PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / API / V2 / Changes_Controller.php

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

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