@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.
- package/dist/booking-engine/handler.d.ts +203 -0
- package/dist/booking-engine/handler.d.ts.map +1 -0
- package/dist/booking-engine/handler.js +330 -0
- package/dist/booking-engine/index.d.ts +8 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +7 -0
- package/dist/catalog-policy.d.ts +33 -0
- package/dist/catalog-policy.d.ts.map +1 -0
- package/dist/catalog-policy.js +421 -0
- package/dist/content-shape.d.ts +217 -0
- package/dist/content-shape.d.ts.map +1 -0
- package/dist/content-shape.js +159 -0
- package/dist/draft-shape.d.ts +43 -0
- package/dist/draft-shape.d.ts.map +1 -0
- package/dist/draft-shape.js +46 -0
- package/dist/events.d.ts +37 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +32 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/routes-content.d.ts +74 -0
- package/dist/routes-content.d.ts.map +1 -0
- package/dist/routes-content.js +117 -0
- package/dist/routes.d.ts +47 -26
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +88 -16
- package/dist/schema-core.d.ts +240 -1
- package/dist/schema-core.d.ts.map +1 -1
- package/dist/schema-core.js +49 -0
- package/dist/schema-itinerary.d.ts +18 -1
- package/dist/schema-itinerary.d.ts.map +1 -1
- package/dist/schema-itinerary.js +1 -0
- package/dist/schema-settings.d.ts +1 -1
- package/dist/schema-sourced-content.d.ts +262 -0
- package/dist/schema-sourced-content.d.ts.map +1 -0
- package/dist/schema-sourced-content.js +69 -0
- package/dist/schema-taxonomy.d.ts +17 -0
- package/dist/schema-taxonomy.d.ts.map +1 -1
- package/dist/schema-taxonomy.js +13 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -0
- package/dist/service-catalog-plane.d.ts +129 -0
- package/dist/service-catalog-plane.d.ts.map +1 -0
- package/dist/service-catalog-plane.js +212 -0
- package/dist/service-content-owned.d.ts +68 -0
- package/dist/service-content-owned.d.ts.map +1 -0
- package/dist/service-content-owned.js +224 -0
- package/dist/service-content-synthesizer.d.ts +90 -0
- package/dist/service-content-synthesizer.d.ts.map +1 -0
- package/dist/service-content-synthesizer.js +171 -0
- package/dist/service-content.d.ts +106 -0
- package/dist/service-content.d.ts.map +1 -0
- package/dist/service-content.js +365 -0
- package/dist/service.d.ts +82 -28
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +4 -0
- package/dist/tasks/brochures.d.ts +2 -1
- package/dist/tasks/brochures.d.ts.map +1 -1
- package/dist/tasks/brochures.js +3 -0
- package/dist/validation-catalog.d.ts +4 -4
- package/dist/validation-config.d.ts +3 -3
- package/dist/validation-content.d.ts +34 -4
- package/dist/validation-content.d.ts.map +1 -1
- package/dist/validation-content.js +13 -0
- package/dist/validation-core.d.ts +53 -3
- package/dist/validation-core.d.ts.map +1 -1
- package/dist/validation-core.js +16 -0
- package/dist/validation-public.d.ts +9 -9
- package/dist/validation-shared.d.ts +4 -4
- 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" });
|