@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 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 (events, signal) => {
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({ events })
161
+ body: JSON.stringify(payload)
162
162
  });
163
163
  const data = await response.json().catch(() => ({}));
164
164
  if (response.ok)
165
- return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
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, signal) => {
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(batch, signal);
205
- result.accepted += answer.accepted;
206
- result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
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, signal) => sendEvents(events, signal),
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 equally relevant results (within 10% of the best score) by up to three sort fields. */
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: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. */
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 sent. */
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
- send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
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 equally relevant results (within 10% of the best score) by up to three sort fields. */
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: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. */
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 sent. */
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
- send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
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 (events, signal) => {
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({ events })
115
+ body: JSON.stringify(payload)
116
116
  });
117
117
  const data = await response.json().catch(() => ({}));
118
118
  if (response.ok)
119
- return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
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, signal) => {
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(batch, signal);
159
- result.accepted += answer.accepted;
160
- result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
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, signal) => sendEvents(events, signal),
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.11.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",