@findlane/server 0.14.0 → 0.15.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
@@ -55,11 +55,14 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
55
55
  | `getRecord(objectID)` | A record and its version. |
56
56
  | `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
57
  | `getSettings()`, `replaceSettings(settings)`, `waitForSettings()` | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, languages, typo tolerance, and text normalization. |
58
+ | `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. |
59
+ | `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
60
  | `getIndexingStatus()`, `waitUntilIndexed(revision)` | Writes return an accepted revision and are searchable when accepted, so `waitUntilIndexed` returns at once. |
59
61
  | `getPublicSearchAccess()`, `setPublicSearchPolicy(policy)`, `usePublicIndexDefaults()` | Restrict what browser search may read and query. |
60
62
  | `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
63
  | `events.forget(ids)` | Deletes the events sent with these visitor or visit IDs, within a week. |
62
64
  | `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). |
65
+ | `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
66
 
64
67
  ### Settings
65
68
 
@@ -72,6 +75,14 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
72
75
 
73
76
  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
77
 
78
+ 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:
79
+
80
+ ```ts
81
+ const comparison = await products.compareSettings({ rules: nextRules })
82
+ const failing = comparison.tests.filter((test) => test.before && !test.after)
83
+ if (!failing.length) await products.replaceSettings({ ...(await products.getSettings()), rules: nextRules })
84
+ ```
85
+
75
86
  ### Browser search
76
87
 
77
88
  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);
@@ -279,6 +290,15 @@ class SearchServerClient {
279
290
  },
280
291
  relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
281
292
  },
293
+ analytics: {
294
+ searches: (options = {}) => report("searches", options),
295
+ queries: (options = {}) => report("queries", options),
296
+ filters: (options = {}) => report("filters", options),
297
+ events: (options = {}) => report("events", options),
298
+ products: (options = {}) => report("products", options),
299
+ productCounts: (options = {}) => report("product-counts", options),
300
+ product: (objectID, options = {}) => report(`products/${encodeURIComponent(objectID)}`, options)
301
+ },
282
302
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
283
303
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
284
304
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
@@ -337,6 +357,16 @@ class SearchServerClient {
337
357
  const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
338
358
  return request("/config", "PUT", body, signal);
339
359
  },
360
+ compareSettings: (settings, options = {}) => {
361
+ const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
362
+ return request("/compare", "POST", {
363
+ settings: body,
364
+ ...options.queries ? { queries: options.queries } : {},
365
+ ...options.searched === false ? { searched: false } : {}
366
+ }, options.signal);
367
+ },
368
+ getSearchTests: async (signal) => (await request("/tests", "GET", undefined, signal)).tests,
369
+ setSearchTests: async (tests, signal) => (await request("/tests", "PUT", { tests }, signal)).tests,
340
370
  waitForSettings: async (options = {}) => {
341
371
  const deadline = Date.now() + (options.timeoutMs ?? 30 * 60000);
342
372
  while (true) {
package/dist/index.d.cts CHANGED
@@ -136,6 +136,101 @@ export type SavedSettings = IndexSettings & {
136
136
  rebuilt: boolean;
137
137
  rebuild: SettingsRebuild | null;
138
138
  };
139
+ /** What a search test expects of its query's results: a record among the first `inTop` results (1 to
140
+ * 20), a record not among the first `notInTop`, or at least `minResults` results (1 to 10,000). */
141
+ export type TestExpectation = {
142
+ objectID: string;
143
+ inTop: number;
144
+ } | {
145
+ objectID: string;
146
+ notInTop: number;
147
+ } | {
148
+ minResults: number;
149
+ };
150
+ /** A search that must keep working, saved apart from the settings, which `compareSettings` checks: a query
151
+ * (empty browses), its filters on facet, filter or sort fields, and what its results should hold. IDs are
152
+ * 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, unique in the index; notes up to 200 characters. */
153
+ export type SearchTest = {
154
+ id: string;
155
+ q: string;
156
+ filters?: SearchRequest["filters"];
157
+ expect: TestExpectation;
158
+ note?: string;
159
+ };
160
+ /** How many compared queries changed in a way, and their searches in the last 30 days. */
161
+ export type CompareCount = {
162
+ queries: number;
163
+ searches: number;
164
+ };
165
+ export type ComparedHit = {
166
+ objectID: string;
167
+ title: string;
168
+ };
169
+ /** Proposed settings compared with the index's own, without saving them (`compareSettings`). */
170
+ export type SettingsComparison = {
171
+ /** The queries compared, and their searches in the last 30 days. */
172
+ compared: number;
173
+ searches: number;
174
+ /** Time (20 seconds) ran out before every query was compared. */
175
+ truncated: boolean;
176
+ summary: {
177
+ topChanged: CompareCount;
178
+ moreResults: CompareCount;
179
+ fewerResults: CompareCount;
180
+ nowNothing: CompareCount;
181
+ nowSomething: CompareCount;
182
+ };
183
+ /** Every search test: whether it passes with the current settings (`before`) and the proposed ones
184
+ * (`after`), or `skipped` when time ran out first, or the search's `error`. */
185
+ tests: (Omit<SearchTest, "note"> & {
186
+ before?: boolean;
187
+ after?: boolean;
188
+ skipped?: true;
189
+ error?: string;
190
+ })[];
191
+ /** Up to 50 queries whose top 10 or total changed, the tests' queries first, then the most searched: the
192
+ * totals and first 5 results with each, and the results that left, joined, and moved within the top 10.
193
+ * Places are in the first 20 results; null is outside them. */
194
+ changes: {
195
+ q: string;
196
+ filters?: SearchRequest["filters"];
197
+ searches: number;
198
+ before: {
199
+ total: number;
200
+ top: ComparedHit[];
201
+ };
202
+ after: {
203
+ total: number;
204
+ top: ComparedHit[];
205
+ };
206
+ gone: (ComparedHit & {
207
+ from: number;
208
+ to: number | null;
209
+ })[];
210
+ added: (ComparedHit & {
211
+ from: number | null;
212
+ to: number;
213
+ })[];
214
+ moved: (ComparedHit & {
215
+ from: number;
216
+ to: number;
217
+ })[];
218
+ }[];
219
+ errors: {
220
+ q: string;
221
+ error: string;
222
+ }[];
223
+ };
224
+ export type CompareOptions = {
225
+ /** More queries to compare, such as those a change is for: text, or `{ q, filters }`. Up to 200. */
226
+ queries?: (string | {
227
+ q: string;
228
+ filters?: SearchRequest["filters"];
229
+ })[];
230
+ /** false leaves out the index's 100 most searched queries of the last 30 days. */
231
+ searched?: boolean;
232
+ signal?: AbortSignal;
233
+ };
139
234
  export type PublicSearchPolicy = {
140
235
  returnFields: string[];
141
236
  searchFields: string[];
@@ -474,6 +569,166 @@ export type EventOptions = {
474
569
  visitorId?: string;
475
570
  visitId?: string;
476
571
  };
572
+ /** An analytics window, in UTC: `range` (7d by default), or `from` and `to`. A date as `from` is the start of
573
+ * that day, and as `to` includes the whole day; `to` is now by default. Analytics go back 90 days: a window
574
+ * starting earlier starts then, and the report's `from` says so. */
575
+ export type AnalyticsOptions = {
576
+ range?: "24h" | "7d" | "30d" | "90d";
577
+ from?: string | Date;
578
+ to?: string | Date;
579
+ /** Searches from browsers (browser search), from servers (API keys), or both, by default. */
580
+ source?: "all" | "browser" | "server";
581
+ signal?: AbortSignal;
582
+ };
583
+ /** What every analytics report starts with. `sampled`: the counts are estimates, because Analytics Engine,
584
+ * which keeps them, sampled some of them; totals and lists stay close, small counts less so. */
585
+ export type AnalyticsReport = {
586
+ index: string;
587
+ from: string;
588
+ to: string;
589
+ source: "all" | "browser" | "server";
590
+ sampled: boolean;
591
+ };
592
+ export type SearchTotals = {
593
+ searches: number;
594
+ /** Searches whose query and result count were recorded: the base for `noResultRate`. */
595
+ tracked: number;
596
+ noResults: number;
597
+ /** `noResults` of `tracked`, 0 to 1; null without tracked searches. */
598
+ noResultRate: number | null;
599
+ browser: number;
600
+ server: number;
601
+ uniqueQueries: number;
602
+ /** Response times at the API, in ms. */
603
+ medianMs: number;
604
+ p95Ms: number;
605
+ };
606
+ export type SearchesReport = AnalyticsReport & {
607
+ interval: "hour" | "day";
608
+ totals: SearchTotals;
609
+ /** The same span just before `from`. */
610
+ previous: SearchTotals & {
611
+ from: string;
612
+ to: string;
613
+ };
614
+ /** Every hour or day of the window, the first one starting at or before `from`. */
615
+ series: {
616
+ t: string;
617
+ searches: number;
618
+ tracked: number;
619
+ noResults: number;
620
+ }[];
621
+ };
622
+ /** A top query: its clicks on results and their average place (from 1), or null when clicks aren't available. */
623
+ export type TopQuery = {
624
+ query: string;
625
+ searches: number;
626
+ noResults: number;
627
+ clicks: number | null;
628
+ averagePosition: number | null;
629
+ };
630
+ export type QueryCount = {
631
+ query: string;
632
+ searches: number;
633
+ };
634
+ export type QueryLists = {
635
+ top: TopQuery;
636
+ noResults: QueryCount;
637
+ noClicks: QueryCount;
638
+ };
639
+ export type QueriesReport<L extends keyof QueryLists = "top"> = AnalyticsReport & {
640
+ list: L;
641
+ queries: QueryLists[L][];
642
+ };
643
+ export type FiltersReport = AnalyticsReport & {
644
+ /** Searches whose filters and sort were recorded. */
645
+ searches: number;
646
+ /** Each filter field with the searches that filtered on it, up to 100. */
647
+ filters: {
648
+ field: string;
649
+ searches: number;
650
+ }[];
651
+ /** Each sort with its searches; `sort` is null for searches that named none, which use the index's default. */
652
+ sorts: {
653
+ sort: string | null;
654
+ searches: number;
655
+ }[];
656
+ };
657
+ export type EventType = "view" | "click" | "addToCart" | "purchase" | "cancel";
658
+ export type EventsReport = AnalyticsReport & {
659
+ interval: "hour" | "day";
660
+ /** Accepted events of each type, by source. */
661
+ types: {
662
+ type: EventType;
663
+ browser: number;
664
+ server: number;
665
+ }[];
666
+ /** Orders accepted, and of them those stored, which Bought together and the order counts use. */
667
+ orders: {
668
+ accepted: number;
669
+ stored: number;
670
+ };
671
+ /** Events from crawlers and scripts, which are dropped. */
672
+ bots: number;
673
+ /** Refused events, by why. */
674
+ refused: {
675
+ reason: string;
676
+ events: number;
677
+ }[];
678
+ series: ({
679
+ t: string;
680
+ } & Record<EventType, number>)[];
681
+ };
682
+ /** A product's counts. `viewers` is how many visitor IDs viewed it, or null when the index's views carry
683
+ * none. `searchClicks` is clicks on it outside recommendations: in search results and product lists. */
684
+ export type ProductCount = {
685
+ objectID: string;
686
+ views: number;
687
+ viewers: number | null;
688
+ searchClicks: number;
689
+ carts: number;
690
+ orders: number;
691
+ };
692
+ export type ListedProduct = ProductCount & {
693
+ title: string;
694
+ image: string | null;
695
+ };
696
+ /** Product reports are exact and about 2 minutes behind; `computedAt` is when they were counted, up to 5
697
+ * minutes ago. `orders` is the orders stored in the window. */
698
+ export type ProductListsReport = AnalyticsReport & {
699
+ computedAt: string;
700
+ orders: number;
701
+ /** The most viewed, most added to carts, and most ordered products, by views. */
702
+ products: ListedProduct[];
703
+ /** Products with 50 or more views, the fewest orders per view first; null without orders. */
704
+ viewedNotBought: ListedProduct[] | null;
705
+ };
706
+ export type ProductCountsReport = AnalyticsReport & {
707
+ computedAt: string;
708
+ orders: number;
709
+ /** false when more than 50,000 products had events, and only the most viewed are listed. */
710
+ complete: boolean;
711
+ products: ProductCount[];
712
+ };
713
+ export type ProductReport = AnalyticsReport & {
714
+ objectID: string;
715
+ interval: "hour" | "day";
716
+ totals: Omit<ProductCount, "objectID">;
717
+ series: ({
718
+ t: string;
719
+ } & Omit<ProductCount, "objectID">)[];
720
+ /** The queries whose results it was clicked in, the most clicks first, up to 20. */
721
+ searchedFor: {
722
+ query: string;
723
+ clicks: number;
724
+ }[];
725
+ /** Clicks on it in each recommendation list. */
726
+ recommendationClicks: {
727
+ goesWith: number;
728
+ similar: number;
729
+ together: number;
730
+ };
731
+ };
477
732
  export declare class FindlaneError extends Error {
478
733
  status: number;
479
734
  constructor(status: number, message: string);
@@ -532,6 +787,39 @@ export declare class SearchServerClient {
532
787
  * and its visitor. Answers with the API's response. */
533
788
  relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
534
789
  };
790
+ /** The index's search analytics. The API key needs the `analytics` scope, and works only from a server:
791
+ * requests from web pages are refused. */
792
+ analytics: {
793
+ /** Searches, searches without results and response times, beside the same span before, and a series per
794
+ * hour (up to 7 days, by default) or day. */
795
+ searches: (options?: AnalyticsOptions & {
796
+ interval?: "hour" | "day";
797
+ }) => Promise<SearchesReport>;
798
+ /** Queries, the most searched first: `top` (by default) with their clicks, `noResults`, or `noClicks`, which
799
+ * found something nobody clicked. `limit` is 1 to 1,000, 100 by default. */
800
+ queries: <L extends keyof QueryLists = "top">(options?: AnalyticsOptions & {
801
+ list?: L;
802
+ limit?: number;
803
+ }) => Promise<QueriesReport<L>>;
804
+ /** The filter fields and sorts searches used. */
805
+ filters: (options?: AnalyticsOptions) => Promise<FiltersReport>;
806
+ /** Events of each type over time and by source, orders stored, and refused and bot events. */
807
+ events: (options?: AnalyticsOptions & {
808
+ interval?: "hour" | "day";
809
+ }) => Promise<EventsReport>;
810
+ /** The most viewed, most added to carts and most ordered products, with titles and images, and those
811
+ * viewed but rarely bought. Exact, from every event kept (13 months, from 9 October 2026). */
812
+ products: (options?: AnalyticsOptions) => Promise<ProductListsReport>;
813
+ /** Every product with events or orders in the window, the most viewed first, up to 50,000: views,
814
+ * viewers, clicks, add-to-carts and orders. Exact, like `products`. */
815
+ productCounts: (options?: AnalyticsOptions) => Promise<ProductCountsReport>;
816
+ /** One product's views, viewers, clicks, add-to-carts and orders per hour or day, the queries it was
817
+ * clicked from, and its clicks in recommendations. Fast, for a product page in an admin: views and
818
+ * clicks are estimates when `sampled`, and an add-to-cart of several products counts toward the first. */
819
+ product: (objectID: string, options?: AnalyticsOptions & {
820
+ interval?: "hour" | "day";
821
+ }) => Promise<ProductReport>;
822
+ };
535
823
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
536
824
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
537
825
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
@@ -545,6 +833,16 @@ export declare class SearchServerClient {
545
833
  delete: (objectID: string, signal?: AbortSignal) => Promise<WriteReceipt>;
546
834
  getSettings: (signal?: AbortSignal) => Promise<CurrentSettings>;
547
835
  replaceSettings: (settings: IndexSettings, signal?: AbortSignal) => Promise<SavedSettings>;
836
+ /** Compares settings with the index's own without saving them: its search tests, `queries`, and its 100
837
+ * most searched queries of the last 30 days, each searched with both, for up to 20 seconds. Takes the
838
+ * settings `replaceSettings` does, or only those that change; `{}` runs the tests with the current
839
+ * settings. A change to what the index stores (new searchable, facet, filter, sort or location fields,
840
+ * or normalization) throws a 409: save it first. Not counted as searches. */
841
+ compareSettings: (settings: Partial<IndexSettings>, options?: CompareOptions) => Promise<SettingsComparison>;
842
+ /** The index's search tests. */
843
+ getSearchTests: (signal?: AbortSignal) => Promise<SearchTest[]>;
844
+ /** Replaces the index's search tests, up to 200; `[]` deletes them. */
845
+ setSearchTests: (tests: SearchTest[], signal?: AbortSignal) => Promise<SearchTest[]>;
548
846
  /** Waits until no settings rebuild is running; throws if it failed. */
549
847
  waitForSettings: (options?: {
550
848
  timeoutMs?: number;
package/dist/index.d.ts CHANGED
@@ -136,6 +136,101 @@ export type SavedSettings = IndexSettings & {
136
136
  rebuilt: boolean;
137
137
  rebuild: SettingsRebuild | null;
138
138
  };
139
+ /** What a search test expects of its query's results: a record among the first `inTop` results (1 to
140
+ * 20), a record not among the first `notInTop`, or at least `minResults` results (1 to 10,000). */
141
+ export type TestExpectation = {
142
+ objectID: string;
143
+ inTop: number;
144
+ } | {
145
+ objectID: string;
146
+ notInTop: number;
147
+ } | {
148
+ minResults: number;
149
+ };
150
+ /** A search that must keep working, saved apart from the settings, which `compareSettings` checks: a query
151
+ * (empty browses), its filters on facet, filter or sort fields, and what its results should hold. IDs are
152
+ * 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, unique in the index; notes up to 200 characters. */
153
+ export type SearchTest = {
154
+ id: string;
155
+ q: string;
156
+ filters?: SearchRequest["filters"];
157
+ expect: TestExpectation;
158
+ note?: string;
159
+ };
160
+ /** How many compared queries changed in a way, and their searches in the last 30 days. */
161
+ export type CompareCount = {
162
+ queries: number;
163
+ searches: number;
164
+ };
165
+ export type ComparedHit = {
166
+ objectID: string;
167
+ title: string;
168
+ };
169
+ /** Proposed settings compared with the index's own, without saving them (`compareSettings`). */
170
+ export type SettingsComparison = {
171
+ /** The queries compared, and their searches in the last 30 days. */
172
+ compared: number;
173
+ searches: number;
174
+ /** Time (20 seconds) ran out before every query was compared. */
175
+ truncated: boolean;
176
+ summary: {
177
+ topChanged: CompareCount;
178
+ moreResults: CompareCount;
179
+ fewerResults: CompareCount;
180
+ nowNothing: CompareCount;
181
+ nowSomething: CompareCount;
182
+ };
183
+ /** Every search test: whether it passes with the current settings (`before`) and the proposed ones
184
+ * (`after`), or `skipped` when time ran out first, or the search's `error`. */
185
+ tests: (Omit<SearchTest, "note"> & {
186
+ before?: boolean;
187
+ after?: boolean;
188
+ skipped?: true;
189
+ error?: string;
190
+ })[];
191
+ /** Up to 50 queries whose top 10 or total changed, the tests' queries first, then the most searched: the
192
+ * totals and first 5 results with each, and the results that left, joined, and moved within the top 10.
193
+ * Places are in the first 20 results; null is outside them. */
194
+ changes: {
195
+ q: string;
196
+ filters?: SearchRequest["filters"];
197
+ searches: number;
198
+ before: {
199
+ total: number;
200
+ top: ComparedHit[];
201
+ };
202
+ after: {
203
+ total: number;
204
+ top: ComparedHit[];
205
+ };
206
+ gone: (ComparedHit & {
207
+ from: number;
208
+ to: number | null;
209
+ })[];
210
+ added: (ComparedHit & {
211
+ from: number | null;
212
+ to: number;
213
+ })[];
214
+ moved: (ComparedHit & {
215
+ from: number;
216
+ to: number;
217
+ })[];
218
+ }[];
219
+ errors: {
220
+ q: string;
221
+ error: string;
222
+ }[];
223
+ };
224
+ export type CompareOptions = {
225
+ /** More queries to compare, such as those a change is for: text, or `{ q, filters }`. Up to 200. */
226
+ queries?: (string | {
227
+ q: string;
228
+ filters?: SearchRequest["filters"];
229
+ })[];
230
+ /** false leaves out the index's 100 most searched queries of the last 30 days. */
231
+ searched?: boolean;
232
+ signal?: AbortSignal;
233
+ };
139
234
  export type PublicSearchPolicy = {
140
235
  returnFields: string[];
141
236
  searchFields: string[];
@@ -474,6 +569,166 @@ export type EventOptions = {
474
569
  visitorId?: string;
475
570
  visitId?: string;
476
571
  };
572
+ /** An analytics window, in UTC: `range` (7d by default), or `from` and `to`. A date as `from` is the start of
573
+ * that day, and as `to` includes the whole day; `to` is now by default. Analytics go back 90 days: a window
574
+ * starting earlier starts then, and the report's `from` says so. */
575
+ export type AnalyticsOptions = {
576
+ range?: "24h" | "7d" | "30d" | "90d";
577
+ from?: string | Date;
578
+ to?: string | Date;
579
+ /** Searches from browsers (browser search), from servers (API keys), or both, by default. */
580
+ source?: "all" | "browser" | "server";
581
+ signal?: AbortSignal;
582
+ };
583
+ /** What every analytics report starts with. `sampled`: the counts are estimates, because Analytics Engine,
584
+ * which keeps them, sampled some of them; totals and lists stay close, small counts less so. */
585
+ export type AnalyticsReport = {
586
+ index: string;
587
+ from: string;
588
+ to: string;
589
+ source: "all" | "browser" | "server";
590
+ sampled: boolean;
591
+ };
592
+ export type SearchTotals = {
593
+ searches: number;
594
+ /** Searches whose query and result count were recorded: the base for `noResultRate`. */
595
+ tracked: number;
596
+ noResults: number;
597
+ /** `noResults` of `tracked`, 0 to 1; null without tracked searches. */
598
+ noResultRate: number | null;
599
+ browser: number;
600
+ server: number;
601
+ uniqueQueries: number;
602
+ /** Response times at the API, in ms. */
603
+ medianMs: number;
604
+ p95Ms: number;
605
+ };
606
+ export type SearchesReport = AnalyticsReport & {
607
+ interval: "hour" | "day";
608
+ totals: SearchTotals;
609
+ /** The same span just before `from`. */
610
+ previous: SearchTotals & {
611
+ from: string;
612
+ to: string;
613
+ };
614
+ /** Every hour or day of the window, the first one starting at or before `from`. */
615
+ series: {
616
+ t: string;
617
+ searches: number;
618
+ tracked: number;
619
+ noResults: number;
620
+ }[];
621
+ };
622
+ /** A top query: its clicks on results and their average place (from 1), or null when clicks aren't available. */
623
+ export type TopQuery = {
624
+ query: string;
625
+ searches: number;
626
+ noResults: number;
627
+ clicks: number | null;
628
+ averagePosition: number | null;
629
+ };
630
+ export type QueryCount = {
631
+ query: string;
632
+ searches: number;
633
+ };
634
+ export type QueryLists = {
635
+ top: TopQuery;
636
+ noResults: QueryCount;
637
+ noClicks: QueryCount;
638
+ };
639
+ export type QueriesReport<L extends keyof QueryLists = "top"> = AnalyticsReport & {
640
+ list: L;
641
+ queries: QueryLists[L][];
642
+ };
643
+ export type FiltersReport = AnalyticsReport & {
644
+ /** Searches whose filters and sort were recorded. */
645
+ searches: number;
646
+ /** Each filter field with the searches that filtered on it, up to 100. */
647
+ filters: {
648
+ field: string;
649
+ searches: number;
650
+ }[];
651
+ /** Each sort with its searches; `sort` is null for searches that named none, which use the index's default. */
652
+ sorts: {
653
+ sort: string | null;
654
+ searches: number;
655
+ }[];
656
+ };
657
+ export type EventType = "view" | "click" | "addToCart" | "purchase" | "cancel";
658
+ export type EventsReport = AnalyticsReport & {
659
+ interval: "hour" | "day";
660
+ /** Accepted events of each type, by source. */
661
+ types: {
662
+ type: EventType;
663
+ browser: number;
664
+ server: number;
665
+ }[];
666
+ /** Orders accepted, and of them those stored, which Bought together and the order counts use. */
667
+ orders: {
668
+ accepted: number;
669
+ stored: number;
670
+ };
671
+ /** Events from crawlers and scripts, which are dropped. */
672
+ bots: number;
673
+ /** Refused events, by why. */
674
+ refused: {
675
+ reason: string;
676
+ events: number;
677
+ }[];
678
+ series: ({
679
+ t: string;
680
+ } & Record<EventType, number>)[];
681
+ };
682
+ /** A product's counts. `viewers` is how many visitor IDs viewed it, or null when the index's views carry
683
+ * none. `searchClicks` is clicks on it outside recommendations: in search results and product lists. */
684
+ export type ProductCount = {
685
+ objectID: string;
686
+ views: number;
687
+ viewers: number | null;
688
+ searchClicks: number;
689
+ carts: number;
690
+ orders: number;
691
+ };
692
+ export type ListedProduct = ProductCount & {
693
+ title: string;
694
+ image: string | null;
695
+ };
696
+ /** Product reports are exact and about 2 minutes behind; `computedAt` is when they were counted, up to 5
697
+ * minutes ago. `orders` is the orders stored in the window. */
698
+ export type ProductListsReport = AnalyticsReport & {
699
+ computedAt: string;
700
+ orders: number;
701
+ /** The most viewed, most added to carts, and most ordered products, by views. */
702
+ products: ListedProduct[];
703
+ /** Products with 50 or more views, the fewest orders per view first; null without orders. */
704
+ viewedNotBought: ListedProduct[] | null;
705
+ };
706
+ export type ProductCountsReport = AnalyticsReport & {
707
+ computedAt: string;
708
+ orders: number;
709
+ /** false when more than 50,000 products had events, and only the most viewed are listed. */
710
+ complete: boolean;
711
+ products: ProductCount[];
712
+ };
713
+ export type ProductReport = AnalyticsReport & {
714
+ objectID: string;
715
+ interval: "hour" | "day";
716
+ totals: Omit<ProductCount, "objectID">;
717
+ series: ({
718
+ t: string;
719
+ } & Omit<ProductCount, "objectID">)[];
720
+ /** The queries whose results it was clicked in, the most clicks first, up to 20. */
721
+ searchedFor: {
722
+ query: string;
723
+ clicks: number;
724
+ }[];
725
+ /** Clicks on it in each recommendation list. */
726
+ recommendationClicks: {
727
+ goesWith: number;
728
+ similar: number;
729
+ together: number;
730
+ };
731
+ };
477
732
  export declare class FindlaneError extends Error {
478
733
  status: number;
479
734
  constructor(status: number, message: string);
@@ -532,6 +787,39 @@ export declare class SearchServerClient {
532
787
  * and its visitor. Answers with the API's response. */
533
788
  relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
534
789
  };
790
+ /** The index's search analytics. The API key needs the `analytics` scope, and works only from a server:
791
+ * requests from web pages are refused. */
792
+ analytics: {
793
+ /** Searches, searches without results and response times, beside the same span before, and a series per
794
+ * hour (up to 7 days, by default) or day. */
795
+ searches: (options?: AnalyticsOptions & {
796
+ interval?: "hour" | "day";
797
+ }) => Promise<SearchesReport>;
798
+ /** Queries, the most searched first: `top` (by default) with their clicks, `noResults`, or `noClicks`, which
799
+ * found something nobody clicked. `limit` is 1 to 1,000, 100 by default. */
800
+ queries: <L extends keyof QueryLists = "top">(options?: AnalyticsOptions & {
801
+ list?: L;
802
+ limit?: number;
803
+ }) => Promise<QueriesReport<L>>;
804
+ /** The filter fields and sorts searches used. */
805
+ filters: (options?: AnalyticsOptions) => Promise<FiltersReport>;
806
+ /** Events of each type over time and by source, orders stored, and refused and bot events. */
807
+ events: (options?: AnalyticsOptions & {
808
+ interval?: "hour" | "day";
809
+ }) => Promise<EventsReport>;
810
+ /** The most viewed, most added to carts and most ordered products, with titles and images, and those
811
+ * viewed but rarely bought. Exact, from every event kept (13 months, from 9 October 2026). */
812
+ products: (options?: AnalyticsOptions) => Promise<ProductListsReport>;
813
+ /** Every product with events or orders in the window, the most viewed first, up to 50,000: views,
814
+ * viewers, clicks, add-to-carts and orders. Exact, like `products`. */
815
+ productCounts: (options?: AnalyticsOptions) => Promise<ProductCountsReport>;
816
+ /** One product's views, viewers, clicks, add-to-carts and orders per hour or day, the queries it was
817
+ * clicked from, and its clicks in recommendations. Fast, for a product page in an admin: views and
818
+ * clicks are estimates when `sampled`, and an add-to-cart of several products counts toward the first. */
819
+ product: (objectID: string, options?: AnalyticsOptions & {
820
+ interval?: "hour" | "day";
821
+ }) => Promise<ProductReport>;
822
+ };
535
823
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
536
824
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
537
825
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
@@ -545,6 +833,16 @@ export declare class SearchServerClient {
545
833
  delete: (objectID: string, signal?: AbortSignal) => Promise<WriteReceipt>;
546
834
  getSettings: (signal?: AbortSignal) => Promise<CurrentSettings>;
547
835
  replaceSettings: (settings: IndexSettings, signal?: AbortSignal) => Promise<SavedSettings>;
836
+ /** Compares settings with the index's own without saving them: its search tests, `queries`, and its 100
837
+ * most searched queries of the last 30 days, each searched with both, for up to 20 seconds. Takes the
838
+ * settings `replaceSettings` does, or only those that change; `{}` runs the tests with the current
839
+ * settings. A change to what the index stores (new searchable, facet, filter, sort or location fields,
840
+ * or normalization) throws a 409: save it first. Not counted as searches. */
841
+ compareSettings: (settings: Partial<IndexSettings>, options?: CompareOptions) => Promise<SettingsComparison>;
842
+ /** The index's search tests. */
843
+ getSearchTests: (signal?: AbortSignal) => Promise<SearchTest[]>;
844
+ /** Replaces the index's search tests, up to 200; `[]` deletes them. */
845
+ setSearchTests: (tests: SearchTest[], signal?: AbortSignal) => Promise<SearchTest[]>;
548
846
  /** Waits until no settings rebuild is running; throws if it failed. */
549
847
  waitForSettings: (options?: {
550
848
  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);
@@ -233,6 +244,15 @@ class SearchServerClient {
233
244
  },
234
245
  relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
235
246
  },
247
+ analytics: {
248
+ searches: (options = {}) => report("searches", options),
249
+ queries: (options = {}) => report("queries", options),
250
+ filters: (options = {}) => report("filters", options),
251
+ events: (options = {}) => report("events", options),
252
+ products: (options = {}) => report("products", options),
253
+ productCounts: (options = {}) => report("product-counts", options),
254
+ product: (objectID, options = {}) => report(`products/${encodeURIComponent(objectID)}`, options)
255
+ },
236
256
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
237
257
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
238
258
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
@@ -291,6 +311,16 @@ class SearchServerClient {
291
311
  const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
292
312
  return request("/config", "PUT", body, signal);
293
313
  },
314
+ compareSettings: (settings, options = {}) => {
315
+ const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
316
+ return request("/compare", "POST", {
317
+ settings: body,
318
+ ...options.queries ? { queries: options.queries } : {},
319
+ ...options.searched === false ? { searched: false } : {}
320
+ }, options.signal);
321
+ },
322
+ getSearchTests: async (signal) => (await request("/tests", "GET", undefined, signal)).tests,
323
+ setSearchTests: async (tests, signal) => (await request("/tests", "PUT", { tests }, signal)).tests,
294
324
  waitForSettings: async (options = {}) => {
295
325
  const deadline = Date.now() + (options.timeoutMs ?? 30 * 60000);
296
326
  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.15.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",