| @@ -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. |