| 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 |
|