@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,394 @@
1
+ // agent-quality: file-size exception -- owner: catalog; existing module stays co-located until a dedicated split preserves behavior and tests.
2
+ /**
3
+ * Native Typesense IndexerAdapter — the v1 default for catalog-plane search.
4
+ *
5
+ * Uses an injected `TypesenseClient` interface (mirroring the storage R2Bucket
6
+ * binding pattern) so the package doesn't take a hard dep on the Typesense
7
+ * HTTP SDK. Templates wire in the actual client.
8
+ *
9
+ * See `docs/architecture/catalog-architecture.md` §5.4.1 for design.
10
+ */
11
+ import { buildDefaultTypesenseQueryBy, buildDefaultTypesenseSearchFields, buildSearchQuery, isTypesenseSortableStringField, typesenseTypeForField, } from "./typesense-search-query.js";
12
+ export { buildDefaultTypesenseQueryBy, buildDefaultTypesenseSearchFields, buildSearchQuery, } from "./typesense-search-query.js";
13
+ const TYPESENSE_CAPABILITIES = {
14
+ supportsKeywordSearch: true,
15
+ supportsHybridSearch: true,
16
+ supportsVectorFields: true,
17
+ vectorDimensions: null, // overridden per-instance based on configured embedding provider
18
+ maxVectorsPerDocument: null,
19
+ supportsCrossAudienceFederation: true,
20
+ supportsAdminDenormalization: true,
21
+ };
22
+ /**
23
+ * Returns the Typesense collection name for one variant slice. Stable across
24
+ * runs so existing collections survive deployments.
25
+ */
26
+ export function collectionName(slice, prefix = "") {
27
+ const base = `${slice.vertical}__${slice.locale}__${slice.audience}__${slice.market}`;
28
+ return prefix ? `${prefix}__${base}` : base;
29
+ }
30
+ /**
31
+ * Builds a Typesense collection schema from the field-policy registry. Maps
32
+ * field-policy types onto Typesense field types using `query` + `class` from
33
+ * the policy.
34
+ */
35
+ export function buildCollectionSchema(slice, registry, options = {}) {
36
+ const fields = [];
37
+ for (const policy of registry.policies) {
38
+ // Skip blob-only fields (stored but not indexed).
39
+ if (policy.query === "blob-only")
40
+ continue;
41
+ // Skip fields not visible to this slice's audience.
42
+ if (slice.audience !== "staff-admin" && !policy.visibility.includes(slice.audience)) {
43
+ continue;
44
+ }
45
+ fields.push(typesenseFieldFromPolicy(policy));
46
+ }
47
+ // Vector field for embeddings (Phase 2). Only added if vectorDimensions is
48
+ // configured.
49
+ if (options.vectorDimensions != null) {
50
+ fields.push({
51
+ name: "text_embedding",
52
+ type: "float[]",
53
+ num_dim: options.vectorDimensions,
54
+ vec_dist: "cosine",
55
+ optional: true,
56
+ });
57
+ }
58
+ return {
59
+ name: collectionName(slice, options.collectionPrefix),
60
+ fields,
61
+ enable_nested_fields: true,
62
+ metadata: {
63
+ voyant: {
64
+ defaultQueryBy: buildDefaultTypesenseQueryBy(registry, slice),
65
+ defaultSearchFields: buildDefaultTypesenseSearchFields(registry, slice),
66
+ },
67
+ },
68
+ };
69
+ }
70
+ function typesenseFieldFromPolicy(policy) {
71
+ const isFacet = policy.reindex === "facet-affecting" || policy.class === "structural";
72
+ // Path-to-field-name: keep the dotted path; Typesense's nested fields handle it.
73
+ const name = policy.path;
74
+ const isList = name.endsWith("[]");
75
+ const baseName = isList ? name.slice(0, -2) : name;
76
+ const type = typesenseTypeForField(baseName, isList);
77
+ return {
78
+ name: baseName,
79
+ type,
80
+ facet: isFacet,
81
+ optional: true,
82
+ sort: type === "string" && isTypesenseSortableStringField(baseName) ? true : undefined,
83
+ };
84
+ }
85
+ /**
86
+ * Recognizes errors that originate from Typesense returning 404 when the
87
+ * collection doesn't exist. The fetch-based template client throws Errors
88
+ * whose message includes the status code; the official `typesense` SDK
89
+ * throws an `ObjectNotFound` with `httpStatus === 404`. Match either.
90
+ */
91
+ function isCollectionNotFoundError(err) {
92
+ if (!err)
93
+ return false;
94
+ const status = typeof err === "object" && err !== null && "httpStatus" in err
95
+ ? err.httpStatus
96
+ : undefined;
97
+ if (status === 404)
98
+ return true;
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ return / 404\b/.test(message) && /Not Found/i.test(message);
101
+ }
102
+ /**
103
+ * Fallback used when `search()` is called before `ensureCollection()` has
104
+ * cached a registry for this vertical. Fetches the live schema from
105
+ * Typesense and synthesizes a minimal `FieldPolicyRegistry` whose policies
106
+ * cover every string / string[] field as `merchandisable + indexed-column`.
107
+ * That gives `buildSearchQuery` a non-empty `query_by` so the search
108
+ * doesn't 404 on `query_by: "title"`.
109
+ */
110
+ async function inferRegistryFromCollection(client, collectionName) {
111
+ const schema = await client.collections(collectionName).retrieve();
112
+ const policies = [];
113
+ for (const field of schema.fields) {
114
+ if (field.type !== "string" && field.type !== "string[]")
115
+ continue;
116
+ if (field.name === "id" || field.name === "text_embedding")
117
+ continue;
118
+ policies.push({
119
+ path: field.type === "string[]" ? `${field.name}[]` : field.name,
120
+ class: "merchandisable",
121
+ merge: "replace",
122
+ drift: "low",
123
+ reindex: "entry",
124
+ snapshot: "never",
125
+ query: "indexed-column",
126
+ localized: false,
127
+ visibility: ["staff", "customer", "partner", "supplier"],
128
+ editRole: "marketing",
129
+ overrideFriction: "none",
130
+ sourceFreshness: "sync",
131
+ });
132
+ }
133
+ const byPath = new Map(policies.map((p) => [p.path, p]));
134
+ return {
135
+ policies,
136
+ byPath,
137
+ resolve: (path) => byPath.get(path),
138
+ };
139
+ }
140
+ export function createTypesenseIndexer(options) {
141
+ const { client, vectorDimensions = null, collectionPrefix = "" } = options;
142
+ const capabilities = {
143
+ ...TYPESENSE_CAPABILITIES,
144
+ vectorDimensions,
145
+ supportsVectorFields: vectorDimensions != null,
146
+ supportsHybridSearch: vectorDimensions != null,
147
+ };
148
+ // Cache the registry per vertical at `ensureCollection` time so `search`
149
+ // can build a correct `query_by` against actual schema fields. Without
150
+ // this, search falls back to `query_by: "title"` and Typesense returns
151
+ // 404 because the products schema has no `title` field.
152
+ //
153
+ // Seeded from `options.registries` so a search-only process (the worker)
154
+ // has the real policies without running `ensureCollection` — otherwise it
155
+ // falls back to the string-only inferred registry and numeric sorts no-op.
156
+ const registryByVertical = new Map(options.registries);
157
+ return {
158
+ capabilities,
159
+ async ensureCollection(slice, registry) {
160
+ registryByVertical.set(slice.vertical, registry);
161
+ const schema = buildCollectionSchema(slice, registry, {
162
+ vectorDimensions,
163
+ collectionPrefix,
164
+ });
165
+ // Typesense maintains `id` implicitly as the document primary key;
166
+ // it must not appear in the schema fields list (the server rejects
167
+ // alters to it with `Field "id" cannot be altered.`). Strip it
168
+ // unconditionally — if a vertical's field policy declares `id`,
169
+ // it's covered by the implicit doc id at index time.
170
+ const fieldsForServer = schema.fields.filter((f) => f.name !== "id");
171
+ const schemaForCreate = {
172
+ ...schema,
173
+ fields: fieldsForServer,
174
+ };
175
+ try {
176
+ await client.collections().create(schemaForCreate);
177
+ return;
178
+ }
179
+ catch {
180
+ // Collection already exists — fall through to the update path.
181
+ }
182
+ // Typesense's `update` only accepts new fields, drops, and
183
+ // drop+add as the way to "alter" an existing field. Diff existing
184
+ // vs desired and emit:
185
+ // - additions for fields that don't exist yet
186
+ // - drop+add pairs for fields whose facet/type drifted (so a
187
+ // policy change like reindex:"entry" → "facet-affecting" gets
188
+ // picked up without operators having to nuke the collection)
189
+ let existing;
190
+ try {
191
+ existing = await client.collections(schema.name).retrieve();
192
+ }
193
+ catch {
194
+ // If retrieve also fails, surface the original create error path
195
+ // by re-trying create — the second create will throw the real cause.
196
+ await client.collections().create(schemaForCreate);
197
+ return;
198
+ }
199
+ const existingByName = new Map(existing.fields.map((f) => [f.name, f]));
200
+ const updates = [];
201
+ for (const desired of fieldsForServer) {
202
+ const current = existingByName.get(desired.name);
203
+ if (!current) {
204
+ updates.push(desired);
205
+ continue;
206
+ }
207
+ if (current.type !== desired.type ||
208
+ (current.facet ?? false) !== (desired.facet ?? false) ||
209
+ (current.sort ?? false) !== (desired.sort ?? false)) {
210
+ updates.push({ name: desired.name, type: desired.type, drop: true });
211
+ updates.push(desired);
212
+ }
213
+ }
214
+ const updatePayload = {
215
+ metadata: schema.metadata,
216
+ };
217
+ if (updates.length > 0) {
218
+ updatePayload.fields = updates;
219
+ }
220
+ await client.collections(schema.name).update(updatePayload);
221
+ },
222
+ async upsert(slice, documents) {
223
+ if (documents.length === 0)
224
+ return;
225
+ const name = collectionName(slice, collectionPrefix);
226
+ const payload = documents.map((d) => flattenDocument(d));
227
+ await client.collections(name).documents().import(payload, { action: "upsert" });
228
+ },
229
+ async delete(slice, ids) {
230
+ if (ids.length === 0)
231
+ return;
232
+ const name = collectionName(slice, collectionPrefix);
233
+ const filterValue = ids.map((id) => `"${id}"`).join(",");
234
+ await client
235
+ .collections(name)
236
+ .documents()
237
+ .delete({ filter_by: `id:[${filterValue}]` });
238
+ },
239
+ async search(slice, request) {
240
+ const name = collectionName(slice, collectionPrefix);
241
+ // Use the registry cached at ensureCollection() time so query_by
242
+ // points at fields that actually exist in the schema. If a caller
243
+ // searches before ensureCollection has run for this vertical, fall
244
+ // back to fetching the live schema and inferring string-typed
245
+ // fields — slower but at least produces a valid query.
246
+ let registry = registryByVertical.get(slice.vertical);
247
+ if (!registry) {
248
+ try {
249
+ registry = await inferRegistryFromCollection(client, name);
250
+ }
251
+ catch (err) {
252
+ // Collection doesn't exist (vertical not indexed yet) — return
253
+ // empty results instead of propagating a 404. Surfacing search
254
+ // errors for unindexed verticals is hostile UX; downstream UI
255
+ // already renders "no results" cleanly.
256
+ if (isCollectionNotFoundError(err)) {
257
+ return { hits: [], total: 0, facets: {} };
258
+ }
259
+ throw err;
260
+ }
261
+ }
262
+ const query = buildSearchQuery(request, registry, slice);
263
+ try {
264
+ const response = await client.collections(name).documents().search(query);
265
+ return mapTypesenseResponse(response);
266
+ }
267
+ catch (err) {
268
+ if (isCollectionNotFoundError(err)) {
269
+ return { hits: [], total: 0, facets: {} };
270
+ }
271
+ throw err;
272
+ }
273
+ },
274
+ async bulkReindex(slice, stream, _options) {
275
+ const name = collectionName(slice, collectionPrefix);
276
+ const batch = [];
277
+ const flush = async () => {
278
+ if (batch.length === 0)
279
+ return;
280
+ const payload = batch.map((d) => flattenDocument(d));
281
+ await client.collections(name).documents().import(payload, { action: "upsert" });
282
+ batch.length = 0;
283
+ };
284
+ for await (const document of stream) {
285
+ batch.push(document);
286
+ if (batch.length >= 200) {
287
+ await flush();
288
+ }
289
+ }
290
+ await flush();
291
+ },
292
+ };
293
+ }
294
+ function flattenDocument(document) {
295
+ const flat = { id: document.id };
296
+ for (const [path, value] of Object.entries(document.fields)) {
297
+ flat[path] = coerceForTypesense(path, value);
298
+ }
299
+ if (document.embeddings) {
300
+ for (const [name, vector] of Object.entries(document.embeddings)) {
301
+ flat[name] = vector;
302
+ }
303
+ }
304
+ if (document.embedding_model_id) {
305
+ flat.embedding_model_id = document.embedding_model_id;
306
+ }
307
+ return flat;
308
+ }
309
+ /**
310
+ * Coerce a field value to match the Typesense schema inferred from the
311
+ * policy path. `null`/`undefined` drop out because optional fields tolerate
312
+ * absence. Arrays recurse element-wise.
313
+ */
314
+ function coerceForTypesense(path, value) {
315
+ if (value == null)
316
+ return undefined;
317
+ if (Array.isArray(value)) {
318
+ const coerced = value.map((v) => coerceForTypesense(path, v)).filter((v) => v !== undefined);
319
+ return coerced;
320
+ }
321
+ const type = typesenseTypeForField(path, false);
322
+ if (type === "bool")
323
+ return coerceBool(value);
324
+ if (type === "float")
325
+ return coerceNumber(value);
326
+ if (type === "int32" || type === "int64")
327
+ return coerceInteger(value);
328
+ if (typeof value === "string")
329
+ return value;
330
+ if (typeof value === "object") {
331
+ // Nested objects round-trip via JSON. Typesense's nested-fields support
332
+ // accepts these only when the schema declares them as `object`/`object[]`,
333
+ // which the policy registry does not currently emit. Stringify so the
334
+ // payload at least lands; downstream consumers can JSON.parse.
335
+ return JSON.stringify(value);
336
+ }
337
+ return String(value);
338
+ }
339
+ function coerceNumber(value) {
340
+ if (typeof value === "number" && Number.isFinite(value))
341
+ return value;
342
+ if (typeof value === "string" && value.trim().length > 0) {
343
+ const parsed = Number(value);
344
+ if (Number.isFinite(parsed))
345
+ return parsed;
346
+ }
347
+ return undefined;
348
+ }
349
+ function coerceInteger(value) {
350
+ const parsed = coerceNumber(value);
351
+ if (parsed === undefined)
352
+ return undefined;
353
+ return Math.trunc(parsed);
354
+ }
355
+ function coerceBool(value) {
356
+ if (typeof value === "boolean")
357
+ return value;
358
+ if (typeof value === "string") {
359
+ if (value === "true")
360
+ return true;
361
+ if (value === "false")
362
+ return false;
363
+ }
364
+ return undefined;
365
+ }
366
+ function mapTypesenseResponse(response) {
367
+ const hits = response.hits.map((hit) => ({
368
+ id: String(hit.document.id ?? ""),
369
+ // Wildcard queries (`q=*`) and pure-vector searches don't compute a
370
+ // `text_match` score — fall back to 0 so downstream consumers always
371
+ // see a number.
372
+ score: hit.text_match ?? 0,
373
+ document: {
374
+ id: String(hit.document.id ?? ""),
375
+ fields: hit.document,
376
+ },
377
+ }));
378
+ const facets = response.facet_counts
379
+ ? Object.fromEntries(response.facet_counts.map((f) => [f.field_name, f.counts]))
380
+ : undefined;
381
+ return {
382
+ hits,
383
+ total: response.found,
384
+ facets,
385
+ };
386
+ }
387
+ /**
388
+ * Helper for verticals that want to register a `DocumentEmitter` against
389
+ * this adapter. Currently a thin pass-through; reserved for future emitter
390
+ * registry extensions.
391
+ */
392
+ export function attachEmitter(emitter) {
393
+ return emitter;
394
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=typesense.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typesense.test.d.ts","sourceRoot":"","sources":["../../src/indexer/typesense.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createFieldPolicyRegistry, defineFieldPolicy } from "../contract.js";
3
+ import { buildCollectionSchema, buildDefaultTypesenseQueryBy, buildDefaultTypesenseSearchFields, buildSearchQuery, createTypesenseIndexer, } from "./typesense.js";
4
+ const slice = {
5
+ vertical: "products",
6
+ locale: "en-GB",
7
+ audience: "customer",
8
+ market: "default",
9
+ };
10
+ const registry = createFieldPolicyRegistry(defineFieldPolicy([
11
+ {
12
+ path: "name",
13
+ class: "merchandisable",
14
+ merge: "replace",
15
+ editRole: "marketing",
16
+ overrideFriction: "none",
17
+ snapshot: "on-book",
18
+ query: "indexed-column",
19
+ visibility: ["customer"],
20
+ },
21
+ {
22
+ path: "priceFromAmountCents",
23
+ class: "structural",
24
+ merge: "source-only",
25
+ editRole: "none",
26
+ overrideFriction: "none",
27
+ snapshot: "on-book",
28
+ query: "indexed-column",
29
+ visibility: ["customer"],
30
+ },
31
+ {
32
+ path: "nextDepartureAt",
33
+ class: "structural",
34
+ merge: "source-only",
35
+ editRole: "none",
36
+ overrideFriction: "none",
37
+ snapshot: "on-book",
38
+ query: "indexed-column",
39
+ visibility: ["customer"],
40
+ },
41
+ {
42
+ path: "nextDepartureDate",
43
+ class: "structural",
44
+ merge: "source-only",
45
+ editRole: "none",
46
+ overrideFriction: "none",
47
+ snapshot: "on-book",
48
+ query: "indexed-column",
49
+ visibility: ["customer"],
50
+ },
51
+ {
52
+ path: "createdAt",
53
+ class: "managed",
54
+ merge: "source-only",
55
+ editRole: "none",
56
+ overrideFriction: "none",
57
+ snapshot: "on-book",
58
+ query: "indexed-column",
59
+ visibility: ["staff"],
60
+ },
61
+ {
62
+ path: "durationDays",
63
+ class: "structural",
64
+ merge: "source-only",
65
+ editRole: "none",
66
+ overrideFriction: "none",
67
+ snapshot: "on-book",
68
+ query: "indexed-column",
69
+ visibility: ["customer"],
70
+ },
71
+ {
72
+ path: "latitude",
73
+ class: "structural",
74
+ merge: "source-only",
75
+ editRole: "none",
76
+ overrideFriction: "none",
77
+ snapshot: "on-book",
78
+ query: "indexed-column",
79
+ visibility: ["customer"],
80
+ },
81
+ {
82
+ path: "hasOffer",
83
+ class: "structural",
84
+ merge: "source-only",
85
+ editRole: "none",
86
+ overrideFriction: "none",
87
+ snapshot: "on-book",
88
+ query: "indexed-column",
89
+ visibility: ["customer"],
90
+ },
91
+ {
92
+ path: "categorySlugs[]",
93
+ class: "structural",
94
+ merge: "source-only",
95
+ editRole: "none",
96
+ overrideFriction: "none",
97
+ snapshot: "on-book",
98
+ query: "indexed-column",
99
+ visibility: ["customer"],
100
+ },
101
+ {
102
+ path: "thumbnailUrl",
103
+ class: "merchandisable",
104
+ merge: "source-only",
105
+ editRole: "none",
106
+ overrideFriction: "none",
107
+ snapshot: "on-book",
108
+ query: "indexed-column",
109
+ visibility: ["customer"],
110
+ },
111
+ {
112
+ path: "status",
113
+ class: "structural",
114
+ merge: "source-only",
115
+ editRole: "none",
116
+ overrideFriction: "none",
117
+ snapshot: "on-book",
118
+ query: "indexed-column",
119
+ visibility: ["staff"],
120
+ },
121
+ ]));
122
+ describe("Typesense catalog indexer", () => {
123
+ it("declares known storefront card fields with numeric and boolean types", () => {
124
+ const schema = buildCollectionSchema(slice, registry);
125
+ expect(schema.fields.find((field) => field.name === "priceFromAmountCents")?.type).toBe("int64");
126
+ expect(schema.fields.find((field) => field.name === "durationDays")?.type).toBe("int64");
127
+ expect(schema.fields.find((field) => field.name === "latitude")?.type).toBe("float");
128
+ expect(schema.fields.find((field) => field.name === "hasOffer")?.type).toBe("bool");
129
+ expect(schema.fields.find((field) => field.name === "nextDepartureAt")?.sort).toBe(true);
130
+ expect(schema.fields.find((field) => field.name === "nextDepartureDate")?.sort).toBe(true);
131
+ expect(schema.metadata).toEqual({
132
+ voyant: {
133
+ defaultQueryBy: "name,categorySlugs",
134
+ defaultSearchFields: ["name", "categorySlugs"],
135
+ },
136
+ });
137
+ });
138
+ it("derives default Typesense query fields from policy-visible searchable text", () => {
139
+ expect(buildDefaultTypesenseSearchFields(registry, slice)).toEqual(["name", "categorySlugs"]);
140
+ expect(buildDefaultTypesenseQueryBy(registry, slice)).toBe("name,categorySlugs");
141
+ });
142
+ it("keeps non-search fields out of Typesense query_by", () => {
143
+ const query = buildSearchQuery({ query: "retreat", mode: "keyword" }, registry, slice);
144
+ expect(query.query_by).toBe("name,categorySlugs");
145
+ expect(query.query_by).not.toContain("categorySlugs[]");
146
+ expect(query.query_by).not.toContain("priceFromAmountCents");
147
+ expect(query.query_by).not.toContain("durationDays");
148
+ expect(query.query_by).not.toContain("hasOffer");
149
+ expect(query.query_by).not.toContain("thumbnailUrl");
150
+ expect(query.query_by).not.toContain("status");
151
+ });
152
+ it("maps typed storefront sort options to engine sort fields", () => {
153
+ const query = buildSearchQuery({ query: "", mode: "keyword", sort: "price-desc" }, registry, slice);
154
+ expect(query.sort_by).toBe("priceFromAmountCents:desc");
155
+ });
156
+ it("maps departure sort to the sortable local departure date", () => {
157
+ const query = buildSearchQuery({ query: "", mode: "keyword", sort: "departure-asc" }, registry, slice);
158
+ expect(query.sort_by).toBe("nextDepartureDate:asc");
159
+ });
160
+ it("normalizes list policy paths for facets and filters", () => {
161
+ const query = buildSearchQuery({
162
+ query: "",
163
+ mode: "keyword",
164
+ facets: [{ field: "categorySlugs[]" }],
165
+ filters: [
166
+ { kind: "in", field: "categorySlugs[]", values: ["cruises", "sailing"] },
167
+ { kind: "eq", field: "departureMonths[]", value: "2026-06" },
168
+ ],
169
+ }, registry);
170
+ expect(query.facet_by).toBe("categorySlugs");
171
+ expect(query.filter_by).toBe('categorySlugs:["cruises","sailing"] && departureMonths:="2026-06"');
172
+ });
173
+ it("does not sort public slices by staff-only newest fields", () => {
174
+ const query = buildSearchQuery({ query: "", mode: "keyword", sort: "newest" }, registry, slice);
175
+ expect(query.sort_by).toBeUndefined();
176
+ });
177
+ it("patches default search metadata onto existing collections without field diffs", async () => {
178
+ const updatePayloads = [];
179
+ const existingSchema = buildCollectionSchema(slice, registry);
180
+ const client = {
181
+ collections: () => ({
182
+ create: async () => {
183
+ throw new Error("already exists");
184
+ },
185
+ update: async (schema) => {
186
+ updatePayloads.push(schema);
187
+ },
188
+ delete: async () => undefined,
189
+ retrieve: async () => ({
190
+ name: existingSchema.name,
191
+ fields: existingSchema.fields,
192
+ enable_nested_fields: true,
193
+ }),
194
+ documents: () => ({
195
+ import: async () => ({}),
196
+ delete: async () => undefined,
197
+ search: async () => ({ hits: [], found: 0 }),
198
+ }),
199
+ }),
200
+ };
201
+ const indexer = createTypesenseIndexer({ client });
202
+ await indexer.ensureCollection(slice, registry);
203
+ expect(updatePayloads).toEqual([
204
+ {
205
+ metadata: {
206
+ voyant: {
207
+ defaultQueryBy: "name,categorySlugs",
208
+ defaultSearchFields: ["name", "categorySlugs"],
209
+ },
210
+ },
211
+ },
212
+ ]);
213
+ });
214
+ it("upserts storefront card values without stringifying typed fields", async () => {
215
+ const imported = [];
216
+ const client = {
217
+ collections: () => ({
218
+ create: async () => undefined,
219
+ update: async () => undefined,
220
+ delete: async () => undefined,
221
+ retrieve: async () => ({ name: "unused", fields: [] }),
222
+ documents: () => ({
223
+ import: async (documents) => {
224
+ imported.push(documents);
225
+ return {};
226
+ },
227
+ delete: async () => undefined,
228
+ search: async () => ({ hits: [], found: 0 }),
229
+ }),
230
+ }),
231
+ };
232
+ const indexer = createTypesenseIndexer({ client });
233
+ const document = {
234
+ id: "prod_abc",
235
+ fields: {
236
+ name: "Retreat",
237
+ priceFromAmountCents: "125000",
238
+ durationDays: 4,
239
+ latitude: "45.76",
240
+ hasOffer: true,
241
+ },
242
+ };
243
+ await indexer.upsert(slice, [document]);
244
+ expect(imported[0]?.[0]).toMatchObject({
245
+ id: "prod_abc",
246
+ name: "Retreat",
247
+ priceFromAmountCents: 125000,
248
+ durationDays: 4,
249
+ latitude: 45.76,
250
+ hasOffer: true,
251
+ });
252
+ });
253
+ });