@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.
- 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 +61 -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 +12 -1
- 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/storage.js +97 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valibot meta-schemas — one per resource kind. These make invalid specs
|
|
3
|
+
* unrepresentable at the shape level: unknown keys are rejected
|
|
4
|
+
* (strictObject), expressions must parse, references must look like
|
|
5
|
+
* addresses. Cross-unit refinements (refs resolve, depends DAG, closed
|
|
6
|
+
* status machines) live in refine.js (K-06), not here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as v from 'valibot';
|
|
10
|
+
|
|
11
|
+
import { isAddress } from './address.js';
|
|
12
|
+
import { isExpr } from './expr.js';
|
|
13
|
+
|
|
14
|
+
export const FIELD_TYPES = [
|
|
15
|
+
'text',
|
|
16
|
+
'number',
|
|
17
|
+
'int',
|
|
18
|
+
'money',
|
|
19
|
+
'bool',
|
|
20
|
+
'date',
|
|
21
|
+
'datetime',
|
|
22
|
+
'email',
|
|
23
|
+
'url',
|
|
24
|
+
'json',
|
|
25
|
+
'file',
|
|
26
|
+
'ref'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const DIALECTS = ['d1', 'sqlite', 'postgres'];
|
|
30
|
+
|
|
31
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
32
|
+
|
|
33
|
+
const ident = v.pipe(v.string(), v.regex(IDENT_RE, 'must be an identifier'));
|
|
34
|
+
const uid = v.optional(
|
|
35
|
+
v.pipe(v.string(), v.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/, 'must be a 26-char ULID'))
|
|
36
|
+
);
|
|
37
|
+
const expr = v.pipe(
|
|
38
|
+
v.string(),
|
|
39
|
+
v.check(isExpr, 'must be a valid expression (see the CEL-subset grammar)')
|
|
40
|
+
);
|
|
41
|
+
const address = v.pipe(
|
|
42
|
+
v.string(),
|
|
43
|
+
v.check(isAddress, 'must be a unit address (module.Kind.name)')
|
|
44
|
+
);
|
|
45
|
+
/** Full address or a bare/dotted local name like `Order` or `Order.id`. */
|
|
46
|
+
const unitRef = v.pipe(
|
|
47
|
+
v.string(),
|
|
48
|
+
v.check(
|
|
49
|
+
(s) => isAddress(s) || s.split('.').every((seg) => IDENT_RE.test(seg)),
|
|
50
|
+
'must be a unit reference'
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const fieldObject = v.pipe(
|
|
55
|
+
v.strictObject({
|
|
56
|
+
type: v.picklist(FIELD_TYPES),
|
|
57
|
+
ref: v.optional(unitRef),
|
|
58
|
+
optional: v.optional(v.boolean()),
|
|
59
|
+
unique: v.optional(v.boolean()),
|
|
60
|
+
default: v.optional(v.unknown())
|
|
61
|
+
}),
|
|
62
|
+
v.check((f) => (f.type === 'ref') === (f.ref !== undefined), 'ref fields need `ref`, others must not have it')
|
|
63
|
+
);
|
|
64
|
+
const field = v.union([v.picklist(FIELD_TYPES), fieldObject]);
|
|
65
|
+
|
|
66
|
+
const example = v.strictObject({
|
|
67
|
+
input: v.optional(v.unknown()),
|
|
68
|
+
expect: v.optional(v.unknown())
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const requiresExamplesWhenCustom = (unit) =>
|
|
72
|
+
unit.impl !== 'custom' || (Array.isArray(unit.examples) && unit.examples.length > 0);
|
|
73
|
+
const CUSTOM_NEEDS_EXAMPLES = '`impl: custom` requires at least one example';
|
|
74
|
+
|
|
75
|
+
const Entity = v.strictObject({
|
|
76
|
+
uid,
|
|
77
|
+
owner: v.optional(ident),
|
|
78
|
+
fields: v.record(ident, field),
|
|
79
|
+
status: v.optional(v.record(ident, v.array(ident)))
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const Query = v.strictObject({
|
|
83
|
+
uid,
|
|
84
|
+
from: unitRef,
|
|
85
|
+
live: v.optional(v.boolean()),
|
|
86
|
+
groupBy: v.optional(v.string()),
|
|
87
|
+
filter: v.optional(expr),
|
|
88
|
+
sort: v.optional(v.union([v.string(), v.array(v.string())])),
|
|
89
|
+
limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)))
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const Action = v.pipe(
|
|
93
|
+
v.strictObject({
|
|
94
|
+
uid,
|
|
95
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
96
|
+
requires: v.optional(expr),
|
|
97
|
+
steps: v.optional(v.array(v.record(v.string(), v.unknown()))),
|
|
98
|
+
emits: v.optional(v.array(v.string())),
|
|
99
|
+
refresh: v.optional(v.array(address)),
|
|
100
|
+
examples: v.optional(v.array(example)),
|
|
101
|
+
impl: v.optional(v.picklist(['generated', 'custom'])),
|
|
102
|
+
transport: v.optional(v.picklist(['form', 'remote']))
|
|
103
|
+
}),
|
|
104
|
+
v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const Policy = v.strictObject({
|
|
108
|
+
uid,
|
|
109
|
+
roles: v.optional(v.array(ident)),
|
|
110
|
+
read: v.optional(expr),
|
|
111
|
+
write: v.optional(expr),
|
|
112
|
+
run: v.optional(v.record(ident, expr))
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const Page = v.pipe(
|
|
116
|
+
v.strictObject({
|
|
117
|
+
uid,
|
|
118
|
+
route: v.pipe(v.string(), v.regex(/^\//, 'route must start with "/"')),
|
|
119
|
+
params: v.optional(v.record(ident, v.string())),
|
|
120
|
+
layout: v.optional(v.string()),
|
|
121
|
+
state: v.optional(v.record(ident, v.string())),
|
|
122
|
+
components: v.optional(v.array(v.record(v.string(), v.unknown()))),
|
|
123
|
+
slots: v.optional(v.array(ident)),
|
|
124
|
+
examples: v.optional(v.array(example)),
|
|
125
|
+
impl: v.optional(v.picklist(['generated', 'custom']))
|
|
126
|
+
}),
|
|
127
|
+
v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const Trigger = v.union([
|
|
131
|
+
address,
|
|
132
|
+
v.strictObject({
|
|
133
|
+
uid,
|
|
134
|
+
action: address,
|
|
135
|
+
schedule: v.optional(v.string()),
|
|
136
|
+
source: v.optional(v.string())
|
|
137
|
+
})
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const Function = v.strictObject({
|
|
141
|
+
uid,
|
|
142
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
143
|
+
output: v.optional(v.unknown()),
|
|
144
|
+
examples: v.pipe(v.array(example), v.minLength(1, 'functions require at least one example'))
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const Component = v.strictObject({
|
|
148
|
+
uid,
|
|
149
|
+
props: v.optional(v.record(ident, v.unknown())),
|
|
150
|
+
events: v.optional(v.record(ident, address)),
|
|
151
|
+
slots: v.optional(v.array(ident))
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// L3 kinds: whole Civet files with declared auth + capabilities.
|
|
155
|
+
// `validate` refuses them without an auth declaration (PLAN §6).
|
|
156
|
+
const level3 = (extra = {}) =>
|
|
157
|
+
v.strictObject({
|
|
158
|
+
uid,
|
|
159
|
+
source: v.string(),
|
|
160
|
+
auth: v.union([v.string(), v.record(v.string(), v.unknown())]),
|
|
161
|
+
capabilities: v.optional(v.array(v.string())),
|
|
162
|
+
...extra
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const Plugin = v.strictObject({
|
|
166
|
+
uid,
|
|
167
|
+
kind: v.picklist(['field', 'step', 'component', 'trigger']),
|
|
168
|
+
source: v.string(),
|
|
169
|
+
contract: v.optional(v.record(v.string(), v.unknown()))
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** Kind → valibot schema for one unit's spec value. */
|
|
173
|
+
export const UNIT_SCHEMAS = {
|
|
174
|
+
Entity,
|
|
175
|
+
Query,
|
|
176
|
+
Action,
|
|
177
|
+
Policy,
|
|
178
|
+
Page,
|
|
179
|
+
Trigger,
|
|
180
|
+
Function,
|
|
181
|
+
Component,
|
|
182
|
+
Route: level3(),
|
|
183
|
+
Worker: level3({ room: v.optional(v.boolean()) }),
|
|
184
|
+
Adapter: level3(),
|
|
185
|
+
Middleware: level3(),
|
|
186
|
+
Plugin
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const collection = v.optional(v.record(v.string(), v.unknown()));
|
|
190
|
+
|
|
191
|
+
/** Module spec shape — collection contents are validated per unit. */
|
|
192
|
+
export const MODULE_SCHEMA = v.strictObject({
|
|
193
|
+
module: ident,
|
|
194
|
+
depends: v.optional(v.array(ident)),
|
|
195
|
+
settings: v.optional(v.record(v.string(), v.unknown())),
|
|
196
|
+
entities: collection,
|
|
197
|
+
queries: collection,
|
|
198
|
+
actions: collection,
|
|
199
|
+
policies: collection,
|
|
200
|
+
pages: collection,
|
|
201
|
+
triggers: collection,
|
|
202
|
+
functions: collection,
|
|
203
|
+
components: collection,
|
|
204
|
+
routes: collection,
|
|
205
|
+
workers: collection,
|
|
206
|
+
adapters: collection,
|
|
207
|
+
middleware: collection,
|
|
208
|
+
plugins: collection
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export const APP_SCHEMA = v.strictObject({
|
|
212
|
+
name: v.optional(v.string()),
|
|
213
|
+
modules: v.optional(v.array(ident)),
|
|
214
|
+
dialect: v.optional(v.picklist(DIALECTS)),
|
|
215
|
+
settings: v.optional(v.record(v.string(), v.unknown()))
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Run a valibot schema and convert its issues to kernel Issues.
|
|
220
|
+
*
|
|
221
|
+
* @param {*} schema
|
|
222
|
+
* @param {*} value
|
|
223
|
+
* @param {string} addr issue address (unit address or module name)
|
|
224
|
+
* @returns {{ level: 'error', address: string, message: string }[]}
|
|
225
|
+
*/
|
|
226
|
+
export function schemaIssues(schema, value, addr) {
|
|
227
|
+
const result = v.safeParse(schema, value);
|
|
228
|
+
if (result.success) return [];
|
|
229
|
+
return result.issues.map((issue) => {
|
|
230
|
+
const path = v.getDotPath(issue);
|
|
231
|
+
return {
|
|
232
|
+
level: 'error',
|
|
233
|
+
address: addr,
|
|
234
|
+
message: path ? `${path}: ${issue.message}` : issue.message
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -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,199 @@
|
|
|
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
|
+
// depends: modules exist (or are external-by-convention? no — depends
|
|
73
|
+
// names must be loaded or the well-known platform module "core") and form a DAG.
|
|
74
|
+
for (const [name, spec] of Object.entries(modules)) {
|
|
75
|
+
for (const dep of dependsOf(name)) {
|
|
76
|
+
if (!(dep in modules) && dep !== 'core') {
|
|
77
|
+
issues.push({
|
|
78
|
+
level: 'error',
|
|
79
|
+
address: name,
|
|
80
|
+
message: `depends: unknown module "${dep}"`
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const visiting = new Set();
|
|
86
|
+
const done = new Set();
|
|
87
|
+
function visit(name, chain) {
|
|
88
|
+
if (done.has(name)) return;
|
|
89
|
+
if (visiting.has(name)) {
|
|
90
|
+
issues.push({
|
|
91
|
+
level: 'error',
|
|
92
|
+
address: name,
|
|
93
|
+
message: `depends: cycle ${[...chain, name].join(' -> ')}`
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
visiting.add(name);
|
|
98
|
+
for (const dep of dependsOf(name)) {
|
|
99
|
+
if (dep in modules) visit(dep, [...chain, name]);
|
|
100
|
+
}
|
|
101
|
+
visiting.delete(name);
|
|
102
|
+
done.add(name);
|
|
103
|
+
}
|
|
104
|
+
for (const name of Object.keys(modules)) visit(name, []);
|
|
105
|
+
|
|
106
|
+
const remoteEnabled = app?.settings?.remoteTransport === true;
|
|
107
|
+
|
|
108
|
+
for (const unit of index.units) {
|
|
109
|
+
const { address: at, module: mod, kind, value } = unit;
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case 'Entity': {
|
|
112
|
+
if (typeof value?.owner === 'string' && !(value.owner in (value.fields ?? {}))) {
|
|
113
|
+
issues.push({
|
|
114
|
+
level: 'error',
|
|
115
|
+
address: at,
|
|
116
|
+
message: `owner "${value.owner}" is not a field of this entity`
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const [fname, f] of Object.entries(value?.fields ?? {})) {
|
|
120
|
+
if (f && typeof f === 'object' && f.type === 'ref') {
|
|
121
|
+
checkRef(at, mod, f.ref, 'Entity', `fields.${fname}.ref`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const status = value?.status;
|
|
125
|
+
if (status && typeof status === 'object') {
|
|
126
|
+
for (const [state, nexts] of Object.entries(status)) {
|
|
127
|
+
for (const nxt of Array.isArray(nexts) ? nexts : []) {
|
|
128
|
+
if (!(nxt in status)) {
|
|
129
|
+
issues.push({
|
|
130
|
+
level: 'error',
|
|
131
|
+
address: at,
|
|
132
|
+
message: `status: transition ${state} -> ${nxt} targets an undeclared state`
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case 'Query':
|
|
141
|
+
checkRef(at, mod, value?.from, 'Entity', 'from');
|
|
142
|
+
break;
|
|
143
|
+
case 'Action': {
|
|
144
|
+
for (const ref of Array.isArray(value?.refresh) ? value.refresh : []) {
|
|
145
|
+
checkRef(at, mod, ref, 'Query', 'refresh');
|
|
146
|
+
}
|
|
147
|
+
if (value?.transport === 'remote' && !remoteEnabled) {
|
|
148
|
+
issues.push({
|
|
149
|
+
level: 'error',
|
|
150
|
+
address: at,
|
|
151
|
+
message:
|
|
152
|
+
'transport: remote is not enabled — opt in with app settings.remoteTransport: true (runtime support landed with R-11)'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'Policy': {
|
|
158
|
+
const entityAddr = formatAddress({ module: mod, kind: 'Entity', name: unit.name });
|
|
159
|
+
if (!index.byAddress.has(entityAddr)) {
|
|
160
|
+
issues.push({
|
|
161
|
+
level: 'error',
|
|
162
|
+
address: at,
|
|
163
|
+
message: `policy "${unit.name}" does not match an entity in module "${mod}"`
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
for (const actionName of Object.keys(value?.run ?? {})) {
|
|
167
|
+
checkRef(at, mod, actionName, 'Action', 'run');
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case 'Page': {
|
|
172
|
+
for (const comp of Array.isArray(value?.components) ? value.components : []) {
|
|
173
|
+
if (comp && typeof comp === 'object') {
|
|
174
|
+
for (const [key, bound] of Object.entries(comp)) {
|
|
175
|
+
if (typeof bound === 'string' && isAddress(bound)) {
|
|
176
|
+
const addr = parseAddress(bound);
|
|
177
|
+
checkRef(at, mod, bound, addr.kind, `components.${key}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'Trigger': {
|
|
185
|
+
const action = typeof value === 'string' ? value : value?.action;
|
|
186
|
+
checkRef(at, mod, action, 'Action', 'trigger action');
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case 'Component': {
|
|
190
|
+
for (const [event, target] of Object.entries(value?.events ?? {})) {
|
|
191
|
+
checkRef(at, mod, target, 'Action', `events.${event}`);
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return issues;
|
|
199
|
+
}
|