@human-synthesis/norns-tron 0.0.1 → 0.0.3

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 CHANGED
@@ -12,9 +12,9 @@ The encoder/decoder core is absorbed from the `apitron` research library; this
12
12
  package adds the Norns framework glue as subpath exports.
13
13
 
14
14
  ```
15
- @human-synthesis/norns-tron encode / decode / defineSchema / registry
15
+ @human-synthesis/norns-tron encode / decode / defineSchema / registry / columnar
16
16
  @human-synthesis/norns-tron/server tronSerializer() for norns route()
17
- @human-synthesis/norns-tron/client api fetch wrapper that speaks TRON
17
+ @human-synthesis/norns-tron/client createApi() fetch wrapper that speaks TRON
18
18
  @human-synthesis/norns-tron/valibot derive wire schemas from valibot schemas
19
19
  ```
20
20
 
@@ -38,8 +38,9 @@ line to roll the whole thing back.
38
38
  Per-route control:
39
39
 
40
40
  ```coffee
41
- export GET := route serializer: tronSerializer({ schema: noteWire }), handler: ...
42
- export POST := route serializer: null, handler: ... # force plain JSON
41
+ export GET := route serializer: tronSerializer({ schema: noteWire }), handler: ... # schema mode
42
+ export GET := route serializer: tronSerializer({ columnar: true }), handler: ... # numeric tables
43
+ export POST := route serializer: null, handler: ... # force plain JSON
43
44
  ```
44
45
 
45
46
  ## Call it from the client
@@ -52,12 +53,33 @@ await api.post '/api/notes', { title, body } # body goes out as TRON too
52
53
 
53
54
  # inside a load function, keep SvelteKit's fetch semantics:
54
55
  api := createApi { fetch }
56
+
57
+ # endpoints served in schema mode tag the wire; hand the client the contracts:
58
+ api := createApi { schemas: [noteWire, userWire] } # or a createRegistry()
55
59
  ```
56
60
 
61
+ Responses are decoded by content type: self-describing TRON on its own,
62
+ tagged schema-mode documents (`#notes.v1`) through the matching compiled
63
+ schema, JSON as JSON. A tag with no registered schema throws instead of
64
+ handing you positional arrays. Non-2xx responses throw `ApiError` with the
65
+ decoded body (`status`, `body.message`, `body.issues` from a `route()` 400).
66
+
57
67
  The 1.7 KB WASM scanner ships embedded as base64 — no asset wiring, works in
58
68
  Node, Bun, and the browser. Under a strict CSP without `unsafe-eval` the
59
69
  decoder transparently falls back to the JS scanner (~1.1–1.5x).
60
70
 
71
+ ## Where it pays off
72
+
73
+ | Payload | Mode | Why |
74
+ |---|---|---|
75
+ | Paged / sorted tables (`DataTable`, admin lists) | schema (`tronSerializer({ schema })` + `createApi({ schemas })`) | no shape on the wire, row constructor compiled once; pairs with norns `listQuery()` / `listResult()` and norns-ui `useList()` |
76
+ | Picker options (`Autocomplete` / `MultiSelect` `source`) | default | small responses ship as JSON automatically; TRON kicks in past ~1 KB |
77
+ | Charts, aggregations, exports of numbers | columnar (`tronSerializer({ columnar: true })` + `api.tape()`) | decodes into one `Float64Array`, ~3x faster than `JSON.parse`, no row objects |
78
+ | Agent / LLM-facing reads (search results, indexes) | self-describing (`tronSerializer()`) | a cold reader gets the declarations in-band; 26–61% fewer tokens on tabular data |
79
+ | Cached lists on Workers (`route({ cache: { ttl } })`) | any | the encoded body is stored per `Accept` variant, so encode runs once per TTL and clients revalidate with `ETag` |
80
+
81
+ The [norns-demo](https://github.com/human-synthesis/norns-demo) has one page per row under `/examples/tron`.
82
+
61
83
  ## Schema mode — the fastest path for internal endpoints
62
84
 
63
85
  Both ends already know the shape from the feature contract, so nothing
@@ -80,7 +102,30 @@ export noteWire := tronSchemaFromValibot noteSchema, { id: 'notes.v1', path: '$.
80
102
  `picklist`/`enum` fields become dictionary columns (integers on the wire),
81
103
  booleans become 0/1. Compile once at module scope — never per request.
82
104
  The `#notes.v1` tag makes version mismatches fail loudly instead of
83
- misdecoding; use `createRegistry()` on a client that consumes several shapes.
105
+ misdecoding; use `createRegistry()` (or the `schemas` array) on a client that
106
+ consumes several shapes. Derivation is for flat object schemas; nested rows
107
+ still work through the self-describing mode.
108
+
109
+ ## Columnar mode — numbers without objects
110
+
111
+ For a table whose values are all numbers (or dictionary-able strings /
112
+ booleans), skip row objects entirely:
113
+
114
+ ```coffee
115
+ # server
116
+ export GET := route
117
+ serializer: tronSerializer({ columnar: true })
118
+ cache: { ttl: 30 }
119
+ handler: ({ container }) => stats(container).perDay() # [{ day, count, chars }, …]
120
+
121
+ # client
122
+ { fields, rows, cols, tape } := await api.tape '/api/stats'
123
+ count := tape[i * cols + fields.indexOf('count')]
124
+ ```
125
+
126
+ `api.tape()` always returns the same `{ fields, rows, cols, tape }` shape:
127
+ the WASM fast path when the server answered columnar TRON, and a packed
128
+ fallback (`tableFromRows`) when it answered JSON or WASM is unavailable.
84
129
 
85
130
  ## Semantics and limits
86
131
 
@@ -89,19 +134,21 @@ misdecoding; use `createRegistry()` on a client that consumes several shapes.
89
134
  serialize as `{}` and `BigInt` throws, exactly like `JSON.stringify`; dev
90
135
  mode logs a warning when a route returns them.
91
136
  - **Not for `load` / form actions** — SvelteKit serializes those with devalue
92
- (which preserves Dates, Maps, Sets). This package targets `route()`
93
- endpoints, LLM-facing output, and service-to-service payloads.
137
+ (which preserves Dates, Maps, Sets) and only dispatches form-encoded POSTs
138
+ to actions. This package targets `route()` endpoints, LLM-facing output,
139
+ and service-to-service payloads; norns-ui's `Form` has an API mode
140
+ (`submit`) for posting through `api.post()`.
94
141
  - Payloads under ~1 KB are emitted as plain JSON automatically (still decoded
95
142
  transparently) — below that size TRON's fixed costs don't pay for themselves.
96
143
  - The wire content type is `application/tron`, not `application/json`: a TRON
97
144
  body may carry a declaration preamble that is not valid JSON.
98
- - Schema mode uses `new Function` for the row constructor (server-side this is
99
- a non-issue; in CSP-restricted browsers the fallback scanner takes over).
145
+ - Schema mode uses `new Function` for the row constructor where allowed and a
146
+ closure fallback elsewhere (Cloudflare Workers, CSP-restricted browsers).
100
147
 
101
148
  ## Development
102
149
 
103
150
  ```sh
104
- bun test # 38 tests: core roundtrips, Date regression, server/client glue, valibot derivation
151
+ bun test # 50 tests: core roundtrips, Date regression, server/client glue, schema registry, columnar tape, valibot derivation
105
152
  bun run embed-wasm # regenerate src/core/wasm-bytes.js after replacing wasm/parserTron2.wasm
106
153
  ```
107
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-tron",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "TRON serialization for the Norns ecosystem — token-efficient, faster-than-JSON wire format for APIs and LLM-facing output.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
package/src/client.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ColumnarResult, CompiledSchema, SchemaRegistry } from './index.js';
2
+
1
3
  export const TRON_CONTENT_TYPE: 'application/tron';
2
4
 
3
5
  export class ApiError extends Error {
@@ -6,8 +8,17 @@ export class ApiError extends Error {
6
8
  response: Response;
7
9
  }
8
10
 
11
+ /** Compiled schemas (from defineSchema / tronSchemaFromValibot) or a createRegistry() registry. */
12
+ export type Schemas = CompiledSchema[] | SchemaRegistry;
13
+
14
+ /** Decode a TRON/JSON body; tagged schema-mode documents need `schemas`. */
15
+ export function decodeText(text: string, schemas?: Schemas): unknown;
16
+
9
17
  /** Decode a Response by its content-type (TRON, JSON, or raw text). */
10
- export function parseResponse(res: Response): Promise<unknown>;
18
+ export function parseResponse(res: Response, schemas?: Schemas): Promise<unknown>;
19
+
20
+ /** Pack row objects into the `{fields, rows, cols, tape}` shape decodeColumnar returns. */
21
+ export function tableFromRows(rows: Array<Record<string, unknown>>, fields?: string[]): ColumnarResult;
11
22
 
12
23
  export interface ApiDefaults {
13
24
  /** fetch impl — pass SvelteKit's `fetch` inside load functions. */
@@ -16,12 +27,21 @@ export interface ApiDefaults {
16
27
  base?: string;
17
28
  /** Headers sent on every request. */
18
29
  headers?: Record<string, string>;
30
+ /** Contracts for endpoints served with `tronSerializer({ schema })`. */
31
+ schemas?: Schemas;
19
32
  }
20
33
 
21
34
  export interface RequestOptions {
22
35
  fetch?: typeof fetch;
23
36
  headers?: Record<string, string>;
24
37
  init?: RequestInit;
38
+ /** Per-call override of the schemas given to createApi. */
39
+ schemas?: Schemas;
40
+ }
41
+
42
+ export interface TapeOptions extends RequestOptions {
43
+ /** Column order when the server answered with row objects instead of a columnar body. */
44
+ fields?: string[];
25
45
  }
26
46
 
27
47
  export interface Api {
@@ -31,6 +51,8 @@ export interface Api {
31
51
  post(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
32
52
  put(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
33
53
  patch(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
54
+ /** GET a numeric table as a flat Float64Array (see `tronSerializer({ columnar: true })`). */
55
+ tape(url: string, opts?: TapeOptions): Promise<ColumnarResult>;
34
56
  }
35
57
 
36
58
  export function createApi(defaults?: ApiDefaults): Api;
package/src/client.js CHANGED
@@ -9,11 +9,20 @@
9
9
  * de-duplication keep working:
10
10
  * const api = createApi({ fetch });
11
11
  *
12
+ * Endpoints served in SCHEMA mode (`tronSerializer({ schema })`) tag the wire
13
+ * with `#id` and carry no shape declarations, so the client must know the
14
+ * contract too. Hand the compiled schemas (or a registry) to createApi and
15
+ * responses are routed to the right decoder by their tag:
16
+ * const api = createApi({ schemas: [noteWire, userWire] });
17
+ *
18
+ * Numeric tables served with `tronSerializer({ columnar: true })` can be read
19
+ * straight into a Float64Array with `api.tape(url)` — no row objects at all.
20
+ *
12
21
  * The WASM scanner ships embedded (~2.3 KB base64) and initializes lazily, so
13
22
  * no asset wiring is needed in the browser. Under a strict CSP without
14
23
  * `unsafe-eval` the decoder transparently falls back to the JS scanner.
15
24
  */
16
- import { decode, encode } from './index.js';
25
+ import { decode, decodeColumnar, encode } from './index.js';
17
26
 
18
27
  export const TRON_CONTENT_TYPE = 'application/tron';
19
28
  const ACCEPT = 'application/tron, application/json;q=0.9, */*;q=0.1';
@@ -33,25 +42,98 @@ export class ApiError extends Error {
33
42
  }
34
43
  }
35
44
 
36
- /** Decode a Response by its content-type (TRON, JSON, or raw text). */
37
- export async function parseResponse(res) {
38
- if (res.status === 204) return null;
45
+ /**
46
+ * Normalize the `schemas` option into something with `decode(text)` that
47
+ * routes by the `#id` tag: a registry from createRegistry(), or an array of
48
+ * compiled schemas from defineSchema() / tronSchemaFromValibot().
49
+ */
50
+ function toRegistry(schemas) {
51
+ if (!schemas) return null;
52
+ if (typeof schemas.decode === 'function' && !Array.isArray(schemas)) return schemas;
53
+ const byId = new Map();
54
+ for (const s of schemas) {
55
+ if (!s || typeof s.decode !== 'function') throw new Error('createApi: `schemas` must contain compiled schemas (defineSchema / tronSchemaFromValibot)');
56
+ if (s.id) byId.set(s.id, s);
57
+ }
58
+ return {
59
+ decode(text) {
60
+ const nl = text.indexOf('\n');
61
+ const id = text.slice(1, nl === -1 ? text.length : nl);
62
+ const s = byId.get(id);
63
+ if (!s) throw new Error(`unknown schema id "${id}" — register it via createApi({ schemas })`);
64
+ return s.decode(text);
65
+ }
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Decode a TRON (or JSON) body. Tagged schema-mode documents (`#id\n…`) need
71
+ * the matching compiled schema; pass a registry or the array given to
72
+ * createApi. Untagged documents are self-describing and decode on their own.
73
+ *
74
+ * @param {string} text
75
+ * @param {any} [schemas]
76
+ */
77
+ export function decodeText(text, schemas) {
78
+ if (text.charCodeAt(0) === 35 /* # */) {
79
+ const registry = toRegistry(schemas);
80
+ if (!registry) {
81
+ const nl = text.indexOf('\n');
82
+ throw new Error(`response is schema-tagged (${text.slice(0, nl === -1 ? 40 : nl)}) but no schemas were given to createApi()`);
83
+ }
84
+ return registry.decode(text);
85
+ }
86
+ return decode(text);
87
+ }
88
+
89
+ /**
90
+ * Decode a Response by its content-type (TRON, JSON, or raw text).
91
+ * @param {Response} res
92
+ * @param {any} [schemas] compiled schemas / registry for tagged TRON bodies
93
+ */
94
+ export async function parseResponse(res, schemas) {
95
+ if (res.status === 204 || res.status === 304) return null;
39
96
  const type = res.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '';
40
97
  const text = await res.text();
41
98
  if (text === '') return null;
42
- if (type === TRON_CONTENT_TYPE) return decode(text);
99
+ if (type === TRON_CONTENT_TYPE) return decodeText(text, schemas);
43
100
  if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(text);
44
101
  return text;
45
102
  }
46
103
 
104
+ /**
105
+ * Row objects -> the same `{fields, rows, cols, tape}` shape decodeColumnar
106
+ * returns. Used when the server answered JSON (no Accept negotiation, small
107
+ * payload) or the WASM scanner is unavailable, so `api.tape()` has one shape.
108
+ *
109
+ * @param {Array<Record<string, unknown>>} rows
110
+ * @param {string[]} [fields] column order (default: keys of the first row)
111
+ */
112
+ export function tableFromRows(rows, fields) {
113
+ if (!Array.isArray(rows) || rows.length === 0) {
114
+ return { fields: fields ?? [], rows: 0, cols: fields?.length ?? 0, tape: new Float64Array(0) };
115
+ }
116
+ const f = fields ?? Object.keys(rows[0]);
117
+ const cols = f.length;
118
+ const tape = new Float64Array(rows.length * cols);
119
+ for (let i = 0; i < rows.length; i++) {
120
+ const r = rows[i];
121
+ for (let k = 0; k < cols; k++) tape[i * cols + k] = Number(r[f[k]]);
122
+ }
123
+ return { fields: f, rows: rows.length, cols, tape };
124
+ }
125
+
47
126
  /**
48
127
  * @param {object} [defaults]
49
128
  * @param {typeof fetch} [defaults.fetch] fetch impl (pass SvelteKit's in load functions)
50
129
  * @param {string} [defaults.base] URL prefix, e.g. 'https://api.example.com'
51
130
  * @param {Record<string, string>} [defaults.headers] headers sent on every request
131
+ * @param {any} [defaults.schemas] compiled schemas (array) or a createRegistry()
132
+ * registry, for endpoints served in schema mode
52
133
  */
53
134
  export function createApi(defaults = {}) {
54
135
  const base = defaults.base ?? '';
136
+ const registry = toRegistry(defaults.schemas);
55
137
 
56
138
  async function request(method, url, body, opts = {}) {
57
139
  // Resolve fetch per call: in the browser `globalThis.fetch` must not be
@@ -67,18 +149,44 @@ export function createApi(defaults = {}) {
67
149
  init.body = encode(body);
68
150
  }
69
151
  const res = await doFetch(base + url, init);
70
- const parsed = await parseResponse(res);
152
+ const parsed = await parseResponse(res, opts.schemas ?? registry);
71
153
  if (!res.ok) throw new ApiError(res.status, parsed, res);
72
154
  return parsed;
73
155
  }
74
156
 
157
+ /**
158
+ * GET a numeric table as a flat Float64Array tape. Fast path: the server
159
+ * used `tronSerializer({ columnar: true })` and WASM is available. Fallback:
160
+ * decode rows and pack them, so callers always get the same shape.
161
+ */
162
+ async function tape(url, opts = {}) {
163
+ const doFetch = opts.fetch ?? defaults.fetch ?? globalThis.fetch;
164
+ const headers = { accept: ACCEPT, ...defaults.headers, ...opts.headers };
165
+ const res = await doFetch(base + url, { method: 'GET', headers, ...opts.init });
166
+ const type = res.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '';
167
+ const text = await res.text();
168
+ if (!res.ok) {
169
+ let parsed = text;
170
+ try { parsed = type === TRON_CONTENT_TYPE ? decodeText(text, registry) : JSON.parse(text); } catch { /* keep text */ }
171
+ throw new ApiError(res.status, parsed, res);
172
+ }
173
+ if (text === '') return tableFromRows([], opts.fields);
174
+ if (type === TRON_CONTENT_TYPE) {
175
+ const col = decodeColumnar(text, true);
176
+ if (col) return col;
177
+ return tableFromRows(decodeText(text, registry), opts.fields);
178
+ }
179
+ return tableFromRows(JSON.parse(text), opts.fields);
180
+ }
181
+
75
182
  return {
76
183
  request,
77
184
  get: (url, opts) => request('GET', url, undefined, opts),
78
185
  del: (url, opts) => request('DELETE', url, undefined, opts),
79
186
  post: (url, body, opts) => request('POST', url, body, opts),
80
187
  put: (url, body, opts) => request('PUT', url, body, opts),
81
- patch: (url, body, opts) => request('PATCH', url, body, opts)
188
+ patch: (url, body, opts) => request('PATCH', url, body, opts),
189
+ tape
82
190
  };
83
191
  }
84
192
 
@@ -0,0 +1,120 @@
1
+ // Row-constructor factories shared by every decoder.
2
+ //
3
+ // The compiled form (`new Function`) hands V8 a monomorphic object literal
4
+ // `{k1:r[0],k2:r[1],...}` — one hidden-class allocation per row. Runtimes
5
+ // that forbid code generation from strings (Cloudflare Workers, strict CSP)
6
+ // throw an EvalError on the first `new Function`, so every call site goes
7
+ // through here and falls back to an equivalent closure: same fields, same
8
+ // order, same dictionary handling — only the allocation is a little slower.
9
+
10
+ let codegenAllowed = null;
11
+
12
+ // Probed once per isolate. `new Function` throws at construction time when
13
+ // code generation is disallowed, which is exactly the signal we want.
14
+ export function canCodegen() {
15
+ if (codegenAllowed === null) {
16
+ try { new Function('return 1')(); codegenAllowed = true; }
17
+ catch (e) { codegenAllowed = false; }
18
+ }
19
+ return codegenAllowed;
20
+ }
21
+
22
+ // ---- plain row constructor -------------------------------------------
23
+ // Returns a factory `fac(D) -> ctor(r)` where D is the per-field dictionary
24
+ // table (index -> value, or null for non-dictionary columns).
25
+ //
26
+ // fields ordered field names
27
+ // dictFlags 1 for dictionary columns, 0 otherwise
28
+ // offset index of the first field inside the wire row (marker mode = 1)
29
+ // typed when true, a dictionary column only dereferences NUMBERS and
30
+ // passes any other wire value through literally (schema mode:
31
+ // the encoder may emit an out-of-set string as-is)
32
+
33
+ export function compiledRowCtorFactory(fields, dictFlags, offset, typed) {
34
+ let body = 'return function(r){return {';
35
+ for (let k = 0; k < fields.length; k++) {
36
+ if (k) body += ',';
37
+ const i = k + offset;
38
+ let src;
39
+ if (dictFlags[k] !== 1) src = 'r[' + i + ']';
40
+ else if (typed) src = '(typeof r[' + i + ']==="number"?D[' + k + '][r[' + i + ']]:r[' + i + '])';
41
+ else src = 'D[' + k + '][r[' + i + ']]';
42
+ body += JSON.stringify(fields[k]) + ':' + src;
43
+ }
44
+ body += '}}';
45
+ return new Function('D', body);
46
+ }
47
+
48
+ export function closureRowCtorFactory(fields, dictFlags, offset, typed) {
49
+ const nf = fields.length;
50
+ const names = fields.slice();
51
+ const flags = dictFlags.slice();
52
+ return function (D) {
53
+ return function (r) {
54
+ const o = {};
55
+ for (let k = 0; k < nf; k++) {
56
+ const v = r[k + offset];
57
+ o[names[k]] = flags[k] === 1 && (!typed || typeof v === 'number') ? D[k][v] : v;
58
+ }
59
+ return o;
60
+ };
61
+ };
62
+ }
63
+
64
+ export function rowCtorFactory(fields, dictFlags, offset, typed) {
65
+ return canCodegen()
66
+ ? compiledRowCtorFactory(fields, dictFlags, offset, typed)
67
+ : closureRowCtorFactory(fields, dictFlags, offset, typed);
68
+ }
69
+
70
+ // ---- lazy row class ---------------------------------------------------
71
+ // simdjson "On Demand" style: the row keeps the raw wire array and exposes
72
+ // one getter per field, plus toJSON() so JSON.stringify sees a plain object.
73
+
74
+ export function compiledLazyClassFactory(fields, dictFlags, offset) {
75
+ let body = 'return class{constructor(r){this._r=r}';
76
+ for (let k = 0; k < fields.length; k++) {
77
+ const fname = JSON.stringify(fields[k]);
78
+ const expr = dictFlags[k] === 1
79
+ ? ('D[' + k + '][this._r[' + (k + offset) + ']]')
80
+ : ('this._r[' + (k + offset) + ']');
81
+ body += 'get ' + fname + '(){return ' + expr + '}';
82
+ }
83
+ body += 'toJSON(){return {';
84
+ for (let k = 0; k < fields.length; k++) {
85
+ if (k) body += ',';
86
+ body += JSON.stringify(fields[k]) + ':this[' + JSON.stringify(fields[k]) + ']';
87
+ }
88
+ body += '}}}';
89
+ return new Function('D', body);
90
+ }
91
+
92
+ export function closureLazyClassFactory(fields, dictFlags, offset) {
93
+ const nf = fields.length;
94
+ const names = fields.slice();
95
+ const flags = dictFlags.slice();
96
+ return function (D) {
97
+ class Row {
98
+ constructor(r) { this._r = r; }
99
+ toJSON() {
100
+ const o = {};
101
+ for (let k = 0; k < nf; k++) o[names[k]] = this[names[k]];
102
+ return o;
103
+ }
104
+ }
105
+ for (let k = 0; k < nf; k++) {
106
+ const i = k + offset;
107
+ const get = flags[k] === 1
108
+ ? function () { return D[k][this._r[i]]; }
109
+ : function () { return this._r[i]; };
110
+ Object.defineProperty(Row.prototype, names[k], { get, enumerable: true, configurable: true });
111
+ }
112
+ return Row;
113
+ };
114
+ }
115
+
116
+ export function lazyClassFactory(fields, dictFlags, offset) {
117
+ return canCodegen()
118
+ ? compiledLazyClassFactory(fields, dictFlags, offset)
119
+ : closureLazyClassFactory(fields, dictFlags, offset);
120
+ }
@@ -1,3 +1,5 @@
1
+ import { rowCtorFactory } from './codegen.js';
2
+
1
3
  // SCHEMA PRE-REGISTRATION — "preload, peek, process".
2
4
  //
3
5
  // In a real API the client already knows the response shape from the interface
@@ -106,19 +108,12 @@ function compile(spec) {
106
108
  }
107
109
  const anyDict = decTables.some(d => d !== null);
108
110
 
109
- // ---- row constructor, compiled ONCE at startup rather than per request ----
111
+ // ---- row constructor, built ONCE at startup rather than per request ----
110
112
  // A dictionary column may still carry a literal string if the encoder saw a
111
- // value outside the declared set, so those columns test the wire type.
112
- let body = 'return function(r){return {';
113
- for (let k = 0; k < nf; k++) {
114
- if (k) body += ',';
115
- const src = decTables[k] === null
116
- ? 'r[' + k + ']'
117
- : '(typeof r[' + k + ']==="number"?D[' + k + '][r[' + k + ']]:r[' + k + '])';
118
- body += JSON.stringify(fields[k]) + ':' + src;
119
- }
120
- body += '}}';
121
- const ctor = new Function('D', body)(decTables);
113
+ // value outside the declared set, so those columns test the wire type
114
+ // (`typed` mode). Compiled via new Function where allowed, closure elsewhere.
115
+ const dictFlags = decTables.map(d => (d === null ? 0 : 1));
116
+ const ctor = rowCtorFactory(fields, dictFlags, 0, true)(decTables);
122
117
 
123
118
  // navigate to the table's container; compiled to a straight-line walk
124
119
  function locate(root) {
@@ -1,3 +1,5 @@
1
+ import { rowCtorFactory } from './codegen.js';
2
+
1
3
  // TABLE MODE — transform-free decoding.
2
4
  //
3
5
  // Profiling the remaining losses showed the decoder was paying for text
@@ -95,13 +97,7 @@ function getCtor(fields, dictFlags, dicts) {
95
97
  const key = dictFlags.join('') + '|' + fields.join(',');
96
98
  let fac = ctorCache.get(key);
97
99
  if (!fac) {
98
- let body = 'return function(r){return {';
99
- for (let k = 0; k < fields.length; k++) {
100
- if (k) body += ',';
101
- body += JSON.stringify(fields[k]) + ':' + (dictFlags[k] === 1 ? ('D[' + k + '][r[' + k + ']]') : ('r[' + k + ']'));
102
- }
103
- body += '}}';
104
- fac = new Function('D', body);
100
+ fac = rowCtorFactory(fields, dictFlags, 0, false);
105
101
  ctorCache.set(key, fac);
106
102
  }
107
103
  return fac(dicts);
@@ -24,10 +24,12 @@
24
24
  // Safe because canonical TRON escapes "(" and ")" inside strings (see encStr
25
25
  // in tron.js), so every raw paren in the document is structural.
26
26
  //
27
- // Caveats: needs new Function (CSP-restricted environments fall back to
28
- // parseFast); a literal U+0001 in the source falls back to the scanner.
27
+ // Caveats: row constructors are compiled with new Function when the runtime
28
+ // allows it and built as closures otherwise (Cloudflare Workers, strict CSP —
29
+ // see codegen.js); a literal U+0001 in the source falls back to the scanner.
29
30
 
30
31
  import { parsePrelude, parseFast, applyTables } from './tron.js';
32
+ import { rowCtorFactory, lazyClassFactory } from './codegen.js';
31
33
 
32
34
  const MARK = '\u0001';
33
35
  const MARK_ESC = '\\u0001'; // the 6-character JSON escape, not the raw char
@@ -48,14 +50,7 @@ function getCtorFactory(fields, dictFlags, offset) {
48
50
  const key = offset + '|' + dictFlags.join('') + '|' + fields.join(',');
49
51
  let fac = ctorFactoryCache.get(key);
50
52
  if (fac) return fac;
51
- let body = 'return function(r){return {';
52
- for (let k = 0; k < fields.length; k++) {
53
- if (k) body += ',';
54
- body += JSON.stringify(fields[k]) + ':';
55
- body += dictFlags[k] === 1 ? ('D[' + k + '][r[' + (k + offset) + ']]') : ('r[' + (k + offset) + ']');
56
- }
57
- body += '}}';
58
- fac = new Function('D', body);
53
+ fac = rowCtorFactory(fields, dictFlags, offset, false);
59
54
  ctorFactoryCache.set(key, fac);
60
55
  return fac;
61
56
  }
@@ -77,21 +72,7 @@ function getLazyClass(fields, dictFlags, offset) {
77
72
  const key = offset + '|' + dictFlags.join('') + '|' + fields.join(',');
78
73
  let fac = lazyClassCache.get(key);
79
74
  if (fac) return fac;
80
- let body = 'return class{constructor(r){this._r=r}';
81
- for (let k = 0; k < fields.length; k++) {
82
- const fname = JSON.stringify(fields[k]);
83
- const expr = dictFlags[k] === 1
84
- ? ('D[' + k + '][this._r[' + (k + offset) + ']]')
85
- : ('this._r[' + (k + offset) + ']');
86
- body += 'get ' + fname + '(){return ' + expr + '}';
87
- }
88
- body += 'toJSON(){return {';
89
- for (let k = 0; k < fields.length; k++) {
90
- if (k) body += ',';
91
- body += JSON.stringify(fields[k]) + ':this[' + JSON.stringify(fields[k]) + ']';
92
- }
93
- body += '}}}';
94
- fac = new Function('D', body);
75
+ fac = lazyClassFactory(fields, dictFlags, offset);
95
76
  lazyClassCache.set(key, fac);
96
77
  return fac;
97
78
  }
package/src/core/tron.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { rowCtorFactory } from './codegen.js';
2
+
1
3
  // TRON — Token Reduced Object Notation (interpretation of the documented format).
2
4
  // A superset of JSON that adds class declarations + positional instantiation:
3
5
  //
@@ -141,13 +143,7 @@ function _tblCtor(fields, dictFlags, dicts) {
141
143
  const key = dictFlags.join('') + '|' + fields.join(',');
142
144
  let fac = _tblCtorCache.get(key);
143
145
  if (!fac) {
144
- let body = 'return function(r){return {';
145
- for (let k = 0; k < fields.length; k++) {
146
- if (k) body += ',';
147
- body += JSON.stringify(fields[k]) + ':' + (dictFlags[k] === 1 ? ('D[' + k + '][r[' + k + ']]') : ('r[' + k + ']'));
148
- }
149
- body += '}}';
150
- fac = new Function('D', body);
146
+ fac = rowCtorFactory(fields, dictFlags, 0, false);
151
147
  _tblCtorCache.set(key, fac);
152
148
  }
153
149
  return fac(dicts);
package/src/server.d.ts CHANGED
@@ -13,6 +13,12 @@ export interface Serializer {
13
13
  export interface TronSerializerOptions extends EncodeOptions {
14
14
  /** Compiled schema from defineSchema() — skips shape detection (fastest mode). */
15
15
  schema?: CompiledSchema;
16
+ /**
17
+ * Emit the columnar form `api.tape()` / `decodeColumnar()` read into a
18
+ * Float64Array. The handler must return a root array of flat numeric rows.
19
+ * Mutually exclusive with `schema`.
20
+ */
21
+ columnar?: boolean;
16
22
  }
17
23
 
18
24
  /** Build a serializer for norns route() / setSerializer() / boot({ serializer }). */
package/src/server.js CHANGED
@@ -10,13 +10,14 @@
10
10
  * Per-route override / opt-out:
11
11
  * export const POST = route({ serializer: null, handler }); // plain JSON
12
12
  * export const GET = route({ serializer: tronSerializer({ schema }), handler });
13
+ * export const GET = route({ serializer: tronSerializer({ columnar: true }), handler });
13
14
  *
14
15
  * The serializer only kicks in when the client asked for TRON via the Accept
15
16
  * header, so curl, third parties and existing consumers keep getting JSON.
16
17
  * Plain JSON is valid TRON, so clients may also send TRON request bodies
17
18
  * unconditionally — `parseBody` reads both.
18
19
  */
19
- import { encode, decode } from './index.js';
20
+ import { encode, encodeColumnar, decode } from './index.js';
20
21
 
21
22
  export const TRON_CONTENT_TYPE = 'application/tron';
22
23
 
@@ -62,20 +63,29 @@ function devWarn(value, path) {
62
63
  * @param {object} [opts]
63
64
  * @param {{encode:Function, decode:Function}} [opts.schema] compiled schema from
64
65
  * defineSchema() — skips shape detection entirely (fastest mode)
66
+ * @param {boolean} [opts.columnar] emit the form `decodeColumnar` / `api.tape()`
67
+ * can read into a Float64Array. The handler must return a root array of
68
+ * flat rows whose values are all numbers (or dictionary-able strings /
69
+ * booleans). Anything else still round-trips, just through the object path.
65
70
  * @param {number} [opts.minBytes] below this JSON size, emit plain JSON (default 1024)
66
71
  * @param {boolean} [opts.dict] dictionary-encode low-cardinality columns (default true)
67
72
  * @param {boolean|'nested'} [opts.table] emit table declarations (default true)
68
73
  * @returns {{serialize: Function, parseBody: Function}}
69
74
  */
70
75
  export function tronSerializer(opts = {}) {
71
- const { schema, ...encodeOpts } = opts;
76
+ const { schema, columnar, ...encodeOpts } = opts;
77
+ if (schema && columnar) throw new Error('tronSerializer: `schema` and `columnar` are mutually exclusive');
72
78
  return {
73
79
  /** @returns {Response|null} null = fall through to json() */
74
80
  serialize(result, event) {
75
81
  if (!acceptsTron(event.request)) return null;
76
82
  const value = result ?? null;
77
83
  if (DEV) devWarn(value, event.url?.pathname ?? '$');
78
- const body = schema ? schema.encode(value) : encode(value, encodeOpts);
84
+ const body = schema
85
+ ? schema.encode(value)
86
+ : columnar
87
+ ? encodeColumnar(value, { minBytes: encodeOpts.minBytes, force: encodeOpts.force })
88
+ : encode(value, encodeOpts);
79
89
  return new Response(body, {
80
90
  headers: { 'content-type': TRON_CONTENT_TYPE }
81
91
  });