@human-synthesis/norns 0.0.16 → 0.2.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 +228 -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 +855 -0
- package/src/kernel/emit-wrangler.js +134 -0
- package/src/kernel/expr-compile.js +191 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +840 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +78 -0
- package/src/kernel/meta.js +381 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +277 -0
- package/src/kernel/trace.js +465 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +216 -0
- package/src/server/boot.js +83 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +66 -1
- package/src/server/endpoint.js +142 -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 +15 -1
- package/src/server/job.js +102 -0
- 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 +179 -0
- package/src/server/service.js +188 -0
- package/src/server/storage.js +97 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration pipeline (K-11): generate → compile schema → drizzle-kit diff.
|
|
3
|
+
*
|
|
4
|
+
* Migrations are committed SQL under `migrations/<module>/`, produced by
|
|
5
|
+
* drizzle-kit (pinned) diffing the emitted Drizzle schema against its own
|
|
6
|
+
* snapshot journal. Additive by default: a diff that drops tables or
|
|
7
|
+
* columns is refused unless `force` — spec `remove` should go through a
|
|
8
|
+
* deprecation step first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { GenerateError, generateApp } from './generate.js';
|
|
17
|
+
import { loadSpecs } from './validate.js';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
const DRIZZLE_DIALECTS = { d1: 'sqlite', sqlite: 'sqlite', postgres: 'postgresql' };
|
|
22
|
+
|
|
23
|
+
const DESTRUCTIVE_SQL = /\bDROP TABLE\b|\bDROP COLUMN\b|__new_/i;
|
|
24
|
+
|
|
25
|
+
function drizzleKitBin() {
|
|
26
|
+
return join(dirname(require.resolve('drizzle-kit')), 'bin.cjs');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sqlFiles(dir) {
|
|
30
|
+
if (!existsSync(dir)) return [];
|
|
31
|
+
return readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Diff one module's emitted schema against its migration journal.
|
|
36
|
+
* Runs drizzle-kit in a scratch copy of `migrations/<module>` and only
|
|
37
|
+
* copies the result back when the new SQL is non-destructive (or forced).
|
|
38
|
+
*
|
|
39
|
+
* @returns {{ created: string[], refusals: import('./generate.js').Refusal[] }}
|
|
40
|
+
*/
|
|
41
|
+
function migrateModule({ moduleName, schemaFile, migrationsDir, workDir, dialect, name, force }) {
|
|
42
|
+
const scratch = join(workDir, moduleName);
|
|
43
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
44
|
+
mkdirSync(scratch, { recursive: true });
|
|
45
|
+
|
|
46
|
+
const { compile } = require('@danielx/civet');
|
|
47
|
+
const schemaJs = join(scratch, 'schema.js');
|
|
48
|
+
writeFileSync(schemaJs, compile(readFileSync(schemaFile, 'utf-8'), { sync: true, js: true }));
|
|
49
|
+
|
|
50
|
+
const out = join(scratch, 'out');
|
|
51
|
+
if (existsSync(migrationsDir)) cpSync(migrationsDir, out, { recursive: true });
|
|
52
|
+
|
|
53
|
+
// drizzle-kit prefixes paths with './' internally, so absolute paths
|
|
54
|
+
// break snapshot reads — keep config paths relative to the scratch cwd.
|
|
55
|
+
const config = join(scratch, 'drizzle.config.js');
|
|
56
|
+
writeFileSync(
|
|
57
|
+
config,
|
|
58
|
+
`export default ${JSON.stringify({ dialect, schema: 'schema.js', out: 'out' }, null, '\t')}\n`
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const before = new Set(sqlFiles(out));
|
|
62
|
+
const run = spawnSync(process.execPath, [drizzleKitBin(), 'generate', '--config', config, '--name', name], {
|
|
63
|
+
cwd: scratch,
|
|
64
|
+
encoding: 'utf-8'
|
|
65
|
+
});
|
|
66
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
67
|
+
const created = sqlFiles(out).filter((f) => !before.has(f));
|
|
68
|
+
// drizzle-kit exits 0 even on load errors, so judge by outcome.
|
|
69
|
+
if (run.status !== 0 || (created.length === 0 && !/No schema changes/.test(output))) {
|
|
70
|
+
throw new Error(`norns migrate: drizzle-kit failed for module "${moduleName}"\n${output}`);
|
|
71
|
+
}
|
|
72
|
+
if (created.length === 0) return { created: [], refusals: [] };
|
|
73
|
+
|
|
74
|
+
const refusals = [];
|
|
75
|
+
if (!force) {
|
|
76
|
+
for (const file of created) {
|
|
77
|
+
const sql = readFileSync(join(out, file), 'utf-8');
|
|
78
|
+
if (DESTRUCTIVE_SQL.test(sql)) {
|
|
79
|
+
refusals.push({
|
|
80
|
+
address: moduleName,
|
|
81
|
+
path: `migrations/${moduleName}/${file}`,
|
|
82
|
+
code: 'DESTRUCTIVE_MIGRATION',
|
|
83
|
+
message: `migration drops tables or columns (${file})`,
|
|
84
|
+
fix: 'deprecate the field/entity first, or re-run with --force after reviewing the SQL'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (refusals.length > 0) return { created: [], refusals };
|
|
90
|
+
|
|
91
|
+
cpSync(out, migrationsDir, { recursive: true });
|
|
92
|
+
return { created, refusals: [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate committed SQL migrations for every module with a schema.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} [dir] specs directory, defaults to `<cwd>/specs`
|
|
99
|
+
* @param {{ out?: string, migrations?: string, force?: boolean }} [opts]
|
|
100
|
+
* @returns {{ version: string, created: Record<string, string[]>, unchanged: string[] }}
|
|
101
|
+
*/
|
|
102
|
+
export function migrateApp(dir, opts = {}) {
|
|
103
|
+
const specs = loadSpecs(dir);
|
|
104
|
+
const appRoot = dirname(specs.dir);
|
|
105
|
+
const genRoot = opts.out ?? join(appRoot, '.norns', 'generated');
|
|
106
|
+
const migrationsRoot = opts.migrations ?? join(appRoot, 'migrations');
|
|
107
|
+
const workDir = join(appRoot, '.norns', 'cache', 'migrate');
|
|
108
|
+
|
|
109
|
+
const dialect = DRIZZLE_DIALECTS[specs.app?.dialect ?? 'd1'];
|
|
110
|
+
generateApp(dir, { out: opts.out, force: opts.force });
|
|
111
|
+
|
|
112
|
+
const created = {};
|
|
113
|
+
const unchanged = [];
|
|
114
|
+
const refusals = [];
|
|
115
|
+
for (const moduleName of Object.keys(specs.modules)) {
|
|
116
|
+
const schemaFile = join(genRoot, 'lib', moduleName, 'schema.c');
|
|
117
|
+
if (!existsSync(schemaFile)) continue;
|
|
118
|
+
const result = migrateModule({
|
|
119
|
+
moduleName,
|
|
120
|
+
schemaFile,
|
|
121
|
+
migrationsDir: join(migrationsRoot, moduleName),
|
|
122
|
+
workDir,
|
|
123
|
+
dialect,
|
|
124
|
+
name: specs.hashes[moduleName].slice(0, 8),
|
|
125
|
+
force: opts.force === true
|
|
126
|
+
});
|
|
127
|
+
refusals.push(...result.refusals);
|
|
128
|
+
if (result.created.length > 0) created[moduleName] = result.created;
|
|
129
|
+
else unchanged.push(moduleName);
|
|
130
|
+
}
|
|
131
|
+
if (refusals.length > 0) throw new GenerateError(refusals);
|
|
132
|
+
|
|
133
|
+
return { version: specs.version, created, unchanged };
|
|
134
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-unit refinements (K-06) — everything the per-kind meta-schemas
|
|
3
|
+
* cannot see because it spans units or modules: references resolve, the
|
|
4
|
+
* `depends:` graph is a DAG, status machines are closed, gated features
|
|
5
|
+
* stay refused.
|
|
6
|
+
*
|
|
7
|
+
* A module listed in `depends:` but not present in `specs/` is treated as
|
|
8
|
+
* external (e.g. the platform-provided `core` module before it is
|
|
9
|
+
* materialized); references into it are not resolvable and are skipped.
|
|
10
|
+
* References into modules that are neither loaded nor declared are errors.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatAddress, indexUnits, isAddress, parseAddress } from './address.js';
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ level: 'error' | 'warning', address: string, message: string }} Issue */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ app: *, modules: Record<string, *> }} specs
|
|
19
|
+
* @returns {Issue[]}
|
|
20
|
+
*/
|
|
21
|
+
export function refineSpecs(specs) {
|
|
22
|
+
const { modules, app } = specs;
|
|
23
|
+
const index = indexUnits(modules);
|
|
24
|
+
/** @type {Issue[]} */
|
|
25
|
+
const issues = [];
|
|
26
|
+
|
|
27
|
+
const dependsOf = (moduleName) => {
|
|
28
|
+
const d = modules[moduleName]?.depends;
|
|
29
|
+
return Array.isArray(d) ? d : [];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve `ref` from `fromModule` expecting a unit of `kind`.
|
|
34
|
+
* Bare names resolve within the module; full addresses anywhere.
|
|
35
|
+
* Returns null when the ref points into an external (declared) module.
|
|
36
|
+
*/
|
|
37
|
+
function checkRef(at, fromModule, ref, kind, what) {
|
|
38
|
+
if (typeof ref !== 'string') return;
|
|
39
|
+
let target;
|
|
40
|
+
if (isAddress(ref)) {
|
|
41
|
+
const addr = parseAddress(ref);
|
|
42
|
+
if (!(addr.module in modules)) {
|
|
43
|
+
if (addr.module === fromModule || dependsOf(fromModule).includes(addr.module)) return;
|
|
44
|
+
issues.push({
|
|
45
|
+
level: 'error',
|
|
46
|
+
address: at,
|
|
47
|
+
message: `${what} "${ref}" points into unknown module "${addr.module}" (not loaded, not in depends)`
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (addr.kind !== kind) {
|
|
52
|
+
issues.push({
|
|
53
|
+
level: 'error',
|
|
54
|
+
address: at,
|
|
55
|
+
message: `${what} "${ref}" must reference a ${kind}, not a ${addr.kind}`
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
target = ref;
|
|
60
|
+
} else {
|
|
61
|
+
target = formatAddress({ module: fromModule, kind, name: ref.split('.')[0] });
|
|
62
|
+
}
|
|
63
|
+
if (!index.byAddress.has(target)) {
|
|
64
|
+
issues.push({
|
|
65
|
+
level: 'error',
|
|
66
|
+
address: at,
|
|
67
|
+
message: `${what} "${ref}" does not resolve (no ${target})`
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `call` steps may target a service operation
|
|
74
|
+
* (`<module>.Service.<name>.<op>`) — the service and operation must both
|
|
75
|
+
* exist. Other call targets are container tokens, resolved at runtime.
|
|
76
|
+
*/
|
|
77
|
+
function checkServiceCall(at, fromModule, call) {
|
|
78
|
+
const m = /^([a-z][a-z0-9_]*)\.Service\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(
|
|
79
|
+
call
|
|
80
|
+
);
|
|
81
|
+
if (!m) return;
|
|
82
|
+
const [, module, service, op] = m;
|
|
83
|
+
if (!(module in modules)) {
|
|
84
|
+
if (dependsOf(fromModule).includes(module)) return;
|
|
85
|
+
issues.push({
|
|
86
|
+
level: 'error',
|
|
87
|
+
address: at,
|
|
88
|
+
message: `call "${call}" points into unknown module "${module}" (not loaded, not in depends)`
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const svc = modules[module]?.services?.[service];
|
|
93
|
+
if (!svc) {
|
|
94
|
+
issues.push({
|
|
95
|
+
level: 'error',
|
|
96
|
+
address: at,
|
|
97
|
+
message: `call "${call}" does not resolve (no ${module}.Service.${service})`
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!svc.operations?.[op]) {
|
|
102
|
+
issues.push({
|
|
103
|
+
level: 'error',
|
|
104
|
+
address: at,
|
|
105
|
+
message: `call "${call}": service "${service}" has no operation "${op}"`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Shared step checks for Actions and Jobs: service calls and job enqueues resolve. */
|
|
111
|
+
function checkFlowSteps(at, fromModule, unitValue) {
|
|
112
|
+
for (const step of Array.isArray(unitValue?.steps) ? unitValue.steps : []) {
|
|
113
|
+
if (typeof step?.call === 'string') checkServiceCall(at, fromModule, step.call);
|
|
114
|
+
if (typeof step?.enqueue === 'string') {
|
|
115
|
+
checkRef(at, fromModule, step.enqueue, 'Job', 'enqueue');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// depends: modules exist (or are external-by-convention? no — depends
|
|
121
|
+
// names must be loaded or the well-known platform module "core") and form a DAG.
|
|
122
|
+
for (const [name, spec] of Object.entries(modules)) {
|
|
123
|
+
for (const dep of dependsOf(name)) {
|
|
124
|
+
if (!(dep in modules) && dep !== 'core') {
|
|
125
|
+
issues.push({
|
|
126
|
+
level: 'error',
|
|
127
|
+
address: name,
|
|
128
|
+
message: `depends: unknown module "${dep}"`
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const visiting = new Set();
|
|
134
|
+
const done = new Set();
|
|
135
|
+
function visit(name, chain) {
|
|
136
|
+
if (done.has(name)) return;
|
|
137
|
+
if (visiting.has(name)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
level: 'error',
|
|
140
|
+
address: name,
|
|
141
|
+
message: `depends: cycle ${[...chain, name].join(' -> ')}`
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
visiting.add(name);
|
|
146
|
+
for (const dep of dependsOf(name)) {
|
|
147
|
+
if (dep in modules) visit(dep, [...chain, name]);
|
|
148
|
+
}
|
|
149
|
+
visiting.delete(name);
|
|
150
|
+
done.add(name);
|
|
151
|
+
}
|
|
152
|
+
for (const name of Object.keys(modules)) visit(name, []);
|
|
153
|
+
|
|
154
|
+
const remoteEnabled = app?.settings?.remoteTransport === true;
|
|
155
|
+
|
|
156
|
+
for (const unit of index.units) {
|
|
157
|
+
const { address: at, module: mod, kind, value } = unit;
|
|
158
|
+
switch (kind) {
|
|
159
|
+
case 'Entity': {
|
|
160
|
+
if (typeof value?.owner === 'string' && !(value.owner in (value.fields ?? {}))) {
|
|
161
|
+
issues.push({
|
|
162
|
+
level: 'error',
|
|
163
|
+
address: at,
|
|
164
|
+
message: `owner "${value.owner}" is not a field of this entity`
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
for (const [fname, f] of Object.entries(value?.fields ?? {})) {
|
|
168
|
+
if (f && typeof f === 'object' && f.type === 'ref') {
|
|
169
|
+
checkRef(at, mod, f.ref, 'Entity', `fields.${fname}.ref`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const status = value?.status;
|
|
173
|
+
if (status && typeof status === 'object') {
|
|
174
|
+
for (const [state, nexts] of Object.entries(status)) {
|
|
175
|
+
for (const nxt of Array.isArray(nexts) ? nexts : []) {
|
|
176
|
+
if (!(nxt in status)) {
|
|
177
|
+
issues.push({
|
|
178
|
+
level: 'error',
|
|
179
|
+
address: at,
|
|
180
|
+
message: `status: transition ${state} -> ${nxt} targets an undeclared state`
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'Query':
|
|
189
|
+
checkRef(at, mod, value?.from, 'Entity', 'from');
|
|
190
|
+
break;
|
|
191
|
+
case 'Action': {
|
|
192
|
+
for (const ref of Array.isArray(value?.refresh) ? value.refresh : []) {
|
|
193
|
+
checkRef(at, mod, ref, 'Query', 'refresh');
|
|
194
|
+
}
|
|
195
|
+
checkFlowSteps(at, mod, value);
|
|
196
|
+
if (value?.transport === 'remote' && !remoteEnabled) {
|
|
197
|
+
issues.push({
|
|
198
|
+
level: 'error',
|
|
199
|
+
address: at,
|
|
200
|
+
message:
|
|
201
|
+
'transport: remote is not enabled — opt in with app settings.remoteTransport: true (runtime support landed with R-11)'
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'Policy': {
|
|
207
|
+
const entityAddr = formatAddress({ module: mod, kind: 'Entity', name: unit.name });
|
|
208
|
+
if (!index.byAddress.has(entityAddr)) {
|
|
209
|
+
issues.push({
|
|
210
|
+
level: 'error',
|
|
211
|
+
address: at,
|
|
212
|
+
message: `policy "${unit.name}" does not match an entity in module "${mod}"`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
for (const actionName of Object.keys(value?.run ?? {})) {
|
|
216
|
+
checkRef(at, mod, actionName, 'Action', 'run');
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'Page': {
|
|
221
|
+
for (const comp of Array.isArray(value?.components) ? value.components : []) {
|
|
222
|
+
if (comp && typeof comp === 'object') {
|
|
223
|
+
for (const [key, bound] of Object.entries(comp)) {
|
|
224
|
+
if (typeof bound === 'string' && isAddress(bound)) {
|
|
225
|
+
const addr = parseAddress(bound);
|
|
226
|
+
checkRef(at, mod, bound, addr.kind, `components.${key}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'Trigger': {
|
|
234
|
+
const action = typeof value === 'string' ? value : value?.action;
|
|
235
|
+
checkRef(at, mod, action, 'Action', 'trigger action');
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'Job': {
|
|
239
|
+
checkFlowSteps(at, mod, value);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'Worker': {
|
|
243
|
+
if (value?.messages) {
|
|
244
|
+
for (const [i, ex] of (value.examples ?? []).entries()) {
|
|
245
|
+
for (const step of ex.script ?? []) {
|
|
246
|
+
if (!(step.send in value.messages)) {
|
|
247
|
+
issues.push({
|
|
248
|
+
level: 'error',
|
|
249
|
+
address: at,
|
|
250
|
+
message: `example ${i}: script sends undeclared message "${step.send}"`
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'Route': {
|
|
259
|
+
issues.push({
|
|
260
|
+
level: 'warning',
|
|
261
|
+
address: at,
|
|
262
|
+
message:
|
|
263
|
+
'schema-less Route is deprecated — declare an Endpoint (route/method/auth/input/output) instead; v3.1 refuses bare Routes'
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'Component': {
|
|
268
|
+
for (const [event, target] of Object.entries(value?.events ?? {})) {
|
|
269
|
+
checkRef(at, mod, target, 'Action', `events.${event}`);
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return issues;
|
|
277
|
+
}
|