@smart-byte/leani-sdk 0.1.0-rc.2 → 0.1.0-rc.4

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 CHANGED
@@ -58,6 +58,10 @@ Destination writes must be idempotent because a crash between those steps can
58
58
  replay a change. Acknowledge a batch's `ackCursor` (or `acknowledgeableCursor`),
59
59
  never a change's own cursor; a backfill session's `deliveryBatches()` yields
60
60
  every such boundary with its changes, while `events()` yields changes only.
61
+ `runBackfill(client, request, { onBatch })` runs one application-owned
62
+ subscription to its end: it creates it, applies and acknowledges each batch,
63
+ and deletes it once complete. After a crash or an abort, the identical request
64
+ resumes it.
61
65
 
62
66
  The browser build assumes a same-origin deployment or a reverse proxy that
63
67
  adds the application's CORS policy. Leani does not enable cross-origin access
@@ -68,7 +72,8 @@ JSON body send `x-leani-request: 1`, which the node requires.
68
72
  Typed subscriptions accept a configured `processor` instance when a kind is
69
73
  ambiguous. `blobs.subscribe()` yields a `{ block, transactions }` payload on
70
74
  apply. Check `operation` before reading processor data: a finalized event has
71
- `{ throughBlock }` instead. Pass an `AbortSignal` to stop an idle stream;
75
+ `{ throughBlock }` instead, or, when `isLiveGapChange()` holds, the
76
+ `{ fromBlock, toBlock }` the live lane skipped. Pass an `AbortSignal` to stop an idle stream;
72
77
  leaving the loop cancels its response body.
73
78
 
74
79
  `queryAndFollow()` returns the first snapshot page and an atomic stream boundary.
@@ -1,5 +1,5 @@
1
1
  export { LeaniError, TransportError } from "./errors.ts";
2
- import type { ChangeEnvelope, DurableConsumer, FetchLike, ProcessorSummary } from "./index.ts";
2
+ import type { ChangeEnvelope, DurableConsumer, FetchLike, ProcessorCoverage, ProcessorSummary } from "./index.ts";
3
3
  export type BackfillExecutionMode = "fill_missing" | "recompute";
4
4
  export type BackfillState = "waiting_for_consumer" | "queued" | "running" | "backpressured" | "storage_backpressured" | "draining" | "complete_reclaimable" | "completed" | "failed" | "cancelled";
5
5
  export interface BackfillRange {
@@ -14,6 +14,16 @@ export interface BackfillStatus {
14
14
  id: string;
15
15
  owner: "subscription";
16
16
  processor: string;
17
+ /**
18
+ * Whether the node configures the processor instance `processor` now.
19
+ * Without it, the stream and acknowledgement routes refuse the
20
+ * subscription. That can be temporary; if the instance will not return,
21
+ * cancel the subscription and delete it with `discardUnacknowledged: true`,
22
+ * which discards its unacknowledged records.
23
+ */
24
+ processorConfigured: boolean;
25
+ /** The required consumer; tell your subscriptions apart by it, not by `id`. */
26
+ consumer: string | null;
17
27
  deliveryStreamId?: string;
18
28
  publicationRevision?: string;
19
29
  fromBlock: number;
@@ -87,6 +97,16 @@ export type CreateBackfillSubscription = CreateBackfillSubscriptionBase & ({
87
97
  fromBlock?: never;
88
98
  toBlock?: never;
89
99
  });
100
+ /**
101
+ * The `fill_missing` subscription for the blocks a live gap notice
102
+ * announces, its `LiveGapChangeData`, or any other range the destination
103
+ * lacks. Its idempotency key derives from the consumer and the range, so
104
+ * creating it again, as after the notice is delivered again, returns the
105
+ * same subscription.
106
+ */
107
+ export declare function liveGapBackfill(range: BackfillRange, request: Omit<CreateBackfillSubscriptionBase, "mode" | "idempotencyKey"> & {
108
+ consumer: NonNullable<CreateBackfillSubscriptionBase["consumer"]>;
109
+ }): CreateBackfillSubscription;
90
110
  export interface BackfillStreamHello {
91
111
  type: "hello";
92
112
  apiVersion: "1";
@@ -172,6 +192,9 @@ export interface LiveStreamHello {
172
192
  heartbeatIntervalMs: string;
173
193
  sessionExpiresAtUnixMs: string;
174
194
  leaseTtlMs: string;
195
+ /** What the processor covers as the session starts: diff it against the
196
+ * destination to find blocks to request as history. */
197
+ coverage: ProcessorCoverage;
175
198
  }
176
199
  export interface LiveStreamBatch<T = unknown> {
177
200
  type: "batch";
@@ -285,8 +308,18 @@ export interface BackfillSubscriptionClient {
285
308
  cancel(subscription: string, options?: {
286
309
  signal?: AbortSignal;
287
310
  }): Promise<BackfillStatus>;
311
+ /** Re-queue a failed subscription to resume from its committed progress. */
312
+ retry(subscription: string, options?: {
313
+ signal?: AbortSignal;
314
+ }): Promise<BackfillStatus>;
315
+ /**
316
+ * Delete a terminal subscription. A failed or cancelled one is refused
317
+ * while its required consumer has unacknowledged records, unless
318
+ * `discardUnacknowledged` is set.
319
+ */
288
320
  delete(subscription: string, options?: {
289
321
  signal?: AbortSignal;
322
+ discardUnacknowledged?: boolean;
290
323
  }): Promise<HistoricalWorkDeletion>;
291
324
  changes<T = unknown>(subscription: string, consumer: string, options?: {
292
325
  credential?: string;
@@ -309,4 +342,43 @@ export interface BackfillSubscriptionClient {
309
342
  subscribe<T = unknown>(request: MultiLaneSubscriptionRequest): Promise<MultiLaneDelivery<T>>;
310
343
  }
311
344
  export declare function createBackfillSubscriptionClient(options: BackfillSubscriptionClientOptions): BackfillSubscriptionClient;
345
+ export type BackfillFailurePolicy = "retry" | "discard" | "throw";
346
+ export interface RunBackfillOptions<T = unknown> {
347
+ /** Apply one history batch. It is acknowledged after this resolves. */
348
+ onBatch(batch: HistoryDeliveryBatch<T>): void | Promise<void>;
349
+ /** What a failed subscription does: retry it (default), delete it, or throw. */
350
+ onFailure?: BackfillFailurePolicy;
351
+ /** Retries before "retry" gives up and throws. Default 3. */
352
+ maxRetries?: number;
353
+ /** The consumer's credential; `request.consumer.credential` by default. */
354
+ credential?: string;
355
+ batching?: BackfillBatchingRequest;
356
+ /**
357
+ * Stops the run, as `request.signal` does too. The run rejects with the
358
+ * abort reason and keeps the subscription.
359
+ */
360
+ signal?: AbortSignal;
361
+ }
362
+ export interface RunBackfillResult {
363
+ outcome: "completed" | "cancelled" | "failed";
364
+ /** The subscription as it ended, before its deletion. */
365
+ subscription: BackfillStatus;
366
+ /** The completion record this run received, if it streamed one. */
367
+ completion?: BackfillStreamCompletion;
368
+ deletion: HistoricalWorkDeletion;
369
+ }
370
+ /**
371
+ * Run an application-owned history subscription to its end: create it,
372
+ * apply and acknowledge each history batch, and delete it once complete.
373
+ * Creation is idempotent by `idempotencyKey`, so running the identical
374
+ * request again, as after a crash, resumes the same subscription after its
375
+ * last acknowledged batch; a batch applied but not acknowledged is delivered
376
+ * again, so `onBatch` must be idempotent. After a lost connection or
377
+ * consumer session, it backs off, reads the subscription's state, and opens
378
+ * the stream again unless the subscription ended meanwhile. A failed
379
+ * subscription follows `onFailure`; a cancelled one is deleted with its
380
+ * unacknowledged records. An `onBatch` error, an abort, or any other failure
381
+ * closes the session and is thrown, leaving the subscription for a rerun.
382
+ */
383
+ export declare function runBackfill<T = unknown>(client: BackfillSubscriptionClient, request: CreateBackfillSubscription, options: RunBackfillOptions<T>): Promise<RunBackfillResult>;
312
384
  export declare function parseNdjson(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<unknown>;
package/dist/backfill.js CHANGED
@@ -30,6 +30,22 @@ const BACKFILL_BATCHING_PROFILES = {
30
30
  export function backfillBatchingProfile(profile) {
31
31
  return { ...BACKFILL_BATCHING_PROFILES[profile] };
32
32
  }
33
+ /**
34
+ * The `fill_missing` subscription for the blocks a live gap notice
35
+ * announces, its `LiveGapChangeData`, or any other range the destination
36
+ * lacks. Its idempotency key derives from the consumer and the range, so
37
+ * creating it again, as after the notice is delivered again, returns the
38
+ * same subscription.
39
+ */
40
+ export function liveGapBackfill(range, request) {
41
+ return {
42
+ ...request,
43
+ mode: "fill_missing",
44
+ fromBlock: range.fromBlock,
45
+ toBlock: range.toBlock,
46
+ idempotencyKey: `live-gap:${request.consumer.id}:${range.fromBlock}-${range.toBlock}`,
47
+ };
48
+ }
33
49
  export class BackfillStreamError extends Error {
34
50
  code;
35
51
  earliestAvailableSequence;
@@ -129,7 +145,8 @@ export function createBackfillSubscriptionClient(options) {
129
145
  },
130
146
  inspect: (subscription, inspectOptions) => requestJson("GET", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, inspectOptions?.signal),
131
147
  cancel: (subscription, cancelOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/cancel`, undefined, cancelOptions?.signal),
132
- delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, deleteOptions?.signal),
148
+ retry: (subscription, retryOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/retry`, undefined, retryOptions?.signal),
149
+ delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}${deleteOptions?.discardUnacknowledged ? "?discardUnacknowledged=true" : ""}`, undefined, deleteOptions?.signal),
133
150
  changes: (subscription, consumer, changeOptions = {}) => {
134
151
  const path = consumerPath(subscription, consumer, "changes");
135
152
  const query = changeOptions.limit === undefined
@@ -142,6 +159,162 @@ export function createBackfillSubscriptionClient(options) {
142
159
  subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, timeoutMs, request),
143
160
  });
144
161
  }
162
+ /**
163
+ * Run an application-owned history subscription to its end: create it,
164
+ * apply and acknowledge each history batch, and delete it once complete.
165
+ * Creation is idempotent by `idempotencyKey`, so running the identical
166
+ * request again, as after a crash, resumes the same subscription after its
167
+ * last acknowledged batch; a batch applied but not acknowledged is delivered
168
+ * again, so `onBatch` must be idempotent. After a lost connection or
169
+ * consumer session, it backs off, reads the subscription's state, and opens
170
+ * the stream again unless the subscription ended meanwhile. A failed
171
+ * subscription follows `onFailure`; a cancelled one is deleted with its
172
+ * unacknowledged records. An `onBatch` error, an abort, or any other failure
173
+ * closes the session and is thrown, leaving the subscription for a rerun.
174
+ */
175
+ export async function runBackfill(client, request, options) {
176
+ const consumer = request.consumer;
177
+ if (consumer === undefined) {
178
+ throw new TypeError("runBackfill requires request.consumer, the consumer that acknowledges the history");
179
+ }
180
+ const maxRetries = options.maxRetries ?? 3;
181
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) {
182
+ throw new TypeError("maxRetries must be a non-negative safe integer");
183
+ }
184
+ // Either signal stops every request of the run.
185
+ const signal = request.signal && options.signal
186
+ ? AbortSignal.any([request.signal, options.signal])
187
+ : (options.signal ?? request.signal);
188
+ let subscription = await client.create({ ...request, signal });
189
+ let completion;
190
+ const finish = async (outcome, discardUnacknowledged) => ({
191
+ outcome,
192
+ subscription,
193
+ completion,
194
+ deletion: await client.delete(subscription.id, {
195
+ signal,
196
+ discardUnacknowledged,
197
+ }),
198
+ });
199
+ let retries = 0;
200
+ let delayMs = 1_000;
201
+ // Whether the subscription may have changed since its status was read.
202
+ let stale = false;
203
+ while (true) {
204
+ const { state } = subscription;
205
+ if (state === "complete_reclaimable" || state === "completed") {
206
+ return finish("completed", false);
207
+ }
208
+ if (state === "cancelled") {
209
+ return finish("cancelled", true);
210
+ }
211
+ if (state === "failed") {
212
+ if (options.onFailure === "discard") {
213
+ return finish("failed", true);
214
+ }
215
+ if (options.onFailure === "throw" || retries >= maxRetries) {
216
+ throw new BackfillStreamError({
217
+ type: "error",
218
+ code: "backfill_failed",
219
+ message: subscription.lastError ?? "the historical subscription failed",
220
+ earliestAvailableSequence: null,
221
+ latestAvailableSequence: null,
222
+ });
223
+ }
224
+ retries += 1;
225
+ subscription = await client.retry(subscription.id, { signal });
226
+ continue;
227
+ }
228
+ try {
229
+ if (stale) {
230
+ subscription = await client.inspect(subscription.id, { signal });
231
+ stale = false;
232
+ continue;
233
+ }
234
+ const session = await client.stream(subscription.id, consumer.id, {
235
+ credential: options.credential ?? consumer.credential,
236
+ batching: options.batching,
237
+ signal,
238
+ });
239
+ delayMs = 1_000;
240
+ await applyHistory(session, options, signal, (received) => {
241
+ completion = received;
242
+ });
243
+ }
244
+ catch (error) {
245
+ if (error instanceof ApplicationFailure) {
246
+ throw error.cause;
247
+ }
248
+ const ended = error instanceof BackfillStreamError &&
249
+ (error.code === "backfill_failed" || error.code === "backfill_cancelled");
250
+ if (!ended) {
251
+ if (!isTransientStreamFailure(error)) {
252
+ throw error;
253
+ }
254
+ await waitForReconnectDelay(delayMs, signal);
255
+ delayMs = Math.min(delayMs * 2, 30_000);
256
+ }
257
+ // The subscription's state decides what follows: it failed or was
258
+ // cancelled, or it may have ended while the stream was down, as when
259
+ // the node applied the completion's acknowledgement but its answer was
260
+ // lost. A stream opened again then would only send heartbeats.
261
+ stale = true;
262
+ continue;
263
+ }
264
+ subscription = await client.inspect(subscription.id, { signal });
265
+ return finish("completed", false);
266
+ }
267
+ }
268
+ /** What the application's `onBatch` threw: never a delivery failure to recover from. */
269
+ class ApplicationFailure {
270
+ cause;
271
+ constructor(cause) {
272
+ this.cause = cause;
273
+ }
274
+ }
275
+ /**
276
+ * Apply and acknowledge each batch of a history session, then acknowledge
277
+ * its completion, which `received` gets first. The session is closed however
278
+ * this ends.
279
+ */
280
+ async function applyHistory(session, options, signal, received) {
281
+ try {
282
+ for await (const batch of session.deliveryBatches()) {
283
+ if (batch.completion === undefined) {
284
+ try {
285
+ await options.onBatch(batch);
286
+ }
287
+ catch (error) {
288
+ throw new ApplicationFailure(error);
289
+ }
290
+ }
291
+ else {
292
+ received(batch.completion);
293
+ }
294
+ await session.acknowledge(batch.ackCursor, { signal });
295
+ if (batch.completion !== undefined) {
296
+ return;
297
+ }
298
+ }
299
+ }
300
+ finally {
301
+ await session.close();
302
+ }
303
+ // The caller's abort ends a session quietly.
304
+ signal?.throwIfAborted();
305
+ throw new TransportError("history stream ended before its completion");
306
+ }
307
+ /**
308
+ * Whether a history stream may be opened again: after a disconnect the SDK
309
+ * reports as retryable, or when the node reports, by HTTP error or stream
310
+ * record, that the consumer's session ended or another connection holds it.
311
+ */
312
+ function isTransientStreamFailure(error) {
313
+ return (isRetryableDeliveryDisconnect(error) ||
314
+ (error instanceof BackfillStreamError &&
315
+ (error.code === "consumer_session_active" ||
316
+ error.code === "consumer_session_lost")));
317
+ }
145
318
  async function openMultiLaneDelivery(fetchImpl, baseUrl, token, timeoutMs, request) {
146
319
  const processor = assertIdentity(request.processor, "processor");
147
320
  const consumer = assertIdentity(request.consumer, "consumer");
@@ -424,21 +597,21 @@ function isRetryableDeliveryDisconnect(error) {
424
597
  error.code === "consumer_session_lost"));
425
598
  }
426
599
  async function waitForReconnectDelay(delayMs, signal) {
427
- if (signal.aborted) {
600
+ if (signal?.aborted) {
428
601
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
429
602
  }
430
603
  await new Promise((resolve, reject) => {
431
604
  const finish = () => {
432
- signal.removeEventListener("abort", abort);
605
+ signal?.removeEventListener("abort", abort);
433
606
  resolve();
434
607
  };
435
608
  const timeout = setTimeout(finish, delayMs);
436
609
  const abort = () => {
437
610
  clearTimeout(timeout);
438
- signal.removeEventListener("abort", abort);
439
- reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
611
+ signal?.removeEventListener("abort", abort);
612
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
440
613
  };
441
- signal.addEventListener("abort", abort, { once: true });
614
+ signal?.addEventListener("abort", abort, { once: true });
442
615
  });
443
616
  }
444
617
  function iterateUnifiedLanes(lanes, claim) {