PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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
← All changes | includes/API/V2/Changes_Controller.php +83 -9 1.10.01.10.18 View file →
@@ -12,8 +12,9 @@
12 12 use WCPOS\WooCommercePOS\Sync\Sync_Journal;
13 13 use WCPOS\WooCommercePOS\Sync\Collections;
14 14 use WCPOS\WooCommercePOS\Sync\Config_Fingerprint;
15 15 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
16 +use WCPOS\WooCommercePOS\Sync\Pos_Visibility;
16 17 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
17 18 use WCPOS\WooCommercePOS\Sync\Request_Int_Param;
18 19 use WP_REST_Controller;
19 20 use WP_REST_Request;
@@ -31,8 +32,18 @@
31 32 final class Changes_Controller extends WP_REST_Controller {
32 33 use Endpoint_Permissions;
33 34 use Request_Int_Param;
34 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 +
35 46 private const PRODUCT_POST_TYPES_SQL = "('product','product_variation')";
36 47 private const EXCLUDED_POST_STATUSES_SQL = "('trash','auto-draft')";
37 48 private const TAX_RATES_NOTE = 'tax rates table has no timestamps; rows carry ids only.';
38 49
@@ -192,8 +203,13 @@
192 203 // head this envelope must carry (see its use below).
193 204 $page = $this->journal->page( $stream_types, $since, $limit );
194 205 $rows = $page['rows'];
195 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 +
196 212 $changes = array();
197 213 $checkpoint_since = $since;
198 214 foreach ( $rows as $row ) {
199 215 $sequence = (int) ( $row['sequence'] ?? 0 );
@@ -205,8 +221,27 @@
205 221 'deleted' => ! empty( $row['deleted'] ) ? 1 : 0,
206 222 'revision' => (string) ( $row['revision'] ?? '' ),
207 223 'modified_gmt' => (string) ( $row['modified_gmt'] ?? '' ),
208 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 +
209 244 // Tag per-row collection ONLY for the unified `all` stream — that is
210 245 // the only consumer (the engine) that needs to disambiguate rows.
211 246 // The single-collection `products` / `tax_rates` endpoints keep their
212 247 // original row shape so their checked-in tests stay valid.
@@ -322,16 +357,17 @@
322 357 )
323 358 );
324 359 }
325 360
326 - $rows = $wpdb->get_results(
361 + $hidden = $this->pos_hidden_ids();
362 + $rows = $wpdb->get_results(
327 363 $wpdb->prepare(
328 364 "SELECT ID FROM {$wpdb->posts}"
329 365 . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL
330 366 . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL
367 + . self::hidden_ids_sql( $hidden )
331 368 . ' AND ID > %d ORDER BY ID ASC LIMIT %d',
332 - $since_id,
333 - $limit
369 + array_merge( $hidden, array( $since_id, $limit ) )
334 370 ),
335 371 ARRAY_A
336 372 );
337 373 $rows = \is_array( $rows ) ? $rows : array();
@@ -417,16 +453,17 @@
417 453 )
418 454 );
419 455 }
420 456
421 - $rows = $wpdb->get_results(
457 + $hidden = $this->pos_hidden_ids();
458 + $rows = $wpdb->get_results(
422 459 $wpdb->prepare(
423 460 "SELECT ID, post_modified_gmt FROM {$wpdb->posts}"
424 461 . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL
425 462 . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL
463 + . self::hidden_ids_sql( $hidden )
426 464 . ' AND ID >= %d AND ID < %d ORDER BY ID ASC',
427 - $range_start,
428 - $range_end
465 + array_merge( $hidden, array( $range_start, $range_end ) )
429 466 ),
430 467 ARRAY_A
431 468 );
432 469 $rows = \is_array( $rows ) ? $rows : array();
@@ -446,8 +483,9 @@
446 483 // MySQL's default group_concat_max_len (1024) silently truncates the
447 484 // concat and corrupts checksums; raise it for this session first.
448 485 $wpdb->query( 'SET SESSION group_concat_max_len = 1048576' );
449 486
487 + $sql_args = array( $bucket_size );
450 488 if ( 'tax_rates' === $collection ) {
451 489 $sql = 'SELECT FLOOR(r.tax_rate_id/%d) AS bucket, COUNT(*) AS record_count,'
452 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,"
453 491 . $this->tax_rate_locations_fingerprint_sql( 'r' )
@@ -455,18 +493,21 @@
455 493 . ' FROM ' . $this->tax_rates_table() . ' r'
456 494 . ' GROUP BY bucket ORDER BY bucket';
457 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.';
458 496 } else {
459 - $sql = 'SELECT FLOOR(ID/%d) AS bucket, COUNT(*) AS record_count,'
497 + $hidden = $this->pos_hidden_ids();
498 + $sql = 'SELECT FLOOR(ID/%d) AS bucket, COUNT(*) AS record_count,'
460 499 . " MD5(GROUP_CONCAT(CONCAT(ID,'|',post_modified_gmt) ORDER BY ID SEPARATOR ',')) AS checksum"
461 500 . " FROM {$wpdb->posts}"
462 501 . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL
463 502 . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL
503 + . self::hidden_ids_sql( $hidden )
464 504 . ' GROUP BY bucket ORDER BY bucket';
465 - $note = 'built on (id, post_modified_gmt) — inherits date_modified blindness by design; hash-backed variant deferred.';
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.';
466 507 }
467 508
468 - $rows = $wpdb->get_results( $wpdb->prepare( $sql, $bucket_size ), ARRAY_A );
509 + $rows = $wpdb->get_results( $wpdb->prepare( $sql, $sql_args ), ARRAY_A );
469 510 $rows = \is_array( $rows ) ? $rows : array();
470 511 $changes = array_map(
471 512 static function ( array $row ): array {
472 513 return array(
@@ -647,8 +688,41 @@
647 688 'changes' => $changes,
648 689 'complete' => $complete,
649 690 'meta' => $meta,
650 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' ) ) . ')';
651 725 }
652 726
653 727 private function collection_for_request( WP_REST_Request $request ): string {
654 728 // NB this intentionally collapses everything except tax_rates to products.