| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync store component. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 11 |
|
| 12 |
/** |
| 13 |
* THE collection registry (wayfinder #420 spec / #421 implementation): one |
| 14 |
* table for the per-collection identity + capabilities that nine sites |
| 15 |
* currently re-encode in three diverging vocabularies (plural collection, |
| 16 |
* singular object_type, proxy resource slug). Consolidation precedent: |
| 17 |
* trait-endpoint-permissions.php — but a trait shares BEHAVIOUR; this shares |
| 18 |
* DATA, so it is a final class of pure lookups (no WP calls — unit-testable |
| 19 |
* with no bootstrap, like the backfill controller's dispatchers it absorbs). |
| 20 |
* |
| 21 |
* Row shape — capabilities are NULLABLE GROUPS, never booleans: a capability |
| 22 |
* a collection lacks is an ABSENT group, so "does X support Y" is |
| 23 |
* `isset($row['y'])` and a group's fields exist only where they mean |
| 24 |
* something (no empty-string taxonomy on a post collection, no id_type on |
| 25 |
* tax_rates). Groups: |
| 26 |
* |
| 27 |
* - object_type — the singular journal vocabulary (always present). |
| 28 |
* - identity — the uuid id-space: id_type (post|user|term|order), the |
| 29 |
* collection's OWN scalar resolver scope (post_type or |
| 30 |
* taxonomy — what resolve_id_by_uuid gets on a push; NOT |
| 31 |
* the backfill scan scope), the ownership detector and the |
| 32 |
* bulk uuid reader (METHOD NAMES on Pos_Uuid — rows stay |
| 33 |
* pure data; consumers resolve callables), and the |
| 34 |
* load_entity strategy key. null ⇒ no uuid identity |
| 35 |
* (tax_rates, ADR 0009). |
| 36 |
* - proxy — the namespaced read route + wc/v3 route + resource slug |
| 37 |
* (the slug vocabulary trap: tax_rates' slug is `taxes`). |
| 38 |
* - write — push support (the write map's route/id_type projection). |
| 39 |
* - journal — the singular object_type the journal emits; orders consume |
| 40 |
* it through their payload-windowed pull lane. |
| 41 |
* - digest — leg-3 existence digests, present ONLY on the id-space |
| 42 |
* OWNER row (products carries product+variation |
| 43 |
* object_types; a copy on variations would double-project). |
| 44 |
* Carries the stored object_types AND the live-row predicate |
| 45 |
* name on Digest_Index, so the reader, the proxy stamper and |
| 46 |
* the authoritative-absence answer all read one id-space fact |
| 47 |
* instead of each re-deciding it. |
| 48 |
* - fingerprint — UNIVERSAL config-change detection membership; every |
| 49 |
* collection carries it (null is invalid), with the barcode |
| 50 |
* flag naming recipe membership. The contract-version lever |
| 51 |
* itself lives in Config_Fingerprint::PAYLOAD_CONTRACT_VERSION, |
| 52 |
* keyed by these same names. |
| 53 |
* - backfill — uuid backfill support: the meta-store kind (post, order, |
| 54 |
* user, or term) and the SCAN |
| 55 |
* scope (products+variations scan together — which is why |
| 56 |
* scan_post_types lives here and not on identity). |
| 57 |
* |
| 58 |
* Fail-closed lookups: unknown is null, NEVER a default. The |
| 59 |
* default→products collapse in class-changes-controller.php:662 (a missing |
| 60 |
* case pulls a PRODUCT with another record's id) is the bug class |
| 61 |
* by_object_type() exists to kill — its consumers skip-and-log on null. |
| 62 |
*/ |
| 63 |
final class Collections { |
| 64 |
|
| 65 |
/** One row per collection, keyed by the canonical plural name. */ |
| 66 |
private const ROWS = array( |
| 67 |
'products' => array( |
| 68 |
'object_type' => 'product', |
| 69 |
'identity' => array( |
| 70 |
'id_type' => 'post', |
| 71 |
'post_type' => 'product', |
| 72 |
'detector' => 'uuid_owned_by_other', |
| 73 |
'bulk_reader' => 'bulk_read_post_uuids', |
| 74 |
'loader' => 'product', |
| 75 |
), |
| 76 |
'proxy' => array( |
| 77 |
'route' => '/products', |
| 78 |
'wc_route' => '/wc/v3/products', |
| 79 |
'slug' => 'products', |
| 80 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Products_Proxy_Behavior::class, |
| 81 |
), |
| 82 |
'write' => array( 'route' => '/wc/v3/products' ), |
| 83 |
'journal' => array( 'object_type' => 'product' ), |
| 84 |
'digest' => array( |
| 85 |
'id_space' => 'products', |
| 86 |
'object_types' => array( 'product', 'variation' ), |
| 87 |
'live_rows' => 'live_row_exists_sql', |
| 88 |
), |
| 89 |
'fingerprint' => array( 'barcode' => true ), |
| 90 |
'backfill' => array( |
| 91 |
'kind' => 'post', |
| 92 |
'scan_post_types' => array( 'product', 'product_variation' ), |
| 93 |
), |
| 94 |
), |
| 95 |
'variations' => array( |
| 96 |
'object_type' => 'variation', |
| 97 |
'identity' => array( |
| 98 |
'id_type' => 'post', |
| 99 |
'post_type' => 'product_variation', |
| 100 |
'detector' => 'uuid_owned_by_other', |
| 101 |
// No bulk reader: variations are stamped through the |
| 102 |
// serialized-product filter, not a proxy list page. |
| 103 |
'bulk_reader' => null, |
| 104 |
'loader' => 'product', |
| 105 |
), |
| 106 |
// No wcpos proxy lane, and that is principled (ADR 0034): the flat |
| 107 |
// /variations route is both the per-id hydration lane AND the |
| 108 |
// list/seed lane (bare collection pages for the idle trickle). |
| 109 |
'proxy' => null, |
| 110 |
// Variations use WooCommerce's nested REST resource. The write controller |
| 111 |
// takes the parent from create payloads and the stored object thereafter. |
| 112 |
'write' => array( 'route' => '/wc/v3/products' ), |
| 113 |
'journal' => array( 'object_type' => 'variation' ), |
| 114 |
'digest' => null, // folded into the products id-space (owner row carries it) |
| 115 |
'fingerprint' => array( 'barcode' => true ), |
| 116 |
'backfill' => array( |
| 117 |
'kind' => 'post', |
| 118 |
'scan_post_types' => array( 'product', 'product_variation' ), |
| 119 |
), |
| 120 |
), |
| 121 |
'orders' => array( |
| 122 |
'object_type' => 'order', |
| 123 |
'identity' => array( |
| 124 |
'id_type' => 'order', |
| 125 |
'detector' => 'uuid_owned_by_other_order', |
| 126 |
'bulk_reader' => null, // payload mode — uuid read from served meta_data |
| 127 |
'loader' => 'order', |
| 128 |
), |
| 129 |
'proxy' => array( |
| 130 |
'route' => '/orders', |
| 131 |
'wc_route' => '/wc/v3/orders', |
| 132 |
'slug' => 'orders', |
| 133 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Orders_Proxy_Behavior::class, |
| 134 |
), |
| 135 |
'write' => array( 'route' => '/wc/v3/orders' ), |
| 136 |
'journal' => array( 'object_type' => 'order' ), // orders consume the journal via the payload-windowed pull lane, catalogue via the pointer stream |
| 137 |
'digest' => array( |
| 138 |
'id_space' => 'orders', |
| 139 |
'object_types' => array( 'order' ), |
| 140 |
'live_rows' => 'order_live_row_exists_sql', |
| 141 |
), |
| 142 |
'fingerprint' => array( 'barcode' => false ), |
| 143 |
'backfill' => array( 'kind' => 'order' ), |
| 144 |
), |
| 145 |
'customers' => array( |
| 146 |
'object_type' => 'customer', |
| 147 |
'identity' => array( |
| 148 |
'id_type' => 'user', |
| 149 |
'detector' => 'uuid_owned_by_other_user', |
| 150 |
'bulk_reader' => 'bulk_read_user_uuids', |
| 151 |
'loader' => 'customer', |
| 152 |
), |
| 153 |
'proxy' => array( |
| 154 |
'route' => '/customers', |
| 155 |
'wc_route' => '/wc/v3/customers', |
| 156 |
'slug' => 'customers', |
| 157 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Customers_Proxy_Behavior::class, |
| 158 |
), |
| 159 |
'write' => array( 'route' => '/wc/v3/customers' ), |
| 160 |
'journal' => array( 'object_type' => 'customer' ), |
| 161 |
'digest' => array( |
| 162 |
'id_space' => 'customers', |
| 163 |
'object_types' => array( 'customer' ), |
| 164 |
'live_rows' => 'customer_live_row_exists_sql', |
| 165 |
), |
| 166 |
'fingerprint' => array( 'barcode' => false ), |
| 167 |
'backfill' => array( 'kind' => 'user' ), |
| 168 |
), |
| 169 |
'categories' => array( |
| 170 |
'object_type' => 'category', |
| 171 |
'identity' => array( |
| 172 |
'id_type' => 'term', |
| 173 |
'taxonomy' => 'product_cat', |
| 174 |
'detector' => 'uuid_owned_by_other_term', |
| 175 |
'bulk_reader' => 'bulk_read_term_uuids', |
| 176 |
'loader' => 'term', |
| 177 |
), |
| 178 |
'proxy' => array( |
| 179 |
'route' => '/products/categories', |
| 180 |
'wc_route' => '/wc/v3/products/categories', |
| 181 |
'slug' => 'categories', |
| 182 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Terms_Proxy_Behavior::class, |
| 183 |
), |
| 184 |
'write' => array( 'route' => '/wc/v3/products/categories' ), |
| 185 |
'journal' => array( 'object_type' => 'category' ), |
| 186 |
'digest' => null, |
| 187 |
'fingerprint' => array( 'barcode' => false ), |
| 188 |
'backfill' => array( |
| 189 |
'kind' => 'term', |
| 190 |
'taxonomy' => 'product_cat', |
| 191 |
), |
| 192 |
), |
| 193 |
'brands' => array( |
| 194 |
'object_type' => 'brand', |
| 195 |
'identity' => array( |
| 196 |
'id_type' => 'term', |
| 197 |
'taxonomy' => 'product_brand', |
| 198 |
'detector' => 'uuid_owned_by_other_term', |
| 199 |
'bulk_reader' => 'bulk_read_term_uuids', |
| 200 |
'loader' => 'term', |
| 201 |
), |
| 202 |
'proxy' => array( |
| 203 |
'route' => '/products/brands', |
| 204 |
'wc_route' => '/wc/v3/products/brands', |
| 205 |
'slug' => 'brands', |
| 206 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Terms_Proxy_Behavior::class, |
| 207 |
), |
| 208 |
'write' => array( 'route' => '/wc/v3/products/brands' ), |
| 209 |
'journal' => array( 'object_type' => 'brand' ), |
| 210 |
'digest' => null, |
| 211 |
'fingerprint' => array( 'barcode' => false ), |
| 212 |
'backfill' => array( |
| 213 |
'kind' => 'term', |
| 214 |
'taxonomy' => 'product_brand', |
| 215 |
), |
| 216 |
), |
| 217 |
'tags' => array( |
| 218 |
'object_type' => 'tag', |
| 219 |
'identity' => array( |
| 220 |
'id_type' => 'term', |
| 221 |
'taxonomy' => 'product_tag', |
| 222 |
'detector' => 'uuid_owned_by_other_term', |
| 223 |
'bulk_reader' => 'bulk_read_term_uuids', |
| 224 |
'loader' => 'term', |
| 225 |
), |
| 226 |
'proxy' => array( |
| 227 |
'route' => '/products/tags', |
| 228 |
'wc_route' => '/wc/v3/products/tags', |
| 229 |
'slug' => 'tags', |
| 230 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Terms_Proxy_Behavior::class, |
| 231 |
), |
| 232 |
'write' => null, // read-only: no client push path exists |
| 233 |
'journal' => array( 'object_type' => 'tag' ), |
| 234 |
'digest' => null, |
| 235 |
'fingerprint' => array( 'barcode' => false ), |
| 236 |
'backfill' => array( |
| 237 |
'kind' => 'term', |
| 238 |
'taxonomy' => 'product_tag', |
| 239 |
), |
| 240 |
), |
| 241 |
'coupons' => array( |
| 242 |
'object_type' => 'coupon', |
| 243 |
'identity' => array( |
| 244 |
'id_type' => 'post', |
| 245 |
'post_type' => 'shop_coupon', |
| 246 |
'detector' => 'uuid_owned_by_other', |
| 247 |
'bulk_reader' => 'bulk_read_post_uuids', |
| 248 |
'loader' => 'coupon', |
| 249 |
), |
| 250 |
'proxy' => array( |
| 251 |
'route' => '/coupons', |
| 252 |
'wc_route' => '/wc/v3/coupons', |
| 253 |
'slug' => 'coupons', |
| 254 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Coupons_Proxy_Behavior::class, |
| 255 |
), |
| 256 |
'write' => array( 'route' => '/wc/v3/coupons' ), |
| 257 |
'journal' => array( 'object_type' => 'coupon' ), |
| 258 |
'digest' => null, |
| 259 |
'fingerprint' => array( 'barcode' => false ), |
| 260 |
'backfill' => array( |
| 261 |
'kind' => 'post', |
| 262 |
'scan_post_types' => array( 'shop_coupon' ), |
| 263 |
), |
| 264 |
), |
| 265 |
'tax_rates' => array( |
| 266 |
'object_type' => 'tax_rate', |
| 267 |
'identity' => null, // ADR 0009: keyed by WooCommerce id — no uuid identity, principled |
| 268 |
'proxy' => array( |
| 269 |
'route' => '/taxes', |
| 270 |
'wc_route' => '/wc/v3/taxes', |
| 271 |
'slug' => 'taxes', |
| 272 |
'behavior' => \WCPOS\WooCommercePOS\API\V2\Proxy\Taxes_Proxy_Behavior::class, |
| 273 |
), |
| 274 |
'write' => null, // principled read-only |
| 275 |
'journal' => array( 'object_type' => 'tax_rate' ), |
| 276 |
'digest' => null, |
| 277 |
'fingerprint' => array( 'barcode' => false ), |
| 278 |
'backfill' => null, // no meta store to stamp |
| 279 |
), |
| 280 |
); |
| 281 |
|
| 282 |
/** Row lookup by canonical plural name. Null for unknown — callers decide |
| 283 |
* their unsupported behavior EXPLICITLY (no default-to-products, ever). */ |
| 284 |
public static function row( string $collection ): ?array { |
| 285 |
return self::ROWS[ $collection ] ?? null; |
| 286 |
} |
| 287 |
|
| 288 |
/** Inverse lookup: singular change-log object_type → row (+ its plural name |
| 289 |
* under '_collection'). `product` resolves EXPLICITLY — it is not a |
| 290 |
* fall-through default anywhere in this class. */ |
| 291 |
public static function by_object_type( string $object_type ): ?array { |
| 292 |
foreach ( self::ROWS as $collection => $row ) { |
| 293 |
if ( $row['object_type'] === $object_type ) { |
| 294 |
return array( '_collection' => $collection ) + $row; |
| 295 |
} |
| 296 |
} |
| 297 |
return null; |
| 298 |
} |
| 299 |
|
| 300 |
/** Resolve a singular object type to its canonical collection name. */ |
| 301 |
public static function collection_for_object_type( string $object_type ): ?string { |
| 302 |
$row = self::by_object_type( $object_type ); |
| 303 |
|
| 304 |
return null === $row ? null : $row['_collection']; |
| 305 |
} |
| 306 |
|
| 307 |
/** Inverse lookup: proxy resource slug → row (tax_rates' slug is `taxes`). */ |
| 308 |
public static function by_proxy_slug( string $slug ): ?array { |
| 309 |
foreach ( self::ROWS as $collection => $row ) { |
| 310 |
if ( isset( $row['proxy'] ) && $row['proxy']['slug'] === $slug ) { |
| 311 |
return array( '_collection' => $collection ) + $row; |
| 312 |
} |
| 313 |
} |
| 314 |
return null; |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Capability projection: every row carrying a non-null `$capability` |
| 319 |
* group, keyed by plural name. The write map, the proxy RESOURCES, the |
| 320 |
* digest dispatch and plugin.php's stamper wiring are all projections of |
| 321 |
* this — adding a collection means adding ONE row above. |
| 322 |
*/ |
| 323 |
public static function with( string $capability ): array { |
| 324 |
$rows = array(); |
| 325 |
foreach ( self::ROWS as $collection => $row ) { |
| 326 |
if ( isset( $row[ $capability ] ) ) { |
| 327 |
$rows[ $collection ] = $row; |
| 328 |
} |
| 329 |
} |
| 330 |
return $rows; |
| 331 |
} |
| 332 |
|
| 333 |
/** The full canonical name list (tests, docs, admin surfaces). */ |
| 334 |
public static function names(): array { |
| 335 |
return array_keys( self::ROWS ); |
| 336 |
} |
| 337 |
} |
| 338 |
|