PluginProbe
Blocks – Reusable Content, Shortcodes & Site Variables / trunk
Blocks – Reusable Content, Shortcodes & Site Variables vtrunk
26.09.03.15 26.08.31.21 26.08.22.20 26.08.22.22 26.08.22.17 26.08.22.13 26.08.21.19 26.08.07.23 26.07.19.14 26.07.13.21 26.07.13.17 26.07.12.13 026.07.07.21 026.07.05.18 026.06.26.20 026.06.26.21 026.06.08.20 026.05.13.14 026.04.29.10 trunk 026.02.22.22 026.03.16.23 026.04.23.13
blocks / includes / signls / src / StateStore.php

StateStore.php in Blocks – Reusable Content, Shortcodes & Site Variables trunk, at includes/signls/src/StateStore.php

333 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Product-scoped non-autoloaded state.
4 *
5 * @package signls-sdk
6 * @license GPL-3.0-or-later
7 */
8
9 namespace Signls\Sdk\V1;
10
11 defined( 'ABSPATH' ) || exit;
12
13 final class StateStore {
14
15 private $product;
16
17 private $option_name;
18
19 private $delivery_intent_option_name;
20
21 /** @var string */
22 private $product_version_option_name;
23
24 public function __construct( string $product ) {
25 $this->product = $product;
26 $this->option_name = 'signls_sdk_v1_' . substr( hash( 'sha256', $product ), 0, 12 );
27 $this->delivery_intent_option_name = $this->option_name . '_delivery_intent';
28 $this->product_version_option_name = $this->option_name . '_product_version';
29 }
30
31 public function product(): string {
32 return $this->product;
33 }
34
35 public function option_name(): string {
36 return $this->option_name;
37 }
38
39 public function delivery_intent_option_name(): string {
40 return $this->delivery_intent_option_name;
41 }
42
43 public function product_version_option_name(): string {
44 return $this->product_version_option_name;
45 }
46
47 public function refresh_concurrency_state(): void {
48 if ( ! function_exists( 'wp_cache_delete' ) ) {
49 return;
50 }
51 foreach ( array( $this->option_name, $this->delivery_intent_option_name, $this->product_version_option_name ) as $option ) {
52 wp_cache_delete( $option, 'options' );
53 }
54 }
55
56 public function mark_delivery_intent(): string {
57 $token = self::new_delivery_intent();
58 if ( '' === $token ) {
59 return '';
60 }
61
62 $current = get_option( $this->delivery_intent_option_name, false );
63 $saved = false === $current
64 ? add_option( $this->delivery_intent_option_name, $token, '', false )
65 : update_option( $this->delivery_intent_option_name, $token, false );
66 return $saved ? $token : '';
67 }
68
69 public function ensure_delivery_intent(): string {
70 $current = get_option( $this->delivery_intent_option_name, false );
71 if ( is_string( $current ) && self::valid_delivery_intent( $current ) ) {
72 return $current;
73 }
74
75 $token = self::new_delivery_intent();
76 if ( '' === $token ) {
77 return '';
78 }
79 $saved = false === $current
80 ? add_option( $this->delivery_intent_option_name, $token, '', false )
81 : update_option( $this->delivery_intent_option_name, $token, false );
82 if ( $saved ) {
83 return $token;
84 }
85 return $this->delivery_intent();
86 }
87
88 public function delivery_intent(): string {
89 $value = get_option( $this->delivery_intent_option_name, '' );
90 return is_string( $value ) && 1 === preg_match( '/^[a-f0-9]{32}$/D', $value ) ? $value : '';
91 }
92
93 public function clear_delivery_intent( string $token ): bool {
94 if ( 1 !== preg_match( '/^[a-f0-9]{32}$/D', $token ) ) {
95 return false;
96 }
97
98 global $wpdb;
99 if ( ! is_object( $wpdb ) || ! isset( $wpdb->options ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'query' ) ) {
100 return false;
101 }
102
103 try {
104 $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic compare-and-delete prevents an older delivery from erasing a newer intent.
105 $wpdb->prepare(
106 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
107 $this->delivery_intent_option_name,
108 $token
109 )
110 );
111 if ( function_exists( 'wp_cache_delete' ) ) {
112 wp_cache_delete( $this->delivery_intent_option_name, 'options' );
113 }
114 return 1 === (int) $deleted;
115 } catch ( \Throwable $error ) {
116 return false;
117 }
118 }
119
120 public function clear_delivery_intent_unconditionally(): bool {
121 return delete_option( $this->delivery_intent_option_name );
122 }
123
124 /**
125 * @return array{observed?: string, pending?: string, intent?: string, settled_intent?: string}
126 */
127 public function product_version_observation(): array {
128 $value = get_option( $this->product_version_option_name, array() );
129 if ( ! is_array( $value ) ) {
130 return array();
131 }
132
133 $observation = array();
134 foreach ( array( 'observed', 'pending' ) as $key ) {
135 if ( isset( $value[ $key ] ) && is_string( $value[ $key ] ) && self::valid_product_version( $value[ $key ] ) ) {
136 $observation[ $key ] = $value[ $key ];
137 }
138 }
139 foreach ( array( 'intent', 'settled_intent' ) as $key ) {
140 if ( isset( $value[ $key ] ) && is_string( $value[ $key ] ) && self::valid_delivery_intent( $value[ $key ] ) ) {
141 $observation[ $key ] = $value[ $key ];
142 }
143 }
144 return $observation;
145 }
146
147 public function baseline_product_version( string $version ): bool {
148 if ( ! self::valid_product_version( $version ) ) {
149 return false;
150 }
151 $current = get_option( $this->product_version_option_name, false );
152 if ( false === $current ) {
153 return add_option(
154 $this->product_version_option_name,
155 array( 'observed' => $version ),
156 '',
157 false
158 );
159 }
160 $observation = $this->product_version_observation();
161 return isset( $observation['observed'] ) && hash_equals( $observation['observed'], $version );
162 }
163
164 public function begin_product_version_transition( string $version, string $intent ): bool {
165 if ( ! self::valid_product_version( $version ) || ! self::valid_delivery_intent( $intent ) ) {
166 return false;
167 }
168 $observation = $this->product_version_observation();
169 $observation['pending'] = $version;
170 $observation['intent'] = $intent;
171 unset( $observation['settled_intent'] );
172 return $this->replace_product_version_observation( $observation );
173 }
174
175 public function rebind_product_version_transition( string $intent ): bool {
176 if ( ! self::valid_delivery_intent( $intent ) ) {
177 return false;
178 }
179 $observation = $this->product_version_observation();
180 if ( ! isset( $observation['pending'] ) ) {
181 return true;
182 }
183 $observation['intent'] = $intent;
184 return $this->replace_product_version_observation( $observation );
185 }
186
187 public function settle_product_version_transition( string $version, string $intent ): bool {
188 if ( ! self::valid_product_version( $version ) || ! self::valid_delivery_intent( $intent ) ) {
189 return false;
190 }
191 $observation = $this->product_version_observation();
192 if (
193 ! isset( $observation['pending'], $observation['intent'] )
194 || ! hash_equals( $observation['pending'], $version )
195 || ! hash_equals( $observation['intent'], $intent )
196 ) {
197 return false;
198 }
199
200 $observation['observed'] = $version;
201 $observation['settled_intent'] = $intent;
202 unset( $observation['pending'], $observation['intent'] );
203 return $this->replace_product_version_observation( $observation );
204 }
205
206 public function clear_settled_product_version_intent( string $intent ): bool {
207 if ( ! self::valid_delivery_intent( $intent ) ) {
208 return false;
209 }
210 $observation = $this->product_version_observation();
211 if ( ! isset( $observation['settled_intent'] ) || ! hash_equals( $observation['settled_intent'], $intent ) ) {
212 return false;
213 }
214 unset( $observation['settled_intent'] );
215 return $this->replace_product_version_observation( $observation );
216 }
217
218 public function all(): array {
219 $value = get_option( $this->option_name, array() );
220 return is_array( $value ) ? $value : array();
221 }
222
223 public function get( string $key, $default = null ) {
224 $state = $this->all();
225 return array_key_exists( $key, $state ) ? $state[ $key ] : $default;
226 }
227
228 public function set( string $key, $value ): bool {
229 $state = $this->all();
230 $state[ $key ] = $value;
231 return $this->replace( $state );
232 }
233
234 public function set_many( array $values ): bool {
235 return $this->replace( array_merge( $this->all(), $values ) );
236 }
237
238 public function delete_keys( array $keys ): bool {
239 $state = $this->all();
240 foreach ( $keys as $key ) {
241 unset( $state[ $key ] );
242 }
243 return $this->replace( $state );
244 }
245
246 public static function pending_keys(): array {
247 return array(
248 'pending_sequence',
249 'pending_body',
250 'pending_body_hash',
251 'pending_sdk_version',
252 'pending_product_version',
253 'pending_payload_revision',
254 'pending_quarantine_class',
255 'pending_quarantine_status',
256 'pending_quarantined_at',
257 'pending_quarantine_probe_at',
258 'failure_class',
259 'next_retry_at',
260 );
261 }
262
263 public function clear_pending(): bool {
264 return $this->delete_keys( self::pending_keys() );
265 }
266
267 public function apply_clock_correction( int $offset, bool $clear_pending ): bool {
268 $state = $this->all();
269 $state['clock_offset'] = $offset;
270 if ( $clear_pending ) {
271 foreach ( self::pending_keys() as $key ) {
272 unset( $state[ $key ] );
273 }
274 }
275 return $this->replace( $state );
276 }
277
278 public function reconcile_sequence( int $expected_sequence ): bool {
279 $state = $this->all();
280 $state['last_acknowledged_sequence'] = max( 0, $expected_sequence - 1 );
281 foreach ( self::pending_keys() as $key ) {
282 unset( $state[ $key ] );
283 }
284 return $this->replace( $state );
285 }
286
287 public function delete(): bool {
288 $state_deleted = delete_option( $this->option_name );
289 $intent_deleted = delete_option( $this->delivery_intent_option_name );
290 $version_deleted = delete_option( $this->product_version_option_name );
291 return $state_deleted || $intent_deleted || $version_deleted;
292 }
293
294 private function replace( array $state ): bool {
295 if ( false === get_option( $this->option_name, false ) ) {
296 return add_option( $this->option_name, $state, '', false );
297 }
298 return update_option( $this->option_name, $state, false );
299 }
300
301 /**
302 * @param array{observed?: string, pending?: string, intent?: string, settled_intent?: string} $observation Version observation.
303 */
304 private function replace_product_version_observation( array $observation ): bool {
305 if ( false === get_option( $this->product_version_option_name, false ) ) {
306 return add_option( $this->product_version_option_name, $observation, '', false );
307 }
308 return update_option( $this->product_version_option_name, $observation, false )
309 || get_option( $this->product_version_option_name, array() ) === $observation;
310 }
311
312 private static function new_delivery_intent(): string {
313 try {
314 return bin2hex( random_bytes( 16 ) );
315 } catch ( \Throwable $error ) {
316 return '';
317 }
318 }
319
320 /** @param mixed $intent Candidate intent. */
321 private static function valid_delivery_intent( $intent ): bool {
322 return is_string( $intent ) && 1 === preg_match( '/^[a-f0-9]{32}$/D', $intent );
323 }
324
325 /** @param mixed $version Candidate product version. */
326 private static function valid_product_version( $version ): bool {
327 return is_string( $version )
328 && '' !== $version
329 && strlen( $version ) <= 64
330 && 1 === preg_match( '/^[\x20-\x7e]+$/D', $version );
331 }
332 }
333