@findlane/server 0.17.0 → 0.19.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/dist/index.cjs CHANGED
@@ -232,7 +232,7 @@ class SearchServerClient {
232
232
  await flush();
233
233
  return result;
234
234
  };
235
- const report = (name2, options) => retry(() => request(`/analytics/${name2}${analyticsQuery(options)}`, "GET", undefined, options.signal), options.signal);
235
+ const report = (name, options) => retry(() => request(`/analytics/${name}${analyticsQuery(options)}`, "GET", undefined, options.signal), options.signal);
236
236
  const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
237
237
  const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
238
238
  const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
@@ -295,16 +295,16 @@ class SearchServerClient {
295
295
  forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
296
296
  return { forgotten };
297
297
  },
298
- proxy: async (request2, visitor = {}) => {
299
- if (request2.method !== "POST")
298
+ proxy: async (request, visitor = {}) => {
299
+ if (request.method !== "POST")
300
300
  return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
301
301
  const tooLarge = () => jsonResponse(JSON.stringify({ error: "The request is too large" }), 413);
302
- if (Number(request2.headers.get("content-length") ?? 0) > relayedBytes)
302
+ if (Number(request.headers.get("content-length") ?? 0) > relayedBytes)
303
303
  return tooLarge();
304
- const body = await request2.text();
304
+ const body = await request.text();
305
305
  if (new TextEncoder().encode(body).length > relayedBytes)
306
306
  return tooLarge();
307
- const found = { ...requestVisitor(request2), ...visitor };
307
+ const found = { ...requestVisitor(request), ...visitor };
308
308
  if (!found.ip)
309
309
  throw new Error("No visitor IP address in the request's headers; pass it: events.proxy(request, { ip })");
310
310
  return relayEvents(body, { ...found, ip: found.ip });
package/dist/index.d.cts CHANGED
@@ -66,9 +66,29 @@ export type IndexSettings = {
66
66
  /** Query suggestions: whole searches offered as shoppers type, and popular searches for an empty box,
67
67
  * from what shoppers search and the catalog. On by default. Changes apply within a few minutes. */
68
68
  querySuggestions?: QuerySuggestionSettings;
69
+ /** Related results (0.18.0): when a search's words find nothing, the products closest to it in meaning, in
70
+ * the response's `related`. Paid plans, indexes of up to 200,000 records. Turned on, every record is
71
+ * embedded within minutes, and `minScore`, the floor, is calibrated from the index's own searches that
72
+ * found nothing; none show until it is. Off by default. */
73
+ relatedResults?: {
74
+ enabled?: boolean;
75
+ minScore?: number | null;
76
+ };
69
77
  normalizationProfile?: "default-v2" | "default-v1" | "icelandic-ascii-v1";
70
78
  /** An object field of lat and lng numbers, such as `_geoloc`, that `near` and `within` search; null for none. */
71
79
  locationField?: string | null;
80
+ /** Shows each value of a field once, such as a product whose colours or sizes are records sharing its ID:
81
+ * the record (or up to `limit`, 1 to 4) that best matches the search. A text or number field with one value;
82
+ * `total` then counts groups. Null for none. */
83
+ distinct?: {
84
+ field: string;
85
+ limit?: number;
86
+ } | null;
87
+ /** Variants listed inside each record, such as a product's flavours: a list of objects at `field`. Each hit
88
+ * then says which one the search matched (`variant`). Null for none. */
89
+ variants?: {
90
+ field: string;
91
+ } | null;
72
92
  /** When saving: types for fields no record has yet, so settings can come before the records. Records'
73
93
  * types win once they have the field. Not returned when reading settings. */
74
94
  fieldTypes?: Record<string, "text" | "number" | "boolean" | "text[]" | "number[]" | "boolean[]" | "location">;
@@ -335,6 +355,11 @@ export type SearchRequest = {
335
355
  /** Only records inside a box, in degrees, such as a map's visible area. West greater than east crosses the
336
356
  * antimeridian. */
337
357
  within?: GeoBox;
358
+ /** With the index's `relatedResults` on: false asks for no related results (0.18.0). */
359
+ related?: boolean;
360
+ /** With the index's `distinct` setting: false shows every record, a number 1 to 4 how many of each group's
361
+ * records. */
362
+ distinct?: boolean | number;
338
363
  };
339
364
  /** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
340
365
  export type GeoPoint = {
@@ -415,8 +440,23 @@ export type SearchResponse<T> = {
415
440
  highlights?: Record<string, string | string[]>;
416
441
  /** With `explain`: why the hit matched and what ordered it. */
417
442
  explain?: HitExplanation;
443
+ /** With the index's `distinct` setting: the hit's group (its value of the group field, null for a record
444
+ * without one) and how many of the group's records matched. */
445
+ group?: {
446
+ value: string | number | null;
447
+ matched: number;
448
+ };
449
+ /** With the index's `variants` setting: which item of the record's variants list the search matched, by
450
+ * its words or by filters on the variants' fields, such as the flavour a shopper typed. Absent when none
451
+ * stands out. */
452
+ variant?: {
453
+ index: number;
454
+ };
418
455
  }[];
456
+ /** The matches; with the index's `distinct` setting, the groups. */
419
457
  total: number;
458
+ /** With `distinct`: the matching records, which `total` counts as groups. */
459
+ totalRecords?: number;
420
460
  /** "gte" when `total` is a lower bound (page count mode). */
421
461
  totalRelation?: "eq" | "gte";
422
462
  hasMore?: boolean;
@@ -441,6 +481,17 @@ export type SearchResponse<T> = {
441
481
  appliedRules?: AppliedRule[];
442
482
  /** When a rule replaced words: the query that was searched. */
443
483
  rewrittenQuery?: string;
484
+ /** With the index's `relatedResults` on (0.18.0): when the query's words found nothing (`hits` empty,
485
+ * `total` 0), the products closest to it in meaning, best first, with their similarity as `score`. Show
486
+ * them under a heading of their own, such as "No exact matches. Related products". */
487
+ related?: {
488
+ hits: Array<{
489
+ id: string;
490
+ score: number | null;
491
+ record: T;
492
+ }>;
493
+ total: number;
494
+ };
444
495
  };
445
496
  /** How a product's recommendations are served (the recommendations add-on). */
446
497
  /** One of an index's lists (docs: Lists): a row of products, such as Popular or New arrivals, from a filter and
@@ -626,7 +677,7 @@ export type ServerEvent = {
626
677
  objectID: string;
627
678
  position?: number;
628
679
  query?: string;
629
- list?: "goesWith" | "similar" | "together";
680
+ list?: "goesWith" | "similar" | "together" | "related";
630
681
  from?: string;
631
682
  timestamp?: number;
632
683
  } | {
package/dist/index.d.ts CHANGED
@@ -66,9 +66,29 @@ export type IndexSettings = {
66
66
  /** Query suggestions: whole searches offered as shoppers type, and popular searches for an empty box,
67
67
  * from what shoppers search and the catalog. On by default. Changes apply within a few minutes. */
68
68
  querySuggestions?: QuerySuggestionSettings;
69
+ /** Related results (0.18.0): when a search's words find nothing, the products closest to it in meaning, in
70
+ * the response's `related`. Paid plans, indexes of up to 200,000 records. Turned on, every record is
71
+ * embedded within minutes, and `minScore`, the floor, is calibrated from the index's own searches that
72
+ * found nothing; none show until it is. Off by default. */
73
+ relatedResults?: {
74
+ enabled?: boolean;
75
+ minScore?: number | null;
76
+ };
69
77
  normalizationProfile?: "default-v2" | "default-v1" | "icelandic-ascii-v1";
70
78
  /** An object field of lat and lng numbers, such as `_geoloc`, that `near` and `within` search; null for none. */
71
79
  locationField?: string | null;
80
+ /** Shows each value of a field once, such as a product whose colours or sizes are records sharing its ID:
81
+ * the record (or up to `limit`, 1 to 4) that best matches the search. A text or number field with one value;
82
+ * `total` then counts groups. Null for none. */
83
+ distinct?: {
84
+ field: string;
85
+ limit?: number;
86
+ } | null;
87
+ /** Variants listed inside each record, such as a product's flavours: a list of objects at `field`. Each hit
88
+ * then says which one the search matched (`variant`). Null for none. */
89
+ variants?: {
90
+ field: string;
91
+ } | null;
72
92
  /** When saving: types for fields no record has yet, so settings can come before the records. Records'
73
93
  * types win once they have the field. Not returned when reading settings. */
74
94
  fieldTypes?: Record<string, "text" | "number" | "boolean" | "text[]" | "number[]" | "boolean[]" | "location">;
@@ -335,6 +355,11 @@ export type SearchRequest = {
335
355
  /** Only records inside a box, in degrees, such as a map's visible area. West greater than east crosses the
336
356
  * antimeridian. */
337
357
  within?: GeoBox;
358
+ /** With the index's `relatedResults` on: false asks for no related results (0.18.0). */
359
+ related?: boolean;
360
+ /** With the index's `distinct` setting: false shows every record, a number 1 to 4 how many of each group's
361
+ * records. */
362
+ distinct?: boolean | number;
338
363
  };
339
364
  /** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
340
365
  export type GeoPoint = {
@@ -415,8 +440,23 @@ export type SearchResponse<T> = {
415
440
  highlights?: Record<string, string | string[]>;
416
441
  /** With `explain`: why the hit matched and what ordered it. */
417
442
  explain?: HitExplanation;
443
+ /** With the index's `distinct` setting: the hit's group (its value of the group field, null for a record
444
+ * without one) and how many of the group's records matched. */
445
+ group?: {
446
+ value: string | number | null;
447
+ matched: number;
448
+ };
449
+ /** With the index's `variants` setting: which item of the record's variants list the search matched, by
450
+ * its words or by filters on the variants' fields, such as the flavour a shopper typed. Absent when none
451
+ * stands out. */
452
+ variant?: {
453
+ index: number;
454
+ };
418
455
  }[];
456
+ /** The matches; with the index's `distinct` setting, the groups. */
419
457
  total: number;
458
+ /** With `distinct`: the matching records, which `total` counts as groups. */
459
+ totalRecords?: number;
420
460
  /** "gte" when `total` is a lower bound (page count mode). */
421
461
  totalRelation?: "eq" | "gte";
422
462
  hasMore?: boolean;
@@ -441,6 +481,17 @@ export type SearchResponse<T> = {
441
481
  appliedRules?: AppliedRule[];
442
482
  /** When a rule replaced words: the query that was searched. */
443
483
  rewrittenQuery?: string;
484
+ /** With the index's `relatedResults` on (0.18.0): when the query's words found nothing (`hits` empty,
485
+ * `total` 0), the products closest to it in meaning, best first, with their similarity as `score`. Show
486
+ * them under a heading of their own, such as "No exact matches. Related products". */
487
+ related?: {
488
+ hits: Array<{
489
+ id: string;
490
+ score: number | null;
491
+ record: T;
492
+ }>;
493
+ total: number;
494
+ };
444
495
  };
445
496
  /** How a product's recommendations are served (the recommendations add-on). */
446
497
  /** One of an index's lists (docs: Lists): a row of products, such as Popular or New arrivals, from a filter and
@@ -626,7 +677,7 @@ export type ServerEvent = {
626
677
  objectID: string;
627
678
  position?: number;
628
679
  query?: string;
629
- list?: "goesWith" | "similar" | "together";
680
+ list?: "goesWith" | "similar" | "together" | "related";
630
681
  from?: string;
631
682
  timestamp?: number;
632
683
  } | {
package/dist/index.js CHANGED
@@ -186,7 +186,7 @@ class SearchServerClient {
186
186
  await flush();
187
187
  return result;
188
188
  };
189
- const report = (name2, options) => retry(() => request(`/analytics/${name2}${analyticsQuery(options)}`, "GET", undefined, options.signal), options.signal);
189
+ const report = (name, options) => retry(() => request(`/analytics/${name}${analyticsQuery(options)}`, "GET", undefined, options.signal), options.signal);
190
190
  const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
191
191
  const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
192
192
  const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
@@ -249,16 +249,16 @@ class SearchServerClient {
249
249
  forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
250
250
  return { forgotten };
251
251
  },
252
- proxy: async (request2, visitor = {}) => {
253
- if (request2.method !== "POST")
252
+ proxy: async (request, visitor = {}) => {
253
+ if (request.method !== "POST")
254
254
  return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
255
255
  const tooLarge = () => jsonResponse(JSON.stringify({ error: "The request is too large" }), 413);
256
- if (Number(request2.headers.get("content-length") ?? 0) > relayedBytes)
256
+ if (Number(request.headers.get("content-length") ?? 0) > relayedBytes)
257
257
  return tooLarge();
258
- const body = await request2.text();
258
+ const body = await request.text();
259
259
  if (new TextEncoder().encode(body).length > relayedBytes)
260
260
  return tooLarge();
261
- const found = { ...requestVisitor(request2), ...visitor };
261
+ const found = { ...requestVisitor(request), ...visitor };
262
262
  if (!found.ip)
263
263
  throw new Error("No visitor IP address in the request's headers; pass it: events.proxy(request, { ip })");
264
264
  return relayEvents(body, { ...found, ip: found.ip });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/server",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",