@findlane/server 0.8.0 → 0.9.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 +61 -0
- package/dist/index.d.cts +63 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +61 -0
- package/package.json +1 -1
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
|
@@ -254,6 +254,58 @@ export type RequestOptions = {
|
|
|
254
254
|
signal?: AbortSignal;
|
|
255
255
|
visitor?: Visitor;
|
|
256
256
|
};
|
|
257
|
+
/** A product in a cart or an order. */
|
|
258
|
+
export type EventItem = {
|
|
259
|
+
objectID: string;
|
|
260
|
+
quantity?: number;
|
|
261
|
+
price?: number;
|
|
262
|
+
};
|
|
263
|
+
/** An event from a server; the API key needs the `events` or `write` scope. `timestamp` (ms since 1970)
|
|
264
|
+
* may be up to 400 days back, for history. Events don't name the shopper. */
|
|
265
|
+
export type ServerEvent = {
|
|
266
|
+
type: "view";
|
|
267
|
+
objectID: string;
|
|
268
|
+
timestamp?: number;
|
|
269
|
+
} | {
|
|
270
|
+
type: "click";
|
|
271
|
+
objectID: string;
|
|
272
|
+
position?: number;
|
|
273
|
+
query?: string;
|
|
274
|
+
list?: "goesWith" | "similar" | "together";
|
|
275
|
+
from?: string;
|
|
276
|
+
timestamp?: number;
|
|
277
|
+
} | {
|
|
278
|
+
type: "addToCart";
|
|
279
|
+
items: EventItem[];
|
|
280
|
+
currency?: string;
|
|
281
|
+
timestamp?: number;
|
|
282
|
+
} | {
|
|
283
|
+
type: "purchase";
|
|
284
|
+
orderID: string;
|
|
285
|
+
items: EventItem[];
|
|
286
|
+
currency?: string;
|
|
287
|
+
timestamp?: number;
|
|
288
|
+
}
|
|
289
|
+
/** Takes a cancelled or refunded order out of Bought together. */
|
|
290
|
+
| {
|
|
291
|
+
type: "cancel";
|
|
292
|
+
orderID: string;
|
|
293
|
+
};
|
|
294
|
+
/** A past order for importOrders; `timestamp` is when it was placed (ms, a Date, or an ISO string). */
|
|
295
|
+
export type Order = {
|
|
296
|
+
orderID: string;
|
|
297
|
+
items: EventItem[];
|
|
298
|
+
currency?: string;
|
|
299
|
+
timestamp?: number | Date | string;
|
|
300
|
+
};
|
|
301
|
+
/** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
|
|
302
|
+
export type EventsResult = {
|
|
303
|
+
accepted: number;
|
|
304
|
+
rejected: {
|
|
305
|
+
index: number;
|
|
306
|
+
reason: string;
|
|
307
|
+
}[];
|
|
308
|
+
};
|
|
257
309
|
export declare class FindlaneError extends Error {
|
|
258
310
|
status: number;
|
|
259
311
|
constructor(status: number, message: string);
|
|
@@ -286,6 +338,17 @@ export declare class SearchServerClient {
|
|
|
286
338
|
/** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
|
|
287
339
|
* The API key needs the `search` scope; not counted as a search. */
|
|
288
340
|
recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
|
|
341
|
+
/** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
|
|
342
|
+
* scope. Orders count once each, however often they're sent. */
|
|
343
|
+
events: {
|
|
344
|
+
/** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
|
|
345
|
+
send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
|
|
346
|
+
/** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
|
|
347
|
+
* cursor), 200 a request. */
|
|
348
|
+
importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
|
|
349
|
+
/** Takes a cancelled or refunded order out of Bought together. */
|
|
350
|
+
cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
|
|
351
|
+
};
|
|
289
352
|
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
290
353
|
getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
|
|
291
354
|
upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
|
package/dist/index.d.ts
CHANGED
|
@@ -254,6 +254,58 @@ export type RequestOptions = {
|
|
|
254
254
|
signal?: AbortSignal;
|
|
255
255
|
visitor?: Visitor;
|
|
256
256
|
};
|
|
257
|
+
/** A product in a cart or an order. */
|
|
258
|
+
export type EventItem = {
|
|
259
|
+
objectID: string;
|
|
260
|
+
quantity?: number;
|
|
261
|
+
price?: number;
|
|
262
|
+
};
|
|
263
|
+
/** An event from a server; the API key needs the `events` or `write` scope. `timestamp` (ms since 1970)
|
|
264
|
+
* may be up to 400 days back, for history. Events don't name the shopper. */
|
|
265
|
+
export type ServerEvent = {
|
|
266
|
+
type: "view";
|
|
267
|
+
objectID: string;
|
|
268
|
+
timestamp?: number;
|
|
269
|
+
} | {
|
|
270
|
+
type: "click";
|
|
271
|
+
objectID: string;
|
|
272
|
+
position?: number;
|
|
273
|
+
query?: string;
|
|
274
|
+
list?: "goesWith" | "similar" | "together";
|
|
275
|
+
from?: string;
|
|
276
|
+
timestamp?: number;
|
|
277
|
+
} | {
|
|
278
|
+
type: "addToCart";
|
|
279
|
+
items: EventItem[];
|
|
280
|
+
currency?: string;
|
|
281
|
+
timestamp?: number;
|
|
282
|
+
} | {
|
|
283
|
+
type: "purchase";
|
|
284
|
+
orderID: string;
|
|
285
|
+
items: EventItem[];
|
|
286
|
+
currency?: string;
|
|
287
|
+
timestamp?: number;
|
|
288
|
+
}
|
|
289
|
+
/** Takes a cancelled or refunded order out of Bought together. */
|
|
290
|
+
| {
|
|
291
|
+
type: "cancel";
|
|
292
|
+
orderID: string;
|
|
293
|
+
};
|
|
294
|
+
/** A past order for importOrders; `timestamp` is when it was placed (ms, a Date, or an ISO string). */
|
|
295
|
+
export type Order = {
|
|
296
|
+
orderID: string;
|
|
297
|
+
items: EventItem[];
|
|
298
|
+
currency?: string;
|
|
299
|
+
timestamp?: number | Date | string;
|
|
300
|
+
};
|
|
301
|
+
/** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
|
|
302
|
+
export type EventsResult = {
|
|
303
|
+
accepted: number;
|
|
304
|
+
rejected: {
|
|
305
|
+
index: number;
|
|
306
|
+
reason: string;
|
|
307
|
+
}[];
|
|
308
|
+
};
|
|
257
309
|
export declare class FindlaneError extends Error {
|
|
258
310
|
status: number;
|
|
259
311
|
constructor(status: number, message: string);
|
|
@@ -286,6 +338,17 @@ export declare class SearchServerClient {
|
|
|
286
338
|
/** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
|
|
287
339
|
* The API key needs the `search` scope; not counted as a search. */
|
|
288
340
|
recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
|
|
341
|
+
/** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
|
|
342
|
+
* scope. Orders count once each, however often they're sent. */
|
|
343
|
+
events: {
|
|
344
|
+
/** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
|
|
345
|
+
send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
|
|
346
|
+
/** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
|
|
347
|
+
* cursor), 200 a request. */
|
|
348
|
+
importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
|
|
349
|
+
/** Takes a cancelled or refunded order out of Bought together. */
|
|
350
|
+
cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
|
|
351
|
+
};
|
|
289
352
|
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
290
353
|
getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
|
|
291
354
|
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.
|
|
3
|
+
"version": "0.9.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",
|