@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.
- package/dist/booking-engine/handler.d.ts +8 -0
- package/dist/booking-engine/handler.d.ts.map +1 -1
- package/dist/booking-engine/handler.js +38 -0
- package/dist/catalog-policy-departures.d.ts +52 -0
- package/dist/catalog-policy-departures.d.ts.map +1 -0
- package/dist/catalog-policy-departures.js +169 -0
- package/dist/catalog-policy-destinations.d.ts +41 -0
- package/dist/catalog-policy-destinations.d.ts.map +1 -0
- package/dist/catalog-policy-destinations.js +121 -0
- package/dist/catalog-policy-pricing.d.ts +55 -0
- package/dist/catalog-policy-pricing.d.ts.map +1 -0
- package/dist/catalog-policy-pricing.js +109 -0
- package/dist/catalog-policy-promotions.d.ts +52 -0
- package/dist/catalog-policy-promotions.d.ts.map +1 -0
- package/dist/catalog-policy-promotions.js +270 -0
- package/dist/catalog-policy-taxonomy.d.ts +51 -0
- package/dist/catalog-policy-taxonomy.d.ts.map +1 -0
- package/dist/catalog-policy-taxonomy.js +191 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/routes.d.ts +254 -19
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +73 -5
- package/dist/schema-taxonomy.d.ts +287 -0
- package/dist/schema-taxonomy.d.ts.map +1 -1
- package/dist/schema-taxonomy.js +47 -0
- package/dist/service-catalog-plane-destinations.d.ts +30 -0
- package/dist/service-catalog-plane-destinations.d.ts.map +1 -0
- package/dist/service-catalog-plane-destinations.js +119 -0
- package/dist/service-catalog-plane-taxonomy.d.ts +73 -0
- package/dist/service-catalog-plane-taxonomy.d.ts.map +1 -0
- package/dist/service-catalog-plane-taxonomy.js +242 -0
- package/dist/service-catalog-plane.d.ts +46 -1
- package/dist/service-catalog-plane.d.ts.map +1 -1
- package/dist/service-catalog-plane.js +42 -6
- package/dist/service.d.ts +98 -19
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +142 -1
- package/dist/tasks/brochures.d.ts +1 -1
- package/dist/validation-content.d.ts +38 -0
- package/dist/validation-content.d.ts.map +1 -1
- package/dist/validation-content.js +29 -0
- 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,
|
|
1
|
+
{"version":3,"file":"service-catalog-plane.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane.ts"],"names":[],"mappings":"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"}
|
|
@@ -168,9 +168,20 @@ export async function buildProductSnapshotInput(db, productId, context) {
|
|
|
168
168
|
pricingBasis: context.pricingBasis,
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|