PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
1.10.20 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 All 164 releases
← All changes | includes/Sync/Pos_Uuid.php +207 -20 1.10.0 → 1.10.20 View file →
@@ -52,20 +52,28 @@
52 52 * with ->key/->value, or arrays), or '' if none. Skips blank / invalid / a
53 53 * blank duplicate in favour of a later valid one.
54 54 */
55 55 public static function read_valid_uuid_from_meta( array $meta_data ): string {
56 + $entry = self::first_valid_uuid_entry( $meta_data );
57 +
58 + return null === $entry ? '' : (string) Meta_Entry::value( $entry );
59 + }
60 +
61 + /**
62 + * The record's CANONICAL uuid entry — the first meta entry carrying a valid
63 + * uuid — or null. One selection rule for every reader: the served value, the
64 + * entry the prune keeps and the provenance check all name the same entry.
65 + *
66 + * @return mixed|null
67 + */
68 + private static function first_valid_uuid_entry( array $meta_data ) {
56 69 foreach ( $meta_data as $meta ) {
57 - $key = Meta_Entry::key( $meta );
58 - if ( self::META_KEY !== $key ) {
59 - continue;
70 + if ( self::META_KEY === Meta_Entry::key( $meta ) && self::is_uuid( Meta_Entry::value( $meta ) ) ) {
71 + return $meta;
60 72 }
61 - $value = Meta_Entry::value( $meta );
62 - if ( self::is_uuid( $value ) ) {
63 - return $value;
64 - }
65 73 }
66 74
67 - return '';
75 + return null;
68 76 }
69 77
70 78 /**
71 79 * Ensure the record carries a stable, UNIQUELY-OWNED uuid: reuse a valid
@@ -78,8 +86,17 @@
78 86 * that copied the meta), we treat it as needing a fresh one rather than serving
79 87 * a duplicate RxDB key. Injected so the branching stays unit-testable; the live
80 88 * wiring uses the $wpdb-backed self::uuid_owned_by_other.
81 89 *
90 + * $opts['trust_persisted'] (default false) settles ownership WITHOUT the
91 + * detector when the uuid was loaded from this record's own meta row and is
92 + * unchanged ({@see is_own_persisted_uuid}) — the ordinary save and read paths,
93 + * where the detector re-proved a fact the row already stated at a cost linear
94 + * in catalog size (#1805, ADR 0038). Leave it off where a loaded duplicate MUST
95 + * be re-keyed: the collision backfill, the proxy stamper's in-response
96 + * duplicates, and the V1 list lanes, whose no-shared-uuid-per-response contract
97 + * has no other check.
98 + *
82 99 * @param mixed $object
83 100 */
84 101 public static function ensure_uuid( $object, array $opts = array() ): string {
85 102 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_meta_data' ) ) {
@@ -104,16 +121,23 @@
104 121 */
105 122 private static function ensure_uuid_without_user_lock( $object, array $opts ): string {
106 123 $collides = $opts['collides'] ?? null;
107 124 $persist = $opts['persist'] ?? true;
108 - $existing = self::read_valid_uuid_from_meta( (array) $object->get_meta_data() );
109 - if ( '' !== $existing && ! ( \is_callable( $collides ) && $collides( $existing, $object ) ) ) {
110 - // Converge any duplicate uuid metas (e.g. a concurrent first-stamp) to
111 - // the single canonical value — deterministic regardless of object-cache
112 - // backend, so no cross-request lock is required for correctness.
113 - self::prune_duplicate_uuid_meta( $object, $persist );
125 + $trust = ! empty( $opts['trust_persisted'] );
126 + $entry = self::first_valid_uuid_entry( (array) $object->get_meta_data() );
127 + $existing = null === $entry ? '' : (string) Meta_Entry::value( $entry );
128 + if ( '' !== $existing ) {
129 + $owned = ! \is_callable( $collides )
130 + || ( $trust && self::is_own_persisted_uuid( $object, $entry ) )
131 + || ! $collides( $existing, $object );
132 + if ( $owned ) {
133 + // Converge any duplicate uuid metas (e.g. a concurrent first-stamp) to
134 + // the single canonical value — deterministic regardless of object-cache
135 + // backend, so no cross-request lock is required for correctness.
136 + self::prune_duplicate_uuid_meta( $object, $persist );
114 137
115 - return $existing;
138 + return $existing;
139 + }
116 140 }
117 141 if ( ! method_exists( $object, 'update_meta_data' ) ) {
118 142 return '';
119 143 }
@@ -124,8 +148,26 @@
124 148 if ( $persist && ! method_exists( $object, 'save_meta_data' ) ) {
125 149 return '';
126 150 }
127 151 $uuid = self::generate_uuid();
152 + if ( '' !== $existing ) {
153 + // A re-key changes the record's client-side primary key (ADR 0038). Rare
154 + // and consequential, so it is always on the record: which record, the
155 + // identity it lost, the one it received.
156 + // The commonest re-key is an unsaved clone (id 0), so the name is what
157 + // identifies it after the fact.
158 + $name = method_exists( $object, 'get_name' ) ? (string) $object->get_name() : '';
159 + Logger::log(
160 + sprintf(
161 + 'Re-keyed %s #%d%s: uuid %s is already owned by another record; it now carries %s.',
162 + \get_class( $object ),
163 + method_exists( $object, 'get_id' ) ? (int) $object->get_id() : 0,
164 + '' === $name ? '' : ' (' . $name . ')',
165 + $existing,
166 + $uuid
167 + )
168 + );
169 + }
128 170 $object->update_meta_data( self::META_KEY, $uuid );
129 171 if ( $persist ) {
130 172 call_user_func( array( $object, 'save_meta_data' ) );
131 173 // A concurrent first-stamp may have persisted its own uuid between our
@@ -165,8 +207,11 @@
165 207 if ( ! method_exists( $customer, 'get_id' ) || $user_id !== (int) $customer->get_id() ) {
166 208 return '';
167 209 }
168 210
211 + // No `trust_persisted` here: V1's customer list has no in-response
212 + // duplicate check, so its "no two records share a uuid" contract rests on
213 + // this detector (Test_Customers_Controller::test_customer_uuid_is_unique).
169 214 return self::ensure_uuid(
170 215 $customer,
171 216 array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_user' ) )
172 217 );
@@ -375,9 +420,18 @@
375 420 }
376 421 $collides = is_a( $object, 'WC_Abstract_Order' )
377 422 ? array( __CLASS__, 'uuid_owned_by_other_order' )
378 423 : array( __CLASS__, 'uuid_owned_by_other' );
379 - $uuid = self::ensure_uuid( $object, array( 'collides' => $collides ) );
424 + // Read path over a record loaded from its own row: a loaded, unchanged uuid
425 + // is trusted (ADR 0038). On the legacy CPT order store the detector is a
426 + // full `wp_postmeta` uuid walk per served order (#1805).
427 + $uuid = self::ensure_uuid(
428 + $object,
429 + array(
430 + 'collides' => $collides,
431 + 'trust_persisted' => true,
432 + )
433 + );
380 434
381 435 return '' === $uuid ? $payload : self::ensure_in_payload( $payload, $uuid );
382 436 }
383 437
@@ -413,8 +467,19 @@
413 467 * `wc_orders_meta`, otherwise in `wp_postmeta`. `wc_get_orders()` with a
414 468 * `meta_query` is NOT supported on the CPT order datastore (it fires a
415 469 * `doing_it_wrong` and returns unfiltered results), so we query the meta table
416 470 * directly — the same shape the plugin's other order-uuid lookups use.
471 + *
472 + * DELIBERATELY UNORDERED — do not add an `ORDER BY` back (#1725). Every caller
473 + * asks a counting question ("does a DIFFERENT record hold this uuid?", "is this
474 + * uuid ambiguous?"), so WHICH two ids come back is immaterial. `wp_postmeta`
475 + * indexes `meta_key` but never `meta_value`, and `ORDER BY m.post_id ASC LIMIT 2`
476 + * made the optimizer abandon the `meta_key` index for an id-ordered walk that
477 + * expects to stop early. In the common case the uuid matches at most one row, so
478 + * it never reaches two and walks the whole table: 887,404 rows and ~1.0 s per
479 + * call on a real store, versus ~51 ms without the clause. HPOS escapes it only
480 + * because `wc_orders_meta` carries a composite `(meta_key, meta_value)` index —
481 + * by data, not by code — so the clause is gone from both branches.
417 482 */
418 483 public static function get_order_ids_by_uuid( string $uuid ): array {
419 484 global $wpdb;
420 485 if ( ! isset( $wpdb ) ) {
@@ -432,9 +497,9 @@
432 497 "SELECT DISTINCT m.order_id FROM {$wpdb->prefix}wc_orders_meta m"
433 498 . " JOIN {$wpdb->prefix}wc_orders o ON o.id = m.order_id AND o.type = 'shop_order'"
434 499 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
435 500 . " AND o.status NOT IN ('trash','auto-draft')"
436 - . ' ORDER BY m.order_id ASC LIMIT 2',
501 + . ' LIMIT 2',
437 502 self::META_KEY,
438 503 $uuid
439 504 )
440 505 );
@@ -444,9 +509,9 @@
444 509 "SELECT DISTINCT m.post_id FROM {$wpdb->postmeta} m"
445 510 . " JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_type = 'shop_order'"
446 511 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
447 512 . " AND p.post_status NOT IN ('trash','auto-draft')"
448 - . ' ORDER BY m.post_id ASC LIMIT 2',
513 + . ' LIMIT 2',
449 514 self::META_KEY,
450 515 $uuid
451 516 )
452 517 );
@@ -469,8 +534,14 @@
469 534 return;
470 535 }
471 536 add_action( 'woocommerce_before_product_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 );
472 537 add_action( 'woocommerce_before_product_variation_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 );
538 + // A record leaving the trash must re-prove ownership: it was invisible to
539 + // the detector while inactive, so another record may hold its uuid now.
540 + // Both storage lanes — `untrashed_post` never fires for HPOS orders and
541 + // `woocommerce_untrash_order` never fires for posts (ADR 0038).
542 + add_action( 'untrashed_post', array( __CLASS__, 'recheck_ownership_after_untrash' ), 10, 1 );
543 + add_action( 'woocommerce_untrash_order', array( __CLASS__, 'recheck_order_ownership_after_untrash' ), 10, 1 );
473 544 }
474 545
475 546 /**
476 547 * Before-save hook: ensure the WC object carries a unique uuid as part of the
@@ -475,8 +546,14 @@
475 546 /**
476 547 * Before-save hook: ensure the WC object carries a unique uuid as part of the
477 548 * in-progress save (persist:false — the save itself writes it).
478 549 *
550 + * The ownership scan runs only for a uuid that did NOT come from this record's
551 + * own persisted meta row (`trust_persisted`, {@see is_own_persisted_uuid}). It
552 + * walks every uuid row in `wp_postmeta` (no `meta_value` index), so on every
553 + * save it cost 0.46 s and 30k rows examined on a 30k-product store, 114 times
554 + * an hour, for a fact the loaded row already stated (#1805, ADR 0038).
555 + *
479 556 * @param mixed $object
480 557 */
481 558 public static function stamp_on_save( $object ): void {
482 559 self::ensure_uuid(
@@ -481,12 +558,122 @@
481 558 public static function stamp_on_save( $object ): void {
482 559 self::ensure_uuid(
483 560 $object,
484 561 array(
485 - 'collides' => array( __CLASS__, 'uuid_owned_by_other' ),
486 - 'persist' => false,
562 + 'collides' => array( __CLASS__, 'uuid_owned_by_other' ),
563 + 'persist' => false,
564 + 'trust_persisted' => true,
487 565 )
488 566 );
567 + }
568 +
569 + /**
570 + * Re-prove uuid ownership for a post that just left the trash (products,
571 + * variations, and orders on the legacy CPT store).
572 + *
573 + * A trashed record is not a live owner, so a clone or import made while it
574 + * was in the trash legitimately kept the copied uuid — and the tills now key
575 + * on that record. A native restore (wp-admin's Restore, `wp_untrash_post()`)
576 + * persists the status change before any WC object save, so neither the write
577 + * hook nor the trusted read path ever sees a trash→live transition: this hook
578 + * is the one seam. It runs the detector once per restore — a rare event — and
579 + * re-keys the RESTORED record when another live record owns its uuid, never
580 + * the record the tills already hold (ADR 0038).
581 + *
582 + * @param mixed $post_id Restored post id (`untrashed_post`).
583 + */
584 + public static function recheck_ownership_after_untrash( $post_id ): void {
585 + $post_id = (int) $post_id;
586 + $post_type = \function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '';
587 + if ( \in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
588 + $object = \function_exists( 'wc_get_product' ) ? wc_get_product( $post_id ) : null;
589 + $collides = array( __CLASS__, 'uuid_owned_by_other' );
590 + } elseif ( 'shop_order' === $post_type ) {
591 + $object = \function_exists( 'wc_get_order' ) ? wc_get_order( $post_id ) : null;
592 + $collides = array( __CLASS__, 'uuid_owned_by_other_order' );
593 + } else {
594 + return;
595 + }
596 + if ( \is_object( $object ) && method_exists( $object, 'get_id' ) && (int) $object->get_id() === $post_id ) {
597 + self::ensure_uuid( $object, array( 'collides' => $collides ) );
598 + }
599 + }
600 +
601 + /**
602 + * HPOS twin of {@see recheck_ownership_after_untrash}: `untrashed_post` never
603 + * fires for orders in the orders table, and `woocommerce_untrash_order` fires
604 + * BEFORE the data store restores the status (a detector run there would see a
605 + * still-trashed row and, worse, the restore's own save would write the old meta
606 + * back). Arm a one-shot on the order's first live object save and re-prove
607 + * ownership then — the same seam {@see Integrity_Digest::record_order_untrashed}
608 + * uses.
609 + *
610 + * @param mixed $order_id Order being restored (`woocommerce_untrash_order`).
611 + */
612 + public static function recheck_order_ownership_after_untrash( $order_id ): void {
613 + $order_id = (int) $order_id;
614 + $handler = static function ( $order ) use ( $order_id, &$handler ): void {
615 + if ( ! \is_object( $order ) || ! method_exists( $order, 'get_id' ) || ! method_exists( $order, 'get_status' )
616 + || (int) $order->get_id() !== $order_id || 'trash' === $order->get_status() ) {
617 + return;
618 + }
619 + remove_action( 'woocommerce_after_order_object_save', $handler );
620 + self::ensure_uuid( $order, array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_order' ) ) );
621 + };
622 + add_action( 'woocommerce_after_order_object_save', $handler );
623 + }
624 +
625 + /**
626 + * True when $entry — the record's canonical uuid entry — was READ from this
627 + * record's own meta row and has not been changed in memory since: the uuid is
628 + * already this record's persisted identity, not a value that arrived by copy.
629 + *
630 + * The ownership detector exists to catch a uuid that reached a record some
631 + * other way, and every such provenance fails this test: a duplicated object
632 + * (WooCommerce's "Duplicate" clones the meta with its ids cleared), an importer
633 + * rewriting the value in memory (a tracked change on the entry), a record with
634 + * no id yet, a record returning from the trash. What passes is the ordinary
635 + * save or read — a stock change, a price edit, a REST update, a served record —
636 + * where the detector re-proved a fact at a cost linear in catalog size.
637 + *
638 + * A copy made WITHOUT hooks (direct SQL, a migration tool) passes too, on both
639 + * records: neither save re-keys it. Deliberate (ADR 0038): the detector caught
640 + * that shape only when one of the two next saved, and re-keyed whichever that
641 + * was — the original as readily as the copy. The collision backfill
642 + * (`/uuid/backfill?mode=collisions`) walks the store once in bounded pages and
643 + * re-keys the later copy, never the owner; it is the repair for that shape.
644 + *
645 + * Duck-typed on WC_Data / WC_Meta_Data (`get_id`, `get_changes`, `get_data`,
646 + * `->id`): a bare array or a fake without change tracking is never trusted.
647 + *
648 + * @param mixed $object
649 + * @param mixed $entry
650 + */
651 + private static function is_own_persisted_uuid( $object, $entry ): bool {
652 + if ( ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) || (int) $object->get_id() <= 0 ) {
653 + return false;
654 + }
655 + // A record coming back from trash/auto-draft was invisible to the ownership
656 + // scan while inactive (inactive rows are not live owners), so another record
657 + // may have adopted its uuid in the meantime — and that record is what the
658 + // tills now key on. The transition back to live is the one ordinary save
659 + // that must re-prove ownership; the loaded status is still in get_data()
660 + // because the before-save hook fires ahead of apply_changes().
661 + if ( method_exists( $object, 'get_changes' ) && method_exists( $object, 'get_data' ) ) {
662 + $changes = (array) $object->get_changes();
663 + if ( isset( $changes['status'] ) ) {
664 + $loaded = (array) $object->get_data();
665 + if ( \in_array( (string) ( $loaded['status'] ?? '' ), array( 'trash', 'auto-draft' ), true ) ) {
666 + return false;
667 + }
668 + }
669 + }
670 + // Only the canonical entry's provenance decides; a trailing duplicate is
671 + // pruned by the save either way.
672 + return \is_object( $entry )
673 + && method_exists( $entry, 'get_changes' )
674 + && ! empty( $entry->id )
675 + && array() === $entry->get_changes();
489 676 }
490 677
491 678 /**
492 679 * True when $uuid is already stored as `_woocommerce_pos_uuid` on a DIFFERENT