@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 +6 -1
- package/dist/backfill.d.ts +73 -1
- package/dist/backfill.js +179 -6
- package/dist/backfill.js.map +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/openapi.generated.d.ts +251 -14
- package/dist/openapi.generated.js.map +1 -1
- package/package.json +2 -2
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
|
|
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.
|
package/dist/backfill.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
439
|
-
reject(signal
|
|
611
|
+
signal?.removeEventListener("abort", abort);
|
|
612
|
+
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
440
613
|
};
|
|
441
|
-
signal
|
|
614
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
442
615
|
});
|
|
443
616
|
}
|
|
444
617
|
function iterateUnifiedLanes(lanes, claim) {
|