@findlane/server 0.8.0 → 0.10.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
@@ -67,6 +67,7 @@ class StagedReplacementError extends Error {
67
67
  this.name = "StagedReplacementError";
68
68
  }
69
69
  }
70
+ var eventsPerRequest = 200;
70
71
  var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
71
72
  var visitorHeaders = (visitor) => visitor?.ip ? {
72
73
  "findlane-visitor-ip": visitor.ip.trim(),
@@ -136,6 +137,54 @@ class SearchServerClient {
136
137
  }
137
138
  }
138
139
  };
140
+ const postEvents = async (events, signal) => {
141
+ for (let attempt = 0;; attempt++) {
142
+ signal?.throwIfAborted();
143
+ let wait = 250 * 2 ** attempt;
144
+ try {
145
+ const response = await this.fetcher(this.baseUrl + path + "/events", {
146
+ method: "POST",
147
+ signal,
148
+ headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
149
+ body: JSON.stringify({ events })
150
+ });
151
+ const data = await response.json().catch(() => ({}));
152
+ if (response.ok)
153
+ return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
154
+ const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
155
+ if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
156
+ throw error;
157
+ const after = Number(response.headers.get("retry-after"));
158
+ if (after > 0)
159
+ wait = Math.min(after * 1000, 60000);
160
+ } catch (error) {
161
+ if (error instanceof FindlaneError || attempt >= 5 || signal?.aborted)
162
+ throw error;
163
+ }
164
+ await pause(wait, signal);
165
+ }
166
+ };
167
+ const sendEvents = async (events, signal) => {
168
+ const result = { accepted: 0, rejected: [] };
169
+ let batch = [];
170
+ let sent = 0;
171
+ const flush = async () => {
172
+ if (!batch.length)
173
+ return;
174
+ const answer = await postEvents(batch, signal);
175
+ result.accepted += answer.accepted;
176
+ result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
177
+ sent += batch.length;
178
+ batch = [];
179
+ };
180
+ for await (const event of events) {
181
+ batch.push(event);
182
+ if (batch.length >= eventsPerRequest)
183
+ await flush();
184
+ }
185
+ await flush();
186
+ return result;
187
+ };
139
188
  const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
140
189
  const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
141
190
  const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
@@ -160,6 +209,18 @@ class SearchServerClient {
160
209
  const { signal, visitor } = requestOptions(requestOptionsOrSignal);
161
210
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
162
211
  },
212
+ events: {
213
+ send: (events, signal) => sendEvents(events, signal),
214
+ importOrders: (orders, signal) => sendEvents(async function* () {
215
+ for await (const { timestamp, ...order } of orders)
216
+ yield {
217
+ type: "purchase",
218
+ ...order,
219
+ ...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
220
+ };
221
+ }(), signal),
222
+ cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal)
223
+ },
163
224
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
164
225
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
165
226
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
package/dist/index.d.cts CHANGED
@@ -189,14 +189,23 @@ export type RecommendationHit<T> = {
189
189
  score: number | null;
190
190
  record: T;
191
191
  };
192
+ /** A Bought together pick: `count` is the orders in the last year that had both products. */
193
+ export type TogetherHit<T> = RecommendationHit<T> & {
194
+ count: number;
195
+ };
192
196
  export type RecommendationsResponse<T> = {
193
197
  objectID: string;
194
- /** false until the product's lists are built; both lists are empty meanwhile. */
198
+ /** false until the product's lists are built; the lists are empty meanwhile. */
195
199
  ready: boolean;
196
200
  /** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
197
201
  goesWith: RecommendationHit<T>[];
198
202
  /** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
199
203
  similar: RecommendationHit<T>[];
204
+ /** Products bought in the same orders as this one, the most orders first, from the orders sent with
205
+ * `index.events`. Empty without orders. */
206
+ together?: TogetherHit<T>[];
207
+ /** What `together`'s counts are of. */
208
+ togetherBasis?: "orders";
200
209
  };
201
210
  /** A corrected query. `applied`: the results are already for `q` (a search's "Showing results for");
202
211
  * otherwise searching `q` finds results that the query didn't ("Did you mean"). `changes` lists each
@@ -254,6 +263,58 @@ export type RequestOptions = {
254
263
  signal?: AbortSignal;
255
264
  visitor?: Visitor;
256
265
  };
266
+ /** A product in a cart or an order. */
267
+ export type EventItem = {
268
+ objectID: string;
269
+ quantity?: number;
270
+ price?: number;
271
+ };
272
+ /** An event from a server; the API key needs the `events` or `write` scope. `timestamp` (ms since 1970)
273
+ * may be up to 400 days back, for history. Events don't name the shopper. */
274
+ export type ServerEvent = {
275
+ type: "view";
276
+ objectID: string;
277
+ timestamp?: number;
278
+ } | {
279
+ type: "click";
280
+ objectID: string;
281
+ position?: number;
282
+ query?: string;
283
+ list?: "goesWith" | "similar" | "together";
284
+ from?: string;
285
+ timestamp?: number;
286
+ } | {
287
+ type: "addToCart";
288
+ items: EventItem[];
289
+ currency?: string;
290
+ timestamp?: number;
291
+ } | {
292
+ type: "purchase";
293
+ orderID: string;
294
+ items: EventItem[];
295
+ currency?: string;
296
+ timestamp?: number;
297
+ }
298
+ /** Takes a cancelled or refunded order out of Bought together. */
299
+ | {
300
+ type: "cancel";
301
+ orderID: string;
302
+ };
303
+ /** A past order for importOrders; `timestamp` is when it was placed (ms, a Date, or an ISO string). */
304
+ export type Order = {
305
+ orderID: string;
306
+ items: EventItem[];
307
+ currency?: string;
308
+ timestamp?: number | Date | string;
309
+ };
310
+ /** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
311
+ export type EventsResult = {
312
+ accepted: number;
313
+ rejected: {
314
+ index: number;
315
+ reason: string;
316
+ }[];
317
+ };
257
318
  export declare class FindlaneError extends Error {
258
319
  status: number;
259
320
  constructor(status: number, message: string);
@@ -286,6 +347,17 @@ export declare class SearchServerClient {
286
347
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
287
348
  * The API key needs the `search` scope; not counted as a search. */
288
349
  recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
350
+ /** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
351
+ * scope. Orders count once each, however often they're sent. */
352
+ events: {
353
+ /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
354
+ send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
355
+ /** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
356
+ * cursor), 200 a request. */
357
+ importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
358
+ /** Takes a cancelled or refunded order out of Bought together. */
359
+ cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
360
+ };
289
361
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
290
362
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
291
363
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
package/dist/index.d.ts CHANGED
@@ -189,14 +189,23 @@ export type RecommendationHit<T> = {
189
189
  score: number | null;
190
190
  record: T;
191
191
  };
192
+ /** A Bought together pick: `count` is the orders in the last year that had both products. */
193
+ export type TogetherHit<T> = RecommendationHit<T> & {
194
+ count: number;
195
+ };
192
196
  export type RecommendationsResponse<T> = {
193
197
  objectID: string;
194
- /** false until the product's lists are built; both lists are empty meanwhile. */
198
+ /** false until the product's lists are built; the lists are empty meanwhile. */
195
199
  ready: boolean;
196
200
  /** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
197
201
  goesWith: RecommendationHit<T>[];
198
202
  /** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
199
203
  similar: RecommendationHit<T>[];
204
+ /** Products bought in the same orders as this one, the most orders first, from the orders sent with
205
+ * `index.events`. Empty without orders. */
206
+ together?: TogetherHit<T>[];
207
+ /** What `together`'s counts are of. */
208
+ togetherBasis?: "orders";
200
209
  };
201
210
  /** A corrected query. `applied`: the results are already for `q` (a search's "Showing results for");
202
211
  * otherwise searching `q` finds results that the query didn't ("Did you mean"). `changes` lists each
@@ -254,6 +263,58 @@ export type RequestOptions = {
254
263
  signal?: AbortSignal;
255
264
  visitor?: Visitor;
256
265
  };
266
+ /** A product in a cart or an order. */
267
+ export type EventItem = {
268
+ objectID: string;
269
+ quantity?: number;
270
+ price?: number;
271
+ };
272
+ /** An event from a server; the API key needs the `events` or `write` scope. `timestamp` (ms since 1970)
273
+ * may be up to 400 days back, for history. Events don't name the shopper. */
274
+ export type ServerEvent = {
275
+ type: "view";
276
+ objectID: string;
277
+ timestamp?: number;
278
+ } | {
279
+ type: "click";
280
+ objectID: string;
281
+ position?: number;
282
+ query?: string;
283
+ list?: "goesWith" | "similar" | "together";
284
+ from?: string;
285
+ timestamp?: number;
286
+ } | {
287
+ type: "addToCart";
288
+ items: EventItem[];
289
+ currency?: string;
290
+ timestamp?: number;
291
+ } | {
292
+ type: "purchase";
293
+ orderID: string;
294
+ items: EventItem[];
295
+ currency?: string;
296
+ timestamp?: number;
297
+ }
298
+ /** Takes a cancelled or refunded order out of Bought together. */
299
+ | {
300
+ type: "cancel";
301
+ orderID: string;
302
+ };
303
+ /** A past order for importOrders; `timestamp` is when it was placed (ms, a Date, or an ISO string). */
304
+ export type Order = {
305
+ orderID: string;
306
+ items: EventItem[];
307
+ currency?: string;
308
+ timestamp?: number | Date | string;
309
+ };
310
+ /** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
311
+ export type EventsResult = {
312
+ accepted: number;
313
+ rejected: {
314
+ index: number;
315
+ reason: string;
316
+ }[];
317
+ };
257
318
  export declare class FindlaneError extends Error {
258
319
  status: number;
259
320
  constructor(status: number, message: string);
@@ -286,6 +347,17 @@ export declare class SearchServerClient {
286
347
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
287
348
  * The API key needs the `search` scope; not counted as a search. */
288
349
  recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
350
+ /** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
351
+ * scope. Orders count once each, however often they're sent. */
352
+ events: {
353
+ /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
354
+ send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
355
+ /** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
356
+ * cursor), 200 a request. */
357
+ importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
358
+ /** Takes a cancelled or refunded order out of Bought together. */
359
+ cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
360
+ };
289
361
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
290
362
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
291
363
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ class StagedReplacementError extends Error {
21
21
  this.name = "StagedReplacementError";
22
22
  }
23
23
  }
24
+ var eventsPerRequest = 200;
24
25
  var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
25
26
  var visitorHeaders = (visitor) => visitor?.ip ? {
26
27
  "findlane-visitor-ip": visitor.ip.trim(),
@@ -90,6 +91,54 @@ class SearchServerClient {
90
91
  }
91
92
  }
92
93
  };
94
+ const postEvents = async (events, signal) => {
95
+ for (let attempt = 0;; attempt++) {
96
+ signal?.throwIfAborted();
97
+ let wait = 250 * 2 ** attempt;
98
+ try {
99
+ const response = await this.fetcher(this.baseUrl + path + "/events", {
100
+ method: "POST",
101
+ signal,
102
+ headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
103
+ body: JSON.stringify({ events })
104
+ });
105
+ const data = await response.json().catch(() => ({}));
106
+ if (response.ok)
107
+ return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
108
+ const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
109
+ if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
110
+ throw error;
111
+ const after = Number(response.headers.get("retry-after"));
112
+ if (after > 0)
113
+ wait = Math.min(after * 1000, 60000);
114
+ } catch (error) {
115
+ if (error instanceof FindlaneError || attempt >= 5 || signal?.aborted)
116
+ throw error;
117
+ }
118
+ await pause(wait, signal);
119
+ }
120
+ };
121
+ const sendEvents = async (events, signal) => {
122
+ const result = { accepted: 0, rejected: [] };
123
+ let batch = [];
124
+ let sent = 0;
125
+ const flush = async () => {
126
+ if (!batch.length)
127
+ return;
128
+ const answer = await postEvents(batch, signal);
129
+ result.accepted += answer.accepted;
130
+ result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
131
+ sent += batch.length;
132
+ batch = [];
133
+ };
134
+ for await (const event of events) {
135
+ batch.push(event);
136
+ if (batch.length >= eventsPerRequest)
137
+ await flush();
138
+ }
139
+ await flush();
140
+ return result;
141
+ };
93
142
  const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
94
143
  const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
95
144
  const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
@@ -114,6 +163,18 @@ class SearchServerClient {
114
163
  const { signal, visitor } = requestOptions(requestOptionsOrSignal);
115
164
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
116
165
  },
166
+ events: {
167
+ send: (events, signal) => sendEvents(events, signal),
168
+ importOrders: (orders, signal) => sendEvents(async function* () {
169
+ for await (const { timestamp, ...order } of orders)
170
+ yield {
171
+ type: "purchase",
172
+ ...order,
173
+ ...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
174
+ };
175
+ }(), signal),
176
+ cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal)
177
+ },
117
178
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
118
179
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
119
180
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/server",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",