@tabnas/transduce 0.1.0 → 0.1.1
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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/* Copyright (c) 2026 tabnas, MIT License */
|
|
2
|
+
|
|
3
|
+
// The metadata-first table transducer: `JsonEvents/1` in, `TableRows/1`
|
|
4
|
+
// out.
|
|
5
|
+
//
|
|
6
|
+
// `TableFromJson` is a `Sink` built on a `Router` with at most two
|
|
7
|
+
// captures: the column metadata, when the schema comes from the document,
|
|
8
|
+
// and the rows. The schema must be known before the first row is emitted,
|
|
9
|
+
// and the transducer holds one row at a time. So metadata that has not
|
|
10
|
+
// completed when a row BEGINS is an `INPUT_ORDER_VIOLATION` raised at the
|
|
11
|
+
// row's start, through the router's `began` hook, before a byte of the row
|
|
12
|
+
// is retained; metadata that arrives twice is the same failure; and a
|
|
13
|
+
// document with no rows is a valid empty table, its schema emitted just
|
|
14
|
+
// before `end`. Rows are projected into schema order by path, so the order
|
|
15
|
+
// of members inside a row never matters, and a number keeps the lexeme the
|
|
16
|
+
// source events carried.
|
|
17
|
+
|
|
18
|
+
import { Datum, Duplicates, getPath } from './datum'
|
|
19
|
+
import { Fail } from './error'
|
|
20
|
+
import { JsonEvent } from './event'
|
|
21
|
+
import { Limits, Metrics, NODE_BYTES, utf8Bytes } from './limits'
|
|
22
|
+
import { CaptureId } from './matcher'
|
|
23
|
+
import { CaptureSpec, RouteSink, Router, Selected } from './route'
|
|
24
|
+
import { Path, Selector } from './selector'
|
|
25
|
+
import { Flow, Sink } from './sink'
|
|
26
|
+
import {
|
|
27
|
+
BoundColumn,
|
|
28
|
+
Cell,
|
|
29
|
+
ColumnMapper,
|
|
30
|
+
PublicColumn,
|
|
31
|
+
TableBinding,
|
|
32
|
+
TableSink,
|
|
33
|
+
boundColumn,
|
|
34
|
+
} from './table'
|
|
35
|
+
|
|
36
|
+
// Where the columns are, once known.
|
|
37
|
+
type Columns =
|
|
38
|
+
| { type: 'static'; bound: BoundColumn[] }
|
|
39
|
+
| { type: 'metadata'; selector: Selector; column: ColumnMapper; bound: BoundColumn[] | null }
|
|
40
|
+
| { type: 'infer'; bound: BoundColumn[] | null }
|
|
41
|
+
|
|
42
|
+
// The route sink behind the transducer: it owns the schema state and the
|
|
43
|
+
// table sink.
|
|
44
|
+
class Core<S extends TableSink> implements RouteSink {
|
|
45
|
+
readonly sink: S
|
|
46
|
+
rowId: CaptureId
|
|
47
|
+
metaId: CaptureId | null
|
|
48
|
+
columns: Columns
|
|
49
|
+
schemaSent = false
|
|
50
|
+
private maxColumns: number
|
|
51
|
+
// The bound on the inferred columns' names, the table's metadata when
|
|
52
|
+
// the first row supplies it.
|
|
53
|
+
private maxMetadataBytes: number
|
|
54
|
+
private metrics: Metrics
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
sink: S,
|
|
58
|
+
rowId: CaptureId,
|
|
59
|
+
metaId: CaptureId | null,
|
|
60
|
+
columns: Columns,
|
|
61
|
+
limits: Limits,
|
|
62
|
+
metrics: Metrics,
|
|
63
|
+
) {
|
|
64
|
+
this.sink = sink
|
|
65
|
+
this.rowId = rowId
|
|
66
|
+
this.metaId = metaId
|
|
67
|
+
this.columns = columns
|
|
68
|
+
this.maxColumns = limits.max_columns
|
|
69
|
+
this.maxMetadataBytes = limits.max_metadata_bytes
|
|
70
|
+
this.metrics = metrics
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
bind(columns: BoundColumn[], from: string): void {
|
|
74
|
+
if (columns.length > this.maxColumns) {
|
|
75
|
+
throw Fail.limit(
|
|
76
|
+
'max_columns',
|
|
77
|
+
this.maxColumns,
|
|
78
|
+
`${from} declares ${columns.length} columns, more than ${this.maxColumns}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
this.columns.bound = columns
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private publicColumns(): PublicColumn[] {
|
|
85
|
+
return (this.columns.bound ?? []).map((c) => ({ label: c.label }))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private sendSchema(): Flow {
|
|
89
|
+
this.schemaSent = true
|
|
90
|
+
return this.sink.tableEvent({ type: 'schema', columns: this.publicColumns() })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private metadata(selected: Selected): void {
|
|
94
|
+
const columns = this.columns
|
|
95
|
+
if ('metadata' !== columns.type) {
|
|
96
|
+
// The router only has a metadata capture when the binding selects one.
|
|
97
|
+
throw Fail.protocol('metadata was delivered to a static schema')
|
|
98
|
+
}
|
|
99
|
+
const at = selected.path.toString()
|
|
100
|
+
if (null !== columns.bound) {
|
|
101
|
+
throw new Fail(
|
|
102
|
+
'INPUT_ORDER_VIOLATION',
|
|
103
|
+
`the column metadata at ${at} was selected twice; a table has one schema`,
|
|
104
|
+
).atPath(at)
|
|
105
|
+
}
|
|
106
|
+
const value = selected.value ?? Datum.null
|
|
107
|
+
if ('array' !== value.type) {
|
|
108
|
+
throw Fail.input(`the column metadata at ${at} is not an array`).atPath(at)
|
|
109
|
+
}
|
|
110
|
+
const descriptors = value.items
|
|
111
|
+
if (descriptors.length > this.maxColumns) {
|
|
112
|
+
throw Fail.limit(
|
|
113
|
+
'max_columns',
|
|
114
|
+
this.maxColumns,
|
|
115
|
+
`the metadata at ${at} declares ${descriptors.length} columns, more than ` +
|
|
116
|
+
`${this.maxColumns}`,
|
|
117
|
+
).atPath(at)
|
|
118
|
+
}
|
|
119
|
+
const bound = descriptors.map((d, i) => {
|
|
120
|
+
try {
|
|
121
|
+
return columns.column(d)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof Fail && null == err.path) {
|
|
124
|
+
const p = selected.path.clone()
|
|
125
|
+
p.push(i)
|
|
126
|
+
err.path = p.toString()
|
|
127
|
+
}
|
|
128
|
+
throw err
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
this.bind(bound, 'the metadata')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private row(selected: Selected): Flow {
|
|
135
|
+
const row = selected.value ?? Datum.null
|
|
136
|
+
if (!this.schemaSent) {
|
|
137
|
+
if ('infer' === this.columns.type && null === this.columns.bound) {
|
|
138
|
+
const at = selected.path.toString()
|
|
139
|
+
if ('object' !== row.type) {
|
|
140
|
+
throw Fail.input(
|
|
141
|
+
`the first row at ${at} is not an object, so no columns can be inferred from it`,
|
|
142
|
+
).atPath(at)
|
|
143
|
+
}
|
|
144
|
+
// The names are the table's metadata for as long as it lasts, so
|
|
145
|
+
// they are held to the bound a metadata capture is: measured as the
|
|
146
|
+
// array of their strings would be.
|
|
147
|
+
let bytes = NODE_BYTES
|
|
148
|
+
for (const k of row.members.keys()) bytes += NODE_BYTES + utf8Bytes(k)
|
|
149
|
+
if (bytes > this.maxMetadataBytes) {
|
|
150
|
+
throw Fail.limit(
|
|
151
|
+
'max_metadata_bytes',
|
|
152
|
+
this.maxMetadataBytes,
|
|
153
|
+
`the first row's ${row.members.size} member names take ${bytes} bytes as the ` +
|
|
154
|
+
`table's columns, more than ${this.maxMetadataBytes}`,
|
|
155
|
+
).atPath(at)
|
|
156
|
+
}
|
|
157
|
+
this.bind(
|
|
158
|
+
[...row.members.keys()].map((k) => boundColumn(k, [k])),
|
|
159
|
+
'the first row',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if ('stop' === this.sendSchema()) return 'stop'
|
|
163
|
+
}
|
|
164
|
+
const columns = this.columns.bound
|
|
165
|
+
if (null === columns) {
|
|
166
|
+
throw Fail.protocol('a row was projected before its schema was bound')
|
|
167
|
+
}
|
|
168
|
+
const cells: Cell[] = []
|
|
169
|
+
for (const col of columns) {
|
|
170
|
+
const found = getPath(row, col.source)
|
|
171
|
+
if (undefined !== found) {
|
|
172
|
+
cells.push(Cell.fromDatum(found))
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
switch (col.missing) {
|
|
176
|
+
case 'missing':
|
|
177
|
+
cells.push(Cell.missing)
|
|
178
|
+
break
|
|
179
|
+
case 'null':
|
|
180
|
+
cells.push(Cell.null)
|
|
181
|
+
break
|
|
182
|
+
case 'error': {
|
|
183
|
+
const at = new Path([...selected.path.segments, ...col.source]).toString()
|
|
184
|
+
throw new Fail(
|
|
185
|
+
'MISSING_VALUE',
|
|
186
|
+
`column ${JSON.stringify(col.label)} has no value at ${at}`,
|
|
187
|
+
).atPath(at)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
this.metrics.rows++
|
|
192
|
+
return this.sink.tableEvent({ type: 'row', cells })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
began(id: CaptureId, _tag: string): void {
|
|
196
|
+
if (id === this.rowId && 'metadata' === this.columns.type && null === this.columns.bound) {
|
|
197
|
+
throw new Fail(
|
|
198
|
+
'INPUT_ORDER_VIOLATION',
|
|
199
|
+
`a row began before the column metadata at ${this.columns.selector} had completed; ` +
|
|
200
|
+
`rows must follow their metadata`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
selected(selected: Selected): Flow {
|
|
206
|
+
if (selected.id === this.metaId) {
|
|
207
|
+
this.metadata(selected)
|
|
208
|
+
return 'continue'
|
|
209
|
+
}
|
|
210
|
+
return this.row(selected)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
end(): Flow {
|
|
214
|
+
if (!this.schemaSent) {
|
|
215
|
+
if ('metadata' === this.columns.type && null === this.columns.bound) {
|
|
216
|
+
const at = this.columns.selector.toString()
|
|
217
|
+
throw Fail.input(`the document has no column metadata at ${at}`).atPath(at)
|
|
218
|
+
}
|
|
219
|
+
// No rows: nothing to infer from, so the table is empty.
|
|
220
|
+
if ('infer' === this.columns.type && null === this.columns.bound) {
|
|
221
|
+
this.bind([], 'the binding')
|
|
222
|
+
}
|
|
223
|
+
if ('stop' === this.sendSchema()) return 'stop'
|
|
224
|
+
}
|
|
225
|
+
return this.sink.tableEvent({ type: 'end' })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The table transducer. A `Sink` for one document's events; the table
|
|
230
|
+
// events go to the wrapped `TableSink` as the rows arrive.
|
|
231
|
+
export class TableFromJson<S extends TableSink> implements Sink {
|
|
232
|
+
private router: Router<Core<S>>
|
|
233
|
+
|
|
234
|
+
// Build the transducer. Rows are materialized under `max_record_bytes`,
|
|
235
|
+
// metadata under `max_metadata_bytes`; a rows selector that may overlap
|
|
236
|
+
// the metadata selector is refused as the router refuses any overlapping
|
|
237
|
+
// materializations.
|
|
238
|
+
constructor(
|
|
239
|
+
binding: TableBinding,
|
|
240
|
+
limits: Limits,
|
|
241
|
+
duplicates: Duplicates,
|
|
242
|
+
metrics: Metrics,
|
|
243
|
+
sink: S,
|
|
244
|
+
) {
|
|
245
|
+
const rowSpec = CaptureSpec.materialize('row', binding.rows).withBudget(
|
|
246
|
+
limits.max_record_bytes,
|
|
247
|
+
'max_record_bytes',
|
|
248
|
+
)
|
|
249
|
+
let columns: Columns
|
|
250
|
+
let specs: CaptureSpec[]
|
|
251
|
+
let metaId: CaptureId | null = null
|
|
252
|
+
const schema = binding.schema
|
|
253
|
+
switch (schema.type) {
|
|
254
|
+
case 'static':
|
|
255
|
+
columns = { type: 'static', bound: schema.columns.slice() }
|
|
256
|
+
specs = [rowSpec]
|
|
257
|
+
break
|
|
258
|
+
case 'metadata':
|
|
259
|
+
columns = {
|
|
260
|
+
type: 'metadata',
|
|
261
|
+
selector: schema.columns,
|
|
262
|
+
column: schema.column,
|
|
263
|
+
bound: null,
|
|
264
|
+
}
|
|
265
|
+
specs = [
|
|
266
|
+
CaptureSpec.materialize('metadata', schema.columns).withBudget(
|
|
267
|
+
limits.max_metadata_bytes,
|
|
268
|
+
'max_metadata_bytes',
|
|
269
|
+
),
|
|
270
|
+
rowSpec,
|
|
271
|
+
]
|
|
272
|
+
metaId = 0
|
|
273
|
+
break
|
|
274
|
+
case 'infer':
|
|
275
|
+
columns = { type: 'infer', bound: null }
|
|
276
|
+
specs = [rowSpec]
|
|
277
|
+
break
|
|
278
|
+
}
|
|
279
|
+
const core = new Core(sink, specs.length - 1, metaId, columns, limits, metrics)
|
|
280
|
+
if ('static' === columns.type) core.bind(columns.bound, 'the binding')
|
|
281
|
+
this.router = new Router(specs, limits, duplicates, metrics, core)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
get sink(): S {
|
|
285
|
+
return this.router.downstream.sink
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Whether the table's `end` has been emitted.
|
|
289
|
+
ended(): boolean {
|
|
290
|
+
return this.router.ended()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
event(ev: JsonEvent): Flow {
|
|
294
|
+
return this.router.event(ev)
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/* Copyright (c) 2026 tabnas, MIT License */
|
|
2
|
+
|
|
3
|
+
// `TableRows/1`: one schema, ordered finite rows, completion.
|
|
4
|
+
//
|
|
5
|
+
// The protocol is flat and budgeted: a row is a finite vector of cells,
|
|
6
|
+
// already projected into schema order by the transducer, and a renderer
|
|
7
|
+
// never sees a source path. Public columns carry a label; the source
|
|
8
|
+
// binding (`BoundColumn`) stays on the transducer's side of the boundary.
|
|
9
|
+
|
|
10
|
+
import { Datum, toText } from './datum'
|
|
11
|
+
import { Fail } from './error'
|
|
12
|
+
import { jsonNumber, jsonString } from './json'
|
|
13
|
+
import { NODE_BYTES, utf8Bytes } from './limits'
|
|
14
|
+
import { Segment, Selector } from './selector'
|
|
15
|
+
import { Flow } from './sink'
|
|
16
|
+
|
|
17
|
+
// One projected value. `missing`: the source had no value at the column's
|
|
18
|
+
// path; not null, not the empty string, not zero: a policy maps or rejects
|
|
19
|
+
// it later.
|
|
20
|
+
export type Cell =
|
|
21
|
+
| { readonly type: 'null' }
|
|
22
|
+
| { readonly type: 'bool'; readonly value: boolean }
|
|
23
|
+
| { readonly type: 'number'; readonly value: number; readonly lexeme: string | null }
|
|
24
|
+
| { readonly type: 'string'; readonly value: string }
|
|
25
|
+
| { readonly type: 'missing' }
|
|
26
|
+
|
|
27
|
+
const NULL: Cell = Object.freeze({ type: 'null' })
|
|
28
|
+
const MISSING: Cell = Object.freeze({ type: 'missing' })
|
|
29
|
+
|
|
30
|
+
export const Cell = Object.freeze({
|
|
31
|
+
null: NULL,
|
|
32
|
+
missing: MISSING,
|
|
33
|
+
bool(value: boolean): Cell {
|
|
34
|
+
return { type: 'bool', value }
|
|
35
|
+
},
|
|
36
|
+
number(value: number, lexeme?: string | null): Cell {
|
|
37
|
+
return { type: 'number', value, lexeme: lexeme ?? null }
|
|
38
|
+
},
|
|
39
|
+
string(value: string): Cell {
|
|
40
|
+
return { type: 'string', value }
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// From a retained value. A container is not a cell; it is serialized as
|
|
44
|
+
// compact JSON text, the lossy but unambiguous choice the standard
|
|
45
|
+
// binding makes and documents.
|
|
46
|
+
fromDatum(d: Datum): Cell {
|
|
47
|
+
switch (d.type) {
|
|
48
|
+
case 'null':
|
|
49
|
+
return NULL
|
|
50
|
+
case 'bool':
|
|
51
|
+
return { type: 'bool', value: d.value }
|
|
52
|
+
case 'number':
|
|
53
|
+
return { type: 'number', value: d.value, lexeme: d.lexeme }
|
|
54
|
+
case 'string':
|
|
55
|
+
return { type: 'string', value: d.value }
|
|
56
|
+
default:
|
|
57
|
+
return { type: 'string', value: toText(d) }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// The bytes this cell retains, on the same basis as a datum's size.
|
|
62
|
+
byteSize(c: Cell): number {
|
|
63
|
+
switch (c.type) {
|
|
64
|
+
case 'number':
|
|
65
|
+
return NODE_BYTES + (null != c.lexeme ? utf8Bytes(c.lexeme) : 8)
|
|
66
|
+
case 'string':
|
|
67
|
+
return NODE_BYTES + utf8Bytes(c.value)
|
|
68
|
+
default:
|
|
69
|
+
return NODE_BYTES
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// The cell as JSON text; `missing` prints as `missing`.
|
|
74
|
+
toText(c: Cell): string {
|
|
75
|
+
switch (c.type) {
|
|
76
|
+
case 'null':
|
|
77
|
+
return 'null'
|
|
78
|
+
case 'bool':
|
|
79
|
+
return String(c.value)
|
|
80
|
+
case 'number':
|
|
81
|
+
return jsonNumber(c.value, c.lexeme)
|
|
82
|
+
case 'string':
|
|
83
|
+
return jsonString(c.value)
|
|
84
|
+
case 'missing':
|
|
85
|
+
return 'missing'
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// What a renderer knows about a column.
|
|
91
|
+
export type PublicColumn = { label: string }
|
|
92
|
+
|
|
93
|
+
// One event of `TableRows/1`: the `schema` exactly once and first, a `row`
|
|
94
|
+
// per row (each exactly as wide as the schema), the `end` exactly once and
|
|
95
|
+
// last, and only after the source validated to its end.
|
|
96
|
+
export type TableEvent =
|
|
97
|
+
| { readonly type: 'schema'; readonly columns: readonly PublicColumn[] }
|
|
98
|
+
| { readonly type: 'row'; readonly cells: readonly Cell[] }
|
|
99
|
+
| { readonly type: 'end' }
|
|
100
|
+
|
|
101
|
+
// A consumer of `TableRows/1`. Throws a `Fail` to fail the run.
|
|
102
|
+
export interface TableSink {
|
|
103
|
+
tableEvent(ev: TableEvent): Flow
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An owned recording of a table, for tests and small results.
|
|
107
|
+
export class Table implements TableSink {
|
|
108
|
+
columns: PublicColumn[] = []
|
|
109
|
+
rows: Cell[][] = []
|
|
110
|
+
ended = false
|
|
111
|
+
|
|
112
|
+
tableEvent(ev: TableEvent): Flow {
|
|
113
|
+
switch (ev.type) {
|
|
114
|
+
case 'schema':
|
|
115
|
+
this.columns = ev.columns.slice()
|
|
116
|
+
break
|
|
117
|
+
case 'row':
|
|
118
|
+
this.rows.push(ev.cells.slice())
|
|
119
|
+
break
|
|
120
|
+
case 'end':
|
|
121
|
+
this.ended = true
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
return 'continue'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// What to do when a row has no value at a column's path: deliver a
|
|
129
|
+
// `missing` cell (the default; the renderer's policy decides), deliver
|
|
130
|
+
// `null`, or fail the run with `MISSING_VALUE`.
|
|
131
|
+
export type MissingPolicy = 'missing' | 'null' | 'error'
|
|
132
|
+
|
|
133
|
+
// A column as the transducer binds it: the public label, and the source
|
|
134
|
+
// path projected from each row. Never crosses into a renderer.
|
|
135
|
+
export type BoundColumn = {
|
|
136
|
+
label: string
|
|
137
|
+
source: Segment[]
|
|
138
|
+
missing: MissingPolicy
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function boundColumn(
|
|
142
|
+
label: string,
|
|
143
|
+
source: Segment[],
|
|
144
|
+
missing: MissingPolicy = 'missing',
|
|
145
|
+
): BoundColumn {
|
|
146
|
+
return { label, source, missing }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Maps one metadata descriptor to a bound column; throws a `Fail` for a
|
|
150
|
+
// descriptor it cannot read.
|
|
151
|
+
export type ColumnMapper = (meta: Datum) => BoundColumn
|
|
152
|
+
|
|
153
|
+
// Where a table's columns come from: declared by the caller (`static`),
|
|
154
|
+
// selected from the source and mapped one descriptor at a time
|
|
155
|
+
// (`metadata`; the metadata must complete before the first row begins),
|
|
156
|
+
// or the first row's member names in its order (`infer`; data-dependent: a
|
|
157
|
+
// later row's extra members are dropped, its absent ones are `missing`).
|
|
158
|
+
export type Schema =
|
|
159
|
+
| { type: 'static'; columns: BoundColumn[] }
|
|
160
|
+
| { type: 'metadata'; columns: Selector; column: ColumnMapper }
|
|
161
|
+
| { type: 'infer' }
|
|
162
|
+
|
|
163
|
+
export const Schema = Object.freeze({
|
|
164
|
+
static(columns: BoundColumn[]): Schema {
|
|
165
|
+
return { type: 'static', columns }
|
|
166
|
+
},
|
|
167
|
+
fromMetadata(columns: Selector, column: ColumnMapper = columnFromMeta): Schema {
|
|
168
|
+
return { type: 'metadata', columns, column }
|
|
169
|
+
},
|
|
170
|
+
infer(): Schema {
|
|
171
|
+
return { type: 'infer' }
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// A table transducer's source binding: the schema, and the rows (each
|
|
176
|
+
// location `rows` names is one row).
|
|
177
|
+
export type TableBinding = {
|
|
178
|
+
schema: Schema
|
|
179
|
+
rows: Selector
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The standard mapping from a metadata descriptor to a column: the spec's
|
|
183
|
+
// `column-from-meta`, reading `title` and a `path` of segments.
|
|
184
|
+
export function columnFromMeta(meta: Datum): BoundColumn {
|
|
185
|
+
if ('object' !== meta.type) {
|
|
186
|
+
throw Fail.input('a column descriptor is not an object')
|
|
187
|
+
}
|
|
188
|
+
const title = meta.members.get('title')
|
|
189
|
+
if (undefined === title || 'string' !== title.type) {
|
|
190
|
+
throw Fail.input('a column descriptor has no string "title"')
|
|
191
|
+
}
|
|
192
|
+
const label = title.value
|
|
193
|
+
const path = meta.members.get('path')
|
|
194
|
+
if (undefined === path || 'array' !== path.type) {
|
|
195
|
+
throw Fail.input(`column ${JSON.stringify(label)} has no "path" array`)
|
|
196
|
+
}
|
|
197
|
+
const source: Segment[] = path.items.map((seg) => {
|
|
198
|
+
if ('string' === seg.type) return seg.value
|
|
199
|
+
if (
|
|
200
|
+
'number' === seg.type &&
|
|
201
|
+
seg.value >= 0 &&
|
|
202
|
+
Number.isInteger(seg.value) &&
|
|
203
|
+
seg.value <= 0xffffffff
|
|
204
|
+
) {
|
|
205
|
+
return seg.value
|
|
206
|
+
}
|
|
207
|
+
throw Fail.input(
|
|
208
|
+
`column ${JSON.stringify(label)} has a path segment that is neither a string nor a ` +
|
|
209
|
+
`non-negative integer: ${toText(seg)}`,
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
return boundColumn(label, source)
|
|
213
|
+
}
|
package/src/transduce.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* Copyright (c) 2026 tabnas, MIT License */
|
|
2
|
+
|
|
3
|
+
// Streaming transducers over any tabnas parser.
|
|
4
|
+
//
|
|
5
|
+
// A transducer decides what a source contributes to an output model; a
|
|
6
|
+
// renderer decides how that model becomes text. This package is the
|
|
7
|
+
// transducer side:
|
|
8
|
+
//
|
|
9
|
+
// - the source protocol `JsonEvent` (`JsonEvents/1`) and the push boundary
|
|
10
|
+
// `Sink`;
|
|
11
|
+
// - sources that produce it from a parsed value, from a live tabnas parse
|
|
12
|
+
// through the engine's rule events, or from line-delimited input;
|
|
13
|
+
// - `Selector`s and the matcher and router that recognize selected scopes
|
|
14
|
+
// in one pass and materialize them under a byte limit;
|
|
15
|
+
// - the table protocol `TableEvent` (`TableRows/1`) and the standard
|
|
16
|
+
// metadata-first table transducer;
|
|
17
|
+
// - `Limits`, `Metrics`, `AbortFlag` and the stable failure `Code`s every
|
|
18
|
+
// stage reports.
|
|
19
|
+
//
|
|
20
|
+
// Everything is synchronous and push-based: the parser calls the first
|
|
21
|
+
// sink from inside its own callback, and a slow writer at the end of the
|
|
22
|
+
// chain slows the parse at the start. That is the backpressure.
|
|
23
|
+
|
|
24
|
+
// This package's version, as package.json declares it.
|
|
25
|
+
export const VERSION = '0.1.1'
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
Datum,
|
|
29
|
+
DatumBuilder,
|
|
30
|
+
byteSize,
|
|
31
|
+
fromJSON,
|
|
32
|
+
fromTabnas,
|
|
33
|
+
getPath,
|
|
34
|
+
takePath,
|
|
35
|
+
toJSON,
|
|
36
|
+
toText,
|
|
37
|
+
walkDatum,
|
|
38
|
+
} from './datum'
|
|
39
|
+
export type { Duplicates } from './datum'
|
|
40
|
+
export { Code, Fail, isFail } from './error'
|
|
41
|
+
export type { Limit } from './error'
|
|
42
|
+
export { Ev, eventEquals, eventText, isEnd, isScalar, isStart } from './event'
|
|
43
|
+
export type { JsonEvent, JsonEventType } from './event'
|
|
44
|
+
export { isJsonNumber, jsonNumber, jsonString, numberText } from './json'
|
|
45
|
+
export { AbortFlag, LIMIT_NAMES, Limits, Metrics, NODE_BYTES, utf8Bytes } from './limits'
|
|
46
|
+
export { Matcher } from './matcher'
|
|
47
|
+
export type { CaptureId, Hit, HitKind } from './matcher'
|
|
48
|
+
export { CaptureSpec, FnRoute, Router, SelectedRecorder } from './route'
|
|
49
|
+
export type { Budget, CaptureMode, RouteSink, Selected } from './route'
|
|
50
|
+
export { ScanEmit, Transition } from './scan'
|
|
51
|
+
export { Path, Selector, keyText, pathText } from './selector'
|
|
52
|
+
export type { Segment, Step } from './selector'
|
|
53
|
+
export { CountSink, EventRecorder, FnSink, TreeContract, replay } from './sink'
|
|
54
|
+
export type { Flow, Sink } from './sink'
|
|
55
|
+
export { Prune, SourceMode, ValueSource, engineFailure, walkValue } from './source'
|
|
56
|
+
export type { Source } from './source'
|
|
57
|
+
export { Guarded } from './guard'
|
|
58
|
+
export { ParserSource } from './parser-source'
|
|
59
|
+
export { DEFAULT_CHUNK_BYTES, LineFormat, LinesSource } from './lines'
|
|
60
|
+
export type { LinesChunk, LinesInput, LinesWriter } from './lines'
|
|
61
|
+
export { INCREMENTAL, capability, isIncremental } from './capability'
|
|
62
|
+
export { Cell, Schema, Table, boundColumn, columnFromMeta } from './table'
|
|
63
|
+
export type {
|
|
64
|
+
BoundColumn,
|
|
65
|
+
ColumnMapper,
|
|
66
|
+
MissingPolicy,
|
|
67
|
+
PublicColumn,
|
|
68
|
+
TableBinding,
|
|
69
|
+
TableEvent,
|
|
70
|
+
TableSink,
|
|
71
|
+
} from './table'
|
|
72
|
+
export { TableFromJson } from './table-from-json'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"esModuleInterop": true,
|
|
4
|
+
"module": "nodenext",
|
|
5
|
+
"noEmitOnError": true,
|
|
6
|
+
"outDir": "../dist",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"target": "es2021",
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationDir": "../dist",
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/value.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* Copyright (c) 2026 tabnas, MIT License */
|
|
2
|
+
|
|
3
|
+
// Reading the engine's values: what a tabnas parse returns in TypeScript.
|
|
4
|
+
//
|
|
5
|
+
// The engine builds plain JavaScript values: `null` and `undefined`,
|
|
6
|
+
// booleans, numbers, strings, arrays and objects. A few grammars hand over
|
|
7
|
+
// host types, which read as the scalars they stand for: a boxed `String`
|
|
8
|
+
// (the engine's text annotation), a `BigInt`, and a TOML `Date`, which
|
|
9
|
+
// reads as the source text it carries (`__toml__.src`), as the Rust
|
|
10
|
+
// runtime's date does.
|
|
11
|
+
//
|
|
12
|
+
// An object's member order is its insertion order. A plain object
|
|
13
|
+
// enumerates integer-like keys first whatever order they were written in,
|
|
14
|
+
// so the engine records the order on the node under `map.ordered` (read
|
|
15
|
+
// with `KEY_ORDER`); the sources switch that on, and `engineKeys` reads
|
|
16
|
+
// it, falling back to `Object.keys`.
|
|
17
|
+
|
|
18
|
+
// The engine's key-order side channel (`keyOrder` in @tabnas/parser).
|
|
19
|
+
const KEY_ORDER = Symbol.for('tabnas.keyOrder')
|
|
20
|
+
|
|
21
|
+
export type EngineScalar =
|
|
22
|
+
| { type: 'null' }
|
|
23
|
+
| { type: 'bool'; value: boolean }
|
|
24
|
+
| { type: 'number'; value: number }
|
|
25
|
+
| { type: 'string'; value: string }
|
|
26
|
+
|
|
27
|
+
const NULL: EngineScalar = { type: 'null' }
|
|
28
|
+
|
|
29
|
+
// The value as a scalar, or `undefined` when it is a container.
|
|
30
|
+
export function engineScalar(v: unknown): EngineScalar | undefined {
|
|
31
|
+
switch (typeof v) {
|
|
32
|
+
case 'undefined':
|
|
33
|
+
return NULL
|
|
34
|
+
case 'boolean':
|
|
35
|
+
return { type: 'bool', value: v }
|
|
36
|
+
case 'number':
|
|
37
|
+
return { type: 'number', value: v }
|
|
38
|
+
case 'bigint':
|
|
39
|
+
return { type: 'number', value: Number(v) }
|
|
40
|
+
case 'string':
|
|
41
|
+
return { type: 'string', value: v }
|
|
42
|
+
case 'object':
|
|
43
|
+
if (null === v) return NULL
|
|
44
|
+
if (Array.isArray(v)) return undefined
|
|
45
|
+
if (v instanceof String) return { type: 'string', value: v.valueOf() }
|
|
46
|
+
if (v instanceof Number) return { type: 'number', value: v.valueOf() }
|
|
47
|
+
if (v instanceof Boolean) return { type: 'bool', value: v.valueOf() }
|
|
48
|
+
if (v instanceof Date) {
|
|
49
|
+
const src = (v as any).__toml__?.src
|
|
50
|
+
return { type: 'string', value: 'string' === typeof src ? src : v.toJSON() ?? 'null' }
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
default:
|
|
54
|
+
// A function or a symbol has no JSON form.
|
|
55
|
+
return NULL
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Whether the value is a container: an array, or an object that is no
|
|
60
|
+
// scalar's host type.
|
|
61
|
+
export function isEngineContainer(v: unknown): boolean {
|
|
62
|
+
return null !== v && 'object' === typeof v && undefined === engineScalar(v)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Whether the value is a map (a container that is not an array).
|
|
66
|
+
export function isEngineMap(v: unknown): v is Record<string, unknown> {
|
|
67
|
+
return isEngineContainer(v) && !Array.isArray(v)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A map's member names, in insertion order: the engine's recorded order
|
|
71
|
+
// where it kept one, each name once and only while the map still holds it,
|
|
72
|
+
// then any member the record missed.
|
|
73
|
+
export function engineKeys(v: unknown): string[] {
|
|
74
|
+
if (null === v || 'object' !== typeof v) return []
|
|
75
|
+
const own = Object.keys(v as object)
|
|
76
|
+
const order: unknown = (v as any)[KEY_ORDER]
|
|
77
|
+
if (!Array.isArray(order) || 0 === order.length) return own
|
|
78
|
+
const has = Object.prototype.hasOwnProperty
|
|
79
|
+
const seen = new Set<string>()
|
|
80
|
+
const out: string[] = []
|
|
81
|
+
for (const k of order) {
|
|
82
|
+
const key = String(k)
|
|
83
|
+
if (seen.has(key) || !has.call(v, key)) continue
|
|
84
|
+
seen.add(key)
|
|
85
|
+
out.push(key)
|
|
86
|
+
}
|
|
87
|
+
if (out.length !== own.length) {
|
|
88
|
+
for (const k of own) {
|
|
89
|
+
if (!seen.has(k)) out.push(k)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|