@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/dist/index.js CHANGED
@@ -1,6 +1,21 @@
1
- import { LeaniError, responseError, errorFromBody } from "./errors.js";
2
- export { LeaniError } from "./errors.js";
3
- export const SDK_VERSION = "0.1.0-rc.1";
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 = {}) => get(`v1/processors/${encodeURIComponent(id)}/collections/${encodeURIComponent(collection)}/entities`, outputQueryValues(queryOptions), queryOptions.signal),
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
- if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) {
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 deadline = AbortSignal.timeout(options.timeoutMs);
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 === undefined
186
- ? extraHeaders
187
- : { "content-type": "application/json", ...extraHeaders }),
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: combined,
190
- });
202
+ signal: withDeadline(signal, options.timeoutMs),
203
+ }, signal);
191
204
  if (!response.ok) {
192
205
  throw await responseError(response);
193
206
  }
194
- return (await response.json());
207
+ return readJson(response, signal);
195
208
  }
196
209
  async function requestVoid(options, method, path, signal) {
197
- const deadline = AbortSignal.timeout(options.timeoutMs);
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: combined,
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
- while (!subscribeOptions.signal?.aborted) {
215
- let response;
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
- response = await options.fetch(buildUrl(options, `v1/processors/${encodeURIComponent(processor)}/stream`, { after }), {
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: subscribeOptions.signal,
220
- });
221
- }
222
- catch (error) {
223
- if (subscribeOptions.signal?.aborted) {
224
- return;
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
- await abortableDelay(jitter(delayMs, options.reconnect.jitter), subscribeOptions.signal);
227
- delayMs = Math.min(delayMs * 2, options.reconnect.maxDelayMs);
228
- continue;
229
- }
230
- if (!response.ok) {
231
- const error = await responseError(response);
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
- await abortableDelay(jitter(delayMs, options.reconnect.jitter), subscribeOptions.signal);
236
- delayMs = Math.min(delayMs * 2, options.reconnect.maxDelayMs);
237
- continue;
238
- }
239
- if (!response.body) {
240
- throw new LeaniError("stream response has no body", {
241
- status: response.status,
242
- code: "internal",
243
- retryable: true,
244
- });
245
- }
246
- delayMs = options.reconnect.initialDelayMs;
247
- try {
248
- for await (const message of parseSse(response.body, subscribeOptions.signal)) {
249
- if (!message.data) {
250
- continue;
251
- }
252
- if (message.event === "hello") {
253
- subscribeOptions.onHello?.(JSON.parse(message.data));
254
- continue;
255
- }
256
- if (message.event === "error") {
257
- throw errorFromBody(200, JSON.parse(message.data));
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 (subscribeOptions.signal?.aborted)
306
+ if (signal?.aborted)
275
307
  return;
276
- if (!(error instanceof StreamReadError))
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
- if (subscribeOptions.signal?.aborted) {
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), subscribeOptions.signal);
320
+ await abortableDelay(jitter(delayMs, options.reconnect.jitter), signal);
285
321
  delayMs = Math.min(delayMs * 2, options.reconnect.maxDelayMs);
286
322
  }
287
323
  }
288
- class StreamReadError extends Error {
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
- const reader = body.getReader();
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
- let buffer = "";
294
- const cancel = () => { void reader.cancel().catch(() => undefined); };
295
- signal?.addEventListener("abort", cancel, { once: true });
296
- if (signal?.aborted)
297
- cancel();
298
- try {
299
- while (true) {
300
- const { done, value } = await reader.read().catch((cause) => {
301
- throw new StreamReadError("SSE connection interrupted", { cause });
302
- });
303
- buffer += decoder.decode(value, { stream: !done });
304
- let boundary = findSseFrameBoundary(buffer, done);
305
- while (boundary) {
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
- if (done) {
317
- // Only a blank line completes an SSE event. Discard a partial final
318
- // frame so reconnect resumes from the last fully delivered cursor.
319
- break;
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
- finally {
324
- signal?.removeEventListener("abort", cancel);
325
- await reader.cancel().catch(() => undefined);
326
- reader.releaseLock();
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
- return null;
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 sseLineEndingLength(buffer, index, endOfStream) {
344
- const character = buffer[index];
345
- if (character === "\n") {
346
- return 1;
347
- }
348
- if (character !== "\r") {
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 = {};