@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/README.md +14 -0
- package/dist/transduce.d.ts +1 -1
- package/dist/transduce.js +1 -1
- package/package.json +3 -1
- package/src/capability.ts +52 -0
- package/src/datum.ts +407 -0
- package/src/error.ts +198 -0
- package/src/event.ts +134 -0
- package/src/guard.ts +112 -0
- package/src/json.ts +82 -0
- package/src/limits.ts +165 -0
- package/src/lines.ts +1247 -0
- package/src/matcher.ts +287 -0
- package/src/parser-source.ts +225 -0
- package/src/route.ts +275 -0
- package/src/rule-events.ts +661 -0
- package/src/scan.ts +83 -0
- package/src/selector.ts +191 -0
- package/src/sink.ts +199 -0
- package/src/source.ts +135 -0
- package/src/table-from-json.ts +296 -0
- package/src/table.ts +213 -0
- package/src/transduce.ts +72 -0
- package/src/tsconfig.json +16 -0
- package/src/value.ts +93 -0
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
|
+
}
|