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

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

615 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync store component.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
11 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries use internal table names and generated SQL fragments.
12 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Interpolation is limited to the class-owned table name.
13 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Database failures are passed to exceptions, not rendered.
14
15 use WP_Error;
16 /**
17 * Server-side idempotency + identity resolution for the generic write surface
18 * (P1-0). Two concerns, one home, because both are collection-agnostic data access:
19 *
20 * - **Idempotency:** a single dedup table records each applied `mutationId` → its
21 * server id, so a retried push never double-applies. ONE schema serves every
22 * collection (postmeta / usermeta / termmeta / tax tables all differ — a table
23 * is *more* uniform than meta), and it SURVIVES a delete (a delete removes the
24 * record + its meta, but the mutation row persists so a retried delete is still
25 * recognised as done).
26 * - **Identity resolution:** the client record uuid → the existing server numeric
27 * id. The server REUSES the client's `_woocommerce_pos_uuid` (#219 stamping) and
28 * NEVER re-keys; this is the lookup that finds the row to update/delete and the
29 * born-twice guard for create.
30 *
31 * Duck-typed by the controller (it accepts any object with these methods), so a
32 * test injects an in-memory fake and the controller's apply logic stays unit-testable.
33 */
34 class Mutation_Store {
35
36 /**
37 * Seconds after which a still-`pending` reservation is treated as a CRASHED
38 * in-flight push and may be reclaimed. A conservative FIXED lease — deliberately
39 * NOT derived from `max_execution_time`: on non-Windows PHP that counts CPU time,
40 * not wall-clock, so a request blocked in a slow WooCommerce write / DB call / stream can
41 * run far longer than it (and `max_execution_time = 0` disables it entirely). The
42 * real wall-clock bound is the web/proxy request timeout, which kills a hung
43 * request long before this default. So a live push completes well within the
44 * lease; a genuinely crashed one self-heals after it. Reclaiming a still-running
45 * reservation would reopen the duplicate-create race the reservation prevents, so
46 * we err large. Filterable for unusual setups.
47 */
48 public function reservation_ttl(): int {
49 return max( 60, (int) apply_filters( 'woocommerce_pos_sync_reservation_ttl', 900 ) );
50 }
51
52 public function table_name(): string {
53 global $wpdb;
54 return $wpdb->prefix . Health::MUTATIONS_TABLE;
55 }
56
57 /** Build the mutation store schema SQL. */
58 public function schema_sql( string $table_name, string $charset_collate = '' ): string {
59 return "CREATE TABLE {$table_name} (
60 "
61 . ' mutation_id VARCHAR(36) NOT NULL,
62 '
63 . ' collection VARCHAR(32) NOT NULL,
64 '
65 . ' record_uuid VARCHAR(36) NOT NULL,
66 '
67 . ' remote_id BIGINT NOT NULL,
68 '
69 . ' operation VARCHAR(8) NOT NULL,
70 '
71 . ' fingerprint CHAR(64) NOT NULL,
72 '
73 . ' status VARCHAR(8) NOT NULL,
74 '
75 . ' response_status SMALLINT NULL,
76 '
77 . ' created_at DATETIME NOT NULL,
78 '
79 . ' PRIMARY KEY (mutation_id),
80 '
81 . ' KEY collection_uuid (collection, record_uuid),
82 '
83 . ' KEY status_created (status, created_at)
84 '
85 . ") {$charset_collate};";
86 }
87
88 /** Install the mutation store table. */
89 public function install(): void {
90 global $wpdb;
91 if ( ! function_exists( 'dbDelta' ) ) {
92 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
93 }
94 dbDelta( $this->schema_sql( $this->table_name(), $wpdb->get_charset_collate() ) );
95 }
96
97 /** A prior reservation or application of this globally unique mutationId, or null. */
98 public function lookup( string $collection, string $mutation_id ): ?array {
99 global $wpdb;
100 $row = $wpdb->get_row(
101 $wpdb->prepare(
102 "SELECT collection, remote_id, operation, record_uuid, fingerprint, status, response_status FROM {$this->table_name()} WHERE mutation_id = %s",
103 $mutation_id
104 ),
105 ARRAY_A
106 );
107 return is_array( $row ) ? $row : null;
108 }
109
110 /**
111 * Atomically CLAIM a mutationId before its (non-idempotent) side effect runs.
112 * `INSERT IGNORE` on the PRIMARY KEY is the atomic gate: exactly one concurrent
113 * caller inserts the `pending` row (returns true); the rest lose (false) and must
114 * replay or wait. This is what makes the create path safe against a timeout-retry
115 * overlapping its own in-flight push.
116 */
117 public function reserve( string $collection, string $mutation_id, string $record_uuid, string $operation, string $fingerprint = '' ): bool {
118 global $wpdb;
119 $affected = $wpdb->query(
120 $wpdb->prepare(
121 "INSERT IGNORE INTO {$this->table_name()} (mutation_id, collection, record_uuid, remote_id, operation, fingerprint, status, created_at) VALUES (%s, %s, %s, 0, %s, %s, 'pending', %s)",
122 $mutation_id,
123 $collection,
124 $record_uuid,
125 $operation,
126 $fingerprint,
127 gmdate( 'Y-m-d H:i:s' )
128 )
129 );
130 return 1 === (int) $affected;
131 }
132
133 /** Checkpoint a completed WooCommerce side effect before the final done transition. */
134 public function mark_applied( string $mutation_id, int $remote_id, int $response_status ): bool {
135 global $wpdb;
136 $affected = $wpdb->update(
137 $this->table_name(),
138 array(
139 'remote_id' => $remote_id,
140 'status' => 'applied',
141 'response_status' => $response_status,
142 ),
143 array(
144 'mutation_id' => $mutation_id,
145 'status' => 'pending',
146 )
147 );
148 return false !== $affected && 1 === (int) $affected;
149 }
150
151 /** Preserve a create side effect whose client identity has not yet been stamped. */
152 public function mark_poison( string $mutation_id, int $remote_id, int $response_status = 201 ): bool {
153 global $wpdb;
154 $affected = $wpdb->update(
155 $this->table_name(),
156 array(
157 'remote_id' => $remote_id,
158 'status' => 'poison',
159 'response_status' => $response_status,
160 ),
161 array(
162 'mutation_id' => $mutation_id,
163 'status' => 'pending',
164 )
165 );
166 return false !== $affected && 1 === (int) $affected;
167 }
168
169 /** Retain an uncertain create side effect for manual recovery, never stale reclaim. */
170 public function mark_indeterminate( string $mutation_id, int $remote_id, int $response_status ): bool {
171 global $wpdb;
172 $affected = $wpdb->update(
173 $this->table_name(),
174 array(
175 'remote_id' => $remote_id,
176 'status' => 'blocked',
177 'response_status' => $response_status,
178 ),
179 array(
180 'mutation_id' => $mutation_id,
181 'status' => 'pending',
182 )
183 );
184 return false !== $affected && 1 === (int) $affected;
185 }
186
187 /** Complete an identity stamp without reopening the non-idempotent create. */
188 public function finalize_poison( string $mutation_id, int $remote_id ): bool {
189 global $wpdb;
190 $affected = $wpdb->update(
191 $this->table_name(),
192 array( 'status' => 'done' ),
193 array(
194 'mutation_id' => $mutation_id,
195 'remote_id' => $remote_id,
196 'status' => 'poison',
197 )
198 );
199 if ( false === $affected || 0 !== (int) $affected ) {
200 return 1 === (int) $affected;
201 }
202
203 $row = $wpdb->get_row(
204 $wpdb->prepare(
205 "SELECT remote_id, status FROM {$this->table_name()} WHERE mutation_id = %s",
206 $mutation_id
207 ),
208 ARRAY_A
209 );
210 return is_array( $row )
211 && 'done' === ( $row['status'] ?? null )
212 && (int) ( $row['remote_id'] ?? -1 ) === $remote_id;
213 }
214
215 /** Mark a checkpointed mutation done. False means the acknowledgement is unsafe. */
216 public function finalize( string $mutation_id, int $remote_id ): bool {
217 global $wpdb;
218 $affected = $wpdb->update(
219 $this->table_name(),
220 array( 'status' => 'done' ),
221 array(
222 'mutation_id' => $mutation_id,
223 'remote_id' => $remote_id,
224 'status' => 'applied',
225 )
226 );
227 if ( false === $affected || 0 !== (int) $affected ) {
228 return 1 === (int) $affected;
229 }
230
231 // A replay may have finalized the same checkpoint after this caller read
232 // `applied` but before its UPDATE ran. That is already the desired durable
233 // result, provided the winning finalize recorded the same remote identity.
234 $row = $wpdb->get_row(
235 $wpdb->prepare(
236 "SELECT remote_id, status FROM {$this->table_name()} WHERE mutation_id = %s",
237 $mutation_id
238 ),
239 ARRAY_A
240 );
241 return is_array( $row )
242 && 'done' === ( $row['status'] ?? null )
243 && (int) ( $row['remote_id'] ?? -1 ) === $remote_id;
244 }
245
246 /** Undo a reservation whose apply FAILED, so an immediate retry can re-claim it. */
247 public function release( string $mutation_id ): void {
248 global $wpdb;
249 $wpdb->delete(
250 $this->table_name(),
251 array(
252 'mutation_id' => $mutation_id,
253 'status' => 'pending',
254 )
255 );
256 }
257
258 /**
259 * Delete one batch of SETTLED rows (done/applied) past their retention.
260 *
261 * Settled rows exist to answer idempotent replays, and a client's retry
262 * horizon is hours — far inside any sane retention window. Pruning is safe
263 * because this store is a fast-path, not the only guard: a replayed create
264 * is caught by uuid identity resolution (the born-twice guard), a replayed
265 * update by the baseRevision compare (it answers 409 instead of replaying
266 * the stored ack — a different response, not a double-apply), and a
267 * replayed delete of an already-gone record is an idempotent success. An
268 * `applied` row past the window is a checkpoint whose finalize never ran
269 * (crash before the ack was sent); its create stamped the uuid before the
270 * checkpoint, so the same guards cover it.
271 *
272 * CREATE rows get their own (longer) cutoff: if a settled create's record
273 * is later deleted server-side, the uuid guard resolves nothing and a
274 * sufficiently late replay would resurrect the record as a new insert.
275 * The mutation row is the only guard for that corner, so creates are kept
276 * well past any plausible client queue age.
277 *
278 * `pending` rows are NEVER retention-pruned: they are the reservation lane
279 * and have their own TTL reclaim (see reclaim_stale()).
280 *
281 * Select-then-delete-by-key (the journal purge's pattern) rather than
282 * DELETE..ORDER BY..LIMIT: no filesort, deterministic under replication,
283 * and correct with or without the (status, created_at) index.
284 *
285 * @param string $cutoff_gmt UTC datetime; non-create rows created before it are pruned.
286 * @param string $create_cutoff_gmt UTC datetime; create rows created before it are pruned.
287 * @param int $limit Maximum rows to delete.
288 *
289 * @return int Rows deleted.
290 */
291 public function prune_settled( string $cutoff_gmt, string $create_cutoff_gmt, int $limit ): int {
292 global $wpdb;
293 if ( $limit < 1 ) {
294 return 0;
295 }
296
297 $ids = $wpdb->get_col(
298 $wpdb->prepare(
299 "SELECT mutation_id FROM {$this->table_name()} WHERE status IN ('done','applied')"
300 . " AND ( ( operation <> 'create' AND created_at < %s ) OR ( operation = 'create' AND created_at < %s ) ) LIMIT %d",
301 $cutoff_gmt,
302 $create_cutoff_gmt,
303 $limit
304 )
305 );
306
307 if ( empty( $ids ) ) {
308 return 0;
309 }
310
311 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%s' ) );
312 $affected = $wpdb->query(
313 $wpdb->prepare(
314 "DELETE FROM {$this->table_name()} WHERE mutation_id IN ({$placeholders}) AND status IN ('done','applied')"
315 . " AND ( ( operation <> 'create' AND created_at < %s ) OR ( operation = 'create' AND created_at < %s ) )",
316 array_merge( $ids, array( $cutoff_gmt, $create_cutoff_gmt ) )
317 )
318 );
319
320 return false === $affected ? 0 : (int) $affected;
321 }
322
323 /**
324 * Delete one batch of FAILURE rows (poison/blocked) older than the cutoff.
325 *
326 * Poison/blocked rows are manual-recovery records — a create side effect
327 * whose client identity was never stamped, so the uuid guard cannot catch a
328 * replay. They are rare (bounded by failures, not traffic) and are kept
329 * forever unless a site opts into a window via
330 * `woocommerce_pos_sync_mutation_failure_retention_days`.
331 *
332 * @param string $cutoff_gmt UTC datetime; only rows created before it are pruned.
333 * @param int $limit Maximum rows to delete.
334 *
335 * @return int Rows deleted.
336 */
337 public function prune_failed( string $cutoff_gmt, int $limit ): int {
338 global $wpdb;
339 if ( $limit < 1 ) {
340 return 0;
341 }
342
343 $ids = $wpdb->get_col(
344 $wpdb->prepare(
345 "SELECT mutation_id FROM {$this->table_name()} WHERE status IN ('poison','blocked') AND created_at < %s LIMIT %d",
346 $cutoff_gmt,
347 $limit
348 )
349 );
350
351 if ( empty( $ids ) ) {
352 return 0;
353 }
354
355 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%s' ) );
356 $affected = $wpdb->query(
357 $wpdb->prepare(
358 "DELETE FROM {$this->table_name()} WHERE mutation_id IN ({$placeholders}) AND status IN ('poison','blocked') AND created_at < %s",
359 array_merge( $ids, array( $cutoff_gmt ) )
360 )
361 );
362
363 return false === $affected ? 0 : (int) $affected;
364 }
365
366 /**
367 * Reclaim a STALE pending non-create reservation (a crashed in-flight push) so
368 * a retry can proceed. A pending create may already have reached WooCommerce;
369 * retain it for manual recovery rather than risk forwarding a duplicate.
370 * Returns true if one was reclaimed.
371 */
372 public function reclaim_stale( string $mutation_id, int $ttl_seconds ): bool {
373 global $wpdb;
374 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $ttl_seconds );
375 $affected = $wpdb->query(
376 $wpdb->prepare(
377 "DELETE FROM {$this->table_name()} WHERE mutation_id = %s AND status = 'pending' AND operation <> 'create' AND created_at < %s",
378 $mutation_id,
379 $cutoff
380 )
381 );
382 return (int) $affected > 0;
383 }
384
385 /**
386 * A stable, short, connection-scoped MySQL advisory-lock name for one record.
387 * GET_LOCK names are capped at 64 chars, so hash the collection+uuid pair.
388 */
389 private function record_lock_name( string $collection, string $uuid ): string {
390 return 'wcpos_rec_' . md5( $collection . '|' . strtolower( $uuid ) );
391 }
392
393 /**
394 * Acquire a per-RECORD advisory lock (collection + uuid) for the duration of an
395 * apply. Without it the optimistic-concurrency check is check-then-write: two
396 * DISTINCT mutations on the same record can both read the same current revision,
397 * both pass the baseRevision compare, and both forward — a silent lost update.
398 * Holding this lock serialises them, so the second writer re-reads the now-updated
399 * revision and gets a real 409 instead of clobbering the first.
400 *
401 * Blocks up to a short timeout (a normal write finishes well within it); returns
402 * false only if the holder is stuck past the timeout, in which case the caller
403 * surfaces a retryable busy response. The lock auto-releases if the holding
404 * connection dies, so a crashed writer never wedges a record.
405 *
406 * NOTE: GET_LOCK is connection-scoped — correct on a single node or a writer-pinned
407 * sync namespace; replica-split safety is the deferred F14 concern.
408 */
409 public function acquire_record_lock( string $collection, string $uuid ): bool {
410 global $wpdb;
411 $timeout = max( 0, (int) apply_filters( 'woocommerce_pos_sync_record_lock_timeout', 5 ) );
412 $got = $wpdb->get_var(
413 $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $this->record_lock_name( $collection, $uuid ), $timeout )
414 );
415 return '1' === (string) $got; // GET_LOCK → 1 acquired, 0 timeout, NULL error
416 }
417
418 /** Release the per-record advisory lock acquired by acquire_record_lock(). */
419 public function release_record_lock( string $collection, string $uuid ): void {
420 global $wpdb;
421 $wpdb->get_var(
422 $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->record_lock_name( $collection, $uuid ) )
423 );
424 }
425
426 /**
427 * Persist the client's uuid as the record's `_woocommerce_pos_uuid`, DIRECTLY
428 * via the meta API. This is the server half of "reuse the client's uuid, never
429 * re-key": `_woocommerce_pos_uuid` is PROTECTED meta (leading underscore), so
430 * wc/v3's REST meta handler drops it from a create/update payload — the only
431 * reliable way to make the client's recordId the server identity is to write it
432 * ourselves after the record exists. Uniform across user/post/term meta.
433 */
434 public function persist_uuid( string $id_type, int $id, string $uuid ): bool {
435 if ( $id <= 0 ) {
436 return false;
437 }
438 $key = Api::UUID_META_KEY;
439 switch ( $id_type ) {
440 case 'user':
441 update_user_meta( $id, $key, $uuid );
442 return (string) get_user_meta( $id, $key, true ) === $uuid;
443 case 'post':
444 update_post_meta( $id, $key, $uuid );
445 return (string) get_post_meta( $id, $key, true ) === $uuid;
446 case 'term':
447 update_term_meta( $id, $key, $uuid );
448 return (string) get_term_meta( $id, $key, true ) === $uuid;
449 case 'order':
450 // HPOS: the uuid lives on the order object's meta, not post meta.
451 $order = function_exists( 'wc_get_order' ) ? wc_get_order( $id ) : null;
452 if ( is_object( $order ) && method_exists( $order, 'update_meta_data' ) ) {
453 $order->update_meta_data( $key, $uuid );
454 if ( method_exists( $order, 'save' ) ) {
455 $order->save();
456 }
457 return method_exists( $order, 'get_meta' ) && (string) $order->get_meta( $key, true ) === $uuid;
458 }
459 return false;
460 }
461 return false;
462 }
463
464 /**
465 * Persist an order's POS audit fields DIRECTLY on the order object (HPOS-safe), exactly as
466 * {@see persist_uuid} does for the uuid: these are PROTECTED (`_`-prefixed) meta that wc/v3
467 * drops from a create payload, so the only reliable place to write them is here, after the
468 * order exists. `$created_via` (an order property, not meta) is set when non-empty; `$meta`
469 * is a key→value map (e.g. `_pos_user`, `_pos_store`, cash-tender). One load + one save.
470 * A missing order (or one that can't carry meta) is a safe no-op.
471 */
472 public function persist_order_audit_meta( int $id, array $meta, string $created_via = '' ): void {
473 if ( $id <= 0 ) {
474 return;
475 }
476 $order = function_exists( 'wc_get_order' ) ? wc_get_order( $id ) : null;
477 if ( ! is_object( $order ) || ! method_exists( $order, 'update_meta_data' ) ) {
478 return;
479 }
480 $changed = false;
481 // created_via is a CONSTANT channel marker — always assert it (also corrects WC's 'rest-api'
482 // default if the create payload's created_via didn't take). Safe to re-set on a replay.
483 if ( '' !== $created_via && method_exists( $order, 'set_created_via' ) ) {
484 $order->set_created_via( $created_via );
485 $changed = true;
486 }
487 // The audit `_pos_*` meta is WRITE-ONCE (captured at the sale). Only fill a MISSING field —
488 // never overwrite an existing one: the born-twice/existing-order path is reachable by ANY
489 // known-uuid create replay, and a retry under a different cashier/store (or a buggy duplicate)
490 // must not silently corrupt the original record's audit trail (codex).
491 foreach ( $meta as $key => $value ) {
492 if ( method_exists( $order, 'get_meta' ) && '' !== (string) $order->get_meta( (string) $key ) ) {
493 continue;
494 }
495 $order->update_meta_data( (string) $key, (string) $value );
496 $changed = true;
497 }
498 if ( $changed && method_exists( $order, 'save' ) ) {
499 $order->save();
500 }
501 }
502
503 /**
504 * The existing server numeric id for a client record uuid, or 0 if none. The
505 * `id_type` selects the meta store the uuid is mirrored into, so this is
506 * uniform per record kind. `tax_rate` has no native meta and is not supported.
507 *
508 * COLLISION-AWARE: a uuid is meant to identify exactly one record, but an importer,
509 * a product duplicator, or a staging->prod DB clone can copy the `_woocommerce_pos_uuid`
510 * meta onto a second record. Resolving such a uuid to an arbitrary first match would
511 * route a write/delete to the WRONG record, so we fetch up to two and **fail closed**
512 * (`WP_Error` 409 `woo_rxdb_sync_identity_ambiguous`) when more than one record carries
513 * the uuid — the caller aborts the mutation (and releases its reservation) rather than
514 * corrupt a record. A unique match is the only id in the result set, so resolution is
515 * deterministic and retries are stable without the query imposing an order.
516 *
517 * DELIBERATELY UNORDERED — do not add an `ORDER BY` back (#1725). Because the outcome
518 * is decided by the COUNT (0 = none, 1 = that id, >1 = fail closed), which two ids a
519 * `LIMIT 2` returns is immaterial. `wp_postmeta`/`wp_usermeta`/`wp_termmeta` index
520 * `meta_key` but never `meta_value`, so `ORDER BY <id> ASC LIMIT 2` made the optimizer
521 * prefer an id-ordered walk that expects to stop early — and, with at most one match,
522 * never does. Measured at ~1.35 s per call on a real store; see Pos_Uuid::get_order_ids_by_uuid.
523 *
524 * @return int|WP_Error 0 if none, the id if unique, or a 409 WP_Error if ambiguous.
525 */
526 public function resolve_id_by_uuid( string $id_type, string $uuid, array $opts = array() ) {
527 $key = Api::UUID_META_KEY;
528 global $wpdb;
529 switch ( $id_type ) {
530 case 'user':
531 $found = $wpdb->get_col(
532 $wpdb->prepare(
533 "SELECT DISTINCT u.ID FROM {$wpdb->users} u"
534 . " JOIN {$wpdb->usermeta} m ON m.user_id = u.ID"
535 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
536 . ' LIMIT 2',
537 $key,
538 $uuid
539 )
540 );
541 break;
542 case 'post':
543 // Only a LIVE post counts as a collision owner: a trashed/auto-draft copy that
544 // shares the uuid (left behind by a duplicator/importer/clone) will never be
545 // served, so it must not make resolution ambiguous. This mirrors the plugin's
546 // live-owner convention (class-pos-uuid.php:208, class-uuid-backfill-controller.php).
547 $post_type = $opts['post_type'] ?? 'any';
548 $sql = "SELECT DISTINCT p.ID FROM {$wpdb->posts} p"
549 . " JOIN {$wpdb->postmeta} m ON m.post_id = p.ID"
550 . ' WHERE m.meta_key = %s AND m.meta_value = %s'
551 . " AND p.post_status NOT IN ('trash','auto-draft')";
552 $args = array( $key, $uuid );
553 if ( 'any' !== $post_type ) {
554 $sql .= ' AND p.post_type = %s';
555 $args[] = $post_type;
556 }
557 $sql .= ' LIMIT 2';
558 $found = $wpdb->get_col( $wpdb->prepare( $sql, ...$args ) );
559 break;
560 case 'term':
561 $found = $wpdb->get_col(
562 $wpdb->prepare(
563 "SELECT DISTINCT term_id FROM {$wpdb->termmeta}"
564 . ' WHERE meta_key = %s AND meta_value = %s'
565 . ' LIMIT 2',
566 $key,
567 $uuid
568 )
569 );
570 $resolved = $this->unique_id_or_ambiguous( is_array( $found ) ? $found : array(), $id_type, $uuid );
571 if ( is_wp_error( $resolved ) || 0 === $resolved ) {
572 return $resolved;
573 }
574 $taxonomy = $opts['taxonomy'] ?? '';
575 if ( '' === $taxonomy ) {
576 return $resolved;
577 }
578 $found = $wpdb->get_col(
579 $wpdb->prepare(
580 "SELECT tt.term_id FROM {$wpdb->term_taxonomy} tt"
581 . ' WHERE tt.term_id = %d AND tt.taxonomy = %s LIMIT 1',
582 $resolved,
583 $taxonomy
584 )
585 );
586 return empty( $found ) ? 0 : $resolved;
587 case 'order':
588 $found = Pos_Uuid::get_order_ids_by_uuid( $uuid );
589 break;
590 default:
591 return 0;
592 }
593
594 return $this->unique_id_or_ambiguous( is_array( $found ) ? $found : array(), $id_type, $uuid );
595 }
596
597 /**
598 * Reduce a (<=2) candidate id list to a single id (0 if none), or a 409 ambiguity
599 * error when more than one distinct record carries the uuid. See resolve_id_by_uuid.
600 *
601 * @return int|WP_Error
602 */
603 private function unique_id_or_ambiguous( array $found, string $id_type, string $uuid ) {
604 $ids = array_values( array_unique( array_map( 'intval', $found ) ) );
605 if ( count( $ids ) > 1 ) {
606 return new WP_Error(
607 'woo_rxdb_sync_identity_ambiguous',
608 sprintf( 'uuid %s resolves to more than one %s record; refusing to write to an arbitrary match.', $uuid, $id_type ),
609 array( 'status' => 409 )
610 );
611 }
612 return empty( $ids ) ? 0 : $ids[0];
613 }
614 }
615