| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync read surface. |
| 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 |
|
| 14 |
/** |
| 15 |
* Representation-config FINGERPRINT — the fourth change signal ADR 0006 adds to |
| 16 |
* close Scenario 1 (settings-change staleness), the gap ADR 0005's three-tier |
| 17 |
* hybrid is STRUCTURALLY blind to. |
| 18 |
* |
| 19 |
* THE PROBLEM. A global WCPOS/WooCommerce SETTING change alters the *served |
| 20 |
* representation* of MANY records without bumping any record's `date_modified` |
| 21 |
* and without touching any record's storage. The canonical case: an admin flips |
| 22 |
* which meta key is the POS "barcode" field (`_sku` -> `_global_unique_id`). |
| 23 |
* Every product's effective barcode changes, but no product row changes — so |
| 24 |
* TIER 1 sequence-log (no save hook fires), TIER 2 hash-checksum (raw row |
| 25 |
* unchanged) are both blind, and TIER 3 revision-hash would catch it but is |
| 26 |
* never polled (112-142s at 10k). See |
| 27 |
* docs/experiments/config-change-signal-2026-06-17.md. |
| 28 |
* |
| 29 |
* THE SIGNAL. Per collection, a cheap FINGERPRINT = md5 over the canonicalized |
| 30 |
* (sorted-key) set of representation-affecting settings for that collection. The |
| 31 |
* client retains a per-collection baseline and diffs each poll; on a move it |
| 32 |
* marks that collection stale and re-derives (or re-fetches). |
| 33 |
* |
| 34 |
* WHY HASHED FROM LIVE OPTIONS, NOT A HOOK COUNTER. The endpoint recomputes this |
| 35 |
* from the ACTUAL current options on every call, so it is SELF-HEALING: a setting |
| 36 |
* changed by a hook-bypassing path (another plugin's `update_option`, wp-cli, a |
| 37 |
* direct SQL write to `wp_options`) is detected on the next poll just like a |
| 38 |
* hooked one — the property a hook-only counter could not give. Live recompute |
| 39 |
* is the ONLY mechanism; there is no stored snapshot to go stale. |
| 40 |
* |
| 41 |
* ADR 0003 (discovery only): the fingerprint LOCATES that a representation config |
| 42 |
* moved; it never produces a value the POS trusts. The trusted re-derivation runs |
| 43 |
* client-side over already-synced filtered payloads (or a normal filtered |
| 44 |
* re-fetch). `barcode_fields` reports the resolved active PAYLOAD field names so |
| 45 |
* the client can rebuild its local barcode index without a server round-trip. |
| 46 |
* |
| 47 |
* The representation-setting set is a deliberate SUPERSET of every option that |
| 48 |
* changes the served representation: under-scoping would MISS a config change, so |
| 49 |
* the safe direction is to over-include. The set MUST GROW as new |
| 50 |
* representation-affecting settings are added. |
| 51 |
*/ |
| 52 |
final class Config_Fingerprint { |
| 53 |
/** |
| 54 |
* Bump when a new one-time cleanup step is added to maybe_cleanup_legacy_options(). |
| 55 |
* Stored per-site so the sweep runs exactly once per upgrade, not once per request. |
| 56 |
*/ |
| 57 |
public const CLEANUP_VERSION = 1; |
| 58 |
|
| 59 |
/** |
| 60 |
* The latch for the one-time sweep. DELIBERATELY NOT under the |
| 61 |
* LEGACY_PROACTIVE_OPTION_PREFIX namespace: an option that both names the sweep |
| 62 |
* and lives inside the range the sweep deletes would erase its own latch and |
| 63 |
* re-run forever. |
| 64 |
*/ |
| 65 |
public const CLEANUP_VERSION_OPTION = 'woocommerce_pos_sync_config_fingerprint_cleanup_version'; |
| 66 |
|
| 67 |
/** |
| 68 |
* Where the removed PROACTIVE snapshot used to be stored, per collection. The |
| 69 |
* write half is gone (its only reader was deleted first); the rows it left in |
| 70 |
* `wp_options` on existing installs are swept by maybe_cleanup_legacy_options(). |
| 71 |
*/ |
| 72 |
private const LEGACY_PROACTIVE_OPTION_PREFIX = 'woocommerce_pos_sync_config_fp_'; |
| 73 |
|
| 74 |
/** |
| 75 |
* Raw barcode META key -> synced-doc PAYLOAD field name, for the mappings the |
| 76 |
* catalog proxy serves as NATIVE top-level wc/v3 fields. Keys are the values |
| 77 |
* the production barcode setting can hold; values are the top-level payload |
| 78 |
* field names the client indexes against. |
| 79 |
* |
| 80 |
* HONESTY CONSTRAINT (review finding 9): this map may ONLY list a TOP-LEVEL |
| 81 |
* field the sync read surface ACTUALLY serves. That surface is the catalog |
| 82 |
* proxy → raw wc/v3 (Catalog_Proxy_Controller forwards to /wc/v3/products), |
| 83 |
* which emits `sku` and `global_unique_id` natively but NEVER a top-level |
| 84 |
* `barcode` — that stamping lives only on the wcpos/v1 Products_Controller |
| 85 |
* (an override of a DIFFERENT namespace the proxy never dispatches through). |
| 86 |
* |
| 87 |
* A custom meta key has no top-level field, but its value DOES reach the |
| 88 |
* client: proxied responses carry it in `meta_data` (pinned by |
| 89 |
* Test_Catalog_Proxy_Barcode read-parity tests). So a key absent from this |
| 90 |
* map is advertised as a `meta_data:<key>` SELECTOR by barcode_fields() if |
| 91 |
* WooCommerce does not classify it as internal. Internal product properties |
| 92 |
* are excluded from serialized `meta_data`, so their selector list remains |
| 93 |
* empty. |
| 94 |
*/ |
| 95 |
private const BARCODE_META_TO_PAYLOAD = array( |
| 96 |
'_sku' => 'sku', |
| 97 |
'_global_unique_id' => 'global_unique_id', |
| 98 |
); |
| 99 |
|
| 100 |
/** The collections this signal covers, in the engine's vocabulary. */ |
| 101 |
/** |
| 102 |
* Registry projection (#421 increment 8): the fingerprinted collections. |
| 103 |
*/ |
| 104 |
public static function collections(): array { |
| 105 |
return array_keys( Collections::with( 'fingerprint' ) ); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* The PAYLOAD CONTRACT version per collection — bump when the SHAPE of a served record changes. |
| 110 |
* |
| 111 |
* # Why this is a representation setting |
| 112 |
* |
| 113 |
* ADR 0006 built this signal for "a global setting change alters the served representation of |
| 114 |
* MANY records without bumping any record's `date_modified`". A PLUGIN UPGRADE that changes a |
| 115 |
* payload's shape is the same event, and the three tiers are blind to it in exactly the same |
| 116 |
* way: tier 1 writes no journal row (no save hook fires), tier 2's digest is derived from the |
| 117 |
* raw DB row and does not move, and tier 3 is only ever reached from a tier-2 mismatch. Without |
| 118 |
* a signal here, a client that synced a record under the old shape keeps it INDEFINITELY. |
| 119 |
* |
| 120 |
* 1.10.0 shipped variations serialized through the PRODUCTS controller — an `images` array |
| 121 |
* instead of the singular `image`, and `get_name()` (the generated post title, which |
| 122 |
* `generate_product_title()` collapses to just the parent name at 3+ attributes) instead of |
| 123 |
* `wc_get_formatted_variation()`. A client can be taught to read either image shape, but a |
| 124 |
* collapsed name is indistinguishable from a correct one, so tolerance cannot repair it. Only a |
| 125 |
* re-pull can, and only this signal asks for one. |
| 126 |
* |
| 127 |
* # Why a version rather than a hash of the payload |
| 128 |
* |
| 129 |
* The shape is a property of the CODE, not of the store's data or settings, so there is nothing |
| 130 |
* live to recompute it from — the honest form is a constant a human bumps in the same commit |
| 131 |
* that changes the shape. Deliberately NOT an option or a filter: nobody but us can change what |
| 132 |
* we serve (see the constants-not-env-vars rule in the repo's agent context). |
| 133 |
* |
| 134 |
* # Skipped-release caveat for the collections #1756 phase 1 added |
| 135 |
* |
| 136 |
* A client cold-adopts a fingerprint key it has never stored, so a contract bump for one of |
| 137 |
* the six phase-1 collections only reaches tills whose server passed through a release that |
| 138 |
* served the key at the OLD version first — a server upgrade that skips straight past phase 1 |
| 139 |
* cold-adopts at the new version with no re-pull. Between phase 1 and the 1.11.0 protocol |
| 140 |
* gate this is moot (recipe changes are batched AT the gate, whose forced resync covers |
| 141 |
* them); if a pre-gate bump for one of the six is ever needed, it needs a first-seen |
| 142 |
* migration protocol first (#1756 phase 2/3 territory — see the issue). |
| 143 |
* |
| 144 |
* ADR 0036 extends that rule to ANY collection serving-recipe change: serializer shape, digest |
| 145 |
* formula key sets (DIGESTED_META_KEYS / CUSTOMER_DIGESTED_META_KEYS in Digest_Index), or the |
| 146 |
* augmentation set. Bump that collection's version IN THE SAME COMMIT; the fingerprint move is |
| 147 |
* what triggers the client re-pull that a silent formula change never did. |
| 148 |
* |
| 149 |
* # Safety against an un-upgraded store |
| 150 |
* |
| 151 |
* A store still on the old plugin never moves this value, so its clients see no change and |
| 152 |
* re-fetch nothing. That is what lets the migration ship with no version gate and no bespoke |
| 153 |
* purge lane: the failure mode a gate would defend against is structurally absent. |
| 154 |
* |
| 155 |
* @var array<string, int> |
| 156 |
*/ |
| 157 |
/** |
| 158 |
* The contract version every collection starts at, and the value that must NOT appear in a |
| 159 |
* fingerprint — see representation_settings(). |
| 160 |
* |
| 161 |
* @var int |
| 162 |
*/ |
| 163 |
private const BASELINE_CONTRACT_VERSION = 1; |
| 164 |
|
| 165 |
private const PAYLOAD_CONTRACT_VERSION = array( |
| 166 |
// products: product serialization + DIGESTED_META_KEYS formula + barcode augmentation. |
| 167 |
'products' => 1, |
| 168 |
// 2 (1.10.1): variations are serialized through WC_REST_Product_Variations_Controller |
| 169 |
// instead of the products controller — singular `image`, `wc_get_formatted_variation()` |
| 170 |
// `name`, and no product-only fields. See the 1.10.1 variations spec, S1/S6. |
| 171 |
// variations: variation serialization + shared DIGESTED_META_KEYS formula + barcode augmentation. |
| 172 |
'variations' => 2, |
| 173 |
// orders: order serialization + the HPOS/CPT order digest formula. |
| 174 |
'orders' => 1, |
| 175 |
// customers: customer serialization + CUSTOMER_DIGESTED_META_KEYS formula. |
| 176 |
'customers' => 1, |
| 177 |
// categories: product-category term serialization + its augmentation set. |
| 178 |
'categories' => 1, |
| 179 |
// brands: product-brand term serialization + its augmentation set. |
| 180 |
'brands' => 1, |
| 181 |
// tags: product-tag term serialization + its augmentation set. |
| 182 |
'tags' => 1, |
| 183 |
// coupons: coupon serialization + its augmentation set. |
| 184 |
'coupons' => 1, |
| 185 |
// tax_rates: tax-rate serialization + its augmentation set. |
| 186 |
'tax_rates' => 1, |
| 187 |
); |
| 188 |
|
| 189 |
/** |
| 190 |
* The canonicalized representation-affecting settings for a collection. |
| 191 |
* DELIBERATELY A SUPERSET: this set must GROW as new representation settings |
| 192 |
* are added, because an omitted setting would silently miss its config |
| 193 |
* change. ksort() is mandatory (ADR 0006 "Canonicalization discipline") — an |
| 194 |
* unstable serialization order would change the hash every poll and |
| 195 |
* false-positive forever. |
| 196 |
*/ |
| 197 |
public function representation_settings( string $collection ): array { |
| 198 |
if ( \in_array( $collection, self::barcode_collections(), true ) ) { |
| 199 |
$settings = array( 'barcode_field' => Barcode_Field::meta_key() ); |
| 200 |
} else { |
| 201 |
// tax_rates (and any non-barcode collection): no representation |
| 202 |
// setting tracked yet. Still hashed, so adding one later just widens |
| 203 |
// this array. |
| 204 |
$settings = array(); |
| 205 |
} |
| 206 |
|
| 207 |
/* |
| 208 |
* The served SHAPE is as much a part of the representation as the settings that fill it — |
| 209 |
* see PAYLOAD_CONTRACT_VERSION. |
| 210 |
* |
| 211 |
* Added ONLY above the baseline. Adding it unconditionally would change the serialization |
| 212 |
* of every collection still at version 1 — `{"barcode_field":"_sku"}` becomes |
| 213 |
* `{"barcode_field":"_sku","payload_contract":1}`, and tax_rates' `[]` becomes an object — |
| 214 |
* so their fingerprints would move too. A fingerprint move marks the collection stale, so |
| 215 |
* upgrading would trigger a full PRODUCTS catalogue re-fetch and a tax-rate refresh on every |
| 216 |
* active till, for a shape that did not change. Omitting the key at the baseline keeps |
| 217 |
* version-1 serialization byte-identical, so only a collection whose contract actually moved |
| 218 |
* is re-pulled. A future bump anywhere makes the key appear, which is itself the change. |
| 219 |
*/ |
| 220 |
$contract = self::payload_contract_version( $collection ); |
| 221 |
if ( self::BASELINE_CONTRACT_VERSION < $contract ) { |
| 222 |
$settings['payload_contract'] = $contract; |
| 223 |
} |
| 224 |
|
| 225 |
ksort( $settings ); |
| 226 |
|
| 227 |
return $settings; |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* This collection's payload contract version. Unknown collections report 1 rather than 0, so a |
| 232 |
* collection added to the registry without a deliberate entry starts from the same baseline as |
| 233 |
* every other unbumped one instead of silently reading as "older than everything". |
| 234 |
* |
| 235 |
* @param string $collection Collection name. |
| 236 |
*/ |
| 237 |
public static function payload_contract_version( string $collection ): int { |
| 238 |
return self::PAYLOAD_CONTRACT_VERSION[ $collection ] ?? self::BASELINE_CONTRACT_VERSION; |
| 239 |
} |
| 240 |
|
| 241 |
/** |
| 242 |
* The per-collection fingerprint — md5 over the canonical settings JSON. |
| 243 |
*/ |
| 244 |
public function fingerprint( string $collection ): string { |
| 245 |
return md5( (string) wp_json_encode( $this->representation_settings( $collection ) ) ); |
| 246 |
} |
| 247 |
|
| 248 |
/** |
| 249 |
* The resolved active barcode selectors for a collection: the payload field |
| 250 |
* name the client indexes (`sku`, `global_unique_id`) for native mappings, |
| 251 |
* or a `meta_data:<key>` selector for any other configured meta key (#1385 |
| 252 |
* — the proxied `meta_data` carries the value, so the client derives its |
| 253 |
* local index from the named entry). Empty for collections with no barcode |
| 254 |
* mapping. |
| 255 |
*/ |
| 256 |
public function barcode_fields( string $collection ): array { |
| 257 |
if ( ! \in_array( $collection, self::barcode_collections(), true ) ) { |
| 258 |
return array(); |
| 259 |
} |
| 260 |
$meta_key = Barcode_Field::meta_key(); |
| 261 |
|
| 262 |
if ( ! \array_key_exists( $meta_key, self::BARCODE_META_TO_PAYLOAD ) ) { |
| 263 |
// @phpstan-ignore-next-line -- WC_Data_Store forwards this public method to its loaded store. |
| 264 |
$internal_meta_keys = \WC_Data_Store::load( 'product' )->get_internal_meta_keys(); |
| 265 |
|
| 266 |
return \in_array( $meta_key, $internal_meta_keys, true ) ? array() : array( 'meta_data:' . $meta_key ); |
| 267 |
} |
| 268 |
$payload_field = self::BARCODE_META_TO_PAYLOAD[ $meta_key ]; |
| 269 |
|
| 270 |
// wc/v3 only serves global_unique_id from WC 9.2 — advertising it on older |
| 271 |
// versions would tell the client to index a field that never arrives. |
| 272 |
if ( 'global_unique_id' === $payload_field && \function_exists( 'WC' ) && version_compare( WC()->version, '9.2', '<' ) ) { |
| 273 |
return array(); |
| 274 |
} |
| 275 |
|
| 276 |
return array( $payload_field ); |
| 277 |
} |
| 278 |
|
| 279 |
/** |
| 280 |
* The endpoint's read model: fingerprints + barcode fields, per collection. |
| 281 |
*/ |
| 282 |
public function snapshot( array $collections ): array { |
| 283 |
$fingerprints = array(); |
| 284 |
$barcode_fields = array(); |
| 285 |
foreach ( $collections as $collection ) { |
| 286 |
$collection = (string) $collection; |
| 287 |
$fingerprints[ $collection ] = $this->fingerprint( $collection ); |
| 288 |
$barcode_fields[ $collection ] = $this->barcode_fields( $collection ); |
| 289 |
} |
| 290 |
|
| 291 |
return array( |
| 292 |
'fingerprints' => $fingerprints, |
| 293 |
'barcode_fields' => $barcode_fields, |
| 294 |
); |
| 295 |
} |
| 296 |
|
| 297 |
/** |
| 298 |
* One-time sweep of the orphaned proactive-snapshot options. An install that ran |
| 299 |
* the old settings-save hook still has `woocommerce_pos_sync_config_fp_<collection>` rows |
| 300 |
* in `wp_options` that NOTHING reads. Deleting the writer left the data behind; |
| 301 |
* this removes it on the next upgrade. |
| 302 |
* |
| 303 |
* Deletes by EXACT key over the known COLLECTIONS rather than a |
| 304 |
* `LIKE 'woocommerce_pos_sync_config_fp_%'` scan: the namespace is a closed set, so the |
| 305 |
* exact-key form needs no $wpdb query and cannot collide with a future option that |
| 306 |
* happens to share the prefix. Only the barcode collections were ever written, so |
| 307 |
* sweeping the now-universal membership (nine collections since #1756) costs a |
| 308 |
* handful of no-op deletes on a fresh install and nothing on an already-swept one |
| 309 |
* (the CLEANUP_VERSION latch above), while catching a stray row from any revision |
| 310 |
* that DID write one. |
| 311 |
* |
| 312 |
* Idempotent and correctness-neutral: the endpoint recomputes the fingerprint from |
| 313 |
* live options as the sole source of truth, so removing these rows cannot change a |
| 314 |
* served value. |
| 315 |
*/ |
| 316 |
public function maybe_cleanup_legacy_options(): void { |
| 317 |
if ( (int) get_option( self::CLEANUP_VERSION_OPTION, 0 ) >= self::CLEANUP_VERSION ) { |
| 318 |
return; |
| 319 |
} |
| 320 |
|
| 321 |
foreach ( self::collections() as $collection ) { |
| 322 |
delete_option( self::LEGACY_PROACTIVE_OPTION_PREFIX . $collection ); |
| 323 |
} |
| 324 |
|
| 325 |
// Autoloaded: the Init constructor reads this latch on every request. |
| 326 |
// Existing rows from older releases are flipped by |
| 327 |
// Activator::autoload_request_latches() on upgrade. |
| 328 |
update_option( self::CLEANUP_VERSION_OPTION, self::CLEANUP_VERSION, true ); |
| 329 |
} |
| 330 |
|
| 331 |
/** Collections whose served representation depends on the barcode setting. */ |
| 332 |
/** |
| 333 |
* Registry projection: the collections with barcode representation settings. |
| 334 |
*/ |
| 335 |
private static function barcode_collections(): array { |
| 336 |
$barcode = array(); |
| 337 |
foreach ( Collections::with( 'fingerprint' ) as $collection => $row ) { |
| 338 |
if ( $row['fingerprint']['barcode'] ) { |
| 339 |
$barcode[] = $collection; |
| 340 |
} |
| 341 |
} |
| 342 |
|
| 343 |
return $barcode; |
| 344 |
} |
| 345 |
} |
| 346 |
|