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

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

311 lines 13.8 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 * # Safety against an un-upgraded store
135 *
136 * A store still on the old plugin never moves this value, so its clients see no change and
137 * re-fetch nothing. That is what lets the migration ship with no version gate and no bespoke
138 * purge lane: the failure mode a gate would defend against is structurally absent.
139 *
140 * @var array<string, int>
141 */
142 /**
143 * The contract version every collection starts at, and the value that must NOT appear in a
144 * fingerprint — see representation_settings().
145 *
146 * @var int
147 */
148 private const BASELINE_CONTRACT_VERSION = 1;
149
150 private const PAYLOAD_CONTRACT_VERSION = array(
151 // 2 (1.10.1): variations are serialized through WC_REST_Product_Variations_Controller
152 // instead of the products controller — singular `image`, `wc_get_formatted_variation()`
153 // `name`, and no product-only fields. See the 1.10.1 variations spec, S1/S6.
154 'variations' => 2,
155 'products' => 1,
156 'tax_rates' => 1,
157 );
158
159 /**
160 * The canonicalized representation-affecting settings for a collection.
161 * DELIBERATELY A SUPERSET: this set must GROW as new representation settings
162 * are added, because an omitted setting would silently miss its config
163 * change. ksort() is mandatory (ADR 0006 "Canonicalization discipline") — an
164 * unstable serialization order would change the hash every poll and
165 * false-positive forever.
166 */
167 public function representation_settings( string $collection ): array {
168 if ( \in_array( $collection, self::barcode_collections(), true ) ) {
169 $settings = array( 'barcode_field' => Barcode_Field::meta_key() );
170 } else {
171 // tax_rates (and any non-barcode collection): no representation
172 // setting tracked yet. Still hashed, so adding one later just widens
173 // this array.
174 $settings = array();
175 }
176
177 /*
178 * The served SHAPE is as much a part of the representation as the settings that fill it —
179 * see PAYLOAD_CONTRACT_VERSION.
180 *
181 * Added ONLY above the baseline. Adding it unconditionally would change the serialization
182 * of every collection still at version 1 — `{"barcode_field":"_sku"}` becomes
183 * `{"barcode_field":"_sku","payload_contract":1}`, and tax_rates' `[]` becomes an object —
184 * so their fingerprints would move too. A fingerprint move marks the collection stale, so
185 * upgrading would trigger a full PRODUCTS catalogue re-fetch and a tax-rate refresh on every
186 * active till, for a shape that did not change. Omitting the key at the baseline keeps
187 * version-1 serialization byte-identical, so only a collection whose contract actually moved
188 * is re-pulled. A future bump anywhere makes the key appear, which is itself the change.
189 */
190 $contract = self::payload_contract_version( $collection );
191 if ( self::BASELINE_CONTRACT_VERSION < $contract ) {
192 $settings['payload_contract'] = $contract;
193 }
194
195 ksort( $settings );
196
197 return $settings;
198 }
199
200 /**
201 * This collection's payload contract version. Unknown collections report 1 rather than 0, so a
202 * collection added to the registry without a deliberate entry starts from the same baseline as
203 * every other unbumped one instead of silently reading as "older than everything".
204 *
205 * @param string $collection Collection name.
206 */
207 public static function payload_contract_version( string $collection ): int {
208 return self::PAYLOAD_CONTRACT_VERSION[ $collection ] ?? self::BASELINE_CONTRACT_VERSION;
209 }
210
211 /**
212 * The per-collection fingerprint — md5 over the canonical settings JSON.
213 */
214 public function fingerprint( string $collection ): string {
215 return md5( (string) wp_json_encode( $this->representation_settings( $collection ) ) );
216 }
217
218 /**
219 * The resolved active barcode selectors for a collection: the payload field
220 * name the client indexes (`sku`, `global_unique_id`) for native mappings,
221 * or a `meta_data:<key>` selector for any other configured meta key (#1385
222 * — the proxied `meta_data` carries the value, so the client derives its
223 * local index from the named entry). Empty for collections with no barcode
224 * mapping.
225 */
226 public function barcode_fields( string $collection ): array {
227 if ( ! \in_array( $collection, self::barcode_collections(), true ) ) {
228 return array();
229 }
230 $meta_key = Barcode_Field::meta_key();
231
232 if ( ! \array_key_exists( $meta_key, self::BARCODE_META_TO_PAYLOAD ) ) {
233 // @phpstan-ignore-next-line -- WC_Data_Store forwards this public method to its loaded store.
234 $internal_meta_keys = \WC_Data_Store::load( 'product' )->get_internal_meta_keys();
235
236 return \in_array( $meta_key, $internal_meta_keys, true ) ? array() : array( 'meta_data:' . $meta_key );
237 }
238 $payload_field = self::BARCODE_META_TO_PAYLOAD[ $meta_key ];
239
240 // wc/v3 only serves global_unique_id from WC 9.2 — advertising it on older
241 // versions would tell the client to index a field that never arrives.
242 if ( 'global_unique_id' === $payload_field && \function_exists( 'WC' ) && version_compare( WC()->version, '9.2', '<' ) ) {
243 return array();
244 }
245
246 return array( $payload_field );
247 }
248
249 /**
250 * The endpoint's read model: fingerprints + barcode fields, per collection.
251 */
252 public function snapshot( array $collections ): array {
253 $fingerprints = array();
254 $barcode_fields = array();
255 foreach ( $collections as $collection ) {
256 $collection = (string) $collection;
257 $fingerprints[ $collection ] = $this->fingerprint( $collection );
258 $barcode_fields[ $collection ] = $this->barcode_fields( $collection );
259 }
260
261 return array(
262 'fingerprints' => $fingerprints,
263 'barcode_fields' => $barcode_fields,
264 );
265 }
266
267 /**
268 * One-time sweep of the orphaned proactive-snapshot options. An install that ran
269 * the old settings-save hook still has `woocommerce_pos_sync_config_fp_<collection>` rows
270 * in `wp_options` that NOTHING reads. Deleting the writer left the data behind;
271 * this removes it on the next upgrade.
272 *
273 * Deletes by EXACT key over the known COLLECTIONS rather than a
274 * `LIKE 'woocommerce_pos_sync_config_fp_%'` scan: the namespace is a closed set, so the
275 * exact-key form needs no $wpdb query and cannot collide with a future option that
276 * happens to share the prefix. Only the barcode collections were ever written, but
277 * sweeping the full COLLECTIONS set costs one extra no-op delete and catches a
278 * stray row from any revision.
279 *
280 * Idempotent and correctness-neutral: the endpoint recomputes the fingerprint from
281 * live options as the sole source of truth, so removing these rows cannot change a
282 * served value.
283 */
284 public function maybe_cleanup_legacy_options(): void {
285 if ( (int) get_option( self::CLEANUP_VERSION_OPTION, 0 ) >= self::CLEANUP_VERSION ) {
286 return;
287 }
288
289 foreach ( self::collections() as $collection ) {
290 delete_option( self::LEGACY_PROACTIVE_OPTION_PREFIX . $collection );
291 }
292
293 update_option( self::CLEANUP_VERSION_OPTION, self::CLEANUP_VERSION, false );
294 }
295
296 /** Collections whose served representation depends on the barcode setting. */
297 /**
298 * Registry projection: the collections with barcode representation settings.
299 */
300 private static function barcode_collections(): array {
301 $barcode = array();
302 foreach ( Collections::with( 'fingerprint' ) as $collection => $row ) {
303 if ( $row['fingerprint']['barcode'] ) {
304 $barcode[] = $collection;
305 }
306 }
307
308 return $barcode;
309 }
310 }
311