@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,322 @@
1
+ import { handleApiError } from "@voyant-travel/hono";
2
+ import { Hono } from "hono";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createCatalogSearchHonoModule, createCatalogSearchRoutes, } from "./routes.js";
5
+ const emptyResults = { total: 0, hits: [], facets: {} };
6
+ function createIndexer(search = async () => emptyResults) {
7
+ return {
8
+ capabilities: {
9
+ supportsKeywordSearch: true,
10
+ supportsHybridSearch: true,
11
+ supportsVectorFields: true,
12
+ vectorDimensions: 3,
13
+ maxVectorsPerDocument: null,
14
+ supportsCrossAudienceFederation: false,
15
+ supportsAdminDenormalization: true,
16
+ },
17
+ ensureCollection: async () => { },
18
+ upsert: async () => { },
19
+ delete: async () => { },
20
+ search,
21
+ bulkReindex: async () => { },
22
+ };
23
+ }
24
+ function routeApp(options) {
25
+ const app = new Hono();
26
+ app.onError(handleApiError);
27
+ app.route("/v1/admin/catalog", createCatalogSearchRoutes(options));
28
+ return app;
29
+ }
30
+ describe("createCatalogSearchRoutes", () => {
31
+ it("exposes admin and public search routes through the Hono module wrapper", () => {
32
+ const module = createCatalogSearchHonoModule({
33
+ resolveRuntime: () => ({
34
+ indexer: createIndexer(),
35
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
36
+ }),
37
+ });
38
+ expect(module.module.name).toBe("catalog");
39
+ expect(module.adminRoutes).toBeTruthy();
40
+ expect(module.publicRoutes).toBeTruthy();
41
+ });
42
+ it("requires a vertical", async () => {
43
+ const app = routeApp({
44
+ surface: "admin",
45
+ resolveRuntime: () => ({
46
+ indexer: createIndexer(),
47
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
48
+ }),
49
+ });
50
+ const response = await app.request("/v1/admin/catalog/search", {
51
+ method: "POST",
52
+ body: JSON.stringify({ query: "rome" }),
53
+ });
54
+ expect(response.status).toBe(400);
55
+ await expect(response.json()).resolves.toEqual({ error: "vertical is required" });
56
+ });
57
+ it("returns shared validation errors for invalid search bodies", async () => {
58
+ const executeSearch = vi.fn(async (_input) => emptyResults);
59
+ const app = routeApp({
60
+ surface: "admin",
61
+ resolveRuntime: () => ({
62
+ indexer: createIndexer(),
63
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
64
+ }),
65
+ executeSearch,
66
+ });
67
+ const response = await app.request("/v1/admin/catalog/search", {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify({
71
+ vertical: "products",
72
+ mode: "unknown",
73
+ pagination: { limit: "10" },
74
+ }),
75
+ });
76
+ expect(response.status).toBe(400);
77
+ await expect(response.json()).resolves.toMatchObject({
78
+ code: "invalid_request",
79
+ });
80
+ expect(executeSearch).not.toHaveBeenCalled();
81
+ });
82
+ it("returns a deployment error when no indexer is configured", async () => {
83
+ const app = routeApp({
84
+ surface: "admin",
85
+ resolveRuntime: () => ({
86
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
87
+ }),
88
+ });
89
+ const response = await app.request("/v1/admin/catalog/search", {
90
+ method: "POST",
91
+ body: JSON.stringify({ vertical: "products" }),
92
+ });
93
+ expect(response.status).toBe(503);
94
+ await expect(response.json()).resolves.toEqual({
95
+ error: "Search indexer is not configured (missing TYPESENSE_HOST)",
96
+ });
97
+ });
98
+ it("uses the admin default audience and the customer public audience", async () => {
99
+ const executeSearch = vi.fn(async (_input) => emptyResults);
100
+ const module = createCatalogSearchHonoModule({
101
+ resolveRuntime: () => ({
102
+ indexer: createIndexer(),
103
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
104
+ }),
105
+ executeSearch,
106
+ });
107
+ const app = new Hono();
108
+ app.route("/v1/admin/catalog", module.adminRoutes);
109
+ app.route("/v1/public/catalog", module.publicRoutes);
110
+ await app.request("/v1/admin/catalog/search", {
111
+ method: "POST",
112
+ body: JSON.stringify({ vertical: "products", mode: "keyword" }),
113
+ });
114
+ await app.request("/v1/public/catalog/search", {
115
+ method: "POST",
116
+ body: JSON.stringify({ vertical: "products", mode: "keyword", locale: "ro-RO" }),
117
+ });
118
+ expect(executeSearch.mock.calls[0]?.[0].slice).toEqual({
119
+ vertical: "products",
120
+ locale: "en-GB",
121
+ audience: "staff",
122
+ market: "default",
123
+ });
124
+ expect(executeSearch.mock.calls[1]?.[0].slice).toEqual({
125
+ vertical: "products",
126
+ locale: "ro-RO",
127
+ audience: "customer",
128
+ market: "default",
129
+ });
130
+ });
131
+ it("downgrades semantic modes to keyword when embeddings are unavailable", async () => {
132
+ const executeSearch = vi.fn(async (_input) => emptyResults);
133
+ const app = routeApp({
134
+ surface: "admin",
135
+ resolveRuntime: () => ({
136
+ indexer: createIndexer(),
137
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
138
+ }),
139
+ executeSearch,
140
+ });
141
+ const response = await app.request("/v1/admin/catalog/search", {
142
+ method: "POST",
143
+ body: JSON.stringify({ vertical: "products", mode: "hybrid", query: "rome" }),
144
+ });
145
+ expect(response.status).toBe(200);
146
+ await expect(response.json()).resolves.toMatchObject({ mode: "keyword" });
147
+ expect(executeSearch).toHaveBeenCalledWith(expect.objectContaining({
148
+ request: expect.objectContaining({ mode: "keyword" }),
149
+ }));
150
+ });
151
+ it("passes typed storefront sort options into the search request", async () => {
152
+ const executeSearch = vi.fn(async (_input) => emptyResults);
153
+ const app = routeApp({
154
+ surface: "public",
155
+ resolveRuntime: () => ({
156
+ indexer: createIndexer(),
157
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
158
+ }),
159
+ executeSearch,
160
+ });
161
+ const response = await app.request("/v1/admin/catalog/search", {
162
+ method: "POST",
163
+ body: JSON.stringify({
164
+ vertical: "products",
165
+ mode: "keyword",
166
+ sort: "price-asc",
167
+ pagination: { limit: 12 },
168
+ }),
169
+ });
170
+ expect(response.status).toBe(200);
171
+ expect(executeSearch).toHaveBeenCalledWith(expect.objectContaining({
172
+ request: expect.objectContaining({
173
+ sort: "price-asc",
174
+ pagination: { limit: 12 },
175
+ }),
176
+ }));
177
+ });
178
+ it("projects storefront cards from indexed fields when requested", async () => {
179
+ const executeSearch = vi.fn(async (_input) => ({
180
+ total: 1,
181
+ facets: {
182
+ "categorySlugs[]": [{ value: "cruises", count: 4 }],
183
+ },
184
+ hits: [
185
+ {
186
+ id: "prod_abc",
187
+ score: 12,
188
+ document: {
189
+ id: "prod_abc",
190
+ fields: {
191
+ name: "Danube Cruise",
192
+ slug: "danube-cruise",
193
+ primaryCategoryId: "cat_cruises",
194
+ primaryCategoryName: "Cruises",
195
+ primaryCategorySlug: "cruises",
196
+ thumbnailUrl: "https://cdn.example/thumb.jpg",
197
+ coverMediaUrl: "https://cdn.example/cover.jpg",
198
+ priceFromAmountCents: 125000,
199
+ priceFromCurrency: "EUR",
200
+ originalPriceFromAmountCents: 150000,
201
+ hasOffer: true,
202
+ bestOfferId: "offer_spring",
203
+ bestOfferName: "Spring Sale",
204
+ bestOfferDiscountKind: "percentage",
205
+ bestOfferDiscountPercent: 15,
206
+ upcomingDepartureCount: 3,
207
+ nextDepartureAt: "2026-06-01T09:00:00Z",
208
+ nextDepartureDate: "2026-06-01",
209
+ "departureMonths[]": ["2026-06", "2026-07"],
210
+ "departureDates[]": ["2026-06-01", "2026-07-15"],
211
+ "regions[]": ["Europe"],
212
+ "countries[]": ["Romania"],
213
+ "cities[]": ["Tulcea"],
214
+ "destinationIds[]": ["dest_ro"],
215
+ "destinationSlugs[]": ["romania"],
216
+ latitude: 45.18,
217
+ longitude: 28.8,
218
+ },
219
+ },
220
+ },
221
+ ],
222
+ }));
223
+ const module = createCatalogSearchHonoModule({
224
+ resolveRuntime: () => ({
225
+ indexer: createIndexer(),
226
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
227
+ }),
228
+ executeSearch,
229
+ });
230
+ const app = new Hono();
231
+ app.route("/v1/public/catalog", module.publicRoutes);
232
+ const response = await app.request("/v1/public/catalog/search", {
233
+ method: "POST",
234
+ body: JSON.stringify({
235
+ vertical: "products",
236
+ mode: "keyword",
237
+ projection: "storefront-card",
238
+ pagination: { limit: 12 },
239
+ }),
240
+ });
241
+ expect(response.status).toBe(200);
242
+ await expect(response.json()).resolves.toMatchObject({
243
+ total: 1,
244
+ facets: {
245
+ "categorySlugs[]": [{ value: "cruises", count: 4 }],
246
+ },
247
+ cards: [
248
+ {
249
+ id: "prod_abc",
250
+ name: "Danube Cruise",
251
+ slug: "danube-cruise",
252
+ primaryCategory: {
253
+ id: "cat_cruises",
254
+ name: "Cruises",
255
+ slug: "cruises",
256
+ },
257
+ media: {
258
+ thumbnailUrl: "https://cdn.example/thumb.jpg",
259
+ coverMediaUrl: "https://cdn.example/cover.jpg",
260
+ },
261
+ priceFrom: {
262
+ amountCents: 125000,
263
+ currency: "EUR",
264
+ originalAmountCents: 150000,
265
+ },
266
+ offerBadges: [
267
+ {
268
+ id: "offer_spring",
269
+ name: "Spring Sale",
270
+ discountKind: "percentage",
271
+ discountPercent: 15,
272
+ discountAmountCents: null,
273
+ },
274
+ ],
275
+ departures: {
276
+ upcomingCount: 3,
277
+ nextDepartureAt: "2026-06-01T09:00:00Z",
278
+ nextDepartureDate: "2026-06-01",
279
+ months: ["2026-06", "2026-07"],
280
+ dates: ["2026-06-01", "2026-07-15"],
281
+ },
282
+ destinations: {
283
+ regions: ["Europe"],
284
+ countries: ["Romania"],
285
+ cities: ["Tulcea"],
286
+ ids: ["dest_ro"],
287
+ slugs: ["romania"],
288
+ },
289
+ coordinates: {
290
+ latitude: 45.18,
291
+ longitude: 28.8,
292
+ },
293
+ },
294
+ ],
295
+ });
296
+ });
297
+ it("retries hybrid searches as keyword when semantic execution fails", async () => {
298
+ const executeSearch = vi
299
+ .fn(async (_input) => emptyResults)
300
+ .mockRejectedValueOnce(new Error("embedding provider unavailable"))
301
+ .mockResolvedValueOnce(emptyResults);
302
+ const app = routeApp({
303
+ surface: "admin",
304
+ resolveRuntime: () => ({
305
+ indexer: createIndexer(),
306
+ embeddings: { kind: "test-embeddings" },
307
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
308
+ }),
309
+ executeSearch,
310
+ });
311
+ const response = await app.request("/v1/admin/catalog/search", {
312
+ method: "POST",
313
+ body: JSON.stringify({ vertical: "products", mode: "hybrid", query: "rome" }),
314
+ });
315
+ expect(response.status).toBe(200);
316
+ await expect(response.json()).resolves.toMatchObject({ mode: "keyword" });
317
+ expect(executeSearch.mock.calls.map(([input]) => input.request.mode)).toEqual([
318
+ "hybrid",
319
+ "keyword",
320
+ ]);
321
+ });
322
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Semantic / hybrid search orchestration.
3
+ *
4
+ * Wraps the Phase 1 `IndexerAdapter.search` with the embedding-generation
5
+ * step. When `mode: "semantic" | "hybrid"`, this helper embeds the query
6
+ * via the configured `EmbeddingProvider`, attaches the vector to the
7
+ * `SearchRequest` as `query_embedding`, and delegates to the adapter.
8
+ *
9
+ * Callers that already have a query embedding (an upstream agent that
10
+ * vectorized the user's intent) can skip embedding by passing
11
+ * `query_embedding` directly — `executeSemanticSearch` honors a
12
+ * caller-supplied vector and skips the embed call.
13
+ *
14
+ * See `docs/architecture/catalog-architecture.md` for the design.
15
+ */
16
+ import type { EmbeddingProvider } from "../embeddings/contract.js";
17
+ import type { IndexerAdapter, IndexerSlice, SearchRequest, SearchResults } from "../indexer/contract.js";
18
+ export interface SemanticSearchOptions {
19
+ /** Adapter to query. */
20
+ adapter: IndexerAdapter;
21
+ /**
22
+ * Embedding provider — used to vectorize the query string when the mode
23
+ * needs vectors. Optional: callers running pure-keyword searches can omit
24
+ * this. `executeSemanticSearch` throws a clear error if mode is
25
+ * `semantic` / `hybrid` and no provider is configured.
26
+ */
27
+ embeddings?: EmbeddingProvider;
28
+ /** The variant slice (vertical, locale, audience, market) to search. */
29
+ slice: IndexerSlice;
30
+ /** The search request. `mode` controls keyword/hybrid/semantic blending. */
31
+ request: SearchRequest;
32
+ }
33
+ /**
34
+ * Run a search request that may need a query embedding generated.
35
+ *
36
+ * Behavior by mode:
37
+ * - `keyword` — adapter.search called directly; no embedding work.
38
+ * - `hybrid` — query string is embedded (unless caller supplied
39
+ * `query_embedding`), adapter blends keyword + vector
40
+ * scores.
41
+ * - `semantic` — query string is embedded, adapter does pure vector
42
+ * similarity. (Engines that don't support pure-semantic
43
+ * typically fall back to hybrid with the keyword weight
44
+ * set very low.)
45
+ *
46
+ * Verifies adapter capabilities at runtime: requesting `semantic` /
47
+ * `hybrid` against an adapter without `supportsVectorFields` throws
48
+ * a clear error rather than silently degrading to keyword-only.
49
+ */
50
+ export declare function executeSemanticSearch(options: SemanticSearchOptions): Promise<SearchResults>;
51
+ /**
52
+ * Helper for callers (typically AI agents) that have already vectorized
53
+ * a query upstream and want to bypass the embedding step entirely. The
54
+ * vector is attached to the request as-is.
55
+ */
56
+ export declare function executeBYOVectorSearch(options: {
57
+ adapter: IndexerAdapter;
58
+ slice: IndexerSlice;
59
+ request: SearchRequest & {
60
+ query_embedding: number[];
61
+ };
62
+ }): Promise<SearchResults>;
63
+ //# sourceMappingURL=semantic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantic.d.ts","sourceRoot":"","sources":["../../src/search/semantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAClE,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,aAAa,EACb,aAAa,EACd,MAAM,wBAAwB,CAAA;AAE/B,MAAM,WAAW,qBAAqB;IACpC,wBAAwB;IACxB,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,iBAAiB,CAAA;IAC9B,wEAAwE;IACxE,KAAK,EAAE,YAAY,CAAA;IACnB,4EAA4E;IAC5E,OAAO,EAAE,aAAa,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,aAAa,CAAC,CAyCxB;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,OAAO,EAAE,cAAc,CAAA;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB,OAAO,EAAE,aAAa,GAAG;QAAE,eAAe,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;CACvD,GAAG,OAAO,CAAC,aAAa,CAAC,CAQzB"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Semantic / hybrid search orchestration.
3
+ *
4
+ * Wraps the Phase 1 `IndexerAdapter.search` with the embedding-generation
5
+ * step. When `mode: "semantic" | "hybrid"`, this helper embeds the query
6
+ * via the configured `EmbeddingProvider`, attaches the vector to the
7
+ * `SearchRequest` as `query_embedding`, and delegates to the adapter.
8
+ *
9
+ * Callers that already have a query embedding (an upstream agent that
10
+ * vectorized the user's intent) can skip embedding by passing
11
+ * `query_embedding` directly — `executeSemanticSearch` honors a
12
+ * caller-supplied vector and skips the embed call.
13
+ *
14
+ * See `docs/architecture/catalog-architecture.md` for the design.
15
+ */
16
+ /**
17
+ * Run a search request that may need a query embedding generated.
18
+ *
19
+ * Behavior by mode:
20
+ * - `keyword` — adapter.search called directly; no embedding work.
21
+ * - `hybrid` — query string is embedded (unless caller supplied
22
+ * `query_embedding`), adapter blends keyword + vector
23
+ * scores.
24
+ * - `semantic` — query string is embedded, adapter does pure vector
25
+ * similarity. (Engines that don't support pure-semantic
26
+ * typically fall back to hybrid with the keyword weight
27
+ * set very low.)
28
+ *
29
+ * Verifies adapter capabilities at runtime: requesting `semantic` /
30
+ * `hybrid` against an adapter without `supportsVectorFields` throws
31
+ * a clear error rather than silently degrading to keyword-only.
32
+ */
33
+ export async function executeSemanticSearch(options) {
34
+ const { adapter, embeddings, slice, request } = options;
35
+ if (request.mode === "keyword") {
36
+ return adapter.search(slice, request);
37
+ }
38
+ if (!adapter.capabilities.supportsVectorFields) {
39
+ throw new Error(`Search mode "${request.mode}" requires an indexer that supports vector fields. ` +
40
+ `Configured adapter does not declare supportsVectorFields. Use mode: "keyword" or swap to a vector-capable adapter (e.g. Typesense).`);
41
+ }
42
+ if (request.mode === "hybrid" && !adapter.capabilities.supportsHybridSearch) {
43
+ throw new Error(`Search mode "hybrid" requires an indexer that declares supportsHybridSearch. ` +
44
+ `Configured adapter does not. Either use mode: "semantic" (pure vector) or swap to a hybrid-capable engine.`);
45
+ }
46
+ // Use caller-supplied embedding if provided; otherwise generate one.
47
+ let queryEmbedding = request.query_embedding;
48
+ if (!queryEmbedding && request.query.length > 0) {
49
+ if (!embeddings) {
50
+ throw new Error(`Search mode "${request.mode}" requires an EmbeddingProvider to vectorize the query. ` +
51
+ `Configure one (e.g. createGeminiEmbeddingProvider) or supply request.query_embedding directly.`);
52
+ }
53
+ const [vector] = await embeddings.embed([request.query]);
54
+ if (!vector) {
55
+ throw new Error("EmbeddingProvider returned no vector for the query string");
56
+ }
57
+ queryEmbedding = vector;
58
+ }
59
+ return adapter.search(slice, {
60
+ ...request,
61
+ query_embedding: queryEmbedding,
62
+ });
63
+ }
64
+ /**
65
+ * Helper for callers (typically AI agents) that have already vectorized
66
+ * a query upstream and want to bypass the embedding step entirely. The
67
+ * vector is attached to the request as-is.
68
+ */
69
+ export async function executeBYOVectorSearch(options) {
70
+ const { adapter, slice, request } = options;
71
+ if (!adapter.capabilities.supportsVectorFields) {
72
+ throw new Error("BYO-vector search requires an indexer with supportsVectorFields. The configured adapter does not.");
73
+ }
74
+ return adapter.search(slice, request);
75
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=semantic.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantic.test.d.ts","sourceRoot":"","sources":["../../src/search/semantic.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { executeBYOVectorSearch, executeSemanticSearch } from "./semantic.js";
3
+ function makeAdapter(capabilities = {}) {
4
+ const baseCapabilities = {
5
+ supportsKeywordSearch: true,
6
+ supportsHybridSearch: true,
7
+ supportsVectorFields: true,
8
+ vectorDimensions: 1536,
9
+ maxVectorsPerDocument: null,
10
+ supportsCrossAudienceFederation: false,
11
+ supportsAdminDenormalization: true,
12
+ ...capabilities,
13
+ };
14
+ const searchSpy = vi.fn(async (_slice, _request) => ({
15
+ hits: [],
16
+ total: 0,
17
+ }));
18
+ return {
19
+ capabilities: baseCapabilities,
20
+ async ensureCollection() { },
21
+ async upsert() { },
22
+ async delete() { },
23
+ async bulkReindex() { },
24
+ search: searchSpy,
25
+ searchSpy,
26
+ };
27
+ }
28
+ function makeEmbeddings() {
29
+ const embedSpy = vi.fn(async (texts) => texts.map(() => [0.1, 0.2, 0.3]));
30
+ return {
31
+ capabilities: {
32
+ modelId: "test/v1",
33
+ dimensions: 3,
34
+ maxTokensPerInput: 1000,
35
+ maxBatchSize: 100,
36
+ },
37
+ embed: embedSpy,
38
+ embedSpy,
39
+ };
40
+ }
41
+ const slice = {
42
+ vertical: "products",
43
+ locale: "en-GB",
44
+ audience: "customer",
45
+ market: "default",
46
+ };
47
+ describe("executeSemanticSearch", () => {
48
+ it("keyword mode delegates directly without embedding", async () => {
49
+ const adapter = makeAdapter();
50
+ const embeddings = makeEmbeddings();
51
+ await executeSemanticSearch({
52
+ adapter,
53
+ embeddings,
54
+ slice,
55
+ request: { query: "wellness", mode: "keyword" },
56
+ });
57
+ expect(embeddings.embedSpy).not.toHaveBeenCalled();
58
+ expect(adapter.searchSpy).toHaveBeenCalledTimes(1);
59
+ });
60
+ it("semantic mode embeds the query and attaches the vector", async () => {
61
+ const adapter = makeAdapter();
62
+ const embeddings = makeEmbeddings();
63
+ await executeSemanticSearch({
64
+ adapter,
65
+ embeddings,
66
+ slice,
67
+ request: { query: "wellness", mode: "semantic" },
68
+ });
69
+ expect(embeddings.embedSpy).toHaveBeenCalledWith(["wellness"]);
70
+ const [, request] = adapter.searchSpy.mock.calls[0];
71
+ expect(request.query_embedding).toEqual([0.1, 0.2, 0.3]);
72
+ });
73
+ it("hybrid mode embeds the query when caller didn't supply one", async () => {
74
+ const adapter = makeAdapter();
75
+ const embeddings = makeEmbeddings();
76
+ await executeSemanticSearch({
77
+ adapter,
78
+ embeddings,
79
+ slice,
80
+ request: { query: "wellness", mode: "hybrid" },
81
+ });
82
+ expect(embeddings.embedSpy).toHaveBeenCalledTimes(1);
83
+ });
84
+ it("honors caller-supplied query_embedding without re-embedding", async () => {
85
+ const adapter = makeAdapter();
86
+ const embeddings = makeEmbeddings();
87
+ const caller_vector = [0.9, 0.8, 0.7];
88
+ await executeSemanticSearch({
89
+ adapter,
90
+ embeddings,
91
+ slice,
92
+ request: {
93
+ query: "wellness",
94
+ mode: "semantic",
95
+ query_embedding: caller_vector,
96
+ },
97
+ });
98
+ expect(embeddings.embedSpy).not.toHaveBeenCalled();
99
+ const [, request] = adapter.searchSpy.mock.calls[0];
100
+ expect(request.query_embedding).toBe(caller_vector);
101
+ });
102
+ it("throws when semantic mode is requested but adapter doesn't support vectors", async () => {
103
+ const adapter = makeAdapter({ supportsVectorFields: false });
104
+ const embeddings = makeEmbeddings();
105
+ await expect(executeSemanticSearch({
106
+ adapter,
107
+ embeddings,
108
+ slice,
109
+ request: { query: "x", mode: "semantic" },
110
+ })).rejects.toThrow(/supports vector fields/);
111
+ });
112
+ it("throws when hybrid mode is requested but adapter doesn't support hybrid", async () => {
113
+ const adapter = makeAdapter({ supportsVectorFields: true, supportsHybridSearch: false });
114
+ const embeddings = makeEmbeddings();
115
+ await expect(executeSemanticSearch({
116
+ adapter,
117
+ embeddings,
118
+ slice,
119
+ request: { query: "x", mode: "hybrid" },
120
+ })).rejects.toThrow(/supportsHybridSearch/);
121
+ });
122
+ });
123
+ describe("executeBYOVectorSearch", () => {
124
+ it("attaches the caller-supplied vector and delegates without embedding", async () => {
125
+ const adapter = makeAdapter();
126
+ const vector = [1, 2, 3];
127
+ await executeBYOVectorSearch({
128
+ adapter,
129
+ slice,
130
+ request: { query: "x", mode: "semantic", query_embedding: vector },
131
+ });
132
+ const [, request] = adapter.searchSpy.mock.calls[0];
133
+ expect(request.query_embedding).toBe(vector);
134
+ });
135
+ it("throws when adapter does not support vector fields", async () => {
136
+ const adapter = makeAdapter({ supportsVectorFields: false });
137
+ await expect(executeBYOVectorSearch({
138
+ adapter,
139
+ slice,
140
+ request: { query: "x", mode: "semantic", query_embedding: [1, 2, 3] },
141
+ })).rejects.toThrow(/supportsVectorFields/);
142
+ });
143
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=build-indexer-document.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-indexer-document.test.d.ts","sourceRoot":"","sources":["../../src/services/build-indexer-document.test.ts"],"names":[],"mappings":""}