@pond-ts/react 0.15.1 → 0.16.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.
Files changed (2) hide show
  1. package/CHANGELOG.md +230 -1
  2. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -7,10 +7,239 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
7
  file covers both packages. Pre-1.0: minor bumps may include new features and
8
8
  type-level changes; patch bumps are strictly additive.
9
9
 
10
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.15.1...HEAD
10
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.16.0...HEAD
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.16.0] — 2026-05-06
15
+
16
+ Live-API ergonomic wave. Four PRs:
17
+ [#122](https://github.com/pjm17971/pond-ts/pull/122) (buffer-as-window
18
+ Tier 1), [#123](https://github.com/pjm17971/pond-ts/pull/123)
19
+ (`stats()` accessor), [#124](https://github.com/pjm17971/pond-ts/pull/124)
20
+ (`history` option + compile-time fused uniqueness),
21
+ [#125](https://github.com/pjm17971/pond-ts/pull/125) (Tier 2 query
22
+ primitives). Strictly additive surface — no public-API removals or
23
+ narrowings.
24
+
25
+ ### Added
26
+
27
+ - **`live.reduce(mapping, opts?)`** on `LiveSeries` and `LiveView`
28
+ — streaming reduce over the source's current buffer. Mirrors
29
+ `series.reduce(mapping)` from batch but reactive: per-event
30
+ `add`, per-eviction `remove`, microtask-deferred trigger
31
+ emission so retention has run before the snapshot. Closes the
32
+ buffer-as-window persona's biggest ergonomic gap.
33
+ - **`live.timeRange()`** on `LiveSeries` and `LiveView` — O(1)
34
+ temporal extent of the current buffer (`undefined` when empty).
35
+ - **`live.eventRate()`** on `LiveSeries` and `LiveView` — O(1)
36
+ events-per-second over the buffer's time span (zero when fewer
37
+ than two events). Convenience over the existing
38
+ `view.eventRate()` shape; no window argument required.
39
+ - **`live.count()`** on `LiveSeries` (alias for `length`) for
40
+ parity with `LiveView.count()` and chainable composition with
41
+ `eventRate()`.
42
+ - **`stats()` accessor on every live accumulator/series.** Per-class
43
+ shapes, all returning a plain record (cumulative integer counters
44
+ + current-state fields):
45
+
46
+ | Class | Shape |
47
+ |---|---|
48
+ | LiveSeries | `{ ingested, evicted, rejected, length, earliestTs?, latestTs? }` |
49
+ | LiveRollingAggregation | `{ eventsObserved, evictions, emissions, windowSize }` |
50
+ | LiveFusedRolling | `{ eventsObserved, evictions, emissions, windowSize, windowsCount }` |
51
+ | LiveAggregation | `{ eventsObserved, bucketsClosed, openBuckets, openBucketStart? }` |
52
+ | LiveReduce | `{ eventsObserved, evictions, emissions, bufferSize }` |
53
+ | LivePartitionedSeries | `{ partitions, eventsRouted }` |
54
+ | LivePartitionedSyncRolling | `{ partitions, eventsObserved, emissions, windowSize }` |
55
+ | LivePartitionedFusedRolling | `{ partitions, eventsObserved, emissions, windowSize, windowsCount }` |
56
+
57
+ Per-event cost: ~1-3 integer increments in already-existing
58
+ handlers. `stats()` itself is O(1) — or O(partitions) for the
59
+ max-across-partitions `windowSize` on partitioned variants.
60
+ Polling-based by design — wall-clock timers inside pond would
61
+ break the data-is-the-clock invariant.
62
+
63
+ - **`history: false | RetentionPolicy` option on
64
+ `LiveRollingAggregation` and `LiveFusedRolling`** (and
65
+ partitioned variants — threaded through
66
+ `LivePartitionedSeries.rolling` end-to-end). Controls how much
67
+ of the rolling's emitted history the accumulator keeps in its
68
+ own output buffer (the one read by `length` / `at(i)`). Default
69
+ `true` preserves current behavior; `false` skips the push
70
+ entirely (`'event'` listeners and `value()` still work, but
71
+ `length` stays at 0); `RetentionPolicy` (`{ maxEvents?, maxAge? }`)
72
+ caps the buffer using the same shape as `LiveSeries.retention`.
73
+ Stricter validation: rejects 0, negative, or non-integer
74
+ `maxEvents`; `Infinity` is the documented "no cap" sentinel.
75
+
76
+ - **Compile-time uniqueness check on fused output columns**
77
+ (`FusedMappingValid<FM>`). Two windows declaring the same
78
+ output name now fail at the call site with a branded error
79
+ type naming the conflict. Wired into all four fused-rolling
80
+ overloads (LiveSeries, LiveView, root + view
81
+ LivePartitionedSeries). Runtime check still in place.
82
+
83
+ - **Tier 2 query primitives on `LiveSeries` and `LiveView`** —
84
+ pure parity additions mirroring `TimeSeries`:
85
+ - `find(pred)`, `some(pred)`, `every(pred)` — O(N) predicate query
86
+ - `includesKey(key)`, `bisect(key)`, `atOrBefore(key)`,
87
+ `atOrAfter(key)` — O(log N) binary search on the sorted buffer
88
+
89
+ Use cases: "is there already an event with key K?" / "what was
90
+ the most recent event before time T?" Both come up in dashboard
91
+ patterns where the live buffer IS the working set.
92
+
93
+ - **`KeyLike` type** exported from the package root (re-exported
94
+ from `TimeSeries`). Accepts `EventKey | TimestampInput |
95
+ TimeRangeInput | IntervalInput`; normalised by the new query
96
+ primitives.
97
+
98
+ - **`DurationLiteral` and `DurationUnit` types** extracted from
99
+ `utils/duration.ts` and exported. Same shape as before, just
100
+ named.
101
+
102
+ - **Concrete return types from partitioned rolling overloads.**
103
+ `LivePartitionedSeries.rolling` and `LivePartitionedView.rolling`
104
+ clock-trigger and fused-mapping overloads now return the concrete
105
+ `LivePartitionedSyncRolling` / `LivePartitionedFusedRolling`
106
+ classes (instead of bare `LiveSource<...>`), exposing `stats()`
107
+ to callers without a cast. Strictly additive — concrete classes
108
+ implement `LiveSource` plus `stats()`.
109
+
110
+ ### Changed
111
+
112
+ - **`LiveSeries.clear()`** now increments the `evicted` counter
113
+ on `stats()` to match the existing `'evict'` listener fan-out.
114
+ Previously cleared the buffer and fired listeners but didn't
115
+ update the counter.
116
+ - **`LiveSeries` insertion comparator** delegates to
117
+ `EventKey.compare` (was previously `begin/end` only). Affects
118
+ interval-keyed series with same-span / different-value
119
+ intervals: previously stored in arrival order — and broke
120
+ `bisect`/`includesKey` queries — now stored in value-ascending
121
+ order. Time-keyed and timeRange-keyed series unaffected.
122
+ - **`LiveView.map(fn)` runtime check** rejects re-keying maps
123
+ that produce non-monotonic outputs. Throws `ValidationError`
124
+ at append time rather than silently breaking the view's
125
+ sorted-buffer invariant (which Tier 2 query primitives rely
126
+ on). Sane transforms (data-only maps, monotonic time-shifts)
127
+ unaffected.
128
+ - **`LiveAggregationOptions.grace`** type tightened from
129
+ `DurationInput | \`${number}${unit}\`` (redundant union) to
130
+ just `DurationInput`. No behavioral change.
131
+
132
+ ### Notes
133
+
134
+ - React package (`@pond-ts/react`) version-bumped lock-step; no
135
+ hook surface changes in this release. New core hooks
136
+ (`useLiveReduce`, `useStats`, optional-window `useEventRate`)
137
+ are queued for a follow-up — see PLAN.md for the design.
138
+ - Codex caught real bugs on every Layer-2-reviewed PR in this
139
+ wave (1 HIGH + 1 MEDIUM on PR #123, 1 HIGH + 1 MEDIUM on
140
+ PR #124, 2 MEDIUM on PR #125). The Layer 2 + Codex two-pass
141
+ protocol earned its keep again.
142
+
143
+ ## [0.15.2] — 2026-05-06
144
+
145
+ Performance fix for live rolling at firehose rates. The gRPC
146
+ experiment's step 6
147
+ ([pond-grpc-experiment#26](https://github.com/pjm17971/pond-grpc-experiment/pull/26))
148
+ attempted to use the non-partitioned `live.rolling({...}, opts)`
149
+ overload for global counters and saw throughput collapse from 88k/s
150
+ to 21k/s — a 4× regression even worse than the V7→V6 gap that
151
+ motivated v0.15.0. The cliff is the same `Array.shift()` pattern
152
+ already flagged as queued tactical work in PLAN; the gRPC encounter
153
+ made it urgent.
154
+
155
+ ### Fixed
156
+
157
+ - **Eviction is now O(1) per ingest in all live rolling classes.**
158
+ Replaced `entries.shift()` (worst-case O(N) on the deque length)
159
+ with a head-index pointer + periodic batched compaction:
160
+ - `LiveFusedRolling.#compactFront` — non-partitioned multi-window
161
+ - `LivePartitionedFusedRolling.#compactPartitionFront` —
162
+ per-partition fused
163
+ - `LiveRollingAggregation.#removeFirst` — single-window
164
+ non-partitioned
165
+ - `LivePartitionedSyncRolling.#evictPartition` — per-partition
166
+ single-window synced
167
+
168
+ The pattern: track a `frontIdx` field; "evicting" advances the
169
+ pointer instead of shifting. When the dead prefix grows past
170
+ half the array length, batch-splice it off and reset the
171
+ pointer. Per-event cost stays O(1) amortized at every live-
172
+ window size — each surviving entry is copied at most once
173
+ between two compactions, and compactions fire at most every
174
+ (live-size) events.
175
+
176
+ An earlier draft also compacted on a fixed 1024-entry threshold;
177
+ Codex's adversarial review on PR #119 caught that this would
178
+ reintroduce O(live_size / 1024) per-eviction cost on large
179
+ windows (100k+ live entries) — the threshold would fire
180
+ repeatedly and copy the entire live slice each time. The
181
+ proportional guard alone has the right amortization invariant.
182
+
183
+ ### Performance
184
+
185
+ `packages/core/scripts/perf-fused-rolling.mjs` — new regression
186
+ scenario that reproduces the cliff (50k-event deque with continuous
187
+ eviction):
188
+
189
+ ```
190
+ Worst-case shift pattern (50s window, 50k fill + 50k evict):
191
+ median (ms) min (ms) max (ms)
192
+ pre-fix 1123.12 1118.47 1149.95
193
+ v0.15.2 53.00 52.34 53.56
194
+ speedup 21.2×
195
+
196
+ Steady-state deque, no eviction (5m window, 200k events):
197
+ median (ms) min (ms) max (ms)
198
+ pre-fix 91.28 89.84 97.04
199
+ v0.15.2 99.28 96.80 103.94
200
+ delta +9% (within noise)
201
+ ```
202
+
203
+ The fix targets the eviction-loop case specifically. Workloads with
204
+ no eviction (or rare eviction relative to ingest) see no change —
205
+ V8's internal hidden-offset optimization handles those well. The
206
+ cliff appears once eviction fires per-ingest at large deque size,
207
+ which is exactly the firehose-rolling shape.
208
+
209
+ ### Why the cliff was hidden
210
+
211
+ V8's `Array.shift()` is amortized O(1) for shift-heavy workloads up
212
+ to ~10k-element arrays — it maintains a hidden offset and only
213
+ periodically compacts. Beyond that size or with mixed access
214
+ patterns, the optimization breaks down and shift falls back to true
215
+ O(N) memcpy. The bench scales from 1k to 50k deque sizes and the
216
+ cliff appears around 30k-40k. Pond's tests pin behavior at small
217
+ window sizes; the cliff was invisible to the test suite, only
218
+ showed up under the gRPC experiment's firehose load.
219
+
220
+ ### What this unlocks
221
+
222
+ The agent's manual-counter workaround in `aggregator/src/aggregate.ts`
223
+ can now drop. The natural shape — a non-partitioned
224
+ `live.rolling({...}, { trigger })` over the firehose — is now
225
+ viable at the rates the experiment cares about. PLAN's
226
+ "`samples` reducer would exhibit a similar shape at firehose"
227
+ caveat also resolves: same fix in the same call sites covers
228
+ samples too.
229
+
230
+ ### Note for downstream consumers
231
+
232
+ This is a **strict-additive perf fix.** All output behavior is
233
+ preserved — same eviction order, same emission timing, same
234
+ snapshot values. The deque's internal representation changed
235
+ (`#entries[0]` may now be a logically-evicted entry until periodic
236
+ compaction); any downstream code reading `#entries` directly would
237
+ break, but those fields are private. Public APIs and types are
238
+ unchanged.
239
+
240
+ [0.16.0]: https://github.com/pjm17971/pond-ts/compare/v0.15.2...v0.16.0
241
+ [0.15.2]: https://github.com/pjm17971/pond-ts/compare/v0.15.1...v0.15.2
242
+
14
243
  ## [0.15.1] — 2026-05-05
15
244
 
16
245
  Type-narrowing follow-up to v0.15.0. The fused partitioned-rolling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/react",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "React hooks for pond-ts live time series",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "test:runtime": "vitest run"
32
32
  },
33
33
  "peerDependencies": {
34
- "pond-ts": "^0.15.0",
34
+ "pond-ts": "^0.16.0",
35
35
  "react": "^18.0.0 || ^19.0.0"
36
36
  },
37
37
  "devDependencies": {