PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Digest_Index.php

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

989 lines 42.7 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 use WCPOS\WooCommercePOS\Services\Barcode_Field;
11
12 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
13 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Queries use internal table names and generated SQL fragments.
14
15 /**
16 * The READ half of the integrity-digest store — the sync engine's query object.
17 *
18 * {@see Integrity_Digest} owns the WRITE half (hook-time upserts, deletes and the
19 * rebuild); this class owns every question the read surface asks of the digest
20 * store, and the canonical SQL both halves share. Callers name a COLLECTION, a
21 * bucket RANGE and (optionally) FILTERS — never a table, a column or a JOIN.
22 *
23 * Three questions, one per REST consumer:
24 *
25 * - {@see bucket_aggregates} — stored-vs-current BIT_XOR per id-range bucket (the scan).
26 * - {@see bucket_drift} — the per-id mismatches inside ONE bucket (the drill-down).
27 * - {@see bucket_listing} — the authoritative live {id, digest, object_type} for one bucket.
28 *
29 * Plus the two scoping questions the same id-space owns:
30 * {@see published_product_ids} (the readable-catalog filter, one home for a rule
31 * that used to be re-spelled in every consumer) and {@see needs_product_rebuild}.
32 *
33 * Servable scoping — publish state and POS visibility — lives HERE rather than in
34 * the controllers, so "what the POS may see" is answered identically wherever the
35 * digest store is read. Visibility comes from {@see Pos_Visibility}, unchanged.
36 */
37 final class Digest_Index {
38
39 /**
40 * Legacy baseline of postmeta keys covered by the product/variation digest. Must include every key the
41 * sql-bypass fixture mutates (_price and _regular_price today — see
42 * class-fixtures-controller.php sql_bypass()) plus the keys a
43 * hook-bypassing import/inventory tool plausibly touches.
44 *
45 * Changing the MEMBERSHIP of this set moves two things, with two different
46 * levers (ADR 0036): the STORED digests rebuild automatically — this set
47 * feeds {@see digest_formula_fingerprint}, whose move schedules the guarded
48 * product-digest rebuild — and the digests CLIENTS hold go stale, which is
49 * what bumping Config_Fingerprint::PAYLOAD_CONTRACT_VERSION for BOTH owners
50 * (products AND variations — the set is shared) in the same commit repairs.
51 * Reordering without a membership change is a no-op everywhere (SQL sorts).
52 * The fingerprint tests pin the semantic set.
53 */
54 public const DIGESTED_META_KEYS = array( '_global_unique_id', '_price', '_regular_price', '_sale_price', '_sku', '_stock', '_stock_status' );
55
56 /**
57 * Option recording the key set the STORED digests were computed from.
58 * Never autoloaded — read only on the scan path. See {@see digest_formula_fingerprint}.
59 */
60 public const FORMULA_FP_OPTION = 'wcpos_integrity_digest_formula_fp';
61
62 /**
63 * Customer usermeta folded into the digest (ADR 0015, Leg-3 phase 7). Kept small + stable —
64 * identity/existence fields the POS keys on, NOT every usermeta row (a churny meta bloats the digest
65 * with irrelevant drift). The customer's core columns (email, display name, registered) come from
66 * wp_users directly; these four are the identifying usermeta. The site-prefixed `capabilities`
67 * meta joins them at runtime in {@see customer_digest_select_sql} (the prefix is per-site, so it
68 * cannot live in a const): roles are part of the served record under #1379, and a hookless
69 * capabilities write (direct update_user_meta/SQL/import) must drift the digest so the
70 * integrity scan can repair the stale role — no role/profile hook fires for those writes.
71 *
72 * Changing the MEMBERSHIP of this set (ADR 0036): bump
73 * Config_Fingerprint::PAYLOAD_CONTRACT_VERSION for `customers` in the same
74 * commit so clients re-pull — and know that the STORED customer digests have
75 * NO automatic rebuild trigger today: {@see digest_formula_fingerprint} folds
76 * the product keys only, so the scan reports store-wide false customer drift
77 * until a manual rebuild. Extending the rebuild trigger to this set is
78 * #1756 phase 2/3 work; until it lands, a change here also needs a
79 * deliberate rebuild plan. The fingerprint tests pin the semantic set.
80 */
81 public const CUSTOMER_DIGESTED_META_KEYS = array( 'first_name', 'last_name', 'billing_email', 'billing_phone' );
82
83 /**
84 * Order postmeta folded into the digest under the CPT (legacy) storage path (ADR 0015, Leg-3 phase 7).
85 * Under HPOS these live as wc_orders COLUMNS (total_amount, customer_id) so no meta join is needed;
86 * this allowlist only applies to the wp_posts fallback. Kept minimal — existence/identity signal.
87 *
88 * Changing the MEMBERSHIP of this set (ADR 0036): bump
89 * Config_Fingerprint::PAYLOAD_CONTRACT_VERSION for `orders` in the same
90 * commit. Like the customer set, it has NO automatic stored-digest rebuild
91 * trigger today ({@see digest_formula_fingerprint} folds product keys only) —
92 * #1756 phase 2/3 owns that. The fingerprint tests pin the semantic set.
93 */
94 public const ORDER_DIGESTED_META_KEYS = array( '_order_total', '_customer_user' );
95
96 /**
97 * The digest object_types sharing the wp_posts (product-space) id range.
98 */
99 public const OBJECT_TYPES_SQL = "('product','variation')";
100
101 private const PRODUCT_POST_TYPES_SQL = "('product','product_variation')";
102 private const EXCLUDED_POST_STATUSES_SQL = "('trash','auto-draft')";
103
104 /**
105 * Request-level memo for {@see digested_meta_keys}, KEYED BY BLOG ID. The hook write path
106 * recomputes the digest on every product save, and the barcode key costs an option read to
107 * resolve, so the set is resolved once per blog per request. Deliberately NOT invalidated
108 * when the setting changes mid-request: every digest written during one request must use
109 * ONE key set, or the stored side of a single request disagrees with itself.
110 *
111 * The blog-id key is load-bearing on multisite (the plugin network-activates). `barcode_field`
112 * is a per-site option and `Abstract_Section::read()` re-reads it uncached, so the SETTING
113 * follows switch_to_blog() correctly — a process-wide memo would not, and a batch that walks
114 * sites would then digest site B's products under site A's barcode key: the stored side would
115 * silently cover the wrong meta key, which is the exact staleness this class exists to catch.
116 *
117 * @var array<int, string[]>
118 */
119 private static array $memoized_digested_meta_keys = array();
120
121 /**
122 * The postmeta keys the product/variation digest ACTUALLY covers: the legacy baseline plus
123 * the site's configured WCPOS barcode key (mono#1234).
124 *
125 * The barcode field is merchant-configurable to any meta key, and only `_sku` /
126 * `_global_unique_id` are baseline keys. A custom carrier key was therefore undigested:
127 * a hookless write to it (importer, direct SQL, inventory tool) produced no journal row
128 * and no digest mismatch, so a till's barcodes went stale INDEFINITELY — the one field
129 * cashiers key on. Folding the configured key in closes that at tier-2 latency.
130 *
131 * Sorted so the set has ONE spelling, which is what makes
132 * {@see digest_formula_fingerprint} stable across requests.
133 *
134 * @return string[]
135 */
136 public static function digested_meta_keys(): array {
137 $blog_id = get_current_blog_id();
138 if ( ! isset( self::$memoized_digested_meta_keys[ $blog_id ] ) ) {
139 $keys = array_values( array_unique( array_merge( self::DIGESTED_META_KEYS, array( Barcode_Field::meta_key() ) ) ) );
140 sort( $keys );
141 self::$memoized_digested_meta_keys[ $blog_id ] = $keys;
142 }
143
144 return self::$memoized_digested_meta_keys[ $blog_id ];
145 }
146
147 /**
148 * Fingerprint of the key set the digest formula currently uses.
149 *
150 * Stored and live digests are only comparable when BOTH were computed from the same key
151 * set. Change the set without rebuilding and every stored digest is an old-formula value:
152 * every bucket mismatches at once and the merchant sees a store-wide false "N records need
153 * attention" for data that is already correct. So the set that produced the stored digests
154 * is recorded (by {@see \WCPOS\WooCommercePOS\Sync\Integrity_Digest::rebuild}) and
155 * compared on scan.
156 */
157 public static function digest_formula_fingerprint(): string {
158 return md5( implode( ',', self::digested_meta_keys() ) );
159 }
160
161 /**
162 * Fingerprint of the BARE baseline — the formula every install used before mono#1234.
163 *
164 * The upgrade path must not flood. Installs upgrading into this code have no recorded
165 * fingerprint, and assuming the worst would schedule a rebuild on every store. A default
166 * store (barcode = `_sku` or `_global_unique_id`, both already baseline keys) has an
167 * unchanged set, so seeding the missing option with THIS value rebuilds only the stores
168 * whose formula genuinely moved: the custom-carrier ones.
169 */
170 public static function legacy_formula_fingerprint(): string {
171 $keys = self::DIGESTED_META_KEYS;
172 sort( $keys );
173
174 return md5( implode( ',', $keys ) );
175 }
176
177 /**
178 * The POS servable-set contract (ADR 0014 WP-M5). Injectable for tests; the
179 * default instance reads the live visibility option.
180 */
181 private Pos_Visibility $visibility;
182
183 public function __construct( ?Pos_Visibility $visibility = null ) {
184 $this->visibility = $visibility ?? new Pos_Visibility();
185 }
186
187 public function table_name(): string {
188 global $wpdb;
189
190 return $wpdb->prefix . Health::STORED_DIGEST_TABLE;
191 }
192
193 /**
194 * Raise the session `group_concat_max_len` before ANY query built on {@see row_digest_select_sql}.
195 * That expression GROUP_CONCATs the digested meta; MySQL's default (1024 bytes) SILENTLY TRUNCATES
196 * for a row with many/large meta. And because the consumers (hook upsert, rebuild, scan,
197 * drill-down, bucket listing) MUST produce byte-identical digests, a truncation on one path but not
198 * another is a PERMANENT false-drift bug — the bucket never converges. So raise it identically
199 * everywhere the expression runs.
200 */
201 public function raise_group_concat_max_len(): void {
202 global $wpdb;
203 $wpdb->query( 'SET SESSION group_concat_max_len = 1048576' );
204 }
205
206 /**
207 * Canonical per-row digest SELECT, shared verbatim by the hook upsert,
208 * the scan's current-side aggregate, the drill-down and the rebuild —
209 * the stored-vs-current comparison is only sound when every consumer
210 * computes the digest with the byte-identical expression.
211 *
212 * 64-bit digest — CAST(CONV(SUBSTRING(MD5(CONCAT_WS('|', ...)),1,16),16,10) AS UNSIGNED) — over the wp_posts content columns plus
213 * the {@see digested_meta_keys} rows (key=value pairs ordered by meta_key then
214 * meta_id, so duplicate meta rows digest deterministically). Every
215 * nullable operand is COALESCE'd because CONCAT_WS silently SKIPS NULL
216 * arguments — ('a', NULL, 'b') would collide with ('a', 'b', '') —
217 * while COALESCE keeps every position present and deterministic.
218 *
219 * $where_sql may reference alias p and contain placeholders; callers
220 * run the final statement through $wpdb->prepare.
221 *
222 * @internal Engine-internal: the digest expression, not a read-surface contract.
223 */
224 public function row_digest_select_sql( string $where_sql = '' ): string {
225 global $wpdb;
226 // esc_sql because one of these keys is merchant-settable free text: the
227 // `barcode_field` setting's REST validator accepts any string, so an
228 // apostrophe would otherwise terminate the IN list — breaking EVERY digest
229 // query (hook upsert and scan alike) and taking the integrity backstop down
230 // with it. The baseline keys are literals; the barcode key is not.
231 $meta_keys_sql = "('" . implode( "','", array_map( 'esc_sql', self::digested_meta_keys() ) ) . "')";
232
233 return 'SELECT p.ID AS id,'
234 . " CASE WHEN p.post_type = 'product_variation' THEN 'variation' ELSE 'product' END AS object_type,"
235 . " CAST(CONV(SUBSTRING(MD5(CONCAT_WS('|',"
236 . ' p.ID,'
237 . " COALESCE(p.post_title,''),"
238 . " COALESCE(p.post_excerpt,''),"
239 . " COALESCE(p.post_content,''),"
240 . " COALESCE(p.post_status,''),"
241 . ' COALESCE(p.post_parent,0),'
242 . ' COALESCE(p.menu_order,0),'
243 . " COALESCE(p.post_modified_gmt,''),"
244 . " COALESCE(GROUP_CONCAT(CONCAT(pm.meta_key,'=',COALESCE(pm.meta_value,'')) ORDER BY pm.meta_key ASC, pm.meta_id ASC SEPARATOR '|'),'')"
245 // 64-bit digest (ADR 0014 M1): the top 16 hex of MD5 → an integer that still folds under
246 // BIT_XOR and fits the BIGINT UNSIGNED column, dropping the CRC32 collision floor from
247 // 2^-32 to 2^-64 (a stable per-bucket false "in sync" is unacceptable in a convergence
248 // backstop, and CRC32 is linear so structured bulk edits correlate collisions).
249 . ')),1,16),16,10) AS UNSIGNED) AS crc'
250 . " FROM {$wpdb->posts} p"
251 . " LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key IN {$meta_keys_sql}"
252 . ' WHERE ' . $this->live_product_predicate_sql( 'p' )
253 . ( '' === $where_sql ? '' : ' AND ' . $where_sql )
254 . ' GROUP BY p.ID';
255 }
256
257 /**
258 * Canonical per-CUSTOMER digest SELECT (ADR 0015, Leg-3 phase 7) — the wp_users analogue of
259 * {@see row_digest_select_sql}. Same 64-bit MD5-derived formula (BIT_XOR-foldable), over a customer's
260 * identity columns + the allowlisted usermeta. ALL wp_users rows are POS customers under #1379
261 * (1.9 parity). `$where_sql` narrows to a single user for the hook upsert (`u.ID = %d`);
262 * empty selects every user.
263 *
264 * @internal Engine-internal: the digest expression, not a read-surface contract.
265 */
266 public function customer_digest_select_sql( string $where_sql = '' ): string {
267 global $wpdb;
268 $meta_keys_sql = "('" . implode( "','", array_merge( self::CUSTOMER_DIGESTED_META_KEYS, array( $wpdb->prefix . 'capabilities' ) ) ) . "')";
269
270 return 'SELECT u.ID AS id,'
271 . " 'customer' AS object_type,"
272 . " CAST(CONV(SUBSTRING(MD5(CONCAT_WS('|',"
273 . ' u.ID,'
274 . " COALESCE(u.user_email,''),"
275 . " COALESCE(u.display_name,''),"
276 . " COALESCE(u.user_registered,''),"
277 . " COALESCE(GROUP_CONCAT(CONCAT(um.meta_key,'=',COALESCE(um.meta_value,'')) ORDER BY um.meta_key ASC, um.umeta_id ASC SEPARATOR '|'),'')"
278 // 64-bit digest (ADR 0014 M1): top 16 hex of MD5 → BIGINT UNSIGNED, folds under BIT_XOR.
279 . ')),1,16),16,10) AS UNSIGNED) AS crc'
280 . " FROM {$wpdb->users} u"
281 . " LEFT JOIN {$wpdb->usermeta} um ON um.user_id = u.ID AND um.meta_key IN {$meta_keys_sql}"
282 . ( '' === $where_sql ? '' : ' WHERE ' . $where_sql )
283 . ' GROUP BY u.ID';
284 }
285
286 /**
287 * Canonical per-ORDER digest SELECT (ADR 0015, Leg-3 phase 7) — HPOS/CPT-aware. Under HPOS orders live
288 * in WooCommerce's own {prefix}wc_orders table (status/total/customer are COLUMNS); under legacy CPT
289 * they're wp_posts + postmeta. Per install exactly ONE path runs (an install is HPOS or CPT, never both),
290 * so stored-vs-current always uses the same path — self-consistent. Same 64-bit formula.
291 *
292 * `$id_condition` narrows to a single order / a bucket range using the neutral `{id}` placeholder,
293 * substituted with the path's real id column (o.id HPOS, p.ID CPT) so callers stay path-agnostic.
294 *
295 * LIVE-VERIFY: the HPOS column set is shape-asserted here (fake wpdb); verify against a real HPOS store.
296 *
297 * @internal Engine-internal: the digest expression, not a read-surface contract.
298 */
299 public function order_digest_select_sql( string $id_condition = '' ): string {
300 global $wpdb;
301 $hpos = $this->orders_are_hpos();
302 $id_col = $hpos ? 'o.id' : 'p.ID';
303 $condition = '' === $id_condition ? '' : ' AND ' . str_replace( '{id}', $id_col, $id_condition );
304
305 if ( $hpos ) {
306 $orders_table = $wpdb->prefix . 'wc_orders';
307 return 'SELECT o.id AS id,'
308 . " 'order' AS object_type,"
309 . " CAST(CONV(SUBSTRING(MD5(CONCAT_WS('|',"
310 . ' o.id,'
311 . " COALESCE(o.status,''),"
312 . " COALESCE(o.type,''),"
313 . " COALESCE(o.total_amount,''),"
314 . ' COALESCE(o.customer_id,0),'
315 . " COALESCE(o.date_updated_gmt,''))),1,16),16,10) AS UNSIGNED) AS crc"
316 . " FROM {$orders_table} o"
317 . ' WHERE ' . $this->live_order_predicate_sql( 'o' )
318 . $condition;
319 }
320
321 $meta_keys_sql = "('" . implode( "','", self::ORDER_DIGESTED_META_KEYS ) . "')";
322 return 'SELECT p.ID AS id,'
323 . " 'order' AS object_type,"
324 . " CAST(CONV(SUBSTRING(MD5(CONCAT_WS('|',"
325 . ' p.ID,'
326 . " COALESCE(p.post_status,''),"
327 . " COALESCE(p.post_modified_gmt,''),"
328 . " COALESCE(GROUP_CONCAT(CONCAT(pm.meta_key,'=',COALESCE(pm.meta_value,'')) ORDER BY pm.meta_key ASC, pm.meta_id ASC SEPARATOR '|'),''))),1,16),16,10) AS UNSIGNED) AS crc"
329 . " FROM {$wpdb->posts} p"
330 . " LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key IN {$meta_keys_sql}"
331 . ' WHERE ' . $this->live_order_predicate_sql( 'p' )
332 . $condition
333 . ' GROUP BY p.ID';
334 }
335
336 /**
337 * Bulk-read the STORED 64-bit digests for ONE collection's id-space -> `[id => digest string]`.
338 *
339 * ONE reader for every id-space. The registry row names the object types the
340 * collection stores (products carries product+variation; customers and orders
341 * carry their own), so no id-space can bleed into another's reconcile and a
342 * fourth id-space needs no fourth body. A collection with no digest group reads
343 * nothing rather than falling through to the products digests.
344 *
345 * The digest is BIGINT UNSIGNED (above PHP_INT_MAX), so it is returned as a
346 * STRING (ADR 0014 M1). Ids with no stored digest yet (never hooked/rebuilt) are
347 * simply absent from the result.
348 *
349 * @param string $collection Canonical plural collection name.
350 * @param int[] $ids Requested ids in the collection's own id-space.
351 *
352 * @return array<int, string>
353 */
354 public function read_digests( string $collection, array $ids ): array {
355 global $wpdb;
356 // Order/customer upserts are coalesced per request and land at shutdown
357 // (see Integrity_Digest::$pending_digests). A read inside the same
358 // request — the pull lane stamping `_rxdb_digest` after a write — must
359 // see the settled digest, so land what is owed before reading.
360 Integrity_Digest::flush_pending_digests();
361 $object_types = self::digest_object_types( $collection );
362 if ( array() === $object_types ) {
363 return array();
364 }
365 $ids = array_values(
366 array_unique(
367 array_filter(
368 array_map( 'intval', $ids ),
369 static function ( $id ) {
370 return $id > 0;
371 }
372 )
373 )
374 );
375 if ( array() === $ids ) {
376 return array();
377 }
378 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
379 $rows = $wpdb->get_results(
380 $wpdb->prepare(
381 'SELECT object_id, digest FROM ' . $this->table_name()
382 . ' WHERE ' . self::object_type_predicate_sql( $object_types )
383 . ' AND object_id IN (' . $placeholders . ')',
384 ...$ids
385 ),
386 ARRAY_A
387 );
388 $out = array();
389 foreach ( (array) $rows as $row ) {
390 $out[ (int) $row['object_id'] ] = (string) $row['digest'];
391 }
392
393 return $out;
394 }
395
396 /**
397 * Narrow a collection's ids to what this store will still serve — the digest
398 * endpoint's authoritative-absence answer.
399 *
400 * FAIL-OPEN, deliberately: on ANY SQL error every requested id comes back
401 * servable. The caller turns an UNservable id into `deleted: true` and the till
402 * acts on that by dropping its local record — for orders, order data — so a
403 * database hiccup must never be able to manufacture a deletion. The same rule
404 * covers a collection this store cannot answer for at all.
405 *
406 * @param string $collection Canonical plural collection name.
407 * @param int[] $ids Requested ids in request order.
408 *
409 * @return int[] Servable ids in request order.
410 */
411 public function servable( string $collection, array $ids ): array {
412 global $wpdb;
413 $ids = array_values( array_map( 'intval', $ids ) );
414 if ( array() === $ids ) {
415 return array();
416 }
417 $row = Collections::row( $collection );
418 $live_rows = isset( $row['digest']['live_rows'] ) ? (string) $row['digest']['live_rows'] : '';
419 // Products are the one collection whose servability is NARROWER than a live
420 // row: POS visibility and the readable-catalog scope both apply, so the
421 // richer reader owns them. Every other id-space is exactly its live row.
422 $products = 'products' === $collection;
423 if ( ! $products && ( '' === $live_rows || ! method_exists( $this, $live_rows ) ) ) {
424 return $ids;
425 }
426 $wpdb->last_error = '';
427 if ( $products ) {
428 $servable_ids = $this->servable_product_ids( $ids, true );
429 } else {
430 $predicate = (string) \call_user_func( array( $this, $live_rows ), 'requested.id' );
431 $requested = implode( ' UNION ALL ', array_fill( 0, \count( $ids ), 'SELECT %d AS id' ) );
432 $servable_ids = $wpdb->get_col(
433 $wpdb->prepare(
434 'SELECT requested.id FROM (' . $requested . ') requested WHERE ' . $predicate,
435 ...$ids
436 )
437 );
438 }
439 /** @var string $last_error */
440 $last_error = $wpdb->last_error;
441
442 return '' !== $last_error
443 ? $ids
444 : array_values( array_intersect( $ids, array_map( 'intval', (array) $servable_ids ) ) );
445 }
446
447 /**
448 * The registry's digest object types for a collection; empty when the collection
449 * owns no digest id-space (fail closed — never a fall-through to products).
450 *
451 * @return string[]
452 */
453 private static function digest_object_types( string $collection ): array {
454 $row = Collections::row( $collection );
455
456 return isset( $row['digest']['object_types'] ) ? (array) $row['digest']['object_types'] : array();
457 }
458
459 /**
460 * `object_type = 'x'` for a single-type id-space, `object_type IN (...)` for a
461 * shared one. Values are registry constants, never request data.
462 *
463 * @param string[] $object_types Stored object types.
464 */
465 private static function object_type_predicate_sql( array $object_types ): string {
466 $quoted = array();
467 foreach ( $object_types as $object_type ) {
468 $quoted[] = "'" . $object_type . "'";
469 }
470
471 return 1 === \count( $quoted )
472 ? 'object_type = ' . $quoted[0]
473 : 'object_type IN (' . implode( ',', $quoted ) . ')';
474 }
475
476 /**
477 * Live-row predicate reused by the drill-down's deleted branch and the rebuild's orphan prune.
478 *
479 * @internal Engine-internal SQL fragment.
480 */
481 public function live_row_exists_sql( string $id_expr ): string {
482 global $wpdb;
483
484 return "EXISTS (SELECT 1 FROM {$wpdb->posts} lp WHERE lp.ID = {$id_expr}"
485 . ' AND ' . $this->live_product_predicate_sql( 'lp' ) . ')';
486 }
487
488 /**
489 * Customer analogue of {@see live_row_exists_sql}: the id still names any WordPress user (ADR 0015).
490 *
491 * @internal Engine-internal SQL fragment.
492 */
493 public function customer_live_row_exists_sql( string $id_expr ): string {
494 global $wpdb;
495
496 return "EXISTS (SELECT 1 FROM {$wpdb->users} lu WHERE lu.ID = {$id_expr})";
497 }
498
499 /**
500 * Order analogue of {@see live_row_exists_sql} — HPOS/CPT-aware (ADR 0015, Leg-3 phase 7).
501 *
502 * @internal Engine-internal SQL fragment.
503 */
504 public function order_live_row_exists_sql( string $id_expr ): string {
505 global $wpdb;
506 if ( $this->orders_are_hpos() ) {
507 $orders_table = $wpdb->prefix . 'wc_orders';
508 return "EXISTS (SELECT 1 FROM {$orders_table} lo WHERE lo.id = {$id_expr}"
509 . ' AND ' . $this->live_order_predicate_sql( 'lo' ) . ')';
510 }
511 return "EXISTS (SELECT 1 FROM {$wpdb->posts} lp WHERE lp.ID = {$id_expr}"
512 . ' AND ' . $this->live_order_predicate_sql( 'lp' ) . ')';
513 }
514
515 /**
516 * Stored-vs-current bucket aggregate over one id window (the integrity scan).
517 *
518 * Bucket aggregate: BIT_XOR over per-row 64-bit digests, deliberately instead of
519 * MD5(GROUP_CONCAT(... ORDER BY id)). XOR is commutative and associative, so the
520 * aggregate needs no ORDER BY and carries no group_concat_max_len truncation
521 * hazard; its state is a constant-size integer regardless of bucket population.
522 * Record counts travel alongside so add/delete imbalances that could cancel in
523 * XOR still flag.
524 *
525 * Digests are unsigned 64-bit (ADR 0014 M1) — above PHP_INT_MAX, so an (int) cast
526 * would SATURATE two distinct high-bit values to the same number and report a
527 * drifted bucket as `match`, hiding drift. They stay strings end to end.
528 *
529 * `max_id` is the larger of both sides' max id, so orphaned stored digests past
530 * the last live post still get scanned before the walk is called complete.
531 *
532 * @param array $range { Bucket window. @type int $bucket_size, @type int $start, @type int $end }
533 * @return array{buckets: array<int, array<string, mixed>>, max_id: int}
534 */
535 public function bucket_aggregates( array $range, string $collection = 'products', array $filters = array() ): array {
536 global $wpdb;
537 $bucket_size = max( 1, (int) ( $range['bucket_size'] ?? 1 ) );
538 $window_start = max( 0, (int) ( $range['start'] ?? 0 ) );
539 $window_end = max( 0, (int) ( $range['end'] ?? 0 ) );
540 $publish = 'products' === $collection && 'publish' === ( $filters['status'] ?? '' );
541 $object_types = self::OBJECT_TYPES_SQL;
542 $current_sql = $this->row_digest_select_sql( 'p.ID >= %d AND p.ID < %d' );
543 if ( 'customers' === $collection ) {
544 $object_types = "('customer')";
545 $current_sql = $this->customer_digest_select_sql( 'u.ID >= %d AND u.ID < %d' );
546 } elseif ( 'orders' === $collection ) {
547 $object_types = "('order')";
548 $current_sql = $this->order_digest_select_sql( '{id} >= %d AND {id} < %d' );
549 }
550 $current_scope = $publish ? $this->product_servable_predicate_sql( 't.id', true ) : array(
551 'sql' => '',
552 'args' => array(),
553 );
554 $stored_scope = $publish ? $this->product_servable_predicate_sql( 'd.object_id', true ) : array(
555 'sql' => '',
556 'args' => array(),
557 );
558 $current_join = $publish ? $this->servable_product_join_sql( 't.id' ) : '';
559 $stored_join = $publish ? $this->servable_product_join_sql( 'd.object_id' ) : '';
560
561 // Current side: one SQL pass — per-row canonical digests aggregated
562 // per bucket inside the DB engine. Raw rows are digested for
563 // DETECTION only; hydration goes through filtered REST (ADR 0003).
564 // Raise group_concat_max_len so the current-side digest matches the
565 // stored-side (written by the hook) byte-for-byte (ADR 0014 / no truncation drift).
566 $this->raise_group_concat_max_len();
567 $current_rows = $wpdb->get_results(
568 $wpdb->prepare(
569 'SELECT FLOOR(t.id / %d) AS bucket, COUNT(*) AS record_count, BIT_XOR(t.crc) AS digest'
570 . ' FROM (' . $current_sql . ') t' . $current_join . ( '' === $current_scope['sql'] ? '' : ' WHERE ' . $current_scope['sql'] )
571 . ' GROUP BY bucket ORDER BY bucket',
572 $bucket_size,
573 $window_start,
574 $window_end,
575 ...$current_scope['args']
576 ),
577 ARRAY_A
578 );
579
580 // Stored side: one SQL pass over the hook-maintained digest table.
581 $stored_rows = $wpdb->get_results(
582 $wpdb->prepare(
583 'SELECT FLOOR(d.object_id / %d) AS bucket, COUNT(*) AS record_count, BIT_XOR(d.digest) AS digest'
584 . ' FROM ' . $this->table_name() . ' d' . $stored_join
585 . ' WHERE d.object_type IN ' . $object_types
586 . ' AND d.object_id >= %d AND d.object_id < %d' . ( '' === $stored_scope['sql'] ? '' : ' AND ' . $stored_scope['sql'] )
587 . ' GROUP BY bucket ORDER BY bucket',
588 $bucket_size,
589 $window_start,
590 $window_end,
591 ...$stored_scope['args']
592 ),
593 ARRAY_A
594 );
595
596 $sides = array();
597 foreach ( \is_array( $stored_rows ) ? $stored_rows : array() as $row ) {
598 $sides[ (int) $row['bucket'] ]['stored'] = $row;
599 }
600 foreach ( \is_array( $current_rows ) ? $current_rows : array() as $row ) {
601 $sides[ (int) $row['bucket'] ]['current'] = $row;
602 }
603 ksort( $sides );
604
605 $buckets = array();
606 foreach ( $sides as $bucket => $side ) {
607 $stored_count = isset( $side['stored'] ) ? (int) $side['stored']['record_count'] : 0;
608 $current_count = isset( $side['current'] ) ? (int) $side['current']['record_count'] : 0;
609 $stored_digest = isset( $side['stored'] ) ? (string) $side['stored']['digest'] : '';
610 $current_digest = isset( $side['current'] ) ? (string) $side['current']['digest'] : '';
611 $buckets[] = array(
612 'bucket' => $bucket,
613 'range' => array(
614 'start' => $bucket * $bucket_size,
615 'end' => ( $bucket + 1 ) * $bucket_size,
616 ),
617 'stored_count' => $stored_count,
618 'current_count' => $current_count,
619 'stored_digest' => $stored_digest,
620 'current_digest' => $current_digest,
621 'match' => $stored_count === $current_count && $stored_digest === $current_digest,
622 );
623 }
624
625 // Completion id: the larger of the last LIVE id under the collection's own
626 // servable predicate and the last STORED id. The live side is MAX(id)
627 // straight off the base table — wrapping the un-windowed per-row digest
628 // SELECT as a derived table just to take its max digested the whole
629 // collection on every scan page (0.4–3 s on real stores; #1805, ADR 0038).
630 $live_max = $this->live_max_id_sql( $collection, $publish );
631 $max_query = 'SELECT GREATEST(COALESCE((' . $live_max['sql'] . '), 0),'
632 . ' COALESCE((SELECT MAX(d.object_id) FROM ' . $this->table_name() . ' d'
633 . ' WHERE d.object_type IN ' . $object_types . '), 0))';
634 $max_id = (int) $wpdb->get_var( empty( $live_max['args'] ) ? $max_query : $wpdb->prepare( $max_query, ...$live_max['args'] ) );
635
636 return array(
637 'buckets' => $buckets,
638 'max_id' => $max_id,
639 );
640 }
641
642 /**
643 * Per-id stored-vs-current comparison inside ONE bucket (the scan drill-down).
644 *
645 * Three mismatch shapes: changed (both sides present, digests differ),
646 * missing_stored (live row never digested — created without hooks or
647 * pre-backfill), deleted (stored digest whose row is gone — hook-bypassing
648 * delete). Digests stay strings (ADR 0014 M1) and are null where the side is
649 * absent.
650 *
651 * @param array $range { Bucket window. @type int $start, @type int $end }
652 *
653 * @return array<int, array<string, mixed>>
654 */
655 public function bucket_drift( array $range ): array {
656 global $wpdb;
657 $range_start = max( 0, (int) ( $range['start'] ?? 0 ) );
658 $range_end = max( 0, (int) ( $range['end'] ?? 0 ) );
659 $table = $this->table_name();
660
661 // Same-formula invariant: the current side must digest identically to the stored side.
662 $this->raise_group_concat_max_len();
663 $rows = $wpdb->get_results(
664 $wpdb->prepare(
665 'SELECT cur.id AS id,'
666 . " CASE WHEN d.digest IS NULL THEN 'missing_stored' ELSE 'changed' END AS status,"
667 . ' d.digest AS stored_digest, cur.crc AS current_digest, cur.object_type AS object_type'
668 . ' FROM (' . $this->row_digest_select_sql( 'p.ID >= %d AND p.ID < %d' ) . ') cur'
669 . " LEFT JOIN {$table} d ON d.object_id = cur.id AND d.object_type = cur.object_type"
670 . ' WHERE d.digest IS NULL OR d.digest <> cur.crc'
671 . ' UNION ALL'
672 . " SELECT d.object_id AS id, 'deleted' AS status, d.digest AS stored_digest, NULL AS current_digest, d.object_type AS object_type"
673 . " FROM {$table} d"
674 . ' WHERE d.object_type IN ' . self::OBJECT_TYPES_SQL
675 . ' AND d.object_id >= %d AND d.object_id < %d'
676 . ' AND NOT ' . $this->live_row_exists_sql( 'd.object_id' )
677 . ' ORDER BY id ASC',
678 $range_start,
679 $range_end,
680 $range_start,
681 $range_end
682 ),
683 ARRAY_A
684 );
685
686 return array_map(
687 static function ( array $row ): array {
688 return array(
689 'id' => (int) $row['id'],
690 'status' => (string) $row['status'],
691 'object_type' => (string) ( $row['object_type'] ?? '' ),
692 // Unsigned 64-bit (ADR 0014 M1): keep as strings — a (int) cast (and JS Number) can't
693 // hold values above PHP_INT_MAX / 2^53 without precision loss.
694 'stored_digest' => null === $row['stored_digest'] ? null : (string) $row['stored_digest'],
695 'current_digest' => null === $row['current_digest'] ? null : (string) $row['current_digest'],
696 );
697 },
698 \is_array( $rows ) ? $rows : array()
699 );
700 }
701
702 /**
703 * The authoritative current {id, digest, object_type} for every live SERVABLE record whose id falls
704 * in the given range of the collection's own id-space (products/variations over wp_posts, customers
705 * over wp_users, orders over HPOS or CPT). Digests come from the SAME 64-bit formula the client's
706 * manifest stores, so the two compare apples-to-apples.
707 *
708 * Products carry the servable scoping the pull filter applies, so the reconcile prunes anything the
709 * POS may no longer see: the optional `status => publish` readable-catalog filter, and ALWAYS the
710 * POS-hidden (`online_only`) ids. READ-SIDE ONLY — a visibility toggle changes no product row (no
711 * hook fires), so stored per-record digests are never touched; omitting the ids from this read is
712 * enough because the client folds THIS list. Products and variations share the wp_posts id-space, so
713 * their two hidden lists union safely on cur.id.
714 *
715 * @param string $collection Digest id-space owner: products | customers | orders.
716 * @param array $range { @type int $start, @type int $end }
717 * @param array $filters { @type string $status 'publish' scopes products to the readable catalog. }
718 *
719 * @return array<int, array{id: int, digest: string, object_type: string}>
720 */
721 public function bucket_listing( string $collection, array $range, array $filters = array() ): array {
722 global $wpdb;
723 $range_start = max( 0, (int) ( $range['start'] ?? 0 ) );
724 $range_end = max( 0, (int) ( $range['end'] ?? 0 ) );
725
726 $servable_join = '';
727 $servable_filter = '';
728 $servable_args = array();
729 if ( 'customers' === $collection ) {
730 $inner_sql = $this->customer_digest_select_sql( 'u.ID >= %d AND u.ID < %d' );
731 } elseif ( 'orders' === $collection ) {
732 // Orders bucket over their own id-space (HPOS o.id / CPT p.ID) via the {id} placeholder.
733 $inner_sql = $this->order_digest_select_sql( '{id} >= %d AND {id} < %d' );
734 } else {
735 $inner_sql = $this->row_digest_select_sql( 'p.ID >= %d AND p.ID < %d' );
736 $servable_join = " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = cur.id";
737 if ( 'publish' === ( $filters['status'] ?? '' ) ) {
738 $servable_join .= " LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent"
739 . " AND catalog_post.post_type = 'product_variation'";
740 }
741 $servable = $this->product_servable_predicate_sql( 'cur.id', 'publish' === ( $filters['status'] ?? '' ) );
742 $servable_filter = '' === $servable['sql'] ? '' : ' WHERE ' . $servable['sql'];
743 $servable_args = $servable['args'];
744 }
745
746 // Same-formula invariant + GROUP_CONCAT stability, exactly as the scan's current side.
747 $this->raise_group_concat_max_len();
748 $rows = $wpdb->get_results(
749 $wpdb->prepare(
750 'SELECT cur.id AS id, cur.crc AS digest, cur.object_type AS object_type FROM ('
751 . $inner_sql
752 . ') cur' . $servable_join . $servable_filter . ' ORDER BY cur.id ASC',
753 $range_start,
754 $range_end,
755 ...$servable_args
756 ),
757 ARRAY_A
758 );
759
760 return array_map(
761 static function ( array $row ): array {
762 return array(
763 'id' => (int) $row['id'],
764 // Unsigned 64-bit (ADR 0014 M1): keep as a string — (int)/JS Number lose precision above 2^53.
765 'digest' => (string) $row['digest'],
766 'object_type' => (string) $row['object_type'],
767 );
768 },
769 \is_array( $rows ) ? $rows : array()
770 );
771 }
772
773 /**
774 * Narrow product-space ids to the readable catalog: published products, and variations whose parent
775 * product is published. The SAME rule {@see bucket_listing} applies with `status => publish`, so the
776 * prime-pass digest read and the reconcile listing can never disagree about what "publish" means.
777 *
778 * @param int[] $ids Requested product-space ids.
779 *
780 * @return int[] The subset that is readable, in the caller's order.
781 */
782 public function published_product_ids( array $ids ): array {
783 global $wpdb;
784 $ids = array_values( array_map( 'intval', $ids ) );
785 if ( array() === $ids ) {
786 return array();
787 }
788 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
789 $published_ids = $wpdb->get_col(
790 $wpdb->prepare(
791 "SELECT p.ID FROM {$wpdb->posts} p"
792 . " LEFT JOIN {$wpdb->posts} parent ON parent.ID = p.post_parent AND p.post_type = 'product_variation'"
793 . ' WHERE p.ID IN (' . $placeholders . ')'
794 . ' AND ' . $this->published_product_predicate_sql( 'p', 'parent' ),
795 ...$ids
796 )
797 );
798
799 return array_values( array_intersect( $ids, array_map( 'intval', (array) $published_ids ) ) );
800 }
801
802 /** Narrow product-space ids to the integrity scan's canonical servable membership. */
803 public function servable_product_ids( array $ids, bool $publish = false ): array {
804 global $wpdb;
805 $ids = array_values( array_map( 'intval', $ids ) );
806 if ( array() === $ids ) {
807 return array();
808 }
809 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
810 $servable = $this->product_servable_predicate_sql( 'catalog_post.ID', $publish );
811 $query_args = array_merge( $ids, $servable['args'] );
812 $servable_ids = $wpdb->get_col(
813 $wpdb->prepare(
814 "SELECT catalog_post.ID FROM {$wpdb->posts} catalog_post"
815 . ( $publish ? " LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent AND catalog_post.post_type = 'product_variation'" : '' )
816 . ' WHERE catalog_post.ID IN (' . $placeholders . ') AND ' . $servable['sql'],
817 ...$query_args
818 )
819 );
820
821 return array_values( array_intersect( $ids, array_map( 'intval', (array) $servable_ids ) ) );
822 }
823
824 /**
825 * True when the product space holds live rows but carries NO stored digests at all — the
826 * "stored side was never backfilled (or was wiped)" signal the scan answers with a guarded rebuild
827 * instead of reporting the whole catalog as drift.
828 */
829 public function needs_product_rebuild(): bool {
830 global $wpdb;
831
832 return (bool) $wpdb->get_var(
833 'SELECT EXISTS (SELECT 1 FROM ' . $wpdb->posts . ' p'
834 . ' WHERE ' . $this->live_product_predicate_sql( 'p' ) . ' LIMIT 1)'
835 . ' AND NOT EXISTS (SELECT 1 FROM ' . $this->table_name()
836 . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL . ' LIMIT 1)'
837 );
838 }
839
840 /**
841 * The readable-catalog predicate over a post alias and its parent alias — a published product,
842 * or a variation whose parent product is published. One home for the rule.
843 */
844 private function published_product_predicate_sql( string $post_alias, string $parent_alias ): string {
845 return "(({$post_alias}.post_type = 'product' AND {$post_alias}.post_status = 'publish')"
846 . " OR ({$post_alias}.post_type = 'product_variation' AND {$parent_alias}.post_type = 'product'"
847 . " AND {$parent_alias}.post_status = 'publish'))";
848 }
849
850 /**
851 * A LIVE product-space row over a `wp_posts` alias: a product or variation that
852 * is not trashed/auto-draft. ONE spelling for the digest SELECT, the live-row
853 * probes, the rebuild guard and the completion id — the scan is only sound
854 * while every side agrees on what "live" means.
855 */
856 private function live_product_predicate_sql( string $alias ): string {
857 return "{$alias}.post_type IN " . self::PRODUCT_POST_TYPES_SQL
858 . " AND {$alias}.post_status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL;
859 }
860
861 /**
862 * A LIVE order over the active order store's alias — `wc_orders` under HPOS,
863 * `wp_posts` under legacy CPT — with the same one-spelling rule as
864 * {@see live_product_predicate_sql}.
865 */
866 private function live_order_predicate_sql( string $alias ): string {
867 if ( $this->orders_are_hpos() ) {
868 return "{$alias}.type = 'shop_order' AND {$alias}.status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL;
869 }
870
871 return "{$alias}.post_type = 'shop_order' AND {$alias}.post_status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL;
872 }
873
874 /**
875 * The joins {@see product_servable_predicate_sql} reads through: the post row
876 * behind `$id_expr` as `catalog_post`, and its parent as `parent_product` when
877 * it is a variation. One spelling for every side of the scan.
878 */
879 private function servable_product_join_sql( string $id_expr ): string {
880 global $wpdb;
881
882 return " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = {$id_expr}" . $this->parent_product_join_sql();
883 }
884
885 /**
886 * `catalog_post`'s parent as `parent_product` when it is a variation (NULL otherwise).
887 */
888 private function parent_product_join_sql(): string {
889 global $wpdb;
890
891 return " LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent AND catalog_post.post_type = 'product_variation'";
892 }
893
894 /**
895 * `MAX(id)` of a collection's LIVE rows under the same predicate its digest
896 * SELECT uses — off the base table, never through a digested row (#1805).
897 *
898 * Customers are every `wp_users` row (#1379), so this is the primary key's end.
899 * Orders and products carry a type/status predicate, so this is one index pass
900 * over the live rows of that type — the honest floor, since no index ends on
901 * the id under a status filter. Under the published product scope the servable
902 * predicate (published, or a variation of a published parent, and not POS-hidden)
903 * applies to the post row itself, exactly as the windowed sides apply it.
904 *
905 * @return array{sql: string, args: array<int, int>}
906 */
907 private function live_max_id_sql( string $collection, bool $publish ): array {
908 global $wpdb;
909 if ( 'customers' === $collection ) {
910 return array(
911 'sql' => "SELECT MAX(u.ID) FROM {$wpdb->users} u",
912 'args' => array(),
913 );
914 }
915 if ( 'orders' === $collection ) {
916 if ( $this->orders_are_hpos() ) {
917 $orders_table = $wpdb->prefix . 'wc_orders';
918 return array(
919 'sql' => "SELECT MAX(o.id) FROM {$orders_table} o WHERE " . $this->live_order_predicate_sql( 'o' ),
920 'args' => array(),
921 );
922 }
923 return array(
924 'sql' => "SELECT MAX(p.ID) FROM {$wpdb->posts} p WHERE " . $this->live_order_predicate_sql( 'p' ),
925 'args' => array(),
926 );
927 }
928 if ( ! $publish ) {
929 return array(
930 'sql' => "SELECT MAX(p.ID) FROM {$wpdb->posts} p WHERE " . $this->live_product_predicate_sql( 'p' ),
931 'args' => array(),
932 );
933 }
934 $scope = $this->product_servable_predicate_sql( 'catalog_post.ID', true );
935 return array(
936 'sql' => "SELECT MAX(catalog_post.ID) FROM {$wpdb->posts} catalog_post" . $this->parent_product_join_sql()
937 . ' WHERE ' . $scope['sql'],
938 'args' => $scope['args'],
939 );
940 }
941
942 private function product_servable_predicate_sql( string $id_expr, bool $publish ): array {
943 $predicates = array(
944 'catalog_post.post_type IN ' . self::PRODUCT_POST_TYPES_SQL,
945 'catalog_post.post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL,
946 );
947 if ( $publish ) {
948 $predicates[] = $this->published_product_predicate_sql( 'catalog_post', 'parent_product' );
949 }
950 $hidden = $this->pos_hidden_product_ids();
951 if ( array() !== $hidden ) {
952 $predicates[] = $id_expr . ' NOT IN (' . implode( ',', array_fill( 0, \count( $hidden ), '%d' ) ) . ')';
953 }
954 return array(
955 'sql' => implode( ' AND ', $predicates ),
956 'args' => $hidden,
957 );
958 }
959
960 /**
961 * Product-space ids hidden from the POS (`online_only`), products and variations unioned — they share
962 * the wp_posts id-space. Read through the {@see Pos_Visibility} contract, never from the option.
963 *
964 * @return int[]
965 */
966 private function pos_hidden_product_ids(): array {
967 return array_values(
968 array_unique(
969 array_map(
970 'intval',
971 array_merge(
972 $this->visibility->online_only_product_ids(),
973 $this->visibility->online_only_variation_ids()
974 )
975 )
976 )
977 );
978 }
979
980 /** True when orders use HPOS (WooCommerce's own tables); false → legacy CPT (wp_posts). */
981 private function orders_are_hpos(): bool {
982 $order_util = '\\Automattic\\WooCommerce\\Utilities\\OrderUtil';
983 if ( class_exists( $order_util ) && method_exists( $order_util, 'custom_orders_table_usage_is_enabled' ) ) {
984 return (bool) call_user_func( array( $order_util, 'custom_orders_table_usage_is_enabled' ) );
985 }
986 return false; // no WC / older WC → CPT
987 }
988 }
989