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.
- package/API.md +211 -34
- package/CHANGELOG.md +1111 -1
- package/dist/live/listener-set.d.ts +18 -0
- package/dist/live/listener-set.js +34 -0
- package/dist/live/live-partitioned-series.d.ts +1 -1
- package/dist/live/live-partitioned-series.js +64 -0
- package/dist/live/live-reduce.d.ts +14 -14
- package/dist/live/live-reduce.js +22 -15
- package/dist/live/live-series.d.ts +18 -21
- package/dist/live/live-series.js +265 -104
- package/dist/live/live-view.d.ts +18 -9
- package/dist/live/live-view.js +72 -8
- package/dist/reducers/index.d.ts +10 -1
- package/dist/reducers/index.js +18 -24
- package/dist/reducers/rolling.d.ts +22 -0
- package/dist/reducers/rolling.js +65 -0
- package/package.json +1 -1
package/dist/live/live-series.js
CHANGED
|
@@ -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
|
|
185
|
-
this.#onBatch = new
|
|
202
|
+
this.#onEvent = new ListenerSet();
|
|
203
|
+
this.#onBatch = new ListenerSet();
|
|
186
204
|
this.#onChunk = new Set();
|
|
187
|
-
this.#onEvict = new
|
|
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
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (this.#
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.#
|
|
540
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
this.#
|
|
554
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
645
|
-
|
|
804
|
+
const errors = [];
|
|
805
|
+
this.#dispatch(this.#onEvict, evicted, errors);
|
|
806
|
+
rethrowFirst(errors);
|
|
646
807
|
}
|
|
647
808
|
}
|
|
648
809
|
toTimeSeries(name) {
|
package/dist/live/live-view.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/live/live-view.js
CHANGED
|
@@ -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
|
|
52
|
-
this.#onEvict = new
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
466
|
-
`older than the previous tail ${
|
|
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 ` +
|
package/dist/reducers/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|