@human-synthesis/norns 0.0.16 → 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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Addressing — stable identity for spec units.
3
+ *
4
+ * A unit lives at `module.Kind.name` (e.g. `orders.Action.submit`); a path
5
+ * extends that with a dotted sub-path into the unit's value
6
+ * (`orders.Entity.Order.fields.note`). Every object-valued unit also carries
7
+ * an immutable `uid` (ULID), so references survive renames: the address is
8
+ * how humans and specs refer to a unit, the uid is how history and the edge
9
+ * graph do.
10
+ *
11
+ * Unit names may themselves contain dots (Trigger sources like
12
+ * `catalog.Product.deleted`), so splitting a path into name vs sub-path is
13
+ * only possible against a loaded spec — that is what the index is for.
14
+ */
15
+
16
+ import { randomBytes } from 'node:crypto';
17
+
18
+ /** Spec collection key → unit kind. Order irrelevant; keys are the schema. */
19
+ export const KIND_KEYS = {
20
+ Entity: 'entities',
21
+ Query: 'queries',
22
+ Action: 'actions',
23
+ Policy: 'policies',
24
+ Page: 'pages',
25
+ Trigger: 'triggers',
26
+ Function: 'functions',
27
+ Component: 'components',
28
+ Route: 'routes',
29
+ Worker: 'workers',
30
+ Adapter: 'adapters',
31
+ Middleware: 'middleware',
32
+ Plugin: 'plugins'
33
+ };
34
+
35
+ export const KINDS = Object.keys(KIND_KEYS);
36
+
37
+ /** @type {Record<string, string>} collection key → Kind */
38
+ export const KEY_KINDS = Object.fromEntries(Object.entries(KIND_KEYS).map(([k, v]) => [v, k]));
39
+
40
+ const MODULE_RE = /^[a-z][a-z0-9_]*$/;
41
+ const NAME_SEGMENT_RE = /^[A-Za-z_$][A-Za-z0-9_$-]*$/;
42
+
43
+ /** @typedef {{ module: string, kind: string, name: string }} Address */
44
+
45
+ /** `orders.Action.submit` ← { module, kind, name }. */
46
+ export function formatAddress({ module, kind, name }) {
47
+ return `${module}.${kind}.${name}`;
48
+ }
49
+
50
+ /**
51
+ * Parse a unit address. The second segment must be a known Kind; everything
52
+ * after it is the name (which may itself contain dots).
53
+ *
54
+ * @param {string} text
55
+ * @returns {Address}
56
+ */
57
+ export function parseAddress(text) {
58
+ const parts = String(text).split('.');
59
+ const [module, kind] = parts;
60
+ const name = parts.slice(2).join('.');
61
+ if (parts.length < 3 || name === '') {
62
+ throw new Error(`invalid address "${text}" — expected module.Kind.name`);
63
+ }
64
+ if (!MODULE_RE.test(module)) {
65
+ throw new Error(`invalid address "${text}" — bad module segment "${module}"`);
66
+ }
67
+ if (!KINDS.includes(kind)) {
68
+ throw new Error(`invalid address "${text}" — unknown kind "${kind}"`);
69
+ }
70
+ if (!parts.slice(2).every((s) => NAME_SEGMENT_RE.test(s))) {
71
+ throw new Error(`invalid address "${text}" — bad name "${name}"`);
72
+ }
73
+ return { module, kind, name };
74
+ }
75
+
76
+ /** @param {string} text */
77
+ export function isAddress(text) {
78
+ try {
79
+ parseAddress(text);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
87
+
88
+ /** New ULID — 10 chars of timestamp + 16 chars of randomness, sortable. */
89
+ export function newUid(now = Date.now()) {
90
+ let ts = '';
91
+ let t = now;
92
+ for (let i = 0; i < 10; i++) {
93
+ ts = CROCKFORD[t % 32] + ts;
94
+ t = Math.floor(t / 32);
95
+ }
96
+ const bytes = randomBytes(16);
97
+ let rand = '';
98
+ for (let i = 0; i < 16; i++) rand += CROCKFORD[bytes[i] % 32];
99
+ return ts + rand;
100
+ }
101
+
102
+ function isUnitObject(value) {
103
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
104
+ }
105
+
106
+ /**
107
+ * @typedef {{
108
+ * address: string, module: string, kind: string, name: string,
109
+ * uid: string | null, value: *
110
+ * }} Unit
111
+ */
112
+
113
+ /**
114
+ * List every unit in one module spec.
115
+ *
116
+ * @param {string} moduleName
117
+ * @param {*} moduleSpec
118
+ * @returns {Unit[]}
119
+ */
120
+ export function listUnits(moduleName, moduleSpec) {
121
+ const units = [];
122
+ if (!isUnitObject(moduleSpec)) return units;
123
+ for (const [key, kind] of Object.entries(KEY_KINDS)) {
124
+ const collection = moduleSpec[key];
125
+ if (!isUnitObject(collection)) continue;
126
+ for (const [name, value] of Object.entries(collection)) {
127
+ units.push({
128
+ address: formatAddress({ module: moduleName, kind, name }),
129
+ module: moduleName,
130
+ kind,
131
+ name,
132
+ uid: isUnitObject(value) && typeof value.uid === 'string' ? value.uid : null,
133
+ value
134
+ });
135
+ }
136
+ }
137
+ return units;
138
+ }
139
+
140
+ /**
141
+ * Assign a fresh uid to every object-valued unit that lacks one, mutating
142
+ * the module specs in place (string-shorthand units carry no uid until
143
+ * they are expanded to objects).
144
+ *
145
+ * @param {Record<string, *>} modules module name → spec value
146
+ * @returns {string[]} addresses that received a uid
147
+ */
148
+ export function ensureUids(modules) {
149
+ const assigned = [];
150
+ for (const [moduleName, spec] of Object.entries(modules)) {
151
+ for (const unit of listUnits(moduleName, spec)) {
152
+ if (unit.uid === null && isUnitObject(unit.value)) {
153
+ unit.value.uid = newUid();
154
+ assigned.push(unit.address);
155
+ }
156
+ }
157
+ }
158
+ return assigned;
159
+ }
160
+
161
+ /**
162
+ * @typedef {{
163
+ * units: Unit[],
164
+ * byAddress: Map<string, Unit>,
165
+ * byUid: Map<string, Unit>,
166
+ * issues: { level: 'error', address: string, message: string }[]
167
+ * }} UnitIndex
168
+ */
169
+
170
+ /**
171
+ * Index all units across an app's modules for address/uid resolution.
172
+ * Duplicate uids are reported as issues (addresses cannot collide — they
173
+ * are object keys).
174
+ *
175
+ * @param {Record<string, *>} modules
176
+ * @returns {UnitIndex}
177
+ */
178
+ export function indexUnits(modules) {
179
+ const units = [];
180
+ const byAddress = new Map();
181
+ const byUid = new Map();
182
+ const issues = [];
183
+ for (const [moduleName, spec] of Object.entries(modules)) {
184
+ for (const unit of listUnits(moduleName, spec)) {
185
+ units.push(unit);
186
+ byAddress.set(unit.address, unit);
187
+ if (unit.uid !== null) {
188
+ const prior = byUid.get(unit.uid);
189
+ if (prior) {
190
+ issues.push({
191
+ level: 'error',
192
+ address: unit.address,
193
+ message: `duplicate uid ${unit.uid} — already used by ${prior.address}`
194
+ });
195
+ } else {
196
+ byUid.set(unit.uid, unit);
197
+ }
198
+ }
199
+ }
200
+ }
201
+ return { units, byAddress, byUid, issues };
202
+ }
203
+
204
+ /**
205
+ * Resolve a path (`module.Kind.name[.sub.path]`) against an index. Because
206
+ * unit names may contain dots, the longest name that matches an existing
207
+ * unit wins; the remainder is the sub-path.
208
+ *
209
+ * @param {UnitIndex} index
210
+ * @param {string} text
211
+ * @returns {{ unit: Unit, subPath: string[] } | null}
212
+ */
213
+ export function resolvePath(index, text) {
214
+ const parts = String(text).split('.');
215
+ if (parts.length < 3) return null;
216
+ const [module, kind] = parts;
217
+ const rest = parts.slice(2);
218
+ for (let take = rest.length; take >= 1; take--) {
219
+ const name = rest.slice(0, take).join('.');
220
+ const unit = index.byAddress.get(formatAddress({ module, kind, name }));
221
+ if (unit) return { unit, subPath: rest.slice(take) };
222
+ }
223
+ return null;
224
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Adopt — wrap existing hand-written code as Level-3 units (K-20, PLAN §268).
3
+ *
4
+ * `adoptUnit` inspects one hand-written source file and proposes the
5
+ * Level-3 declaration (`{ source, auth, capabilities }` under Route /
6
+ * Worker / Adapter / Middleware) that would bring it under spec ownership.
7
+ * Everything is inference-with-evidence: the kind comes from the file's
8
+ * export shape, capabilities from what it touches, and auth from whether
9
+ * it reads the user/session — but auth is a *declaration*, so every
10
+ * proposal says the human must confirm it. Like absorb, this only ever
11
+ * proposes ops; nothing is applied here.
12
+ */
13
+
14
+ import { KIND_KEYS } from './address.js';
15
+
16
+ const NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$-]*$/;
17
+ const HTTP_VERBS = 'GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD';
18
+
19
+ const KIND_RULES = [
20
+ {
21
+ kind: 'Worker',
22
+ test: (src, path) =>
23
+ (/export\s+default\s*\{[^}]*\b(fetch|scheduled|queue)\b/s.test(src) && 'exports a default { fetch/scheduled/queue } worker object') ||
24
+ (/extends\s+DurableObject\b/.test(src) && 'a class extends DurableObject') ||
25
+ (/\bwebSocketMessage\s*[(:=]/.test(src) && 'implements webSocketMessage') ||
26
+ (/addEventListener\(\s*['"](fetch|scheduled)['"]/.test(src) && "registers a global fetch/scheduled listener") ||
27
+ (/\.worker\.(c|civet|js|ts)$/.test(path) && 'file is named *.worker.*')
28
+ },
29
+ {
30
+ kind: 'Route',
31
+ test: (src, path) =>
32
+ (new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+|const\\s+)?(${HTTP_VERBS})\\b`).test(src) && 'exports HTTP verb handlers') ||
33
+ (new RegExp(`\\b(${HTTP_VERBS})\\s*:=`).test(src) && 'exports HTTP verb handlers (Civet)') ||
34
+ (/\+server\.(c|civet|js|ts)$/.test(path) && 'file is a +server route module')
35
+ },
36
+ {
37
+ kind: 'Middleware',
38
+ test: (src) =>
39
+ (/export\s+(?:const\s+)?handle\b|(?:^|\s)handle\s*:=/.test(src) && 'exports a `handle` hook') ||
40
+ (/\bsequence\s*\(/.test(src) && 'composes hooks with sequence()') ||
41
+ (/\(\s*\{\s*event\s*,\s*resolve\s*\}\s*\)/.test(src) && 'takes the ({ event, resolve }) middleware contract')
42
+ },
43
+ {
44
+ kind: 'Adapter',
45
+ test: (src) =>
46
+ /export\s+(default|const|function|async|\{)|(?:^|\n)\s*[A-Za-z_$][\w$]*\s*:=/.test(src) &&
47
+ 'no route/worker/middleware shape — treated as an adapter around its exports'
48
+ }
49
+ ];
50
+
51
+ const CAPABILITY_RULES = [
52
+ { name: 'db', re: /resolve\(\s*['"]db['"]\s*\)|from\s+['"]drizzle-orm|\bD1Database\b/, why: 'touches the database' },
53
+ { name: 'events', re: /resolve\(\s*['"]events['"]\s*\)|\.emit\(/, why: 'emits events' },
54
+ { name: 'storage', re: /resolve\(\s*['"]storage['"]\s*\)|\bR2Bucket\b/, why: 'uses the storage adapter' },
55
+ { name: 'network', re: /\bfetch\s*\(|https?:\/\//, why: 'makes outbound network calls' },
56
+ { name: 'env', re: /\benv\.[A-Z_]|platform\.env|\$env\b/, why: 'reads environment bindings' },
57
+ { name: 'schedule', re: /\bscheduled\b|\bcron\b/i, why: 'runs on a schedule' },
58
+ { name: 'websocket', re: /\bWebSocket\b|webSocketMessage/, why: 'speaks WebSocket' }
59
+ ];
60
+
61
+ const AUTH_RE = /locals\.user|\buser\.roles\b|\bsession\b|better-auth|\bgetUser\b/;
62
+
63
+ function nameFromPath(path) {
64
+ const base = String(path)
65
+ .split('/')
66
+ .filter(Boolean)
67
+ .pop()
68
+ ?.replace(/\.(c|civet|n|js|ts)$/, '')
69
+ ?.replace(/^\+/, '');
70
+ if (!base) return null;
71
+ const name = base.replace(/[^A-Za-z0-9_$-]/g, '-');
72
+ return NAME_RE.test(name) ? name : null;
73
+ }
74
+
75
+ export function inferKind(source, path) {
76
+ for (const rule of KIND_RULES) {
77
+ const evidence = rule.test(source, String(path));
78
+ if (evidence) return { kind: rule.kind, evidence };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ export function inferCapabilities(source) {
84
+ const capabilities = [];
85
+ const evidence = {};
86
+ for (const rule of CAPABILITY_RULES) {
87
+ if (rule.re.test(source)) {
88
+ capabilities.push(rule.name);
89
+ evidence[rule.name] = rule.why;
90
+ }
91
+ }
92
+ return { capabilities, evidence };
93
+ }
94
+
95
+ export function inferAuth(source) {
96
+ return AUTH_RE.test(source)
97
+ ? { auth: 'authenticated', evidence: 'references the user/session' }
98
+ : { auth: 'public', evidence: 'no user/session reference found' };
99
+ }
100
+
101
+ /**
102
+ * Propose adopting one hand-written file as a Level-3 unit.
103
+ *
104
+ * @param {{ app: object|null, modules: Record<string, object> }} specs
105
+ * @param {{ module: string, path: string, source: string|null }} file
106
+ * @returns {{ path, adoptable: false, reason } |
107
+ * { path, adoptable: true, address, kind, name, ops, inferred, notes }}
108
+ */
109
+ export function adoptUnit(specs, { module, path, source }) {
110
+ const no = (reason) => ({ path, adoptable: false, reason });
111
+
112
+ const moduleSpec = specs.modules?.[module];
113
+ if (!moduleSpec) return no(`module "${module}" is not in specs — add the module before adopting into it`);
114
+ if (typeof source !== 'string' || source.trim() === '') return no(`no source at ${path}`);
115
+
116
+ const name = nameFromPath(path);
117
+ if (!name) return no(`could not derive a unit name from "${path}"`);
118
+
119
+ const kindMatch = inferKind(source, path);
120
+ if (!kindMatch) return no('file has no recognisable exports — nothing to wrap as a unit');
121
+ const { kind, evidence: kindEvidence } = kindMatch;
122
+
123
+ const existing = moduleSpec[KIND_KEYS[kind]]?.[name];
124
+ if (existing) return no(`${module}.${kind}.${name} already exists — rename the file or the unit`);
125
+
126
+ const { capabilities, evidence: capabilityEvidence } = inferCapabilities(source);
127
+ const { auth, evidence: authEvidence } = inferAuth(source);
128
+
129
+ const value = { source: String(path), auth };
130
+ if (capabilities.length > 0) value.capabilities = capabilities;
131
+ if (kind === 'Worker' && /extends\s+DurableObject\b|webSocketMessage/.test(source)) value.room = true;
132
+
133
+ const address = `${module}.${kind}.${name}`;
134
+ return {
135
+ path,
136
+ adoptable: true,
137
+ address,
138
+ kind,
139
+ name,
140
+ ops: [{ op: 'set', path: address, value }],
141
+ inferred: { kind: kindEvidence, capabilities: capabilityEvidence, auth: authEvidence },
142
+ notes: [
143
+ `auth was inferred as "${auth}" (${authEvidence}) — auth is a declaration, confirm it before applying`,
144
+ 'adopting declares ownership and capabilities; the file itself stays hand-written (Level 3)'
145
+ ]
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Adopt several files at once (the `spec.adopt {path}` directory case).
151
+ *
152
+ * @param {{ app: object|null, modules: Record<string, object> }} specs
153
+ * @param {{ module: string, files: Array<{ path: string, source: string|null }> }} input
154
+ */
155
+ export function adoptFiles(specs, { module, files }) {
156
+ return { proposals: files.map(({ path, source }) => adoptUnit(specs, { module, path, source })) };
157
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Status machines (K-17): every entity with a `status` map gets, in
3
+ * `lib/<module>/machines.c`:
4
+ *
5
+ * - `<Entity>Machine := machine(<Entity>Status)` — the runtime machine
6
+ * over the transition map the schema emitter already exports, and
7
+ * - `transition<Entity>` — a policy-wrapped generic transition Action
8
+ * (`{ id, to }`); only legal edges pass (`machine.assert` → 409),
9
+ * and it emits `<module>.<Entity>.transitioned` for triggers.
10
+ *
11
+ * Authored actions stay the named, guarded edges (`submit`, `win`, …);
12
+ * this is the structural fallback that makes every edge reachable
13
+ * without hand-writing an action per edge.
14
+ */
15
+
16
+ const header = (moduleName) =>
17
+ `// GENERATED by \`norns generate\` from specs/${moduleName}.tron — do not edit.`;
18
+
19
+ export function emitModuleMachines(moduleName, moduleSpec) {
20
+ const entities = Object.entries(moduleSpec.entities ?? {})
21
+ .filter(([, e]) => Object.keys(e?.status ?? {}).length > 0)
22
+ .sort(([a], [b]) => (a < b ? -1 : 1));
23
+ if (entities.length === 0) return null;
24
+
25
+ const schemaImports = [];
26
+ const policyImports = [];
27
+ const blocks = [];
28
+
29
+ for (const [name, entity] of entities) {
30
+ const states = Object.keys(entity.status).sort();
31
+ const policy = moduleSpec.policies?.[name];
32
+ schemaImports.push(name, `${name}Status`);
33
+ if (policy?.write !== undefined) policyImports.push(`${name}Policy`);
34
+
35
+ const body = [
36
+ `\t\tconst db = container.resolve('db')`,
37
+ `\t\tconst row = (await db.select().from(${name}).where(eq(${name}.id, input.id)).limit(1))[0]`,
38
+ `\t\tif (!row) throw error(404, ${JSON.stringify(`${name} not found`)})`
39
+ ];
40
+ if (policy?.write !== undefined) {
41
+ body.push(`\t\tif (!${name}Policy.write.check(row, user)) throw error(403, 'forbidden')`);
42
+ }
43
+ body.push(
44
+ `\t\t${name}Machine.assert(row.status, input.to)`,
45
+ `\t\tawait db.update(${name}).set({ status: input.to }).where(eq(${name}.id, input.id))`,
46
+ `\t\tawait container.resolve('events').emit(${JSON.stringify(`${moduleName}.${name}.transitioned`)}, { row, input, user })`,
47
+ `\t\treturn { ok: true }`
48
+ );
49
+
50
+ blocks.push(`export ${name}Machine := machine(${name}Status)`);
51
+ blocks.push(
52
+ [
53
+ `export transition${name} := {`,
54
+ [
55
+ `\taddress: ${JSON.stringify(`${moduleName}.Action.transition${name}`)}`,
56
+ `\tinput: v.strictObject({ id: v.string(), to: v.picklist(${JSON.stringify(states)}) })`,
57
+ [`\trun: async ({ input, container, user }) => {`, ...body, `\t}`].join('\n')
58
+ ].join(',\n'),
59
+ `}`
60
+ ].join('\n')
61
+ );
62
+ }
63
+
64
+ const lines = [
65
+ header(moduleName),
66
+ '',
67
+ `import { error } from '@sveltejs/kit'`,
68
+ `import { eq } from 'drizzle-orm'`,
69
+ `import * as v from 'valibot'`,
70
+ `import { machine } from '@human-synthesis/norns/server'`,
71
+ '',
72
+ `import { ${schemaImports.join(', ')} } from './schema.c'`
73
+ ];
74
+ if (policyImports.length > 0) {
75
+ lines.push(`import { ${policyImports.join(', ')} } from './policies.c'`);
76
+ }
77
+ lines.push('', blocks.join('\n\n'), '');
78
+ return { path: `lib/${moduleName}/machines.c`, text: lines.join('\n') };
79
+ }
80
+
81
+ export const machinesEmitter = {
82
+ name: 'machines',
83
+ emit: ({ moduleName, moduleSpec }) => {
84
+ const file = emitModuleMachines(moduleName, moduleSpec);
85
+ return file ? [file] : [];
86
+ }
87
+ };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Entity emitter (K-10): every entity in a module becomes a Drizzle table,
3
+ * a valibot schema, and (if it has one) a status-machine map, emitted as
4
+ * Civet into `lib/<module>/schema.c`. Dialect comes from `specs/app.tron`:
5
+ * sqlite/d1 emit sqliteTable, postgres emits pgTable (K-16).
6
+ *
7
+ * Conventions:
8
+ * - implicit `id` text primary key on every entity
9
+ * - `money` is integer cents; `date`/`datetime` are timestamp integers
10
+ * - `optional: true` → nullable column + v.optional in the schema
11
+ * - the initial status state is the one no transition targets; output is
12
+ * sorted, so emission is independent of spec key order
13
+ * - `<Entity>Input` = schema minus `id` (for action/form input)
14
+ */
15
+
16
+ const SQLITE_COLUMNS = {
17
+ text: (col) => `text('${col}')`,
18
+ email: (col) => `text('${col}')`,
19
+ url: (col) => `text('${col}')`,
20
+ file: (col) => `text('${col}')`,
21
+ ref: (col) => `text('${col}')`,
22
+ int: (col) => `integer('${col}')`,
23
+ money: (col) => `integer('${col}')`,
24
+ number: (col) => `real('${col}')`,
25
+ bool: (col) => `integer('${col}', { mode: 'boolean' })`,
26
+ date: (col) => `integer('${col}', { mode: 'timestamp' })`,
27
+ datetime: (col) => `integer('${col}', { mode: 'timestamp' })`,
28
+ json: (col) => `text('${col}', { mode: 'json' })`
29
+ };
30
+
31
+ const SQLITE_IMPORT = {
32
+ text: 'text',
33
+ email: 'text',
34
+ url: 'text',
35
+ file: 'text',
36
+ ref: 'text',
37
+ int: 'integer',
38
+ money: 'integer',
39
+ number: 'real',
40
+ bool: 'integer',
41
+ date: 'integer',
42
+ datetime: 'integer',
43
+ json: 'text'
44
+ };
45
+
46
+ const PG_COLUMNS = {
47
+ text: (col) => `text('${col}')`,
48
+ email: (col) => `text('${col}')`,
49
+ url: (col) => `text('${col}')`,
50
+ file: (col) => `text('${col}')`,
51
+ ref: (col) => `text('${col}')`,
52
+ int: (col) => `integer('${col}')`,
53
+ money: (col) => `integer('${col}')`,
54
+ number: (col) => `doublePrecision('${col}')`,
55
+ bool: (col) => `boolean('${col}')`,
56
+ // mode 'date' matches the sqlite timestamp columns, so runtime code sees
57
+ // Date objects under either dialect
58
+ date: (col) => `timestamp('${col}', { mode: 'date' })`,
59
+ datetime: (col) => `timestamp('${col}', { mode: 'date' })`,
60
+ json: (col) => `jsonb('${col}')`
61
+ };
62
+
63
+ const PG_IMPORT = {
64
+ text: 'text',
65
+ email: 'text',
66
+ url: 'text',
67
+ file: 'text',
68
+ ref: 'text',
69
+ int: 'integer',
70
+ money: 'integer',
71
+ number: 'doublePrecision',
72
+ bool: 'boolean',
73
+ date: 'timestamp',
74
+ datetime: 'timestamp',
75
+ json: 'jsonb'
76
+ };
77
+
78
+ const DIALECT_TABLES = {
79
+ d1: { table: 'sqliteTable', from: 'drizzle-orm/sqlite-core', columns: SQLITE_COLUMNS, imports: SQLITE_IMPORT },
80
+ sqlite: { table: 'sqliteTable', from: 'drizzle-orm/sqlite-core', columns: SQLITE_COLUMNS, imports: SQLITE_IMPORT },
81
+ postgres: { table: 'pgTable', from: 'drizzle-orm/pg-core', columns: PG_COLUMNS, imports: PG_IMPORT }
82
+ };
83
+
84
+ export const VALIBOT = {
85
+ text: 'v.string()',
86
+ file: 'v.string()',
87
+ ref: 'v.string()',
88
+ email: 'v.pipe(v.string(), v.email())',
89
+ url: 'v.pipe(v.string(), v.url())',
90
+ int: 'v.pipe(v.number(), v.integer())',
91
+ money: 'v.pipe(v.number(), v.integer())',
92
+ number: 'v.number()',
93
+ bool: 'v.boolean()',
94
+ date: 'v.pipe(v.string(), v.isoDate())',
95
+ datetime: 'v.pipe(v.string(), v.isoTimestamp())',
96
+ json: 'v.unknown()'
97
+ };
98
+
99
+ const snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
100
+
101
+ export const normalizeField = (f) => (typeof f === 'string' ? { type: f } : f);
102
+
103
+ /** The state no transition targets; falls back to the (sorted) first. */
104
+ function initialState(status) {
105
+ const states = Object.keys(status).sort();
106
+ const targeted = new Set(Object.values(status).flat());
107
+ const sources = states.filter((s) => !targeted.has(s));
108
+ return sources.length === 1 ? sources[0] : states[0];
109
+ }
110
+
111
+ /**
112
+ * Emit `lib/<module>/schema.c` for one module, or null when it has no
113
+ * entities.
114
+ *
115
+ * @param {string} moduleName
116
+ * @param {*} moduleSpec
117
+ * @returns {{ path: string, text: string } | null}
118
+ */
119
+ export function emitModuleSchema(moduleName, moduleSpec, dialect = 'd1') {
120
+ const entities = Object.entries(moduleSpec?.entities ?? {});
121
+ if (entities.length === 0) return null;
122
+ const { table, from, columns: COLUMNS, imports: IMPORTS } = DIALECT_TABLES[dialect] ?? DIALECT_TABLES.d1;
123
+
124
+ const columnFns = new Set(['text']); // id column is always text
125
+ const blocks = [];
126
+
127
+ for (const [name, entity] of entities.sort(([a], [b]) => (a < b ? -1 : 1))) {
128
+ const fields = Object.entries(entity.fields ?? {})
129
+ .map(([f, def]) => [f, normalizeField(def)])
130
+ .sort(([a], [b]) => (a < b ? -1 : 1));
131
+ const states = Object.keys(entity.status ?? {}).sort();
132
+
133
+ const columns = [`\tid: text('id').primaryKey()`];
134
+ for (const [fieldName, def] of fields) {
135
+ columnFns.add(IMPORTS[def.type]);
136
+ let col = `\t${fieldName}: ${COLUMNS[def.type](snake(fieldName))}`;
137
+ if (!def.optional) col += '.notNull()';
138
+ if (def.unique) col += '.unique()';
139
+ if (def.default !== undefined) col += `.default(${JSON.stringify(def.default)})`;
140
+ columns.push(col);
141
+ }
142
+ if (states.length > 0) {
143
+ columns.push(
144
+ `\tstatus: text('status').notNull().default(${JSON.stringify(initialState(entity.status))})`
145
+ );
146
+ }
147
+
148
+ const schemaFields = [`\tid: v.string()`];
149
+ for (const [fieldName, def] of fields) {
150
+ const base = VALIBOT[def.type];
151
+ schemaFields.push(`\t${fieldName}: ${def.optional ? `v.optional(${base})` : base}`);
152
+ }
153
+ if (states.length > 0) {
154
+ schemaFields.push(`\tstatus: v.picklist(${JSON.stringify(states)})`);
155
+ }
156
+
157
+ const lines = [
158
+ `export ${name} := ${table}('${snake(moduleName)}_${snake(name)}', {`,
159
+ columns.join(',\n'),
160
+ `})`,
161
+ ``
162
+ ];
163
+ if (states.length > 0) {
164
+ const rows = states.map(
165
+ (s) => `\t${s}: [${entity.status[s].map((t) => JSON.stringify(t)).join(', ')}]`
166
+ );
167
+ lines.push(`export ${name}Status := {`, rows.join(',\n'), `}`, ``);
168
+ }
169
+ lines.push(
170
+ `export ${name}Schema := v.strictObject({`,
171
+ schemaFields.join(',\n'),
172
+ `})`,
173
+ ``,
174
+ `export ${name}Input := v.omit(${name}Schema, ['id'])`
175
+ );
176
+ blocks.push(lines.join('\n'));
177
+ }
178
+
179
+ const imports = [...columnFns].sort().join(', ');
180
+ const text = [
181
+ `// GENERATED by \`norns generate\` from specs/${moduleName}.tron — do not edit.`,
182
+ `import { ${table}, ${imports} } from '${from}'`,
183
+ `import * as v from 'valibot'`,
184
+ ``,
185
+ blocks.join('\n\n'),
186
+ ``
187
+ ].join('\n');
188
+
189
+ return { path: `lib/${moduleName}/schema.c`, text };
190
+ }
191
+
192
+ /** @type {{ name: string, emit: (ctx: *) => { path: string, text: string }[] }} */
193
+ export const schemaEmitter = {
194
+ name: 'schema',
195
+ emit({ moduleName, moduleSpec, specs }) {
196
+ const file = emitModuleSchema(moduleName, moduleSpec, specs?.app?.dialect ?? 'd1');
197
+ return file ? [file] : [];
198
+ }
199
+ };