@substrat-run/contracts 0.143.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.
@@ -26,7 +26,9 @@
26
26
  */
27
27
  import { LIST_PAGE_MAX } from './pagination.js';
28
28
  import { z } from 'zod';
29
- import { DERIVED_KINDS, 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
  // ---------------------------------------------------------------------------
@@ -489,27 +491,6 @@ const DERIVATION_PLANS = new WeakMap();
489
491
  export function derivationPlanOf(operation) {
490
492
  return DERIVATION_PLANS.get(operation);
491
493
  }
492
- /**
493
- * A schema read structurally, through the wrappers that only change optionality: the zod def
494
- * underneath, and whether any wrapper on the way admits `null`.
495
- */
496
- function unwrapped(schema) {
497
- let current = schema;
498
- let nullable = false;
499
- for (let depth = 0; depth < 8; depth++) {
500
- const def = current?._zod?.def;
501
- if (!def)
502
- return { base: undefined, nullable };
503
- if (def.type === 'nullable' || def.type === 'nullish')
504
- nullable = true;
505
- else if (def.type !== 'optional' && def.type !== 'readonly')
506
- return { base: def, nullable };
507
- current = def.innerType;
508
- }
509
- return { base: undefined, nullable };
510
- }
511
- const baseDefOf = (schema) => unwrapped(schema).base;
512
- const acceptsNull = (schema) => unwrapped(schema).nullable;
513
494
  /** The column kinds a derived write binds as they arrive: a JSON or boolean column needs encoding. */
514
495
  const DERIVED_WRITE_KINDS = new Set(['string', 'number', 'literal']);
515
496
  /** This module's entity whose `fields` the output IS, by identity — a projection is not the row. */
@@ -560,13 +541,16 @@ function inputFields(decl) {
560
541
  }
561
542
  /** Clauses every derived shape shares: no per-row walk, no per-field projection, no bin access. */
562
543
  function commonClause(decl, kind) {
563
- if (decl.narrows !== undefined)
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') {
564
546
  return 'it narrows per row (`narrows`), which is a walk the handler composes';
547
+ }
565
548
  if (decl.gates !== undefined)
566
549
  return 'it gates fields (`gates`), a projection the handler applies';
567
550
  if (decl.inputOptional !== undefined)
568
551
  return 'it declares `inputOptional`';
569
- if (decl.trashed !== undefined && !(kind === 'delete' && decl.trashed === 'purges')) {
552
+ const reachesBin = (kind === 'delete' && decl.trashed === 'purges') || (kind === 'restore' && decl.trashed === 'admits');
553
+ if (decl.trashed !== undefined && !reachesBin) {
570
554
  return `it declares \`trashed: '${String(decl.trashed)}'\` — reaching the bin is authored`;
571
555
  }
572
556
  return null;
@@ -614,6 +598,15 @@ function filterTypeIssue(decl, field, column, entity) {
614
598
  }
615
599
  return null;
616
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
+ }
617
610
  function listPlan(decl, entities) {
618
611
  const over = decl.paged?.over;
619
612
  if (over === undefined)
@@ -636,18 +629,13 @@ function listPlan(decl, entities) {
636
629
  const filterable = new Set(over.filterable ?? []);
637
630
  const filters = [];
638
631
  const { required, optional } = inputFields(decl);
632
+ let parent;
639
633
  if (permission.entity !== undefined) {
640
- if (!(base.def.parents ?? []).includes(permission.entity)) {
641
- return `its check narrows to '${permission.entity}', which is not a parent of '${name}'`;
642
- }
643
- const idFrom = permission.idFrom;
644
- const column = snakeCaseField(idFrom);
645
- // The filter is what scopes the page to the checked parent, so it must BE the link to that
646
- // parent — a filter on any other column passes the check on one row and pages another set.
647
- const link = `${snakeCaseField(permission.entity)}_id`;
648
- if (column !== link) {
649
- return `the parent's id '${idFrom}' filters column '${column}', which is not the link to '${permission.entity}' ('${link}')`;
650
- }
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;
651
639
  if (!required.includes(idFrom) || !filterable.has(column)) {
652
640
  return `the parent's id '${idFrom}' is not a required input filtering the declared \`filterable\` column '${column}'`;
653
641
  }
@@ -660,18 +648,67 @@ function listPlan(decl, entities) {
660
648
  if (field !== permission.idFrom)
661
649
  return `input field '${field}' is required, and only the parent's id may be`;
662
650
  }
651
+ let viewFrom;
663
652
  for (const field of optional) {
664
- const column = snakeCaseField(field);
665
- if (!filterable.has(column))
666
- return `input field '${field}' is not a declared \`filterable\` column`;
667
- if (inputDefaultIssue(decl.input?.shape[field]) !== null)
668
- return `input field '${field}' has a default`;
669
- const issue = filterTypeIssue(decl, field, column, base.def);
670
- if (issue)
671
- return issue;
672
- filters.push({ field, column });
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);
673
664
  }
674
- return { kind: 'list', ...base.head, permission, filters, total: decl.paged?.total === true };
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;
675
712
  }
676
713
  /** The clauses a derived write shares: narrowed to the row it writes, announcing it, by that row's id. */
677
714
  function writeTarget(decl, entities) {
@@ -714,29 +751,64 @@ function updatePlan(decl, entities) {
714
751
  if (optional.length === 0)
715
752
  return 'its input has no field to write';
716
753
  const columnShape = base.def.fields.shape;
754
+ const stamps = stampsOf(name, base.def);
717
755
  const fields = [];
718
756
  for (const field of optional) {
719
- const column = snakeCaseField(field);
720
- const columnSchema = columnShape[column];
721
- if (columnSchema === undefined)
722
- return `body field '${field}' is not a column of '${name}'`;
723
- if (column === base.head.primaryKey)
724
- return `body field '${field}' would rewrite the row's id`;
725
- const fieldSchema = decl.input?.shape[field];
726
- const kind = baseDefOf(columnSchema)?.type;
727
- if (kind === 'enum') {
728
- return `column '${column}' is an enum — a change of state is a lifecycle edge with its own operation`;
729
- }
730
- if (!kind || !DERIVED_WRITE_KINDS.has(kind))
731
- return `column '${column}' is not a string or number column`;
732
- if (baseDefOf(fieldSchema)?.type !== kind)
733
- return `body field '${field}' is not the same type as column '${column}'`;
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;
734
761
  if (acceptsNull(fieldSchema) && !acceptsNull(columnSchema)) {
735
762
  return `body field '${field}' accepts null, and column '${column}' cannot be cleared`;
736
763
  }
737
764
  fields.push({ field, column });
738
765
  }
739
- return { kind: 'update', ...base.head, permission: target.permission, idFrom, fields, emit: emitOf(decl) };
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 }));
740
812
  }
741
813
  function deletePlan(decl, entities) {
742
814
  if (decl.http?.method !== 'DELETE')
@@ -759,11 +831,200 @@ function deletePlan(decl, entities) {
759
831
  }
760
832
  return { kind: 'delete', ...base.head, permission: target.permission, idFrom, emit: emitOf(decl) };
761
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
+ }
762
1017
  const PLANNERS = {
763
1018
  get: getPlan,
764
1019
  list: listPlan,
765
1020
  update: updatePlan,
766
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,
767
1028
  };
768
1029
  /** The plan for one shape, or the clause the declaration fails — the shared clauses first. */
769
1030
  function planFor(kind, decl, entities) {
@@ -772,9 +1033,10 @@ function planFor(kind, decl, entities) {
772
1033
  /**
773
1034
  * The shape the platform could derive this operation's handler as, read off its declaration and
774
1035
  * the module's entities alone — `undefined` when it matches none of `DerivedKind`. Pure: this
775
- * classifies, and `defineOperations` is what refuses. At most one shape matches: a `get` is
776
- * served as GET and not paged, a `list` is paged, an `update` is a PATCH that emits, a `delete`
777
- * is a DELETE answering `{ id, deleted }`.
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`).
778
1040
  */
779
1041
  export function derivationOf(decl, entities) {
780
1042
  for (const kind of DERIVED_KINDS) {
@@ -784,6 +1046,13 @@ export function derivationOf(decl, entities) {
784
1046
  }
785
1047
  return undefined;
786
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
+ }
787
1056
  /**
788
1057
  * A handler the platform could write is either written by it or excused, by name (#1773).
789
1058
  *
@@ -829,10 +1098,13 @@ function assertHandlersDeclared(operations, entities) {
829
1098
  }
830
1099
  if (authored !== undefined)
831
1100
  continue;
832
- throw new Error(`model: '${name}' is derivable from the model as \`${plan.kind}\` over '${plan.entity}', and declares neither ` +
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 ` +
833
1105
  '`derive` nor `authored`.\n' +
834
1106
  ` A hand-written ${plan.kind} restates its declaration, and each restatement drifts (#1773).\n` +
835
- ` Remedy: declare \`derive: '${plan.kind}'\` and delete its handler, or declare ` +
1107
+ ` Remedy: declare ${derives} and delete its handler, or declare ` +
836
1108
  `\`authored: '<why the derived ${plan.kind} is wrong here>'\`.`);
837
1109
  }
838
1110
  }
@@ -1504,4 +1776,21 @@ export function defineEngineRoutes(operations) {
1504
1776
  return out;
1505
1777
  };
1506
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
+ }
1507
1796
  //# sourceMappingURL=operations.js.map