PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / V2 / Changes_Controller.php

Changes_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/API/V2/Changes_Controller.php

710 lines 25.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS v2 graduated change-signal read surface.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use WCPOS\WooCommercePOS\Logger;
11 use WCPOS\WooCommercePOS\Sync\Api;
12 use WCPOS\WooCommercePOS\Sync\Sync_Journal;
13 use WCPOS\WooCommercePOS\Sync\Collections;
14 use WCPOS\WooCommercePOS\Sync\Config_Fingerprint;
15 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
16 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
17 use WCPOS\WooCommercePOS\Sync\Request_Int_Param;
18 use WP_REST_Controller;
19 use WP_REST_Request;
20 use WP_REST_Server;
21
22 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
23 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries use internal table names and generated SQL fragments.
24
25 /**
26 * Graduated v2 change-signal read surface.
27 *
28 * Five GET endpoints under /changes/ share a common response envelope,
29 * graduated from the change-signal candidate matrix (#1405, #1406).
30 */
31 final class Changes_Controller extends WP_REST_Controller {
32 use Endpoint_Permissions;
33 use Request_Int_Param;
34
35 private const PRODUCT_POST_TYPES_SQL = "('product','product_variation')";
36 private const EXCLUDED_POST_STATUSES_SQL = "('trash','auto-draft')";
37 private const TAX_RATES_NOTE = 'tax rates table has no timestamps; rows carry ids only.';
38
39 private Sync_Journal $journal;
40
41 public function __construct( ?Sync_Journal $journal = null ) {
42 $this->journal = $journal ?? new Sync_Journal();
43 }
44
45 public function register_routes(): void {
46 $common_args = array(
47 'collection' => array(
48 'default' => 'products',
49 'sanitize_callback' => 'sanitize_text_field',
50 ),
51 'limit' => array(
52 'default' => 100,
53 'sanitize_callback' => 'absint',
54 ),
55 );
56
57 register_rest_route(
58 Api::ROUTE_NAMESPACE,
59 '/changes/sequence-log',
60 array(
61 'methods' => WP_REST_Server::READABLE,
62 'callback' => array( $this, 'sequence_log' ),
63 'permission_callback' => array( $this, 'permissions_check' ),
64 'args' => array_merge(
65 $common_args,
66 array(
67 'since' => array(
68 'default' => 0,
69 'sanitize_callback' => 'absint',
70 ),
71 )
72 ),
73 )
74 );
75
76 register_rest_route(
77 Api::ROUTE_NAMESPACE,
78 '/changes/revision-hash',
79 array(
80 'methods' => WP_REST_Server::READABLE,
81 'callback' => array( $this, 'revision_hash' ),
82 'permission_callback' => array( $this, 'permissions_check' ),
83 'args' => array(
84 'collection' => array(
85 'default' => 'products',
86 'sanitize_callback' => 'sanitize_text_field',
87 ),
88 'limit' => array(
89 'default' => 50,
90 'sanitize_callback' => 'absint',
91 ),
92 'since_id' => array(
93 'default' => 0,
94 'sanitize_callback' => 'absint',
95 ),
96 ),
97 )
98 );
99
100 register_rest_route(
101 Api::ROUTE_NAMESPACE,
102 '/changes/range-checksum',
103 array(
104 'methods' => WP_REST_Server::READABLE,
105 'callback' => array( $this, 'range_checksum' ),
106 'permission_callback' => array( $this, 'permissions_check' ),
107 'args' => array(
108 'collection' => array(
109 'default' => 'products',
110 'sanitize_callback' => 'sanitize_text_field',
111 ),
112 'bucket_size' => array(
113 'default' => 1000,
114 'sanitize_callback' => 'absint',
115 ),
116 'bucket' => array( 'sanitize_callback' => 'absint' ),
117 ),
118 )
119 );
120
121 register_rest_route(
122 Api::ROUTE_NAMESPACE,
123 '/changes/tick',
124 array(
125 'methods' => WP_REST_Server::READABLE,
126 'callback' => array( $this, 'tick' ),
127 'permission_callback' => array( $this, 'permissions_check' ),
128 'args' => array_merge(
129 $common_args,
130 array(
131 'since' => array(
132 'default' => 0,
133 'sanitize_callback' => 'absint',
134 ),
135 )
136 ),
137 )
138 );
139
140 register_rest_route(
141 Api::ROUTE_NAMESPACE,
142 '/changes/config-fingerprint',
143 array(
144 'methods' => WP_REST_Server::READABLE,
145 'callback' => array( $this, 'config_fingerprint' ),
146 'permission_callback' => array( $this, 'permissions_check' ),
147 'args' => array(
148 'collection' => array( 'sanitize_callback' => 'sanitize_text_field' ),
149 ),
150 )
151 );
152 }
153
154 /**
155 * Hook-maintained journal queried by global sequence cursor.
156 */
157 public function sequence_log( WP_REST_Request $request ) {
158 // `all` (the unified catalogue stream) is recognised ONLY here, from the raw param;
159 // collection_for_request() never returns it, so the other endpoints can't.
160 $is_all = ( 'all' === (string) $request->get_param( 'collection' ) );
161 $collection = $is_all ? 'all' : $this->collection_for_request( $request );
162 $limit = $this->int_param( $request, 'limit', 100, 1, 1000 );
163 $since = max( 0, (int) ( $request->get_param( 'since' ) ?? 0 ) );
164
165 // Embed the FULL fingerprint set regardless of this stream's `collection`
166 // param: the products stream serves product AND variation rows, and a client
167 // replacing its standalone all-collections fingerprint poll with this
168 // embedded member must never see a narrowed snapshot (stale variations).
169 $config_fingerprint = $this->config_fingerprint_data( $request, true );
170 $stream_types = $this->object_types_for_collection( $collection );
171 // STREAM-SCOPED head: orders share this AUTO_INCREMENT space, and a head
172 // that moves on foreign writes would leave this stream's cursor forever
173 // "behind" — killing the 304 idle path and forcing an empty 200 per poll.
174 $head_sequence = $this->journal->head_sequence( $stream_types );
175 // STREAM-SCOPED horizon, for the same reason: a prune on the OTHER
176 // stream must not read as lost history here (see Sync_Journal's
177 // PRUNE_WATERMARK_OPTION_PREFIX).
178 $horizon = $this->journal->prune_watermark( $stream_types );
179 $etag = $this->sequence_log_etag( $head_sequence, $config_fingerprint, $horizon );
180 $headers = array(
181 'ETag' => $etag,
182 'Cache-Control' => 'no-store',
183 );
184
185 if ( $since === $head_sequence && $this->if_none_match_matches( $request, $etag ) ) {
186 return new \WP_REST_Response( null, 304, $headers );
187 }
188
189 // The catalogue-only `all` stream and the narrowed streams explicitly name
190 // their object types — products span TWO of them in the one
191 // global sequence. The page's `head` is read after the rows, which is the
192 // head this envelope must carry (see its use below).
193 $page = $this->journal->page( $stream_types, $since, $limit );
194 $rows = $page['rows'];
195
196 $changes = array();
197 $checkpoint_since = $since;
198 foreach ( $rows as $row ) {
199 $sequence = (int) ( $row['sequence'] ?? 0 );
200 $checkpoint_since = max( $checkpoint_since, $sequence );
201 $object_type = isset( $row['object_type'] ) ? (string) $row['object_type'] : '';
202 $change = array(
203 'sequence' => $sequence,
204 'id' => (int) ( $row['object_id'] ?? 0 ),
205 'deleted' => ! empty( $row['deleted'] ) ? 1 : 0,
206 'revision' => (string) ( $row['revision'] ?? '' ),
207 'modified_gmt' => (string) ( $row['modified_gmt'] ?? '' ),
208 );
209 // Tag per-row collection ONLY for the unified `all` stream — that is
210 // the only consumer (the engine) that needs to disambiguate rows.
211 // The single-collection `products` / `tax_rates` endpoints keep their
212 // original row shape so their checked-in tests stay valid.
213 if ( $is_all ) {
214 if ( '' !== $object_type ) {
215 $mapped = Collections::collection_for_object_type( $object_type );
216 if ( null === $mapped ) {
217 // Fail closed: an unknown/future object_type (or a typo)
218 // must NOT be mis-labelled — the client would pull the
219 // WRONG record type with this row's numeric id. Drop the
220 // row (the cursor still advances past it via the echoed
221 // checkpoint) and say so once per request.
222 Logger::log( \sprintf( 'WCPOS sync: dropped journal row with unknown object_type "%s" (sequence %s)', $object_type, $change['sequence'] ) );
223
224 continue;
225 }
226 $change['collection'] = $mapped;
227 } else {
228 $change['collection'] = $collection;
229 }
230 }
231 $changes[] = $change;
232 }
233
234 // Head of THIS STREAM's sequence space. A FRESH client (no resident history)
235 // jumps its cursor straight to `head` in ONE request — the on-demand
236 // baseline — instead of draining the entire historical journal
237 // 100/page. The existing catalog is the baseline (built by greedy/on-demand
238 // pulls); only changes AFTER head need replaying. See finding F1 in
239 // docs/pos-replication-model.md. The page reads it AFTER its rows, so the
240 // envelope's head stays >= every served row (a row can land between the
241 // early ETag head-read and the page query). The early read above serves
242 // only the 304 short-circuit. Because the head is stream-scoped, a drained
243 // cursor reaches it naturally — no served-checkpoint jump is needed, and
244 // none may be added: jumping `since` to a head read after the rows would
245 // skip any in-stream row that committed between the two reads.
246 $head_sequence = $page['head'];
247 $complete = \count( $rows ) < $limit;
248
249 // Rebuild the served ETag from the refreshed head (CodeRabbit review): if a
250 // row landed between the reads, an ETag stamped with the stale head could
251 // never match the client's next at-head poll, costing one useless full 200.
252 // A newer-head ETag can't hide rows — the 304 branch still requires the NEXT
253 // request's since to equal that request's freshly-read head.
254 // The lossy-pruning boundary, NOT MIN(sequence): compaction keeps each
255 // object's newest row, so the oldest surviving sequence says nothing
256 // about pruned tombstones above it. A cursor at or past the watermark
257 // has missed nothing; below it, the client must reconcile via the
258 // integrity surfaces. Zero = no lossy pruning has ever run. Re-read
259 // after the page for the same reason the head is: a prune concurrent
260 // with the page read must be reported, never silently served past.
261 $horizon = $this->journal->prune_watermark( $stream_types );
262 $headers['ETag'] = $this->sequence_log_etag( $head_sequence, $config_fingerprint, $horizon );
263
264 $data = $this->envelope(
265 $collection,
266 array(
267 'since' => $checkpoint_since,
268 'head' => $head_sequence,
269 'horizon' => $horizon,
270 'epoch' => $this->journal->ensure_epoch(),
271 ),
272 $changes,
273 $complete
274 );
275 $data['config_fingerprint'] = $config_fingerprint;
276
277 return new \WP_REST_Response( $data, 200, $headers );
278 }
279
280 /**
281 * Deepest repair tier: hash each record's full served representation.
282 * Expensive by design — raw SQL pages ids (discovery only, ADR 0003);
283 * the hashed value is hydrated through the filtered REST path.
284 */
285 public function revision_hash( WP_REST_Request $request ) {
286 $collection = $this->collection_for_request( $request );
287 $limit = $this->int_param( $request, 'limit', 50, 1, 200 );
288 $since_id = max( 0, (int) ( $request->get_param( 'since_id' ) ?? 0 ) );
289 $note = 'full filtered REST serialization per record on every poll; the serialization cost is the point of this repair tier.';
290 global $wpdb;
291
292 if ( 'tax_rates' === $collection ) {
293 $rows = $wpdb->get_results(
294 $wpdb->prepare(
295 'SELECT * FROM ' . $this->tax_rates_table()
296 . ' WHERE tax_rate_id > %d ORDER BY tax_rate_id ASC LIMIT %d',
297 $since_id,
298 $limit
299 ),
300 ARRAY_A
301 );
302 $rows = \is_array( $rows ) ? $rows : array();
303
304 $changes = array();
305 $checkpoint_id = $since_id;
306 foreach ( $rows as $row ) {
307 $checkpoint_id = (int) ( $row['tax_rate_id'] ?? 0 );
308 $changes[] = array(
309 'id' => $checkpoint_id,
310 'revision' => md5( (string) wp_json_encode( $row ) ),
311 );
312 }
313
314 return rest_ensure_response(
315 $this->envelope(
316 $collection,
317 array( 'since_id' => $checkpoint_id ),
318 $changes,
319 \count( $rows ) < $limit,
320 true,
321 $note
322 )
323 );
324 }
325
326 $rows = $wpdb->get_results(
327 $wpdb->prepare(
328 "SELECT ID FROM {$wpdb->posts}"
329 . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL
330 . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL
331 . ' AND ID > %d ORDER BY ID ASC LIMIT %d',
332 $since_id,
333 $limit
334 ),
335 ARRAY_A
336 );
337 $rows = \is_array( $rows ) ? $rows : array();
338
339 $changes = array();
340 $checkpoint_id = $since_id;
341 $serialization_request = new WP_REST_Request( 'GET', '/' );
342 $serializer = new Product_Serializer();
343 foreach ( $rows as $row ) {
344 $id = (int) $row['ID'];
345 $checkpoint_id = $id;
346 $product = wc_get_product( $id );
347 if ( ! $product ) {
348 continue;
349 }
350 // THE product assembly line (Product_Serializer): filtered REST
351 // representation, never a raw projection (ADR 0003).
352 // Per-record delay mirrors the orders controller's per-document
353 // serialize_document delays — this is where slow-php hurts most.
354 $payload = $serializer->serialize( $product, $serialization_request );
355 $changes[] = array(
356 'id' => $id,
357 'revision' => md5( (string) wp_json_encode( $this->canonicalize_for_revision( $payload ) ) ),
358 );
359 }
360
361 return rest_ensure_response(
362 $this->envelope(
363 $collection,
364 array( 'since_id' => $checkpoint_id ),
365 $changes,
366 \count( $rows ) < $limit,
367 true,
368 $note
369 )
370 );
371 }
372
373 /**
374 * Bucketed integrity checksums with per-bucket audit-list drill-down.
375 */
376 public function range_checksum( WP_REST_Request $request ) {
377 $collection = $this->collection_for_request( $request );
378 $bucket_size = $this->int_param( $request, 'bucket_size', 1000, 1, 10000 );
379 $bucket_raw = $request->get_param( 'bucket' );
380 global $wpdb;
381
382 if ( null !== $bucket_raw && '' !== $bucket_raw ) {
383 $bucket = max( 0, (int) $bucket_raw );
384 $range_start = $bucket * $bucket_size;
385 $range_end = $range_start + $bucket_size;
386 $checkpoint = array(
387 'bucket_size' => $bucket_size,
388 'bucket' => $bucket,
389 );
390
391 if ( 'tax_rates' === $collection ) {
392 $rows = $wpdb->get_results(
393 $wpdb->prepare(
394 'SELECT tax_rate_id FROM ' . $this->tax_rates_table()
395 . ' WHERE tax_rate_id >= %d AND tax_rate_id < %d ORDER BY tax_rate_id ASC',
396 $range_start,
397 $range_end
398 ),
399 ARRAY_A
400 );
401 $rows = \is_array( $rows ) ? $rows : array();
402 $changes = array_map(
403 static function ( array $row ): array {
404 return array( 'id' => (int) $row['tax_rate_id'] );
405 },
406 $rows
407 );
408
409 return rest_ensure_response(
410 $this->envelope(
411 $collection,
412 $checkpoint,
413 $changes,
414 true,
415 true,
416 self::TAX_RATES_NOTE
417 )
418 );
419 }
420
421 $rows = $wpdb->get_results(
422 $wpdb->prepare(
423 "SELECT ID, post_modified_gmt FROM {$wpdb->posts}"
424 . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL
425 . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL
426 . ' AND ID >= %d AND ID < %d ORDER BY ID ASC',
427 $range_start,
428 $range_end
429 ),
430 ARRAY_A
431 );
432 $rows = \is_array( $rows ) ? $rows : array();
433 $changes = array_map(
434 static function ( array $row ): array {
435 return array(
436 'id' => (int) $row['ID'],
437 'modified_gmt' => (string) $row['post_modified_gmt'],
438 );
439 },
440 $rows
441 );
442
443 return rest_ensure_response( $this->envelope( $collection, $checkpoint, $changes, true ) );
444 }
445
446 // MySQL's default group_concat_max_len (1024) silently truncates the
447 // concat and corrupts checksums; raise it for this session first.
448 $wpdb->query( 'SET SESSION group_concat_max_len = 1048576' );
449
450 if ( 'tax_rates' === $collection ) {
451 $sql = 'SELECT FLOOR(r.tax_rate_id/%d) AS bucket, COUNT(*) AS record_count,'
452 . " MD5(GROUP_CONCAT(CONCAT_WS('|',r.tax_rate_id,r.tax_rate_country,r.tax_rate_state,r.tax_rate,r.tax_rate_name,r.tax_rate_priority,r.tax_rate_compound,r.tax_rate_shipping,r.tax_rate_order,r.tax_rate_class,"
453 . $this->tax_rate_locations_fingerprint_sql( 'r' )
454 . ") ORDER BY r.tax_rate_id SEPARATOR ',')) AS checksum"
455 . ' FROM ' . $this->tax_rates_table() . ' r'
456 . ' GROUP BY bucket ORDER BY bucket';
457 $note = 'checksum covers every tax-rate column AND its postcode/city locations (F12), so any rate edit — including a location-only change that does not fire woocommerce_tax_rate_updated — moves the checksum; tax rates have no timestamps.';
458 } else {
459 $sql = 'SELECT FLOOR(ID/%d) AS bucket, COUNT(*) AS record_count,'
460 . " MD5(GROUP_CONCAT(CONCAT(ID,'|',post_modified_gmt) ORDER BY ID SEPARATOR ',')) AS checksum"
461 . " FROM {$wpdb->posts}"
462 . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL
463 . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL
464 . ' GROUP BY bucket ORDER BY bucket';
465 $note = 'built on (id, post_modified_gmt) — inherits date_modified blindness by design; hash-backed variant deferred.';
466 }
467
468 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $bucket_size ), ARRAY_A );
469 $rows = \is_array( $rows ) ? $rows : array();
470 $changes = array_map(
471 static function ( array $row ): array {
472 return array(
473 'bucket' => (int) $row['bucket'],
474 'record_count' => (int) $row['record_count'],
475 'checksum' => (string) $row['checksum'],
476 );
477 },
478 $rows
479 );
480
481 return rest_ensure_response(
482 $this->envelope(
483 $collection,
484 array( 'bucket_size' => $bucket_size ),
485 $changes,
486 true,
487 true,
488 $note
489 )
490 );
491 }
492
493 /**
494 * Representation-config fingerprint per ADR 0006 (Scenario 1 — settings-change
495 * staleness). Hashes the LIVE representation-affecting
496 * options per collection on EVERY call, so it is self-healing against
497 * hook-bypassing settings writes — the property a hook-only counter cannot
498 * give. The optional `collection` scopes to one valid member of
499 * Config_Fingerprint::COLLECTIONS; anything else (or absent)
500 * reports all of them. Discovery only (ADR 0003): the fingerprint locates
501 * that a config moved; the trusted re-derivation runs client-side.
502 */
503 public function config_fingerprint( WP_REST_Request $request ) {
504 return rest_ensure_response( $this->config_fingerprint_data( $request ) );
505 }
506
507 /**
508 * Combined change signal: one poll for idle registers.
509 *
510 * Reports the sequence head and representation fingerprint without reading
511 * a page of journal rows. Its validator and conditional-request semantics
512 * stay shared with sequence_log() so cached validators remain compatible.
513 */
514 public function tick( WP_REST_Request $request ) {
515 $since = max( 0, (int) ( $request->get_param( 'since' ) ?? 0 ) );
516 $config_fingerprint = $this->config_fingerprint_data( $request, true );
517 // Tick is the catalogue lane's probe — serve the catalogue stream's head,
518 // not the global one, or steady order writes would kill the 304 idle path.
519 $stream_types = $this->object_types_for_collection( 'all' );
520 $head_sequence = $this->journal->head_sequence( $stream_types );
521 $horizon = $this->journal->prune_watermark( $stream_types );
522 $etag = $this->sequence_log_etag( $head_sequence, $config_fingerprint, $horizon );
523 $headers = array(
524 'ETag' => $etag,
525 'Cache-Control' => 'no-store',
526 );
527
528 if ( $since === $head_sequence && $this->if_none_match_matches( $request, $etag ) ) {
529 return new \WP_REST_Response( null, 304, $headers );
530 }
531
532 return new \WP_REST_Response(
533 array(
534 'checkpoint' => array(
535 'since' => $since,
536 'head' => $head_sequence,
537 'horizon' => $horizon,
538 'epoch' => $this->journal->ensure_epoch(),
539 ),
540 'changes' => array(),
541 // Tick ships no page, so within its contract there is nothing incomplete;
542 // the client synthesizes its own envelope and never reads this field.
543 'complete' => true,
544 'config_fingerprint' => $config_fingerprint,
545 'meta' => array( 'supported' => true ),
546 ),
547 200,
548 $headers
549 );
550 }
551
552 /**
553 * The sequence-log validator: head sequence + a stable hash of the
554 * representation-affecting fingerprint data.
555 */
556 private function sequence_log_etag( int $head_sequence, array $config_fingerprint, int $horizon ): string {
557 // The validator must cover EVERY client-visible reset boundary, not just
558 // head+config: an install can regenerate the epoch, and retention can
559 // advance the horizon, while this stream's head is unchanged — an at-head
560 // client presenting the old validator would then 304 forever and never
561 // observe the very fields that trigger its rebaseline.
562 return '"' . $head_sequence . ':' . md5(
563 (string) wp_json_encode(
564 array(
565 'fingerprints' => $config_fingerprint['fingerprints'],
566 'barcode_fields' => $config_fingerprint['barcode_fields'],
567 'epoch' => $this->journal->ensure_epoch(),
568 'horizon' => $horizon,
569 )
570 )
571 ) . '"';
572 }
573
574 /**
575 * Apply RFC 9110 If-None-Match matching to the current validator.
576 */
577 private function if_none_match_matches( WP_REST_Request $request, string $etag ): bool {
578 // Accept the wildcard, weak validators (W/"…"), and comma-separated
579 // validator lists. Malformed headers never match (full 200 response).
580 $if_none_match = trim( (string) $request->get_header( 'If-None-Match' ) );
581 $entity_tag_pattern = '(?:W/)?"[\x21\x23-\x7E\x80-\xFF]*"';
582 $if_none_match_list_pattern = '~\A[ \t]*(?:,[ \t]*)*'
583 . $entity_tag_pattern
584 . '(?:[ \t]*,(?:[ \t]*' . $entity_tag_pattern . ')?)*[ \t]*\z~D';
585
586 if ( '*' === $if_none_match ) {
587 return true;
588 }
589 if ( 1 !== preg_match( $if_none_match_list_pattern, $if_none_match ) ) {
590 return false;
591 }
592
593 preg_match_all( '~(?:W/)?"([\x21\x23-\x7E\x80-\xFF]*)"~', $if_none_match, $validators );
594
595 return in_array( substr( $etag, 1, -1 ), $validators[1], true );
596 }
597
598 /**
599 * Build the config-fingerprint response data shared by both polling routes.
600 *
601 * @param bool $all_collections Ignore the request's `collection` narrowing and
602 * report every collection (the sequence-log embed).
603 */
604 private function config_fingerprint_data(
605 WP_REST_Request $request,
606 bool $all_collections = false
607 ): array {
608 $requested = (string) ( $request->get_param( 'collection' ) ?? '' );
609 $collections = ! $all_collections && \in_array( $requested, Config_Fingerprint::collections(), true )
610 ? array( $requested )
611 : Config_Fingerprint::collections();
612
613 $fp = new Config_Fingerprint();
614 $snap = $fp->snapshot( $collections );
615
616 return array(
617 'fingerprints' => $snap['fingerprints'],
618 'barcode_fields' => $snap['barcode_fields'],
619 'meta' => array( 'supported' => true ),
620 );
621 }
622
623 /**
624 * A revision must change only when the record's representation changes.
625 * Some derived REST fields are volatile per request — related_ids comes
626 * back in randomized order on every GET — and would make every sweep see
627 * phantom changes (measured live: 43/52 false positives per no-op sweep).
628 */
629 private function canonicalize_for_revision( array $payload ): array {
630 $excluded = apply_filters( 'woocommerce_pos_sync_revision_excluded_fields', array( 'related_ids', '_links', 'links' ) );
631 foreach ( (array) $excluded as $field ) {
632 unset( $payload[ $field ] );
633 }
634
635 return $payload;
636 }
637
638 private function envelope( string $collection, array $checkpoint, array $changes, bool $complete, bool $supported = true, ?string $note = null ): array {
639 $meta = array( 'supported' => $supported );
640 if ( null !== $note ) {
641 $meta['note'] = $note;
642 }
643
644 return array(
645 'collection' => $collection,
646 'checkpoint' => $checkpoint,
647 'changes' => $changes,
648 'complete' => $complete,
649 'meta' => $meta,
650 );
651 }
652
653 private function collection_for_request( WP_REST_Request $request ): string {
654 // NB this intentionally collapses everything except tax_rates to products.
655 // The unified `all` mode is recognised ONLY inside sequence_log() (read
656 // from the raw param there); the other /changes/* handlers have no `all`
657 // branch, so they must never see it or they would label product rows as
658 // collection:"all".
659 return 'tax_rates' === (string) $request->get_param( 'collection' ) ? 'tax_rates' : 'products';
660 }
661
662 /**
663 * The journal object_types one stream serves. `all` is the catalogue stream,
664 * `tax_rates` is a single type, and `products`
665 * spans product AND variation because a variation change is a products-stream
666 * event. This is the only place the endpoint names object types; the journal
667 * itself owns the query.
668 *
669 * @return string[]
670 */
671 private function object_types_for_collection( string $collection ): array {
672 if ( 'all' === $collection ) {
673 // Projected from the Collections registry (journal group minus orders)
674 // so a new collection cannot be journalled yet invisible to this stream.
675 return Sync_Journal::catalogue_object_types();
676 }
677 if ( 'tax_rates' === $collection ) {
678 return array( 'tax_rate' );
679 }
680
681 return array( 'product', 'variation' );
682 }
683
684 private function tax_rates_table(): string {
685 global $wpdb;
686
687 return $wpdb->prefix . 'woocommerce_tax_rates';
688 }
689
690 private function tax_rate_locations_table(): string {
691 global $wpdb;
692
693 return $wpdb->prefix . 'woocommerce_tax_rate_locations';
694 }
695
696 /**
697 * A per-rate fingerprint of its postcode/city locations (F12), folded into the tax-rate checksum.
698 * Editing a rate's locations (WC_Tax::_update_tax_rate_postcodes/_cities) does NOT fire
699 * woocommerce_tax_rate_updated and does not touch the wp_woocommerce_tax_rates row, so without
700 * this a location-only edit is invisible to the change signal while the pulled payload still
701 * carries the locations — clients would serve a stale rate. Correlated subquery, deterministically
702 * ordered; the session group_concat_max_len is already raised above.
703 */
704 private function tax_rate_locations_fingerprint_sql( string $rate_alias ): string {
705 return "COALESCE((SELECT GROUP_CONCAT(CONCAT_WS(':', l.location_type, l.location_code)"
706 . ' ORDER BY l.location_type, l.location_code, l.location_id SEPARATOR \';\')'
707 . ' FROM ' . $this->tax_rate_locations_table() . " l WHERE l.tax_rate_id = {$rate_alias}.tax_rate_id), '')";
708 }
709 }
710