@substrat-run/contracts 0.142.0 → 0.144.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.
Files changed (56) hide show
  1. package/dist/capability.d.ts +174 -0
  2. package/dist/capability.d.ts.map +1 -1
  3. package/dist/capability.js +88 -2
  4. package/dist/capability.js.map +1 -1
  5. package/dist/control-plane.d.ts +2 -0
  6. package/dist/control-plane.d.ts.map +1 -1
  7. package/dist/control-plane.js +8 -2
  8. package/dist/control-plane.js.map +1 -1
  9. package/dist/deploy.d.ts +34 -0
  10. package/dist/deploy.d.ts.map +1 -1
  11. package/dist/deploy.js +74 -2
  12. package/dist/deploy.js.map +1 -1
  13. package/dist/entity-state.d.ts +14 -0
  14. package/dist/entity-state.d.ts.map +1 -1
  15. package/dist/entity-state.js +7 -0
  16. package/dist/entity-state.js.map +1 -1
  17. package/dist/errors.d.ts +12 -0
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +15 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/kernel-events.d.ts.map +1 -1
  22. package/dist/kernel-events.js +3 -1
  23. package/dist/kernel-events.js.map +1 -1
  24. package/dist/manifest.d.ts +9 -0
  25. package/dist/manifest.d.ts.map +1 -1
  26. package/dist/manifest.js +9 -0
  27. package/dist/manifest.js.map +1 -1
  28. package/dist/model.d.ts +138 -0
  29. package/dist/model.d.ts.map +1 -1
  30. package/dist/model.js +74 -0
  31. package/dist/model.js.map +1 -1
  32. package/dist/operations.d.ts +234 -14
  33. package/dist/operations.d.ts.map +1 -1
  34. package/dist/operations.js +654 -4
  35. package/dist/operations.js.map +1 -1
  36. package/dist/permission.d.ts +34 -0
  37. package/dist/permission.d.ts.map +1 -1
  38. package/dist/permission.js +30 -0
  39. package/dist/permission.js.map +1 -1
  40. package/dist/platform-request.d.ts +92 -0
  41. package/dist/platform-request.d.ts.map +1 -1
  42. package/dist/platform-request.js +38 -1
  43. package/dist/platform-request.js.map +1 -1
  44. package/dist/registry-diff.d.ts +12 -1
  45. package/dist/registry-diff.d.ts.map +1 -1
  46. package/dist/registry-diff.js +10 -4
  47. package/dist/registry-diff.js.map +1 -1
  48. package/dist/wire-headers.d.ts +13 -0
  49. package/dist/wire-headers.d.ts.map +1 -1
  50. package/dist/wire-headers.js +10 -0
  51. package/dist/wire-headers.js.map +1 -1
  52. package/dist/zod-read.d.ts +20 -0
  53. package/dist/zod-read.d.ts.map +1 -0
  54. package/dist/zod-read.js +27 -0
  55. package/dist/zod-read.js.map +1 -0
  56. package/package.json +1 -1
@@ -26,7 +26,9 @@
26
26
  */
27
27
  import { LIST_PAGE_MAX } from './pagination.js';
28
28
  import { z } from 'zod';
29
- import { primaryKeyOf } from './model.js';
29
+ import { entityStateName, entityStateOutput } from './entity-state.js';
30
+ import { DERIVED_KINDS, primaryKeyOf, stampsOf, } from './model.js';
31
+ import { acceptsNull, baseDefOf } from './zod-read.js';
30
32
  // ---------------------------------------------------------------------------
31
33
  // The composer.
32
34
  // ---------------------------------------------------------------------------
@@ -61,6 +63,7 @@ export function defineOperations(entities, _permissions, engines) {
61
63
  assertFieldBagsDeclareConcurrency(operations, entities, engines ?? []);
62
64
  assertPatchInputs(operations);
63
65
  assertTrashedDeclarations(operations, entities);
66
+ assertHandlersDeclared(operations, entities);
64
67
  return operations;
65
68
  };
66
69
  }
@@ -475,10 +478,640 @@ function assertPatchInputs(operations) {
475
478
  }
476
479
  }
477
480
  }
481
+ /**
482
+ * Every derived declaration's plan, keyed by the declaration object itself. Keyed per operation
483
+ * rather than per map so a module that spreads its operations into a larger map still reaches
484
+ * them, and module-private so nothing but `defineOperations` can say what a handler does.
485
+ */
486
+ const DERIVATION_PLANS = new WeakMap();
487
+ /**
488
+ * The plan `defineOperations` recorded for a `derive` declaration — `undefined` for anything else,
489
+ * including a declaration that never passed through `defineOperations`.
490
+ */
491
+ export function derivationPlanOf(operation) {
492
+ return DERIVATION_PLANS.get(operation);
493
+ }
494
+ /** The column kinds a derived write binds as they arrive: a JSON or boolean column needs encoding. */
495
+ const DERIVED_WRITE_KINDS = new Set(['string', 'number', 'literal']);
496
+ /** This module's entity whose `fields` the output IS, by identity — a projection is not the row. */
497
+ function entityOfOutput(output, entities) {
498
+ return Object.keys(entities).find((name) => entities[name]?.fields === output);
499
+ }
500
+ /** The entity's definition and the plan's shared head, or the clause the entity fails. */
501
+ function derivableEntity(entityName, entities) {
502
+ const def = entities[entityName];
503
+ if (!def)
504
+ return `'${entityName}' is not this module's entity — a composed engine's table is the engine's to read`;
505
+ const primaryKey = primaryKeyOf(entityName, def);
506
+ if (primaryKey.length !== 1)
507
+ return `'${entityName}' has a composite primary key, so no one id addresses a row`;
508
+ return {
509
+ def,
510
+ head: { entity: entityName, table: def.table, primaryKey: primaryKey[0], columns: Object.keys(def.fields.shape) },
511
+ };
512
+ }
513
+ /** The leading check, read back: a scope key, `{ key, entity, idFrom }`, or the clause it fails. */
514
+ function plainPermission(permission) {
515
+ if (typeof permission === 'string')
516
+ return { key: permission };
517
+ const p = permission;
518
+ if (typeof p?.key === 'string' && typeof p.entity === 'string' && typeof p.idFrom === 'string') {
519
+ return { key: p.key, entity: p.entity, idFrom: p.idFrom };
520
+ }
521
+ return 'its permission is resolved, carried in a ref, or narrowed per row — the derived check is a scope key or `{ key, entity, idFrom }`';
522
+ }
523
+ function emitOf(decl) {
524
+ const e = decl.emits ?? {};
525
+ return {
526
+ type: e.type,
527
+ schemaVersion: e.schemaVersion,
528
+ piiClass: e.piiClass,
529
+ ...(typeof e.subjectId === 'string' ? { subjectId: e.subjectId } : {}),
530
+ payload: [...(e.payload ?? [])],
531
+ };
532
+ }
533
+ /** The input's fields, split by whether a caller may omit them. */
534
+ function inputFields(decl) {
535
+ const required = [];
536
+ const optional = [];
537
+ for (const [field, schema] of Object.entries(decl.input?.shape ?? {})) {
538
+ (isOptionalSchema(schema) ? optional : required).push(field);
539
+ }
540
+ return { required, optional };
541
+ }
542
+ /** Clauses every derived shape shares: no per-row walk, no per-field projection, no bin access. */
543
+ function commonClause(decl, kind) {
544
+ // The bin page's per-row check is `ctx.pageTrashed`'s own, so its `narrows` is part of its shape.
545
+ if (decl.narrows !== undefined && kind !== 'trashed') {
546
+ return 'it narrows per row (`narrows`), which is a walk the handler composes';
547
+ }
548
+ if (decl.gates !== undefined)
549
+ return 'it gates fields (`gates`), a projection the handler applies';
550
+ if (decl.inputOptional !== undefined)
551
+ return 'it declares `inputOptional`';
552
+ const reachesBin = (kind === 'delete' && decl.trashed === 'purges') || (kind === 'restore' && decl.trashed === 'admits');
553
+ if (decl.trashed !== undefined && !reachesBin) {
554
+ return `it declares \`trashed: '${String(decl.trashed)}'\` — reaching the bin is authored`;
555
+ }
556
+ return null;
557
+ }
558
+ function getPlan(decl, entities) {
559
+ if (decl.paged !== undefined)
560
+ return 'it is paged — a page is a `list`';
561
+ if (decl.emits !== undefined)
562
+ return 'it emits, and a read announces nothing';
563
+ // An invoke-only operation that emits nothing may still write — a silent transition reads the
564
+ // same from here — so a `get` is one the declaration itself SERVES as a read.
565
+ if (decl.http?.method !== 'GET')
566
+ return 'it is not served as GET, the method that says it only reads';
567
+ const name = entityOfOutput(decl.output, entities);
568
+ if (name === undefined)
569
+ return "its output is not one of this module's entities' own `fields`";
570
+ const base = derivableEntity(name, entities);
571
+ if (typeof base === 'string')
572
+ return base;
573
+ const { required, optional } = inputFields(decl);
574
+ if (required.length !== 1 || optional.length !== 0)
575
+ return 'its input is not the id alone';
576
+ const idFrom = required[0];
577
+ const permission = plainPermission(decl.permission);
578
+ if (typeof permission === 'string')
579
+ return permission;
580
+ if (permission.entity !== undefined && (permission.entity !== name || permission.idFrom !== idFrom)) {
581
+ return `its check narrows to '${permission.entity}' by '${String(permission.idFrom)}', not to the row it reads`;
582
+ }
583
+ if (permission.entity === undefined && base.def.trash !== undefined) {
584
+ return `'${name}' declares \`trash\`, and only a check narrowed to the row lets the host refuse a binned one`;
585
+ }
586
+ return { kind: 'get', ...base.head, permission, idFrom };
587
+ }
588
+ /** The column kinds a derived filter binds as they arrive — a boolean filter over a 0/1 column needs encoding. */
589
+ const DERIVED_FILTER_KINDS = new Set(['string', 'number', 'literal', 'enum']);
590
+ /** A derived page binds the input value as the filter, so the two must be the same kind. */
591
+ function filterTypeIssue(decl, field, column, entity) {
592
+ const columnKind = baseDefOf(entity.fields.shape[column])?.type;
593
+ if (!columnKind || !DERIVED_FILTER_KINDS.has(columnKind)) {
594
+ return `filter column '${column}' is not a string, number or enum column`;
595
+ }
596
+ if (baseDefOf(decl.input?.shape[field])?.type !== columnKind) {
597
+ return `input field '${field}' is not the same type as filter column '${column}', so the handler maps it`;
598
+ }
599
+ return null;
600
+ }
601
+ /** One optional input as a declared filter of the page, or the clause it fails. */
602
+ function optionalFilterOf(decl, field, filterable, entity) {
603
+ const column = snakeCaseField(field);
604
+ if (!filterable.has(column))
605
+ return `input field '${field}' is not a declared \`filterable\` column`;
606
+ if (inputDefaultIssue(decl.input?.shape[field]) !== null)
607
+ return `input field '${field}' has a default`;
608
+ return filterTypeIssue(decl, field, column, entity) ?? { field, column };
609
+ }
610
+ function listPlan(decl, entities) {
611
+ const over = decl.paged?.over;
612
+ if (over === undefined)
613
+ return 'it is not a kernel-composed page (`paged.over`)';
614
+ // A page is a read, matched as exactly as a `get`: a paged command that emits, or one served
615
+ // as anything but GET, writes as well as reads, and its handler is its own.
616
+ if (decl.emits !== undefined)
617
+ return 'it emits, and a read announces nothing';
618
+ if (decl.http?.method !== 'GET')
619
+ return 'it is not served as GET, the method that says it only reads';
620
+ const name = over.entity;
621
+ if (decl.output !== entities[name]?.fields)
622
+ return `its output is not '${name}'s own \`fields\``;
623
+ const base = derivableEntity(name, entities);
624
+ if (typeof base === 'string')
625
+ return base;
626
+ const permission = plainPermission(decl.permission);
627
+ if (typeof permission === 'string')
628
+ return permission;
629
+ const filterable = new Set(over.filterable ?? []);
630
+ const filters = [];
631
+ const { required, optional } = inputFields(decl);
632
+ let parent;
633
+ if (permission.entity !== undefined) {
634
+ const target = parentTarget(name, base.def, permission, entities, 'filters');
635
+ if (typeof target === 'string')
636
+ return target;
637
+ parent = target;
638
+ const { idFrom, column } = target;
639
+ if (!required.includes(idFrom) || !filterable.has(column)) {
640
+ return `the parent's id '${idFrom}' is not a required input filtering the declared \`filterable\` column '${column}'`;
641
+ }
642
+ const issue = filterTypeIssue(decl, idFrom, column, base.def);
643
+ if (issue)
644
+ return issue;
645
+ filters.push({ field: idFrom, column });
646
+ }
647
+ for (const field of required) {
648
+ if (field !== permission.idFrom)
649
+ return `input field '${field}' is required, and only the parent's id may be`;
650
+ }
651
+ let viewFrom;
652
+ for (const field of optional) {
653
+ if (field === VIEW_FIELD && base.def.archive !== undefined) {
654
+ const issue = viewIssue(decl.input?.shape[field], filterable);
655
+ if (issue)
656
+ return issue;
657
+ viewFrom = field;
658
+ continue;
659
+ }
660
+ const filter = optionalFilterOf(decl, field, filterable, base.def);
661
+ if (typeof filter === 'string')
662
+ return filter;
663
+ filters.push(filter);
664
+ }
665
+ return {
666
+ kind: 'list',
667
+ ...base.head,
668
+ permission,
669
+ filters,
670
+ total: decl.paged?.total === true,
671
+ ...(parent ? { parent } : {}),
672
+ ...(viewFrom !== undefined ? { viewFrom } : {}),
673
+ };
674
+ }
675
+ /**
676
+ * The parent a check narrows to, for a page of its children or a child created under it (#2157).
677
+ * The input's id must fill the LINK column `<parent>_id` — a filter or a value on any other column
678
+ * passes the check on one row and reads or writes another.
679
+ */
680
+ function parentTarget(name, def, permission, entities, verb) {
681
+ const parent = permission.entity;
682
+ if (!(def.parents ?? []).includes(parent))
683
+ return `its check narrows to '${parent}', which is not a parent of '${name}'`;
684
+ const idFrom = permission.idFrom;
685
+ const column = snakeCaseField(idFrom);
686
+ const link = `${snakeCaseField(parent)}_id`;
687
+ if (column !== link) {
688
+ return `the parent's id '${idFrom}' ${verb} column '${column}', which is not the link to '${parent}' ('${link}')`;
689
+ }
690
+ const base = derivableEntity(parent, entities);
691
+ if (typeof base === 'string')
692
+ return base;
693
+ return { entity: parent, table: base.head.table, primaryKey: base.head.primaryKey, idFrom, column };
694
+ }
695
+ /** The input field a derived page of an archivable entity reads its view from (#119). */
696
+ const VIEW_FIELD = 'view';
697
+ /** The views `ctx.page` serves; the bin is its own read (`trashed`). */
698
+ const PAGE_VIEWS = entityStateName.exclude(['trashed']).options;
699
+ /** Why `view` cannot be passed to `ctx.page` as it arrives, or `null`. */
700
+ function viewIssue(schema, filterable) {
701
+ if (filterable.has(VIEW_FIELD)) {
702
+ return `'${VIEW_FIELD}' is both the archive view and a declared \`filterable\` column, so the input means two things`;
703
+ }
704
+ if (inputDefaultIssue(schema) !== null)
705
+ return `input field '${VIEW_FIELD}' has a default`;
706
+ const base = baseDefOf(schema);
707
+ const values = base?.type === 'enum' ? Object.values(base.entries ?? {}).map(String).sort() : [];
708
+ if (values.join() !== [...PAGE_VIEWS].sort().join()) {
709
+ return `input field '${VIEW_FIELD}' is not \`z.enum(['active', 'archived'])\`, the views a page serves`;
710
+ }
711
+ return null;
712
+ }
713
+ /** The clauses a derived write shares: narrowed to the row it writes, announcing it, by that row's id. */
714
+ function writeTarget(decl, entities) {
715
+ const permission = plainPermission(decl.permission);
716
+ if (typeof permission === 'string')
717
+ return permission;
718
+ if (permission.entity === undefined)
719
+ return 'its check is scope-wide, and a derived write is narrowed to the row it writes';
720
+ const name = permission.entity;
721
+ const base = derivableEntity(name, entities);
722
+ if (typeof base === 'string')
723
+ return base;
724
+ if (decl.emits?.entity !== name)
725
+ return `it does not emit about '${name}', the row it writes`;
726
+ if (decl.emits.entityIdFrom !== base.head.primaryKey) {
727
+ return `its event takes its subject from '${String(decl.emits.entityIdFrom)}', not the row's id '${base.head.primaryKey}'`;
728
+ }
729
+ const idFrom = permission.idFrom;
730
+ return { name, base, idFrom, permission: { key: permission.key, entity: name, idFrom } };
731
+ }
732
+ function updatePlan(decl, entities) {
733
+ if (decl.http?.method !== 'PATCH')
734
+ return 'it is not served as PATCH, the method whose absent fields stay untouched';
735
+ if (decl.patchException !== undefined)
736
+ return 'it declares `patchException`, so its body is not a field bag';
737
+ if (decl.paged !== undefined)
738
+ return 'it is paged';
739
+ const target = writeTarget(decl, entities);
740
+ if (typeof target === 'string')
741
+ return target;
742
+ const { name, base, idFrom } = target;
743
+ if (decl.output !== base.def.fields)
744
+ return `its output is not '${name}'s own \`fields\``;
745
+ if (decl.concurrency?.over !== name || decl.concurrency.idFrom !== idFrom) {
746
+ return `it declares no \`concurrency: { over: '${name}', idFrom: '${idFrom}' }\`, and a field bag without one loses updates`;
747
+ }
748
+ const { required, optional } = inputFields(decl);
749
+ if (required.length !== 1 || required[0] !== idFrom)
750
+ return `its input requires more than the id '${idFrom}'`;
751
+ if (optional.length === 0)
752
+ return 'its input has no field to write';
753
+ const columnShape = base.def.fields.shape;
754
+ const stamps = stampsOf(name, base.def);
755
+ const fields = [];
756
+ for (const field of optional) {
757
+ const target = writableColumn(decl, field, name, base.head.primaryKey, columnShape, stamps, UPDATE_WORDS);
758
+ if (typeof target === 'string')
759
+ return target;
760
+ const { column, columnSchema, fieldSchema } = target;
761
+ if (acceptsNull(fieldSchema) && !acceptsNull(columnSchema)) {
762
+ return `body field '${field}' accepts null, and column '${column}' cannot be cleared`;
763
+ }
764
+ fields.push({ field, column });
765
+ }
766
+ return {
767
+ kind: 'update',
768
+ ...base.head,
769
+ permission: target.permission,
770
+ idFrom,
771
+ fields,
772
+ emit: emitOf(decl),
773
+ };
774
+ }
775
+ const UPDATE_WORDS = {
776
+ field: 'body field',
777
+ onId: "would rewrite the row's id",
778
+ onEnum: 'a change of state is a lifecycle edge with its own operation',
779
+ };
780
+ const CREATE_WORDS = {
781
+ field: 'input field',
782
+ onId: "would set the row's id, which a create mints",
783
+ onEnum: "a row's first state is its lifecycle's, and authored",
784
+ };
785
+ /**
786
+ * The column one input field of a derived write fills, by its snake_case name — or the clause it
787
+ * fails: no such column, the id, a stamp, an enum, a kind the write cannot bind as it arrives, or
788
+ * a field of another type. Whether it may be empty is the caller's rule.
789
+ */
790
+ function writableColumn(decl, field, name, primaryKey, columnShape, stamps, words) {
791
+ const column = snakeCaseField(field);
792
+ const columnSchema = columnShape[column];
793
+ if (columnSchema === undefined)
794
+ return `${words.field} '${field}' is not a column of '${name}'`;
795
+ if (column === primaryKey)
796
+ return `${words.field} '${field}' ${words.onId}`;
797
+ if (stamps[column] !== undefined)
798
+ return `${words.field} '${field}' writes '${column}', which is stamped`;
799
+ const fieldSchema = decl.input?.shape[field];
800
+ const kind = baseDefOf(columnSchema)?.type;
801
+ if (kind === 'enum')
802
+ return `column '${column}' is an enum — ${words.onEnum}`;
803
+ if (!kind || !DERIVED_WRITE_KINDS.has(kind))
804
+ return `column '${column}' is not a string or number column`;
805
+ if (baseDefOf(fieldSchema)?.type !== kind)
806
+ return `${words.field} '${field}' is not the same type as column '${column}'`;
807
+ return { column, columnSchema, fieldSchema };
808
+ }
809
+ /** The declared stamps, in declared order. */
810
+ function stampList(stamps) {
811
+ return Object.entries(stamps).map(([column, kind]) => ({ column, kind }));
812
+ }
813
+ function deletePlan(decl, entities) {
814
+ if (decl.http?.method !== 'DELETE')
815
+ return 'it is not served as DELETE, the method that says it removes the row';
816
+ if (decl.paged !== undefined)
817
+ return 'it is paged';
818
+ const target = writeTarget(decl, entities);
819
+ if (typeof target === 'string')
820
+ return target;
821
+ const { name, base, idFrom } = target;
822
+ const outputKeys = Object.keys(decl.output?.shape ?? {}).sort();
823
+ if (outputKeys.join() !== 'deleted,id')
824
+ return 'its output is not `{ id, deleted }`';
825
+ const { required, optional } = inputFields(decl);
826
+ if (required.length !== 1 || required[0] !== idFrom || optional.length !== 0)
827
+ return `its input is not the id '${idFrom}' alone`;
828
+ const children = Object.keys(entities).filter((child) => (entities[child]?.parents ?? []).includes(name));
829
+ if (children.length > 0) {
830
+ return `${children.map((c) => `'${c}'`).join(', ')} declare${children.length === 1 ? 's' : ''} '${name}' as parent, so a delete would orphan them — a cascade is authored`;
831
+ }
832
+ return { kind: 'delete', ...base.head, permission: target.permission, idFrom, emit: emitOf(decl) };
833
+ }
834
+ /** A column a row may leave empty — what a derived create writes when no source names it. */
835
+ const columnMayBeNull = (schema) => acceptsNull(schema) || isOptionalSchema(schema);
836
+ function createPlan(decl, entities) {
837
+ if (decl.http?.method !== 'POST')
838
+ return 'it is not served as POST, the method that says it adds a row';
839
+ if (decl.paged !== undefined)
840
+ return 'it is paged';
841
+ if (decl.concurrency !== undefined)
842
+ return 'it declares `concurrency`, and a row that does not exist yet has no version';
843
+ const name = decl.emits?.entity;
844
+ if (typeof name !== 'string')
845
+ return 'it emits nothing, and a create announces the row it adds';
846
+ const base = derivableEntity(name, entities);
847
+ if (typeof base === 'string')
848
+ return base;
849
+ const { primaryKey } = base.head;
850
+ if (decl.output !== base.def.fields)
851
+ return `its output is not '${name}'s own \`fields\``;
852
+ if (decl.emits?.entityIdFrom !== primaryKey) {
853
+ return `its event takes its subject from '${String(decl.emits?.entityIdFrom)}', not the row's id '${primaryKey}'`;
854
+ }
855
+ const columnShape = base.def.fields.shape;
856
+ if (baseDefOf(columnShape[primaryKey])?.type !== 'string') {
857
+ return `'${name}'s id '${primaryKey}' is not a string column, so a minted id cannot fill it`;
858
+ }
859
+ const permission = plainPermission(decl.permission);
860
+ if (typeof permission === 'string')
861
+ return permission;
862
+ const { required, optional } = inputFields(decl);
863
+ let parent;
864
+ if (permission.entity !== undefined) {
865
+ const target = parentTarget(name, base.def, permission, entities, 'fills');
866
+ if (typeof target === 'string')
867
+ return target;
868
+ if (!required.includes(target.idFrom))
869
+ return `the parent's id '${target.idFrom}' is not a required input`;
870
+ if (baseDefOf(decl.input?.shape[target.idFrom])?.type !== baseDefOf(columnShape[target.column])?.type) {
871
+ return `input field '${target.idFrom}' is not the same type as link column '${target.column}'`;
872
+ }
873
+ parent = target;
874
+ }
875
+ const stamps = stampsOf(name, base.def);
876
+ // Every input field fills the column of its name — an input the row does not hold is one the
877
+ // handler does something else with.
878
+ const values = [];
879
+ for (const field of [...required, ...optional]) {
880
+ if (field === parent?.idFrom)
881
+ continue;
882
+ if (snakeCaseField(field) === parent?.column)
883
+ return `input field '${field}' writes the link column '${parent.column}'`;
884
+ const target = writableColumn(decl, field, name, primaryKey, columnShape, stamps, CREATE_WORDS);
885
+ if (typeof target === 'string')
886
+ return target;
887
+ const { column, columnSchema, fieldSchema } = target;
888
+ if ((optional.includes(field) || acceptsNull(fieldSchema)) && !columnMayBeNull(columnSchema)) {
889
+ return `input field '${field}' may be absent or null, and column '${column}' cannot be empty`;
890
+ }
891
+ values.push({ field, column });
892
+ }
893
+ const filled = new Set([primaryKey, ...(parent ? [parent.column] : []), ...Object.keys(stamps), ...values.map((v) => v.column)]);
894
+ for (const column of Object.keys(columnShape)) {
895
+ if (!filled.has(column) && !columnMayBeNull(columnShape[column])) {
896
+ return `column '${column}' is filled by nothing the model names — no input, stamp or parent link — and cannot be empty`;
897
+ }
898
+ }
899
+ const key = [...(base.def.key ?? [])];
900
+ const fromInput = new Set([...(parent ? [parent.column] : []), ...values.map((v) => v.column)]);
901
+ for (const column of key) {
902
+ if (!fromInput.has(column))
903
+ return `its natural key column '${column}' is not filled from the input, so no existing row can be matched by it`;
904
+ }
905
+ // A replay answers the row already holding the key. The check passed on the parent the caller
906
+ // named, so the key must pin that parent too, or the lookup answers another parent's row.
907
+ if (parent && key.length > 0 && !key.includes(parent.column)) {
908
+ return `its natural key does not include the link to '${parent.entity}' ('${parent.column}'), so a replay could answer another ${parent.entity}'s row`;
909
+ }
910
+ // A replay would also answer a row the kernel hides (#119, K-45): a binned one to a caller who
911
+ // may not see the bin, or an archived one the active view leaves out — and skipping hidden rows
912
+ // only moves the clash to the insert. What re-creating one means is the vertical's to decide.
913
+ const hides = [base.def.trash ? '`trash`' : null, base.def.archive ? '`archive`' : null].filter(Boolean);
914
+ if (key.length > 0 && hides.length > 0) {
915
+ return `'${name}' declares ${hides.join(' and ')} and a natural key, so a replay could answer a row the kernel hides — whether re-creating one restores it, unarchives it or refuses is the vertical's call, so its handler is written by hand`;
916
+ }
917
+ return {
918
+ kind: 'create',
919
+ ...base.head,
920
+ permission,
921
+ ...(parent ? { parent } : {}),
922
+ values,
923
+ stamps: stampList(stamps),
924
+ key,
925
+ emit: emitOf(decl),
926
+ };
927
+ }
928
+ /** The derived kinds that are one kernel move (#119, K-45), and which declaration gates each. */
929
+ const MOVE_GATE = {
930
+ archive: 'archive',
931
+ unarchive: 'archive',
932
+ trash: 'trash',
933
+ restore: 'trash',
934
+ };
935
+ /**
936
+ * One kernel move: a POST of the id alone, narrowed to that row under the key the entity declares
937
+ * for it, answering `entityStateOutput` and declaring no event, since the kernel emits the move.
938
+ */
939
+ function movePlan(kind) {
940
+ return (decl, entities) => {
941
+ if (decl.http?.method !== 'POST')
942
+ return 'it is not served as POST, the method a move of state is';
943
+ if (decl.paged !== undefined)
944
+ return 'it is paged';
945
+ if (decl.emits !== undefined)
946
+ return 'it declares `emits`, and the kernel announces the move itself';
947
+ if (decl.output !== entityStateOutput)
948
+ return 'its output is not `entityStateOutput`';
949
+ const permission = plainPermission(decl.permission);
950
+ if (typeof permission === 'string')
951
+ return permission;
952
+ if (permission.entity === undefined)
953
+ return 'its check is scope-wide, and a move is checked on the row it moves';
954
+ const name = permission.entity;
955
+ const base = derivableEntity(name, entities);
956
+ if (typeof base === 'string')
957
+ return base;
958
+ const gate = MOVE_GATE[kind];
959
+ const declared = base.def[gate]?.permission;
960
+ if (declared === undefined)
961
+ return `'${name}' declares no \`${gate}\``;
962
+ if (permission.key !== declared) {
963
+ return `its check is '${permission.key}', not the key '${name}' declares for its ${gate}, '${declared}'`;
964
+ }
965
+ const idFrom = permission.idFrom;
966
+ const { required, optional } = inputFields(decl);
967
+ if (required.length !== 1 || required[0] !== idFrom || optional.length !== 0)
968
+ return `its input is not the id '${idFrom}' alone`;
969
+ if (kind === 'restore' && decl.trashed !== 'admits') {
970
+ return "it does not declare `trashed: 'admits'`, and a restore is the move that reaches a binned row";
971
+ }
972
+ return { kind, ...base.head, permission: { key: declared, entity: name, idFrom }, idFrom };
973
+ };
974
+ }
975
+ /** The bin: `ctx.pageTrashed` over an entity that declares `trash`, which checks its key per row. */
976
+ function trashedPlan(decl, entities) {
977
+ const over = decl.paged?.over;
978
+ if (over === undefined)
979
+ return 'it is not a kernel-composed page (`paged.over`)';
980
+ if (decl.emits !== undefined)
981
+ return 'it emits, and a read announces nothing';
982
+ if (decl.http?.method !== 'GET')
983
+ return 'it is not served as GET, the method that says it only reads';
984
+ if (decl.paged?.total !== undefined)
985
+ return 'it asks for a total, and a page of the bin carries none';
986
+ const name = over.entity;
987
+ if (decl.output !== entities[name]?.fields)
988
+ return `its output is not '${name}'s own \`fields\``;
989
+ const base = derivableEntity(name, entities);
990
+ if (typeof base === 'string')
991
+ return base;
992
+ const key = base.def.trash?.permission;
993
+ if (key === undefined)
994
+ return `'${name}' declares no \`trash\``;
995
+ // `ctx.pageTrashed` checks the trash key on every row: a leading check on top would deny the
996
+ // person whose key is narrowed to the rows they binned, and say nothing the walk does not.
997
+ if (decl.permission !== undefined)
998
+ return 'it declares a leading check, and the bin is checked row by row';
999
+ // `unchecked` says the operation checks nothing, which a bin walked under its key is not.
1000
+ const narrows = decl.narrows;
1001
+ if (narrows?.unchecked !== undefined || narrows?.checks?.length !== 1 || narrows.checks[0] !== key) {
1002
+ return `it does not declare \`narrows\` checking exactly '${key}', the key the bin is walked under`;
1003
+ }
1004
+ const filterable = new Set(over.filterable ?? []);
1005
+ const filters = [];
1006
+ const { required, optional } = inputFields(decl);
1007
+ if (required.length !== 0)
1008
+ return `input field '${required[0]}' is required, and a page of the bin takes filters only`;
1009
+ for (const field of optional) {
1010
+ const filter = optionalFilterOf(decl, field, filterable, base.def);
1011
+ if (typeof filter === 'string')
1012
+ return filter;
1013
+ filters.push(filter);
1014
+ }
1015
+ return { kind: 'trashed', ...base.head, permission: { key }, filters };
1016
+ }
1017
+ const PLANNERS = {
1018
+ get: getPlan,
1019
+ list: listPlan,
1020
+ update: updatePlan,
1021
+ delete: deletePlan,
1022
+ create: createPlan,
1023
+ archive: movePlan('archive'),
1024
+ unarchive: movePlan('unarchive'),
1025
+ trash: movePlan('trash'),
1026
+ restore: movePlan('restore'),
1027
+ trashed: trashedPlan,
1028
+ };
1029
+ /** The plan for one shape, or the clause the declaration fails — the shared clauses first. */
1030
+ function planFor(kind, decl, entities) {
1031
+ return commonClause(decl, kind) ?? PLANNERS[kind](decl, entities);
1032
+ }
1033
+ /**
1034
+ * The shape the platform could derive this operation's handler as, read off its declaration and
1035
+ * the module's entities alone — `undefined` when it matches none of `DerivedKind`. Pure: this
1036
+ * classifies, and `defineOperations` is what refuses. The first shape that matches is answered:
1037
+ * a `get` is served as GET and not paged, a `list` is paged, an `update` is a PATCH that emits, a
1038
+ * `delete` is a DELETE answering `{ id, deleted }`, a `create` is a POST that emits. The moves are
1039
+ * the exception (`derivedKindsOf`).
1040
+ */
1041
+ export function derivationOf(decl, entities) {
1042
+ for (const kind of DERIVED_KINDS) {
1043
+ const plan = planFor(kind, decl, entities);
1044
+ if (typeof plan !== 'string')
1045
+ return plan;
1046
+ }
1047
+ return undefined;
1048
+ }
1049
+ /**
1050
+ * Every shape a declaration matches. One, except for a move (#119): archive and unarchive declare
1051
+ * the same shape, so a hand-written one is named as both and `derive` says which it is.
1052
+ */
1053
+ function derivedKindsOf(decl, entities) {
1054
+ return DERIVED_KINDS.filter((kind) => typeof planFor(kind, decl, entities) !== 'string');
1055
+ }
1056
+ /**
1057
+ * A handler the platform could write is either written by it or excused, by name (#1773).
1058
+ *
1059
+ * Runs at module load, beside the PATCH and paged gates, and for their reason: it fires in every
1060
+ * build, test, dev server and `lint:model --check`, so it cannot be true only of the modules a
1061
+ * tool knew about. A derivable operation declaring neither `derive` nor `authored` is refused;
1062
+ * so are a `derive` whose shape does not match (with the clause that fails), an `authored` on
1063
+ * an operation nothing could derive, and the two together.
1064
+ */
1065
+ function assertHandlersDeclared(operations, entities) {
1066
+ for (const [name, op] of Object.entries(operations)) {
1067
+ const decl = op;
1068
+ const { derive, authored } = decl;
1069
+ if (derive !== undefined && authored !== undefined) {
1070
+ throw new Error(`model: '${name}' declares both \`derive\` and \`authored\` — a handler is written by the platform or by you, not both.\n` +
1071
+ ' Remedy: keep `derive` and delete the handler, or keep `authored` and drop `derive`.');
1072
+ }
1073
+ if (authored !== undefined && (typeof authored !== 'string' || authored.trim() === '')) {
1074
+ throw new Error(`model: '${name}' declares \`authored\` without a reason.\n` +
1075
+ ' Remedy: say what the derived handler would get wrong here, or declare `derive` and delete the handler.');
1076
+ }
1077
+ if (derive !== undefined) {
1078
+ if (!DERIVED_KINDS.includes(derive)) {
1079
+ throw new Error(`model: '${name}' declares \`derive: '${String(derive)}'\` — the derivable shapes are ${DERIVED_KINDS.join(', ')}.\n` +
1080
+ ' Remedy: declare one of them, or drop `derive` and write the handler.');
1081
+ }
1082
+ const plan = planFor(derive, decl, entities);
1083
+ if (typeof plan === 'string') {
1084
+ throw new Error(`model: '${name}' declares \`derive: '${String(derive)}'\`, but ${plan}.\n` +
1085
+ ' Remedy: make the declaration the shape it derives, or drop `derive` and write the handler.');
1086
+ }
1087
+ DERIVATION_PLANS.set(op, plan);
1088
+ continue;
1089
+ }
1090
+ const plan = derivationOf(decl, entities);
1091
+ if (plan === undefined) {
1092
+ if (authored !== undefined) {
1093
+ throw new Error(`model: '${name}' declares \`authored\`, but nothing about it is derivable — it matches none of ` +
1094
+ `${DERIVED_KINDS.join(', ')}, so every handler for it is authored already.\n` +
1095
+ ' Remedy: remove `authored`.');
1096
+ }
1097
+ continue;
1098
+ }
1099
+ if (authored !== undefined)
1100
+ continue;
1101
+ const kinds = derivedKindsOf(decl, entities);
1102
+ const named = kinds.map((k) => `\`${k}\``).join(' or ');
1103
+ const derives = kinds.map((k) => `\`derive: '${k}'\``).join(' or ');
1104
+ throw new Error(`model: '${name}' is derivable from the model as ${named} over '${plan.entity}', and declares neither ` +
1105
+ '`derive` nor `authored`.\n' +
1106
+ ` A hand-written ${plan.kind} restates its declaration, and each restatement drifts (#1773).\n` +
1107
+ ` Remedy: declare ${derives} and delete its handler, or declare ` +
1108
+ `\`authored: '<why the derived ${plan.kind} is wrong here>'\`.`);
1109
+ }
1110
+ }
478
1111
  /**
479
1112
  * The concurrency each operation declares, for the host (#129).
480
1113
  *
481
- * Handed over beside `operationInputs` and read the same way — the adapter
1114
+ * Handed over as `operations.concurrency`, beside the inputs, and read the same way — the adapter
482
1115
  * compares versions from this map rather than each handler being trusted to. Same
483
1116
  * argument as the parse: one place that cannot be forgotten beats a rule every
484
1117
  * new operation has to remember.
@@ -497,8 +1130,8 @@ export function operationConcurrencyOf(operations) {
497
1130
  * name → the entity each operation addresses by id, for the host (#119).
498
1131
  *
499
1132
  * Every operation whose leading check is `{ entity, idFrom }`, whether or not its entity
500
- * declares `trash` — the host keeps the ones whose entity does. Handed over beside
501
- * `operationInputs`, and required by the host from any module with a trashable entity, so a
1133
+ * declares `trash` — the host keeps the ones whose entity does. Carried by `operations.inputs`,
1134
+ * and required by the host from any module with a trashable entity, so a
502
1135
  * module cannot leave its binned entities reachable by forgetting the line.
503
1136
  */
504
1137
  export function operationTargetsOf(operations) {
@@ -1143,4 +1776,21 @@ export function defineEngineRoutes(operations) {
1143
1776
  return out;
1144
1777
  };
1145
1778
  }
1779
+ /**
1780
+ * Declare an app's hand-handled routes (#2170).
1781
+ *
1782
+ * ```ts
1783
+ * export const routes = defineRoutes({
1784
+ * 'dashboard/list-findings': {
1785
+ * summary: "This team's findings, most recently seen first",
1786
+ * input: z.object({ status: findingStatus.optional() }),
1787
+ * output: z.object({ available: z.boolean(), entries: z.array(findingEntry) }),
1788
+ * http: { method: 'GET', path: '/findings' },
1789
+ * },
1790
+ * });
1791
+ * ```
1792
+ */
1793
+ export function defineRoutes(routes) {
1794
+ return routes;
1795
+ }
1146
1796
  //# sourceMappingURL=operations.js.map