PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.2
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.2
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 / Integrity_Digest.php

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

633 lines 25.6 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\Logger;
11
12 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
13 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries use internal table names and generated SQL fragments.
14 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Database failures are passed to exceptions, not rendered.
15
16 use RuntimeException;
17
18 /**
19 * Hash-backed range-checksum support: stored per-record content digests.
20 *
21 * STORES a digest of each product/variation's raw DB row at hook time (the
22 * same save/delete hooks class-change-log.php uses), so the integrity scan
23 * can compare — entirely in SQL — the aggregate of CURRENT raw-row digests
24 * against the aggregate of STORED digests per id-range bucket. If hooks
25 * fired for every write, stored == current (and sequence-log already
26 * reported the change); a bucket mismatch therefore means exactly "content
27 * changed without hooks firing" — the sql-bypass signature — at GROUP BY
28 * prices instead of revision-hash's full-hydration prices.
29 *
30 * The digest basis is deliberately the RAW DB ROW, NOT the filtered REST
31 * payload: this signal is detection-only (discovery of WHERE drift
32 * happened, ADR 0003 "discovery, never values"); hydration of anything the
33 * POS trusts still goes through the filtered REST path. The flip side is
34 * documented too: a raw-row digest cannot see a plugin changing the served
35 * representation without touching the row — that staleness case remains
36 * revision-hash territory.
37 *
38 * This class is the WRITE half. The READ half — every question the REST read
39 * surface asks of the store, plus the canonical digest SQL both halves share —
40 * lives in {@see Digest_Index}. The SQL-fragment accessors that used to hang off
41 * this class are kept as deprecated delegates so existing callers keep working.
42 */
43 final class Integrity_Digest {
44
45 /**
46 * Wall-clock ms spent inside the digest write hooks during the CURRENT
47 * request. Read (and reset) by the product-edit fixture for the
48 * hook-overhead bench's per-component breakdown. Two microtime() calls
49 * per hook fire — negligible against the INSERT…SELECT it wraps.
50 */
51 public static float $request_write_ms = 0.0;
52
53 /**
54 * @see Digest_Index::DIGESTED_META_KEYS The BASELINE key set.
55 * The formula the digest actually uses is Digest_Index::digested_meta_keys(),
56 * which folds in the configured barcode key (mono#1234).
57 */
58 public const DIGESTED_META_KEYS = Digest_Index::DIGESTED_META_KEYS;
59
60 /** @see Digest_Index::CUSTOMER_DIGESTED_META_KEYS The digest formula's home. */
61 public const CUSTOMER_DIGESTED_META_KEYS = Digest_Index::CUSTOMER_DIGESTED_META_KEYS;
62
63 /** @see Digest_Index::ORDER_DIGESTED_META_KEYS The digest formula's home. */
64 public const ORDER_DIGESTED_META_KEYS = Digest_Index::ORDER_DIGESTED_META_KEYS;
65
66 /** @see Digest_Index::OBJECT_TYPES_SQL The product-space object types. */
67 public const OBJECT_TYPES_SQL = Digest_Index::OBJECT_TYPES_SQL;
68
69 public const REBUILD_HOOK = 'wcpos_integrity_digest_rebuild';
70 public const REBUILD_LOCK = 'wcpos_integrity_digest_rebuild_lock';
71 public const REBUILD_LOCK_TTL = 300;
72
73 /**
74 * The read half + the canonical digest SQL. The write statements below compose
75 * their INSERT…SELECT sources from it, so stored and current digests are
76 * computed by ONE expression — the invariant the whole scan rests on.
77 */
78 private Digest_Index $index;
79
80 public function __construct( ?Digest_Index $index = null ) {
81 $this->index = $index ?? new Digest_Index();
82 }
83
84 public function table_name(): string {
85 return $this->index->table_name();
86 }
87
88 /**
89 * Separate current-state table rather than a column on the change-log:
90 * the change-log is an append-only event journal (many rows per object,
91 * tombstones included) while the stored digest is exactly one row per
92 * live object — different cardinality and lifecycle. Folding the digest
93 * into the log would force a latest-row-per-object subquery on every
94 * scan, destroying the GROUP BY price this design exists for.
95 *
96 * digest is BIGINT UNSIGNED holding a 64-bit value (top 16 hex of MD5): integer
97 * storage keeps the BIT_XOR bucket aggregate a pure integer fold with
98 * constant-size state, where a CHAR hash would need GROUP_CONCAT (and
99 * its max_len truncation hazard) to aggregate.
100 */
101 public function schema_sql( string $table_name, string $charset_collate = '' ): string {
102 return "CREATE TABLE {$table_name} (\n"
103 . " object_type VARCHAR(20) NOT NULL,\n"
104 . " object_id BIGINT UNSIGNED NOT NULL,\n"
105 . " digest BIGINT UNSIGNED NOT NULL,\n"
106 . " updated_gmt DATETIME NOT NULL,\n"
107 . " PRIMARY KEY (object_type, object_id),\n"
108 . " KEY object_id (object_id)\n"
109 . ") {$charset_collate};";
110 }
111
112 public function install(): void {
113 global $wpdb;
114 if ( ! function_exists( 'dbDelta' ) ) {
115 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
116 }
117 dbDelta( $this->schema_sql( $this->table_name(), $wpdb->get_charset_collate() ) );
118 }
119
120
121 /**
122 * Same save/delete hooks the change-log listens to (products and
123 * variations only — tax rates live in their own table outside the
124 * wp_posts id space this scan buckets; they stay covered by the plain
125 * range-checksum candidate, whose checksum covers the full rate row).
126 */
127 public function register_hooks(): void {
128 add_action( 'woocommerce_new_product', array( $this, 'record_post_saved' ), 10, 1 );
129 add_action( 'woocommerce_update_product', array( $this, 'record_post_saved' ), 10, 1 );
130 add_action( 'woocommerce_new_product_variation', array( $this, 'record_post_saved' ), 10, 1 );
131 add_action( 'woocommerce_update_product_variation', array( $this, 'record_post_saved' ), 10, 1 );
132 // Untrash does not reliably re-fire woocommerce_update_product; the
133 // upsert is a no-op for non-live rows, so hooking it is free.
134 add_action( 'untrashed_post', array( $this, 'record_post_untrashed' ), 10, 1 );
135 add_action( 'wp_trash_post', array( $this, 'record_post_deleted' ), 10, 1 );
136 add_action( 'before_delete_post', array( $this, 'record_post_deleted' ), 10, 1 );
137
138 // Leg-3 phase 7 (ADR 0015): ALL WordPress users are POS customers under
139 // #1379 (1.9 parity). Saves and role changes idempotently upsert their
140 // digest; only delete_user removes it.
141 add_action( 'user_register', array( $this, 'record_customer_saved' ), 10, 1 );
142 add_action( 'profile_update', array( $this, 'record_customer_saved' ), 10, 1 );
143 add_action( 'woocommerce_created_customer', array( $this, 'record_customer_saved' ), 10, 1 );
144 add_action( 'woocommerce_new_customer', array( $this, 'record_customer_saved' ), 10, 1 );
145 add_action( 'woocommerce_update_customer', array( $this, 'record_customer_saved' ), 10, 1 );
146 add_action( 'set_user_role', array( $this, 'record_customer_saved' ), 10, 1 );
147 // add_role()/remove_role() fire ONLY add_user_role/remove_user_role, so
148 // register both to capture membership changes in the served record.
149 add_action( 'add_user_role', array( $this, 'record_customer_saved' ), 10, 1 );
150 add_action( 'remove_user_role', array( $this, 'record_customer_saved' ), 10, 1 );
151 add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 );
152
153 // Leg-3 phase 7 (ADR 0015): order digest maintenance. Storage-agnostic WC order hooks (fire under
154 // HPOS AND CPT), matching the sync-index's order hooks. upsert/delete are idempotent (no dedup).
155 add_action( 'woocommerce_new_order', array( $this, 'record_order_saved' ), 10, 1 );
156 add_action( 'woocommerce_update_order', array( $this, 'record_order_saved' ), 10, 1 );
157 add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 );
158 add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 );
159 // Untrash recreation: `untrashed_post` (handled by record_post_untrashed)
160 // never fires for COT orders — without the HPOS twin hook a restored
161 // order's digest is never recreated and integrity scans treat it as
162 // deleted forever.
163 add_action( 'woocommerce_untrash_order', array( $this, 'record_order_untrashed' ), 10, 1 );
164 }
165
166 /**
167 * Recreate a COT order's digest once its restore completes.
168 *
169 * `woocommerce_untrash_order` fires BEFORE the data store restores the
170 * status, and the restore's internal save fires no observer hook we bind
171 * (verified: `woocommerce_update_order` does not fire there) — so an
172 * immediate upsert would read a still-trashed row and write nothing. Arm a
173 * one-shot on the order's first object save after it leaves the trash and
174 * upsert then.
175 *
176 * @param int $order_id Order being restored.
177 */
178 public function record_order_untrashed( int $order_id ): void {
179 $handler = function ( $order ) use ( $order_id, &$handler ): void {
180 if ( ! \is_object( $order ) || ! method_exists( $order, 'get_id' ) || ! method_exists( $order, 'get_status' ) || (int) $order->get_id() !== $order_id || 'trash' === $order->get_status() ) {
181 return;
182 }
183 remove_action( 'woocommerce_after_order_object_save', $handler );
184 $this->record_order_saved( $order_id );
185 };
186 add_action( 'woocommerce_after_order_object_save', $handler );
187 }
188
189 /**
190 * Cron entry point for rebuilding unexpectedly empty or stale product digests.
191 */
192 public static function run_scheduled_rebuild(): void {
193 $lease = get_transient( self::REBUILD_LOCK );
194 try {
195 ( new self() )->rebuild( true );
196 } catch ( \Throwable $exception ) {
197 Logger::error( 'WCPOS sync: scheduled integrity digest rebuild failed: ' . $exception->getMessage() );
198 } finally {
199 self::release_rebuild_lock( $lease );
200 }
201 }
202
203 /**
204 * Release the rebuild lease only if this run still owns it — a rebuild that
205 * outlived the lock TTL must not delete a successor's fresh lease.
206 *
207 * @param mixed $lease The lease value captured when this run started.
208 */
209 public static function release_rebuild_lock( $lease ): void {
210 if ( get_transient( self::REBUILD_LOCK ) === $lease ) {
211 delete_transient( self::REBUILD_LOCK );
212 }
213 }
214
215 /**
216 * Wire THE digest stamper onto both served read lanes (#421 increment 3).
217 *
218 * ONE named static serves every digest id-space: it resolves the registry row
219 * from the lane's resource slug, so a collection that gains a digest group is
220 * stamped by adding a row and nothing else. The composed callback name this
221 * used to build (`stamp_proxy_{object_type}_digests`) could name a method that
222 * did not exist — add_filter() does not validate callables, so the miss only
223 * surfaced as a fatal at apply_filters() time, on a catalogue proxy read.
224 *
225 * Both public filter names stay live and both are registered here, so the order
226 * pull lane is wired by the same call as the proxy lane instead of by hand in
227 * Init. Returns the digest-and-proxy collections (the wiring golden pins them).
228 *
229 * @return string[] Collections whose served records carry a stored digest.
230 */
231 public static function register_proxy_digest_stampers(): array {
232 $registered = array();
233 foreach ( Collections::with( 'digest' ) as $collection => $row ) {
234 if ( ! isset( $row['proxy'] ) ) {
235 continue;
236 }
237 $registered[] = $collection;
238 }
239 add_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'stamp_digests' ), 10, 3 );
240 add_filter( 'woocommerce_pos_sync_order_pull_payloads', array( __CLASS__, 'stamp_digests' ), 10, 3 );
241
242 return $registered;
243 }
244
245 /**
246 * Attach each served record's stored 64-bit digest as a top-level `_rxdb_digest`
247 * string, so the client seeds its existence-reconcile manifest (ADR 0014 Leg 3)
248 * as records flow through the NORMAL pull — no separate fetch. The client reads
249 * it into the sidecar manifest; it is NOT persisted into the document. A record
250 * with no stored digest yet simply carries no `_rxdb_digest`.
251 *
252 * The lane's resource slug picks the id-space (the registry owns the mapping,
253 * including the slug traps), and a resource with no digest group — or none at
254 * all — returns the payload untouched.
255 *
256 * @param mixed $data Served list of records.
257 * @param mixed $resource Lane resource slug.
258 * @param mixed $request Request context.
259 *
260 * @return mixed
261 */
262 public static function stamp_digests( $data, $resource = '', $request = null ) {
263 if ( ! \is_array( $data ) || ! \is_string( $resource ) || '' === $resource ) {
264 return $data;
265 }
266 $row = Collections::by_proxy_slug( $resource );
267 if ( null === $row || ! isset( $row['digest'] ) ) {
268 return $data;
269 }
270 $ids = array();
271 foreach ( $data as $record ) {
272 if ( \is_array( $record ) && isset( $record['id'] ) ) {
273 $ids[] = (int) $record['id'];
274 }
275 }
276 if ( array() === $ids ) {
277 return $data;
278 }
279 $digests = ( new Digest_Index() )->read_digests( $row['_collection'], $ids );
280 foreach ( $data as $index => $record ) {
281 if ( \is_array( $record ) && isset( $record['id'] ) && isset( $digests[ (int) $record['id'] ] ) ) {
282 $data[ $index ]['_rxdb_digest'] = $digests[ (int) $record['id'] ];
283 }
284 }
285
286 return $data;
287 }
288
289 /**
290 * Canonical per-CUSTOMER digest SELECT (ADR 0015, Leg-3 phase 7).
291 *
292 * @deprecated Use {@see Digest_Index::customer_digest_select_sql()}.
293 */
294 public function customer_digest_select_sql( string $where_sql = '' ): string {
295 return $this->index->customer_digest_select_sql( $where_sql );
296 }
297
298 /**
299 * Customer digest maintenance (ADR 0015, Leg-3 phase 7) — every WordPress
300 * user is a POS customer, so saves and role changes always upsert.
301 */
302 public function record_customer_saved( int $user_id ): void {
303 $this->observe(
304 function () use ( $user_id ): void {
305 $this->upsert_customer_digest( $user_id );
306 }
307 );
308 }
309
310 public function record_customer_deleted( int $user_id ): void {
311 $this->observe(
312 function () use ( $user_id ): void {
313 $this->delete_customer_digest( $user_id );
314 }
315 );
316 }
317
318 /**
319 * Observation hooks must never break the host write that fired them: a
320 * broken or missing digest store is a sync problem (the integrity scan and
321 * the health gate surface it), not a reason to fatal a WooCommerce save.
322 * The ops paths (rebuild/prune) keep throwing — they run on demand and
323 * want the loudness.
324 *
325 * @param callable $observer The digest write to attempt.
326 */
327 private function observe( callable $observer ): void {
328 try {
329 $observer();
330 } catch ( \Throwable $e ) {
331 Logger::error( 'Sync digest observer failed (sync will self-heal via scan/rebuild): ' . $e->getMessage() );
332 }
333 }
334
335 private function delete_customer_digest( int $user_id ): void {
336 global $wpdb;
337 $deleted = $wpdb->delete(
338 $this->table_name(),
339 array(
340 'object_type' => 'customer',
341 'object_id' => $user_id,
342 ),
343 array( '%s', '%d' )
344 );
345 if ( false === $deleted ) {
346 throw new RuntimeException( 'delete stored customer digest failed: ' . $wpdb->last_error );
347 }
348 }
349
350 /**
351 * Order digest maintenance (ADR 0015, Leg-3 phase 7). The WC order hooks are storage-agnostic (fire
352 * under HPOS AND CPT); the digest SQL's `type='shop_order'` filter makes the upsert a no-op for any
353 * non-order, so no type re-check is needed here.
354 */
355 public function record_order_saved( int $order_id ): void {
356 $this->observe(
357 function () use ( $order_id ): void {
358 $this->upsert_order_digest( $order_id );
359 }
360 );
361 }
362
363 public function record_order_deleted( int $order_id ): void {
364 $this->observe(
365 function () use ( $order_id ): void {
366 $this->delete_order_digest( $order_id );
367 }
368 );
369 }
370
371 private function delete_order_digest( int $order_id ): void {
372 global $wpdb;
373 $deleted = $wpdb->delete(
374 $this->table_name(),
375 array(
376 'object_type' => 'order',
377 'object_id' => $order_id,
378 ),
379 array( '%s', '%d' )
380 );
381 if ( false === $deleted ) {
382 throw new RuntimeException( 'delete stored order digest failed: ' . $wpdb->last_error );
383 }
384 }
385
386 /** Order analogue of {@see upsert_customer_digest}: compute + store one order's digest (HPOS or CPT). */
387 public function upsert_order_digest( int $order_id ): void {
388 global $wpdb;
389 $started = microtime( true );
390 $this->index->raise_group_concat_max_len();
391 $result = $wpdb->query(
392 $wpdb->prepare(
393 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
394 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
395 . ' FROM (' . $this->index->order_digest_select_sql( '{id} = %d' ) . ') t'
396 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
397 $order_id
398 )
399 );
400 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
401 if ( false === $result ) {
402 throw new RuntimeException( 'upsert stored order digest failed: ' . $wpdb->last_error );
403 }
404 }
405
406 public function record_post_saved( int $post_id ): void {
407 $this->observe(
408 function () use ( $post_id ): void {
409 $this->upsert_digest( $post_id );
410 }
411 );
412 }
413
414 public function record_post_untrashed( int $post_id ): void {
415 if ( 'shop_order' === get_post_type( $post_id ) ) {
416 $this->record_order_saved( $post_id );
417 return;
418 }
419 $this->record_post_saved( $post_id );
420 }
421
422 public function record_post_deleted( int $post_id ): void {
423 $post_type = get_post_type( $post_id );
424 if ( ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
425 return;
426 }
427 $this->observe(
428 function () use ( $post_id, $post_type ): void {
429 $this->delete_post_digest( $post_id, $post_type );
430 }
431 );
432 }
433
434 /**
435 * Remove a product/variation digest row after a hooked delete.
436 *
437 * A hooked delete removes the stored row so stored == current again.
438 * Only a hook-BYPASSING delete leaves an orphan digest behind, which
439 * the scan reports as a mismatch (stored side carries a row the
440 * current side lacks) and the drill-down labels status=deleted.
441 *
442 * @param int $post_id The deleted post id.
443 * @param string $post_type Its post type (product | product_variation).
444 */
445 private function delete_post_digest( int $post_id, string $post_type ): void {
446 global $wpdb;
447 $started = microtime( true );
448 $deleted = $wpdb->delete(
449 $this->table_name(),
450 array(
451 'object_type' => 'product_variation' === $post_type ? 'variation' : 'product',
452 'object_id' => $post_id,
453 ),
454 array( '%s', '%d' )
455 );
456 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
457 if ( false === $deleted ) {
458 throw new RuntimeException( 'delete stored digest failed: ' . $wpdb->last_error );
459 }
460 }
461
462 /**
463 * One round trip: the digest is computed in SQL from the raw row and
464 * upserted in the same statement — PHP never materializes the value.
465 * No-op for rows outside the live predicate (the delete hook owns those).
466 */
467 public function upsert_digest( int $post_id ): void {
468 global $wpdb;
469 // Time from BEFORE the session setup so timing.digest_ms covers ALL digest hook work
470 // (the raise runs inside the save hook — codex P3).
471 $started = microtime( true );
472 $this->index->raise_group_concat_max_len();
473 $result = $wpdb->query(
474 $wpdb->prepare(
475 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
476 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
477 . ' FROM (' . $this->index->row_digest_select_sql( 'p.ID = %d' ) . ') t'
478 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
479 $post_id
480 )
481 );
482 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
483 if ( false === $result ) {
484 throw new RuntimeException( 'upsert stored digest failed: ' . $wpdb->last_error );
485 }
486 }
487
488 /**
489 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
490 * compute and store one WordPress user's customer digest in a single
491 * INSERT…SELECT. Only the delete hook removes it.
492 */
493 public function upsert_customer_digest( int $user_id ): void {
494 global $wpdb;
495 $started = microtime( true );
496 $this->index->raise_group_concat_max_len();
497 $result = $wpdb->query(
498 $wpdb->prepare(
499 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
500 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
501 . ' FROM (' . $this->index->customer_digest_select_sql( 'u.ID = %d' ) . ') t'
502 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
503 $user_id
504 )
505 );
506 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
507 if ( false === $result ) {
508 throw new RuntimeException( 'upsert stored customer digest failed: ' . $wpdb->last_error );
509 }
510 }
511
512 /**
513 * Backfill/repair: prune orphans, then digest every live row in one
514 * INSERT…SELECT pass. Pre-existing catalogs (the 10k seed) become fully
515 * digestable in one call; measured timing is returned so the lab can
516 * report the backfill price.
517 *
518 * @param bool $products_only Whether to stop after rebuilding product digests.
519 */
520 public function rebuild( bool $products_only = false ): array {
521 global $wpdb;
522 $this->index->raise_group_concat_max_len();
523 $started = microtime( true );
524
525 $orphans_deleted = $wpdb->query(
526 'DELETE FROM ' . $this->table_name()
527 . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL
528 . ' AND NOT ' . $this->index->live_row_exists_sql( 'object_id' )
529 );
530 if ( false === $orphans_deleted ) {
531 throw new RuntimeException( 'prune orphan stored digests failed: ' . $wpdb->last_error );
532 }
533
534 // Affected-rows semantics of ON DUPLICATE KEY: 1 per insert, 2 per
535 // update, 0 per already-matching row — reported raw as "writes".
536 // updated_gmt is assigned FIRST and only when the digest actually
537 // changed (assignments evaluate left-to-right, so the IF must read
538 // the pre-update digest before the digest assignment overwrites it).
539 // Otherwise a repeated rebuild rewrites UTC_TIMESTAMP() into every
540 // row, counts the whole table as writes, and inflates the
541 // hash-checksum baseline cost (codex review).
542 $writes = $wpdb->query(
543 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
544 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
545 . ' FROM (' . $this->index->row_digest_select_sql() . ') t'
546 . ' ON DUPLICATE KEY UPDATE'
547 . ' updated_gmt = IF(digest <=> VALUES(digest), updated_gmt, VALUES(updated_gmt)),'
548 . ' digest = VALUES(digest)'
549 );
550 if ( false === $writes ) {
551 throw new RuntimeException( 'rebuild stored digests failed: ' . $wpdb->last_error );
552 }
553 update_option( Digest_Index::FORMULA_FP_OPTION, Digest_Index::digest_formula_fingerprint(), false );
554
555 if ( $products_only ) {
556 $stored_total = (int) $wpdb->get_var(
557 'SELECT COUNT(*) FROM ' . $this->table_name() . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL
558 );
559
560 return array(
561 'writes' => (int) $writes,
562 'orphans_deleted' => (int) $orphans_deleted,
563 'stored_total' => $stored_total,
564 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
565 );
566 }
567
568 // Leg-3 phase 7 (ADR 0015): customers share the digest table via their own 'customer' rows —
569 // the same prune-orphans + INSERT…SELECT pass, over the customer predicate + id-space. A stored
570 // customer whose user vanished or lost the customer role is an orphan (a role removal never fires
571 // before_delete_post, so the rebuild is the backstop that reconciles it).
572 $customer_orphans = $wpdb->query(
573 'DELETE FROM ' . $this->table_name()
574 . " WHERE object_type = 'customer'"
575 . ' AND NOT ' . $this->index->customer_live_row_exists_sql( 'object_id' )
576 );
577 if ( false === $customer_orphans ) {
578 throw new RuntimeException( 'prune orphan customer digests failed: ' . $wpdb->last_error );
579 }
580 $customer_writes = $wpdb->query(
581 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
582 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
583 . ' FROM (' . $this->index->customer_digest_select_sql() . ') t'
584 . ' ON DUPLICATE KEY UPDATE'
585 . ' updated_gmt = IF(digest <=> VALUES(digest), updated_gmt, VALUES(updated_gmt)),'
586 . ' digest = VALUES(digest)'
587 );
588 if ( false === $customer_writes ) {
589 throw new RuntimeException( 'rebuild customer digests failed: ' . $wpdb->last_error );
590 }
591
592 // Leg-3 phase 7 (ADR 0015): orders share the digest table via their own 'order' rows (HPOS or CPT).
593 // Same prune-orphans + INSERT…SELECT pass; Digest_Index::order_digest_select_sql() emits the storage-correct SQL
594 // (the CPT path GROUP BYs, the HPOS path does not — both valid as an INSERT…SELECT source).
595 $order_orphans = $wpdb->query(
596 'DELETE FROM ' . $this->table_name()
597 . " WHERE object_type = 'order'"
598 . ' AND NOT ' . $this->index->order_live_row_exists_sql( 'object_id' )
599 );
600 if ( false === $order_orphans ) {
601 throw new RuntimeException( 'prune orphan order digests failed: ' . $wpdb->last_error );
602 }
603 $order_writes = $wpdb->query(
604 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
605 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
606 . ' FROM (' . $this->index->order_digest_select_sql() . ') t'
607 . ' ON DUPLICATE KEY UPDATE'
608 . ' updated_gmt = IF(digest <=> VALUES(digest), updated_gmt, VALUES(updated_gmt)),'
609 . ' digest = VALUES(digest)'
610 );
611 if ( false === $order_writes ) {
612 throw new RuntimeException( 'rebuild order digests failed: ' . $wpdb->last_error );
613 }
614
615 $stored_total = (int) $wpdb->get_var(
616 'SELECT COUNT(*) FROM ' . $this->table_name() . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL
617 );
618 $customer_stored_total = (int) $wpdb->get_var(
619 'SELECT COUNT(*) FROM ' . $this->table_name() . " WHERE object_type = 'customer'"
620 );
621 $order_stored_total = (int) $wpdb->get_var(
622 'SELECT COUNT(*) FROM ' . $this->table_name() . " WHERE object_type = 'order'"
623 );
624
625 return array(
626 'writes' => (int) $writes + (int) $customer_writes + (int) $order_writes,
627 'orphans_deleted' => (int) $orphans_deleted + (int) $customer_orphans + (int) $order_orphans,
628 'stored_total' => $stored_total + $customer_stored_total + $order_stored_total,
629 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
630 );
631 }
632 }
633