pond-ts 0.65.0 → 0.67.0

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.
@@ -17,6 +17,7 @@ import { ChunkedColumnarLiveStorage, materializeEventsFromStore, materializeEven
17
17
  import { validateAndNormalizeColumnar } from '../batch/validate.js';
18
18
  import { ColumnarStore } from '../columnar/store.js';
19
19
  import { parseDuration } from '../core/duration.js';
20
+ import { ListenerSet } from './listener-set.js';
20
21
  // ── Single-row validation ───────────────────────────────────────
21
22
  const FIRST_COL_KINDS = new Set([
22
23
  'time',
@@ -94,6 +95,11 @@ function assertCellKind(kind, value, name) {
94
95
  * channels; snapshot to the batch layer at any time with `toTimeSeries()`.
95
96
  * Example: `new LiveSeries({ name, schema, retention: { maxEvents: 10_000 } })`.
96
97
  */
98
+ /** [PND-LIVFIX] Surface the first listener error once the push has completed. */
99
+ function rethrowFirst(errors) {
100
+ if (errors.length > 0)
101
+ throw errors[0];
102
+ }
97
103
  export class LiveSeries {
98
104
  /** @internal */
99
105
  [EMITS_EVICT] = true;
@@ -113,6 +119,18 @@ export class LiveSeries {
113
119
  #onEvent;
114
120
  #onBatch;
115
121
  #onEvict;
122
+ /**
123
+ * [PND-LIVFIX] Re-entrancy guard. `> 0` while a push is fanning out to
124
+ * listeners. A push that arrives from inside a listener (`#dispatchDepth
125
+ * > 0`) is queued on `#pendingPushes` and run after the outer push has
126
+ * fully completed (retention, `'batch'`, `'evict'`), so emission order
127
+ * stays monotonic and the nested rows are order-checked against the
128
+ * buffer at their turn rather than mid-fan-out.
129
+ */
130
+ #dispatchDepth = 0;
131
+ #pendingPushes = [];
132
+ /** `true` while the outermost guard is draining `#pendingPushes`. */
133
+ #draining = false;
116
134
  /**
117
135
  * Column-native delta subscribers (chunked backing only). Fires with
118
136
  * the appended `ColumnarStore` per `pushMany` — the internal hook the
@@ -181,10 +199,10 @@ export class LiveSeries {
181
199
  this.#perRow = array;
182
200
  this.#storage = array;
183
201
  }
184
- this.#onEvent = new Set();
185
- this.#onBatch = new Set();
202
+ this.#onEvent = new ListenerSet();
203
+ this.#onBatch = new ListenerSet();
186
204
  this.#onChunk = new Set();
187
- this.#onEvict = new Set();
205
+ this.#onEvict = new ListenerSet();
188
206
  }
189
207
  get length() {
190
208
  return this.#storage.length;
@@ -305,64 +323,145 @@ export class LiveSeries {
305
323
  * **Commit granularity differs by backing.** On the `Event[]`
306
324
  * backing each row is appended then its `'event'` fires, so a
307
325
  * handler observes `length` grow row-by-row (`1, 2, …`) within one
308
- * `pushMany`, and a handler that throws mid-batch leaves only the
309
- * rows up to the throw committed. On the chunked columnar backing
310
- * (top-level `strict` time-keyed series) the whole batch is appended
311
- * as one chunk *before* any `'event'` fires — so a handler sees the
312
- * full post-batch `length` for every event of the batch, and a
313
- * handler that throws mid-fan-out leaves the *entire* batch committed
314
- * (the chunk is already appended). Both leave `length` and `ingested`
315
- * mutually consistent after a throw; they differ only in how much of
316
- * the batch is committed. This is intrinsic to all-or-nothing
317
- * columnar append — per-row commit would reintroduce the per-row
318
- * `Event` cost the chunked backing exists to avoid. The cross-backing
319
- * contract callers can rely on: every successfully-ingested row fires
320
- * exactly one `'event'`, in order, before `'batch'`/`'evict'`.
326
+ * `pushMany`. On the chunked columnar backing (top-level `strict`
327
+ * time-keyed series) the whole batch is appended as one chunk
328
+ * *before* any `'event'` fires, so a handler sees the full post-batch
329
+ * `length` for every event of the batch. This is intrinsic to
330
+ * all-or-nothing columnar append — per-row commit would reintroduce
331
+ * the per-row `Event` cost the chunked backing exists to avoid. The
332
+ * cross-backing contract callers can rely on: every
333
+ * successfully-ingested row fires exactly one `'event'`, in order,
334
+ * before `'batch'`/`'evict'`.
335
+ *
336
+ * **Listener errors are isolated** ([PND-LIVFIX]). A listener that
337
+ * throws does not stop the push: every row is still committed, the
338
+ * remaining listeners still run, retention still runs, and `'batch'` /
339
+ * `'evict'` still fire. The first error is rethrown to the caller once
340
+ * the push has completed, so `length`, `ingested` and every subscriber
341
+ * (a derived `filter()` view included) agree afterwards. A push made
342
+ * from inside a listener is queued and runs after the current push,
343
+ * in arrival order, so emission stays monotonic.
321
344
  */
322
345
  pushMany(rows) {
323
346
  if (rows.length === 0)
324
347
  return;
325
- // Chunked (column-native) path: validate the whole batch into
326
- // columns, no per-row Event. Only top-level strict time-keyed
327
- // series select this backing.
328
- if (this.#chunked) {
329
- this.#pushManyColumnar(rows);
348
+ if (this.#dispatchDepth > 0) {
349
+ // Re-entrant push from inside a listener: defer until the outer
350
+ // push has completed. See `#dispatchDepth`.
351
+ this.#pendingPushes.push(() => this.pushMany(rows));
330
352
  return;
331
353
  }
332
- // Per-row (Event[]) path.
333
- const added = [];
334
- for (const row of rows) {
335
- const event = this.#validateRow(row);
336
- if (this.#insertPerRow(event)) {
337
- // Increment the counter immediately after a successful
338
- // insert and BEFORE listener fan-out. If a listener throws
339
- // partway through the loop, the event is committed in the
340
- // buffer and reflected in `length` — `ingested` must
341
- // reflect that too, so callers can recover from listener
342
- // exceptions without observability counters lying.
343
- this.#statsIngested++;
344
- added.push(event);
345
- for (const fn of this.#onEvent)
346
- fn(event);
354
+ this.#guarded(() => {
355
+ // Chunked (column-native) path: validate the whole batch into
356
+ // columns, no per-row Event. Only top-level strict time-keyed
357
+ // series select this backing.
358
+ if (this.#chunked) {
359
+ this.#pushManyColumnar(rows);
360
+ return;
347
361
  }
348
- else {
349
- // Drop-mode silent rejection: out-of-order event under
350
- // `ordering: 'drop'`. Strict / reorder modes throw; those
351
- // never reach this counter.
352
- this.#statsRejected++;
362
+ // Per-row (Event[]) path.
363
+ const added = [];
364
+ const errors = [];
365
+ for (const row of rows) {
366
+ const event = this.#validateRow(row);
367
+ if (this.#insertPerRow(event)) {
368
+ // Increment the counter immediately after a successful
369
+ // insert and BEFORE listener fan-out. If a listener throws
370
+ // partway through the loop, the event is committed in the
371
+ // buffer and reflected in `length` — `ingested` must
372
+ // reflect that too, so callers can recover from listener
373
+ // exceptions without observability counters lying.
374
+ this.#statsIngested++;
375
+ added.push(event);
376
+ this.#dispatch(this.#onEvent, event, errors);
377
+ }
378
+ else {
379
+ // Drop-mode silent rejection: out-of-order event under
380
+ // `ordering: 'drop'`. Strict / reorder modes throw; those
381
+ // never reach this counter.
382
+ this.#statsRejected++;
383
+ }
384
+ }
385
+ if (added.length === 0)
386
+ return;
387
+ // `#applyRetention` updates `#statsEvicted` internally and returns
388
+ // the materialized evicted events only when an `'evict'` listener
389
+ // will consume them.
390
+ const evicted = this.#applyRetention();
391
+ this.#dispatch(this.#onBatch, added, errors);
392
+ if (evicted.length > 0) {
393
+ this.#dispatch(this.#onEvict, evicted, errors);
353
394
  }
395
+ rethrowFirst(errors);
396
+ });
397
+ }
398
+ /**
399
+ * [PND-LIVFIX] Run one push under the re-entrancy guard: bump
400
+ * `#dispatchDepth` for its duration, then — from the OUTERMOST guard
401
+ * only — drain the pushes that listeners queued, strictly FIFO. A
402
+ * queued push runs as its own guarded push, so its listeners can queue
403
+ * more; those land at the tail of the same queue and run after every
404
+ * push queued before them. (Draining from every level, as the first
405
+ * cut did, let a grandchild jump a queued sibling — a review repro:
406
+ * listener on 1 queues 10 then 20, listener on 10 queues 30, and 30
407
+ * ran before 20, which strict ordering then rejected.) An error from
408
+ * the outer push is rethrown AFTER the queue has drained — queued rows
409
+ * were accepted by `push()` and must not be lost to a listener's
410
+ * exception elsewhere.
411
+ */
412
+ #guarded(run) {
413
+ let failure;
414
+ this.#dispatchDepth += 1;
415
+ try {
416
+ run();
354
417
  }
355
- if (added.length === 0)
418
+ catch (error) {
419
+ failure = { error };
420
+ }
421
+ finally {
422
+ this.#dispatchDepth -= 1;
423
+ }
424
+ if (this.#dispatchDepth === 0 && !this.#draining) {
425
+ this.#draining = true;
426
+ try {
427
+ while (this.#pendingPushes.length > 0) {
428
+ const push = this.#pendingPushes.shift();
429
+ try {
430
+ push();
431
+ }
432
+ catch (error) {
433
+ failure ??= { error };
434
+ }
435
+ }
436
+ }
437
+ finally {
438
+ this.#draining = false;
439
+ }
440
+ }
441
+ if (failure)
442
+ throw failure.error;
443
+ }
444
+ /**
445
+ * [PND-LIVFIX] Fan out to a listener set with error isolation. Iterates
446
+ * a snapshot, so a listener that adds or removes listeners during
447
+ * dispatch neither disturbs the others nor fires for the event it was
448
+ * added during. A throwing listener is recorded and the remaining
449
+ * listeners still run; the caller rethrows the first error once the
450
+ * whole push (retention, `'batch'`, `'evict'`) has completed. Before
451
+ * this, a throw skipped retention entirely and left every later
452
+ * subscriber — a derived `filter()` view included — permanently out of
453
+ * sync with the buffer (audit 2026-06 §4.1).
454
+ */
455
+ #dispatch(set, arg, errors) {
456
+ if (set.size === 0)
356
457
  return;
357
- // `#applyRetention` updates `#statsEvicted` internally and returns
358
- // the materialized evicted events only when an `'evict'` listener
359
- // will consume them.
360
- const evicted = this.#applyRetention();
361
- for (const fn of this.#onBatch)
362
- fn(added);
363
- if (evicted.length > 0) {
364
- for (const fn of this.#onEvict)
365
- fn(evicted);
458
+ for (const fn of set.snapshot()) {
459
+ try {
460
+ fn(arg);
461
+ }
462
+ catch (error) {
463
+ errors.push(error);
464
+ }
366
465
  }
367
466
  }
368
467
  /**
@@ -424,21 +523,19 @@ export class LiveSeries {
424
523
  const added = this.#onEvent.size > 0 || this.#onBatch.size > 0
425
524
  ? materializeEventsFromStoreAt(source, indices, this.schema)
426
525
  : null;
427
- if (added) {
428
- for (const ev of added) {
429
- for (const fn of this.#onEvent)
430
- fn(ev);
526
+ this.#guarded(() => {
527
+ const errors = [];
528
+ if (added) {
529
+ for (const ev of added)
530
+ this.#dispatch(this.#onEvent, ev, errors);
431
531
  }
432
- }
433
- const evicted = this.#applyRetention();
434
- if (added) {
435
- for (const fn of this.#onBatch)
436
- fn(added);
437
- }
438
- if (evicted.length > 0) {
439
- for (const fn of this.#onEvict)
440
- fn(evicted);
441
- }
532
+ const evicted = this.#applyRetention();
533
+ if (added)
534
+ this.#dispatch(this.#onBatch, added, errors);
535
+ if (evicted.length > 0)
536
+ this.#dispatch(this.#onEvict, evicted, errors);
537
+ rethrowFirst(errors);
538
+ });
442
539
  }
443
540
  /**
444
541
  * Append one already-built `ColumnarStore` chunk, then run the
@@ -480,24 +577,20 @@ export class LiveSeries {
480
577
  ? materializeEventsFromStore(store, this.schema)
481
578
  : null;
482
579
  // `'event'` fan-out fires before retention (the ordering contract).
580
+ const errors = [];
483
581
  if (added) {
484
- for (const ev of added) {
485
- for (const fn of this.#onEvent)
486
- fn(ev);
487
- }
582
+ for (const ev of added)
583
+ this.#dispatch(this.#onEvent, ev, errors);
488
584
  }
489
585
  // Retention always runs. It updates `#statsEvicted` internally and
490
586
  // returns the materialized evicted events only when an `'evict'`
491
587
  // listener exists (else `dropPrefix`, returning `[]`).
492
588
  const evicted = this.#applyRetention();
493
- if (added) {
494
- for (const fn of this.#onBatch)
495
- fn(added);
496
- }
497
- if (evicted.length > 0) {
498
- for (const fn of this.#onEvict)
499
- fn(evicted);
500
- }
589
+ if (added)
590
+ this.#dispatch(this.#onBatch, added, errors);
591
+ if (evicted.length > 0)
592
+ this.#dispatch(this.#onEvict, evicted, errors);
593
+ rethrowFirst(errors);
501
594
  }
502
595
  /**
503
596
  * @internal — fast-path ingest for events that have already been
@@ -536,38 +629,105 @@ export class LiveSeries {
536
629
  // chunked series. Reconstruct rows and reuse `#pushManyColumnar`
537
630
  // (re-validates — acceptable on this rare path; the strict
538
631
  // order-check still applies, matching the per-row trusted path).
539
- if (this.#chunked) {
540
- const rows = new Array(events.length);
541
- for (let i = 0; i < events.length; i += 1) {
542
- rows[i] = this.#eventToRow(events[i]);
543
- }
544
- this.#pushManyColumnar(rows);
632
+ if (this.#dispatchDepth > 0) {
633
+ this.#pendingPushes.push(() => this._pushTrustedEvents(events));
545
634
  return;
546
635
  }
547
- const added = [];
548
- for (const event of events) {
549
- if (this.#insertPerRow(event)) {
550
- // See pushMany — counter advances before listener fan-out
551
- // so partial-failure on any listener still leaves
552
- // `ingested` consistent with `length`.
553
- this.#statsIngested++;
554
- added.push(event);
555
- for (const fn of this.#onEvent)
556
- fn(event);
636
+ this.#guarded(() => {
637
+ if (this.#chunked) {
638
+ const rows = new Array(events.length);
639
+ for (let i = 0; i < events.length; i += 1) {
640
+ rows[i] = this.#eventToRow(events[i]);
641
+ }
642
+ this.#pushManyColumnar(rows);
643
+ return;
557
644
  }
558
- else {
559
- this.#statsRejected++;
645
+ const added = [];
646
+ const errors = [];
647
+ for (const event of events) {
648
+ if (this.#insertPerRow(event)) {
649
+ // See pushMany — counter advances before listener fan-out
650
+ // so partial-failure on any listener still leaves
651
+ // `ingested` consistent with `length`.
652
+ this.#statsIngested++;
653
+ added.push(event);
654
+ this.#dispatch(this.#onEvent, event, errors);
655
+ }
656
+ else {
657
+ this.#statsRejected++;
658
+ }
560
659
  }
660
+ if (added.length === 0)
661
+ return;
662
+ const evicted = this.#applyRetention();
663
+ this.#dispatch(this.#onBatch, added, errors);
664
+ if (evicted.length > 0)
665
+ this.#dispatch(this.#onEvict, evicted, errors);
666
+ rethrowFirst(errors);
667
+ });
668
+ }
669
+ /**
670
+ * @internal — number of row-materialising listeners (`'event'` +
671
+ * `'batch'`). Zero means a push on the chunked backing builds no
672
+ * `Event` objects at all. Exposed for the [PND-LIVFIX] chained-dispose
673
+ * pin, which asserts a disposed chain leaves nothing subscribed.
674
+ */
675
+ get _rowListenerCount() {
676
+ return this.#onEvent.size + this.#onBatch.size;
677
+ }
678
+ /**
679
+ * @internal — [PND-LIVFIX] the order this series evicts relative to
680
+ * arrival. `'sorted'` for `ordering: 'reorder'`, whose retention drops
681
+ * the sorted prefix (possibly a late, i.e. recent, arrival);
682
+ * `'arrival'` otherwise. `LiveReduce` selects its windowed reducer
683
+ * state on this.
684
+ */
685
+ get _evictionOrder() {
686
+ return this.#ordering === 'reorder' ? 'sorted' : 'arrival';
687
+ }
688
+ /**
689
+ * @internal — [PND-LIVFIX] age-based retention against an EXTERNAL
690
+ * clock. `#applyRetention` measures `maxAge` from this buffer's own
691
+ * newest event, which is right for a standalone series but leaves a
692
+ * partition sub-series that has gone quiet holding its events forever:
693
+ * nothing ever pushes into it, so its clock never advances (audit
694
+ * 2026-06 §4.3 — a 10 s `maxAge` partition still held its event 9,999 s
695
+ * later). `LivePartitionedSeries` calls this on every partition as the
696
+ * source's watermark advances, so a quiet partition ages out against
697
+ * the time the source has actually reached. Emits `'evict'` exactly as
698
+ * push-driven retention does, so `LiveReduce` and views stay in sync.
699
+ * No-op without `maxAge` or on an empty buffer. Called while this
700
+ * series is mid-dispatch (a listener on it pushed re-entrantly), it is
701
+ * queued behind that push like any other re-entrant operation, so it
702
+ * cannot be starved.
703
+ */
704
+ _sweepAge(latestMs) {
705
+ if (this.#maxAgeMs === Infinity)
706
+ return;
707
+ if (this.#dispatchDepth > 0) {
708
+ this.#pendingPushes.push(() => this._sweepAge(latestMs));
709
+ return;
561
710
  }
562
- if (added.length === 0)
711
+ const len = this.#storage.length;
712
+ if (len === 0)
713
+ return;
714
+ const cutoff = latestMs - this.#maxAgeMs;
715
+ let i = 0;
716
+ while (i < len && this.#storage.beginAt(i) < cutoff)
717
+ i += 1;
718
+ if (i === 0)
719
+ return;
720
+ this.#statsEvicted += i;
721
+ if (this.#onEvict.size === 0) {
722
+ this.#storage.dropPrefix(i);
563
723
  return;
564
- const evicted = this.#applyRetention();
565
- for (const fn of this.#onBatch)
566
- fn(added);
567
- if (evicted.length > 0) {
568
- for (const fn of this.#onEvict)
569
- fn(evicted);
570
724
  }
725
+ const evicted = this.#storage.evictPrefix(i);
726
+ this.#guarded(() => {
727
+ const errors = [];
728
+ this.#dispatch(this.#onEvict, evicted, errors);
729
+ rethrowFirst(errors);
730
+ });
571
731
  }
572
732
  /**
573
733
  * @internal — whether this series uses the chunked columnar backing
@@ -641,8 +801,9 @@ export class LiveSeries {
641
801
  // `evicted` counter on `stats()` for consistency. JSDoc on
642
802
  // `stats().evicted` documents both paths.
643
803
  this.#statsEvicted += evicted.length;
644
- for (const fn of this.#onEvict)
645
- fn(evicted);
804
+ const errors = [];
805
+ this.#dispatch(this.#onEvict, evicted, errors);
806
+ rethrowFirst(errors);
646
807
  }
647
808
  }
648
809
  toTimeSeries(name) {
@@ -37,15 +37,6 @@ type ViewOptions<S extends SeriesSchema> = {
37
37
  * Reuses `KeyColumnForSchema`, gated on the time variant.
38
38
  */
39
39
  type TimeKeyOnly<S extends SeriesSchema> = KeyColumnForSchema<S> extends TimeKeyColumn ? KeyColumnForSchema<S> : 'LiveView.keyColumn() supports time-keyed views only — use toTimeSeries() for other key kinds';
40
- /**
41
- * A derived, subscribable live projection — the result of chainable live
42
- * operators such as `filter`, `map`, `select`, `fill`, `diff`, and `rate`.
43
- * A view subscribes to its source `LiveSource`, runs each event through
44
- * its process function as it arrives (emitting a transformed event or
45
- * skipping it), and mirrors source eviction — you obtain one from an
46
- * operator rather than constructing it directly. Example:
47
- * `live.filter((e) => (e.get('value') as number) > 5)`.
48
- */
49
40
  export declare class LiveView<S extends SeriesSchema> implements LiveSource<S> {
50
41
  #private;
51
42
  readonly name: string;
@@ -188,6 +179,24 @@ export declare class LiveView<S extends SeriesSchema> implements LiveSource<S> {
188
179
  };
189
180
  on(type: 'event', fn: EventListener<S>): () => void;
190
181
  on(type: 'evict', fn: EvictListener<S>): () => void;
182
+ /**
183
+ * Detach this view from its source and drop its own listeners. After
184
+ * `dispose()` the view stops receiving events; its buffer is left as it
185
+ * was (a final `toTimeSeries()` snapshot still works) and further
186
+ * `dispose()` calls are no-ops.
187
+ *
188
+ * **Chain-aware.** `live.filter(p).map(f)` creates an intermediate view
189
+ * that nothing but the outer view references, and before [PND-LIVFIX]
190
+ * disposing the outer view left that intermediate subscribed to the
191
+ * source forever, processing every push into an unbounded buffer with
192
+ * no handle to stop it (audit 2026-06 §4.4). Now, when this view's source
193
+ * is itself a `LiveView` and this was its last subscriber, the source is
194
+ * disposed too, and so on up the chain. A source view that still has
195
+ * another subscriber is left alone. If you hold a reference to an
196
+ * intermediate and want to keep reading it after disposing a derived
197
+ * view, keep a listener on it — a view with no subscribers is treated
198
+ * as unreachable.
199
+ */
191
200
  dispose(): void;
192
201
  }
193
202
  /**
@@ -1,3 +1,4 @@
1
+ import { ListenerSet } from './listener-set.js';
1
2
  import { Event } from '../core/event.js';
2
3
  import { ValidationError } from '../core/errors.js';
3
4
  import { LiveAggregation } from './live-aggregation.js';
@@ -19,6 +20,36 @@ import { parseDuration } from '../core/duration.js';
19
20
  * operator rather than constructing it directly. Example:
20
21
  * `live.filter((e) => (e.get('value') as number) > 5)`.
21
22
  */
23
+ /**
24
+ * [PND-LIVFIX] Fan out to a listener set with error isolation: iterate a
25
+ * snapshot (a listener may add or remove listeners while running) and
26
+ * let every listener run before surfacing the first error. Mirrors
27
+ * `LiveSeries`'s dispatch so a throwing subscriber on a view cannot
28
+ * starve the view's other subscribers.
29
+ */
30
+ function dispatchIsolated(set, arg) {
31
+ if (set.size === 0)
32
+ return;
33
+ let first;
34
+ for (const fn of set.snapshot()) {
35
+ try {
36
+ fn(arg);
37
+ }
38
+ catch (error) {
39
+ first ??= { error };
40
+ }
41
+ }
42
+ if (first)
43
+ throw first.error;
44
+ }
45
+ /** A readable key for error messages — `String(key)` on a key object is `[object Object]`. */
46
+ function describeKey(event) {
47
+ const begin = new Date(event.begin()).toISOString();
48
+ const end = event.end?.();
49
+ return end !== undefined && end !== event.begin()
50
+ ? `[${begin}, ${new Date(end).toISOString()})`
51
+ : begin;
52
+ }
22
53
  export class LiveView {
23
54
  /** @internal */
24
55
  [EMITS_EVICT] = true;
@@ -41,15 +72,19 @@ export class LiveView {
41
72
  #onEvent;
42
73
  #onEvict;
43
74
  #unsubscribe;
75
+ /** The source this view subscribes to — kept for chain-aware dispose. */
76
+ #source;
77
+ #disposed = false;
44
78
  constructor(source, process, options) {
45
79
  this.name = source.name;
46
80
  this.schema = options?.schema ?? source.schema;
81
+ this.#source = source;
47
82
  this.#events = [];
48
83
  this.#process = process;
49
84
  this.#evict = options?.evict;
50
85
  this.#windowMs = options?.windowMs;
51
- this.#onEvent = new Set();
52
- this.#onEvict = new Set();
86
+ this.#onEvent = new ListenerSet();
87
+ this.#onEvict = new ListenerSet();
53
88
  for (let i = 0; i < source.length; i++) {
54
89
  const result = this.#process(source.at(i));
55
90
  if (result !== undefined)
@@ -61,8 +96,7 @@ export class LiveView {
61
96
  if (result !== undefined) {
62
97
  this.#appendChecked(result);
63
98
  this.#applyEviction();
64
- for (const fn of this.#onEvent)
65
- fn(result);
99
+ dispatchIsolated(this.#onEvent, result);
66
100
  }
67
101
  });
68
102
  // Mirror source eviction: when the source removes old events, remove
@@ -86,8 +120,7 @@ export class LiveView {
86
120
  if (i > 0) {
87
121
  const removed = this.#events.splice(0, i);
88
122
  this.#version += 1;
89
- for (const fn of this.#onEvict)
90
- fn(removed);
123
+ dispatchIsolated(this.#onEvict, removed);
91
124
  }
92
125
  });
93
126
  }
@@ -430,8 +463,39 @@ export class LiveView {
430
463
  set.delete(fn);
431
464
  };
432
465
  }
466
+ /**
467
+ * Detach this view from its source and drop its own listeners. After
468
+ * `dispose()` the view stops receiving events; its buffer is left as it
469
+ * was (a final `toTimeSeries()` snapshot still works) and further
470
+ * `dispose()` calls are no-ops.
471
+ *
472
+ * **Chain-aware.** `live.filter(p).map(f)` creates an intermediate view
473
+ * that nothing but the outer view references, and before [PND-LIVFIX]
474
+ * disposing the outer view left that intermediate subscribed to the
475
+ * source forever, processing every push into an unbounded buffer with
476
+ * no handle to stop it (audit 2026-06 §4.4). Now, when this view's source
477
+ * is itself a `LiveView` and this was its last subscriber, the source is
478
+ * disposed too, and so on up the chain. A source view that still has
479
+ * another subscriber is left alone. If you hold a reference to an
480
+ * intermediate and want to keep reading it after disposing a derived
481
+ * view, keep a listener on it — a view with no subscribers is treated
482
+ * as unreachable.
483
+ */
433
484
  dispose() {
485
+ if (this.#disposed)
486
+ return;
487
+ this.#disposed = true;
434
488
  this.#unsubscribe();
489
+ this.#onEvent.clear();
490
+ this.#onEvict.clear();
491
+ const source = this.#source;
492
+ if (source instanceof LiveView && source._listenerCount === 0) {
493
+ source.dispose();
494
+ }
495
+ }
496
+ /** @internal — subscribers on this view (`'event'` + `'evict'`). */
497
+ get _listenerCount() {
498
+ return this.#onEvent.size + this.#onEvict.size;
435
499
  }
436
500
  #applyEviction() {
437
501
  if (!this.#evict)
@@ -462,8 +526,8 @@ export class LiveView {
462
526
  #appendChecked(event) {
463
527
  const last = this.#events[this.#events.length - 1];
464
528
  if (last && event.key().compare(last.key()) < 0) {
465
- throw new ValidationError(`LiveView: processed event has key ${String(event.key())} ` +
466
- `older than the previous tail ${String(last.key())}. ` +
529
+ throw new ValidationError(`LiveView: processed event has key ${describeKey(event)} ` +
530
+ `older than the previous tail ${describeKey(last)}. ` +
467
531
  `Re-keying maps that produce non-monotonic output break the ` +
468
532
  `view's sorted-buffer invariant. Use a transform that ` +
469
533
  `preserves keys, or perform time-axis rewrites on a snapshot ` +
@@ -36,5 +36,14 @@ export declare function bucketStateFor(reducer: AggregateReducer): AggregateBuck
36
36
  * Custom-function reducers shine on low-rate streams where
37
37
  * convenience matters more than per-snapshot cost.
38
38
  */
39
- export declare function rollingStateFor(reducer: AggregateReducer): RollingReducerState;
39
+ /**
40
+ * Which order a source removes events from its buffer, relative to the
41
+ * order it delivered them. `'arrival'` (append-only sources — the chunked
42
+ * backing, `strict` / `drop`): oldest-arrived first, so a forward-sliding
43
+ * window state is exact. `'sorted'` (`reorder` + retention): the sorted
44
+ * prefix, which may be a later arrival — the windowed states must accept
45
+ * removal of any index ([PND-LIVFIX]).
46
+ */
47
+ export type EvictionOrder = 'arrival' | 'sorted';
48
+ export declare function rollingStateFor(reducer: AggregateReducer, evictionOrder?: EvictionOrder): RollingReducerState;
40
49
  //# sourceMappingURL=index.d.ts.map