@findlane/server 0.11.0 → 0.13.0
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/README.md +3 -2
- package/dist/index.cjs +23 -10
- package/dist/index.d.cts +157 -7
- package/dist/index.d.ts +157 -7
- package/dist/index.js +23 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
|
|
|
47
47
|
|
|
48
48
|
| Method | What it does |
|
|
49
49
|
| --- | --- |
|
|
50
|
-
| `search(request, options?)` | Search with a query, filters, facets, a sort, paging, and highlighting. `options` takes `signal` and `visitor` (below). |
|
|
50
|
+
| `search(request, options?)` | Search with a query, filters, facets, a sort, paging, and highlighting. `explain: true` says why each hit matched and what ordered it, and `appliedRules` lists the index's query rules the query fired. `options` takes `signal` and `visitor` (below). |
|
|
51
51
|
| `upsert(records)` | Add or update up to 1,000 records by `objectID`. |
|
|
52
52
|
| `replaceAll(records)` | Replace the whole catalog in one request (up to 100,000 records). |
|
|
53
53
|
| `replaceAllStaged(records, options)` | Replace a larger catalog in batches under one stage ID, switched in one commit. Retrying with the same `stageId` resumes. `abortReplacement(stageId)` drops a stage. |
|
|
@@ -57,7 +57,8 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
|
|
|
57
57
|
| `getSettings()`, `replaceSettings(settings)`, `waitForSettings()` | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, languages, typo tolerance, and text normalization. |
|
|
58
58
|
| `getIndexingStatus()`, `waitUntilIndexed(revision)` | Writes return an accepted revision and are searchable when accepted, so `waitUntilIndexed` returns at once. |
|
|
59
59
|
| `getPublicSearchAccess()`, `setPublicSearchPolicy(policy)`, `usePublicIndexDefaults()` | Restrict what browser search may read and query. |
|
|
60
|
-
| `events.send(events)`, `events.importOrders(orders)`, `events.cancelOrder(orderID)` | Orders and other events for the index's Events page and Bought together; the key needs the `events` or `write` scope. |
|
|
60
|
+
| `events.send(events, { visitorId, visitId }?)`, `events.importOrders(orders)`, `events.cancelOrder(orderID)` | Orders and other events for the index's Events page and Bought together; the key needs the `events` or `write` scope. Your own anonymous visitor IDs link a shopper's events, when the index takes them. |
|
|
61
|
+
| `events.forget(ids)` | Deletes the events sent with these visitor or visit IDs, within a week. |
|
|
61
62
|
| `events.proxy(request)`, `events.relay(body, visitor)` | A route on your own site for the browser SDK's events (its `eventsUrl`), which ad blockers let through. See [Events](https://findlane.dev/docs/events#through-your-own-domain). |
|
|
62
63
|
|
|
63
64
|
### Settings
|
package/dist/index.cjs
CHANGED
|
@@ -149,7 +149,7 @@ class SearchServerClient {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
};
|
|
152
|
-
const postEvents = async (
|
|
152
|
+
const postEvents = async (payload, signal) => {
|
|
153
153
|
for (let attempt = 0;; attempt++) {
|
|
154
154
|
signal?.throwIfAborted();
|
|
155
155
|
let wait = 250 * 2 ** attempt;
|
|
@@ -158,11 +158,11 @@ class SearchServerClient {
|
|
|
158
158
|
method: "POST",
|
|
159
159
|
signal,
|
|
160
160
|
headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
|
|
161
|
-
body: JSON.stringify(
|
|
161
|
+
body: JSON.stringify(payload)
|
|
162
162
|
});
|
|
163
163
|
const data = await response.json().catch(() => ({}));
|
|
164
164
|
if (response.ok)
|
|
165
|
-
return
|
|
165
|
+
return data;
|
|
166
166
|
const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
|
|
167
167
|
if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
|
|
168
168
|
throw error;
|
|
@@ -194,16 +194,23 @@ class SearchServerClient {
|
|
|
194
194
|
const retryAfter = response.headers.get("retry-after");
|
|
195
195
|
return jsonResponse(await response.text(), response.status, retryAfter ? { "retry-after": retryAfter } : {});
|
|
196
196
|
};
|
|
197
|
-
const sendEvents = async (events,
|
|
197
|
+
const sendEvents = async (events, options = {}) => {
|
|
198
|
+
const { signal, visitorId, visitId } = options;
|
|
198
199
|
const result = { accepted: 0, rejected: [] };
|
|
199
200
|
let batch = [];
|
|
200
201
|
let sent = 0;
|
|
201
202
|
const flush = async () => {
|
|
202
203
|
if (!batch.length)
|
|
203
204
|
return;
|
|
204
|
-
const answer = await postEvents(
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
const answer = await postEvents({
|
|
206
|
+
...visitorId === undefined ? {} : { visitorId },
|
|
207
|
+
...visitId === undefined ? {} : { visitId },
|
|
208
|
+
events: batch
|
|
209
|
+
}, signal);
|
|
210
|
+
result.accepted += answer.accepted ?? 0;
|
|
211
|
+
result.rejected.push(...(answer.rejected ?? []).map((item) => ({ ...item, index: item.index + sent })));
|
|
212
|
+
if (answer.visitorIds === "off")
|
|
213
|
+
result.visitorIds = "off";
|
|
207
214
|
sent += batch.length;
|
|
208
215
|
batch = [];
|
|
209
216
|
};
|
|
@@ -240,7 +247,7 @@ class SearchServerClient {
|
|
|
240
247
|
return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
|
|
241
248
|
},
|
|
242
249
|
events: {
|
|
243
|
-
send: (events,
|
|
250
|
+
send: (events, options) => sendEvents(events, options === undefined ? {} : ("aborted" in options) ? { signal: options } : options),
|
|
244
251
|
importOrders: (orders, signal) => sendEvents(async function* () {
|
|
245
252
|
for await (const { timestamp, ...order } of orders)
|
|
246
253
|
yield {
|
|
@@ -248,8 +255,14 @@ class SearchServerClient {
|
|
|
248
255
|
...order,
|
|
249
256
|
...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
|
|
250
257
|
};
|
|
251
|
-
}(), signal),
|
|
252
|
-
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal),
|
|
258
|
+
}(), { signal }),
|
|
259
|
+
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], { signal }),
|
|
260
|
+
forget: async (ids, signal) => {
|
|
261
|
+
let forgotten = 0;
|
|
262
|
+
for (let start = 0;start < ids.length; start += 1000)
|
|
263
|
+
forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
|
|
264
|
+
return { forgotten };
|
|
265
|
+
},
|
|
253
266
|
proxy: async (request2, visitor = {}) => {
|
|
254
267
|
if (request2.method !== "POST")
|
|
255
268
|
return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
|
package/dist/index.d.cts
CHANGED
|
@@ -12,20 +12,46 @@ export type SortOption = SortRule & {
|
|
|
12
12
|
thenBy: SortRule[];
|
|
13
13
|
};
|
|
14
14
|
export type IndexSettings = {
|
|
15
|
-
/** One to five fields, matched with their weight.
|
|
15
|
+
/** One to five fields, matched with their weight. `typos: false` matches a field's words exactly or as
|
|
16
|
+
* prefixes, never with typos, for codes, SKUs, and brands. */
|
|
16
17
|
searchableAttributes: {
|
|
17
18
|
field: string;
|
|
18
19
|
weight: number;
|
|
20
|
+
typos?: false;
|
|
19
21
|
}[];
|
|
20
22
|
facetFields: string[];
|
|
21
23
|
filterFields?: string[];
|
|
22
24
|
typoTolerance: boolean;
|
|
25
|
+
/** Words of at least `oneTypo` letters match with one typo, and of at least `twoTypos` with two.
|
|
26
|
+
* `{ oneTypo: 4, twoTypos: 7 }` by default; oneTypo is 3 to 20, twoTypos from oneTypo to 50. */
|
|
27
|
+
typoWordLengths?: {
|
|
28
|
+
oneTypo: number;
|
|
29
|
+
twoTypos: number;
|
|
30
|
+
};
|
|
31
|
+
/** Up to 1,000 single words that never match with typos, though prefixes still do, such as brand names. */
|
|
32
|
+
exactWords?: string[];
|
|
23
33
|
sorts: SortOption[];
|
|
24
34
|
defaultSort: string;
|
|
25
|
-
/** Orders
|
|
35
|
+
/** Orders every result by up to two sort fields before relevance, in relevance and named sorts (not
|
|
36
|
+
* distance), such as `[{ field: "soldOut", direction: "asc" }]` for in stock first. Relevance and
|
|
37
|
+
* custom ranking order each group. Records without the field come last. */
|
|
38
|
+
rankFirst?: SortRule[];
|
|
39
|
+
/** Orders equally relevant results (within `relevanceTolerance` of the best score) by up to three sort fields. */
|
|
26
40
|
customRanking?: SortRule[];
|
|
41
|
+
/** How close to the best match's score, in percent (1 to 50), results count as equally relevant for
|
|
42
|
+
* custom ranking, and for distance in searches with a query and `near`. 10 by default. */
|
|
43
|
+
relevanceTolerance?: number;
|
|
27
44
|
/** Groups of interchangeable words or phrases (up to four words each). */
|
|
28
45
|
synonyms?: string[][];
|
|
46
|
+
/** A query for an input also matches its 1 to 20 synonyms, but not the other way round. Up to 1,000,
|
|
47
|
+
* each term up to four words. */
|
|
48
|
+
oneWaySynonyms?: {
|
|
49
|
+
input: string;
|
|
50
|
+
synonyms: string[];
|
|
51
|
+
}[];
|
|
52
|
+
/** Up to 1,000 query rules, applied at query time, so a change takes effect at once. Up to 10 fire per
|
|
53
|
+
* search, in this order, and an earlier rule's filter on a field wins over a later one's. */
|
|
54
|
+
rules?: QueryRule[];
|
|
29
55
|
/** Languages whose common words queries drop, and whose plurals and alternative spellings match. */
|
|
30
56
|
languages?: Array<"da" | "de" | "en" | "is" | "no" | "sv">;
|
|
31
57
|
/** Searches say which words typo tolerance corrected, and `suggest()` offers a corrected query for a
|
|
@@ -39,6 +65,40 @@ export type IndexSettings = {
|
|
|
39
65
|
fieldTypes?: Record<string, "text" | "number" | "boolean" | "text[]" | "number[]" | "boolean[]" | "location">;
|
|
40
66
|
[key: string]: unknown;
|
|
41
67
|
};
|
|
68
|
+
/** When a query is, starts with, or contains one of a rule's phrases, the rule's `then` applies to that
|
|
69
|
+
* search. Phrases are compared as synonyms are: without case, accents, or the index languages' stop words,
|
|
70
|
+
* and with their plurals and other spellings, so a rule for "balloon" also fires for "balloons". */
|
|
71
|
+
export type QueryRule = {
|
|
72
|
+
/** 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, unique in the index. */
|
|
73
|
+
id: string;
|
|
74
|
+
/** Up to 200 characters, for the people reading the settings. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/** false keeps the rule without applying it. Reading settings always returns it; true when left out. */
|
|
77
|
+
enabled?: boolean;
|
|
78
|
+
if: {
|
|
79
|
+
/** 1 to 20 phrases of one to four words. */
|
|
80
|
+
phrases: string[];
|
|
81
|
+
/** The query is one of the phrases, starts with one, or contains one; "contains" when left out. */
|
|
82
|
+
match?: "is" | "startsWith" | "contains";
|
|
83
|
+
};
|
|
84
|
+
/** One or more of these. `filters`, `boost` and `bury` take a search's filters, on 1 to 10 fields. */
|
|
85
|
+
then: {
|
|
86
|
+
/** Added to the search's filters, except on fields the search (or an earlier rule) already filters.
|
|
87
|
+
* Records without the field drop out, so `boost` is usually the better choice. */
|
|
88
|
+
filters?: SearchRequest["filters"];
|
|
89
|
+
/** Records matching these come before the others, after `rankFirst`'s groups, in searches with a query
|
|
90
|
+
* sorted by relevance. Several rules' boosts add up. */
|
|
91
|
+
boost?: SearchRequest["filters"];
|
|
92
|
+
/** Records matching these come after the others, in the same searches as `boost`. */
|
|
93
|
+
bury?: SearchRequest["filters"];
|
|
94
|
+
/** Up to 100 objectIDs left out of the results. */
|
|
95
|
+
hide?: string[];
|
|
96
|
+
/** Words searched instead of the matched phrase, up to 64 characters; "" removes the phrase. */
|
|
97
|
+
replace?: string;
|
|
98
|
+
/** JSON up to 4 KB returned with the results in `appliedRules`, such as a banner or a redirect. */
|
|
99
|
+
userData?: unknown;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
42
102
|
/** A settings change running in the background (with the settings it applies), or the last one's
|
|
43
103
|
* failure. `kind` is "searchable" (new searchable fields filled in) or "rebuild" (a full copy). */
|
|
44
104
|
export type SettingsRebuild = {
|
|
@@ -100,7 +160,13 @@ export type SearchRequest = {
|
|
|
100
160
|
q?: string;
|
|
101
161
|
/** A sort ID from the index settings, "relevance", or "distance" (with `near`). */
|
|
102
162
|
sort?: string;
|
|
103
|
-
/** Field values to keep
|
|
163
|
+
/** Field values to keep. Each field takes a value (a list field contains it), or an object of
|
|
164
|
+
* conditions that all apply: `in` (any of up to 100 values), `all` (a list field has every one),
|
|
165
|
+
* `not` and `notIn` (records without the field stay in), and `gte`/`lte` for numbers and dates, such
|
|
166
|
+
* as `{ tags: { in: ["kids", "adult"], notIn: ["party"] }, price: { lte: 50 } }`. `"$or"` takes one to
|
|
167
|
+
* four groups of field filters and keeps records that match any group:
|
|
168
|
+
* `{ inStock: true, $or: [{ brand: "Acme" }, { price: { lte: 20 } }] }`. Up to 20 field filters,
|
|
169
|
+
* counting those in groups. */
|
|
104
170
|
filters?: Record<string, unknown>;
|
|
105
171
|
facets?: string[];
|
|
106
172
|
/** Facets (also in `facets`) counted without the filter on their own field, so a list of choices
|
|
@@ -122,6 +188,13 @@ export type SearchRequest = {
|
|
|
122
188
|
preTag?: string;
|
|
123
189
|
postTag?: string;
|
|
124
190
|
};
|
|
191
|
+
/** Say why each hit matched and what ordered it, in each hit's `explain`: each query word's best match in
|
|
192
|
+
* each searchable field, the relevance band, and the ranking fields' values. For understanding and tuning
|
|
193
|
+
* ranking; browser search doesn't explain results. */
|
|
194
|
+
explain?: boolean;
|
|
195
|
+
/** false searches without the index's query rules, such as to compare results with and without them. On by
|
|
196
|
+
* default; browser search doesn't take it. */
|
|
197
|
+
enableRules?: boolean;
|
|
125
198
|
/** Nearest first from a point, with each hit's `distance` in meters; `radius` (meters) keeps only what's
|
|
126
199
|
* within it. With a query, the best matches come first and distance orders them. Needs the index's
|
|
127
200
|
* location field. Records without a location are left out. */
|
|
@@ -144,6 +217,60 @@ export type GeoBox = {
|
|
|
144
217
|
east: number;
|
|
145
218
|
west: number;
|
|
146
219
|
};
|
|
220
|
+
/** How a query word matched: as itself, as the start of a longer word, within its typos, through a synonym, or
|
|
221
|
+
* as another form of the word in the index's languages (a plural or another spelling). */
|
|
222
|
+
export type MatchKind = "exact" | "prefix" | "typo" | "synonym" | "form";
|
|
223
|
+
/** A query word's best match in one searchable field. `word` is the matched word (or a synonym's phrase) as
|
|
224
|
+
* the record writes it. `weight` is what that kind of match weighs in the search: the field's weight times 1
|
|
225
|
+
* for exact, 0.8 for a synonym or word form, 0.2 for a prefix, and 0.1 for a typo. A typo has `edits`, or
|
|
226
|
+
* `firstLetter` when the first letter differs (tried only when nothing matched without); a synonym has the
|
|
227
|
+
* `synonym` that matched. */
|
|
228
|
+
export type FieldMatch = {
|
|
229
|
+
field: string;
|
|
230
|
+
word: string;
|
|
231
|
+
how: MatchKind;
|
|
232
|
+
weight: number;
|
|
233
|
+
edits?: number;
|
|
234
|
+
firstLetter?: true;
|
|
235
|
+
synonym?: string;
|
|
236
|
+
};
|
|
237
|
+
/** A ranking field and the hit's value for it; null when the record has none (it comes last). */
|
|
238
|
+
export type RankingValue = SortRule & {
|
|
239
|
+
value: unknown;
|
|
240
|
+
};
|
|
241
|
+
/** Why a hit matched and what ordered it, with `explain`. Scores are for the whole record, so matches carry
|
|
242
|
+
* weights rather than points that add up to `score`. */
|
|
243
|
+
export type HitExplanation = {
|
|
244
|
+
/** Each query word (after the languages' stop words, up to eight) with its best match in each searchable
|
|
245
|
+
* field, strongest first. Every word matches somewhere. Empty without a query. */
|
|
246
|
+
words: {
|
|
247
|
+
query: string;
|
|
248
|
+
matches: FieldMatch[];
|
|
249
|
+
}[];
|
|
250
|
+
/** With custom ranking (or `near`) and a query: 0 for results within `relevanceTolerance` of `bestScore`,
|
|
251
|
+
* 1 for the next band down, and so on. Distance (with `near`), then custom ranking, orders the results
|
|
252
|
+
* within a band. */
|
|
253
|
+
band?: number | null;
|
|
254
|
+
/** The best score in the hit's rank first group, among results as boosted by the search's rules (the whole
|
|
255
|
+
* search without either). */
|
|
256
|
+
bestScore?: number | null;
|
|
257
|
+
/** The index's rank first fields. */
|
|
258
|
+
rankFirst?: RankingValue[];
|
|
259
|
+
/** The index's custom ranking fields, when sorting by relevance. */
|
|
260
|
+
customRanking?: RankingValue[];
|
|
261
|
+
/** The named sort's fields, when sorting by one. */
|
|
262
|
+
sort?: RankingValue[];
|
|
263
|
+
/** When the query's rules boosted or buried results: those whose filters the hit matched, if any. */
|
|
264
|
+
rules?: {
|
|
265
|
+
id: string;
|
|
266
|
+
effect: "boost" | "bury";
|
|
267
|
+
}[];
|
|
268
|
+
};
|
|
269
|
+
/** A query rule the search fired, with its `userData`, if it has any. */
|
|
270
|
+
export type AppliedRule = {
|
|
271
|
+
id: string;
|
|
272
|
+
userData?: unknown;
|
|
273
|
+
};
|
|
147
274
|
export type SearchResponse<T> = {
|
|
148
275
|
hits: {
|
|
149
276
|
id: string;
|
|
@@ -153,6 +280,8 @@ export type SearchResponse<T> = {
|
|
|
153
280
|
distance?: number;
|
|
154
281
|
/** With `highlight`: matched searchable fields by path; list fields give one string per value. */
|
|
155
282
|
highlights?: Record<string, string | string[]>;
|
|
283
|
+
/** With `explain`: why the hit matched and what ordered it. */
|
|
284
|
+
explain?: HitExplanation;
|
|
156
285
|
}[];
|
|
157
286
|
total: number;
|
|
158
287
|
/** "gte" when `total` is a lower bound (page count mode). */
|
|
@@ -172,12 +301,17 @@ export type SearchResponse<T> = {
|
|
|
172
301
|
/** With the index's `didYouMean` setting: the query with the words typo tolerance corrected, when the
|
|
173
302
|
* results matched some words only by typo ("Showing results for …"), else null. */
|
|
174
303
|
didYouMean?: DidYouMean | null;
|
|
304
|
+
/** The index's query rules the query fired, in the index's order. Left out when none fired. */
|
|
305
|
+
appliedRules?: AppliedRule[];
|
|
306
|
+
/** When a rule replaced words: the query that was searched. */
|
|
307
|
+
rewrittenQuery?: string;
|
|
175
308
|
};
|
|
176
309
|
/** How a product's recommendations are served (the recommendations add-on). */
|
|
177
310
|
export type RecommendationsRequest = {
|
|
178
311
|
/** Up to this many products in each list, 1 to 20; 8 by default. */
|
|
179
312
|
limit?: number;
|
|
180
|
-
/** Only picks matching these filters, as in a search (fields configured as filters), such as `{ inStock: true }`.
|
|
313
|
+
/** Only picks matching these filters, as in a search (fields configured as filters), such as `{ inStock: true }`.
|
|
314
|
+
* Each field's filter takes the same shapes; `$or` is for searches only. */
|
|
181
315
|
filters?: SearchRequest["filters"];
|
|
182
316
|
/** The record fields to return; all of them by default. */
|
|
183
317
|
attributesToRetrieve?: string[];
|
|
@@ -314,13 +448,23 @@ export type Order = {
|
|
|
314
448
|
currency?: string;
|
|
315
449
|
timestamp?: number | Date | string;
|
|
316
450
|
};
|
|
317
|
-
/** How many events were accepted, and which were refused and why; `index` counts from the first event
|
|
451
|
+
/** How many events were accepted, and which were refused and why; `index` counts from the first event
|
|
452
|
+
* sent. `visitorIds: "off"` when IDs were sent and dropped: the index doesn't take them. */
|
|
318
453
|
export type EventsResult = {
|
|
319
454
|
accepted: number;
|
|
320
455
|
rejected: {
|
|
321
456
|
index: number;
|
|
322
457
|
reason: string;
|
|
323
458
|
}[];
|
|
459
|
+
visitorIds?: "off";
|
|
460
|
+
};
|
|
461
|
+
/** Options for `events.send`. `visitorId` and `visitId` are your own anonymous IDs for the shopper and
|
|
462
|
+
* the visit, for all the events sent: 16 to 64 letters, digits, - or _, never an email address, a
|
|
463
|
+
* phone number or a customer number. They're kept only when the index takes visitor IDs. */
|
|
464
|
+
export type EventOptions = {
|
|
465
|
+
signal?: AbortSignal;
|
|
466
|
+
visitorId?: string;
|
|
467
|
+
visitId?: string;
|
|
324
468
|
};
|
|
325
469
|
export declare class FindlaneError extends Error {
|
|
326
470
|
status: number;
|
|
@@ -357,13 +501,19 @@ export declare class SearchServerClient {
|
|
|
357
501
|
/** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
|
|
358
502
|
* scope. Orders count once each, however often they're sent. */
|
|
359
503
|
events: {
|
|
360
|
-
/** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`.
|
|
361
|
-
|
|
504
|
+
/** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. Pass
|
|
505
|
+
* `{ visitorId, visitId }` to link a shopper's events (see EventOptions). */
|
|
506
|
+
send: (events: ServerEvent[], options?: AbortSignal | EventOptions) => Promise<EventsResult>;
|
|
362
507
|
/** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
|
|
363
508
|
* cursor), 200 a request. */
|
|
364
509
|
importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
|
|
365
510
|
/** Takes a cancelled or refunded order out of Bought together. */
|
|
366
511
|
cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
|
|
512
|
+
/** Deletes the events sent with these visitor or visit IDs, for shoppers who asked: Findlane does it
|
|
513
|
+
* within a week. 1,000 IDs a request. */
|
|
514
|
+
forget: (ids: string[], signal?: AbortSignal) => Promise<{
|
|
515
|
+
forgotten: number;
|
|
516
|
+
}>;
|
|
367
517
|
/** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
|
|
368
518
|
* block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
|
|
369
519
|
* route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
|
package/dist/index.d.ts
CHANGED
|
@@ -12,20 +12,46 @@ export type SortOption = SortRule & {
|
|
|
12
12
|
thenBy: SortRule[];
|
|
13
13
|
};
|
|
14
14
|
export type IndexSettings = {
|
|
15
|
-
/** One to five fields, matched with their weight.
|
|
15
|
+
/** One to five fields, matched with their weight. `typos: false` matches a field's words exactly or as
|
|
16
|
+
* prefixes, never with typos, for codes, SKUs, and brands. */
|
|
16
17
|
searchableAttributes: {
|
|
17
18
|
field: string;
|
|
18
19
|
weight: number;
|
|
20
|
+
typos?: false;
|
|
19
21
|
}[];
|
|
20
22
|
facetFields: string[];
|
|
21
23
|
filterFields?: string[];
|
|
22
24
|
typoTolerance: boolean;
|
|
25
|
+
/** Words of at least `oneTypo` letters match with one typo, and of at least `twoTypos` with two.
|
|
26
|
+
* `{ oneTypo: 4, twoTypos: 7 }` by default; oneTypo is 3 to 20, twoTypos from oneTypo to 50. */
|
|
27
|
+
typoWordLengths?: {
|
|
28
|
+
oneTypo: number;
|
|
29
|
+
twoTypos: number;
|
|
30
|
+
};
|
|
31
|
+
/** Up to 1,000 single words that never match with typos, though prefixes still do, such as brand names. */
|
|
32
|
+
exactWords?: string[];
|
|
23
33
|
sorts: SortOption[];
|
|
24
34
|
defaultSort: string;
|
|
25
|
-
/** Orders
|
|
35
|
+
/** Orders every result by up to two sort fields before relevance, in relevance and named sorts (not
|
|
36
|
+
* distance), such as `[{ field: "soldOut", direction: "asc" }]` for in stock first. Relevance and
|
|
37
|
+
* custom ranking order each group. Records without the field come last. */
|
|
38
|
+
rankFirst?: SortRule[];
|
|
39
|
+
/** Orders equally relevant results (within `relevanceTolerance` of the best score) by up to three sort fields. */
|
|
26
40
|
customRanking?: SortRule[];
|
|
41
|
+
/** How close to the best match's score, in percent (1 to 50), results count as equally relevant for
|
|
42
|
+
* custom ranking, and for distance in searches with a query and `near`. 10 by default. */
|
|
43
|
+
relevanceTolerance?: number;
|
|
27
44
|
/** Groups of interchangeable words or phrases (up to four words each). */
|
|
28
45
|
synonyms?: string[][];
|
|
46
|
+
/** A query for an input also matches its 1 to 20 synonyms, but not the other way round. Up to 1,000,
|
|
47
|
+
* each term up to four words. */
|
|
48
|
+
oneWaySynonyms?: {
|
|
49
|
+
input: string;
|
|
50
|
+
synonyms: string[];
|
|
51
|
+
}[];
|
|
52
|
+
/** Up to 1,000 query rules, applied at query time, so a change takes effect at once. Up to 10 fire per
|
|
53
|
+
* search, in this order, and an earlier rule's filter on a field wins over a later one's. */
|
|
54
|
+
rules?: QueryRule[];
|
|
29
55
|
/** Languages whose common words queries drop, and whose plurals and alternative spellings match. */
|
|
30
56
|
languages?: Array<"da" | "de" | "en" | "is" | "no" | "sv">;
|
|
31
57
|
/** Searches say which words typo tolerance corrected, and `suggest()` offers a corrected query for a
|
|
@@ -39,6 +65,40 @@ export type IndexSettings = {
|
|
|
39
65
|
fieldTypes?: Record<string, "text" | "number" | "boolean" | "text[]" | "number[]" | "boolean[]" | "location">;
|
|
40
66
|
[key: string]: unknown;
|
|
41
67
|
};
|
|
68
|
+
/** When a query is, starts with, or contains one of a rule's phrases, the rule's `then` applies to that
|
|
69
|
+
* search. Phrases are compared as synonyms are: without case, accents, or the index languages' stop words,
|
|
70
|
+
* and with their plurals and other spellings, so a rule for "balloon" also fires for "balloons". */
|
|
71
|
+
export type QueryRule = {
|
|
72
|
+
/** 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, unique in the index. */
|
|
73
|
+
id: string;
|
|
74
|
+
/** Up to 200 characters, for the people reading the settings. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/** false keeps the rule without applying it. Reading settings always returns it; true when left out. */
|
|
77
|
+
enabled?: boolean;
|
|
78
|
+
if: {
|
|
79
|
+
/** 1 to 20 phrases of one to four words. */
|
|
80
|
+
phrases: string[];
|
|
81
|
+
/** The query is one of the phrases, starts with one, or contains one; "contains" when left out. */
|
|
82
|
+
match?: "is" | "startsWith" | "contains";
|
|
83
|
+
};
|
|
84
|
+
/** One or more of these. `filters`, `boost` and `bury` take a search's filters, on 1 to 10 fields. */
|
|
85
|
+
then: {
|
|
86
|
+
/** Added to the search's filters, except on fields the search (or an earlier rule) already filters.
|
|
87
|
+
* Records without the field drop out, so `boost` is usually the better choice. */
|
|
88
|
+
filters?: SearchRequest["filters"];
|
|
89
|
+
/** Records matching these come before the others, after `rankFirst`'s groups, in searches with a query
|
|
90
|
+
* sorted by relevance. Several rules' boosts add up. */
|
|
91
|
+
boost?: SearchRequest["filters"];
|
|
92
|
+
/** Records matching these come after the others, in the same searches as `boost`. */
|
|
93
|
+
bury?: SearchRequest["filters"];
|
|
94
|
+
/** Up to 100 objectIDs left out of the results. */
|
|
95
|
+
hide?: string[];
|
|
96
|
+
/** Words searched instead of the matched phrase, up to 64 characters; "" removes the phrase. */
|
|
97
|
+
replace?: string;
|
|
98
|
+
/** JSON up to 4 KB returned with the results in `appliedRules`, such as a banner or a redirect. */
|
|
99
|
+
userData?: unknown;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
42
102
|
/** A settings change running in the background (with the settings it applies), or the last one's
|
|
43
103
|
* failure. `kind` is "searchable" (new searchable fields filled in) or "rebuild" (a full copy). */
|
|
44
104
|
export type SettingsRebuild = {
|
|
@@ -100,7 +160,13 @@ export type SearchRequest = {
|
|
|
100
160
|
q?: string;
|
|
101
161
|
/** A sort ID from the index settings, "relevance", or "distance" (with `near`). */
|
|
102
162
|
sort?: string;
|
|
103
|
-
/** Field values to keep
|
|
163
|
+
/** Field values to keep. Each field takes a value (a list field contains it), or an object of
|
|
164
|
+
* conditions that all apply: `in` (any of up to 100 values), `all` (a list field has every one),
|
|
165
|
+
* `not` and `notIn` (records without the field stay in), and `gte`/`lte` for numbers and dates, such
|
|
166
|
+
* as `{ tags: { in: ["kids", "adult"], notIn: ["party"] }, price: { lte: 50 } }`. `"$or"` takes one to
|
|
167
|
+
* four groups of field filters and keeps records that match any group:
|
|
168
|
+
* `{ inStock: true, $or: [{ brand: "Acme" }, { price: { lte: 20 } }] }`. Up to 20 field filters,
|
|
169
|
+
* counting those in groups. */
|
|
104
170
|
filters?: Record<string, unknown>;
|
|
105
171
|
facets?: string[];
|
|
106
172
|
/** Facets (also in `facets`) counted without the filter on their own field, so a list of choices
|
|
@@ -122,6 +188,13 @@ export type SearchRequest = {
|
|
|
122
188
|
preTag?: string;
|
|
123
189
|
postTag?: string;
|
|
124
190
|
};
|
|
191
|
+
/** Say why each hit matched and what ordered it, in each hit's `explain`: each query word's best match in
|
|
192
|
+
* each searchable field, the relevance band, and the ranking fields' values. For understanding and tuning
|
|
193
|
+
* ranking; browser search doesn't explain results. */
|
|
194
|
+
explain?: boolean;
|
|
195
|
+
/** false searches without the index's query rules, such as to compare results with and without them. On by
|
|
196
|
+
* default; browser search doesn't take it. */
|
|
197
|
+
enableRules?: boolean;
|
|
125
198
|
/** Nearest first from a point, with each hit's `distance` in meters; `radius` (meters) keeps only what's
|
|
126
199
|
* within it. With a query, the best matches come first and distance orders them. Needs the index's
|
|
127
200
|
* location field. Records without a location are left out. */
|
|
@@ -144,6 +217,60 @@ export type GeoBox = {
|
|
|
144
217
|
east: number;
|
|
145
218
|
west: number;
|
|
146
219
|
};
|
|
220
|
+
/** How a query word matched: as itself, as the start of a longer word, within its typos, through a synonym, or
|
|
221
|
+
* as another form of the word in the index's languages (a plural or another spelling). */
|
|
222
|
+
export type MatchKind = "exact" | "prefix" | "typo" | "synonym" | "form";
|
|
223
|
+
/** A query word's best match in one searchable field. `word` is the matched word (or a synonym's phrase) as
|
|
224
|
+
* the record writes it. `weight` is what that kind of match weighs in the search: the field's weight times 1
|
|
225
|
+
* for exact, 0.8 for a synonym or word form, 0.2 for a prefix, and 0.1 for a typo. A typo has `edits`, or
|
|
226
|
+
* `firstLetter` when the first letter differs (tried only when nothing matched without); a synonym has the
|
|
227
|
+
* `synonym` that matched. */
|
|
228
|
+
export type FieldMatch = {
|
|
229
|
+
field: string;
|
|
230
|
+
word: string;
|
|
231
|
+
how: MatchKind;
|
|
232
|
+
weight: number;
|
|
233
|
+
edits?: number;
|
|
234
|
+
firstLetter?: true;
|
|
235
|
+
synonym?: string;
|
|
236
|
+
};
|
|
237
|
+
/** A ranking field and the hit's value for it; null when the record has none (it comes last). */
|
|
238
|
+
export type RankingValue = SortRule & {
|
|
239
|
+
value: unknown;
|
|
240
|
+
};
|
|
241
|
+
/** Why a hit matched and what ordered it, with `explain`. Scores are for the whole record, so matches carry
|
|
242
|
+
* weights rather than points that add up to `score`. */
|
|
243
|
+
export type HitExplanation = {
|
|
244
|
+
/** Each query word (after the languages' stop words, up to eight) with its best match in each searchable
|
|
245
|
+
* field, strongest first. Every word matches somewhere. Empty without a query. */
|
|
246
|
+
words: {
|
|
247
|
+
query: string;
|
|
248
|
+
matches: FieldMatch[];
|
|
249
|
+
}[];
|
|
250
|
+
/** With custom ranking (or `near`) and a query: 0 for results within `relevanceTolerance` of `bestScore`,
|
|
251
|
+
* 1 for the next band down, and so on. Distance (with `near`), then custom ranking, orders the results
|
|
252
|
+
* within a band. */
|
|
253
|
+
band?: number | null;
|
|
254
|
+
/** The best score in the hit's rank first group, among results as boosted by the search's rules (the whole
|
|
255
|
+
* search without either). */
|
|
256
|
+
bestScore?: number | null;
|
|
257
|
+
/** The index's rank first fields. */
|
|
258
|
+
rankFirst?: RankingValue[];
|
|
259
|
+
/** The index's custom ranking fields, when sorting by relevance. */
|
|
260
|
+
customRanking?: RankingValue[];
|
|
261
|
+
/** The named sort's fields, when sorting by one. */
|
|
262
|
+
sort?: RankingValue[];
|
|
263
|
+
/** When the query's rules boosted or buried results: those whose filters the hit matched, if any. */
|
|
264
|
+
rules?: {
|
|
265
|
+
id: string;
|
|
266
|
+
effect: "boost" | "bury";
|
|
267
|
+
}[];
|
|
268
|
+
};
|
|
269
|
+
/** A query rule the search fired, with its `userData`, if it has any. */
|
|
270
|
+
export type AppliedRule = {
|
|
271
|
+
id: string;
|
|
272
|
+
userData?: unknown;
|
|
273
|
+
};
|
|
147
274
|
export type SearchResponse<T> = {
|
|
148
275
|
hits: {
|
|
149
276
|
id: string;
|
|
@@ -153,6 +280,8 @@ export type SearchResponse<T> = {
|
|
|
153
280
|
distance?: number;
|
|
154
281
|
/** With `highlight`: matched searchable fields by path; list fields give one string per value. */
|
|
155
282
|
highlights?: Record<string, string | string[]>;
|
|
283
|
+
/** With `explain`: why the hit matched and what ordered it. */
|
|
284
|
+
explain?: HitExplanation;
|
|
156
285
|
}[];
|
|
157
286
|
total: number;
|
|
158
287
|
/** "gte" when `total` is a lower bound (page count mode). */
|
|
@@ -172,12 +301,17 @@ export type SearchResponse<T> = {
|
|
|
172
301
|
/** With the index's `didYouMean` setting: the query with the words typo tolerance corrected, when the
|
|
173
302
|
* results matched some words only by typo ("Showing results for …"), else null. */
|
|
174
303
|
didYouMean?: DidYouMean | null;
|
|
304
|
+
/** The index's query rules the query fired, in the index's order. Left out when none fired. */
|
|
305
|
+
appliedRules?: AppliedRule[];
|
|
306
|
+
/** When a rule replaced words: the query that was searched. */
|
|
307
|
+
rewrittenQuery?: string;
|
|
175
308
|
};
|
|
176
309
|
/** How a product's recommendations are served (the recommendations add-on). */
|
|
177
310
|
export type RecommendationsRequest = {
|
|
178
311
|
/** Up to this many products in each list, 1 to 20; 8 by default. */
|
|
179
312
|
limit?: number;
|
|
180
|
-
/** Only picks matching these filters, as in a search (fields configured as filters), such as `{ inStock: true }`.
|
|
313
|
+
/** Only picks matching these filters, as in a search (fields configured as filters), such as `{ inStock: true }`.
|
|
314
|
+
* Each field's filter takes the same shapes; `$or` is for searches only. */
|
|
181
315
|
filters?: SearchRequest["filters"];
|
|
182
316
|
/** The record fields to return; all of them by default. */
|
|
183
317
|
attributesToRetrieve?: string[];
|
|
@@ -314,13 +448,23 @@ export type Order = {
|
|
|
314
448
|
currency?: string;
|
|
315
449
|
timestamp?: number | Date | string;
|
|
316
450
|
};
|
|
317
|
-
/** How many events were accepted, and which were refused and why; `index` counts from the first event
|
|
451
|
+
/** How many events were accepted, and which were refused and why; `index` counts from the first event
|
|
452
|
+
* sent. `visitorIds: "off"` when IDs were sent and dropped: the index doesn't take them. */
|
|
318
453
|
export type EventsResult = {
|
|
319
454
|
accepted: number;
|
|
320
455
|
rejected: {
|
|
321
456
|
index: number;
|
|
322
457
|
reason: string;
|
|
323
458
|
}[];
|
|
459
|
+
visitorIds?: "off";
|
|
460
|
+
};
|
|
461
|
+
/** Options for `events.send`. `visitorId` and `visitId` are your own anonymous IDs for the shopper and
|
|
462
|
+
* the visit, for all the events sent: 16 to 64 letters, digits, - or _, never an email address, a
|
|
463
|
+
* phone number or a customer number. They're kept only when the index takes visitor IDs. */
|
|
464
|
+
export type EventOptions = {
|
|
465
|
+
signal?: AbortSignal;
|
|
466
|
+
visitorId?: string;
|
|
467
|
+
visitId?: string;
|
|
324
468
|
};
|
|
325
469
|
export declare class FindlaneError extends Error {
|
|
326
470
|
status: number;
|
|
@@ -357,13 +501,19 @@ export declare class SearchServerClient {
|
|
|
357
501
|
/** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
|
|
358
502
|
* scope. Orders count once each, however often they're sent. */
|
|
359
503
|
events: {
|
|
360
|
-
/** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`.
|
|
361
|
-
|
|
504
|
+
/** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. Pass
|
|
505
|
+
* `{ visitorId, visitId }` to link a shopper's events (see EventOptions). */
|
|
506
|
+
send: (events: ServerEvent[], options?: AbortSignal | EventOptions) => Promise<EventsResult>;
|
|
362
507
|
/** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
|
|
363
508
|
* cursor), 200 a request. */
|
|
364
509
|
importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
|
|
365
510
|
/** Takes a cancelled or refunded order out of Bought together. */
|
|
366
511
|
cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
|
|
512
|
+
/** Deletes the events sent with these visitor or visit IDs, for shoppers who asked: Findlane does it
|
|
513
|
+
* within a week. 1,000 IDs a request. */
|
|
514
|
+
forget: (ids: string[], signal?: AbortSignal) => Promise<{
|
|
515
|
+
forgotten: number;
|
|
516
|
+
}>;
|
|
367
517
|
/** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
|
|
368
518
|
* block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
|
|
369
519
|
* route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
|
package/dist/index.js
CHANGED
|
@@ -103,7 +103,7 @@ class SearchServerClient {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
};
|
|
106
|
-
const postEvents = async (
|
|
106
|
+
const postEvents = async (payload, signal) => {
|
|
107
107
|
for (let attempt = 0;; attempt++) {
|
|
108
108
|
signal?.throwIfAborted();
|
|
109
109
|
let wait = 250 * 2 ** attempt;
|
|
@@ -112,11 +112,11 @@ class SearchServerClient {
|
|
|
112
112
|
method: "POST",
|
|
113
113
|
signal,
|
|
114
114
|
headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
|
|
115
|
-
body: JSON.stringify(
|
|
115
|
+
body: JSON.stringify(payload)
|
|
116
116
|
});
|
|
117
117
|
const data = await response.json().catch(() => ({}));
|
|
118
118
|
if (response.ok)
|
|
119
|
-
return
|
|
119
|
+
return data;
|
|
120
120
|
const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
|
|
121
121
|
if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
|
|
122
122
|
throw error;
|
|
@@ -148,16 +148,23 @@ class SearchServerClient {
|
|
|
148
148
|
const retryAfter = response.headers.get("retry-after");
|
|
149
149
|
return jsonResponse(await response.text(), response.status, retryAfter ? { "retry-after": retryAfter } : {});
|
|
150
150
|
};
|
|
151
|
-
const sendEvents = async (events,
|
|
151
|
+
const sendEvents = async (events, options = {}) => {
|
|
152
|
+
const { signal, visitorId, visitId } = options;
|
|
152
153
|
const result = { accepted: 0, rejected: [] };
|
|
153
154
|
let batch = [];
|
|
154
155
|
let sent = 0;
|
|
155
156
|
const flush = async () => {
|
|
156
157
|
if (!batch.length)
|
|
157
158
|
return;
|
|
158
|
-
const answer = await postEvents(
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
const answer = await postEvents({
|
|
160
|
+
...visitorId === undefined ? {} : { visitorId },
|
|
161
|
+
...visitId === undefined ? {} : { visitId },
|
|
162
|
+
events: batch
|
|
163
|
+
}, signal);
|
|
164
|
+
result.accepted += answer.accepted ?? 0;
|
|
165
|
+
result.rejected.push(...(answer.rejected ?? []).map((item) => ({ ...item, index: item.index + sent })));
|
|
166
|
+
if (answer.visitorIds === "off")
|
|
167
|
+
result.visitorIds = "off";
|
|
161
168
|
sent += batch.length;
|
|
162
169
|
batch = [];
|
|
163
170
|
};
|
|
@@ -194,7 +201,7 @@ class SearchServerClient {
|
|
|
194
201
|
return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
|
|
195
202
|
},
|
|
196
203
|
events: {
|
|
197
|
-
send: (events,
|
|
204
|
+
send: (events, options) => sendEvents(events, options === undefined ? {} : ("aborted" in options) ? { signal: options } : options),
|
|
198
205
|
importOrders: (orders, signal) => sendEvents(async function* () {
|
|
199
206
|
for await (const { timestamp, ...order } of orders)
|
|
200
207
|
yield {
|
|
@@ -202,8 +209,14 @@ class SearchServerClient {
|
|
|
202
209
|
...order,
|
|
203
210
|
...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
|
|
204
211
|
};
|
|
205
|
-
}(), signal),
|
|
206
|
-
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal),
|
|
212
|
+
}(), { signal }),
|
|
213
|
+
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], { signal }),
|
|
214
|
+
forget: async (ids, signal) => {
|
|
215
|
+
let forgotten = 0;
|
|
216
|
+
for (let start = 0;start < ids.length; start += 1000)
|
|
217
|
+
forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
|
|
218
|
+
return { forgotten };
|
|
219
|
+
},
|
|
207
220
|
proxy: async (request2, visitor = {}) => {
|
|
208
221
|
if (request2.method !== "POST")
|
|
209
222
|
return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@findlane/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Findlane server SDK: upload catalogs, manage index settings, and search with a secret workspace API key.",
|
|
5
5
|
"keywords": ["findlane", "search", "full-text search", "typo tolerance", "ecommerce search", "search api", "sdk"],
|
|
6
6
|
"license": "MIT",
|