PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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.0, at includes/Sync/Config_Fingerprint.php

231 lines 9.7 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 canonicalized representation-affecting settings for a collection.
110 * DELIBERATELY A SUPERSET: this set must GROW as new representation settings
111 * are added, because an omitted setting would silently miss its config
112 * change. ksort() is mandatory (ADR 0006 "Canonicalization discipline") — an
113 * unstable serialization order would change the hash every poll and
114 * false-positive forever.
115 */
116 public function representation_settings( string $collection ): array {
117 if ( \in_array( $collection, self::barcode_collections(), true ) ) {
118 $settings = array( 'barcode_field' => Barcode_Field::meta_key() );
119 } else {
120 // tax_rates (and any non-barcode collection): no representation
121 // setting tracked yet. Still hashed, so adding one later just widens
122 // this array.
123 $settings = array();
124 }
125
126 ksort( $settings );
127
128 return $settings;
129 }
130
131 /**
132 * The per-collection fingerprint — md5 over the canonical settings JSON.
133 */
134 public function fingerprint( string $collection ): string {
135 return md5( (string) wp_json_encode( $this->representation_settings( $collection ) ) );
136 }
137
138 /**
139 * The resolved active barcode selectors for a collection: the payload field
140 * name the client indexes (`sku`, `global_unique_id`) for native mappings,
141 * or a `meta_data:<key>` selector for any other configured meta key (#1385
142 * — the proxied `meta_data` carries the value, so the client derives its
143 * local index from the named entry). Empty for collections with no barcode
144 * mapping.
145 */
146 public function barcode_fields( string $collection ): array {
147 if ( ! \in_array( $collection, self::barcode_collections(), true ) ) {
148 return array();
149 }
150 $meta_key = Barcode_Field::meta_key();
151
152 if ( ! \array_key_exists( $meta_key, self::BARCODE_META_TO_PAYLOAD ) ) {
153 // @phpstan-ignore-next-line -- WC_Data_Store forwards this public method to its loaded store.
154 $internal_meta_keys = \WC_Data_Store::load( 'product' )->get_internal_meta_keys();
155
156 return \in_array( $meta_key, $internal_meta_keys, true ) ? array() : array( 'meta_data:' . $meta_key );
157 }
158 $payload_field = self::BARCODE_META_TO_PAYLOAD[ $meta_key ];
159
160 // wc/v3 only serves global_unique_id from WC 9.2 — advertising it on older
161 // versions would tell the client to index a field that never arrives.
162 if ( 'global_unique_id' === $payload_field && \function_exists( 'WC' ) && version_compare( WC()->version, '9.2', '<' ) ) {
163 return array();
164 }
165
166 return array( $payload_field );
167 }
168
169 /**
170 * The endpoint's read model: fingerprints + barcode fields, per collection.
171 */
172 public function snapshot( array $collections ): array {
173 $fingerprints = array();
174 $barcode_fields = array();
175 foreach ( $collections as $collection ) {
176 $collection = (string) $collection;
177 $fingerprints[ $collection ] = $this->fingerprint( $collection );
178 $barcode_fields[ $collection ] = $this->barcode_fields( $collection );
179 }
180
181 return array(
182 'fingerprints' => $fingerprints,
183 'barcode_fields' => $barcode_fields,
184 );
185 }
186
187 /**
188 * One-time sweep of the orphaned proactive-snapshot options. An install that ran
189 * the old settings-save hook still has `woocommerce_pos_sync_config_fp_<collection>` rows
190 * in `wp_options` that NOTHING reads. Deleting the writer left the data behind;
191 * this removes it on the next upgrade.
192 *
193 * Deletes by EXACT key over the known COLLECTIONS rather than a
194 * `LIKE 'woocommerce_pos_sync_config_fp_%'` scan: the namespace is a closed set, so the
195 * exact-key form needs no $wpdb query and cannot collide with a future option that
196 * happens to share the prefix. Only the barcode collections were ever written, but
197 * sweeping the full COLLECTIONS set costs one extra no-op delete and catches a
198 * stray row from any revision.
199 *
200 * Idempotent and correctness-neutral: the endpoint recomputes the fingerprint from
201 * live options as the sole source of truth, so removing these rows cannot change a
202 * served value.
203 */
204 public function maybe_cleanup_legacy_options(): void {
205 if ( (int) get_option( self::CLEANUP_VERSION_OPTION, 0 ) >= self::CLEANUP_VERSION ) {
206 return;
207 }
208
209 foreach ( self::collections() as $collection ) {
210 delete_option( self::LEGACY_PROACTIVE_OPTION_PREFIX . $collection );
211 }
212
213 update_option( self::CLEANUP_VERSION_OPTION, self::CLEANUP_VERSION, false );
214 }
215
216 /** Collections whose served representation depends on the barcode setting. */
217 /**
218 * Registry projection: the collections with barcode representation settings.
219 */
220 private static function barcode_collections(): array {
221 $barcode = array();
222 foreach ( Collections::with( 'fingerprint' ) as $collection => $row ) {
223 if ( $row['fingerprint']['barcode'] ) {
224 $barcode[] = $collection;
225 }
226 }
227
228 return $barcode;
229 }
230 }
231