@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.
- package/dist/capability.d.ts +107 -0
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +72 -1
- package/dist/capability.js.map +1 -1
- package/dist/deploy.d.ts +30 -16
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +74 -2
- package/dist/deploy.js.map +1 -1
- package/dist/entity-state.d.ts +14 -0
- package/dist/entity-state.d.ts.map +1 -1
- package/dist/entity-state.js +7 -0
- package/dist/entity-state.js.map +1 -1
- package/dist/kernel-events.d.ts.map +1 -1
- package/dist/kernel-events.js +3 -1
- package/dist/kernel-events.js.map +1 -1
- package/dist/model.d.ts +93 -28
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +59 -5
- package/dist/model.js.map +1 -1
- package/dist/operations.d.ts +129 -7
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +355 -66
- package/dist/operations.js.map +1 -1
- package/dist/permission.d.ts +34 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/permission.js +30 -0
- package/dist/permission.js.map +1 -1
- package/dist/registry-diff.d.ts +12 -1
- package/dist/registry-diff.d.ts.map +1 -1
- package/dist/registry-diff.js +10 -4
- package/dist/registry-diff.js.map +1 -1
- package/dist/zod-read.d.ts +20 -0
- package/dist/zod-read.d.ts.map +1 -0
- package/dist/zod-read.js +27 -0
- package/dist/zod-read.js.map +1 -0
- package/package.json +1 -1
package/dist/operations.js
CHANGED
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { LIST_PAGE_MAX } from './pagination.js';
|
|
28
28
|
import { z } from 'zod';
|
|
29
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const column =
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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 {
|
|
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
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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 {
|
|
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.
|
|
776
|
-
* served as GET and not paged, a `list` is paged, an `update` is a PATCH that emits, a
|
|
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
|
-
|
|
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
|
|
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
|