@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.
- package/LICENSE +201 -0
- package/README.md +190 -0
- package/dist/adapter/booking-forwarding.d.ts +2 -0
- package/dist/adapter/booking-forwarding.d.ts.map +1 -0
- package/dist/adapter/booking-forwarding.js +1 -0
- package/dist/adapter/channel-push-contracts.d.ts +2 -0
- package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
- package/dist/adapter/channel-push-contracts.js +1 -0
- package/dist/adapter/contract.d.ts +2 -0
- package/dist/adapter/contract.d.ts.map +1 -0
- package/dist/adapter/contract.js +1 -0
- package/dist/adapter/contract.test.d.ts +2 -0
- package/dist/adapter/contract.test.d.ts.map +1 -0
- package/dist/adapter/contract.test.js +390 -0
- package/dist/adapter/provider-contracts.d.ts +2 -0
- package/dist/adapter/provider-contracts.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.js +1 -0
- package/dist/adapter/provider-contracts.test.d.ts +2 -0
- package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.test.js +206 -0
- package/dist/adapter/schemas.d.ts +2 -0
- package/dist/adapter/schemas.d.ts.map +1 -0
- package/dist/adapter/schemas.js +1 -0
- package/dist/adapter/schemas.test.d.ts +2 -0
- package/dist/adapter/schemas.test.d.ts.map +1 -0
- package/dist/adapter/schemas.test.js +344 -0
- package/dist/booking-engine/book.d.ts +124 -0
- package/dist/booking-engine/book.d.ts.map +1 -0
- package/dist/booking-engine/book.js +311 -0
- package/dist/booking-engine/cancel.d.ts +40 -0
- package/dist/booking-engine/cancel.d.ts.map +1 -0
- package/dist/booking-engine/cancel.js +56 -0
- package/dist/booking-engine/checkout-finalize.d.ts +146 -0
- package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
- package/dist/booking-engine/checkout-finalize.js +132 -0
- package/dist/booking-engine/contracts.d.ts +9 -0
- package/dist/booking-engine/contracts.d.ts.map +1 -0
- package/dist/booking-engine/contracts.js +8 -0
- package/dist/booking-engine/contracts.test.d.ts +2 -0
- package/dist/booking-engine/contracts.test.d.ts.map +1 -0
- package/dist/booking-engine/contracts.test.js +116 -0
- package/dist/booking-engine/draft-shape.d.ts +10 -0
- package/dist/booking-engine/draft-shape.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.js +9 -0
- package/dist/booking-engine/draft-shape.test.d.ts +2 -0
- package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.test.js +74 -0
- package/dist/booking-engine/drafts-schema.d.ts +302 -0
- package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
- package/dist/booking-engine/drafts-schema.js +53 -0
- package/dist/booking-engine/drafts-service.d.ts +41 -0
- package/dist/booking-engine/drafts-service.d.ts.map +1 -0
- package/dist/booking-engine/drafts-service.js +108 -0
- package/dist/booking-engine/errors.d.ts +81 -0
- package/dist/booking-engine/errors.d.ts.map +1 -0
- package/dist/booking-engine/errors.js +113 -0
- package/dist/booking-engine/index.d.ts +36 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +34 -0
- package/dist/booking-engine/orders.d.ts +41 -0
- package/dist/booking-engine/orders.d.ts.map +1 -0
- package/dist/booking-engine/orders.js +49 -0
- package/dist/booking-engine/owned-handler.d.ts +166 -0
- package/dist/booking-engine/owned-handler.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.js +50 -0
- package/dist/booking-engine/owned-handler.test.d.ts +2 -0
- package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.test.js +63 -0
- package/dist/booking-engine/promotions-contract.d.ts +8 -0
- package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
- package/dist/booking-engine/promotions-contract.js +7 -0
- package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
- package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
- package/dist/booking-engine/quote-enricher.test.js +138 -0
- package/dist/booking-engine/quote.d.ts +163 -0
- package/dist/booking-engine/quote.d.ts.map +1 -0
- package/dist/booking-engine/quote.js +259 -0
- package/dist/booking-engine/registry.d.ts +85 -0
- package/dist/booking-engine/registry.d.ts.map +1 -0
- package/dist/booking-engine/registry.js +118 -0
- package/dist/booking-engine/registry.test.d.ts +2 -0
- package/dist/booking-engine/registry.test.d.ts.map +1 -0
- package/dist/booking-engine/registry.test.js +132 -0
- package/dist/booking-engine/routes-contracts.d.ts +169 -0
- package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
- package/dist/booking-engine/routes-contracts.js +63 -0
- package/dist/booking-engine/routes.d.ts +7 -0
- package/dist/booking-engine/routes.d.ts.map +1 -0
- package/dist/booking-engine/routes.js +443 -0
- package/dist/booking-engine/routes.test.d.ts +2 -0
- package/dist/booking-engine/routes.test.d.ts.map +1 -0
- package/dist/booking-engine/routes.test.js +304 -0
- package/dist/booking-engine/schema.d.ts +455 -0
- package/dist/booking-engine/schema.d.ts.map +1 -0
- package/dist/booking-engine/schema.js +75 -0
- package/dist/booking-engine/snapshot-content.d.ts +120 -0
- package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.js +110 -0
- package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
- package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.test.js +213 -0
- package/dist/booking-engine/sync.d.ts +136 -0
- package/dist/booking-engine/sync.d.ts.map +1 -0
- package/dist/booking-engine/sync.js +177 -0
- package/dist/booking-engine/sync.test.d.ts +2 -0
- package/dist/booking-engine/sync.test.d.ts.map +1 -0
- package/dist/booking-engine/sync.test.js +377 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/contract.test.js +107 -0
- package/dist/drift/events.d.ts +2 -0
- package/dist/drift/events.d.ts.map +1 -0
- package/dist/drift/events.js +1 -0
- package/dist/drift/events.test.d.ts +2 -0
- package/dist/drift/events.test.d.ts.map +1 -0
- package/dist/drift/events.test.js +100 -0
- package/dist/embeddings/contract.d.ts +85 -0
- package/dist/embeddings/contract.d.ts.map +1 -0
- package/dist/embeddings/contract.js +42 -0
- package/dist/embeddings/contract.test.d.ts +2 -0
- package/dist/embeddings/contract.test.d.ts.map +1 -0
- package/dist/embeddings/contract.test.js +30 -0
- package/dist/embeddings/gemini.d.ts +110 -0
- package/dist/embeddings/gemini.d.ts.map +1 -0
- package/dist/embeddings/gemini.js +118 -0
- package/dist/embeddings/gemini.test.d.ts +2 -0
- package/dist/embeddings/gemini.test.d.ts.map +1 -0
- package/dist/embeddings/gemini.test.js +132 -0
- package/dist/embeddings/model-registry.d.ts +62 -0
- package/dist/embeddings/model-registry.d.ts.map +1 -0
- package/dist/embeddings/model-registry.js +78 -0
- package/dist/embeddings/model-registry.test.d.ts +2 -0
- package/dist/embeddings/model-registry.test.d.ts.map +1 -0
- package/dist/embeddings/model-registry.test.js +81 -0
- package/dist/embeddings/openai.d.ts +81 -0
- package/dist/embeddings/openai.d.ts.map +1 -0
- package/dist/embeddings/openai.js +123 -0
- package/dist/embeddings/openai.test.d.ts +2 -0
- package/dist/embeddings/openai.test.d.ts.map +1 -0
- package/dist/embeddings/openai.test.js +164 -0
- package/dist/events/taxonomy.d.ts +158 -0
- package/dist/events/taxonomy.d.ts.map +1 -0
- package/dist/events/taxonomy.js +99 -0
- package/dist/events/taxonomy.test.d.ts +2 -0
- package/dist/events/taxonomy.test.d.ts.map +1 -0
- package/dist/events/taxonomy.test.js +48 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/indexer/contract.d.ts +203 -0
- package/dist/indexer/contract.d.ts.map +1 -0
- package/dist/indexer/contract.js +16 -0
- package/dist/indexer/typesense-search-query.d.ts +31 -0
- package/dist/indexer/typesense-search-query.d.ts.map +1 -0
- package/dist/indexer/typesense-search-query.js +185 -0
- package/dist/indexer/typesense.d.ts +105 -0
- package/dist/indexer/typesense.d.ts.map +1 -0
- package/dist/indexer/typesense.js +394 -0
- package/dist/indexer/typesense.test.d.ts +2 -0
- package/dist/indexer/typesense.test.d.ts.map +1 -0
- package/dist/indexer/typesense.test.js +253 -0
- package/dist/overlay/resolver.d.ts +101 -0
- package/dist/overlay/resolver.d.ts.map +1 -0
- package/dist/overlay/resolver.js +167 -0
- package/dist/overlay/resolver.test.d.ts +2 -0
- package/dist/overlay/resolver.test.d.ts.map +1 -0
- package/dist/overlay/resolver.test.js +179 -0
- package/dist/overlay/schema.d.ts +266 -0
- package/dist/overlay/schema.d.ts.map +1 -0
- package/dist/overlay/schema.js +71 -0
- package/dist/provenance.d.ts +2 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +1 -0
- package/dist/schema-sourced-entries.d.ts +344 -0
- package/dist/schema-sourced-entries.d.ts.map +1 -0
- package/dist/schema-sourced-entries.js +75 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +20 -0
- package/dist/search/federate.d.ts +58 -0
- package/dist/search/federate.d.ts.map +1 -0
- package/dist/search/federate.js +103 -0
- package/dist/search/federate.test.d.ts +2 -0
- package/dist/search/federate.test.d.ts.map +1 -0
- package/dist/search/federate.test.js +146 -0
- package/dist/search/rerank.d.ts +77 -0
- package/dist/search/rerank.d.ts.map +1 -0
- package/dist/search/rerank.js +68 -0
- package/dist/search/rerank.test.d.ts +2 -0
- package/dist/search/rerank.test.d.ts.map +1 -0
- package/dist/search/rerank.test.js +60 -0
- package/dist/search/routes.d.ts +144 -0
- package/dist/search/routes.d.ts.map +1 -0
- package/dist/search/routes.js +288 -0
- package/dist/search/routes.test.d.ts +2 -0
- package/dist/search/routes.test.d.ts.map +1 -0
- package/dist/search/routes.test.js +322 -0
- package/dist/search/semantic.d.ts +63 -0
- package/dist/search/semantic.d.ts.map +1 -0
- package/dist/search/semantic.js +75 -0
- package/dist/search/semantic.test.d.ts +2 -0
- package/dist/search/semantic.test.d.ts.map +1 -0
- package/dist/search/semantic.test.js +143 -0
- package/dist/services/build-indexer-document.test.d.ts +2 -0
- package/dist/services/build-indexer-document.test.d.ts.map +1 -0
- package/dist/services/build-indexer-document.test.js +102 -0
- package/dist/services/content-service.d.ts +125 -0
- package/dist/services/content-service.d.ts.map +1 -0
- package/dist/services/content-service.js +139 -0
- package/dist/services/content-service.test.d.ts +2 -0
- package/dist/services/content-service.test.d.ts.map +1 -0
- package/dist/services/content-service.test.js +322 -0
- package/dist/services/indexer-service.d.ts +109 -0
- package/dist/services/indexer-service.d.ts.map +1 -0
- package/dist/services/indexer-service.js +123 -0
- package/dist/services/indexer-service.test.d.ts +2 -0
- package/dist/services/indexer-service.test.d.ts.map +1 -0
- package/dist/services/indexer-service.test.js +176 -0
- package/dist/services/overlay-service.d.ts +108 -0
- package/dist/services/overlay-service.d.ts.map +1 -0
- package/dist/services/overlay-service.js +211 -0
- package/dist/services/overlay-service.test.d.ts +2 -0
- package/dist/services/overlay-service.test.d.ts.map +1 -0
- package/dist/services/overlay-service.test.js +79 -0
- package/dist/services/snapshot-builder.test.d.ts +2 -0
- package/dist/services/snapshot-builder.test.d.ts.map +1 -0
- package/dist/services/snapshot-builder.test.js +93 -0
- package/dist/services/snapshot-service.d.ts +78 -0
- package/dist/services/snapshot-service.d.ts.map +1 -0
- package/dist/services/snapshot-service.js +165 -0
- package/dist/services/sourced-entry-service.d.ts +142 -0
- package/dist/services/sourced-entry-service.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.js +203 -0
- package/dist/services/sourced-entry-service.test.d.ts +10 -0
- package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.test.js +66 -0
- package/dist/snapshot/schema.d.ts +362 -0
- package/dist/snapshot/schema.d.ts.map +1 -0
- package/dist/snapshot/schema.js +102 -0
- 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 @@
|
|
|
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
|
+
});
|