@thru/replay 0.2.19 → 0.2.21

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.d.cts CHANGED
@@ -165,6 +165,11 @@ interface EventReplayOptions {
165
165
  /** Factory to create fresh clients on reconnection. Enables robust reconnection. */
166
166
  clientFactory?: () => EventSource;
167
167
  startSlot: Slot;
168
+ /** Last fully processed event. Allows backfill to resume within a slot. */
169
+ resumeAfter?: {
170
+ slot: Slot;
171
+ eventId: string;
172
+ };
168
173
  safetyMargin?: bigint;
169
174
  pageSize?: number;
170
175
  filter?: Filter;
package/dist/index.d.ts CHANGED
@@ -165,6 +165,11 @@ interface EventReplayOptions {
165
165
  /** Factory to create fresh clients on reconnection. Enables robust reconnection. */
166
166
  clientFactory?: () => EventSource;
167
167
  startSlot: Slot;
168
+ /** Last fully processed event. Allows backfill to resume within a slot. */
169
+ resumeAfter?: {
170
+ slot: Slot;
171
+ eventId: string;
172
+ };
168
173
  safetyMargin?: bigint;
169
174
  pageSize?: number;
170
175
  filter?: Filter;
package/dist/index.mjs CHANGED
@@ -358,6 +358,27 @@ function compareBigint(a, b) {
358
358
  if (a === b) return 0;
359
359
  return a < b ? -1 : 1;
360
360
  }
361
+ function isNonDecreasing(items, extractSlot) {
362
+ for (let idx = 1; idx < items.length; idx += 1) {
363
+ if (extractSlot(items[idx]) < extractSlot(items[idx - 1])) return false;
364
+ }
365
+ return true;
366
+ }
367
+ function assertBackfillPageOrder(previousPage, currentPage, extractSlot) {
368
+ if (!isNonDecreasing(currentPage, extractSlot)) {
369
+ throw new Error(
370
+ "backfill source returned a page that is not ordered by ascending slot"
371
+ );
372
+ }
373
+ if (!previousPage?.length || !currentPage.length) return;
374
+ const previousMaxSlot = extractSlot(previousPage[previousPage.length - 1]);
375
+ const currentMinSlot = extractSlot(currentPage[0]);
376
+ if (currentMinSlot < previousMaxSlot) {
377
+ throw new Error(
378
+ `backfill source returned pages out of ascending slot order: page minimum slot ${currentMinSlot} is before previous page maximum slot ${previousMaxSlot}`
379
+ );
380
+ }
381
+ }
361
382
  var ReplayStream = class {
362
383
  config;
363
384
  logger;
@@ -418,6 +439,29 @@ var ReplayStream = class {
418
439
  this.logger.info(
419
440
  `replay entering BACKFILLING state (startSlot=${startSlot}, safetyMargin=${safetyMargin})`
420
441
  );
442
+ let pendingOrderedPage = null;
443
+ const emitBackfillItems = async function* (self, items) {
444
+ for (const item of items) {
445
+ const slot = extractSlot(item);
446
+ const key = keyOf(item);
447
+ if (slot < startSlot) continue;
448
+ if (seenItem(slot, key)) {
449
+ self.metrics.discardedDuplicates += 1;
450
+ continue;
451
+ }
452
+ currentSlot = slot;
453
+ recordEmission(slot, key);
454
+ self.metrics.emittedBackfill += 1;
455
+ yield item;
456
+ }
457
+ };
458
+ const flushPendingBackfill = async function* (self) {
459
+ if (!pendingOrderedPage) return;
460
+ for await (const item of emitBackfillItems(self, pendingOrderedPage)) {
461
+ yield item;
462
+ }
463
+ pendingOrderedPage = null;
464
+ };
421
465
  let emptyPageRetries = 0;
422
466
  const MAX_EMPTY_PAGE_RETRIES = 10;
423
467
  while (!backfillDone) {
@@ -428,6 +472,9 @@ var ReplayStream = class {
428
472
  this.logger.error(
429
473
  `backfill returned ${MAX_EMPTY_PAGE_RETRIES} consecutive empty pages; treating as done`
430
474
  );
475
+ for await (const item of flushPendingBackfill(this)) {
476
+ yield item;
477
+ }
431
478
  break;
432
479
  }
433
480
  const backoffMs = calculateBackoff(emptyPageRetries - 1, DEFAULT_RETRY_CONFIG);
@@ -438,21 +485,18 @@ var ReplayStream = class {
438
485
  continue;
439
486
  }
440
487
  emptyPageRetries = 0;
441
- const sorted = [...page.items].sort(
442
- (a, b) => compareBigint(extractSlot(a), extractSlot(b))
443
- );
444
- for (const item of sorted) {
445
- const slot = extractSlot(item);
446
- const key = keyOf(item);
447
- if (slot < startSlot) continue;
448
- if (seenItem(slot, key)) {
449
- this.metrics.discardedDuplicates += 1;
450
- continue;
488
+ assertBackfillPageOrder(pendingOrderedPage, page.items, extractSlot);
489
+ if (pendingOrderedPage !== null) {
490
+ for await (const item of flushPendingBackfill(this)) {
491
+ yield item;
492
+ }
493
+ }
494
+ pendingOrderedPage = [...page.items];
495
+ const reachedEnd = page.done || page.cursor === void 0;
496
+ if (reachedEnd) {
497
+ for await (const item of flushPendingBackfill(this)) {
498
+ yield item;
451
499
  }
452
- currentSlot = slot;
453
- recordEmission(slot, key);
454
- this.metrics.emittedBackfill += 1;
455
- yield item;
456
500
  }
457
501
  const duplicatesTrimmed = livePump.discardBufferedUpTo(currentSlot);
458
502
  this.metrics.discardedDuplicates += duplicatesTrimmed;
@@ -461,13 +505,16 @@ var ReplayStream = class {
461
505
  if (maxStreamSlot !== null) {
462
506
  const catchUpSlot = maxStreamSlot > safetyMargin ? maxStreamSlot - safetyMargin : 0n;
463
507
  if (currentSlot >= catchUpSlot) {
508
+ for await (const item of flushPendingBackfill(this)) {
509
+ yield item;
510
+ }
464
511
  this.logger.info(
465
512
  `replay reached SWITCHING threshold (currentSlot=${currentSlot}, maxStreamSlot=${maxStreamSlot}, catchUpSlot=${catchUpSlot})`
466
513
  );
467
514
  backfillDone = true;
468
515
  }
469
516
  }
470
- if (page.done || cursor === void 0) backfillDone = true;
517
+ if (reachedEnd) backfillDone = true;
471
518
  }
472
519
  this.logger.info(`replay entering SWITCHING state (currentSlot=${currentSlot})`);
473
520
  const { drained, discarded } = livePump.enableStreaming(currentSlot);
@@ -796,7 +843,7 @@ function createEventReplay(options) {
796
843
  orderBy: PAGE_ORDER_ASC3,
797
844
  pageToken: cursor
798
845
  });
799
- const baseFilter = slotLiteralFilter("event.slot", startSlot);
846
+ const baseFilter = eventBackfillFilter(startSlot, options.resumeAfter);
800
847
  const mergedFilter = combineFilters(baseFilter, options.filter);
801
848
  const response = await client.listEvents(
802
849
  create(ListEventsRequestSchema, {
@@ -807,13 +854,19 @@ function createEventReplay(options) {
807
854
  return backfillPage(response.events, response.page);
808
855
  };
809
856
  const createSubscribeLive = (client) => (startSlot) => {
810
- const mergedFilter = combineFilters(slotLiteralFilter("event.slot", startSlot), options.filter);
857
+ const mergedFilter = combineFilters(
858
+ slotLiteralFilter("event.slot", startSlot),
859
+ options.filter
860
+ );
811
861
  const request = create(StreamEventsRequestSchema, {
812
862
  filter: mergedFilter
813
863
  });
814
864
  return mapAsyncIterable(
815
865
  client.streamEvents(request),
816
- (resp) => streamResponseToEvent(resp)
866
+ (resp) => {
867
+ const event = streamResponseToEvent(resp);
868
+ return shouldEmitLiveEvent(event, startSlot, options.resumeAfter) ? event : null;
869
+ }
817
870
  );
818
871
  };
819
872
  const onReconnect = options.clientFactory ? () => {
@@ -851,6 +904,52 @@ function eventKey(event) {
851
904
  const slotPart = event.slot?.toString() ?? "0";
852
905
  return `${slotPart}:${event.callIdx ?? 0}`;
853
906
  }
907
+ function eventBackfillFilter(startSlot, resumeAfter) {
908
+ const boundary = parseEventId(resumeAfter);
909
+ if (!boundary || startSlot > boundary.slot) {
910
+ return slotLiteralFilter("event.slot", startSlot);
911
+ }
912
+ return create(FilterSchema, {
913
+ expression: `event.slot > uint(${boundary.slot.toString()}) || (event.slot == uint(${boundary.slot.toString()}) && (event.block_offset > uint(${boundary.blockOffset.toString()}) || (event.block_offset == uint(${boundary.blockOffset.toString()}) && event.call_idx >= uint(${boundary.callIdx.toString()}))))`
914
+ });
915
+ }
916
+ function parseEventId(resumeAfter) {
917
+ if (!resumeAfter?.eventId) return null;
918
+ return parseCanonicalEventId(resumeAfter.eventId, resumeAfter.slot);
919
+ }
920
+ function parseCanonicalEventId(eventId, expectedSlot) {
921
+ if (!eventId) return null;
922
+ const parts = eventId.split(":");
923
+ if (parts.length === 5) {
924
+ const [, slotPart, blockOffsetPart, callIdxPart, eventIdxPart] = parts;
925
+ const slot = BigInt(slotPart);
926
+ if (slot !== expectedSlot) return null;
927
+ return {
928
+ slot,
929
+ blockOffset: BigInt(blockOffsetPart),
930
+ callIdx: BigInt(callIdxPart),
931
+ eventIdx: BigInt(eventIdxPart)
932
+ };
933
+ }
934
+ return null;
935
+ }
936
+ function isAfterBoundary(event, boundary) {
937
+ if (event.slot !== boundary.slot) return event.slot > boundary.slot;
938
+ if (event.blockOffset !== boundary.blockOffset) {
939
+ return event.blockOffset > boundary.blockOffset;
940
+ }
941
+ if (event.callIdx !== boundary.callIdx) return event.callIdx > boundary.callIdx;
942
+ return event.eventIdx > boundary.eventIdx;
943
+ }
944
+ function shouldEmitLiveEvent(event, startSlot, resumeAfter) {
945
+ const boundary = parseEventId(resumeAfter);
946
+ if (!boundary || startSlot > boundary.slot) return true;
947
+ const eventSlot = event.slot ?? 0n;
948
+ if (eventSlot > boundary.slot) return true;
949
+ if (eventSlot < boundary.slot) return false;
950
+ const eventPosition = parseCanonicalEventId(event.eventId, boundary.slot);
951
+ return eventPosition ? isAfterBoundary(eventPosition, boundary) : false;
952
+ }
854
953
 
855
954
  // src/page-assembler.ts
856
955
  var PAGE_SIZE = 4096;