PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Config_Fingerprint.php

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

346 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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