@human-synthesis/norns 0.0.15 → 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/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +224 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +587 -0
- package/src/kernel/emit-wrangler.js +114 -0
- package/src/kernel/expr-compile.js +188 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/generate.js +397 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +71 -0
- package/src/kernel/meta.js +237 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +199 -0
- package/src/kernel/trace.js +277 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +72 -0
- package/src/server/boot.js +67 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +61 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +13 -2
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +162 -0
- package/src/server/route.js +46 -2
- package/src/server/storage.js +97 -0
|
@@ -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
|
+
};
|