@human-synthesis/norns 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Norns — SvelteKit with Civet, Pug, and the .n / .civet / .c file extensions",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
@@ -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',
@@ -71,7 +71,7 @@ function parseUnitExpr(exprSrc, states) {
71
71
  function whereCall(exprSrc, { entity, ownerField, states }) {
72
72
  const ast = JSON.stringify(parseUnitExpr(exprSrc, states));
73
73
  const owner = ownerField ? `, ownerField: ${JSON.stringify(ownerField)}` : '';
74
- return `compileWhere(${ast}, { table: ${entity}, ops, user${owner} })`;
74
+ return `compileWhere(${ast}, { table: ${entity}, ops, user: ctx.user${owner} })`;
75
75
  }
76
76
 
77
77
  function guardExpr(exprSrc, ownerField, states) {
@@ -256,6 +256,42 @@ function inputSchema(moduleName, action, specs) {
256
256
  return `v.strictObject({ ${fields.join(', ')} })`;
257
257
  }
258
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
+
259
295
  export function emitModuleActions(moduleName, moduleSpec, specs) {
260
296
  const actions = moduleSpec.actions ?? {};
261
297
  const names = Object.keys(actions).sort();
@@ -263,6 +299,7 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
263
299
 
264
300
  const entityImports = new Map();
265
301
  const policyImports = new Map();
302
+ const serviceImports = new Map();
266
303
  const customImports = [];
267
304
  const fns = [];
268
305
 
@@ -328,7 +365,9 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
328
365
  `\t\tawait container.resolve('events').emit(${JSON.stringify(step.emit)}, { row, input, user })`
329
366
  );
330
367
  } else if (step.call) {
331
- body.push(`\t\tawait container.resolve(${JSON.stringify(step.call)})({ row, input, user })`);
368
+ body.push(callStep(step, moduleName, serviceImports, '{ row, input, user }'));
369
+ } else if (step.enqueue) {
370
+ body.push(enqueueStep(step, moduleName));
332
371
  }
333
372
  }
334
373
  for (const evt of custom ? [] : (action.emits ?? [])) {
@@ -338,6 +377,25 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
338
377
  }
339
378
  } else if (custom) {
340
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
+ }
341
399
  }
342
400
  if (custom) {
343
401
  customImports.push(`import ${name}Body from '$custom/${moduleName}/actions/${name}.c'`);
@@ -360,11 +418,118 @@ export function emitModuleActions(moduleName, moduleSpec, specs) {
360
418
  for (const [file, names_] of groupImports(policyImports)) {
361
419
  lines.push(`import { ${names_.join(', ')} } from '${file}'`);
362
420
  }
421
+ for (const [file, names_] of groupImports(serviceImports)) {
422
+ lines.push(`import { ${names_.join(', ')} } from '${file}'`);
423
+ }
363
424
  lines.push(...customImports);
364
425
  lines.push('', fns.join('\n\n'), '');
365
426
  return { path: `lib/${moduleName}/actions.c`, text: lines.join('\n') };
366
427
  }
367
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
+
368
533
  /* ------------------------------------------------------------------ */
369
534
  /* Triggers → lib/<module>/triggers.c */
370
535
  /* ------------------------------------------------------------------ */
@@ -439,20 +604,69 @@ function routeSegments(route) {
439
604
  .map((seg) => (seg.startsWith(':') ? `[${seg.slice(1)}]` : seg));
440
605
  }
441
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
+
442
638
  /** Collect { queries, actions, components } bound by a page spec. */
443
- function pageBindings(pageSpec) {
639
+ function pageBindings(pageSpec, specs) {
444
640
  const queries = new Map();
445
641
  const actions = new Map();
642
+ const snippets = new Map();
446
643
  const components = [];
447
644
  for (const entry of pageSpec.components ?? []) {
448
645
  const keys = Object.keys(entry);
449
646
  if (keys.length === 0) continue;
450
- const [first, ...rest] = keys;
647
+ const first = componentKey(entry);
451
648
  const component = { tag: pascal(first), props: [] };
452
649
  for (const key of keys) {
453
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
+ }
454
662
  const parsed = typeof value === 'string' && isAddress(value) ? parseAddress(value) : null;
455
- if (parsed?.kind === 'Query') {
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') {
456
670
  queries.set(parsed.name, parsed.module);
457
671
  component.props.push(
458
672
  key === first
@@ -472,7 +686,7 @@ function pageBindings(pageSpec) {
472
686
  }
473
687
  components.push(component);
474
688
  }
475
- return { queries, actions, components };
689
+ return { queries, actions, snippets, components };
476
690
  }
477
691
 
478
692
  export function emitModulePages(moduleName, moduleSpec, specs) {
@@ -485,7 +699,7 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
485
699
  const segments = routeSegments(spec.route);
486
700
  const dir = ['routes', ...segments].join('/');
487
701
  const lib = up(segments.length + 1) + 'lib';
488
- const { queries, actions, components } = pageBindings(spec);
702
+ const { queries, actions, snippets, components } = pageBindings(spec, specs);
489
703
  // Queries marked `live: true` get a depends key in the load and an
490
704
  // EventSource subscription in the page, so refresh signals from
491
705
  // /_norns/live re-run the load (R-11).
@@ -553,9 +767,18 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
553
767
  for (const c of components) {
554
768
  pug.push(`\t${c.tag}(${c.props.join(' ')})`);
555
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
+ }
556
776
  const stateKeys = Object.keys(spec.state ?? {}).sort();
557
777
  pug.push('', '<script>');
558
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
+ }
559
782
  pug.push(`\t{ data, form } := $props()`);
560
783
  for (const key of stateKeys) pug.push(`\t${key} := $state(null)`);
561
784
  if (liveScript) pug.push(liveScript.effect);
@@ -566,6 +789,45 @@ export function emitModulePages(moduleName, moduleSpec, specs) {
566
789
  return files;
567
790
  }
568
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
+
569
831
  /* ------------------------------------------------------------------ */
570
832
 
571
833
  const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
@@ -576,6 +838,8 @@ const single = (fn) => ({ moduleName, moduleSpec, specs }) => {
576
838
  export const policiesEmitter = { name: 'policies', emit: single(emitModulePolicies) };
577
839
  export const queriesEmitter = { name: 'queries', emit: single(emitModuleQueries) };
578
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) };
579
843
  export const triggersEmitter = { name: 'triggers', emit: single(emitModuleTriggers) };
580
844
  export const pagesEmitter = {
581
845
  name: 'pages',
@@ -585,3 +849,7 @@ export const remotesEmitter = {
585
849
  name: 'remotes',
586
850
  emit: ({ moduleName, moduleSpec }) => emitModuleRemotes(moduleName, moduleSpec) ?? []
587
851
  };
852
+ export const endpointsEmitter = {
853
+ name: 'endpoints',
854
+ emit: ({ moduleName, moduleSpec }) => emitModuleEndpoints(moduleName, moduleSpec) ?? []
855
+ };
@@ -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
- if (cf.queue === true) {
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: [{ queue }]
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
- return ops.eq(column([ownerField]), user?.id);
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) {