@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,855 @@
|
|
|
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: ctx.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
|
+
const SERVICE_CALL_RE = /^([a-z][a-z0-9_]*)\.Service\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
260
|
+
const SCOPE_PATH_RE = /^(row|input|user)(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
|
261
|
+
|
|
262
|
+
/** `with:` values are scope paths (`input.id`) or JSON literals; no `with` passes the action input through. */
|
|
263
|
+
function withArg(withMap) {
|
|
264
|
+
if (!withMap || typeof withMap !== 'object') return 'input';
|
|
265
|
+
const entries = Object.keys(withMap)
|
|
266
|
+
.sort()
|
|
267
|
+
.map((k) => {
|
|
268
|
+
const val = withMap[k];
|
|
269
|
+
const isPath = typeof val === 'string' && SCOPE_PATH_RE.test(val);
|
|
270
|
+
return `${k}: ${isPath ? val : JSON.stringify(val)}`;
|
|
271
|
+
});
|
|
272
|
+
return `{ ${entries.join(', ')} }`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A `call` step: service operations go through the generated typed client; anything else resolves a container token. */
|
|
276
|
+
function callStep(step, moduleName, serviceImports, ctx) {
|
|
277
|
+
const m = SERVICE_CALL_RE.exec(step.call);
|
|
278
|
+
if (m) {
|
|
279
|
+
const [, module, service, op] = m;
|
|
280
|
+
const local = module === moduleName ? './' : `../${module}/`;
|
|
281
|
+
serviceImports.set(service, `${local}services.c`);
|
|
282
|
+
return `\t\tawait ${service}.${op}(${withArg(step.with)}, container)`;
|
|
283
|
+
}
|
|
284
|
+
return `\t\tawait container.resolve(${JSON.stringify(step.call)})(${ctx})`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const JOB_ADDR_RE = /^[a-z][a-z0-9_]*\.Job\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
288
|
+
|
|
289
|
+
/** An `enqueue` step hands the input to the `jobs` facade (R-14): Queues in prod, inline in dev. */
|
|
290
|
+
function enqueueStep(step, moduleName) {
|
|
291
|
+
const addr = JOB_ADDR_RE.test(step.enqueue) ? step.enqueue : `${moduleName}.Job.${step.enqueue}`;
|
|
292
|
+
return `\t\tawait container.resolve('jobs').enqueue(${JSON.stringify(addr)}, ${withArg(step.with)}, user)`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
296
|
+
const actions = moduleSpec.actions ?? {};
|
|
297
|
+
const names = Object.keys(actions).sort();
|
|
298
|
+
if (names.length === 0) return null;
|
|
299
|
+
|
|
300
|
+
const entityImports = new Map();
|
|
301
|
+
const policyImports = new Map();
|
|
302
|
+
const serviceImports = new Map();
|
|
303
|
+
const customImports = [];
|
|
304
|
+
const fns = [];
|
|
305
|
+
|
|
306
|
+
for (const name of names) {
|
|
307
|
+
const action = actions[name];
|
|
308
|
+
const custom = action.impl === 'custom';
|
|
309
|
+
const target = actionEntity(moduleName, action, specs);
|
|
310
|
+
const address = `${moduleName}.Action.${name}`;
|
|
311
|
+
const body = [];
|
|
312
|
+
|
|
313
|
+
if (target) {
|
|
314
|
+
const { module, entity } = target;
|
|
315
|
+
const local = module === moduleName ? './' : `../${module}/`;
|
|
316
|
+
entityImports.set(entity, `${local}schema.c`);
|
|
317
|
+
const policy = policyFor(specs, module, entity);
|
|
318
|
+
const ownerField = ownerFieldOf(specs, module, entity);
|
|
319
|
+
const idKey =
|
|
320
|
+
Object.keys(action.input ?? {})
|
|
321
|
+
.sort()
|
|
322
|
+
.find((k) => action.input[k] === `${entity}.id`) ?? 'id';
|
|
323
|
+
|
|
324
|
+
body.push(
|
|
325
|
+
`\t\tconst db = container.resolve('db')`,
|
|
326
|
+
`\t\tconst row = (await db.select().from(${entity}).where(eq(${entity}.id, input.${idKey})).limit(1))[0]`,
|
|
327
|
+
`\t\tif (!row) throw error(404, ${JSON.stringify(`${entity} not found`)})`
|
|
328
|
+
);
|
|
329
|
+
if (policy?.write !== undefined) {
|
|
330
|
+
policyImports.set(`${entity}Policy`, `${local}policies.c`);
|
|
331
|
+
body.push(`\t\tif (!${entity}Policy.write.check(row, user)) throw error(403, 'forbidden')`);
|
|
332
|
+
}
|
|
333
|
+
if (policy?.run?.[name] !== undefined) {
|
|
334
|
+
policyImports.set(`${entity}Policy`, `${local}policies.c`);
|
|
335
|
+
body.push(`\t\tif (!${entity}Policy.run.${name}(row, user)) throw error(403, 'forbidden')`);
|
|
336
|
+
}
|
|
337
|
+
if (action.requires !== undefined) {
|
|
338
|
+
body.push(
|
|
339
|
+
`\t\tif (!(${guardExpr(action.requires, ownerField, statesOf(specs, module, entity))})) throw error(409, ${JSON.stringify(`requires failed: ${action.requires}`)})`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (custom) {
|
|
343
|
+
body.push(`\t\treturn ${name}Body({ row, input, container, user })`);
|
|
344
|
+
}
|
|
345
|
+
for (const step of custom ? [] : (action.steps ?? [])) {
|
|
346
|
+
if (step.set) {
|
|
347
|
+
const { entity: setEntity, ...fields } = step.set;
|
|
348
|
+
const sets = Object.keys(fields)
|
|
349
|
+
.sort()
|
|
350
|
+
.map((f) => `${f}: ${JSON.stringify(fields[f])}`);
|
|
351
|
+
// K-17: a status write must be a legal edge of the entity's
|
|
352
|
+
// machine, independent of any authored `requires` guard.
|
|
353
|
+
if ('status' in fields && statesOf(specs, module, entity).size > 0) {
|
|
354
|
+
entityImports.set(`${entity}Status`, `${local}schema.c`);
|
|
355
|
+
const to = JSON.stringify(fields.status);
|
|
356
|
+
body.push(
|
|
357
|
+
`\t\tif (!(${entity}Status[row.status] ?? []).includes(${to})) throw error(409, 'invalid transition ' + row.status + ' -> ' + ${to})`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
body.push(
|
|
361
|
+
`\t\tawait db.update(${entity}).set({ ${sets.join(', ')} }).where(eq(${entity}.id, input.${idKey}))`
|
|
362
|
+
);
|
|
363
|
+
} else if (step.emit) {
|
|
364
|
+
body.push(
|
|
365
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { row, input, user })`
|
|
366
|
+
);
|
|
367
|
+
} else if (step.call) {
|
|
368
|
+
body.push(callStep(step, moduleName, serviceImports, '{ row, input, user }'));
|
|
369
|
+
} else if (step.enqueue) {
|
|
370
|
+
body.push(enqueueStep(step, moduleName));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (const evt of custom ? [] : (action.emits ?? [])) {
|
|
374
|
+
body.push(
|
|
375
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { row, input, user })`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
} else if (custom) {
|
|
379
|
+
body.push(`\t\treturn ${name}Body({ input, container, user })`);
|
|
380
|
+
} else {
|
|
381
|
+
// No entity target: pure flow actions (integrations, notifications)
|
|
382
|
+
// still run their emit/call steps.
|
|
383
|
+
for (const step of action.steps ?? []) {
|
|
384
|
+
if (step.emit) {
|
|
385
|
+
body.push(
|
|
386
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { input, user })`
|
|
387
|
+
);
|
|
388
|
+
} else if (step.call) {
|
|
389
|
+
body.push(callStep(step, moduleName, serviceImports, '{ input, user }'));
|
|
390
|
+
} else if (step.enqueue) {
|
|
391
|
+
body.push(enqueueStep(step, moduleName));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const evt of action.emits ?? []) {
|
|
395
|
+
body.push(
|
|
396
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { input, user })`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (custom) {
|
|
401
|
+
customImports.push(`import ${name}Body from '$custom/${moduleName}/actions/${name}.c'`);
|
|
402
|
+
} else {
|
|
403
|
+
body.push(`\t\treturn { ok: true }`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const schema = inputSchema(moduleName, action, specs);
|
|
407
|
+
const props = [`\taddress: ${JSON.stringify(address)}`];
|
|
408
|
+
if (schema) props.push(`\tinput: ${schema}`);
|
|
409
|
+
if (action.refresh) props.push(`\trefresh: ${JSON.stringify(action.refresh)}`);
|
|
410
|
+
props.push([`\trun: async ({ input, container, user }) => {`, ...body, `\t}`].join('\n'));
|
|
411
|
+
fns.push([`export ${name} := {`, props.join(',\n'), `}`].join('\n'));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const lines = [header(moduleName), '', `import { error } from '@sveltejs/kit'`, `import { eq } from 'drizzle-orm'`, `import * as v from 'valibot'`, ''];
|
|
415
|
+
for (const [file, names_] of groupImports(entityImports)) {
|
|
416
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
417
|
+
}
|
|
418
|
+
for (const [file, names_] of groupImports(policyImports)) {
|
|
419
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
420
|
+
}
|
|
421
|
+
for (const [file, names_] of groupImports(serviceImports)) {
|
|
422
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
423
|
+
}
|
|
424
|
+
lines.push(...customImports);
|
|
425
|
+
lines.push('', fns.join('\n\n'), '');
|
|
426
|
+
return { path: `lib/${moduleName}/actions.c`, text: lines.join('\n') };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ------------------------------------------------------------------ */
|
|
430
|
+
/* Services → lib/<module>/services.c */
|
|
431
|
+
/* ------------------------------------------------------------------ */
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Service manifests (D15) become typed clients. Credentials resolve at call
|
|
435
|
+
* time from the env the container carries — never from the spec. `services`
|
|
436
|
+
* maps unit addresses to clients so boot can container-register them (R-14).
|
|
437
|
+
*/
|
|
438
|
+
export function emitModuleServices(moduleName, moduleSpec) {
|
|
439
|
+
const services = moduleSpec.services ?? {};
|
|
440
|
+
const names = Object.keys(services).sort();
|
|
441
|
+
if (names.length === 0) return null;
|
|
442
|
+
|
|
443
|
+
const lines = [header(moduleName), '', `import { serviceClient } from '@human-synthesis/norns/server'`, ''];
|
|
444
|
+
const entries = [];
|
|
445
|
+
for (const name of names) {
|
|
446
|
+
const svc = services[name];
|
|
447
|
+
const operations = {};
|
|
448
|
+
for (const op of Object.keys(svc.operations ?? {}).sort()) {
|
|
449
|
+
const o = svc.operations[op];
|
|
450
|
+
operations[op] = {
|
|
451
|
+
method: o.method ?? 'POST',
|
|
452
|
+
path: o.path ?? `/${op}`,
|
|
453
|
+
...(o.input !== undefined ? { input: o.input } : {}),
|
|
454
|
+
...(o.output !== undefined ? { output: o.output } : {})
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const def = {
|
|
458
|
+
name: `${moduleName}.Service.${name}`,
|
|
459
|
+
base: svc.base,
|
|
460
|
+
auth: svc.auth,
|
|
461
|
+
operations
|
|
462
|
+
};
|
|
463
|
+
lines.push(`export ${name} := serviceClient(${JSON.stringify(def, null, '\t')})`, '');
|
|
464
|
+
entries.push(`\t${JSON.stringify(`${moduleName}.Service.${name}`)}: ${name}`);
|
|
465
|
+
}
|
|
466
|
+
lines.push(`export services := {`, entries.join(',\n'), `}`, '');
|
|
467
|
+
return { path: `lib/${moduleName}/services.c`, text: lines.join('\n') };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/* ------------------------------------------------------------------ */
|
|
471
|
+
/* Jobs → lib/<module>/jobs.c */
|
|
472
|
+
/* ------------------------------------------------------------------ */
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Jobs (K-22) become `job({...})` units carrying their retry/dlq contract;
|
|
476
|
+
* `registerJobs` (R-14) wires them to `job:<address>` bus messages with
|
|
477
|
+
* retry/backoff/DLQ semantics — Cloudflare Queues in prod, inline in dev.
|
|
478
|
+
*/
|
|
479
|
+
export function emitModuleJobs(moduleName, moduleSpec) {
|
|
480
|
+
const jobs = moduleSpec.jobs ?? {};
|
|
481
|
+
const names = Object.keys(jobs).sort();
|
|
482
|
+
if (names.length === 0) return null;
|
|
483
|
+
|
|
484
|
+
const serviceImports = new Map();
|
|
485
|
+
const customImports = [];
|
|
486
|
+
const fns = [];
|
|
487
|
+
const entries = [];
|
|
488
|
+
|
|
489
|
+
for (const name of names) {
|
|
490
|
+
const j = jobs[name];
|
|
491
|
+
const custom = j.impl === 'custom';
|
|
492
|
+
const address = `${moduleName}.Job.${name}`;
|
|
493
|
+
const body = [];
|
|
494
|
+
if (custom) {
|
|
495
|
+
customImports.push(`import ${name}Body from '$custom/${moduleName}/jobs/${name}.c'`);
|
|
496
|
+
body.push(`\t\treturn ${name}Body({ input, container, user })`);
|
|
497
|
+
} else {
|
|
498
|
+
for (const step of j.steps ?? []) {
|
|
499
|
+
if (step.emit) {
|
|
500
|
+
body.push(
|
|
501
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { input, user })`
|
|
502
|
+
);
|
|
503
|
+
} else if (step.call) {
|
|
504
|
+
body.push(callStep(step, moduleName, serviceImports, '{ input, user }'));
|
|
505
|
+
} else if (step.enqueue) {
|
|
506
|
+
body.push(enqueueStep(step, moduleName));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const evt of j.emits ?? []) {
|
|
510
|
+
body.push(
|
|
511
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { input, user })`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const props = [`\taddress: ${JSON.stringify(address)}`, `\tretry: ${JSON.stringify(j.retry)}`];
|
|
516
|
+
if (j.dlq) props.push(`\tdlq: ${JSON.stringify(j.dlq)}`);
|
|
517
|
+
if (j.concurrency) props.push(`\tconcurrency: ${j.concurrency}`);
|
|
518
|
+
props.push([`\trun: async ({ input, container, user }) => {`, ...body, `\t}`].join('\n'));
|
|
519
|
+
fns.push([`export ${name} := job({`, props.join(',\n'), `})`].join('\n'));
|
|
520
|
+
entries.push(`\t${JSON.stringify(address)}: ${name}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const lines = [header(moduleName), '', `import { job } from '@human-synthesis/norns/server'`, ''];
|
|
524
|
+
for (const [file, names_] of groupImports(serviceImports)) {
|
|
525
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
526
|
+
}
|
|
527
|
+
lines.push(...customImports);
|
|
528
|
+
lines.push('', fns.join('\n\n'), '');
|
|
529
|
+
lines.push(`export jobs := {`, entries.join(',\n'), `}`, '');
|
|
530
|
+
return { path: `lib/${moduleName}/jobs.c`, text: lines.join('\n') };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* ------------------------------------------------------------------ */
|
|
534
|
+
/* Triggers → lib/<module>/triggers.c */
|
|
535
|
+
/* ------------------------------------------------------------------ */
|
|
536
|
+
|
|
537
|
+
export function emitModuleTriggers(moduleName, moduleSpec) {
|
|
538
|
+
const triggers = moduleSpec.triggers ?? {};
|
|
539
|
+
const names = Object.keys(triggers).sort();
|
|
540
|
+
if (names.length === 0) return null;
|
|
541
|
+
|
|
542
|
+
const actionImports = new Map();
|
|
543
|
+
const entries = [];
|
|
544
|
+
for (const name of names) {
|
|
545
|
+
const t = triggers[name];
|
|
546
|
+
const spec = typeof t === 'string' ? { action: t } : t;
|
|
547
|
+
const parsed = parseAddress(spec.action);
|
|
548
|
+
const local = parsed.module === moduleName ? './' : `../${parsed.module}/`;
|
|
549
|
+
actionImports.set(parsed.name, `${local}actions.c`);
|
|
550
|
+
const props = [`on: ${JSON.stringify(name)}`, `action: ${parsed.name}`];
|
|
551
|
+
if (spec.schedule) props.push(`schedule: ${JSON.stringify(spec.schedule)}`);
|
|
552
|
+
if (spec.source) props.push(`source: ${JSON.stringify(spec.source)}`);
|
|
553
|
+
entries.push(`\t{ ${props.join(', ')} }`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const lines = [header(moduleName), ''];
|
|
557
|
+
for (const [file, names_] of groupImports(actionImports)) {
|
|
558
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
559
|
+
}
|
|
560
|
+
lines.push('', `export triggers := [`, entries.join(',\n'), `]`, '');
|
|
561
|
+
return { path: `lib/${moduleName}/triggers.c`, text: lines.join('\n') };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* ------------------------------------------------------------------ */
|
|
565
|
+
/* Remote actions → routes/api/<module>/<action>/+server.c */
|
|
566
|
+
/* ------------------------------------------------------------------ */
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Actions with `transport: 'remote'` also get a POST endpoint wrapping the
|
|
570
|
+
* same generated unit (schema + guards + refresh publication ride along
|
|
571
|
+
* via `remoteAction`). Client side: `remoteCall` in `norns/live-client`.
|
|
572
|
+
*/
|
|
573
|
+
export function emitModuleRemotes(moduleName, moduleSpec) {
|
|
574
|
+
const actions = moduleSpec.actions ?? {};
|
|
575
|
+
const names = Object.keys(actions)
|
|
576
|
+
.filter((n) => actions[n]?.transport === 'remote')
|
|
577
|
+
.sort();
|
|
578
|
+
if (names.length === 0) return null;
|
|
579
|
+
|
|
580
|
+
return names.map((name) => ({
|
|
581
|
+
path: `routes/api/${moduleName}/${name}/+server.c`,
|
|
582
|
+
text: [
|
|
583
|
+
header(moduleName),
|
|
584
|
+
'',
|
|
585
|
+
`import { remoteAction } from '@human-synthesis/norns/server'`,
|
|
586
|
+
'',
|
|
587
|
+
`import { ${name} } from '${up(4)}lib/${moduleName}/actions.c'`,
|
|
588
|
+
'',
|
|
589
|
+
`export POST := remoteAction(${name})`,
|
|
590
|
+
''
|
|
591
|
+
].join('\n')
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* ------------------------------------------------------------------ */
|
|
596
|
+
/* Pages → routes<route>/+page.server.c + +page.n */
|
|
597
|
+
/* ------------------------------------------------------------------ */
|
|
598
|
+
|
|
599
|
+
/** '/orders/:id' → ['orders', '[id]'] */
|
|
600
|
+
function routeSegments(route) {
|
|
601
|
+
return route
|
|
602
|
+
.split('/')
|
|
603
|
+
.filter(Boolean)
|
|
604
|
+
.map((seg) => (seg.startsWith(':') ? `[${seg.slice(1)}]` : seg));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** '/api/chat' from a `stream:` Endpoint address (falls back to the address). */
|
|
608
|
+
function endpointRoute(specs, address) {
|
|
609
|
+
const parsed = isAddress(address) ? parseAddress(address) : null;
|
|
610
|
+
return specs?.modules?.[parsed?.module]?.endpoints?.[parsed?.name]?.route ?? address;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The tag key of a page component entry. Authors write the tag first, but
|
|
615
|
+
* TRON canonicalization sorts record keys on write, so the primary must be
|
|
616
|
+
* recovered semantically: a realtime object binding ({stream}/{room}) wins,
|
|
617
|
+
* else a Query address, else an Action address (the form target), else the
|
|
618
|
+
* first key in record order.
|
|
619
|
+
*/
|
|
620
|
+
export function componentKey(entry) {
|
|
621
|
+
const keys = Object.keys(entry ?? {});
|
|
622
|
+
let query = null;
|
|
623
|
+
let action = null;
|
|
624
|
+
for (const key of keys) {
|
|
625
|
+
const value = entry[key];
|
|
626
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
627
|
+
if (typeof value.stream === 'string' || typeof value.room === 'string') return key;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (typeof value !== 'string' || !isAddress(value)) continue;
|
|
631
|
+
const { kind } = parseAddress(value);
|
|
632
|
+
if (kind === 'Query') query ??= key;
|
|
633
|
+
else if (kind === 'Action') action ??= key;
|
|
634
|
+
}
|
|
635
|
+
return query ?? action ?? keys[0] ?? null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Collect { queries, actions, components } bound by a page spec. */
|
|
639
|
+
function pageBindings(pageSpec, specs) {
|
|
640
|
+
const queries = new Map();
|
|
641
|
+
const actions = new Map();
|
|
642
|
+
const snippets = new Map();
|
|
643
|
+
const components = [];
|
|
644
|
+
for (const entry of pageSpec.components ?? []) {
|
|
645
|
+
const keys = Object.keys(entry);
|
|
646
|
+
if (keys.length === 0) continue;
|
|
647
|
+
const first = componentKey(entry);
|
|
648
|
+
const component = { tag: pascal(first), props: [] };
|
|
649
|
+
for (const key of keys) {
|
|
650
|
+
const value = entry[key];
|
|
651
|
+
if (key === first && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
652
|
+
// realtime bindings (K-27): the component connects itself via
|
|
653
|
+
// streamSource(url) / roomChannel(name) from norns/live-client
|
|
654
|
+
if (typeof value.stream === 'string') {
|
|
655
|
+
component.props.push(`streamSource=${JSON.stringify(endpointRoute(specs, value.stream))}`);
|
|
656
|
+
}
|
|
657
|
+
if (typeof value.room === 'string') {
|
|
658
|
+
component.props.push(`roomChannel=${JSON.stringify(value.room)}`);
|
|
659
|
+
}
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const parsed = typeof value === 'string' && isAddress(value) ? parseAddress(value) : null;
|
|
663
|
+
if (parsed?.kind === 'Snippet') {
|
|
664
|
+
// U-07: the page wraps the custom body in a `+snippet` forwarding
|
|
665
|
+
// the declared args as props, and binds that snippet to the slot
|
|
666
|
+
const unit = specs?.modules?.[parsed.module]?.snippets?.[parsed.name];
|
|
667
|
+
snippets.set(parsed.name, { module: parsed.module, args: unit?.args ?? [] });
|
|
668
|
+
component.props.push(`${key}!="{${parsed.name}}"`);
|
|
669
|
+
} else if (parsed?.kind === 'Query') {
|
|
670
|
+
queries.set(parsed.name, parsed.module);
|
|
671
|
+
component.props.push(
|
|
672
|
+
key === first
|
|
673
|
+
? `data!="{data.${parsed.name}}"`
|
|
674
|
+
: `${key}!="{data.${parsed.name}}"`
|
|
675
|
+
);
|
|
676
|
+
} else if (parsed?.kind === 'Action') {
|
|
677
|
+
actions.set(parsed.name, parsed.module);
|
|
678
|
+
// an Action in the component slot is the form target, so it binds
|
|
679
|
+
// the conventional `action` prop rather than the slot key
|
|
680
|
+
component.props.push(`${key === first ? 'action' : key}="?/${parsed.name}"`);
|
|
681
|
+
} else if (typeof value === 'string' && key !== first) {
|
|
682
|
+
component.props.push(`${key}=${JSON.stringify(value)}`);
|
|
683
|
+
} else if ((typeof value === 'number' || typeof value === 'boolean') && key !== first) {
|
|
684
|
+
component.props.push(`${key}!="{${JSON.stringify(value)}}"`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
components.push(component);
|
|
688
|
+
}
|
|
689
|
+
return { queries, actions, snippets, components };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function emitModulePages(moduleName, moduleSpec, specs) {
|
|
693
|
+
const pages = moduleSpec.pages ?? {};
|
|
694
|
+
const names = Object.keys(pages).sort();
|
|
695
|
+
const files = [];
|
|
696
|
+
|
|
697
|
+
for (const name of names) {
|
|
698
|
+
const spec = pages[name];
|
|
699
|
+
const segments = routeSegments(spec.route);
|
|
700
|
+
const dir = ['routes', ...segments].join('/');
|
|
701
|
+
const lib = up(segments.length + 1) + 'lib';
|
|
702
|
+
const { queries, actions, snippets, components } = pageBindings(spec, specs);
|
|
703
|
+
// Queries marked `live: true` get a depends key in the load and an
|
|
704
|
+
// EventSource subscription in the page, so refresh signals from
|
|
705
|
+
// /_norns/live re-run the load (R-11).
|
|
706
|
+
const liveAddrs = [...queries]
|
|
707
|
+
.filter(([q, m]) => specs?.modules?.[m]?.queries?.[q]?.live === true)
|
|
708
|
+
.map(([q, m]) => `${m}.Query.${q}`)
|
|
709
|
+
.sort();
|
|
710
|
+
|
|
711
|
+
const lines = [header(moduleName), '', `import { page } from '@human-synthesis/norns/server'`, ''];
|
|
712
|
+
for (const [file, names_] of groupImports(
|
|
713
|
+
new Map([...queries].map(([q, m]) => [q, `${lib}/${m}/queries.c`]))
|
|
714
|
+
)) {
|
|
715
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
716
|
+
}
|
|
717
|
+
for (const [file, names_] of groupImports(
|
|
718
|
+
new Map([...actions].map(([a, m]) => [a, `${lib}/${m}/actions.c`]))
|
|
719
|
+
)) {
|
|
720
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
721
|
+
}
|
|
722
|
+
lines.push('');
|
|
723
|
+
|
|
724
|
+
const loads = [...queries.keys()].sort();
|
|
725
|
+
lines.push(
|
|
726
|
+
`export load := page.load({`,
|
|
727
|
+
`\thandler: async (ctx) => {`,
|
|
728
|
+
...liveAddrs.map((addr) => `\t\tctx.event.depends(${JSON.stringify(`norns:${addr}`)})`),
|
|
729
|
+
`\t\treturn { ${loads.map((q) => `${q}: await ${q}(ctx)`).join(', ')} }`,
|
|
730
|
+
`\t}`,
|
|
731
|
+
`})`
|
|
732
|
+
);
|
|
733
|
+
const acts = [...actions.keys()].sort();
|
|
734
|
+
if (acts.length > 0) {
|
|
735
|
+
lines.push('', `export actions := page.actions({`, acts.map((a) => `\t${a}: ${a}`).join(',\n'), `})`);
|
|
736
|
+
}
|
|
737
|
+
lines.push('');
|
|
738
|
+
files.push({ path: `${dir}/+page.server.c`, text: lines.join('\n') });
|
|
739
|
+
|
|
740
|
+
const liveScript = liveAddrs.length
|
|
741
|
+
? {
|
|
742
|
+
imports: [
|
|
743
|
+
`\timport { invalidate } from '$app/navigation'`,
|
|
744
|
+
`\timport { liveQueries } from '@human-synthesis/norns/live-client'`
|
|
745
|
+
],
|
|
746
|
+
effect: `\t$effect(() => liveQueries(${JSON.stringify(liveAddrs)}, invalidate))`
|
|
747
|
+
}
|
|
748
|
+
: null;
|
|
749
|
+
|
|
750
|
+
const pug = [header(moduleName), ''];
|
|
751
|
+
if (spec.impl === 'custom') {
|
|
752
|
+
// Level-2: the template body lives in src/, the generated shell
|
|
753
|
+
// stays the routing surface and passes the page contract through.
|
|
754
|
+
pug.push(
|
|
755
|
+
`Body(data!="{data}" form!="{form}")`,
|
|
756
|
+
'',
|
|
757
|
+
'<script>',
|
|
758
|
+
...(liveScript?.imports ?? []),
|
|
759
|
+
`\timport Body from '$custom/${moduleName}/pages/${name}.n'`,
|
|
760
|
+
`\t{ data, form } := $props()`,
|
|
761
|
+
...(liveScript ? [liveScript.effect] : []),
|
|
762
|
+
'</script>',
|
|
763
|
+
''
|
|
764
|
+
);
|
|
765
|
+
} else {
|
|
766
|
+
pug.push(`section.norns-page`);
|
|
767
|
+
for (const c of components) {
|
|
768
|
+
pug.push(`\t${c.tag}(${c.props.join(' ')})`);
|
|
769
|
+
}
|
|
770
|
+
for (const [sname, s] of [...snippets].sort(([a], [b]) => a.localeCompare(b))) {
|
|
771
|
+
pug.push('', `+snippet('${sname}'${s.args.length ? `, ${s.args.join(', ')}` : ''})`);
|
|
772
|
+
pug.push(
|
|
773
|
+
`\t${pascal(sname)}${s.args.length ? `(${s.args.map((a) => `${a}!="{${a}}"`).join(' ')})` : ''}`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const stateKeys = Object.keys(spec.state ?? {}).sort();
|
|
777
|
+
pug.push('', '<script>');
|
|
778
|
+
if (liveScript) pug.push(...liveScript.imports);
|
|
779
|
+
for (const [sname, s] of [...snippets].sort(([a], [b]) => a.localeCompare(b))) {
|
|
780
|
+
pug.push(`\timport ${pascal(sname)} from '$custom/${s.module}/snippets/${sname}.n'`);
|
|
781
|
+
}
|
|
782
|
+
pug.push(`\t{ data, form } := $props()`);
|
|
783
|
+
for (const key of stateKeys) pug.push(`\t${key} := $state(null)`);
|
|
784
|
+
if (liveScript) pug.push(liveScript.effect);
|
|
785
|
+
pug.push('</script>', '');
|
|
786
|
+
}
|
|
787
|
+
files.push({ path: `${dir}/+page.n`, text: pug.join('\n') });
|
|
788
|
+
}
|
|
789
|
+
return files;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* ------------------------------------------------------------------ */
|
|
793
|
+
/* Endpoints → routes<route>/+server.c (D14/K-23) */
|
|
794
|
+
/* ------------------------------------------------------------------ */
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Each Endpoint becomes a `+server.c` shell at its declared route: the
|
|
798
|
+
* `endpoint()` runtime verifies auth, validates input, runs the custom
|
|
799
|
+
* body from `src/<m>/endpoints/<name>.c`, then validates output — or, in
|
|
800
|
+
* `stream` mode, serves the body's yielded frames as typed SSE.
|
|
801
|
+
*/
|
|
802
|
+
export function emitModuleEndpoints(moduleName, moduleSpec) {
|
|
803
|
+
const endpoints = moduleSpec.endpoints ?? {};
|
|
804
|
+
const names = Object.keys(endpoints).sort();
|
|
805
|
+
if (names.length === 0) return null;
|
|
806
|
+
|
|
807
|
+
return names.map((name) => {
|
|
808
|
+
const spec = endpoints[name];
|
|
809
|
+
const def = { name: `${moduleName}.Endpoint.${name}`, auth: spec.auth };
|
|
810
|
+
if (spec.input !== undefined) def.input = spec.input;
|
|
811
|
+
if (spec.output !== undefined) def.output = spec.output;
|
|
812
|
+
if (spec.stream !== undefined) def.stream = spec.stream;
|
|
813
|
+
const defJson = JSON.stringify(def, null, '\t');
|
|
814
|
+
const withBody = defJson.slice(0, defJson.lastIndexOf('}')).trimEnd() + `,\n\tbody: ${name}Body\n}`;
|
|
815
|
+
return {
|
|
816
|
+
path: ['routes', ...routeSegments(spec.route), '+server.c'].join('/'),
|
|
817
|
+
text: [
|
|
818
|
+
header(moduleName),
|
|
819
|
+
'',
|
|
820
|
+
`import { endpoint } from '@human-synthesis/norns/server'`,
|
|
821
|
+
'',
|
|
822
|
+
`import ${name}Body from '$custom/${moduleName}/endpoints/${name}.c'`,
|
|
823
|
+
'',
|
|
824
|
+
`export ${spec.method ?? 'POST'} := endpoint(${withBody})`,
|
|
825
|
+
''
|
|
826
|
+
].join('\n')
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/* ------------------------------------------------------------------ */
|
|
832
|
+
|
|
833
|
+
const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
|
|
834
|
+
const file = fn(moduleName, moduleSpec, specs);
|
|
835
|
+
return file ? [file] : [];
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
export const policiesEmitter = { name: 'policies', emit: single(emitModulePolicies) };
|
|
839
|
+
export const queriesEmitter = { name: 'queries', emit: single(emitModuleQueries) };
|
|
840
|
+
export const actionsEmitter = { name: 'actions', emit: single(emitModuleActions) };
|
|
841
|
+
export const servicesEmitter = { name: 'services', emit: single(emitModuleServices) };
|
|
842
|
+
export const jobsEmitter = { name: 'jobs', emit: single(emitModuleJobs) };
|
|
843
|
+
export const triggersEmitter = { name: 'triggers', emit: single(emitModuleTriggers) };
|
|
844
|
+
export const pagesEmitter = {
|
|
845
|
+
name: 'pages',
|
|
846
|
+
emit: ({ moduleName, moduleSpec, specs }) => emitModulePages(moduleName, moduleSpec, specs)
|
|
847
|
+
};
|
|
848
|
+
export const remotesEmitter = {
|
|
849
|
+
name: 'remotes',
|
|
850
|
+
emit: ({ moduleName, moduleSpec }) => emitModuleRemotes(moduleName, moduleSpec) ?? []
|
|
851
|
+
};
|
|
852
|
+
export const endpointsEmitter = {
|
|
853
|
+
name: 'endpoints',
|
|
854
|
+
emit: ({ moduleName, moduleSpec }) => emitModuleEndpoints(moduleName, moduleSpec) ?? []
|
|
855
|
+
};
|