| 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.Security.EscapeOutput.ExceptionNotEscaped -- Database failures are passed to exceptions, not rendered. |
| 13 |
|
| 14 |
use Automattic\WooCommerce\Utilities\OrderUtil; |
| 15 |
use WCPOS\WooCommercePOS\Logger; |
| 16 |
|
| 17 |
final class Sync_Journal { |
| 18 |
/** Persisted order backfill cursor. */ |
| 19 |
public const BACKFILL_OPTION = 'woocommerce_pos_sync_index_backfill'; |
| 20 |
|
| 21 |
/** Generation marker for the journal sequence space. */ |
| 22 |
public const EPOCH_OPTION = 'woocommerce_pos_sync_journal_epoch'; |
| 23 |
|
| 24 |
/** Wall-clock ms spent inside record() during the CURRENT request. */ |
| 25 |
public static float $request_write_ms = 0.0; |
| 26 |
|
| 27 |
/** Per-request dedup of identical customer lifecycle events. */ |
| 28 |
private array $recorded_this_request = array(); |
| 29 |
|
| 30 |
/** |
| 31 |
* The ONE order whose `hook:update` row is owed but not yet written. |
| 32 |
* |
| 33 |
* WooCommerce saves an order many times while building it: one Store API |
| 34 |
* checkout fires `woocommerce_update_order` eleven times, and even a plain |
| 35 |
* `$order->save()` on a CPT store fires it three times. Every firing used to |
| 36 |
* append a row (an fsync, ~3 ms) after a three-query `wc_get_order()` — 12 |
| 37 |
* rows and ~66 ms for ONE online order, measured 2026-09-03 on dev-next. |
| 38 |
* A journal row is a change POINTER (ADR 0033), so one row per order per |
| 39 |
* request carries the same information. |
| 40 |
* |
| 41 |
* Capacity one preserves ordering before a different order or other-origin |
| 42 |
* row. Static so that guarantee holds across journal instances. The first |
| 43 |
* instance needing the queue binds its writer, including after shutdown; |
| 44 |
* all instances write the same table. Retaining the hook's order object |
| 45 |
* avoids a refetch. See Request_Write_Queue for the queue mechanics. |
| 46 |
*/ |
| 47 |
private static ?Request_Write_Queue $pending_updates = null; |
| 48 |
|
| 49 |
/** |
| 50 |
* Option-name prefix for the per-object-type lossy-prune watermarks. |
| 51 |
* |
| 52 |
* The watermark is scoped per object type for the same reason heads are |
| 53 |
* stream-scoped: the streams share one AUTO_INCREMENT space, so a single |
| 54 |
* global watermark advanced by an order prune would sit far above a quiet |
| 55 |
* catalogue stream's head. Every catalogue cursor would then read as |
| 56 |
* "below the horizon" and rebaseline on EVERY poll, forever — and |
| 57 |
* symmetrically for the order lane. A stream's horizon may only move for |
| 58 |
* rows that stream can serve. |
| 59 |
*/ |
| 60 |
const PRUNE_WATERMARK_OPTION_PREFIX = 'wcpos_change_log_prune_watermark_'; |
| 61 |
|
| 62 |
public function table_name(): string { |
| 63 |
global $wpdb; |
| 64 |
return $wpdb->prefix . Health::SYNC_JOURNAL_TABLE; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* The `revision` column is a per-lane union: a `date_modified` stamp for |
| 69 |
* catalogue/customer rows, `''` for live order rows (order revisions are |
| 70 |
* computed at pull time — ADR 0033), `'deleted'` for order tombstones, and |
| 71 |
* legacy pre-#1746 order rows may still carry stored `sha256:` hashes, |
| 72 |
* which the pull planner's stored-wins branch serves until they age out. |
| 73 |
*/ |
| 74 |
public function schema_sql( string $table_name, string $charset_collate = '' ): string { |
| 75 |
return "CREATE TABLE {$table_name} (\n" |
| 76 |
. " sequence BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n" |
| 77 |
. " object_type VARCHAR(20) NOT NULL,\n" |
| 78 |
. " object_id BIGINT UNSIGNED NOT NULL,\n" |
| 79 |
. " deleted TINYINT(1) NOT NULL DEFAULT 0,\n" |
| 80 |
. " revision VARCHAR(80) NOT NULL DEFAULT '',\n" |
| 81 |
. " modified_gmt DATETIME NOT NULL,\n" |
| 82 |
. " origin VARCHAR(40) NOT NULL DEFAULT 'hook',\n" |
| 83 |
. " created_gmt DATETIME NOT NULL,\n" |
| 84 |
. " PRIMARY KEY (sequence),\n" |
| 85 |
. " KEY type_sequence (object_type, sequence),\n" |
| 86 |
. " KEY type_object (object_type, object_id)\n" |
| 87 |
. ") {$charset_collate};"; |
| 88 |
} |
| 89 |
|
| 90 |
public function install(): void { |
| 91 |
global $wpdb; |
| 92 |
$table_name = $this->table_name(); |
| 93 |
$table_existed = Health::table_exists( $table_name ); |
| 94 |
if ( ! function_exists( 'dbDelta' ) ) { |
| 95 |
require_once ABSPATH . 'wp-admin/includes/upgrade.php'; |
| 96 |
} |
| 97 |
dbDelta( $this->schema_sql( $table_name, $wpdb->get_charset_collate() ) ); |
| 98 |
|
| 99 |
if ( ! $table_existed && Health::table_exists( $table_name ) ) { |
| 100 |
self::reset_prune_watermarks(); |
| 101 |
} |
| 102 |
if ( ! Health::table_exists( $table_name ) ) { |
| 103 |
return; |
| 104 |
} |
| 105 |
|
| 106 |
// The epoch marks a SEQUENCE GENERATION. Regenerate it only when the |
| 107 |
// table was actually (re)created — dbDelta on a surviving table is a |
| 108 |
// no-op and every row survives, so activation/upgrade re-runs must not |
| 109 |
// force every client (both lanes, since the epoch is journal-global) |
| 110 |
// into a needless resync-from-zero. |
| 111 |
if ( ! $table_existed ) { |
| 112 |
$this->regenerate_epoch(); |
| 113 |
} |
| 114 |
$backfill = $this->backfill_status(); |
| 115 |
$backfill_has_progress = 'idle' !== $backfill['status'] || $backfill['nextPage'] > 1 || $backfill['processed'] > 0; |
| 116 |
if ( 0 === $this->head_sequence() && $backfill_has_progress ) { |
| 117 |
delete_option( self::BACKFILL_OPTION ); |
| 118 |
} |
| 119 |
|
| 120 |
// A store with no orders has nothing to backfill: mark it complete on a |
| 121 |
// fresh table so sequence-zero pulls are journal-authoritative from the |
| 122 |
// first write (Order_Query holds the baseline on the modified-date scan |
| 123 |
// until the backfill is complete). Stores WITH history stay incomplete |
| 124 |
// until the admin backfill runs. Runs after the stale-cursor cleanup |
| 125 |
// above so a carried-over cursor cannot masquerade as completion. |
| 126 |
if ( ! $table_existed && function_exists( 'wc_get_orders' ) ) { |
| 127 |
$existing = wc_get_orders( |
| 128 |
array( |
| 129 |
'type' => 'shop_order', |
| 130 |
'limit' => 1, |
| 131 |
'return' => 'ids', |
| 132 |
) |
| 133 |
); |
| 134 |
if ( is_array( $existing ) && array() === $existing ) { |
| 135 |
update_option( |
| 136 |
self::BACKFILL_OPTION, |
| 137 |
array( |
| 138 |
'status' => 'complete', |
| 139 |
'nextPage' => 1, |
| 140 |
'pageSize' => null, |
| 141 |
'processed' => 0, |
| 142 |
'lastOrderId' => 0, |
| 143 |
'lastRunGmt' => gmdate( 'c' ), |
| 144 |
), |
| 145 |
false |
| 146 |
); |
| 147 |
} |
| 148 |
} |
| 149 |
} |
| 150 |
|
| 151 |
/** Return the stable id for the current sequence generation. */ |
| 152 |
public function ensure_epoch(): string { |
| 153 |
$epoch = (string) get_option( self::EPOCH_OPTION, '' ); |
| 154 |
if ( '' !== $epoch ) { |
| 155 |
return $epoch; |
| 156 |
} |
| 157 |
|
| 158 |
$epoch = $this->mint_epoch(); |
| 159 |
add_option( self::EPOCH_OPTION, $epoch, '', true ); |
| 160 |
return (string) get_option( self::EPOCH_OPTION, $epoch ); |
| 161 |
} |
| 162 |
|
| 163 |
/** Force a new id for the current sequence generation. */ |
| 164 |
public function regenerate_epoch(): string { |
| 165 |
$epoch = $this->mint_epoch(); |
| 166 |
update_option( self::EPOCH_OPTION, $epoch, true ); |
| 167 |
return $epoch; |
| 168 |
} |
| 169 |
|
| 170 |
private function mint_epoch(): string { |
| 171 |
return function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : md5( uniqid( 'wcpos-epoch', true ) ); |
| 172 |
} |
| 173 |
|
| 174 |
/** Append a schema-upgrade customer row for every live user. */ |
| 175 |
public function append_customer_updates_for_all_users(): bool { |
| 176 |
global $wpdb; |
| 177 |
$now = gmdate( 'Y-m-d H:i:s' ); |
| 178 |
|
| 179 |
return false !== $wpdb->query( |
| 180 |
$wpdb->prepare( |
| 181 |
'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, deleted, revision, modified_gmt, origin, created_gmt)' |
| 182 |
. " SELECT 'customer', ID, 0, '', %s, 'schema-upgrade', %s FROM " . $wpdb->users, |
| 183 |
$now, |
| 184 |
$now |
| 185 |
) |
| 186 |
); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Append one tombstone per catalogue post id, in a single statement. |
| 191 |
* |
| 192 |
* The per-record `record_post_deleted()` path loads a `WC_Product` for the revision stamp, which |
| 193 |
* is fine for the handful of records one settings write moves and hopeless for the whole hidden |
| 194 |
* set of a store that keeps thousands of products online-only. This is the bulk form, shaped like |
| 195 |
* `append_customer_updates_for_all_users()`: one INSERT ... SELECT, no revision (a tombstone |
| 196 |
* carries no state the client compares), and the post type read from `wp_posts` so a stale id in |
| 197 |
* the merchant's list cannot announce a change to an unrelated record. |
| 198 |
* |
| 199 |
* @param int[] $ids Product / variation post ids. |
| 200 |
*/ |
| 201 |
public function append_catalogue_tombstones( array $ids ): bool { |
| 202 |
global $wpdb; |
| 203 |
|
| 204 |
$ids = array_values( |
| 205 |
array_unique( |
| 206 |
array_filter( |
| 207 |
array_map( 'intval', $ids ), |
| 208 |
static function ( int $id ): bool { |
| 209 |
return $id > 0; |
| 210 |
} |
| 211 |
) |
| 212 |
) |
| 213 |
); |
| 214 |
if ( array() === $ids ) { |
| 215 |
return true; |
| 216 |
} |
| 217 |
|
| 218 |
$now = gmdate( 'Y-m-d H:i:s' ); |
| 219 |
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); |
| 220 |
|
| 221 |
return false !== $wpdb->query( |
| 222 |
$wpdb->prepare( |
| 223 |
'INSERT INTO ' . $this->table_name() |
| 224 |
. ' (object_type, object_id, deleted, revision, modified_gmt, origin, created_gmt)' |
| 225 |
. " SELECT CASE p.post_type WHEN 'product_variation' THEN 'variation' ELSE 'product' END," |
| 226 |
. " p.ID, 1, '', %s, 'visibility-seed', %s" |
| 227 |
. " FROM {$wpdb->posts} p" |
| 228 |
. " WHERE p.post_type IN ('product','product_variation') AND p.ID IN ({$placeholders})" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %d placeholder list generated from count(); the ids are bound below. |
| 229 |
. ' ORDER BY p.ID', |
| 230 |
$now, |
| 231 |
$now, |
| 232 |
...$ids |
| 233 |
) |
| 234 |
); |
| 235 |
} |
| 236 |
|
| 237 |
public function register_hooks(): void { |
| 238 |
add_action( 'woocommerce_new_product', array( $this, 'record_product_created' ), 10, 1 ); |
| 239 |
add_action( 'woocommerce_update_product', array( $this, 'record_product_updated' ), 10, 1 ); |
| 240 |
add_action( 'woocommerce_new_product_variation', array( $this, 'record_variation_created' ), 10, 1 ); |
| 241 |
add_action( 'woocommerce_update_product_variation', array( $this, 'record_variation_updated' ), 10, 1 ); |
| 242 |
add_action( 'woocommerce_new_coupon', array( $this, 'record_coupon_created' ), 10, 1 ); |
| 243 |
add_action( 'woocommerce_update_coupon', array( $this, 'record_coupon_updated' ), 10, 1 ); |
| 244 |
add_action( 'wp_trash_post', array( $this, 'record_post_deleted' ), 10, 1 ); |
| 245 |
add_action( 'before_delete_post', array( $this, 'record_post_deleted' ), 10, 1 ); |
| 246 |
add_action( 'untrashed_post', array( $this, 'record_post_untrashed' ), 10, 1 ); |
| 247 |
add_action( 'woocommerce_tax_rate_added', array( $this, 'record_tax_rate_created' ), 10, 1 ); |
| 248 |
add_action( 'woocommerce_tax_rate_updated', array( $this, 'record_tax_rate_updated' ), 10, 1 ); |
| 249 |
add_action( 'woocommerce_tax_rate_deleted', array( $this, 'record_tax_rate_deleted' ), 10, 1 ); |
| 250 |
add_action( 'created_term', array( $this, 'record_term_created' ), 10, 3 ); |
| 251 |
add_action( 'edited_term', array( $this, 'record_term_edited' ), 10, 3 ); |
| 252 |
add_action( 'delete_term', array( $this, 'record_term_deleted' ), 10, 3 ); |
| 253 |
add_action( 'added_term_meta', array( $this, 'record_term_meta_change' ), 10, 2 ); |
| 254 |
add_action( 'updated_term_meta', array( $this, 'record_term_meta_change' ), 10, 2 ); |
| 255 |
add_action( 'deleted_term_meta', array( $this, 'record_term_meta_deleted' ), 10, 2 ); |
| 256 |
add_action( 'user_register', array( $this, 'record_customer_created' ), 10, 1 ); |
| 257 |
add_action( 'woocommerce_created_customer', array( $this, 'record_customer_created_persisted' ), 10, 1 ); |
| 258 |
add_action( 'woocommerce_new_customer', array( $this, 'record_customer_created_persisted' ), 10, 1 ); |
| 259 |
add_action( 'profile_update', array( $this, 'record_customer_profile_update' ), 10, 2 ); |
| 260 |
add_action( 'set_user_role', array( $this, 'record_customer_role_change' ), 10, 3 ); |
| 261 |
add_action( 'add_user_role', array( $this, 'record_customer_role_added' ), 10, 2 ); |
| 262 |
add_action( 'remove_user_role', array( $this, 'record_customer_role_removed' ), 10, 2 ); |
| 263 |
add_action( 'woocommerce_update_customer', array( $this, 'record_customer_updated' ), 10, 1 ); |
| 264 |
add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 ); |
| 265 |
add_action( 'woocommerce_new_order', array( $this, 'record_order_created' ), 10, 1 ); |
| 266 |
// Two args: the data store passes ($order_id, $order). Keeping the object |
| 267 |
// lets the coalesced flush read modified_gmt without a refetch. |
| 268 |
add_action( 'woocommerce_update_order', array( $this, 'record_order_updated' ), 10, 2 ); |
| 269 |
// Request boundary for the coalesced order update row. LAST on shutdown: |
| 270 |
// WooCommerce saves the customer at 10 and the session at 20, and any |
| 271 |
// save those trigger must still find the slot open. Zero accepted args: |
| 272 |
// do_action( 'shutdown' ) passes an empty string otherwise. |
| 273 |
add_action( 'shutdown', array( $this, 'flush_pending_order_updates_at_shutdown' ), PHP_INT_MAX, 0 ); |
| 274 |
add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 275 |
add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 276 |
add_action( 'woocommerce_untrash_order', array( $this, 'record_cot_order_untrashed' ), 10, 1 ); |
| 277 |
add_action( 'woocommerce_pos_invalidate', array( $this, 'record_invalidation' ), 10, 2 ); |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Record an out-of-band change announced by an extension. |
| 282 |
* |
| 283 |
* Plugins fire `woocommerce_pos_invalidate` when they change a record's |
| 284 |
* SERVED representation in a way no save hook announces — a filter-only |
| 285 |
* output change (a pricing filter, an added payload field). The journal |
| 286 |
* appends a pointer row; clients hydrate pointer rows by sequence, so the |
| 287 |
* re-served payload carries the plugin's change. Formula fingerprints |
| 288 |
* (#1742) will eventually make representation changes directly detectable; |
| 289 |
* until then this action is the documented relief valve. |
| 290 |
* |
| 291 |
* `$object_type` is the registry's SINGULAR journal name: `product`, |
| 292 |
* `variation`, `customer`, `order`, `tax_rate`, and the other journalled |
| 293 |
* catalogue types. A plural (`products`) or unknown type is logged and |
| 294 |
* ignored. Rows land with origin `invalidate` on every type. |
| 295 |
* |
| 296 |
* @since 1.10.3 |
| 297 |
* |
| 298 |
* @param string $object_type Canonical (singular) journal object type. |
| 299 |
* @param int $object_id Changed object ID. |
| 300 |
*/ |
| 301 |
public function record_invalidation( $object_type = '', $object_id = 0 ): void { |
| 302 |
// Loose signature on purpose: a public action handler whose posture is |
| 303 |
// log-and-ignore — a one-arg or wrong-typed do_action() must not fatal |
| 304 |
// the calling plugin's request. |
| 305 |
$object_type = is_scalar( $object_type ) ? (string) $object_type : ''; |
| 306 |
$object_id = is_scalar( $object_id ) ? (int) $object_id : 0; |
| 307 |
$collection = Collections::by_object_type( $object_type ); |
| 308 |
if ( $object_id <= 0 || null === $collection || ! isset( $collection['journal'] ) ) { |
| 309 |
Logger::log( sprintf( 'WCPOS sync: ignored invalidation for object_type "%s" (id %d)', $object_type, $object_id ) ); |
| 310 |
return; |
| 311 |
} |
| 312 |
|
| 313 |
if ( 'order' === $object_type ) { |
| 314 |
$this->record_order_change( $object_id, 'invalidate', false ); |
| 315 |
return; |
| 316 |
} |
| 317 |
$loader = (string) ( $collection['identity']['loader'] ?? '' ); |
| 318 |
if ( 'product' === $loader ) { |
| 319 |
$object = function_exists( 'wc_get_product' ) ? wc_get_product( $object_id ) : null; |
| 320 |
$this->record( $object_type, $object_id, false, self::object_revision( $object ), 'invalidate' ); |
| 321 |
if ( 'variation' === $object_type ) { |
| 322 |
// Native variation paths always pair the parent row — the parent |
| 323 |
// document carries the variable price range — so an invalidation |
| 324 |
// must too, or the relief valve half-works. Recorded inline (not via |
| 325 |
// record_variation_parent) so the paired row keeps the 'invalidate' |
| 326 |
// origin the contract above promises for every row this action lands. |
| 327 |
$parent_id = function_exists( 'wp_get_post_parent_id' ) ? (int) wp_get_post_parent_id( $object_id ) : 0; |
| 328 |
if ( $parent_id > 0 ) { |
| 329 |
$parent = function_exists( 'wc_get_product' ) ? wc_get_product( $parent_id ) : null; |
| 330 |
$this->record( 'product', $parent_id, false, self::object_revision( $parent ), 'invalidate' ); |
| 331 |
} |
| 332 |
} |
| 333 |
return; |
| 334 |
} |
| 335 |
if ( 'customer' === $loader ) { |
| 336 |
try { |
| 337 |
$customer = class_exists( '\\WC_Customer' ) ? new \WC_Customer( $object_id ) : null; |
| 338 |
} catch ( \Exception $e ) { |
| 339 |
Logger::log( sprintf( 'WCPOS sync: ignored invalidation for missing customer %d', $object_id ) ); |
| 340 |
return; |
| 341 |
} |
| 342 |
$this->record( 'customer', $object_id, false, self::object_revision( $customer ), 'invalidate', true, 'invalidate' ); |
| 343 |
return; |
| 344 |
} |
| 345 |
$this->record( $object_type, $object_id, false, '', 'invalidate' ); |
| 346 |
} |
| 347 |
|
| 348 |
public function record_product_created( int $product_id ): void { |
| 349 |
$this->record_catalogue_object( 'product', $product_id, false ); |
| 350 |
} |
| 351 |
|
| 352 |
public function record_product_updated( int $product_id ): void { |
| 353 |
$this->record_catalogue_object( 'product', $product_id, false ); |
| 354 |
} |
| 355 |
|
| 356 |
public function record_variation_created( int $variation_id ): void { |
| 357 |
$this->record_catalogue_object( 'variation', $variation_id, false ); |
| 358 |
$this->record_variation_parent( $variation_id ); |
| 359 |
} |
| 360 |
|
| 361 |
public function record_variation_updated( int $variation_id ): void { |
| 362 |
$this->record_catalogue_object( 'variation', $variation_id, false ); |
| 363 |
$this->record_variation_parent( $variation_id ); |
| 364 |
} |
| 365 |
|
| 366 |
/** Record the representation change to a variation's parent product. */ |
| 367 |
private function record_variation_parent( int $variation_id ): void { |
| 368 |
$parent_id = function_exists( 'wp_get_post_parent_id' ) ? (int) wp_get_post_parent_id( $variation_id ) : 0; |
| 369 |
if ( $parent_id > 0 ) { |
| 370 |
$this->record_catalogue_object( 'product', $parent_id, false ); |
| 371 |
} |
| 372 |
} |
| 373 |
|
| 374 |
public function record_post_deleted( int $post_id ): void { |
| 375 |
$post_type = get_post_type( $post_id ); |
| 376 |
if ( 'product' === $post_type ) { |
| 377 |
$this->record_catalogue_object( 'product', $post_id, true ); |
| 378 |
return; |
| 379 |
} |
| 380 |
if ( 'product_variation' === $post_type ) { |
| 381 |
$this->record_catalogue_object( 'variation', $post_id, true ); |
| 382 |
$this->record_variation_parent( $post_id ); |
| 383 |
return; |
| 384 |
} |
| 385 |
if ( 'shop_coupon' === $post_type ) { |
| 386 |
$this->record( 'coupon', $post_id, true ); |
| 387 |
return; |
| 388 |
} |
| 389 |
if ( 'shop_order' === $post_type ) { |
| 390 |
$this->record_order_deleted( $post_id ); |
| 391 |
} |
| 392 |
} |
| 393 |
|
| 394 |
public function record_post_untrashed( int $post_id ): void { |
| 395 |
$post_type = get_post_type( $post_id ); |
| 396 |
if ( 'product' === $post_type ) { |
| 397 |
$this->record_product_updated( $post_id ); |
| 398 |
return; |
| 399 |
} |
| 400 |
if ( 'product_variation' === $post_type ) { |
| 401 |
$this->record_variation_updated( $post_id ); |
| 402 |
return; |
| 403 |
} |
| 404 |
if ( 'shop_coupon' === $post_type ) { |
| 405 |
$this->record_coupon_updated( $post_id ); |
| 406 |
return; |
| 407 |
} |
| 408 |
if ( 'shop_order' === $post_type ) { |
| 409 |
$this->record_order_untrashed( $post_id ); |
| 410 |
} |
| 411 |
} |
| 412 |
|
| 413 |
public function record_coupon_created( int $coupon_id ): void { |
| 414 |
$this->record( 'coupon', $coupon_id, false ); |
| 415 |
} |
| 416 |
|
| 417 |
public function record_coupon_updated( int $coupon_id ): void { |
| 418 |
$this->record( 'coupon', $coupon_id, false ); |
| 419 |
} |
| 420 |
|
| 421 |
/** Map tracked product taxonomies to journal object types. */ |
| 422 |
private static function term_taxonomy_object_types(): array { |
| 423 |
$map = array(); |
| 424 |
foreach ( Collections::with( 'identity' ) as $row ) { |
| 425 |
if ( isset( $row['identity']['taxonomy'] ) ) { |
| 426 |
$map[ $row['identity']['taxonomy'] ] = $row['object_type']; |
| 427 |
} |
| 428 |
} |
| 429 |
return $map; |
| 430 |
} |
| 431 |
|
| 432 |
public function record_term_created( int $term_id, int $tt_id, string $taxonomy ): void { |
| 433 |
$this->record_term_change( $term_id, $taxonomy, 'create' ); |
| 434 |
} |
| 435 |
|
| 436 |
public function record_term_edited( int $term_id, int $tt_id, string $taxonomy ): void { |
| 437 |
$this->record_term_change( $term_id, $taxonomy, 'update' ); |
| 438 |
} |
| 439 |
|
| 440 |
public function record_term_deleted( int $term_id, int $tt_id, string $taxonomy ): void { |
| 441 |
$this->record_term_change( $term_id, $taxonomy, 'delete' ); |
| 442 |
} |
| 443 |
|
| 444 |
private function record_term_change( int $term_id, string $taxonomy, string $change_type ): void { |
| 445 |
$object_type = self::term_taxonomy_object_types()[ $taxonomy ] ?? null; |
| 446 |
if ( null !== $object_type ) { |
| 447 |
$this->record( $object_type, $term_id, 'delete' === $change_type ); |
| 448 |
} |
| 449 |
} |
| 450 |
|
| 451 |
/** Record added or updated metadata for a tracked term. */ |
| 452 |
public function record_term_meta_change( int $meta_id, int $term_id ): void { |
| 453 |
$this->record_term_representation_change( $term_id ); |
| 454 |
} |
| 455 |
|
| 456 |
/** Record deleted metadata for a tracked term. */ |
| 457 |
public function record_term_meta_deleted( array $meta_ids, int $term_id ): void { |
| 458 |
$this->record_term_representation_change( $term_id ); |
| 459 |
} |
| 460 |
|
| 461 |
/** Resolve a meta hook's term and record it only when tracked. */ |
| 462 |
private function record_term_representation_change( int $term_id ): void { |
| 463 |
$term = get_term( $term_id ); |
| 464 |
if ( is_object( $term ) && isset( $term->taxonomy ) ) { |
| 465 |
$this->record_term_change( $term_id, (string) $term->taxonomy, 'update' ); |
| 466 |
} |
| 467 |
} |
| 468 |
|
| 469 |
public function record_tax_rate_created( int $tax_rate_id ): void { |
| 470 |
$this->record( 'tax_rate', $tax_rate_id, false ); |
| 471 |
} |
| 472 |
|
| 473 |
public function record_tax_rate_updated( int $tax_rate_id ): void { |
| 474 |
$this->record( 'tax_rate', $tax_rate_id, false ); |
| 475 |
} |
| 476 |
|
| 477 |
public function record_tax_rate_deleted( int $tax_rate_id ): void { |
| 478 |
$this->record( 'tax_rate', $tax_rate_id, true ); |
| 479 |
} |
| 480 |
|
| 481 |
public function record_customer_created( int $customer_id ): void { |
| 482 |
$this->record_customer( $customer_id, false, true, 'create' ); |
| 483 |
} |
| 484 |
|
| 485 |
/** WooCommerce create hooks for any user — definitive post-persist create with persisted dedup. */ |
| 486 |
public function record_customer_created_persisted( int $customer_id ): void { |
| 487 |
$this->record_customer( $customer_id, false, false, 'create' ); |
| 488 |
} |
| 489 |
|
| 490 |
/** Record the definitive post-persist customer update. */ |
| 491 |
public function record_customer_updated( int $customer_id ): void { |
| 492 |
$this->record_customer( $customer_id, false, false ); |
| 493 |
} |
| 494 |
|
| 495 |
/** Record a WordPress profile update as a customer update. */ |
| 496 |
public function record_customer_profile_update( int $user_id, $old_user_data = null ): void { |
| 497 |
$this->record_customer( $user_id, false ); |
| 498 |
} |
| 499 |
|
| 500 |
/** Record a customer role replacement. */ |
| 501 |
public function record_customer_role_change( int $user_id, $role = '', $old_roles = array() ): void { |
| 502 |
$this->record_customer( $user_id, false ); |
| 503 |
} |
| 504 |
|
| 505 |
/** add_user_role handler — any added role changes the served customer record. */ |
| 506 |
public function record_customer_role_added( int $user_id, $role = '' ): void { |
| 507 |
$this->record_customer( $user_id, false ); |
| 508 |
} |
| 509 |
|
| 510 |
/** Record a removed customer role as a present update. */ |
| 511 |
public function record_customer_role_removed( int $user_id, $role = '' ): void { |
| 512 |
$this->record_customer( $user_id, false ); |
| 513 |
} |
| 514 |
|
| 515 |
/** delete_user handler — deletion removes any user from the POS customer space. */ |
| 516 |
public function record_customer_deleted( int $customer_id ): void { |
| 517 |
$this->record_customer( $customer_id, true, true, 'delete' ); |
| 518 |
} |
| 519 |
|
| 520 |
public function record_order_created( int $order_id ): void { |
| 521 |
$this->record_order_change( $order_id, 'hook:create', false ); |
| 522 |
} |
| 523 |
|
| 524 |
/** |
| 525 |
* Mark an order's `hook:update` row as owed; the row lands on flush. |
| 526 |
* |
| 527 |
* See {@see $pending_updates} for why this is deferred. Direct callers |
| 528 |
* that need an immediate row use {@see record_order_change()}. |
| 529 |
* |
| 530 |
* @param int $order_id Order id from the hook. |
| 531 |
* @param \WC_Abstract_Order|mixed $order Order object from the hook (second |
| 532 |
* argument of `woocommerce_update_order`), |
| 533 |
* or anything else to fall back to a |
| 534 |
* refetch at flush time. |
| 535 |
*/ |
| 536 |
public function record_order_updated( int $order_id, $order = null ): void { |
| 537 |
$order = $order instanceof \WC_Abstract_Order ? $order : null; |
| 538 |
$this->queue()->owe( 'order', $order_id, $order ); |
| 539 |
} |
| 540 |
|
| 541 |
/** |
| 542 |
* Write the owed `hook:update` row, if any. |
| 543 |
* |
| 544 |
* Called from {@see record_order_change()} before any other-origin row and |
| 545 |
* from the shutdown flush. Safe to call repeatedly: a flushed order is no |
| 546 |
* longer pending. |
| 547 |
*/ |
| 548 |
public function flush_pending_order_updates(): void { |
| 549 |
if ( null !== self::$pending_updates ) { |
| 550 |
self::$pending_updates->flush(); |
| 551 |
} |
| 552 |
} |
| 553 |
|
| 554 |
/** |
| 555 |
* The `shutdown` callback: flush, then write every later update immediately. |
| 556 |
*/ |
| 557 |
public function flush_pending_order_updates_at_shutdown(): void { |
| 558 |
$this->queue()->flush_at_shutdown(); |
| 559 |
} |
| 560 |
|
| 561 |
/** |
| 562 |
* Discard per-request coalescing state. Tests only: the PHPUnit process |
| 563 |
* never reaches `shutdown`, so the static queue would leak between |
| 564 |
* test cases otherwise. |
| 565 |
* |
| 566 |
* @internal |
| 567 |
*/ |
| 568 |
public static function reset_request_state(): void { |
| 569 |
self::$pending_updates = null; |
| 570 |
} |
| 571 |
|
| 572 |
/** Bind the first journal instance to the request's shared queue. */ |
| 573 |
private function queue(): Request_Write_Queue { |
| 574 |
if ( null === self::$pending_updates ) { |
| 575 |
self::$pending_updates = new Request_Write_Queue( |
| 576 |
1, |
| 577 |
function ( $type, $id, $order ): void { |
| 578 |
$this->record_order_change( $id, 'hook:update', false, $order ); |
| 579 |
} |
| 580 |
); |
| 581 |
} |
| 582 |
return self::$pending_updates; |
| 583 |
} |
| 584 |
|
| 585 |
public function record_order_deleted( int $order_id ): void { |
| 586 |
$this->record_order_change( $order_id, 'hook:delete', true ); |
| 587 |
} |
| 588 |
|
| 589 |
public function record_order_untrashed( int $order_id ): void { |
| 590 |
$this->record_order_change( $order_id, 'hook:untrash', false ); |
| 591 |
} |
| 592 |
|
| 593 |
/** |
| 594 |
* Record an HPOS order's restore once the status change has settled. |
| 595 |
* |
| 596 |
* `woocommerce_untrash_order` fires BEFORE the data store restores the |
| 597 |
* status, so the row cannot be written there. The restore then performs |
| 598 |
* MORE THAN ONE object save, so the journal row's modified_gmt must be read |
| 599 |
* from the SETTLED order for checkpoint ordering. The revision is computed |
| 600 |
* at pull time rather than stored here. |
| 601 |
* |
| 602 |
* Measured sequence for an HPOS untrash (status read from wc_orders): |
| 603 |
* |
| 604 |
* woocommerce_untrash_order stored=trash |
| 605 |
* after_order_object_save object=pending stored=wc-pending |
| 606 |
* after_order_object_save object=pending stored=wc-pending |
| 607 |
* woocommerce_order_status_changed stored=wc-pending from=trash |
| 608 |
* |
| 609 |
* `woocommerce_order_status_changed` fires once, last, with the stored |
| 610 |
* status settled — so observe that instead. CPT orders never reach here: |
| 611 |
* their restore fires only `untrashed_post` (see record_post_untrashed). |
| 612 |
* |
| 613 |
* @param int $order_id Order being restored. |
| 614 |
*/ |
| 615 |
public function record_cot_order_untrashed( int $order_id ): void { |
| 616 |
$handler = function ( $id, $from ) use ( $order_id, &$handler ): void { |
| 617 |
if ( (int) $id !== $order_id || 'trash' !== $from ) { |
| 618 |
return; |
| 619 |
} |
| 620 |
remove_action( 'woocommerce_order_status_changed', $handler ); |
| 621 |
$this->record_order_untrashed( $order_id ); |
| 622 |
}; |
| 623 |
add_action( 'woocommerce_order_status_changed', $handler, 10, 2 ); |
| 624 |
} |
| 625 |
|
| 626 |
/** |
| 627 |
* Append one order row immediately. |
| 628 |
* |
| 629 |
* @param int $order_id Order id. |
| 630 |
* @param string $origin Row origin (`hook:create`, `hook:update`, …). |
| 631 |
* @param bool $deleted Whether the row is a tombstone. |
| 632 |
* @param \WC_Abstract_Order|mixed $order The order object when the caller already holds it; |
| 633 |
* anything else triggers a refetch. |
| 634 |
* |
| 635 |
* @return bool Whether the insert succeeded. |
| 636 |
*/ |
| 637 |
public function record_order_change( int $order_id, string $origin, bool $deleted, $order = null ): bool { |
| 638 |
global $wpdb; |
| 639 |
if ( 'hook:update' !== $origin ) { |
| 640 |
if ( 'hook:create' === $origin && $this->queue()->owes( 'order', $order_id ) ) { |
| 641 |
// The Store API saves a checkout-draft several times BEFORE |
| 642 |
// `woocommerce_new_order` fires. Both rows would point at the same |
| 643 |
// live record, so the create row makes the owed update row redundant. |
| 644 |
$this->queue()->drop( 'order', $order_id ); |
| 645 |
} else { |
| 646 |
// Land the owed update row FIRST so the stream never reads as |
| 647 |
// delete-then-update (a replay would resurrect a trashed order). |
| 648 |
$this->flush_pending_order_updates(); |
| 649 |
} |
| 650 |
} |
| 651 |
if ( ! $order instanceof \WC_Abstract_Order ) { |
| 652 |
$order = wc_get_order( $order_id ); |
| 653 |
} |
| 654 |
$modified_date = $order ? $order->get_date_modified() : null; |
| 655 |
$modified = $modified_date ? gmdate( 'Y-m-d H:i:s', $modified_date->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' ); |
| 656 |
// Order revisions are computed at pull time from the served payload (ADR 0033, |
| 657 |
// #1746) — an order journal row is a change pointer, not a content stamp. |
| 658 |
// 'deleted' is kept for wire compatibility (it flows into served checkpoints) |
| 659 |
// and diagnostics; the planner branches on the `deleted` flag, not this value. |
| 660 |
$revision = $deleted ? 'deleted' : ''; |
| 661 |
|
| 662 |
$now = gmdate( 'Y-m-d H:i:s' ); |
| 663 |
return false !== $wpdb->insert( |
| 664 |
$this->table_name(), |
| 665 |
array( |
| 666 |
'object_type' => 'order', |
| 667 |
'object_id' => $order_id, |
| 668 |
'deleted' => $deleted ? 1 : 0, |
| 669 |
'revision' => $revision, |
| 670 |
'modified_gmt' => $modified, |
| 671 |
'origin' => $origin, |
| 672 |
'created_gmt' => $now, |
| 673 |
), |
| 674 |
array( '%s', '%d', '%d', '%s', '%s', '%s', '%s' ) |
| 675 |
); |
| 676 |
} |
| 677 |
|
| 678 |
private function record_catalogue_object( string $object_type, int $object_id, bool $deleted ): void { |
| 679 |
$object = function_exists( 'wc_get_product' ) ? wc_get_product( $object_id ) : null; |
| 680 |
$this->record( $object_type, $object_id, $deleted, self::object_revision( $object ) ); |
| 681 |
} |
| 682 |
|
| 683 |
private function record_customer( int $customer_id, bool $deleted, bool $dedup = true, string $dedup_namespace = 'update' ): void { |
| 684 |
$customer = class_exists( '\\WC_Customer' ) ? new \WC_Customer( $customer_id ) : null; |
| 685 |
$this->record( 'customer', $customer_id, $deleted, self::object_revision( $customer ), 'hook', $dedup, $dedup_namespace ); |
| 686 |
} |
| 687 |
|
| 688 |
private static function object_revision( $object ): string { |
| 689 |
$date = is_object( $object ) && method_exists( $object, 'get_date_modified' ) ? $object->get_date_modified() : null; |
| 690 |
return $date && method_exists( $date, 'getTimestamp' ) ? gmdate( 'Y-m-d H:i:s', $date->getTimestamp() ) : ''; |
| 691 |
} |
| 692 |
|
| 693 |
public function record( string $object_type, int $object_id, bool $deleted, string $revision = '', string $origin = 'hook', bool $dedup = true, string $dedup_namespace = '' ): void { |
| 694 |
global $wpdb; |
| 695 |
$dedup_key = null; |
| 696 |
if ( 'customer' === $object_type ) { |
| 697 |
$dedup_key = ( $dedup ? '' : 'persisted:' ) . $object_id . ':' . ( '' !== $dedup_namespace ? $dedup_namespace : ( $deleted ? 'delete' : 'update' ) ); |
| 698 |
if ( isset( $this->recorded_this_request[ $dedup_key ] ) ) { |
| 699 |
if ( $dedup ) { |
| 700 |
return; // a pre-persist duplicate carries no new state |
| 701 |
} |
| 702 |
$wpdb->delete( |
| 703 |
$this->table_name(), |
| 704 |
array( 'sequence' => (int) $this->recorded_this_request[ $dedup_key ] ), |
| 705 |
array( '%d' ) |
| 706 |
); |
| 707 |
} |
| 708 |
} |
| 709 |
$started = microtime( true ); |
| 710 |
$now = gmdate( 'Y-m-d H:i:s' ); |
| 711 |
$wpdb->insert( |
| 712 |
$this->table_name(), |
| 713 |
array( |
| 714 |
'object_type' => $object_type, |
| 715 |
'object_id' => $object_id, |
| 716 |
'deleted' => $deleted ? 1 : 0, |
| 717 |
'revision' => $revision, |
| 718 |
'modified_gmt' => $now, |
| 719 |
'origin' => $origin, |
| 720 |
'created_gmt' => $now, |
| 721 |
), |
| 722 |
array( '%s', '%d', '%d', '%s', '%s', '%s', '%s' ) |
| 723 |
); |
| 724 |
if ( null !== $dedup_key ) { |
| 725 |
$this->recorded_this_request[ $dedup_key ] = $dedup ? true : (int) $wpdb->insert_id; |
| 726 |
} |
| 727 |
self::$request_write_ms += ( microtime( true ) - $started ) * 1000; |
| 728 |
} |
| 729 |
|
| 730 |
/** Return the persisted order-backfill cursor. */ |
| 731 |
public function backfill_status(): array { |
| 732 |
$status = get_option( self::BACKFILL_OPTION, array() ); |
| 733 |
$status = is_array( $status ) ? $status : array(); |
| 734 |
|
| 735 |
return array( |
| 736 |
'status' => isset( $status['status'] ) ? (string) $status['status'] : 'idle', |
| 737 |
'nextPage' => isset( $status['nextPage'] ) ? max( 1, (int) $status['nextPage'] ) : 1, |
| 738 |
'pageSize' => isset( $status['pageSize'] ) ? max( 1, min( 250, (int) $status['pageSize'] ) ) : null, |
| 739 |
'processed' => isset( $status['processed'] ) ? max( 0, (int) $status['processed'] ) : 0, |
| 740 |
'lastOrderId' => isset( $status['lastOrderId'] ) ? max( 0, (int) $status['lastOrderId'] ) : 0, |
| 741 |
'lastRunGmt' => isset( $status['lastRunGmt'] ) ? (string) $status['lastRunGmt'] : null, |
| 742 |
); |
| 743 |
} |
| 744 |
|
| 745 |
/** Clear the full persisted order-backfill cursor. */ |
| 746 |
public function reset_backfill_state(): void { |
| 747 |
delete_option( self::BACKFILL_OPTION ); |
| 748 |
} |
| 749 |
|
| 750 |
/** Append one bounded page of existing orders to the journal. */ |
| 751 |
public function run_backfill_chunk( int $limit ): array { |
| 752 |
$requested_limit = max( 1, min( 250, $limit ) ); |
| 753 |
$status = $this->backfill_status(); |
| 754 |
if ( 'complete' === $status['status'] ) { |
| 755 |
return array_merge( $status, array( 'processedThisRun' => 0 ) ); |
| 756 |
} |
| 757 |
|
| 758 |
$page_size = null === $status['pageSize'] ? $requested_limit : (int) $status['pageSize']; |
| 759 |
$last_order_id = $status['lastOrderId']; |
| 760 |
$query_args = array( |
| 761 |
'type' => 'shop_order', |
| 762 |
'limit' => $page_size, |
| 763 |
'orderby' => 'ID', |
| 764 |
'order' => 'ASC', |
| 765 |
'return' => 'ids', |
| 766 |
); |
| 767 |
$posts_where = null; |
| 768 |
if ( $last_order_id > 0 ) { |
| 769 |
if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) { |
| 770 |
$query_args['field_query'] = array( |
| 771 |
array( |
| 772 |
'field' => 'id', |
| 773 |
'value' => $last_order_id, |
| 774 |
'compare' => '>', |
| 775 |
), |
| 776 |
); |
| 777 |
} else { |
| 778 |
$posts_where = static function ( string $where ) use ( $last_order_id ): string { |
| 779 |
global $wpdb; |
| 780 |
return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $last_order_id ); |
| 781 |
}; |
| 782 |
add_filter( 'posts_where', $posts_where ); |
| 783 |
} |
| 784 |
} |
| 785 |
try { |
| 786 |
/** @var array<int|numeric-string>|mixed $queried_ids The 'return' => 'ids' arg yields ids; the stub over-narrows to WC_Order[]. */ |
| 787 |
$queried_ids = wc_get_orders( $query_args ); |
| 788 |
} finally { |
| 789 |
if ( null !== $posts_where ) { |
| 790 |
remove_filter( 'posts_where', $posts_where ); |
| 791 |
} |
| 792 |
} |
| 793 |
$ids = is_array( $queried_ids ) ? array_map( 'absint', $queried_ids ) : array(); |
| 794 |
$processed_this_run = 0; |
| 795 |
$failed_this_run = 0; |
| 796 |
foreach ( $ids as $id ) { |
| 797 |
if ( $this->record_order_change( $id, 'backfill', false ) ) { |
| 798 |
$processed_this_run++; |
| 799 |
$last_order_id = $id; |
| 800 |
} else { |
| 801 |
$failed_this_run++; |
| 802 |
break; |
| 803 |
} |
| 804 |
} |
| 805 |
|
| 806 |
$all_writes_succeeded = 0 === $failed_this_run; |
| 807 |
$complete = $all_writes_succeeded && count( $ids ) < $page_size; |
| 808 |
$advance_page = $all_writes_succeeded && count( $ids ) === $page_size; |
| 809 |
$next_status = array( |
| 810 |
'status' => $complete ? 'complete' : 'running', |
| 811 |
'nextPage' => $advance_page ? $status['nextPage'] + 1 : $status['nextPage'], |
| 812 |
'pageSize' => $page_size, |
| 813 |
'processed' => $status['processed'] + $processed_this_run, |
| 814 |
'lastOrderId' => $last_order_id, |
| 815 |
'lastRunGmt' => gmdate( 'c' ), |
| 816 |
); |
| 817 |
update_option( self::BACKFILL_OPTION, $next_status, false ); |
| 818 |
|
| 819 |
return array_merge( $next_status, array( 'processedThisRun' => $processed_this_run ) ); |
| 820 |
} |
| 821 |
|
| 822 |
/** |
| 823 |
* The catalogue pointer-stream's object types, projected from the registry — |
| 824 |
* every journal-covered collection except orders (which consume the journal |
| 825 |
* via the payload-windowed pull lane). Single source for the sequence-log |
| 826 |
* `all` stream and the purge's per-stream head protection. |
| 827 |
* |
| 828 |
* @return string[] |
| 829 |
*/ |
| 830 |
public static function catalogue_object_types(): array { |
| 831 |
$types = array(); |
| 832 |
foreach ( Collections::with( 'journal' ) as $row ) { |
| 833 |
$object_type = (string) ( $row['journal']['object_type'] ?? '' ); |
| 834 |
if ( '' !== $object_type && 'order' !== $object_type ) { |
| 835 |
$types[] = $object_type; |
| 836 |
} |
| 837 |
} |
| 838 |
|
| 839 |
return $types; |
| 840 |
} |
| 841 |
|
| 842 |
/** |
| 843 |
* Head of the sequence space — the change stream's current end. |
| 844 |
* |
| 845 |
* With `$object_types`, the head is STREAM-SCOPED: the highest sequence any |
| 846 |
* row of those types holds. Every reader must serve the head of the stream |
| 847 |
* it serves — orders and catalogue share one AUTO_INCREMENT space, so the |
| 848 |
* global head moves on foreign writes. A stream-scoped head is what lets a |
| 849 |
* cursor actually reach `head` (the 304 idle condition) while the other |
| 850 |
* lane keeps writing, and is the pre-unification semantic of both lanes. |
| 851 |
* |
| 852 |
* @param string[] $object_types Empty = global head (retention clamps only). |
| 853 |
*/ |
| 854 |
public function head_sequence( array $object_types = array() ): int { |
| 855 |
global $wpdb; |
| 856 |
if ( ! $this->table_available() ) { |
| 857 |
return 0; |
| 858 |
} |
| 859 |
|
| 860 |
$types = array_values( array_filter( array_map( 'strval', $object_types ), static fn( string $t ): bool => '' !== $t ) ); |
| 861 |
if ( array() === $types ) { |
| 862 |
return (int) $wpdb->get_var( 'SELECT MAX(sequence) FROM ' . $this->table_name() ); |
| 863 |
} |
| 864 |
|
| 865 |
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); |
| 866 |
|
| 867 |
return (int) $wpdb->get_var( |
| 868 |
$wpdb->prepare( |
| 869 |
'SELECT MAX(sequence) FROM ' . $this->table_name() . " WHERE object_type IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %s placeholder list generated from count(). |
| 870 |
...$types |
| 871 |
) |
| 872 |
); |
| 873 |
} |
| 874 |
|
| 875 |
public function table_available(): bool { |
| 876 |
global $wpdb; |
| 877 |
$table = $this->table_name(); |
| 878 |
return $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); |
| 879 |
} |
| 880 |
|
| 881 |
/** Return one sequence page for the order pull lane. */ |
| 882 |
public function rows_after_sequence( int $sequence, int $limit, string $object_type = 'order' ): array { |
| 883 |
global $wpdb; |
| 884 |
if ( ! $this->table_available() ) { |
| 885 |
return array(); |
| 886 |
} |
| 887 |
|
| 888 |
$rows = $wpdb->get_results( |
| 889 |
$wpdb->prepare( |
| 890 |
'SELECT sequence, object_id AS order_id, modified_gmt, revision, deleted, origin, created_gmt FROM ' . $this->table_name() |
| 891 |
. ' WHERE object_type = %s AND sequence > %d ORDER BY sequence ASC LIMIT %d', |
| 892 |
$object_type, |
| 893 |
max( 0, $sequence ), |
| 894 |
max( 1, min( 251, $limit ) ) |
| 895 |
), |
| 896 |
ARRAY_A |
| 897 |
); |
| 898 |
|
| 899 |
return is_array( $rows ) ? array_map( array( self::class, 'normalize_order_row' ), $rows ) : array(); |
| 900 |
} |
| 901 |
|
| 902 |
private static function normalize_order_row( array $row ): array { |
| 903 |
return array( |
| 904 |
'sequence' => isset( $row['sequence'] ) ? (int) $row['sequence'] : 0, |
| 905 |
'order_id' => isset( $row['order_id'] ) ? (int) $row['order_id'] : 0, |
| 906 |
'modified_gmt' => isset( $row['modified_gmt'] ) ? (string) $row['modified_gmt'] : gmdate( 'Y-m-d H:i:s' ), |
| 907 |
'revision' => isset( $row['revision'] ) ? (string) $row['revision'] : '', |
| 908 |
'deleted' => ! empty( $row['deleted'] ) ? 1 : 0, |
| 909 |
'origin' => isset( $row['origin'] ) ? (string) $row['origin'] : 'hook:update', |
| 910 |
'created_gmt' => isset( $row['created_gmt'] ) ? (string) $row['created_gmt'] : gmdate( 'Y-m-d H:i:s' ), |
| 911 |
); |
| 912 |
} |
| 913 |
|
| 914 |
/** Oldest sequence that remains available to incremental-sync clients. */ |
| 915 |
public function oldest_sequence(): int { |
| 916 |
global $wpdb; |
| 917 |
|
| 918 |
return (int) $wpdb->get_var( 'SELECT MIN(sequence) FROM ' . $this->table_name() ); |
| 919 |
} |
| 920 |
|
| 921 |
/** Delete one batch of superseded rows through the supplied sequence. */ |
| 922 |
public function compact( int $cutoff_sequence, string $cutoff_gmt, int $batch ): int { |
| 923 |
global $wpdb; |
| 924 |
if ( $cutoff_sequence <= 0 || $batch <= 0 ) { |
| 925 |
return 0; |
| 926 |
} |
| 927 |
|
| 928 |
$table = $this->table_name(); |
| 929 |
$deleted = $wpdb->query( |
| 930 |
$wpdb->prepare( |
| 931 |
'DELETE FROM ' . $table . ' WHERE sequence <= %d AND sequence IN (' |
| 932 |
. ' SELECT sequence FROM (' |
| 933 |
. ' SELECT stale.sequence FROM ' . $table . ' stale' |
| 934 |
. ' WHERE stale.sequence <= %d' |
| 935 |
. ' AND stale.created_gmt < %s' |
| 936 |
. ' AND EXISTS (' |
| 937 |
. ' SELECT 1 FROM ' . $table . ' newer' |
| 938 |
. ' WHERE newer.object_type = stale.object_type' |
| 939 |
. ' AND newer.object_id = stale.object_id' |
| 940 |
. ' AND newer.sequence > stale.sequence' |
| 941 |
. ' ) ORDER BY stale.sequence ASC LIMIT %d' |
| 942 |
. ' ) compactable' |
| 943 |
. ' )', |
| 944 |
$cutoff_sequence, |
| 945 |
$cutoff_sequence, |
| 946 |
$cutoff_gmt, |
| 947 |
$batch |
| 948 |
) |
| 949 |
); |
| 950 |
|
| 951 |
return false === $deleted ? 0 : (int) $deleted; |
| 952 |
} |
| 953 |
|
| 954 |
/** |
| 955 |
* Delete one batch of expired tombstones — the log's only LOSSY deletion. |
| 956 |
* |
| 957 |
* @param int $cutoff_sequence Highest sequence this batch may remove. |
| 958 |
* @param string $cutoff_gmt Rows created before this are expired. |
| 959 |
* @param int $batch Maximum rows to remove. |
| 960 |
* @param array $object_types Restrict to one stream's types; empty = every type. |
| 961 |
*/ |
| 962 |
public function prune_tombstones( int $cutoff_sequence, string $cutoff_gmt, int $batch, array $object_types = array() ): array { |
| 963 |
global $wpdb; |
| 964 |
$none = array( |
| 965 |
'deleted' => 0, |
| 966 |
'watermark' => 0, |
| 967 |
); |
| 968 |
if ( $cutoff_sequence <= 0 || $batch <= 0 ) { |
| 969 |
return $none; |
| 970 |
} |
| 971 |
|
| 972 |
// Each stream is pruned under its OWN cutoff (Sync_Journal_Purge), so the |
| 973 |
// batch must not reach across streams: an order tombstone is not eligible |
| 974 |
// merely because the catalogue's cutoff cleared it. |
| 975 |
$types = array_values( array_unique( array_filter( array_map( 'strval', $object_types ), static fn( string $type ): bool => '' !== $type ) ) ); |
| 976 |
$type_where = ''; |
| 977 |
$type_args = array(); |
| 978 |
if ( array() !== $types ) { |
| 979 |
$type_where = ' AND object_type IN (' . implode( ',', array_fill( 0, \count( $types ), '%s' ) ) . ')'; |
| 980 |
$type_args = $types; |
| 981 |
} |
| 982 |
|
| 983 |
$rows = $wpdb->get_results( |
| 984 |
$wpdb->prepare( |
| 985 |
'SELECT sequence, object_type FROM ' . $this->table_name() |
| 986 |
. ' WHERE deleted = 1 AND sequence <= %d AND created_gmt < %s' . $type_where // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %s placeholder list generated from count(). |
| 987 |
. ' ORDER BY sequence ASC LIMIT %d', |
| 988 |
$cutoff_sequence, |
| 989 |
$cutoff_gmt, |
| 990 |
...array_merge( $type_args, array( $batch ) ) |
| 991 |
), |
| 992 |
ARRAY_A |
| 993 |
); |
| 994 |
if ( empty( $rows ) ) { |
| 995 |
return $none; |
| 996 |
} |
| 997 |
|
| 998 |
// Each pruned row raises ONLY its own object type's watermark: a client |
| 999 |
// reading a stream that never held these rows has missed nothing. |
| 1000 |
$sequences = array(); |
| 1001 |
$per_type = array(); |
| 1002 |
foreach ( $rows as $row ) { |
| 1003 |
$sequence = (int) $row['sequence']; |
| 1004 |
$object_type = (string) $row['object_type']; |
| 1005 |
$sequences[] = $sequence; |
| 1006 |
$per_type[ $object_type ] = max( $per_type[ $object_type ] ?? 0, $sequence ); |
| 1007 |
} |
| 1008 |
$watermark = max( $sequences ); |
| 1009 |
|
| 1010 |
// Publish every watermark BEFORE deleting anything. A row deleted while |
| 1011 |
// its type's horizon still reads below it is silently lost history — no |
| 1012 |
// client would ever learn to reconcile it — so a failed write aborts the |
| 1013 |
// whole batch and the next run retries it. |
| 1014 |
foreach ( $per_type as $object_type => $sequence ) { |
| 1015 |
$this->advance_prune_watermark( $object_type, $sequence ); |
| 1016 |
if ( $this->prune_watermark( array( $object_type ) ) < $sequence ) { |
| 1017 |
return $none; |
| 1018 |
} |
| 1019 |
} |
| 1020 |
|
| 1021 |
$deleted = $wpdb->query( |
| 1022 |
'DELETE FROM ' . $this->table_name() |
| 1023 |
. ' WHERE sequence IN (' . implode( ',', $sequences ) . ')' |
| 1024 |
); |
| 1025 |
|
| 1026 |
return array( |
| 1027 |
'deleted' => false === $deleted ? 0 : (int) $deleted, |
| 1028 |
'watermark' => $watermark, |
| 1029 |
); |
| 1030 |
} |
| 1031 |
|
| 1032 |
/** Resolve a wall-clock cutoff to one stable sequence boundary. */ |
| 1033 |
public function sequence_at_or_before( string $cutoff_gmt ): int { |
| 1034 |
global $wpdb; |
| 1035 |
|
| 1036 |
return (int) $wpdb->get_var( |
| 1037 |
$wpdb->prepare( |
| 1038 |
'SELECT MAX(sequence) FROM ' . $this->table_name() . ' WHERE created_gmt < %s', |
| 1039 |
$cutoff_gmt |
| 1040 |
) |
| 1041 |
); |
| 1042 |
} |
| 1043 |
|
| 1044 |
/** Option holding one object type's lossy-prune watermark. */ |
| 1045 |
public static function prune_watermark_option( string $object_type ): string { |
| 1046 |
return self::PRUNE_WATERMARK_OPTION_PREFIX . $object_type; |
| 1047 |
} |
| 1048 |
|
| 1049 |
/** |
| 1050 |
* Highest sequence ever removed by lossy tombstone pruning FROM A STREAM. |
| 1051 |
* |
| 1052 |
* Mirrors head_sequence(): the caller names the object types its stream |
| 1053 |
* serves and gets that stream's boundary. An empty list means every |
| 1054 |
* registered type — the whole journal's boundary. |
| 1055 |
* |
| 1056 |
* @param array $object_types Object types the reading stream serves. |
| 1057 |
*/ |
| 1058 |
public function prune_watermark( array $object_types = array() ): int { |
| 1059 |
$watermark = 0; |
| 1060 |
foreach ( self::watermark_object_types( $object_types ) as $object_type ) { |
| 1061 |
$watermark = max( $watermark, (int) get_option( self::prune_watermark_option( $object_type ), 0 ) ); |
| 1062 |
} |
| 1063 |
|
| 1064 |
return $watermark; |
| 1065 |
} |
| 1066 |
|
| 1067 |
/** |
| 1068 |
* Advance one object type's persisted watermark (never moves backwards). |
| 1069 |
* |
| 1070 |
* @param string $object_type Journal object type the pruned rows belonged to. |
| 1071 |
* @param int $sequence Highest sequence pruned for that type. |
| 1072 |
*/ |
| 1073 |
public function advance_prune_watermark( string $object_type, int $sequence ): void { |
| 1074 |
global $wpdb; |
| 1075 |
if ( $sequence <= 0 || '' === $object_type ) { |
| 1076 |
return; |
| 1077 |
} |
| 1078 |
|
| 1079 |
$option = self::prune_watermark_option( $object_type ); |
| 1080 |
$wpdb->query( |
| 1081 |
$wpdb->prepare( |
| 1082 |
'INSERT INTO ' . $wpdb->options . " (option_name, option_value, autoload) VALUES (%s, %d, 'yes')" |
| 1083 |
. ' ON DUPLICATE KEY UPDATE option_value = GREATEST(CAST(option_value AS UNSIGNED), %d)', |
| 1084 |
$option, |
| 1085 |
$sequence, |
| 1086 |
$sequence |
| 1087 |
) |
| 1088 |
); |
| 1089 |
wp_cache_delete( $option, 'options' ); |
| 1090 |
wp_cache_delete( 'alloptions', 'options' ); |
| 1091 |
wp_cache_delete( 'notoptions', 'options' ); |
| 1092 |
} |
| 1093 |
|
| 1094 |
/** Drop every per-type watermark — the journal's history is starting over. */ |
| 1095 |
public static function reset_prune_watermarks(): void { |
| 1096 |
global $wpdb; |
| 1097 |
|
| 1098 |
// Trailing separator trimmed from the LIKE so this also clears the single |
| 1099 |
// pre-stream-scoping watermark a pre-release install may still carry. |
| 1100 |
$names = $wpdb->get_col( |
| 1101 |
$wpdb->prepare( |
| 1102 |
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 1103 |
$wpdb->esc_like( rtrim( self::PRUNE_WATERMARK_OPTION_PREFIX, '_' ) ) . '%' |
| 1104 |
) |
| 1105 |
); |
| 1106 |
foreach ( (array) $names as $name ) { |
| 1107 |
delete_option( (string) $name ); |
| 1108 |
} |
| 1109 |
} |
| 1110 |
|
| 1111 |
/** |
| 1112 |
* Object types a watermark read covers: the named stream, or all of them. |
| 1113 |
* |
| 1114 |
* @param array $object_types Object types named by the caller. |
| 1115 |
*/ |
| 1116 |
private static function watermark_object_types( array $object_types ): array { |
| 1117 |
$types = array_values( |
| 1118 |
array_filter( |
| 1119 |
array_map( 'strval', $object_types ), |
| 1120 |
static fn( string $type ): bool => '' !== $type |
| 1121 |
) |
| 1122 |
); |
| 1123 |
if ( array() !== $types ) { |
| 1124 |
return array_unique( $types ); |
| 1125 |
} |
| 1126 |
|
| 1127 |
return array_unique( array_merge( array( 'order' ), self::catalogue_object_types() ) ); |
| 1128 |
} |
| 1129 |
|
| 1130 |
/** One page of the change stream past a cursor, plus the head it was read against. */ |
| 1131 |
public function page( array $object_types, int $since, int $limit ): array { |
| 1132 |
global $wpdb; |
| 1133 |
$types = array(); |
| 1134 |
foreach ( $object_types as $object_type ) { |
| 1135 |
$object_type = (string) $object_type; |
| 1136 |
if ( '' !== $object_type ) { |
| 1137 |
$types[] = $object_type; |
| 1138 |
} |
| 1139 |
} |
| 1140 |
|
| 1141 |
$sql = 'SELECT sequence, object_id, object_type, deleted, revision, modified_gmt FROM ' . $this->table_name() . ' WHERE '; |
| 1142 |
$args = array(); |
| 1143 |
if ( array() !== $types ) { |
| 1144 |
$sql .= 'object_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ') AND '; |
| 1145 |
$args = $types; |
| 1146 |
} |
| 1147 |
$sql .= 'sequence > %d ORDER BY sequence ASC LIMIT %d'; |
| 1148 |
$args[] = max( 0, $since ); |
| 1149 |
$args[] = max( 1, $limit ); |
| 1150 |
|
| 1151 |
$rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$args ), ARRAY_A ); |
| 1152 |
|
| 1153 |
return array( |
| 1154 |
'rows' => array_map( |
| 1155 |
static function ( array $row ): array { |
| 1156 |
return self::normalize_row( $row ); |
| 1157 |
}, |
| 1158 |
is_array( $rows ) ? $rows : array() |
| 1159 |
), |
| 1160 |
'head' => $this->head_sequence( $types ), |
| 1161 |
); |
| 1162 |
} |
| 1163 |
|
| 1164 |
/** Coerce a raw journal row to the served scalar types. */ |
| 1165 |
private static function normalize_row( array $row ): array { |
| 1166 |
return array( |
| 1167 |
'sequence' => isset( $row['sequence'] ) ? (int) $row['sequence'] : 0, |
| 1168 |
'object_id' => isset( $row['object_id'] ) ? (int) $row['object_id'] : 0, |
| 1169 |
'object_type' => isset( $row['object_type'] ) ? (string) $row['object_type'] : '', |
| 1170 |
'deleted' => ! empty( $row['deleted'] ) ? 1 : 0, |
| 1171 |
'revision' => isset( $row['revision'] ) ? (string) $row['revision'] : '', |
| 1172 |
'modified_gmt' => isset( $row['modified_gmt'] ) ? (string) $row['modified_gmt'] : gmdate( 'Y-m-d H:i:s' ), |
| 1173 |
); |
| 1174 |
} |
| 1175 |
} |
| 1176 |
|