@findlane/server 0.11.0 → 0.12.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
@@ -57,7 +57,8 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
57
57
  | `getSettings()`, `replaceSettings(settings)`, `waitForSettings()` | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, languages, typo tolerance, and text normalization. |
58
58
  | `getIndexingStatus()`, `waitUntilIndexed(revision)` | Writes return an accepted revision and are searchable when accepted, so `waitUntilIndexed` returns at once. |
59
59
  | `getPublicSearchAccess()`, `setPublicSearchPolicy(policy)`, `usePublicIndexDefaults()` | Restrict what browser search may read and query. |
60
- | `events.send(events)`, `events.importOrders(orders)`, `events.cancelOrder(orderID)` | Orders and other events for the index's Events page and Bought together; the key needs the `events` or `write` scope. |
60
+ | `events.send(events, { visitorId, visitId }?)`, `events.importOrders(orders)`, `events.cancelOrder(orderID)` | Orders and other events for the index's Events page and Bought together; the key needs the `events` or `write` scope. Your own anonymous visitor IDs link a shopper's events, when the index takes them. |
61
+ | `events.forget(ids)` | Deletes the events sent with these visitor or visit IDs, within a week. |
61
62
  | `events.proxy(request)`, `events.relay(body, visitor)` | A route on your own site for the browser SDK's events (its `eventsUrl`), which ad blockers let through. See [Events](https://findlane.dev/docs/events#through-your-own-domain). |
62
63
 
63
64
  ### Settings
package/dist/index.cjs CHANGED
@@ -149,7 +149,7 @@ class SearchServerClient {
149
149
  }
150
150
  }
151
151
  };
152
- const postEvents = async (events, signal) => {
152
+ const postEvents = async (payload, signal) => {
153
153
  for (let attempt = 0;; attempt++) {
154
154
  signal?.throwIfAborted();
155
155
  let wait = 250 * 2 ** attempt;
@@ -158,11 +158,11 @@ class SearchServerClient {
158
158
  method: "POST",
159
159
  signal,
160
160
  headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
161
- body: JSON.stringify({ events })
161
+ body: JSON.stringify(payload)
162
162
  });
163
163
  const data = await response.json().catch(() => ({}));
164
164
  if (response.ok)
165
- return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
165
+ return data;
166
166
  const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
167
167
  if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
168
168
  throw error;
@@ -194,16 +194,23 @@ class SearchServerClient {
194
194
  const retryAfter = response.headers.get("retry-after");
195
195
  return jsonResponse(await response.text(), response.status, retryAfter ? { "retry-after": retryAfter } : {});
196
196
  };
197
- const sendEvents = async (events, signal) => {
197
+ const sendEvents = async (events, options = {}) => {
198
+ const { signal, visitorId, visitId } = options;
198
199
  const result = { accepted: 0, rejected: [] };
199
200
  let batch = [];
200
201
  let sent = 0;
201
202
  const flush = async () => {
202
203
  if (!batch.length)
203
204
  return;
204
- const answer = await postEvents(batch, signal);
205
- result.accepted += answer.accepted;
206
- result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
205
+ const answer = await postEvents({
206
+ ...visitorId === undefined ? {} : { visitorId },
207
+ ...visitId === undefined ? {} : { visitId },
208
+ events: batch
209
+ }, signal);
210
+ result.accepted += answer.accepted ?? 0;
211
+ result.rejected.push(...(answer.rejected ?? []).map((item) => ({ ...item, index: item.index + sent })));
212
+ if (answer.visitorIds === "off")
213
+ result.visitorIds = "off";
207
214
  sent += batch.length;
208
215
  batch = [];
209
216
  };
@@ -240,7 +247,7 @@ class SearchServerClient {
240
247
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
241
248
  },
242
249
  events: {
243
- send: (events, signal) => sendEvents(events, signal),
250
+ send: (events, options) => sendEvents(events, options === undefined ? {} : ("aborted" in options) ? { signal: options } : options),
244
251
  importOrders: (orders, signal) => sendEvents(async function* () {
245
252
  for await (const { timestamp, ...order } of orders)
246
253
  yield {
@@ -248,8 +255,14 @@ class SearchServerClient {
248
255
  ...order,
249
256
  ...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
250
257
  };
251
- }(), signal),
252
- cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal),
258
+ }(), { signal }),
259
+ cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], { signal }),
260
+ forget: async (ids, signal) => {
261
+ let forgotten = 0;
262
+ for (let start = 0;start < ids.length; start += 1000)
263
+ forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
264
+ return { forgotten };
265
+ },
253
266
  proxy: async (request2, visitor = {}) => {
254
267
  if (request2.method !== "POST")
255
268
  return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
package/dist/index.d.cts CHANGED
@@ -314,13 +314,23 @@ export type Order = {
314
314
  currency?: string;
315
315
  timestamp?: number | Date | string;
316
316
  };
317
- /** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
317
+ /** How many events were accepted, and which were refused and why; `index` counts from the first event
318
+ * sent. `visitorIds: "off"` when IDs were sent and dropped: the index doesn't take them. */
318
319
  export type EventsResult = {
319
320
  accepted: number;
320
321
  rejected: {
321
322
  index: number;
322
323
  reason: string;
323
324
  }[];
325
+ visitorIds?: "off";
326
+ };
327
+ /** Options for `events.send`. `visitorId` and `visitId` are your own anonymous IDs for the shopper and
328
+ * the visit, for all the events sent: 16 to 64 letters, digits, - or _, never an email address, a
329
+ * phone number or a customer number. They're kept only when the index takes visitor IDs. */
330
+ export type EventOptions = {
331
+ signal?: AbortSignal;
332
+ visitorId?: string;
333
+ visitId?: string;
324
334
  };
325
335
  export declare class FindlaneError extends Error {
326
336
  status: number;
@@ -357,13 +367,19 @@ export declare class SearchServerClient {
357
367
  /** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
358
368
  * scope. Orders count once each, however often they're sent. */
359
369
  events: {
360
- /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
361
- send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
370
+ /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. Pass
371
+ * `{ visitorId, visitId }` to link a shopper's events (see EventOptions). */
372
+ send: (events: ServerEvent[], options?: AbortSignal | EventOptions) => Promise<EventsResult>;
362
373
  /** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
363
374
  * cursor), 200 a request. */
364
375
  importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
365
376
  /** Takes a cancelled or refunded order out of Bought together. */
366
377
  cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
378
+ /** Deletes the events sent with these visitor or visit IDs, for shoppers who asked: Findlane does it
379
+ * within a week. 1,000 IDs a request. */
380
+ forget: (ids: string[], signal?: AbortSignal) => Promise<{
381
+ forgotten: number;
382
+ }>;
367
383
  /** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
368
384
  * block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
369
385
  * route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
package/dist/index.d.ts CHANGED
@@ -314,13 +314,23 @@ export type Order = {
314
314
  currency?: string;
315
315
  timestamp?: number | Date | string;
316
316
  };
317
- /** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
317
+ /** How many events were accepted, and which were refused and why; `index` counts from the first event
318
+ * sent. `visitorIds: "off"` when IDs were sent and dropped: the index doesn't take them. */
318
319
  export type EventsResult = {
319
320
  accepted: number;
320
321
  rejected: {
321
322
  index: number;
322
323
  reason: string;
323
324
  }[];
325
+ visitorIds?: "off";
326
+ };
327
+ /** Options for `events.send`. `visitorId` and `visitId` are your own anonymous IDs for the shopper and
328
+ * the visit, for all the events sent: 16 to 64 letters, digits, - or _, never an email address, a
329
+ * phone number or a customer number. They're kept only when the index takes visitor IDs. */
330
+ export type EventOptions = {
331
+ signal?: AbortSignal;
332
+ visitorId?: string;
333
+ visitId?: string;
324
334
  };
325
335
  export declare class FindlaneError extends Error {
326
336
  status: number;
@@ -357,13 +367,19 @@ export declare class SearchServerClient {
357
367
  /** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
358
368
  * scope. Orders count once each, however often they're sent. */
359
369
  events: {
360
- /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
361
- send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
370
+ /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. Pass
371
+ * `{ visitorId, visitId }` to link a shopper's events (see EventOptions). */
372
+ send: (events: ServerEvent[], options?: AbortSignal | EventOptions) => Promise<EventsResult>;
362
373
  /** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
363
374
  * cursor), 200 a request. */
364
375
  importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
365
376
  /** Takes a cancelled or refunded order out of Bought together. */
366
377
  cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
378
+ /** Deletes the events sent with these visitor or visit IDs, for shoppers who asked: Findlane does it
379
+ * within a week. 1,000 IDs a request. */
380
+ forget: (ids: string[], signal?: AbortSignal) => Promise<{
381
+ forgotten: number;
382
+ }>;
367
383
  /** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
368
384
  * block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
369
385
  * route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
package/dist/index.js CHANGED
@@ -103,7 +103,7 @@ class SearchServerClient {
103
103
  }
104
104
  }
105
105
  };
106
- const postEvents = async (events, signal) => {
106
+ const postEvents = async (payload, signal) => {
107
107
  for (let attempt = 0;; attempt++) {
108
108
  signal?.throwIfAborted();
109
109
  let wait = 250 * 2 ** attempt;
@@ -112,11 +112,11 @@ class SearchServerClient {
112
112
  method: "POST",
113
113
  signal,
114
114
  headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
115
- body: JSON.stringify({ events })
115
+ body: JSON.stringify(payload)
116
116
  });
117
117
  const data = await response.json().catch(() => ({}));
118
118
  if (response.ok)
119
- return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
119
+ return data;
120
120
  const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
121
121
  if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
122
122
  throw error;
@@ -148,16 +148,23 @@ class SearchServerClient {
148
148
  const retryAfter = response.headers.get("retry-after");
149
149
  return jsonResponse(await response.text(), response.status, retryAfter ? { "retry-after": retryAfter } : {});
150
150
  };
151
- const sendEvents = async (events, signal) => {
151
+ const sendEvents = async (events, options = {}) => {
152
+ const { signal, visitorId, visitId } = options;
152
153
  const result = { accepted: 0, rejected: [] };
153
154
  let batch = [];
154
155
  let sent = 0;
155
156
  const flush = async () => {
156
157
  if (!batch.length)
157
158
  return;
158
- const answer = await postEvents(batch, signal);
159
- result.accepted += answer.accepted;
160
- result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
159
+ const answer = await postEvents({
160
+ ...visitorId === undefined ? {} : { visitorId },
161
+ ...visitId === undefined ? {} : { visitId },
162
+ events: batch
163
+ }, signal);
164
+ result.accepted += answer.accepted ?? 0;
165
+ result.rejected.push(...(answer.rejected ?? []).map((item) => ({ ...item, index: item.index + sent })));
166
+ if (answer.visitorIds === "off")
167
+ result.visitorIds = "off";
161
168
  sent += batch.length;
162
169
  batch = [];
163
170
  };
@@ -194,7 +201,7 @@ class SearchServerClient {
194
201
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
195
202
  },
196
203
  events: {
197
- send: (events, signal) => sendEvents(events, signal),
204
+ send: (events, options) => sendEvents(events, options === undefined ? {} : ("aborted" in options) ? { signal: options } : options),
198
205
  importOrders: (orders, signal) => sendEvents(async function* () {
199
206
  for await (const { timestamp, ...order } of orders)
200
207
  yield {
@@ -202,8 +209,14 @@ class SearchServerClient {
202
209
  ...order,
203
210
  ...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
204
211
  };
205
- }(), signal),
206
- cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal),
212
+ }(), { signal }),
213
+ cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], { signal }),
214
+ forget: async (ids, signal) => {
215
+ let forgotten = 0;
216
+ for (let start = 0;start < ids.length; start += 1000)
217
+ forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
218
+ return { forgotten };
219
+ },
207
220
  proxy: async (request2, visitor = {}) => {
208
221
  if (request2.method !== "POST")
209
222
  return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/server",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",