@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,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit templates (K-12): Query, Action, Policy, Page, Trigger.
|
|
3
|
+
*
|
|
4
|
+
* Guard-first by construction: emitted actions check the entity's write
|
|
5
|
+
* policy (and any per-action run rule) before touching the database;
|
|
6
|
+
* emitted queries AND the read policy's row rule into every select. All
|
|
7
|
+
* iteration is over sorted keys so output is independent of spec key
|
|
8
|
+
* order. Level-2 (`impl: custom`) actions and pages emit shells (K-13):
|
|
9
|
+
* the generated code keeps the guards and the contract, then delegates to
|
|
10
|
+
* the hand-written body imported via the `$custom` alias (→ `src/`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { isAddress, parseAddress } from './address.js';
|
|
14
|
+
import { VALIBOT, normalizeField } from './emit-schema.js';
|
|
15
|
+
import { compileGuard } from './expr-compile.js';
|
|
16
|
+
import { parseExpr } from './expr.js';
|
|
17
|
+
|
|
18
|
+
const header = (moduleName) =>
|
|
19
|
+
`// GENERATED by \`norns generate\` from specs/${moduleName}.tron — do not edit.`;
|
|
20
|
+
|
|
21
|
+
const pascal = (name) => name[0].toUpperCase() + name.slice(1);
|
|
22
|
+
|
|
23
|
+
/** `Order` (in-module) or `catalog.Entity.Product` → { module, entity }. */
|
|
24
|
+
function resolveEntityRef(moduleName, ref) {
|
|
25
|
+
if (isAddress(ref)) {
|
|
26
|
+
const parsed = parseAddress(ref);
|
|
27
|
+
return { module: parsed.module, entity: parsed.name };
|
|
28
|
+
}
|
|
29
|
+
return { module: moduleName, entity: ref };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function policyFor(specs, module, entity) {
|
|
33
|
+
return specs.modules[module]?.policies?.[entity];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ownerFieldOf(specs, module, entity) {
|
|
37
|
+
return specs.modules[module]?.entities?.[entity]?.owner;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Relative import prefix from a file at `depth` dirs below the generated root. */
|
|
41
|
+
const up = (depth) => '../'.repeat(depth);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* In specs, `status == draft` compares against the state *name* — the
|
|
45
|
+
* canonical form is unquoted (PLAN §5.2). The parser reads `draft` as a
|
|
46
|
+
* path, so rewrite single-segment paths that name a state of the target
|
|
47
|
+
* entity into literals when compared with `status`.
|
|
48
|
+
*/
|
|
49
|
+
function withStateLiterals(node, states) {
|
|
50
|
+
if (!node || typeof node !== 'object' || !node.op) return node;
|
|
51
|
+
if (node.op === 'and' || node.op === 'or' || node.op === 'not') {
|
|
52
|
+
return { op: node.op, args: node.args.map((a) => withStateLiterals(a, states)) };
|
|
53
|
+
}
|
|
54
|
+
const isStatus = (n) => n.path?.length === 1 && n.path[0] === 'status';
|
|
55
|
+
const asState = (n) =>
|
|
56
|
+
n.path?.length === 1 && states.has(n.path[0]) ? { lit: n.path[0] } : n;
|
|
57
|
+
let [a, b] = node.args;
|
|
58
|
+
if (isStatus(a)) b = asState(b);
|
|
59
|
+
else if (isStatus(b)) a = asState(a);
|
|
60
|
+
return { op: node.op, args: [a, b] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function statesOf(specs, module, entity) {
|
|
64
|
+
return new Set(Object.keys(specs.modules[module]?.entities?.[entity]?.status ?? {}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseUnitExpr(exprSrc, states) {
|
|
68
|
+
return withStateLiterals(parseExpr(exprSrc), states ?? new Set());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function whereCall(exprSrc, { entity, ownerField, states }) {
|
|
72
|
+
const ast = JSON.stringify(parseUnitExpr(exprSrc, states));
|
|
73
|
+
const owner = ownerField ? `, ownerField: ${JSON.stringify(ownerField)}` : '';
|
|
74
|
+
return `compileWhere(${ast}, { table: ${entity}, ops, user${owner} })`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function guardExpr(exprSrc, ownerField, states) {
|
|
78
|
+
return compileGuard(parseUnitExpr(exprSrc, states), { ownerField });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ------------------------------------------------------------------ */
|
|
82
|
+
/* Policies → lib/<module>/policies.c */
|
|
83
|
+
/* ------------------------------------------------------------------ */
|
|
84
|
+
|
|
85
|
+
export function emitModulePolicies(moduleName, moduleSpec, specs) {
|
|
86
|
+
const policies = moduleSpec.policies ?? {};
|
|
87
|
+
const names = Object.keys(policies).sort();
|
|
88
|
+
if (names.length === 0) return null;
|
|
89
|
+
|
|
90
|
+
const lines = [header(moduleName), '', `import { compileWhere } from '@human-synthesis/norns/server'`, '', `import { ${names.join(', ')} } from './schema.c'`, ''];
|
|
91
|
+
|
|
92
|
+
for (const entity of names) {
|
|
93
|
+
const policy = policies[entity];
|
|
94
|
+
const ownerField = ownerFieldOf(specs, moduleName, entity);
|
|
95
|
+
const states = statesOf(specs, moduleName, entity);
|
|
96
|
+
const owner = ownerField ? `, ownerField: ${JSON.stringify(ownerField)}` : '';
|
|
97
|
+
const body = [`\tentity: ${entity}`];
|
|
98
|
+
if (ownerField) body.push(`\townerField: ${JSON.stringify(ownerField)}`);
|
|
99
|
+
for (const rule of ['read', 'write']) {
|
|
100
|
+
if (policy[rule] === undefined) continue;
|
|
101
|
+
const ast = JSON.stringify(parseUnitExpr(policy[rule], states));
|
|
102
|
+
body.push(
|
|
103
|
+
[
|
|
104
|
+
`\t${rule}: {`,
|
|
105
|
+
`\t\tcheck: (row, user) => ${guardExpr(policy[rule], ownerField, states)},`,
|
|
106
|
+
`\t\twhere: (ops, user) => compileWhere(${ast}, { table: ${entity}, ops, user${owner} })`,
|
|
107
|
+
`\t}`
|
|
108
|
+
].join('\n')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const runs = Object.keys(policy.run ?? {}).sort();
|
|
112
|
+
if (runs.length > 0) {
|
|
113
|
+
body.push(
|
|
114
|
+
[
|
|
115
|
+
`\trun: {`,
|
|
116
|
+
runs
|
|
117
|
+
.map((a) => `\t\t${a}: (row, user) => ${guardExpr(policy.run[a], ownerField, states)}`)
|
|
118
|
+
.join(',\n'),
|
|
119
|
+
`\t}`
|
|
120
|
+
].join('\n')
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
lines.push(`export ${entity}Policy := {`, body.join(',\n'), `}`, '');
|
|
124
|
+
}
|
|
125
|
+
return { path: `lib/${moduleName}/policies.c`, text: lines.join('\n') };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
/* Queries → lib/<module>/queries.c */
|
|
130
|
+
/* ------------------------------------------------------------------ */
|
|
131
|
+
|
|
132
|
+
const OPS_CONST = `ops := { 'and': dz.and, 'or': dz.or, 'not': dz.not, eq: dz.eq, ne: dz.ne, lt: dz.lt, lte: dz.lte, gt: dz.gt, gte: dz.gte, inArray: dz.inArray, bool: (b) => b ? dz.sql\`1 = 1\` : dz.sql\`1 = 0\` }`;
|
|
133
|
+
|
|
134
|
+
const GROUP_ROWS = [
|
|
135
|
+
`groupRows := (rows, key) => {`,
|
|
136
|
+
`\tconst out = {}`,
|
|
137
|
+
`\tfor (const row of rows) (out[row[key]] ??= []).push(row)`,
|
|
138
|
+
`\treturn out`,
|
|
139
|
+
`}`
|
|
140
|
+
].join('\n');
|
|
141
|
+
|
|
142
|
+
export function emitModuleQueries(moduleName, moduleSpec, specs) {
|
|
143
|
+
const queries = moduleSpec.queries ?? {};
|
|
144
|
+
const names = Object.keys(queries).sort();
|
|
145
|
+
if (names.length === 0) return null;
|
|
146
|
+
|
|
147
|
+
const entityImports = new Map();
|
|
148
|
+
const policyImports = new Map();
|
|
149
|
+
const fns = [];
|
|
150
|
+
let needsGroup = false;
|
|
151
|
+
let needsWhere = false;
|
|
152
|
+
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
const q = queries[name];
|
|
155
|
+
const { module, entity } = resolveEntityRef(moduleName, q.from);
|
|
156
|
+
const local = module === moduleName ? './' : `../${module}/`;
|
|
157
|
+
entityImports.set(entity, `${local}schema.c`);
|
|
158
|
+
|
|
159
|
+
const policy = policyFor(specs, module, entity);
|
|
160
|
+
const ownerField = ownerFieldOf(specs, module, entity);
|
|
161
|
+
const wheres = [];
|
|
162
|
+
if (policy?.read !== undefined) {
|
|
163
|
+
policyImports.set(`${entity}Policy`, `${local}policies.c`);
|
|
164
|
+
wheres.push(`${entity}Policy.read.where(ops, ctx.user)`);
|
|
165
|
+
}
|
|
166
|
+
if (q.filter !== undefined) {
|
|
167
|
+
needsWhere = true;
|
|
168
|
+
wheres.push(whereCall(q.filter, { entity, ownerField, states: statesOf(specs, module, entity) }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const chain = [`db.select().from(${entity})`];
|
|
172
|
+
if (wheres.length === 1) chain.push(`.where(${wheres[0]})`);
|
|
173
|
+
else if (wheres.length > 1) chain.push(`.where(dz.and(${wheres.join(', ')}))`);
|
|
174
|
+
for (const sort of [q.sort ?? []].flat()) {
|
|
175
|
+
const desc = sort.startsWith('-');
|
|
176
|
+
chain.push(`.orderBy(dz.${desc ? 'desc' : 'asc'}(${entity}.${desc ? sort.slice(1) : sort}))`);
|
|
177
|
+
}
|
|
178
|
+
if (q.limit !== undefined) chain.push(`.limit(${q.limit})`);
|
|
179
|
+
|
|
180
|
+
const body = [
|
|
181
|
+
`export ${name} := async (ctx) => {`,
|
|
182
|
+
`\tconst db = ctx.container.resolve('db')`,
|
|
183
|
+
`\tconst rows = await ${chain.join('')}`
|
|
184
|
+
];
|
|
185
|
+
if (q.groupBy) {
|
|
186
|
+
needsGroup = true;
|
|
187
|
+
body.push(`\treturn groupRows(rows, ${JSON.stringify(q.groupBy)})`);
|
|
188
|
+
} else {
|
|
189
|
+
body.push(`\treturn rows`);
|
|
190
|
+
}
|
|
191
|
+
body.push(`}`);
|
|
192
|
+
fns.push(body.join('\n'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const lines = [header(moduleName), '', `import * as dz from 'drizzle-orm'`];
|
|
196
|
+
if (needsWhere) lines.push(`import { compileWhere } from '@human-synthesis/norns/server'`);
|
|
197
|
+
lines.push('');
|
|
198
|
+
for (const [file, names_] of groupImports(entityImports)) {
|
|
199
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
200
|
+
}
|
|
201
|
+
for (const [file, names_] of groupImports(policyImports)) {
|
|
202
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
203
|
+
}
|
|
204
|
+
lines.push('', OPS_CONST, '');
|
|
205
|
+
if (needsGroup) lines.push(GROUP_ROWS, '');
|
|
206
|
+
lines.push(fns.join('\n\n'), '');
|
|
207
|
+
return { path: `lib/${moduleName}/queries.c`, text: lines.join('\n') };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function groupImports(map) {
|
|
211
|
+
const byFile = new Map();
|
|
212
|
+
for (const [name, file] of [...map.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
213
|
+
if (!byFile.has(file)) byFile.set(file, []);
|
|
214
|
+
byFile.get(file).push(name);
|
|
215
|
+
}
|
|
216
|
+
return [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ------------------------------------------------------------------ */
|
|
220
|
+
/* Actions → lib/<module>/actions.c */
|
|
221
|
+
/* ------------------------------------------------------------------ */
|
|
222
|
+
|
|
223
|
+
/** The entity an action operates on: first `set` step, else its input refs. */
|
|
224
|
+
export function actionEntity(moduleName, action, specs) {
|
|
225
|
+
for (const step of action.steps ?? []) {
|
|
226
|
+
if (step.set?.entity) return resolveEntityRef(moduleName, step.set.entity);
|
|
227
|
+
}
|
|
228
|
+
for (const key of Object.keys(action.input ?? {}).sort()) {
|
|
229
|
+
const ref = action.input[key];
|
|
230
|
+
if (typeof ref !== 'string') continue;
|
|
231
|
+
const entity = ref.replace(/\?$/, '').split('.')[0];
|
|
232
|
+
if (specs.modules[moduleName]?.entities?.[entity]) return { module: moduleName, entity };
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function inputSchema(moduleName, action, specs) {
|
|
238
|
+
const input = action.input ?? {};
|
|
239
|
+
const keys = Object.keys(input).sort();
|
|
240
|
+
if (keys.length === 0) return null;
|
|
241
|
+
const fields = keys.map((key) => {
|
|
242
|
+
const ref = input[key];
|
|
243
|
+
let schema = 'v.unknown()';
|
|
244
|
+
if (typeof ref === 'string') {
|
|
245
|
+
const optional = ref.endsWith('?');
|
|
246
|
+
const [entity, field] = ref.replace(/\?$/, '').split('.');
|
|
247
|
+
const def =
|
|
248
|
+
field === 'id'
|
|
249
|
+
? { type: 'text' }
|
|
250
|
+
: normalizeField(specs.modules[moduleName]?.entities?.[entity]?.fields?.[field] ?? 'text');
|
|
251
|
+
schema = VALIBOT[def.type] ?? 'v.string()';
|
|
252
|
+
if (optional) schema = `v.optional(${schema})`;
|
|
253
|
+
}
|
|
254
|
+
return `${key}: ${schema}`;
|
|
255
|
+
});
|
|
256
|
+
return `v.strictObject({ ${fields.join(', ')} })`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
260
|
+
const actions = moduleSpec.actions ?? {};
|
|
261
|
+
const names = Object.keys(actions).sort();
|
|
262
|
+
if (names.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
const entityImports = new Map();
|
|
265
|
+
const policyImports = new Map();
|
|
266
|
+
const customImports = [];
|
|
267
|
+
const fns = [];
|
|
268
|
+
|
|
269
|
+
for (const name of names) {
|
|
270
|
+
const action = actions[name];
|
|
271
|
+
const custom = action.impl === 'custom';
|
|
272
|
+
const target = actionEntity(moduleName, action, specs);
|
|
273
|
+
const address = `${moduleName}.Action.${name}`;
|
|
274
|
+
const body = [];
|
|
275
|
+
|
|
276
|
+
if (target) {
|
|
277
|
+
const { module, entity } = target;
|
|
278
|
+
const local = module === moduleName ? './' : `../${module}/`;
|
|
279
|
+
entityImports.set(entity, `${local}schema.c`);
|
|
280
|
+
const policy = policyFor(specs, module, entity);
|
|
281
|
+
const ownerField = ownerFieldOf(specs, module, entity);
|
|
282
|
+
const idKey =
|
|
283
|
+
Object.keys(action.input ?? {})
|
|
284
|
+
.sort()
|
|
285
|
+
.find((k) => action.input[k] === `${entity}.id`) ?? 'id';
|
|
286
|
+
|
|
287
|
+
body.push(
|
|
288
|
+
`\t\tconst db = container.resolve('db')`,
|
|
289
|
+
`\t\tconst row = (await db.select().from(${entity}).where(eq(${entity}.id, input.${idKey})).limit(1))[0]`,
|
|
290
|
+
`\t\tif (!row) throw error(404, ${JSON.stringify(`${entity} not found`)})`
|
|
291
|
+
);
|
|
292
|
+
if (policy?.write !== undefined) {
|
|
293
|
+
policyImports.set(`${entity}Policy`, `${local}policies.c`);
|
|
294
|
+
body.push(`\t\tif (!${entity}Policy.write.check(row, user)) throw error(403, 'forbidden')`);
|
|
295
|
+
}
|
|
296
|
+
if (policy?.run?.[name] !== undefined) {
|
|
297
|
+
policyImports.set(`${entity}Policy`, `${local}policies.c`);
|
|
298
|
+
body.push(`\t\tif (!${entity}Policy.run.${name}(row, user)) throw error(403, 'forbidden')`);
|
|
299
|
+
}
|
|
300
|
+
if (action.requires !== undefined) {
|
|
301
|
+
body.push(
|
|
302
|
+
`\t\tif (!(${guardExpr(action.requires, ownerField, statesOf(specs, module, entity))})) throw error(409, ${JSON.stringify(`requires failed: ${action.requires}`)})`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (custom) {
|
|
306
|
+
body.push(`\t\treturn ${name}Body({ row, input, container, user })`);
|
|
307
|
+
}
|
|
308
|
+
for (const step of custom ? [] : (action.steps ?? [])) {
|
|
309
|
+
if (step.set) {
|
|
310
|
+
const { entity: setEntity, ...fields } = step.set;
|
|
311
|
+
const sets = Object.keys(fields)
|
|
312
|
+
.sort()
|
|
313
|
+
.map((f) => `${f}: ${JSON.stringify(fields[f])}`);
|
|
314
|
+
// K-17: a status write must be a legal edge of the entity's
|
|
315
|
+
// machine, independent of any authored `requires` guard.
|
|
316
|
+
if ('status' in fields && statesOf(specs, module, entity).size > 0) {
|
|
317
|
+
entityImports.set(`${entity}Status`, `${local}schema.c`);
|
|
318
|
+
const to = JSON.stringify(fields.status);
|
|
319
|
+
body.push(
|
|
320
|
+
`\t\tif (!(${entity}Status[row.status] ?? []).includes(${to})) throw error(409, 'invalid transition ' + row.status + ' -> ' + ${to})`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
body.push(
|
|
324
|
+
`\t\tawait db.update(${entity}).set({ ${sets.join(', ')} }).where(eq(${entity}.id, input.${idKey}))`
|
|
325
|
+
);
|
|
326
|
+
} else if (step.emit) {
|
|
327
|
+
body.push(
|
|
328
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { row, input, user })`
|
|
329
|
+
);
|
|
330
|
+
} else if (step.call) {
|
|
331
|
+
body.push(`\t\tawait container.resolve(${JSON.stringify(step.call)})({ row, input, user })`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const evt of custom ? [] : (action.emits ?? [])) {
|
|
335
|
+
body.push(
|
|
336
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { row, input, user })`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
} else if (custom) {
|
|
340
|
+
body.push(`\t\treturn ${name}Body({ input, container, user })`);
|
|
341
|
+
}
|
|
342
|
+
if (custom) {
|
|
343
|
+
customImports.push(`import ${name}Body from '$custom/${moduleName}/actions/${name}.c'`);
|
|
344
|
+
} else {
|
|
345
|
+
body.push(`\t\treturn { ok: true }`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const schema = inputSchema(moduleName, action, specs);
|
|
349
|
+
const props = [`\taddress: ${JSON.stringify(address)}`];
|
|
350
|
+
if (schema) props.push(`\tinput: ${schema}`);
|
|
351
|
+
if (action.refresh) props.push(`\trefresh: ${JSON.stringify(action.refresh)}`);
|
|
352
|
+
props.push([`\trun: async ({ input, container, user }) => {`, ...body, `\t}`].join('\n'));
|
|
353
|
+
fns.push([`export ${name} := {`, props.join(',\n'), `}`].join('\n'));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const lines = [header(moduleName), '', `import { error } from '@sveltejs/kit'`, `import { eq } from 'drizzle-orm'`, `import * as v from 'valibot'`, ''];
|
|
357
|
+
for (const [file, names_] of groupImports(entityImports)) {
|
|
358
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
359
|
+
}
|
|
360
|
+
for (const [file, names_] of groupImports(policyImports)) {
|
|
361
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
362
|
+
}
|
|
363
|
+
lines.push(...customImports);
|
|
364
|
+
lines.push('', fns.join('\n\n'), '');
|
|
365
|
+
return { path: `lib/${moduleName}/actions.c`, text: lines.join('\n') };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ------------------------------------------------------------------ */
|
|
369
|
+
/* Triggers → lib/<module>/triggers.c */
|
|
370
|
+
/* ------------------------------------------------------------------ */
|
|
371
|
+
|
|
372
|
+
export function emitModuleTriggers(moduleName, moduleSpec) {
|
|
373
|
+
const triggers = moduleSpec.triggers ?? {};
|
|
374
|
+
const names = Object.keys(triggers).sort();
|
|
375
|
+
if (names.length === 0) return null;
|
|
376
|
+
|
|
377
|
+
const actionImports = new Map();
|
|
378
|
+
const entries = [];
|
|
379
|
+
for (const name of names) {
|
|
380
|
+
const t = triggers[name];
|
|
381
|
+
const spec = typeof t === 'string' ? { action: t } : t;
|
|
382
|
+
const parsed = parseAddress(spec.action);
|
|
383
|
+
const local = parsed.module === moduleName ? './' : `../${parsed.module}/`;
|
|
384
|
+
actionImports.set(parsed.name, `${local}actions.c`);
|
|
385
|
+
const props = [`on: ${JSON.stringify(name)}`, `action: ${parsed.name}`];
|
|
386
|
+
if (spec.schedule) props.push(`schedule: ${JSON.stringify(spec.schedule)}`);
|
|
387
|
+
if (spec.source) props.push(`source: ${JSON.stringify(spec.source)}`);
|
|
388
|
+
entries.push(`\t{ ${props.join(', ')} }`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const lines = [header(moduleName), ''];
|
|
392
|
+
for (const [file, names_] of groupImports(actionImports)) {
|
|
393
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
394
|
+
}
|
|
395
|
+
lines.push('', `export triggers := [`, entries.join(',\n'), `]`, '');
|
|
396
|
+
return { path: `lib/${moduleName}/triggers.c`, text: lines.join('\n') };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ------------------------------------------------------------------ */
|
|
400
|
+
/* Remote actions → routes/api/<module>/<action>/+server.c */
|
|
401
|
+
/* ------------------------------------------------------------------ */
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Actions with `transport: 'remote'` also get a POST endpoint wrapping the
|
|
405
|
+
* same generated unit (schema + guards + refresh publication ride along
|
|
406
|
+
* via `remoteAction`). Client side: `remoteCall` in `norns/live-client`.
|
|
407
|
+
*/
|
|
408
|
+
export function emitModuleRemotes(moduleName, moduleSpec) {
|
|
409
|
+
const actions = moduleSpec.actions ?? {};
|
|
410
|
+
const names = Object.keys(actions)
|
|
411
|
+
.filter((n) => actions[n]?.transport === 'remote')
|
|
412
|
+
.sort();
|
|
413
|
+
if (names.length === 0) return null;
|
|
414
|
+
|
|
415
|
+
return names.map((name) => ({
|
|
416
|
+
path: `routes/api/${moduleName}/${name}/+server.c`,
|
|
417
|
+
text: [
|
|
418
|
+
header(moduleName),
|
|
419
|
+
'',
|
|
420
|
+
`import { remoteAction } from '@human-synthesis/norns/server'`,
|
|
421
|
+
'',
|
|
422
|
+
`import { ${name} } from '${up(4)}lib/${moduleName}/actions.c'`,
|
|
423
|
+
'',
|
|
424
|
+
`export POST := remoteAction(${name})`,
|
|
425
|
+
''
|
|
426
|
+
].join('\n')
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* ------------------------------------------------------------------ */
|
|
431
|
+
/* Pages → routes<route>/+page.server.c + +page.n */
|
|
432
|
+
/* ------------------------------------------------------------------ */
|
|
433
|
+
|
|
434
|
+
/** '/orders/:id' → ['orders', '[id]'] */
|
|
435
|
+
function routeSegments(route) {
|
|
436
|
+
return route
|
|
437
|
+
.split('/')
|
|
438
|
+
.filter(Boolean)
|
|
439
|
+
.map((seg) => (seg.startsWith(':') ? `[${seg.slice(1)}]` : seg));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Collect { queries, actions, components } bound by a page spec. */
|
|
443
|
+
function pageBindings(pageSpec) {
|
|
444
|
+
const queries = new Map();
|
|
445
|
+
const actions = new Map();
|
|
446
|
+
const components = [];
|
|
447
|
+
for (const entry of pageSpec.components ?? []) {
|
|
448
|
+
const keys = Object.keys(entry);
|
|
449
|
+
if (keys.length === 0) continue;
|
|
450
|
+
const [first, ...rest] = keys;
|
|
451
|
+
const component = { tag: pascal(first), props: [] };
|
|
452
|
+
for (const key of keys) {
|
|
453
|
+
const value = entry[key];
|
|
454
|
+
const parsed = typeof value === 'string' && isAddress(value) ? parseAddress(value) : null;
|
|
455
|
+
if (parsed?.kind === 'Query') {
|
|
456
|
+
queries.set(parsed.name, parsed.module);
|
|
457
|
+
component.props.push(
|
|
458
|
+
key === first
|
|
459
|
+
? `data!="{data.${parsed.name}}"`
|
|
460
|
+
: `${key}!="{data.${parsed.name}}"`
|
|
461
|
+
);
|
|
462
|
+
} else if (parsed?.kind === 'Action') {
|
|
463
|
+
actions.set(parsed.name, parsed.module);
|
|
464
|
+
// an Action in the component slot is the form target, so it binds
|
|
465
|
+
// the conventional `action` prop rather than the slot key
|
|
466
|
+
component.props.push(`${key === first ? 'action' : key}="?/${parsed.name}"`);
|
|
467
|
+
} else if (typeof value === 'string' && key !== first) {
|
|
468
|
+
component.props.push(`${key}=${JSON.stringify(value)}`);
|
|
469
|
+
} else if ((typeof value === 'number' || typeof value === 'boolean') && key !== first) {
|
|
470
|
+
component.props.push(`${key}!="{${JSON.stringify(value)}}"`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
components.push(component);
|
|
474
|
+
}
|
|
475
|
+
return { queries, actions, components };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function emitModulePages(moduleName, moduleSpec, specs) {
|
|
479
|
+
const pages = moduleSpec.pages ?? {};
|
|
480
|
+
const names = Object.keys(pages).sort();
|
|
481
|
+
const files = [];
|
|
482
|
+
|
|
483
|
+
for (const name of names) {
|
|
484
|
+
const spec = pages[name];
|
|
485
|
+
const segments = routeSegments(spec.route);
|
|
486
|
+
const dir = ['routes', ...segments].join('/');
|
|
487
|
+
const lib = up(segments.length + 1) + 'lib';
|
|
488
|
+
const { queries, actions, components } = pageBindings(spec);
|
|
489
|
+
// Queries marked `live: true` get a depends key in the load and an
|
|
490
|
+
// EventSource subscription in the page, so refresh signals from
|
|
491
|
+
// /_norns/live re-run the load (R-11).
|
|
492
|
+
const liveAddrs = [...queries]
|
|
493
|
+
.filter(([q, m]) => specs?.modules?.[m]?.queries?.[q]?.live === true)
|
|
494
|
+
.map(([q, m]) => `${m}.Query.${q}`)
|
|
495
|
+
.sort();
|
|
496
|
+
|
|
497
|
+
const lines = [header(moduleName), '', `import { page } from '@human-synthesis/norns/server'`, ''];
|
|
498
|
+
for (const [file, names_] of groupImports(
|
|
499
|
+
new Map([...queries].map(([q, m]) => [q, `${lib}/${m}/queries.c`]))
|
|
500
|
+
)) {
|
|
501
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
502
|
+
}
|
|
503
|
+
for (const [file, names_] of groupImports(
|
|
504
|
+
new Map([...actions].map(([a, m]) => [a, `${lib}/${m}/actions.c`]))
|
|
505
|
+
)) {
|
|
506
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
507
|
+
}
|
|
508
|
+
lines.push('');
|
|
509
|
+
|
|
510
|
+
const loads = [...queries.keys()].sort();
|
|
511
|
+
lines.push(
|
|
512
|
+
`export load := page.load({`,
|
|
513
|
+
`\thandler: async (ctx) => {`,
|
|
514
|
+
...liveAddrs.map((addr) => `\t\tctx.event.depends(${JSON.stringify(`norns:${addr}`)})`),
|
|
515
|
+
`\t\treturn { ${loads.map((q) => `${q}: await ${q}(ctx)`).join(', ')} }`,
|
|
516
|
+
`\t}`,
|
|
517
|
+
`})`
|
|
518
|
+
);
|
|
519
|
+
const acts = [...actions.keys()].sort();
|
|
520
|
+
if (acts.length > 0) {
|
|
521
|
+
lines.push('', `export actions := page.actions({`, acts.map((a) => `\t${a}: ${a}`).join(',\n'), `})`);
|
|
522
|
+
}
|
|
523
|
+
lines.push('');
|
|
524
|
+
files.push({ path: `${dir}/+page.server.c`, text: lines.join('\n') });
|
|
525
|
+
|
|
526
|
+
const liveScript = liveAddrs.length
|
|
527
|
+
? {
|
|
528
|
+
imports: [
|
|
529
|
+
`\timport { invalidate } from '$app/navigation'`,
|
|
530
|
+
`\timport { liveQueries } from '@human-synthesis/norns/live-client'`
|
|
531
|
+
],
|
|
532
|
+
effect: `\t$effect(() => liveQueries(${JSON.stringify(liveAddrs)}, invalidate))`
|
|
533
|
+
}
|
|
534
|
+
: null;
|
|
535
|
+
|
|
536
|
+
const pug = [header(moduleName), ''];
|
|
537
|
+
if (spec.impl === 'custom') {
|
|
538
|
+
// Level-2: the template body lives in src/, the generated shell
|
|
539
|
+
// stays the routing surface and passes the page contract through.
|
|
540
|
+
pug.push(
|
|
541
|
+
`Body(data!="{data}" form!="{form}")`,
|
|
542
|
+
'',
|
|
543
|
+
'<script>',
|
|
544
|
+
...(liveScript?.imports ?? []),
|
|
545
|
+
`\timport Body from '$custom/${moduleName}/pages/${name}.n'`,
|
|
546
|
+
`\t{ data, form } := $props()`,
|
|
547
|
+
...(liveScript ? [liveScript.effect] : []),
|
|
548
|
+
'</script>',
|
|
549
|
+
''
|
|
550
|
+
);
|
|
551
|
+
} else {
|
|
552
|
+
pug.push(`section.norns-page`);
|
|
553
|
+
for (const c of components) {
|
|
554
|
+
pug.push(`\t${c.tag}(${c.props.join(' ')})`);
|
|
555
|
+
}
|
|
556
|
+
const stateKeys = Object.keys(spec.state ?? {}).sort();
|
|
557
|
+
pug.push('', '<script>');
|
|
558
|
+
if (liveScript) pug.push(...liveScript.imports);
|
|
559
|
+
pug.push(`\t{ data, form } := $props()`);
|
|
560
|
+
for (const key of stateKeys) pug.push(`\t${key} := $state(null)`);
|
|
561
|
+
if (liveScript) pug.push(liveScript.effect);
|
|
562
|
+
pug.push('</script>', '');
|
|
563
|
+
}
|
|
564
|
+
files.push({ path: `${dir}/+page.n`, text: pug.join('\n') });
|
|
565
|
+
}
|
|
566
|
+
return files;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* ------------------------------------------------------------------ */
|
|
570
|
+
|
|
571
|
+
const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
|
|
572
|
+
const file = fn(moduleName, moduleSpec, specs);
|
|
573
|
+
return file ? [file] : [];
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
export const policiesEmitter = { name: 'policies', emit: single(emitModulePolicies) };
|
|
577
|
+
export const queriesEmitter = { name: 'queries', emit: single(emitModuleQueries) };
|
|
578
|
+
export const actionsEmitter = { name: 'actions', emit: single(emitModuleActions) };
|
|
579
|
+
export const triggersEmitter = { name: 'triggers', emit: single(emitModuleTriggers) };
|
|
580
|
+
export const pagesEmitter = {
|
|
581
|
+
name: 'pages',
|
|
582
|
+
emit: ({ moduleName, moduleSpec, specs }) => emitModulePages(moduleName, moduleSpec, specs)
|
|
583
|
+
};
|
|
584
|
+
export const remotesEmitter = {
|
|
585
|
+
name: 'remotes',
|
|
586
|
+
emit: ({ moduleName, moduleSpec }) => emitModuleRemotes(moduleName, moduleSpec) ?? []
|
|
587
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrangler config generation (R-12): the Cloudflare deploy surface is
|
|
3
|
+
* derived from the specs, not hand-maintained. `generateApp` writes
|
|
4
|
+
* `wrangler.json` into the generated root; deploy with
|
|
5
|
+
* `wrangler deploy -c .norns/generated/wrangler.json`.
|
|
6
|
+
*
|
|
7
|
+
* Binding names are fixed contracts (`DB`, `STORAGE`, `EVENTS`, `ASSETS`,
|
|
8
|
+
* `ROOM`)
|
|
9
|
+
* so runtime adapters can rely on them. Account-specific values live in
|
|
10
|
+
* `app.settings.cloudflare` — spec-canonical, like everything else:
|
|
11
|
+
*
|
|
12
|
+
* settings: { cloudflare: { d1_id: '…', compatibility_date: '…', queue: true } }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Pinned so regeneration is deterministic; bump deliberately with runtime upgrades.
|
|
16
|
+
const DEFAULT_COMPAT_DATE = '2025-06-01';
|
|
17
|
+
|
|
18
|
+
const fieldType = (f) => (typeof f === 'string' ? f : f?.type);
|
|
19
|
+
|
|
20
|
+
function hasFileFields(specs) {
|
|
21
|
+
for (const mod of Object.values(specs.modules)) {
|
|
22
|
+
for (const entity of Object.values(mod.entities ?? {})) {
|
|
23
|
+
for (const field of Object.values(entity?.fields ?? {})) {
|
|
24
|
+
if (fieldType(field) === 'file') return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A Room DO is needed for live queries and for Worker units marked `room`. */
|
|
32
|
+
function needsRoom(specs) {
|
|
33
|
+
for (const mod of Object.values(specs.modules)) {
|
|
34
|
+
for (const query of Object.values(mod.queries ?? {})) {
|
|
35
|
+
if (query?.live === true) return true;
|
|
36
|
+
}
|
|
37
|
+
for (const worker of Object.values(mod.workers ?? {})) {
|
|
38
|
+
if (worker?.room === true) return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cronSchedules(specs) {
|
|
45
|
+
const crons = new Set();
|
|
46
|
+
for (const mod of Object.values(specs.modules)) {
|
|
47
|
+
for (const trigger of Object.values(mod.triggers ?? {})) {
|
|
48
|
+
if (typeof trigger?.schedule === 'string') crons.add(trigger.schedule);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...crons].sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the wrangler configuration object for an app's specs.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ app?: *, modules: Record<string, *> }} specs loaded specs (loadSpecs shape)
|
|
58
|
+
* @returns {object} wrangler config, JSON-serializable
|
|
59
|
+
*/
|
|
60
|
+
export function wranglerConfig(specs) {
|
|
61
|
+
const app = specs.app ?? {};
|
|
62
|
+
const cf = app.settings?.cloudflare ?? {};
|
|
63
|
+
const name = (app.name ?? 'app').toLowerCase().replaceAll('_', '-');
|
|
64
|
+
const dialect = app.dialect ?? 'd1';
|
|
65
|
+
|
|
66
|
+
const config = {
|
|
67
|
+
name,
|
|
68
|
+
main: '.svelte-kit/cloudflare/_worker.js',
|
|
69
|
+
compatibility_date: cf.compatibility_date ?? DEFAULT_COMPAT_DATE,
|
|
70
|
+
compatibility_flags: ['nodejs_compat'],
|
|
71
|
+
assets: { binding: 'ASSETS', directory: '.svelte-kit/cloudflare' }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (dialect === 'd1') {
|
|
75
|
+
config.d1_databases = [
|
|
76
|
+
{
|
|
77
|
+
binding: 'DB',
|
|
78
|
+
database_name: `${name}-db`,
|
|
79
|
+
database_id: cf.d1_id ?? '<set app.settings.cloudflare.d1_id>'
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (hasFileFields(specs) || cf.r2 === true) {
|
|
85
|
+
config.r2_buckets = [{ binding: 'STORAGE', bucket_name: `${name}-storage` }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (cf.queue === true) {
|
|
89
|
+
const queue = `${name}-events`;
|
|
90
|
+
config.queues = {
|
|
91
|
+
producers: [{ binding: 'EVENTS', queue }],
|
|
92
|
+
consumers: [{ queue }]
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (needsRoom(specs)) {
|
|
97
|
+
const className = cf.room_class ?? 'NornsRoom';
|
|
98
|
+
config.durable_objects = { bindings: [{ name: 'ROOM', class_name: className }] };
|
|
99
|
+
config.migrations = [{ tag: 'norns-room-v1', new_classes: [className] }];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const crons = cronSchedules(specs);
|
|
103
|
+
if (crons.length > 0) config.triggers = { crons };
|
|
104
|
+
|
|
105
|
+
return config;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {*} specs @returns {{ path: string, text: string }} */
|
|
109
|
+
export function wranglerFile(specs) {
|
|
110
|
+
return {
|
|
111
|
+
path: 'wrangler.json',
|
|
112
|
+
text: JSON.stringify(wranglerConfig(specs), null, '\t') + '\n'
|
|
113
|
+
};
|
|
114
|
+
}
|