| @@ -105,8 +105,89 @@ | ||
| 105 | 105 | return array_keys( Collections::with( 'fingerprint' ) ); |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | 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 | + /** | |
| 109 | 190 | * The canonicalized representation-affecting settings for a collection. |
| 110 | 191 | * DELIBERATELY A SUPERSET: this set must GROW as new representation settings |
| 111 | 192 | * are added, because an omitted setting would silently miss its config |
| 112 | 193 | * change. ksort() is mandatory (ADR 0006 "Canonicalization discipline") — an |
| @@ -122,8 +203,26 @@ | ||
| 122 | 203 | // this array. |
| 123 | 204 | $settings = array(); |
| 124 | 205 | } |
| 125 | 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 | + | |
| 126 | 225 | ksort( $settings ); |
| 127 | 226 | |
| 128 | 227 | return $settings; |
| 129 | 228 | } |
| @@ -128,8 +227,19 @@ | ||
| 128 | 227 | return $settings; |
| 129 | 228 | } |
| 130 | 229 | |
| 131 | 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 | + /** | |
| 132 | 242 | * The per-collection fingerprint — md5 over the canonical settings JSON. |
| 133 | 243 | */ |
| 134 | 244 | public function fingerprint( string $collection ): string { |
| 135 | 245 | return md5( (string) wp_json_encode( $this->representation_settings( $collection ) ) ); |
| @@ -192,11 +302,13 @@ | ||
| 192 | 302 | * |
| 193 | 303 | * Deletes by EXACT key over the known COLLECTIONS rather than a |
| 194 | 304 | * `LIKE 'woocommerce_pos_sync_config_fp_%'` scan: the namespace is a closed set, so the |
| 195 | 305 | * 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. | |
| 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. | |
| 199 | 311 | * |
| 200 | 312 | * Idempotent and correctness-neutral: the endpoint recomputes the fingerprint from |
| 201 | 313 | * live options as the sole source of truth, so removing these rows cannot change a |
| 202 | 314 | * served value. |
| @@ -209,9 +321,12 @@ | ||
| 209 | 321 | foreach ( self::collections() as $collection ) { |
| 210 | 322 | delete_option( self::LEGACY_PROACTIVE_OPTION_PREFIX . $collection ); |
| 211 | 323 | } |
| 212 | 324 | |
| 213 | - update_option( self::CLEANUP_VERSION_OPTION, self::CLEANUP_VERSION, false ); | |
| 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 ); | |
| 214 | 329 | } |
| 215 | 330 | |
| 216 | 331 | /** Collections whose served representation depends on the barcode setting. */ |
| 217 | 332 | /** |