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
woocommerce-pos / includes / Sync / Pos_Uuid.php

Pos_Uuid.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/Sync/Pos_Uuid.php

844 lines 34.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync read surface.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use Exception;
11 use Ramsey\Uuid\Uuid;
12 use WC_Customer;
13 use WC_Order_Item;
14 use WCPOS\WooCommercePOS\Logger;
15
16 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
17 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries are prepared before execution.
18
19 /**
20 * Uniform record identity on the server side — the sole authority that stamps,
21 * validates, deduplicates, and checks ownership of `_woocommerce_pos_uuid`.
22 * Legacy API callers delegate here (ADR 0021, decision c).
23 *
24 * The client uses this uuid as the stable RxDB primary key (ADR 0008, guardrail
25 * G1): a record carries the SAME identity on the server and the client, may be
26 * born on either side, and is NEVER re-keyed. This is the server half of that
27 * contract — a record pulled from the lab namespace arrives WITH its uuid, so the
28 * client never has to mint a divergent one for a server-born record. Multisite
29 * customer first-stamps are serialized while adopting legacy per-blog values.
30 *
31 * Reads an existing valid uuid from the record's meta; if absent/invalid it
32 * generates one and PERSISTS it (so it is stable across pulls), then mirrors it
33 * into the serialized payload's `meta_data`. Duck-typed on the WC_Data methods so
34 * it stays unit-testable without WooCommerce loaded. UUID convergence does not
35 * use an object-cache lock; sync writes are serialized by their record lock,
36 * while stamping deterministically converges duplicate meta rows.
37 */
38 class Pos_Uuid {
39 public const META_KEY = Api::UUID_META_KEY;
40 /** Meta key carrying the freshly-recomputed variable-product price range (P2-2). */
41
42 /**
43 * A standard 8-4-4-4-12 uuid shape (any version), case-insensitive.
44 */
45 public static function is_uuid( $value ): bool {
46 return \is_string( $value )
47 && (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value );
48 }
49
50 /**
51 * The first VALID uuid among a record's meta entries (WC_Meta_Data objects
52 * with ->key/->value, or arrays), or '' if none. Skips blank / invalid / a
53 * blank duplicate in favour of a later valid one.
54 */
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 ) {
69 foreach ( $meta_data as $meta ) {
70 if ( self::META_KEY === Meta_Entry::key( $meta ) && self::is_uuid( Meta_Entry::value( $meta ) ) ) {
71 return $meta;
72 }
73 }
74
75 return null;
76 }
77
78 /**
79 * Ensure the record carries a stable, UNIQUELY-OWNED uuid: reuse a valid
80 * existing one, else generate + persist a new one. Returns the uuid (or '' if
81 * the object can't carry meta). Duck-typed on get_meta_data / update_meta_data
82 * / save_meta_data.
83 *
84 * $opts['collides'] is an optional callable (uuid, object) => bool: when it
85 * reports the existing uuid is already owned by ANOTHER record (a clone/import
86 * that copied the meta), we treat it as needing a fresh one rather than serving
87 * a duplicate RxDB key. Injected so the branching stays unit-testable; the live
88 * wiring uses the $wpdb-backed self::uuid_owned_by_other.
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 *
99 * @param mixed $object
100 */
101 public static function ensure_uuid( $object, array $opts = array() ): string {
102 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_meta_data' ) ) {
103 return '';
104 }
105 if (
106 $object instanceof WC_Customer
107 && \function_exists( 'is_multisite' )
108 && is_multisite()
109 && '' === self::read_valid_uuid_from_meta( (array) $object->get_meta_data() )
110 ) {
111 return self::ensure_multisite_customer_uuid( $object, $opts );
112 }
113
114 return self::ensure_uuid_without_user_lock( $object, $opts );
115 }
116
117 /**
118 * Ensure a UUID after any customer-specific coordination has completed.
119 *
120 * @param mixed $object
121 */
122 private static function ensure_uuid_without_user_lock( $object, array $opts ): string {
123 $collides = $opts['collides'] ?? null;
124 $persist = $opts['persist'] ?? true;
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 );
137
138 return $existing;
139 }
140 }
141 if ( ! method_exists( $object, 'update_meta_data' ) ) {
142 return '';
143 }
144 // Minting persists by default: a freshly-generated uuid that isn't written
145 // back would differ on the next pull, making identity unstable — worse than
146 // none. persist:false is for a BEFORE-save hook, where the in-progress save
147 // writes the meta, so we add it but skip a redundant second save.
148 if ( $persist && ! method_exists( $object, 'save_meta_data' ) ) {
149 return '';
150 }
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 }
170 $object->update_meta_data( self::META_KEY, $uuid );
171 if ( $persist ) {
172 call_user_func( array( $object, 'save_meta_data' ) );
173 // A concurrent first-stamp may have persisted its own uuid between our
174 // read and save. Re-read and converge on the first-valid row so every
175 // racer returns the SAME winner instead of each serving its own mint.
176 if ( $object instanceof \WC_Data ) {
177 $object->read_meta_data( true );
178 self::prune_duplicate_uuid_meta( $object, true );
179 $stored = self::read_valid_uuid_from_meta( (array) $object->get_meta_data() );
180 if ( self::is_uuid( $stored ) ) {
181 return $stored;
182 }
183 }
184 }
185
186 return $uuid;
187 }
188
189 /**
190 * Legacy WP_User adapter for the shared WC_Data identity path (ADR 0021).
191 *
192 * @param mixed $user WP_User-like object or numeric user id.
193 */
194 public static function ensure_user_uuid( $user ): string {
195 $user_id = \is_object( $user ) && isset( $user->ID ) ? (int) $user->ID : (int) $user;
196 if ( $user_id <= 0 || ! class_exists( WC_Customer::class ) ) {
197 return '';
198 }
199
200 try {
201 $customer = new WC_Customer( $user_id );
202 } catch ( Exception $e ) {
203 Logger::log( 'Unable to load customer for UUID stamping: ' . $e->getMessage() );
204 return '';
205 }
206
207 if ( ! method_exists( $customer, 'get_id' ) || $user_id !== (int) $customer->get_id() ) {
208 return '';
209 }
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).
214 return self::ensure_uuid(
215 $customer,
216 array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_user' ) )
217 );
218 }
219
220 /**
221 * Ensure the WC_Order_Item has a valid UUID.
222 *
223 * @param WC_Order_Item $item The order item object.
224 * @return void
225 */
226 public static function ensure_order_item_uuid( WC_Order_Item $item ): void {
227 global $wpdb;
228
229 if ( self::is_uuid( $item->get_meta( self::META_KEY ) ) ) {
230 return;
231 }
232
233 $lock_key = 'wc_pos_uuid_order_item_' . $item->get_id();
234 $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_key, 10 ) );
235 if ( '1' !== (string) $acquired ) {
236 Logger::log( 'Unable to acquire lock for order item UUID update for order item id ' . $item->get_id() );
237 return;
238 }
239 try {
240 // Persist any pending meta, then check the STORED uuid directly —
241 // a full read_meta_data(true) reload would clobber sibling in-memory
242 // meta on lanes where the datastore cache lags (HPOS misc `_sku`).
243 $item->save_meta_data();
244 $uuid = wc_get_order_item_meta( $item->get_id(), self::META_KEY, true );
245 if ( ! self::is_uuid( $uuid ) ) {
246 $uuid = Uuid::uuid4()->toString();
247 $item->update_meta_data( self::META_KEY, $uuid );
248 $item->save_meta_data();
249 } elseif ( $uuid !== $item->get_meta( self::META_KEY ) ) {
250 // A concurrent request minted first; converge the stale in-memory
251 // item on the stored winner so the served payload carries it.
252 $item->update_meta_data( self::META_KEY, $uuid );
253 }
254 } finally {
255 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_key ) );
256 }
257 }
258
259 /**
260 * Promote a legacy per-blog cashier uuid to the network-wide key.
261 *
262 * Before identity consolidated here, the cashier endpoint minted
263 * `_woocommerce_pos_uuid_{blog_id}` on multisite while every other reader used
264 * the plain network-wide key — forking one user into two RxDB identities. When
265 * the plain key holds no valid uuid yet, adopt the current blog's legacy value
266 * so existing multisite cashiers keep their identity regardless of which
267 * endpoint reads them first. An existing valid plain uuid wins, because it is
268 * what /customers has already served to clients. Legacy rows are left in place
269 * (harmless, rollback-safe). A legacy value owned by ANOTHER user — under the
270 * network key or the current blog's legacy key — is never adopted: the first
271 * reader must not claim a shared legacy uuid as their network identity
272 * (#1465). When two users share the same legacy value, neither adopts it and
273 * both are minted fresh; the 1.10.0 migration resolves the ambiguity for
274 * users it reaches first.
275 *
276 * @param WC_Customer $customer Customer being stamped.
277 */
278 private static function adopt_legacy_multisite_user_uuid( WC_Customer $customer ): void {
279 $existing = self::read_valid_uuid_from_meta( (array) $customer->get_meta_data() );
280 if ( self::is_uuid( $existing ) ) {
281 return;
282 }
283
284 $user_id = (int) $customer->get_id();
285 $legacy = get_user_meta( $user_id, self::META_KEY . '_' . get_current_blog_id(), true );
286 if ( self::is_uuid( $legacy ) && ! self::legacy_uuid_owned_by_other_user( $legacy, $customer ) ) {
287 // Never clobber a uuid another request persisted concurrently — its
288 // client is already keyed on it. A unique add when no row exists; a
289 // compare-and-swap against the OBSERVED invalid value otherwise, so a
290 // lock-timeout fallback that replaced the corrupt row between our
291 // read and this write survives (the CAS no-ops and ensure_uuid then
292 // serves the concurrent winner).
293 $stored_rows = get_user_meta( $user_id, self::META_KEY, false );
294 if ( array() === $stored_rows ) {
295 add_user_meta( $user_id, self::META_KEY, $legacy, true );
296 } elseif ( ! self::is_uuid( $stored_rows[0] ) ) {
297 update_user_meta( $user_id, self::META_KEY, $legacy, $stored_rows[0] );
298 }
299 }
300 }
301
302 /**
303 * Serialize first-stamp and legacy adoption for one multisite customer.
304 */
305 private static function ensure_multisite_customer_uuid( WC_Customer $customer, array $opts ): string {
306 global $wpdb;
307
308 $user_id = (int) $customer->get_id();
309 $lock_name = 'wcpos_user_uuid_' . $user_id;
310 $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, 5 ) );
311 if ( '1' !== (string) $acquired ) {
312 // The lock holder is (or was) stamping this user. This uuid is the
313 // client's RxDB primary key, so never serve '' — and when a legacy
314 // identity may be mid-adoption, serve it read-only rather than writing
315 // anything that would pre-empt the adoption and fork the user.
316 $customer->read_meta_data( true );
317 $persisted = self::read_valid_uuid_from_meta( (array) $customer->get_meta_data() );
318 if ( self::is_uuid( $persisted ) ) {
319 return $persisted;
320 }
321
322 // An adoptable legacy uuid is what the holder will promote — serve the
323 // same value read-only so this response and the adoption agree.
324 $legacy = get_user_meta( $user_id, self::META_KEY . '_' . get_current_blog_id(), true );
325 if ( self::is_uuid( $legacy ) && ! self::legacy_uuid_owned_by_other_user( $legacy, $customer ) ) {
326 return $legacy;
327 }
328
329 // First stamp under contention: persist a fallback without clobbering a
330 // concurrent winner — a unique add when no row exists, a compare-and-swap
331 // against the (invalid) first row otherwise — then serve whichever row
332 // stuck so every racer converges on one persisted identity.
333 $fallback = self::generate_uuid();
334 $stored_rows = get_user_meta( $user_id, self::META_KEY, false );
335 if ( array() === $stored_rows ) {
336 add_user_meta( $user_id, self::META_KEY, $fallback, true );
337 } elseif ( ! self::is_uuid( $stored_rows[0] ) ) {
338 update_user_meta( $user_id, self::META_KEY, $fallback, $stored_rows[0] );
339 }
340 $stored = get_user_meta( $user_id, self::META_KEY, true );
341
342 return self::is_uuid( $stored ) ? $stored : $fallback;
343 }
344
345 try {
346 $customer->read_meta_data( true );
347 self::adopt_legacy_multisite_user_uuid( $customer );
348 $customer->read_meta_data( true );
349
350 return self::ensure_uuid_without_user_lock( $customer, $opts );
351 } finally {
352 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
353 }
354 }
355
356 /**
357 * Legacy WP_Term adapter for the shared identity path (ADR 0021).
358 *
359 * @param mixed $term WP_Term-like object or numeric term id.
360 */
361 public static function ensure_term_uuid( $term ): string {
362 $term_id = \is_object( $term ) && isset( $term->term_id ) ? (int) $term->term_id : (int) $term;
363 if ( $term_id <= 0 ) {
364 return '';
365 }
366
367 $adapter = new Term_Meta_Adapter( $term_id );
368
369 return self::ensure_uuid(
370 $adapter,
371 array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_term' ) )
372 );
373 }
374
375 /**
376 * Return a copy of the SERIALIZED payload whose `meta_data` mirrors `$uuid`
377 * exactly once (entries here are arrays: ['id'=>,'key'=>,'value'=>]). Drops
378 * blank / duplicate / mismatched `_woocommerce_pos_uuid` entries so the served
379 * record always carries one canonical identity.
380 */
381 public static function ensure_in_payload( array $payload, string $uuid ): array {
382 $meta = ( isset( $payload['meta_data'] ) && \is_array( $payload['meta_data'] ) ) ? $payload['meta_data'] : array();
383 $others = array();
384 foreach ( $meta as $entry ) {
385 $key = Meta_Entry::key( $entry );
386 if ( self::META_KEY !== $key ) {
387 $others[] = $entry;
388 }
389 }
390 $others[] = array(
391 'key' => self::META_KEY,
392 'value' => $uuid,
393 );
394 $payload['meta_data'] = array_values( $others );
395
396 return $payload;
397 }
398
399 /**
400 * Hook for the per-collection `woocommerce_pos_sync_serialized_*` filters (product,
401 * order, …): stamp the served record's stable uuid (persisting a new one if
402 * needed) and mirror it into the payload, so EVERY read carries the identity the
403 * client keys on — regardless of how the record was born. A non-array payload or
404 * an object that can't carry meta passes through unchanged.
405 *
406 * Collection-agnostic: `ensure_uuid` only needs the WC_Data meta API
407 * (get/update/save_meta_data), which orders (HPOS-safe), customers, and terms all
408 * provide. Collision detection IS storage-specific: orders live in HPOS tables
409 * (not `wp_postmeta`), so they get the order-aware detector; products/variations
410 * keep the post-scoped one. Customers and terms use their storage-specific
411 * adapters and detectors.
412 *
413 * @param mixed $payload
414 * @param mixed $object
415 * @param null|mixed $request
416 */
417 public static function stamp_serialized_record( $payload, $object, $request = null ) {
418 if ( ! \is_array( $payload ) ) {
419 return $payload;
420 }
421 $collides = is_a( $object, 'WC_Abstract_Order' )
422 ? array( __CLASS__, 'uuid_owned_by_other_order' )
423 : array( __CLASS__, 'uuid_owned_by_other' );
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 );
434
435 return '' === $uuid ? $payload : self::ensure_in_payload( $payload, $uuid );
436 }
437
438 /**
439 * Order-aware variant of {@see uuid_owned_by_other}. HPOS order meta does NOT live
440 * in `wp_postmeta`, so the post-scoped detector can't see an order that already owns
441 * `$uuid` — a duplicated/imported order with a copied uuid would slip through and two
442 * orders would share one RxDB key. Query the orders store (HPOS-safe via
443 * `wc_get_orders`) for the uuid; a match on a DIFFERENT order id is a real collision.
444 *
445 * @param mixed $uuid
446 * @param mixed $object
447 */
448 public static function uuid_owned_by_other_order( $uuid, $object ): bool {
449 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) || ! \function_exists( 'wc_get_orders' ) ) {
450 return false;
451 }
452 $order_id = (int) $object->get_id();
453 foreach ( self::get_order_ids_by_uuid( (string) $uuid ) as $other_id ) {
454 if ( (int) $other_id !== $order_id ) {
455 return true;
456 }
457 }
458
459 return false;
460 }
461
462 /**
463 * Return at most two order ids carrying a UUID. The legacy create controller
464 * treats two results as an ambiguous identity and fails closed.
465 *
466 * Datastore-aware direct meta lookup: under HPOS the uuid lives in
467 * `wc_orders_meta`, otherwise in `wp_postmeta`. `wc_get_orders()` with a
468 * `meta_query` is NOT supported on the CPT order datastore (it fires a
469 * `doing_it_wrong` and returns unfiltered results), so we query the meta table
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.
482 */
483 public static function get_order_ids_by_uuid( string $uuid ): array {
484 global $wpdb;
485 if ( ! isset( $wpdb ) ) {
486 return array();
487 }
488
489 $order_util = '\\Automattic\\WooCommerce\\Utilities\\OrderUtil';
490 $hpos = class_exists( $order_util )
491 && method_exists( $order_util, 'custom_orders_table_usage_is_enabled' )
492 && call_user_func( array( $order_util, 'custom_orders_table_usage_is_enabled' ) );
493
494 if ( $hpos ) {
495 $ids = $wpdb->get_col(
496 $wpdb->prepare(
497 "SELECT DISTINCT m.order_id FROM {$wpdb->prefix}wc_orders_meta m"
498 . " JOIN {$wpdb->prefix}wc_orders o ON o.id = m.order_id AND o.type = 'shop_order'"
499 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
500 . " AND o.status NOT IN ('trash','auto-draft')"
501 . ' LIMIT 2',
502 self::META_KEY,
503 $uuid
504 )
505 );
506 } else {
507 $ids = $wpdb->get_col(
508 $wpdb->prepare(
509 "SELECT DISTINCT m.post_id FROM {$wpdb->postmeta} m"
510 . " JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_type = 'shop_order'"
511 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
512 . " AND p.post_status NOT IN ('trash','auto-draft')"
513 . ' LIMIT 2',
514 self::META_KEY,
515 $uuid
516 )
517 );
518 }
519
520 return \is_array( $ids ) ? array_values( $ids ) : array();
521 }
522
523 /**
524 * Register WRITE-time stamping: a record gets its uuid the moment it is saved,
525 * so every read path (catalog proxy, change-signal hydration) then serves it
526 * straight from postmeta with no per-path stamping copy. Hooked BEFORE the data
527 * store writes, so the uuid lands in the SAME save — no second write, no
528 * change-log cascade, and no concurrent-first-READ race (stamping is per-save,
529 * not per-reader). The read-time filter remains as a fallback for records that
530 * predate these hooks until the backfill runs.
531 */
532 public static function register_hooks(): void {
533 if ( ! \function_exists( 'add_action' ) ) {
534 return;
535 }
536 add_action( 'woocommerce_before_product_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 );
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 );
544 }
545
546 /**
547 * Before-save hook: ensure the WC object carries a unique uuid as part of the
548 * in-progress save (persist:false — the save itself writes it).
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 *
556 * @param mixed $object
557 */
558 public static function stamp_on_save( $object ): void {
559 self::ensure_uuid(
560 $object,
561 array(
562 'collides' => array( __CLASS__, 'uuid_owned_by_other' ),
563 'persist' => false,
564 'trust_persisted' => true,
565 )
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();
676 }
677
678 /**
679 * True when $uuid is already stored as `_woocommerce_pos_uuid` on a DIFFERENT
680 * post (a cloned/imported record that copied the meta). Post-scoped (products
681 * + variations); terms/customers live in their own meta tables and get their
682 * own detector when those seams land. Returns false when $wpdb or the object's
683 * id is unavailable (e.g. unit tests inject a fake detector instead).
684 *
685 * @param mixed $uuid
686 * @param mixed $object
687 */
688 public static function uuid_owned_by_other( $uuid, $object ): bool {
689 global $wpdb;
690 if ( ! isset( $wpdb ) || ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) ) {
691 return false;
692 }
693 // Only an ACTIVE post counts as a live owner — a trashed/auto-draft record
694 // sharing the uuid is not a real collision (it will never be served), so it
695 // must not force a needless regeneration on the active record.
696 $sql = $wpdb->prepare(
697 "SELECT COUNT(*) FROM {$wpdb->postmeta} m
698 JOIN {$wpdb->posts} p ON p.ID = m.post_id
699 WHERE m.meta_key = %s AND m.meta_value = %s AND m.post_id <> %d
700 AND p.post_status NOT IN ('trash','auto-draft')",
701 self::META_KEY,
702 $uuid,
703 (int) $object->get_id()
704 );
705
706 return (int) $wpdb->get_var( $sql ) > 0;
707 }
708
709 /**
710 * User-table twin of uuid_owned_by_other for CUSTOMERS (WC_Customer over
711 * wp_usermeta). DELIBERATE asymmetry: WP users have no post_status / trash, so
712 * every user row carrying the uuid is a live owner — there is NO status
713 * exclusion (a status filter here would reference a non-existent column).
714 *
715 * @param mixed $uuid
716 * @param mixed $object
717 */
718 public static function uuid_owned_by_other_user( $uuid, $object ): bool {
719 global $wpdb;
720 if ( ! isset( $wpdb ) || ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) ) {
721 return false;
722 }
723 $sql = $wpdb->prepare(
724 "SELECT COUNT(*) FROM {$wpdb->usermeta} m
725 JOIN {$wpdb->users} u ON u.ID = m.user_id
726 WHERE m.meta_key = %s AND m.meta_value = %s AND m.user_id <> %d",
727 self::META_KEY,
728 $uuid,
729 (int) $object->get_id()
730 );
731
732 return (int) $wpdb->get_var( $sql ) > 0;
733 }
734
735 /**
736 * Adoption-gate twin of uuid_owned_by_other_user(): also checks the CURRENT
737 * blog's legacy per-blog key (`_woocommerce_pos_uuid_{blog}`), because a
738 * value another user still holds under that legacy key is the same RxDB
739 * primary key, and adopting it would fork that user's identity (#1465).
740 *
741 * Deliberately NOT used for the live-identity collision check: a stale
742 * duplicated legacy row must never discard an already-served network uuid,
743 * so `collides` stays scoped to the network key.
744 *
745 * @param mixed $uuid Candidate legacy uuid.
746 * @param WC_Customer $customer Customer being stamped.
747 */
748 private static function legacy_uuid_owned_by_other_user( $uuid, WC_Customer $customer ): bool {
749 global $wpdb;
750 if ( self::uuid_owned_by_other_user( $uuid, $customer ) ) {
751 return true;
752 }
753 if ( ! isset( $wpdb ) ) {
754 return false;
755 }
756 $sql = $wpdb->prepare(
757 "SELECT COUNT(*) FROM {$wpdb->usermeta} m
758 JOIN {$wpdb->users} u ON u.ID = m.user_id
759 WHERE m.meta_key = %s AND m.meta_value = %s AND m.user_id <> %d",
760 self::META_KEY . '_' . get_current_blog_id(),
761 $uuid,
762 (int) $customer->get_id()
763 );
764
765 return (int) $wpdb->get_var( $sql ) > 0;
766 }
767
768 /**
769 * Term-table twin for CATEGORIES + BRANDS. CROSS-TAXONOMY by design: product_cat
770 * and product_brand SHARE wp_termmeta, so a uuid on a different term in EITHER
771 * taxonomy is a real RxDB-primary-key clash — match on term_id across ALL
772 * taxonomies (NO taxonomy scoping). Terms have no trash/status, so no status
773 * exclusion (like users, unlike posts).
774 *
775 * @param mixed $uuid
776 * @param mixed $object
777 */
778 public static function uuid_owned_by_other_term( $uuid, $object ): bool {
779 global $wpdb;
780 if ( ! isset( $wpdb ) || ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) ) {
781 return false;
782 }
783 $sql = $wpdb->prepare(
784 "SELECT COUNT(*) FROM {$wpdb->termmeta} WHERE meta_key = %s AND meta_value = %s AND term_id <> %d",
785 self::META_KEY,
786 $uuid,
787 (int) $object->get_id()
788 );
789
790 return (int) $wpdb->get_var( $sql ) > 0;
791 }
792
793 /**
794 * A v4 uuid — `wp_generate_uuid4()` under WordPress, else a local fallback.
795 */
796 public static function generate_uuid(): string {
797 if ( \function_exists( 'wp_generate_uuid4' ) ) {
798 return wp_generate_uuid4();
799 }
800 $data = random_bytes( 16 );
801 $data[6] = \chr( ( \ord( $data[6] ) & 0x0f ) | 0x40 ); // version 4
802 $data[8] = \chr( ( \ord( $data[8] ) & 0x3f ) | 0x80 ); // variant 10xx
803
804 return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
805 }
806
807 /**
808 * Collapse duplicate `_woocommerce_pos_uuid` metas to ONE canonical entry —
809 * keep the first valid uuid, delete every other uuid meta (a concurrent-stamp
810 * duplicate, or a blank/invalid straggler). Only persisted metas (with a meta
811 * id) are deleted; a freshly-added unsaved one is left for the in-progress save.
812 * When $persist, the cleanup is saved immediately; otherwise the ongoing save
813 * (before-save hook) applies the deletions. Mirrors production's dedup, and is
814 * what makes the concurrent-first-stamp outcome correct without a lock.
815 *
816 * @param mixed $object
817 */
818 private static function prune_duplicate_uuid_meta( $object, bool $persist ): void {
819 if ( ! \is_object( $object ) || ! method_exists( $object, 'get_meta_data' ) || ! method_exists( $object, 'delete_meta_data_by_mid' ) ) {
820 return;
821 }
822 $kept_valid = false;
823 $deleted = false;
824 foreach ( (array) $object->get_meta_data() as $meta ) {
825 if ( ! \is_object( $meta ) || self::META_KEY !== Meta_Entry::key( $meta ) ) {
826 continue;
827 }
828 if ( ! $kept_valid && self::is_uuid( Meta_Entry::value( $meta ) ) ) {
829 $kept_valid = true; // keep the first valid uuid meta
830
831 continue;
832 }
833 $mid = $meta->id ?? null;
834 if ( null !== $mid ) {
835 $object->delete_meta_data_by_mid( $mid );
836 $deleted = true;
837 }
838 }
839 if ( $deleted && $persist && method_exists( $object, 'save_meta_data' ) ) {
840 $object->save_meta_data();
841 }
842 }
843 }
844