@findlane/server 0.1.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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/index.d.ts +208 -0
- package/dist/index.js +199 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dagur Leo Bergsson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @findlane/server
|
|
2
|
+
|
|
3
|
+
The Findlane server SDK: upload and update catalogs, manage index settings, and search, with a secret workspace API key. It runs wherever `fetch` does: Node.js 18+, Bun, Deno, and edge runtimes. Never bundle it, or its key, into browser code; use [`@findlane/browser`](https://www.npmjs.com/package/@findlane/browser) there.
|
|
4
|
+
|
|
5
|
+
Docs: https://findlane.dev/docs/server-sdk
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @findlane/server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { SearchServerClient } from "@findlane/server"
|
|
17
|
+
|
|
18
|
+
const client = new SearchServerClient({
|
|
19
|
+
workspaceId: process.env.FINDLANE_WORKSPACE_ID!,
|
|
20
|
+
apiKey: process.env.FINDLANE_API_KEY!,
|
|
21
|
+
})
|
|
22
|
+
const products = client.index("products")
|
|
23
|
+
|
|
24
|
+
await products.upsert([{ objectID: "sku-1", title: "Trail shoe", brand: { name: "Acme" }, price: 12900 }])
|
|
25
|
+
const results = await products.search({ q: "trail shoe", limit: 10 })
|
|
26
|
+
console.log(results.total, results.hits[0]?.record.title)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The workspace ID is under Settings in the dashboard, and keys are under API keys. A key has one or both scopes:
|
|
30
|
+
|
|
31
|
+
- `search`: `search()`.
|
|
32
|
+
- `write`: record writes, settings, public search restrictions, and indexing status.
|
|
33
|
+
|
|
34
|
+
## An index's methods
|
|
35
|
+
|
|
36
|
+
`client.index<T>(name)` returns these; `T` types your records, which each need a string `objectID`.
|
|
37
|
+
|
|
38
|
+
| Method | What it does |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `search(request)` | Search with a query, filters, facets, a sort, paging, and highlighting. |
|
|
41
|
+
| `upsert(records)` | Add or update up to 1,000 records by `objectID`. |
|
|
42
|
+
| `replaceAll(records)` | Replace the whole catalog in one request (up to 100,000 records). |
|
|
43
|
+
| `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. |
|
|
44
|
+
| `delete(objectID)` | Delete a record. |
|
|
45
|
+
| `getRecord(objectID)` | A record and its version. |
|
|
46
|
+
| `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. |
|
|
47
|
+
| `getSettings()`, `replaceSettings(settings)`, `waitForSettings()` | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, typo tolerance, and text normalization. |
|
|
48
|
+
| `getIndexingStatus()`, `waitUntilIndexed(revision)` | Writes return an accepted revision and are searchable when accepted, so `waitUntilIndexed` returns at once. |
|
|
49
|
+
| `getPublicSearchAccess()`, `setPublicSearchPolicy(policy)`, `usePublicIndexDefaults()` | Restrict what browser search may read and query. |
|
|
50
|
+
|
|
51
|
+
### Settings
|
|
52
|
+
|
|
53
|
+
`replaceSettings` sends the full settings; fetch them first to keep the other fields. Its `applied` says how the change took effect:
|
|
54
|
+
|
|
55
|
+
- `instant`: query-time settings, and removing or reordering searchable fields.
|
|
56
|
+
- `fields`: facet, filter, sort, and ranking fields, filled in place.
|
|
57
|
+
- `searchable`: new searchable fields, filled in.
|
|
58
|
+
- `rebuild`: text normalization; the index is rebuilt.
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
### Browser search
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
Failed requests throw a `FindlaneError` with the HTTP `status` and the API's message. A 402 means a plan limit, such as records or monthly searches. `replaceAllStaged` throws a `StagedReplacementError` with the `stageId` to retry. Every method takes an optional `AbortSignal`.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export type JsonRecord = {
|
|
2
|
+
objectID: string;
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export type SortRule = {
|
|
6
|
+
field: string;
|
|
7
|
+
direction: "asc" | "desc";
|
|
8
|
+
};
|
|
9
|
+
export type SortOption = SortRule & {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
thenBy: SortRule[];
|
|
13
|
+
};
|
|
14
|
+
export type IndexSettings = {
|
|
15
|
+
/** One to five fields, matched with their weight. */
|
|
16
|
+
searchableAttributes: {
|
|
17
|
+
field: string;
|
|
18
|
+
weight: number;
|
|
19
|
+
}[];
|
|
20
|
+
facetFields: string[];
|
|
21
|
+
filterFields?: string[];
|
|
22
|
+
typoTolerance: boolean;
|
|
23
|
+
sorts: SortOption[];
|
|
24
|
+
defaultSort: string;
|
|
25
|
+
/** Orders equally relevant results (within 10% of the best score) by up to three sort fields. */
|
|
26
|
+
customRanking?: SortRule[];
|
|
27
|
+
/** Groups of interchangeable words or phrases (up to four words each). */
|
|
28
|
+
synonyms?: string[][];
|
|
29
|
+
normalizationProfile?: "default-v1" | "icelandic-ascii-v1";
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
/** A settings change running in the background (with the settings it applies), or the last one's
|
|
33
|
+
* failure. `kind` is "searchable" (new searchable fields filled in) or "rebuild" (a full copy). */
|
|
34
|
+
export type SettingsRebuild = {
|
|
35
|
+
status: "running";
|
|
36
|
+
kind: "searchable" | "rebuild";
|
|
37
|
+
copied: number;
|
|
38
|
+
total: number;
|
|
39
|
+
startedAt: string;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
settings: IndexSettings;
|
|
42
|
+
} | {
|
|
43
|
+
status: "failed";
|
|
44
|
+
code: number;
|
|
45
|
+
message: string;
|
|
46
|
+
finishedAt: string;
|
|
47
|
+
settings: IndexSettings;
|
|
48
|
+
};
|
|
49
|
+
/** The active settings, and a rebuild that will replace them (or the last one's failure). */
|
|
50
|
+
export type CurrentSettings = IndexSettings & {
|
|
51
|
+
rebuild: SettingsRebuild | null;
|
|
52
|
+
};
|
|
53
|
+
/** How saved settings were applied: "none" (unchanged), "instant" (query-time settings, and removed
|
|
54
|
+
* or reordered searchable fields), "fields" (facet, filter, sort, and ranking fields filled in
|
|
55
|
+
* place), "searchable" (new searchable fields filled in, after any field changes), or "rebuild" (text
|
|
56
|
+
* normalization). The last two continue in the background when large:
|
|
57
|
+
* `rebuilt` is false and `rebuild` shows progress; searches use the previous settings until then. */
|
|
58
|
+
export type SavedSettings = IndexSettings & {
|
|
59
|
+
applied: "none" | "instant" | "fields" | "searchable" | "rebuild";
|
|
60
|
+
rebuilt: boolean;
|
|
61
|
+
rebuild: SettingsRebuild | null;
|
|
62
|
+
};
|
|
63
|
+
export type PublicSearchPolicy = {
|
|
64
|
+
returnFields: string[];
|
|
65
|
+
searchFields: string[];
|
|
66
|
+
filterFields: string[];
|
|
67
|
+
facetFields: string[];
|
|
68
|
+
sortIds: string[];
|
|
69
|
+
defaultSort: string;
|
|
70
|
+
};
|
|
71
|
+
export type PublicSearchAccess = {
|
|
72
|
+
publicId: string;
|
|
73
|
+
allowedOrigins: string[];
|
|
74
|
+
policy: PublicSearchPolicy | null;
|
|
75
|
+
};
|
|
76
|
+
export type WriteReceipt = {
|
|
77
|
+
acceptedRevision: number;
|
|
78
|
+
recordCount: number;
|
|
79
|
+
recordVersion?: number;
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
};
|
|
82
|
+
export type RecordSnapshot<T> = {
|
|
83
|
+
objectID: string;
|
|
84
|
+
version: number;
|
|
85
|
+
record: T | null;
|
|
86
|
+
};
|
|
87
|
+
export type SearchRequest = {
|
|
88
|
+
q?: string;
|
|
89
|
+
/** A sort ID from the index settings, or "relevance". */
|
|
90
|
+
sort?: string;
|
|
91
|
+
/** Field values to keep: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. */
|
|
92
|
+
filters?: Record<string, unknown>;
|
|
93
|
+
facets?: string[];
|
|
94
|
+
limit?: number;
|
|
95
|
+
offset?: number;
|
|
96
|
+
attributesToRetrieve?: string[];
|
|
97
|
+
/** "page" (default) counts only the returned page when a query matches more; "exact" counts every match. */
|
|
98
|
+
countMode?: "page" | "exact";
|
|
99
|
+
/** "page" (default) counts facet values in the returned page; "exact" counts them across every match. */
|
|
100
|
+
facetMode?: "page" | "exact";
|
|
101
|
+
/** Mark the words the query matched in each hit's searchable fields: true for <mark> tags, or custom tags.
|
|
102
|
+
* Highlighted values are HTML-escaped, so they can be inserted as HTML. */
|
|
103
|
+
highlight?: boolean | {
|
|
104
|
+
preTag?: string;
|
|
105
|
+
postTag?: string;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
export type SearchResponse<T> = {
|
|
109
|
+
hits: {
|
|
110
|
+
id: string;
|
|
111
|
+
score: number | null;
|
|
112
|
+
record: T;
|
|
113
|
+
/** With `highlight`: matched searchable fields by path; list fields give one string per value. */
|
|
114
|
+
highlights?: Record<string, string | string[]>;
|
|
115
|
+
}[];
|
|
116
|
+
total: number;
|
|
117
|
+
/** "gte" when `total` is a lower bound (page count mode). */
|
|
118
|
+
totalRelation?: "eq" | "gte";
|
|
119
|
+
hasMore?: boolean;
|
|
120
|
+
facets: Record<string, Record<string, number>>;
|
|
121
|
+
tookMs: number;
|
|
122
|
+
sort?: string;
|
|
123
|
+
};
|
|
124
|
+
export type ConditionalWriteOptions = {
|
|
125
|
+
expectedVersion: number;
|
|
126
|
+
idempotencyKey?: string;
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
};
|
|
129
|
+
export type IndexingStatus = {
|
|
130
|
+
acceptedRevision: number;
|
|
131
|
+
indexedRevision: number;
|
|
132
|
+
lastError?: string | null;
|
|
133
|
+
[key: string]: unknown;
|
|
134
|
+
};
|
|
135
|
+
export type ReplacementStage = {
|
|
136
|
+
stageId: string;
|
|
137
|
+
status: "open" | "committed" | "aborted";
|
|
138
|
+
baseRevision: number;
|
|
139
|
+
recordCount: number;
|
|
140
|
+
batchCount: number;
|
|
141
|
+
acceptedRevision: number | null;
|
|
142
|
+
};
|
|
143
|
+
export type StagedReplaceOptions = {
|
|
144
|
+
stageId?: string;
|
|
145
|
+
/** Records per batch: 1–1,000 (default 500). */
|
|
146
|
+
batchSize?: number;
|
|
147
|
+
/** Bytes per batch request (default 8 MiB). */
|
|
148
|
+
maxBatchBytes?: number;
|
|
149
|
+
signal?: AbortSignal;
|
|
150
|
+
};
|
|
151
|
+
export declare class StagedReplacementError extends Error {
|
|
152
|
+
stageId: string;
|
|
153
|
+
constructor(stageId: string, cause: unknown);
|
|
154
|
+
}
|
|
155
|
+
export declare class FindlaneError extends Error {
|
|
156
|
+
status: number;
|
|
157
|
+
constructor(status: number, message: string);
|
|
158
|
+
}
|
|
159
|
+
/** The hosted Findlane API, used unless `baseUrl` says otherwise. */
|
|
160
|
+
export declare const defaultBaseUrl = "https://api.findlane.dev";
|
|
161
|
+
export type ServerClientOptions = {
|
|
162
|
+
/** The API to call; defaults to the hosted Findlane API. */
|
|
163
|
+
baseUrl?: string;
|
|
164
|
+
workspaceId: string;
|
|
165
|
+
apiKey: string;
|
|
166
|
+
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
167
|
+
};
|
|
168
|
+
export declare class SearchServerClient {
|
|
169
|
+
private readonly baseUrl;
|
|
170
|
+
private readonly workspaceId;
|
|
171
|
+
private readonly key;
|
|
172
|
+
private readonly fetcher;
|
|
173
|
+
constructor(options: ServerClientOptions);
|
|
174
|
+
index<T extends {
|
|
175
|
+
objectID: string;
|
|
176
|
+
} = JsonRecord>(name: string): {
|
|
177
|
+
/** Searches the index; the API key needs the `search` scope. */
|
|
178
|
+
search: <R = T>(query?: SearchRequest, signal?: AbortSignal) => Promise<SearchResponse<R>>;
|
|
179
|
+
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
180
|
+
getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
|
|
181
|
+
upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
|
|
182
|
+
deleteOne: (objectID: string, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
|
|
183
|
+
replaceAll: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
184
|
+
beginReplacement: (stageId: string, signal?: AbortSignal) => Promise<ReplacementStage>;
|
|
185
|
+
uploadReplacementBatch: (stageId: string, batchId: string, records: T[], signal?: AbortSignal) => Promise<ReplacementStage>;
|
|
186
|
+
commitReplacement: (stageId: string, expectedBatches: number, expectedRecords: number, signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
187
|
+
abortReplacement: (stageId: string, signal?: AbortSignal) => Promise<ReplacementStage>;
|
|
188
|
+
replaceAllStaged: (records: Iterable<T> | AsyncIterable<T>, options?: StagedReplaceOptions) => Promise<WriteReceipt>;
|
|
189
|
+
delete: (objectID: string, signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
190
|
+
getSettings: (signal?: AbortSignal) => Promise<CurrentSettings>;
|
|
191
|
+
replaceSettings: (settings: IndexSettings, signal?: AbortSignal) => Promise<SavedSettings>;
|
|
192
|
+
/** Waits until no settings rebuild is running; throws if it failed. */
|
|
193
|
+
waitForSettings: (options?: {
|
|
194
|
+
timeoutMs?: number;
|
|
195
|
+
intervalMs?: number;
|
|
196
|
+
signal?: AbortSignal;
|
|
197
|
+
}) => Promise<CurrentSettings>;
|
|
198
|
+
getPublicSearchAccess: (signal?: AbortSignal) => Promise<PublicSearchAccess>;
|
|
199
|
+
setPublicSearchPolicy: (policy: PublicSearchPolicy, signal?: AbortSignal) => Promise<PublicSearchAccess>;
|
|
200
|
+
usePublicIndexDefaults: (signal?: AbortSignal) => Promise<PublicSearchAccess>;
|
|
201
|
+
getIndexingStatus: (signal?: AbortSignal) => Promise<IndexingStatus>;
|
|
202
|
+
waitUntilIndexed: (revision: number, options?: {
|
|
203
|
+
timeoutMs?: number;
|
|
204
|
+
intervalMs?: number;
|
|
205
|
+
signal?: AbortSignal;
|
|
206
|
+
}) => Promise<IndexingStatus>;
|
|
207
|
+
};
|
|
208
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function pause(ms, signal) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const onAbort = () => {
|
|
5
|
+
clearTimeout(timer);
|
|
6
|
+
reject(signal?.reason);
|
|
7
|
+
};
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
signal?.removeEventListener("abort", onAbort);
|
|
10
|
+
resolve();
|
|
11
|
+
}, ms);
|
|
12
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class StagedReplacementError extends Error {
|
|
17
|
+
stageId;
|
|
18
|
+
constructor(stageId, cause) {
|
|
19
|
+
super(`Staged replacement ${stageId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
20
|
+
this.stageId = stageId;
|
|
21
|
+
this.name = "StagedReplacementError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class FindlaneError extends Error {
|
|
26
|
+
status;
|
|
27
|
+
constructor(status, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.status = status;
|
|
30
|
+
this.name = "FindlaneError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
var defaultBaseUrl = "https://api.findlane.dev";
|
|
34
|
+
|
|
35
|
+
class SearchServerClient {
|
|
36
|
+
baseUrl;
|
|
37
|
+
workspaceId;
|
|
38
|
+
key;
|
|
39
|
+
fetcher;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.baseUrl = (options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "");
|
|
42
|
+
this.workspaceId = options.workspaceId;
|
|
43
|
+
this.key = options.apiKey;
|
|
44
|
+
this.fetcher = options.fetch ?? fetch;
|
|
45
|
+
if (!this.baseUrl || !this.workspaceId || !this.key)
|
|
46
|
+
throw new Error("workspaceId and apiKey are required");
|
|
47
|
+
}
|
|
48
|
+
index(name) {
|
|
49
|
+
const path = `/api/teams/${encodeURIComponent(this.workspaceId)}/indexes/${encodeURIComponent(name)}`;
|
|
50
|
+
const request = async (suffix, method = "GET", payload, signal) => {
|
|
51
|
+
const response = await this.fetcher(this.baseUrl + path + suffix, {
|
|
52
|
+
method,
|
|
53
|
+
headers: {
|
|
54
|
+
authorization: `Bearer ${this.key}`,
|
|
55
|
+
...payload === undefined ? {} : { "content-type": "application/json" }
|
|
56
|
+
},
|
|
57
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
58
|
+
signal
|
|
59
|
+
});
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
|
|
63
|
+
return data;
|
|
64
|
+
};
|
|
65
|
+
const retry = async (work, signal) => {
|
|
66
|
+
for (let attempt = 0;; attempt++) {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
try {
|
|
69
|
+
return await work();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (attempt >= 3 || error instanceof FindlaneError && ![408, 429, 502, 503, 504].includes(error.status))
|
|
72
|
+
throw error;
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
const onAbort = () => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
reject(signal?.reason);
|
|
77
|
+
};
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
signal?.removeEventListener("abort", onAbort);
|
|
80
|
+
resolve();
|
|
81
|
+
}, 100 * 2 ** attempt);
|
|
82
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const stagePath = (stageId) => `/staged-replacements/${encodeURIComponent(stageId)}`;
|
|
88
|
+
const beginReplacement = (stageId, signal) => retry(() => request(stagePath(stageId), "POST", undefined, signal), signal);
|
|
89
|
+
const uploadReplacementBatch = (stageId, batchId, records, signal) => retry(() => request(`${stagePath(stageId)}/batches/${encodeURIComponent(batchId)}`, "POST", { records }, signal), signal);
|
|
90
|
+
const commitReplacement = (stageId, expectedBatches, expectedRecords, signal) => retry(() => request(`${stagePath(stageId)}/commit`, "POST", { expectedBatches, expectedRecords }, signal), signal);
|
|
91
|
+
const writeOne = (operation, objectID, record, options) => {
|
|
92
|
+
if (!Number.isSafeInteger(options.expectedVersion) || options.expectedVersion < 0)
|
|
93
|
+
throw new RangeError("expectedVersion must be a non-negative integer");
|
|
94
|
+
const idempotencyKey = options.idempotencyKey ?? globalThis.crypto.randomUUID();
|
|
95
|
+
const payload = { operation, objectID, ...record === undefined ? {} : { record }, expectedVersion: options.expectedVersion, idempotencyKey };
|
|
96
|
+
return retry(() => request("/record-writes", "POST", payload, options.signal), options.signal);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
search: (query = {}, signal) => request("/search", "POST", query, signal),
|
|
100
|
+
upsert: (records, signal) => request("/records", "POST", { records }, signal),
|
|
101
|
+
getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
|
|
102
|
+
upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
|
|
103
|
+
deleteOne: (objectID, options) => writeOne("delete", objectID, undefined, options),
|
|
104
|
+
replaceAll: (records, signal) => request("/records", "PUT", { records }, signal),
|
|
105
|
+
beginReplacement,
|
|
106
|
+
uploadReplacementBatch,
|
|
107
|
+
commitReplacement,
|
|
108
|
+
abortReplacement: (stageId, signal) => retry(() => request(stagePath(stageId), "DELETE", undefined, signal), signal),
|
|
109
|
+
replaceAllStaged: async (records, options = {}) => {
|
|
110
|
+
const stageId = options.stageId ?? globalThis.crypto.randomUUID();
|
|
111
|
+
const batchSize = options.batchSize ?? 500;
|
|
112
|
+
const maxBatchBytes = options.maxBatchBytes ?? 8 * 1024 * 1024;
|
|
113
|
+
if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 1000)
|
|
114
|
+
throw new RangeError("batchSize must be between 1 and 1,000");
|
|
115
|
+
if (!Number.isInteger(maxBatchBytes) || maxBatchBytes < 32 || maxBatchBytes > 32 * 1024 * 1024)
|
|
116
|
+
throw new RangeError("maxBatchBytes must be between 32 bytes and 32 MiB");
|
|
117
|
+
try {
|
|
118
|
+
const stage = await beginReplacement(stageId, options.signal);
|
|
119
|
+
if (stage.status === "committed")
|
|
120
|
+
return { acceptedRevision: stage.acceptedRevision, recordCount: stage.recordCount, replaced: true };
|
|
121
|
+
if (stage.status !== "open")
|
|
122
|
+
throw new Error(`Stage is ${stage.status}`);
|
|
123
|
+
let batch = [];
|
|
124
|
+
let batchBytes = 14;
|
|
125
|
+
let batchCount = 0;
|
|
126
|
+
let recordCount = 0;
|
|
127
|
+
const upload = async () => {
|
|
128
|
+
if (batch.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
await uploadReplacementBatch(stageId, String(batchCount), batch, options.signal);
|
|
131
|
+
recordCount += batch.length;
|
|
132
|
+
batchCount++;
|
|
133
|
+
batch = [];
|
|
134
|
+
batchBytes = 14;
|
|
135
|
+
};
|
|
136
|
+
for await (const record of records) {
|
|
137
|
+
options.signal?.throwIfAborted();
|
|
138
|
+
const recordBytes = new TextEncoder().encode(JSON.stringify(record)).byteLength;
|
|
139
|
+
if (recordBytes + 14 > maxBatchBytes)
|
|
140
|
+
throw new RangeError(`A record exceeds maxBatchBytes (${maxBatchBytes})`);
|
|
141
|
+
if (batch.length && (batch.length >= batchSize || batchBytes + recordBytes + 1 > maxBatchBytes))
|
|
142
|
+
await upload();
|
|
143
|
+
batch.push(record);
|
|
144
|
+
batchBytes += recordBytes + 1;
|
|
145
|
+
}
|
|
146
|
+
await upload();
|
|
147
|
+
return await commitReplacement(stageId, batchCount, recordCount, options.signal);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new StagedReplacementError(stageId, error);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
delete: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "DELETE", undefined, signal),
|
|
153
|
+
getSettings: (signal) => request("/config", "GET", undefined, signal),
|
|
154
|
+
replaceSettings: (settings, signal) => {
|
|
155
|
+
const { rebuild: _rebuild, rebuilt: _rebuilt, applied: _applied, ...body } = settings;
|
|
156
|
+
return request("/config", "PUT", body, signal);
|
|
157
|
+
},
|
|
158
|
+
waitForSettings: async (options = {}) => {
|
|
159
|
+
const deadline = Date.now() + (options.timeoutMs ?? 30 * 60000);
|
|
160
|
+
while (true) {
|
|
161
|
+
options.signal?.throwIfAborted();
|
|
162
|
+
const settings = await request("/config", "GET", undefined, options.signal);
|
|
163
|
+
if (settings.rebuild?.status === "failed")
|
|
164
|
+
throw new Error(`Settings rebuild failed: ${settings.rebuild.message}`);
|
|
165
|
+
if (!settings.rebuild)
|
|
166
|
+
return settings;
|
|
167
|
+
if (Date.now() >= deadline)
|
|
168
|
+
throw new Error("Timed out waiting for the settings rebuild");
|
|
169
|
+
await pause(Math.min(options.intervalMs ?? 1000, deadline - Date.now()), options.signal);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
getPublicSearchAccess: (signal) => request("/public-access", "GET", undefined, signal),
|
|
173
|
+
setPublicSearchPolicy: (policy, signal) => request("/public-access/policy", "PUT", { policy }, signal),
|
|
174
|
+
usePublicIndexDefaults: (signal) => request("/public-access/policy", "DELETE", undefined, signal),
|
|
175
|
+
getIndexingStatus: (signal) => request("/indexing", "GET", undefined, signal),
|
|
176
|
+
waitUntilIndexed: async (revision, options = {}) => {
|
|
177
|
+
const deadline = Date.now() + (options.timeoutMs ?? 30000);
|
|
178
|
+
const interval = options.intervalMs ?? 100;
|
|
179
|
+
while (true) {
|
|
180
|
+
options.signal?.throwIfAborted();
|
|
181
|
+
const status = await request("/indexing", "GET", undefined, options.signal);
|
|
182
|
+
if (status.lastError)
|
|
183
|
+
throw new Error(status.lastError);
|
|
184
|
+
if (status.indexedRevision >= revision)
|
|
185
|
+
return status;
|
|
186
|
+
if (Date.now() >= deadline)
|
|
187
|
+
throw new Error(`Timed out waiting for revision ${revision}`);
|
|
188
|
+
await pause(Math.min(interval, deadline - Date.now()), options.signal);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export {
|
|
195
|
+
FindlaneError,
|
|
196
|
+
SearchServerClient,
|
|
197
|
+
StagedReplacementError,
|
|
198
|
+
defaultBaseUrl
|
|
199
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@findlane/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Findlane server SDK: upload catalogs, manage index settings, and search with a secret workspace API key.",
|
|
5
|
+
"keywords": ["findlane", "search", "full-text search", "typo tolerance", "ecommerce search", "search api", "sdk"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://findlane.dev/docs/server-sdk",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"engines": { "node": ">=18" },
|
|
21
|
+
"publishConfig": { "access": "public" },
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "rm -rf dist && bun build src/index.ts --target=node --format=esm --outdir=dist && tsc -p tsconfig.build.json",
|
|
24
|
+
"test": "bun test tests",
|
|
25
|
+
"prepublishOnly": "bun run build && bun run test"
|
|
26
|
+
}
|
|
27
|
+
}
|