@human-synthesis/norns 0.1.0 → 0.2.1
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/package.json +1 -1
- package/src/kernel/address.js +4 -0
- package/src/kernel/emit-units.js +283 -7
- package/src/kernel/emit-wrangler.js +22 -2
- package/src/kernel/expr-compile.js +4 -1
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +500 -23
- package/src/kernel/index.js +9 -2
- package/src/kernel/meta.js +145 -1
- package/src/kernel/refine.js +78 -0
- package/src/kernel/trace.js +215 -27
- package/src/live-client.js +144 -0
- package/src/server/boot.js +22 -0
- package/src/server/db.js +5 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/index.js +3 -0
- package/src/server/job.js +102 -0
- package/src/server/room.js +17 -0
- package/src/server/service.js +188 -0
package/package.json
CHANGED
package/src/kernel/address.js
CHANGED
|
@@ -25,6 +25,10 @@ export const KIND_KEYS = {
|
|
|
25
25
|
Trigger: 'triggers',
|
|
26
26
|
Function: 'functions',
|
|
27
27
|
Component: 'components',
|
|
28
|
+
Snippet: 'snippets',
|
|
29
|
+
Service: 'services',
|
|
30
|
+
Job: 'jobs',
|
|
31
|
+
Endpoint: 'endpoints',
|
|
28
32
|
Route: 'routes',
|
|
29
33
|
Worker: 'workers',
|
|
30
34
|
Adapter: 'adapters',
|
package/src/kernel/emit-units.js
CHANGED
|
@@ -68,10 +68,16 @@ function parseUnitExpr(exprSrc, states) {
|
|
|
68
68
|
return withStateLiterals(parseExpr(exprSrc), states ?? new Set());
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** `dealBoard` → `Deal Board`, `leads` → `Leads`. */
|
|
72
|
+
export function humanizeName(name) {
|
|
73
|
+
const spaced = String(name).replaceAll('_', ' ').replaceAll('-', ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
74
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
function whereCall(exprSrc, { entity, ownerField, states }) {
|
|
72
78
|
const ast = JSON.stringify(parseUnitExpr(exprSrc, states));
|
|
73
79
|
const owner = ownerField ? `, ownerField: ${JSON.stringify(ownerField)}` : '';
|
|
74
|
-
return `compileWhere(${ast}, { table: ${entity}, ops, user${owner} })`;
|
|
80
|
+
return `compileWhere(${ast}, { table: ${entity}, ops, user: ctx.user${owner} })`;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
function guardExpr(exprSrc, ownerField, states) {
|
|
@@ -256,6 +262,42 @@ function inputSchema(moduleName, action, specs) {
|
|
|
256
262
|
return `v.strictObject({ ${fields.join(', ')} })`;
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
const SERVICE_CALL_RE = /^([a-z][a-z0-9_]*)\.Service\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
266
|
+
const SCOPE_PATH_RE = /^(row|input|user)(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
|
267
|
+
|
|
268
|
+
/** `with:` values are scope paths (`input.id`) or JSON literals; no `with` passes the action input through. */
|
|
269
|
+
function withArg(withMap) {
|
|
270
|
+
if (!withMap || typeof withMap !== 'object') return 'input';
|
|
271
|
+
const entries = Object.keys(withMap)
|
|
272
|
+
.sort()
|
|
273
|
+
.map((k) => {
|
|
274
|
+
const val = withMap[k];
|
|
275
|
+
const isPath = typeof val === 'string' && SCOPE_PATH_RE.test(val);
|
|
276
|
+
return `${k}: ${isPath ? val : JSON.stringify(val)}`;
|
|
277
|
+
});
|
|
278
|
+
return `{ ${entries.join(', ')} }`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A `call` step: service operations go through the generated typed client; anything else resolves a container token. */
|
|
282
|
+
function callStep(step, moduleName, serviceImports, ctx) {
|
|
283
|
+
const m = SERVICE_CALL_RE.exec(step.call);
|
|
284
|
+
if (m) {
|
|
285
|
+
const [, module, service, op] = m;
|
|
286
|
+
const local = module === moduleName ? './' : `../${module}/`;
|
|
287
|
+
serviceImports.set(service, `${local}services.c`);
|
|
288
|
+
return `\t\tawait ${service}.${op}(${withArg(step.with)}, container)`;
|
|
289
|
+
}
|
|
290
|
+
return `\t\tawait container.resolve(${JSON.stringify(step.call)})(${ctx})`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const JOB_ADDR_RE = /^[a-z][a-z0-9_]*\.Job\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
294
|
+
|
|
295
|
+
/** An `enqueue` step hands the input to the `jobs` facade (R-14): Queues in prod, inline in dev. */
|
|
296
|
+
function enqueueStep(step, moduleName) {
|
|
297
|
+
const addr = JOB_ADDR_RE.test(step.enqueue) ? step.enqueue : `${moduleName}.Job.${step.enqueue}`;
|
|
298
|
+
return `\t\tawait container.resolve('jobs').enqueue(${JSON.stringify(addr)}, ${withArg(step.with)}, user)`;
|
|
299
|
+
}
|
|
300
|
+
|
|
259
301
|
export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
260
302
|
const actions = moduleSpec.actions ?? {};
|
|
261
303
|
const names = Object.keys(actions).sort();
|
|
@@ -263,6 +305,7 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
|
263
305
|
|
|
264
306
|
const entityImports = new Map();
|
|
265
307
|
const policyImports = new Map();
|
|
308
|
+
const serviceImports = new Map();
|
|
266
309
|
const customImports = [];
|
|
267
310
|
const fns = [];
|
|
268
311
|
|
|
@@ -328,7 +371,9 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
|
328
371
|
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { row, input, user })`
|
|
329
372
|
);
|
|
330
373
|
} else if (step.call) {
|
|
331
|
-
body.push(
|
|
374
|
+
body.push(callStep(step, moduleName, serviceImports, '{ row, input, user }'));
|
|
375
|
+
} else if (step.enqueue) {
|
|
376
|
+
body.push(enqueueStep(step, moduleName));
|
|
332
377
|
}
|
|
333
378
|
}
|
|
334
379
|
for (const evt of custom ? [] : (action.emits ?? [])) {
|
|
@@ -338,6 +383,25 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
|
338
383
|
}
|
|
339
384
|
} else if (custom) {
|
|
340
385
|
body.push(`\t\treturn ${name}Body({ input, container, user })`);
|
|
386
|
+
} else {
|
|
387
|
+
// No entity target: pure flow actions (integrations, notifications)
|
|
388
|
+
// still run their emit/call steps.
|
|
389
|
+
for (const step of action.steps ?? []) {
|
|
390
|
+
if (step.emit) {
|
|
391
|
+
body.push(
|
|
392
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { input, user })`
|
|
393
|
+
);
|
|
394
|
+
} else if (step.call) {
|
|
395
|
+
body.push(callStep(step, moduleName, serviceImports, '{ input, user }'));
|
|
396
|
+
} else if (step.enqueue) {
|
|
397
|
+
body.push(enqueueStep(step, moduleName));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const evt of action.emits ?? []) {
|
|
401
|
+
body.push(
|
|
402
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { input, user })`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
341
405
|
}
|
|
342
406
|
if (custom) {
|
|
343
407
|
customImports.push(`import ${name}Body from '$custom/${moduleName}/actions/${name}.c'`);
|
|
@@ -360,11 +424,118 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
|
|
|
360
424
|
for (const [file, names_] of groupImports(policyImports)) {
|
|
361
425
|
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
362
426
|
}
|
|
427
|
+
for (const [file, names_] of groupImports(serviceImports)) {
|
|
428
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
429
|
+
}
|
|
363
430
|
lines.push(...customImports);
|
|
364
431
|
lines.push('', fns.join('\n\n'), '');
|
|
365
432
|
return { path: `lib/${moduleName}/actions.c`, text: lines.join('\n') };
|
|
366
433
|
}
|
|
367
434
|
|
|
435
|
+
/* ------------------------------------------------------------------ */
|
|
436
|
+
/* Services → lib/<module>/services.c */
|
|
437
|
+
/* ------------------------------------------------------------------ */
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Service manifests (D15) become typed clients. Credentials resolve at call
|
|
441
|
+
* time from the env the container carries — never from the spec. `services`
|
|
442
|
+
* maps unit addresses to clients so boot can container-register them (R-14).
|
|
443
|
+
*/
|
|
444
|
+
export function emitModuleServices(moduleName, moduleSpec) {
|
|
445
|
+
const services = moduleSpec.services ?? {};
|
|
446
|
+
const names = Object.keys(services).sort();
|
|
447
|
+
if (names.length === 0) return null;
|
|
448
|
+
|
|
449
|
+
const lines = [header(moduleName), '', `import { serviceClient } from '@human-synthesis/norns/server'`, ''];
|
|
450
|
+
const entries = [];
|
|
451
|
+
for (const name of names) {
|
|
452
|
+
const svc = services[name];
|
|
453
|
+
const operations = {};
|
|
454
|
+
for (const op of Object.keys(svc.operations ?? {}).sort()) {
|
|
455
|
+
const o = svc.operations[op];
|
|
456
|
+
operations[op] = {
|
|
457
|
+
method: o.method ?? 'POST',
|
|
458
|
+
path: o.path ?? `/${op}`,
|
|
459
|
+
...(o.input !== undefined ? { input: o.input } : {}),
|
|
460
|
+
...(o.output !== undefined ? { output: o.output } : {})
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const def = {
|
|
464
|
+
name: `${moduleName}.Service.${name}`,
|
|
465
|
+
base: svc.base,
|
|
466
|
+
auth: svc.auth,
|
|
467
|
+
operations
|
|
468
|
+
};
|
|
469
|
+
lines.push(`export ${name} := serviceClient(${JSON.stringify(def, null, '\t')})`, '');
|
|
470
|
+
entries.push(`\t${JSON.stringify(`${moduleName}.Service.${name}`)}: ${name}`);
|
|
471
|
+
}
|
|
472
|
+
lines.push(`export services := {`, entries.join(',\n'), `}`, '');
|
|
473
|
+
return { path: `lib/${moduleName}/services.c`, text: lines.join('\n') };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* ------------------------------------------------------------------ */
|
|
477
|
+
/* Jobs → lib/<module>/jobs.c */
|
|
478
|
+
/* ------------------------------------------------------------------ */
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Jobs (K-22) become `job({...})` units carrying their retry/dlq contract;
|
|
482
|
+
* `registerJobs` (R-14) wires them to `job:<address>` bus messages with
|
|
483
|
+
* retry/backoff/DLQ semantics — Cloudflare Queues in prod, inline in dev.
|
|
484
|
+
*/
|
|
485
|
+
export function emitModuleJobs(moduleName, moduleSpec) {
|
|
486
|
+
const jobs = moduleSpec.jobs ?? {};
|
|
487
|
+
const names = Object.keys(jobs).sort();
|
|
488
|
+
if (names.length === 0) return null;
|
|
489
|
+
|
|
490
|
+
const serviceImports = new Map();
|
|
491
|
+
const customImports = [];
|
|
492
|
+
const fns = [];
|
|
493
|
+
const entries = [];
|
|
494
|
+
|
|
495
|
+
for (const name of names) {
|
|
496
|
+
const j = jobs[name];
|
|
497
|
+
const custom = j.impl === 'custom';
|
|
498
|
+
const address = `${moduleName}.Job.${name}`;
|
|
499
|
+
const body = [];
|
|
500
|
+
if (custom) {
|
|
501
|
+
customImports.push(`import ${name}Body from '$custom/${moduleName}/jobs/${name}.c'`);
|
|
502
|
+
body.push(`\t\treturn ${name}Body({ input, container, user })`);
|
|
503
|
+
} else {
|
|
504
|
+
for (const step of j.steps ?? []) {
|
|
505
|
+
if (step.emit) {
|
|
506
|
+
body.push(
|
|
507
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { input, user })`
|
|
508
|
+
);
|
|
509
|
+
} else if (step.call) {
|
|
510
|
+
body.push(callStep(step, moduleName, serviceImports, '{ input, user }'));
|
|
511
|
+
} else if (step.enqueue) {
|
|
512
|
+
body.push(enqueueStep(step, moduleName));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const evt of j.emits ?? []) {
|
|
516
|
+
body.push(
|
|
517
|
+
`\t\tawait container.resolve('events').emit(${JSON.stringify(evt)}, { input, user })`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const props = [`\taddress: ${JSON.stringify(address)}`, `\tretry: ${JSON.stringify(j.retry)}`];
|
|
522
|
+
if (j.dlq) props.push(`\tdlq: ${JSON.stringify(j.dlq)}`);
|
|
523
|
+
if (j.concurrency) props.push(`\tconcurrency: ${j.concurrency}`);
|
|
524
|
+
props.push([`\trun: async ({ input, container, user }) => {`, ...body, `\t}`].join('\n'));
|
|
525
|
+
fns.push([`export ${name} := job({`, props.join(',\n'), `})`].join('\n'));
|
|
526
|
+
entries.push(`\t${JSON.stringify(address)}: ${name}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const lines = [header(moduleName), '', `import { job } from '@human-synthesis/norns/server'`, ''];
|
|
530
|
+
for (const [file, names_] of groupImports(serviceImports)) {
|
|
531
|
+
lines.push(`import { ${names_.join(', ')} } from '${file}'`);
|
|
532
|
+
}
|
|
533
|
+
lines.push(...customImports);
|
|
534
|
+
lines.push('', fns.join('\n\n'), '');
|
|
535
|
+
lines.push(`export jobs := {`, entries.join(',\n'), `}`, '');
|
|
536
|
+
return { path: `lib/${moduleName}/jobs.c`, text: lines.join('\n') };
|
|
537
|
+
}
|
|
538
|
+
|
|
368
539
|
/* ------------------------------------------------------------------ */
|
|
369
540
|
/* Triggers → lib/<module>/triggers.c */
|
|
370
541
|
/* ------------------------------------------------------------------ */
|
|
@@ -439,20 +610,69 @@ function routeSegments(route) {
|
|
|
439
610
|
.map((seg) => (seg.startsWith(':') ? `[${seg.slice(1)}]` : seg));
|
|
440
611
|
}
|
|
441
612
|
|
|
613
|
+
/** '/api/chat' from a `stream:` Endpoint address (falls back to the address). */
|
|
614
|
+
function endpointRoute(specs, address) {
|
|
615
|
+
const parsed = isAddress(address) ? parseAddress(address) : null;
|
|
616
|
+
return specs?.modules?.[parsed?.module]?.endpoints?.[parsed?.name]?.route ?? address;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The tag key of a page component entry. Authors write the tag first, but
|
|
621
|
+
* TRON canonicalization sorts record keys on write, so the primary must be
|
|
622
|
+
* recovered semantically: a realtime object binding ({stream}/{room}) wins,
|
|
623
|
+
* else a Query address, else an Action address (the form target), else the
|
|
624
|
+
* first key in record order.
|
|
625
|
+
*/
|
|
626
|
+
export function componentKey(entry) {
|
|
627
|
+
const keys = Object.keys(entry ?? {});
|
|
628
|
+
let query = null;
|
|
629
|
+
let action = null;
|
|
630
|
+
for (const key of keys) {
|
|
631
|
+
const value = entry[key];
|
|
632
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
633
|
+
if (typeof value.stream === 'string' || typeof value.room === 'string') return key;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (typeof value !== 'string' || !isAddress(value)) continue;
|
|
637
|
+
const { kind } = parseAddress(value);
|
|
638
|
+
if (kind === 'Query') query ??= key;
|
|
639
|
+
else if (kind === 'Action') action ??= key;
|
|
640
|
+
}
|
|
641
|
+
return query ?? action ?? keys[0] ?? null;
|
|
642
|
+
}
|
|
643
|
+
|
|
442
644
|
/** Collect { queries, actions, components } bound by a page spec. */
|
|
443
|
-
function pageBindings(pageSpec) {
|
|
645
|
+
function pageBindings(pageSpec, specs) {
|
|
444
646
|
const queries = new Map();
|
|
445
647
|
const actions = new Map();
|
|
648
|
+
const snippets = new Map();
|
|
446
649
|
const components = [];
|
|
447
650
|
for (const entry of pageSpec.components ?? []) {
|
|
448
651
|
const keys = Object.keys(entry);
|
|
449
652
|
if (keys.length === 0) continue;
|
|
450
|
-
const
|
|
653
|
+
const first = componentKey(entry);
|
|
451
654
|
const component = { tag: pascal(first), props: [] };
|
|
452
655
|
for (const key of keys) {
|
|
453
656
|
const value = entry[key];
|
|
657
|
+
if (key === first && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
658
|
+
// realtime bindings (K-27): the component connects itself via
|
|
659
|
+
// streamSource(url) / roomChannel(name) from norns/live-client
|
|
660
|
+
if (typeof value.stream === 'string') {
|
|
661
|
+
component.props.push(`streamSource=${JSON.stringify(endpointRoute(specs, value.stream))}`);
|
|
662
|
+
}
|
|
663
|
+
if (typeof value.room === 'string') {
|
|
664
|
+
component.props.push(`roomChannel=${JSON.stringify(value.room)}`);
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
454
668
|
const parsed = typeof value === 'string' && isAddress(value) ? parseAddress(value) : null;
|
|
455
|
-
if (parsed?.kind === '
|
|
669
|
+
if (parsed?.kind === 'Snippet') {
|
|
670
|
+
// U-07: the page wraps the custom body in a `+snippet` forwarding
|
|
671
|
+
// the declared args as props, and binds that snippet to the slot
|
|
672
|
+
const unit = specs?.modules?.[parsed.module]?.snippets?.[parsed.name];
|
|
673
|
+
snippets.set(parsed.name, { module: parsed.module, args: unit?.args ?? [] });
|
|
674
|
+
component.props.push(`${key}!="{${parsed.name}}"`);
|
|
675
|
+
} else if (parsed?.kind === 'Query') {
|
|
456
676
|
queries.set(parsed.name, parsed.module);
|
|
457
677
|
component.props.push(
|
|
458
678
|
key === first
|
|
@@ -472,7 +692,7 @@ function pageBindings(pageSpec) {
|
|
|
472
692
|
}
|
|
473
693
|
components.push(component);
|
|
474
694
|
}
|
|
475
|
-
return { queries, actions, components };
|
|
695
|
+
return { queries, actions, snippets, components };
|
|
476
696
|
}
|
|
477
697
|
|
|
478
698
|
export function emitModulePages(moduleName, moduleSpec, specs) {
|
|
@@ -485,7 +705,7 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
|
|
|
485
705
|
const segments = routeSegments(spec.route);
|
|
486
706
|
const dir = ['routes', ...segments].join('/');
|
|
487
707
|
const lib = up(segments.length + 1) + 'lib';
|
|
488
|
-
const { queries, actions, components } = pageBindings(spec);
|
|
708
|
+
const { queries, actions, snippets, components } = pageBindings(spec, specs);
|
|
489
709
|
// Queries marked `live: true` get a depends key in the load and an
|
|
490
710
|
// EventSource subscription in the page, so refresh signals from
|
|
491
711
|
// /_norns/live re-run the load (R-11).
|
|
@@ -550,12 +770,23 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
|
|
|
550
770
|
);
|
|
551
771
|
} else {
|
|
552
772
|
pug.push(`section.norns-page`);
|
|
773
|
+
const titleFrom = ['index', 'home', 'main', 'page'].includes(name) ? moduleName : name;
|
|
774
|
+
pug.push(`\th1.norns-page-title ${humanizeName(titleFrom)}`);
|
|
553
775
|
for (const c of components) {
|
|
554
776
|
pug.push(`\t${c.tag}(${c.props.join(' ')})`);
|
|
555
777
|
}
|
|
778
|
+
for (const [sname, s] of [...snippets].sort(([a], [b]) => a.localeCompare(b))) {
|
|
779
|
+
pug.push('', `+snippet('${sname}'${s.args.length ? `, ${s.args.join(', ')}` : ''})`);
|
|
780
|
+
pug.push(
|
|
781
|
+
`\t${pascal(sname)}${s.args.length ? `(${s.args.map((a) => `${a}!="{${a}}"`).join(' ')})` : ''}`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
556
784
|
const stateKeys = Object.keys(spec.state ?? {}).sort();
|
|
557
785
|
pug.push('', '<script>');
|
|
558
786
|
if (liveScript) pug.push(...liveScript.imports);
|
|
787
|
+
for (const [sname, s] of [...snippets].sort(([a], [b]) => a.localeCompare(b))) {
|
|
788
|
+
pug.push(`\timport ${pascal(sname)} from '$custom/${s.module}/snippets/${sname}.n'`);
|
|
789
|
+
}
|
|
559
790
|
pug.push(`\t{ data, form } := $props()`);
|
|
560
791
|
for (const key of stateKeys) pug.push(`\t${key} := $state(null)`);
|
|
561
792
|
if (liveScript) pug.push(liveScript.effect);
|
|
@@ -566,6 +797,45 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
|
|
|
566
797
|
return files;
|
|
567
798
|
}
|
|
568
799
|
|
|
800
|
+
/* ------------------------------------------------------------------ */
|
|
801
|
+
/* Endpoints → routes<route>/+server.c (D14/K-23) */
|
|
802
|
+
/* ------------------------------------------------------------------ */
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Each Endpoint becomes a `+server.c` shell at its declared route: the
|
|
806
|
+
* `endpoint()` runtime verifies auth, validates input, runs the custom
|
|
807
|
+
* body from `src/<m>/endpoints/<name>.c`, then validates output — or, in
|
|
808
|
+
* `stream` mode, serves the body's yielded frames as typed SSE.
|
|
809
|
+
*/
|
|
810
|
+
export function emitModuleEndpoints(moduleName, moduleSpec) {
|
|
811
|
+
const endpoints = moduleSpec.endpoints ?? {};
|
|
812
|
+
const names = Object.keys(endpoints).sort();
|
|
813
|
+
if (names.length === 0) return null;
|
|
814
|
+
|
|
815
|
+
return names.map((name) => {
|
|
816
|
+
const spec = endpoints[name];
|
|
817
|
+
const def = { name: `${moduleName}.Endpoint.${name}`, auth: spec.auth };
|
|
818
|
+
if (spec.input !== undefined) def.input = spec.input;
|
|
819
|
+
if (spec.output !== undefined) def.output = spec.output;
|
|
820
|
+
if (spec.stream !== undefined) def.stream = spec.stream;
|
|
821
|
+
const defJson = JSON.stringify(def, null, '\t');
|
|
822
|
+
const withBody = defJson.slice(0, defJson.lastIndexOf('}')).trimEnd() + `,\n\tbody: ${name}Body\n}`;
|
|
823
|
+
return {
|
|
824
|
+
path: ['routes', ...routeSegments(spec.route), '+server.c'].join('/'),
|
|
825
|
+
text: [
|
|
826
|
+
header(moduleName),
|
|
827
|
+
'',
|
|
828
|
+
`import { endpoint } from '@human-synthesis/norns/server'`,
|
|
829
|
+
'',
|
|
830
|
+
`import ${name}Body from '$custom/${moduleName}/endpoints/${name}.c'`,
|
|
831
|
+
'',
|
|
832
|
+
`export ${spec.method ?? 'POST'} := endpoint(${withBody})`,
|
|
833
|
+
''
|
|
834
|
+
].join('\n')
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
569
839
|
/* ------------------------------------------------------------------ */
|
|
570
840
|
|
|
571
841
|
const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
|
|
@@ -576,6 +846,8 @@ const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
|
|
|
576
846
|
export const policiesEmitter = { name: 'policies', emit: single(emitModulePolicies) };
|
|
577
847
|
export const queriesEmitter = { name: 'queries', emit: single(emitModuleQueries) };
|
|
578
848
|
export const actionsEmitter = { name: 'actions', emit: single(emitModuleActions) };
|
|
849
|
+
export const servicesEmitter = { name: 'services', emit: single(emitModuleServices) };
|
|
850
|
+
export const jobsEmitter = { name: 'jobs', emit: single(emitModuleJobs) };
|
|
579
851
|
export const triggersEmitter = { name: 'triggers', emit: single(emitModuleTriggers) };
|
|
580
852
|
export const pagesEmitter = {
|
|
581
853
|
name: 'pages',
|
|
@@ -585,3 +857,7 @@ export const remotesEmitter = {
|
|
|
585
857
|
name: 'remotes',
|
|
586
858
|
emit: ({ moduleName, moduleSpec }) => emitModuleRemotes(moduleName, moduleSpec) ?? []
|
|
587
859
|
};
|
|
860
|
+
export const endpointsEmitter = {
|
|
861
|
+
name: 'endpoints',
|
|
862
|
+
emit: ({ moduleName, moduleSpec }) => emitModuleEndpoints(moduleName, moduleSpec) ?? []
|
|
863
|
+
};
|
|
@@ -41,6 +41,17 @@ function needsRoom(specs) {
|
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Jobs share the app events queue in v3-A; the strictest job wins the consumer settings. */
|
|
45
|
+
function jobUnits(specs) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const mod of Object.values(specs.modules)) {
|
|
48
|
+
for (const j of Object.values(mod.jobs ?? {})) {
|
|
49
|
+
if (j && typeof j === 'object') out.push(j);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
function cronSchedules(specs) {
|
|
45
56
|
const crons = new Set();
|
|
46
57
|
for (const mod of Object.values(specs.modules)) {
|
|
@@ -85,11 +96,20 @@ export function wranglerConfig(specs) {
|
|
|
85
96
|
config.r2_buckets = [{ binding: 'STORAGE', bucket_name: `${name}-storage` }];
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
const jobs = jobUnits(specs);
|
|
100
|
+
if (cf.queue === true || jobs.length > 0) {
|
|
89
101
|
const queue = `${name}-events`;
|
|
102
|
+
const consumer = { queue };
|
|
103
|
+
if (jobs.length > 0) {
|
|
104
|
+
consumer.max_retries = Math.max(...jobs.map((j) => j.retry?.attempts ?? 1));
|
|
105
|
+
const dlqs = [...new Set(jobs.map((j) => j.dlq).filter(Boolean))].sort();
|
|
106
|
+
if (dlqs.length > 0) consumer.dead_letter_queue = dlqs[0];
|
|
107
|
+
const conc = jobs.map((j) => j.concurrency).filter(Boolean);
|
|
108
|
+
if (conc.length > 0) consumer.max_concurrency = Math.min(...conc);
|
|
109
|
+
}
|
|
90
110
|
config.queues = {
|
|
91
111
|
producers: [{ binding: 'EVENTS', queue }],
|
|
92
|
-
consumers: [
|
|
112
|
+
consumers: [consumer]
|
|
93
113
|
};
|
|
94
114
|
}
|
|
95
115
|
|
|
@@ -147,7 +147,10 @@ export function compileWhere(ast, ctx) {
|
|
|
147
147
|
if ('path' in node) return ops.eq(column(node.path), true);
|
|
148
148
|
if (node.owner === true) {
|
|
149
149
|
if (!ownerField) throw new Error('cannot compile `owner` without ownerField');
|
|
150
|
-
|
|
150
|
+
// Anonymous user → deny, matching evalExpr. Binding undefined into
|
|
151
|
+
// `owner = ?` is a driver error on D1 rather than an empty result.
|
|
152
|
+
if (user?.id == null) return ops.bool(false);
|
|
153
|
+
return ops.eq(column([ownerField]), user.id);
|
|
151
154
|
}
|
|
152
155
|
if ('role' in node) return ops.bool(!!user?.roles?.includes(node.role));
|
|
153
156
|
switch (node.op) {
|