@pond-ts/fit 0.31.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/CHANGELOG.md +3254 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/activity/index.d.ts +254 -0
- package/dist/activity/index.js +652 -0
- package/dist/cjs-fallback.cjs +15 -0
- package/dist/geo/index.d.ts +358 -0
- package/dist/geo/index.js +864 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/intervals.d.ts +10 -0
- package/dist/intervals.js +17 -0
- package/dist/power/index.d.ts +126 -0
- package/dist/power/index.js +279 -0
- package/dist/profile/index.d.ts +125 -0
- package/dist/profile/index.js +217 -0
- package/dist/quantities.d.ts +108 -0
- package/dist/quantities.js +207 -0
- package/dist/summary/index.d.ts +138 -0
- package/dist/summary/index.js +450 -0
- package/dist/track/index.d.ts +44 -0
- package/dist/track/index.js +65 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +10 -0
- package/dist/units.d.ts +41 -0
- package/dist/units.js +69 -0
- package/dist/zones/index.d.ts +32 -0
- package/dist/zones/index.js +61 -0
- package/package.json +46 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,3254 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
|
|
7
|
+
`@pond-ts/fit` — release together under a single `v*` tag, so this file covers
|
|
8
|
+
them all. Pre-1.0: minor bumps may include new features and type-level changes;
|
|
9
|
+
patch bumps are strictly additive.
|
|
10
|
+
|
|
11
|
+
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.31.0...HEAD
|
|
12
|
+
[0.31.0]: https://github.com/pjm17971/pond-ts/compare/v0.30.0...v0.31.0
|
|
13
|
+
[0.30.0]: https://github.com/pjm17971/pond-ts/compare/v0.29.0...v0.30.0
|
|
14
|
+
[0.29.0]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...v0.29.0
|
|
15
|
+
[0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
|
|
16
|
+
[0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
|
|
17
|
+
[0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
|
|
18
|
+
[0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
|
|
19
|
+
[0.24.0]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...v0.24.0
|
|
20
|
+
[0.23.0]: https://github.com/pjm17971/pond-ts/compare/v0.22.0...v0.23.0
|
|
21
|
+
[0.22.0]: https://github.com/pjm17971/pond-ts/compare/v0.21.0...v0.22.0
|
|
22
|
+
[0.21.0]: https://github.com/pjm17971/pond-ts/compare/v0.20.0...v0.21.0
|
|
23
|
+
[0.20.0]: https://github.com/pjm17971/pond-ts/compare/v0.19.0...v0.20.0
|
|
24
|
+
[0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
|
|
25
|
+
[0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
|
|
26
|
+
|
|
27
|
+
## [0.31.0] — 2026-06-28
|
|
28
|
+
|
|
29
|
+
First published release of **`@pond-ts/charts`** and **`@pond-ts/fit`** (both were
|
|
30
|
+
previously `private`). All four packages — `pond-ts`, `@pond-ts/react`,
|
|
31
|
+
`@pond-ts/charts`, `@pond-ts/fit` — now release together, lock-step, under one
|
|
32
|
+
`v*` tag.
|
|
33
|
+
|
|
34
|
+
### Added — `pond-ts` (core)
|
|
35
|
+
|
|
36
|
+
- **`ValueSeries` + `TimeSeries.byValue(axis)` — the value axis as a closed
|
|
37
|
+
type.** `byValue` re-keys a series onto a monotonic non-time **value axis**
|
|
38
|
+
(distance, cumulative work, …), returning a `ValueSeries` — the value-keyed
|
|
39
|
+
counterpart of `TimeSeries`. It carries the ordering-based operators
|
|
40
|
+
(`axisValues`, `axisAt`, `column`, `nearestIndex`, `sliceByValue`); the
|
|
41
|
+
calendar/clock operators are deliberately absent — a value axis has no
|
|
42
|
+
wall-clock semantics, and the disjoint `ValueSeriesSchema` makes them
|
|
43
|
+
type-impossible. The axis must be **defined, finite, and non-decreasing at
|
|
44
|
+
every row** (it becomes the index); it is dropped from the value columns (it
|
|
45
|
+
is now the key) and the rest reshare zero-copy. Substrate: a new `'value'`
|
|
46
|
+
`KeyKind` + `ValueKeyColumn`. Projection is O(N + C); `nearestIndex` is
|
|
47
|
+
O(log N); `sliceByValue` is O(log N + C) zero-copy. (value-axis RFC Phase 1.)
|
|
48
|
+
- **`scan(source, step, init, options?)` — typed-accumulator running fold.** The
|
|
49
|
+
general form of `cumulative` (the classic `mapAccumL`): the accumulator `A`
|
|
50
|
+
(any value, seeded from `init`) is **decoupled** from the numeric `output` and
|
|
51
|
+
the output column. `step(acc, value, i)` returns `[nextAcc, output]`. With no
|
|
52
|
+
`options.output` the source column is **replaced** in place (as `cumulative`
|
|
53
|
+
does); with `options.output` a **new** column is appended and the source is
|
|
54
|
+
left intact. Missing-cell carry, stored-`NaN`, and multi-entity semantics are
|
|
55
|
+
inherited from `cumulative` (scope per entity with
|
|
56
|
+
`partitionBy(col).scan(...).collect()`). Column-native, O(N + C), no event
|
|
57
|
+
materialization. Enables `split = scan + byColumn` — materialize cross-bin
|
|
58
|
+
state (e.g. hysteresis elevation gain) into a column, then segment it with
|
|
59
|
+
`byColumn`'s pure, order-free reducers. (estela F-geo-2-splits; value-axis
|
|
60
|
+
RFC wave lead.)
|
|
61
|
+
|
|
62
|
+
### Added — `@pond-ts/charts` (initial release)
|
|
63
|
+
|
|
64
|
+
- **First public release.** A React charting layer over pond-ts — a canvas data
|
|
65
|
+
plane with SVG interactive overlays. `ChartContainer` / `ChartRow` / `Layers`
|
|
66
|
+
composition; `LineChart`, `AreaChart`, `BarChart`, `Scatter`, `BoxPlot`;
|
|
67
|
+
`TimeAxis` / `YAxis` / `XAxis` (time **and** value x-axes); the cursor system
|
|
68
|
+
(staffed flag, per-row cursor modes); shared gap-rendering modes; and the
|
|
69
|
+
estela theme. Peer-depends on `pond-ts`, `@pond-ts/react`, and React 18/19.
|
|
70
|
+
|
|
71
|
+
### Added — `@pond-ts/fit` (initial release)
|
|
72
|
+
|
|
73
|
+
- **First public release.** A fitness / activity domain library over pond-ts — the
|
|
74
|
+
`Activity` / `Section` façade, unit-safe quantities (`Distance` / `Speed` /
|
|
75
|
+
`Power` / … with `.format()`), geo / power / zones analytics, `Profile` +
|
|
76
|
+
`usingProfile()` → `ProfiledActivity` / `ProfiledSection`, and the `Track`
|
|
77
|
+
value object. Façade-first: one curated flat barrel, with the functional
|
|
78
|
+
operator surface kept internal. Peer-depends on `pond-ts`.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **All `@pond-ts/*` peer / dependency ranges widened to `^0.31.0`** for the
|
|
83
|
+
lock-step release.
|
|
84
|
+
|
|
85
|
+
## [0.30.0] — 2026-06-17
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
|
|
89
|
+
- **`rollingByColumn(col, { radius, at }, mapping)` — evaluate at explicit
|
|
90
|
+
centers.** `at` takes a **non-decreasing** array of center values (e.g. a chart's
|
|
91
|
+
coarse display grid) and returns **one record per center**, instead of the
|
|
92
|
+
default one-per-row. A center with no rows within `±radius` yields each
|
|
93
|
+
reducer's empty value. Same O(n + centers) two-pointer. Closes the
|
|
94
|
+
evaluate-at-grid gap surfaced adopting `rollingByColumn` for a chart variance
|
|
95
|
+
band. (estela F-rolling-by-row.)
|
|
96
|
+
- **`smooth(col, 'movingAverage' | 'loess', { …, missing: 'skip' })` —
|
|
97
|
+
validity-respecting smoothing.** By default (`missing: 'bridge'`) a cell whose
|
|
98
|
+
own value is missing is still assigned a smoothed value from its present
|
|
99
|
+
neighbours — the line is drawn _across_ the hole. `missing: 'skip'` keeps a
|
|
100
|
+
missing cell **missing** in the output, so a sustained dropout (a coast, a
|
|
101
|
+
sensor gap) is preserved as a break rather than fabricated through. Present
|
|
102
|
+
cells smooth over only the present values in their window either way. `ema`
|
|
103
|
+
takes no `missing` option (it is causal and never fabricates across a gap). A
|
|
104
|
+
`maxGap` hard segment boundary is a deferred follow-on. (estela
|
|
105
|
+
F-smooth-interactive.)
|
|
106
|
+
|
|
107
|
+
### Changed
|
|
108
|
+
|
|
109
|
+
- **`byColumn(…, { inclusive: '(]' })` floor edge is now inclusive.** Under
|
|
110
|
+
`'(]'`, interior bins stay upper-inclusive (`(eᵢ, eᵢ₊₁]`) but the **floor `e₀`
|
|
111
|
+
is inclusive** (bin 0 is `[e₀, e₁]`), so a value at exactly the minimum edge —
|
|
112
|
+
e.g. a `0` W coast/stop sample at a zone floor of 0 — lands in bin 0 instead of
|
|
113
|
+
being dropped (the `include_lowest` convention). Previously the floor was
|
|
114
|
+
exclusive. (estela F-inclusive-floor.)
|
|
115
|
+
|
|
116
|
+
## [0.29.0] — 2026-06-17
|
|
117
|
+
|
|
118
|
+
### Added
|
|
119
|
+
|
|
120
|
+
- **`byColumn({ edges, inclusive })`** — `inclusive: '(]'` makes edge bins
|
|
121
|
+
upper-inclusive (`(eᵢ, eᵢ₊₁]`), for Coggan power / HR zones where a sample on a
|
|
122
|
+
zone's top edge belongs to the lower zone (the first edge becomes an exclusive
|
|
123
|
+
floor). Defaults to `'[)'` (unchanged — lower-inclusive `[eᵢ, eᵢ₊₁)`). (estela
|
|
124
|
+
F-geo-2 zone inclusivity.)
|
|
125
|
+
- **`'mean'` reducer alias for `'avg'`** — `'mean'` is now an accepted built-in
|
|
126
|
+
reducer name across `aggregate` / `rolling` / `byColumn` / `rollingByColumn` /
|
|
127
|
+
`reduce` (and the live equivalents), at **both runtime and the type level**: it
|
|
128
|
+
resolves to the `avg` kernel and classifies as numeric output
|
|
129
|
+
(`number | undefined`), exactly like `'avg'`. Matches the column API's
|
|
130
|
+
`Float64Column.mean()`. (estela F-reducer-naming.)
|
|
131
|
+
|
|
132
|
+
### Fixed
|
|
133
|
+
|
|
134
|
+
- **`RowForSchema` honors `required: false`** — a **value** column declared
|
|
135
|
+
`required: false` now accepts `undefined` in its tuple-row cell at the type
|
|
136
|
+
level (matching the runtime, which records it as missing), so optional cells no
|
|
137
|
+
longer need an `as never` cast. The **key (first) column stays required** even
|
|
138
|
+
if marked `required: false` (the constructor always requires it). `null` is
|
|
139
|
+
still not admitted for tuple rows (only the JSON object-row path takes `null`).
|
|
140
|
+
Correspondingly, **`.rows` / `toRows()` now type an optional cell as
|
|
141
|
+
`… | undefined`** (`NormalizedRowForSchema`), so reading a possibly-missing
|
|
142
|
+
cell is no longer unsoundly typed as present — a type tightening on output for
|
|
143
|
+
schemas that use `required: false`. (estela F-geo-row-optional; Codex-hardened.)
|
|
144
|
+
|
|
145
|
+
## [0.28.0] — 2026-06-17
|
|
146
|
+
|
|
147
|
+
### Added
|
|
148
|
+
|
|
149
|
+
- **`TimeSeries.rollingByColumn(col, { radius }, mapping)` — windowed value-axis
|
|
150
|
+
aggregation.** The sliding-window sibling of `byColumn`: slides a centered
|
|
151
|
+
`±radius` window along a **non-decreasing** numeric column and reduces it at
|
|
152
|
+
every row, returning one record per row (positionally aligned with the
|
|
153
|
+
series). Where `byColumn` collapses rows into disjoint value-bins (the
|
|
154
|
+
value-axis analogue of `aggregate`), `rollingByColumn` is the value-axis
|
|
155
|
+
analogue of `rolling`. Built for windowed-percentile bands over a derived axis
|
|
156
|
+
(e.g. a spread band over cumulative distance). A missing/non-finite axis row is
|
|
157
|
+
excluded from every window and emits each reducer's empty value. O(n) two-pointer
|
|
158
|
+
sweep. See `docs/notes/rolling-by-column.md`.
|
|
159
|
+
- **`TimeSeries.withColumn(name, values)` — attach a computed numeric column.**
|
|
160
|
+
Appends a `Float64Array` / `(number | undefined)[]` as a new `number` column
|
|
161
|
+
(the schema type widens to include it), so a derived array — cumulative
|
|
162
|
+
distance, speed, gradient — can re-enter the pond pipeline as a real column
|
|
163
|
+
that `aggregate` / `byColumn` / `rollingByColumn` / `column(name)` can
|
|
164
|
+
reference. Existing key + value columns are shared by reference (zero-copy);
|
|
165
|
+
only the new column is added. `values` must match `series.length`; defined
|
|
166
|
+
cells are validated against the numeric intake contract (**non-finite is
|
|
167
|
+
rejected** — pass `undefined` for a missing cell, not `NaN`).
|
|
168
|
+
|
|
169
|
+
### Added
|
|
170
|
+
|
|
171
|
+
- **`TimeSeries.byColumn(col, { width, origin? } | { edges }, mapping)` —
|
|
172
|
+
value-axis aggregation.** Where `aggregate` buckets the temporal key,
|
|
173
|
+
`byColumn` buckets rows by the **value** of a numeric column and reduces each
|
|
174
|
+
bin, returning an ordered array of `{ start, end, ...aggregates }` records
|
|
175
|
+
(one per bin) — not a `TimeSeries`, since value-bins (distance / power ranges)
|
|
176
|
+
aren't time-indexed. `{ width }` gives even bins emitted contiguously from the
|
|
177
|
+
lowest to highest occupied bin (monotonic source → splits / profile;
|
|
178
|
+
non-monotonic → histogram); `{ edges }` gives explicit ascending bins (e.g.
|
|
179
|
+
power zones). Reuses the reducer mapping + non-finite policy. Rows whose bin
|
|
180
|
+
value is missing / non-finite (or, for `edges`, out of range) are dropped;
|
|
181
|
+
empty bins emit the reducer's empty value; a non-finite / wrong-kind reducer
|
|
182
|
+
result throws `ValidationError`. See `docs/notes/bycolumn-value-axis.md`.
|
|
183
|
+
|
|
184
|
+
### Changed
|
|
185
|
+
|
|
186
|
+
- **`rolling(...)` now builds its output columns directly instead of
|
|
187
|
+
materializing events.** The rolling family was the last batch operator still
|
|
188
|
+
assembling a row per event and re-validating/re-packing it through the
|
|
189
|
+
constructor; it now reads the key axis and source values straight off the
|
|
190
|
+
columnar store and writes the result columns via trusted construction. The
|
|
191
|
+
result is unchanged for the common (scalar) cases. Measured: `rolling` with
|
|
192
|
+
`avg`/`sum` ~2.2–2.7× faster; rolling `stdev` on 100k events ~3.3–7.3×
|
|
193
|
+
(a 1-event window 45.7 ms → 6.3 ms); partitioned rolling ~1.8×.
|
|
194
|
+
`baseline` / `outliers` (which delegate to `rolling`) inherit the speedup.
|
|
195
|
+
- **Behavior note — `array` columns:** an identity-comparing reducer (`keep`,
|
|
196
|
+
or a custom reducer using `===` on the cell) on an `array`-kind source
|
|
197
|
+
column now compares the value stored in the column, not the original object
|
|
198
|
+
reference passed at construction. Two rows given the _same_ array object
|
|
199
|
+
therefore read as distinct. Scalar columns (number / string / boolean) are
|
|
200
|
+
unaffected. A non-finite or wrong-kind reducer result is still rejected with
|
|
201
|
+
a `ValidationError`, exactly as the constructor's intake did.
|
|
202
|
+
|
|
203
|
+
## [0.25.0] — 2026-06-15
|
|
204
|
+
|
|
205
|
+
### Changed
|
|
206
|
+
|
|
207
|
+
- **Reducers now treat non-finite numerics (`NaN` / `±Infinity`) as missing —
|
|
208
|
+
they are skipped — uniformly across every built-in reducer and all four
|
|
209
|
+
execution paths (`reduce`, the columnar fast path, `aggregate`/bucket, and
|
|
210
|
+
`rolling`/live).** Previously the paths disagreed on non-finite input: e.g.
|
|
211
|
+
`min`/`max` returned a position-dependent wrong extreme on the batch/columnar
|
|
212
|
+
paths but the true extreme on aggregate/rolling; `sum`/`avg` propagated
|
|
213
|
+
`NaN`. Non-finite can't enter via the row API (intake rejects it) — it only
|
|
214
|
+
arises inside computed columns (`cumulative` overflow, `diff`/`rate`
|
|
215
|
+
overflow, `collapse`, trusted construction) — so this only changes results
|
|
216
|
+
for those degenerate values, and makes every path agree. The three-layer
|
|
217
|
+
contract: **intake** stays strict (rejects non-finite), **computed writers**
|
|
218
|
+
stay permissive (pack honest non-finite), **reducers** are robust (skip it).
|
|
219
|
+
A standing parity-matrix test now pins all paths together. See
|
|
220
|
+
`docs/notes/reducer-nan-policy.md`. This also resolves the `aggregate('stdev')`
|
|
221
|
+
divergence class and the `min`/`max` NaN-laundering bug.
|
|
222
|
+
- Internal: `Float64Column` gained an `allFinite` fast-path flag (data-derived
|
|
223
|
+
at construction, conservative-by-default) so reducers skip the per-element
|
|
224
|
+
finite check on provably-finite columns — keeping the policy's cost off the
|
|
225
|
+
hot path (min/max/count stay at their pre-policy speed).
|
|
226
|
+
|
|
227
|
+
### Fixed
|
|
228
|
+
|
|
229
|
+
- **`rolling(window, { x: 'stdev' })` is now numerically stable.** It was the
|
|
230
|
+
last batch stdev path still on the one-pass `Σx²/n − mean²`, which cancels
|
|
231
|
+
catastrophically on near-equal large values (`[1e10, 1e10+1, …]` → `0`
|
|
232
|
+
instead of ≈1.118, or a negative variance → `NaN`) and drifts on trending
|
|
233
|
+
data (cumulative distance, elevation). It now uses Welford's online variance
|
|
234
|
+
with an order-independent **delete** — deviation-space, so no cancellation,
|
|
235
|
+
and removal **by value**, which keeps it correct under the live layer's
|
|
236
|
+
`reorder`-mode eviction (a positional/FIFO remove would have broken it; the
|
|
237
|
+
documented "stdev is reorder-safe" contract is preserved). Rolling-stdev
|
|
238
|
+
values shift in the last ULPs (now correct); the path stays O(1) and within
|
|
239
|
+
run-noise of the old one-pass, and a single-element window now reports exactly
|
|
240
|
+
`0` at any magnitude. Like any subtractive sliding variance, evicting an
|
|
241
|
+
outlier far outside the residual spread loses precision — negligible until the
|
|
242
|
+
evicted point is ~1e7–1e8× the residual stdev, far beyond realistic data.
|
|
243
|
+
- A standing differential-fuzz parity suite now pins every built-in reducer's
|
|
244
|
+
execution paths (columnar fast path vs `bucket` vs `rolling`, and the FIFO
|
|
245
|
+
sliding window vs a from-scratch recompute) against silent drift across
|
|
246
|
+
randomized magnitudes and window sizes — the class of bug behind the stdev
|
|
247
|
+
and `min`/`max` divergences.
|
|
248
|
+
|
|
249
|
+
## [0.24.0] — 2026-06-14
|
|
250
|
+
|
|
251
|
+
### Changed
|
|
252
|
+
|
|
253
|
+
- **`TimeSeries.timeRange()` is now a columnar key-axis read instead of a
|
|
254
|
+
reduce over materialized events.** Behavior is unchanged, but the old
|
|
255
|
+
implementation materialized every `Event` on its first call — and because
|
|
256
|
+
`aggregate()` defaults its `range` to `series.timeRange()`, a one-shot
|
|
257
|
+
`aggregate()` paid full event materialization before the columnar fast
|
|
258
|
+
path could run, erasing the win. The new path reads the key column's
|
|
259
|
+
begin/end axis directly: O(1) for time-keyed series, a typed-array scan
|
|
260
|
+
for range/interval-keyed series, with no event materialization. Measured
|
|
261
|
+
on 1M rows: `timeRange()` itself ~407 ms → ~0.002 ms (time-keyed); cold
|
|
262
|
+
`aggregate()` with a defaulted range ~387 ms → ~6 ms (~63×). Every
|
|
263
|
+
`timeRange()` / `overlaps` / `contains` / `intersection` caller benefits.
|
|
264
|
+
(Audit v2 §3.3.)
|
|
265
|
+
- **`aggregate()` now takes the columnar fast path when a mapping mixes
|
|
266
|
+
numeric reducers with `first` / `last`.** Previously a single `first` or
|
|
267
|
+
`last` column (they have no numeric `reduceColumn`) bailed the entire call
|
|
268
|
+
to the row path. They now qualify via a boundary scan — the first/last
|
|
269
|
+
_defined_ cell, on any column kind. Behavior is unchanged. The big
|
|
270
|
+
beneficiary is **partitioned `aggregate`**, which auto-injects a `'first'`
|
|
271
|
+
reducer for the partition column and so was excluded from the fast path on
|
|
272
|
+
every call (audit v2 §3.2/§3.3). Measured on 1M rows, flat
|
|
273
|
+
`{ cpu: 'avg', host: 'first' }`: ~37.7 ms → ~4.8 ms (~7.8×); the
|
|
274
|
+
pure-numeric path is unchanged. (The remaining `partitionBy` materialization
|
|
275
|
+
cost is addressed separately by the columnar `partitionBy` split.)
|
|
276
|
+
- **`partitionBy(...)` now splits the columnar store directly instead of
|
|
277
|
+
materializing events.** `collect()` / the per-partition sugar methods
|
|
278
|
+
(`fill` / `diff` / `rolling` / …) and `toMap()` previously walked
|
|
279
|
+
`this.events` to bucket rows, then rebuilt each partition via `fromEvents`
|
|
280
|
+
(re-validating + re-packing) — silently re-paying the event-materialization
|
|
281
|
+
tax the columnar wave removed, and making `partitionBy(host).fill().collect()`
|
|
282
|
+
the #1 batch hotspot. They now group row indices off the store and gather
|
|
283
|
+
each partition via a zero-materialization columnar selection. Behavior is
|
|
284
|
+
unchanged (partition order, the `' undefined'` missing-key bucket, composite
|
|
285
|
+
keys, and declared `groups` all preserved). Measured on 100k rows / 64
|
|
286
|
+
partitions: `toMap()` ~389 → ~25 ns/row (~15×, no event materialization at
|
|
287
|
+
all); `diff().collect()` ~2×; `fill(hold).collect()` ~1.7× (the residual is
|
|
288
|
+
`TimeSeries.concat` still materializing to re-sort — a separate follow-up).
|
|
289
|
+
Declared-`groups` membership is validated by the same columnar scan, so that
|
|
290
|
+
path is materialization-free too (~331 → ~33 ns/row). **Behavior note:**
|
|
291
|
+
per-partition sub-series from `toMap()` / `apply()` now lazily materialize
|
|
292
|
+
their own `Event` objects rather than reusing the source's instances — cell
|
|
293
|
+
values are identical; only object identity differs (`collect()`, which
|
|
294
|
+
returns the source unchanged, is unaffected). (Audit v2 §3.2.)
|
|
295
|
+
|
|
296
|
+
## [0.23.0] — 2026-06-13
|
|
297
|
+
|
|
298
|
+
### Added
|
|
299
|
+
|
|
300
|
+
- **`new TimeSeries({ …, sort: true })` (and `TimeSeries.fromJSON`) sort rows by
|
|
301
|
+
key on construction.** Pond requires rows in non-decreasing key order and
|
|
302
|
+
throws otherwise; `sort: true` accepts unsorted input (messy CSVs, merged
|
|
303
|
+
sources) and sorts it for you instead of forcing a manual pre-sort. The sort
|
|
304
|
+
is **stable** — rows with equal keys keep their input order — matching what
|
|
305
|
+
`TimeSeries.fromEvents` already does. The out-of-order error now names the
|
|
306
|
+
option. (Audit v2 §5 F3.)
|
|
307
|
+
|
|
308
|
+
### Changed
|
|
309
|
+
|
|
310
|
+
- **CommonJS consumers now get a clear error instead of
|
|
311
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`.** Both `pond-ts` and `@pond-ts/react`
|
|
312
|
+
add a `require` condition to their `exports["."]` map pointing at a tiny
|
|
313
|
+
shipped CJS stub that throws an ESM-only message naming `import` as the
|
|
314
|
+
fix. The packages remain ES-module-only; this only improves the error a
|
|
315
|
+
`require('pond-ts')` caller sees. (Audit v2 §5 F6/F7/F9/F10/F11)
|
|
316
|
+
- **Published tarballs no longer ship `*.js.map` / `*.d.ts.map` source
|
|
317
|
+
maps.** The maps referenced a `../src` tree that was never included in
|
|
318
|
+
the tarball (`files: ["dist", …]`), so they were dead weight (~⅓ of the
|
|
319
|
+
unpacked size). A `prepack` step now strips them from the published
|
|
320
|
+
artifact for both packages; local `npm run build` still emits them, so
|
|
321
|
+
in-repo debugging is unaffected. (Audit v2 §5 F6/F7/F9/F10/F11)
|
|
322
|
+
|
|
323
|
+
### Fixed
|
|
324
|
+
|
|
325
|
+
- **Shipped `.d.ts` now type-check under `skipLibCheck: false`.** The internal
|
|
326
|
+
`EMITS_EVICT` marker symbol was `@internal` (stripped from the emitted
|
|
327
|
+
`series.d.ts`) but still referenced by un-stripped public declarations — a
|
|
328
|
+
by-name re-export in `schema/index.d.ts` and the `[EMITS_EVICT]` brand members
|
|
329
|
+
on `LiveSeries` / `LiveView` — leaving dangling references that broke strict
|
|
330
|
+
consumer builds with **TS2305**. Those references are now `@internal` too, so
|
|
331
|
+
the symbol is fully stripped from the published types; runtime behavior is
|
|
332
|
+
unchanged. (Audit v2 §5 F2.)
|
|
333
|
+
- **`TimeSeries.at(-1)` counts from the end**, matching `LiveSeries.at` and
|
|
334
|
+
`Array.prototype.at` (it previously returned `undefined` for any negative
|
|
335
|
+
index). Deep underflow (e.g. `at(-100)` on a 3-event series) still returns
|
|
336
|
+
`undefined`, and the non-integer / `NaN` guard is unchanged. (Audit v2 §5 F8.)
|
|
337
|
+
- **Docs: corrected `Time.asString()` (does not exist), the missing
|
|
338
|
+
`aggregate`/`materialize` → `pivotByGroup` rekey pointer, and an
|
|
339
|
+
inaccurate `rolling().value()` return-type example.** The getting-started
|
|
340
|
+
example now calls `event.key().toDate().toISOString()`; the aggregation
|
|
341
|
+
and reshape pages note that interval-keyed output must be rekeyed with
|
|
342
|
+
`.asTime({ at: 'begin' })` before a time-keyed transform like
|
|
343
|
+
`pivotByGroup` (whose runtime error now says so too); the rolling page
|
|
344
|
+
documents `value()` as `Record<string, ColumnValue | undefined>`.
|
|
345
|
+
(Audit v2 §5 F6/F7/F9/F10/F11)
|
|
346
|
+
- **Mixed shorthand + `{ from, using }` mappings now keep every output
|
|
347
|
+
column in the result type (Audit v2 §5 F1).** Calling
|
|
348
|
+
`aggregate` / `rolling` / `reduce` with a mapping that mixes the
|
|
349
|
+
shorthand form (`cpu: 'avg'`) and the spec form
|
|
350
|
+
(`cpu_p95: { from: 'cpu', using: 'p95' }`) in one call — the
|
|
351
|
+
docs-blessed pattern — previously resolved to the shorthand overload
|
|
352
|
+
and **silently dropped every spec-keyed output column from the result
|
|
353
|
+
type** (`event.get('cpu_p95')` failed to compile with `TS2345`), even
|
|
354
|
+
though the runtime emitted the column. The two overloads
|
|
355
|
+
(`AggregateMap` shorthand + `AggregateOutputMap` spec) are now
|
|
356
|
+
collapsed into one unified mapping shape whose result schema dispatches
|
|
357
|
+
per output key, so all columns survive and each narrows to its
|
|
358
|
+
reducer's output kind. Runtime behavior is unchanged — this is a
|
|
359
|
+
types-only fix plus the tests that should have caught it.
|
|
360
|
+
- **The unified mapping keeps the shorthand compile-time guards.** A
|
|
361
|
+
shorthand reducer is still kind-checked against its source column
|
|
362
|
+
(`host: 'avg'` on a `string` column stays a compile error), and a bare
|
|
363
|
+
reducer on a key that is not a source column (`ghost: 'avg'` — a typo
|
|
364
|
+
the runtime rejects with "unknown source column") is now a compile
|
|
365
|
+
error too. Spec keys (`{ from, using }`) remain free output names.
|
|
366
|
+
Inline mapping literals get full validation; values pre-widened to
|
|
367
|
+
`AggregateMap<S>` and broad-schema (`TimeSeries<SeriesSchema>`)
|
|
368
|
+
callers keep the permissive shape. `AggregateOutputMap` is retained
|
|
369
|
+
as a back-compat alias of `AggregateMap`.
|
|
370
|
+
|
|
371
|
+
## [0.22.0] — 2026-06-12
|
|
372
|
+
|
|
373
|
+
### Changed
|
|
374
|
+
|
|
375
|
+
- **`asTime` / `asTimeRange` / `asInterval` are now column-native.** They
|
|
376
|
+
reinterpret the key's kind (a "rekey") straight off the existing key's
|
|
377
|
+
`begin` / `end` buffers instead of materializing events — value columns pass
|
|
378
|
+
through by reference. `asTimeRange` and `asTime` with `begin` / `end` reuse
|
|
379
|
+
the key buffer zero-copy (≈ **9×** faster on a build → rekey → read pipeline);
|
|
380
|
+
`asTime({ at: 'center' })` adds one midpoint pass; `asInterval` builds the
|
|
381
|
+
label column (string → `StringColumn`, number → `Float64Column`, inferred
|
|
382
|
+
from the first label and required consistent across rows). `asTime` with
|
|
383
|
+
`center` / `end` throws if anchoring a source with overlapping extents would
|
|
384
|
+
produce a non-monotonic time axis (preserving the prior validation — `begin`
|
|
385
|
+
is always sorted and is exempt).
|
|
386
|
+
- **Breaking: `asInterval`'s label function now receives the interval's
|
|
387
|
+
`TimeRange` (its `[begin, end]` extent) and index — not the whole `Event`.**
|
|
388
|
+
The canonical form is unchanged: `series.asInterval(range => range.begin())`
|
|
389
|
+
works exactly as before (both `Event` and `TimeRange` expose `begin()` /
|
|
390
|
+
`end()`). Only a label fn that read a _value column_ off the event (e.g.
|
|
391
|
+
`event => event.get('label')`) needs rewriting — compute the label before
|
|
392
|
+
`asInterval`, or derive it from the extent. The constant form
|
|
393
|
+
(`asInterval('bucket')` / `asInterval(42)`) is unaffected. (Pre-1.0 minor;
|
|
394
|
+
this is the change that lets the function form stay on the columnar path.)
|
|
395
|
+
|
|
396
|
+
### Fixed
|
|
397
|
+
|
|
398
|
+
- **`mapColumns` rejects a non-finite numeric result at write.** A mapper on a
|
|
399
|
+
`number` column that returns `NaN` or `±Infinity` now throws a `RangeError`,
|
|
400
|
+
consistent with construction intake (which already rejects non-finite
|
|
401
|
+
numbers). Previously the value was packed into the column, where the reduce
|
|
402
|
+
fast path and the row path could disagree on the same bucket (e.g.
|
|
403
|
+
`aggregate('min')` returning a different result depending on which path ran).
|
|
404
|
+
A stored `NaN` is still a defined value the mapper sees — map it to a finite
|
|
405
|
+
number, or to `undefined` (missing), to clean it. (Closes a hole introduced
|
|
406
|
+
alongside `mapColumns` in 0.21.0.)
|
|
407
|
+
- **`aggregate('stdev')` is now numerically stable and path-independent.** The
|
|
408
|
+
bucketed row path (`bucketState`) used a one-pass `sq/n − mean²` accumulator
|
|
409
|
+
that cancels catastrophically on near-equal large-magnitude values —
|
|
410
|
+
returning `0` (e.g. `[1e10, 1e10+1, 1e10+2, 1e10+3]` → `0` instead of
|
|
411
|
+
`≈1.118`), or a negative variance whose `sqrt` is `NaN` that the validating
|
|
412
|
+
constructor then rejected with a throw. Because the columnar fast path is
|
|
413
|
+
all-or-nothing, an unrelated mapping (e.g. a `count` over a string column)
|
|
414
|
+
could silently flip the _same_ series' stdev. All three batch paths (`reduce`,
|
|
415
|
+
`reduceColumn`, `bucketState`) now share **one Welford recurrence** — O(1) per
|
|
416
|
+
element, no buffer (so the live aggregation path that shares `bucketState`
|
|
417
|
+
stays O(1)), `m2 ≥ 0` by construction — so they agree regardless of magnitude.
|
|
418
|
+
(Even the prior two-pass `Σv/n`-then-deviations drifted ~8.7% from the true
|
|
419
|
+
value at `2^52`, where the summed mean rounds — so unifying on Welford, not
|
|
420
|
+
two-pass, was necessary.) **Correction:** 0.21.0's columnar `aggregate()` fast
|
|
421
|
+
path (#186) was described as "signature + semantics unchanged", but it did
|
|
422
|
+
change released `stdev` output for fast-path-qualifying aggregates; this fix
|
|
423
|
+
makes every path agree. (`rolling`/`smooth` stdev keep the one-pass form for
|
|
424
|
+
now — a separate, deferred item.)
|
|
425
|
+
|
|
426
|
+
## [0.21.0] — 2026-06-11
|
|
427
|
+
|
|
428
|
+
### Added
|
|
429
|
+
|
|
430
|
+
- **`TimeSeries.mapColumns({ col: (value) => newValue })`** — a per-cell column
|
|
431
|
+
value transform. The column-scoped counterpart of the event-based `map()`:
|
|
432
|
+
where `map(schema, event => newEvent)` rebuilds whole rows through an
|
|
433
|
+
arbitrary closure (and can change the schema/key), `mapColumns` transforms
|
|
434
|
+
individual columns' values in place, reading the columns directly (no
|
|
435
|
+
per-row `Event`) so it stays on the fast columnar path. Same kind in/out
|
|
436
|
+
(number→number, string→string, …), so the schema is unchanged; missing cells
|
|
437
|
+
carry (the mapper isn't called on `undefined`). ~5–6× faster than the
|
|
438
|
+
`map()` workaround on a build → transform → read pipeline.
|
|
439
|
+
|
|
440
|
+
### Changed
|
|
441
|
+
|
|
442
|
+
- **`select` / `rename` / `slice` / `cumulative` / `diff` / `rate` /
|
|
443
|
+
`pctChange` / `fill` / `shift` / `collapse` are now column-native.** They
|
|
444
|
+
reshape the columnar store directly instead of materializing events, so the
|
|
445
|
+
columnar construction win is preserved through these transforms — build →
|
|
446
|
+
transform → read pipelines run several× faster (~7–10× for `select` /
|
|
447
|
+
`rename` / `slice`; ~5–7× for the `cumulative` / `diff` / `rate` / `fill` /
|
|
448
|
+
`shift` / `collapse` folds). No API change for type-correct callers (one
|
|
449
|
+
narrow `fill` behavior change is noted under Fixed). `cumulative` / `diff` /
|
|
450
|
+
`rate` / `pctChange` / `fill` / `shift` / `collapse` are also the first
|
|
451
|
+
operators extracted into `batch/operators/` (internal refactor); `fill`
|
|
452
|
+
rebuilds only the columns it actually changes; `slice` normalizes
|
|
453
|
+
`Array.prototype.slice` semantics onto a zero-copy `withRowRange` reshape;
|
|
454
|
+
`collapse` reads only the keyed columns and passes the kept columns through
|
|
455
|
+
by reference.
|
|
456
|
+
|
|
457
|
+
### Fixed
|
|
458
|
+
|
|
459
|
+
- **`rename` now rejects target-name collisions** (e.g. renaming `a` → `b`
|
|
460
|
+
when `b` already exists) with a clear error, instead of silently producing a
|
|
461
|
+
duplicate-named schema. Also fixes a prototype-chain bug where a column named
|
|
462
|
+
`toString` (or another `Object.prototype` member) could be corrupted during
|
|
463
|
+
a rename.
|
|
464
|
+
- **`fill` now throws on a kind-mismatched literal** (e.g.
|
|
465
|
+
`fill({ value: 'banana' })` where `value` is numeric — type-allowed because
|
|
466
|
+
mapping values are the broad `FillStrategy | ScalarValue`) with a clear
|
|
467
|
+
`RangeError` naming the column, instead of silently producing an
|
|
468
|
+
internally-inconsistent series (the old events path returned the literal
|
|
469
|
+
from `.get()` while the numeric column read `NaN`). The throw is
|
|
470
|
+
gap-dependent — it only fires when the literal would actually be placed.
|
|
471
|
+
|
|
472
|
+
## [0.20.0] — 2026-06-04
|
|
473
|
+
|
|
474
|
+
Two internal performance improvements driven by the dashboard experiment at
|
|
475
|
+
256-host stress. **No public API changes** — both are behavior-preserving.
|
|
476
|
+
|
|
477
|
+
### Changed
|
|
478
|
+
|
|
479
|
+
- **Column-native partition routing.** `partitionBy(...)` over a strict
|
|
480
|
+
time-keyed source now routes its source chunks into per-partition
|
|
481
|
+
**chunked** sub-series via a coalescing staging tier, replacing the
|
|
482
|
+
per-partition `Event[]` retention. A large drop in retained memory and
|
|
483
|
+
object count at high partition counts (gRPC bench at 256 partitions: 60×
|
|
484
|
+
fewer columnar stores, −99.4% `Event` retention, +24% sustained throughput)
|
|
485
|
+
([#175](https://github.com/pjm17971/pond-ts/pull/175)). Behavior-preserving;
|
|
486
|
+
internal only — no public surface added.
|
|
487
|
+
- **`LiveView.toTimeSeries()` snapshot caching.** The built `TimeSeries` is
|
|
488
|
+
memoized against an internal mutation counter, so back-to-back
|
|
489
|
+
identical-state calls (multiple subscribers, framework commit batching,
|
|
490
|
+
StrictMode double-invoke) return the cached instance by reference instead of
|
|
491
|
+
rebuilding the whole snapshot — ~44 ms → ~0 at a 262k-event window. A
|
|
492
|
+
fresh-state call still builds; safe because `TimeSeries` is immutable
|
|
493
|
+
([#180](https://github.com/pjm17971/pond-ts/pull/180)).
|
|
494
|
+
|
|
495
|
+
## [0.19.0] — 2026-06-02
|
|
496
|
+
|
|
497
|
+
Adds an **experimental column-read surface to the live side** — read typed
|
|
498
|
+
columns straight off a `LiveView` without materializing a `TimeSeries`
|
|
499
|
+
snapshot — driven by the dashboard experiment's per-tick memo cost. Plus a
|
|
500
|
+
`useTimeSeries` schema-inference fix. The live column surface is
|
|
501
|
+
**experimental and expected to keep moving in 0.19.x**.
|
|
502
|
+
|
|
503
|
+
### Added
|
|
504
|
+
|
|
505
|
+
- **`LiveView` column-read surface (experimental).** Read columns directly
|
|
506
|
+
off a windowed live view, the column-API counterpart to the batch
|
|
507
|
+
`TimeSeries` surface ([#179](https://github.com/pjm17971/pond-ts/pull/179)):
|
|
508
|
+
- `liveView.column(name)` — a numeric value column gathered from the view's
|
|
509
|
+
current events (string / array columns are a compile error; read those as
|
|
510
|
+
scalars or snapshot via `toTimeSeries()`).
|
|
511
|
+
- `liveView.keyColumn()` — the time axis (`TimeKeyColumn`; time-keyed views
|
|
512
|
+
only, enforced at compile time).
|
|
513
|
+
- `liveView.partitionBy(col).toMap(fn)` — a walk-now per-partition read
|
|
514
|
+
returning `Map<string, R>`, mirroring `TimeSeries.partitionBy().toMap()`
|
|
515
|
+
but without per-partition `TimeSeries` construction. Distinct from
|
|
516
|
+
`LiveSeries.partitionBy` (which is subscription-oriented). Throws on a
|
|
517
|
+
missing / key partition column rather than silently merging.
|
|
518
|
+
- `LiveColumnGroup` — the per-partition view passed to the `toMap` callback.
|
|
519
|
+
- **`@pond-ts/react`: `useLiveVersion(source, { throttle })` (experimental)**
|
|
520
|
+
— a `useSyncExternalStore`-based change signal that bumps on append **and**
|
|
521
|
+
eviction, so a component can read columns off a live view each render
|
|
522
|
+
without manufacturing a `TimeSeries` snapshot. Closes the
|
|
523
|
+
render-before-subscribe gap; throttling bounds only the React notification
|
|
524
|
+
([#179](https://github.com/pjm17971/pond-ts/pull/179)).
|
|
525
|
+
|
|
526
|
+
### Changed
|
|
527
|
+
|
|
528
|
+
- **`useTimeSeries` collapsed to a single generic** `<S extends SeriesSchema>`
|
|
529
|
+
so the schema infers from `input.schema`. The prior two-generic signature
|
|
530
|
+
lost `S` through the input-wrapper generic and resolved
|
|
531
|
+
`result.column('cpu')` to `never`; the accepted input type is unchanged, so
|
|
532
|
+
this is an inference fix — but a caller passing two explicit type arguments
|
|
533
|
+
must drop the second ([#176](https://github.com/pjm17971/pond-ts/pull/176)).
|
|
534
|
+
|
|
535
|
+
## [0.18.0] — 2026-05-30
|
|
536
|
+
|
|
537
|
+
This release graduates the **Phase 4.7 columnar substrate** from
|
|
538
|
+
framework-internal (shipped piecemeal to `main` since v0.17.1) to a
|
|
539
|
+
user-visible **public column API**, plus a column-native live buffer that
|
|
540
|
+
fixes a high-partition-count OOM. Everything is additive except one
|
|
541
|
+
documented breaking change (interval label kinds) and one documented
|
|
542
|
+
behavior change (chunked-backed `pushMany` commit semantics). Pre-1.0: the
|
|
543
|
+
column API is expected to keep moving toward its eventual shape.
|
|
544
|
+
|
|
545
|
+
### Added
|
|
546
|
+
|
|
547
|
+
- **Public column API (Phase 4.7 step 8).** A column-centric extraction
|
|
548
|
+
surface on `TimeSeries`, for high-throughput and charting consumers that
|
|
549
|
+
want typed-array access instead of per-`Event` iteration. Additive — every
|
|
550
|
+
existing row / `Event` API is unchanged.
|
|
551
|
+
- `series.column(name)` returns a schema-narrowed typed column view, with
|
|
552
|
+
public re-exports of the `Float64Column` / `BooleanColumn` /
|
|
553
|
+
`StringColumn` / `KeyColumn` (time / timeRange / interval) variants
|
|
554
|
+
([#154](https://github.com/pjm17971/pond-ts/pull/154),
|
|
555
|
+
[#155](https://github.com/pjm17971/pond-ts/pull/155)).
|
|
556
|
+
- `Float64Column`: scalar reductions (`min` / `max` / `sum` / `mean` /
|
|
557
|
+
`count` / …) and `scan`
|
|
558
|
+
([#155](https://github.com/pjm17971/pond-ts/pull/155)); `bin(...)` for
|
|
559
|
+
histogram / downsample bucketing
|
|
560
|
+
([#156](https://github.com/pjm17971/pond-ts/pull/156)); and
|
|
561
|
+
`toFloat64Array()` for a storage-agnostic gather into a dense array
|
|
562
|
+
([#165](https://github.com/pjm17971/pond-ts/pull/165)).
|
|
563
|
+
- `KeyColumn.at(i)` and `.slice(start, end)`
|
|
564
|
+
([#159](https://github.com/pjm17971/pond-ts/pull/159)).
|
|
565
|
+
- **Columnar substrate (Phase 4.7 step 1, framework layer).** All
|
|
566
|
+
eight sub-steps (1a–1h) shipped to main as PRs #132 / #133 /
|
|
567
|
+
#134 / #135 / #136 / #147 / #148 / #149. See `PLAN.md` and
|
|
568
|
+
[`packages/core/src/columnar/README.md`](packages/core/src/columnar/README.md)
|
|
569
|
+
for the full inventory. Framework-internal — surfaced behind the existing
|
|
570
|
+
`TimeSeries` API at step 2 (below) and the public column API at step 8
|
|
571
|
+
(above).
|
|
572
|
+
|
|
573
|
+
### Changed
|
|
574
|
+
|
|
575
|
+
- **Chunked columnar live backing for strict time-keyed `LiveSeries`**
|
|
576
|
+
([#170](https://github.com/pjm17971/pond-ts/pull/170)). A top-level
|
|
577
|
+
`LiveSeries` with `ordering: 'strict'` and a time key now backs its
|
|
578
|
+
retained window with batch-granular columnar chunks instead of an
|
|
579
|
+
`Event[]` window — each `pushMany` validates straight into typed columns,
|
|
580
|
+
retaining **zero `Event` objects** (~4.7× less retained heap in-pond; the
|
|
581
|
+
high-partition-count OOM fix). Two consequences:
|
|
582
|
+
- **`pushMany` commit semantics** on the chunked path: the batch is
|
|
583
|
+
appended atomically _before_ any `'event'` fires, so a listener observes
|
|
584
|
+
the full post-batch `length` (not a row-by-row `1, 2, 3`), and a listener
|
|
585
|
+
that throws mid-batch leaves the whole batch committed. The per-row
|
|
586
|
+
`Event[]` backing (`reorder` / `drop` / interval-keyed /
|
|
587
|
+
internally-created series) keeps per-row commit. Listener _values_ and
|
|
588
|
+
`event → batch → evict` ordering are unchanged.
|
|
589
|
+
- **`LiveReduce` eviction** resolves by event identity (primary) with a
|
|
590
|
+
FIFO-frontier fallback for the chunked backing's materialized evictions —
|
|
591
|
+
correct for both `reorder` and the chunked backing. `min` / `max` /
|
|
592
|
+
`first` / `last` / `samples` over a `reorder` source **with retention**
|
|
593
|
+
remain a documented limitation (see `LiveReduce` JSDoc and PLAN
|
|
594
|
+
"Deferred") — pre-existing, not introduced here.
|
|
595
|
+
- **Internal, behavior-preserving performance work.** Column-native intake
|
|
596
|
+
bypasses per-row `Event` allocation at `TimeSeries` construction
|
|
597
|
+
([#151](https://github.com/pjm17971/pond-ts/pull/151)); numeric reducers
|
|
598
|
+
(`min` / `max` / `sum` / `avg` / …) compute over typed-array columns where
|
|
599
|
+
available, with NaN parity preserved
|
|
600
|
+
([#153](https://github.com/pjm17971/pond-ts/pull/153)); the live storage
|
|
601
|
+
strategy was extracted behind an internal interface
|
|
602
|
+
([#168](https://github.com/pjm17971/pond-ts/pull/168)).
|
|
603
|
+
|
|
604
|
+
### Changed (BREAKING)
|
|
605
|
+
|
|
606
|
+
- **Interval-keyed series must use one label type throughout**
|
|
607
|
+
([#150](https://github.com/pjm17971/pond-ts/pull/150)). Pre-2a,
|
|
608
|
+
TimeSeries silently tolerated mixed-kind interval labels —
|
|
609
|
+
rows with `value: 'row-1'` (string) and `value: 2` (number) could
|
|
610
|
+
coexist in a single series because events were stored as a raw
|
|
611
|
+
array with no per-column type alignment. The columnar substrate
|
|
612
|
+
introduced at Phase 4.7 enforces one label kind per column via
|
|
613
|
+
`IntervalKeyColumn`, so mixed-kind labels now throw at series
|
|
614
|
+
construction with a row-pointed error message.
|
|
615
|
+
- **Affected:** Any series built via `new TimeSeries(...)`,
|
|
616
|
+
`TimeSeries.fromJSON(...)`, `TimeSeries.fromEvents(...)`, or
|
|
617
|
+
any transform that produces interval-keyed events, where the
|
|
618
|
+
`value` field of `IntervalInput` rows or `Interval` keys
|
|
619
|
+
mixes `string` and `number` types.
|
|
620
|
+
- **Migration:** Choose one label kind for the whole series.
|
|
621
|
+
Numeric labels can be stringified at intake (`String(label)`)
|
|
622
|
+
if the downstream consumer accepts string equality; string
|
|
623
|
+
labels parseable as integers can be converted to numbers at
|
|
624
|
+
intake. The error message names the first offending row so
|
|
625
|
+
the offending data is easy to find.
|
|
626
|
+
- **Rationale:** Aligns the row-API contract with the columnar
|
|
627
|
+
substrate's per-column kind discipline (matching Polars /
|
|
628
|
+
Arrow / Parquet). The previous behavior produced type-broken
|
|
629
|
+
events that worked only because TimeSeries didn't enforce
|
|
630
|
+
per-column alignment; downstream columnar operators (the
|
|
631
|
+
upcoming reducer adaptation in steps 3+) require it.
|
|
632
|
+
- **Affected types:** `IntervalValue` remains `string | number`
|
|
633
|
+
per the `Interval` class contract. The runtime restriction
|
|
634
|
+
is at the **series** level (all intervals within one series
|
|
635
|
+
must share a kind), not the per-interval level. Type-level
|
|
636
|
+
narrowing of `IntervalKeyedSchema<S>` over label kind is a
|
|
637
|
+
follow-up deferred to a later sub-step.
|
|
638
|
+
|
|
639
|
+
## [0.17.1] — 2026-05-11
|
|
640
|
+
|
|
641
|
+
Bug fix: `live.partitionBy()` now default-inherits `ordering`,
|
|
642
|
+
`graceWindow`, and `retention` from the source `LiveSeries`. Surfaced
|
|
643
|
+
by the gRPC experiment's
|
|
644
|
+
[M4 late-data friction note](https://github.com/pjm17971/pond-grpc-experiment/blob/main/friction-notes/M4.md),
|
|
645
|
+
which measured `99.5%` of late events crashing the partition router
|
|
646
|
+
under `source = LiveSeries({ ordering: 'reorder', graceWindow })`
|
|
647
|
+
followed by bare `partitionBy('host')`.
|
|
648
|
+
|
|
649
|
+
### Fixed
|
|
650
|
+
|
|
651
|
+
- **`LiveSeries.partitionBy(by)` default-inherits source config**
|
|
652
|
+
([#TBD](https://github.com/pjm17971/pond-ts/pull/TBD)). Pre-fix,
|
|
653
|
+
per-partition sub-series were constructed with default
|
|
654
|
+
`ordering: 'strict'` regardless of source mode. Under a `'reorder'`
|
|
655
|
+
source, late events that the source accepted via its reorder path
|
|
656
|
+
were routed into the partition's `#insert` and threw with a
|
|
657
|
+
strict-mode error; the throw propagated back through the source's
|
|
658
|
+
listener fan-out into `live.push()`.
|
|
659
|
+
|
|
660
|
+
Post-fix, `partitionBy(by)` defaults each per-partition sub-series'
|
|
661
|
+
`ordering`, `graceWindow`, and `retention` to the source's values.
|
|
662
|
+
Explicit options on `partitionBy(by, { ordering, ... })` override
|
|
663
|
+
per-field. `graceWindow` inheritance is gated on effective ordering
|
|
664
|
+
being `'reorder'` (LiveSeries rejects strict + graceWindow combos).
|
|
665
|
+
|
|
666
|
+
```ts
|
|
667
|
+
// Pre-0.17.1: crashed the partition router
|
|
668
|
+
const live = new LiveSeries({
|
|
669
|
+
name: 'metrics',
|
|
670
|
+
schema,
|
|
671
|
+
ordering: 'reorder',
|
|
672
|
+
graceWindow: '30s',
|
|
673
|
+
});
|
|
674
|
+
live.partitionBy('host'); // ← partition was strict regardless
|
|
675
|
+
|
|
676
|
+
// Post-0.17.1: partitions inherit reorder + 30s grace; late events
|
|
677
|
+
// accept correctly via the reorder path.
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Existing callers with explicit `partitionBy(by, { ordering, ... })`:
|
|
681
|
+
unchanged. Existing callers on `'strict'` sources: unchanged.
|
|
682
|
+
Existing callers on `'reorder'` sources with bare `partitionBy`:
|
|
683
|
+
the previously-thrown late events now accept correctly — bug fix,
|
|
684
|
+
not a behavior change anyone could rely on.
|
|
685
|
+
|
|
686
|
+
- **`collect()` and `apply()` on `LivePartitionedSeries` default-
|
|
687
|
+
inherit `ordering` and `graceWindow`** from the partitioned series
|
|
688
|
+
(which inherits from source). Pre-fix, the unified buffer defaulted
|
|
689
|
+
to `'strict'`, so partition fan-in on a `'reorder'` source could
|
|
690
|
+
deliver events out-of-order to a strict unified buffer and throw.
|
|
691
|
+
Retention stays caller-explicit on these per the existing append-
|
|
692
|
+
only fan-in semantics.
|
|
693
|
+
|
|
694
|
+
### Notes
|
|
695
|
+
|
|
696
|
+
- **Six regression tests pin the new defaults** in
|
|
697
|
+
`LivePartitionedSeries.test.ts`: inherited ordering, inherited
|
|
698
|
+
graceWindow within reorder, inherited retention on partitions,
|
|
699
|
+
explicit override of inheritance, strict-source no-change, and the
|
|
700
|
+
edge case where overriding ordering to strict suppresses graceWindow
|
|
701
|
+
inheritance. `collect()` inheritance pinned separately.
|
|
702
|
+
- The gRPC experiment's M4 friction note also surfaced milestone B
|
|
703
|
+
(capability-based late repair) as **driver-light by empirical test**
|
|
704
|
+
after Codex's adversarial pass caught simulator RNG leakage across
|
|
705
|
+
A/B legs. Drift signal collapsed to within noise on every host once
|
|
706
|
+
all randomness sources were seeded — milestone B's library design
|
|
707
|
+
stays sound, but the gRPC experiment's measurement style (last-tick
|
|
708
|
+
`.value()` reads) doesn't surface its payoff. Milestone B sequencing
|
|
709
|
+
updated in PLAN.md to reflect this finding.
|
|
710
|
+
|
|
711
|
+
## [0.17.0] — 2026-05-08
|
|
712
|
+
|
|
713
|
+
`sample({...})` operator wave: bounded-memory stream thinning, surfaced
|
|
714
|
+
by the gRPC experiment's M3.5 finish-line work
|
|
715
|
+
([friction note](https://github.com/pjm17971/pond-grpc-experiment/blob/main/friction-notes/rfcs/bounded-memory-sampling.md)
|
|
716
|
+
with measured firehose numbers). Decouples downstream baseline window
|
|
717
|
+
length from event rate — at firehose rates × stride 10, `sd / sqrt(N)`
|
|
718
|
+
standard error stays well below per-event noise while a 5-minute
|
|
719
|
+
baseline that wouldn't fit in a Node heap un-sampled does at
|
|
720
|
+
stride 10. PR [#129](https://github.com/pjm17971/pond-ts/pull/129).
|
|
721
|
+
|
|
722
|
+
### Added
|
|
723
|
+
|
|
724
|
+
- **`series.sample({ stride | reservoir })`** on `TimeSeries` and
|
|
725
|
+
`PartitionedTimeSeries` — single-pass thinning that keeps the
|
|
726
|
+
`TimeSeries<S>` schema. Stride is deterministic 1-in-N
|
|
727
|
+
(`{ stride: N }`); reservoir is random K-of-N via single-pass
|
|
728
|
+
[Vitter's Algorithm R](https://en.wikipedia.org/wiki/Reservoir_sampling#Simple:_Algorithm_R)
|
|
729
|
+
(`{ reservoir: { size: K } }`), sorted by key on output to preserve
|
|
730
|
+
the chronological invariant. The canonical visualization shape:
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
series.sample({ reservoir: { size: 500 } }).toRows();
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
500 uncorrelated points drawn uniformly from the source — no
|
|
737
|
+
`aggregate(seq, ...)` grid collapse, no regular-spacing artifact,
|
|
738
|
+
fixed point count regardless of source size. Per-partition state on
|
|
739
|
+
`PartitionedTimeSeries.sample(...)` — each partition gets its own
|
|
740
|
+
K-event reservoir or stride counter.
|
|
741
|
+
|
|
742
|
+
- **`live.sample({ stride })`** on `LiveSeries`, `LiveView`,
|
|
743
|
+
`LivePartitionedSeries`, `LivePartitionedView` — closure-captured
|
|
744
|
+
counter inside a `LiveView<S>`, so the chainable surface (`filter`,
|
|
745
|
+
`rolling`, `reduce`, `select`, `map`, `diff`, `rate`, `cumulative`,
|
|
746
|
+
`fill`) is immediately available downstream of the sample. The
|
|
747
|
+
bounded-memory firehose pattern:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
live.partitionBy('host').sample({ stride: 10 }).rolling('5m', mapping);
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Each host's stream is thinned 1-in-10 before flowing into a per-host
|
|
754
|
+
5m rolling window. `live.stats().ingested` and `live.on('batch', cb)`
|
|
755
|
+
are upstream of any `.sample(...)` op — they continue counting true
|
|
756
|
+
throughput; only consumers downstream see the thinned stream.
|
|
757
|
+
|
|
758
|
+
- **Sampling docs page** at
|
|
759
|
+
[`pond-ts/transforms/sampling`](https://pjm17971.github.io/pond-ts/docs/pond-ts/transforms/sampling/)
|
|
760
|
+
covering when-to-use-which decision table, both strategies, the
|
|
761
|
+
visualization shape, multi-entity considerations, and a forward-link
|
|
762
|
+
to the live counterpart. New `## Sampling: bounded-memory thinning`
|
|
763
|
+
section in
|
|
764
|
+
[Live transforms](https://pjm17971.github.io/pond-ts/docs/pond-ts/live/live-transforms#sampling).
|
|
765
|
+
|
|
766
|
+
### Deferred
|
|
767
|
+
|
|
768
|
+
- **Live-side reservoir sampling** is queued for v0.18.0+. Algorithm R's
|
|
769
|
+
random-slot replacement produces non-prefix evictions, but the existing
|
|
770
|
+
live-eviction protocol (`'evict'` event + cutoff-based mirroring in
|
|
771
|
+
`LiveView`) assumes prefix evictions only. Bridging needs an exact-
|
|
772
|
+
removal eviction channel — arriving with the streaming RFC's
|
|
773
|
+
`LiveChange` model (Phase 4.5 milestone A). For visualization-shaped
|
|
774
|
+
reservoir today, materialize via `live.toTimeSeries().sample({ reservoir })`.
|
|
775
|
+
|
|
776
|
+
### Notes
|
|
777
|
+
|
|
778
|
+
- **Multi-entity bias trap** is documented in JSDoc on the pre-partition
|
|
779
|
+
sites (`LiveSeries.sample`, `LiveView.sample`) with the
|
|
780
|
+
`partitionBy(...).sample(...)` recommendation, matching the existing
|
|
781
|
+
convention for `rolling` / `aggregate` / `fill` / `diff` / `rate` /
|
|
782
|
+
`cumulative` / `pctChange` / `reduce`. An earlier iteration of #129
|
|
783
|
+
shipped a type-level `unsafeGlobal: true` token; pulled during review
|
|
784
|
+
for consistency with how every other stateful live operator handles
|
|
785
|
+
the same multi-entity consideration. Token-of-the-week novelty was
|
|
786
|
+
the wrong shape; the doc warning is the same answer the other
|
|
787
|
+
operators already give.
|
|
788
|
+
|
|
789
|
+
- **Legacy `rolling.sample(seq)` doc references removed.** Pre-v0.12
|
|
790
|
+
pond exposed `LiveRollingAggregation.sample(sequence)` as a separate
|
|
791
|
+
method (deleted in v0.12.0, replaced by `Trigger.every`). Active doc
|
|
792
|
+
references in `pond-ts/live/triggering.mdx`,
|
|
793
|
+
`pond-ts/transforms/alignment.mdx`, `pond-ts/transforms/rolling.mdx`,
|
|
794
|
+
and `pond-ts/live/live-transforms.mdx` removed to eliminate the
|
|
795
|
+
naming-collision confusion now that `series.sample({ stride | reservoir })`
|
|
796
|
+
is a real but completely unrelated operator. Historical record
|
|
797
|
+
preserved in PLAN.md, the v0.11.8 CHANGELOG entry, and the triggers RFC.
|
|
798
|
+
|
|
799
|
+
## [0.16.1] — 2026-05-06
|
|
800
|
+
|
|
801
|
+
Patch wave addressing one ergonomic gap surfaced by the gRPC
|
|
802
|
+
experiment ([pond-grpc-experiment#29](https://github.com/pjm17971/pond-grpc-experiment/pull/29))
|
|
803
|
+
plus the v0.16.0 docs deploy that broke since v0.15.2.
|
|
804
|
+
|
|
805
|
+
### Added
|
|
806
|
+
|
|
807
|
+
- **`PartitionedTimeSeries.aggregate(...)` and `.rolling(...)` now
|
|
808
|
+
auto-inject the partition column into the user's mapping**
|
|
809
|
+
([#128](https://github.com/pjm17971/pond-ts/pull/128)). The
|
|
810
|
+
natural shape just works:
|
|
811
|
+
|
|
812
|
+
```ts
|
|
813
|
+
series
|
|
814
|
+
.partitionBy('host')
|
|
815
|
+
.aggregate(Sequence.every('600ms'), { cpu_avg: 'avg' });
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
Pre-fix this threw `column "host" not in schema` at the rewrap
|
|
819
|
+
step because the user's mapping didn't carry the partition
|
|
820
|
+
column through; users had to add `host: 'first'` mechanically
|
|
821
|
+
to every partitioned-aggregate call. Pond now adds it
|
|
822
|
+
automatically — `'first'` is by-construction-correct since
|
|
823
|
+
every row in a single partition shares that column's value.
|
|
824
|
+
User-supplied mappings for the partition column win (auto-
|
|
825
|
+
inject is a no-op when the user has already opted in).
|
|
826
|
+
Composite partitions (`partitionBy(['host', 'region'])`)
|
|
827
|
+
auto-inject every partition column. Strictly additive — the
|
|
828
|
+
pre-fix workaround pattern still works unchanged.
|
|
829
|
+
|
|
830
|
+
### Fixed
|
|
831
|
+
|
|
832
|
+
- **Docs deploy workflow unblocked**
|
|
833
|
+
([#126](https://github.com/pjm17971/pond-ts/pull/126)). Has
|
|
834
|
+
been failing since v0.15.2 with `Cannot find name
|
|
835
|
+
'queueMicrotask'` — TypeDoc runs the same tsconfig as the
|
|
836
|
+
npm-publish path but from a different cwd, where `@types/node`
|
|
837
|
+
doesn't resolve. Fixed via a one-line ambient declaration in
|
|
838
|
+
`LiveReduce.ts`. No runtime change; `queueMicrotask` is still
|
|
839
|
+
the host-provided global it always was.
|
|
840
|
+
|
|
841
|
+
### Changed
|
|
842
|
+
|
|
843
|
+
- **Updated `LiveSeries` tool comparisons in the docs**
|
|
844
|
+
([#127](https://github.com/pjm17971/pond-ts/pull/127)).
|
|
845
|
+
Tightened the Beam/Flink, PondJS, and pandas comparison tables
|
|
846
|
+
to be technically accurate. Doc prose only; no code change.
|
|
847
|
+
|
|
848
|
+
### Notes
|
|
849
|
+
|
|
850
|
+
- **Captured `@pond-ts/charts` design constraints in PLAN.md**
|
|
851
|
+
([#128](https://github.com/pjm17971/pond-ts/pull/128)). The
|
|
852
|
+
gRPC experiment's M3.5 friction note hit Recharts' SVG render
|
|
853
|
+
cliff at firehose loads (~75-80k SVG nodes per render, ~1 fps
|
|
854
|
+
at 10 hosts × 70k events/s). Four constraints from real
|
|
855
|
+
workload now baked into the plan so the eventual extraction
|
|
856
|
+
starts with the answer key — not new code, just durable
|
|
857
|
+
design capture.
|
|
858
|
+
|
|
859
|
+
## [0.16.0] — 2026-05-06
|
|
860
|
+
|
|
861
|
+
Live-API ergonomic wave. Four PRs:
|
|
862
|
+
[#122](https://github.com/pjm17971/pond-ts/pull/122) (buffer-as-window
|
|
863
|
+
Tier 1), [#123](https://github.com/pjm17971/pond-ts/pull/123)
|
|
864
|
+
(`stats()` accessor), [#124](https://github.com/pjm17971/pond-ts/pull/124)
|
|
865
|
+
(`history` option + compile-time fused uniqueness),
|
|
866
|
+
[#125](https://github.com/pjm17971/pond-ts/pull/125) (Tier 2 query
|
|
867
|
+
primitives). Strictly additive surface — no public-API removals or
|
|
868
|
+
narrowings.
|
|
869
|
+
|
|
870
|
+
### Added
|
|
871
|
+
|
|
872
|
+
- **`live.reduce(mapping, opts?)`** on `LiveSeries` and `LiveView`
|
|
873
|
+
— streaming reduce over the source's current buffer. Mirrors
|
|
874
|
+
`series.reduce(mapping)` from batch but reactive: per-event
|
|
875
|
+
`add`, per-eviction `remove`, microtask-deferred trigger
|
|
876
|
+
emission so retention has run before the snapshot. Closes the
|
|
877
|
+
buffer-as-window persona's biggest ergonomic gap.
|
|
878
|
+
- **`live.timeRange()`** on `LiveSeries` and `LiveView` — O(1)
|
|
879
|
+
temporal extent of the current buffer (`undefined` when empty).
|
|
880
|
+
- **`live.eventRate()`** on `LiveSeries` and `LiveView` — O(1)
|
|
881
|
+
events-per-second over the buffer's time span (zero when fewer
|
|
882
|
+
than two events). Convenience over the existing
|
|
883
|
+
`view.eventRate()` shape; no window argument required.
|
|
884
|
+
- **`live.count()`** on `LiveSeries` (alias for `length`) for
|
|
885
|
+
parity with `LiveView.count()` and chainable composition with
|
|
886
|
+
`eventRate()`.
|
|
887
|
+
- **`stats()` accessor on every live accumulator/series.** Per-class
|
|
888
|
+
shapes, all returning a plain record (cumulative integer counters
|
|
889
|
+
- current-state fields):
|
|
890
|
+
|
|
891
|
+
| Class | Shape |
|
|
892
|
+
| --------------------------- | --------------------------------------------------------------------- |
|
|
893
|
+
| LiveSeries | `{ ingested, evicted, rejected, length, earliestTs?, latestTs? }` |
|
|
894
|
+
| LiveRollingAggregation | `{ eventsObserved, evictions, emissions, windowSize }` |
|
|
895
|
+
| LiveFusedRolling | `{ eventsObserved, evictions, emissions, windowSize, windowsCount }` |
|
|
896
|
+
| LiveAggregation | `{ eventsObserved, bucketsClosed, openBuckets, openBucketStart? }` |
|
|
897
|
+
| LiveReduce | `{ eventsObserved, evictions, emissions, bufferSize }` |
|
|
898
|
+
| LivePartitionedSeries | `{ partitions, eventsRouted }` |
|
|
899
|
+
| LivePartitionedSyncRolling | `{ partitions, eventsObserved, emissions, windowSize }` |
|
|
900
|
+
| LivePartitionedFusedRolling | `{ partitions, eventsObserved, emissions, windowSize, windowsCount }` |
|
|
901
|
+
|
|
902
|
+
Per-event cost: ~1-3 integer increments in already-existing
|
|
903
|
+
handlers. `stats()` itself is O(1) — or O(partitions) for the
|
|
904
|
+
max-across-partitions `windowSize` on partitioned variants.
|
|
905
|
+
Polling-based by design — wall-clock timers inside pond would
|
|
906
|
+
break the data-is-the-clock invariant.
|
|
907
|
+
|
|
908
|
+
- **`history: false | RetentionPolicy` option on
|
|
909
|
+
`LiveRollingAggregation` and `LiveFusedRolling`** (and
|
|
910
|
+
partitioned variants — threaded through
|
|
911
|
+
`LivePartitionedSeries.rolling` end-to-end). Controls how much
|
|
912
|
+
of the rolling's emitted history the accumulator keeps in its
|
|
913
|
+
own output buffer (the one read by `length` / `at(i)`). Default
|
|
914
|
+
`true` preserves current behavior; `false` skips the push
|
|
915
|
+
entirely (`'event'` listeners and `value()` still work, but
|
|
916
|
+
`length` stays at 0); `RetentionPolicy` (`{ maxEvents?, maxAge? }`)
|
|
917
|
+
caps the buffer using the same shape as `LiveSeries.retention`.
|
|
918
|
+
Stricter validation: rejects 0, negative, or non-integer
|
|
919
|
+
`maxEvents`; `Infinity` is the documented "no cap" sentinel.
|
|
920
|
+
|
|
921
|
+
- **Compile-time uniqueness check on fused output columns**
|
|
922
|
+
(`FusedMappingValid<FM>`). Two windows declaring the same
|
|
923
|
+
output name now fail at the call site with a branded error
|
|
924
|
+
type naming the conflict. Wired into all four fused-rolling
|
|
925
|
+
overloads (LiveSeries, LiveView, root + view
|
|
926
|
+
LivePartitionedSeries). Runtime check still in place.
|
|
927
|
+
|
|
928
|
+
- **Tier 2 query primitives on `LiveSeries` and `LiveView`** —
|
|
929
|
+
pure parity additions mirroring `TimeSeries`:
|
|
930
|
+
- `find(pred)`, `some(pred)`, `every(pred)` — O(N) predicate query
|
|
931
|
+
- `includesKey(key)`, `bisect(key)`, `atOrBefore(key)`,
|
|
932
|
+
`atOrAfter(key)` — O(log N) binary search on the sorted buffer
|
|
933
|
+
|
|
934
|
+
Use cases: "is there already an event with key K?" / "what was
|
|
935
|
+
the most recent event before time T?" Both come up in dashboard
|
|
936
|
+
patterns where the live buffer IS the working set.
|
|
937
|
+
|
|
938
|
+
- **`KeyLike` type** exported from the package root (re-exported
|
|
939
|
+
from `TimeSeries`). Accepts `EventKey | TimestampInput |
|
|
940
|
+
TimeRangeInput | IntervalInput`; normalised by the new query
|
|
941
|
+
primitives.
|
|
942
|
+
|
|
943
|
+
- **`DurationLiteral` and `DurationUnit` types** extracted from
|
|
944
|
+
`utils/duration.ts` and exported. Same shape as before, just
|
|
945
|
+
named.
|
|
946
|
+
|
|
947
|
+
- **Concrete return types from partitioned rolling overloads.**
|
|
948
|
+
`LivePartitionedSeries.rolling` and `LivePartitionedView.rolling`
|
|
949
|
+
clock-trigger and fused-mapping overloads now return the concrete
|
|
950
|
+
`LivePartitionedSyncRolling` / `LivePartitionedFusedRolling`
|
|
951
|
+
classes (instead of bare `LiveSource<...>`), exposing `stats()`
|
|
952
|
+
to callers without a cast. Strictly additive — concrete classes
|
|
953
|
+
implement `LiveSource` plus `stats()`.
|
|
954
|
+
|
|
955
|
+
### Changed
|
|
956
|
+
|
|
957
|
+
- **`LiveSeries.clear()`** now increments the `evicted` counter
|
|
958
|
+
on `stats()` to match the existing `'evict'` listener fan-out.
|
|
959
|
+
Previously cleared the buffer and fired listeners but didn't
|
|
960
|
+
update the counter.
|
|
961
|
+
- **`LiveSeries` insertion comparator** delegates to
|
|
962
|
+
`EventKey.compare` (was previously `begin/end` only). Affects
|
|
963
|
+
interval-keyed series with same-span / different-value
|
|
964
|
+
intervals: previously stored in arrival order — and broke
|
|
965
|
+
`bisect`/`includesKey` queries — now stored in value-ascending
|
|
966
|
+
order. Time-keyed and timeRange-keyed series unaffected.
|
|
967
|
+
- **`LiveView.map(fn)` runtime check** rejects re-keying maps
|
|
968
|
+
that produce non-monotonic outputs. Throws `ValidationError`
|
|
969
|
+
at append time rather than silently breaking the view's
|
|
970
|
+
sorted-buffer invariant (which Tier 2 query primitives rely
|
|
971
|
+
on). Sane transforms (data-only maps, monotonic time-shifts)
|
|
972
|
+
unaffected.
|
|
973
|
+
- **`LiveAggregationOptions.grace`** type tightened from
|
|
974
|
+
`DurationInput | \`${number}${unit}\``(redundant union) to
|
|
975
|
+
just`DurationInput`. No behavioral change.
|
|
976
|
+
|
|
977
|
+
### Notes
|
|
978
|
+
|
|
979
|
+
- React package (`@pond-ts/react`) version-bumped lock-step; no
|
|
980
|
+
hook surface changes in this release. New core hooks
|
|
981
|
+
(`useLiveReduce`, `useStats`, optional-window `useEventRate`)
|
|
982
|
+
are queued for a follow-up — see PLAN.md for the design.
|
|
983
|
+
- Codex caught real bugs on every Layer-2-reviewed PR in this
|
|
984
|
+
wave (1 HIGH + 1 MEDIUM on PR #123, 1 HIGH + 1 MEDIUM on
|
|
985
|
+
PR #124, 2 MEDIUM on PR #125). The Layer 2 + Codex two-pass
|
|
986
|
+
protocol earned its keep again.
|
|
987
|
+
|
|
988
|
+
## [0.15.2] — 2026-05-06
|
|
989
|
+
|
|
990
|
+
Performance fix for live rolling at firehose rates. The gRPC
|
|
991
|
+
experiment's step 6
|
|
992
|
+
([pond-grpc-experiment#26](https://github.com/pjm17971/pond-grpc-experiment/pull/26))
|
|
993
|
+
attempted to use the non-partitioned `live.rolling({...}, opts)`
|
|
994
|
+
overload for global counters and saw throughput collapse from 88k/s
|
|
995
|
+
to 21k/s — a 4× regression even worse than the V7→V6 gap that
|
|
996
|
+
motivated v0.15.0. The cliff is the same `Array.shift()` pattern
|
|
997
|
+
already flagged as queued tactical work in PLAN; the gRPC encounter
|
|
998
|
+
made it urgent.
|
|
999
|
+
|
|
1000
|
+
### Fixed
|
|
1001
|
+
|
|
1002
|
+
- **Eviction is now O(1) per ingest in all live rolling classes.**
|
|
1003
|
+
Replaced `entries.shift()` (worst-case O(N) on the deque length)
|
|
1004
|
+
with a head-index pointer + periodic batched compaction:
|
|
1005
|
+
- `LiveFusedRolling.#compactFront` — non-partitioned multi-window
|
|
1006
|
+
- `LivePartitionedFusedRolling.#compactPartitionFront` —
|
|
1007
|
+
per-partition fused
|
|
1008
|
+
- `LiveRollingAggregation.#removeFirst` — single-window
|
|
1009
|
+
non-partitioned
|
|
1010
|
+
- `LivePartitionedSyncRolling.#evictPartition` — per-partition
|
|
1011
|
+
single-window synced
|
|
1012
|
+
|
|
1013
|
+
The pattern: track a `frontIdx` field; "evicting" advances the
|
|
1014
|
+
pointer instead of shifting. When the dead prefix grows past
|
|
1015
|
+
half the array length, batch-splice it off and reset the
|
|
1016
|
+
pointer. Per-event cost stays O(1) amortized at every live-
|
|
1017
|
+
window size — each surviving entry is copied at most once
|
|
1018
|
+
between two compactions, and compactions fire at most every
|
|
1019
|
+
(live-size) events.
|
|
1020
|
+
|
|
1021
|
+
An earlier draft also compacted on a fixed 1024-entry threshold;
|
|
1022
|
+
Codex's adversarial review on PR #119 caught that this would
|
|
1023
|
+
reintroduce O(live_size / 1024) per-eviction cost on large
|
|
1024
|
+
windows (100k+ live entries) — the threshold would fire
|
|
1025
|
+
repeatedly and copy the entire live slice each time. The
|
|
1026
|
+
proportional guard alone has the right amortization invariant.
|
|
1027
|
+
|
|
1028
|
+
### Performance
|
|
1029
|
+
|
|
1030
|
+
`packages/core/scripts/perf-fused-rolling.mjs` — new regression
|
|
1031
|
+
scenario that reproduces the cliff (50k-event deque with continuous
|
|
1032
|
+
eviction):
|
|
1033
|
+
|
|
1034
|
+
```
|
|
1035
|
+
Worst-case shift pattern (50s window, 50k fill + 50k evict):
|
|
1036
|
+
median (ms) min (ms) max (ms)
|
|
1037
|
+
pre-fix 1123.12 1118.47 1149.95
|
|
1038
|
+
v0.15.2 53.00 52.34 53.56
|
|
1039
|
+
speedup 21.2×
|
|
1040
|
+
|
|
1041
|
+
Steady-state deque, no eviction (5m window, 200k events):
|
|
1042
|
+
median (ms) min (ms) max (ms)
|
|
1043
|
+
pre-fix 91.28 89.84 97.04
|
|
1044
|
+
v0.15.2 99.28 96.80 103.94
|
|
1045
|
+
delta +9% (within noise)
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
The fix targets the eviction-loop case specifically. Workloads with
|
|
1049
|
+
no eviction (or rare eviction relative to ingest) see no change —
|
|
1050
|
+
V8's internal hidden-offset optimization handles those well. The
|
|
1051
|
+
cliff appears once eviction fires per-ingest at large deque size,
|
|
1052
|
+
which is exactly the firehose-rolling shape.
|
|
1053
|
+
|
|
1054
|
+
### Why the cliff was hidden
|
|
1055
|
+
|
|
1056
|
+
V8's `Array.shift()` is amortized O(1) for shift-heavy workloads up
|
|
1057
|
+
to ~10k-element arrays — it maintains a hidden offset and only
|
|
1058
|
+
periodically compacts. Beyond that size or with mixed access
|
|
1059
|
+
patterns, the optimization breaks down and shift falls back to true
|
|
1060
|
+
O(N) memcpy. The bench scales from 1k to 50k deque sizes and the
|
|
1061
|
+
cliff appears around 30k-40k. Pond's tests pin behavior at small
|
|
1062
|
+
window sizes; the cliff was invisible to the test suite, only
|
|
1063
|
+
showed up under the gRPC experiment's firehose load.
|
|
1064
|
+
|
|
1065
|
+
### What this unlocks
|
|
1066
|
+
|
|
1067
|
+
The agent's manual-counter workaround in `aggregator/src/aggregate.ts`
|
|
1068
|
+
can now drop. The natural shape — a non-partitioned
|
|
1069
|
+
`live.rolling({...}, { trigger })` over the firehose — is now
|
|
1070
|
+
viable at the rates the experiment cares about. PLAN's
|
|
1071
|
+
"`samples` reducer would exhibit a similar shape at firehose"
|
|
1072
|
+
caveat also resolves: same fix in the same call sites covers
|
|
1073
|
+
samples too.
|
|
1074
|
+
|
|
1075
|
+
### Note for downstream consumers
|
|
1076
|
+
|
|
1077
|
+
This is a **strict-additive perf fix.** All output behavior is
|
|
1078
|
+
preserved — same eviction order, same emission timing, same
|
|
1079
|
+
snapshot values. The deque's internal representation changed
|
|
1080
|
+
(`#entries[0]` may now be a logically-evicted entry until periodic
|
|
1081
|
+
compaction); any downstream code reading `#entries` directly would
|
|
1082
|
+
break, but those fields are private. Public APIs and types are
|
|
1083
|
+
unchanged.
|
|
1084
|
+
|
|
1085
|
+
[0.17.1]: https://github.com/pjm17971/pond-ts/compare/v0.17.0...v0.17.1
|
|
1086
|
+
[0.17.0]: https://github.com/pjm17971/pond-ts/compare/v0.16.1...v0.17.0
|
|
1087
|
+
[0.16.1]: https://github.com/pjm17971/pond-ts/compare/v0.16.0...v0.16.1
|
|
1088
|
+
[0.16.0]: https://github.com/pjm17971/pond-ts/compare/v0.15.2...v0.16.0
|
|
1089
|
+
[0.15.2]: https://github.com/pjm17971/pond-ts/compare/v0.15.1...v0.15.2
|
|
1090
|
+
|
|
1091
|
+
## [0.15.1] — 2026-05-05
|
|
1092
|
+
|
|
1093
|
+
Type-narrowing follow-up to v0.15.0. The fused partitioned-rolling
|
|
1094
|
+
typing chain exposed a pre-existing pond limitation where
|
|
1095
|
+
`partitionBy('host')` widened the partition-column type instead of
|
|
1096
|
+
narrowing it to the literal `'host'`. The gRPC experiment's V8
|
|
1097
|
+
migration ([pond-grpc-experiment#22](https://github.com/pjm17971/pond-grpc-experiment/pull/22))
|
|
1098
|
+
worked around it as `partitionBy<'host'>('host')` — clobbering the
|
|
1099
|
+
value-type parameter `K` to fill the column-name slot. v0.15.1
|
|
1100
|
+
captures the column literal directly so the workaround can drop.
|
|
1101
|
+
|
|
1102
|
+
### Fixed
|
|
1103
|
+
|
|
1104
|
+
- **`partitionBy` narrows the partition column literal.** The
|
|
1105
|
+
`by` argument's literal type now flows into a new `ByCol`
|
|
1106
|
+
generic on `LivePartitionedSeries<S, K, ByCol>` and
|
|
1107
|
+
`LivePartitionedView<SBase, R, K, ByCol>`. Threaded through every
|
|
1108
|
+
per-partition method (`fill`, `diff`, `rate`, `pctChange`,
|
|
1109
|
+
`cumulative`, `apply`, the rolling overloads). The fused
|
|
1110
|
+
partitioned-rolling overload's
|
|
1111
|
+
`FusedPartitionedRollingSchema<S, ByCol, FM>` now resolves
|
|
1112
|
+
correctly without the `<'host'>` workaround:
|
|
1113
|
+
|
|
1114
|
+
```ts
|
|
1115
|
+
// Before v0.15.1: needed the explicit type arg to narrow
|
|
1116
|
+
// host through the fused-rolling schema chain.
|
|
1117
|
+
live.partitionBy<'host'>('host').rolling({ ... }, { trigger });
|
|
1118
|
+
|
|
1119
|
+
// v0.15.1+: the literal 'host' is captured automatically.
|
|
1120
|
+
live.partitionBy('host').rolling({ ... }, { trigger });
|
|
1121
|
+
// Output schema includes `host` narrowed to its column kind;
|
|
1122
|
+
// event.get('host') resolves correctly.
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
Existing V8 callers using the `partitionBy<'host'>('host')`
|
|
1126
|
+
workaround continue to narrow correctly. Type-parameter order
|
|
1127
|
+
on `partitionBy` is `<ByCol, K>` (column name first, value type
|
|
1128
|
+
second) so the explicit `<'host'>` binds the literal to `ByCol`
|
|
1129
|
+
— exactly what the workaround intended pre-v0.15.1. The
|
|
1130
|
+
workaround can now drop because automatic inference does the
|
|
1131
|
+
same job, but it doesn't have to.
|
|
1132
|
+
|
|
1133
|
+
### Type system
|
|
1134
|
+
|
|
1135
|
+
- `LivePartitionedSeries<S, K, ByCol>` — third generic added with
|
|
1136
|
+
default `keyof EventDataForSchema<S> & string`. Backwards-
|
|
1137
|
+
compatible: existing references to `LivePartitionedSeries<S, K>`
|
|
1138
|
+
and `LivePartitionedSeries<S>` resolve to the upper-bound default.
|
|
1139
|
+
- `LivePartitionedView<SBase, R, K, ByCol>` — same shape; `ByCol`
|
|
1140
|
+
threaded through every chain hop so partition-column literals
|
|
1141
|
+
survive `partitionBy('host').fill(...).rolling({...}, opts)`.
|
|
1142
|
+
|
|
1143
|
+
### Test surface
|
|
1144
|
+
|
|
1145
|
+
`test-d/fused-rolling.test-d.ts` extended to pin the narrowing at
|
|
1146
|
+
both the root and chained levels:
|
|
1147
|
+
|
|
1148
|
+
```ts
|
|
1149
|
+
const fC = live.partitionBy('host').rolling({ ... }, { trigger });
|
|
1150
|
+
sampleEvent.get('host'); // narrows to string | undefined
|
|
1151
|
+
|
|
1152
|
+
const chained = live.partitionBy('host').fill({ cpu: 'hold' })
|
|
1153
|
+
.rolling({ '1m': { cpu_avg: ... } }, { trigger });
|
|
1154
|
+
chainedSample.get('host'); // narrows correctly through the chain
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
All 1115 + 55 runtime tests still pass; type-d clean.
|
|
1158
|
+
|
|
1159
|
+
[0.15.1]: https://github.com/pjm17971/pond-ts/compare/v0.15.0...v0.15.1
|
|
1160
|
+
|
|
1161
|
+
## [0.15.0] — 2026-05-05
|
|
1162
|
+
|
|
1163
|
+
The "fused multi-window rolling" release. Shipping the primitive
|
|
1164
|
+
that closes the gRPC experiment's V6→V7 architectural cliff: a
|
|
1165
|
+
keyed-form overload on `live.rolling()` that maintains N windows
|
|
1166
|
+
in one ingest pass over a single shared deque, emits one merged
|
|
1167
|
+
event per trigger boundary, and (on the partitioned variant) eats
|
|
1168
|
+
the doubled `#routeEvent` / `#evictPartition` / `_pushTrustedEvents`
|
|
1169
|
+
hops V7 surfaced.
|
|
1170
|
+
|
|
1171
|
+
Two independent signals motivated this: the gRPC profile-diff
|
|
1172
|
+
(PR #19 in `pond-grpc-experiment`) and the buffer-as-window
|
|
1173
|
+
persona's metric-agent call site
|
|
1174
|
+
(`series.rolling(RETENTION, mapping, ...)` as workaround). Both
|
|
1175
|
+
point at one primitive; both shipped together. RFC #20 in
|
|
1176
|
+
`pond-grpc-experiment` is the design record.
|
|
1177
|
+
|
|
1178
|
+
### Added
|
|
1179
|
+
|
|
1180
|
+
- **Keyed-form fused rolling on `LiveSeries.rolling`,
|
|
1181
|
+
`LiveView.rolling`, and `LivePartitionedSeries.rolling`.** Pass
|
|
1182
|
+
a record of `{ duration: mapping }` instead of `(window, mapping)`
|
|
1183
|
+
to declare multiple windows; the rolling maintains them all in
|
|
1184
|
+
one ingest pass:
|
|
1185
|
+
|
|
1186
|
+
```ts
|
|
1187
|
+
const fused = byHost.rolling(
|
|
1188
|
+
{
|
|
1189
|
+
'1m': {
|
|
1190
|
+
cpu_avg: { from: 'cpu', using: 'avg' },
|
|
1191
|
+
cpu_sd: { from: 'cpu', using: 'stdev' },
|
|
1192
|
+
},
|
|
1193
|
+
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
|
|
1194
|
+
},
|
|
1195
|
+
{ trigger: Trigger.every('200ms') },
|
|
1196
|
+
);
|
|
1197
|
+
// fused emits one merged event per boundary with all four
|
|
1198
|
+
// columns; one ingest pass per source event.
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
- **Output: one merged stream.** All declared windows' columns
|
|
1202
|
+
concatenated into one record per trigger fire — not N
|
|
1203
|
+
accumulators or N streams. User code collapses to one event
|
|
1204
|
+
handler (the V7 → V8 migration in the gRPC experiment drops
|
|
1205
|
+
~30 lines of `pendingByTs` / `partsFor` / `tryEmit` join
|
|
1206
|
+
machinery).
|
|
1207
|
+
- **Constraints.** Time-based windows only (object keys are
|
|
1208
|
+
duration strings); single trigger across all windows by
|
|
1209
|
+
design (per-window cadence falls back to two `rolling()`
|
|
1210
|
+
calls, paying the V7 cost). On partitioned series, clock
|
|
1211
|
+
trigger is required.
|
|
1212
|
+
- **Per-window options.** Use the elaborated value form
|
|
1213
|
+
(`{ mapping, minSamples }`) when one window needs different
|
|
1214
|
+
options from the rest; bare-mapping value stays clean for
|
|
1215
|
+
the common case.
|
|
1216
|
+
- **Duplicate output column names** across windows are rejected
|
|
1217
|
+
at construction with a clear error. Partition column auto-
|
|
1218
|
+
injection is unified across all windows.
|
|
1219
|
+
- **Single-window equivalence pin.**
|
|
1220
|
+
`live.rolling('1m', mapping, opts)` and
|
|
1221
|
+
`live.rolling({ '1m': mapping }, opts)` produce identical
|
|
1222
|
+
output (locked down by tests).
|
|
1223
|
+
|
|
1224
|
+
- **`LiveFusedRolling<S, Out>`** — non-partitioned class, exposed
|
|
1225
|
+
on the public surface via `live.rolling({...}, opts)`.
|
|
1226
|
+
- **`LivePartitionedFusedRolling<S, K, Out>`** — synchronised-cross-
|
|
1227
|
+
partition class, exposed via `byHost.rolling({...}, { trigger })`.
|
|
1228
|
+
- **Type-level surface:** `FusedMapping<S>`, `FusedMappingValue<S>`,
|
|
1229
|
+
`FusedMappingElaborated<S>`, `FusedRollingSchema<S, FM>`,
|
|
1230
|
+
`FusedPartitionedRollingSchema<S, ByCol, FM>`, and
|
|
1231
|
+
`DurationString` — all exported from `pond-ts`. Output column
|
|
1232
|
+
kinds narrow correctly through `event.get('cpu_avg')` to
|
|
1233
|
+
`number | undefined`.
|
|
1234
|
+
|
|
1235
|
+
### Performance
|
|
1236
|
+
|
|
1237
|
+
`packages/core/scripts/perf-fused-rolling.mjs` — bench against
|
|
1238
|
+
gRPC RFC #20 acceptance criteria. Headline numbers (median of 3
|
|
1239
|
+
runs, `node --expose-gc`):
|
|
1240
|
+
|
|
1241
|
+
```
|
|
1242
|
+
Partitioned, 100k events × 100 hosts (the gRPC use case):
|
|
1243
|
+
wall (ms) heap (MB)
|
|
1244
|
+
single rolling baseline 95.20 74.33
|
|
1245
|
+
two separate rollings (V7 shape) 141.12 101.71
|
|
1246
|
+
fused two-window (V8 shape) 112.36 68.46
|
|
1247
|
+
|
|
1248
|
+
Fused vs V7 shape: -20.4% wall, -32.7% heap
|
|
1249
|
+
Fused vs baseline: +18.0% wall, -7.9% heap
|
|
1250
|
+
|
|
1251
|
+
Partitioned, 100k events × 1000 hosts (saturation):
|
|
1252
|
+
wall (ms) heap (MB)
|
|
1253
|
+
two separate rollings (V7 shape) 700.35 556.56
|
|
1254
|
+
fused two-window (V8 shape) 446.21 309.25
|
|
1255
|
+
|
|
1256
|
+
Fused vs V7 shape: -36.3% wall, -44.4% heap
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
**Scaling beyond two windows — the architectural argument
|
|
1260
|
+
verified.** Every per-event pond hop runs ONCE in fused vs N times
|
|
1261
|
+
in N separate rollings. The bench scales N from 2 to 5 windows
|
|
1262
|
+
over the same 100k-events × 100-hosts source:
|
|
1263
|
+
|
|
1264
|
+
```
|
|
1265
|
+
Separate (ms) Fused (ms) Wall delta
|
|
1266
|
+
N = 2 152.91 102.91 -32.7%
|
|
1267
|
+
N = 3 186.63 79.89 -57.2%
|
|
1268
|
+
N = 4 245.42 107.51 -56.2%
|
|
1269
|
+
N = 5 279.79 118.90 -57.5%
|
|
1270
|
+
|
|
1271
|
+
Separate (MB) Fused (MB) Heap delta
|
|
1272
|
+
N = 2 108.13 72.20 -33.2%
|
|
1273
|
+
N = 3 93.30 43.08 -53.8%
|
|
1274
|
+
N = 4 113.69 47.19 -58.5%
|
|
1275
|
+
N = 5 137.17 47.12 -65.6%
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
Fused stays roughly constant (~100ms) across N=2..5; separate
|
|
1279
|
+
scales linearly. At N=5: **2.4× faster wall, 34% of the heap.**
|
|
1280
|
+
|
|
1281
|
+
The architectural cliff is closed and the win compounds with N.
|
|
1282
|
+
Fused rolling's per-event cost is O(1) in the number of windows
|
|
1283
|
+
for pipeline overhead — only O(N) for the unavoidable per-window
|
|
1284
|
+
reducer-state updates (which separate also pays). Heap is
|
|
1285
|
+
dominated by the saved per-rolling deque + per-partition state.
|
|
1286
|
+
|
|
1287
|
+
### Notes on what this does NOT include
|
|
1288
|
+
|
|
1289
|
+
- **`live.reduce(mapping)` sugar.** Designed in PLAN as
|
|
1290
|
+
`live.rolling({ buffer: mapping }, { history: false })`; the
|
|
1291
|
+
`'buffer'` sentinel is reserved at the type level but throws at
|
|
1292
|
+
runtime for now. Lands with the buffer-as-window Tier 1 PR.
|
|
1293
|
+
- **`TimeSeries.rolling` snapshot-side parity.** The keyed-form
|
|
1294
|
+
overload is live-side only in v0.15.0; batch-side comes in a
|
|
1295
|
+
follow-up.
|
|
1296
|
+
- **Path A (share `LiveSeries` buffer).** Currently Path B (own
|
|
1297
|
+
deque) — fused rolling subscribes via `'event'` and maintains
|
|
1298
|
+
its own per-partition deque. Path A is a transparent perf
|
|
1299
|
+
follow-up; same API.
|
|
1300
|
+
- **Compile-time uniqueness check on output columns.** Runtime
|
|
1301
|
+
check is in place; the type-level `CheckUniqueOutputs` helper
|
|
1302
|
+
is parked as a follow-up. Same with tightening `DurationString`
|
|
1303
|
+
to reject `'1min'`-style typos at the type level (today's
|
|
1304
|
+
template-literal type is permissive; runtime `parseDuration`
|
|
1305
|
+
catches malformed durations).
|
|
1306
|
+
|
|
1307
|
+
### Migration
|
|
1308
|
+
|
|
1309
|
+
Existing `live.rolling(window, mapping, opts)` calls are
|
|
1310
|
+
unchanged. The keyed form is opt-in and additive. Two-rolling
|
|
1311
|
+
patterns can migrate by collapsing to one fused call:
|
|
1312
|
+
|
|
1313
|
+
```ts
|
|
1314
|
+
// Before:
|
|
1315
|
+
const baseline = byHost.rolling('1m', m1, { trigger });
|
|
1316
|
+
const slice = byHost.rolling('200ms', m2, { trigger });
|
|
1317
|
+
// Then a per-(ts, host) join over both event streams …
|
|
1318
|
+
|
|
1319
|
+
// After:
|
|
1320
|
+
const fused = byHost.rolling({ '1m': m1, '200ms': m2 }, { trigger });
|
|
1321
|
+
fused.on('event', (e) => {
|
|
1322
|
+
// All columns from both windows on one event.
|
|
1323
|
+
});
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
[0.15.0]: https://github.com/pjm17971/pond-ts/compare/v0.14.3...v0.15.0
|
|
1327
|
+
|
|
1328
|
+
## [0.14.3] — 2026-05-04
|
|
1329
|
+
|
|
1330
|
+
A targeted allocation fix in the `'samples'` reducer's rolling-state
|
|
1331
|
+
implementation. Motivated by gRPC experiment V7 numbers — at the
|
|
1332
|
+
ceiling regime (1k partitions × 1k events/s, 1M target) the all-
|
|
1333
|
+
pond pipeline using `samples()` regressed throughput ~19% vs V6's
|
|
1334
|
+
hybrid pond-rolling + manual-deque pattern, with +17% heap at
|
|
1335
|
+
moderate loads. Per-event cost analysis pointed at a 1-element
|
|
1336
|
+
`ScalarValue[]` allocation per scalar `add()` — one wasted
|
|
1337
|
+
allocation per event compounding under sustained kHz × N-partition
|
|
1338
|
+
load.
|
|
1339
|
+
|
|
1340
|
+
### Changed
|
|
1341
|
+
|
|
1342
|
+
- **`samples.rollingState()` skips array wrap for scalar source
|
|
1343
|
+
columns.** Scalar values (the common case at saturation) now
|
|
1344
|
+
store directly into the keyed map; only array-kind sources
|
|
1345
|
+
build a sub-array (because `remove(index)` needs to drop a
|
|
1346
|
+
single event's contributions together). Snapshot branches on
|
|
1347
|
+
`Array.isArray` to flatten the mixed map.
|
|
1348
|
+
|
|
1349
|
+
```
|
|
1350
|
+
Focused micro-bench (5M scalar add+remove cycles):
|
|
1351
|
+
median (ms) min (ms) max (ms)
|
|
1352
|
+
baseline (v0.14.2) 239.85 236.62 244.58
|
|
1353
|
+
v0.14.3 209.09 207.42 215.26
|
|
1354
|
+
delta −12.8% −12.3% −12.0%
|
|
1355
|
+
|
|
1356
|
+
Integration bench (100k events × N hosts, full pipeline):
|
|
1357
|
+
Tight wall-clock parity within run-to-run noise across all
|
|
1358
|
+
scenarios (samples 1m/5s, scalar/array). Allocation pressure
|
|
1359
|
+
isn't the dominant cost at this scale; the optimization
|
|
1360
|
+
compounds only at saturation regimes where GC pressure stacks.
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
Behavior is preserved bit-for-bit — every existing
|
|
1364
|
+
`samples-reducer.test.ts` assertion passes without modification.
|
|
1365
|
+
|
|
1366
|
+
### Added
|
|
1367
|
+
|
|
1368
|
+
- `packages/core/scripts/perf-samples-reducer.mjs` — benchmark
|
|
1369
|
+
covering the focused micro-bench + four integration scenarios
|
|
1370
|
+
(scalar moderate / scalar high-cardinality / scalar high-churn
|
|
1371
|
+
/ array source) with a comparison anchor against `'avg'` on
|
|
1372
|
+
the same shape. Run with `node --expose-gc` for heap numbers.
|
|
1373
|
+
|
|
1374
|
+
### Note on saturation regimes
|
|
1375
|
+
|
|
1376
|
+
V7's regression isn't fully closed by this fix. The remaining gap
|
|
1377
|
+
is architectural — V7 routes events through two full
|
|
1378
|
+
`LiveRollingAggregation` pipelines (Map ops + reducer state +
|
|
1379
|
+
trigger dispatch + subscriber fan-out per pipeline), where V6's
|
|
1380
|
+
hybrid had one pond rolling for stats plus a passive
|
|
1381
|
+
`array.push` listener for raw values. At the kHz × 1k-partition
|
|
1382
|
+
saturation regime, the manual-deque pattern is genuinely the
|
|
1383
|
+
right shape; pond's `samples` is for typical loads where per-
|
|
1384
|
+
event overhead is invisible. A shared-buffer primitive (parked
|
|
1385
|
+
as `tap()` in PLAN.md) would close the saturation gap; out of
|
|
1386
|
+
scope for v0.14.3.
|
|
1387
|
+
|
|
1388
|
+
[0.14.3]: https://github.com/pjm17971/pond-ts/compare/v0.14.2...v0.14.3
|
|
1389
|
+
|
|
1390
|
+
## [0.14.2] — 2026-05-03
|
|
1391
|
+
|
|
1392
|
+
Hotfix over v0.14.1 — closes a type-narrowing gap on the new
|
|
1393
|
+
`'samples'` reducer that the v0.14.1 Layer 2 review caught
|
|
1394
|
+
post-merge. The runtime worked, but TypeScript didn't know about
|
|
1395
|
+
`'samples'`: passing it through `series.aggregate({ col: 'samples' })`
|
|
1396
|
+
or `live.rolling(window, { col: 'samples' })` produced
|
|
1397
|
+
`Type '"samples"' is not assignable to type 'AggregateReducer'`,
|
|
1398
|
+
and `series.reduce({ col: 'samples' }).col` fell through to
|
|
1399
|
+
`ColumnValue | undefined` instead of the narrowed array type.
|
|
1400
|
+
|
|
1401
|
+
### Fixed
|
|
1402
|
+
|
|
1403
|
+
- **`'samples'` is now in the type system everywhere.** Added to
|
|
1404
|
+
`AggregateFunction` union, both branches of
|
|
1405
|
+
`AggregateFunctionsForKind` (numeric and array/string/boolean),
|
|
1406
|
+
`AggregateKindForColumn` (so output columns get
|
|
1407
|
+
`kind: 'array'`), `ArrayAggregateKind`, and the array branch of
|
|
1408
|
+
`ReduceResult` in `types-reduce.ts`.
|
|
1409
|
+
|
|
1410
|
+
```ts
|
|
1411
|
+
// Pre-v0.14.2: TS error, but ran correctly.
|
|
1412
|
+
// Post-v0.14.2: typechecks and narrows the same way `unique` and
|
|
1413
|
+
// `top${N}` do — `ReadonlyArray<T>` for source kind T.
|
|
1414
|
+
series.reduce({ vals: 'samples' }).vals; // ReadonlyArray<number> | undefined
|
|
1415
|
+
|
|
1416
|
+
series.aggregate(Sequence.every('5s'), { vals: 'samples' });
|
|
1417
|
+
// Output column: { name: 'vals', kind: 'array' }
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
- **`reducer-reference.mdx`** updated: "14 built-in reducers" → 15.
|
|
1421
|
+
|
|
1422
|
+
### Added
|
|
1423
|
+
|
|
1424
|
+
- `test-d/types.test-d.ts` block pinning `'samples'` narrowing
|
|
1425
|
+
parity with `'unique'` / `'top${N}'`. Closes the regression hole
|
|
1426
|
+
the v0.14.1 review surfaced.
|
|
1427
|
+
|
|
1428
|
+
### Known follow-up
|
|
1429
|
+
|
|
1430
|
+
The v0.14.1 review also flagged that `npm run verify`'s
|
|
1431
|
+
`test:type` step uses `tsconfig.types.json` (covers `src` +
|
|
1432
|
+
`test-d/`), not `tsconfig.vitest.json` (covers `test/`) — that's
|
|
1433
|
+
why the missing `'samples'` narrowing didn't fail CI even though
|
|
1434
|
+
`packages/core/test/samples-reducer.test.ts` had ~30 type errors.
|
|
1435
|
+
Captured in DOCPLAN.md / PLAN.md as a future safety-net widening;
|
|
1436
|
+
not in scope for v0.14.2 because pre-existing test files have
|
|
1437
|
+
their own type drift that would need cleanup first.
|
|
1438
|
+
|
|
1439
|
+
[0.14.2]: https://github.com/pjm17971/pond-ts/compare/v0.14.1...v0.14.2
|
|
1440
|
+
|
|
1441
|
+
## [0.14.1] — 2026-05-03
|
|
1442
|
+
|
|
1443
|
+
The "samples reducer + lifted custom-fn guard" release. Surfaced by
|
|
1444
|
+
the gRPC experiment's step-4 (anomaly density) walkback: the use
|
|
1445
|
+
case "compute counts of values exceeding `k·σ` from a baseline" needs
|
|
1446
|
+
the **raw values** from the rolling window, but pond's existing
|
|
1447
|
+
built-ins all collapse to scalars or deduplicate. Custom-function
|
|
1448
|
+
reducers — which would cover the use case cleanly — worked on batch
|
|
1449
|
+
but were rejected at runtime on live with a `TypeError` pointing at
|
|
1450
|
+
`AggregateOutputMap` aliases (which don't actually solve "all values"
|
|
1451
|
+
either). Two related changes ship together to close both gaps.
|
|
1452
|
+
|
|
1453
|
+
### Added
|
|
1454
|
+
|
|
1455
|
+
- **`'samples'` built-in reducer.** Returns the bucket's defined
|
|
1456
|
+
values as an array, in arrival order, with duplicates preserved.
|
|
1457
|
+
Sits beside `'unique'` (which deduplicates) and `'top${N}'` (which
|
|
1458
|
+
bounds and frequency-orders) — same array-output kind, same
|
|
1459
|
+
type-system narrowing through `AggregateOutputMap`. Library-
|
|
1460
|
+
implemented; per-event cost is O(1) `add` / O(1) `remove`
|
|
1461
|
+
(Map-keyed by event index); `snapshot` is O(N) array copy.
|
|
1462
|
+
Memory O(window size).
|
|
1463
|
+
|
|
1464
|
+
```ts
|
|
1465
|
+
// Anomaly density: count samples > k·σ from a separate baseline.
|
|
1466
|
+
const stats = live.rolling(
|
|
1467
|
+
'1m',
|
|
1468
|
+
{
|
|
1469
|
+
mean: { from: 'cpu', using: 'avg' },
|
|
1470
|
+
sd: { from: 'cpu', using: 'stdev' },
|
|
1471
|
+
},
|
|
1472
|
+
{ trigger: Trigger.every('30s') },
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
const recent = live.rolling('200ms', {
|
|
1476
|
+
vals: { from: 'cpu', using: 'samples' },
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// At each tick, count threshold crossings against the baseline:
|
|
1480
|
+
stats.on('event', (e) => {
|
|
1481
|
+
const samples = recent.value().vals as ReadonlyArray<number>;
|
|
1482
|
+
const counts = thresholds.map(
|
|
1483
|
+
(k) => samples.filter((v) => v - e.get('mean') > k * e.get('sd')).length,
|
|
1484
|
+
);
|
|
1485
|
+
// ... emit anomaly density
|
|
1486
|
+
});
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
Like `unique`, `samples` flattens one level on array-kind source
|
|
1490
|
+
columns. Returns `[]` for an empty bucket.
|
|
1491
|
+
|
|
1492
|
+
### Changed
|
|
1493
|
+
|
|
1494
|
+
- **Custom-function reducers now work on live.** Removed the runtime
|
|
1495
|
+
`TypeError` guards on `LiveAggregation`, `LiveRollingAggregation`,
|
|
1496
|
+
and `LivePartitionedSyncRolling` that previously rejected
|
|
1497
|
+
function-typed reducers. New `bucketStateFor` and `rollingStateFor`
|
|
1498
|
+
helpers in `reducers/index.ts` route built-ins to their dedicated
|
|
1499
|
+
O(1) machinery and wrap custom functions in a generic adapter:
|
|
1500
|
+
- **Bucket adapter** (`LiveAggregation`): buffers values, calls
|
|
1501
|
+
the function once at `snapshot()` time. O(N) per snapshot.
|
|
1502
|
+
- **Rolling adapter** (`LiveRollingAggregation`,
|
|
1503
|
+
`LivePartitionedSyncRolling`): Map-keyed by event index for O(1)
|
|
1504
|
+
`add` / O(1) `remove`; `snapshot()` calls the function with
|
|
1505
|
+
`Array.from(map.values())` in arrival order. **O(N) per
|
|
1506
|
+
snapshot** — the function re-runs over the current window each
|
|
1507
|
+
time the accumulator emits.
|
|
1508
|
+
|
|
1509
|
+
Documented as the explicit trade-off: convenience of writing
|
|
1510
|
+
`(values) => ...` inline against the perf cliff at high event
|
|
1511
|
+
rates. For high-throughput streams prefer built-ins or `'samples'`
|
|
1512
|
+
(collapse the window once on the producer side, run custom logic
|
|
1513
|
+
on the consumer). For low-rate dashboards / debug pipelines /
|
|
1514
|
+
prototypes, the convenience usually wins.
|
|
1515
|
+
|
|
1516
|
+
Pre-v0.14.1, calling `live.rolling(...)` with a custom-function
|
|
1517
|
+
reducer threw `TypeError: live rolling reducer for output 'X' must
|
|
1518
|
+
be a built-in name; ...`. Post-v0.14.1, the same call constructs
|
|
1519
|
+
successfully and runs.
|
|
1520
|
+
|
|
1521
|
+
### Tests
|
|
1522
|
+
|
|
1523
|
+
- 15 new tests in `test/samples-reducer.test.ts` covering: batch
|
|
1524
|
+
reduce / aggregate / rolling (including the array-source
|
|
1525
|
+
flattening); live aggregate (per-bucket arrays); live rolling
|
|
1526
|
+
(window eviction, snapshot correctness through multiple cycles);
|
|
1527
|
+
synced partitioned rolling with samples per partition; an
|
|
1528
|
+
end-to-end anomaly-density-against-baseline scenario.
|
|
1529
|
+
- 2 obsolete tests in `LiveAggregateOutputMap.test.ts` rewritten —
|
|
1530
|
+
previously asserted the rejection error, now assert that custom
|
|
1531
|
+
functions construct successfully and produce the right value.
|
|
1532
|
+
- Total core tests: 1087 (was 1072).
|
|
1533
|
+
|
|
1534
|
+
### Docs
|
|
1535
|
+
|
|
1536
|
+
- `pond-ts/transforms/reducer-reference.mdx`: new `'samples'` entry
|
|
1537
|
+
in the Array-producing reducers section; "Choosing a reducer"
|
|
1538
|
+
matrix updated; empty-bucket and rolling-complexity tables
|
|
1539
|
+
updated; Custom reducers section gained the live perf-cliff
|
|
1540
|
+
callout.
|
|
1541
|
+
- `pond-ts/transforms/rolling.mdx`: replaced the "Custom-function
|
|
1542
|
+
reducers are batch-only" note with the new "O(N) per snapshot on
|
|
1543
|
+
live" perf-cliff note pointing at the reducer reference.
|
|
1544
|
+
|
|
1545
|
+
[0.14.1]: https://github.com/pjm17971/pond-ts/compare/v0.14.0...v0.14.1
|
|
1546
|
+
|
|
1547
|
+
## [0.14.0] — 2026-05-01
|
|
1548
|
+
|
|
1549
|
+
Two perf wins driven by the gRPC experiment's V3 profiling pass
|
|
1550
|
+
(PR #14 on `pond-grpc-experiment`): `estimateEventBytes` at 6.2%
|
|
1551
|
+
self time and the partition router's `Event → row → Event`
|
|
1552
|
+
round-trip (combined ~7% in `#validateRow` + `Event` constructor
|
|
1553
|
+
re-allocations). Both root-caused, both fixed.
|
|
1554
|
+
|
|
1555
|
+
Benchmark deltas on `scripts/perf-live-partitioned.mjs`
|
|
1556
|
+
(100k events, median ms):
|
|
1557
|
+
|
|
1558
|
+
| Scenario | Before | After | Δ |
|
|
1559
|
+
| ------------------------------------ | -----: | ----: | -------: |
|
|
1560
|
+
| bare `LiveSeries.push` | 41.11 | 30.08 | **−27%** |
|
|
1561
|
+
| `partitionBy('host')` routing (10) | 83.14 | 39.10 | **−53%** |
|
|
1562
|
+
| `partitionBy + collect()` | 124.82 | 49.96 | **−60%** |
|
|
1563
|
+
| `partitionBy + apply(fill)` | 120.53 | 49.64 | **−59%** |
|
|
1564
|
+
| `partitionBy('host')` routing (1000) | 105.92 | 43.23 | **−59%** |
|
|
1565
|
+
|
|
1566
|
+
The bare-push delta is from the byte-estimate removal; the
|
|
1567
|
+
partition-routing deltas are from the trusted-pipeline path that
|
|
1568
|
+
skips `Event → row → Event` reconstruction at every routing hop.
|
|
1569
|
+
|
|
1570
|
+
### Removed (breaking, pre-1.0)
|
|
1571
|
+
|
|
1572
|
+
- **`retention.maxBytes`** option on `LiveSeriesOptions`. Speculative
|
|
1573
|
+
feature from pre-v0.10 that no real user has reached for. Use
|
|
1574
|
+
`retention.maxEvents` for count-based caps; `maxBytes` was
|
|
1575
|
+
approximate (rough per-event byte estimate) and the imprecision
|
|
1576
|
+
meant it was rarely used as designed.
|
|
1577
|
+
|
|
1578
|
+
Migration: replace `{ retention: { maxBytes: N } }` with
|
|
1579
|
+
`{ retention: { maxEvents: M } }` where M is your desired
|
|
1580
|
+
upper bound on event count.
|
|
1581
|
+
|
|
1582
|
+
### Changed
|
|
1583
|
+
|
|
1584
|
+
- **`estimateEventBytes` and the `#byteEstimate` accumulator
|
|
1585
|
+
removed** from `LiveSeries`. Closes the 6.2% per-push self-time
|
|
1586
|
+
line the gRPC experiment surfaced. Bare push is now ~27% faster
|
|
1587
|
+
for the typical case where `maxBytes` was never set.
|
|
1588
|
+
|
|
1589
|
+
- **Partition router uses a trusted-pipeline fast path.**
|
|
1590
|
+
`LivePartitionedSeries.#routeEvent`, `collect()`, and `apply()`
|
|
1591
|
+
previously round-tripped `Event → row → Event` at every routing
|
|
1592
|
+
hop — re-validating and re-allocating Events that the source
|
|
1593
|
+
pipeline had already constructed. New `_pushTrustedEvents` method
|
|
1594
|
+
on `LiveSeries` accepts pre-validated Event references (under a
|
|
1595
|
+
schema-identity contract; only used internally where the source
|
|
1596
|
+
and target schemas are guaranteed identical). Closes the ~7%
|
|
1597
|
+
combined self-time line in `#validateRow` (×2) and `Event`
|
|
1598
|
+
constructor (×2) that the gRPC profile flagged.
|
|
1599
|
+
|
|
1600
|
+
Trusted-pipeline applies to: the source-to-partition route, the
|
|
1601
|
+
per-partition replay-on-construct prefix, the unified-buffer
|
|
1602
|
+
`collect()` subscriber, and `apply()`'s factory-output forwarding.
|
|
1603
|
+
All four sites had identical schemas at both ends — the trust
|
|
1604
|
+
contract holds without runtime re-checking.
|
|
1605
|
+
|
|
1606
|
+
`_pushTrustedEvents` is `@internal` and not exported from the
|
|
1607
|
+
public type surface. Reach for `pushMany` from any external
|
|
1608
|
+
context; the trusted variant skips schema validation and is
|
|
1609
|
+
only safe for pond's own internal pipelines.
|
|
1610
|
+
|
|
1611
|
+
### Tests
|
|
1612
|
+
|
|
1613
|
+
- 4 new tests in `test/LiveSeries.test.ts` for the trusted-pipeline
|
|
1614
|
+
path: insertion without re-validation, listener fan-out and
|
|
1615
|
+
retention behaviour, ordering enforcement (strict still rejects
|
|
1616
|
+
out-of-order on the trusted path — the trust contract is only
|
|
1617
|
+
about validation/allocation, not insertion ordering), empty-array
|
|
1618
|
+
no-op.
|
|
1619
|
+
- Removed the `retention: maxBytes` describe block in
|
|
1620
|
+
`test/LiveSeries.test.ts` and the `forwards retention.maxBytes`
|
|
1621
|
+
assertion in `test/LiveSeries.snapshot-append.test.ts`.
|
|
1622
|
+
- Total core tests: 1072 (was 1070; +4 new for the trusted path,
|
|
1623
|
+
−2 for the removed maxBytes assertions).
|
|
1624
|
+
|
|
1625
|
+
### Docs
|
|
1626
|
+
|
|
1627
|
+
- `live-series.mdx`: retention table and example trimmed to
|
|
1628
|
+
`maxEvents` + `maxAge` only. Removed the byte-estimate prose.
|
|
1629
|
+
|
|
1630
|
+
[0.14.0]: https://github.com/pjm17971/pond-ts/compare/v0.13.2...v0.14.0
|
|
1631
|
+
|
|
1632
|
+
## [0.13.2] — 2026-05-01
|
|
1633
|
+
|
|
1634
|
+
Strictly additive over v0.13.1. Adds `Trigger.count(n)` per the
|
|
1635
|
+
second wave of Codex feedback after webapp-telemetry adoption. Use
|
|
1636
|
+
case: "very hot metrics like row stale times or handler payload
|
|
1637
|
+
sizes where event-time boundaries may lag during bursts, but
|
|
1638
|
+
per-event is too noisy."
|
|
1639
|
+
|
|
1640
|
+
### Added
|
|
1641
|
+
|
|
1642
|
+
- **`Trigger.count(n)`** — third trigger primitive alongside
|
|
1643
|
+
`Trigger.event()` and `Trigger.clock(seq)` /
|
|
1644
|
+
`Trigger.every(duration)`. Emits one rolling-window snapshot
|
|
1645
|
+
every `n` source events, with the counter resetting on each fire
|
|
1646
|
+
(so "events since the last emission," not "every Nth event modulo
|
|
1647
|
+
the input"):
|
|
1648
|
+
|
|
1649
|
+
```ts
|
|
1650
|
+
const rolling = timings.rolling(
|
|
1651
|
+
'5m',
|
|
1652
|
+
{ latency: 'p95' },
|
|
1653
|
+
{ trigger: Trigger.count(1000) },
|
|
1654
|
+
);
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
- **Data-driven** — counter only advances on event ingestion, no
|
|
1658
|
+
`setTimeout` inside the library. The first emission fires on
|
|
1659
|
+
the `n`th event, not the first.
|
|
1660
|
+
- **Per-partition** — when applied via `partitionBy(...).rolling(...)`,
|
|
1661
|
+
each partition counts independently. Count does not synchronise
|
|
1662
|
+
emission across partitions; use `Trigger.clock` for that.
|
|
1663
|
+
- **Rejects non-positive integers** — `Trigger.count(0)`,
|
|
1664
|
+
`Trigger.count(-1)`, `Trigger.count(1.5)`, and `Trigger.count(NaN)`
|
|
1665
|
+
throw at construction with a clear error.
|
|
1666
|
+
|
|
1667
|
+
### Changed
|
|
1668
|
+
|
|
1669
|
+
- **Trigger taxonomy expanded.** `Trigger` union is now
|
|
1670
|
+
`EventTrigger | ClockTrigger | CountTrigger`. Per-partition
|
|
1671
|
+
rolling overload widened to accept count triggers and route them
|
|
1672
|
+
to the `LivePartitionedView` per-partition path (not the synced
|
|
1673
|
+
rolling — count semantics across partitions are ambiguous and
|
|
1674
|
+
there's no killer use case for either choice yet).
|
|
1675
|
+
|
|
1676
|
+
### Docs
|
|
1677
|
+
|
|
1678
|
+
- `live-transforms.mdx`: trigger section now lists all three
|
|
1679
|
+
primitives up front with a dedicated subsection on count
|
|
1680
|
+
semantics. JSDoc on `LiveRollingAggregation.trigger` updated to
|
|
1681
|
+
mention count.
|
|
1682
|
+
- PLAN.md: trigger-taxonomy expansion RFC sketch captured —
|
|
1683
|
+
documents the shipped `count` plus deferred decisions on `idle`
|
|
1684
|
+
(the wall-clock crossing, requires its own RFC), `any` (composite,
|
|
1685
|
+
ships after singletons exist), and `threshold` / `manual`
|
|
1686
|
+
(declined / deferred as misclassified or sugar over existing
|
|
1687
|
+
primitives).
|
|
1688
|
+
|
|
1689
|
+
### Tests
|
|
1690
|
+
|
|
1691
|
+
- 7 new tests in `test/Triggers.test.ts`:
|
|
1692
|
+
- `Trigger.count(n)` shape and freeze
|
|
1693
|
+
- Non-positive integer rejection (zero, negative, fractional, NaN)
|
|
1694
|
+
- Emission cadence: snapshots every Nth event with correct
|
|
1695
|
+
rolling-window values
|
|
1696
|
+
- `Trigger.count(1)` behavioural equivalence to `Trigger.event()`
|
|
1697
|
+
- No emission during quiet periods (data-driven)
|
|
1698
|
+
- `rolling.value()` independent of trigger
|
|
1699
|
+
- Per-partition independent counting via
|
|
1700
|
+
`partitionBy().rolling(..., { trigger: Trigger.count(2) })`
|
|
1701
|
+
- Total core tests: 1070 (was 1063).
|
|
1702
|
+
|
|
1703
|
+
[0.13.2]: https://github.com/pjm17971/pond-ts/compare/v0.13.1...v0.13.2
|
|
1704
|
+
|
|
1705
|
+
## [0.13.1] — 2026-05-01
|
|
1706
|
+
|
|
1707
|
+
Strictly additive over v0.13.0. Adds a sugar factory on `Trigger`
|
|
1708
|
+
following Codex feedback after adopting v0.12 triggers in the
|
|
1709
|
+
production webapp telemetry app: the explicit form
|
|
1710
|
+
(`Trigger.clock(Sequence.every('30s'))`) is "ceremony-heavy for the
|
|
1711
|
+
common case."
|
|
1712
|
+
|
|
1713
|
+
### Added
|
|
1714
|
+
|
|
1715
|
+
- **`Trigger.every(duration, options?)`** — sugar for the common
|
|
1716
|
+
`Trigger.clock(Sequence.every(duration, options))` pattern. Removes
|
|
1717
|
+
the need to import `Sequence` for trigger-only use sites. Forwards
|
|
1718
|
+
`{ anchor }` to `Sequence.every` and inherits the same fixed-step
|
|
1719
|
+
validation:
|
|
1720
|
+
|
|
1721
|
+
```ts
|
|
1722
|
+
// Before
|
|
1723
|
+
live.rolling('1m', mapping, {
|
|
1724
|
+
trigger: Trigger.clock(Sequence.every('30s')),
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
// After
|
|
1728
|
+
live.rolling('1m', mapping, { trigger: Trigger.every('30s') });
|
|
1729
|
+
|
|
1730
|
+
// Anchored variant (passes through to Sequence.every):
|
|
1731
|
+
Trigger.every('30s', { anchor: 5_000 });
|
|
1732
|
+
```
|
|
1733
|
+
|
|
1734
|
+
The explicit `Trigger.clock(seq)` form remains for callers who
|
|
1735
|
+
already hold a `Sequence` object (e.g. one shared across batch
|
|
1736
|
+
`series.aggregate(seq, ...)` and live triggers) — `Trigger.every`
|
|
1737
|
+
always builds a fresh `Sequence`.
|
|
1738
|
+
|
|
1739
|
+
### Docs
|
|
1740
|
+
|
|
1741
|
+
- Telemetry recipe and live-transforms doc updated to lead with
|
|
1742
|
+
the sugar form. `Trigger.clock` documented as the explicit form
|
|
1743
|
+
for "I already have a Sequence object" cases.
|
|
1744
|
+
- JSDoc on `LiveRollingAggregation.trigger` and the partitioned
|
|
1745
|
+
rolling clock-trigger example updated to show the sugar.
|
|
1746
|
+
|
|
1747
|
+
### Tests
|
|
1748
|
+
|
|
1749
|
+
- 3 new tests in `test/Triggers.test.ts` covering: sugar produces
|
|
1750
|
+
`kind: 'clock'` with correct stepMs/anchor; anchor option
|
|
1751
|
+
forwards correctly; behavioural equivalence between
|
|
1752
|
+
`Trigger.every('30s')` and `Trigger.clock(Sequence.every('30s'))`
|
|
1753
|
+
pinned by emission-time comparison through a real
|
|
1754
|
+
`LiveRollingAggregation`.
|
|
1755
|
+
- Total core tests: 1063 (was 1060).
|
|
1756
|
+
|
|
1757
|
+
[0.13.1]: https://github.com/pjm17971/pond-ts/compare/v0.13.0...v0.13.1
|
|
1758
|
+
|
|
1759
|
+
## [0.13.0] — 2026-05-01
|
|
1760
|
+
|
|
1761
|
+
The "AggregateOutputMap on live" release. Closes the feature-parity
|
|
1762
|
+
gap between batch and live aggregation: the `{ alias: { from, using } }`
|
|
1763
|
+
mapping shape that batch `TimeSeries.rolling`/`aggregate` already
|
|
1764
|
+
accepted now works on `LiveSeries.rolling`, `LiveSeries.aggregate`,
|
|
1765
|
+
and the synchronised partitioned form. Multiple stats from one
|
|
1766
|
+
source column in a single rolling deque — no more "one rolling per
|
|
1767
|
+
percentile" workaround.
|
|
1768
|
+
|
|
1769
|
+
The shared runtime helper (`normalizeAggregateColumns`) was already
|
|
1770
|
+
doing the work for batch; this release extracts it to
|
|
1771
|
+
`aggregate-columns.ts` and threads the type-level overloads through
|
|
1772
|
+
the live surface.
|
|
1773
|
+
|
|
1774
|
+
### Added
|
|
1775
|
+
|
|
1776
|
+
- **`AggregateOutputMap` on `LiveSeries.rolling` and
|
|
1777
|
+
`LiveSeries.aggregate`.** Compose multiple built-in reducers from
|
|
1778
|
+
one source column in a single pass:
|
|
1779
|
+
|
|
1780
|
+
```ts
|
|
1781
|
+
const band = live.rolling('1m', {
|
|
1782
|
+
mean: { from: 'cpu', using: 'avg' },
|
|
1783
|
+
sd: { from: 'cpu', using: 'stdev' },
|
|
1784
|
+
});
|
|
1785
|
+
band.value(); // { mean, sd } — single deque, one walk
|
|
1786
|
+
```
|
|
1787
|
+
|
|
1788
|
+
Threaded through `LiveView.rolling`/`aggregate`,
|
|
1789
|
+
`LiveAggregation.rolling`, `LiveRollingAggregation.aggregate`,
|
|
1790
|
+
`LivePartitionedSeries.rolling`, and `LivePartitionedView.rolling`
|
|
1791
|
+
— so chained pipelines (`live.filter(...).rolling(...)`,
|
|
1792
|
+
`live.partitionBy(c).fill(...).rolling(..., { trigger: ... })`)
|
|
1793
|
+
accept either shape.
|
|
1794
|
+
|
|
1795
|
+
- **Synchronised partitioned rolling with `AggregateOutputMap`.**
|
|
1796
|
+
`partitionBy(col).rolling(window, mapping, { trigger: Trigger.clock(seq) })`
|
|
1797
|
+
now accepts the alias form. Output schema becomes
|
|
1798
|
+
`[time, <partitionColumn>, ...aliasColumns]`. The collision check
|
|
1799
|
+
rejects when an alias output collides with the partition column
|
|
1800
|
+
name (compare against the alias, not the source column).
|
|
1801
|
+
|
|
1802
|
+
### Changed
|
|
1803
|
+
|
|
1804
|
+
- **Better error message when a custom-function reducer is passed to
|
|
1805
|
+
live aggregation.** `LiveAggregation` already failed at construction
|
|
1806
|
+
via `resolveReducer(reducer)` (with a generic `unsupported aggregate
|
|
1807
|
+
reducer` message); now the eager built-in-name check runs first and
|
|
1808
|
+
emits a targeted error pointing at the `AggregateOutputMap` alias
|
|
1809
|
+
workaround. Same eager behavior on `LivePartitionedSyncRolling`,
|
|
1810
|
+
which previously failed lazily when the first partition spawned —
|
|
1811
|
+
now fails at construction. Aligns with `LiveRollingAggregation`'s
|
|
1812
|
+
long-standing eager check.
|
|
1813
|
+
|
|
1814
|
+
- **Shared `normalizeAggregateColumns` helper.** Extracted from
|
|
1815
|
+
`TimeSeries.ts` into `aggregate-columns.ts` and used by all three
|
|
1816
|
+
live accumulators (`LiveRollingAggregation`, `LiveAggregation`,
|
|
1817
|
+
`LivePartitionedSyncRolling`). Single source of truth for column
|
|
1818
|
+
normalisation; identical error messages across batch and live
|
|
1819
|
+
(`unknown source column`).
|
|
1820
|
+
|
|
1821
|
+
### Constraints
|
|
1822
|
+
|
|
1823
|
+
- **Custom-function reducers remain batch-only.** Live rolling and
|
|
1824
|
+
live aggregation still require built-in reducer names (`'avg'`,
|
|
1825
|
+
`'p95'`, etc.). Custom `(values) => ...` functions don't have the
|
|
1826
|
+
incremental add/remove machinery the live path needs and are
|
|
1827
|
+
rejected at construction with a clear error pointing at the
|
|
1828
|
+
`AggregateOutputMap` workaround. This is the established
|
|
1829
|
+
recommendation: alias multiple built-ins to compose stats from
|
|
1830
|
+
one source column.
|
|
1831
|
+
|
|
1832
|
+
### Fixed
|
|
1833
|
+
|
|
1834
|
+
- **`partitionBy(...).rolling(..., options)` now accepts `options` as
|
|
1835
|
+
a variable typed `LiveRollingOptions`, not just inline literals.**
|
|
1836
|
+
Pre-fix, the four narrowed overloads on
|
|
1837
|
+
`LivePartitionedSeries.rolling` and `LivePartitionedView.rolling`
|
|
1838
|
+
required TS to see the `trigger` field's discriminator at the call
|
|
1839
|
+
site — so a caller writing
|
|
1840
|
+
`const opts: LiveRollingOptions = { trigger: Trigger.event() };
|
|
1841
|
+
partitioned.rolling(window, mapping, opts);` got `TS2769 No
|
|
1842
|
+
overload matches this call`. Pre-existing hole on the partitioned
|
|
1843
|
+
surface; surfaced by the v0.13.0 Codex adversarial pass. Closed by
|
|
1844
|
+
adding catch-all overloads that accept the broader
|
|
1845
|
+
`LiveRollingOptions` and return the union of both trigger
|
|
1846
|
+
branches; the four narrowed overloads above still match inline
|
|
1847
|
+
literals first, so callers keep the precise return type when they
|
|
1848
|
+
pass the trigger inline. Pinned with `test-d/types.test-d.ts`
|
|
1849
|
+
coverage using both inline-literal and variable forms.
|
|
1850
|
+
|
|
1851
|
+
### Tests
|
|
1852
|
+
|
|
1853
|
+
- 16 new tests in `test/LiveAggregateOutputMap.test.ts` covering:
|
|
1854
|
+
flat live rolling/aggregate with the alias form, chained-view
|
|
1855
|
+
rolling/aggregate, `LiveAggregation.rolling` and
|
|
1856
|
+
`LiveRollingAggregation.aggregate` chainable accumulators,
|
|
1857
|
+
per-partition rolling, synchronised partitioned rolling with
|
|
1858
|
+
alias outputs, output-vs-source column-collision rejection on
|
|
1859
|
+
the synced form, and explicit kind override.
|
|
1860
|
+
- 2 existing tests updated (`LiveAggregation` and
|
|
1861
|
+
`LiveRollingAggregation` "unknown column" → "unknown source column"
|
|
1862
|
+
to match the shared helper's error string).
|
|
1863
|
+
- Test count: 1060 (was 1044).
|
|
1864
|
+
|
|
1865
|
+
### Docs
|
|
1866
|
+
|
|
1867
|
+
- `transforms/rolling.mdx`: live section now documents the
|
|
1868
|
+
`AggregateOutputMap` shape with a band-chart example, plus a
|
|
1869
|
+
callout that custom functions remain batch-only.
|
|
1870
|
+
- `recipes/telemetry-reporting.mdx`: "Want multiple percentiles?"
|
|
1871
|
+
section rewritten — the workaround note is gone, replaced with
|
|
1872
|
+
the single-pass `{ p50, p95, p99 }` pattern.
|
|
1873
|
+
|
|
1874
|
+
[0.13.0]: https://github.com/pjm17971/pond-ts/compare/v0.12.1...v0.13.0
|
|
1875
|
+
|
|
1876
|
+
## [0.12.1] — 2026-05-01
|
|
1877
|
+
|
|
1878
|
+
Strictly additive over v0.12.0. Closes the chained-view restriction
|
|
1879
|
+
on synchronised partitioned rolling. The trigger option now applies
|
|
1880
|
+
consistently across the entire `rolling()` surface — chained sugar
|
|
1881
|
+
methods on the partitioned surface (`fill`, `diff`, `rate`,
|
|
1882
|
+
`pctChange`, `cumulative`) no longer break it.
|
|
1883
|
+
|
|
1884
|
+
### Changed
|
|
1885
|
+
|
|
1886
|
+
- **`partitionBy(col).<chained>().rolling(window, m, { trigger: Trigger.clock(seq) })` now works.**
|
|
1887
|
+
Previously this threw a clear-but-restrictive error. The chain
|
|
1888
|
+
factory runs per partition; the sync rolling subscribes to each
|
|
1889
|
+
chain output instead of the raw partition events. Output schema is
|
|
1890
|
+
unchanged (`[time, <partitionColumn>, ...mappingColumns]`); the
|
|
1891
|
+
partition tag is set from the routing key, so chains that drop the
|
|
1892
|
+
partition column still emit correctly.
|
|
1893
|
+
|
|
1894
|
+
Motivating example — per-host gap-filling before synchronised
|
|
1895
|
+
ticks:
|
|
1896
|
+
|
|
1897
|
+
```ts
|
|
1898
|
+
const ticks = live
|
|
1899
|
+
.partitionBy('host')
|
|
1900
|
+
.fill({ cpu: 'hold' })
|
|
1901
|
+
.rolling(
|
|
1902
|
+
'1m',
|
|
1903
|
+
{ cpu: 'avg' },
|
|
1904
|
+
{ trigger: Trigger.clock(Sequence.every('200ms')) },
|
|
1905
|
+
);
|
|
1906
|
+
```
|
|
1907
|
+
|
|
1908
|
+
Coherence-of-feature fix: the trigger concept now applies wherever
|
|
1909
|
+
`rolling()` appears in the partitioned chain, not just in the
|
|
1910
|
+
one-step case. Captured in the RFC's post-implementation notes
|
|
1911
|
+
alongside the deferred-and-now-shipped section.
|
|
1912
|
+
|
|
1913
|
+
### Tests
|
|
1914
|
+
|
|
1915
|
+
- 4 new tests in `test/Triggers.test.ts` covering chained-view sync
|
|
1916
|
+
rolling: `fill().rolling(.., trigger)`, output schema, cross-
|
|
1917
|
+
partition synchronisation through the chain, dispose semantics
|
|
1918
|
+
through the chain, and replay-on-construction with the chain factory.
|
|
1919
|
+
- 1 test removed (the throw-on-chained-view assertion that no longer
|
|
1920
|
+
applies).
|
|
1921
|
+
- Test count: 34 (was 30). Total core tests: 1043 (was 1039).
|
|
1922
|
+
|
|
1923
|
+
[0.12.1]: https://github.com/pjm17971/pond-ts/compare/v0.12.0...v0.12.1
|
|
1924
|
+
|
|
1925
|
+
## [0.12.0] — 2026-05-01
|
|
1926
|
+
|
|
1927
|
+
The "triggers" release. Major redesign of how live accumulators
|
|
1928
|
+
control emission cadence — `Trigger` is now a first-class concept
|
|
1929
|
+
shaped by two converging real-world use cases (synchronised
|
|
1930
|
+
partitioned tick aggregation in the gRPC pipeline experiment,
|
|
1931
|
+
sequence-sampled rolling in webapp telemetry).
|
|
1932
|
+
|
|
1933
|
+
Two correctness audits before publish: a Layer 2 Claude review
|
|
1934
|
+
(column collision, dispose, late-spawn, peer-dep) and a Codex
|
|
1935
|
+
adversarial review (quiet-partition stale samples, pre-existing
|
|
1936
|
+
data replay at construction, spawn-listener cleanup). All findings
|
|
1937
|
+
fixed and pinned with regression tests. 1039 / 1039 tests pass.
|
|
1938
|
+
|
|
1939
|
+
### Added
|
|
1940
|
+
|
|
1941
|
+
- **Trigger as a first-class concept.** A new `Trigger` factory
|
|
1942
|
+
exposed at the package root lets `LiveRollingAggregation` switch
|
|
1943
|
+
emission cadence without changing any other shape:
|
|
1944
|
+
|
|
1945
|
+
```ts
|
|
1946
|
+
import { LiveSeries, Sequence, Trigger } from 'pond-ts';
|
|
1947
|
+
|
|
1948
|
+
// Webapp telemetry: rolling 1m p95, emit on every 30 s of event-time
|
|
1949
|
+
const rolling = timings.rolling(
|
|
1950
|
+
'1m',
|
|
1951
|
+
{ latency: 'p95' },
|
|
1952
|
+
{ trigger: Trigger.clock(Sequence.every('30s')) },
|
|
1953
|
+
);
|
|
1954
|
+
|
|
1955
|
+
rolling.on('event', (e) =>
|
|
1956
|
+
fetch('/api/telemetry', { method: 'POST', body: JSON.stringify(e.data()) }),
|
|
1957
|
+
);
|
|
1958
|
+
rolling.value(); // current rolling-window snapshot, independent of trigger
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
Two trigger variants in this release:
|
|
1962
|
+
- **`Trigger.event()`** — per-event emission. Default; the historical
|
|
1963
|
+
behavior of `LiveRollingAggregation` when no trigger is specified.
|
|
1964
|
+
- **`Trigger.clock(sequence)`** — sequence-triggered emission. One
|
|
1965
|
+
snapshot fires when a source event crosses an epoch-aligned
|
|
1966
|
+
boundary of the (fixed-step) `Sequence`. Output keyed at boundary
|
|
1967
|
+
instants. Calendar sequences are rejected upfront.
|
|
1968
|
+
|
|
1969
|
+
Future variants (`Trigger.count(n)`, custom predicates, compound
|
|
1970
|
+
triggers) are reserved but not yet shipped.
|
|
1971
|
+
|
|
1972
|
+
- **Synchronised partitioned rolling.** `LivePartitionedSeries.rolling`
|
|
1973
|
+
now accepts a clock trigger. The output is a
|
|
1974
|
+
`LiveSource<RowSchema>` whose schema is `[time, <partitionColumn>,
|
|
1975
|
+
...mappingColumns]`; on every boundary crossing, one event fires
|
|
1976
|
+
per known partition, all sharing the same boundary timestamp.
|
|
1977
|
+
Synchronised across partitions by construction (the bucket index is
|
|
1978
|
+
shared, not per-partition).
|
|
1979
|
+
|
|
1980
|
+
```ts
|
|
1981
|
+
// Dashboard tick aggregation: 100 hosts, 200ms cadence
|
|
1982
|
+
const ticks = live
|
|
1983
|
+
.partitionBy('host')
|
|
1984
|
+
.rolling(
|
|
1985
|
+
'1m',
|
|
1986
|
+
{ cpu: 'avg' },
|
|
1987
|
+
{ trigger: Trigger.clock(Sequence.every('200ms')) },
|
|
1988
|
+
);
|
|
1989
|
+
|
|
1990
|
+
ticks.on('event', (e) => {
|
|
1991
|
+
// e.begin() === <boundary timestamp>, same for every host this tick
|
|
1992
|
+
// e.get('host') === 'api-1' | 'api-2' | …
|
|
1993
|
+
// e.get('cpu') === <rolling avg for that host>
|
|
1994
|
+
});
|
|
1995
|
+
```
|
|
1996
|
+
|
|
1997
|
+
Restricted to direct-after-`partitionBy` in this release: chained
|
|
1998
|
+
sugar (`partitionBy(c).fill(...).rolling(...)`) rejects clock
|
|
1999
|
+
triggers with a clear error. Lifts in a future release once a real
|
|
2000
|
+
use case appears.
|
|
2001
|
+
|
|
2002
|
+
Closes the gRPC experiment's M3.5 dashboard friction note (the
|
|
2003
|
+
hand-rolled `HostAggregator` becomes ~10 lines of pond code).
|
|
2004
|
+
|
|
2005
|
+
### Removed (breaking — pre-1.0)
|
|
2006
|
+
|
|
2007
|
+
- **`LiveSequenceRollingAggregation`** class deleted. Its capability
|
|
2008
|
+
is preserved as `LiveRollingAggregation` with
|
|
2009
|
+
`{ trigger: Trigger.clock(sequence) }`. Migration: replace
|
|
2010
|
+
`live.rolling('1m', m).sample(seq)` with
|
|
2011
|
+
`live.rolling('1m', m, { trigger: Trigger.clock(seq) })`. Single
|
|
2012
|
+
rolling object now serves both backend reporting and direct
|
|
2013
|
+
`.value()` reads (no separate sampler reference).
|
|
2014
|
+
- **`.sample(sequence)`** method removed from `LiveRollingAggregation`.
|
|
2015
|
+
Use the trigger option above.
|
|
2016
|
+
|
|
2017
|
+
### Changed
|
|
2018
|
+
|
|
2019
|
+
- **`LiveRollingOptions`** gains an optional `trigger?: Trigger`
|
|
2020
|
+
field. Default behavior (no `trigger` specified) is unchanged from
|
|
2021
|
+
v0.11.x — per-event emission. Backward compatible for everyone
|
|
2022
|
+
who didn't use `.sample()`.
|
|
2023
|
+
|
|
2024
|
+
### Performance
|
|
2025
|
+
|
|
2026
|
+
- New benchmark `scripts/perf-triggers.mjs` covers both
|
|
2027
|
+
non-partitioned and synchronised partitioned cases. Headline numbers
|
|
2028
|
+
on a current MacBook Pro:
|
|
2029
|
+
- Non-partitioned: clock(30s) ~50% faster than per-event baseline
|
|
2030
|
+
(emission is rarer); clock(1s) similar.
|
|
2031
|
+
- Synchronised partitioned (100 hosts, 30k events at realistic
|
|
2032
|
+
rates): ~300 ns/emission at 200ms cadence; +205% over per-
|
|
2033
|
+
partition baseline at the high end. Well within budget for the
|
|
2034
|
+
motivating dashboard use case.
|
|
2035
|
+
|
|
2036
|
+
### Notes
|
|
2037
|
+
|
|
2038
|
+
- **`docs/rfcs/triggers.md`** captures the full design rationale,
|
|
2039
|
+
the four sign-off questions, and the migration plan. Read this if
|
|
2040
|
+
you want the "why this shape" context.
|
|
2041
|
+
|
|
2042
|
+
### Known limitations
|
|
2043
|
+
|
|
2044
|
+
- **Synchronised partitioned rolling output type is loose** —
|
|
2045
|
+
`LiveSource<SeriesSchema>` rather than a schema-narrowed shape.
|
|
2046
|
+
Runtime schema is correct; only static types widen. Tightening is
|
|
2047
|
+
queued for a follow-up release.
|
|
2048
|
+
- **Synchronised partitioned rolling rejects column-name collisions**
|
|
2049
|
+
between the partition column and any reducer-output column at
|
|
2050
|
+
construction (e.g. `partitionBy('cpu').rolling('1m', { cpu: 'avg' }, { trigger })`).
|
|
2051
|
+
Rename the reducer output (once `AggregateOutputMap` lands on live
|
|
2052
|
+
rolling) or partition by a different column.
|
|
2053
|
+
- **Late-spawn partitions only appear in ticks after their first event
|
|
2054
|
+
arrives.** A partition unknown to the sync source contributes no
|
|
2055
|
+
row to the current tick. Use `partitionBy(col, { groups: [...] })`
|
|
2056
|
+
to eagerly include partitions from construction.
|
|
2057
|
+
|
|
2058
|
+
## [0.11.8] — 2026-04-30
|
|
2059
|
+
|
|
2060
|
+
### Added
|
|
2061
|
+
|
|
2062
|
+
- **`rolling.sample(sequence)`** on `LiveRollingAggregation` — taps a
|
|
2063
|
+
rolling aggregation and emits one snapshot of the rolling state each
|
|
2064
|
+
time a source event crosses an epoch-aligned boundary of `sequence`.
|
|
2065
|
+
Closes the frontend-telemetry gap: collect high-frequency timing
|
|
2066
|
+
events, sample p95 latency to a backend every 30 s, while the same
|
|
2067
|
+
rolling drives an in-app live display (no duplicated deque).
|
|
2068
|
+
|
|
2069
|
+
```ts
|
|
2070
|
+
const rolling = timings.rolling('1m', { latency: 'p95' });
|
|
2071
|
+
|
|
2072
|
+
// One sampler → backend report every 30 s of event time
|
|
2073
|
+
const reported = rolling.sample(Sequence.every('30s'));
|
|
2074
|
+
reported.on('event', (e) =>
|
|
2075
|
+
fetch('/api/telemetry', { method: 'POST', body: JSON.stringify(e.data()) }),
|
|
2076
|
+
);
|
|
2077
|
+
|
|
2078
|
+
// Same rolling drives the UI live display
|
|
2079
|
+
useLiveQuery(timings, () => rolling.value());
|
|
2080
|
+
```
|
|
2081
|
+
|
|
2082
|
+
`sequence` must be a fixed-step `Sequence`; calendar sequences
|
|
2083
|
+
(`Sequence.daily()` etc.) are rejected upfront — boundary indexing
|
|
2084
|
+
needs a constant step.
|
|
2085
|
+
|
|
2086
|
+
Emission is **data-driven**: no `setInterval`. If the source goes
|
|
2087
|
+
quiet, no events fire. A single source event spanning multiple
|
|
2088
|
+
boundaries fires exactly one event at the new bucket. Snapshot is
|
|
2089
|
+
taken after the boundary-crossing event is ingested by the rolling,
|
|
2090
|
+
so the emitted value includes that event's contribution.
|
|
2091
|
+
|
|
2092
|
+
**Independent lifetimes.** `sample.dispose()` only detaches the
|
|
2093
|
+
sampler from the rolling; the rolling's lifecycle stays the user's
|
|
2094
|
+
responsibility. One rolling can power multiple `.sample()` cadences
|
|
2095
|
+
plus direct `rolling.value()` reads without coupling.
|
|
2096
|
+
|
|
2097
|
+
- **`LiveSequenceRollingAggregation` exported** from package root with
|
|
2098
|
+
full `LiveSource<Out>` surface and the same view-transform set as
|
|
2099
|
+
`LiveRollingAggregation` (`filter`, `map`, `select`, `window`,
|
|
2100
|
+
`diff`, `rate`, `pctChange`, `fill`, `cumulative`, `rolling`,
|
|
2101
|
+
`aggregate`).
|
|
2102
|
+
|
|
2103
|
+
- **Telemetry-reporting recipe** at
|
|
2104
|
+
`website/docs/recipes/telemetry-reporting.mdx` — end-to-end
|
|
2105
|
+
frontend-collection → backend-summary pattern using `.sample()`,
|
|
2106
|
+
plus the React in-app display via `useLiveQuery`.
|
|
2107
|
+
|
|
2108
|
+
[0.12.0]: https://github.com/pjm17971/pond-ts/compare/v0.11.8...v0.12.0
|
|
2109
|
+
[0.11.8]: https://github.com/pjm17971/pond-ts/compare/v0.11.7...v0.11.8
|
|
2110
|
+
|
|
2111
|
+
## [0.11.7] — 2026-04-29
|
|
2112
|
+
|
|
2113
|
+
### Added
|
|
2114
|
+
|
|
2115
|
+
- **`LiveView.count()` and `LiveView.eventRate()` terminal accessors.**
|
|
2116
|
+
Read the current event count and events-per-second over a windowed
|
|
2117
|
+
view directly — closes the
|
|
2118
|
+
`useCurrent(live, { cpu: 'count' }, { tail: '1m' }).cpu / 60`
|
|
2119
|
+
boilerplate surfaced by the gRPC experiment.
|
|
2120
|
+
```ts
|
|
2121
|
+
const eventsPerSec = live.window('1m').eventRate(); // events/sec
|
|
2122
|
+
const eventsInWindow = live.window('1m').count();
|
|
2123
|
+
```
|
|
2124
|
+
`eventRate()` requires a time-based window (`window('1m')`) and
|
|
2125
|
+
throws on count-based windows (`window(100)`) — there's no
|
|
2126
|
+
denominator to use. Distinct from `LiveView.rate(columns)`,
|
|
2127
|
+
which is the per-column derivative operator (rate-of-change of
|
|
2128
|
+
values).
|
|
2129
|
+
- `LiveView.{filter,map,select}` now propagate the parent's window
|
|
2130
|
+
duration to the child view, so chains like
|
|
2131
|
+
`live.window('1m').filter(...).eventRate()` work as expected.
|
|
2132
|
+
- `@pond-ts/react` ships **`useEventRate(source, '1m')`** — a
|
|
2133
|
+
reactive hook returning the events-per-second number, throttled
|
|
2134
|
+
on `'event'` like `useSnapshot`. Hooks mounted on already-
|
|
2135
|
+
populated sources render the actual rate on first paint via
|
|
2136
|
+
lazy `useState` init.
|
|
2137
|
+
```tsx
|
|
2138
|
+
const eventsPerSec = useEventRate(liveSeries, '1m');
|
|
2139
|
+
// <div>EVENT RATE {eventsPerSec.toFixed(1)}/s</div>
|
|
2140
|
+
```
|
|
2141
|
+
|
|
2142
|
+
[0.11.7]: https://github.com/pjm17971/pond-ts/compare/v0.11.6...v0.11.7
|
|
2143
|
+
|
|
2144
|
+
## [0.11.6] — 2026-04-29
|
|
2145
|
+
|
|
2146
|
+
### Added
|
|
2147
|
+
|
|
2148
|
+
- **`LiveSeries.toJSON()` return-type narrowing on `rowFormat`.**
|
|
2149
|
+
Overloads keyed on `rowFormat: 'array' | 'object'` so consumers
|
|
2150
|
+
read `result.rows` without a cast. Tuple form returns
|
|
2151
|
+
`TimeSeriesJsonOutputArray<S>`; object form returns
|
|
2152
|
+
`TimeSeriesJsonOutputObject<S>`. Both new types exported from
|
|
2153
|
+
`pond-ts/types`. The companion narrowing on `TimeSeries.toJSON`
|
|
2154
|
+
is still parked — it cascades TS2394 errors through unrelated
|
|
2155
|
+
overload sets in `TimeSeries.ts`. See PLAN.md.
|
|
2156
|
+
- New types: `TimeSeriesJsonOutputArray<S>` and
|
|
2157
|
+
`TimeSeriesJsonOutputObject<S>`. Use these for typed assignment
|
|
2158
|
+
(`const out: TimeSeriesJsonOutputArray<S> = ts.toJSON()`) or
|
|
2159
|
+
cast (`ts.toJSON() as TimeSeriesJsonOutputArray<S>`) until the
|
|
2160
|
+
`TimeSeries.toJSON` narrowing lands.
|
|
2161
|
+
|
|
2162
|
+
### Documentation
|
|
2163
|
+
|
|
2164
|
+
- `count` reducer JSDoc clarifies that **duplicate temporal keys
|
|
2165
|
+
do not collapse** — multiple events sharing one `Time` key each
|
|
2166
|
+
contribute independently to the count. Walks the per-column
|
|
2167
|
+
value array, not unique keys. Behavior is consistent across
|
|
2168
|
+
`reduce`, `aggregate`, `rolling`, `LiveAggregation`, and
|
|
2169
|
+
`LiveRollingAggregation` — pinned by `test/duplicate-keys.test.ts`
|
|
2170
|
+
(9 tests covering every layer including the
|
|
2171
|
+
"dashboard-defaults" 480-events-at-8/s scenario from the gRPC
|
|
2172
|
+
experiment's M1 friction notes).
|
|
2173
|
+
|
|
2174
|
+
[0.11.6]: https://github.com/pjm17971/pond-ts/compare/v0.11.5...v0.11.6
|
|
2175
|
+
|
|
2176
|
+
## [0.11.5] — 2026-04-29
|
|
2177
|
+
|
|
2178
|
+
### Fixed
|
|
2179
|
+
|
|
2180
|
+
- Published tarballs for both `pond-ts` and `@pond-ts/react` now
|
|
2181
|
+
include `README.md`, `LICENSE`, and `CHANGELOG.md`. Earlier
|
|
2182
|
+
releases shipped only `dist/` + `package.json`, which left the
|
|
2183
|
+
npm page rendering as "This package does not have a README"
|
|
2184
|
+
despite the comprehensive root README. The repo-root files were
|
|
2185
|
+
invisible to `npm pack` because npm publishes from the package
|
|
2186
|
+
directory and only auto-includes README/LICENSE when those files
|
|
2187
|
+
live in the package dir itself. Each package now has a `prepack`
|
|
2188
|
+
step that copies them in from the repo root before build.
|
|
2189
|
+
|
|
2190
|
+
[0.11.5]: https://github.com/pjm17971/pond-ts/compare/v0.11.4...v0.11.5
|
|
2191
|
+
|
|
2192
|
+
## [0.11.4] — 2026-04-29
|
|
2193
|
+
|
|
2194
|
+
### Added
|
|
2195
|
+
|
|
2196
|
+
- **`LiveSeries` snapshot/append primitives** — closes the gap
|
|
2197
|
+
where networked `LiveSeries` setups (gRPC, WebSocket fanout) had
|
|
2198
|
+
to hand-roll the parallel APIs that already existed on
|
|
2199
|
+
`TimeSeries`.
|
|
2200
|
+
- **Codec-agnostic typed-tuple primitives:** `LiveSeries.toRows()`,
|
|
2201
|
+
`LiveSeries.toObjects()`, `LiveSeries.pushMany(rows)`,
|
|
2202
|
+
`Event.toRow(schema)`. Operate in `RowForSchema<S>` typed
|
|
2203
|
+
tuples — JSON, MessagePack, protobuf, anything else applies at
|
|
2204
|
+
the application boundary, not inside the library.
|
|
2205
|
+
- **JSON sugar layered on top:** `LiveSeries.toJSON()`,
|
|
2206
|
+
`LiveSeries.fromJSON(input, options?)`,
|
|
2207
|
+
`LiveSeries.pushJson(rows)`, `Event.toJsonRow(schema)`. Closes
|
|
2208
|
+
the wire→push safety hole — `pushJson` validates a
|
|
2209
|
+
`JsonRowForSchema<S>` against the schema at compile time, so
|
|
2210
|
+
schema evolution breaks the call site instead of swallowing
|
|
2211
|
+
via `live.push(row as never)`.
|
|
2212
|
+
- **`pushMany(rows)` is non-variadic.** Pair with the existing
|
|
2213
|
+
variadic `push(...rows)` (now a one-line wrapper); reach for
|
|
2214
|
+
`pushMany` when ingesting a snapshot or any large array —
|
|
2215
|
+
variadic spread allocates a stack frame per element and can
|
|
2216
|
+
blow on multi-thousand-row snapshots.
|
|
2217
|
+
|
|
2218
|
+
Surfaced by the gRPC experiment's M1 milestone
|
|
2219
|
+
([pond-grpc-experiment#3](https://github.com/pjm17971/pond-grpc-experiment/pull/3)).
|
|
2220
|
+
See PLAN.md Phase 4 for the deferred adaptor-extraction
|
|
2221
|
+
framing (codec strategies parked until two real codecs exist
|
|
2222
|
+
in working code).
|
|
2223
|
+
|
|
2224
|
+
### Changed
|
|
2225
|
+
|
|
2226
|
+
- `LiveSeries.push(...rows)` is now a wrapper around
|
|
2227
|
+
`LiveSeries.pushMany(rows)`. Behavior is identical — same
|
|
2228
|
+
validation, listener fires, and retention pass.
|
|
2229
|
+
|
|
2230
|
+
[0.11.4]: https://github.com/pjm17971/pond-ts/compare/v0.11.3...v0.11.4
|
|
2231
|
+
|
|
2232
|
+
## [0.11.3] — 2026-04-28
|
|
2233
|
+
|
|
2234
|
+
### Added
|
|
2235
|
+
|
|
2236
|
+
- **`pond-ts/types` subpath export** — type-only entry point that
|
|
2237
|
+
exposes the schema-shape, row-shape, and JSON-shape types
|
|
2238
|
+
(`SeriesSchema`, `ColumnDef`, `RowForSchema`,
|
|
2239
|
+
`JsonRowForSchema`, etc.) without dragging in the runtime.
|
|
2240
|
+
Schema-as-contract consumers — packages whose only job is to
|
|
2241
|
+
declare the `as const` schema flowing through producer /
|
|
2242
|
+
aggregator / web — can now constrain literals via
|
|
2243
|
+
`satisfies SeriesSchema` without adding `pond-ts` as a runtime
|
|
2244
|
+
dependency. Surfaced by the gRPC experiment's `packages/shared`,
|
|
2245
|
+
where `import { SeriesSchema } from 'pond-ts'` would have
|
|
2246
|
+
pulled in the whole library for one type.
|
|
2247
|
+
|
|
2248
|
+
```ts
|
|
2249
|
+
import type { SeriesSchema } from 'pond-ts/types';
|
|
2250
|
+
export const schema = [
|
|
2251
|
+
{ name: 'time', kind: 'time' },
|
|
2252
|
+
{ name: 'cpu', kind: 'number' },
|
|
2253
|
+
] as const satisfies SeriesSchema;
|
|
2254
|
+
```
|
|
2255
|
+
|
|
2256
|
+
Existing `import { SeriesSchema } from 'pond-ts'` calls keep
|
|
2257
|
+
working unchanged.
|
|
2258
|
+
|
|
2259
|
+
[0.11.3]: https://github.com/pjm17971/pond-ts/compare/v0.11.2...v0.11.3
|
|
2260
|
+
|
|
2261
|
+
## [0.11.2] — 2026-04-28
|
|
2262
|
+
|
|
2263
|
+
### Added
|
|
2264
|
+
|
|
2265
|
+
- `minSamples` option on `TimeSeries.rolling`,
|
|
2266
|
+
`PartitionedTimeSeries.rolling`, `LiveRollingAggregation`, and
|
|
2267
|
+
the `LivePartitionedSeries` rolling sugar — suppresses output
|
|
2268
|
+
rows whose window contains fewer than the configured number of
|
|
2269
|
+
source events. Forwarded to `TimeSeries.baseline` and
|
|
2270
|
+
`TimeSeries.outliers` (and their per-partition variants), which
|
|
2271
|
+
pass it to their internal rolling pass. Defaults to `0` (no
|
|
2272
|
+
gate) so existing call sites are unaffected. Use it on noisy
|
|
2273
|
+
rolling stats (e.g. the rolling stdev that feeds
|
|
2274
|
+
`baseline()`'s ±σ bands) to hide the warm-up region where a
|
|
2275
|
+
tiny-sample stdev would collapse the band tight enough to
|
|
2276
|
+
false-flag normal events.
|
|
2277
|
+
|
|
2278
|
+
[0.11.2]: https://github.com/pjm17971/pond-ts/compare/v0.11.1...v0.11.2
|
|
2279
|
+
|
|
2280
|
+
## [0.11.1] — 2026-04-27
|
|
2281
|
+
|
|
2282
|
+
Closes a packaging footgun the dashboard agent surfaced while
|
|
2283
|
+
upgrading from `pond-ts@0.10.1` to `pond-ts@0.11.0`.
|
|
2284
|
+
|
|
2285
|
+
When users had `@pond-ts/react@0.10.1` (which declared
|
|
2286
|
+
`dependencies: { "pond-ts": "^0.10.0" }`) and bumped only
|
|
2287
|
+
`pond-ts` to `0.11.0`, npm satisfied the react package's `^0.10.0`
|
|
2288
|
+
range by nesting a _second_ copy of `pond-ts@0.10.1` under
|
|
2289
|
+
`@pond-ts/react/node_modules/`. Two pond-ts copies meant two
|
|
2290
|
+
distinct `Sequence` / `Time` / etc. classes with non-shared JS
|
|
2291
|
+
private (`#`) brands. TypeScript surfaced this as
|
|
2292
|
+
`Property '#private' refers to a different member`, which is
|
|
2293
|
+
opaque without the package context.
|
|
2294
|
+
|
|
2295
|
+
### Changed
|
|
2296
|
+
|
|
2297
|
+
- **`@pond-ts/react`**: moved `pond-ts` from `dependencies` to
|
|
2298
|
+
`peerDependencies` (range unchanged: `^0.11.0`). With peer-dep
|
|
2299
|
+
semantics, npm refuses to install a duplicate `pond-ts`; instead
|
|
2300
|
+
it warns at install time about peer-version mismatch — concrete,
|
|
2301
|
+
actionable feedback rather than a runtime brand-check failure.
|
|
2302
|
+
|
|
2303
|
+
This is the standard pattern for packages that wrap another
|
|
2304
|
+
library's classes (`react-dom` peer-deps `react`, etc.):
|
|
2305
|
+
`@pond-ts/react`'s hooks return and operate on `pond-ts`
|
|
2306
|
+
instances, so they MUST share class identity with the consumer's
|
|
2307
|
+
`pond-ts`.
|
|
2308
|
+
|
|
2309
|
+
**Mild break:** consumers who installed only `@pond-ts/react`
|
|
2310
|
+
and relied on the transitive `pond-ts` will now get an npm
|
|
2311
|
+
warning and need to add `pond-ts` to their direct dependencies.
|
|
2312
|
+
In practice anyone using `@pond-ts/react` is already importing
|
|
2313
|
+
`pond-ts` types/classes, so the typical setup already has it
|
|
2314
|
+
declared explicitly.
|
|
2315
|
+
|
|
2316
|
+
### Notes
|
|
2317
|
+
|
|
2318
|
+
- **Why caret (`^0.11.0`) and not exact pin?** Pre-1.0 caret
|
|
2319
|
+
semver only accepts patches within the same minor (so
|
|
2320
|
+
`^0.11.0` matches 0.11.x but not 0.12.0). That already
|
|
2321
|
+
enforces minor-level lockstep — exact pinning would force
|
|
2322
|
+
consumers to bump both packages for every patch, even when one
|
|
2323
|
+
package's bump is a lockstep no-op.
|
|
2324
|
+
|
|
2325
|
+
[0.11.1]: https://github.com/pjm17971/pond-ts/compare/v0.11.0...v0.11.1
|
|
2326
|
+
|
|
2327
|
+
## [0.11.0] — 2026-04-27
|
|
2328
|
+
|
|
2329
|
+
The "live partitioning" release. Closes the cross-entity
|
|
2330
|
+
correctness story end-to-end — the per-partition primitives we
|
|
2331
|
+
shipped in v0.9.0 / v0.10.0 for batch now have a live counterpart
|
|
2332
|
+
that handles ingestion, retention, grace, and stateful pipelines
|
|
2333
|
+
on multi-host streams.
|
|
2334
|
+
|
|
2335
|
+
Without this, every multi-host live pipeline (rolling avg, fill,
|
|
2336
|
+
diff, rate, cumulative, pctChange) silently mixes data across
|
|
2337
|
+
entities — the same hazard the partitionBy work resolved for
|
|
2338
|
+
batch, but live-side. Dashboard agent's v0.9.0 round-2 feedback
|
|
2339
|
+
explicitly named "LivePartitionedSeries would be the obvious next
|
|
2340
|
+
step" as the missing piece.
|
|
2341
|
+
|
|
2342
|
+
### Added
|
|
2343
|
+
|
|
2344
|
+
- **`liveSeries.partitionBy(col, options?)`** — returns
|
|
2345
|
+
`LivePartitionedSeries<S, K>`, the live counterpart to
|
|
2346
|
+
`PartitionedTimeSeries`. Routes events from a source
|
|
2347
|
+
`LiveSource<S>` into per-partition `LiveSeries<S>` sub-buffers,
|
|
2348
|
+
each with its own retention, grace window, and stateful
|
|
2349
|
+
operator pipeline.
|
|
2350
|
+
|
|
2351
|
+
Per-partition semantics (settled in design):
|
|
2352
|
+
- Retention applies per partition (a chatty host can't squeeze
|
|
2353
|
+
a quiet one out of the buffer)
|
|
2354
|
+
- Grace windows apply per partition (late events touch only
|
|
2355
|
+
their own partition)
|
|
2356
|
+
- Aggregation timing is per partition (one host's rolling avg
|
|
2357
|
+
fires when that host has enough data)
|
|
2358
|
+
- Auto-spawn on new partition values; optional `groups` for
|
|
2359
|
+
typed declared partitions (mirrors batch typed-groups)
|
|
2360
|
+
|
|
2361
|
+
Terminals:
|
|
2362
|
+
- `.toMap()` → `Map<K, LiveSource<S>>` for direct per-partition
|
|
2363
|
+
subscription
|
|
2364
|
+
- `.collect()` → unified `LiveSeries<S>` (append-only fan-in)
|
|
2365
|
+
- `.apply(factory)` → unified `LiveSeries<R>` with per-
|
|
2366
|
+
partition operator chains
|
|
2367
|
+
- `.dispose()` cleans up source subscription, all per-partition
|
|
2368
|
+
pipeline subscribers, and `toMap`-created factory chains
|
|
2369
|
+
|
|
2370
|
+
- **Typed chainable sugar** — `partitioned.fill(...).rolling(...).collect()`
|
|
2371
|
+
matches the batch chainable view. Sugar coverage on both
|
|
2372
|
+
`LivePartitionedSeries` and the chained `LivePartitionedView`:
|
|
2373
|
+
`fill`, `diff`, `rate`, `pctChange`, `cumulative`, `rolling`.
|
|
2374
|
+
|
|
2375
|
+
```ts
|
|
2376
|
+
const cpuSmoothed = live
|
|
2377
|
+
.partitionBy('host')
|
|
2378
|
+
.fill({ cpu: 'hold' })
|
|
2379
|
+
.rolling('1m', { cpu: 'avg', host: 'last' })
|
|
2380
|
+
.collect();
|
|
2381
|
+
```
|
|
2382
|
+
|
|
2383
|
+
`LivePartitionedView<SBase, R, K>` is a lazy chain step holding
|
|
2384
|
+
a composed factory; terminals delegate to the root partitioned
|
|
2385
|
+
series. Auto-spawn flows through the chain — a new partition
|
|
2386
|
+
triggers a fresh factory invocation.
|
|
2387
|
+
|
|
2388
|
+
- **`LivePartitionedView`** exported from package root.
|
|
2389
|
+
|
|
2390
|
+
- **`ARCHITECTURE.md`** at repo root — first-pass document for
|
|
2391
|
+
contributors (human or AI) reading the codebase cold. Covers
|
|
2392
|
+
layered model, stateful primitives, recurring patterns
|
|
2393
|
+
(typed-groups, trusted construction via `static #foo`,
|
|
2394
|
+
factory-based per-partition state, append-only fan-in vs
|
|
2395
|
+
mirrored materialization, per-method JSDoc warnings, perf-
|
|
2396
|
+
check discipline), decision log, and conventions.
|
|
2397
|
+
|
|
2398
|
+
### Changed
|
|
2399
|
+
|
|
2400
|
+
- **CLAUDE.md** points to `ARCHITECTURE.md` so future sessions
|
|
2401
|
+
discover it alongside `PLAN.md`.
|
|
2402
|
+
|
|
2403
|
+
### Notes
|
|
2404
|
+
|
|
2405
|
+
- **Append-only fan-in semantics** for `collect()` and `apply()`
|
|
2406
|
+
on `LivePartitionedSeries` — per-partition retention/grace
|
|
2407
|
+
evictions do NOT propagate to the unified buffer. Documented
|
|
2408
|
+
via JSDoc; the unified buffer's own retention is independent.
|
|
2409
|
+
Use `toMap()` for current per-partition state.
|
|
2410
|
+
|
|
2411
|
+
- **Post-commit error semantics for partition rejection** — when
|
|
2412
|
+
the partition view throws inside the source's event listener
|
|
2413
|
+
(rogue value, partition ordering rejection), the source has
|
|
2414
|
+
already committed the event. Documented in
|
|
2415
|
+
`LiveSeries.partitionBy` JSDoc; recommend upstream input
|
|
2416
|
+
validation if source/partition atomicity matters.
|
|
2417
|
+
|
|
2418
|
+
- **Rolling drops partition column unless explicitly added.**
|
|
2419
|
+
`LiveSeries.rolling` (and the partitioned chain via it) only
|
|
2420
|
+
retains columns named in `mapping` — include `host: 'last'` (or
|
|
2421
|
+
similar) to keep the partition tag visible in the unified
|
|
2422
|
+
output. Documented in `rolling`'s JSDoc on both the
|
|
2423
|
+
`LivePartitionedSeries` and `LivePartitionedView` surfaces.
|
|
2424
|
+
|
|
2425
|
+
### Performance
|
|
2426
|
+
|
|
2427
|
+
- Routing overhead measured at ~88ms for 100k events × 10 hosts
|
|
2428
|
+
(50ms over bare push). Apples-to-apples vs equivalent un-
|
|
2429
|
+
partitioned operator chains: ~1.8-2.6× cost. Constant per
|
|
2430
|
+
event (~0.8 µs); cardinality scales flat (Map lookup is O(1)).
|
|
2431
|
+
See `scripts/perf-live-partitioned.mjs`.
|
|
2432
|
+
|
|
2433
|
+
- An `_acceptEvent` private-method optimization to bypass row
|
|
2434
|
+
re-validation in partition routing was scoped and rejected for
|
|
2435
|
+
v0.11 — the benefit (~0.3-0.4 µs/event saved) is marginal for
|
|
2436
|
+
typical telemetry workloads (1-10k events/sec) and the cost
|
|
2437
|
+
(validation-bypass primitive on the public API surface) wasn't
|
|
2438
|
+
justified. May revisit if a high-throughput user surfaces the
|
|
2439
|
+
bottleneck with real workload data.
|
|
2440
|
+
|
|
2441
|
+
[0.11.0]: https://github.com/pjm17971/pond-ts/compare/v0.10.1...v0.11.0
|
|
2442
|
+
|
|
2443
|
+
## [0.10.1] — 2026-04-27
|
|
2444
|
+
|
|
2445
|
+
Strictly additive over v0.10.0. Closes the export gap surfaced by
|
|
2446
|
+
the Codex CSV-cleaner v0.10 retest:
|
|
2447
|
+
|
|
2448
|
+
> `MaterializeSchema` exists in `dist/types.d.ts` but is not
|
|
2449
|
+
> exported from the package root, so the script had to spell out
|
|
2450
|
+
> the materialized schema locally for strict typing.
|
|
2451
|
+
|
|
2452
|
+
### Added
|
|
2453
|
+
|
|
2454
|
+
- **`MaterializeSchema<S>`** now exported from the package root.
|
|
2455
|
+
Users typing `materialize` output (or composing it into wrapper
|
|
2456
|
+
utilities) can import the type directly from `pond-ts` instead
|
|
2457
|
+
of digging into the dist-types.
|
|
2458
|
+
- **`DedupeKeep<S>`** also exported (was the same gap — the type
|
|
2459
|
+
for the `dedupe({ keep })` resolver function shape). Closes the
|
|
2460
|
+
same friction for callers writing custom dedupe resolvers in
|
|
2461
|
+
isolation.
|
|
2462
|
+
|
|
2463
|
+
[0.10.1]: https://github.com/pjm17971/pond-ts/compare/v0.10.0...v0.10.1
|
|
2464
|
+
|
|
2465
|
+
## [0.10.0] — 2026-04-27
|
|
2466
|
+
|
|
2467
|
+
The "round-2 dashboard agent feedback" release. After v0.9.0
|
|
2468
|
+
shipped the cross-entity correctness wave, three independent
|
|
2469
|
+
agents (Codex CSV-cleaner, fresh CSV-cleaner eval, dashboard
|
|
2470
|
+
agent) flagged refinements. v0.10 delivers all three:
|
|
2471
|
+
|
|
2472
|
+
- A grid-completion primitive that doesn't pre-pick a fill method
|
|
2473
|
+
(Codex's "regularize without filling" friction)
|
|
2474
|
+
- A terminal `toMap` that materializes the partition view directly
|
|
2475
|
+
to a Map keyed by partition value (dashboard agent's
|
|
2476
|
+
`.collect().groupBy(col, fn)` chain pain)
|
|
2477
|
+
- Typed partition declaration via `groups` for narrowed Map keys
|
|
2478
|
+
and declared-order iteration (dashboard agent's third
|
|
2479
|
+
refinement; mirrors `pivotByGroup({ groups })`)
|
|
2480
|
+
|
|
2481
|
+
Strictly additive over v0.9.x — no behavior changes for existing
|
|
2482
|
+
callers.
|
|
2483
|
+
|
|
2484
|
+
### Added
|
|
2485
|
+
|
|
2486
|
+
- **`series.materialize(sequence, options?)`** — emits one
|
|
2487
|
+
time-keyed row per sequence bucket, populating value columns
|
|
2488
|
+
from a chosen source event in the bucket (or `undefined` for
|
|
2489
|
+
empty buckets). Does only the grid step; pairs naturally with
|
|
2490
|
+
`fill()` for explicit fill-policy control:
|
|
2491
|
+
|
|
2492
|
+
```ts
|
|
2493
|
+
series
|
|
2494
|
+
.partitionBy('host')
|
|
2495
|
+
.materialize(Sequence.every('1m'))
|
|
2496
|
+
.fill({ cpu: 'linear' }, { maxGap: '3m' })
|
|
2497
|
+
.collect();
|
|
2498
|
+
```
|
|
2499
|
+
|
|
2500
|
+
Three `select` modes: `'first'` / `'last'` (default) /
|
|
2501
|
+
`'nearest'` — all bucket-bounded; empty buckets emit
|
|
2502
|
+
`undefined` regardless. Three `sample` anchors:
|
|
2503
|
+
`'begin'` (default) / `'center'` / `'end'`. Output schema
|
|
2504
|
+
widens value columns to optional (`MaterializeSchema<S>`).
|
|
2505
|
+
|
|
2506
|
+
The `PartitionedTimeSeries.materialize` sugar auto-populates
|
|
2507
|
+
the partition column on every output row, including
|
|
2508
|
+
empty-bucket rows — without this, downstream code would need a
|
|
2509
|
+
`.fill({ host: 'hold' })` step that fails for partitions where
|
|
2510
|
+
every event sits in a long-outage gap.
|
|
2511
|
+
|
|
2512
|
+
Distinct from `align()` (which mandates a `'hold'` or
|
|
2513
|
+
`'linear'` fill method and returns interval-keyed) and
|
|
2514
|
+
`aggregate()` (which applies a per-column reducer). See
|
|
2515
|
+
`cleaning.mdx` for the full operator-comparison table.
|
|
2516
|
+
|
|
2517
|
+
- **`PartitionedTimeSeries.toMap(transform?)`** — terminal that
|
|
2518
|
+
returns `Map<key, TimeSeries<S>>` (or `Map<key, R>` with a
|
|
2519
|
+
transform) directly from the partition view. Replaces the
|
|
2520
|
+
`.collect().groupBy(col, fn)` chain dashboard code was using.
|
|
2521
|
+
|
|
2522
|
+
Three overloads cover the common shapes: bare per-partition
|
|
2523
|
+
`TimeSeries`, transform that returns `TimeSeries<R>`, and
|
|
2524
|
+
transform that returns arbitrary `R`. Map iteration order
|
|
2525
|
+
matches the order each partition was first encountered in the
|
|
2526
|
+
source events (or declared order when `groups` is set).
|
|
2527
|
+
|
|
2528
|
+
Map keys are stringified partition values for single-column
|
|
2529
|
+
partitions (preserving the natural string representation:
|
|
2530
|
+
`'api-1'`, `'eu'`, etc.), or JSON arrays for composite
|
|
2531
|
+
partitions (`'["api-1","eu"]'`). `undefined` partition values
|
|
2532
|
+
use the leading-space sentinel `' undefined'` to avoid
|
|
2533
|
+
collision with the literal string `'undefined'` — distinct
|
|
2534
|
+
from `groupBy`'s bare `'undefined'` key, which silently
|
|
2535
|
+
collapses the two cases. Documented as an intentional
|
|
2536
|
+
improvement; migrators changing from `.get('undefined')` to
|
|
2537
|
+
`.get(' undefined')`.
|
|
2538
|
+
|
|
2539
|
+
**3.3× faster than the `.collect().groupBy(col, fn)` chain it
|
|
2540
|
+
replaces** at 100k events × 10 hosts (33 ms vs 108 ms,
|
|
2541
|
+
measured by `scripts/perf-partitioned-toMap.mjs`).
|
|
2542
|
+
|
|
2543
|
+
- **`series.partitionBy(col, { groups })` typed declaration**
|
|
2544
|
+
— pre-declares the expected partition values, narrowing the
|
|
2545
|
+
partition view's `K` type from `string` to the literal union.
|
|
2546
|
+
Propagates through every sugar method's return type and through
|
|
2547
|
+
`toMap`'s `Map` key:
|
|
2548
|
+
|
|
2549
|
+
```ts
|
|
2550
|
+
const HOSTS = ['api-1', 'api-2', 'api-3'] as const;
|
|
2551
|
+
const byHost = series
|
|
2552
|
+
.partitionBy('host', { groups: HOSTS })
|
|
2553
|
+
.fill({ cpu: 'linear' })
|
|
2554
|
+
.toMap();
|
|
2555
|
+
// byHost: Map<'api-1' | 'api-2' | 'api-3', TimeSeries<S>>
|
|
2556
|
+
```
|
|
2557
|
+
|
|
2558
|
+
Mirrors `pivotByGroup({ groups })` — same design vocabulary,
|
|
2559
|
+
same discipline: declared-order iteration, empty declared
|
|
2560
|
+
groups produce empty entries, partition values not in `groups`
|
|
2561
|
+
throw at construction time, empty `groups: []` and duplicate
|
|
2562
|
+
values throw upfront, single-column only (composite + groups
|
|
2563
|
+
throws). Numeric and boolean partition columns are stringified
|
|
2564
|
+
by the encoder, so declared groups must be the stringified
|
|
2565
|
+
form (`groups: ['1', '2']` for a numeric column).
|
|
2566
|
+
|
|
2567
|
+
- **Per-method `**Multi-entity series:**` JSDoc warnings**
|
|
2568
|
+
remain on every stateful operator (shipped in v0.9.0); the
|
|
2569
|
+
v0.10 operators (`materialize`, `toMap`) inherit the same
|
|
2570
|
+
discoverability.
|
|
2571
|
+
|
|
2572
|
+
### Changed
|
|
2573
|
+
|
|
2574
|
+
- **CLAUDE.md adds a perf-check policy.** New operators that walk
|
|
2575
|
+
events, allocate per-event, or scale with input dimensions
|
|
2576
|
+
must have an analytical complexity statement, a benchmark
|
|
2577
|
+
script (`packages/core/scripts/perf-<operator>.mjs`), and
|
|
2578
|
+
before/after numbers in the commit message. Surfaces in the
|
|
2579
|
+
Layer 1 self-review checklist. Every v0.10 PR followed this:
|
|
2580
|
+
`materialize` got `perf-materialize.mjs` (and two optimization
|
|
2581
|
+
passes that landed –41% on the partitioned variant);
|
|
2582
|
+
`toMap` got `perf-partitioned-toMap.mjs` (3.3× speedup
|
|
2583
|
+
measurement); typed `groups` got `perf-partitionby-groups.mjs`
|
|
2584
|
+
(zero chain-step regression via the class-private trusted
|
|
2585
|
+
factory).
|
|
2586
|
+
|
|
2587
|
+
[0.10.0]: https://github.com/pjm17971/pond-ts/compare/v0.9.1...v0.10.0
|
|
2588
|
+
|
|
2589
|
+
## [0.9.1] — 2026-04-26
|
|
2590
|
+
|
|
2591
|
+
Strictly additive over v0.9.0. Closes a sugar-method type bug
|
|
2592
|
+
identified independently by two agents (a fresh CSV-cleaner eval
|
|
2593
|
+
against v0.9.0 and Codex on a v0.9.0 retest), plus folds in two
|
|
2594
|
+
fresh-agent doc improvements.
|
|
2595
|
+
|
|
2596
|
+
### Fixed
|
|
2597
|
+
|
|
2598
|
+
- **`PartitionedTimeSeries.fill` now accepts `maxGap`.** PR #78
|
|
2599
|
+
added `maxGap` to `TimeSeries.fill` for v0.9.0 but the partitioned
|
|
2600
|
+
sugar's option type was not widened, so the headline v0.9.0 chain —
|
|
2601
|
+
`partitionBy('host').fill('linear', { maxGap: '5m' })` — failed
|
|
2602
|
+
type checking and forced callers into `.apply()`. The underlying
|
|
2603
|
+
impl already passed options through, so this is a one-line type
|
|
2604
|
+
widening: `{ limit?: number; maxGap?: DurationInput }`.
|
|
2605
|
+
|
|
2606
|
+
### Added
|
|
2607
|
+
|
|
2608
|
+
- **9 new tests** under `TimeSeries.partitionBy.test.ts`:
|
|
2609
|
+
- 4 regression tests pinning the partitioned `fill(maxGap)` chain
|
|
2610
|
+
works (bare `maxGap`, all-or-nothing per-partition span,
|
|
2611
|
+
`limit + maxGap` composition, full `partitionBy + dedupe +
|
|
2612
|
+
fill(maxGap)` chain).
|
|
2613
|
+
- 5 composite-key round-trip tests addressing a refinement flagged
|
|
2614
|
+
by the dashboard agent: `partitionBy(['host', 'region'])`
|
|
2615
|
+
preserves both key columns in the schema, on every output event,
|
|
2616
|
+
keeps `(host, region)` tuples distinct (no collapse on host
|
|
2617
|
+
alone), and round-trips through `apply()` and the full chain.
|
|
2618
|
+
- **`cleaning.mdx` "Schema first — `required: false`" section.**
|
|
2619
|
+
Leads the page; documents why optional cells need the flag and
|
|
2620
|
+
surfaces the `fromJSON`/`null` workaround for the known
|
|
2621
|
+
`RowForSchema` variance limitation. Previously this prose only
|
|
2622
|
+
lived in the 0.8.2 changelog (fresh-agent feedback).
|
|
2623
|
+
- **`cleaning.mdx` "End-to-end multi-entity cleaning pipeline"
|
|
2624
|
+
section.** The unified `partitionBy + dedupe + fill(maxGap)`
|
|
2625
|
+
chain in one place plus a step-by-step hazard table.
|
|
2626
|
+
Previously split across three sections (fresh-agent feedback).
|
|
2627
|
+
|
|
2628
|
+
[0.9.1]: https://github.com/pjm17971/pond-ts/compare/v0.9.0...v0.9.1
|
|
2629
|
+
|
|
2630
|
+
## [0.9.0] — 2026-04-26
|
|
2631
|
+
|
|
2632
|
+
The "cross-entity correctness + cleaning hygiene" release. Three
|
|
2633
|
+
independent CSV-cleaner agent runs (Codex, Claude, Gemini) all hit
|
|
2634
|
+
the same shape: stateful transforms (`fill('linear')`, `rolling`,
|
|
2635
|
+
`diff`, etc.) silently mix data across entities on multi-host
|
|
2636
|
+
series, and `fill('linear', { limit: 3 })` fabricates interpolated
|
|
2637
|
+
data across long outages instead of leaving the unknown unknown.
|
|
2638
|
+
|
|
2639
|
+
v0.9.0 ships three operator-level fixes plus a discoverability pass
|
|
2640
|
+
on every affected method's JSDoc.
|
|
2641
|
+
|
|
2642
|
+
### Added
|
|
2643
|
+
|
|
2644
|
+
- **`series.partitionBy(col).<op>(...).collect()`** — chainable
|
|
2645
|
+
per-partition view over `TimeSeries`. Sugar methods for every
|
|
2646
|
+
stateful operator (`fill`, `align`, `rolling`, `smooth`,
|
|
2647
|
+
`baseline`, `outliers`, `diff`, `rate`, `pctChange`, `cumulative`,
|
|
2648
|
+
`shift`, `aggregate`, `dedupe`) run the underlying transform per
|
|
2649
|
+
partition. `.collect()` materializes back to `TimeSeries<S>`.
|
|
2650
|
+
`.apply(g => /* arbitrary chain */)` is the terminal escape hatch.
|
|
2651
|
+
One primitive covers the cross-entity hazard for every at-risk
|
|
2652
|
+
method, instead of adding a `partitionBy` option to each.
|
|
2653
|
+
- **`series.dedupe({ keep })`** — first-class deduplication with
|
|
2654
|
+
policies: `'first' | 'last' | 'error' | 'drop' | { min: col } |
|
|
2655
|
+
{ max: col } | (events) => Event`. Default key is the full event
|
|
2656
|
+
key (`begin` for time-keyed, `begin+end` for time-range,
|
|
2657
|
+
`begin+end+value` for interval-keyed); default resolution is
|
|
2658
|
+
`'last'`. `partitionBy('host').dedupe()` is the multi-entity
|
|
2659
|
+
pattern.
|
|
2660
|
+
- **`fill(strategy, { maxGap })`** — duration-based gap cap,
|
|
2661
|
+
complements the existing count-based `limit`. Both compose; most
|
|
2662
|
+
restrictive wins.
|
|
2663
|
+
|
|
2664
|
+
### Changed
|
|
2665
|
+
|
|
2666
|
+
- **`fill` is now all-or-nothing.** A gap either fits both caps and
|
|
2667
|
+
is filled entirely, or exceeds either cap and is left fully
|
|
2668
|
+
unfilled. Previously `limit: 3` on a 5-cell gap filled 3 cells and
|
|
2669
|
+
left 2 unfilled — propagating stale `'hold'` values past their
|
|
2670
|
+
useful lifetime and inventing misleading `'linear'` slopes across
|
|
2671
|
+
long outages. Existing `limit` callers see strictly more
|
|
2672
|
+
conservative behavior; to opt back in to partial fill, set
|
|
2673
|
+
`limit`/`maxGap` larger than any gap you want filled.
|
|
2674
|
+
- **Every stateful TimeSeries method's JSDoc** now includes a
|
|
2675
|
+
`**Multi-entity series:**` warning paragraph naming the operator's
|
|
2676
|
+
specific cross-entity hazard and pointing at the
|
|
2677
|
+
`partitionBy(col).<method>(...).collect()` pattern. Discoverable
|
|
2678
|
+
in LSP hover, IDE quick-help, and any tool that reads type
|
|
2679
|
+
definitions.
|
|
2680
|
+
- **`PartitionedTimeSeries` view** preserves partition state across
|
|
2681
|
+
every sugar call, so multi-step per-partition chains compose
|
|
2682
|
+
cleanly without re-partitioning at each step.
|
|
2683
|
+
|
|
2684
|
+
### Fixed
|
|
2685
|
+
|
|
2686
|
+
- Pre-existing brand-check bug on `series.filter(...).diff(...)`
|
|
2687
|
+
and similar chains: events constructed via
|
|
2688
|
+
`#fromTrustedEvents` (which uses `Object.create` to bypass the
|
|
2689
|
+
constructor) hit a JS-private brand check on `#diffOrRate` and
|
|
2690
|
+
threw. Refactored to a class-static private (`static
|
|
2691
|
+
#diffOrRate`) — runtime-private without the per-instance brand
|
|
2692
|
+
failure.
|
|
2693
|
+
|
|
2694
|
+
[0.9.0]: https://github.com/pjm17971/pond-ts/compare/v0.8.2...v0.9.0
|
|
2695
|
+
|
|
2696
|
+
## [0.8.2] — 2026-04-26
|
|
2697
|
+
|
|
2698
|
+
Strictly additive over v0.8.1. Closes friction surfaced by two
|
|
2699
|
+
independent agent runs against a realistic CSV-cleaning task —
|
|
2700
|
+
specifically, the missing fan-in primitive that forces callers out
|
|
2701
|
+
of the typed contract when reassembling per-host transformed
|
|
2702
|
+
subseries.
|
|
2703
|
+
|
|
2704
|
+
### Added
|
|
2705
|
+
|
|
2706
|
+
- **`TimeSeries.concat([s1, s2, ...])`** — concatenates the events of
|
|
2707
|
+
N same-schema `TimeSeries` instances, re-sorted by key. The
|
|
2708
|
+
row-append / vertical-stack counterpart to `joinMany` (which
|
|
2709
|
+
column-merges by key). Matches `Array.prototype.concat` /
|
|
2710
|
+
`pandas.concat(axis=0)` / SQL `UNION ALL` semantics. Closes the
|
|
2711
|
+
round-trip after `groupBy(col, fn)` + per-group transforms without
|
|
2712
|
+
forcing callers to unwrap events back to row tuples.
|
|
2713
|
+
|
|
2714
|
+
```ts
|
|
2715
|
+
const filledByHost = series.groupBy('host', (g) =>
|
|
2716
|
+
g.fill({ cpu: 'linear' }, { limit: 2 }),
|
|
2717
|
+
);
|
|
2718
|
+
const combined = TimeSeries.concat([...filledByHost.values()]);
|
|
2719
|
+
// back to one TimeSeries<S>; events from all hosts re-sorted.
|
|
2720
|
+
```
|
|
2721
|
+
|
|
2722
|
+
Schemas must match column-by-column on `name` and `kind`; throws
|
|
2723
|
+
upfront on mismatch. Same-key events from different inputs are
|
|
2724
|
+
both kept (row-append, not key-dedupe).
|
|
2725
|
+
|
|
2726
|
+
Coming from pondjs: `timeSeriesListMerge`'s concatenation case
|
|
2727
|
+
maps to `TimeSeries.concat([...])`; its column-union case maps to
|
|
2728
|
+
`TimeSeries.joinMany([...])`.
|
|
2729
|
+
|
|
2730
|
+
- **`TimeSeries.fromEvents(events, { schema, name })`** — builds a
|
|
2731
|
+
typed series from a flat `Event[]` array. Sorts by key. Companion
|
|
2732
|
+
to `merge` for the case where you have raw events rather than a
|
|
2733
|
+
list of series.
|
|
2734
|
+
|
|
2735
|
+
- **`TimeRange.toJSON()`** returns `{ start: number, end: number }`,
|
|
2736
|
+
the same shape `JsonTimeRangeInput` accepts, so
|
|
2737
|
+
`new TimeRange(range.toJSON())` round-trips. Implicitly invoked by
|
|
2738
|
+
`JSON.stringify(range)`.
|
|
2739
|
+
|
|
2740
|
+
- **`TimeRange.toString()`** returns ISO-8601 `start/end` format
|
|
2741
|
+
(e.g. `2025-01-15T09:00:00.000Z/2025-01-15T10:00:00.000Z`) for
|
|
2742
|
+
debug logs and human-readable display.
|
|
2743
|
+
|
|
2744
|
+
### Known limitation
|
|
2745
|
+
|
|
2746
|
+
Two type-level fixes flagged by the agents are tracked but deferred
|
|
2747
|
+
to a future variance refactor:
|
|
2748
|
+
|
|
2749
|
+
- `toJSON()` returns `TimeSeriesJsonInput<SeriesSchema>` (loose),
|
|
2750
|
+
not `TimeSeriesJsonInput<S>`. Cast the result at the call site
|
|
2751
|
+
if you need the narrow schema preserved.
|
|
2752
|
+
- `RowForSchema` doesn't honor `required: false`. Use `fromJSON`
|
|
2753
|
+
with `null` cells instead of the row-array constructor with
|
|
2754
|
+
`undefined`.
|
|
2755
|
+
|
|
2756
|
+
Both are real but blocked by class-wide invariance through method
|
|
2757
|
+
overloads. See PLAN.md "Known type-level limitation" for the full
|
|
2758
|
+
story.
|
|
2759
|
+
|
|
2760
|
+
## [0.8.1] — 2026-04-26
|
|
2761
|
+
|
|
2762
|
+
Strictly additive over v0.8.0 — typed overload narrows result types when
|
|
2763
|
+
opted in via `groups`; untyped form is unchanged. Plus a docs reorg.
|
|
2764
|
+
|
|
2765
|
+
### Added
|
|
2766
|
+
|
|
2767
|
+
- **`pivotByGroup` typed overload** — pass `{ groups: [...] as const }`
|
|
2768
|
+
and the output schema becomes literal-typed, so downstream
|
|
2769
|
+
`baseline` / `rolling` / `toPoints` calls narrow without `as never`
|
|
2770
|
+
casts. Eliminates the dashboard friction reported on v0.8.0.
|
|
2771
|
+
|
|
2772
|
+
```ts
|
|
2773
|
+
const HOSTS = ['api-1', 'api-2'] as const;
|
|
2774
|
+
const wide = long.pivotByGroup('host', 'cpu', { groups: HOSTS });
|
|
2775
|
+
// wide.schema is now literal-typed:
|
|
2776
|
+
// [time, { name: 'api-1_cpu', kind: 'number', required: false },
|
|
2777
|
+
// { name: 'api-2_cpu', kind: 'number', required: false }]
|
|
2778
|
+
wide.baseline('api-1_cpu', { window: '1m', sigma: 2 }); // no cast
|
|
2779
|
+
```
|
|
2780
|
+
|
|
2781
|
+
Behavior in the typed path: declaration order (not alphabetical),
|
|
2782
|
+
declared-but-empty groups still emit columns, runtime values not
|
|
2783
|
+
in the declared set throw upfront. Untyped form (no `groups`)
|
|
2784
|
+
keeps existing alphabetical / dynamic-discovery / loose-output
|
|
2785
|
+
behavior.
|
|
2786
|
+
|
|
2787
|
+
### Changed
|
|
2788
|
+
|
|
2789
|
+
- **Docs site reorganized.** `Transforms` → **TimeSeries**;
|
|
2790
|
+
`Live` → **LiveSeries**; new **Advanced** section for charting and
|
|
2791
|
+
array columns. Concepts moves to `Start here`. New **Reshaping**
|
|
2792
|
+
page splits `pivotByGroup` / `groupBy` / `join` / `joinMany` from
|
|
2793
|
+
Aggregation, plus a new **Queries** page covering `at` / `first` /
|
|
2794
|
+
`timeRange` / `includesKey` / `intersection` / iterators / output
|
|
2795
|
+
forms — everything that interrogates a series rather than
|
|
2796
|
+
transforming it. JSON ingest renamed to **Ingest** and slotted as
|
|
2797
|
+
the first page under TimeSeries.
|
|
2798
|
+
|
|
2799
|
+
## [0.8.0] — 2026-04-25
|
|
2800
|
+
|
|
2801
|
+
### Added
|
|
2802
|
+
|
|
2803
|
+
- **`TimeSeries.pivotByGroup(groupCol, valueCol, options?)`** — long-to-wide
|
|
2804
|
+
reshape on a categorical column. Each distinct value of `groupCol` becomes
|
|
2805
|
+
its own column in the output schema named `${group}_${value}`, holding the
|
|
2806
|
+
value column at that timestamp. Rows sharing a timestamp collapse into one
|
|
2807
|
+
output row; missing `(timestamp, group)` cells are `undefined`.
|
|
2808
|
+
|
|
2809
|
+
```ts
|
|
2810
|
+
// Long: { ts, cpu, host } per row
|
|
2811
|
+
// Wide: { ts, "api-1_cpu", "api-2_cpu", ... } per row
|
|
2812
|
+
long.pivotByGroup('host', 'cpu').toPoints();
|
|
2813
|
+
// Drops straight into <Line dataKey="api-1_cpu" /> etc.
|
|
2814
|
+
```
|
|
2815
|
+
|
|
2816
|
+
Duplicate `(timestamp, group)` pairs throw by default; opt-in
|
|
2817
|
+
`{ aggregate: 'avg' | 'sum' | 'first' | 'last' | 'min' | 'max' | 'median' | 'p95' | ... }`
|
|
2818
|
+
to combine. The aggregator's output kind must match the value column's
|
|
2819
|
+
kind — `count`, `unique`, `topN` and other kind-changing reducers are
|
|
2820
|
+
rejected upfront with a clear error. Output schema is dynamic so the
|
|
2821
|
+
return type is `TimeSeries<SeriesSchema>` (loosely typed). Time-keyed
|
|
2822
|
+
input required.
|
|
2823
|
+
|
|
2824
|
+
Use `pivotByGroup` for the per-group dashboard case ("one source, many
|
|
2825
|
+
producers, one chart line per producer"). Use `groupBy + joinMany` when
|
|
2826
|
+
each group spawns multiple derived columns (e.g. per-host baseline →
|
|
2827
|
+
cpu/avg/upper/lower per host). At 200k events × 100 groups, runs in
|
|
2828
|
+
~43 ms — at parity with hand-rolled JS that skips `TimeSeries`
|
|
2829
|
+
construction entirely.
|
|
2830
|
+
|
|
2831
|
+
### Changed
|
|
2832
|
+
|
|
2833
|
+
- Charting docs lead with `series.join(other, ...).toPoints()` for
|
|
2834
|
+
cross-source overlays. The manual `mergeWideRows` recipe is demoted to
|
|
2835
|
+
"non-`TimeSeries` inputs". A new "Per-group wide rows" section covers
|
|
2836
|
+
`pivotByGroup` end-to-end with Recharts.
|
|
2837
|
+
|
|
2838
|
+
### Notes
|
|
2839
|
+
|
|
2840
|
+
- **Live counterpart deferred.** No `LiveSeries.pivotByGroup` /
|
|
2841
|
+
`LiveSeries.merge` / `LiveSeries.join` yet — see PLAN.md "Known scope
|
|
2842
|
+
gap: live merge / join". Snapshot-then-batch is the workaround:
|
|
2843
|
+
`useSnapshot` per source + `useMemo` running a batch `pivotByGroup` or
|
|
2844
|
+
`join`.
|
|
2845
|
+
|
|
2846
|
+
## [0.7.0] — 2026-04-25
|
|
2847
|
+
|
|
2848
|
+
### Changed (breaking)
|
|
2849
|
+
|
|
2850
|
+
- **`TimeSeries.toPoints()` returns wide rows** instead of single-column
|
|
2851
|
+
`{ ts, value }[]`. Every event becomes one row with `ts` plus every
|
|
2852
|
+
value column from the schema as a top-level key:
|
|
2853
|
+
|
|
2854
|
+
```ts
|
|
2855
|
+
// Before: // After:
|
|
2856
|
+
series.toPoints('cpu');
|
|
2857
|
+
series.toPoints();
|
|
2858
|
+
// [{ ts, value }, ...] // [{ ts, cpu, host, ... }, ...]
|
|
2859
|
+
```
|
|
2860
|
+
|
|
2861
|
+
This aligns pond-ts's multi-column nature with what every chart
|
|
2862
|
+
library actually wants (Recharts, Observable Plot, visx all consume
|
|
2863
|
+
wide rows directly). Band charts, multi-series overlays, and
|
|
2864
|
+
`<Area>` ranged-`dataKey` patterns become a single `toPoints()`
|
|
2865
|
+
call instead of a manual merge.
|
|
2866
|
+
|
|
2867
|
+
**Migration:** for the common single-column case, compose with
|
|
2868
|
+
`select`:
|
|
2869
|
+
|
|
2870
|
+
```ts
|
|
2871
|
+
const cpuPoints = series.select('cpu').toPoints();
|
|
2872
|
+
// [{ ts, cpu }, ...]
|
|
2873
|
+
```
|
|
2874
|
+
|
|
2875
|
+
Then read the column by name (`row.cpu`) instead of the old
|
|
2876
|
+
`.value`. Wide form keeps every event — the old narrow form
|
|
2877
|
+
dropped events whose column was `undefined`; the new form preserves
|
|
2878
|
+
them so chart libraries can render gaps via `connectNulls={false}`.
|
|
2879
|
+
|
|
2880
|
+
**Watch out for `value`-named columns.** If your schema has a value
|
|
2881
|
+
column literally named `value`, the new wide rows will have a
|
|
2882
|
+
`value` key that looks identical to the old narrow shape — but it's
|
|
2883
|
+
the column-named-`value`, not the narrow-form `value`. Audit any
|
|
2884
|
+
`row.value` reads after upgrading; the safe migration is
|
|
2885
|
+
`row.<schema-column-name>`.
|
|
2886
|
+
|
|
2887
|
+
- **`TimeSeries.fromPoints()` accepts wide-row points** with a schema
|
|
2888
|
+
of any number of value columns. Schema's first column must still be
|
|
2889
|
+
`kind: 'time'`.
|
|
2890
|
+
|
|
2891
|
+
```ts
|
|
2892
|
+
TimeSeries.fromPoints(
|
|
2893
|
+
[{ ts: 0, cpu: 0.3, host: 'api-1' }, ...],
|
|
2894
|
+
{
|
|
2895
|
+
schema: [
|
|
2896
|
+
{ name: 'time', kind: 'time' },
|
|
2897
|
+
{ name: 'cpu', kind: 'number' },
|
|
2898
|
+
{ name: 'host', kind: 'string' },
|
|
2899
|
+
] as const,
|
|
2900
|
+
},
|
|
2901
|
+
);
|
|
2902
|
+
```
|
|
2903
|
+
|
|
2904
|
+
Previously restricted to exactly two columns with `{ ts, value }`
|
|
2905
|
+
rows; that form is gone.
|
|
2906
|
+
|
|
2907
|
+
## [0.6.0] — 2026-04-25
|
|
2908
|
+
|
|
2909
|
+
### Added
|
|
2910
|
+
|
|
2911
|
+
- **`'end'` sample option** for `align()` and `Sequence.bounded()`. Joins
|
|
2912
|
+
`'begin'` and `'center'` as a third anchor inside each grid step.
|
|
2913
|
+
Useful for end-of-period readings (close-of-day, last value before
|
|
2914
|
+
bucket close). Inclusion semantics are left-exclusive
|
|
2915
|
+
(`sample ∈ (range.begin, range.end]`) so an end-sample at exactly
|
|
2916
|
+
`range.begin()` doesn't pull in an interval that sits entirely
|
|
2917
|
+
before the range.
|
|
2918
|
+
|
|
2919
|
+
### Type-surface change
|
|
2920
|
+
|
|
2921
|
+
- `AlignSample` and `SequenceSample` literal unions widen from
|
|
2922
|
+
`'begin' | 'center'` to `'begin' | 'center' | 'end'`. Pattern-matching
|
|
2923
|
+
consumers that exhaustively `switch` on the old two-value union
|
|
2924
|
+
silently miss the new arm — minor bump rather than a patch per this
|
|
2925
|
+
project's "patch bumps are strictly additive" rule. Update any
|
|
2926
|
+
`switch (sample)` blocks to handle `'end'` (or add a `default`).
|
|
2927
|
+
|
|
2928
|
+
## [0.5.11] — 2026-04-24
|
|
2929
|
+
|
|
2930
|
+
### Fixed
|
|
2931
|
+
|
|
2932
|
+
- **`LiveSeries` rejects `graceWindow > retention.maxAge` at construction.**
|
|
2933
|
+
A late event accepted within grace but older than `maxAge` would be evicted
|
|
2934
|
+
immediately by retention — the grace contract would be meaningless. The
|
|
2935
|
+
guard only fires when both options are set explicitly; default behavior is
|
|
2936
|
+
unchanged. `LiveAggregation` bucket closure (which inherits grace from the
|
|
2937
|
+
source) still behaves as before.
|
|
2938
|
+
|
|
2939
|
+
### Changed
|
|
2940
|
+
|
|
2941
|
+
- Docs: clarified `graceWindow`'s scope in the `LiveSeriesOptions`
|
|
2942
|
+
docstring. Enforced at ingest and honored by `LiveAggregation` bucket
|
|
2943
|
+
closure; `rolling()` / `window()` live views do not re-flow late events
|
|
2944
|
+
through historical windows. Matches the actual pipeline behavior; full
|
|
2945
|
+
late-event propagation through live transforms is explicitly out of
|
|
2946
|
+
scope (see Akidau's Streaming 102 for the larger story).
|
|
2947
|
+
|
|
2948
|
+
## [0.5.10] — 2026-04-24
|
|
2949
|
+
|
|
2950
|
+
### Fixed
|
|
2951
|
+
|
|
2952
|
+
- **`baseline()` emits `undefined` for `upper` / `lower` when the rolling
|
|
2953
|
+
window is flat (`sd === 0`)** — matching `outliers()`'s behavior. Before,
|
|
2954
|
+
a zero-width band would cause a naive `value > upper || value < lower`
|
|
2955
|
+
filter to flag every non-equal point as anomalous inside a constant run.
|
|
2956
|
+
The `avg` and `sd` columns still report their true values; only the band
|
|
2957
|
+
edges collapse to `undefined`.
|
|
2958
|
+
|
|
2959
|
+
### Changed
|
|
2960
|
+
|
|
2961
|
+
- Internal: consolidated a duplicate `OptionalNumberCol` type alias into
|
|
2962
|
+
the pre-existing `OptionalNumberColumn`. No surface change.
|
|
2963
|
+
- Docs: walked back an over-claim in `outliers()`'s docstring. It was
|
|
2964
|
+
documented as "sugar over `baseline().filter()`" but is implemented
|
|
2965
|
+
independently. Now says the two are conceptually equivalent.
|
|
2966
|
+
|
|
2967
|
+
## [0.5.9] — 2026-04-23
|
|
2968
|
+
|
|
2969
|
+
### Added
|
|
2970
|
+
|
|
2971
|
+
- **`TimeSeries.baseline(col, opts)`** — rolling-stats primitive. Runs one
|
|
2972
|
+
rolling pass and appends four optional number columns (`avg`, `sd`,
|
|
2973
|
+
`upper = avg + σ·sd`, `lower = avg - σ·sd`) to the source schema. Band
|
|
2974
|
+
charts read `toPoints('upper')` / `toPoints('lower')` directly; outlier
|
|
2975
|
+
filters compare against `upper` / `lower`. Replaces the band-plus-outliers
|
|
2976
|
+
two-pass pattern with one call. Custom column names via `{ names }` if the
|
|
2977
|
+
defaults collide.
|
|
2978
|
+
|
|
2979
|
+
[0.8.2]: https://github.com/pjm17971/pond-ts/compare/v0.8.1...v0.8.2
|
|
2980
|
+
[0.8.1]: https://github.com/pjm17971/pond-ts/compare/v0.8.0...v0.8.1
|
|
2981
|
+
[0.8.0]: https://github.com/pjm17971/pond-ts/compare/v0.7.0...v0.8.0
|
|
2982
|
+
[0.7.0]: https://github.com/pjm17971/pond-ts/compare/v0.6.0...v0.7.0
|
|
2983
|
+
[0.6.0]: https://github.com/pjm17971/pond-ts/compare/v0.5.11...v0.6.0
|
|
2984
|
+
[0.5.11]: https://github.com/pjm17971/pond-ts/compare/v0.5.10...v0.5.11
|
|
2985
|
+
[0.5.10]: https://github.com/pjm17971/pond-ts/compare/v0.5.9...v0.5.10
|
|
2986
|
+
[0.5.9]: https://github.com/pjm17971/pond-ts/compare/v0.5.8...v0.5.9
|
|
2987
|
+
|
|
2988
|
+
## [0.5.8] — 2026-04-23
|
|
2989
|
+
|
|
2990
|
+
### Added
|
|
2991
|
+
|
|
2992
|
+
- **`TimeSeries.outliers(col, { window, sigma, alignment? })`** —
|
|
2993
|
+
rolling-baseline outlier detection. Returns `TimeSeries<S>` filtered to
|
|
2994
|
+
events whose value deviates from the trailing rolling average by more than
|
|
2995
|
+
`sigma · rolling_stdev`. Composes directly with aggregate, groupBy, etc.
|
|
2996
|
+
- **`TimeSeries.prototype.toPoints(col)`** — flat `{ ts, value }[]` export
|
|
2997
|
+
matching conventional chart-library shape (Recharts, Observable Plot, d3).
|
|
2998
|
+
Filters `undefined` values; returns a frozen array.
|
|
2999
|
+
- **`TimeSeries.fromPoints(points, { schema, name? })`** — inverse
|
|
3000
|
+
constructor for round-tripping chart-style points back into pond-native
|
|
3001
|
+
operations. Schema must have exactly two columns.
|
|
3002
|
+
|
|
3003
|
+
[0.5.8]: https://github.com/pjm17971/pond-ts/compare/v0.5.7...v0.5.8
|
|
3004
|
+
|
|
3005
|
+
## [0.5.7] — 2026-04-23
|
|
3006
|
+
|
|
3007
|
+
### Added
|
|
3008
|
+
|
|
3009
|
+
- **`smooth('ema', { warmup: N })`** — drops the first `N` output rows so
|
|
3010
|
+
callers don't have to write `.slice(N)` after every EMA call. The smoother
|
|
3011
|
+
still processes those events, so kept rows are computed against a warm EMA.
|
|
3012
|
+
`warmup: 0` is a no-op; warmup ≥ series length returns an empty series.
|
|
3013
|
+
|
|
3014
|
+
[0.5.7]: https://github.com/pjm17971/pond-ts/compare/v0.5.6...v0.5.7
|
|
3015
|
+
|
|
3016
|
+
## [0.5.6] — 2026-04-23
|
|
3017
|
+
|
|
3018
|
+
### Added
|
|
3019
|
+
|
|
3020
|
+
- **`useCurrent` reference stability** — the returned record and each of its
|
|
3021
|
+
fields are reference-stable across renders when structurally unchanged. A
|
|
3022
|
+
no-op push (same aggregate values) hands back the previous references;
|
|
3023
|
+
downstream `useMemo([current.host], ...)` only re-runs when that specific
|
|
3024
|
+
field changes. Scalar fields compare via `===`; array fields compare length
|
|
3025
|
+
then elementwise.
|
|
3026
|
+
|
|
3027
|
+
[0.5.6]: https://github.com/pjm17971/pond-ts/compare/v0.5.5...v0.5.6
|
|
3028
|
+
|
|
3029
|
+
## [0.5.5] — 2026-04-23
|
|
3030
|
+
|
|
3031
|
+
### Added
|
|
3032
|
+
|
|
3033
|
+
- **Narrow return types for `rolling` + `aggregate` output-map overloads.**
|
|
3034
|
+
`rolling(w, { avg: { from: 'cpu', using: 'avg' }, ... })` now returns
|
|
3035
|
+
`TimeSeries<RollingOutputMapSchema<S, M>>` — `e.get('avg')` narrows to
|
|
3036
|
+
`number | undefined` instead of `ColumnValue | undefined`, and `e.key()`
|
|
3037
|
+
preserves the source's first-column kind. Same fix on `aggregate`'s
|
|
3038
|
+
output-map overload.
|
|
3039
|
+
|
|
3040
|
+
### Fixed
|
|
3041
|
+
|
|
3042
|
+
- `min` / `max` were missing from the numeric-reducer list in `ReduceResult`
|
|
3043
|
+
(v0.5.2 regression). Both reducers have `outputKind: 'number'` at runtime;
|
|
3044
|
+
the type now agrees. `reduce({ cpu: 'max' })` narrows to `number | undefined`.
|
|
3045
|
+
|
|
3046
|
+
[0.5.5]: https://github.com/pjm17971/pond-ts/compare/v0.5.4...v0.5.5
|
|
3047
|
+
|
|
3048
|
+
## [0.5.4] — 2026-04-23
|
|
3049
|
+
|
|
3050
|
+
### Added
|
|
3051
|
+
|
|
3052
|
+
- **`rolling` accepts `AggregateOutputMap`** — feature parity with
|
|
3053
|
+
`aggregate`. Multi-reducer-per-column now works in one pass:
|
|
3054
|
+
```ts
|
|
3055
|
+
series.rolling('1m', {
|
|
3056
|
+
avg: { from: 'cpu', using: 'avg' },
|
|
3057
|
+
sd: { from: 'cpu', using: 'stdev' },
|
|
3058
|
+
});
|
|
3059
|
+
```
|
|
3060
|
+
Two new overloads on both window-only and sequence-driven forms.
|
|
3061
|
+
|
|
3062
|
+
### Changed
|
|
3063
|
+
|
|
3064
|
+
- `rolling`'s internal column walker now routes through the shared
|
|
3065
|
+
`normalizeAggregateColumns` helper. Schema-column order is preserved for
|
|
3066
|
+
`AggregateMap` inputs so the runtime layout continues to match
|
|
3067
|
+
`RollingSchema<S, M>`.
|
|
3068
|
+
|
|
3069
|
+
[0.5.4]: https://github.com/pjm17971/pond-ts/compare/v0.5.3...v0.5.4
|
|
3070
|
+
|
|
3071
|
+
## [0.5.3] — 2026-04-23
|
|
3072
|
+
|
|
3073
|
+
### Added
|
|
3074
|
+
|
|
3075
|
+
- **Source-kind narrowing on array-output reducers in `ReduceResult`.**
|
|
3076
|
+
`unique` and `` `top${number}` `` now narrow their output to
|
|
3077
|
+
`ReadonlyArray<T>` where `T` is the source column's element type:
|
|
3078
|
+
```ts
|
|
3079
|
+
series.reduce({ host: 'unique' }).host;
|
|
3080
|
+
// ^ ReadonlyArray<string> | undefined (was ReadonlyArray<ScalarValue>)
|
|
3081
|
+
```
|
|
3082
|
+
Array-kind source columns fall back to the wide `ReadonlyArray<ScalarValue>`
|
|
3083
|
+
union since element kind isn't schema-visible.
|
|
3084
|
+
|
|
3085
|
+
[0.5.3]: https://github.com/pjm17971/pond-ts/compare/v0.5.2...v0.5.3
|
|
3086
|
+
|
|
3087
|
+
## [0.5.2] — 2026-04-23
|
|
3088
|
+
|
|
3089
|
+
### Added
|
|
3090
|
+
|
|
3091
|
+
- **`TimeSeries.reduce` per-entry type narrowing.** Numeric reducers
|
|
3092
|
+
(`sum`/`avg`/`count`/`median`/`stdev`/`difference`/`pNN`) narrow to
|
|
3093
|
+
`number | undefined`; `unique`/`top${N}` narrow to `ReadonlyArray<…> |
|
|
3094
|
+
undefined`; `first`/`last`/`keep` preserve the source column kind. Custom
|
|
3095
|
+
reducer functions and `AggregateOutputSpec` entries keep the wide
|
|
3096
|
+
`ColumnValue | undefined` fallback. Narrowing lives in the new
|
|
3097
|
+
`types-reduce.ts` — same file-split pattern used later for the output-map
|
|
3098
|
+
narrowing.
|
|
3099
|
+
|
|
3100
|
+
### Changed
|
|
3101
|
+
|
|
3102
|
+
- `useCurrent` now aliases `ReduceResult<S, Mapping>` directly; the hook's
|
|
3103
|
+
duplicated narrowing logic is gone.
|
|
3104
|
+
|
|
3105
|
+
[0.5.2]: https://github.com/pjm17971/pond-ts/compare/v0.5.1...v0.5.2
|
|
3106
|
+
|
|
3107
|
+
## [0.5.1] — 2026-04-23
|
|
3108
|
+
|
|
3109
|
+
### Added
|
|
3110
|
+
|
|
3111
|
+
- **`TimeSeries.tail(duration?)`** — trailing temporal slice, the
|
|
3112
|
+
counterpart to `Array.slice(-n)`. Called with no argument, returns the
|
|
3113
|
+
whole series. Composes with every other `TimeSeries` method.
|
|
3114
|
+
- **`useCurrent` hook (`@pond-ts/react`)** — subscribes to a live source and
|
|
3115
|
+
returns the current value of a reducer mapping. Signature:
|
|
3116
|
+
`useCurrent(source, mapping, { tail?, throttle? })`. Stable-shape record
|
|
3117
|
+
even while the source is empty, so destructuring on first render is safe.
|
|
3118
|
+
|
|
3119
|
+
[0.5.1]: https://github.com/pjm17971/pond-ts/compare/v0.5.0...v0.5.1
|
|
3120
|
+
|
|
3121
|
+
## [0.5.0] — 2026-04-23
|
|
3122
|
+
|
|
3123
|
+
### Added
|
|
3124
|
+
|
|
3125
|
+
- **First-class `'array'` column kind.** New `ArrayValue = ReadonlyArray<ScalarValue>`
|
|
3126
|
+
and `ColumnValue = ScalarValue | ArrayValue` types. Array columns are inert
|
|
3127
|
+
with respect to numerical operators (`diff`, `rate`, `cumulative`,
|
|
3128
|
+
`rolling`-over-numbers skip them automatically via `NumericColumnNameForSchema`).
|
|
3129
|
+
- **`unique` reducer** — distinct sorted values; works in `reduce`,
|
|
3130
|
+
`aggregate`, and `rolling`. Flattens array-kind sources one level (set union
|
|
3131
|
+
across arrays in a bucket).
|
|
3132
|
+
- **`top(n)` reducer** — top N values by frequency with deterministic
|
|
3133
|
+
tie-break. String-pattern dispatch (`'top3'`, `'top10'`) parallel to `pNN`,
|
|
3134
|
+
plus a `top(n)` helper that returns the typed string literal. Incremental
|
|
3135
|
+
bucket + rolling state via a count map. Also flattens array-kind sources.
|
|
3136
|
+
- **Five array-prefixed operators on `TimeSeries`**:
|
|
3137
|
+
- `arrayContains(col, value)` — has this one
|
|
3138
|
+
- `arrayContainsAll(col, values)` — has every one (AND / subset)
|
|
3139
|
+
- `arrayContainsAny(col, values)` — has at least one (OR / intersection)
|
|
3140
|
+
- `arrayAggregate(col, reducer, { as?, kind? })` — per-event reduction
|
|
3141
|
+
reusing the full reducer registry (count, sum, avg, unique, custom, etc.).
|
|
3142
|
+
Replace in place or append via `as`.
|
|
3143
|
+
- `arrayExplode(col, { as?, kind? })` — fan each event out into one event
|
|
3144
|
+
per array element. Replace the array column or keep it alongside a scalar
|
|
3145
|
+
sibling.
|
|
3146
|
+
- **LiveSeries accepts `kind: 'array'`** on its schema with array cells
|
|
3147
|
+
frozen on push.
|
|
3148
|
+
- **JSON round-trip** for array cells works unchanged (toJSON / fromJSON
|
|
3149
|
+
pass arrays through naturally).
|
|
3150
|
+
- **Docs**: new `guides/arrays.mdx` reference page;
|
|
3151
|
+
`examples/error-rate-dashboard.mdx` scenario walkthrough backed 1:1 by an
|
|
3152
|
+
E2E test; `reducer-reference.mdx` expanded with concrete input/output
|
|
3153
|
+
examples for `unique` and `top(n)`.
|
|
3154
|
+
|
|
3155
|
+
### Changed
|
|
3156
|
+
|
|
3157
|
+
- **`reduce()` / `ReduceResult` / `CustomAggregateReducer` return types** widened
|
|
3158
|
+
from `ScalarValue | undefined` to `ColumnValue | undefined`. Narrowed
|
|
3159
|
+
annotations (`: number | undefined`) keep working; only callers with
|
|
3160
|
+
explicit `: ScalarValue | undefined` annotations need to widen.
|
|
3161
|
+
(v0.5.2 narrows these further per-entry.)
|
|
3162
|
+
|
|
3163
|
+
[0.5.0]: https://github.com/pjm17971/pond-ts/compare/v0.4.3...v0.5.0
|
|
3164
|
+
|
|
3165
|
+
## [0.4.3] — 2026-04-22
|
|
3166
|
+
|
|
3167
|
+
### Added
|
|
3168
|
+
|
|
3169
|
+
- `useLiveQuery` and `useLatest` hooks in `@pond-ts/react`.
|
|
3170
|
+
|
|
3171
|
+
### Fixed
|
|
3172
|
+
|
|
3173
|
+
- LiveView eviction mirroring (uses `EMITS_EVICT` symbol to safely detect
|
|
3174
|
+
evict-capable sources; avoids duck-typing that broke on `LiveAggregation`).
|
|
3175
|
+
- Type narrowing through `LiveAggregation` / `LiveRollingAggregation` via
|
|
3176
|
+
`Out` type parameter.
|
|
3177
|
+
- `Time.toDate()` convenience method.
|
|
3178
|
+
- `useWindow` under React StrictMode (view creation moved to `useEffect`).
|
|
3179
|
+
- `TimeSeries[Symbol.iterator]` and `toArray()` for ergonomic iteration.
|
|
3180
|
+
- `useSnapshot` accepts `SnapshotSource<S>` structural type (no casts for
|
|
3181
|
+
`LiveAggregation` input).
|
|
3182
|
+
|
|
3183
|
+
[0.4.3]: https://github.com/pjm17971/pond-ts/compare/v0.4.2...v0.4.3
|
|
3184
|
+
|
|
3185
|
+
## [0.4.2] — 2026-04-21
|
|
3186
|
+
|
|
3187
|
+
### Changed
|
|
3188
|
+
|
|
3189
|
+
- First release using npm OIDC Trusted Publisher (no stored tokens).
|
|
3190
|
+
|
|
3191
|
+
[0.4.2]: https://github.com/pjm17971/pond-ts/compare/v0.4.1...v0.4.2
|
|
3192
|
+
|
|
3193
|
+
## [0.4.1] — 2026-04-21
|
|
3194
|
+
|
|
3195
|
+
Administrative — no behavioral changes.
|
|
3196
|
+
|
|
3197
|
+
[0.4.1]: https://github.com/pjm17971/pond-ts/compare/v0.4.0...v0.4.1
|
|
3198
|
+
|
|
3199
|
+
## [0.4.0] — 2026-04-21
|
|
3200
|
+
|
|
3201
|
+
### Added
|
|
3202
|
+
|
|
3203
|
+
- **`@pond-ts/react` package** — React hooks for live series
|
|
3204
|
+
(`useLiveSeries`, `useTimeSeries`, `useSnapshot`, `useWindow`, `useDerived`,
|
|
3205
|
+
`takeSnapshot`). Monorepo restructure completed.
|
|
3206
|
+
- **LiveView + LiveSource composition** — `filter`, `map`, `select`,
|
|
3207
|
+
`window` views that compose with `LiveAggregation` / `LiveRollingAggregation`
|
|
3208
|
+
via a shared `LiveSource<S>` interface.
|
|
3209
|
+
- **Live per-event and carry-forward transforms** — `diff`, `rate`,
|
|
3210
|
+
`pctChange`, `fill`, `cumulative` available as LiveView variants.
|
|
3211
|
+
- **Grace period on `LiveAggregation`** — delays bucket closing so
|
|
3212
|
+
out-of-order events within a window accumulate into their correct bucket.
|
|
3213
|
+
Defaults from source `LiveSeries`'s `graceWindow`.
|
|
3214
|
+
- **Streaming dashboard example** with E2E tests.
|
|
3215
|
+
- **Benchmark suite** comparing `pond-ts` vs `pondjs`.
|
|
3216
|
+
|
|
3217
|
+
[0.4.0]: https://github.com/pjm17971/pond-ts/compare/v0.3.0...v0.4.0
|
|
3218
|
+
|
|
3219
|
+
## [0.3.0] — 2026-04-21
|
|
3220
|
+
|
|
3221
|
+
### Added
|
|
3222
|
+
|
|
3223
|
+
- **`LiveSeries`** — mutable, append-optimized streaming buffer sharing the
|
|
3224
|
+
same schema type as `TimeSeries`. Retention policies (`maxEvents`,
|
|
3225
|
+
`maxAge`, `maxBytes`). Synchronous subscriptions (`event`, `batch`,
|
|
3226
|
+
`evict`). Ordering modes (`strict`, `drop`, `reorder`).
|
|
3227
|
+
- **`LiveAggregation`** — incremental bucketed aggregation over a
|
|
3228
|
+
`LiveSource`.
|
|
3229
|
+
- **`LiveRollingAggregation`** — sliding-window reduction over a
|
|
3230
|
+
`LiveSource`.
|
|
3231
|
+
|
|
3232
|
+
[0.3.0]: https://github.com/pjm17971/pond-ts/compare/v0.2.0...v0.3.0
|
|
3233
|
+
|
|
3234
|
+
## [0.2.0] — 2026-04-16
|
|
3235
|
+
|
|
3236
|
+
### Added
|
|
3237
|
+
|
|
3238
|
+
- **Phase 2 batch expansion**: `reduce`, `groupBy`, `diff`, `rate`, `fill`.
|
|
3239
|
+
- **Phase 2.5 columnar primitives**: `pctChange`, `cumulative`, `shift`,
|
|
3240
|
+
`bfill` fill strategy.
|
|
3241
|
+
- **Aggregator parity with pondjs**: `median`, `stdev`, `percentile`
|
|
3242
|
+
(`pNN`), `difference`, `keep`.
|
|
3243
|
+
|
|
3244
|
+
[0.2.0]: https://github.com/pjm17971/pond-ts/compare/v0.1.4...v0.2.0
|
|
3245
|
+
|
|
3246
|
+
## [0.1.x] — 2026-04-16
|
|
3247
|
+
|
|
3248
|
+
Phase 0 (core performance) and Phase 1 (batch hardening) releases. Five
|
|
3249
|
+
critical O(N²) hot paths optimized (172× aggregate, 182× rolling, 15×
|
|
3250
|
+
movingAverage, 7.5× loess, 819× includesKey, 134× alignLinearAt).
|
|
3251
|
+
`toJSON`/`fromJSON` round-trip, custom aggregate reducers, edge-case
|
|
3252
|
+
coverage across every analytical primitive.
|
|
3253
|
+
|
|
3254
|
+
See [tag history](https://github.com/pjm17971/pond-ts/tags) for details.
|