@voyantjs/products 0.19.0 → 0.21.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 (72) hide show
  1. package/dist/booking-engine/handler.d.ts +203 -0
  2. package/dist/booking-engine/handler.d.ts.map +1 -0
  3. package/dist/booking-engine/handler.js +330 -0
  4. package/dist/booking-engine/index.d.ts +8 -0
  5. package/dist/booking-engine/index.d.ts.map +1 -0
  6. package/dist/booking-engine/index.js +7 -0
  7. package/dist/catalog-policy.d.ts +33 -0
  8. package/dist/catalog-policy.d.ts.map +1 -0
  9. package/dist/catalog-policy.js +421 -0
  10. package/dist/content-shape.d.ts +217 -0
  11. package/dist/content-shape.d.ts.map +1 -0
  12. package/dist/content-shape.js +159 -0
  13. package/dist/draft-shape.d.ts +43 -0
  14. package/dist/draft-shape.d.ts.map +1 -0
  15. package/dist/draft-shape.js +46 -0
  16. package/dist/events.d.ts +37 -0
  17. package/dist/events.d.ts.map +1 -0
  18. package/dist/events.js +32 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -0
  22. package/dist/routes-content.d.ts +74 -0
  23. package/dist/routes-content.d.ts.map +1 -0
  24. package/dist/routes-content.js +117 -0
  25. package/dist/routes.d.ts +47 -26
  26. package/dist/routes.d.ts.map +1 -1
  27. package/dist/routes.js +88 -16
  28. package/dist/schema-core.d.ts +240 -1
  29. package/dist/schema-core.d.ts.map +1 -1
  30. package/dist/schema-core.js +49 -0
  31. package/dist/schema-itinerary.d.ts +18 -1
  32. package/dist/schema-itinerary.d.ts.map +1 -1
  33. package/dist/schema-itinerary.js +1 -0
  34. package/dist/schema-settings.d.ts +1 -1
  35. package/dist/schema-sourced-content.d.ts +262 -0
  36. package/dist/schema-sourced-content.d.ts.map +1 -0
  37. package/dist/schema-sourced-content.js +69 -0
  38. package/dist/schema-taxonomy.d.ts +17 -0
  39. package/dist/schema-taxonomy.d.ts.map +1 -1
  40. package/dist/schema-taxonomy.js +13 -0
  41. package/dist/schema.d.ts +1 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +1 -0
  44. package/dist/service-catalog-plane.d.ts +129 -0
  45. package/dist/service-catalog-plane.d.ts.map +1 -0
  46. package/dist/service-catalog-plane.js +212 -0
  47. package/dist/service-content-owned.d.ts +68 -0
  48. package/dist/service-content-owned.d.ts.map +1 -0
  49. package/dist/service-content-owned.js +224 -0
  50. package/dist/service-content-synthesizer.d.ts +90 -0
  51. package/dist/service-content-synthesizer.d.ts.map +1 -0
  52. package/dist/service-content-synthesizer.js +171 -0
  53. package/dist/service-content.d.ts +106 -0
  54. package/dist/service-content.d.ts.map +1 -0
  55. package/dist/service-content.js +365 -0
  56. package/dist/service.d.ts +82 -28
  57. package/dist/service.d.ts.map +1 -1
  58. package/dist/service.js +4 -0
  59. package/dist/tasks/brochures.d.ts +2 -1
  60. package/dist/tasks/brochures.d.ts.map +1 -1
  61. package/dist/tasks/brochures.js +3 -0
  62. package/dist/validation-catalog.d.ts +4 -4
  63. package/dist/validation-config.d.ts +3 -3
  64. package/dist/validation-content.d.ts +34 -4
  65. package/dist/validation-content.d.ts.map +1 -1
  66. package/dist/validation-content.js +13 -0
  67. package/dist/validation-core.d.ts +53 -3
  68. package/dist/validation-core.d.ts.map +1 -1
  69. package/dist/validation-core.js +16 -0
  70. package/dist/validation-public.d.ts +9 -9
  71. package/dist/validation-shared.d.ts +4 -4
  72. package/package.json +12 -6
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Product content service — `getProductContent` with owned-vs-sourced
3
+ * dispatch, locale-resolved cache reads, SWR refresh, and synthesizer
4
+ * fallback.
5
+ *
6
+ * One entry point per vertical; the catalog plane stays neutral about
7
+ * per-vertical content shapes. Detail routes (operator and storefront)
8
+ * call `getProductContent(db, entityId, scope, options)` and get back a
9
+ * fully-resolved `ContentLocaleResolution<ProductContent>` regardless
10
+ * of whether the row is owned or sourced.
11
+ *
12
+ * Owned rows (entities in the products table without a sourced-entry
13
+ * row) read from the products tables directly — out of scope for this
14
+ * file in v1; callers that need owned reads compose them around this
15
+ * service. Phase D ships sourced + synthesizer; owned dispatch
16
+ * narrows when first sourced template adopts.
17
+ *
18
+ * See `docs/architecture/catalog-sourced-content.md` §3.3, §3.4, §3.6.
19
+ */
20
+ import { createInvalidateOnDrift, fetchOverlaysForEntity, isStale, pickBestCachedLocale, readSourcedEntry, withContentRefreshLock, } from "@voyantjs/catalog";
21
+ import { and, eq } from "drizzle-orm";
22
+ import { mergeOverlaysIntoProductContent, PRODUCTS_CONTENT_SCHEMA_VERSION, productContentSchema, validateProductContent, } from "./content-shape.js";
23
+ import { PRODUCTS_CONTENT_MARKET_ANY, productsSourcedContentTable, } from "./schema-sourced-content.js";
24
+ import { buildOwnedProductContent } from "./service-content-owned.js";
25
+ import { synthesizeProductContent, } from "./service-content-synthesizer.js";
26
+ /** Default TTL when the adapter doesn't pin `fresh_until`. */
27
+ const PRODUCTS_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h, per §3.4
28
+ /**
29
+ * Read the rich product content for one entity, resolving locale
30
+ * preference, applying overlays, and refreshing in the background when
31
+ * stale. Returns `null` only when the entity is unknown (no
32
+ * sourced-entry row, no owned row).
33
+ */
34
+ export async function getProductContent(db, entityId, scope, options) {
35
+ const sourcedEntry = await readSourcedEntry(db, "products", entityId);
36
+ if (!sourcedEntry) {
37
+ // Owned-product path. Read from the products module's own tables
38
+ // and project to ProductContent — locale resolution against
39
+ // product_translations + product_option_translations uses the
40
+ // same pickBestCachedLocale scoring the sourced cache reads use.
41
+ // Overlay merge applies the same way it does for sourced rows.
42
+ const owned = await buildOwnedProductContent(db, entityId, {
43
+ preferredLocales: scope.preferredLocales,
44
+ });
45
+ if (!owned)
46
+ return null;
47
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
48
+ const merged = mergeOverlaysIntoProductContent(owned.content, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
49
+ onOverlayError: options.onOverlayError
50
+ ? (e) => options.onOverlayError({
51
+ field_path: e.overlay.field_path,
52
+ reason: e.reason,
53
+ })
54
+ : undefined,
55
+ });
56
+ return {
57
+ content: merged,
58
+ resolution: {
59
+ candidate: { locale: owned.servedLocale, payload: merged },
60
+ served_locale: owned.servedLocale,
61
+ match_kind: owned.matchKind,
62
+ },
63
+ source: "owned",
64
+ served_stale: false,
65
+ synthesized: false,
66
+ machine_translated: false,
67
+ };
68
+ }
69
+ // Wrap the entry as a ProvenanceReadResult so the synthesizer can
70
+ // consume it without re-reading.
71
+ const provenance = {
72
+ kind: "sourced",
73
+ provenance: {
74
+ source_kind: sourcedEntry.source_kind,
75
+ source_provider: sourcedEntry.source_provider ?? undefined,
76
+ source_connection_id: sourcedEntry.source_connection_id ?? undefined,
77
+ source_ref: sourcedEntry.source_ref ?? undefined,
78
+ source_freshness: sourcedEntry.source_freshness,
79
+ last_sourced_at: sourcedEntry.last_sourced_at ?? undefined,
80
+ },
81
+ entry_id: sourcedEntry.id,
82
+ status: sourcedEntry.status,
83
+ projection: sourcedEntry.projection,
84
+ projection_etag: sourcedEntry.projection_etag,
85
+ projection_seen_at: sourcedEntry.projection_seen_at,
86
+ first_seen_at: sourcedEntry.first_seen_at,
87
+ last_seen_at: sourcedEntry.last_seen_at,
88
+ };
89
+ const adapter = sourcedEntry.source_connection_id
90
+ ? (options.registry.resolveByConnection(sourcedEntry.source_connection_id) ??
91
+ options.registry.byKind(sourcedEntry.source_kind)[0]?.adapter)
92
+ : options.registry.byKind(sourcedEntry.source_kind)[0]?.adapter;
93
+ const adapterCtx = options.buildAdapterContext?.(adapter) ?? {
94
+ connection_id: sourcedEntry.source_connection_id ?? sourcedEntry.source_kind,
95
+ };
96
+ const market = scope.market ?? PRODUCTS_CONTENT_MARKET_ANY;
97
+ const acceptMT = scope.acceptMachineTranslated ?? true;
98
+ if (options.forceFresh && adapter?.getContent) {
99
+ const fresh = await fetchFreshContent(db, adapter, adapterCtx, {
100
+ entity_module: "products",
101
+ entity_id: entityId,
102
+ locale: scope.preferredLocales[0] ?? "en-GB",
103
+ market,
104
+ currency: scope.currency,
105
+ }, options);
106
+ if (fresh) {
107
+ return finalizeFresh(db, entityId, fresh, scope, options);
108
+ }
109
+ }
110
+ // 1. Look up cached candidates across all locales for this entity.
111
+ const cachedRows = await fetchCacheCandidates(db, entityId, market);
112
+ const eligibleRows = acceptMT ? cachedRows : cachedRows.filter((r) => !r.machine_translated);
113
+ const best = pickBestCachedLocale(eligibleRows.map((row) => ({ ...row, locale: row.locale })), scope.preferredLocales);
114
+ const shouldRefreshLegacyAvailability = best
115
+ ? hasLegacyDepartureAvailabilityGap(best.candidate)
116
+ : false;
117
+ if (best && !isStale(best.candidate) && !shouldRefreshLegacyAvailability) {
118
+ return finalizeFromCache(db, entityId, best, "sourced-cache", false, options);
119
+ }
120
+ if (best && (isStale(best.candidate) || shouldRefreshLegacyAvailability)) {
121
+ // SWR for ordinary stale reads. Legacy demo content without
122
+ // departure capacity is refreshed synchronously so operator
123
+ // availability surfaces do not show effectively-unlimited slots.
124
+ if (adapter?.getContent) {
125
+ const refreshRequest = {
126
+ entity_module: "products",
127
+ entity_id: entityId,
128
+ locale: scope.preferredLocales[0] ?? best.candidate.locale,
129
+ market,
130
+ currency: scope.currency,
131
+ };
132
+ if (shouldRefreshLegacyAvailability) {
133
+ const fresh = await fetchFreshContent(db, adapter, adapterCtx, refreshRequest, options);
134
+ if (fresh)
135
+ return finalizeFresh(db, entityId, fresh, scope, options);
136
+ }
137
+ else {
138
+ void scheduleRefresh(db, adapter, adapterCtx, refreshRequest);
139
+ }
140
+ }
141
+ return finalizeFromCache(db, entityId, best, "sourced-cache", true, options);
142
+ }
143
+ // No cache row at all — must produce content somehow.
144
+ if (!adapter?.getContent) {
145
+ // Thin adapter or no adapter registered — synthesize from
146
+ // projection + overlay + plane metadata (§3.6).
147
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
148
+ const synthesized = synthesizeProductContent({ locale: scope.preferredLocales[0] ?? "en-GB" }, {
149
+ provenance,
150
+ overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
151
+ });
152
+ return wrapSynthesized(synthesized, scope, false);
153
+ }
154
+ // Cache miss with a rich adapter — block on the adapter, dedupe
155
+ // across workers via advisory lock, and write through to the cache.
156
+ const fresh = await fetchFreshContent(db, adapter, adapterCtx, {
157
+ entity_module: "products",
158
+ entity_id: entityId,
159
+ locale: scope.preferredLocales[0] ?? "en-GB",
160
+ market,
161
+ currency: scope.currency,
162
+ }, options);
163
+ if (!fresh) {
164
+ // The adapter call could not get the lock AND there's no cached
165
+ // row — fall back to synthesizer rather than blocking forever.
166
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
167
+ const synthesized = synthesizeProductContent({ locale: scope.preferredLocales[0] ?? "en-GB" }, {
168
+ provenance,
169
+ overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
170
+ });
171
+ return wrapSynthesized(synthesized, scope, false);
172
+ }
173
+ return finalizeFresh(db, entityId, fresh, scope, options);
174
+ }
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ // Cache candidates
177
+ // ─────────────────────────────────────────────────────────────────────────────
178
+ async function fetchCacheCandidates(db, entityId, market) {
179
+ const rows = await db
180
+ .select()
181
+ .from(productsSourcedContentTable)
182
+ .where(and(eq(productsSourcedContentTable.entity_id, entityId), eq(productsSourcedContentTable.market, market), eq(productsSourcedContentTable.content_schema_version, PRODUCTS_CONTENT_SCHEMA_VERSION)));
183
+ return rows;
184
+ }
185
+ function hasLegacyDepartureAvailabilityGap(row) {
186
+ const validation = validateProductContent(row.payload);
187
+ if (!validation.valid)
188
+ return false;
189
+ return validation.content.departures.some((departure) => departure.capacity == null && departure.remaining == null);
190
+ }
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+ // Fresh fetch + write-through
193
+ // ─────────────────────────────────────────────────────────────────────────────
194
+ async function fetchFreshContent(db, adapter, ctx, request, _options) {
195
+ const result = await withContentRefreshLock(db, {
196
+ entityModule: request.entity_module,
197
+ entityId: request.entity_id,
198
+ locale: request.locale,
199
+ market: request.market,
200
+ }, async () => {
201
+ const got = await adapter.getContent(ctx, request);
202
+ const validation = validateProductContent(got.content);
203
+ if (!validation.valid) {
204
+ // Surface adapter integration bugs, but don't write to cache.
205
+ throw new Error(`products getContent for ${request.entity_id} failed validation: ${validation.reason}`);
206
+ }
207
+ await writeCacheRow(db, request, got);
208
+ return got;
209
+ });
210
+ return result ?? null;
211
+ }
212
+ function scheduleRefresh(db, adapter, ctx, request) {
213
+ // Fire-and-forget. Errors are swallowed — a failed refresh just
214
+ // leaves the stale row in place; the next read tries again.
215
+ void withContentRefreshLock(db, {
216
+ entityModule: request.entity_module,
217
+ entityId: request.entity_id,
218
+ locale: request.locale,
219
+ market: request.market,
220
+ }, async () => {
221
+ const got = await adapter.getContent(ctx, request);
222
+ const validation = validateProductContent(got.content);
223
+ if (!validation.valid)
224
+ return;
225
+ await writeCacheRow(db, request, got);
226
+ }).catch(() => {
227
+ // intentionally swallow — see comment above
228
+ });
229
+ }
230
+ async function writeCacheRow(db, request, result) {
231
+ const market = request.market ?? PRODUCTS_CONTENT_MARKET_ANY;
232
+ const now = new Date();
233
+ // Date-like fields may arrive as strings when the adapter is an HTTP
234
+ // client (JSON.parse doesn't deserialize ISO timestamps to Date).
235
+ // Coerce at the cache-write boundary so the drizzle timestamp column
236
+ // gets a real Date — `value.toISOString is not a function` otherwise.
237
+ const sourceUpdatedAt = toDateOrNull(result.source_updated_at);
238
+ const freshUntil = toDateOrNull(result.fresh_until) ?? new Date(now.getTime() + PRODUCTS_DEFAULT_TTL_MS);
239
+ await db
240
+ .insert(productsSourcedContentTable)
241
+ .values({
242
+ entity_id: request.entity_id,
243
+ locale: request.locale,
244
+ market,
245
+ payload: result.content,
246
+ content_schema_version: result.content_schema_version,
247
+ returned_locale: result.returned_locale,
248
+ machine_translated: result.machine_translated ?? false,
249
+ source_updated_at: sourceUpdatedAt,
250
+ fetched_at: now,
251
+ fresh_until: freshUntil,
252
+ etag: result.etag ?? null,
253
+ fetch_status: "ok",
254
+ fetch_error: null,
255
+ })
256
+ .onConflictDoUpdate({
257
+ target: [
258
+ productsSourcedContentTable.entity_id,
259
+ productsSourcedContentTable.locale,
260
+ productsSourcedContentTable.market,
261
+ ],
262
+ set: {
263
+ payload: result.content,
264
+ content_schema_version: result.content_schema_version,
265
+ returned_locale: result.returned_locale,
266
+ machine_translated: result.machine_translated ?? false,
267
+ source_updated_at: sourceUpdatedAt,
268
+ fetched_at: now,
269
+ fresh_until: freshUntil,
270
+ etag: result.etag ?? null,
271
+ fetch_status: "ok",
272
+ fetch_error: null,
273
+ },
274
+ });
275
+ }
276
+ function toDateOrNull(value) {
277
+ if (!value)
278
+ return null;
279
+ if (value instanceof Date)
280
+ return value;
281
+ const parsed = new Date(value);
282
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
283
+ }
284
+ // ─────────────────────────────────────────────────────────────────────────────
285
+ // Finalizers — overlay merge + return shape
286
+ // ─────────────────────────────────────────────────────────────────────────────
287
+ async function finalizeFromCache(db, entityId, best, source, servedStale, options) {
288
+ const cachedPayload = best.candidate.payload;
289
+ const validation = validateProductContent(cachedPayload);
290
+ if (!validation.valid) {
291
+ // Schema-version-mismatch case is filtered upstream; if we hit
292
+ // here, the cache row is corrupt for some other reason. Treat as
293
+ // cache miss → caller's next layer (synthesizer) handles.
294
+ throw new Error(`products cache row for ${entityId} (${best.candidate.locale}) failed validation: ${validation.reason}`);
295
+ }
296
+ const cachedContent = validation.content;
297
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
298
+ const merged = mergeOverlaysIntoProductContent(cachedContent, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
299
+ onOverlayError: options.onOverlayError
300
+ ? (e) => options.onOverlayError({
301
+ field_path: e.overlay.field_path,
302
+ reason: e.reason,
303
+ })
304
+ : undefined,
305
+ });
306
+ return {
307
+ content: merged,
308
+ resolution: {
309
+ candidate: { locale: best.candidate.locale, payload: merged },
310
+ served_locale: best.candidate.returned_locale,
311
+ match_kind: best.match_kind,
312
+ },
313
+ source,
314
+ served_stale: servedStale,
315
+ synthesized: false,
316
+ machine_translated: best.candidate.machine_translated,
317
+ };
318
+ }
319
+ async function finalizeFresh(db, entityId, fresh, scope, options) {
320
+ const cachedContent = productContentSchema.parse(fresh.content);
321
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
322
+ const merged = mergeOverlaysIntoProductContent(cachedContent, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
323
+ onOverlayError: options.onOverlayError
324
+ ? (e) => options.onOverlayError({
325
+ field_path: e.overlay.field_path,
326
+ reason: e.reason,
327
+ })
328
+ : undefined,
329
+ });
330
+ return {
331
+ content: merged,
332
+ resolution: {
333
+ candidate: { locale: scope.preferredLocales[0] ?? fresh.returned_locale, payload: merged },
334
+ served_locale: fresh.returned_locale,
335
+ match_kind: scope.preferredLocales[0] === fresh.returned_locale ? "exact" : "language_match",
336
+ },
337
+ source: "sourced-fresh",
338
+ served_stale: false,
339
+ synthesized: false,
340
+ machine_translated: fresh.machine_translated ?? false,
341
+ };
342
+ }
343
+ function wrapSynthesized(synthesized, scope, servedStale) {
344
+ return {
345
+ content: synthesized.content,
346
+ resolution: {
347
+ candidate: { locale: synthesized.served_locale, payload: synthesized.content },
348
+ served_locale: synthesized.served_locale,
349
+ match_kind: scope.preferredLocales[0] === synthesized.served_locale ? "exact" : "any",
350
+ },
351
+ source: "synthesized",
352
+ served_stale: servedStale,
353
+ synthesized: true,
354
+ machine_translated: false,
355
+ };
356
+ }
357
+ /**
358
+ * Drift event consumer — sets `fresh_until = now()` on every cache row
359
+ * matching the event's (entity_module, entity_id [, locale [, market]])
360
+ * scope. The next read serves stale + schedules a SWR refresh.
361
+ *
362
+ * Templates subscribe this to the catalog plane's drift-event bus.
363
+ * Per sourced-content §3.4.1.
364
+ */
365
+ export const invalidateProductContentOnDrift = createInvalidateOnDrift(productsSourcedContentTable, { entityModule: "products" });