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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * [PND-LIVFIX] A listener set whose iteration snapshot is cached: `add` /
3
+ * `delete` invalidate it, `snapshot()` rebuilds it lazily. Dispatch
4
+ * iterates the snapshot, so a listener that subscribes or unsubscribes
5
+ * mid-fan-out neither disturbs the others nor fires for the event it was
6
+ * added during — without allocating an array per event on the hot path
7
+ * (subscriptions change rarely; events arrive at kHz).
8
+ */
9
+ export declare class ListenerSet<F extends (...args: never[]) => void> {
10
+ #private;
11
+ get size(): number;
12
+ add(fn: F): void;
13
+ delete(fn: F): void;
14
+ clear(): void;
15
+ snapshot(): readonly F[];
16
+ [Symbol.iterator](): Iterator<F>;
17
+ }
18
+ //# sourceMappingURL=listener-set.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * [PND-LIVFIX] A listener set whose iteration snapshot is cached: `add` /
3
+ * `delete` invalidate it, `snapshot()` rebuilds it lazily. Dispatch
4
+ * iterates the snapshot, so a listener that subscribes or unsubscribes
5
+ * mid-fan-out neither disturbs the others nor fires for the event it was
6
+ * added during — without allocating an array per event on the hot path
7
+ * (subscriptions change rarely; events arrive at kHz).
8
+ */
9
+ export class ListenerSet {
10
+ #set = new Set();
11
+ #snapshot = null;
12
+ get size() {
13
+ return this.#set.size;
14
+ }
15
+ add(fn) {
16
+ this.#set.add(fn);
17
+ this.#snapshot = null;
18
+ }
19
+ delete(fn) {
20
+ if (this.#set.delete(fn))
21
+ this.#snapshot = null;
22
+ }
23
+ clear() {
24
+ this.#set.clear();
25
+ this.#snapshot = null;
26
+ }
27
+ snapshot() {
28
+ return (this.#snapshot ??= Array.from(this.#set));
29
+ }
30
+ [Symbol.iterator]() {
31
+ return this.snapshot()[Symbol.iterator]();
32
+ }
33
+ }
34
+ //# sourceMappingURL=listener-set.js.map
@@ -6,7 +6,7 @@ import { type LiveFillMapping, type LiveFillStrategy } from './live-view.js';
6
6
  import type { Trigger } from './triggers.js';
7
7
  import { type DiffSchema, type EventDataForSchema, type LiveSource, type NumericColumnNameForSchema, type RollingSchema, type SeriesSchema } from '../schema/index.js';
8
8
  import type { FusedMapping, FusedMappingValid, FusedPartitionedRollingSchema, ValidatedAggregateMap } from '../schema/index.js';
9
- import type { DurationInput } from '../core/duration.js';
9
+ import { type DurationInput } from '../core/duration.js';
10
10
  import type { LiveRollingOptions, RollingWindow } from './live-rolling-aggregation.js';
11
11
  /**
12
12
  * Per-partition retention and grace settings for a partitioned live
@@ -3,6 +3,14 @@ import { LiveRollingAggregation } from './live-rolling-aggregation.js';
3
3
  import { LivePartitionedSyncRolling } from './live-partitioned-sync-rolling.js';
4
4
  import { LivePartitionedFusedRolling } from './live-partitioned-fused-rolling.js';
5
5
  import { makeCumulativeView, makeDiffView, makeFillView, makeStrideSampleView, } from './live-view.js';
6
+ import { parseDuration } from '../core/duration.js';
7
+ /** [PND-LIVFIX] The option keys `partitionBy` accepts; anything else throws. */
8
+ const PARTITION_OPTION_KEYS = new Set([
9
+ 'groups',
10
+ 'retention',
11
+ 'graceWindow',
12
+ 'ordering',
13
+ ]);
6
14
  /** Encoder for partition values → keys. Mirrors the batch single-column case. */
7
15
  function partitionKey(event, col) {
8
16
  const v = event.data()[col];
@@ -92,10 +100,33 @@ export class LivePartitionedSeries {
92
100
  // Pipeline counters for {@link LivePartitionedSeries.stats}.
93
101
  // Cumulative since construction; never reset.
94
102
  #statsEventsRouted = 0;
103
+ /**
104
+ * [PND-LIVFIX] Quiet-partition age sweep. Per-partition `maxAge` used
105
+ * to run only when a partition received a push, so a partition that
106
+ * went quiet never evicted (audit 2026-06 §4.3). The router now tracks
107
+ * the source watermark (newest key routed) and, when it has advanced
108
+ * by at least `#sweepEveryMs` since the last sweep, asks EVERY
109
+ * partition to age out against it (`LiveSeries._sweepAge`). The
110
+ * throttle keeps the hot path O(1) per event on high-cardinality
111
+ * sources — a sweep is O(partitions) — while bounding how long a quiet
112
+ * partition can overstay `maxAge` to `maxAge / 8`. `Infinity` when the
113
+ * partition retention has no `maxAge` (nothing to sweep).
114
+ */
115
+ #sweepEveryMs;
116
+ #watermarkMs = -Infinity;
117
+ #lastSweepMs = -Infinity;
95
118
  constructor(source, by, options = {}) {
96
119
  this.name = source.name;
97
120
  this.schema = source.schema;
98
121
  this.by = by;
122
+ // [PND-LIVFIX] Unknown options used to be silently ignored, so a JS
123
+ // caller passing e.g. `maxPartitions` got no signal (audit §4.3).
124
+ for (const key of Object.keys(options)) {
125
+ if (!PARTITION_OPTION_KEYS.has(key)) {
126
+ throw new TypeError(`LivePartitionedSeries: unknown option "${key}". Known options: ` +
127
+ `${[...PARTITION_OPTION_KEYS].join(', ')}.`);
128
+ }
129
+ }
99
130
  if (!source.schema.some((c) => c.name === by)) {
100
131
  throw new TypeError(`LivePartitionedSeries: column "${String(by)}" not in schema`);
101
132
  }
@@ -120,6 +151,11 @@ export class LivePartitionedSeries {
120
151
  };
121
152
  this.#onSpawn = new Set();
122
153
  this.#disposers = new Set();
154
+ const maxAge = options.retention?.maxAge;
155
+ this.#sweepEveryMs =
156
+ maxAge === undefined
157
+ ? Infinity
158
+ : Math.max(1, Math.floor(parseDuration(maxAge) / 8));
123
159
  // Column-native routing path: when the source is a chunked-backed
124
160
  // LiveSeries (top-level strict time-keyed), route its appended chunks
125
161
  // through `scatterByPartition`-style bucketing into per-partition
@@ -698,6 +734,32 @@ export class LivePartitionedSeries {
698
734
  // instead of round-tripping `Event → row → Event` (which would
699
735
  // re-validate and re-allocate per event).
700
736
  part._pushTrustedEvents([event]);
737
+ this.#advanceWatermark(event.begin());
738
+ }
739
+ /** [PND-LIVFIX] Track the newest routed key and sweep quiet partitions. */
740
+ #advanceWatermark(beginMs) {
741
+ if (this.#sweepEveryMs === Infinity)
742
+ return;
743
+ if (beginMs > this.#watermarkMs)
744
+ this.#watermarkMs = beginMs;
745
+ if (this.#watermarkMs - this.#lastSweepMs < this.#sweepEveryMs)
746
+ return;
747
+ this.#lastSweepMs = this.#watermarkMs;
748
+ // A partition's `'evict'` listener may throw; the sweep must still
749
+ // reach every other partition (else a quiet partition waits another
750
+ // throttle window on someone else's bug). Isolate per partition and
751
+ // surface the first error after the loop, like a push does.
752
+ let first;
753
+ for (const part of this.#partitions.values()) {
754
+ try {
755
+ part._sweepAge(this.#watermarkMs);
756
+ }
757
+ catch (error) {
758
+ first ??= { error };
759
+ }
760
+ }
761
+ if (first)
762
+ throw first.error;
701
763
  }
702
764
  /**
703
765
  * Column-native routing: bucket a source chunk's rows by partition and
@@ -733,6 +795,8 @@ export class LivePartitionedSeries {
733
795
  one[0] = i;
734
796
  part._stageRows(store, one);
735
797
  }
798
+ if (n > 0)
799
+ this.#advanceWatermark(store.beginAt(n - 1));
736
800
  }
737
801
  /**
738
802
  * One-time replay of a chunked source's existing buffer at
@@ -65,20 +65,20 @@ type EventListener = (event: any) => void;
65
65
  * snapshot to a `TimeSeries` first via
66
66
  * `live.toTimeSeries().reduce(...)`.
67
67
  *
68
- * **Caveat — `reorder` + retention specifically.** The windowed
69
- * reducers `min` / `max` / `first` / `last` / `samples` maintain
70
- * forward-sliding-window state (a monotone deque, or head-removal
71
- * ordered entries) that assumes eviction removes the OLDEST-arrived
72
- * event first. That holds for `strict` / `drop` (append-only) and
73
- * the chunked backing, but NOT for `reorder` + retention, where the
74
- * source evicts the sorted-prefix — which may be a later arrival. On
75
- * that combination those five reducers can report stale or
76
- * `undefined` snapshots. The value-based reducers (`avg`, `count`,
77
- * `sum`, `stdev`, `median`, `percentile`, `unique`) remove by value
78
- * and stay correct. This is a long-standing limitation (it predates
79
- * the chunked backing); for reliable windowed extrema on a reorder
80
- * source, snapshot to a `TimeSeries` and `reduce` there. Tracked in
81
- * PLAN.md "Deferred".
68
+ * **`reorder` + retention** ([PND-LIVFIX]). The windowed reducers
69
+ * `min` / `max` / `first` / `last` normally keep forward-sliding-window
70
+ * state (a monotone deque, head-removal ordered entries) that assumes
71
+ * eviction removes the oldest-arrived event first — true for `strict` /
72
+ * `drop` and the chunked backing. A `reorder` source evicts its sorted
73
+ * prefix, which may be a later arrival, and those states used to go
74
+ * stale or `undefined` there. Over a `reorder` source this class now
75
+ * selects removal-by-any-index structures for those four reducers (a
76
+ * sorted array for the extrema, an index-keyed map for `first` / `last`)
77
+ * — exact in any eviction order, at O(n) per update instead of O(1),
78
+ * and only on that source shape. `first` / `last` still follow
79
+ * ARRIVAL order (the caveat above), never a value the source has
80
+ * evicted. `samples` and the value-based reducers were already
81
+ * order-independent.
82
82
  *
83
83
  * **Source contract — `EMITS_EVICT` is load-bearing.** This
84
84
  * class's reducer state stays in sync with the source's current
@@ -68,20 +68,20 @@ import { EMITS_EVICT, } from '../schema/index.js';
68
68
  * snapshot to a `TimeSeries` first via
69
69
  * `live.toTimeSeries().reduce(...)`.
70
70
  *
71
- * **Caveat — `reorder` + retention specifically.** The windowed
72
- * reducers `min` / `max` / `first` / `last` / `samples` maintain
73
- * forward-sliding-window state (a monotone deque, or head-removal
74
- * ordered entries) that assumes eviction removes the OLDEST-arrived
75
- * event first. That holds for `strict` / `drop` (append-only) and
76
- * the chunked backing, but NOT for `reorder` + retention, where the
77
- * source evicts the sorted-prefix — which may be a later arrival. On
78
- * that combination those five reducers can report stale or
79
- * `undefined` snapshots. The value-based reducers (`avg`, `count`,
80
- * `sum`, `stdev`, `median`, `percentile`, `unique`) remove by value
81
- * and stay correct. This is a long-standing limitation (it predates
82
- * the chunked backing); for reliable windowed extrema on a reorder
83
- * source, snapshot to a `TimeSeries` and `reduce` there. Tracked in
84
- * PLAN.md "Deferred".
71
+ * **`reorder` + retention** ([PND-LIVFIX]). The windowed reducers
72
+ * `min` / `max` / `first` / `last` normally keep forward-sliding-window
73
+ * state (a monotone deque, head-removal ordered entries) that assumes
74
+ * eviction removes the oldest-arrived event first — true for `strict` /
75
+ * `drop` and the chunked backing. A `reorder` source evicts its sorted
76
+ * prefix, which may be a later arrival, and those states used to go
77
+ * stale or `undefined` there. Over a `reorder` source this class now
78
+ * selects removal-by-any-index structures for those four reducers (a
79
+ * sorted array for the extrema, an index-keyed map for `first` / `last`)
80
+ * — exact in any eviction order, at O(n) per update instead of O(1),
81
+ * and only on that source shape. `first` / `last` still follow
82
+ * ARRIVAL order (the caveat above), never a value the source has
83
+ * evicted. `samples` and the value-based reducers were already
84
+ * order-independent.
85
85
  *
86
86
  * **Source contract — `EMITS_EVICT` is load-bearing.** This
87
87
  * class's reducer state stays in sync with the source's current
@@ -182,7 +182,14 @@ export class LiveReduce {
182
182
  // the live aggregation surface; keeps `LiveReduce`'s reducer
183
183
  // semantics identical to `aggregate` / `rolling`.
184
184
  this.#columns = normalizeAggregateColumns(source.schema, mapping);
185
- this.#states = this.#columns.map((c) => rollingStateFor(c.reducer));
185
+ // [PND-LIVFIX] A `reorder` source evicts its sorted prefix, which can
186
+ // be a later arrival than the FIFO frontier; the windowed states for
187
+ // `min` / `max` / `first` / `last` must then accept removal of any
188
+ // index. Ask the source (internal getter on `LiveSeries`); anything
189
+ // else is append-only.
190
+ const evictionOrder = source._evictionOrder ??
191
+ 'arrival';
192
+ this.#states = this.#columns.map((c) => rollingStateFor(c.reducer, evictionOrder));
186
193
  // Output schema: source's first (time/keyed) column + each
187
194
  // reducer's output column. Matches LiveRollingAggregation's
188
195
  // schema shape for consistency.
@@ -41,14 +41,6 @@ export type LiveSeriesOptions<S extends SeriesSchema> = {
41
41
  type EventListener<S extends SeriesSchema> = (event: EventForSchema<S>) => void;
42
42
  type BatchListener<S extends SeriesSchema> = (events: ReadonlyArray<EventForSchema<S>>) => void;
43
43
  type EvictListener<S extends SeriesSchema> = (events: ReadonlyArray<EventForSchema<S>>) => void;
44
- /**
45
- * A mutable, append-optimized event buffer for live ingestion — the entry
46
- * point to the live layer. `push` validates rows against the same schema
47
- * as `TimeSeries`, retention (`maxEvents` / `maxAge`) bounds the buffer,
48
- * and subscribers react on the `'event'`, `'batch'`, and `'evict'`
49
- * channels; snapshot to the batch layer at any time with `toTimeSeries()`.
50
- * Example: `new LiveSeries({ name, schema, retention: { maxEvents: 10_000 } })`.
51
- */
52
44
  export declare class LiveSeries<S extends SeriesSchema> {
53
45
  #private;
54
46
  readonly name: string;
@@ -100,19 +92,24 @@ export declare class LiveSeries<S extends SeriesSchema> {
100
92
  * **Commit granularity differs by backing.** On the `Event[]`
101
93
  * backing each row is appended then its `'event'` fires, so a
102
94
  * handler observes `length` grow row-by-row (`1, 2, …`) within one
103
- * `pushMany`, and a handler that throws mid-batch leaves only the
104
- * rows up to the throw committed. On the chunked columnar backing
105
- * (top-level `strict` time-keyed series) the whole batch is appended
106
- * as one chunk *before* any `'event'` fires — so a handler sees the
107
- * full post-batch `length` for every event of the batch, and a
108
- * handler that throws mid-fan-out leaves the *entire* batch committed
109
- * (the chunk is already appended). Both leave `length` and `ingested`
110
- * mutually consistent after a throw; they differ only in how much of
111
- * the batch is committed. This is intrinsic to all-or-nothing
112
- * columnar append — per-row commit would reintroduce the per-row
113
- * `Event` cost the chunked backing exists to avoid. The cross-backing
114
- * contract callers can rely on: every successfully-ingested row fires
115
- * exactly one `'event'`, in order, before `'batch'`/`'evict'`.
95
+ * `pushMany`. On the chunked columnar backing (top-level `strict`
96
+ * time-keyed series) the whole batch is appended as one chunk
97
+ * *before* any `'event'` fires, so a handler sees the full post-batch
98
+ * `length` for every event of the batch. This is intrinsic to
99
+ * all-or-nothing columnar append — per-row commit would reintroduce
100
+ * the per-row `Event` cost the chunked backing exists to avoid. The
101
+ * cross-backing contract callers can rely on: every
102
+ * successfully-ingested row fires exactly one `'event'`, in order,
103
+ * before `'batch'`/`'evict'`.
104
+ *
105
+ * **Listener errors are isolated** ([PND-LIVFIX]). A listener that
106
+ * throws does not stop the push: every row is still committed, the
107
+ * remaining listeners still run, retention still runs, and `'batch'` /
108
+ * `'evict'` still fire. The first error is rethrown to the caller once
109
+ * the push has completed, so `length`, `ingested` and every subscriber
110
+ * (a derived `filter()` view included) agree afterwards. A push made
111
+ * from inside a listener is queued and runs after the current push,
112
+ * in arrival order, so emission stays monotonic.
116
113
  */
117
114
  pushMany(rows: ReadonlyArray<RowForSchema<S>>): void;
118
115
  /**