@voyantjs/products 0.28.3 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/booking-engine/handler.d.ts +8 -0
  2. package/dist/booking-engine/handler.d.ts.map +1 -1
  3. package/dist/booking-engine/handler.js +38 -0
  4. package/dist/catalog-policy-departures.d.ts +52 -0
  5. package/dist/catalog-policy-departures.d.ts.map +1 -0
  6. package/dist/catalog-policy-departures.js +169 -0
  7. package/dist/catalog-policy-destinations.d.ts +41 -0
  8. package/dist/catalog-policy-destinations.d.ts.map +1 -0
  9. package/dist/catalog-policy-destinations.js +121 -0
  10. package/dist/catalog-policy-pricing.d.ts +55 -0
  11. package/dist/catalog-policy-pricing.d.ts.map +1 -0
  12. package/dist/catalog-policy-pricing.js +109 -0
  13. package/dist/catalog-policy-promotions.d.ts +52 -0
  14. package/dist/catalog-policy-promotions.d.ts.map +1 -0
  15. package/dist/catalog-policy-promotions.js +270 -0
  16. package/dist/catalog-policy-taxonomy.d.ts +51 -0
  17. package/dist/catalog-policy-taxonomy.d.ts.map +1 -0
  18. package/dist/catalog-policy-taxonomy.js +191 -0
  19. package/dist/events.d.ts +1 -1
  20. package/dist/events.d.ts.map +1 -1
  21. package/dist/routes.d.ts +254 -19
  22. package/dist/routes.d.ts.map +1 -1
  23. package/dist/routes.js +73 -5
  24. package/dist/schema-taxonomy.d.ts +287 -0
  25. package/dist/schema-taxonomy.d.ts.map +1 -1
  26. package/dist/schema-taxonomy.js +47 -0
  27. package/dist/service-catalog-plane-destinations.d.ts +30 -0
  28. package/dist/service-catalog-plane-destinations.d.ts.map +1 -0
  29. package/dist/service-catalog-plane-destinations.js +119 -0
  30. package/dist/service-catalog-plane-taxonomy.d.ts +73 -0
  31. package/dist/service-catalog-plane-taxonomy.d.ts.map +1 -0
  32. package/dist/service-catalog-plane-taxonomy.js +242 -0
  33. package/dist/service-catalog-plane.d.ts +46 -1
  34. package/dist/service-catalog-plane.d.ts.map +1 -1
  35. package/dist/service-catalog-plane.js +42 -6
  36. package/dist/service.d.ts +98 -19
  37. package/dist/service.d.ts.map +1 -1
  38. package/dist/service.js +142 -1
  39. package/dist/tasks/brochures.d.ts +1 -1
  40. package/dist/validation-content.d.ts +38 -0
  41. package/dist/validation-content.d.ts.map +1 -1
  42. package/dist/validation-content.js +29 -0
  43. package/package.json +77 -7
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Catalog plane field policy for product → promotional-offer denormalization.
3
+ *
4
+ * Promotions are projected as **annotations only** — this policy does NOT
5
+ * touch the existing `priceFromAmountCents` field (that's emitted by the
6
+ * pricing extension and the two extensions can't read each other's output;
7
+ * see `docs/architecture/promotions-architecture.md` §3.7). Instead, we
8
+ * emit `bestOffer*` + `originalPriceFromAmountCents` and storefront
9
+ * consumers compute the effective price client-side:
10
+ *
11
+ * effective = bestOfferDiscountKind === "percentage"
12
+ * ? round(price × (1 - bestOfferDiscountPercent/100))
13
+ * : price - bestOfferDiscountAmountCents
14
+ *
15
+ * Catalog filter / sort behavior continues to use `priceFromAmountCents`
16
+ * (the list price). A customer searching `< $200` won't find a
17
+ * `$250 → $180` discounted product via the filter — acknowledged v1
18
+ * limitation, see §15.1 of the architecture doc.
19
+ *
20
+ * Conditional offers are surfaced separately so storefront cards can
21
+ * render hints like "From 4 pax: extra 5% off" — the catalog plane
22
+ * doesn't know pax at index time, so any offer with a `minPax` condition
23
+ * lands here instead of in `bestOffer*`.
24
+ *
25
+ * Wire this policy into the products registry by composing with
26
+ * `productCatalogPolicy`:
27
+ *
28
+ * const registry = createFieldPolicyRegistry([
29
+ * ...productCatalogPolicy,
30
+ * ...productPromotionsCatalogPolicy,
31
+ * ])
32
+ *
33
+ * and wire `createProductPromotionsProjectionExtension` (from
34
+ * `@voyantjs/promotions/service-catalog-plane-promotions`) into
35
+ * `createProductDocumentBuilder` so the values land in the doc.
36
+ *
37
+ * Out of scope here:
38
+ * - Localized offer names. Offers are operator-managed in one language
39
+ * for v1 (`localized: false`); a `promotional_offer_translations`
40
+ * table mirrors `destinations_translations` if/when needed.
41
+ * - Discount-aware filter / sort. Requires the §15.1 ordered-extensions
42
+ * contract change; deferred.
43
+ */
44
+ import { type FieldPolicyInput } from "@voyantjs/catalog/contract";
45
+ declare const PRODUCT_PROMOTIONS_FIELD_POLICY: FieldPolicyInput[];
46
+ /**
47
+ * Resolved promotions policy. Compose with `productCatalogPolicy` when
48
+ * building the products registry.
49
+ */
50
+ export declare const productPromotionsCatalogPolicy: import("@voyantjs/catalog").FieldPolicy[];
51
+ export { PRODUCT_PROMOTIONS_FIELD_POLICY };
52
+ //# sourceMappingURL=catalog-policy-promotions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-policy-promotions.d.ts","sourceRoot":"","sources":["../src/catalog-policy-promotions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAErF,QAAA,MAAM,+BAA+B,EAAE,gBAAgB,EA4NtD,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,8BAA8B,2CAAqD,CAAA;AAEhG,OAAO,EAAE,+BAA+B,EAAE,CAAA"}
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Catalog plane field policy for product → promotional-offer denormalization.
3
+ *
4
+ * Promotions are projected as **annotations only** — this policy does NOT
5
+ * touch the existing `priceFromAmountCents` field (that's emitted by the
6
+ * pricing extension and the two extensions can't read each other's output;
7
+ * see `docs/architecture/promotions-architecture.md` §3.7). Instead, we
8
+ * emit `bestOffer*` + `originalPriceFromAmountCents` and storefront
9
+ * consumers compute the effective price client-side:
10
+ *
11
+ * effective = bestOfferDiscountKind === "percentage"
12
+ * ? round(price × (1 - bestOfferDiscountPercent/100))
13
+ * : price - bestOfferDiscountAmountCents
14
+ *
15
+ * Catalog filter / sort behavior continues to use `priceFromAmountCents`
16
+ * (the list price). A customer searching `< $200` won't find a
17
+ * `$250 → $180` discounted product via the filter — acknowledged v1
18
+ * limitation, see §15.1 of the architecture doc.
19
+ *
20
+ * Conditional offers are surfaced separately so storefront cards can
21
+ * render hints like "From 4 pax: extra 5% off" — the catalog plane
22
+ * doesn't know pax at index time, so any offer with a `minPax` condition
23
+ * lands here instead of in `bestOffer*`.
24
+ *
25
+ * Wire this policy into the products registry by composing with
26
+ * `productCatalogPolicy`:
27
+ *
28
+ * const registry = createFieldPolicyRegistry([
29
+ * ...productCatalogPolicy,
30
+ * ...productPromotionsCatalogPolicy,
31
+ * ])
32
+ *
33
+ * and wire `createProductPromotionsProjectionExtension` (from
34
+ * `@voyantjs/promotions/service-catalog-plane-promotions`) into
35
+ * `createProductDocumentBuilder` so the values land in the doc.
36
+ *
37
+ * Out of scope here:
38
+ * - Localized offer names. Offers are operator-managed in one language
39
+ * for v1 (`localized: false`); a `promotional_offer_translations`
40
+ * table mirrors `destinations_translations` if/when needed.
41
+ * - Discount-aware filter / sort. Requires the §15.1 ordered-extensions
42
+ * contract change; deferred.
43
+ */
44
+ import { defineFieldPolicy } from "@voyantjs/catalog/contract";
45
+ const PRODUCT_PROMOTIONS_FIELD_POLICY = [
46
+ // ── Best-offer existence flag ────────────────────────────────────────────
47
+ // Storefront filters use this for "show only products on sale" and to
48
+ // suppress an empty badge.
49
+ {
50
+ path: "hasOffer",
51
+ class: "structural",
52
+ merge: "source-only",
53
+ drift: "low",
54
+ reindex: "facet-affecting",
55
+ snapshot: "on-book",
56
+ query: "indexed-column",
57
+ localized: false,
58
+ visibility: ["staff", "customer", "partner"],
59
+ editRole: "none",
60
+ overrideFriction: "none",
61
+ sourceFreshness: "sync",
62
+ },
63
+ // ── Best-offer ID ────────────────────────────────────────────────────────
64
+ // Used for deep-linking from a search card to the offer-detail page.
65
+ {
66
+ path: "bestOfferId",
67
+ class: "structural",
68
+ merge: "source-only",
69
+ drift: "low",
70
+ reindex: "facet-affecting",
71
+ snapshot: "on-book",
72
+ query: "indexed-column",
73
+ localized: false,
74
+ visibility: ["staff", "customer", "partner"],
75
+ editRole: "none",
76
+ overrideFriction: "none",
77
+ sourceFreshness: "sync",
78
+ },
79
+ // ── Best-offer name (display) ────────────────────────────────────────────
80
+ // Single-locale in v1 — see file-header notes.
81
+ {
82
+ path: "bestOfferName",
83
+ class: "structural",
84
+ merge: "source-only",
85
+ drift: "low",
86
+ reindex: "facet-affecting",
87
+ snapshot: "on-book",
88
+ query: "indexed-column",
89
+ localized: false,
90
+ visibility: ["staff", "customer", "partner"],
91
+ editRole: "none",
92
+ overrideFriction: "none",
93
+ sourceFreshness: "sync",
94
+ },
95
+ // ── Best-offer discount kind ─────────────────────────────────────────────
96
+ // `"percentage"` | `"fixed_amount"` | `null`. Drives storefront's badge
97
+ // rendering (% vs $).
98
+ {
99
+ path: "bestOfferDiscountKind",
100
+ class: "structural",
101
+ merge: "source-only",
102
+ drift: "low",
103
+ reindex: "facet-affecting",
104
+ snapshot: "on-book",
105
+ query: "indexed-column",
106
+ localized: false,
107
+ visibility: ["staff", "customer", "partner"],
108
+ editRole: "none",
109
+ overrideFriction: "none",
110
+ sourceFreshness: "sync",
111
+ },
112
+ // ── Best-offer percentage (when kind = percentage) ───────────────────────
113
+ {
114
+ path: "bestOfferDiscountPercent",
115
+ class: "structural",
116
+ merge: "source-only",
117
+ drift: "low",
118
+ reindex: "facet-affecting",
119
+ snapshot: "on-book",
120
+ query: "indexed-column",
121
+ localized: false,
122
+ visibility: ["staff", "customer", "partner"],
123
+ editRole: "none",
124
+ overrideFriction: "none",
125
+ sourceFreshness: "sync",
126
+ },
127
+ // ── Best-offer cents off (when kind = fixed_amount) ──────────────────────
128
+ {
129
+ path: "bestOfferDiscountAmountCents",
130
+ class: "structural",
131
+ merge: "source-only",
132
+ drift: "low",
133
+ reindex: "facet-affecting",
134
+ snapshot: "on-book",
135
+ query: "indexed-column",
136
+ localized: false,
137
+ visibility: ["staff", "customer", "partner"],
138
+ editRole: "none",
139
+ overrideFriction: "none",
140
+ sourceFreshness: "sync",
141
+ },
142
+ // ── Original (un-discounted) price ───────────────────────────────────────
143
+ // Populated only when an offer applies (so consumers know what to
144
+ // strike through). When `null`, no offer applies and consumers should
145
+ // render `priceFromAmountCents` plain.
146
+ {
147
+ path: "originalPriceFromAmountCents",
148
+ class: "structural",
149
+ merge: "source-only",
150
+ drift: "low",
151
+ reindex: "facet-affecting",
152
+ snapshot: "on-book",
153
+ query: "indexed-column",
154
+ localized: false,
155
+ visibility: ["staff", "customer", "partner"],
156
+ editRole: "none",
157
+ overrideFriction: "none",
158
+ sourceFreshness: "sync",
159
+ },
160
+ // ── Conditional-offer existence flag ─────────────────────────────────────
161
+ // Surfaced when an offer has a `minPax` condition the catalog plane
162
+ // can't satisfy at index time. Storefront uses this for the "From N
163
+ // pax: extra X% off" hint.
164
+ {
165
+ path: "hasConditionalOffer",
166
+ class: "structural",
167
+ merge: "source-only",
168
+ drift: "low",
169
+ reindex: "facet-affecting",
170
+ snapshot: "on-book",
171
+ query: "indexed-column",
172
+ localized: false,
173
+ visibility: ["staff", "customer", "partner"],
174
+ editRole: "none",
175
+ overrideFriction: "none",
176
+ sourceFreshness: "sync",
177
+ },
178
+ {
179
+ path: "conditionalOfferId",
180
+ class: "structural",
181
+ merge: "source-only",
182
+ drift: "low",
183
+ reindex: "facet-affecting",
184
+ snapshot: "on-book",
185
+ query: "indexed-column",
186
+ localized: false,
187
+ visibility: ["staff", "customer", "partner"],
188
+ editRole: "none",
189
+ overrideFriction: "none",
190
+ sourceFreshness: "sync",
191
+ },
192
+ {
193
+ path: "conditionalOfferName",
194
+ class: "structural",
195
+ merge: "source-only",
196
+ drift: "low",
197
+ reindex: "facet-affecting",
198
+ snapshot: "on-book",
199
+ query: "indexed-column",
200
+ localized: false,
201
+ visibility: ["staff", "customer", "partner"],
202
+ editRole: "none",
203
+ overrideFriction: "none",
204
+ sourceFreshness: "sync",
205
+ },
206
+ {
207
+ path: "conditionalOfferDiscountKind",
208
+ class: "structural",
209
+ merge: "source-only",
210
+ drift: "low",
211
+ reindex: "facet-affecting",
212
+ snapshot: "on-book",
213
+ query: "indexed-column",
214
+ localized: false,
215
+ visibility: ["staff", "customer", "partner"],
216
+ editRole: "none",
217
+ overrideFriction: "none",
218
+ sourceFreshness: "sync",
219
+ },
220
+ {
221
+ path: "conditionalOfferDiscountPercent",
222
+ class: "structural",
223
+ merge: "source-only",
224
+ drift: "low",
225
+ reindex: "facet-affecting",
226
+ snapshot: "on-book",
227
+ query: "indexed-column",
228
+ localized: false,
229
+ visibility: ["staff", "customer", "partner"],
230
+ editRole: "none",
231
+ overrideFriction: "none",
232
+ sourceFreshness: "sync",
233
+ },
234
+ {
235
+ path: "conditionalOfferDiscountAmountCents",
236
+ class: "structural",
237
+ merge: "source-only",
238
+ drift: "low",
239
+ reindex: "facet-affecting",
240
+ snapshot: "on-book",
241
+ query: "indexed-column",
242
+ localized: false,
243
+ visibility: ["staff", "customer", "partner"],
244
+ editRole: "none",
245
+ overrideFriction: "none",
246
+ sourceFreshness: "sync",
247
+ },
248
+ // ── Conditional-offer minPax threshold ───────────────────────────────────
249
+ // Drives the "From N pax" message text.
250
+ {
251
+ path: "conditionalOfferMinPax",
252
+ class: "structural",
253
+ merge: "source-only",
254
+ drift: "low",
255
+ reindex: "facet-affecting",
256
+ snapshot: "on-book",
257
+ query: "indexed-column",
258
+ localized: false,
259
+ visibility: ["staff", "customer", "partner"],
260
+ editRole: "none",
261
+ overrideFriction: "none",
262
+ sourceFreshness: "sync",
263
+ },
264
+ ];
265
+ /**
266
+ * Resolved promotions policy. Compose with `productCatalogPolicy` when
267
+ * building the products registry.
268
+ */
269
+ export const productPromotionsCatalogPolicy = defineFieldPolicy(PRODUCT_PROMOTIONS_FIELD_POLICY);
270
+ export { PRODUCT_PROMOTIONS_FIELD_POLICY };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Catalog plane field policy for product → taxonomy denormalization
3
+ * (categories + tags).
4
+ *
5
+ * These fields don't live on the `products` table — they're joined from
6
+ * `product_category_products` → `product_categories` (with parent walk for
7
+ * hierarchy denormalization) and `product_tag_products` → `product_tags` at
8
+ * index time and projected onto the product search document. See
9
+ * `docs/architecture/catalog-architecture.md` §5.4 — the search index is the
10
+ * canonical place for cross-entity denormalization.
11
+ *
12
+ * Storefront product cards filter and display by taxonomy:
13
+ * - "Adventure tours" (filter: `categories[]` includes "Adventure")
14
+ * - A product tagged "Hiking" (parent "Adventure") MUST also surface under
15
+ * the "Adventure" filter — Typesense can't recurse, so the projection
16
+ * denormalizes the full ancestor chain into `categories[]` /
17
+ * `categoryIds[]`.
18
+ * - "Family-friendly" (filter: `tags[]` includes "Family-friendly")
19
+ *
20
+ * Wire this policy into the products registry by composing with
21
+ * `productCatalogPolicy`:
22
+ *
23
+ * const registry = createFieldPolicyRegistry([
24
+ * ...productCatalogPolicy,
25
+ * ...productTaxonomyCatalogPolicy,
26
+ * ])
27
+ *
28
+ * and wire `createProductTaxonomyProjectionExtension` into
29
+ * `createProductDocumentBuilder` so the values land in the doc.
30
+ *
31
+ * Localization (#502): `product_category_translations` and
32
+ * `product_tag_translations` tables back the locale-aware label fields.
33
+ * Slice-locale rows win; if no row exists for the slice, the projection
34
+ * falls back to the canonical `productCategories.name` /
35
+ * `productTags.name`. Slug stays single-locale on `productCategories.slug`
36
+ * (per #502 non-goals — operators want stable URLs that don't shift when
37
+ * translations are edited).
38
+ *
39
+ * Out of scope here:
40
+ * - Per-locale slugs. One canonical slug per category.
41
+ * - Tag hierarchy. Tags are flat by design.
42
+ */
43
+ import { type FieldPolicyInput } from "@voyantjs/catalog/contract";
44
+ declare const PRODUCT_TAXONOMY_FIELD_POLICY: FieldPolicyInput[];
45
+ /**
46
+ * Resolved taxonomy policy. Compose with `productCatalogPolicy` when
47
+ * building the products registry.
48
+ */
49
+ export declare const productTaxonomyCatalogPolicy: import("@voyantjs/catalog").FieldPolicy[];
50
+ export { PRODUCT_TAXONOMY_FIELD_POLICY };
51
+ //# sourceMappingURL=catalog-policy-taxonomy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-policy-taxonomy.d.ts","sourceRoot":"","sources":["../src/catalog-policy-taxonomy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAErF,QAAA,MAAM,6BAA6B,EAAE,gBAAgB,EAiJpD,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,2CAAmD,CAAA;AAE5F,OAAO,EAAE,6BAA6B,EAAE,CAAA"}
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Catalog plane field policy for product → taxonomy denormalization
3
+ * (categories + tags).
4
+ *
5
+ * These fields don't live on the `products` table — they're joined from
6
+ * `product_category_products` → `product_categories` (with parent walk for
7
+ * hierarchy denormalization) and `product_tag_products` → `product_tags` at
8
+ * index time and projected onto the product search document. See
9
+ * `docs/architecture/catalog-architecture.md` §5.4 — the search index is the
10
+ * canonical place for cross-entity denormalization.
11
+ *
12
+ * Storefront product cards filter and display by taxonomy:
13
+ * - "Adventure tours" (filter: `categories[]` includes "Adventure")
14
+ * - A product tagged "Hiking" (parent "Adventure") MUST also surface under
15
+ * the "Adventure" filter — Typesense can't recurse, so the projection
16
+ * denormalizes the full ancestor chain into `categories[]` /
17
+ * `categoryIds[]`.
18
+ * - "Family-friendly" (filter: `tags[]` includes "Family-friendly")
19
+ *
20
+ * Wire this policy into the products registry by composing with
21
+ * `productCatalogPolicy`:
22
+ *
23
+ * const registry = createFieldPolicyRegistry([
24
+ * ...productCatalogPolicy,
25
+ * ...productTaxonomyCatalogPolicy,
26
+ * ])
27
+ *
28
+ * and wire `createProductTaxonomyProjectionExtension` into
29
+ * `createProductDocumentBuilder` so the values land in the doc.
30
+ *
31
+ * Localization (#502): `product_category_translations` and
32
+ * `product_tag_translations` tables back the locale-aware label fields.
33
+ * Slice-locale rows win; if no row exists for the slice, the projection
34
+ * falls back to the canonical `productCategories.name` /
35
+ * `productTags.name`. Slug stays single-locale on `productCategories.slug`
36
+ * (per #502 non-goals — operators want stable URLs that don't shift when
37
+ * translations are edited).
38
+ *
39
+ * Out of scope here:
40
+ * - Per-locale slugs. One canonical slug per category.
41
+ * - Tag hierarchy. Tags are flat by design.
42
+ */
43
+ import { defineFieldPolicy } from "@voyantjs/catalog/contract";
44
+ const PRODUCT_TAXONOMY_FIELD_POLICY = [
45
+ // ── Category labels (denormalized with ancestors, locale-aware) ──────────
46
+ // One entry per category in the linked-category-plus-ancestors set, deduped.
47
+ // A product linked to "Hiking" (parent: "Adventure") emits both
48
+ // ["Hiking", "Adventure"] so any-level filter matches a single equality.
49
+ // Locale-keyed since #502 — falls back to canonical name when no
50
+ // translation exists for the slice's locale.
51
+ {
52
+ path: "categories[]",
53
+ class: "structural",
54
+ merge: "source-only",
55
+ drift: "low",
56
+ reindex: "entry-locale",
57
+ snapshot: "on-book",
58
+ query: "indexed-column",
59
+ localized: true,
60
+ visibility: ["staff", "customer", "partner"],
61
+ editRole: "none",
62
+ overrideFriction: "none",
63
+ sourceFreshness: "sync",
64
+ },
65
+ // ── Category IDs (denormalized with ancestors) ───────────────────────────
66
+ // Same denormalization as `categories[]` but for ID-based filters
67
+ // (landing pages pinned to a category id rather than a label).
68
+ {
69
+ path: "categoryIds[]",
70
+ class: "structural",
71
+ merge: "source-only",
72
+ drift: "low",
73
+ reindex: "facet-affecting",
74
+ snapshot: "on-book",
75
+ query: "indexed-column",
76
+ localized: false,
77
+ visibility: ["staff", "customer", "partner"],
78
+ editRole: "none",
79
+ overrideFriction: "none",
80
+ sourceFreshness: "sync",
81
+ },
82
+ // ── Category slugs (locale-stable, denormalized with ancestors) ──────────
83
+ // Storefront category-page links want stable URLs that don't shift when
84
+ // labels are edited. `product_categories.slug` exists today.
85
+ {
86
+ path: "categorySlugs[]",
87
+ class: "structural",
88
+ merge: "source-only",
89
+ drift: "low",
90
+ reindex: "facet-affecting",
91
+ snapshot: "on-book",
92
+ query: "indexed-column",
93
+ localized: false,
94
+ visibility: ["staff", "customer", "partner"],
95
+ editRole: "none",
96
+ overrideFriction: "none",
97
+ sourceFreshness: "sync",
98
+ },
99
+ // ── Primary category (single, for badge display) ─────────────────────────
100
+ // Picked as the directly-linked category with the lowest sortOrder on
101
+ // `product_category_products` (the operator-controlled per-product
102
+ // ordering); ties broken by category name. Ancestors are NOT considered
103
+ // for primary — operators pin a leaf via the link table. Null when the
104
+ // product has no category links.
105
+ {
106
+ path: "primaryCategoryId",
107
+ class: "structural",
108
+ merge: "source-only",
109
+ drift: "low",
110
+ reindex: "facet-affecting",
111
+ snapshot: "on-book",
112
+ query: "indexed-column",
113
+ localized: false,
114
+ visibility: ["staff", "customer", "partner"],
115
+ editRole: "none",
116
+ overrideFriction: "none",
117
+ sourceFreshness: "sync",
118
+ },
119
+ {
120
+ // Locale-aware since #502 — the primary's name follows the same
121
+ // translation lookup as `categories[]`.
122
+ path: "primaryCategoryName",
123
+ class: "structural",
124
+ merge: "source-only",
125
+ drift: "low",
126
+ reindex: "entry-locale",
127
+ snapshot: "on-book",
128
+ query: "indexed-column",
129
+ localized: true,
130
+ visibility: ["staff", "customer", "partner"],
131
+ editRole: "none",
132
+ overrideFriction: "none",
133
+ sourceFreshness: "sync",
134
+ },
135
+ {
136
+ path: "primaryCategorySlug",
137
+ class: "structural",
138
+ merge: "source-only",
139
+ drift: "low",
140
+ reindex: "facet-affecting",
141
+ snapshot: "on-book",
142
+ query: "indexed-column",
143
+ localized: false,
144
+ visibility: ["staff", "customer", "partner"],
145
+ editRole: "none",
146
+ overrideFriction: "none",
147
+ sourceFreshness: "sync",
148
+ },
149
+ // ── Tag labels ───────────────────────────────────────────────────────────
150
+ // `products.tags` (a free-form `text[]` column) already projects to
151
+ // `tags[]` from the base product policy. The structured `product_tags`
152
+ // table is a separate, normalized taxonomy — its labels land here under
153
+ // `tagLabels[]` to avoid colliding with the legacy column. One entry per
154
+ // linked tag.
155
+ {
156
+ // Locale-aware since #502 — falls back to canonical `productTags.name`
157
+ // when no `product_tag_translations` row exists for the slice's locale.
158
+ path: "tagLabels[]",
159
+ class: "structural",
160
+ merge: "source-only",
161
+ drift: "low",
162
+ reindex: "entry-locale",
163
+ snapshot: "on-book",
164
+ query: "indexed-column",
165
+ localized: true,
166
+ visibility: ["staff", "customer", "partner"],
167
+ editRole: "none",
168
+ overrideFriction: "none",
169
+ sourceFreshness: "sync",
170
+ },
171
+ {
172
+ path: "tagIds[]",
173
+ class: "structural",
174
+ merge: "source-only",
175
+ drift: "low",
176
+ reindex: "facet-affecting",
177
+ snapshot: "on-book",
178
+ query: "indexed-column",
179
+ localized: false,
180
+ visibility: ["staff", "customer", "partner"],
181
+ editRole: "none",
182
+ overrideFriction: "none",
183
+ sourceFreshness: "sync",
184
+ },
185
+ ];
186
+ /**
187
+ * Resolved taxonomy policy. Compose with `productCatalogPolicy` when
188
+ * building the products registry.
189
+ */
190
+ export const productTaxonomyCatalogPolicy = defineFieldPolicy(PRODUCT_TAXONOMY_FIELD_POLICY);
191
+ export { PRODUCT_TAXONOMY_FIELD_POLICY };
package/dist/events.d.ts CHANGED
@@ -19,7 +19,7 @@ export interface ProductContentChangedEvent {
19
19
  * push hashes the full current content at push time so this field is
20
20
  * not load-bearing for correctness.
21
21
  */
22
- axis?: "product" | "option" | "day" | "media" | "feature" | "faq" | "location" | "destination" | "translation";
22
+ axis?: "product" | "option" | "day" | "media" | "feature" | "faq" | "location" | "destination" | "category" | "tag" | "translation";
23
23
  }
24
24
  /**
25
25
  * Helper for route handlers / services to fire `product.content.changed`
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAE9C,+BAA+B;AAC/B,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAA;AAE/E,MAAM,WAAW,0BAA0B;IACzC,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV;;;;OAIG;IACH,IAAI,CAAC,EACD,SAAS,GACT,QAAQ,GACR,KAAK,GACL,OAAO,GACP,SAAS,GACT,KAAK,GACL,UAAU,GACV,aAAa,GACb,aAAa,CAAA;CAClB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,GAAG,SAAS,EAC9B,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,IAAI,CAAC,CAMf"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAE9C,+BAA+B;AAC/B,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAA;AAE/E,MAAM,WAAW,0BAA0B;IACzC,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV;;;;OAIG;IACH,IAAI,CAAC,EACD,SAAS,GACT,QAAQ,GACR,KAAK,GACL,OAAO,GACP,SAAS,GACT,KAAK,GACL,UAAU,GACV,aAAa,GACb,UAAU,GACV,KAAK,GACL,aAAa,CAAA;CAClB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,GAAG,SAAS,EAC9B,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,IAAI,CAAC,CAMf"}