@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.
@@ -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
+ }
@@ -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.2'
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
+ }