@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/error.ts ADDED
@@ -0,0 +1,198 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // Failures, with stable codes.
4
+ //
5
+ // The code is the contract: scripts and agents branch on it, and every
6
+ // renderer, transducer and host uses the same set. The message, path and
7
+ // limit are informative. A code is never renamed, removed or repurposed;
8
+ // one may be added.
9
+
10
+ const CODES = [
11
+ 'DSL_PARSE_ERROR',
12
+ 'DSL_TYPE_ERROR',
13
+ 'STREAM_REUSED',
14
+ 'STREAMABILITY_UNKNOWN',
15
+ 'INPUT_ORDER_VIOLATION',
16
+ 'CAPTURE_OVERLAP_UNSUPPORTED',
17
+ 'MISSING_VALUE',
18
+ 'DUPLICATE_MEMBER',
19
+ 'INVALID_NUMBER',
20
+ 'PROTOCOL_ORDER_ERROR',
21
+ 'TARGET_VALUE_UNREPRESENTABLE',
22
+ 'RESOURCE_LIMIT_EXCEEDED',
23
+ 'INPUT_INVALID',
24
+ 'OUTPUT_FAILED',
25
+ 'ABORTED',
26
+ ] as const
27
+
28
+ // One of the stable failure codes, written in `SCREAMING_SNAKE_CASE`.
29
+ export type Code = (typeof CODES)[number]
30
+
31
+ type CodeTable = { readonly [K in Code]: K } & {
32
+ // Every code, in declaration order (the Rust `Code::ALL`).
33
+ readonly ALL: readonly Code[]
34
+ // A code by its written form, or `undefined`.
35
+ parse(text: string): Code | undefined
36
+ }
37
+
38
+ const table: any = {}
39
+ for (const c of CODES) table[c] = c
40
+ table.ALL = Object.freeze([...CODES])
41
+ table.parse = (text: string): Code | undefined =>
42
+ (CODES as readonly string[]).includes(text) ? (text as Code) : undefined
43
+
44
+ // The codes as constants: `Code.INPUT_INVALID`, and `Code.ALL`.
45
+ export const Code: CodeTable = Object.freeze(table)
46
+
47
+ // The limit a `RESOURCE_LIMIT_EXCEEDED` failure names.
48
+ export type Limit = {
49
+ // The `Limits` field, as written there (`max_record_bytes`).
50
+ name: string
51
+ value: number
52
+ }
53
+
54
+ // A failure: a code, and what is known about where and why.
55
+ //
56
+ // `committedOutput` says whether bytes had already been written when the
57
+ // failure was found: an incremental export cannot take them back, and the
58
+ // caller must be told the output may be partial.
59
+ export class Fail extends Error {
60
+ code: Code
61
+ // The input path the failure concerns, in jq syntax, when one applies.
62
+ path?: string
63
+ limit?: Limit
64
+ // 1-based source row and column, when the failure has a position.
65
+ row?: number
66
+ col?: number
67
+ // The source file the position is in, when the program the failure came
68
+ // from was compiled from several.
69
+ file?: string
70
+ committedOutput: boolean
71
+
72
+ constructor(code: Code, message: string) {
73
+ super(message)
74
+ this.name = 'Fail'
75
+ this.code = code
76
+ this.committedOutput = false
77
+ }
78
+
79
+ // The failure with the source file its position is in.
80
+ inFile(file: string): this {
81
+ this.file = file
82
+ return this
83
+ }
84
+
85
+ atPath(path: string): this {
86
+ this.path = path
87
+ return this
88
+ }
89
+
90
+ at(row: number, col: number): this {
91
+ this.row = row
92
+ this.col = col
93
+ return this
94
+ }
95
+
96
+ committed(): this {
97
+ this.committedOutput = true
98
+ return this
99
+ }
100
+
101
+ // A limit failure, named after the `Limits` field that was passed.
102
+ static limit(name: string, value: number, message: string): Fail {
103
+ const f = new Fail('RESOURCE_LIMIT_EXCEEDED', message)
104
+ f.limit = { name, value }
105
+ return f
106
+ }
107
+
108
+ static protocol(message: string): Fail {
109
+ return new Fail('PROTOCOL_ORDER_ERROR', message)
110
+ }
111
+
112
+ static input(message: string): Fail {
113
+ return new Fail('INPUT_INVALID', message)
114
+ }
115
+
116
+ static output(message: string): Fail {
117
+ return new Fail('OUTPUT_FAILED', message)
118
+ }
119
+
120
+ static aborted(): Fail {
121
+ return new Fail('ABORTED', 'the run was cancelled')
122
+ }
123
+
124
+ // The engine's own error, as an input failure carrying its code,
125
+ // position and report.
126
+ static fromTabnas(e: any): Fail {
127
+ const f = new Fail('INPUT_INVALID', `${engineCode(e)}: ${engineDetail(e)}`)
128
+ const pos = enginePosition(e)
129
+ if (pos) {
130
+ f.row = pos.row
131
+ f.col = pos.col
132
+ }
133
+ return f
134
+ }
135
+
136
+ // The failure as a plain object: `code`, `message`, and `path`, `limit`
137
+ // (`{name, value}`), `row`, `col`, `file`, `output` ("partial" or
138
+ // "none") when they apply. This is the shape hosts print.
139
+ toJSON(): Record<string, unknown> {
140
+ const out: Record<string, unknown> = {
141
+ code: this.code,
142
+ message: this.message,
143
+ }
144
+ if (null != this.path) out.path = this.path
145
+ if (null != this.limit) out.limit = { name: this.limit.name, value: this.limit.value }
146
+ if (null != this.row) out.row = this.row
147
+ if (null != this.col) out.col = this.col
148
+ if (null != this.file) out.file = this.file
149
+ out.output = this.committedOutput ? 'partial' : 'none'
150
+ return out
151
+ }
152
+
153
+ toString(): string {
154
+ let s = `${this.code}: ${this.message}`
155
+ if (null != this.path) s += ` at ${this.path}`
156
+ if (null != this.file && null != this.row && null != this.col) {
157
+ s += ` (${this.file}:${this.row}:${this.col})`
158
+ } else if (null != this.row && null != this.col) {
159
+ s += ` (${this.row}:${this.col})`
160
+ } else if (null != this.file) {
161
+ s += ` (in ${this.file})`
162
+ }
163
+ if (null != this.limit) s += ` [${this.limit.name} = ${this.limit.value}]`
164
+ return s
165
+ }
166
+ }
167
+
168
+ // Whether a thrown value is a `Fail`.
169
+ export function isFail(e: unknown): e is Fail {
170
+ return e instanceof Fail
171
+ }
172
+
173
+ // The engine error's code (`unexpected`, `cancel`, ...).
174
+ export function engineCode(e: any): string {
175
+ return 'string' === typeof e?.code ? e.code : 'unknown'
176
+ }
177
+
178
+ // The engine error's one-line description, without the code.
179
+ export function engineDetail(e: any): string {
180
+ let detail = ''
181
+ try {
182
+ if ('function' === typeof e?.toJSON) detail = e.toJSON()?.message ?? ''
183
+ } catch (_err) {
184
+ detail = ''
185
+ }
186
+ if ('' === detail) detail = String(e?.message ?? e)
187
+ return detail.trimEnd()
188
+ }
189
+
190
+ // The engine error's 1-based position, when it has one.
191
+ export function enginePosition(e: any): { row: number; col: number } | undefined {
192
+ const row = e?.lineNumber
193
+ const col = e?.columnNumber
194
+ if ('number' === typeof row && 0 < row) {
195
+ return { row, col: 'number' === typeof col ? col : 0 }
196
+ }
197
+ return undefined
198
+ }
package/src/event.ts ADDED
@@ -0,0 +1,134 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // `JsonEvents/1`: the source protocol every transducer consumes.
4
+ //
5
+ // A source (a tabnas parse, a parsed value, a line-delimited reader) emits
6
+ // the events of one document in order: container boundaries, keys, whole
7
+ // scalars, and one `end` after the root value. Events are plain frozen
8
+ // objects, so a recorder keeps them as they are and a stage that keeps
9
+ // anything keeps the event itself.
10
+ //
11
+ // Scalars arrive whole because the tabnas lexer produces whole tokens. A
12
+ // later `JsonEvents/2` may chunk strings; it will be a separately named
13
+ // protocol, never a change to this one.
14
+
15
+ // One event of `JsonEvents/1`, a tagged union on `type`.
16
+ //
17
+ // `number` carries the machine value and, when the source could hand it
18
+ // over, its `lexeme` (the source text), else `null`; a renderer falls back
19
+ // to the value. Keeping both is how `50.25` stays `50.25` and how a number
20
+ // beyond a double's exact range keeps its digits.
21
+ export type JsonEvent =
22
+ | { readonly type: 'object_start' }
23
+ | { readonly type: 'object_end' }
24
+ | { readonly type: 'array_start' }
25
+ | { readonly type: 'array_end' }
26
+ | { readonly type: 'key'; readonly key: string }
27
+ | { readonly type: 'null' }
28
+ | { readonly type: 'bool'; readonly value: boolean }
29
+ | { readonly type: 'number'; readonly value: number; readonly lexeme: string | null }
30
+ | { readonly type: 'string'; readonly value: string }
31
+ | { readonly type: 'end' }
32
+
33
+ export type JsonEventType = JsonEvent['type']
34
+
35
+ // The events without a payload are shared, so emitting one allocates
36
+ // nothing.
37
+ const OBJECT_START: JsonEvent = Object.freeze({ type: 'object_start' })
38
+ const OBJECT_END: JsonEvent = Object.freeze({ type: 'object_end' })
39
+ const ARRAY_START: JsonEvent = Object.freeze({ type: 'array_start' })
40
+ const ARRAY_END: JsonEvent = Object.freeze({ type: 'array_end' })
41
+ const NULL: JsonEvent = Object.freeze({ type: 'null' })
42
+ const TRUE: JsonEvent = Object.freeze({ type: 'bool', value: true })
43
+ const FALSE: JsonEvent = Object.freeze({ type: 'bool', value: false })
44
+ const END: JsonEvent = Object.freeze({ type: 'end' })
45
+
46
+ // Constructors for every event.
47
+ export const Ev = Object.freeze({
48
+ objectStart: OBJECT_START,
49
+ objectEnd: OBJECT_END,
50
+ arrayStart: ARRAY_START,
51
+ arrayEnd: ARRAY_END,
52
+ null: NULL,
53
+ end: END,
54
+ key(key: string): JsonEvent {
55
+ return { type: 'key', key }
56
+ },
57
+ bool(value: boolean): JsonEvent {
58
+ return value ? TRUE : FALSE
59
+ },
60
+ number(value: number, lexeme?: string | null): JsonEvent {
61
+ return { type: 'number', value, lexeme: lexeme ?? null }
62
+ },
63
+ string(value: string): JsonEvent {
64
+ return { type: 'string', value }
65
+ },
66
+ })
67
+
68
+ // Whether the event opens a container.
69
+ export function isStart(ev: JsonEvent): boolean {
70
+ return 'object_start' === ev.type || 'array_start' === ev.type
71
+ }
72
+
73
+ // Whether the event closes a container.
74
+ export function isEnd(ev: JsonEvent): boolean {
75
+ return 'object_end' === ev.type || 'array_end' === ev.type
76
+ }
77
+
78
+ // Whether the event is a whole scalar value.
79
+ export function isScalar(ev: JsonEvent): boolean {
80
+ return (
81
+ 'null' === ev.type ||
82
+ 'bool' === ev.type ||
83
+ 'number' === ev.type ||
84
+ 'string' === ev.type
85
+ )
86
+ }
87
+
88
+ // Two events are equal: same type and payload. Numbers compare by
89
+ // `Object.is` (so `-0` is not `0`) and lexeme.
90
+ export function eventEquals(a: JsonEvent, b: JsonEvent): boolean {
91
+ if (a.type !== b.type) return false
92
+ switch (a.type) {
93
+ case 'key':
94
+ return a.key === (b as any).key
95
+ case 'bool':
96
+ case 'string':
97
+ return a.value === (b as any).value
98
+ case 'number':
99
+ return (
100
+ (Object.is(a.value, (b as any).value) ||
101
+ (Number.isNaN(a.value) && Number.isNaN((b as any).value))) &&
102
+ a.lexeme === (b as any).lexeme
103
+ )
104
+ default:
105
+ return true
106
+ }
107
+ }
108
+
109
+ // The event as short text, for messages: `{`, `}`, `[`, `]`, `key "a"`,
110
+ // a scalar as JSON, `end`.
111
+ export function eventText(ev: JsonEvent): string {
112
+ switch (ev.type) {
113
+ case 'object_start':
114
+ return '{'
115
+ case 'object_end':
116
+ return '}'
117
+ case 'array_start':
118
+ return '['
119
+ case 'array_end':
120
+ return ']'
121
+ case 'key':
122
+ return 'key ' + JSON.stringify(ev.key)
123
+ case 'null':
124
+ return 'null'
125
+ case 'bool':
126
+ return String(ev.value)
127
+ case 'number':
128
+ return null != ev.lexeme ? ev.lexeme : String(ev.value)
129
+ case 'string':
130
+ return JSON.stringify(ev.value)
131
+ case 'end':
132
+ return 'end'
133
+ }
134
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,112 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // The source-side limits, applied to events as they are produced.
4
+ //
5
+ // Every source emits through a `Guarded` sink, so the three limits a source
6
+ // owns (`max_depth`, `max_key_bytes`, `max_scalar_bytes`) are checked once,
7
+ // in one place, whether the events come from a live parse or from a walk
8
+ // over a parsed value; the abort flag is polled per event so a walk stops
9
+ // as promptly as a parse does; and the source metrics (events, keys,
10
+ // scalars) are counted locally and flushed in one step.
11
+
12
+ import { Fail } from './error'
13
+ import { JsonEvent } from './event'
14
+ import { AbortFlag, Limits, Metrics, utf8Bytes } from './limits'
15
+ import { Flow, Sink } from './sink'
16
+
17
+ // A sink wrapper that enforces the source limits and counts.
18
+ export class Guarded<S extends Sink> implements Sink {
19
+ readonly inner: S
20
+ private maxDepth: number
21
+ private maxKeyBytes: number
22
+ private maxScalarBytes: number
23
+ private abort: AbortFlag
24
+ private metrics: Metrics
25
+ private open = 0
26
+ private events = 0
27
+ private keys = 0
28
+ private scalars = 0
29
+
30
+ constructor(inner: S, limits: Limits, abort: AbortFlag, metrics: Metrics) {
31
+ this.inner = inner
32
+ this.maxDepth = limits.max_depth
33
+ this.maxKeyBytes = limits.max_key_bytes
34
+ this.maxScalarBytes = limits.max_scalar_bytes
35
+ this.abort = abort
36
+ this.metrics = metrics
37
+ }
38
+
39
+ // Add the counts so far to the shared metrics and start again.
40
+ flush(): void {
41
+ this.metrics.events += this.events
42
+ this.metrics.keys += this.keys
43
+ this.metrics.scalars += this.scalars
44
+ this.events = 0
45
+ this.keys = 0
46
+ this.scalars = 0
47
+ }
48
+
49
+ // Open containers right now.
50
+ depth(): number {
51
+ return this.open
52
+ }
53
+
54
+ private scalar(bytes: number): void {
55
+ this.scalars++
56
+ if (bytes > this.maxScalarBytes) {
57
+ throw Fail.limit(
58
+ 'max_scalar_bytes',
59
+ this.maxScalarBytes,
60
+ `a scalar of ${bytes} bytes is larger than ${this.maxScalarBytes}`,
61
+ )
62
+ }
63
+ }
64
+
65
+ event(ev: JsonEvent): Flow {
66
+ if (this.abort.isAborted()) throw Fail.aborted()
67
+ this.events++
68
+ switch (ev.type) {
69
+ case 'object_start':
70
+ case 'array_start':
71
+ this.open++
72
+ if (this.open > this.maxDepth) {
73
+ throw Fail.limit(
74
+ 'max_depth',
75
+ this.maxDepth,
76
+ `a container is nested deeper than ${this.maxDepth}`,
77
+ )
78
+ }
79
+ break
80
+ case 'object_end':
81
+ case 'array_end':
82
+ this.open = Math.max(0, this.open - 1)
83
+ break
84
+ case 'key': {
85
+ this.keys++
86
+ const n = utf8Bytes(ev.key)
87
+ if (n > this.maxKeyBytes) {
88
+ throw Fail.limit(
89
+ 'max_key_bytes',
90
+ this.maxKeyBytes,
91
+ `a key of ${n} bytes is longer than ${this.maxKeyBytes}`,
92
+ )
93
+ }
94
+ break
95
+ }
96
+ case 'string':
97
+ this.scalar(utf8Bytes(ev.value))
98
+ break
99
+ case 'number':
100
+ this.scalar(null != ev.lexeme ? utf8Bytes(ev.lexeme) : 0)
101
+ break
102
+ case 'null':
103
+ case 'bool':
104
+ this.scalar(0)
105
+ break
106
+ case 'end':
107
+ this.flush()
108
+ break
109
+ }
110
+ return this.inner.event(ev)
111
+ }
112
+ }
package/src/json.ts ADDED
@@ -0,0 +1,82 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // JSON text: the escaping and number spelling every runtime shares.
4
+
5
+ // `s` as a JSON string literal, escaped as RFC 8259 requires: `"` and `\`
6
+ // escaped, the short forms `\b \f \n \r \t`, other control characters as
7
+ // `\u00XX` with lowercase hex, and everything else as itself.
8
+ export function jsonString(s: string): string {
9
+ let out = '"'
10
+ let from = 0
11
+ for (let i = 0; i < s.length; i++) {
12
+ const c = s.charCodeAt(i)
13
+ if (c >= 0x20 && c !== 0x22 && c !== 0x5c) continue
14
+ if (i > from) out += s.slice(from, i)
15
+ from = i + 1
16
+ switch (c) {
17
+ case 0x22:
18
+ out += '\\"'
19
+ break
20
+ case 0x5c:
21
+ out += '\\\\'
22
+ break
23
+ case 0x08:
24
+ out += '\\b'
25
+ break
26
+ case 0x0c:
27
+ out += '\\f'
28
+ break
29
+ case 0x0a:
30
+ out += '\\n'
31
+ break
32
+ case 0x0d:
33
+ out += '\\r'
34
+ break
35
+ case 0x09:
36
+ out += '\\t'
37
+ break
38
+ default:
39
+ out += '\\u' + c.toString(16).padStart(4, '0')
40
+ }
41
+ }
42
+ if (from < s.length) out += s.slice(from)
43
+ return out + '"'
44
+ }
45
+
46
+ // A number with no lexeme, as text: the shortest digits that read back as
47
+ // the same double, written positionally, never with an exponent (`1e21` is
48
+ // `1000000000000000000000`, `1e-7` is `0.0000001`, `-0` is `-0`). This is
49
+ // the spelling the Rust runtime's `f64` `Display` gives, so a container
50
+ // cell holding such a number reads the same in every runtime;
51
+ // `DIVERGENCE.md` keeps the decision open. A non-finite value has no JSON
52
+ // form and is `null`.
53
+ export function numberText(value: number): string {
54
+ if (!Number.isFinite(value)) return 'null'
55
+ if (Object.is(value, -0)) return '-0'
56
+ if (0 === value) return '0'
57
+ const neg = value < 0
58
+ const exp = Math.abs(value).toExponential()
59
+ const at = exp.indexOf('e')
60
+ const digits = exp.slice(0, at).replace('.', '')
61
+ const e = parseInt(exp.slice(at + 1), 10)
62
+ let text: string
63
+ if (e >= digits.length - 1) {
64
+ text = digits + '0'.repeat(e - (digits.length - 1))
65
+ } else if (e >= 0) {
66
+ text = digits.slice(0, e + 1) + '.' + digits.slice(e + 1)
67
+ } else {
68
+ text = '0.' + '0'.repeat(-e - 1) + digits
69
+ }
70
+ return neg ? '-' + text : text
71
+ }
72
+
73
+ // A number as JSON text: its lexeme when known, else `numberText`.
74
+ export function jsonNumber(value: number, lexeme: string | null | undefined): string {
75
+ return null != lexeme ? lexeme : numberText(value)
76
+ }
77
+
78
+ // RFC 8259's number grammar: `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`.
79
+ // Only such a lexeme is kept, so a renderer can write it as it stands.
80
+ export function isJsonNumber(s: string): boolean {
81
+ return /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/.test(s)
82
+ }
package/src/limits.ts ADDED
@@ -0,0 +1,165 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // Limits, metrics and cancellation.
4
+ //
5
+ // Limits are part of a plan, not advice: a stage that would exceed one
6
+ // fails with `RESOURCE_LIMIT_EXCEEDED` naming the field, rather than
7
+ // switching to a slower or larger algorithm. Values are payload bytes
8
+ // (UTF-8 lengths of keys and scalars, and the sum of them for a retained
9
+ // value plus a fixed per-node allowance), which is a stable and portable
10
+ // measure, the same in every runtime; they are not a heap measurement. A
11
+ // JavaScript string is UTF-16, so every count here goes through
12
+ // `utf8Bytes`, never `length`.
13
+
14
+ // Per-run limits. Every field is named in a failure as written here, which
15
+ // is why the fields keep the snake_case names every runtime shares.
16
+ export type Limits = {
17
+ // Container nesting the source may reach.
18
+ max_depth: number
19
+ // Bytes in one object key.
20
+ max_key_bytes: number
21
+ // Bytes in one scalar (a string's text, a number's lexeme).
22
+ max_scalar_bytes: number
23
+ // Bytes retained for metadata (the column descriptors).
24
+ max_metadata_bytes: number
25
+ // Columns a schema may declare.
26
+ max_columns: number
27
+ // Bytes retained for one row before projection.
28
+ max_record_bytes: number
29
+ // Bytes retained for any one materialized capture.
30
+ max_capture_bytes: number
31
+ // Bytes the run may write; `null` for no limit.
32
+ max_output_bytes: number | null
33
+ }
34
+
35
+ // The names of the `Limits` fields, in declaration order.
36
+ export const LIMIT_NAMES: readonly (keyof Limits)[] = Object.freeze([
37
+ 'max_depth',
38
+ 'max_key_bytes',
39
+ 'max_scalar_bytes',
40
+ 'max_metadata_bytes',
41
+ 'max_columns',
42
+ 'max_record_bytes',
43
+ 'max_capture_bytes',
44
+ 'max_output_bytes',
45
+ ])
46
+
47
+ export const Limits = Object.freeze({
48
+ // The defaults: 256, 64 KiB, 16 MiB, 16 MiB, 10 000, 64 MiB, 64 MiB, none.
49
+ default(): Limits {
50
+ return {
51
+ max_depth: 256,
52
+ max_key_bytes: 64 * 1024,
53
+ max_scalar_bytes: 16 * 1024 * 1024,
54
+ max_metadata_bytes: 16 * 1024 * 1024,
55
+ max_columns: 10_000,
56
+ max_record_bytes: 64 * 1024 * 1024,
57
+ max_capture_bytes: 64 * 1024 * 1024,
58
+ max_output_bytes: null,
59
+ }
60
+ },
61
+
62
+ // No limit on anything that can be unlimited, and the largest values
63
+ // otherwise. For tests and trusted, measured inputs only.
64
+ unlimited(): Limits {
65
+ const max = Number.MAX_SAFE_INTEGER
66
+ return {
67
+ max_depth: max,
68
+ max_key_bytes: max,
69
+ max_scalar_bytes: max,
70
+ max_metadata_bytes: max,
71
+ max_columns: max,
72
+ max_record_bytes: max,
73
+ max_capture_bytes: max,
74
+ max_output_bytes: null,
75
+ }
76
+ },
77
+
78
+ // The defaults with `fields` applied over them.
79
+ with(fields: Partial<Limits>): Limits {
80
+ return { ...Limits.default(), ...fields }
81
+ },
82
+ })
83
+
84
+ // The fixed allowance counted for every retained node (a container, a
85
+ // member, an element) on top of its payload bytes.
86
+ export const NODE_BYTES = 16
87
+
88
+ // The UTF-8 length of a string: `é` is 2, `日` 3, `😀` 4. A lone
89
+ // surrogate counts 3, as its replacement character would.
90
+ export function utf8Bytes(s: string): number {
91
+ const n = s.length
92
+ // ASCII fast path: most keys and scalars never leave it.
93
+ let i = 0
94
+ for (; i < n; i++) {
95
+ if (s.charCodeAt(i) >= 0x80) break
96
+ }
97
+ if (i === n) return n
98
+ return Buffer.byteLength(s, 'utf8')
99
+ }
100
+
101
+ // Counters and high-water marks a run reports, shared between stages.
102
+ export class Metrics {
103
+ // Source events seen.
104
+ events = 0
105
+ // Object keys seen.
106
+ keys = 0
107
+ // Scalars seen.
108
+ scalars = 0
109
+ // Rows delivered to the table protocol.
110
+ rows = 0
111
+ // Bytes currently held by materialized captures.
112
+ captured_bytes = 0
113
+ // The most bytes ever held by captures at once.
114
+ captured_bytes_high = 0
115
+ // The most bytes ever retained at once across every retaining stage.
116
+ retained_bytes_high = 0
117
+ // Bytes written to the output.
118
+ output_bytes = 0
119
+
120
+ // Account `bytes` as captured now, and raise the high-water marks.
121
+ capture(bytes: number): void {
122
+ this.captured_bytes += bytes
123
+ if (this.captured_bytes > this.captured_bytes_high) {
124
+ this.captured_bytes_high = this.captured_bytes
125
+ }
126
+ if (this.captured_bytes > this.retained_bytes_high) {
127
+ this.retained_bytes_high = this.captured_bytes
128
+ }
129
+ }
130
+
131
+ // Release `bytes` captured earlier.
132
+ release(bytes: number): void {
133
+ this.captured_bytes -= bytes
134
+ }
135
+
136
+ // The metrics as a plain object, one field per counter.
137
+ toJSON(): Record<string, number> {
138
+ return {
139
+ events: this.events,
140
+ keys: this.keys,
141
+ scalars: this.scalars,
142
+ rows: this.rows,
143
+ captured_bytes: this.captured_bytes,
144
+ captured_bytes_high: this.captured_bytes_high,
145
+ retained_bytes_high: this.retained_bytes_high,
146
+ output_bytes: this.output_bytes,
147
+ }
148
+ }
149
+ }
150
+
151
+ // A cancellation flag shared by the caller, the source and the stages.
152
+ //
153
+ // The source polls it between parse steps through the engine's parse
154
+ // budget and stops with `ABORTED`; long loops in stages poll it too.
155
+ export class AbortFlag {
156
+ private aborted = false
157
+
158
+ abort(): void {
159
+ this.aborted = true
160
+ }
161
+
162
+ isAborted(): boolean {
163
+ return this.aborted
164
+ }
165
+ }