@findlane/server 0.14.0 → 0.16.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
@@ -48,6 +48,7 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
48
48
  | Method | What it does |
49
49
  | --- | --- |
50
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
+ | `querySuggestions({ q, limit? })`, `popularSearches({ limit? })` | Whole searches to offer as a visitor types, `[{ q, typed }]`, and the index's popular searches for an empty box. Not counted as searches. |
51
52
  | `upsert(records)` | Add or update up to 1,000 records by `objectID`. |
52
53
  | `replaceAll(records)` | Replace the whole catalog in one request (up to 100,000 records). |
53
54
  | `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. |
@@ -55,11 +56,14 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
55
56
  | `getRecord(objectID)` | A record and its version. |
56
57
  | `upsertOne(record, { expectedVersion })`, `deleteOne(objectID, { expectedVersion })` | Write only if the record is still at `expectedVersion` (0 for one that never existed); retried with one idempotency key. |
57
58
  | `getSettings()`, `replaceSettings(settings)`, `waitForSettings()` | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, languages, typo tolerance, and text normalization. |
59
+ | `compareSettings(settings, { queries, searched }?)` | Compare settings with the index's own without saving them: its search tests and most searched queries, each searched with both. Says which tests would fail and whose results change. |
60
+ | `getSearchTests()`, `setSearchTests(tests)` | Searches that must keep working, saved apart from the settings, such as a record in the top 3 or at least 100 results. `compareSettings` checks them. |
58
61
  | `getIndexingStatus()`, `waitUntilIndexed(revision)` | Writes return an accepted revision and are searchable when accepted, so `waitUntilIndexed` returns at once. |
59
62
  | `getPublicSearchAccess()`, `setPublicSearchPolicy(policy)`, `usePublicIndexDefaults()` | Restrict what browser search may read and query. |
60
63
  | `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
64
  | `events.forget(ids)` | Deletes the events sent with these visitor or visit IDs, within a week. |
62
65
  | `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). |
66
+ | `analytics.searches()`, `.queries({ list, limit })`, `.filters()`, `.events()`, `.products()`, `.productCounts()`, `.product(objectID)` | The index's analytics, for your own admin: searches over time, top queries with their clicks, queries without results or clicks, the filters used, events, and each product's views, add-to-carts and orders. The key needs the `analytics` scope, and works only from a server. See [Analytics](https://findlane.dev/docs/api#analytics). |
63
67
 
64
68
  ### Settings
65
69
 
@@ -72,6 +76,14 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
72
76
 
73
77
  A large `searchable` or `rebuild` change continues in the background, with its progress in `rebuild` (also returned by `getSettings`). Searches use the previous settings until it finishes, and `waitForSettings()` waits for it.
74
78
 
79
+ Before saving a change to rules, synonyms, typo tolerance, or ranking, `compareSettings` shows what it does, and `{}` runs the search tests with the current settings:
80
+
81
+ ```ts
82
+ const comparison = await products.compareSettings({ rules: nextRules })
83
+ const failing = comparison.tests.filter((test) => test.before && !test.after)
84
+ if (!failing.length) await products.replaceSettings({ ...(await products.getSettings()), rules: nextRules })
85
+ ```
86
+
75
87
  ### Browser search
76
88
 
77
89
  Only upload fields you intend to make public if browser search is enabled. `getPublicSearchAccess()` shows the current mode. `setPublicSearchPolicy({ returnFields, searchFields, filterFields, facetFields, sortIds, defaultSort })` restricts the index at the API, and `usePublicIndexDefaults()` removes the restrictions. Only a workspace owner can change allowed browser origins, in the dashboard.
package/dist/index.cjs CHANGED
@@ -67,6 +67,16 @@ class StagedReplacementError extends Error {
67
67
  this.name = "StagedReplacementError";
68
68
  }
69
69
  }
70
+ function analyticsQuery(options) {
71
+ const params = new URLSearchParams;
72
+ for (const name of ["range", "from", "to", "interval", "source", "list", "limit"]) {
73
+ const value = options[name];
74
+ if (value !== undefined)
75
+ params.set(name, value instanceof Date ? value.toISOString() : String(value));
76
+ }
77
+ const query = params.toString();
78
+ return query ? `?${query}` : "";
79
+ }
70
80
  var eventsPerRequest = 200;
71
81
  var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
72
82
  var relayedBytes = 256 * 1024;
@@ -222,6 +232,7 @@ class SearchServerClient {
222
232
  await flush();
223
233
  return result;
224
234
  };
235
+ const report = (name2, options) => retry(() => request(`/analytics/${name2}${analyticsQuery(options)}`, "GET", undefined, options.signal), options.signal);
225
236
  const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
226
237
  const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
227
238
  const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
@@ -242,6 +253,14 @@ class SearchServerClient {
242
253
  const { signal, visitor } = requestOptions(options);
243
254
  return (await request("/suggest", "POST", query, signal, visitorHeaders(visitor))).didYouMean;
244
255
  },
256
+ querySuggestions: async (query, options) => {
257
+ const { signal } = requestOptions(options);
258
+ return (await request("/query-suggestions", "POST", query, signal)).suggestions;
259
+ },
260
+ popularSearches: async (options = {}, requestOptionsOrSignal) => {
261
+ const { signal } = requestOptions(requestOptionsOrSignal);
262
+ return (await request("/query-suggestions", "POST", { q: "", limit: options.limit ?? 6 }, signal)).suggestions;
263
+ },
245
264
  recommendations: (objectID, options = {}, requestOptionsOrSignal) => {
246
265
  const { signal, visitor } = requestOptions(requestOptionsOrSignal);
247
266
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
@@ -279,6 +298,15 @@ class SearchServerClient {
279
298
  },
280
299
  relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
281
300
  },
301
+ analytics: {
302
+ searches: (options = {}) => report("searches", options),
303
+ queries: (options = {}) => report("queries", options),
304
+ filters: (options = {}) => report("filters", options),
305
+ events: (options = {}) => report("events", options),
306
+ products: (options = {}) => report("products", options),
307
+ productCounts: (options = {}) => report("product-counts", options),
308
+ product: (objectID, options = {}) => report(`products/${encodeURIComponent(objectID)}`, options)
309
+ },
282
310
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
283
311
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
284
312
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
@@ -337,6 +365,16 @@ class SearchServerClient {
337
365
  const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
338
366
  return request("/config", "PUT", body, signal);
339
367
  },
368
+ compareSettings: (settings, options = {}) => {
369
+ const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
370
+ return request("/compare", "POST", {
371
+ settings: body,
372
+ ...options.queries ? { queries: options.queries } : {},
373
+ ...options.searched === false ? { searched: false } : {}
374
+ }, options.signal);
375
+ },
376
+ getSearchTests: async (signal) => (await request("/tests", "GET", undefined, signal)).tests,
377
+ setSearchTests: async (tests, signal) => (await request("/tests", "PUT", { tests }, signal)).tests,
340
378
  waitForSettings: async (options = {}) => {
341
379
  const deadline = Date.now() + (options.timeoutMs ?? 30 * 60000);
342
380
  while (true) {
package/dist/index.d.cts CHANGED
@@ -63,6 +63,9 @@ export type IndexSettings = {
63
63
  * Exact matches rank first. Searches use it once the index's word list is built, a minute or so after it's
64
64
  * turned on. Off by default. */
65
65
  compoundWords?: boolean;
66
+ /** Query suggestions: whole searches offered as shoppers type, and popular searches for an empty box,
67
+ * from what shoppers search and the catalog. On by default. Changes apply within a few minutes. */
68
+ querySuggestions?: QuerySuggestionSettings;
66
69
  normalizationProfile?: "default-v2" | "default-v1" | "icelandic-ascii-v1";
67
70
  /** An object field of lat and lng numbers, such as `_geoloc`, that `near` and `within` search; null for none. */
68
71
  locationField?: string | null;
@@ -71,6 +74,33 @@ export type IndexSettings = {
71
74
  fieldTypes?: Record<string, "text" | "number" | "boolean" | "text[]" | "number[]" | "boolean[]" | "location">;
72
75
  [key: string]: unknown;
73
76
  };
77
+ /** An index's query suggestion settings. When saving, give only those to change. */
78
+ export type QuerySuggestionSettings = {
79
+ /** false: no suggestions. */
80
+ enabled?: boolean;
81
+ /** false: no popular searches for an empty box; typed suggestions still use them. */
82
+ popularSearches?: boolean;
83
+ /** Popular searches an empty box shows, 1 to 10; 6 by default. */
84
+ popularCount?: number;
85
+ /** A shoppers' search becomes a suggestion once searched this many times (3 by default), on this many
86
+ * different days (2 by default). */
87
+ minSearches?: number;
88
+ minDays?: number;
89
+ /** The facet and searchable fields whose values are suggested, up to 20; null (the default) chooses
90
+ * readable ones from the records, leaving out slugs, sizes and long text. */
91
+ fields?: string[] | null;
92
+ /** Searches to suggest, up to 1,000; to show first, in the empty box too, up to 20; and never to show,
93
+ * nor anything with their words in a row, up to 1,000. One to six words each. */
94
+ added?: string[];
95
+ pinned?: string[];
96
+ hidden?: string[];
97
+ };
98
+ /** A whole search to offer as a shopper types, or in an empty box. `typed`: where what was typed is in
99
+ * `q`, as UTF-16 offsets (end exclusive); popular searches have none. */
100
+ export type QuerySuggestion = {
101
+ q: string;
102
+ typed?: [number, number];
103
+ };
74
104
  /** When a query is, starts with, or contains one of a rule's phrases, the rule's `then` applies to that
75
105
  * search. Phrases are compared as synonyms are: without case, accents, or the index languages' stop words,
76
106
  * and with their plurals and other spellings, so a rule for "balloon" also fires for "balloons". */
@@ -136,6 +166,101 @@ export type SavedSettings = IndexSettings & {
136
166
  rebuilt: boolean;
137
167
  rebuild: SettingsRebuild | null;
138
168
  };
169
+ /** What a search test expects of its query's results: a record among the first `inTop` results (1 to
170
+ * 20), a record not among the first `notInTop`, or at least `minResults` results (1 to 10,000). */
171
+ export type TestExpectation = {
172
+ objectID: string;
173
+ inTop: number;
174
+ } | {
175
+ objectID: string;
176
+ notInTop: number;
177
+ } | {
178
+ minResults: number;
179
+ };
180
+ /** A search that must keep working, saved apart from the settings, which `compareSettings` checks: a query
181
+ * (empty browses), its filters on facet, filter or sort fields, and what its results should hold. IDs are
182
+ * 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, unique in the index; notes up to 200 characters. */
183
+ export type SearchTest = {
184
+ id: string;
185
+ q: string;
186
+ filters?: SearchRequest["filters"];
187
+ expect: TestExpectation;
188
+ note?: string;
189
+ };
190
+ /** How many compared queries changed in a way, and their searches in the last 30 days. */
191
+ export type CompareCount = {
192
+ queries: number;
193
+ searches: number;
194
+ };
195
+ export type ComparedHit = {
196
+ objectID: string;
197
+ title: string;
198
+ };
199
+ /** Proposed settings compared with the index's own, without saving them (`compareSettings`). */
200
+ export type SettingsComparison = {
201
+ /** The queries compared, and their searches in the last 30 days. */
202
+ compared: number;
203
+ searches: number;
204
+ /** Time (20 seconds) ran out before every query was compared. */
205
+ truncated: boolean;
206
+ summary: {
207
+ topChanged: CompareCount;
208
+ moreResults: CompareCount;
209
+ fewerResults: CompareCount;
210
+ nowNothing: CompareCount;
211
+ nowSomething: CompareCount;
212
+ };
213
+ /** Every search test: whether it passes with the current settings (`before`) and the proposed ones
214
+ * (`after`), or `skipped` when time ran out first, or the search's `error`. */
215
+ tests: (Omit<SearchTest, "note"> & {
216
+ before?: boolean;
217
+ after?: boolean;
218
+ skipped?: true;
219
+ error?: string;
220
+ })[];
221
+ /** Up to 50 queries whose top 10 or total changed, the tests' queries first, then the most searched: the
222
+ * totals and first 5 results with each, and the results that left, joined, and moved within the top 10.
223
+ * Places are in the first 20 results; null is outside them. */
224
+ changes: {
225
+ q: string;
226
+ filters?: SearchRequest["filters"];
227
+ searches: number;
228
+ before: {
229
+ total: number;
230
+ top: ComparedHit[];
231
+ };
232
+ after: {
233
+ total: number;
234
+ top: ComparedHit[];
235
+ };
236
+ gone: (ComparedHit & {
237
+ from: number;
238
+ to: number | null;
239
+ })[];
240
+ added: (ComparedHit & {
241
+ from: number | null;
242
+ to: number;
243
+ })[];
244
+ moved: (ComparedHit & {
245
+ from: number;
246
+ to: number;
247
+ })[];
248
+ }[];
249
+ errors: {
250
+ q: string;
251
+ error: string;
252
+ }[];
253
+ };
254
+ export type CompareOptions = {
255
+ /** More queries to compare, such as those a change is for: text, or `{ q, filters }`. Up to 200. */
256
+ queries?: (string | {
257
+ q: string;
258
+ filters?: SearchRequest["filters"];
259
+ })[];
260
+ /** false leaves out the index's 100 most searched queries of the last 30 days. */
261
+ searched?: boolean;
262
+ signal?: AbortSignal;
263
+ };
139
264
  export type PublicSearchPolicy = {
140
265
  returnFields: string[];
141
266
  searchFields: string[];
@@ -474,6 +599,166 @@ export type EventOptions = {
474
599
  visitorId?: string;
475
600
  visitId?: string;
476
601
  };
602
+ /** An analytics window, in UTC: `range` (7d by default), or `from` and `to`. A date as `from` is the start of
603
+ * that day, and as `to` includes the whole day; `to` is now by default. Analytics go back 90 days: a window
604
+ * starting earlier starts then, and the report's `from` says so. */
605
+ export type AnalyticsOptions = {
606
+ range?: "24h" | "7d" | "30d" | "90d";
607
+ from?: string | Date;
608
+ to?: string | Date;
609
+ /** Searches from browsers (browser search), from servers (API keys), or both, by default. */
610
+ source?: "all" | "browser" | "server";
611
+ signal?: AbortSignal;
612
+ };
613
+ /** What every analytics report starts with. `sampled`: the counts are estimates, because Analytics Engine,
614
+ * which keeps them, sampled some of them; totals and lists stay close, small counts less so. */
615
+ export type AnalyticsReport = {
616
+ index: string;
617
+ from: string;
618
+ to: string;
619
+ source: "all" | "browser" | "server";
620
+ sampled: boolean;
621
+ };
622
+ export type SearchTotals = {
623
+ searches: number;
624
+ /** Searches whose query and result count were recorded: the base for `noResultRate`. */
625
+ tracked: number;
626
+ noResults: number;
627
+ /** `noResults` of `tracked`, 0 to 1; null without tracked searches. */
628
+ noResultRate: number | null;
629
+ browser: number;
630
+ server: number;
631
+ uniqueQueries: number;
632
+ /** Response times at the API, in ms. */
633
+ medianMs: number;
634
+ p95Ms: number;
635
+ };
636
+ export type SearchesReport = AnalyticsReport & {
637
+ interval: "hour" | "day";
638
+ totals: SearchTotals;
639
+ /** The same span just before `from`. */
640
+ previous: SearchTotals & {
641
+ from: string;
642
+ to: string;
643
+ };
644
+ /** Every hour or day of the window, the first one starting at or before `from`. */
645
+ series: {
646
+ t: string;
647
+ searches: number;
648
+ tracked: number;
649
+ noResults: number;
650
+ }[];
651
+ };
652
+ /** A top query: its clicks on results and their average place (from 1), or null when clicks aren't available. */
653
+ export type TopQuery = {
654
+ query: string;
655
+ searches: number;
656
+ noResults: number;
657
+ clicks: number | null;
658
+ averagePosition: number | null;
659
+ };
660
+ export type QueryCount = {
661
+ query: string;
662
+ searches: number;
663
+ };
664
+ export type QueryLists = {
665
+ top: TopQuery;
666
+ noResults: QueryCount;
667
+ noClicks: QueryCount;
668
+ };
669
+ export type QueriesReport<L extends keyof QueryLists = "top"> = AnalyticsReport & {
670
+ list: L;
671
+ queries: QueryLists[L][];
672
+ };
673
+ export type FiltersReport = AnalyticsReport & {
674
+ /** Searches whose filters and sort were recorded. */
675
+ searches: number;
676
+ /** Each filter field with the searches that filtered on it, up to 100. */
677
+ filters: {
678
+ field: string;
679
+ searches: number;
680
+ }[];
681
+ /** Each sort with its searches; `sort` is null for searches that named none, which use the index's default. */
682
+ sorts: {
683
+ sort: string | null;
684
+ searches: number;
685
+ }[];
686
+ };
687
+ export type EventType = "view" | "click" | "addToCart" | "purchase" | "cancel";
688
+ export type EventsReport = AnalyticsReport & {
689
+ interval: "hour" | "day";
690
+ /** Accepted events of each type, by source. */
691
+ types: {
692
+ type: EventType;
693
+ browser: number;
694
+ server: number;
695
+ }[];
696
+ /** Orders accepted, and of them those stored, which Bought together and the order counts use. */
697
+ orders: {
698
+ accepted: number;
699
+ stored: number;
700
+ };
701
+ /** Events from crawlers and scripts, which are dropped. */
702
+ bots: number;
703
+ /** Refused events, by why. */
704
+ refused: {
705
+ reason: string;
706
+ events: number;
707
+ }[];
708
+ series: ({
709
+ t: string;
710
+ } & Record<EventType, number>)[];
711
+ };
712
+ /** A product's counts. `viewers` is how many visitor IDs viewed it, or null when the index's views carry
713
+ * none. `searchClicks` is clicks on it outside recommendations: in search results and product lists. */
714
+ export type ProductCount = {
715
+ objectID: string;
716
+ views: number;
717
+ viewers: number | null;
718
+ searchClicks: number;
719
+ carts: number;
720
+ orders: number;
721
+ };
722
+ export type ListedProduct = ProductCount & {
723
+ title: string;
724
+ image: string | null;
725
+ };
726
+ /** Product reports are exact and about 2 minutes behind; `computedAt` is when they were counted, up to 5
727
+ * minutes ago. `orders` is the orders stored in the window. */
728
+ export type ProductListsReport = AnalyticsReport & {
729
+ computedAt: string;
730
+ orders: number;
731
+ /** The most viewed, most added to carts, and most ordered products, by views. */
732
+ products: ListedProduct[];
733
+ /** Products with 50 or more views, the fewest orders per view first; null without orders. */
734
+ viewedNotBought: ListedProduct[] | null;
735
+ };
736
+ export type ProductCountsReport = AnalyticsReport & {
737
+ computedAt: string;
738
+ orders: number;
739
+ /** false when more than 50,000 products had events, and only the most viewed are listed. */
740
+ complete: boolean;
741
+ products: ProductCount[];
742
+ };
743
+ export type ProductReport = AnalyticsReport & {
744
+ objectID: string;
745
+ interval: "hour" | "day";
746
+ totals: Omit<ProductCount, "objectID">;
747
+ series: ({
748
+ t: string;
749
+ } & Omit<ProductCount, "objectID">)[];
750
+ /** The queries whose results it was clicked in, the most clicks first, up to 20. */
751
+ searchedFor: {
752
+ query: string;
753
+ clicks: number;
754
+ }[];
755
+ /** Clicks on it in each recommendation list. */
756
+ recommendationClicks: {
757
+ goesWith: number;
758
+ similar: number;
759
+ together: number;
760
+ };
761
+ };
477
762
  export declare class FindlaneError extends Error {
478
763
  status: number;
479
764
  constructor(status: number, message: string);
@@ -503,6 +788,18 @@ export declare class SearchServerClient {
503
788
  * same request (its q, filters, and fields matter). Needs the `didYouMean` setting; not counted as
504
789
  * a search. */
505
790
  suggest: (query: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<DidYouMean | null>;
791
+ /** Query suggestions: whole searches to offer as a shopper types `q`, the best first; with `q` empty, the
792
+ * index's popular searches. The API key needs the `search` scope; not counted as searches. `limit`: 1 to
793
+ * 10, 5 by default. */
794
+ querySuggestions: (query: {
795
+ q: string;
796
+ limit?: number;
797
+ }, options?: AbortSignal | RequestOptions) => Promise<QuerySuggestion[]>;
798
+ /** The index's popular searches, for an empty search box, with the owner's pinned searches first;
799
+ * empty until the index has enough searches. The list changes daily. */
800
+ popularSearches: (options?: {
801
+ limit?: number;
802
+ }, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<QuerySuggestion[]>;
506
803
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
507
804
  * The API key needs the `search` scope; not counted as a search. */
508
805
  recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
@@ -532,6 +829,39 @@ export declare class SearchServerClient {
532
829
  * and its visitor. Answers with the API's response. */
533
830
  relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
534
831
  };
832
+ /** The index's search analytics. The API key needs the `analytics` scope, and works only from a server:
833
+ * requests from web pages are refused. */
834
+ analytics: {
835
+ /** Searches, searches without results and response times, beside the same span before, and a series per
836
+ * hour (up to 7 days, by default) or day. */
837
+ searches: (options?: AnalyticsOptions & {
838
+ interval?: "hour" | "day";
839
+ }) => Promise<SearchesReport>;
840
+ /** Queries, the most searched first: `top` (by default) with their clicks, `noResults`, or `noClicks`, which
841
+ * found something nobody clicked. `limit` is 1 to 1,000, 100 by default. */
842
+ queries: <L extends keyof QueryLists = "top">(options?: AnalyticsOptions & {
843
+ list?: L;
844
+ limit?: number;
845
+ }) => Promise<QueriesReport<L>>;
846
+ /** The filter fields and sorts searches used. */
847
+ filters: (options?: AnalyticsOptions) => Promise<FiltersReport>;
848
+ /** Events of each type over time and by source, orders stored, and refused and bot events. */
849
+ events: (options?: AnalyticsOptions & {
850
+ interval?: "hour" | "day";
851
+ }) => Promise<EventsReport>;
852
+ /** The most viewed, most added to carts and most ordered products, with titles and images, and those
853
+ * viewed but rarely bought. Exact, from every event kept (13 months, from 9 October 2026). */
854
+ products: (options?: AnalyticsOptions) => Promise<ProductListsReport>;
855
+ /** Every product with events or orders in the window, the most viewed first, up to 50,000: views,
856
+ * viewers, clicks, add-to-carts and orders. Exact, like `products`. */
857
+ productCounts: (options?: AnalyticsOptions) => Promise<ProductCountsReport>;
858
+ /** One product's views, viewers, clicks, add-to-carts and orders per hour or day, the queries it was
859
+ * clicked from, and its clicks in recommendations. Fast, for a product page in an admin: views and
860
+ * clicks are estimates when `sampled`, and an add-to-cart of several products counts toward the first. */
861
+ product: (objectID: string, options?: AnalyticsOptions & {
862
+ interval?: "hour" | "day";
863
+ }) => Promise<ProductReport>;
864
+ };
535
865
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
536
866
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
537
867
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
@@ -545,6 +875,16 @@ export declare class SearchServerClient {
545
875
  delete: (objectID: string, signal?: AbortSignal) => Promise<WriteReceipt>;
546
876
  getSettings: (signal?: AbortSignal) => Promise<CurrentSettings>;
547
877
  replaceSettings: (settings: IndexSettings, signal?: AbortSignal) => Promise<SavedSettings>;
878
+ /** Compares settings with the index's own without saving them: its search tests, `queries`, and its 100
879
+ * most searched queries of the last 30 days, each searched with both, for up to 20 seconds. Takes the
880
+ * settings `replaceSettings` does, or only those that change; `{}` runs the tests with the current
881
+ * settings. A change to what the index stores (new searchable, facet, filter, sort or location fields,
882
+ * or normalization) throws a 409: save it first. Not counted as searches. */
883
+ compareSettings: (settings: Partial<IndexSettings>, options?: CompareOptions) => Promise<SettingsComparison>;
884
+ /** The index's search tests. */
885
+ getSearchTests: (signal?: AbortSignal) => Promise<SearchTest[]>;
886
+ /** Replaces the index's search tests, up to 200; `[]` deletes them. */
887
+ setSearchTests: (tests: SearchTest[], signal?: AbortSignal) => Promise<SearchTest[]>;
548
888
  /** Waits until no settings rebuild is running; throws if it failed. */
549
889
  waitForSettings: (options?: {
550
890
  timeoutMs?: number;
package/dist/index.d.ts CHANGED
@@ -63,6 +63,9 @@ export type IndexSettings = {
63
63
  * Exact matches rank first. Searches use it once the index's word list is built, a minute or so after it's
64
64
  * turned on. Off by default. */
65
65
  compoundWords?: boolean;
66
+ /** Query suggestions: whole searches offered as shoppers type, and popular searches for an empty box,
67
+ * from what shoppers search and the catalog. On by default. Changes apply within a few minutes. */
68
+ querySuggestions?: QuerySuggestionSettings;
66
69
  normalizationProfile?: "default-v2" | "default-v1" | "icelandic-ascii-v1";
67
70
  /** An object field of lat and lng numbers, such as `_geoloc`, that `near` and `within` search; null for none. */
68
71
  locationField?: string | null;
@@ -71,6 +74,33 @@ export type IndexSettings = {
71
74
  fieldTypes?: Record<string, "text" | "number" | "boolean" | "text[]" | "number[]" | "boolean[]" | "location">;
72
75
  [key: string]: unknown;
73
76
  };
77
+ /** An index's query suggestion settings. When saving, give only those to change. */
78
+ export type QuerySuggestionSettings = {
79
+ /** false: no suggestions. */
80
+ enabled?: boolean;
81
+ /** false: no popular searches for an empty box; typed suggestions still use them. */
82
+ popularSearches?: boolean;
83
+ /** Popular searches an empty box shows, 1 to 10; 6 by default. */
84
+ popularCount?: number;
85
+ /** A shoppers' search becomes a suggestion once searched this many times (3 by default), on this many
86
+ * different days (2 by default). */
87
+ minSearches?: number;
88
+ minDays?: number;
89
+ /** The facet and searchable fields whose values are suggested, up to 20; null (the default) chooses
90
+ * readable ones from the records, leaving out slugs, sizes and long text. */
91
+ fields?: string[] | null;
92
+ /** Searches to suggest, up to 1,000; to show first, in the empty box too, up to 20; and never to show,
93
+ * nor anything with their words in a row, up to 1,000. One to six words each. */
94
+ added?: string[];
95
+ pinned?: string[];
96
+ hidden?: string[];
97
+ };
98
+ /** A whole search to offer as a shopper types, or in an empty box. `typed`: where what was typed is in
99
+ * `q`, as UTF-16 offsets (end exclusive); popular searches have none. */
100
+ export type QuerySuggestion = {
101
+ q: string;
102
+ typed?: [number, number];
103
+ };
74
104
  /** When a query is, starts with, or contains one of a rule's phrases, the rule's `then` applies to that
75
105
  * search. Phrases are compared as synonyms are: without case, accents, or the index languages' stop words,
76
106
  * and with their plurals and other spellings, so a rule for "balloon" also fires for "balloons". */
@@ -136,6 +166,101 @@ export type SavedSettings = IndexSettings & {
136
166
  rebuilt: boolean;
137
167
  rebuild: SettingsRebuild | null;
138
168
  };
169
+ /** What a search test expects of its query's results: a record among the first `inTop` results (1 to
170
+ * 20), a record not among the first `notInTop`, or at least `minResults` results (1 to 10,000). */
171
+ export type TestExpectation = {
172
+ objectID: string;
173
+ inTop: number;
174
+ } | {
175
+ objectID: string;
176
+ notInTop: number;
177
+ } | {
178
+ minResults: number;
179
+ };
180
+ /** A search that must keep working, saved apart from the settings, which `compareSettings` checks: a query
181
+ * (empty browses), its filters on facet, filter or sort fields, and what its results should hold. IDs are
182
+ * 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, unique in the index; notes up to 200 characters. */
183
+ export type SearchTest = {
184
+ id: string;
185
+ q: string;
186
+ filters?: SearchRequest["filters"];
187
+ expect: TestExpectation;
188
+ note?: string;
189
+ };
190
+ /** How many compared queries changed in a way, and their searches in the last 30 days. */
191
+ export type CompareCount = {
192
+ queries: number;
193
+ searches: number;
194
+ };
195
+ export type ComparedHit = {
196
+ objectID: string;
197
+ title: string;
198
+ };
199
+ /** Proposed settings compared with the index's own, without saving them (`compareSettings`). */
200
+ export type SettingsComparison = {
201
+ /** The queries compared, and their searches in the last 30 days. */
202
+ compared: number;
203
+ searches: number;
204
+ /** Time (20 seconds) ran out before every query was compared. */
205
+ truncated: boolean;
206
+ summary: {
207
+ topChanged: CompareCount;
208
+ moreResults: CompareCount;
209
+ fewerResults: CompareCount;
210
+ nowNothing: CompareCount;
211
+ nowSomething: CompareCount;
212
+ };
213
+ /** Every search test: whether it passes with the current settings (`before`) and the proposed ones
214
+ * (`after`), or `skipped` when time ran out first, or the search's `error`. */
215
+ tests: (Omit<SearchTest, "note"> & {
216
+ before?: boolean;
217
+ after?: boolean;
218
+ skipped?: true;
219
+ error?: string;
220
+ })[];
221
+ /** Up to 50 queries whose top 10 or total changed, the tests' queries first, then the most searched: the
222
+ * totals and first 5 results with each, and the results that left, joined, and moved within the top 10.
223
+ * Places are in the first 20 results; null is outside them. */
224
+ changes: {
225
+ q: string;
226
+ filters?: SearchRequest["filters"];
227
+ searches: number;
228
+ before: {
229
+ total: number;
230
+ top: ComparedHit[];
231
+ };
232
+ after: {
233
+ total: number;
234
+ top: ComparedHit[];
235
+ };
236
+ gone: (ComparedHit & {
237
+ from: number;
238
+ to: number | null;
239
+ })[];
240
+ added: (ComparedHit & {
241
+ from: number | null;
242
+ to: number;
243
+ })[];
244
+ moved: (ComparedHit & {
245
+ from: number;
246
+ to: number;
247
+ })[];
248
+ }[];
249
+ errors: {
250
+ q: string;
251
+ error: string;
252
+ }[];
253
+ };
254
+ export type CompareOptions = {
255
+ /** More queries to compare, such as those a change is for: text, or `{ q, filters }`. Up to 200. */
256
+ queries?: (string | {
257
+ q: string;
258
+ filters?: SearchRequest["filters"];
259
+ })[];
260
+ /** false leaves out the index's 100 most searched queries of the last 30 days. */
261
+ searched?: boolean;
262
+ signal?: AbortSignal;
263
+ };
139
264
  export type PublicSearchPolicy = {
140
265
  returnFields: string[];
141
266
  searchFields: string[];
@@ -474,6 +599,166 @@ export type EventOptions = {
474
599
  visitorId?: string;
475
600
  visitId?: string;
476
601
  };
602
+ /** An analytics window, in UTC: `range` (7d by default), or `from` and `to`. A date as `from` is the start of
603
+ * that day, and as `to` includes the whole day; `to` is now by default. Analytics go back 90 days: a window
604
+ * starting earlier starts then, and the report's `from` says so. */
605
+ export type AnalyticsOptions = {
606
+ range?: "24h" | "7d" | "30d" | "90d";
607
+ from?: string | Date;
608
+ to?: string | Date;
609
+ /** Searches from browsers (browser search), from servers (API keys), or both, by default. */
610
+ source?: "all" | "browser" | "server";
611
+ signal?: AbortSignal;
612
+ };
613
+ /** What every analytics report starts with. `sampled`: the counts are estimates, because Analytics Engine,
614
+ * which keeps them, sampled some of them; totals and lists stay close, small counts less so. */
615
+ export type AnalyticsReport = {
616
+ index: string;
617
+ from: string;
618
+ to: string;
619
+ source: "all" | "browser" | "server";
620
+ sampled: boolean;
621
+ };
622
+ export type SearchTotals = {
623
+ searches: number;
624
+ /** Searches whose query and result count were recorded: the base for `noResultRate`. */
625
+ tracked: number;
626
+ noResults: number;
627
+ /** `noResults` of `tracked`, 0 to 1; null without tracked searches. */
628
+ noResultRate: number | null;
629
+ browser: number;
630
+ server: number;
631
+ uniqueQueries: number;
632
+ /** Response times at the API, in ms. */
633
+ medianMs: number;
634
+ p95Ms: number;
635
+ };
636
+ export type SearchesReport = AnalyticsReport & {
637
+ interval: "hour" | "day";
638
+ totals: SearchTotals;
639
+ /** The same span just before `from`. */
640
+ previous: SearchTotals & {
641
+ from: string;
642
+ to: string;
643
+ };
644
+ /** Every hour or day of the window, the first one starting at or before `from`. */
645
+ series: {
646
+ t: string;
647
+ searches: number;
648
+ tracked: number;
649
+ noResults: number;
650
+ }[];
651
+ };
652
+ /** A top query: its clicks on results and their average place (from 1), or null when clicks aren't available. */
653
+ export type TopQuery = {
654
+ query: string;
655
+ searches: number;
656
+ noResults: number;
657
+ clicks: number | null;
658
+ averagePosition: number | null;
659
+ };
660
+ export type QueryCount = {
661
+ query: string;
662
+ searches: number;
663
+ };
664
+ export type QueryLists = {
665
+ top: TopQuery;
666
+ noResults: QueryCount;
667
+ noClicks: QueryCount;
668
+ };
669
+ export type QueriesReport<L extends keyof QueryLists = "top"> = AnalyticsReport & {
670
+ list: L;
671
+ queries: QueryLists[L][];
672
+ };
673
+ export type FiltersReport = AnalyticsReport & {
674
+ /** Searches whose filters and sort were recorded. */
675
+ searches: number;
676
+ /** Each filter field with the searches that filtered on it, up to 100. */
677
+ filters: {
678
+ field: string;
679
+ searches: number;
680
+ }[];
681
+ /** Each sort with its searches; `sort` is null for searches that named none, which use the index's default. */
682
+ sorts: {
683
+ sort: string | null;
684
+ searches: number;
685
+ }[];
686
+ };
687
+ export type EventType = "view" | "click" | "addToCart" | "purchase" | "cancel";
688
+ export type EventsReport = AnalyticsReport & {
689
+ interval: "hour" | "day";
690
+ /** Accepted events of each type, by source. */
691
+ types: {
692
+ type: EventType;
693
+ browser: number;
694
+ server: number;
695
+ }[];
696
+ /** Orders accepted, and of them those stored, which Bought together and the order counts use. */
697
+ orders: {
698
+ accepted: number;
699
+ stored: number;
700
+ };
701
+ /** Events from crawlers and scripts, which are dropped. */
702
+ bots: number;
703
+ /** Refused events, by why. */
704
+ refused: {
705
+ reason: string;
706
+ events: number;
707
+ }[];
708
+ series: ({
709
+ t: string;
710
+ } & Record<EventType, number>)[];
711
+ };
712
+ /** A product's counts. `viewers` is how many visitor IDs viewed it, or null when the index's views carry
713
+ * none. `searchClicks` is clicks on it outside recommendations: in search results and product lists. */
714
+ export type ProductCount = {
715
+ objectID: string;
716
+ views: number;
717
+ viewers: number | null;
718
+ searchClicks: number;
719
+ carts: number;
720
+ orders: number;
721
+ };
722
+ export type ListedProduct = ProductCount & {
723
+ title: string;
724
+ image: string | null;
725
+ };
726
+ /** Product reports are exact and about 2 minutes behind; `computedAt` is when they were counted, up to 5
727
+ * minutes ago. `orders` is the orders stored in the window. */
728
+ export type ProductListsReport = AnalyticsReport & {
729
+ computedAt: string;
730
+ orders: number;
731
+ /** The most viewed, most added to carts, and most ordered products, by views. */
732
+ products: ListedProduct[];
733
+ /** Products with 50 or more views, the fewest orders per view first; null without orders. */
734
+ viewedNotBought: ListedProduct[] | null;
735
+ };
736
+ export type ProductCountsReport = AnalyticsReport & {
737
+ computedAt: string;
738
+ orders: number;
739
+ /** false when more than 50,000 products had events, and only the most viewed are listed. */
740
+ complete: boolean;
741
+ products: ProductCount[];
742
+ };
743
+ export type ProductReport = AnalyticsReport & {
744
+ objectID: string;
745
+ interval: "hour" | "day";
746
+ totals: Omit<ProductCount, "objectID">;
747
+ series: ({
748
+ t: string;
749
+ } & Omit<ProductCount, "objectID">)[];
750
+ /** The queries whose results it was clicked in, the most clicks first, up to 20. */
751
+ searchedFor: {
752
+ query: string;
753
+ clicks: number;
754
+ }[];
755
+ /** Clicks on it in each recommendation list. */
756
+ recommendationClicks: {
757
+ goesWith: number;
758
+ similar: number;
759
+ together: number;
760
+ };
761
+ };
477
762
  export declare class FindlaneError extends Error {
478
763
  status: number;
479
764
  constructor(status: number, message: string);
@@ -503,6 +788,18 @@ export declare class SearchServerClient {
503
788
  * same request (its q, filters, and fields matter). Needs the `didYouMean` setting; not counted as
504
789
  * a search. */
505
790
  suggest: (query: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<DidYouMean | null>;
791
+ /** Query suggestions: whole searches to offer as a shopper types `q`, the best first; with `q` empty, the
792
+ * index's popular searches. The API key needs the `search` scope; not counted as searches. `limit`: 1 to
793
+ * 10, 5 by default. */
794
+ querySuggestions: (query: {
795
+ q: string;
796
+ limit?: number;
797
+ }, options?: AbortSignal | RequestOptions) => Promise<QuerySuggestion[]>;
798
+ /** The index's popular searches, for an empty search box, with the owner's pinned searches first;
799
+ * empty until the index has enough searches. The list changes daily. */
800
+ popularSearches: (options?: {
801
+ limit?: number;
802
+ }, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<QuerySuggestion[]>;
506
803
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
507
804
  * The API key needs the `search` scope; not counted as a search. */
508
805
  recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
@@ -532,6 +829,39 @@ export declare class SearchServerClient {
532
829
  * and its visitor. Answers with the API's response. */
533
830
  relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
534
831
  };
832
+ /** The index's search analytics. The API key needs the `analytics` scope, and works only from a server:
833
+ * requests from web pages are refused. */
834
+ analytics: {
835
+ /** Searches, searches without results and response times, beside the same span before, and a series per
836
+ * hour (up to 7 days, by default) or day. */
837
+ searches: (options?: AnalyticsOptions & {
838
+ interval?: "hour" | "day";
839
+ }) => Promise<SearchesReport>;
840
+ /** Queries, the most searched first: `top` (by default) with their clicks, `noResults`, or `noClicks`, which
841
+ * found something nobody clicked. `limit` is 1 to 1,000, 100 by default. */
842
+ queries: <L extends keyof QueryLists = "top">(options?: AnalyticsOptions & {
843
+ list?: L;
844
+ limit?: number;
845
+ }) => Promise<QueriesReport<L>>;
846
+ /** The filter fields and sorts searches used. */
847
+ filters: (options?: AnalyticsOptions) => Promise<FiltersReport>;
848
+ /** Events of each type over time and by source, orders stored, and refused and bot events. */
849
+ events: (options?: AnalyticsOptions & {
850
+ interval?: "hour" | "day";
851
+ }) => Promise<EventsReport>;
852
+ /** The most viewed, most added to carts and most ordered products, with titles and images, and those
853
+ * viewed but rarely bought. Exact, from every event kept (13 months, from 9 October 2026). */
854
+ products: (options?: AnalyticsOptions) => Promise<ProductListsReport>;
855
+ /** Every product with events or orders in the window, the most viewed first, up to 50,000: views,
856
+ * viewers, clicks, add-to-carts and orders. Exact, like `products`. */
857
+ productCounts: (options?: AnalyticsOptions) => Promise<ProductCountsReport>;
858
+ /** One product's views, viewers, clicks, add-to-carts and orders per hour or day, the queries it was
859
+ * clicked from, and its clicks in recommendations. Fast, for a product page in an admin: views and
860
+ * clicks are estimates when `sampled`, and an add-to-cart of several products counts toward the first. */
861
+ product: (objectID: string, options?: AnalyticsOptions & {
862
+ interval?: "hour" | "day";
863
+ }) => Promise<ProductReport>;
864
+ };
535
865
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
536
866
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
537
867
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
@@ -545,6 +875,16 @@ export declare class SearchServerClient {
545
875
  delete: (objectID: string, signal?: AbortSignal) => Promise<WriteReceipt>;
546
876
  getSettings: (signal?: AbortSignal) => Promise<CurrentSettings>;
547
877
  replaceSettings: (settings: IndexSettings, signal?: AbortSignal) => Promise<SavedSettings>;
878
+ /** Compares settings with the index's own without saving them: its search tests, `queries`, and its 100
879
+ * most searched queries of the last 30 days, each searched with both, for up to 20 seconds. Takes the
880
+ * settings `replaceSettings` does, or only those that change; `{}` runs the tests with the current
881
+ * settings. A change to what the index stores (new searchable, facet, filter, sort or location fields,
882
+ * or normalization) throws a 409: save it first. Not counted as searches. */
883
+ compareSettings: (settings: Partial<IndexSettings>, options?: CompareOptions) => Promise<SettingsComparison>;
884
+ /** The index's search tests. */
885
+ getSearchTests: (signal?: AbortSignal) => Promise<SearchTest[]>;
886
+ /** Replaces the index's search tests, up to 200; `[]` deletes them. */
887
+ setSearchTests: (tests: SearchTest[], signal?: AbortSignal) => Promise<SearchTest[]>;
548
888
  /** Waits until no settings rebuild is running; throws if it failed. */
549
889
  waitForSettings: (options?: {
550
890
  timeoutMs?: number;
package/dist/index.js CHANGED
@@ -21,6 +21,16 @@ class StagedReplacementError extends Error {
21
21
  this.name = "StagedReplacementError";
22
22
  }
23
23
  }
24
+ function analyticsQuery(options) {
25
+ const params = new URLSearchParams;
26
+ for (const name of ["range", "from", "to", "interval", "source", "list", "limit"]) {
27
+ const value = options[name];
28
+ if (value !== undefined)
29
+ params.set(name, value instanceof Date ? value.toISOString() : String(value));
30
+ }
31
+ const query = params.toString();
32
+ return query ? `?${query}` : "";
33
+ }
24
34
  var eventsPerRequest = 200;
25
35
  var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
26
36
  var relayedBytes = 256 * 1024;
@@ -176,6 +186,7 @@ class SearchServerClient {
176
186
  await flush();
177
187
  return result;
178
188
  };
189
+ const report = (name2, options) => retry(() => request(`/analytics/${name2}${analyticsQuery(options)}`, "GET", undefined, options.signal), options.signal);
179
190
  const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
180
191
  const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
181
192
  const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
@@ -196,6 +207,14 @@ class SearchServerClient {
196
207
  const { signal, visitor } = requestOptions(options);
197
208
  return (await request("/suggest", "POST", query, signal, visitorHeaders(visitor))).didYouMean;
198
209
  },
210
+ querySuggestions: async (query, options) => {
211
+ const { signal } = requestOptions(options);
212
+ return (await request("/query-suggestions", "POST", query, signal)).suggestions;
213
+ },
214
+ popularSearches: async (options = {}, requestOptionsOrSignal) => {
215
+ const { signal } = requestOptions(requestOptionsOrSignal);
216
+ return (await request("/query-suggestions", "POST", { q: "", limit: options.limit ?? 6 }, signal)).suggestions;
217
+ },
199
218
  recommendations: (objectID, options = {}, requestOptionsOrSignal) => {
200
219
  const { signal, visitor } = requestOptions(requestOptionsOrSignal);
201
220
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
@@ -233,6 +252,15 @@ class SearchServerClient {
233
252
  },
234
253
  relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
235
254
  },
255
+ analytics: {
256
+ searches: (options = {}) => report("searches", options),
257
+ queries: (options = {}) => report("queries", options),
258
+ filters: (options = {}) => report("filters", options),
259
+ events: (options = {}) => report("events", options),
260
+ products: (options = {}) => report("products", options),
261
+ productCounts: (options = {}) => report("product-counts", options),
262
+ product: (objectID, options = {}) => report(`products/${encodeURIComponent(objectID)}`, options)
263
+ },
236
264
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
237
265
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
238
266
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
@@ -291,6 +319,16 @@ class SearchServerClient {
291
319
  const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
292
320
  return request("/config", "PUT", body, signal);
293
321
  },
322
+ compareSettings: (settings, options = {}) => {
323
+ const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
324
+ return request("/compare", "POST", {
325
+ settings: body,
326
+ ...options.queries ? { queries: options.queries } : {},
327
+ ...options.searched === false ? { searched: false } : {}
328
+ }, options.signal);
329
+ },
330
+ getSearchTests: async (signal) => (await request("/tests", "GET", undefined, signal)).tests,
331
+ setSearchTests: async (tests, signal) => (await request("/tests", "PUT", { tests }, signal)).tests,
294
332
  waitForSettings: async (options = {}) => {
295
333
  const deadline = Date.now() + (options.timeoutMs ?? 30 * 60000);
296
334
  while (true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/server",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",