@voyant-travel/inventory 0.1.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 (311) hide show
  1. package/LICENSE +201 -0
  2. package/dist/action-ledger-drift.d.ts +29 -0
  3. package/dist/action-ledger-drift.d.ts.map +1 -0
  4. package/dist/action-ledger-drift.js +338 -0
  5. package/dist/action-ledger.d.ts +104 -0
  6. package/dist/action-ledger.d.ts.map +1 -0
  7. package/dist/action-ledger.js +100 -0
  8. package/dist/authoring/builder.d.ts +37 -0
  9. package/dist/authoring/builder.d.ts.map +1 -0
  10. package/dist/authoring/builder.js +248 -0
  11. package/dist/authoring/clone-content.d.ts +38 -0
  12. package/dist/authoring/clone-content.d.ts.map +1 -0
  13. package/dist/authoring/clone-content.js +367 -0
  14. package/dist/authoring/clone-pricing.d.ts +9 -0
  15. package/dist/authoring/clone-pricing.d.ts.map +1 -0
  16. package/dist/authoring/clone-pricing.js +242 -0
  17. package/dist/authoring/clone.d.ts +45 -0
  18. package/dist/authoring/clone.d.ts.map +1 -0
  19. package/dist/authoring/clone.js +142 -0
  20. package/dist/authoring/errors.d.ts +21 -0
  21. package/dist/authoring/errors.d.ts.map +1 -0
  22. package/dist/authoring/errors.js +13 -0
  23. package/dist/authoring/extension.d.ts +248 -0
  24. package/dist/authoring/extension.d.ts.map +1 -0
  25. package/dist/authoring/extension.js +116 -0
  26. package/dist/authoring/index.d.ts +12 -0
  27. package/dist/authoring/index.d.ts.map +1 -0
  28. package/dist/authoring/index.js +11 -0
  29. package/dist/authoring/schema.d.ts +85 -0
  30. package/dist/authoring/schema.d.ts.map +1 -0
  31. package/dist/authoring/schema.js +16 -0
  32. package/dist/authoring/service.d.ts +28 -0
  33. package/dist/authoring/service.d.ts.map +1 -0
  34. package/dist/authoring/service.js +66 -0
  35. package/dist/authoring/spec.d.ts +524 -0
  36. package/dist/authoring/spec.d.ts.map +1 -0
  37. package/dist/authoring/spec.js +167 -0
  38. package/dist/authoring/validate.d.ts +17 -0
  39. package/dist/authoring/validate.d.ts.map +1 -0
  40. package/dist/authoring/validate.js +83 -0
  41. package/dist/authoring.d.ts +2 -0
  42. package/dist/authoring.d.ts.map +1 -0
  43. package/dist/authoring.js +1 -0
  44. package/dist/booking-engine/handler-support.d.ts +91 -0
  45. package/dist/booking-engine/handler-support.d.ts.map +1 -0
  46. package/dist/booking-engine/handler-support.js +355 -0
  47. package/dist/booking-engine/handler.d.ts +404 -0
  48. package/dist/booking-engine/handler.d.ts.map +1 -0
  49. package/dist/booking-engine/handler.js +398 -0
  50. package/dist/booking-engine/index.d.ts +8 -0
  51. package/dist/booking-engine/index.d.ts.map +1 -0
  52. package/dist/booking-engine/index.js +7 -0
  53. package/dist/booking-engine.d.ts +2 -0
  54. package/dist/booking-engine.d.ts.map +1 -0
  55. package/dist/booking-engine.js +1 -0
  56. package/dist/booking-extension.d.ts +278 -0
  57. package/dist/booking-extension.d.ts.map +1 -0
  58. package/dist/booking-extension.js +161 -0
  59. package/dist/catalog-policy-departures.d.ts +52 -0
  60. package/dist/catalog-policy-departures.d.ts.map +1 -0
  61. package/dist/catalog-policy-departures.js +169 -0
  62. package/dist/catalog-policy-destinations.d.ts +43 -0
  63. package/dist/catalog-policy-destinations.d.ts.map +1 -0
  64. package/dist/catalog-policy-destinations.js +165 -0
  65. package/dist/catalog-policy-pricing.d.ts +55 -0
  66. package/dist/catalog-policy-pricing.d.ts.map +1 -0
  67. package/dist/catalog-policy-pricing.js +109 -0
  68. package/dist/catalog-policy-promotions.d.ts +52 -0
  69. package/dist/catalog-policy-promotions.d.ts.map +1 -0
  70. package/dist/catalog-policy-promotions.js +270 -0
  71. package/dist/catalog-policy-taxonomy.d.ts +51 -0
  72. package/dist/catalog-policy-taxonomy.d.ts.map +1 -0
  73. package/dist/catalog-policy-taxonomy.js +191 -0
  74. package/dist/catalog-policy.d.ts +33 -0
  75. package/dist/catalog-policy.d.ts.map +1 -0
  76. package/dist/catalog-policy.js +733 -0
  77. package/dist/content-shape.d.ts +15 -0
  78. package/dist/content-shape.d.ts.map +1 -0
  79. package/dist/content-shape.js +28 -0
  80. package/dist/draft-shape.d.ts +43 -0
  81. package/dist/draft-shape.d.ts.map +1 -0
  82. package/dist/draft-shape.js +48 -0
  83. package/dist/events.d.ts +37 -0
  84. package/dist/events.d.ts.map +1 -0
  85. package/dist/events.js +32 -0
  86. package/dist/extras/catalog-policy.d.ts +30 -0
  87. package/dist/extras/catalog-policy.d.ts.map +1 -0
  88. package/dist/extras/catalog-policy.js +319 -0
  89. package/dist/extras/content-shape.d.ts +5 -0
  90. package/dist/extras/content-shape.d.ts.map +1 -0
  91. package/dist/extras/content-shape.js +13 -0
  92. package/dist/extras/draft-shape.d.ts +34 -0
  93. package/dist/extras/draft-shape.d.ts.map +1 -0
  94. package/dist/extras/draft-shape.js +69 -0
  95. package/dist/extras/routes.d.ts +380 -0
  96. package/dist/extras/routes.d.ts.map +1 -0
  97. package/dist/extras/routes.js +59 -0
  98. package/dist/extras/schema-sourced-content.d.ts +254 -0
  99. package/dist/extras/schema-sourced-content.d.ts.map +1 -0
  100. package/dist/extras/schema-sourced-content.js +45 -0
  101. package/dist/extras/schema.d.ts +628 -0
  102. package/dist/extras/schema.d.ts.map +1 -0
  103. package/dist/extras/schema.js +87 -0
  104. package/dist/extras/service-catalog-plane.d.ts +77 -0
  105. package/dist/extras/service-catalog-plane.d.ts.map +1 -0
  106. package/dist/extras/service-catalog-plane.js +219 -0
  107. package/dist/extras/service-content-synthesizer.d.ts +41 -0
  108. package/dist/extras/service-content-synthesizer.d.ts.map +1 -0
  109. package/dist/extras/service-content-synthesizer.js +138 -0
  110. package/dist/extras/service-content.d.ts +48 -0
  111. package/dist/extras/service-content.d.ts.map +1 -0
  112. package/dist/extras/service-content.js +253 -0
  113. package/dist/extras/service.d.ts +185 -0
  114. package/dist/extras/service.d.ts.map +1 -0
  115. package/dist/extras/service.js +96 -0
  116. package/dist/extras/validation.d.ts +437 -0
  117. package/dist/extras/validation.d.ts.map +1 -0
  118. package/dist/extras/validation.js +149 -0
  119. package/dist/extras.d.ts +267 -0
  120. package/dist/extras.d.ts.map +1 -0
  121. package/dist/extras.js +19 -0
  122. package/dist/index.d.ts +22 -0
  123. package/dist/index.d.ts.map +1 -0
  124. package/dist/index.js +32 -0
  125. package/dist/interface.d.ts +5869 -0
  126. package/dist/interface.d.ts.map +1 -0
  127. package/dist/interface.js +54 -0
  128. package/dist/public-routes.d.ts +2 -0
  129. package/dist/public-routes.d.ts.map +1 -0
  130. package/dist/public-routes.js +1 -0
  131. package/dist/public-validation.d.ts +2 -0
  132. package/dist/public-validation.d.ts.map +1 -0
  133. package/dist/public-validation.js +1 -0
  134. package/dist/read-model.d.ts +25 -0
  135. package/dist/read-model.d.ts.map +1 -0
  136. package/dist/read-model.js +99 -0
  137. package/dist/route-env.d.ts +22 -0
  138. package/dist/route-env.d.ts.map +1 -0
  139. package/dist/route-env.js +1 -0
  140. package/dist/routes-associations.d.ts +164 -0
  141. package/dist/routes-associations.d.ts.map +1 -0
  142. package/dist/routes-associations.js +100 -0
  143. package/dist/routes-catalog.d.ts +436 -0
  144. package/dist/routes-catalog.d.ts.map +1 -0
  145. package/dist/routes-catalog.js +104 -0
  146. package/dist/routes-configuration.d.ts +773 -0
  147. package/dist/routes-configuration.d.ts.map +1 -0
  148. package/dist/routes-configuration.js +364 -0
  149. package/dist/routes-content.d.ts +74 -0
  150. package/dist/routes-content.d.ts.map +1 -0
  151. package/dist/routes-content.js +117 -0
  152. package/dist/routes-core.d.ts +331 -0
  153. package/dist/routes-core.d.ts.map +1 -0
  154. package/dist/routes-core.js +95 -0
  155. package/dist/routes-itinerary.d.ts +759 -0
  156. package/dist/routes-itinerary.d.ts.map +1 -0
  157. package/dist/routes-itinerary.js +387 -0
  158. package/dist/routes-maintenance.d.ts +32 -0
  159. package/dist/routes-maintenance.d.ts.map +1 -0
  160. package/dist/routes-maintenance.js +14 -0
  161. package/dist/routes-media.d.ts +634 -0
  162. package/dist/routes-media.d.ts.map +1 -0
  163. package/dist/routes-media.js +245 -0
  164. package/dist/routes-merchandising.d.ts +1120 -0
  165. package/dist/routes-merchandising.d.ts.map +1 -0
  166. package/dist/routes-merchandising.js +377 -0
  167. package/dist/routes-options.d.ts +363 -0
  168. package/dist/routes-options.d.ts.map +1 -0
  169. package/dist/routes-options.js +173 -0
  170. package/dist/routes-public.d.ts +776 -0
  171. package/dist/routes-public.d.ts.map +1 -0
  172. package/dist/routes-public.js +119 -0
  173. package/dist/routes-translations.d.ts +489 -0
  174. package/dist/routes-translations.d.ts.map +1 -0
  175. package/dist/routes-translations.js +258 -0
  176. package/dist/routes.d.ts +5097 -0
  177. package/dist/routes.d.ts.map +1 -0
  178. package/dist/routes.js +64 -0
  179. package/dist/schema-core.d.ts +1238 -0
  180. package/dist/schema-core.d.ts.map +1 -0
  181. package/dist/schema-core.js +157 -0
  182. package/dist/schema-itinerary.d.ts +1169 -0
  183. package/dist/schema-itinerary.d.ts.map +1 -0
  184. package/dist/schema-itinerary.js +130 -0
  185. package/dist/schema-relations.d.ts +117 -0
  186. package/dist/schema-relations.d.ts.map +1 -0
  187. package/dist/schema-relations.js +192 -0
  188. package/dist/schema-settings.d.ts +1800 -0
  189. package/dist/schema-settings.d.ts.map +1 -0
  190. package/dist/schema-settings.js +220 -0
  191. package/dist/schema-shared.d.ts +15 -0
  192. package/dist/schema-shared.d.ts.map +1 -0
  193. package/dist/schema-shared.js +91 -0
  194. package/dist/schema-sourced-content.d.ts +262 -0
  195. package/dist/schema-sourced-content.d.ts.map +1 -0
  196. package/dist/schema-sourced-content.js +69 -0
  197. package/dist/schema-taxonomy.d.ts +1363 -0
  198. package/dist/schema-taxonomy.d.ts.map +1 -0
  199. package/dist/schema-taxonomy.js +203 -0
  200. package/dist/schema.d.ts +10 -0
  201. package/dist/schema.d.ts.map +1 -0
  202. package/dist/schema.js +9 -0
  203. package/dist/service-aggregates.d.ts +29 -0
  204. package/dist/service-aggregates.d.ts.map +1 -0
  205. package/dist/service-aggregates.js +56 -0
  206. package/dist/service-catalog-plane-destinations.d.ts +30 -0
  207. package/dist/service-catalog-plane-destinations.d.ts.map +1 -0
  208. package/dist/service-catalog-plane-destinations.js +143 -0
  209. package/dist/service-catalog-plane-taxonomy.d.ts +73 -0
  210. package/dist/service-catalog-plane-taxonomy.d.ts.map +1 -0
  211. package/dist/service-catalog-plane-taxonomy.js +242 -0
  212. package/dist/service-catalog-plane.d.ts +179 -0
  213. package/dist/service-catalog-plane.d.ts.map +1 -0
  214. package/dist/service-catalog-plane.js +431 -0
  215. package/dist/service-catalog.d.ts +251 -0
  216. package/dist/service-catalog.d.ts.map +1 -0
  217. package/dist/service-catalog.js +517 -0
  218. package/dist/service-configuration.d.ts +261 -0
  219. package/dist/service-configuration.d.ts.map +1 -0
  220. package/dist/service-configuration.js +343 -0
  221. package/dist/service-content-owned.d.ts +68 -0
  222. package/dist/service-content-owned.d.ts.map +1 -0
  223. package/dist/service-content-owned.js +329 -0
  224. package/dist/service-content-synthesizer.d.ts +90 -0
  225. package/dist/service-content-synthesizer.d.ts.map +1 -0
  226. package/dist/service-content-synthesizer.js +178 -0
  227. package/dist/service-content.d.ts +106 -0
  228. package/dist/service-content.d.ts.map +1 -0
  229. package/dist/service-content.js +388 -0
  230. package/dist/service-core.d.ts +194 -0
  231. package/dist/service-core.d.ts.map +1 -0
  232. package/dist/service-core.js +213 -0
  233. package/dist/service-delivery-formats.d.ts +58 -0
  234. package/dist/service-delivery-formats.d.ts.map +1 -0
  235. package/dist/service-delivery-formats.js +107 -0
  236. package/dist/service-destinations.d.ts +223 -0
  237. package/dist/service-destinations.d.ts.map +1 -0
  238. package/dist/service-destinations.js +310 -0
  239. package/dist/service-itinerary-history.d.ts +457 -0
  240. package/dist/service-itinerary-history.d.ts.map +1 -0
  241. package/dist/service-itinerary-history.js +135 -0
  242. package/dist/service-itinerary.d.ts +1149 -0
  243. package/dist/service-itinerary.d.ts.map +1 -0
  244. package/dist/service-itinerary.js +419 -0
  245. package/dist/service-media.d.ts +272 -0
  246. package/dist/service-media.d.ts.map +1 -0
  247. package/dist/service-media.js +320 -0
  248. package/dist/service-merchandising.d.ts +184 -0
  249. package/dist/service-merchandising.d.ts.map +1 -0
  250. package/dist/service-merchandising.js +181 -0
  251. package/dist/service-option-translations.d.ts +268 -0
  252. package/dist/service-option-translations.d.ts.map +1 -0
  253. package/dist/service-option-translations.js +300 -0
  254. package/dist/service-options.d.ts +181 -0
  255. package/dist/service-options.d.ts.map +1 -0
  256. package/dist/service-options.js +179 -0
  257. package/dist/service-product-destinations.d.ts +37 -0
  258. package/dist/service-product-destinations.d.ts.map +1 -0
  259. package/dist/service-product-destinations.js +94 -0
  260. package/dist/service-public.d.ts +664 -0
  261. package/dist/service-public.d.ts.map +1 -0
  262. package/dist/service-public.js +374 -0
  263. package/dist/service-taxonomy.d.ts +197 -0
  264. package/dist/service-taxonomy.d.ts.map +1 -0
  265. package/dist/service-taxonomy.js +221 -0
  266. package/dist/service.d.ts +3929 -0
  267. package/dist/service.d.ts.map +1 -0
  268. package/dist/service.js +28 -0
  269. package/dist/tasks/brochure-printers.d.ts +31 -0
  270. package/dist/tasks/brochure-printers.d.ts.map +1 -0
  271. package/dist/tasks/brochure-printers.js +149 -0
  272. package/dist/tasks/brochure-templates.d.ts +36 -0
  273. package/dist/tasks/brochure-templates.d.ts.map +1 -0
  274. package/dist/tasks/brochure-templates.js +110 -0
  275. package/dist/tasks/brochures.d.ts +43 -0
  276. package/dist/tasks/brochures.d.ts.map +1 -0
  277. package/dist/tasks/brochures.js +72 -0
  278. package/dist/tasks/generate-pdf.d.ts +8 -0
  279. package/dist/tasks/generate-pdf.d.ts.map +1 -0
  280. package/dist/tasks/generate-pdf.js +106 -0
  281. package/dist/tasks/index.d.ts +5 -0
  282. package/dist/tasks/index.d.ts.map +1 -0
  283. package/dist/tasks/index.js +4 -0
  284. package/dist/tasks/pdf-text.d.ts +2 -0
  285. package/dist/tasks/pdf-text.d.ts.map +1 -0
  286. package/dist/tasks/pdf-text.js +40 -0
  287. package/dist/tasks.d.ts +2 -0
  288. package/dist/tasks.d.ts.map +1 -0
  289. package/dist/tasks.js +1 -0
  290. package/dist/validation-catalog.d.ts +2 -0
  291. package/dist/validation-catalog.d.ts.map +1 -0
  292. package/dist/validation-catalog.js +3 -0
  293. package/dist/validation-config.d.ts +2 -0
  294. package/dist/validation-config.d.ts.map +1 -0
  295. package/dist/validation-config.js +3 -0
  296. package/dist/validation-content.d.ts +2 -0
  297. package/dist/validation-content.d.ts.map +1 -0
  298. package/dist/validation-content.js +3 -0
  299. package/dist/validation-core.d.ts +2 -0
  300. package/dist/validation-core.d.ts.map +1 -0
  301. package/dist/validation-core.js +3 -0
  302. package/dist/validation-public.d.ts +2 -0
  303. package/dist/validation-public.d.ts.map +1 -0
  304. package/dist/validation-public.js +3 -0
  305. package/dist/validation-shared.d.ts +2 -0
  306. package/dist/validation-shared.d.ts.map +1 -0
  307. package/dist/validation-shared.js +3 -0
  308. package/dist/validation.d.ts +2 -0
  309. package/dist/validation.d.ts.map +1 -0
  310. package/dist/validation.js +3 -0
  311. package/package.json +204 -0
@@ -0,0 +1,106 @@
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 { type ContentLocaleResolution, type InvalidateOnDrift, type SourceAdapter, type SourceAdapterContext } from "@voyant-travel/catalog";
21
+ import type { SourceAdapterRegistry } from "@voyant-travel/catalog/booking-engine";
22
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
23
+ import { type ProductContent } from "./content-shape.js";
24
+ export interface ProductContentScope {
25
+ /**
26
+ * Ordered locale preference, most-preferred first. The deployment's
27
+ * configured fallback chain should be appended by the caller (e.g.
28
+ * `["ro-RO", "ro", "en-GB", "en"]` for a ro-RO storefront request).
29
+ */
30
+ preferredLocales: ReadonlyArray<string>;
31
+ /** Optional market — `'*'` (any) by default. */
32
+ market?: string;
33
+ /** Optional currency for SWR refresh. Not used by the cache. */
34
+ currency?: string;
35
+ /**
36
+ * When false, the read service skips machine-translated rows in
37
+ * favor of the next-best non-MT match. False on operator-side views
38
+ * where ops want to see "real" content before deciding to override.
39
+ * Default: true (storefront-friendly).
40
+ */
41
+ acceptMachineTranslated?: boolean;
42
+ }
43
+ export interface GetProductContentOptions {
44
+ /**
45
+ * Adapter registry — used to resolve the adapter for SWR refresh
46
+ * and cache-miss fetches. Required because the read service needs
47
+ * to know which adapter handles a given source_kind.
48
+ */
49
+ registry: SourceAdapterRegistry;
50
+ /**
51
+ * Builds the per-call adapter context (typically supplies the
52
+ * connection_id + correlation_id).
53
+ */
54
+ buildAdapterContext?: (adapter: SourceAdapter) => SourceAdapterContext;
55
+ /**
56
+ * Optional sink for per-overlay diagnostics emitted by the
57
+ * content-shape-aware merger.
58
+ */
59
+ onOverlayError?: (event: {
60
+ field_path: string;
61
+ reason: string;
62
+ }) => void;
63
+ /**
64
+ * Bypass a fresh cache row and fetch directly from the source adapter.
65
+ * Use this for volatile fields embedded in content payloads, such as
66
+ * sourced departure capacity, where a 24h rich-content TTL is too coarse.
67
+ */
68
+ forceFresh?: boolean;
69
+ }
70
+ /**
71
+ * The successful result of a content read. Carries the resolved content
72
+ * payload, locale-match metadata, and freshness markers so callers can
73
+ * render UI hints ("served in English").
74
+ */
75
+ export interface ResolvedProductContent {
76
+ content: ProductContent;
77
+ resolution: ContentLocaleResolution<{
78
+ locale: string;
79
+ payload: ProductContent;
80
+ }>;
81
+ /** Where the resolved content came from. */
82
+ source: "sourced-cache" | "sourced-fresh" | "synthesized" | "owned";
83
+ /** True when the cache row was stale and a background refresh was scheduled. */
84
+ served_stale: boolean;
85
+ /** True for synthesizer output. */
86
+ synthesized: boolean;
87
+ /** True when the upstream marked this content machine-translated. */
88
+ machine_translated: boolean;
89
+ }
90
+ /**
91
+ * Read the rich product content for one entity, resolving locale
92
+ * preference, applying overlays, and refreshing in the background when
93
+ * stale. Returns `null` only when the entity is unknown (no
94
+ * sourced-entry row, no owned row).
95
+ */
96
+ export declare function getProductContent(db: AnyDrizzleDb, entityId: string, scope: ProductContentScope, options: GetProductContentOptions): Promise<ResolvedProductContent | null>;
97
+ /**
98
+ * Drift event consumer — sets `fresh_until = now()` on every cache row
99
+ * matching the event's (entity_module, entity_id [, locale [, market]])
100
+ * scope. The next read serves stale + schedules a SWR refresh.
101
+ *
102
+ * Templates subscribe this to the catalog plane's drift-event bus.
103
+ * Per sourced-content §3.4.1.
104
+ */
105
+ export declare const invalidateProductContentOnDrift: InvalidateOnDrift;
106
+ //# sourceMappingURL=service-content.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-content.d.ts","sourceRoot":"","sources":["../src/service-content.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EACL,KAAK,uBAAuB,EAK5B,KAAK,iBAAiB,EAKtB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EAE1B,MAAM,wBAAwB,CAAA;AAC/B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAA;AAClF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAGrD,OAAO,EAGL,KAAK,cAAc,EAGpB,MAAM,oBAAoB,CAAA;AAe3B,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACvC,gDAAgD;IAChD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAClC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,QAAQ,EAAE,qBAAqB,CAAA;IAC/B;;;OAGG;IACH,mBAAmB,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,oBAAoB,CAAA;IACtE;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IACxE;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,cAAc,CAAA;IACvB,UAAU,EAAE,uBAAuB,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,cAAc,CAAA;KAAE,CAAC,CAAA;IAChF,4CAA4C;IAC5C,MAAM,EAAE,eAAe,GAAG,eAAe,GAAG,aAAa,GAAG,OAAO,CAAA;IACnE,gFAAgF;IAChF,YAAY,EAAE,OAAO,CAAA;IACrB,mCAAmC;IACnC,WAAW,EAAE,OAAO,CAAA;IACpB,qEAAqE;IACrE,kBAAkB,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,YAAY,EAChB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,mBAAmB,EAC1B,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAgMxC;AAmRD;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,EAAE,iBAG7C,CAAA"}
@@ -0,0 +1,388 @@
1
+ // agent-quality: file-size exception -- owner: inventory; existing service module stays co-located until a dedicated split preserves behavior and tests.
2
+ /**
3
+ * Product content service — `getProductContent` with owned-vs-sourced
4
+ * dispatch, locale-resolved cache reads, SWR refresh, and synthesizer
5
+ * fallback.
6
+ *
7
+ * One entry point per vertical; the catalog plane stays neutral about
8
+ * per-vertical content shapes. Detail routes (operator and storefront)
9
+ * call `getProductContent(db, entityId, scope, options)` and get back a
10
+ * fully-resolved `ContentLocaleResolution<ProductContent>` regardless
11
+ * of whether the row is owned or sourced.
12
+ *
13
+ * Owned rows (entities in the products table without a sourced-entry
14
+ * row) read from the products tables directly — out of scope for this
15
+ * file in v1; callers that need owned reads compose them around this
16
+ * service. Phase D ships sourced + synthesizer; owned dispatch
17
+ * narrows when first sourced template adopts.
18
+ *
19
+ * See `docs/architecture/catalog-sourced-content.md` §3.3, §3.4, §3.6.
20
+ */
21
+ import { createInvalidateOnDrift, fetchOverlaysForEntity, isStale, pickBestCachedLocale, readSourcedEntry, withContentRefreshLock, } from "@voyant-travel/catalog";
22
+ import { and, eq } from "drizzle-orm";
23
+ import { mergeOverlaysIntoProductContent, PRODUCTS_CONTENT_SCHEMA_VERSION, productContentSchema, validateProductContent, } from "./content-shape.js";
24
+ import { PRODUCTS_CONTENT_MARKET_ANY, productsSourcedContentTable, } from "./schema-sourced-content.js";
25
+ import { buildOwnedProductContent } from "./service-content-owned.js";
26
+ import { synthesizeProductContent, } from "./service-content-synthesizer.js";
27
+ /** Default TTL when the adapter doesn't pin `fresh_until`. */
28
+ const PRODUCTS_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h, per §3.4
29
+ /**
30
+ * Read the rich product content for one entity, resolving locale
31
+ * preference, applying overlays, and refreshing in the background when
32
+ * stale. Returns `null` only when the entity is unknown (no
33
+ * sourced-entry row, no owned row).
34
+ */
35
+ export async function getProductContent(db, entityId, scope, options) {
36
+ const sourcedEntry = await readSourcedEntry(db, "products", entityId);
37
+ if (!sourcedEntry) {
38
+ // Owned-product path. Read from the products module's own tables
39
+ // and project to ProductContent — locale resolution against
40
+ // product_translations + product_option_translations uses the
41
+ // same pickBestCachedLocale scoring the sourced cache reads use.
42
+ // Overlay merge applies the same way it does for sourced rows.
43
+ const owned = await buildOwnedProductContent(db, entityId, {
44
+ preferredLocales: scope.preferredLocales,
45
+ });
46
+ if (!owned)
47
+ return null;
48
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
49
+ const merged = mergeOverlaysIntoProductContent(owned.content, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
50
+ onOverlayError: options.onOverlayError
51
+ ? (e) => options.onOverlayError({
52
+ field_path: e.overlay.field_path,
53
+ reason: e.reason,
54
+ })
55
+ : undefined,
56
+ });
57
+ return {
58
+ content: merged,
59
+ resolution: {
60
+ candidate: { locale: owned.servedLocale, payload: merged },
61
+ served_locale: owned.servedLocale,
62
+ match_kind: owned.matchKind,
63
+ },
64
+ source: "owned",
65
+ served_stale: false,
66
+ synthesized: false,
67
+ machine_translated: false,
68
+ };
69
+ }
70
+ // Wrap the entry as a ProvenanceReadResult so the synthesizer can
71
+ // consume it without re-reading.
72
+ const provenance = {
73
+ kind: "sourced",
74
+ provenance: {
75
+ source_kind: sourcedEntry.source_kind,
76
+ source_provider: sourcedEntry.source_provider ?? undefined,
77
+ source_connection_id: sourcedEntry.source_connection_id ?? undefined,
78
+ source_ref: sourcedEntry.source_ref ?? undefined,
79
+ source_freshness: sourcedEntry.source_freshness,
80
+ last_sourced_at: sourcedEntry.last_sourced_at ?? undefined,
81
+ },
82
+ entry_id: sourcedEntry.id,
83
+ status: sourcedEntry.status,
84
+ projection: sourcedEntry.projection,
85
+ projection_etag: sourcedEntry.projection_etag,
86
+ projection_seen_at: sourcedEntry.projection_seen_at,
87
+ first_seen_at: sourcedEntry.first_seen_at,
88
+ last_seen_at: sourcedEntry.last_seen_at,
89
+ };
90
+ const adapter = sourcedEntry.source_connection_id
91
+ ? (options.registry.resolveByConnection(sourcedEntry.source_connection_id) ??
92
+ options.registry.byKind(sourcedEntry.source_kind)[0]?.adapter)
93
+ : options.registry.byKind(sourcedEntry.source_kind)[0]?.adapter;
94
+ const adapterCtx = options.buildAdapterContext?.(adapter) ?? {
95
+ connection_id: sourcedEntry.source_connection_id ?? sourcedEntry.source_kind,
96
+ };
97
+ const market = scope.market ?? PRODUCTS_CONTENT_MARKET_ANY;
98
+ const acceptMT = scope.acceptMachineTranslated ?? true;
99
+ const ownsContentCache = adapter?.capabilities.ownsContentCache === true;
100
+ if (ownsContentCache) {
101
+ if (!adapter?.getContent) {
102
+ throw new Error(`products adapter for ${entityId} declares ownsContentCache but does not implement getContent`);
103
+ }
104
+ const fresh = await fetchPassThroughContent(adapter, adapterCtx, {
105
+ entity_module: "products",
106
+ entity_id: entityId,
107
+ locale: scope.preferredLocales[0] ?? "en-GB",
108
+ market,
109
+ currency: scope.currency,
110
+ });
111
+ return finalizeFresh(db, entityId, fresh, scope, options);
112
+ }
113
+ if (options.forceFresh && adapter?.getContent) {
114
+ const fresh = await fetchFreshContent(db, adapter, adapterCtx, {
115
+ entity_module: "products",
116
+ entity_id: entityId,
117
+ locale: scope.preferredLocales[0] ?? "en-GB",
118
+ market,
119
+ currency: scope.currency,
120
+ }, options);
121
+ if (fresh) {
122
+ return finalizeFresh(db, entityId, fresh, scope, options);
123
+ }
124
+ }
125
+ // 1. Look up cached candidates across all locales for this entity.
126
+ const cachedRows = await fetchCacheCandidates(db, entityId, market);
127
+ const eligibleRows = acceptMT ? cachedRows : cachedRows.filter((r) => !r.machine_translated);
128
+ const best = pickBestCachedLocale(eligibleRows.map((row) => ({ ...row, locale: row.locale })), scope.preferredLocales);
129
+ const shouldRefreshLegacyAvailability = best
130
+ ? hasLegacyDepartureAvailabilityGap(best.candidate)
131
+ : false;
132
+ if (best && !isStale(best.candidate) && !shouldRefreshLegacyAvailability) {
133
+ return finalizeFromCache(db, entityId, best, "sourced-cache", false, options);
134
+ }
135
+ if (best && (isStale(best.candidate) || shouldRefreshLegacyAvailability)) {
136
+ // SWR for ordinary stale reads. Legacy demo content without
137
+ // departure capacity is refreshed synchronously so operator
138
+ // availability surfaces do not show effectively-unlimited slots.
139
+ if (adapter?.getContent) {
140
+ const refreshRequest = {
141
+ entity_module: "products",
142
+ entity_id: entityId,
143
+ locale: scope.preferredLocales[0] ?? best.candidate.locale,
144
+ market,
145
+ currency: scope.currency,
146
+ };
147
+ if (shouldRefreshLegacyAvailability) {
148
+ const fresh = await fetchFreshContent(db, adapter, adapterCtx, refreshRequest, options);
149
+ if (fresh)
150
+ return finalizeFresh(db, entityId, fresh, scope, options);
151
+ }
152
+ else {
153
+ void scheduleRefresh(db, adapter, adapterCtx, refreshRequest);
154
+ }
155
+ }
156
+ return finalizeFromCache(db, entityId, best, "sourced-cache", true, options);
157
+ }
158
+ // No cache row at all — must produce content somehow.
159
+ if (!adapter?.getContent) {
160
+ // Thin adapter or no adapter registered — synthesize from
161
+ // projection + overlay + plane metadata (§3.6).
162
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
163
+ const synthesized = synthesizeProductContent({ locale: scope.preferredLocales[0] ?? "en-GB" }, {
164
+ provenance,
165
+ overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
166
+ });
167
+ return wrapSynthesized(synthesized, scope, false);
168
+ }
169
+ // Cache miss with a rich adapter — block on the adapter, dedupe
170
+ // across workers via advisory lock, and write through to the cache.
171
+ const fresh = await fetchFreshContent(db, adapter, adapterCtx, {
172
+ entity_module: "products",
173
+ entity_id: entityId,
174
+ locale: scope.preferredLocales[0] ?? "en-GB",
175
+ market,
176
+ currency: scope.currency,
177
+ }, options);
178
+ if (!fresh) {
179
+ // The adapter call could not get the lock AND there's no cached
180
+ // row — fall back to synthesizer rather than blocking forever.
181
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
182
+ const synthesized = synthesizeProductContent({ locale: scope.preferredLocales[0] ?? "en-GB" }, {
183
+ provenance,
184
+ overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
185
+ });
186
+ return wrapSynthesized(synthesized, scope, false);
187
+ }
188
+ return finalizeFresh(db, entityId, fresh, scope, options);
189
+ }
190
+ // ─────────────────────────────────────────────────────────────────────────────
191
+ // Cache candidates
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+ async function fetchCacheCandidates(db, entityId, market) {
194
+ const rows = await db
195
+ .select()
196
+ .from(productsSourcedContentTable)
197
+ .where(and(eq(productsSourcedContentTable.entity_id, entityId), eq(productsSourcedContentTable.market, market), eq(productsSourcedContentTable.content_schema_version, PRODUCTS_CONTENT_SCHEMA_VERSION)));
198
+ return rows;
199
+ }
200
+ function hasLegacyDepartureAvailabilityGap(row) {
201
+ const validation = validateProductContent(row.payload);
202
+ if (!validation.valid)
203
+ return false;
204
+ return validation.content.departures.some((departure) => departure.capacity == null && departure.remaining == null);
205
+ }
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+ // Fresh fetch + write-through
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+ async function fetchPassThroughContent(adapter, ctx, request) {
210
+ const got = await adapter.getContent(ctx, request);
211
+ const validation = validateProductContent(got.content);
212
+ if (!validation.valid) {
213
+ throw new Error(`products getContent for ${request.entity_id} failed validation: ${validation.reason}`);
214
+ }
215
+ return got;
216
+ }
217
+ async function fetchFreshContent(db, adapter, ctx, request, _options) {
218
+ const result = await withContentRefreshLock(db, {
219
+ entityModule: request.entity_module,
220
+ entityId: request.entity_id,
221
+ locale: request.locale,
222
+ market: request.market,
223
+ }, async () => {
224
+ const got = await adapter.getContent(ctx, request);
225
+ const validation = validateProductContent(got.content);
226
+ if (!validation.valid) {
227
+ // Surface adapter integration bugs, but don't write to cache.
228
+ throw new Error(`products getContent for ${request.entity_id} failed validation: ${validation.reason}`);
229
+ }
230
+ await writeCacheRow(db, request, got);
231
+ return got;
232
+ });
233
+ return result ?? null;
234
+ }
235
+ function scheduleRefresh(db, adapter, ctx, request) {
236
+ // Fire-and-forget. Errors are swallowed — a failed refresh just
237
+ // leaves the stale row in place; the next read tries again.
238
+ void withContentRefreshLock(db, {
239
+ entityModule: request.entity_module,
240
+ entityId: request.entity_id,
241
+ locale: request.locale,
242
+ market: request.market,
243
+ }, async () => {
244
+ const got = await adapter.getContent(ctx, request);
245
+ const validation = validateProductContent(got.content);
246
+ if (!validation.valid)
247
+ return;
248
+ await writeCacheRow(db, request, got);
249
+ }).catch(() => {
250
+ // intentionally swallow — see comment above
251
+ });
252
+ }
253
+ async function writeCacheRow(db, request, result) {
254
+ const market = request.market ?? PRODUCTS_CONTENT_MARKET_ANY;
255
+ const now = new Date();
256
+ // Date-like fields may arrive as strings when the adapter is an HTTP
257
+ // client (JSON.parse doesn't deserialize ISO timestamps to Date).
258
+ // Coerce at the cache-write boundary so the drizzle timestamp column
259
+ // gets a real Date — `value.toISOString is not a function` otherwise.
260
+ const sourceUpdatedAt = toDateOrNull(result.source_updated_at);
261
+ const freshUntil = toDateOrNull(result.fresh_until) ?? new Date(now.getTime() + PRODUCTS_DEFAULT_TTL_MS);
262
+ await db
263
+ .insert(productsSourcedContentTable)
264
+ .values({
265
+ entity_id: request.entity_id,
266
+ locale: request.locale,
267
+ market,
268
+ payload: result.content,
269
+ content_schema_version: result.content_schema_version,
270
+ returned_locale: result.returned_locale,
271
+ machine_translated: result.machine_translated ?? false,
272
+ source_updated_at: sourceUpdatedAt,
273
+ fetched_at: now,
274
+ fresh_until: freshUntil,
275
+ etag: result.etag ?? null,
276
+ fetch_status: "ok",
277
+ fetch_error: null,
278
+ })
279
+ .onConflictDoUpdate({
280
+ target: [
281
+ productsSourcedContentTable.entity_id,
282
+ productsSourcedContentTable.locale,
283
+ productsSourcedContentTable.market,
284
+ ],
285
+ set: {
286
+ payload: result.content,
287
+ content_schema_version: result.content_schema_version,
288
+ returned_locale: result.returned_locale,
289
+ machine_translated: result.machine_translated ?? false,
290
+ source_updated_at: sourceUpdatedAt,
291
+ fetched_at: now,
292
+ fresh_until: freshUntil,
293
+ etag: result.etag ?? null,
294
+ fetch_status: "ok",
295
+ fetch_error: null,
296
+ },
297
+ });
298
+ }
299
+ function toDateOrNull(value) {
300
+ if (!value)
301
+ return null;
302
+ if (value instanceof Date)
303
+ return value;
304
+ const parsed = new Date(value);
305
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
306
+ }
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ // Finalizers — overlay merge + return shape
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+ async function finalizeFromCache(db, entityId, best, source, servedStale, options) {
311
+ const cachedPayload = best.candidate.payload;
312
+ const validation = validateProductContent(cachedPayload);
313
+ if (!validation.valid) {
314
+ // Schema-version-mismatch case is filtered upstream; if we hit
315
+ // here, the cache row is corrupt for some other reason. Treat as
316
+ // cache miss → caller's next layer (synthesizer) handles.
317
+ throw new Error(`products cache row for ${entityId} (${best.candidate.locale}) failed validation: ${validation.reason}`);
318
+ }
319
+ const cachedContent = validation.content;
320
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
321
+ const merged = mergeOverlaysIntoProductContent(cachedContent, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
322
+ onOverlayError: options.onOverlayError
323
+ ? (e) => options.onOverlayError({
324
+ field_path: e.overlay.field_path,
325
+ reason: e.reason,
326
+ })
327
+ : undefined,
328
+ });
329
+ return {
330
+ content: merged,
331
+ resolution: {
332
+ candidate: { locale: best.candidate.locale, payload: merged },
333
+ served_locale: best.candidate.returned_locale,
334
+ match_kind: best.match_kind,
335
+ },
336
+ source,
337
+ served_stale: servedStale,
338
+ synthesized: false,
339
+ machine_translated: best.candidate.machine_translated,
340
+ };
341
+ }
342
+ async function finalizeFresh(db, entityId, fresh, scope, options) {
343
+ const cachedContent = productContentSchema.parse(fresh.content);
344
+ const overlays = await fetchOverlaysForEntity(db, "products", entityId);
345
+ const merged = mergeOverlaysIntoProductContent(cachedContent, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
346
+ onOverlayError: options.onOverlayError
347
+ ? (e) => options.onOverlayError({
348
+ field_path: e.overlay.field_path,
349
+ reason: e.reason,
350
+ })
351
+ : undefined,
352
+ });
353
+ return {
354
+ content: merged,
355
+ resolution: {
356
+ candidate: { locale: scope.preferredLocales[0] ?? fresh.returned_locale, payload: merged },
357
+ served_locale: fresh.returned_locale,
358
+ match_kind: scope.preferredLocales[0] === fresh.returned_locale ? "exact" : "language_match",
359
+ },
360
+ source: "sourced-fresh",
361
+ served_stale: false,
362
+ synthesized: false,
363
+ machine_translated: fresh.machine_translated ?? false,
364
+ };
365
+ }
366
+ function wrapSynthesized(synthesized, scope, servedStale) {
367
+ return {
368
+ content: synthesized.content,
369
+ resolution: {
370
+ candidate: { locale: synthesized.served_locale, payload: synthesized.content },
371
+ served_locale: synthesized.served_locale,
372
+ match_kind: scope.preferredLocales[0] === synthesized.served_locale ? "exact" : "any",
373
+ },
374
+ source: "synthesized",
375
+ served_stale: servedStale,
376
+ synthesized: true,
377
+ machine_translated: false,
378
+ };
379
+ }
380
+ /**
381
+ * Drift event consumer — sets `fresh_until = now()` on every cache row
382
+ * matching the event's (entity_module, entity_id [, locale [, market]])
383
+ * scope. The next read serves stale + schedules a SWR refresh.
384
+ *
385
+ * Templates subscribe this to the catalog plane's drift-event bus.
386
+ * Per sourced-content §3.4.1.
387
+ */
388
+ export const invalidateProductContentOnDrift = createInvalidateOnDrift(productsSourcedContentTable, { entityModule: "products" });