@voyantjs/products 0.28.3 → 0.29.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,242 @@
1
+ /**
2
+ * Projection extension that joins product → categories (with ancestor walk)
3
+ * and product → tags, contributing taxonomy fields onto the product search
4
+ * document.
5
+ *
6
+ * Wire via `createProductDocumentBuilder({ extensions: [taxonomyExtension] })`.
7
+ * Requires the registry to include `productTaxonomyCatalogPolicy` —
8
+ * otherwise the contributed fields are silently dropped by the indexer's
9
+ * field-policy filter.
10
+ *
11
+ * Hierarchy denormalization: Typesense can't recurse, so a product linked
12
+ * to "Hiking" (parent: "Adventure") needs both labels in `categories[]` for
13
+ * the "Adventure" filter to match. The projection walks the parent chain
14
+ * iteratively, filtering inactive ancestors (so an operator-paused parent
15
+ * stops surfacing its still-active children under the parent filter).
16
+ *
17
+ * Localization (#502): when a `product_category_translations` /
18
+ * `product_tag_translations` row exists for the slice's locale, its `name`
19
+ * wins. Otherwise the projection falls back to the canonical
20
+ * `productCategories.name` / `productTags.name` (the legacy single-locale
21
+ * column). This makes the upgrade non-breaking — operators that haven't
22
+ * created translations keep seeing the English label on every locale slice
23
+ * exactly as before.
24
+ *
25
+ * Slugs stay single-locale on `productCategories.slug` (per #502 non-goals
26
+ * — operators want stable URLs that don't shift when translations are
27
+ * edited).
28
+ */
29
+ import { and, eq, inArray } from "drizzle-orm";
30
+ import { productCategories, productCategoryProducts, productCategoryTranslations, productTagProducts, productTags, productTagTranslations, } from "./schema-taxonomy.js";
31
+ /**
32
+ * Resolve linked category ids for a product, ordered by the operator-set
33
+ * `sortOrder` on `product_category_products` (lowest first). Direct links
34
+ * only — ancestors are walked separately.
35
+ */
36
+ async function fetchDirectCategoryLinks(db, productId) {
37
+ return db
38
+ .select({
39
+ categoryId: productCategoryProducts.categoryId,
40
+ sortOrder: productCategoryProducts.sortOrder,
41
+ })
42
+ .from(productCategoryProducts)
43
+ .where(eq(productCategoryProducts.productId, productId));
44
+ }
45
+ /**
46
+ * Walk up the parent chain from a set of seed category ids, filtering
47
+ * inactive rows. Returns every active row reachable from the seeds.
48
+ *
49
+ * Iterative breadth-first to bound depth and avoid Drizzle recursive-CTE
50
+ * complexity. Real-world category trees are O(depth ≤ 5), so this issues
51
+ * at most a handful of `inArray` lookups regardless of breadth.
52
+ *
53
+ * Cycle-protected via a visited set — a misconfigured parent loop won't
54
+ * spin the indexer.
55
+ */
56
+ async function walkActiveCategoryChain(db, seedIds) {
57
+ const resolved = new Map();
58
+ const visited = new Set();
59
+ let frontier = Array.from(new Set(seedIds));
60
+ while (frontier.length > 0) {
61
+ for (const id of frontier)
62
+ visited.add(id);
63
+ const rows = await db
64
+ .select({
65
+ id: productCategories.id,
66
+ parentId: productCategories.parentId,
67
+ name: productCategories.name,
68
+ slug: productCategories.slug,
69
+ active: productCategories.active,
70
+ })
71
+ .from(productCategories)
72
+ .where(and(inArray(productCategories.id, frontier), eq(productCategories.active, true)));
73
+ const nextFrontier = [];
74
+ for (const row of rows) {
75
+ resolved.set(row.id, row);
76
+ if (row.parentId && !visited.has(row.parentId) && !resolved.has(row.parentId)) {
77
+ nextFrontier.push(row.parentId);
78
+ }
79
+ }
80
+ frontier = Array.from(new Set(nextFrontier));
81
+ }
82
+ return resolved;
83
+ }
84
+ async function fetchProductTags(db, productId) {
85
+ return db
86
+ .select({ id: productTags.id, name: productTags.name })
87
+ .from(productTagProducts)
88
+ .innerJoin(productTags, eq(productTagProducts.tagId, productTags.id))
89
+ .where(eq(productTagProducts.productId, productId));
90
+ }
91
+ /**
92
+ * Look up locale-specific category names for the given category ids. Returns
93
+ * a Map keyed by category id; entries only exist when a translation row was
94
+ * found for the slice locale. Callers fall back to the canonical
95
+ * `productCategories.name` for any missing entry.
96
+ */
97
+ async function fetchCategoryTranslations(db, categoryIds, languageTag) {
98
+ if (categoryIds.length === 0)
99
+ return new Map();
100
+ const rows = await db
101
+ .select({
102
+ categoryId: productCategoryTranslations.categoryId,
103
+ name: productCategoryTranslations.name,
104
+ })
105
+ .from(productCategoryTranslations)
106
+ .where(and(inArray(productCategoryTranslations.categoryId, categoryIds), eq(productCategoryTranslations.languageTag, languageTag)));
107
+ const out = new Map();
108
+ for (const row of rows)
109
+ out.set(row.categoryId, row.name);
110
+ return out;
111
+ }
112
+ async function fetchTagTranslations(db, tagIds, languageTag) {
113
+ if (tagIds.length === 0)
114
+ return new Map();
115
+ const rows = await db
116
+ .select({
117
+ tagId: productTagTranslations.tagId,
118
+ name: productTagTranslations.name,
119
+ })
120
+ .from(productTagTranslations)
121
+ .where(and(inArray(productTagTranslations.tagId, tagIds), eq(productTagTranslations.languageTag, languageTag)));
122
+ const out = new Map();
123
+ for (const row of rows)
124
+ out.set(row.tagId, row.name);
125
+ return out;
126
+ }
127
+ /**
128
+ * Pure aggregation kernel. `categoryNameByLocale` and `tagNameByLocale`
129
+ * carry slice-locale translations; entries override the canonical name on
130
+ * the row. Missing entries fall back to the canonical name. Slug stays
131
+ * canonical regardless — there's no per-locale slug.
132
+ */
133
+ function buildTaxonomyProjection(directLinks, resolvedCategories, tags, categoryNameByLocale = new Map(), tagNameByLocale = new Map()) {
134
+ // Primary = first direct link (by sortOrder asc, then category name asc)
135
+ // that resolved as active. Tie-break uses the canonical row name so the
136
+ // ordering is stable across slice locales — a translation that sorts
137
+ // differently shouldn't shuffle which category is "primary".
138
+ const sortedDirect = [...directLinks].sort((a, b) => {
139
+ if (a.sortOrder !== b.sortOrder)
140
+ return a.sortOrder - b.sortOrder;
141
+ const nameA = resolvedCategories.get(a.categoryId)?.name ?? "";
142
+ const nameB = resolvedCategories.get(b.categoryId)?.name ?? "";
143
+ return nameA.localeCompare(nameB);
144
+ });
145
+ let primary = null;
146
+ for (const link of sortedDirect) {
147
+ const row = resolvedCategories.get(link.categoryId);
148
+ if (row) {
149
+ primary = row;
150
+ break;
151
+ }
152
+ }
153
+ // Categories[] = direct links + ancestors, deduped, ordered by direct-link
154
+ // sortOrder first then ancestor walk order. Stable enough that storefronts
155
+ // can rely on `categories[0]` being a representative label.
156
+ const seenIds = new Set();
157
+ const categoryIds = [];
158
+ const categoryNames = [];
159
+ const categorySlugs = [];
160
+ function emit(row) {
161
+ if (seenIds.has(row.id))
162
+ return;
163
+ seenIds.add(row.id);
164
+ categoryIds.push(row.id);
165
+ // Locale override wins; otherwise use the canonical row.name.
166
+ categoryNames.push(categoryNameByLocale.get(row.id) ?? row.name);
167
+ categorySlugs.push(row.slug);
168
+ }
169
+ // Pass 1: direct links first, in operator-controlled order.
170
+ for (const link of sortedDirect) {
171
+ const row = resolvedCategories.get(link.categoryId);
172
+ if (row)
173
+ emit(row);
174
+ }
175
+ // Pass 2: ancestors of direct links, walking up via parentId.
176
+ for (const link of sortedDirect) {
177
+ let cursor = resolvedCategories.get(link.categoryId)?.parentId ?? null;
178
+ const guard = new Set(); // local guard against malformed loops
179
+ while (cursor && !guard.has(cursor)) {
180
+ guard.add(cursor);
181
+ const parentRow = resolvedCategories.get(cursor);
182
+ if (!parentRow)
183
+ break;
184
+ emit(parentRow);
185
+ cursor = parentRow.parentId;
186
+ }
187
+ }
188
+ return {
189
+ categoryIds,
190
+ categoryNames,
191
+ categorySlugs,
192
+ primaryCategoryId: primary?.id ?? null,
193
+ primaryCategoryName: primary ? (categoryNameByLocale.get(primary.id) ?? primary.name) : null,
194
+ primaryCategorySlug: primary?.slug ?? null,
195
+ tagIds: tags.map((t) => t.id),
196
+ tagLabels: tags.map((t) => tagNameByLocale.get(t.id) ?? t.name),
197
+ };
198
+ }
199
+ /**
200
+ * Construct the taxonomy projection extension.
201
+ *
202
+ * Returns a `ProductProjectionExtension` ready to pass to
203
+ * `createProductDocumentBuilder`.
204
+ */
205
+ export function createProductTaxonomyProjectionExtension() {
206
+ return {
207
+ name: "products:taxonomy",
208
+ async project(db, productId, slice) {
209
+ const [directLinks, tags] = await Promise.all([
210
+ fetchDirectCategoryLinks(db, productId),
211
+ fetchProductTags(db, productId),
212
+ ]);
213
+ const seedIds = directLinks.map((l) => l.categoryId);
214
+ const resolvedCategories = seedIds.length > 0
215
+ ? await walkActiveCategoryChain(db, seedIds)
216
+ : new Map();
217
+ // Translations cover the FULL chain (direct + ancestors), so look up
218
+ // by every resolved-category id, not just the seed set.
219
+ const allCategoryIds = Array.from(resolvedCategories.keys());
220
+ const tagIds = tags.map((t) => t.id);
221
+ const [categoryNameByLocale, tagNameByLocale] = await Promise.all([
222
+ fetchCategoryTranslations(db, allCategoryIds, slice.locale),
223
+ fetchTagTranslations(db, tagIds, slice.locale),
224
+ ]);
225
+ const projection = buildTaxonomyProjection(directLinks, resolvedCategories, tags, categoryNameByLocale, tagNameByLocale);
226
+ return new Map([
227
+ ["categories[]", projection.categoryNames],
228
+ ["categoryIds[]", projection.categoryIds],
229
+ ["categorySlugs[]", projection.categorySlugs],
230
+ ["primaryCategoryId", projection.primaryCategoryId],
231
+ ["primaryCategoryName", projection.primaryCategoryName],
232
+ ["primaryCategorySlug", projection.primaryCategorySlug],
233
+ ["tagLabels[]", projection.tagLabels],
234
+ ["tagIds[]", projection.tagIds],
235
+ ]);
236
+ },
237
+ };
238
+ }
239
+ // Internal exports for unit tests — kept separate from the public surface.
240
+ export const __test__ = {
241
+ buildTaxonomyProjection,
242
+ };
@@ -18,7 +18,7 @@
18
18
  * pattern this file establishes (replicated for cruises, hospitality, etc.
19
19
  * in their own service-catalog-plane.ts files).
20
20
  */
21
- import { type CaptureSnapshotInput, type DocumentBuilder, type DocumentEmitter, type IndexerDocument, type IndexerSlice, type PricingBasis, type Provenance, type ResolvedView, type ResolverScope, type Visibility } from "@voyantjs/catalog";
21
+ import { type CaptureSnapshotInput, type DocumentBuilder, type DocumentEmitter, type FieldPolicy, type FieldPolicyRegistry, type IndexerDocument, type IndexerSlice, type PricingBasis, type Provenance, type ResolvedView, type ResolverScope, type Visibility } from "@voyantjs/catalog";
22
22
  import type { AnyDrizzleDb } from "@voyantjs/db";
23
23
  import { products } from "./schema-core.js";
24
24
  /**
@@ -99,6 +99,37 @@ export declare function listResolvedProducts(db: AnyDrizzleDb, rows: ReadonlyArr
99
99
  export declare function buildProductSnapshotInput(db: AnyDrizzleDb, productId: string, context: ProductCatalogContext & {
100
100
  pricingBasis?: PricingBasis;
101
101
  }): Promise<Omit<CaptureSnapshotInput, "bookingId"> | null>;
102
+ /**
103
+ * A projection extension contributes additional field-keyed entries to the
104
+ * product search document. The builder runs all extensions in parallel after
105
+ * fetching the product row, then merges their entries into the base
106
+ * projection before emitting.
107
+ *
108
+ * Used by child-entity registries (destinations, taxonomy, departures, etc.)
109
+ * to denormalize fields onto the product doc. See architecture §5.4 — the
110
+ * search index is the canonical place for cross-entity denormalization.
111
+ *
112
+ * `buildIndexerDocument` silently drops entries whose paths aren't registered
113
+ * in the field-policy registry — so an extension's contributed registry must
114
+ * be composed into the registry passed to `createProductDocumentBuilder` for
115
+ * its fields to actually land in the document.
116
+ */
117
+ export interface ProductProjectionExtension {
118
+ /** Identifier — used for diagnostics and logging only. */
119
+ readonly name: string;
120
+ /**
121
+ * Contribute additional projection entries for one product. The slice
122
+ * carries locale and audience for translation lookups and audience
123
+ * filtering.
124
+ */
125
+ project(db: AnyDrizzleDb, productId: string, slice: IndexerSlice): Promise<ReadonlyMap<string, unknown>>;
126
+ }
127
+ /**
128
+ * Compose the registry from the base product policy plus any contributing
129
+ * extensions' policies. Templates wire this when they enable child-entity
130
+ * registries.
131
+ */
132
+ export declare function createProductsRegistry(...extensionPolicies: ReadonlyArray<ReadonlyArray<FieldPolicy>>): FieldPolicyRegistry;
102
133
  /**
103
134
  * Construct a sync `DocumentEmitter` for products. The emitter takes a
104
135
  * pre-fetched product row + a slice and returns the indexer document
@@ -107,9 +138,13 @@ export declare function buildProductSnapshotInput(db: AnyDrizzleDb, productId: s
107
138
  * Bulk-reindex pipelines that already have rows in hand call this directly.
108
139
  * Live reindex paths use `createProductDocumentBuilder` below, which fetches
109
140
  * the row before emitting.
141
+ *
142
+ * Pass a custom `registry` when the deployment composes additional
143
+ * child-entity policies; otherwise the default products registry is used.
110
144
  */
111
145
  export declare function createProductDocumentEmitter(context: {
112
146
  sellerOperatorId: string;
147
+ registry?: FieldPolicyRegistry;
113
148
  }): DocumentEmitter<typeof products.$inferSelect>;
114
149
  /**
115
150
  * Async `DocumentBuilder` for products — fetches the row by id, then emits.
@@ -118,9 +153,19 @@ export declare function createProductDocumentEmitter(context: {
118
153
  * Returns `null` if the product no longer exists (e.g. it was deleted
119
154
  * between the reindex enqueue and the worker picking it up). Callers can
120
155
  * treat `null` as a delete signal.
156
+ *
157
+ * `extensions` denormalize child-entity fields onto the product doc. They
158
+ * run in parallel after the base row is fetched. An extension that throws
159
+ * fails the whole build — failures here would otherwise produce silently
160
+ * incomplete documents.
161
+ *
162
+ * Pass a custom `registry` (composed via `createProductsRegistry`) when
163
+ * extensions contribute fields beyond the base products policy.
121
164
  */
122
165
  export declare function createProductDocumentBuilder(db: AnyDrizzleDb, context: {
123
166
  sellerOperatorId: string;
167
+ extensions?: ReadonlyArray<ProductProjectionExtension>;
168
+ registry?: FieldPolicyRegistry;
124
169
  }): DocumentBuilder;
125
170
  /**
126
171
  * Re-exports for routes that only import from this file.
@@ -1 +1 @@
1
- {"version":3,"file":"service-catalog-plane.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAGL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EACpB,KAAK,eAAe,EAEpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,aAAa,EAElB,KAAK,UAAU,EAChB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAc3C;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,OAAO,QAAQ,CAAC,YAAY,EACjC,OAAO,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACpC,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAwC9B;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,OAAO,QAAQ,CAAC,YAAY,EAClC,QAAQ,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACrC,UAAU,CAKZ;AAED,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,gBAAgB,EAAE,MAAM,CAAA;IACxB,qCAAqC;IACrC,KAAK,EAAE,aAAa,CAAA;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,YAAY,EAChB,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAS9B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,aAAa,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,EACjD,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,EAAE,CAAC,CAkBzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,qBAAqB,GAAG;IAAE,YAAY,CAAC,EAAE,YAAY,CAAA;CAAE,GAC/D,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,WAAW,CAAC,GAAG,IAAI,CAAC,CASzD;AAMD;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,gBAAgB,EAAE,MAAM,CAAA;CACzB,GAAG,eAAe,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,CAWhD;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAC1C,EAAE,EAAE,YAAY,EAChB,OAAO,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACpC,eAAe,CAQjB;AAED;;GAEG;AACH,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,aAAa,EACb,UAAU,GACX,CAAA"}
1
+ {"version":3,"file":"service-catalog-plane.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAGL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,aAAa,EAElB,KAAK,UAAU,EAChB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAc3C;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,OAAO,QAAQ,CAAC,YAAY,EACjC,OAAO,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACpC,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAwC9B;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,OAAO,QAAQ,CAAC,YAAY,EAClC,QAAQ,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACrC,UAAU,CAKZ;AAED,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,gBAAgB,EAAE,MAAM,CAAA;IACxB,qCAAqC;IACrC,KAAK,EAAE,aAAa,CAAA;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,YAAY,EAChB,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAS9B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,aAAa,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,EACjD,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,EAAE,CAAC,CAkBzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,qBAAqB,GAAG;IAAE,YAAY,CAAC,EAAE,YAAY,CAAA;CAAE,GAC/D,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,WAAW,CAAC,GAAG,IAAI,CAAC,CASzD;AAMD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,0BAA0B;IACzC,0DAA0D;IAC1D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,OAAO,CACL,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,YAAY,GAClB,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CACzC;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,iBAAiB,EAAE,aAAa,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC,GAC9D,mBAAmB,CAOrB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAC/B,GAAG,eAAe,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,CAWhD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,4BAA4B,CAC1C,EAAE,EAAE,YAAY,EAChB,OAAO,EAAE;IACP,gBAAgB,EAAE,MAAM,CAAA;IACxB,UAAU,CAAC,EAAE,aAAa,CAAC,0BAA0B,CAAC,CAAA;IACtD,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAC/B,GACA,eAAe,CA0BjB;AAED;;GAEG;AACH,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,aAAa,EACb,UAAU,GACX,CAAA"}
@@ -168,9 +168,20 @@ export async function buildProductSnapshotInput(db, productId, context) {
168
168
  pricingBasis: context.pricingBasis,
169
169
  });
170
170
  }
171
- // ─────────────────────────────────────────────────────────────────────────────
172
- // Indexer document emission
173
- // ─────────────────────────────────────────────────────────────────────────────
171
+ /**
172
+ * Compose the registry from the base product policy plus any contributing
173
+ * extensions' policies. Templates wire this when they enable child-entity
174
+ * registries.
175
+ */
176
+ export function createProductsRegistry(...extensionPolicies) {
177
+ if (extensionPolicies.length === 0)
178
+ return getProductsRegistry();
179
+ const composed = [...productCatalogPolicy];
180
+ for (const policies of extensionPolicies) {
181
+ composed.push(...policies);
182
+ }
183
+ return createFieldPolicyRegistry(composed);
184
+ }
174
185
  /**
175
186
  * Construct a sync `DocumentEmitter` for products. The emitter takes a
176
187
  * pre-fetched product row + a slice and returns the indexer document
@@ -179,9 +190,12 @@ export async function buildProductSnapshotInput(db, productId, context) {
179
190
  * Bulk-reindex pipelines that already have rows in hand call this directly.
180
191
  * Live reindex paths use `createProductDocumentBuilder` below, which fetches
181
192
  * the row before emitting.
193
+ *
194
+ * Pass a custom `registry` when the deployment composes additional
195
+ * child-entity policies; otherwise the default products registry is used.
182
196
  */
183
197
  export function createProductDocumentEmitter(context) {
184
- const registry = getProductsRegistry();
198
+ const registry = context.registry ?? getProductsRegistry();
185
199
  return {
186
200
  vertical: "products",
187
201
  emit(source, slice) {
@@ -199,14 +213,36 @@ export function createProductDocumentEmitter(context) {
199
213
  * Returns `null` if the product no longer exists (e.g. it was deleted
200
214
  * between the reindex enqueue and the worker picking it up). Callers can
201
215
  * treat `null` as a delete signal.
216
+ *
217
+ * `extensions` denormalize child-entity fields onto the product doc. They
218
+ * run in parallel after the base row is fetched. An extension that throws
219
+ * fails the whole build — failures here would otherwise produce silently
220
+ * incomplete documents.
221
+ *
222
+ * Pass a custom `registry` (composed via `createProductsRegistry`) when
223
+ * extensions contribute fields beyond the base products policy.
202
224
  */
203
225
  export function createProductDocumentBuilder(db, context) {
204
- const emitter = createProductDocumentEmitter(context);
226
+ const registry = context.registry ?? getProductsRegistry();
227
+ const extensions = context.extensions ?? [];
205
228
  return async (entityId, slice) => {
206
229
  const rows = await db.select().from(products).where(eq(products.id, entityId)).limit(1);
207
230
  const row = rows[0];
208
231
  if (!row)
209
232
  return null;
210
- return emitter.emit(row, slice);
233
+ const baseProjection = productRowToProjection(row, {
234
+ sellerOperatorId: context.sellerOperatorId,
235
+ });
236
+ if (extensions.length === 0) {
237
+ return buildIndexerDocument(registry, baseProjection, slice, entityId);
238
+ }
239
+ const extensionProjections = await Promise.all(extensions.map((ext) => ext.project(db, entityId, slice)));
240
+ const merged = new Map(baseProjection);
241
+ for (const projection of extensionProjections) {
242
+ for (const [path, value] of projection) {
243
+ merged.set(path, value);
244
+ }
245
+ }
246
+ return buildIndexerDocument(registry, merged, slice, entityId);
211
247
  };
212
248
  }