@smart-byte/leani-sdk 0.1.0-rc.1

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.
@@ -0,0 +1,839 @@
1
+ import { LeaniError, responseError } from "./errors.js";
2
+ export { LeaniError } from "./errors.js";
3
+ const BACKFILL_BATCHING_PROFILES = {
4
+ latency: {
5
+ targetEncodedBytes: 256 * 1024,
6
+ maximumEncodedBytes: 1024 * 1024,
7
+ maximumEvents: 2_000,
8
+ maximumProcessedBlocks: 512,
9
+ maximumDelayMs: 10,
10
+ compression: "gzip",
11
+ },
12
+ balanced: {
13
+ targetEncodedBytes: 1024 * 1024,
14
+ maximumEncodedBytes: 4 * 1024 * 1024,
15
+ maximumEvents: 10_000,
16
+ maximumProcessedBlocks: 4_096,
17
+ maximumDelayMs: 25,
18
+ compression: "gzip",
19
+ },
20
+ throughput: {
21
+ targetEncodedBytes: 4 * 1024 * 1024,
22
+ maximumEncodedBytes: 16 * 1024 * 1024,
23
+ maximumEvents: 20_000,
24
+ maximumProcessedBlocks: 8_192,
25
+ maximumDelayMs: 50,
26
+ compression: "gzip",
27
+ },
28
+ };
29
+ export function backfillBatchingProfile(profile) {
30
+ return { ...BACKFILL_BATCHING_PROFILES[profile] };
31
+ }
32
+ export class BackfillStreamError extends Error {
33
+ code;
34
+ earliestAvailableSequence;
35
+ latestAvailableSequence;
36
+ constructor(record) {
37
+ super(record.message);
38
+ this.name =
39
+ record.type === "reset_required"
40
+ ? "BackfillStreamResetError"
41
+ : "BackfillStreamError";
42
+ this.code = record.code;
43
+ this.earliestAvailableSequence = record.earliestAvailableSequence;
44
+ this.latestAvailableSequence = record.latestAvailableSequence;
45
+ }
46
+ }
47
+ export function createBackfillSubscriptionClient(options) {
48
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
49
+ const fetchImpl = options.fetch ?? globalThis.fetch;
50
+ if (!fetchImpl) {
51
+ throw new TypeError("a fetch implementation is required");
52
+ }
53
+ const timeoutMs = options.timeoutMs ?? 30_000;
54
+ assertPositiveInteger(timeoutMs, "timeoutMs");
55
+ const requestJson = async (method, path, body, signal, credential, sessionToken) => {
56
+ const response = await fetchImpl(new URL(path, baseUrl), {
57
+ method,
58
+ headers: requestHeaders(options.token, "application/json", credential, sessionToken),
59
+ body: body === undefined ? undefined : JSON.stringify(body),
60
+ signal: signal
61
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
62
+ : AbortSignal.timeout(timeoutMs),
63
+ });
64
+ if (!response.ok) {
65
+ throw await responseError(response);
66
+ }
67
+ return (await response.json());
68
+ };
69
+ return Object.freeze({
70
+ baseUrl,
71
+ create: (request) => requestJson("POST", "admin/v1/backfill-subscriptions", {
72
+ processor: request.processor,
73
+ ...(request.ranges === undefined
74
+ ? {
75
+ fromBlock: assertBlock(request.fromBlock, "fromBlock"),
76
+ toBlock: assertUpperBound(request.toBlock, "toBlock"),
77
+ }
78
+ : {
79
+ ranges: request.ranges.map((range, index) => ({
80
+ fromBlock: assertBlock(range.fromBlock, `ranges[${index}].fromBlock`),
81
+ toBlock: assertUpperBound(range.toBlock, `ranges[${index}].toBlock`),
82
+ })),
83
+ }),
84
+ mode: request.mode ?? "fill_missing",
85
+ consumer: request.consumer === undefined
86
+ ? undefined
87
+ : {
88
+ id: assertIdentity(request.consumer.id, "consumer.id"),
89
+ role: request.consumer.role,
90
+ leaseTtlSeconds: assertPositiveInteger(request.consumer.leaseTtlSeconds, "consumer.leaseTtlSeconds"),
91
+ credential: request.consumer.credential === undefined
92
+ ? undefined
93
+ : assertIdentity(request.consumer.credential, "consumer.credential"),
94
+ },
95
+ limits: request.limits === undefined
96
+ ? undefined
97
+ : {
98
+ maxUnacknowledgedBlocks: assertPositiveInteger(request.limits.maxUnacknowledgedBlocks, "limits.maxUnacknowledgedBlocks"),
99
+ maxUnacknowledgedBytes: assertPositiveInteger(request.limits.maxUnacknowledgedBytes, "limits.maxUnacknowledgedBytes"),
100
+ resumeBelowRatio: assertRatio(request.limits.resumeBelowRatio, "limits.resumeBelowRatio"),
101
+ },
102
+ batching: request.batching === undefined
103
+ ? undefined
104
+ : {
105
+ targetEncodedBytes: request.batching.targetEncodedBytes === undefined
106
+ ? undefined
107
+ : assertPositiveInteger(request.batching.targetEncodedBytes, "batching.targetEncodedBytes"),
108
+ maximumEncodedBytes: request.batching.maximumEncodedBytes === undefined
109
+ ? undefined
110
+ : assertPositiveInteger(request.batching.maximumEncodedBytes, "batching.maximumEncodedBytes"),
111
+ maximumEvents: request.batching.maximumEvents === undefined
112
+ ? undefined
113
+ : assertPositiveInteger(request.batching.maximumEvents, "batching.maximumEvents"),
114
+ maximumProcessedBlocks: request.batching.maximumProcessedBlocks === undefined
115
+ ? undefined
116
+ : assertPositiveInteger(request.batching.maximumProcessedBlocks, "batching.maximumProcessedBlocks"),
117
+ maximumDelayMs: request.batching.maximumDelayMs === undefined
118
+ ? undefined
119
+ : assertPositiveInteger(request.batching.maximumDelayMs, "batching.maximumDelayMs"),
120
+ compression: request.batching.compression,
121
+ },
122
+ idempotencyKey: assertIdentity(request.idempotencyKey, "idempotencyKey"),
123
+ }, request.signal),
124
+ list: async (listOptions) => {
125
+ const response = await requestJson("GET", "admin/v1/backfill-subscriptions", undefined, listOptions?.signal);
126
+ return response.data;
127
+ },
128
+ inspect: (subscription, inspectOptions) => requestJson("GET", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, inspectOptions?.signal),
129
+ cancel: (subscription, cancelOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/cancel`, undefined, cancelOptions?.signal),
130
+ delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, deleteOptions?.signal),
131
+ changes: (subscription, consumer, changeOptions = {}) => {
132
+ const path = consumerPath(subscription, consumer, "changes");
133
+ const url = new URL(path, baseUrl);
134
+ if (changeOptions.limit !== undefined) {
135
+ url.searchParams.set("limit", String(assertPositiveInteger(changeOptions.limit, "limit")));
136
+ }
137
+ return fetchImpl(url, {
138
+ headers: requestHeaders(options.token, "application/json", changeOptions.credential),
139
+ signal: changeOptions.signal,
140
+ }).then(async (response) => {
141
+ if (!response.ok) {
142
+ throw await responseError(response);
143
+ }
144
+ return (await response.json());
145
+ });
146
+ },
147
+ stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
148
+ streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, processor, consumer, streamOptions.credential, streamOptions.signal),
149
+ subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, request),
150
+ });
151
+ }
152
+ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
153
+ const processor = assertIdentity(request.processor, "processor");
154
+ const consumer = assertIdentity(request.consumer, "consumer");
155
+ const history = request.lanes.history ?? [];
156
+ const historyIds = history.map(({ subscriptionId }, index) => assertIdentity(subscriptionId, `lanes.history[${index}].subscriptionId`));
157
+ if (new Set(historyIds).size !== historyIds.length) {
158
+ throw new TypeError("history subscription IDs must be unique");
159
+ }
160
+ if (!request.lanes.live && historyIds.length === 0) {
161
+ throw new TypeError("subscribe requires at least one live or history lane");
162
+ }
163
+ const view = request.view ?? "live_priority";
164
+ const lanes = [];
165
+ try {
166
+ if (request.lanes.live) {
167
+ const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, processor, consumer, request.credential, signal);
168
+ lanes.push(unifiedLiveLane(await open(), open, request.signal));
169
+ }
170
+ for (const subscriptionId of historyIds) {
171
+ const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, subscriptionId, consumer, request.credential, request.batching, signal);
172
+ lanes.push(unifiedHistoryLane(subscriptionId, await open(), open, request.signal));
173
+ }
174
+ }
175
+ catch (error) {
176
+ await Promise.allSettled(lanes.map((lane) => lane.close()));
177
+ throw error;
178
+ }
179
+ if (lanes.length > 1 &&
180
+ lanes.some((lane) => lane.deliveryOrdering === "canonical")) {
181
+ await Promise.allSettled(lanes.map((lane) => lane.close()));
182
+ throw new TypeError("canonical processors cannot interleave live and history lanes");
183
+ }
184
+ const claimed = new Set();
185
+ const claim = (selected) => {
186
+ const duplicate = selected.find((lane) => claimed.has(lane.streamId));
187
+ if (duplicate) {
188
+ throw new TypeError(`delivery lane ${duplicate.streamId} is already being iterated`);
189
+ }
190
+ for (const lane of selected) {
191
+ claimed.add(lane.streamId);
192
+ }
193
+ };
194
+ const iterate = (selected) => iterateUnifiedLanes([selected], claim);
195
+ const byStream = new Map(lanes.map((lane) => [lane.streamId, lane]));
196
+ const live = lanes.find((lane) => lane.kind === "live");
197
+ const byHistoryId = new Map(lanes
198
+ .filter((lane) => lane.kind === "history")
199
+ .map((lane) => [lane.laneId, lane]));
200
+ return Object.freeze({
201
+ batches: () => {
202
+ if (view !== "live_priority") {
203
+ throw new TypeError("batches() requires view=live_priority; use lane-specific iterators");
204
+ }
205
+ return iterateUnifiedLanes(lanes, claim);
206
+ },
207
+ liveBatches: () => {
208
+ if (view !== "lane_specific") {
209
+ throw new TypeError("liveBatches() requires view=lane_specific");
210
+ }
211
+ if (!live) {
212
+ throw new TypeError("the delivery subscription has no live lane");
213
+ }
214
+ return iterate(live);
215
+ },
216
+ historyBatches: (subscriptionId) => {
217
+ if (view !== "lane_specific") {
218
+ throw new TypeError("historyBatches() requires view=lane_specific");
219
+ }
220
+ const id = assertIdentity(subscriptionId, "subscriptionId");
221
+ const lane = byHistoryId.get(id);
222
+ if (!lane) {
223
+ throw new TypeError(`history subscription ${id} is not selected`);
224
+ }
225
+ return iterate(lane);
226
+ },
227
+ acknowledge: (batch, acknowledgeOptions) => {
228
+ const lane = byStream.get(batch.streamId);
229
+ if (!lane || lane.laneId !== batch.laneId || lane.kind !== batch.laneKind) {
230
+ throw new TypeError("delivery batch does not belong to this subscription object");
231
+ }
232
+ return lane.acknowledge(batch.ackCursor, acknowledgeOptions);
233
+ },
234
+ close: async () => {
235
+ const outcomes = await Promise.allSettled(lanes.map((lane) => lane.close()));
236
+ const failed = outcomes.find((outcome) => outcome.status === "rejected");
237
+ if (failed) {
238
+ throw failed.reason;
239
+ }
240
+ },
241
+ });
242
+ }
243
+ function unifiedLiveLane(initialSession, open, signal) {
244
+ const connection = reconnectingLaneConnection(initialSession, open, signal);
245
+ return {
246
+ kind: "live",
247
+ laneId: "live",
248
+ streamId: initialSession.hello.streamId,
249
+ deliveryOrdering: initialSession.hello.processor.deliveryOrdering,
250
+ async next() {
251
+ while (true) {
252
+ const next = await connection.next();
253
+ if (next.done)
254
+ return { done: true, value: undefined };
255
+ if (next.value.type === "heartbeat")
256
+ continue;
257
+ const record = next.value;
258
+ return {
259
+ done: false,
260
+ value: {
261
+ laneKind: "live",
262
+ laneId: "live",
263
+ streamId: record.streamId,
264
+ ackCursor: record.acknowledgeableCursor,
265
+ events: record.changes,
266
+ record,
267
+ },
268
+ };
269
+ }
270
+ },
271
+ acknowledge: connection.acknowledge,
272
+ close: connection.close,
273
+ };
274
+ }
275
+ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
276
+ const connection = reconnectingLaneConnection(initialSession, open, signal);
277
+ let complete = false;
278
+ return {
279
+ kind: "history",
280
+ laneId: subscriptionId,
281
+ streamId: initialSession.hello.streamId,
282
+ deliveryOrdering: initialSession.hello.processor.deliveryOrdering,
283
+ async next() {
284
+ if (complete)
285
+ return { done: true, value: undefined };
286
+ while (true) {
287
+ const next = await connection.next();
288
+ if (next.done)
289
+ return { done: true, value: undefined };
290
+ if (next.value.type === "heartbeat")
291
+ continue;
292
+ if (next.value.type === "backfill_complete") {
293
+ complete = true;
294
+ const record = next.value;
295
+ return {
296
+ done: false,
297
+ value: {
298
+ laneKind: "history",
299
+ laneId: subscriptionId,
300
+ streamId: record.streamId,
301
+ ackCursor: record.cursor,
302
+ events: [],
303
+ completion: record,
304
+ record,
305
+ },
306
+ };
307
+ }
308
+ const record = next.value;
309
+ return {
310
+ done: false,
311
+ value: {
312
+ laneKind: "history",
313
+ laneId: subscriptionId,
314
+ streamId: record.streamId,
315
+ ackCursor: record.acknowledgeableCursor,
316
+ events: record.changes,
317
+ record,
318
+ },
319
+ };
320
+ }
321
+ },
322
+ acknowledge: connection.acknowledge,
323
+ close: connection.close,
324
+ };
325
+ }
326
+ function reconnectingLaneConnection(initialSession, open, signal) {
327
+ const expected = initialSession.hello;
328
+ const stop = new AbortController();
329
+ const reconnectSignal = signal
330
+ ? AbortSignal.any([signal, stop.signal])
331
+ : stop.signal;
332
+ let session = initialSession;
333
+ let iterator = session[Symbol.asyncIterator]();
334
+ let closed = false;
335
+ let reconnectTask;
336
+ const reconnect = () => {
337
+ reconnectTask ??= (async () => {
338
+ await session.close().catch(() => undefined);
339
+ let delayMs = 25;
340
+ while (!reconnectSignal.aborted) {
341
+ try {
342
+ const replacement = await open(reconnectSignal);
343
+ try {
344
+ validateReplacementHello(expected, replacement.hello);
345
+ }
346
+ catch (error) {
347
+ await replacement.close().catch(() => undefined);
348
+ throw error;
349
+ }
350
+ session = replacement;
351
+ iterator = replacement[Symbol.asyncIterator]();
352
+ return;
353
+ }
354
+ catch (error) {
355
+ if (!isRetryableDeliveryDisconnect(error))
356
+ throw error;
357
+ await waitForReconnectDelay(delayMs, reconnectSignal);
358
+ delayMs = Math.min(delayMs * 2, 5_000);
359
+ }
360
+ }
361
+ throw reconnectSignal.reason ?? new DOMException("Aborted", "AbortError");
362
+ })().finally(() => {
363
+ reconnectTask = undefined;
364
+ });
365
+ return reconnectTask;
366
+ };
367
+ const next = async () => {
368
+ while (!closed && !reconnectSignal.aborted) {
369
+ try {
370
+ const result = await iterator.next();
371
+ if (!result.done)
372
+ return result;
373
+ }
374
+ catch (error) {
375
+ if (!isRetryableDeliveryDisconnect(error))
376
+ throw error;
377
+ }
378
+ await reconnect();
379
+ }
380
+ return { done: true, value: undefined };
381
+ };
382
+ const acknowledge = async (cursor, options) => {
383
+ try {
384
+ return await session.acknowledge(cursor, options);
385
+ }
386
+ catch (error) {
387
+ if (!isRetryableDeliveryDisconnect(error))
388
+ throw error;
389
+ await reconnect();
390
+ return session.acknowledge(cursor, options);
391
+ }
392
+ };
393
+ return {
394
+ next,
395
+ acknowledge,
396
+ close: async () => {
397
+ if (closed)
398
+ return;
399
+ closed = true;
400
+ stop.abort();
401
+ await session.close();
402
+ await reconnectTask?.catch((error) => {
403
+ if (!isAbortError(error))
404
+ throw error;
405
+ });
406
+ },
407
+ };
408
+ }
409
+ function validateReplacementHello(expected, replacement) {
410
+ if (replacement.streamId !== expected.streamId ||
411
+ replacement.storeEpoch !== expected.storeEpoch ||
412
+ replacement.processor.id !== expected.processor.id ||
413
+ replacement.processor.version !== expected.processor.version ||
414
+ replacement.processor.codeHash !== expected.processor.codeHash ||
415
+ replacement.processor.configHash !== expected.processor.configHash ||
416
+ replacement.processor.deliveryOrdering !==
417
+ expected.processor.deliveryOrdering) {
418
+ throw streamProtocolError("live", "replacement connection changed its durable lane identity");
419
+ }
420
+ }
421
+ function isRetryableDeliveryDisconnect(error) {
422
+ if (isAbortError(error) || error instanceof BackfillStreamError)
423
+ return false;
424
+ if (error instanceof LeaniError) {
425
+ return error.retryable || error.code === "consumer_session_active" ||
426
+ error.code === "consumer_session_lost";
427
+ }
428
+ if (!(error instanceof Error))
429
+ return false;
430
+ if (error.name === "consumer_session_active" ||
431
+ error.name === "consumer_session_lost") {
432
+ return true;
433
+ }
434
+ if (error.name === "NetworkError" || error.name === "TimeoutError")
435
+ return true;
436
+ return (error.name === "TypeError" &&
437
+ /fetch|network|load failed|socket|connection/i.test(error.message));
438
+ }
439
+ function isAbortError(error) {
440
+ return error instanceof Error && error.name === "AbortError";
441
+ }
442
+ async function waitForReconnectDelay(delayMs, signal) {
443
+ if (signal.aborted) {
444
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
445
+ }
446
+ await new Promise((resolve, reject) => {
447
+ const finish = () => {
448
+ signal.removeEventListener("abort", abort);
449
+ resolve();
450
+ };
451
+ const timeout = setTimeout(finish, delayMs);
452
+ const abort = () => {
453
+ clearTimeout(timeout);
454
+ signal.removeEventListener("abort", abort);
455
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
456
+ };
457
+ signal.addEventListener("abort", abort, { once: true });
458
+ });
459
+ }
460
+ function iterateUnifiedLanes(lanes, claim) {
461
+ return {
462
+ async *[Symbol.asyncIterator]() {
463
+ claim(lanes);
464
+ const ready = [];
465
+ const settled = new Map();
466
+ let wake;
467
+ let active = lanes.length;
468
+ const schedule = (index) => {
469
+ lanes[index].next().then((result) => {
470
+ settled.set(index, { result });
471
+ ready.push(index);
472
+ wake?.();
473
+ wake = undefined;
474
+ }, (error) => {
475
+ settled.set(index, { error });
476
+ ready.push(index);
477
+ wake?.();
478
+ wake = undefined;
479
+ });
480
+ };
481
+ for (let index = 0; index < lanes.length; index += 1)
482
+ schedule(index);
483
+ while (active > 0) {
484
+ if (ready.length === 0) {
485
+ await new Promise((resolve) => {
486
+ wake = resolve;
487
+ });
488
+ }
489
+ const livePosition = ready.findIndex((index) => lanes[index].kind === "live");
490
+ const [index] = ready.splice(livePosition >= 0 ? livePosition : 0, 1);
491
+ const outcome = settled.get(index);
492
+ settled.delete(index);
493
+ if (!outcome)
494
+ throw new Error("delivery scheduler lost a settled lane");
495
+ if ("error" in outcome)
496
+ throw outcome.error;
497
+ if (outcome.result.done) {
498
+ active -= 1;
499
+ continue;
500
+ }
501
+ const completed = outcome.result.value.completion !== undefined;
502
+ if (completed) {
503
+ active -= 1;
504
+ }
505
+ yield outcome.result.value;
506
+ if (!completed) {
507
+ schedule(index);
508
+ await Promise.resolve();
509
+ }
510
+ }
511
+ },
512
+ };
513
+ }
514
+ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consumer, credential, batching, signal) {
515
+ const session = await openDeliverySession(fetchImpl, baseUrl, token, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
516
+ const batches = async function* () {
517
+ for await (const record of session) {
518
+ if (record.type === "batch") {
519
+ yield record;
520
+ }
521
+ }
522
+ };
523
+ const events = async function* () {
524
+ for await (const batch of batches()) {
525
+ yield* batch.changes;
526
+ }
527
+ };
528
+ return Object.freeze({
529
+ hello: session.hello,
530
+ acknowledge: session.acknowledge.bind(session),
531
+ close: session.close.bind(session),
532
+ batches,
533
+ events,
534
+ [Symbol.asyncIterator]: session[Symbol.asyncIterator].bind(session),
535
+ });
536
+ }
537
+ async function openLiveStream(fetchImpl, baseUrl, token, processor, consumer, credential, signal) {
538
+ return openDeliverySession(fetchImpl, baseUrl, token, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
539
+ }
540
+ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
541
+ const connectionStop = new AbortController();
542
+ const stopFromCaller = () => connectionStop.abort(signal?.reason);
543
+ if (signal?.aborted) {
544
+ stopFromCaller();
545
+ }
546
+ else {
547
+ signal?.addEventListener("abort", stopFromCaller, { once: true });
548
+ }
549
+ const response = await fetchImpl(new URL(streamPath, baseUrl), {
550
+ headers: requestHeaders(token, "application/x-ndjson", credential),
551
+ signal: connectionStop.signal,
552
+ });
553
+ if (!response.ok) {
554
+ signal?.removeEventListener("abort", stopFromCaller);
555
+ throw await responseError(response);
556
+ }
557
+ if (!response.body) {
558
+ signal?.removeEventListener("abort", stopFromCaller);
559
+ throw new BackfillStreamError({
560
+ type: "error",
561
+ code: "stream_body_missing",
562
+ message: `${streamKind} stream response has no body`,
563
+ earliestAvailableSequence: null,
564
+ latestAvailableSequence: null,
565
+ });
566
+ }
567
+ const records = parseNdjson(response.body, connectionStop.signal);
568
+ const first = await records.next();
569
+ if (first.done) {
570
+ connectionStop.abort();
571
+ signal?.removeEventListener("abort", stopFromCaller);
572
+ throw streamProtocolError(streamKind, "stream ended before its hello record");
573
+ }
574
+ const firstType = first.value.type;
575
+ if (firstType === "error" || firstType === "reset_required") {
576
+ connectionStop.abort();
577
+ signal?.removeEventListener("abort", stopFromCaller);
578
+ throw new BackfillStreamError(first.value);
579
+ }
580
+ if (firstType !== "hello") {
581
+ connectionStop.abort();
582
+ signal?.removeEventListener("abort", stopFromCaller);
583
+ throw streamProtocolError(streamKind, "first stream record is not hello");
584
+ }
585
+ const wireHello = first.value;
586
+ const sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
587
+ const leaseTtlMs = parseLeaseTtl(wireHello.leaseTtlMs);
588
+ const { sessionToken: _sessionToken, ...publicHello } = wireHello;
589
+ const renewalStop = new AbortController();
590
+ let renewalError;
591
+ const renewalTask = renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, renewalStop.signal).catch((error) => {
592
+ if (!renewalStop.signal.aborted) {
593
+ renewalError = error;
594
+ }
595
+ });
596
+ let iteratorClaimed = false;
597
+ let closeTask;
598
+ const close = () => {
599
+ closeTask ??= (async () => {
600
+ renewalStop.abort();
601
+ connectionStop.abort();
602
+ signal?.removeEventListener("abort", stopFromCaller);
603
+ try {
604
+ await records.return(undefined);
605
+ }
606
+ catch (error) {
607
+ if (!connectionStop.signal.aborted) {
608
+ throw error;
609
+ }
610
+ }
611
+ await renewalTask;
612
+ try {
613
+ await fetchImpl(new URL(renewPath, baseUrl), {
614
+ method: "DELETE",
615
+ headers: requestHeaders(token, "application/json", credential, sessionToken),
616
+ });
617
+ }
618
+ catch {
619
+ // The lease also expires and is passively released when the stream
620
+ // disconnects. Explicit release only makes clean reconnect immediate.
621
+ }
622
+ })();
623
+ return closeTask;
624
+ };
625
+ const iterate = async function* () {
626
+ if (iteratorClaimed) {
627
+ throw new TypeError("a delivery session can only be iterated once");
628
+ }
629
+ iteratorClaimed = true;
630
+ try {
631
+ while (true) {
632
+ if (renewalError !== undefined) {
633
+ throw renewalError;
634
+ }
635
+ const next = await records.next();
636
+ if (next.done) {
637
+ if (renewalError !== undefined) {
638
+ throw renewalError;
639
+ }
640
+ return;
641
+ }
642
+ const record = next.value;
643
+ const recordType = record.type;
644
+ if (recordType === "error" || recordType === "reset_required") {
645
+ throw new BackfillStreamError(record);
646
+ }
647
+ if (recordType === "hello") {
648
+ throw streamProtocolError(streamKind, "stream sent a second hello record");
649
+ }
650
+ yield record;
651
+ }
652
+ }
653
+ finally {
654
+ await close();
655
+ }
656
+ };
657
+ return Object.freeze({
658
+ hello: publicHello,
659
+ acknowledge: async (cursor, acknowledgeOptions) => {
660
+ if (closeTask !== undefined) {
661
+ throw new TypeError("delivery session is closed");
662
+ }
663
+ if (renewalError !== undefined) {
664
+ throw renewalError;
665
+ }
666
+ const acknowledge = await fetchImpl(new URL(acknowledgePath, baseUrl), {
667
+ method: "POST",
668
+ headers: requestHeaders(token, "application/json", credential, sessionToken),
669
+ body: JSON.stringify({ cursor: assertIdentity(cursor, "cursor") }),
670
+ signal: acknowledgeOptions?.signal,
671
+ });
672
+ if (!acknowledge.ok) {
673
+ throw await responseError(acknowledge);
674
+ }
675
+ return (await acknowledge.json());
676
+ },
677
+ close,
678
+ [Symbol.asyncIterator]: iterate,
679
+ });
680
+ }
681
+ function streamProtocolError(streamKind, message) {
682
+ return new BackfillStreamError({
683
+ type: "error",
684
+ code: "invalid_stream_protocol",
685
+ message: `${streamKind} ${message}`,
686
+ earliestAvailableSequence: null,
687
+ latestAvailableSequence: null,
688
+ });
689
+ }
690
+ async function renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, stop) {
691
+ const intervalMs = Math.max(50, Math.floor(leaseTtlMs / 3));
692
+ while (!stop.aborted) {
693
+ await abortableDelay(intervalMs, stop);
694
+ if (stop.aborted) {
695
+ return;
696
+ }
697
+ const response = await fetchImpl(new URL(renewPath, baseUrl), {
698
+ method: "POST",
699
+ headers: requestHeaders(token, "application/json", credential, sessionToken),
700
+ signal: stop,
701
+ });
702
+ if (!response.ok) {
703
+ throw await responseError(response);
704
+ }
705
+ await response.arrayBuffer();
706
+ }
707
+ }
708
+ function abortableDelay(delayMs, signal) {
709
+ return new Promise((resolve) => {
710
+ const timeout = setTimeout(resolve, delayMs);
711
+ signal.addEventListener("abort", () => {
712
+ clearTimeout(timeout);
713
+ resolve();
714
+ }, { once: true });
715
+ });
716
+ }
717
+ function parseLeaseTtl(value) {
718
+ const ttl = Number(value);
719
+ if (!Number.isSafeInteger(ttl) || ttl <= 0) {
720
+ throw new TypeError("stream hello contains an invalid leaseTtlMs");
721
+ }
722
+ return ttl;
723
+ }
724
+ export async function* parseNdjson(body, signal) {
725
+ const reader = body.getReader();
726
+ const cancel = () => { void reader.cancel().catch(() => { }); };
727
+ signal?.addEventListener("abort", cancel, { once: true });
728
+ if (signal?.aborted)
729
+ cancel();
730
+ const decoder = new TextDecoder();
731
+ let buffer = "";
732
+ try {
733
+ while (true) {
734
+ if (signal?.aborted) {
735
+ throw signal.reason ?? new DOMException("aborted", "AbortError");
736
+ }
737
+ const { done, value } = await reader.read();
738
+ buffer += decoder.decode(value, { stream: !done });
739
+ let newline = buffer.indexOf("\n");
740
+ while (newline >= 0) {
741
+ const line = buffer.slice(0, newline).trim();
742
+ buffer = buffer.slice(newline + 1);
743
+ if (line) {
744
+ yield JSON.parse(line);
745
+ }
746
+ newline = buffer.indexOf("\n");
747
+ }
748
+ if (done) {
749
+ const finalLine = buffer.trim();
750
+ if (finalLine) {
751
+ yield JSON.parse(finalLine);
752
+ }
753
+ return;
754
+ }
755
+ }
756
+ }
757
+ finally {
758
+ signal?.removeEventListener("abort", cancel);
759
+ await reader.cancel().catch(() => { });
760
+ reader.releaseLock();
761
+ }
762
+ }
763
+ function normalizeBaseUrl(value) {
764
+ const url = new URL(value);
765
+ if (!url.pathname.endsWith("/")) {
766
+ url.pathname += "/";
767
+ }
768
+ return url;
769
+ }
770
+ function requestHeaders(token, accept, credential, sessionToken) {
771
+ const headers = new Headers({ accept });
772
+ if (token) {
773
+ headers.set("authorization", `Bearer ${token}`);
774
+ }
775
+ if (credential !== undefined) {
776
+ headers.set("x-leani-consumer-credential", assertIdentity(credential, "credential"));
777
+ }
778
+ if (sessionToken !== undefined) {
779
+ headers.set("x-leani-consumer-session", assertIdentity(sessionToken, "sessionToken"));
780
+ }
781
+ if (accept === "application/json") {
782
+ headers.set("content-type", "application/json");
783
+ }
784
+ return headers;
785
+ }
786
+ function consumerPath(subscription, consumer, operation) {
787
+ return (`v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}` +
788
+ `/consumers/${encodeURIComponent(assertIdentity(consumer, "consumer"))}/${operation}`);
789
+ }
790
+ function backfillStreamPath(subscription, consumer, batching) {
791
+ const path = consumerPath(subscription, consumer, "stream");
792
+ if (batching === undefined)
793
+ return path;
794
+ const query = new URLSearchParams();
795
+ for (const [field, value] of [
796
+ ["targetEncodedBytes", batching.targetEncodedBytes],
797
+ ["maximumEncodedBytes", batching.maximumEncodedBytes],
798
+ ["maximumEvents", batching.maximumEvents],
799
+ ["maximumProcessedBlocks", batching.maximumProcessedBlocks],
800
+ ["maximumDelayMs", batching.maximumDelayMs],
801
+ ]) {
802
+ if (value !== undefined) {
803
+ query.set(field, String(assertPositiveInteger(value, `batching.${field}`)));
804
+ }
805
+ }
806
+ return query.size === 0 ? path : `${path}?${query}`;
807
+ }
808
+ function liveConsumerPath(processor, consumer, operation) {
809
+ return (`v1/processors/${encodeURIComponent(assertIdentity(processor, "processor"))}` +
810
+ `/streams/live/consumers/${encodeURIComponent(assertIdentity(consumer, "consumer"))}/${operation}`);
811
+ }
812
+ function assertBlock(value, field) {
813
+ if (!Number.isSafeInteger(value) || value < 0) {
814
+ throw new TypeError(`${field} must be a non-negative safe integer`);
815
+ }
816
+ return value;
817
+ }
818
+ function assertUpperBound(value, field) {
819
+ return value === "finalized" ? value : assertBlock(value, field);
820
+ }
821
+ function assertPositiveInteger(value, field) {
822
+ if (!Number.isSafeInteger(value) || value <= 0) {
823
+ throw new TypeError(`${field} must be a positive safe integer`);
824
+ }
825
+ return value;
826
+ }
827
+ function assertIdentity(value, field) {
828
+ if (!value.trim()) {
829
+ throw new TypeError(`${field} must not be empty`);
830
+ }
831
+ return value;
832
+ }
833
+ function assertRatio(value, field) {
834
+ if (!Number.isFinite(value) || value <= 0 || value >= 1) {
835
+ throw new TypeError(`${field} must be greater than zero and less than one`);
836
+ }
837
+ return value;
838
+ }
839
+ //# sourceMappingURL=backfill.js.map