@smart-byte/leani-sdk 0.1.0-rc.1 → 0.1.0-rc.3
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 +23 -8
- package/dist/backfill.d.ts +39 -3
- package/dist/backfill.js +295 -191
- package/dist/backfill.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +58 -5
- package/dist/index.js +186 -150
- package/dist/index.js.map +1 -1
- package/dist/openapi.generated.d.ts +681 -92
- package/dist/openapi.generated.js.map +1 -1
- package/dist/transport.d.ts +39 -0
- package/dist/transport.js +166 -0
- package/dist/transport.js.map +1 -0
- package/package.json +11 -8
- package/dist/backfill.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/openapi.generated.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { LeaniError, responseError, errorFromBody } from "./errors.js";
|
|
2
|
-
|
|
3
|
-
export
|
|
1
|
+
import { LeaniError, TransportError, responseError, errorFromBody, } from "./errors.js";
|
|
2
|
+
import { concatBytes, idleWatchdog, readChunks, readJson, resolveUnder, send, withDeadline, } from "./transport.js";
|
|
3
|
+
export { LeaniError, TransportError } from "./errors.js";
|
|
4
|
+
// Kept literal for toolchain compatibility; tests pin it to package.json.
|
|
5
|
+
export const SDK_VERSION = "0.1.0-rc.3";
|
|
6
|
+
/** Sent on mutations without a JSON body, which the node otherwise refuses. */
|
|
7
|
+
const LEANI_REQUEST_HEADER = Object.freeze({
|
|
8
|
+
"x-leani-request": "1",
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* Whether `change` announces blocks the live lane skipped, as after a
|
|
12
|
+
* restart at a later finalized anchor. Nothing else delivers them: request
|
|
13
|
+
* them as history, for example with `liveGapBackfill` from
|
|
14
|
+
* `@smart-byte/leani-sdk/backfill`.
|
|
15
|
+
*/
|
|
16
|
+
export function isLiveGapChange(change) {
|
|
17
|
+
return change.operation === "finalized" && change.kind === "system.live_gap.put";
|
|
18
|
+
}
|
|
4
19
|
export class ResetRequiredError extends LeaniError {
|
|
5
20
|
change;
|
|
6
21
|
constructor(change) {
|
|
@@ -49,7 +64,13 @@ export function createLeaniClient(options) {
|
|
|
49
64
|
changeHead: (id, options) => get(`v1/processors/${encodeURIComponent(id)}/changes/head`, undefined, options?.signal),
|
|
50
65
|
resetLiveLane: (id, options) => post(`admin/v1/processors/${encodeURIComponent(id)}/lanes/live/reset`, undefined, options?.signal),
|
|
51
66
|
listCollections: (id, options) => get(`v1/processors/${encodeURIComponent(id)}/collections`, undefined, options?.signal),
|
|
52
|
-
queryEntities: (id, collection, queryOptions = {}) =>
|
|
67
|
+
queryEntities: (id, collection, queryOptions = {}) => {
|
|
68
|
+
const path = `v1/processors/${encodeURIComponent(id)}/collections/${encodeURIComponent(collection)}/entities`;
|
|
69
|
+
// Only POST creates a snapshot; its later pages are read by GET.
|
|
70
|
+
return queryOptions.cursor === undefined
|
|
71
|
+
? post(path, outputQueryBody(queryOptions), queryOptions.signal)
|
|
72
|
+
: get(path, outputQueryValues(queryOptions), queryOptions.signal);
|
|
73
|
+
},
|
|
53
74
|
queryAndFollow: (id, collection, queryOptions = {}) => post(`v1/processors/${encodeURIComponent(id)}/collections/${encodeURIComponent(collection)}/query-and-follow`, outputQueryBody(queryOptions), queryOptions.signal),
|
|
54
75
|
getEntity: (id, collection, key, options) => get(`v1/processors/${encodeURIComponent(id)}/collections/${encodeURIComponent(collection)}/entities/${encodeURIComponent(assertEntityKey(key))}`, undefined, options?.signal),
|
|
55
76
|
releaseSnapshot: (id, snapshotId, options) => remove(`v1/processors/${encodeURIComponent(id)}/query-snapshots/${encodeURIComponent(snapshotId)}`, options?.signal),
|
|
@@ -159,13 +180,7 @@ function normalizeQueryBasePath(configured, fallback) {
|
|
|
159
180
|
return path;
|
|
160
181
|
}
|
|
161
182
|
function buildUrl(options, path, query) {
|
|
162
|
-
|
|
163
|
-
throw new TypeError("request path must not contain an origin or URL scheme");
|
|
164
|
-
}
|
|
165
|
-
const url = new URL(path, options.baseUrl);
|
|
166
|
-
if (url.origin !== options.baseUrl.origin) {
|
|
167
|
-
throw new TypeError("request path must resolve to the configured Leani origin");
|
|
168
|
-
}
|
|
183
|
+
const url = resolveUnder(options.baseUrl, path);
|
|
169
184
|
if (query) {
|
|
170
185
|
for (const [key, value] of Object.entries(query)) {
|
|
171
186
|
if (value !== undefined) {
|
|
@@ -176,185 +191,206 @@ function buildUrl(options, path, query) {
|
|
|
176
191
|
return url;
|
|
177
192
|
}
|
|
178
193
|
async function requestJson(options, method, path, query, body, signal, extraHeaders) {
|
|
179
|
-
const
|
|
180
|
-
const combined = signal
|
|
181
|
-
? AbortSignal.any([signal, deadline])
|
|
182
|
-
: deadline;
|
|
183
|
-
const response = await options.fetch(buildUrl(options, path, query), {
|
|
194
|
+
const response = await send(options.fetch, buildUrl(options, path, query), {
|
|
184
195
|
method,
|
|
185
|
-
headers: headers(options, "application/json", body
|
|
186
|
-
? extraHeaders
|
|
187
|
-
:
|
|
196
|
+
headers: headers(options, "application/json", body !== undefined
|
|
197
|
+
? { "content-type": "application/json", ...extraHeaders }
|
|
198
|
+
: method === "GET"
|
|
199
|
+
? extraHeaders
|
|
200
|
+
: { ...LEANI_REQUEST_HEADER, ...extraHeaders }),
|
|
188
201
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
189
|
-
signal:
|
|
190
|
-
});
|
|
202
|
+
signal: withDeadline(signal, options.timeoutMs),
|
|
203
|
+
}, signal);
|
|
191
204
|
if (!response.ok) {
|
|
192
205
|
throw await responseError(response);
|
|
193
206
|
}
|
|
194
|
-
return (
|
|
207
|
+
return readJson(response, signal);
|
|
195
208
|
}
|
|
196
209
|
async function requestVoid(options, method, path, signal) {
|
|
197
|
-
const
|
|
198
|
-
const combined = signal
|
|
199
|
-
? AbortSignal.any([signal, deadline])
|
|
200
|
-
: deadline;
|
|
201
|
-
const response = await options.fetch(buildUrl(options, path), {
|
|
210
|
+
const response = await send(options.fetch, buildUrl(options, path), {
|
|
202
211
|
method,
|
|
203
|
-
headers: headers(options, "application/json"),
|
|
204
|
-
signal:
|
|
205
|
-
});
|
|
212
|
+
headers: headers(options, "application/json", LEANI_REQUEST_HEADER),
|
|
213
|
+
signal: withDeadline(signal, options.timeoutMs),
|
|
214
|
+
}, signal);
|
|
206
215
|
if (!response.ok) {
|
|
207
216
|
throw await responseError(response);
|
|
208
217
|
}
|
|
209
218
|
}
|
|
219
|
+
/** The default `idleTimeoutMs`: four of the node's keep-alive intervals. */
|
|
220
|
+
const DEFAULT_SSE_IDLE_TIMEOUT_MS = 60_000;
|
|
221
|
+
/**
|
|
222
|
+
* Retryable `error` events in a row, with no new change between them, after
|
|
223
|
+
* which a subscription throws the last one instead of reconnecting again.
|
|
224
|
+
*/
|
|
225
|
+
const MAX_ERROR_EVENT_RETRIES = 5;
|
|
210
226
|
async function* subscribeProcessor(options, processor, subscribeOptions) {
|
|
227
|
+
const signal = subscribeOptions.signal;
|
|
228
|
+
const idleTimeoutMs = subscribeOptions.idleTimeoutMs ?? DEFAULT_SSE_IDLE_TIMEOUT_MS;
|
|
229
|
+
if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs <= 0) {
|
|
230
|
+
throw new TypeError("idleTimeoutMs must be a positive safe integer");
|
|
231
|
+
}
|
|
211
232
|
let after = subscribeOptions.after;
|
|
212
233
|
let lastSequence;
|
|
213
234
|
let delayMs = options.reconnect.initialDelayMs;
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
let errorEventsWithoutProgress = 0;
|
|
236
|
+
while (!signal?.aborted) {
|
|
237
|
+
const connection = new AbortController();
|
|
238
|
+
const connectionSignal = signal
|
|
239
|
+
? AbortSignal.any([signal, connection.signal])
|
|
240
|
+
: connection.signal;
|
|
241
|
+
const watchdog = idleWatchdog(idleTimeoutMs, () => connection.abort(new TransportError(`SSE stream sent nothing for ${idleTimeoutMs} ms`)));
|
|
216
242
|
try {
|
|
217
|
-
|
|
243
|
+
watchdog.arm();
|
|
244
|
+
const response = await send(options.fetch, buildUrl(options, `v1/processors/${encodeURIComponent(processor)}/stream`, { after }), {
|
|
218
245
|
headers: headers(options, "text/event-stream"),
|
|
219
|
-
signal:
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
246
|
+
signal: connectionSignal,
|
|
247
|
+
}, signal);
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
const error = await responseError(response);
|
|
250
|
+
if (!error.retryable) {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
225
253
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (!error.retryable) {
|
|
233
|
-
throw error;
|
|
254
|
+
else if (!response.body) {
|
|
255
|
+
throw new LeaniError("stream response has no body", {
|
|
256
|
+
status: response.status,
|
|
257
|
+
code: "internal",
|
|
258
|
+
retryable: true,
|
|
259
|
+
});
|
|
234
260
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
else {
|
|
262
|
+
// A stream that opens ends the backoff of earlier failures.
|
|
263
|
+
delayMs = options.reconnect.initialDelayMs;
|
|
264
|
+
const messages = sseMessages(readChunks(response.body, connectionSignal, watchdog.arm), signal);
|
|
265
|
+
for await (const message of messages) {
|
|
266
|
+
if (!message.data) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (message.event === "hello") {
|
|
270
|
+
subscribeOptions.onHello?.(JSON.parse(message.data));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (message.event === "error") {
|
|
274
|
+
const error = errorFromBody(200, JSON.parse(message.data));
|
|
275
|
+
errorEventsWithoutProgress += 1;
|
|
276
|
+
if (!error.retryable ||
|
|
277
|
+
errorEventsWithoutProgress > MAX_ERROR_EVENT_RETRIES) {
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
// The node ends the stream after an error event. Opening the
|
|
281
|
+
// stream reset the backoff, so it grows with these events.
|
|
282
|
+
delayMs = Math.min(options.reconnect.initialDelayMs *
|
|
283
|
+
2 ** (errorEventsWithoutProgress - 1), options.reconnect.maxDelayMs);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
const change = JSON.parse(message.data);
|
|
287
|
+
validateChangeEnvelope(change);
|
|
288
|
+
if (change.operation === "reset_required") {
|
|
289
|
+
throw new ResetRequiredError(change);
|
|
290
|
+
}
|
|
291
|
+
if (lastSequence !== undefined &&
|
|
292
|
+
compareSequences(change.sequence, lastSequence) <= 0) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
lastSequence = change.sequence;
|
|
296
|
+
after = change.cursor;
|
|
297
|
+
errorEventsWithoutProgress = 0;
|
|
298
|
+
// The time the application spends on a change is not silence.
|
|
299
|
+
watchdog.disarm();
|
|
300
|
+
yield change;
|
|
301
|
+
watchdog.arm();
|
|
258
302
|
}
|
|
259
|
-
const change = JSON.parse(message.data);
|
|
260
|
-
validateChangeEnvelope(change);
|
|
261
|
-
if (change.operation === "reset_required") {
|
|
262
|
-
throw new ResetRequiredError(change);
|
|
263
|
-
}
|
|
264
|
-
if (lastSequence !== undefined &&
|
|
265
|
-
compareSequences(change.sequence, lastSequence) <= 0) {
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
lastSequence = change.sequence;
|
|
269
|
-
after = change.cursor;
|
|
270
|
-
yield change;
|
|
271
303
|
}
|
|
272
304
|
}
|
|
273
305
|
catch (error) {
|
|
274
|
-
if (
|
|
306
|
+
if (signal?.aborted)
|
|
275
307
|
return;
|
|
276
|
-
|
|
308
|
+
// Resume only transport failures; malformed events, terminal errors,
|
|
309
|
+
// and user callbacks must fail visibly instead of reconnecting forever.
|
|
310
|
+
if (!(error instanceof TransportError))
|
|
277
311
|
throw error;
|
|
278
|
-
// Resume only transport failures; malformed events and user callbacks
|
|
279
|
-
// must fail visibly instead of entering an endless reconnect loop.
|
|
280
312
|
}
|
|
281
|
-
|
|
313
|
+
finally {
|
|
314
|
+
watchdog.disarm();
|
|
315
|
+
connection.abort();
|
|
316
|
+
}
|
|
317
|
+
if (signal?.aborted) {
|
|
282
318
|
break;
|
|
283
319
|
}
|
|
284
|
-
await abortableDelay(jitter(delayMs, options.reconnect.jitter),
|
|
320
|
+
await abortableDelay(jitter(delayMs, options.reconnect.jitter), signal);
|
|
285
321
|
delayMs = Math.min(delayMs * 2, options.reconnect.maxDelayMs);
|
|
286
322
|
}
|
|
287
323
|
}
|
|
288
|
-
|
|
289
|
-
|
|
324
|
+
/** The largest SSE event the client buffers before it fails the stream. */
|
|
325
|
+
const MAX_SSE_EVENT_BYTES = 8 * 1024 * 1024;
|
|
326
|
+
const LINE_FEED = 0x0a;
|
|
327
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
290
328
|
export async function* parseSse(body, signal) {
|
|
291
|
-
|
|
329
|
+
yield* sseMessages(readChunks(body, signal), signal);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Split SSE events at blank lines, scanning each byte once. Line endings are
|
|
333
|
+
* ASCII, so a scan by byte never splits a UTF-8 character.
|
|
334
|
+
*/
|
|
335
|
+
async function* sseMessages(chunks, signal) {
|
|
292
336
|
const decoder = new TextDecoder();
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (signal?.aborted)
|
|
307
|
-
return;
|
|
308
|
-
const frame = buffer.slice(0, boundary.index);
|
|
309
|
-
buffer = buffer.slice(boundary.index + boundary.length);
|
|
310
|
-
const parsed = parseSseFrame(frame);
|
|
311
|
-
if (parsed) {
|
|
312
|
-
yield parsed;
|
|
313
|
-
}
|
|
314
|
-
boundary = findSseFrameBoundary(buffer, done);
|
|
337
|
+
// The bytes received so far of the event in progress.
|
|
338
|
+
let pending = [];
|
|
339
|
+
let pendingBytes = 0;
|
|
340
|
+
let lineEmpty = true;
|
|
341
|
+
let afterCarriageReturn = false;
|
|
342
|
+
for await (const chunk of chunks) {
|
|
343
|
+
let eventStart = 0;
|
|
344
|
+
for (let index = 0; index < chunk.length; index += 1) {
|
|
345
|
+
const byte = chunk[index];
|
|
346
|
+
if (byte === LINE_FEED && afterCarriageReturn) {
|
|
347
|
+
// The second half of a CRLF, which chunks may split.
|
|
348
|
+
afterCarriageReturn = false;
|
|
349
|
+
continue;
|
|
315
350
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
351
|
+
afterCarriageReturn = byte === CARRIAGE_RETURN;
|
|
352
|
+
if (byte !== LINE_FEED && byte !== CARRIAGE_RETURN) {
|
|
353
|
+
lineEmpty = false;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!lineEmpty) {
|
|
357
|
+
lineEmpty = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// A blank line completes the event.
|
|
361
|
+
const tail = chunk.subarray(eventStart, index);
|
|
362
|
+
eventStart = index + 1;
|
|
363
|
+
if (pendingBytes + tail.length > MAX_SSE_EVENT_BYTES) {
|
|
364
|
+
throw sseEventTooLarge();
|
|
365
|
+
}
|
|
366
|
+
const event = concatBytes(pending, pendingBytes, tail);
|
|
367
|
+
pending = [];
|
|
368
|
+
pendingBytes = 0;
|
|
369
|
+
if (signal?.aborted)
|
|
370
|
+
return;
|
|
371
|
+
const parsed = parseSseFrame(decoder.decode(event));
|
|
372
|
+
if (parsed) {
|
|
373
|
+
yield parsed;
|
|
320
374
|
}
|
|
321
375
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
function findSseFrameBoundary(buffer, endOfStream) {
|
|
330
|
-
for (let index = 0; index < buffer.length; index += 1) {
|
|
331
|
-
const firstLength = sseLineEndingLength(buffer, index, endOfStream);
|
|
332
|
-
if (firstLength === 0) {
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
const secondLength = sseLineEndingLength(buffer, index + firstLength, endOfStream);
|
|
336
|
-
if (secondLength > 0) {
|
|
337
|
-
return { index, length: firstLength + secondLength };
|
|
376
|
+
const rest = chunk.subarray(eventStart);
|
|
377
|
+
if (rest.length > 0) {
|
|
378
|
+
pendingBytes += rest.length;
|
|
379
|
+
if (pendingBytes > MAX_SSE_EVENT_BYTES) {
|
|
380
|
+
throw sseEventTooLarge();
|
|
381
|
+
}
|
|
382
|
+
pending.push(rest);
|
|
338
383
|
}
|
|
339
|
-
index += firstLength - 1;
|
|
340
384
|
}
|
|
341
|
-
|
|
385
|
+
// Only a blank line completes an SSE event. A partial final event is
|
|
386
|
+
// discarded, so a reconnect resumes from the last complete one.
|
|
342
387
|
}
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return 0;
|
|
350
|
-
}
|
|
351
|
-
if (buffer[index + 1] === "\n") {
|
|
352
|
-
return 2;
|
|
353
|
-
}
|
|
354
|
-
if (index + 1 < buffer.length || endOfStream) {
|
|
355
|
-
return 1;
|
|
356
|
-
}
|
|
357
|
-
return 0;
|
|
388
|
+
function sseEventTooLarge() {
|
|
389
|
+
return new LeaniError(`SSE event exceeds ${MAX_SSE_EVENT_BYTES} bytes`, {
|
|
390
|
+
status: 200,
|
|
391
|
+
code: "invalid_response",
|
|
392
|
+
retryable: false,
|
|
393
|
+
});
|
|
358
394
|
}
|
|
359
395
|
function parseSseFrame(frame) {
|
|
360
396
|
const message = {};
|