@human-synthesis/norns-tron 0.0.1 → 0.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-tron",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
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)",
@@ -24,6 +24,7 @@
24
24
  "./server": "./src/server.js",
25
25
  "./client": "./src/client.js",
26
26
  "./valibot": "./src/valibot.js",
27
+ "./spec": "./src/spec.js",
27
28
  "./package.json": "./package.json"
28
29
  },
29
30
  "peerDependencies": {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Canonical pretty-printer for on-disk TRON spec files.
3
+ *
4
+ * The canonical form is the git-tracked representation of a Norns spec:
5
+ * deterministic (same value -> byte-identical text), line-oriented (one field
6
+ * per line so diffs and merges stay readable), and JSON-compatible (plain JSON
7
+ * is valid TRON, so `decode()` reads canonical files with zero special cases).
8
+ *
9
+ * Rules:
10
+ * - JSON value semantics: `toJSON()` honored (Dates -> ISO strings),
11
+ * `undefined` keys dropped, NaN/Infinity -> null — exactly JSON.stringify.
12
+ * - Keys sorted: priority list first (spec-aware default), rest by codepoint.
13
+ * - A node renders inline when its one-line form fits `maxInline` columns at
14
+ * its indentation; otherwise one child per line. The rule is a pure
15
+ * function of the value, so formatting never depends on prior state.
16
+ * - 2-space indent, LF, single trailing newline.
17
+ */
18
+
19
+ /**
20
+ * Spec-aware key ordering: identity and contract keys surface before bulk
21
+ * collections, so unit diffs lead with what a reviewer needs first. Unknown
22
+ * keys sort after all of these, alphabetically.
23
+ */
24
+ export const SPEC_KEY_PRIORITY = [
25
+ // app / module identity
26
+ 'module', 'depends', 'dialect', 'uid', 'name',
27
+ // unit contract essentials
28
+ 'type', 'ref', 'from', 'route', 'owner', 'input', 'requires', 'transport',
29
+ 'props', 'events', 'slots', 'live', 'groupBy',
30
+ // unit body
31
+ 'fields', 'status', 'steps', 'emits', 'refresh', 'read', 'write',
32
+ 'state', 'components', 'examples', 'impl', 'source', 'body',
33
+ // module collections (bulk, last)
34
+ 'entities', 'queries', 'actions', 'policies', 'pages',
35
+ 'triggers', 'functions', 'settings',
36
+ ];
37
+
38
+ const DEFAULT_MAX_INLINE = 100;
39
+ const DEFAULT_INDENT = 2;
40
+
41
+ /**
42
+ * Format a value as canonical TRON text.
43
+ *
44
+ * @param {*} value anything JSON.stringify accepts
45
+ * @param {{indent?:number, maxInline?:number, keyPriority?:string[]}} [opts]
46
+ * @returns {string} canonical text, LF line endings, single trailing newline
47
+ */
48
+ export function formatCanonical(value, opts) {
49
+ const o = opts || {};
50
+ const ctx = {
51
+ indent: ' '.repeat(o.indent === undefined ? DEFAULT_INDENT : o.indent),
52
+ maxInline: o.maxInline === undefined ? DEFAULT_MAX_INLINE : o.maxInline,
53
+ priority: buildPriority(o.keyPriority === undefined ? SPEC_KEY_PRIORITY : o.keyPriority),
54
+ };
55
+ // Normalize through JSON to inherit its value semantics exactly
56
+ // (toJSON, undefined-key dropping, NaN -> null, BigInt throws).
57
+ const json = JSON.stringify(value);
58
+ if (json === undefined) return 'null\n';
59
+ const tree = JSON.parse(json);
60
+ return render(tree, '', ctx) + '\n';
61
+ }
62
+
63
+ function buildPriority(list) {
64
+ const m = new Map();
65
+ for (let i = 0; i < list.length; i++) if (!m.has(list[i])) m.set(list[i], i);
66
+ return m;
67
+ }
68
+
69
+ function sortedKeys(node, priority) {
70
+ return Object.keys(node).sort((a, b) => {
71
+ const pa = priority.has(a) ? priority.get(a) : Infinity;
72
+ const pb = priority.has(b) ? priority.get(b) : Infinity;
73
+ if (pa !== pb) return pa - pb;
74
+ return a < b ? -1 : a > b ? 1 : 0;
75
+ });
76
+ }
77
+
78
+ function inline(node, ctx) {
79
+ if (node === null || typeof node !== 'object') return JSON.stringify(node);
80
+ if (Array.isArray(node)) {
81
+ if (node.length === 0) return '[]';
82
+ const parts = new Array(node.length);
83
+ for (let i = 0; i < node.length; i++) parts[i] = inline(node[i], ctx);
84
+ return '[' + parts.join(', ') + ']';
85
+ }
86
+ const keys = sortedKeys(node, ctx.priority);
87
+ if (keys.length === 0) return '{}';
88
+ const parts = new Array(keys.length);
89
+ for (let i = 0; i < keys.length; i++) {
90
+ parts[i] = JSON.stringify(keys[i]) + ': ' + inline(node[keys[i]], ctx);
91
+ }
92
+ return '{ ' + parts.join(', ') + ' }';
93
+ }
94
+
95
+ function render(node, pad, ctx) {
96
+ const flat = inline(node, ctx);
97
+ if (pad.length + flat.length <= ctx.maxInline) return flat;
98
+ if (node === null || typeof node !== 'object') return flat;
99
+ const childPad = pad + ctx.indent;
100
+ if (Array.isArray(node)) {
101
+ const items = new Array(node.length);
102
+ for (let i = 0; i < node.length; i++) {
103
+ items[i] = childPad + render(node[i], childPad, ctx);
104
+ }
105
+ return '[\n' + items.join(',\n') + '\n' + pad + ']';
106
+ }
107
+ const keys = sortedKeys(node, ctx.priority);
108
+ const items = new Array(keys.length);
109
+ for (let i = 0; i < keys.length; i++) {
110
+ items[i] = childPad + JSON.stringify(keys[i]) + ': ' + render(node[keys[i]], childPad, ctx);
111
+ }
112
+ return '{\n' + items.join(',\n') + '\n' + pad + '}';
113
+ }
package/src/index.d.ts CHANGED
@@ -87,6 +87,19 @@ export function defineSchema(spec: SchemaSpec): CompiledSchema;
87
87
  /** A registry for services serving several response shapes. */
88
88
  export function createRegistry(): SchemaRegistry;
89
89
 
90
+ /**
91
+ * Format a value as canonical TRON text — the deterministic, line-oriented,
92
+ * JSON-compatible on-disk form used for spec files. Same value → byte-identical
93
+ * text; `decode()` reads it back with no special cases.
94
+ */
95
+ export function formatCanonical(
96
+ value: unknown,
97
+ opts?: { indent?: number; maxInline?: number; keyPriority?: string[] }
98
+ ): string;
99
+
100
+ /** Default key ordering for spec files: contract keys before bulk collections. */
101
+ export const SPEC_KEY_PRIORITY: string[];
102
+
90
103
  /** True when the WASM fast path is usable here. */
91
104
  export function wasmAvailable(): boolean;
92
105
 
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import * as ENCODER from './core/tron-encode.js';
21
21
  import * as AUTO from './core/tron-auto.js';
22
22
  import * as SCHEMA from './core/tron-schema.js';
23
23
  import * as WASM from './core/tron-wasm.js';
24
+ import { formatCanonical, SPEC_KEY_PRIORITY } from './canonical.js';
24
25
 
25
26
  // Below this many bytes of equivalent JSON, the fixed costs of shape detection
26
27
  // dominate and plain JSON is simply faster and no smaller. Measured crossover
@@ -155,6 +156,8 @@ const raw = {
155
156
  export {
156
157
  encode,
157
158
  decode,
159
+ formatCanonical,
160
+ SPEC_KEY_PRIORITY,
158
161
  encodeColumnar,
159
162
  decodeColumnar,
160
163
  defineSchema,
package/src/spec.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ export const SPEC_EXT: '.tron';
2
+ export const APP_SPEC: 'app';
3
+
4
+ /** sha256 hex of a value's canonical text — the unit of change detection. */
5
+ export function specHash(value: unknown): string;
6
+
7
+ /** Decode one spec file (canonical or any TRON/JSON). */
8
+ export function readSpec(file: string): unknown;
9
+
10
+ /**
11
+ * Write a value to `file` in canonical form, creating parent dirs.
12
+ * No-op (changed: false) when on-disk bytes already match.
13
+ */
14
+ export function writeSpec(
15
+ file: string,
16
+ value: unknown
17
+ ): { text: string; hash: string; changed: boolean };
18
+
19
+ /** Read a whole `specs/` directory. */
20
+ export function readSpecs(dir: string): {
21
+ app: unknown;
22
+ modules: Record<string, unknown>;
23
+ hashes: Record<string, string>;
24
+ version: string;
25
+ };
26
+
27
+ /** Combine per-module hashes into one deterministic app version hash. */
28
+ export function combineHashes(hashes: Record<string, string>): string;
package/src/spec.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Spec-file helpers — server-side only (`@human-synthesis/norns-tron/spec`).
3
+ *
4
+ * A Norns app's canonical spec lives in `specs/` as one `.tron` file per
5
+ * module plus `app.tron`, written in canonical form (see canonical.js).
6
+ * These helpers read/write that directory and compute the content-addressed
7
+ * version hashes used by `ifVersion` optimistic checks and incremental
8
+ * generation.
9
+ */
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
13
+ import { basename, dirname, join } from 'node:path';
14
+
15
+ import { formatCanonical } from './canonical.js';
16
+ import * as AUTO from './core/tron-auto.js';
17
+
18
+ export const SPEC_EXT = '.tron';
19
+ export const APP_SPEC = 'app';
20
+
21
+ /** sha256 hex of a value's canonical text — the unit of change detection. */
22
+ export function specHash(value) {
23
+ return createHash('sha256').update(formatCanonical(value), 'utf-8').digest('hex');
24
+ }
25
+
26
+ /** Decode one spec file (canonical or any TRON/JSON). */
27
+ export function readSpec(file) {
28
+ return AUTO.decode(readFileSync(file, 'utf-8'));
29
+ }
30
+
31
+ /**
32
+ * Write a value to `file` in canonical form, creating parent dirs.
33
+ * Skips the write when the on-disk bytes already match, so watchers and
34
+ * mtimes stay quiet on no-op applies.
35
+ *
36
+ * @returns {{ text: string, hash: string, changed: boolean }}
37
+ */
38
+ export function writeSpec(file, value) {
39
+ const text = formatCanonical(value);
40
+ const hash = createHash('sha256').update(text, 'utf-8').digest('hex');
41
+ let existing = null;
42
+ try {
43
+ existing = readFileSync(file, 'utf-8');
44
+ } catch {
45
+ // new file
46
+ }
47
+ if (existing === text) return { text, hash, changed: false };
48
+ mkdirSync(dirname(file), { recursive: true });
49
+ writeFileSync(file, text, 'utf-8');
50
+ return { text, hash, changed: true };
51
+ }
52
+
53
+ /**
54
+ * Read a whole `specs/` directory.
55
+ *
56
+ * @param {string} dir
57
+ * @returns {{
58
+ * app: *, // app.tron contents (null if absent)
59
+ * modules: Record<string, *>, // module name -> spec value
60
+ * hashes: Record<string, string>, // per-file canonical hash (incl. 'app')
61
+ * version: string // hash over all files — the app version
62
+ * }}
63
+ */
64
+ export function readSpecs(dir) {
65
+ const names = readdirSync(dir)
66
+ .filter((f) => f.endsWith(SPEC_EXT))
67
+ .sort();
68
+ const modules = {};
69
+ const hashes = {};
70
+ let app = null;
71
+ for (const f of names) {
72
+ const name = basename(f, SPEC_EXT);
73
+ const value = readSpec(join(dir, f));
74
+ hashes[name] = specHash(value);
75
+ if (name === APP_SPEC) app = value;
76
+ else modules[name] = value;
77
+ }
78
+ return { app, modules, hashes, version: combineHashes(hashes) };
79
+ }
80
+
81
+ /**
82
+ * Combine per-module hashes into one app version hash. Order-independent
83
+ * input, deterministic output.
84
+ */
85
+ export function combineHashes(hashes) {
86
+ const h = createHash('sha256');
87
+ for (const name of Object.keys(hashes).sort()) {
88
+ h.update(name).update(':').update(hashes[name]).update('\n');
89
+ }
90
+ return h.digest('hex');
91
+ }