@findlane/server 0.7.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/README.md +11 -1
- package/dist/index.cjs +81 -5
- package/dist/index.d.cts +82 -4
- package/dist/index.d.ts +82 -4
- package/dist/index.js +81 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,13 +31,23 @@ The workspace ID is under Settings in the dashboard, and keys are under API keys
|
|
|
31
31
|
- `search`: `search()`.
|
|
32
32
|
- `write`: record writes, settings, public search restrictions, and indexing status.
|
|
33
33
|
|
|
34
|
+
## Searching for a visitor
|
|
35
|
+
|
|
36
|
+
When your server searches for someone, such as a shopper loading a server-rendered search page, pass the visitor. The index's limits and bot rules then apply to them as they do to browser search, and searches from verified crawlers like Googlebot are free:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent: req.get("user-agent") } })
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`suggest()` and `recommendations()` take the same option. Without it, a search counts as your server's own.
|
|
43
|
+
|
|
34
44
|
## An index's methods
|
|
35
45
|
|
|
36
46
|
`client.index<T>(name)` returns these; `T` types your records, which each need a string `objectID`.
|
|
37
47
|
|
|
38
48
|
| Method | What it does |
|
|
39
49
|
| --- | --- |
|
|
40
|
-
| `search(request)` | Search with a query, filters, facets, a sort, paging, and highlighting. |
|
|
50
|
+
| `search(request, options?)` | Search with a query, filters, facets, a sort, paging, and highlighting. `options` takes `signal` and `visitor` (below). |
|
|
41
51
|
| `upsert(records)` | Add or update up to 1,000 records by `objectID`. |
|
|
42
52
|
| `replaceAll(records)` | Replace the whole catalog in one request (up to 100,000 records). |
|
|
43
53
|
| `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. |
|
package/dist/index.cjs
CHANGED
|
@@ -67,6 +67,12 @@ class StagedReplacementError extends Error {
|
|
|
67
67
|
this.name = "StagedReplacementError";
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
var eventsPerRequest = 200;
|
|
71
|
+
var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
|
|
72
|
+
var visitorHeaders = (visitor) => visitor?.ip ? {
|
|
73
|
+
"findlane-visitor-ip": visitor.ip.trim(),
|
|
74
|
+
...visitor.userAgent ? { "findlane-visitor-agent": visitor.userAgent.replace(/[^\x20-\x7e]/g, "").slice(0, 512) } : {}
|
|
75
|
+
} : {};
|
|
70
76
|
|
|
71
77
|
class FindlaneError extends Error {
|
|
72
78
|
status;
|
|
@@ -93,12 +99,13 @@ class SearchServerClient {
|
|
|
93
99
|
}
|
|
94
100
|
index(name) {
|
|
95
101
|
const path = `/api/teams/${encodeURIComponent(this.workspaceId)}/indexes/${encodeURIComponent(name)}`;
|
|
96
|
-
const request = async (suffix, method = "GET", payload, signal) => {
|
|
102
|
+
const request = async (suffix, method = "GET", payload, signal, extra = {}) => {
|
|
97
103
|
const response = await this.fetcher(this.baseUrl + path + suffix, {
|
|
98
104
|
method,
|
|
99
105
|
headers: {
|
|
100
106
|
authorization: `Bearer ${this.key}`,
|
|
101
|
-
...payload === undefined ? {} : { "content-type": "application/json" }
|
|
107
|
+
...payload === undefined ? {} : { "content-type": "application/json" },
|
|
108
|
+
...extra
|
|
102
109
|
},
|
|
103
110
|
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
104
111
|
signal
|
|
@@ -130,6 +137,54 @@ class SearchServerClient {
|
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
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
|
+
};
|
|
133
188
|
const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
|
|
134
189
|
const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
|
|
135
190
|
const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
|
|
@@ -142,9 +197,30 @@ class SearchServerClient {
|
|
|
142
197
|
return retry(() => request("/record-writes", "POST", payload, options.signal), options.signal);
|
|
143
198
|
};
|
|
144
199
|
return {
|
|
145
|
-
search: (query = {},
|
|
146
|
-
|
|
147
|
-
|
|
200
|
+
search: (query = {}, options) => {
|
|
201
|
+
const { signal, visitor } = requestOptions(options);
|
|
202
|
+
return request("/search", "POST", query, signal, visitorHeaders(visitor));
|
|
203
|
+
},
|
|
204
|
+
suggest: async (query, options) => {
|
|
205
|
+
const { signal, visitor } = requestOptions(options);
|
|
206
|
+
return (await request("/suggest", "POST", query, signal, visitorHeaders(visitor))).didYouMean;
|
|
207
|
+
},
|
|
208
|
+
recommendations: (objectID, options = {}, requestOptionsOrSignal) => {
|
|
209
|
+
const { signal, visitor } = requestOptions(requestOptionsOrSignal);
|
|
210
|
+
return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
|
|
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
|
+
},
|
|
148
224
|
upsert: (records, signal) => request("/records", "POST", { records }, signal),
|
|
149
225
|
getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
|
|
150
226
|
upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
|
package/dist/index.d.cts
CHANGED
|
@@ -240,6 +240,72 @@ export declare class StagedReplacementError extends Error {
|
|
|
240
240
|
stageId: string;
|
|
241
241
|
constructor(stageId: string, cause: unknown);
|
|
242
242
|
}
|
|
243
|
+
/** The visitor a server searches for, such as a shopper loading a server-rendered search page. With it,
|
|
244
|
+
* the index's limits and bot rules apply to that visitor, as they do to browser search, and searches
|
|
245
|
+
* from verified crawlers like Googlebot are free. */
|
|
246
|
+
export type Visitor = {
|
|
247
|
+
/** The visitor's IP address: on Express `req.ip`, on Next.js the first address in `x-forwarded-for`. */
|
|
248
|
+
ip: string;
|
|
249
|
+
/** The visitor's User-Agent header. */
|
|
250
|
+
userAgent?: string;
|
|
251
|
+
};
|
|
252
|
+
/** Options for a search, a suggestion, or recommendations. */
|
|
253
|
+
export type RequestOptions = {
|
|
254
|
+
signal?: AbortSignal;
|
|
255
|
+
visitor?: Visitor;
|
|
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
|
+
};
|
|
243
309
|
export declare class FindlaneError extends Error {
|
|
244
310
|
status: number;
|
|
245
311
|
constructor(status: number, message: string);
|
|
@@ -262,15 +328,27 @@ export declare class SearchServerClient {
|
|
|
262
328
|
index<T extends {
|
|
263
329
|
objectID: string;
|
|
264
330
|
} = JsonRecord>(name: string): {
|
|
265
|
-
/** Searches the index; the API key needs the `search` scope.
|
|
266
|
-
|
|
331
|
+
/** Searches the index; the API key needs the `search` scope. Pass `{ visitor }` when searching for
|
|
332
|
+
* someone, such as a shopper loading a server-rendered page (see Visitor). */
|
|
333
|
+
search: <R = T>(query?: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<SearchResponse<R>>;
|
|
267
334
|
/** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the
|
|
268
335
|
* same request (its q, filters, and fields matter). Needs the `didYouMean` setting; not counted as
|
|
269
336
|
* a search. */
|
|
270
|
-
suggest: (query: SearchRequest,
|
|
337
|
+
suggest: (query: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<DidYouMean | null>;
|
|
271
338
|
/** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
|
|
272
339
|
* The API key needs the `search` scope; not counted as a search. */
|
|
273
|
-
recommendations: <R = T>(objectID: string, options?: RecommendationsRequest,
|
|
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
|
+
};
|
|
274
352
|
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
275
353
|
getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
|
|
276
354
|
upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
|
package/dist/index.d.ts
CHANGED
|
@@ -240,6 +240,72 @@ export declare class StagedReplacementError extends Error {
|
|
|
240
240
|
stageId: string;
|
|
241
241
|
constructor(stageId: string, cause: unknown);
|
|
242
242
|
}
|
|
243
|
+
/** The visitor a server searches for, such as a shopper loading a server-rendered search page. With it,
|
|
244
|
+
* the index's limits and bot rules apply to that visitor, as they do to browser search, and searches
|
|
245
|
+
* from verified crawlers like Googlebot are free. */
|
|
246
|
+
export type Visitor = {
|
|
247
|
+
/** The visitor's IP address: on Express `req.ip`, on Next.js the first address in `x-forwarded-for`. */
|
|
248
|
+
ip: string;
|
|
249
|
+
/** The visitor's User-Agent header. */
|
|
250
|
+
userAgent?: string;
|
|
251
|
+
};
|
|
252
|
+
/** Options for a search, a suggestion, or recommendations. */
|
|
253
|
+
export type RequestOptions = {
|
|
254
|
+
signal?: AbortSignal;
|
|
255
|
+
visitor?: Visitor;
|
|
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
|
+
};
|
|
243
309
|
export declare class FindlaneError extends Error {
|
|
244
310
|
status: number;
|
|
245
311
|
constructor(status: number, message: string);
|
|
@@ -262,15 +328,27 @@ export declare class SearchServerClient {
|
|
|
262
328
|
index<T extends {
|
|
263
329
|
objectID: string;
|
|
264
330
|
} = JsonRecord>(name: string): {
|
|
265
|
-
/** Searches the index; the API key needs the `search` scope.
|
|
266
|
-
|
|
331
|
+
/** Searches the index; the API key needs the `search` scope. Pass `{ visitor }` when searching for
|
|
332
|
+
* someone, such as a shopper loading a server-rendered page (see Visitor). */
|
|
333
|
+
search: <R = T>(query?: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<SearchResponse<R>>;
|
|
267
334
|
/** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the
|
|
268
335
|
* same request (its q, filters, and fields matter). Needs the `didYouMean` setting; not counted as
|
|
269
336
|
* a search. */
|
|
270
|
-
suggest: (query: SearchRequest,
|
|
337
|
+
suggest: (query: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<DidYouMean | null>;
|
|
271
338
|
/** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
|
|
272
339
|
* The API key needs the `search` scope; not counted as a search. */
|
|
273
|
-
recommendations: <R = T>(objectID: string, options?: RecommendationsRequest,
|
|
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
|
+
};
|
|
274
352
|
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
275
353
|
getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
|
|
276
354
|
upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,12 @@ class StagedReplacementError extends Error {
|
|
|
21
21
|
this.name = "StagedReplacementError";
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
var eventsPerRequest = 200;
|
|
25
|
+
var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
|
|
26
|
+
var visitorHeaders = (visitor) => visitor?.ip ? {
|
|
27
|
+
"findlane-visitor-ip": visitor.ip.trim(),
|
|
28
|
+
...visitor.userAgent ? { "findlane-visitor-agent": visitor.userAgent.replace(/[^\x20-\x7e]/g, "").slice(0, 512) } : {}
|
|
29
|
+
} : {};
|
|
24
30
|
|
|
25
31
|
class FindlaneError extends Error {
|
|
26
32
|
status;
|
|
@@ -47,12 +53,13 @@ class SearchServerClient {
|
|
|
47
53
|
}
|
|
48
54
|
index(name) {
|
|
49
55
|
const path = `/api/teams/${encodeURIComponent(this.workspaceId)}/indexes/${encodeURIComponent(name)}`;
|
|
50
|
-
const request = async (suffix, method = "GET", payload, signal) => {
|
|
56
|
+
const request = async (suffix, method = "GET", payload, signal, extra = {}) => {
|
|
51
57
|
const response = await this.fetcher(this.baseUrl + path + suffix, {
|
|
52
58
|
method,
|
|
53
59
|
headers: {
|
|
54
60
|
authorization: `Bearer ${this.key}`,
|
|
55
|
-
...payload === undefined ? {} : { "content-type": "application/json" }
|
|
61
|
+
...payload === undefined ? {} : { "content-type": "application/json" },
|
|
62
|
+
...extra
|
|
56
63
|
},
|
|
57
64
|
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
58
65
|
signal
|
|
@@ -84,6 +91,54 @@ class SearchServerClient {
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
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
|
+
};
|
|
87
142
|
const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
|
|
88
143
|
const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
|
|
89
144
|
const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
|
|
@@ -96,9 +151,30 @@ class SearchServerClient {
|
|
|
96
151
|
return retry(() => request("/record-writes", "POST", payload, options.signal), options.signal);
|
|
97
152
|
};
|
|
98
153
|
return {
|
|
99
|
-
search: (query = {},
|
|
100
|
-
|
|
101
|
-
|
|
154
|
+
search: (query = {}, options) => {
|
|
155
|
+
const { signal, visitor } = requestOptions(options);
|
|
156
|
+
return request("/search", "POST", query, signal, visitorHeaders(visitor));
|
|
157
|
+
},
|
|
158
|
+
suggest: async (query, options) => {
|
|
159
|
+
const { signal, visitor } = requestOptions(options);
|
|
160
|
+
return (await request("/suggest", "POST", query, signal, visitorHeaders(visitor))).didYouMean;
|
|
161
|
+
},
|
|
162
|
+
recommendations: (objectID, options = {}, requestOptionsOrSignal) => {
|
|
163
|
+
const { signal, visitor } = requestOptions(requestOptionsOrSignal);
|
|
164
|
+
return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
|
|
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
|
+
},
|
|
102
178
|
upsert: (records, signal) => request("/records", "POST", { records }, signal),
|
|
103
179
|
getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
|
|
104
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",
|