@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,377 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createSourceAdapterRegistry } from "./registry.js";
3
+ import { syncSources } from "./sync.js";
4
+ function drizzleStub(methods) {
5
+ return methods;
6
+ }
7
+ /**
8
+ * Stub IndexerService that records every reindex call and synthesizes
9
+ * its own slices so the sync's pagination + dispatch are observable
10
+ * without spinning up Typesense.
11
+ */
12
+ function makeStubIndexer(slicesByVertical) {
13
+ const upserted = [];
14
+ const deleted = [];
15
+ const service = {
16
+ async ensureCollections() { },
17
+ async reindexEntity(entityModule, entityId, builder) {
18
+ const slices = slicesByVertical[entityModule] ?? [];
19
+ for (const slice of slices) {
20
+ const doc = await builder(entityId, slice);
21
+ if (doc)
22
+ upserted.push({ slice, doc });
23
+ }
24
+ },
25
+ async reindexEntityForSlice(slice, entityId, builder) {
26
+ const doc = await builder(entityId, slice);
27
+ if (doc)
28
+ upserted.push({ slice, doc });
29
+ },
30
+ async deleteEntity(entityModule, entityId) {
31
+ deleted.push({ entityModule, entityId });
32
+ },
33
+ async search(_slice, _request) {
34
+ return { total: 0, hits: [] };
35
+ },
36
+ slicesForVertical(entityModule) {
37
+ return slicesByVertical[entityModule] ?? [];
38
+ },
39
+ };
40
+ return { service, upserted, deleted };
41
+ }
42
+ /**
43
+ * Stub field-policy registry that pretends every projection field is
44
+ * indexed and visible to staff. The sync's job is to call
45
+ * `buildIndexerDocument` with the right registry, slice, and entity id —
46
+ * the registry's actual policy logic is covered by other tests.
47
+ */
48
+ function makePassthroughRegistry() {
49
+ const policy = (path) => ({
50
+ path,
51
+ class: "managed",
52
+ merge: "source-only",
53
+ drift: "low",
54
+ reindex: "entry",
55
+ snapshot: "never",
56
+ query: "indexed-column",
57
+ localized: false,
58
+ visibility: ["staff", "customer", "partner", "supplier"],
59
+ editRole: "none",
60
+ overrideFriction: "none",
61
+ sourceFreshness: "static",
62
+ });
63
+ return {
64
+ policies: [],
65
+ byPath: new Map(),
66
+ resolve(path) {
67
+ return policy(path);
68
+ },
69
+ };
70
+ }
71
+ function makeAdapter(kind, pages) {
72
+ let pageIdx = 0;
73
+ return {
74
+ kind,
75
+ capabilities: {
76
+ verticals: ["products"],
77
+ supportsLiveResolution: false,
78
+ supportsDriftDetection: false,
79
+ supportsBookingForwarding: false,
80
+ postBookOperations: [],
81
+ },
82
+ connect: async () => undefined,
83
+ pause: async () => undefined,
84
+ disconnect: async () => undefined,
85
+ getState: async () => "active",
86
+ discover: async () => {
87
+ const page = pages[pageIdx] ?? { projections: [], next: undefined };
88
+ pageIdx += 1;
89
+ return {
90
+ projections: page.projections.map((id) => ({
91
+ entity_module: "products",
92
+ entity_id: id,
93
+ provenance: { source_kind: kind, source_freshness: "sync" },
94
+ fields: { id, name: `Inventory ${id}`, status: "active" },
95
+ })),
96
+ next_cursor: page.next,
97
+ };
98
+ },
99
+ };
100
+ }
101
+ describe("syncSources", () => {
102
+ it("paginates discovery and pushes each projection into every slice for the vertical", async () => {
103
+ const registry = createSourceAdapterRegistry();
104
+ registry.register(makeAdapter("demo", [
105
+ { projections: ["a", "b"], next: "page-2" },
106
+ { projections: ["c"], next: undefined },
107
+ ]));
108
+ const slices = [
109
+ { vertical: "products", locale: "en-GB", audience: "staff", market: "default" },
110
+ { vertical: "products", locale: "en-GB", audience: "customer", market: "default" },
111
+ ];
112
+ const { service, upserted } = makeStubIndexer({ products: slices });
113
+ const fieldPolicyRegistries = new Map([["products", makePassthroughRegistry()]]);
114
+ const summary = await syncSources({
115
+ registry,
116
+ indexerService: service,
117
+ fieldPolicyRegistries,
118
+ });
119
+ expect(summary.totalProjections).toBe(3);
120
+ expect(summary.adapters[0]?.pages).toBe(2);
121
+ expect(summary.adapters[0]?.projectionsSynced).toBe(3);
122
+ expect(summary.adapters[0]?.verticalsTouched).toEqual(["products"]);
123
+ // 3 projections × 2 slices = 6 upserts
124
+ expect(upserted).toHaveLength(6);
125
+ expect(upserted.map((u) => u.doc.id).sort()).toEqual(["a", "a", "b", "b", "c", "c"]);
126
+ });
127
+ it("skips projections whose vertical has no field-policy registry", async () => {
128
+ const registry = createSourceAdapterRegistry();
129
+ registry.register(makeAdapter("demo", [{ projections: ["x"], next: undefined }]));
130
+ const slices = [
131
+ { vertical: "products", locale: "en-GB", audience: "staff", market: "default" },
132
+ ];
133
+ const { service, upserted } = makeStubIndexer({ products: slices });
134
+ // Empty registry map → projection should be skipped, not crash.
135
+ const summary = await syncSources({
136
+ registry,
137
+ indexerService: service,
138
+ fieldPolicyRegistries: new Map(),
139
+ });
140
+ expect(summary.totalProjections).toBe(0);
141
+ expect(summary.adapters[0]?.projectionsSynced).toBe(0);
142
+ expect(summary.adapters[0]?.skippedNoRegistry).toBe(1);
143
+ expect(upserted).toHaveLength(0);
144
+ });
145
+ it("emits onProgress per page", async () => {
146
+ const registry = createSourceAdapterRegistry();
147
+ registry.register(makeAdapter("demo", [
148
+ { projections: ["a", "b"], next: "p2" },
149
+ { projections: ["c"], next: undefined },
150
+ ]));
151
+ const { service } = makeStubIndexer({
152
+ products: [{ vertical: "products", locale: "en-GB", audience: "staff", market: "default" }],
153
+ });
154
+ const fieldPolicyRegistries = new Map([["products", makePassthroughRegistry()]]);
155
+ const events = [];
156
+ await syncSources({
157
+ registry,
158
+ indexerService: service,
159
+ fieldPolicyRegistries,
160
+ onProgress(event) {
161
+ events.push(event.pageSize);
162
+ },
163
+ });
164
+ expect(events).toEqual([2, 1]);
165
+ });
166
+ it("counts owned vs sourced projections without a DB handle", async () => {
167
+ // Adapter that emits one sourced + one owned projection. Without a
168
+ // DB handle in scope, sync skips the sourced-entry upsert entirely;
169
+ // it still counts owned vs sourced for diagnostics.
170
+ const registry = createSourceAdapterRegistry();
171
+ registry.register({
172
+ kind: "mixed",
173
+ capabilities: {
174
+ verticals: ["products"],
175
+ supportsLiveResolution: false,
176
+ supportsDriftDetection: false,
177
+ supportsBookingForwarding: false,
178
+ postBookOperations: [],
179
+ },
180
+ connect: async () => undefined,
181
+ pause: async () => undefined,
182
+ disconnect: async () => undefined,
183
+ getState: async () => "active",
184
+ discover: async () => ({
185
+ projections: [
186
+ {
187
+ entity_module: "products",
188
+ entity_id: "owned_a",
189
+ provenance: { source_kind: "owned", source_freshness: "static" },
190
+ fields: { id: "owned_a", name: "Owned A", status: "active" },
191
+ },
192
+ {
193
+ entity_module: "products",
194
+ entity_id: "src_b",
195
+ provenance: { source_kind: "direct:tui", source_freshness: "sync" },
196
+ fields: { id: "src_b", name: "Sourced B", status: "active" },
197
+ },
198
+ ],
199
+ next_cursor: undefined,
200
+ }),
201
+ });
202
+ const { service } = makeStubIndexer({
203
+ products: [{ vertical: "products", locale: "en-GB", audience: "staff", market: "default" }],
204
+ });
205
+ const fieldPolicyRegistries = new Map([["products", makePassthroughRegistry()]]);
206
+ const summary = await syncSources({
207
+ registry,
208
+ indexerService: service,
209
+ fieldPolicyRegistries,
210
+ // No db handle → sourced-entry upsert path is gated off.
211
+ });
212
+ expect(summary.adapters[0]?.projectionsSynced).toBe(2);
213
+ expect(summary.adapters[0]?.ownedProjections).toBe(1);
214
+ expect(summary.adapters[0]?.sourcedEntriesUpserted).toBe(0);
215
+ });
216
+ it("upserts sourced-entry rows when a DB handle is in scope (stub)", async () => {
217
+ // Stub the upsert path by passing a fake `db` and asserting the
218
+ // counter advances. The integration test covers the actual SQL.
219
+ const registry = createSourceAdapterRegistry();
220
+ registry.register(makeAdapter("demo", [{ projections: ["a", "b"], next: undefined }]));
221
+ const { service } = makeStubIndexer({
222
+ products: [{ vertical: "products", locale: "en-GB", audience: "staff", market: "default" }],
223
+ });
224
+ const fieldPolicyRegistries = new Map([["products", makePassthroughRegistry()]]);
225
+ // Stub db whose .insert(...).values(...).onConflictDoUpdate(...).returning()
226
+ // returns one row. Mirrors enough of the drizzle chain to exercise sync.
227
+ const upsertCalls = [];
228
+ const stubDb = drizzleStub({
229
+ insert() {
230
+ return {
231
+ values(v) {
232
+ upsertCalls.push(v);
233
+ return {
234
+ onConflictDoUpdate() {
235
+ return {
236
+ async returning() {
237
+ return [{ id: "cse_x", entity_module: "products", entity_id: "a" }];
238
+ },
239
+ };
240
+ },
241
+ };
242
+ },
243
+ };
244
+ },
245
+ });
246
+ const summary = await syncSources({
247
+ registry,
248
+ indexerService: service,
249
+ fieldPolicyRegistries,
250
+ db: stubDb,
251
+ });
252
+ expect(summary.adapters[0]?.sourcedEntriesUpserted).toBe(2);
253
+ expect(upsertCalls).toHaveLength(2);
254
+ });
255
+ it("applies wrapBuilder to every per-projection builder", async () => {
256
+ const registry = createSourceAdapterRegistry();
257
+ registry.register(makeAdapter("demo", [{ projections: ["a"], next: undefined }]));
258
+ const { service, upserted } = makeStubIndexer({
259
+ products: [{ vertical: "products", locale: "en-GB", audience: "staff", market: "default" }],
260
+ });
261
+ const fieldPolicyRegistries = new Map([["products", makePassthroughRegistry()]]);
262
+ let wrapped = 0;
263
+ await syncSources({
264
+ registry,
265
+ indexerService: service,
266
+ fieldPolicyRegistries,
267
+ wrapBuilder(inner) {
268
+ wrapped += 1;
269
+ return async (entityId, slice) => {
270
+ const doc = await inner(entityId, slice);
271
+ if (!doc)
272
+ return null;
273
+ return {
274
+ ...doc,
275
+ fields: { ...doc.fields, wrapped: true },
276
+ };
277
+ };
278
+ },
279
+ });
280
+ expect(wrapped).toBe(1);
281
+ expect(upserted[0]?.doc.fields.wrapped).toBe(true);
282
+ });
283
+ it("marks missing sourced rows withdrawn and deletes them from index slices after a successful pruned sync", async () => {
284
+ const registry = createSourceAdapterRegistry();
285
+ registry.register("conn-a", {
286
+ kind: "demo",
287
+ capabilities: {
288
+ verticals: ["products"],
289
+ supportsLiveResolution: false,
290
+ supportsDriftDetection: false,
291
+ supportsBookingForwarding: false,
292
+ postBookOperations: [],
293
+ },
294
+ connect: async () => undefined,
295
+ pause: async () => undefined,
296
+ disconnect: async () => undefined,
297
+ getState: async () => "active",
298
+ discover: async () => ({
299
+ projections: [
300
+ {
301
+ entity_module: "products",
302
+ entity_id: "active_a",
303
+ provenance: {
304
+ source_kind: "demo",
305
+ source_connection_id: "conn-a",
306
+ source_ref: "src-a",
307
+ source_freshness: "sync",
308
+ },
309
+ fields: { id: "active_a", name: "Active A", status: "active" },
310
+ },
311
+ ],
312
+ next_cursor: undefined,
313
+ }),
314
+ });
315
+ const { service, deleted } = makeStubIndexer({
316
+ products: [{ vertical: "products", locale: "en-GB", audience: "staff", market: "default" }],
317
+ });
318
+ const fieldPolicyRegistries = new Map([["products", makePassthroughRegistry()]]);
319
+ const updatedIds = [];
320
+ const stubDb = drizzleStub({
321
+ insert() {
322
+ return {
323
+ values() {
324
+ return {
325
+ onConflictDoUpdate() {
326
+ return {
327
+ async returning() {
328
+ return [{ id: "cse_active", entity_module: "products", entity_id: "active_a" }];
329
+ },
330
+ };
331
+ },
332
+ };
333
+ },
334
+ };
335
+ },
336
+ select() {
337
+ return {
338
+ from() {
339
+ return {
340
+ async where() {
341
+ return [
342
+ {
343
+ id: "cse_stale",
344
+ entity_module: "products",
345
+ entity_id: "stale_b",
346
+ },
347
+ ];
348
+ },
349
+ };
350
+ },
351
+ };
352
+ },
353
+ update() {
354
+ return {
355
+ set() {
356
+ return {
357
+ async where(condition) {
358
+ updatedIds.push([String(condition)]);
359
+ },
360
+ };
361
+ },
362
+ };
363
+ },
364
+ });
365
+ const summary = await syncSources({
366
+ registry,
367
+ indexerService: service,
368
+ fieldPolicyRegistries,
369
+ db: stubDb,
370
+ pruneMissing: true,
371
+ verticals: ["products"],
372
+ });
373
+ expect(summary.adapters[0]?.withdrawnProjections).toBe(1);
374
+ expect(deleted).toEqual([{ entityModule: "products", entityId: "stale_b" }]);
375
+ expect(updatedIds).toHaveLength(1);
376
+ });
377
+ });
@@ -0,0 +1,2 @@
1
+ export * from "@voyant-travel/catalog-contracts/contract";
2
+ //# sourceMappingURL=contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAA"}
@@ -0,0 +1 @@
1
+ export * from "@voyant-travel/catalog-contracts/contract";
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=contract.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.test.d.ts","sourceRoot":"","sources":["../src/contract.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ancestorPaths, createFieldPolicyRegistry, defineFieldPolicy, FieldPolicyError, } from "./contract.js";
3
+ const baseLeaf = {
4
+ class: "merchandisable",
5
+ merge: "replace",
6
+ editRole: "marketing",
7
+ overrideFriction: "none",
8
+ snapshot: "on-book",
9
+ };
10
+ describe("ancestorPaths", () => {
11
+ it("returns empty for a single-segment path", () => {
12
+ expect(ancestorPaths("title")).toEqual([]);
13
+ });
14
+ it("returns nearest-first for nested paths", () => {
15
+ expect(ancestorPaths("geography.countries[].name")).toEqual([
16
+ "geography.countries[]",
17
+ "geography",
18
+ ]);
19
+ });
20
+ it("treats list[] as one segment", () => {
21
+ expect(ancestorPaths("gallery[].caption")).toEqual(["gallery[]"]);
22
+ });
23
+ });
24
+ describe("defineFieldPolicy — required axes", () => {
25
+ it("throws if a non-inheriting axis is missing", () => {
26
+ expect(() => defineFieldPolicy([
27
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid for the test -- owner: catalog; existing suppression is intentional pending typed cleanup.
28
+ { path: "title", class: "merchandisable" },
29
+ ])).toThrow(FieldPolicyError);
30
+ });
31
+ it("throws on duplicate paths", () => {
32
+ expect(() => defineFieldPolicy([
33
+ { path: "title", ...baseLeaf },
34
+ { path: "title", ...baseLeaf },
35
+ ])).toThrow(/duplicate/);
36
+ });
37
+ it("throws on malformed path syntax", () => {
38
+ expect(() => defineFieldPolicy([{ path: "with spaces", ...baseLeaf }])).toThrow(/invalid path segment/);
39
+ });
40
+ });
41
+ describe("defineFieldPolicy — inheritance", () => {
42
+ it("inherits drift / reindex / query / localized / visibility / sourceFreshness from ancestor", () => {
43
+ const policies = defineFieldPolicy([
44
+ {
45
+ path: "geography.countries[]",
46
+ ...baseLeaf,
47
+ class: "structural",
48
+ merge: "source-only",
49
+ editRole: "ops",
50
+ drift: "high",
51
+ reindex: "facet-affecting",
52
+ query: "indexed-column",
53
+ localized: false,
54
+ visibility: ["staff", "customer", "partner"],
55
+ sourceFreshness: "sync",
56
+ },
57
+ {
58
+ // Leaf declares only the non-inheriting axes; everything else inherits.
59
+ path: "geography.countries[].name",
60
+ ...baseLeaf,
61
+ class: "merchandisable",
62
+ merge: "replace",
63
+ editRole: "marketing",
64
+ },
65
+ ]);
66
+ const leaf = policies[1];
67
+ expect(leaf?.drift).toBe("high");
68
+ expect(leaf?.reindex).toBe("facet-affecting");
69
+ expect(leaf?.query).toBe("indexed-column");
70
+ expect(leaf?.visibility).toEqual(["staff", "customer", "partner"]);
71
+ expect(leaf?.sourceFreshness).toBe("sync");
72
+ });
73
+ it("applies registry defaults when no ancestor declares the inheriting axis", () => {
74
+ const policies = defineFieldPolicy([{ path: "title", ...baseLeaf }]);
75
+ const title = policies[0];
76
+ expect(title?.drift).toBe("none");
77
+ expect(title?.reindex).toBe("none");
78
+ expect(title?.query).toBe("blob-only");
79
+ expect(title?.localized).toBe(false);
80
+ expect(title?.visibility).toEqual(["staff"]);
81
+ expect(title?.sourceFreshness).toBeNull();
82
+ });
83
+ });
84
+ describe("createFieldPolicyRegistry", () => {
85
+ it("resolves exact matches first", () => {
86
+ const policies = defineFieldPolicy([
87
+ { path: "title", ...baseLeaf },
88
+ { path: "gallery[]", ...baseLeaf },
89
+ { path: "gallery[].caption", ...baseLeaf, editRole: "marketing" },
90
+ ]);
91
+ const registry = createFieldPolicyRegistry(policies);
92
+ expect(registry.resolve("gallery[].caption")?.path).toBe("gallery[].caption");
93
+ expect(registry.resolve("gallery[]")?.path).toBe("gallery[]");
94
+ expect(registry.resolve("title")?.path).toBe("title");
95
+ });
96
+ it("falls back to nearest ancestor when exact match is missing", () => {
97
+ const policies = defineFieldPolicy([{ path: "geography", ...baseLeaf, class: "structural" }]);
98
+ const registry = createFieldPolicyRegistry(policies);
99
+ // No exact policy for `geography.country` — falls back to `geography`.
100
+ expect(registry.resolve("geography.country")?.path).toBe("geography");
101
+ });
102
+ it("returns undefined when no ancestor matches", () => {
103
+ const policies = defineFieldPolicy([{ path: "title", ...baseLeaf }]);
104
+ const registry = createFieldPolicyRegistry(policies);
105
+ expect(registry.resolve("description")).toBeUndefined();
106
+ });
107
+ });
@@ -0,0 +1,2 @@
1
+ export * from "@voyant-travel/catalog-contracts/drift/events";
2
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/drift/events.ts"],"names":[],"mappings":"AAAA,cAAc,+CAA+C,CAAA"}
@@ -0,0 +1 @@
1
+ export * from "@voyant-travel/catalog-contracts/drift/events";
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=events.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.test.d.ts","sourceRoot":"","sources":["../../src/drift/events.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { blocksBookings, maxDriftSeverity, } from "./events.js";
3
+ describe("maxDriftSeverity", () => {
4
+ it("returns 'none' for an empty drift list", () => {
5
+ expect(maxDriftSeverity([])).toBe("none");
6
+ });
7
+ it("returns the highest severity present", () => {
8
+ const drifts = [
9
+ { field_path: "a", severity: "low", had_overlay: false },
10
+ { field_path: "b", severity: "high", had_overlay: false },
11
+ { field_path: "c", severity: "medium", had_overlay: false },
12
+ ];
13
+ expect(maxDriftSeverity(drifts)).toBe("high");
14
+ });
15
+ it("treats critical as the top of the order", () => {
16
+ const drifts = [
17
+ { field_path: "a", severity: "critical", had_overlay: true },
18
+ { field_path: "b", severity: "high", had_overlay: false },
19
+ ];
20
+ expect(maxDriftSeverity(drifts)).toBe("critical");
21
+ });
22
+ });
23
+ describe("blocksBookings", () => {
24
+ it("returns true only for critical severity", () => {
25
+ expect(blocksBookings("critical")).toBe(true);
26
+ expect(blocksBookings("high")).toBe(false);
27
+ expect(blocksBookings("medium")).toBe(false);
28
+ expect(blocksBookings("low")).toBe(false);
29
+ expect(blocksBookings("none")).toBe(false);
30
+ });
31
+ });
32
+ describe("CatalogDriftEvent", () => {
33
+ it("carries field-level drifts plus computed severity + booking-block flag", () => {
34
+ const event = {
35
+ drift_event_id: "cdrf_01HXX",
36
+ entity_module: "products",
37
+ entity_id: "prod_abc",
38
+ source_connection_id: "conn_tui",
39
+ source_kind: "direct:tui",
40
+ drifts: [{ field_path: "title", severity: "low", had_overlay: false }],
41
+ detected_at: new Date(),
42
+ max_severity: "low",
43
+ blocks_bookings: false,
44
+ };
45
+ expect(event.drifts).toHaveLength(1);
46
+ expect(event.blocks_bookings).toBe(false);
47
+ });
48
+ });
49
+ describe("ContentDriftEvent — sibling to CatalogDriftEvent", () => {
50
+ it("matches a single (entity_module, entity_id) when locale + market are unset", () => {
51
+ const event = {
52
+ id: "cnde_01HXX",
53
+ entity_module: "products",
54
+ entity_id: "prod_abc",
55
+ kind: "content_changed",
56
+ detected_at: new Date(),
57
+ };
58
+ expect(event.locale).toBeUndefined();
59
+ expect(event.market).toBeUndefined();
60
+ expect(event.kind).toBe("content_changed");
61
+ });
62
+ it("scopes invalidation to one locale + market when set", () => {
63
+ const event = {
64
+ id: "cnde_01HXY",
65
+ entity_module: "cruises",
66
+ entity_id: "crus_xyz",
67
+ locale: "de-DE",
68
+ market: "DE",
69
+ kind: "content_changed",
70
+ previous_etag: 'W/"abc"',
71
+ current_etag: 'W/"def"',
72
+ detected_at: new Date(),
73
+ };
74
+ expect(event.locale).toBe("de-DE");
75
+ expect(event.market).toBe("DE");
76
+ expect(event.previous_etag).toBe('W/"abc"');
77
+ expect(event.current_etag).toBe('W/"def"');
78
+ });
79
+ it("models the locale-added kind for newly-served languages", () => {
80
+ const event = {
81
+ id: "cnde_01HXZ",
82
+ entity_module: "products",
83
+ entity_id: "prod_abc",
84
+ locale: "ro-RO",
85
+ kind: "content_locale_added",
86
+ detected_at: new Date(),
87
+ };
88
+ expect(event.kind).toBe("content_locale_added");
89
+ });
90
+ it("models explicit invalidation for ops escalations", () => {
91
+ const event = {
92
+ id: "cnde_01HY0",
93
+ entity_module: "products",
94
+ entity_id: "prod_abc",
95
+ kind: "content_invalidated",
96
+ detected_at: new Date(),
97
+ };
98
+ expect(event.kind).toBe("content_invalidated");
99
+ });
100
+ });