@tabnas/transduce 0.1.0 → 0.1.2

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/src/route.ts ADDED
@@ -0,0 +1,275 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // Captures: recognize selected scopes and deliver them, complete, in
4
+ // source order.
5
+ //
6
+ // A `Router` is a `Sink` that feeds every event through one `Matcher` for
7
+ // all of its `CaptureSpec`s and hands each completed match to a
8
+ // `RouteSink`. A `materialize` capture is built into a `Datum` under a
9
+ // byte budget and delivered when its value completes; an `observe` capture
10
+ // delivers only the path, at the value's end, and retains nothing.
11
+ // Materialized captures may not nest or coincide: the router holds at most
12
+ // one value at a time, which is what makes its retention one selected
13
+ // scope rather than a document. That is checked at construction,
14
+ // conservatively, with `Selector.mayOverlap`, and again at run time so a
15
+ // stream the check could not foresee still fails rather than mixing two
16
+ // values.
17
+
18
+ import { Datum, DatumBuilder, Duplicates } from './datum'
19
+ import { Fail } from './error'
20
+ import { JsonEvent } from './event'
21
+ import { Limits, Metrics } from './limits'
22
+ import { CaptureId, Matcher } from './matcher'
23
+ import { Path, Selector } from './selector'
24
+ import { Flow, Sink } from './sink'
25
+
26
+ // What a capture keeps of its match: build the value and deliver it whole
27
+ // (`materialize`), or deliver only that the value occurred, and where,
28
+ // when it ends (`observe`).
29
+ export type CaptureMode = 'materialize' | 'observe'
30
+
31
+ // The byte budget one materialized capture may not exceed, named after the
32
+ // `Limits` field the failure reports.
33
+ export type Budget = { bytes: number; name: string }
34
+
35
+ // One capture: a tag for the consumer, the selector to match, the mode,
36
+ // and for a `materialize` capture an optional budget (else the router's
37
+ // `max_capture_bytes`).
38
+ export class CaptureSpec {
39
+ readonly tag: string
40
+ readonly selector: Selector
41
+ readonly mode: CaptureMode
42
+ budget: Budget | null = null
43
+
44
+ constructor(tag: string, selector: Selector, mode: CaptureMode) {
45
+ this.tag = tag
46
+ this.selector = selector
47
+ this.mode = mode
48
+ }
49
+
50
+ static materialize(tag: string, selector: Selector): CaptureSpec {
51
+ return new CaptureSpec(tag, selector, 'materialize')
52
+ }
53
+
54
+ static observe(tag: string, selector: Selector): CaptureSpec {
55
+ return new CaptureSpec(tag, selector, 'observe')
56
+ }
57
+
58
+ // This spec with its own budget, whose failure names `name`.
59
+ withBudget(bytes: number, name: string): CaptureSpec {
60
+ this.budget = { bytes, name }
61
+ return this
62
+ }
63
+ }
64
+
65
+ // One completed match: the spec's position in the router's list, its tag,
66
+ // the concrete path, and the value for a `materialize` capture (`null`
67
+ // for `observe`).
68
+ export type Selected = {
69
+ id: CaptureId
70
+ tag: string
71
+ path: Path
72
+ value: Datum | null
73
+ }
74
+
75
+ // The consumer of a router's matches.
76
+ export interface RouteSink {
77
+ // A capture's value is beginning. Nothing has been retained for it yet,
78
+ // so a consumer that knows the value is out of order can refuse it here
79
+ // at no cost (throw a `Fail`); the router adds the path to a failure
80
+ // that has none.
81
+ began?(id: CaptureId, tag: string): void
82
+
83
+ selected(selected: Selected): Flow
84
+
85
+ // The document ended, validated. Exactly once, after the last match.
86
+ end(): Flow
87
+ }
88
+
89
+ // A route sink that records every match.
90
+ export class SelectedRecorder implements RouteSink {
91
+ readonly selections: Selected[] = []
92
+ ended = false
93
+
94
+ selected(selected: Selected): Flow {
95
+ this.selections.push(selected)
96
+ return 'continue'
97
+ }
98
+
99
+ end(): Flow {
100
+ this.ended = true
101
+ return 'continue'
102
+ }
103
+ }
104
+
105
+ // A route sink made of a function; `end` continues.
106
+ export class FnRoute implements RouteSink {
107
+ private fn: (selected: Selected) => Flow | void
108
+
109
+ constructor(fn: (selected: Selected) => Flow | void) {
110
+ this.fn = fn
111
+ }
112
+
113
+ selected(selected: Selected): Flow {
114
+ return this.fn(selected) || 'continue'
115
+ }
116
+
117
+ end(): Flow {
118
+ return 'continue'
119
+ }
120
+ }
121
+
122
+ // The one materialization in progress.
123
+ type Active = {
124
+ id: CaptureId
125
+ builder: DatumBuilder
126
+ // The depth its value began at.
127
+ depth: number
128
+ }
129
+
130
+ // Recognizes every capture in one pass and delivers completed matches.
131
+ export class Router<D extends RouteSink> implements Sink {
132
+ readonly specs: readonly CaptureSpec[]
133
+ readonly downstream: D
134
+ private matcher: Matcher
135
+ private active: Active | null = null
136
+ // Observed values in progress: `[capture, depth]`, innermost last.
137
+ private observing: [CaptureId, number][] = []
138
+ private maxDepth: number
139
+ private maxCaptureBytes: number
140
+ private duplicates: Duplicates
141
+ // For the capture accounting only (`captured_bytes` and its high-water
142
+ // marks). The source counts events, keys and scalars.
143
+ private metrics: Metrics
144
+ private isEnded = false
145
+
146
+ // Build a router; two specs that may overlap are refused unless both
147
+ // observe (`CAPTURE_OVERLAP_UNSUPPORTED`).
148
+ constructor(
149
+ specs: readonly CaptureSpec[],
150
+ limits: Limits,
151
+ duplicates: Duplicates,
152
+ metrics: Metrics,
153
+ downstream: D,
154
+ ) {
155
+ for (let i = 0; i < specs.length; i++) {
156
+ for (let j = i + 1; j < specs.length; j++) {
157
+ const a = specs[i]
158
+ const b = specs[j]
159
+ const bothObserve = 'observe' === a.mode && 'observe' === b.mode
160
+ if (!bothObserve && a.selector.mayOverlap(b.selector)) {
161
+ throw new Fail(
162
+ 'CAPTURE_OVERLAP_UNSUPPORTED',
163
+ `captures ${JSON.stringify(a.tag)} (${a.selector}) and ` +
164
+ `${JSON.stringify(b.tag)} (${b.selector}) may select overlapping scopes; ` +
165
+ `only observed captures may overlap`,
166
+ )
167
+ }
168
+ }
169
+ }
170
+ this.specs = specs.slice()
171
+ this.matcher = new Matcher(specs.map((s) => s.selector))
172
+ this.downstream = downstream
173
+ this.maxDepth = limits.max_depth
174
+ this.maxCaptureBytes = limits.max_capture_bytes
175
+ this.duplicates = duplicates
176
+ this.metrics = metrics
177
+ }
178
+
179
+ // Whether `end` has been delivered.
180
+ ended(): boolean {
181
+ return this.isEnded
182
+ }
183
+
184
+ // Start the capture `id` at the value beginning at `depth`.
185
+ private begin(id: CaptureId, depth: number): void {
186
+ const spec = this.specs[id]
187
+ if (this.downstream.began) {
188
+ try {
189
+ this.downstream.began(id, spec.tag)
190
+ } catch (err) {
191
+ if (err instanceof Fail && null == err.path) {
192
+ err.path = this.matcher.path(depth).toString()
193
+ }
194
+ throw err
195
+ }
196
+ }
197
+ if ('observe' === spec.mode) {
198
+ this.observing.push([id, depth])
199
+ return
200
+ }
201
+ if (null !== this.active) {
202
+ const path = this.matcher.path(depth).toString()
203
+ throw new Fail(
204
+ 'CAPTURE_OVERLAP_UNSUPPORTED',
205
+ `capture ${JSON.stringify(spec.tag)} began at ${path} while capture ` +
206
+ `${JSON.stringify(this.specs[this.active.id].tag)} was still being materialized`,
207
+ ).atPath(path)
208
+ }
209
+ const budget = spec.budget ?? { bytes: this.maxCaptureBytes, name: 'max_capture_bytes' }
210
+ this.active = {
211
+ id,
212
+ builder: new DatumBuilder(budget.bytes, budget.name, this.duplicates),
213
+ depth,
214
+ }
215
+ }
216
+
217
+ private deliver(id: CaptureId, path: Path, value: Datum | null): Flow {
218
+ return this.downstream.selected({ id, tag: this.specs[id].tag, path, value })
219
+ }
220
+
221
+ event(ev: JsonEvent): Flow {
222
+ const hit = this.matcher.event(ev)
223
+ if ('start' === hit.kind && hit.depth + 1 > this.maxDepth) {
224
+ const path = this.matcher.path(hit.depth).toString()
225
+ throw Fail.limit(
226
+ 'max_depth',
227
+ this.maxDepth,
228
+ `a container at ${path} is nested deeper than ${this.maxDepth}`,
229
+ ).atPath(path)
230
+ }
231
+ for (let k = 0; k < hit.begins; k++) {
232
+ this.begin(this.matcher.begins()[k], hit.depth)
233
+ }
234
+ const active = this.active
235
+ if (null !== active) {
236
+ try {
237
+ active.builder.event(ev)
238
+ } catch (err) {
239
+ // The builder reports no position; the value's path is spelled
240
+ // here, only now that something went wrong.
241
+ if (err instanceof Fail) err.atPath(this.matcher.path(active.depth).toString())
242
+ throw err
243
+ }
244
+ if (active.builder.finished()) {
245
+ const bytes = active.builder.bytes()
246
+ const value = active.builder.take() as Datum
247
+ this.active = null
248
+ const path = this.matcher.path(hit.depth)
249
+ this.metrics.capture(bytes)
250
+ let flow: Flow
251
+ try {
252
+ flow = this.deliver(active.id, path, value)
253
+ } finally {
254
+ this.metrics.release(bytes)
255
+ }
256
+ if ('stop' === flow) return 'stop'
257
+ }
258
+ }
259
+ if ('scalar' === hit.kind || 'close' === hit.kind) {
260
+ while (0 < this.observing.length) {
261
+ const [id, depth] = this.observing[this.observing.length - 1]
262
+ if (depth !== hit.depth) break
263
+ this.observing.pop()
264
+ if ('stop' === this.deliver(id, this.matcher.path(hit.depth), null)) {
265
+ return 'stop'
266
+ }
267
+ }
268
+ }
269
+ if ('end' === hit.kind) {
270
+ this.isEnded = true
271
+ return this.downstream.end()
272
+ }
273
+ return 'continue'
274
+ }
275
+ }