@voyant-travel/catalog 0.117.2

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 (243) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/dist/adapter/booking-forwarding.d.ts +2 -0
  4. package/dist/adapter/booking-forwarding.d.ts.map +1 -0
  5. package/dist/adapter/booking-forwarding.js +1 -0
  6. package/dist/adapter/channel-push-contracts.d.ts +2 -0
  7. package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
  8. package/dist/adapter/channel-push-contracts.js +1 -0
  9. package/dist/adapter/contract.d.ts +2 -0
  10. package/dist/adapter/contract.d.ts.map +1 -0
  11. package/dist/adapter/contract.js +1 -0
  12. package/dist/adapter/contract.test.d.ts +2 -0
  13. package/dist/adapter/contract.test.d.ts.map +1 -0
  14. package/dist/adapter/contract.test.js +390 -0
  15. package/dist/adapter/provider-contracts.d.ts +2 -0
  16. package/dist/adapter/provider-contracts.d.ts.map +1 -0
  17. package/dist/adapter/provider-contracts.js +1 -0
  18. package/dist/adapter/provider-contracts.test.d.ts +2 -0
  19. package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
  20. package/dist/adapter/provider-contracts.test.js +206 -0
  21. package/dist/adapter/schemas.d.ts +2 -0
  22. package/dist/adapter/schemas.d.ts.map +1 -0
  23. package/dist/adapter/schemas.js +1 -0
  24. package/dist/adapter/schemas.test.d.ts +2 -0
  25. package/dist/adapter/schemas.test.d.ts.map +1 -0
  26. package/dist/adapter/schemas.test.js +344 -0
  27. package/dist/booking-engine/book.d.ts +124 -0
  28. package/dist/booking-engine/book.d.ts.map +1 -0
  29. package/dist/booking-engine/book.js +311 -0
  30. package/dist/booking-engine/cancel.d.ts +40 -0
  31. package/dist/booking-engine/cancel.d.ts.map +1 -0
  32. package/dist/booking-engine/cancel.js +56 -0
  33. package/dist/booking-engine/checkout-finalize.d.ts +146 -0
  34. package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
  35. package/dist/booking-engine/checkout-finalize.js +132 -0
  36. package/dist/booking-engine/contracts.d.ts +9 -0
  37. package/dist/booking-engine/contracts.d.ts.map +1 -0
  38. package/dist/booking-engine/contracts.js +8 -0
  39. package/dist/booking-engine/contracts.test.d.ts +2 -0
  40. package/dist/booking-engine/contracts.test.d.ts.map +1 -0
  41. package/dist/booking-engine/contracts.test.js +116 -0
  42. package/dist/booking-engine/draft-shape.d.ts +10 -0
  43. package/dist/booking-engine/draft-shape.d.ts.map +1 -0
  44. package/dist/booking-engine/draft-shape.js +9 -0
  45. package/dist/booking-engine/draft-shape.test.d.ts +2 -0
  46. package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
  47. package/dist/booking-engine/draft-shape.test.js +74 -0
  48. package/dist/booking-engine/drafts-schema.d.ts +302 -0
  49. package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
  50. package/dist/booking-engine/drafts-schema.js +53 -0
  51. package/dist/booking-engine/drafts-service.d.ts +41 -0
  52. package/dist/booking-engine/drafts-service.d.ts.map +1 -0
  53. package/dist/booking-engine/drafts-service.js +108 -0
  54. package/dist/booking-engine/errors.d.ts +81 -0
  55. package/dist/booking-engine/errors.d.ts.map +1 -0
  56. package/dist/booking-engine/errors.js +113 -0
  57. package/dist/booking-engine/index.d.ts +36 -0
  58. package/dist/booking-engine/index.d.ts.map +1 -0
  59. package/dist/booking-engine/index.js +34 -0
  60. package/dist/booking-engine/orders.d.ts +41 -0
  61. package/dist/booking-engine/orders.d.ts.map +1 -0
  62. package/dist/booking-engine/orders.js +49 -0
  63. package/dist/booking-engine/owned-handler.d.ts +166 -0
  64. package/dist/booking-engine/owned-handler.d.ts.map +1 -0
  65. package/dist/booking-engine/owned-handler.js +50 -0
  66. package/dist/booking-engine/owned-handler.test.d.ts +2 -0
  67. package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
  68. package/dist/booking-engine/owned-handler.test.js +63 -0
  69. package/dist/booking-engine/promotions-contract.d.ts +8 -0
  70. package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
  71. package/dist/booking-engine/promotions-contract.js +7 -0
  72. package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
  73. package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
  74. package/dist/booking-engine/quote-enricher.test.js +138 -0
  75. package/dist/booking-engine/quote.d.ts +163 -0
  76. package/dist/booking-engine/quote.d.ts.map +1 -0
  77. package/dist/booking-engine/quote.js +259 -0
  78. package/dist/booking-engine/registry.d.ts +85 -0
  79. package/dist/booking-engine/registry.d.ts.map +1 -0
  80. package/dist/booking-engine/registry.js +118 -0
  81. package/dist/booking-engine/registry.test.d.ts +2 -0
  82. package/dist/booking-engine/registry.test.d.ts.map +1 -0
  83. package/dist/booking-engine/registry.test.js +132 -0
  84. package/dist/booking-engine/routes-contracts.d.ts +169 -0
  85. package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
  86. package/dist/booking-engine/routes-contracts.js +63 -0
  87. package/dist/booking-engine/routes.d.ts +7 -0
  88. package/dist/booking-engine/routes.d.ts.map +1 -0
  89. package/dist/booking-engine/routes.js +443 -0
  90. package/dist/booking-engine/routes.test.d.ts +2 -0
  91. package/dist/booking-engine/routes.test.d.ts.map +1 -0
  92. package/dist/booking-engine/routes.test.js +304 -0
  93. package/dist/booking-engine/schema.d.ts +455 -0
  94. package/dist/booking-engine/schema.d.ts.map +1 -0
  95. package/dist/booking-engine/schema.js +75 -0
  96. package/dist/booking-engine/snapshot-content.d.ts +120 -0
  97. package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
  98. package/dist/booking-engine/snapshot-content.js +110 -0
  99. package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
  100. package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
  101. package/dist/booking-engine/snapshot-content.test.js +213 -0
  102. package/dist/booking-engine/sync.d.ts +136 -0
  103. package/dist/booking-engine/sync.d.ts.map +1 -0
  104. package/dist/booking-engine/sync.js +177 -0
  105. package/dist/booking-engine/sync.test.d.ts +2 -0
  106. package/dist/booking-engine/sync.test.d.ts.map +1 -0
  107. package/dist/booking-engine/sync.test.js +377 -0
  108. package/dist/contract.d.ts +2 -0
  109. package/dist/contract.d.ts.map +1 -0
  110. package/dist/contract.js +1 -0
  111. package/dist/contract.test.d.ts +2 -0
  112. package/dist/contract.test.d.ts.map +1 -0
  113. package/dist/contract.test.js +107 -0
  114. package/dist/drift/events.d.ts +2 -0
  115. package/dist/drift/events.d.ts.map +1 -0
  116. package/dist/drift/events.js +1 -0
  117. package/dist/drift/events.test.d.ts +2 -0
  118. package/dist/drift/events.test.d.ts.map +1 -0
  119. package/dist/drift/events.test.js +100 -0
  120. package/dist/embeddings/contract.d.ts +85 -0
  121. package/dist/embeddings/contract.d.ts.map +1 -0
  122. package/dist/embeddings/contract.js +42 -0
  123. package/dist/embeddings/contract.test.d.ts +2 -0
  124. package/dist/embeddings/contract.test.d.ts.map +1 -0
  125. package/dist/embeddings/contract.test.js +30 -0
  126. package/dist/embeddings/gemini.d.ts +110 -0
  127. package/dist/embeddings/gemini.d.ts.map +1 -0
  128. package/dist/embeddings/gemini.js +118 -0
  129. package/dist/embeddings/gemini.test.d.ts +2 -0
  130. package/dist/embeddings/gemini.test.d.ts.map +1 -0
  131. package/dist/embeddings/gemini.test.js +132 -0
  132. package/dist/embeddings/model-registry.d.ts +62 -0
  133. package/dist/embeddings/model-registry.d.ts.map +1 -0
  134. package/dist/embeddings/model-registry.js +78 -0
  135. package/dist/embeddings/model-registry.test.d.ts +2 -0
  136. package/dist/embeddings/model-registry.test.d.ts.map +1 -0
  137. package/dist/embeddings/model-registry.test.js +81 -0
  138. package/dist/embeddings/openai.d.ts +81 -0
  139. package/dist/embeddings/openai.d.ts.map +1 -0
  140. package/dist/embeddings/openai.js +123 -0
  141. package/dist/embeddings/openai.test.d.ts +2 -0
  142. package/dist/embeddings/openai.test.d.ts.map +1 -0
  143. package/dist/embeddings/openai.test.js +164 -0
  144. package/dist/events/taxonomy.d.ts +158 -0
  145. package/dist/events/taxonomy.d.ts.map +1 -0
  146. package/dist/events/taxonomy.js +99 -0
  147. package/dist/events/taxonomy.test.d.ts +2 -0
  148. package/dist/events/taxonomy.test.d.ts.map +1 -0
  149. package/dist/events/taxonomy.test.js +48 -0
  150. package/dist/index.d.ts +27 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +39 -0
  153. package/dist/indexer/contract.d.ts +203 -0
  154. package/dist/indexer/contract.d.ts.map +1 -0
  155. package/dist/indexer/contract.js +16 -0
  156. package/dist/indexer/typesense-search-query.d.ts +31 -0
  157. package/dist/indexer/typesense-search-query.d.ts.map +1 -0
  158. package/dist/indexer/typesense-search-query.js +185 -0
  159. package/dist/indexer/typesense.d.ts +105 -0
  160. package/dist/indexer/typesense.d.ts.map +1 -0
  161. package/dist/indexer/typesense.js +394 -0
  162. package/dist/indexer/typesense.test.d.ts +2 -0
  163. package/dist/indexer/typesense.test.d.ts.map +1 -0
  164. package/dist/indexer/typesense.test.js +253 -0
  165. package/dist/overlay/resolver.d.ts +101 -0
  166. package/dist/overlay/resolver.d.ts.map +1 -0
  167. package/dist/overlay/resolver.js +167 -0
  168. package/dist/overlay/resolver.test.d.ts +2 -0
  169. package/dist/overlay/resolver.test.d.ts.map +1 -0
  170. package/dist/overlay/resolver.test.js +179 -0
  171. package/dist/overlay/schema.d.ts +266 -0
  172. package/dist/overlay/schema.d.ts.map +1 -0
  173. package/dist/overlay/schema.js +71 -0
  174. package/dist/provenance.d.ts +2 -0
  175. package/dist/provenance.d.ts.map +1 -0
  176. package/dist/provenance.js +1 -0
  177. package/dist/schema-sourced-entries.d.ts +344 -0
  178. package/dist/schema-sourced-entries.d.ts.map +1 -0
  179. package/dist/schema-sourced-entries.js +75 -0
  180. package/dist/schema.d.ts +21 -0
  181. package/dist/schema.d.ts.map +1 -0
  182. package/dist/schema.js +20 -0
  183. package/dist/search/federate.d.ts +58 -0
  184. package/dist/search/federate.d.ts.map +1 -0
  185. package/dist/search/federate.js +103 -0
  186. package/dist/search/federate.test.d.ts +2 -0
  187. package/dist/search/federate.test.d.ts.map +1 -0
  188. package/dist/search/federate.test.js +146 -0
  189. package/dist/search/rerank.d.ts +77 -0
  190. package/dist/search/rerank.d.ts.map +1 -0
  191. package/dist/search/rerank.js +68 -0
  192. package/dist/search/rerank.test.d.ts +2 -0
  193. package/dist/search/rerank.test.d.ts.map +1 -0
  194. package/dist/search/rerank.test.js +60 -0
  195. package/dist/search/routes.d.ts +144 -0
  196. package/dist/search/routes.d.ts.map +1 -0
  197. package/dist/search/routes.js +288 -0
  198. package/dist/search/routes.test.d.ts +2 -0
  199. package/dist/search/routes.test.d.ts.map +1 -0
  200. package/dist/search/routes.test.js +322 -0
  201. package/dist/search/semantic.d.ts +63 -0
  202. package/dist/search/semantic.d.ts.map +1 -0
  203. package/dist/search/semantic.js +75 -0
  204. package/dist/search/semantic.test.d.ts +2 -0
  205. package/dist/search/semantic.test.d.ts.map +1 -0
  206. package/dist/search/semantic.test.js +143 -0
  207. package/dist/services/build-indexer-document.test.d.ts +2 -0
  208. package/dist/services/build-indexer-document.test.d.ts.map +1 -0
  209. package/dist/services/build-indexer-document.test.js +102 -0
  210. package/dist/services/content-service.d.ts +125 -0
  211. package/dist/services/content-service.d.ts.map +1 -0
  212. package/dist/services/content-service.js +139 -0
  213. package/dist/services/content-service.test.d.ts +2 -0
  214. package/dist/services/content-service.test.d.ts.map +1 -0
  215. package/dist/services/content-service.test.js +322 -0
  216. package/dist/services/indexer-service.d.ts +109 -0
  217. package/dist/services/indexer-service.d.ts.map +1 -0
  218. package/dist/services/indexer-service.js +123 -0
  219. package/dist/services/indexer-service.test.d.ts +2 -0
  220. package/dist/services/indexer-service.test.d.ts.map +1 -0
  221. package/dist/services/indexer-service.test.js +176 -0
  222. package/dist/services/overlay-service.d.ts +108 -0
  223. package/dist/services/overlay-service.d.ts.map +1 -0
  224. package/dist/services/overlay-service.js +211 -0
  225. package/dist/services/overlay-service.test.d.ts +2 -0
  226. package/dist/services/overlay-service.test.d.ts.map +1 -0
  227. package/dist/services/overlay-service.test.js +79 -0
  228. package/dist/services/snapshot-builder.test.d.ts +2 -0
  229. package/dist/services/snapshot-builder.test.d.ts.map +1 -0
  230. package/dist/services/snapshot-builder.test.js +93 -0
  231. package/dist/services/snapshot-service.d.ts +78 -0
  232. package/dist/services/snapshot-service.d.ts.map +1 -0
  233. package/dist/services/snapshot-service.js +165 -0
  234. package/dist/services/sourced-entry-service.d.ts +142 -0
  235. package/dist/services/sourced-entry-service.d.ts.map +1 -0
  236. package/dist/services/sourced-entry-service.js +203 -0
  237. package/dist/services/sourced-entry-service.test.d.ts +10 -0
  238. package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
  239. package/dist/services/sourced-entry-service.test.js +66 -0
  240. package/dist/snapshot/schema.d.ts +362 -0
  241. package/dist/snapshot/schema.d.ts.map +1 -0
  242. package/dist/snapshot/schema.js +102 -0
  243. package/package.json +210 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Snapshot content capture per sourced-content §5.1.
3
+ *
4
+ * The booking engine calls a `SnapshotContentCapturer` at commit time
5
+ * to refresh content from the adapter and embed the result in the
6
+ * snapshot's `frozen_payload.content_capture` envelope. The
7
+ * capturer's contract is "refresh-with-fallback":
8
+ *
9
+ * 1. Try to fetch fresh content from the adapter (and write through
10
+ * to the per-vertical content cache so the next read sees the
11
+ * fresh row).
12
+ * 2. On adapter error (network blip, rate limit, transient outage),
13
+ * fall back to the cache — including stale rows.
14
+ * 3. If neither is available, throw `SnapshotContentUnavailableError`.
15
+ * The booking engine propagates this and aborts the commit. We
16
+ * deliberately do NOT snapshot from the indexed projection —
17
+ * refunds and audit need real "what was sold" content, not a
18
+ * stub.
19
+ *
20
+ * The orchestrator stays in the booking-engine sub-package because the
21
+ * envelope shape, error types, and integration point all live with
22
+ * `bookEntity`. Per-vertical content services (products, cruises,
23
+ * etc.) implement `ContentSnapshotAdapter` and the template wires them
24
+ * into a single capturer via `composeSnapshotContentCapturer`.
25
+ *
26
+ * See `docs/architecture/catalog-sourced-content.md` §5.1.
27
+ */
28
+ import { SnapshotContentUnavailableError } from "./errors.js";
29
+ /**
30
+ * Compose a `SnapshotContentCapturer` from a registry of per-vertical
31
+ * `ContentSnapshotAdapter`s and an adapter registry. Use when the
32
+ * template wants the §5.1 refresh-with-fallback behavior wired
33
+ * uniformly across verticals.
34
+ *
35
+ * Returns null when:
36
+ * - The entity_module isn't in the per-vertical adapter map
37
+ * (assumed owned or out-of-scope; engine skips the envelope).
38
+ * - The source adapter for `source_kind` doesn't implement
39
+ * `getContent` AND no cache row exists. (When `getContent` is
40
+ * missing but a cache row exists, we serve the cache row — the
41
+ * vertical's synthesizer is for read paths, not write paths.)
42
+ *
43
+ * Throws `SnapshotContentUnavailableError` when neither fresh nor
44
+ * cached content can be produced.
45
+ */
46
+ export function composeSnapshotContentCapturer(options) {
47
+ return async function captureSnapshotContent(input) {
48
+ const verticalAdapter = options.contentAdapters.get(input.entity_module);
49
+ if (!verticalAdapter) {
50
+ // No content service for this vertical → owned or out-of-scope.
51
+ return null;
52
+ }
53
+ const sourceAdapter = input.source_connection_id
54
+ ? (options.registry.resolveByConnection(input.source_connection_id) ??
55
+ options.registry.byKind(input.source_kind)[0]?.adapter)
56
+ : options.registry.byKind(input.source_kind)[0]?.adapter;
57
+ if (!sourceAdapter) {
58
+ // No source adapter registered. Try cache fallback first; if
59
+ // even that fails, signal unavailable so the engine can abort.
60
+ return readCachedOrThrow(verticalAdapter, input, "no source adapter registered");
61
+ }
62
+ const request = {
63
+ entity_module: input.entity_module,
64
+ entity_id: input.entity_id,
65
+ locale: input.locale,
66
+ market: input.market,
67
+ currency: input.currency,
68
+ };
69
+ if (sourceAdapter.getContent) {
70
+ try {
71
+ const fresh = await verticalAdapter.refresh(input.db, sourceAdapter, input.adapterContext, request);
72
+ return {
73
+ source: "fresh",
74
+ fetched_at: new Date(),
75
+ content_etag: fresh.etag,
76
+ content_schema_version: fresh.content_schema_version,
77
+ content: fresh.content,
78
+ };
79
+ }
80
+ catch (err) {
81
+ const reason = err instanceof Error ? err.message : String(err);
82
+ return readCachedOrThrow(verticalAdapter, input, reason);
83
+ }
84
+ }
85
+ // Adapter is thin (no getContent). Cache-or-throw — synthesizer
86
+ // is read-time only, not snapshot-time.
87
+ return readCachedOrThrow(verticalAdapter, input, "adapter does not implement getContent");
88
+ };
89
+ }
90
+ async function readCachedOrThrow(verticalAdapter, input, fallbackReason) {
91
+ const request = {
92
+ entity_module: input.entity_module,
93
+ entity_id: input.entity_id,
94
+ locale: input.locale,
95
+ market: input.market,
96
+ currency: input.currency,
97
+ };
98
+ const cached = await verticalAdapter.readCached(input.db, request);
99
+ if (!cached) {
100
+ throw new SnapshotContentUnavailableError(input.entity_module, input.entity_id, fallbackReason);
101
+ }
102
+ return {
103
+ source: "cache_fallback",
104
+ fetched_at: cached.fetched_at,
105
+ fallback_reason: fallbackReason,
106
+ content_etag: cached.etag ?? undefined,
107
+ content_schema_version: cached.content_schema_version,
108
+ content: cached.payload,
109
+ };
110
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=snapshot-content.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot-content.test.d.ts","sourceRoot":"","sources":["../../src/booking-engine/snapshot-content.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { SnapshotContentUnavailableError } from "./errors.js";
3
+ import { createSourceAdapterRegistry } from "./registry.js";
4
+ import { composeSnapshotContentCapturer, } from "./snapshot-content.js";
5
+ function fakeDb() {
6
+ return {};
7
+ }
8
+ function makeRichAdapter(getContent) {
9
+ return {
10
+ kind: "rich",
11
+ capabilities: {
12
+ verticals: ["products"],
13
+ supportsLiveResolution: false,
14
+ supportsDriftDetection: false,
15
+ supportsBookingForwarding: false,
16
+ postBookOperations: [],
17
+ supportsContentFetch: true,
18
+ },
19
+ connect: async () => undefined,
20
+ pause: async () => undefined,
21
+ disconnect: async () => undefined,
22
+ getState: async () => "active",
23
+ discover: async () => ({ projections: [], next_cursor: undefined }),
24
+ getContent,
25
+ };
26
+ }
27
+ function makeThinAdapter() {
28
+ return {
29
+ kind: "thin",
30
+ capabilities: {
31
+ verticals: ["products"],
32
+ supportsLiveResolution: false,
33
+ supportsDriftDetection: false,
34
+ supportsBookingForwarding: false,
35
+ postBookOperations: [],
36
+ supportsContentFetch: false,
37
+ },
38
+ connect: async () => undefined,
39
+ pause: async () => undefined,
40
+ disconnect: async () => undefined,
41
+ getState: async () => "active",
42
+ discover: async () => ({ projections: [], next_cursor: undefined }),
43
+ };
44
+ }
45
+ const baseInput = {
46
+ db: fakeDb(),
47
+ entity_module: "products",
48
+ entity_id: "prod_abc",
49
+ source_kind: "rich",
50
+ source_connection_id: "conn_1",
51
+ source_ref: "REF-1",
52
+ locale: "en-GB",
53
+ market: "GB",
54
+ currency: "GBP",
55
+ adapterContext: { connection_id: "conn_1" },
56
+ };
57
+ describe("composeSnapshotContentCapturer — refresh-with-fallback per §5.1", () => {
58
+ it("returns null when the entity_module isn't in the per-vertical map (owned / out-of-scope)", async () => {
59
+ const registry = createSourceAdapterRegistry();
60
+ const capturer = composeSnapshotContentCapturer({
61
+ registry,
62
+ contentAdapters: new Map(),
63
+ });
64
+ const result = await capturer({ ...baseInput, entity_module: "owned-vertical" });
65
+ expect(result).toBeNull();
66
+ });
67
+ it("returns 'fresh' when the adapter call succeeds — writes through to cache", async () => {
68
+ const fresh = {
69
+ entity_module: "products",
70
+ entity_id: "prod_abc",
71
+ source_ref: "REF-1",
72
+ returned_locale: "en-GB",
73
+ content: { product: { id: "prod_abc", name: "Sample" } },
74
+ content_schema_version: "products/v1",
75
+ etag: 'W/"abc"',
76
+ };
77
+ const refresh = vi.fn(async () => fresh);
78
+ const verticalAdapter = {
79
+ refresh,
80
+ readCached: async () => null,
81
+ };
82
+ const registry = createSourceAdapterRegistry();
83
+ registry.register(makeRichAdapter(async () => fresh));
84
+ const capturer = composeSnapshotContentCapturer({
85
+ registry,
86
+ contentAdapters: new Map([["products", verticalAdapter]]),
87
+ });
88
+ const result = await capturer(baseInput);
89
+ expect(result).not.toBeNull();
90
+ expect(result?.source).toBe("fresh");
91
+ expect(result?.content_schema_version).toBe("products/v1");
92
+ expect(result?.content_etag).toBe('W/"abc"');
93
+ expect(result?.content).toEqual({ product: { id: "prod_abc", name: "Sample" } });
94
+ expect(refresh).toHaveBeenCalledTimes(1);
95
+ });
96
+ it("falls back to cache when adapter.getContent throws", async () => {
97
+ const cachedFetchedAt = new Date("2026-04-01T12:00:00Z");
98
+ const verticalAdapter = {
99
+ refresh: vi.fn(async () => {
100
+ throw new Error("upstream rate-limit");
101
+ }),
102
+ readCached: vi.fn(async () => ({
103
+ payload: { product: { id: "prod_abc", name: "Sample (cached)" } },
104
+ content_schema_version: "products/v1",
105
+ fetched_at: cachedFetchedAt,
106
+ etag: 'W/"old"',
107
+ })),
108
+ };
109
+ const registry = createSourceAdapterRegistry();
110
+ registry.register(makeRichAdapter(async () => {
111
+ throw new Error("upstream rate-limit");
112
+ }));
113
+ const capturer = composeSnapshotContentCapturer({
114
+ registry,
115
+ contentAdapters: new Map([["products", verticalAdapter]]),
116
+ });
117
+ const result = await capturer(baseInput);
118
+ expect(result?.source).toBe("cache_fallback");
119
+ expect(result?.fallback_reason).toBe("upstream rate-limit");
120
+ expect(result?.fetched_at).toEqual(cachedFetchedAt);
121
+ expect(result?.content).toEqual({ product: { id: "prod_abc", name: "Sample (cached)" } });
122
+ });
123
+ it("throws SnapshotContentUnavailableError when neither fresh nor cached produces content", async () => {
124
+ const verticalAdapter = {
125
+ refresh: async () => {
126
+ throw new Error("upstream down");
127
+ },
128
+ readCached: async () => null,
129
+ };
130
+ const registry = createSourceAdapterRegistry();
131
+ registry.register(makeRichAdapter(async () => {
132
+ throw new Error("upstream down");
133
+ }));
134
+ const capturer = composeSnapshotContentCapturer({
135
+ registry,
136
+ contentAdapters: new Map([["products", verticalAdapter]]),
137
+ });
138
+ await expect(capturer(baseInput)).rejects.toBeInstanceOf(SnapshotContentUnavailableError);
139
+ });
140
+ it("returns cache-fallback when adapter is thin and a cache row exists", async () => {
141
+ const cachedFetchedAt = new Date("2026-03-01T00:00:00Z");
142
+ const verticalAdapter = {
143
+ refresh: async () => {
144
+ throw new Error("should not be called for thin adapter");
145
+ },
146
+ readCached: async () => ({
147
+ payload: { product: { id: "prod_abc", name: "Sample" } },
148
+ content_schema_version: "products/v1",
149
+ fetched_at: cachedFetchedAt,
150
+ etag: null,
151
+ }),
152
+ };
153
+ const registry = createSourceAdapterRegistry();
154
+ registry.register(makeThinAdapter());
155
+ const capturer = composeSnapshotContentCapturer({
156
+ registry,
157
+ contentAdapters: new Map([["products", verticalAdapter]]),
158
+ });
159
+ const result = await capturer({ ...baseInput, source_kind: "thin" });
160
+ expect(result?.source).toBe("cache_fallback");
161
+ expect(result?.fallback_reason).toContain("does not implement getContent");
162
+ });
163
+ it("throws when adapter is thin and no cache row exists", async () => {
164
+ const verticalAdapter = {
165
+ refresh: async () => {
166
+ throw new Error("never");
167
+ },
168
+ readCached: async () => null,
169
+ };
170
+ const registry = createSourceAdapterRegistry();
171
+ registry.register(makeThinAdapter());
172
+ const capturer = composeSnapshotContentCapturer({
173
+ registry,
174
+ contentAdapters: new Map([["products", verticalAdapter]]),
175
+ });
176
+ await expect(capturer({ ...baseInput, source_kind: "thin" })).rejects.toBeInstanceOf(SnapshotContentUnavailableError);
177
+ });
178
+ it("threads the request scope (locale/market/currency) into refresh + readCached", async () => {
179
+ const seenRequests = [];
180
+ const verticalAdapter = {
181
+ refresh: async (_db, _adapter, _ctx, request) => {
182
+ seenRequests.push(request);
183
+ return {
184
+ entity_module: request.entity_module,
185
+ entity_id: request.entity_id,
186
+ source_ref: "REF-1",
187
+ returned_locale: request.locale,
188
+ content: {},
189
+ content_schema_version: "products/v1",
190
+ };
191
+ },
192
+ readCached: async () => null,
193
+ };
194
+ const registry = createSourceAdapterRegistry();
195
+ registry.register(makeRichAdapter(async () => ({
196
+ entity_module: "products",
197
+ entity_id: "prod_abc",
198
+ source_ref: "REF-1",
199
+ returned_locale: "ro-RO",
200
+ content: {},
201
+ content_schema_version: "products/v1",
202
+ })));
203
+ const capturer = composeSnapshotContentCapturer({
204
+ registry,
205
+ contentAdapters: new Map([["products", verticalAdapter]]),
206
+ });
207
+ await capturer({ ...baseInput, locale: "ro-RO", market: "RO", currency: "RON" });
208
+ expect(seenRequests).toHaveLength(1);
209
+ expect(seenRequests[0]?.locale).toBe("ro-RO");
210
+ expect(seenRequests[0]?.market).toBe("RO");
211
+ expect(seenRequests[0]?.currency).toBe("RON");
212
+ });
213
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Source discovery sync — pulls projections from every registered
3
+ * `SourceAdapter` and pushes them into the deployment's indexer so
4
+ * sourced inventory shows up in the catalog UI alongside owned rows.
5
+ *
6
+ * Mirrors the bulk reindex flow (`scripts/reindex.ts` in operator
7
+ * templates) for owned rows, except the data comes from
8
+ * `adapter.discover()` instead of a Drizzle table scan.
9
+ *
10
+ * The orchestrator is pure: it takes the registry, the indexer service,
11
+ * and the per-vertical field-policy registries, and returns a summary
12
+ * of what was synced. Templates wire env / Typesense / embeddings; this
13
+ * function only touches what was passed in.
14
+ *
15
+ * Usage pattern (in a template script):
16
+ *
17
+ * const summary = await syncSources({
18
+ * registry,
19
+ * indexerService,
20
+ * fieldPolicyRegistries,
21
+ * })
22
+ *
23
+ * Drift events, scheduled re-runs, and concurrency limits are deferred
24
+ * to the catalog plane's normal drift pipeline (foundation §5.5);
25
+ * `syncSources` is a one-shot bulk pass driven by a CLI or cron job.
26
+ */
27
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
28
+ import type { SourceAdapter, SourceAdapterContext } from "../adapter/contract.js";
29
+ import type { FieldPolicyRegistry } from "../contract.js";
30
+ import type { DocumentBuilder, IndexerService } from "../services/indexer-service.js";
31
+ import type { SourceAdapterRegistry } from "./registry.js";
32
+ export interface SyncSourcesOptions {
33
+ /** Booking-engine registry — every registered adapter's `discover` is fanned out. */
34
+ registry: SourceAdapterRegistry;
35
+ /** Indexer the projections land in. Caller passes the same instance the live route uses. */
36
+ indexerService: IndexerService;
37
+ /**
38
+ * Per-vertical field-policy registries keyed by `entity_module`.
39
+ * Same shape the indexer service is built with; passed separately so
40
+ * the sync can build documents from projections without re-reading
41
+ * the registry from the indexer service.
42
+ */
43
+ fieldPolicyRegistries: ReadonlyMap<string, FieldPolicyRegistry>;
44
+ /**
45
+ * Drizzle DB handle. When set, sync upserts a row into
46
+ * `catalog_sourced_entries` for every sourced projection (the durable
47
+ * provenance + projection-capture store, sourced-content §2.5). The
48
+ * upsert is idempotent on `(entity_module, entity_id)` and runs before
49
+ * the indexer write. Owned projections are skipped — they live in the
50
+ * vertical's owned schema, not the sourced-entry store.
51
+ *
52
+ * Optional only because pure-indexer test setups (no DB) want to use
53
+ * `syncSources` for indexer-shape coverage. Production wiring always
54
+ * passes a DB.
55
+ */
56
+ db?: AnyDrizzleDb;
57
+ /**
58
+ * Optional adapter context override. Most adapters need at minimum
59
+ * a `connection_id`; the demo plugin doesn't care so the default
60
+ * `{ connection_id: adapter.kind }` is sufficient. Templates with
61
+ * real connections pass their connection id (or build a per-adapter
62
+ * map keyed by `kind`).
63
+ */
64
+ buildAdapterContext?: (adapter: SourceAdapter) => SourceAdapterContext;
65
+ /**
66
+ * Optional wrapper around the per-projection `DocumentBuilder` — used
67
+ * to attach embeddings via `withEmbedding` (the operator starter's
68
+ * helper) without coupling this orchestrator to any embedding
69
+ * provider.
70
+ */
71
+ wrapBuilder?: (builder: DocumentBuilder) => DocumentBuilder;
72
+ /** Per-page log hook — called every page so callers can show progress. */
73
+ onProgress?: (event: SyncProgressEvent) => void;
74
+ /**
75
+ * Optional vertical allow-list. Scheduled vertical refreshes use this to run
76
+ * only the adapter projections relevant to the current job.
77
+ */
78
+ verticals?: ReadonlyArray<string>;
79
+ /**
80
+ * When true, rows from the same source/connection/vertical that were not
81
+ * emitted by a successful discovery pass are marked withdrawn and deleted
82
+ * from catalog search slices. Failed adapter passes never prune.
83
+ */
84
+ pruneMissing?: boolean;
85
+ }
86
+ export interface SyncProgressEvent {
87
+ adapter: string;
88
+ page: number;
89
+ pageSize: number;
90
+ totalSoFar: number;
91
+ }
92
+ export interface SyncAdapterSummary {
93
+ adapter: string;
94
+ pages: number;
95
+ projectionsSynced: number;
96
+ /**
97
+ * Verticals the adapter touched, derived from `entity_module` on
98
+ * each projection. Useful for the CLI to print per-vertical counts.
99
+ */
100
+ verticalsTouched: string[];
101
+ /**
102
+ * Projections skipped because no field-policy registry was registered
103
+ * for their `entity_module`. Common when an adapter declares more
104
+ * verticals than the deployment indexes.
105
+ */
106
+ skippedNoRegistry: number;
107
+ /**
108
+ * Projections that landed in `catalog_sourced_entries` (sourced rows
109
+ * with a DB handle in scope). Owned projections are not counted —
110
+ * they're not part of the sourced-entry store.
111
+ */
112
+ sourcedEntriesUpserted: number;
113
+ /**
114
+ * Owned projections passed through unchanged. Owned-flagged
115
+ * projections via `discover()` are an unusual case (most adapters
116
+ * emit only sourced rows) but the orchestrator still routes them to
117
+ * the indexer.
118
+ */
119
+ ownedProjections: number;
120
+ /**
121
+ * Previously active sourced rows that were not emitted by a successful
122
+ * discovery pass, marked withdrawn and removed from search slices.
123
+ */
124
+ withdrawnProjections: number;
125
+ }
126
+ export interface SyncSourcesSummary {
127
+ adapters: SyncAdapterSummary[];
128
+ totalProjections: number;
129
+ }
130
+ /**
131
+ * Run a one-shot discovery sync against every registered adapter.
132
+ * Throws if an adapter doesn't support `discover` (a contract violation —
133
+ * adapters wishing to participate in the sync must implement it).
134
+ */
135
+ export declare function syncSources(options: SyncSourcesOptions): Promise<SyncSourcesSummary>;
136
+ //# sourceMappingURL=sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/booking-engine/sync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAGzD,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAOrF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,MAAM,WAAW,kBAAkB;IACjC,qFAAqF;IACrF,QAAQ,EAAE,qBAAqB,CAAA;IAC/B,4FAA4F;IAC5F,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;;OAKG;IACH,qBAAqB,EAAE,WAAW,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IAC/D;;;;;;;;;;;OAWG;IACH,EAAE,CAAC,EAAE,YAAY,CAAA;IACjB;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,oBAAoB,CAAA;IACtE;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,eAAe,CAAA;IAC3D,0EAA0E;IAC1E,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAA;IAC/C;;;OAGG;IACH,SAAS,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACjC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,iBAAiB,EAAE,MAAM,CAAA;IACzB;;;OAGG;IACH,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,sBAAsB,EAAE,MAAM,CAAA;IAC9B;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,CAAA;IACxB;;;OAGG;IACH,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA6H1F"}
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Source discovery sync — pulls projections from every registered
3
+ * `SourceAdapter` and pushes them into the deployment's indexer so
4
+ * sourced inventory shows up in the catalog UI alongside owned rows.
5
+ *
6
+ * Mirrors the bulk reindex flow (`scripts/reindex.ts` in operator
7
+ * templates) for owned rows, except the data comes from
8
+ * `adapter.discover()` instead of a Drizzle table scan.
9
+ *
10
+ * The orchestrator is pure: it takes the registry, the indexer service,
11
+ * and the per-vertical field-policy registries, and returns a summary
12
+ * of what was synced. Templates wire env / Typesense / embeddings; this
13
+ * function only touches what was passed in.
14
+ *
15
+ * Usage pattern (in a template script):
16
+ *
17
+ * const summary = await syncSources({
18
+ * registry,
19
+ * indexerService,
20
+ * fieldPolicyRegistries,
21
+ * })
22
+ *
23
+ * Drift events, scheduled re-runs, and concurrency limits are deferred
24
+ * to the catalog plane's normal drift pipeline (foundation §5.5);
25
+ * `syncSources` is a one-shot bulk pass driven by a CLI or cron job.
26
+ */
27
+ import { isOwned } from "../provenance.js";
28
+ import { buildIndexerDocument } from "../services/indexer-service.js";
29
+ import { markMissingSourcedEntriesWithdrawn, upsertSourcedEntry, } from "../services/sourced-entry-service.js";
30
+ /**
31
+ * Run a one-shot discovery sync against every registered adapter.
32
+ * Throws if an adapter doesn't support `discover` (a contract violation —
33
+ * adapters wishing to participate in the sync must implement it).
34
+ */
35
+ export async function syncSources(options) {
36
+ const verticalFilter = options.verticals ? new Set(options.verticals) : undefined;
37
+ // Iterate every registered (connection_id, adapter) pair — multiple
38
+ // connections of the same kind each get their own discovery pass.
39
+ // Skip adapters that don't implement `discover` (outbound-only
40
+ // channel-push adapters).
41
+ const entries = options.registry
42
+ .connections()
43
+ .map((connectionId) => ({
44
+ connectionId,
45
+ adapter: options.registry.resolveByConnectionOrThrow(connectionId),
46
+ }))
47
+ .filter((e) => typeof e.adapter.discover === "function")
48
+ .filter((e) => !verticalFilter ||
49
+ e.adapter.capabilities.verticals.some((vertical) => verticalFilter.has(vertical)));
50
+ const adapterSummaries = [];
51
+ let totalProjections = 0;
52
+ for (const { connectionId, adapter } of entries) {
53
+ const adapterCtx = options.buildAdapterContext?.(adapter) ?? {
54
+ connection_id: connectionId,
55
+ };
56
+ const summary = {
57
+ adapter: adapter.kind,
58
+ pages: 0,
59
+ projectionsSynced: 0,
60
+ verticalsTouched: [],
61
+ skippedNoRegistry: 0,
62
+ sourcedEntriesUpserted: 0,
63
+ ownedProjections: 0,
64
+ withdrawnProjections: 0,
65
+ };
66
+ const verticals = new Set();
67
+ const seenBySource = new Map();
68
+ let cursor;
69
+ do {
70
+ // `discover` is optional in the contract; the filter above
71
+ // ensures we only iterate adapters that implement it.
72
+ const page = await adapter.discover?.(adapterCtx, cursor);
73
+ if (!page)
74
+ break;
75
+ summary.pages += 1;
76
+ options.onProgress?.({
77
+ adapter: adapter.kind,
78
+ page: summary.pages,
79
+ pageSize: page.projections.length,
80
+ totalSoFar: summary.projectionsSynced + page.projections.length,
81
+ });
82
+ for (const projection of page.projections) {
83
+ if (verticalFilter && !verticalFilter.has(projection.entity_module)) {
84
+ continue;
85
+ }
86
+ const registry = options.fieldPolicyRegistries.get(projection.entity_module);
87
+ if (!registry) {
88
+ summary.skippedNoRegistry += 1;
89
+ continue;
90
+ }
91
+ verticals.add(projection.entity_module);
92
+ // Durable sourced-entry capture (sourced-content §2.5.2).
93
+ // Owned projections skip — they live in the vertical's owned
94
+ // schema. Sourced projections upsert before the indexer write so
95
+ // the sourced-entry store is the canonical local copy of what
96
+ // discover() said for downstream synthesizer reads.
97
+ if (isOwned(projection.provenance)) {
98
+ summary.ownedProjections += 1;
99
+ }
100
+ else if (options.db) {
101
+ await upsertSourcedEntry(options.db, { projection });
102
+ summary.sourcedEntriesUpserted += 1;
103
+ if (options.pruneMissing) {
104
+ trackSeenSourcedProjection(seenBySource, {
105
+ entityModule: projection.entity_module,
106
+ entityId: projection.entity_id,
107
+ sourceKind: projection.provenance.source_kind,
108
+ sourceConnectionId: projection.provenance.source_connection_id,
109
+ });
110
+ }
111
+ }
112
+ const projectionMap = toProjectionMap(projection.fields);
113
+ const baseBuilder = async (_entityId, slice) => buildIndexerDocument(registry, projectionMap, slice, projection.entity_id);
114
+ const builder = options.wrapBuilder ? options.wrapBuilder(baseBuilder) : baseBuilder;
115
+ await options.indexerService.reindexEntity(projection.entity_module, projection.entity_id, builder);
116
+ summary.projectionsSynced += 1;
117
+ totalProjections += 1;
118
+ }
119
+ cursor = page.next_cursor;
120
+ } while (cursor);
121
+ if (options.pruneMissing && options.db) {
122
+ ensureAdapterVerticalPruneScopes(seenBySource, adapter, connectionId, verticalFilter);
123
+ for (const seen of seenBySource.values()) {
124
+ const withdrawn = await markMissingSourcedEntriesWithdrawn(options.db, {
125
+ entityModule: seen.entityModule,
126
+ sourceKind: seen.sourceKind,
127
+ sourceConnectionId: seen.sourceConnectionId,
128
+ seenEntityIds: seen.entityIds,
129
+ });
130
+ for (const row of withdrawn) {
131
+ await options.indexerService.deleteEntity(row.entity_module, row.entity_id);
132
+ }
133
+ summary.withdrawnProjections += withdrawn.length;
134
+ }
135
+ }
136
+ summary.verticalsTouched = [...verticals];
137
+ adapterSummaries.push(summary);
138
+ }
139
+ return { adapters: adapterSummaries, totalProjections };
140
+ }
141
+ function toProjectionMap(fields) {
142
+ return new Map(Object.entries(fields));
143
+ }
144
+ function trackSeenSourcedProjection(seenBySource, input) {
145
+ const key = sourceScopeKey(input);
146
+ let seen = seenBySource.get(key);
147
+ if (!seen) {
148
+ seen = {
149
+ entityModule: input.entityModule,
150
+ sourceKind: input.sourceKind,
151
+ sourceConnectionId: input.sourceConnectionId,
152
+ entityIds: new Set(),
153
+ };
154
+ seenBySource.set(key, seen);
155
+ }
156
+ seen.entityIds.add(input.entityId);
157
+ }
158
+ function ensureAdapterVerticalPruneScopes(seenBySource, adapter, connectionId, verticalFilter) {
159
+ const verticals = adapter.capabilities.verticals.filter((vertical) => !verticalFilter || verticalFilter.has(vertical));
160
+ for (const entityModule of verticals) {
161
+ const input = {
162
+ entityModule,
163
+ sourceKind: adapter.kind,
164
+ sourceConnectionId: connectionId,
165
+ };
166
+ const key = sourceScopeKey(input);
167
+ if (seenBySource.has(key))
168
+ continue;
169
+ seenBySource.set(key, {
170
+ ...input,
171
+ entityIds: new Set(),
172
+ });
173
+ }
174
+ }
175
+ function sourceScopeKey(input) {
176
+ return `${input.entityModule}\u0000${input.sourceKind}\u0000${input.sourceConnectionId ?? ""}`;
177
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sync.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.test.d.ts","sourceRoot":"","sources":["../../src/booking-engine/sync.test.ts"],"names":[],"mappings":""}