@graview/core 0.1.0 → 0.1.3
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/README.md +7 -0
- package/dist/app.d.ts +17 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js.map +1 -1
- package/dist/arrange.d.ts +3 -2
- package/dist/arrange.d.ts.map +1 -1
- package/dist/arrange.js +19 -2
- package/dist/arrange.js.map +1 -1
- package/dist/beginning.d.ts +5 -2
- package/dist/beginning.d.ts.map +1 -1
- package/dist/beginning.js +28 -0
- package/dist/beginning.js.map +1 -1
- package/dist/capabilities.d.ts +32 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +31 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli/check/index.d.ts +1 -0
- package/dist/cli/check/index.d.ts.map +1 -1
- package/dist/cli/check/index.js +1 -0
- package/dist/cli/check/index.js.map +1 -1
- package/dist/cli/check/lenses.d.ts.map +1 -1
- package/dist/cli/check/lenses.js +25 -0
- package/dist/cli/check/lenses.js.map +1 -1
- package/dist/cli/check/policy.d.ts.map +1 -1
- package/dist/cli/check/policy.js +35 -0
- package/dist/cli/check/policy.js.map +1 -1
- package/dist/cli/check/relations.d.ts.map +1 -1
- package/dist/cli/check/relations.js +31 -0
- package/dist/cli/check/relations.js.map +1 -1
- package/dist/cli/check/views.d.ts +14 -0
- package/dist/cli/check/views.d.ts.map +1 -0
- package/dist/cli/check/views.js +27 -0
- package/dist/cli/check/views.js.map +1 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +2 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/create.d.ts +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +63 -1
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/index.d.ts +18 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +66 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/conformance/fixtures.d.ts +41 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/fixtures.js +950 -0
- package/dist/conformance/fixtures.js.map +1 -0
- package/dist/conformance/index.d.ts +54 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +81 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/document/canonical.d.ts +4 -0
- package/dist/document/canonical.d.ts.map +1 -0
- package/dist/document/canonical.js +29 -0
- package/dist/document/canonical.js.map +1 -0
- package/dist/document/compile.d.ts +43 -0
- package/dist/document/compile.d.ts.map +1 -0
- package/dist/document/compile.js +807 -0
- package/dist/document/compile.js.map +1 -0
- package/dist/document/diff.d.ts +32 -0
- package/dist/document/diff.d.ts.map +1 -0
- package/dist/document/diff.js +148 -0
- package/dist/document/diff.js.map +1 -0
- package/dist/document/edit.d.ts +63 -0
- package/dist/document/edit.d.ts.map +1 -0
- package/dist/document/edit.js +1432 -0
- package/dist/document/edit.js.map +1 -0
- package/dist/document/expr/analyze.d.ts +14 -0
- package/dist/document/expr/analyze.d.ts.map +1 -0
- package/dist/document/expr/analyze.js +52 -0
- package/dist/document/expr/analyze.js.map +1 -0
- package/dist/document/expr/evaluate.d.ts +36 -0
- package/dist/document/expr/evaluate.d.ts.map +1 -0
- package/dist/document/expr/evaluate.js +344 -0
- package/dist/document/expr/evaluate.js.map +1 -0
- package/dist/document/expr/index.d.ts +8 -0
- package/dist/document/expr/index.d.ts.map +1 -0
- package/dist/document/expr/index.js +5 -0
- package/dist/document/expr/index.js.map +1 -0
- package/dist/document/expr/parse.d.ts +50 -0
- package/dist/document/expr/parse.d.ts.map +1 -0
- package/dist/document/expr/parse.js +240 -0
- package/dist/document/expr/parse.js.map +1 -0
- package/dist/document/expr/print.d.ts +4 -0
- package/dist/document/expr/print.d.ts.map +1 -0
- package/dist/document/expr/print.js +81 -0
- package/dist/document/expr/print.js.map +1 -0
- package/dist/document/findings.d.ts +15 -0
- package/dist/document/findings.d.ts.map +1 -0
- package/dist/document/findings.js +30 -0
- package/dist/document/findings.js.map +1 -0
- package/dist/document/graview-template.d.ts +148 -0
- package/dist/document/graview-template.d.ts.map +1 -0
- package/dist/document/graview-template.js +450 -0
- package/dist/document/graview-template.js.map +1 -0
- package/dist/document/index.d.ts +27 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +16 -0
- package/dist/document/index.js.map +1 -0
- package/dist/document/migrate.d.ts +41 -0
- package/dist/document/migrate.d.ts.map +1 -0
- package/dist/document/migrate.js +252 -0
- package/dist/document/migrate.js.map +1 -0
- package/dist/document/patch.d.ts +16 -0
- package/dist/document/patch.d.ts.map +1 -0
- package/dist/document/patch.js +79 -0
- package/dist/document/patch.js.map +1 -0
- package/dist/document/rules.d.ts +40 -0
- package/dist/document/rules.d.ts.map +1 -0
- package/dist/document/rules.js +88 -0
- package/dist/document/rules.js.map +1 -0
- package/dist/document/schema.d.ts +461 -0
- package/dist/document/schema.d.ts.map +1 -0
- package/dist/document/schema.js +199 -0
- package/dist/document/schema.js.map +1 -0
- package/dist/document/template.d.ts +28 -0
- package/dist/document/template.d.ts.map +1 -0
- package/dist/document/template.js +117 -0
- package/dist/document/template.js.map +1 -0
- package/dist/document/to-document.d.ts +34 -0
- package/dist/document/to-document.d.ts.map +1 -0
- package/dist/document/to-document.js +186 -0
- package/dist/document/to-document.js.map +1 -0
- package/dist/document/upgrade.d.ts +9 -0
- package/dist/document/upgrade.d.ts.map +1 -0
- package/dist/document/upgrade.js +19 -0
- package/dist/document/upgrade.js.map +1 -0
- package/dist/document/views.d.ts +65 -0
- package/dist/document/views.d.ts.map +1 -0
- package/dist/document/views.js +264 -0
- package/dist/document/views.js.map +1 -0
- package/dist/formats.d.ts +50 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +75 -0
- package/dist/formats.js.map +1 -0
- package/dist/graph/graph.d.ts +61 -3
- package/dist/graph/graph.d.ts.map +1 -1
- package/dist/graph/graph.js +187 -20
- package/dist/graph/graph.js.map +1 -1
- package/dist/index.d.ts +43 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -11
- package/dist/index.js.map +1 -1
- package/dist/integrity.d.ts +46 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +179 -0
- package/dist/integrity.js.map +1 -0
- package/dist/invariants/engine.d.ts +8 -0
- package/dist/invariants/engine.d.ts.map +1 -1
- package/dist/invariants/engine.js +40 -2
- package/dist/invariants/engine.js.map +1 -1
- package/dist/invariants/types.d.ts +19 -0
- package/dist/invariants/types.d.ts.map +1 -1
- package/dist/labels.d.ts +65 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +123 -0
- package/dist/labels.js.map +1 -0
- package/dist/mutations/define-mutation.d.ts.map +1 -1
- package/dist/mutations/define-mutation.js +5 -6
- package/dist/mutations/define-mutation.js.map +1 -1
- package/dist/mutations/derive-edits.d.ts.map +1 -1
- package/dist/mutations/derive-edits.js +20 -1
- package/dist/mutations/derive-edits.js.map +1 -1
- package/dist/mutations/form.d.ts +9 -0
- package/dist/mutations/form.d.ts.map +1 -1
- package/dist/mutations/form.js +20 -0
- package/dist/mutations/form.js.map +1 -1
- package/dist/mutations/node-ref.d.ts +6 -0
- package/dist/mutations/node-ref.d.ts.map +1 -1
- package/dist/mutations/node-ref.js +22 -8
- package/dist/mutations/node-ref.js.map +1 -1
- package/dist/mutations/types.d.ts +8 -0
- package/dist/mutations/types.d.ts.map +1 -1
- package/dist/mutations/words.d.ts +49 -0
- package/dist/mutations/words.d.ts.map +1 -0
- package/dist/mutations/words.js +61 -0
- package/dist/mutations/words.js.map +1 -0
- package/dist/ops/log.d.ts +156 -4
- package/dist/ops/log.d.ts.map +1 -1
- package/dist/ops/log.js +234 -51
- package/dist/ops/log.js.map +1 -1
- package/dist/ops/revisions.d.ts +66 -0
- package/dist/ops/revisions.d.ts.map +1 -0
- package/dist/ops/revisions.js +97 -0
- package/dist/ops/revisions.js.map +1 -0
- package/dist/ops/types.d.ts +49 -1
- package/dist/ops/types.d.ts.map +1 -1
- package/dist/ops/undo.d.ts +29 -2
- package/dist/ops/undo.d.ts.map +1 -1
- package/dist/ops/undo.js +110 -1
- package/dist/ops/undo.js.map +1 -1
- package/dist/ops/withheld.d.ts +37 -0
- package/dist/ops/withheld.d.ts.map +1 -0
- package/dist/ops/withheld.js +84 -0
- package/dist/ops/withheld.js.map +1 -0
- package/dist/permissions/policy.d.ts +15 -0
- package/dist/permissions/policy.d.ts.map +1 -1
- package/dist/permissions/policy.js +28 -1
- package/dist/permissions/policy.js.map +1 -1
- package/dist/permissions/sight.d.ts +66 -0
- package/dist/permissions/sight.d.ts.map +1 -0
- package/dist/permissions/sight.js +131 -0
- package/dist/permissions/sight.js.map +1 -0
- package/dist/permissions/types.d.ts +26 -0
- package/dist/permissions/types.d.ts.map +1 -1
- package/dist/permissions/types.js.map +1 -1
- package/dist/persistence/memory.d.ts.map +1 -1
- package/dist/persistence/memory.js +25 -0
- package/dist/persistence/memory.js.map +1 -1
- package/dist/persistence/sql.d.ts +43 -0
- package/dist/persistence/sql.d.ts.map +1 -0
- package/dist/persistence/sql.js +154 -0
- package/dist/persistence/sql.js.map +1 -0
- package/dist/persistence/sqlite.d.ts +17 -3
- package/dist/persistence/sqlite.d.ts.map +1 -1
- package/dist/persistence/sqlite.js +32 -96
- package/dist/persistence/sqlite.js.map +1 -1
- package/dist/persistence/types.d.ts +19 -0
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/presence.d.ts +56 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +62 -2
- package/dist/presence.js.map +1 -1
- package/dist/refusal.d.ts +34 -0
- package/dist/refusal.d.ts.map +1 -0
- package/dist/refusal.js +40 -0
- package/dist/refusal.js.map +1 -0
- package/dist/scaffold/from-template.d.ts +16 -0
- package/dist/scaffold/from-template.d.ts.map +1 -0
- package/dist/scaffold/from-template.js +157 -0
- package/dist/scaffold/from-template.js.map +1 -0
- package/dist/scaffold/index.d.ts +10 -1
- package/dist/scaffold/index.d.ts.map +1 -1
- package/dist/scaffold/index.js +35 -7
- package/dist/scaffold/index.js.map +1 -1
- package/dist/scaffold/names.d.ts +4 -0
- package/dist/scaffold/names.d.ts.map +1 -1
- package/dist/scaffold/names.js.map +1 -1
- package/dist/scaffold/project.d.ts +1 -1
- package/dist/scaffold/project.d.ts.map +1 -1
- package/dist/scaffold/project.js +44 -13
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/ui.d.ts.map +1 -1
- package/dist/scaffold/ui.js +19 -10
- package/dist/scaffold/ui.js.map +1 -1
- package/dist/schema/define-node.d.ts +20 -0
- package/dist/schema/define-node.d.ts.map +1 -1
- package/dist/schema/define-node.js +27 -2
- package/dist/schema/define-node.js.map +1 -1
- package/dist/schema/path.d.ts +19 -0
- package/dist/schema/path.d.ts.map +1 -0
- package/dist/schema/path.js +34 -0
- package/dist/schema/path.js.map +1 -0
- package/dist/schema/types.d.ts +17 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/seen.d.ts +64 -0
- package/dist/seen.d.ts.map +1 -0
- package/dist/seen.js +200 -0
- package/dist/seen.js.map +1 -0
- package/dist/store.d.ts +378 -8
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +801 -41
- package/dist/store.js.map +1 -1
- package/dist/testing.d.ts +6 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +3 -1
- package/dist/testing.js.map +1 -1
- package/dist/theme/palettes.d.ts.map +1 -1
- package/dist/theme/palettes.js +4 -0
- package/dist/theme/palettes.js.map +1 -1
- package/dist/theme/types.d.ts +9 -0
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/types.js +4 -0
- package/dist/theme/types.js.map +1 -1
- package/dist/validate-graph.d.ts +84 -0
- package/dist/validate-graph.d.ts.map +1 -0
- package/dist/validate-graph.js +295 -0
- package/dist/validate-graph.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/views/types.d.ts +8 -0
- package/dist/views/types.d.ts.map +1 -1
- package/dist/views/types.js +5 -0
- package/dist/views/types.js.map +1 -1
- package/dist/watched.d.ts +39 -0
- package/dist/watched.d.ts.map +1 -1
- package/dist/watched.js +94 -1
- package/dist/watched.js.map +1 -1
- package/dist/who.d.ts +24 -1
- package/dist/who.d.ts.map +1 -1
- package/dist/who.js +49 -2
- package/dist/who.js.map +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,1432 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ExprSyntaxError, parseExpr } from "./expr/parse.js";
|
|
3
|
+
import { printExpr } from "./expr/print.js";
|
|
4
|
+
import { error } from "./findings.js";
|
|
5
|
+
import { coerce } from "./migrate.js";
|
|
6
|
+
import { ActSpec, EDGE_NAME, EdgeSpec, FIELD_NAME, FIELD_TYPES, FieldSpec, KindSpec, NAME, RuleSpec, } from "./schema.js";
|
|
7
|
+
import { parseTemplate, TemplateError } from "./template.js";
|
|
8
|
+
import { VIEW_SLOTS } from "./views.js";
|
|
9
|
+
/** The ops, for tool schemas and the guide. */
|
|
10
|
+
export const EDIT_OPS = [
|
|
11
|
+
"add-kind",
|
|
12
|
+
"rename-kind",
|
|
13
|
+
"remove-kind",
|
|
14
|
+
"add-field",
|
|
15
|
+
"rename-field",
|
|
16
|
+
"retype-field",
|
|
17
|
+
"set-options",
|
|
18
|
+
"set-required",
|
|
19
|
+
"set-default",
|
|
20
|
+
"remove-field",
|
|
21
|
+
"add-relation",
|
|
22
|
+
"rename-relation",
|
|
23
|
+
"remove-relation",
|
|
24
|
+
"add-act",
|
|
25
|
+
"remove-act",
|
|
26
|
+
"add-rule",
|
|
27
|
+
"remove-rule",
|
|
28
|
+
"set-brand",
|
|
29
|
+
"set-label",
|
|
30
|
+
"set-describe",
|
|
31
|
+
"set-view",
|
|
32
|
+
];
|
|
33
|
+
/** Words the rule language keeps: a field or relation renamed to one could not be named in a rule. */
|
|
34
|
+
const KEYWORDS = new Set(["and", "or", "not", "in", "where", "true", "false", "null"]);
|
|
35
|
+
const MAX_EDITS = 50;
|
|
36
|
+
// ── shapes ─────────────────────────────────────────────────────────────────
|
|
37
|
+
const kindName = z.string().regex(NAME, 'kind names are lower-case words joined by hyphens, like "guest-list"');
|
|
38
|
+
const fieldName = z.string().regex(FIELD_NAME, 'field names are one word or camelCase, like "dueDate"');
|
|
39
|
+
const edgeName = z.string().regex(EDGE_NAME, 'relation names are one camelCase word, like "tendedBy"');
|
|
40
|
+
const actName = z.string().regex(NAME, 'act names are lower-case words joined by hyphens, like "mark-paid"');
|
|
41
|
+
const SHAPES = {
|
|
42
|
+
"add-kind": z.object({ op: z.literal("add-kind"), kind: kindName, act: z.boolean().optional() }).passthrough(),
|
|
43
|
+
"rename-kind": z.object({ op: z.literal("rename-kind"), kind: kindName, to: kindName, noun: z.string().min(1).max(40).optional(), plural: z.string().min(1).max(40).optional() }).strict(),
|
|
44
|
+
"remove-kind": z.object({ op: z.literal("remove-kind"), kind: kindName }).strict(),
|
|
45
|
+
"add-field": z.object({ op: z.literal("add-field"), kind: kindName, field: fieldName, fill: z.unknown().optional(), spec: z.record(z.string(), z.unknown()).optional() }).passthrough(),
|
|
46
|
+
"rename-field": z.object({ op: z.literal("rename-field"), kind: kindName, field: fieldName, to: fieldName }).strict(),
|
|
47
|
+
"retype-field": z.object({ op: z.literal("retype-field"), kind: kindName, field: fieldName, type: z.enum(FIELD_TYPES), options: z.array(z.string().min(1).max(80)).min(1).max(100).optional(), of: z.enum(["string", "number", "date"]).optional(), format: z.enum(["money", "percent", "duration"]).optional() }).strict(),
|
|
48
|
+
"set-options": z.object({ op: z.literal("set-options"), kind: kindName, field: fieldName, add: z.array(z.string().min(1).max(80)).optional(), remove: z.array(z.string().min(1).max(80)).optional() }).strict(),
|
|
49
|
+
"set-required": z.object({ op: z.literal("set-required"), kind: kindName, field: fieldName, required: z.boolean() }).strict(),
|
|
50
|
+
"set-default": z.object({ op: z.literal("set-default"), kind: kindName, field: fieldName, default: z.unknown() }).strict(),
|
|
51
|
+
"remove-field": z.object({ op: z.literal("remove-field"), kind: kindName, field: fieldName }).strict(),
|
|
52
|
+
"add-relation": z.object({ op: z.literal("add-relation"), kind: kindName, relation: edgeName }).passthrough(),
|
|
53
|
+
"rename-relation": z.object({ op: z.literal("rename-relation"), kind: kindName, relation: edgeName, to: edgeName }).strict(),
|
|
54
|
+
"remove-relation": z.object({ op: z.literal("remove-relation"), kind: kindName, relation: edgeName }).strict(),
|
|
55
|
+
"add-act": z.object({ op: z.literal("add-act"), act: actName, replace: z.boolean().optional() }).passthrough(),
|
|
56
|
+
"remove-act": z.object({ op: z.literal("remove-act"), act: actName }).strict(),
|
|
57
|
+
"add-rule": z.object({ op: z.literal("add-rule"), rule: actName, replace: z.boolean().optional() }).passthrough(),
|
|
58
|
+
"remove-rule": z.object({ op: z.literal("remove-rule"), rule: actName }).strict(),
|
|
59
|
+
"set-brand": z.object({ op: z.literal("set-brand"), accent: z.union([z.string(), z.null()]), name: z.string().min(1).max(60).optional() }).strict(),
|
|
60
|
+
"set-label": z.object({ op: z.literal("set-label"), kind: kindName, field: fieldName.optional(), label: z.union([z.string().min(1).max(300), z.null()]) }).strict(),
|
|
61
|
+
"set-describe": z.object({ op: z.literal("set-describe"), kind: kindName, describe: z.union([z.string().min(1).max(300), z.null()]) }).strict(),
|
|
62
|
+
"set-view": z.object({ op: z.literal("set-view"), kind: kindName, slot: z.enum(VIEW_SLOTS), blocks: z.union([z.array(z.unknown()).min(1), z.null()]) }).strict(),
|
|
63
|
+
};
|
|
64
|
+
const NONE = new Set();
|
|
65
|
+
const clone = (v) => structuredClone(v);
|
|
66
|
+
const asArray = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
|
|
67
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
68
|
+
const refName = (v) => (typeof v === "string" && /^\$[A-Za-z][A-Za-z0-9]*$/.test(v) ? v.slice(1) : undefined);
|
|
69
|
+
const kebab = (camel) => camel.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
70
|
+
const words = (name) => kebab(name).replace(/-/g, " ");
|
|
71
|
+
function hasKind(kinds, kind) {
|
|
72
|
+
return kinds === "*" || kinds.has(kind);
|
|
73
|
+
}
|
|
74
|
+
function kindsIn(doc, kinds) {
|
|
75
|
+
return kinds === "*" ? Object.keys(doc.kinds) : [...kinds].filter((k) => doc.kinds[k]);
|
|
76
|
+
}
|
|
77
|
+
function union(a, b) {
|
|
78
|
+
if (a === "*" || b === "*")
|
|
79
|
+
return "*";
|
|
80
|
+
return new Set([...a, ...b]);
|
|
81
|
+
}
|
|
82
|
+
/** The kinds a relation, followed from `kinds`, arrives at. */
|
|
83
|
+
function targets(doc, kinds, edge) {
|
|
84
|
+
let out = new Set();
|
|
85
|
+
for (const k of kindsIn(doc, kinds)) {
|
|
86
|
+
const e = doc.kinds[k].edges?.[edge];
|
|
87
|
+
if (e)
|
|
88
|
+
out = union(out, e.to === "*" ? "*" : new Set(e.to));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Every kind that declares a relation by this name. */
|
|
93
|
+
function sources(doc, edge) {
|
|
94
|
+
return new Set(Object.keys(doc.kinds).filter((k) => doc.kinds[k].edges?.[edge]));
|
|
95
|
+
}
|
|
96
|
+
/** The kinds of record an expression yields, standing at `ctx` — or none, for a plain value. */
|
|
97
|
+
function kindsOf(doc, e, ctx) {
|
|
98
|
+
switch (e.t) {
|
|
99
|
+
case "ident":
|
|
100
|
+
return targets(doc, ctx, e.name);
|
|
101
|
+
case "member":
|
|
102
|
+
return targets(doc, kindsOf(doc, e.object, ctx), e.name);
|
|
103
|
+
case "where":
|
|
104
|
+
return kindsOf(doc, e.set, ctx);
|
|
105
|
+
case "call": {
|
|
106
|
+
const lit = e.args[0]?.t === "lit" && typeof e.args[0].value === "string" ? e.args[0].value : undefined;
|
|
107
|
+
if (e.fn === "out" && lit)
|
|
108
|
+
return targets(doc, "*", lit);
|
|
109
|
+
if (e.fn === "in" && lit)
|
|
110
|
+
return sources(doc, lit);
|
|
111
|
+
if (e.fn === "all" && lit)
|
|
112
|
+
return new Set([lit]);
|
|
113
|
+
if (e.fn === "if" && e.args.length === 3)
|
|
114
|
+
return union(kindsOf(doc, e.args[1], ctx), kindsOf(doc, e.args[2], ctx));
|
|
115
|
+
return NONE;
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
return NONE;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Every name in an expression, with what it is read from, mapped through `rename`.
|
|
123
|
+
* Returns the same tree when nothing changed. The kinds are worked out from the
|
|
124
|
+
* document as it stands BEFORE the rename.
|
|
125
|
+
*/
|
|
126
|
+
function mapNames(doc, e, ctx, rename) {
|
|
127
|
+
const go = (x, at) => {
|
|
128
|
+
switch (x.t) {
|
|
129
|
+
case "lit":
|
|
130
|
+
return x;
|
|
131
|
+
case "ident": {
|
|
132
|
+
const name = rename({ role: "name", kinds: at, name: x.name });
|
|
133
|
+
return name === x.name ? x : { ...x, name };
|
|
134
|
+
}
|
|
135
|
+
case "list": {
|
|
136
|
+
const items = x.items.map((i) => go(i, at));
|
|
137
|
+
return items.every((i, n) => i === x.items[n]) ? x : { ...x, items };
|
|
138
|
+
}
|
|
139
|
+
case "member": {
|
|
140
|
+
const object = go(x.object, at);
|
|
141
|
+
const name = rename({ role: "name", kinds: kindsOf(doc, x.object, at), name: x.name });
|
|
142
|
+
return object === x.object && name === x.name ? x : { ...x, object, name };
|
|
143
|
+
}
|
|
144
|
+
case "unary": {
|
|
145
|
+
const operand = go(x.operand, at);
|
|
146
|
+
return operand === x.operand ? x : { ...x, operand };
|
|
147
|
+
}
|
|
148
|
+
case "binary": {
|
|
149
|
+
const left = go(x.left, at);
|
|
150
|
+
const right = go(x.right, at);
|
|
151
|
+
return left === x.left && right === x.right ? x : { ...x, left, right };
|
|
152
|
+
}
|
|
153
|
+
case "where": {
|
|
154
|
+
const set = go(x.set, at);
|
|
155
|
+
const filter = go(x.filter, kindsOf(doc, x.set, at));
|
|
156
|
+
return set === x.set && filter === x.filter ? x : { ...x, set, filter };
|
|
157
|
+
}
|
|
158
|
+
case "call": {
|
|
159
|
+
const first = x.args[0];
|
|
160
|
+
const literal = first?.t === "lit" && typeof first.value === "string" ? first.value : undefined;
|
|
161
|
+
let args;
|
|
162
|
+
if ((x.fn === "out" || x.fn === "in" || x.fn === "all") && literal !== undefined && x.args.length === 1) {
|
|
163
|
+
const name = rename({ role: x.fn === "all" ? "kind" : "edge", kinds: NONE, name: literal });
|
|
164
|
+
args = name === literal ? [...x.args] : [{ ...first, value: name }];
|
|
165
|
+
}
|
|
166
|
+
else if ((x.fn === "every" || x.fn === "some") && x.args.length === 2) {
|
|
167
|
+
args = [go(x.args[0], at), go(x.args[1], kindsOf(doc, x.args[0], at))];
|
|
168
|
+
}
|
|
169
|
+
else if ((x.fn === "sum" || x.fn === "min" || x.fn === "max") && x.args.length === 2) {
|
|
170
|
+
const second = x.args[1];
|
|
171
|
+
const members = kindsOf(doc, x.args[0], at);
|
|
172
|
+
let field = second;
|
|
173
|
+
if (second.t === "ident" || (second.t === "lit" && typeof second.value === "string")) {
|
|
174
|
+
const was = second.t === "ident" ? second.name : second.value;
|
|
175
|
+
const name = rename({ role: "field", kinds: members, name: was });
|
|
176
|
+
if (name !== was)
|
|
177
|
+
field = second.t === "ident" ? { ...second, name } : { ...second, value: name };
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
field = go(second, at);
|
|
181
|
+
args = [go(x.args[0], at), field];
|
|
182
|
+
}
|
|
183
|
+
else
|
|
184
|
+
args = x.args.map((a) => go(a, at));
|
|
185
|
+
return args.every((a, n) => a === x.args[n]) ? x : { ...x, args };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
return go(e, ctx);
|
|
190
|
+
}
|
|
191
|
+
function renamer(doc, r) {
|
|
192
|
+
return (site) => {
|
|
193
|
+
if (r.t === "kind")
|
|
194
|
+
return site.role === "kind" && site.name === r.from ? r.to : site.name;
|
|
195
|
+
if (r.t === "edge") {
|
|
196
|
+
if (site.role === "edge")
|
|
197
|
+
return site.name === r.from ? r.to : site.name;
|
|
198
|
+
if (site.role === "name" && site.name === r.from && kindsIn(doc, site.kinds).some((k) => doc.kinds[k].edges?.[r.from]))
|
|
199
|
+
return r.to;
|
|
200
|
+
return site.name;
|
|
201
|
+
}
|
|
202
|
+
if ((site.role === "name" || site.role === "field") && site.name === r.from && hasKind(site.kinds, r.kind) && doc.kinds[r.kind]?.fields?.[r.from])
|
|
203
|
+
return r.to;
|
|
204
|
+
return site.name;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Does this expression read the field / relation / kind at all? */
|
|
208
|
+
function mentions(doc, e, ctx, r) {
|
|
209
|
+
return mapNames(doc, e, ctx, renamer(doc, r)) !== e;
|
|
210
|
+
}
|
|
211
|
+
/** An expression's text with the rename applied, or the same text when it does not mention it. */
|
|
212
|
+
function rewriteExpr(doc, source, ctx, r) {
|
|
213
|
+
let e;
|
|
214
|
+
try {
|
|
215
|
+
e = parseExpr(source);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (err instanceof ExprSyntaxError)
|
|
219
|
+
return source; // the check will say so
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
const next = mapNames(doc, e, ctx, renamer(doc, r));
|
|
223
|
+
return next === e ? source : printExpr(next);
|
|
224
|
+
}
|
|
225
|
+
function parts(source) {
|
|
226
|
+
try {
|
|
227
|
+
return parseTemplate(source);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
if (err instanceof TemplateError || err instanceof ExprSyntaxError)
|
|
231
|
+
return null;
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A template with the rename applied inside its braces; the words around them are kept. */
|
|
236
|
+
function rewriteTemplate(doc, source, ctx, r, prose) {
|
|
237
|
+
const ps = parts(source);
|
|
238
|
+
if (!ps)
|
|
239
|
+
return source;
|
|
240
|
+
let changed = false;
|
|
241
|
+
const out = ps.map((p) => {
|
|
242
|
+
if (p.text !== undefined) {
|
|
243
|
+
const t = prose ? prose(p.text) : p.text;
|
|
244
|
+
if (t !== p.text)
|
|
245
|
+
changed = true;
|
|
246
|
+
return t;
|
|
247
|
+
}
|
|
248
|
+
const next = mapNames(doc, p.expr, ctx, renamer(doc, r));
|
|
249
|
+
if (next === p.expr)
|
|
250
|
+
return `{${p.source}${p.format ? `|${p.format}` : ""}}`;
|
|
251
|
+
changed = true;
|
|
252
|
+
return `{${printExpr(next)}${p.format ? `|${p.format}` : ""}}`;
|
|
253
|
+
});
|
|
254
|
+
return changed ? out.join("") : source;
|
|
255
|
+
}
|
|
256
|
+
function templateMentions(doc, source, ctx, r) {
|
|
257
|
+
if (!source)
|
|
258
|
+
return false;
|
|
259
|
+
const ps = parts(source);
|
|
260
|
+
return !!ps && ps.some((p) => p.expr && mentions(doc, p.expr, ctx, r));
|
|
261
|
+
}
|
|
262
|
+
function exprMentions(doc, source, ctx, r) {
|
|
263
|
+
if (!source)
|
|
264
|
+
return false;
|
|
265
|
+
try {
|
|
266
|
+
return mentions(doc, parseExpr(source), ctx, r);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
if (err instanceof ExprSyntaxError)
|
|
270
|
+
return false;
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Whole-word, case-keeping replacement in prose (titles, descriptions, the words of a sentence). Prose is not code. */
|
|
275
|
+
function proseSwap(from, to) {
|
|
276
|
+
const a = words(from);
|
|
277
|
+
const b = words(to);
|
|
278
|
+
const re = new RegExp(`\\b${a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "gi");
|
|
279
|
+
return (text) => text.replace(re, (m) => (m[0] === m[0].toUpperCase() ? b.charAt(0).toUpperCase() + b.slice(1) : b));
|
|
280
|
+
}
|
|
281
|
+
// ── what an act touches ──────────────────────────────────────────────────────
|
|
282
|
+
const subjectKinds = (act) => (act.on ? new Set(asArray(act.on)) : NONE);
|
|
283
|
+
/** The kinds each `set` in an act writes to: its subject, a record it made, or (for a `$ref` target) any kind. */
|
|
284
|
+
function setTargets(act) {
|
|
285
|
+
const out = [];
|
|
286
|
+
const made = new Map();
|
|
287
|
+
if (act.creates)
|
|
288
|
+
made.set("new", act.creates);
|
|
289
|
+
(act.effects ?? []).forEach((e) => {
|
|
290
|
+
if (e.create && e.as)
|
|
291
|
+
made.set(e.as, e.create);
|
|
292
|
+
});
|
|
293
|
+
if (act.sets)
|
|
294
|
+
out.push({ set: act.sets, kinds: subjectKinds(act), where: "sets" });
|
|
295
|
+
(act.effects ?? []).forEach((e, index) => {
|
|
296
|
+
if (e.create)
|
|
297
|
+
out.push({ set: e.set ?? {}, kinds: new Set([e.create]), where: "create", index });
|
|
298
|
+
else if (e.set) {
|
|
299
|
+
const target = refName(e.target ?? "$subject");
|
|
300
|
+
out.push({ set: e.set, kinds: target === "subject" ? subjectKinds(act) : made.has(target ?? "") ? new Set([made.get(target)]) : "*", where: "effect", index });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
// ── the edits ────────────────────────────────────────────────────────────────
|
|
306
|
+
class Editor {
|
|
307
|
+
doc;
|
|
308
|
+
said = [];
|
|
309
|
+
/** The agent tools this change renames or reshapes: a model that listed them before calls what is gone. */
|
|
310
|
+
toolsMoved = new Set();
|
|
311
|
+
findings = [];
|
|
312
|
+
fills = [];
|
|
313
|
+
/** Where each kind, field and relation of the current draft came from in the base document (undefined: new in this change). */
|
|
314
|
+
kindOrigin = new Map();
|
|
315
|
+
fieldOrigin = new Map();
|
|
316
|
+
edgeOrigin = new Map();
|
|
317
|
+
constructor(doc, base) {
|
|
318
|
+
this.doc = doc;
|
|
319
|
+
for (const [kind, spec] of Object.entries(base.kinds)) {
|
|
320
|
+
this.kindOrigin.set(kind, kind);
|
|
321
|
+
for (const f of Object.keys(spec.fields))
|
|
322
|
+
this.fieldOrigin.set(`${kind}.${f}`, f);
|
|
323
|
+
for (const e of Object.keys(spec.edges ?? {}))
|
|
324
|
+
this.edgeOrigin.set(`${kind}.${e}`, e);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
fail(i, path, message, fix) {
|
|
328
|
+
this.findings.push(error("edit", `edits.${i}${path ? `.${path}` : ""}`, message, fix));
|
|
329
|
+
}
|
|
330
|
+
kind(i, kind) {
|
|
331
|
+
const spec = this.doc.kinds[kind];
|
|
332
|
+
if (!spec)
|
|
333
|
+
this.fail(i, "kind", `"${kind}" is not a kind this app has; it has ${Object.keys(this.doc.kinds).join(", ")}`);
|
|
334
|
+
return spec;
|
|
335
|
+
}
|
|
336
|
+
field(i, kind, field) {
|
|
337
|
+
const spec = this.kind(i, kind);
|
|
338
|
+
if (!spec)
|
|
339
|
+
return undefined;
|
|
340
|
+
const f = spec.fields[field];
|
|
341
|
+
if (!f)
|
|
342
|
+
this.fail(i, "field", `${kind} has no field "${field}"; it has ${Object.keys(spec.fields).join(", ")}`);
|
|
343
|
+
return f;
|
|
344
|
+
}
|
|
345
|
+
freeName(i, kind, name, what) {
|
|
346
|
+
const spec = this.doc.kinds[kind];
|
|
347
|
+
if (KEYWORDS.has(name)) {
|
|
348
|
+
this.fail(i, "to", `"${name}" is a word the rule language keeps, so a rule could not name a ${what} by it`);
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
if (name === "id" || name === "kind") {
|
|
352
|
+
this.fail(i, "to", `a ${what} cannot be called "${name}" — the framework keeps it; try "type" or "category"`);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
if (spec.fields[name] || spec.edges?.[name]) {
|
|
356
|
+
this.fail(i, "to", `${kind} already has a ${spec.fields[name] ? "field" : "relation"} called "${name}"`);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
apply(i, edit) {
|
|
362
|
+
const op = edit["op"];
|
|
363
|
+
if (typeof op !== "string" || !EDIT_OPS.includes(op)) {
|
|
364
|
+
this.fail(i, "op", `${op === undefined ? "an edit names its op" : `"${String(op)}" is not an edit Graview knows`}`, `one of ${EDIT_OPS.join(", ")}`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const parsed = SHAPES[op].safeParse(edit);
|
|
368
|
+
if (!parsed.success) {
|
|
369
|
+
for (const issue of parsed.error.issues) {
|
|
370
|
+
const path = issue.path.map(String).join(".");
|
|
371
|
+
this.fail(i, path, issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of ${op}` : issue.message);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const e = parsed.data;
|
|
376
|
+
switch (op) {
|
|
377
|
+
case "add-kind":
|
|
378
|
+
return this.addKind(i, e);
|
|
379
|
+
case "rename-kind":
|
|
380
|
+
return this.renameKind(i, e);
|
|
381
|
+
case "remove-kind":
|
|
382
|
+
return this.removeKind(i, e.kind);
|
|
383
|
+
case "add-field":
|
|
384
|
+
return this.addField(i, e);
|
|
385
|
+
case "rename-field":
|
|
386
|
+
return this.renameField(i, e.kind, e.field, e.to);
|
|
387
|
+
case "retype-field":
|
|
388
|
+
return this.retypeField(i, e);
|
|
389
|
+
case "set-options":
|
|
390
|
+
return this.setOptions(i, e);
|
|
391
|
+
case "set-required": {
|
|
392
|
+
const f = this.field(i, e.kind, e.field);
|
|
393
|
+
if (!f)
|
|
394
|
+
return;
|
|
395
|
+
if (e.required)
|
|
396
|
+
f.required = true;
|
|
397
|
+
else
|
|
398
|
+
delete f.required;
|
|
399
|
+
this.said.push(`${e.kind}'s ${e.field} is ${e.required ? "now required" : "no longer required"}.`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
case "set-default": {
|
|
403
|
+
const f = this.field(i, e.kind, e.field);
|
|
404
|
+
if (!f)
|
|
405
|
+
return;
|
|
406
|
+
if (e.default === null || e.default === undefined) {
|
|
407
|
+
delete f.default;
|
|
408
|
+
this.said.push(`${e.kind}'s ${e.field} has no default now.`);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
if (f.type === "enum" && !(f.options ?? []).includes(e.default))
|
|
412
|
+
return this.fail(i, "default", `"${String(e.default)}" is not one of ${e.field}'s options (${(f.options ?? []).join(", ")})`);
|
|
413
|
+
f.default = e.default;
|
|
414
|
+
this.said.push(`${e.kind}'s ${e.field} starts as ${JSON.stringify(e.default)} on new records.`);
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
case "remove-field":
|
|
419
|
+
return this.removeField(i, e.kind, e.field);
|
|
420
|
+
case "add-relation":
|
|
421
|
+
return this.addRelation(i, e);
|
|
422
|
+
case "rename-relation":
|
|
423
|
+
return this.renameRelation(i, e.kind, e.relation, e.to);
|
|
424
|
+
case "remove-relation":
|
|
425
|
+
return this.removeRelation(i, e.kind, e.relation);
|
|
426
|
+
case "add-act":
|
|
427
|
+
return this.addAct(i, e);
|
|
428
|
+
case "remove-act":
|
|
429
|
+
return this.removeAct(i, e.act);
|
|
430
|
+
case "add-rule":
|
|
431
|
+
return this.addRule(i, e);
|
|
432
|
+
case "remove-rule": {
|
|
433
|
+
if (!this.doc.rules?.[e.rule])
|
|
434
|
+
return this.fail(i, "rule", `there is no rule "${e.rule}"${this.doc.rules ? `; the rules are ${Object.keys(this.doc.rules).join(", ")}` : ""}`);
|
|
435
|
+
const title = this.doc.rules[e.rule].title ?? e.rule;
|
|
436
|
+
delete this.doc.rules[e.rule];
|
|
437
|
+
this.said.push(`The rule "${title}" is removed.`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
case "set-brand": {
|
|
441
|
+
if (e.accent === null) {
|
|
442
|
+
delete this.doc.brand;
|
|
443
|
+
this.said.push("The app goes back to Graview's colours.");
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(e.accent))
|
|
447
|
+
return this.fail(i, "accent", 'an accent is a colour like "#c2577a"');
|
|
448
|
+
this.doc.brand = { accent: e.accent, ...(e.name ? { name: e.name } : this.doc.brand?.name ? { name: this.doc.brand.name } : {}) };
|
|
449
|
+
this.said.push(`The app's accent colour becomes ${e.accent}.`);
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
case "set-label": {
|
|
454
|
+
const spec = this.kind(i, e.kind);
|
|
455
|
+
if (!spec)
|
|
456
|
+
return;
|
|
457
|
+
if (e.field) {
|
|
458
|
+
const f = this.field(i, e.kind, e.field);
|
|
459
|
+
if (!f)
|
|
460
|
+
return;
|
|
461
|
+
if (e.label === null)
|
|
462
|
+
delete f.label;
|
|
463
|
+
else if (e.label.length > 60)
|
|
464
|
+
return this.fail(i, "label", "a field's label is a few words, at most 60 characters");
|
|
465
|
+
else
|
|
466
|
+
f.label = e.label;
|
|
467
|
+
this.said.push(e.label === null ? `${e.kind}'s ${e.field} is shown by its name.` : `${e.kind}'s ${e.field} is shown as "${e.label}".`);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
if (e.label === null)
|
|
471
|
+
delete spec.label;
|
|
472
|
+
else
|
|
473
|
+
spec.label = e.label;
|
|
474
|
+
this.said.push(e.label === null ? `A ${e.kind} is called by its first word field again.` : `A ${e.kind} is called "${e.label}".`);
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
case "set-describe": {
|
|
479
|
+
const spec = this.kind(i, e.kind);
|
|
480
|
+
if (!spec)
|
|
481
|
+
return;
|
|
482
|
+
if (e.describe === null)
|
|
483
|
+
delete spec.describe;
|
|
484
|
+
else
|
|
485
|
+
spec.describe = e.describe;
|
|
486
|
+
this.said.push(e.describe === null ? `A ${e.kind} has no summary line.` : `A ${e.kind}'s summary line reads "${e.describe}".`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
case "set-view": {
|
|
490
|
+
if (!this.kind(i, e.kind))
|
|
491
|
+
return;
|
|
492
|
+
const views = (this.doc.views ??= {});
|
|
493
|
+
if (e.blocks === null) {
|
|
494
|
+
if (views[e.kind])
|
|
495
|
+
delete views[e.kind][e.slot];
|
|
496
|
+
if (views[e.kind] && Object.keys(views[e.kind]).length === 0)
|
|
497
|
+
delete views[e.kind];
|
|
498
|
+
if (Object.keys(views).length === 0)
|
|
499
|
+
delete this.doc.views;
|
|
500
|
+
this.said.push(`A ${e.kind} ${e.slot} goes back to Graview's own look.`);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
(views[e.kind] ??= {})[e.slot] = e.blocks;
|
|
504
|
+
this.said.push(`A ${e.kind} ${e.slot} gets a look of its own (${e.blocks.length} block${e.blocks.length === 1 ? "" : "s"}).`);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// ── kinds ──
|
|
511
|
+
addKind(i, e) {
|
|
512
|
+
const { op: _op, kind, act, ...rest } = e;
|
|
513
|
+
if (this.doc.kinds[kind])
|
|
514
|
+
return this.fail(i, "kind", `there is already a kind "${kind}"`);
|
|
515
|
+
const parsed = KindSpec.safeParse(rest);
|
|
516
|
+
if (!parsed.success) {
|
|
517
|
+
for (const issue of parsed.error.issues)
|
|
518
|
+
this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a kind` : issue.message);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const spec = { ...parsed.data };
|
|
522
|
+
delete spec.renamedFrom;
|
|
523
|
+
this.doc.kinds[kind] = spec;
|
|
524
|
+
this.kindOrigin.set(kind, undefined);
|
|
525
|
+
this.said.push(`A new kind, ${kind}, with ${Object.keys(spec.fields).join(", ")}.`);
|
|
526
|
+
// A kind nobody can make is a kind nobody can use: give it its "add" act unless asked not to.
|
|
527
|
+
const makes = Object.values(this.doc.acts ?? {}).some((a) => a.creates === kind || (a.effects ?? []).some((x) => x.create === kind));
|
|
528
|
+
if (act !== false && !makes) {
|
|
529
|
+
let name = `add-${kind}`;
|
|
530
|
+
for (let n = 2; this.doc.acts?.[name]; n++)
|
|
531
|
+
name = `add-${kind}-${n}`;
|
|
532
|
+
const noun = spec.noun ?? words(kind);
|
|
533
|
+
(this.doc.acts ??= {})[name] = { title: `Add ${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`, description: `Add a new ${noun}.`, creates: kind };
|
|
534
|
+
this.said.push(`A new act, "${name}", to add one.`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
renameKind(i, e) {
|
|
538
|
+
const spec = this.kind(i, e.kind);
|
|
539
|
+
if (!spec)
|
|
540
|
+
return;
|
|
541
|
+
if (this.doc.kinds[e.to])
|
|
542
|
+
return this.fail(i, "to", `there is already a kind "${e.to}"`);
|
|
543
|
+
const from = e.kind;
|
|
544
|
+
const to = e.to;
|
|
545
|
+
const touched = this.rewriteAll({ t: "kind", from, to });
|
|
546
|
+
// The acts the framework derives for every kind are named for it, so they move with it.
|
|
547
|
+
this.toolsMoved.add(`edit-${from} is now edit-${to}`);
|
|
548
|
+
this.toolsMoved.add(`remove-${from} is now remove-${to}`);
|
|
549
|
+
// The kind itself, in order, continuing its records.
|
|
550
|
+
const kinds = {};
|
|
551
|
+
for (const [k, v] of Object.entries(this.doc.kinds))
|
|
552
|
+
kinds[k === from ? to : k] = v;
|
|
553
|
+
this.doc.kinds = kinds;
|
|
554
|
+
const origin = this.kindOrigin.get(from);
|
|
555
|
+
this.kindOrigin.delete(from);
|
|
556
|
+
this.kindOrigin.set(to, origin);
|
|
557
|
+
if (origin)
|
|
558
|
+
spec.renamedFrom = origin;
|
|
559
|
+
else
|
|
560
|
+
delete spec.renamedFrom;
|
|
561
|
+
for (const map of [this.fieldOrigin, this.edgeOrigin])
|
|
562
|
+
for (const [key, v] of [...map])
|
|
563
|
+
if (key.startsWith(`${from}.`)) {
|
|
564
|
+
map.delete(key);
|
|
565
|
+
map.set(`${to}.${key.slice(from.length + 1)}`, v);
|
|
566
|
+
}
|
|
567
|
+
for (const f of this.fills)
|
|
568
|
+
if (f.kind === from)
|
|
569
|
+
f.kind = to;
|
|
570
|
+
if (e.noun)
|
|
571
|
+
spec.noun = e.noun;
|
|
572
|
+
else if (spec.noun === words(from) || spec.noun === from)
|
|
573
|
+
spec.noun = words(to);
|
|
574
|
+
if (e.plural)
|
|
575
|
+
spec.plural = e.plural;
|
|
576
|
+
else if (spec.plural && (spec.plural === `${words(from)}s` || spec.plural === `${from}s`))
|
|
577
|
+
spec.plural = `${words(to)}s`;
|
|
578
|
+
// Acts named for the kind follow it: add-vendor → add-supplier.
|
|
579
|
+
for (const name of Object.keys(this.doc.acts ?? {})) {
|
|
580
|
+
const act = this.doc.acts[name];
|
|
581
|
+
if (!(asArray(act.on).includes(to) || act.creates === to || (act.effects ?? []).some((x) => x.create === to)))
|
|
582
|
+
continue;
|
|
583
|
+
const next = swapToken(name, from, to);
|
|
584
|
+
if (next !== name && !this.doc.acts[next] && NAME.test(next)) {
|
|
585
|
+
this.renameAct(name, next);
|
|
586
|
+
touched.push(`act ${name} → ${next}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
this.said.push(`The ${from} kind is renamed to ${to}; its records are kept${touched.length ? `, and ${listOf(touched)} follow${touched.length === 1 ? "s" : ""}` : ""}.`);
|
|
590
|
+
}
|
|
591
|
+
removeKind(i, kind) {
|
|
592
|
+
if (!this.kind(i, kind))
|
|
593
|
+
return;
|
|
594
|
+
if (Object.keys(this.doc.kinds).length === 1)
|
|
595
|
+
return this.fail(i, "kind", "an app keeps at least one kind");
|
|
596
|
+
const gone = [];
|
|
597
|
+
delete this.doc.kinds[kind];
|
|
598
|
+
this.kindOrigin.delete(kind);
|
|
599
|
+
// Relations that only led to it go with it.
|
|
600
|
+
for (const [k, spec] of Object.entries(this.doc.kinds)) {
|
|
601
|
+
for (const [edge, e] of Object.entries(spec.edges ?? {})) {
|
|
602
|
+
if (e.to === "*" || !e.to.includes(kind))
|
|
603
|
+
continue;
|
|
604
|
+
const left = e.to.filter((t) => t !== kind);
|
|
605
|
+
if (left.length > 0)
|
|
606
|
+
e.to = left;
|
|
607
|
+
else {
|
|
608
|
+
this.dropRelation(k, edge, gone);
|
|
609
|
+
gone.push(`the "${edge}" relation from ${k}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
for (const [name, act] of Object.entries(this.doc.acts ?? {})) {
|
|
614
|
+
const on = asArray(act.on);
|
|
615
|
+
if (act.creates === kind || (act.effects ?? []).some((x) => x.create === kind) || (on.includes(kind) && on.length === 1)) {
|
|
616
|
+
this.removeActQuietly(name);
|
|
617
|
+
gone.push(`act ${name}`);
|
|
618
|
+
}
|
|
619
|
+
else if (on.includes(kind))
|
|
620
|
+
act.on = on.filter((k) => k !== kind);
|
|
621
|
+
}
|
|
622
|
+
for (const [name, rule] of Object.entries(this.doc.rules ?? {})) {
|
|
623
|
+
if (rule.over === kind || exprMentions(this.doc, rule.when, rule.over === "graph" ? NONE : new Set([rule.over]), { t: "kind", from: kind, to: "\u0000" }) || exprMentions(this.doc, rule.require, rule.over === "graph" ? NONE : new Set([rule.over]), { t: "kind", from: kind, to: "\u0000" })) {
|
|
624
|
+
delete this.doc.rules[name];
|
|
625
|
+
gone.push(`rule ${name}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (this.doc.views?.[kind]) {
|
|
629
|
+
delete this.doc.views[kind];
|
|
630
|
+
if (Object.keys(this.doc.views).length === 0)
|
|
631
|
+
delete this.doc.views;
|
|
632
|
+
}
|
|
633
|
+
for (const g of this.doc.policy?.grants ?? [])
|
|
634
|
+
if (Array.isArray(g.kinds))
|
|
635
|
+
g.kinds = g.kinds.filter((k) => k !== kind);
|
|
636
|
+
if (this.doc.policy?.sees) {
|
|
637
|
+
this.doc.policy.sees = this.doc.policy.sees.map((s) => ({ ...s, kinds: s.kinds.filter((k) => k !== kind) })).filter((s) => s.kinds.length > 0);
|
|
638
|
+
if (this.doc.policy.sees.length === 0)
|
|
639
|
+
delete this.doc.policy.sees;
|
|
640
|
+
}
|
|
641
|
+
for (const lens of this.doc.lenses ?? [])
|
|
642
|
+
if (isObject(lens["bindings"]))
|
|
643
|
+
delete lens["bindings"][kind];
|
|
644
|
+
for (const f of [...this.fills])
|
|
645
|
+
if (f.kind === kind)
|
|
646
|
+
this.fills.splice(this.fills.indexOf(f), 1);
|
|
647
|
+
this.said.push(`The ${kind} kind is removed, and every ${kind} with it${gone.length ? `; so are ${listOf(gone)}` : ""}.`);
|
|
648
|
+
}
|
|
649
|
+
// ── fields ──
|
|
650
|
+
addField(i, e) {
|
|
651
|
+
const spec = this.kind(i, e.kind);
|
|
652
|
+
if (!spec)
|
|
653
|
+
return;
|
|
654
|
+
const { op: _op, kind, field, fill, spec: given, ...inline } = e;
|
|
655
|
+
if (spec.fields[field] || spec.edges?.[field])
|
|
656
|
+
return this.fail(i, "field", `${kind} already has a ${spec.fields[field] ? "field" : "relation"} called "${field}"`);
|
|
657
|
+
if (KEYWORDS.has(field))
|
|
658
|
+
return this.fail(i, "field", `"${field}" is a word the rule language keeps; pick another name`);
|
|
659
|
+
const parsed = FieldSpec.safeParse({ ...(given ?? {}), ...inline });
|
|
660
|
+
if (!parsed.success) {
|
|
661
|
+
for (const issue of parsed.error.issues)
|
|
662
|
+
this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a field` : issue.message);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const f = { ...parsed.data };
|
|
666
|
+
delete f.renamedFrom;
|
|
667
|
+
if (f.type === "enum" && f.default !== undefined && !f.options.includes(f.default))
|
|
668
|
+
return this.fail(i, "default", `"${String(f.default)}" is not one of the options`);
|
|
669
|
+
spec.fields[field] = f;
|
|
670
|
+
this.fieldOrigin.set(`${kind}.${field}`, undefined);
|
|
671
|
+
let filled = "";
|
|
672
|
+
if (fill !== undefined && fill !== null) {
|
|
673
|
+
if (isObject(fill) && typeof fill["from"] === "string") {
|
|
674
|
+
const src = spec.fields[fill["from"]];
|
|
675
|
+
if (!src || fill["from"] === field)
|
|
676
|
+
return this.fail(i, "fill.from", `${kind} has no other field "${fill["from"]}" to copy from`);
|
|
677
|
+
this.fills.push({ kind, field, from: fill["from"] });
|
|
678
|
+
filled = `, copied from ${fill["from"]} on the records already there`;
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
const t = typeof fill === "number" ? "number" : typeof fill === "boolean" ? "boolean" : Array.isArray(fill) ? "list" : "string";
|
|
682
|
+
if (coerce(fill, t, f) === undefined)
|
|
683
|
+
return this.fail(i, "fill", `${JSON.stringify(fill)} is not a ${f.type}${f.type === "enum" ? ` option (${f.options.join(", ")})` : ""}`);
|
|
684
|
+
this.fills.push({ kind, field, value: coerce(fill, t, f) });
|
|
685
|
+
filled = `, set to ${JSON.stringify(fill)} on the records already there`;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
this.said.push(`${kind} gains a field, ${field} (${f.type}${f.required ? ", required" : ""})${filled}.`);
|
|
689
|
+
}
|
|
690
|
+
renameField(i, kind, field, to) {
|
|
691
|
+
const f = this.field(i, kind, field);
|
|
692
|
+
if (!f)
|
|
693
|
+
return;
|
|
694
|
+
if (to === field)
|
|
695
|
+
return;
|
|
696
|
+
if (!this.freeName(i, kind, to, "field"))
|
|
697
|
+
return;
|
|
698
|
+
const r = { t: "field", kind, from: field, to };
|
|
699
|
+
const touched = this.rewriteAll(r);
|
|
700
|
+
// An act's arguments are named for the fields it writes: the derived edit, and any act that writes this one.
|
|
701
|
+
this.toolsMoved.add(`edit-${kind} asks for ${to} where it asked for ${field}`);
|
|
702
|
+
const spec = this.doc.kinds[kind];
|
|
703
|
+
const fields = {};
|
|
704
|
+
for (const [k, v] of Object.entries(spec.fields))
|
|
705
|
+
fields[k === field ? to : k] = v;
|
|
706
|
+
spec.fields = fields;
|
|
707
|
+
const origin = this.fieldOrigin.get(`${kind}.${field}`);
|
|
708
|
+
this.fieldOrigin.delete(`${kind}.${field}`);
|
|
709
|
+
this.fieldOrigin.set(`${kind}.${to}`, origin);
|
|
710
|
+
// A field new in this change has no records to move; one that was there continues under its new name.
|
|
711
|
+
if (origin && this.kindOrigin.get(kind))
|
|
712
|
+
f.renamedFrom = origin;
|
|
713
|
+
else
|
|
714
|
+
delete f.renamedFrom;
|
|
715
|
+
if (spec.lifecycle?.field === field)
|
|
716
|
+
spec.lifecycle.field = to;
|
|
717
|
+
if (spec.glance?.includes(field)) {
|
|
718
|
+
spec.glance = spec.glance.map((name) => (name === field ? to : name));
|
|
719
|
+
touched.push(`${kind}'s glance`);
|
|
720
|
+
}
|
|
721
|
+
for (const x of this.fills) {
|
|
722
|
+
if (x.kind === kind && x.field === field)
|
|
723
|
+
x.field = to;
|
|
724
|
+
if (x.kind === kind && x.from === field)
|
|
725
|
+
x.from = to;
|
|
726
|
+
}
|
|
727
|
+
const prose = proseSwap(field, to);
|
|
728
|
+
if (f.label)
|
|
729
|
+
f.label = prose(f.label);
|
|
730
|
+
if (f.description)
|
|
731
|
+
f.description = prose(f.description);
|
|
732
|
+
this.said.push(`${kind}'s ${field} is renamed to ${to}; its values are kept${touched.length ? `, and ${listOf(touched)} now say${touched.length === 1 ? "s" : ""} ${to}` : ""}.`);
|
|
733
|
+
}
|
|
734
|
+
retypeField(i, e) {
|
|
735
|
+
const f = this.field(i, e.kind, e.field);
|
|
736
|
+
if (!f)
|
|
737
|
+
return;
|
|
738
|
+
if (e.type === "enum" && !e.options && !f.options)
|
|
739
|
+
return this.fail(i, "options", `to make ${e.field} a choice, list its "options"`);
|
|
740
|
+
const was = f.type;
|
|
741
|
+
f.type = e.type;
|
|
742
|
+
if (e.type === "enum")
|
|
743
|
+
f.options = e.options ?? f.options;
|
|
744
|
+
else
|
|
745
|
+
delete f.options;
|
|
746
|
+
if (e.type === "list")
|
|
747
|
+
f.of = e.of ?? f.of ?? "string";
|
|
748
|
+
else
|
|
749
|
+
delete f.of;
|
|
750
|
+
if (e.format)
|
|
751
|
+
f.format = e.format;
|
|
752
|
+
else if (e.type !== "number" && e.type !== "integer")
|
|
753
|
+
delete f.format;
|
|
754
|
+
if (f.default !== undefined && coerce(f.default, was, f) === undefined)
|
|
755
|
+
delete f.default;
|
|
756
|
+
else if (f.default !== undefined)
|
|
757
|
+
f.default = coerce(f.default, was, f);
|
|
758
|
+
this.said.push(`${e.kind}'s ${e.field} changes from ${was} to ${e.type}; values that convert are kept.`);
|
|
759
|
+
}
|
|
760
|
+
setOptions(i, e) {
|
|
761
|
+
const f = this.field(i, e.kind, e.field);
|
|
762
|
+
if (!f)
|
|
763
|
+
return;
|
|
764
|
+
if (f.type !== "enum")
|
|
765
|
+
return this.fail(i, "field", `${e.field} is a ${f.type}, not a choice; use retype-field to make it one`);
|
|
766
|
+
const add = (e.add ?? []).filter((o) => !f.options.includes(o));
|
|
767
|
+
const remove = e.remove ?? [];
|
|
768
|
+
const unknown = remove.filter((o) => !f.options.includes(o));
|
|
769
|
+
if (unknown.length)
|
|
770
|
+
return this.fail(i, "remove", `${e.field} does not offer ${unknown.map((o) => `"${o}"`).join(", ")}; it offers ${f.options.join(", ")}`);
|
|
771
|
+
const options = [...f.options.filter((o) => !remove.includes(o)), ...add];
|
|
772
|
+
if (options.length === 0)
|
|
773
|
+
return this.fail(i, "remove", "a choice keeps at least one option");
|
|
774
|
+
f.options = options;
|
|
775
|
+
const out = [];
|
|
776
|
+
if (add.length)
|
|
777
|
+
out.push(`now also offers ${add.map((o) => `"${o}"`).join(", ")}`);
|
|
778
|
+
if (remove.length)
|
|
779
|
+
out.push(`no longer offers ${remove.map((o) => `"${o}"`).join(", ")}`);
|
|
780
|
+
if (f.default !== undefined && remove.includes(f.default)) {
|
|
781
|
+
delete f.default;
|
|
782
|
+
out.push("has no default now");
|
|
783
|
+
}
|
|
784
|
+
// An act that sets a dropped option cannot stand.
|
|
785
|
+
for (const [name, act] of Object.entries(this.doc.acts ?? {})) {
|
|
786
|
+
for (const t of setTargets(act))
|
|
787
|
+
if (hasKind(t.kinds, e.kind) && typeof t.set[e.field] === "string" && remove.includes(t.set[e.field])) {
|
|
788
|
+
this.removeActQuietly(name);
|
|
789
|
+
out.push(`the act ${name} (it set that option) is removed`);
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
this.said.push(`${e.kind}'s ${e.field} ${listOf(out)}.`);
|
|
794
|
+
}
|
|
795
|
+
removeField(i, kind, field) {
|
|
796
|
+
const spec = this.kind(i, kind);
|
|
797
|
+
if (!spec)
|
|
798
|
+
return;
|
|
799
|
+
if (!spec.fields[field])
|
|
800
|
+
return void this.field(i, kind, field);
|
|
801
|
+
if (Object.keys(spec.fields).length === 1)
|
|
802
|
+
return this.fail(i, "field", `${kind} keeps at least one field; remove the kind instead`);
|
|
803
|
+
const r = { t: "field", kind, from: field, to: "\u0000" };
|
|
804
|
+
const gone = this.dropMentions(r);
|
|
805
|
+
delete spec.fields[field];
|
|
806
|
+
this.fieldOrigin.delete(`${kind}.${field}`);
|
|
807
|
+
if (spec.lifecycle?.field === field) {
|
|
808
|
+
delete spec.lifecycle;
|
|
809
|
+
gone.push(`${kind}'s lifecycle`);
|
|
810
|
+
}
|
|
811
|
+
// A glance says the fields that are left; one that said only this one is unsaid again.
|
|
812
|
+
if (spec.glance?.includes(field)) {
|
|
813
|
+
spec.glance = spec.glance.filter((name) => name !== field);
|
|
814
|
+
if (spec.glance.length === 0) {
|
|
815
|
+
delete spec.glance;
|
|
816
|
+
gone.push(`${kind}'s glance`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (const x of [...this.fills])
|
|
820
|
+
if (x.kind === kind && (x.field === field || x.from === field))
|
|
821
|
+
this.fills.splice(this.fills.indexOf(x), 1);
|
|
822
|
+
this.said.push(`${kind} loses its ${field} field, and every value in it${gone.length ? `; ${listOf(gone)} ${gone.length === 1 ? "goes" : "go"} with it` : ""}.`);
|
|
823
|
+
}
|
|
824
|
+
// ── relations ──
|
|
825
|
+
addRelation(i, e) {
|
|
826
|
+
const spec = this.kind(i, e.kind);
|
|
827
|
+
if (!spec)
|
|
828
|
+
return;
|
|
829
|
+
const { op: _op, kind, relation, ...rest } = e;
|
|
830
|
+
if (spec.fields[relation] || spec.edges?.[relation])
|
|
831
|
+
return this.fail(i, "relation", `${kind} already has a ${spec.fields[relation] ? "field" : "relation"} called "${relation}"`);
|
|
832
|
+
if (KEYWORDS.has(relation))
|
|
833
|
+
return this.fail(i, "relation", `"${relation}" is a word the rule language keeps; pick another name`);
|
|
834
|
+
const parsed = EdgeSpec.safeParse({ ...rest, to: typeof rest.to === "string" && rest.to !== "*" ? [rest.to] : rest.to });
|
|
835
|
+
if (!parsed.success) {
|
|
836
|
+
for (const issue of parsed.error.issues)
|
|
837
|
+
this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a relation` : issue.message);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const edge = { ...parsed.data };
|
|
841
|
+
delete edge.renamedFrom;
|
|
842
|
+
if (edge.to !== "*")
|
|
843
|
+
for (const t of edge.to)
|
|
844
|
+
if (!this.doc.kinds[t])
|
|
845
|
+
return this.fail(i, "to", `"${t}" is not a kind this app has`);
|
|
846
|
+
(spec.edges ??= {})[relation] = edge;
|
|
847
|
+
this.edgeOrigin.set(`${kind}.${relation}`, undefined);
|
|
848
|
+
this.said.push(`${kind} gains a relation, "${relation}", to ${edge.to === "*" ? "any record" : edge.to.join(" or ")}.`);
|
|
849
|
+
}
|
|
850
|
+
renameRelation(i, kind, relation, to) {
|
|
851
|
+
const spec = this.kind(i, kind);
|
|
852
|
+
if (!spec)
|
|
853
|
+
return;
|
|
854
|
+
if (!spec.edges?.[relation])
|
|
855
|
+
return this.fail(i, "relation", `${kind} has no relation "${relation}"${spec.edges ? `; it has ${Object.keys(spec.edges).join(", ")}` : ""}`);
|
|
856
|
+
// A relation is one name across the app (links are stored by it), so every kind that declares it follows.
|
|
857
|
+
const holders = [...sources(this.doc, relation)];
|
|
858
|
+
for (const k of holders)
|
|
859
|
+
if (!this.freeName(i, k, to, "relation"))
|
|
860
|
+
return;
|
|
861
|
+
if (Object.values(this.doc.kinds).some((s) => s.edges?.[to]))
|
|
862
|
+
return this.fail(i, "to", `another kind already has a relation called "${to}"`);
|
|
863
|
+
const touched = this.rewriteAll({ t: "edge", from: relation, to });
|
|
864
|
+
for (const k of holders) {
|
|
865
|
+
const s = this.doc.kinds[k];
|
|
866
|
+
const edges = {};
|
|
867
|
+
for (const [n, v] of Object.entries(s.edges))
|
|
868
|
+
edges[n === relation ? to : n] = v;
|
|
869
|
+
s.edges = edges;
|
|
870
|
+
const origin = this.edgeOrigin.get(`${k}.${relation}`);
|
|
871
|
+
this.edgeOrigin.delete(`${k}.${relation}`);
|
|
872
|
+
this.edgeOrigin.set(`${k}.${to}`, origin);
|
|
873
|
+
if (origin)
|
|
874
|
+
edges[to].renamedFrom = origin;
|
|
875
|
+
else
|
|
876
|
+
delete edges[to].renamedFrom;
|
|
877
|
+
}
|
|
878
|
+
this.said.push(`The "${relation}" relation is renamed to "${to}"; its links are kept${touched.length ? `, and ${listOf(touched)} follow${touched.length === 1 ? "s" : ""}` : ""}.`);
|
|
879
|
+
}
|
|
880
|
+
removeRelation(i, kind, relation) {
|
|
881
|
+
const spec = this.kind(i, kind);
|
|
882
|
+
if (!spec)
|
|
883
|
+
return;
|
|
884
|
+
if (!spec.edges?.[relation])
|
|
885
|
+
return this.fail(i, "relation", `${kind} has no relation "${relation}"`);
|
|
886
|
+
const gone = [];
|
|
887
|
+
this.dropRelation(kind, relation, gone);
|
|
888
|
+
this.said.push(`The "${relation}" relation from ${kind} is removed, with every link of that kind${gone.length ? `; ${listOf(gone)} ${gone.length === 1 ? "goes" : "go"} with it` : ""}.`);
|
|
889
|
+
}
|
|
890
|
+
dropRelation(kind, relation, gone) {
|
|
891
|
+
const others = [...sources(this.doc, relation)].filter((k) => k !== kind);
|
|
892
|
+
// Only when no other kind keeps a relation of the same name do its mentions go too.
|
|
893
|
+
if (others.length === 0)
|
|
894
|
+
gone.push(...this.dropMentions({ t: "edge", from: relation, to: "\u0000" }));
|
|
895
|
+
delete this.doc.kinds[kind].edges[relation];
|
|
896
|
+
if (Object.keys(this.doc.kinds[kind].edges).length === 0)
|
|
897
|
+
delete this.doc.kinds[kind].edges;
|
|
898
|
+
this.edgeOrigin.delete(`${kind}.${relation}`);
|
|
899
|
+
}
|
|
900
|
+
// ── acts and rules ──
|
|
901
|
+
addAct(i, e) {
|
|
902
|
+
const { op: _op, act, replace, ...rest } = e;
|
|
903
|
+
if (this.doc.acts?.[act] && !replace)
|
|
904
|
+
return this.fail(i, "act", `there is already an act "${act}"`, "pass replace: true to change it, or pick another name");
|
|
905
|
+
const parsed = ActSpec.safeParse(rest);
|
|
906
|
+
if (!parsed.success) {
|
|
907
|
+
for (const issue of parsed.error.issues)
|
|
908
|
+
this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of an act` : issue.message);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const existed = Boolean(this.doc.acts?.[act]);
|
|
912
|
+
(this.doc.acts ??= {})[act] = parsed.data;
|
|
913
|
+
this.said.push(existed ? `The act "${parsed.data.title ?? act}" changes.` : `A new act: ${parsed.data.title ?? act}.`);
|
|
914
|
+
}
|
|
915
|
+
removeAct(i, act) {
|
|
916
|
+
if (!this.doc.acts?.[act])
|
|
917
|
+
return this.fail(i, "act", `there is no act "${act}"${this.doc.acts ? `; the acts are ${Object.keys(this.doc.acts).join(", ")}` : ""}`);
|
|
918
|
+
const title = this.doc.acts[act].title ?? act;
|
|
919
|
+
const gone = this.removeActQuietly(act);
|
|
920
|
+
this.said.push(`The act "${title}" is removed${gone.length ? `; so ${gone.length === 1 ? "is" : "are"} ${listOf(gone)}` : ""}.`);
|
|
921
|
+
}
|
|
922
|
+
/** Remove an act and what names it: grants list it no more, repairs no longer offer it. */
|
|
923
|
+
removeActQuietly(act) {
|
|
924
|
+
const gone = [];
|
|
925
|
+
delete this.doc.acts[act];
|
|
926
|
+
if (Object.keys(this.doc.acts).length === 0)
|
|
927
|
+
delete this.doc.acts;
|
|
928
|
+
if (this.doc.policy) {
|
|
929
|
+
this.doc.policy.grants = this.doc.policy.grants
|
|
930
|
+
.map((g) => (Array.isArray(g.mutations) ? { ...g, mutations: g.mutations.filter((m) => m !== act) } : g))
|
|
931
|
+
.filter((g) => g.mutations === "*" || g.mutations.length > 0);
|
|
932
|
+
}
|
|
933
|
+
for (const [name, rule] of Object.entries(this.doc.rules ?? {})) {
|
|
934
|
+
if (!rule.repairs)
|
|
935
|
+
continue;
|
|
936
|
+
const kept = rule.repairs.filter((r) => r.act !== act);
|
|
937
|
+
if (kept.length !== rule.repairs.length)
|
|
938
|
+
gone.push(`the "${rule.repairs.find((r) => r.act === act)?.label ?? act}" repair of rule ${name}`);
|
|
939
|
+
if (kept.length > 0)
|
|
940
|
+
rule.repairs = kept;
|
|
941
|
+
else
|
|
942
|
+
delete rule.repairs;
|
|
943
|
+
}
|
|
944
|
+
return gone;
|
|
945
|
+
}
|
|
946
|
+
addRule(i, e) {
|
|
947
|
+
const { op: _op, rule, replace, ...rest } = e;
|
|
948
|
+
if (this.doc.rules?.[rule] && !replace)
|
|
949
|
+
return this.fail(i, "rule", `there is already a rule "${rule}"`, "pass replace: true to change it, or pick another name");
|
|
950
|
+
const parsed = RuleSpec.safeParse(rest);
|
|
951
|
+
if (!parsed.success) {
|
|
952
|
+
for (const issue of parsed.error.issues)
|
|
953
|
+
this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a rule` : issue.message);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const existed = Boolean(this.doc.rules?.[rule]);
|
|
957
|
+
(this.doc.rules ??= {})[rule] = parsed.data;
|
|
958
|
+
this.said.push(existed ? `The rule "${parsed.data.title ?? rule}" changes.` : `A new rule: ${parsed.data.title ?? rule}.`);
|
|
959
|
+
}
|
|
960
|
+
/** An act gets a new name, and every grant and repair that named it follows. */
|
|
961
|
+
renameAct(from, to) {
|
|
962
|
+
this.toolsMoved.add(`${from} is now ${to}`);
|
|
963
|
+
const acts = {};
|
|
964
|
+
for (const [k, v] of Object.entries(this.doc.acts))
|
|
965
|
+
acts[k === from ? to : k] = v;
|
|
966
|
+
this.doc.acts = acts;
|
|
967
|
+
for (const g of this.doc.policy?.grants ?? [])
|
|
968
|
+
if (Array.isArray(g.mutations))
|
|
969
|
+
g.mutations = g.mutations.map((m) => (m === from ? to : m));
|
|
970
|
+
for (const rule of Object.values(this.doc.rules ?? {}))
|
|
971
|
+
for (const r of rule.repairs ?? [])
|
|
972
|
+
if (r.act === from)
|
|
973
|
+
r.act = to;
|
|
974
|
+
}
|
|
975
|
+
// ── every place a name is said ──
|
|
976
|
+
/**
|
|
977
|
+
* Apply a rename everywhere it is said, except the declaration itself (the caller
|
|
978
|
+
* moves that). Returns the places that changed, as words.
|
|
979
|
+
*/
|
|
980
|
+
rewriteAll(r) {
|
|
981
|
+
const doc = this.doc;
|
|
982
|
+
const touched = [];
|
|
983
|
+
const prose = r.t === "field" ? proseSwap(r.from, r.to) : r.t === "kind" ? proseSwap(r.from, r.to) : undefined;
|
|
984
|
+
const exprAt = (source, ctx) => (source === undefined ? undefined : rewriteExpr(doc, source, ctx, r));
|
|
985
|
+
const tmplAt = (source, ctx, words) => (source === undefined ? undefined : rewriteTemplate(doc, source, ctx, r, words));
|
|
986
|
+
// Kinds: templates, the lifecycle, where relations lead.
|
|
987
|
+
for (const [kind, spec] of Object.entries(doc.kinds)) {
|
|
988
|
+
const here = new Set([kind]);
|
|
989
|
+
for (const key of ["label", "describe"]) {
|
|
990
|
+
const next = tmplAt(spec[key], here);
|
|
991
|
+
if (next !== spec[key]) {
|
|
992
|
+
spec[key] = next;
|
|
993
|
+
touched.push(`${kind}'s ${key}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (r.t === "kind")
|
|
997
|
+
for (const e of Object.values(spec.edges ?? {}))
|
|
998
|
+
if (Array.isArray(e.to) && e.to.includes(r.from))
|
|
999
|
+
e.to = e.to.map((t) => (t === r.from ? r.to : t));
|
|
1000
|
+
}
|
|
1001
|
+
// Acts: what they act on and make, the fields they write, guards, refusals, computed values.
|
|
1002
|
+
const renamedArgs = new Map();
|
|
1003
|
+
for (const [name, act] of Object.entries(doc.acts ?? {})) {
|
|
1004
|
+
const before = JSON.stringify(act);
|
|
1005
|
+
const on = subjectKinds(act);
|
|
1006
|
+
if (r.t === "kind") {
|
|
1007
|
+
if (act.on !== undefined)
|
|
1008
|
+
act.on = Array.isArray(act.on) ? act.on.map((k) => (k === r.from ? r.to : k)) : act.on === r.from ? r.to : act.on;
|
|
1009
|
+
if (act.creates === r.from)
|
|
1010
|
+
act.creates = r.to;
|
|
1011
|
+
for (const e of act.effects ?? [])
|
|
1012
|
+
if (e.create === r.from)
|
|
1013
|
+
e.create = r.to;
|
|
1014
|
+
}
|
|
1015
|
+
if (r.t === "edge") {
|
|
1016
|
+
if (act.connects === r.from)
|
|
1017
|
+
act.connects = r.to;
|
|
1018
|
+
if (act.severs === r.from)
|
|
1019
|
+
act.severs = r.to;
|
|
1020
|
+
for (const e of act.effects ?? []) {
|
|
1021
|
+
if (e.connect === r.from)
|
|
1022
|
+
e.connect = r.to;
|
|
1023
|
+
if (e.sever === r.from)
|
|
1024
|
+
e.sever = r.to;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (r.t === "field") {
|
|
1028
|
+
const args = new Map();
|
|
1029
|
+
for (const t of setTargets(act)) {
|
|
1030
|
+
if (!hasKind(t.kinds, r.kind) || !(r.from in t.set))
|
|
1031
|
+
continue;
|
|
1032
|
+
if (t.kinds === "*" && Object.entries(doc.kinds).some(([k, s]) => k !== r.kind && s.fields[r.from]))
|
|
1033
|
+
continue;
|
|
1034
|
+
const renamed = {};
|
|
1035
|
+
for (const [k, v] of Object.entries(t.set))
|
|
1036
|
+
renamed[k === r.from ? r.to : k] = k === r.from && v === `$${r.from}` ? `$${r.to}` : v;
|
|
1037
|
+
if (t.set[r.from] === `$${r.from}`)
|
|
1038
|
+
args.set(r.from, r.to);
|
|
1039
|
+
if (t.where === "sets")
|
|
1040
|
+
act.sets = renamed;
|
|
1041
|
+
else
|
|
1042
|
+
act.effects[t.index].set = renamed;
|
|
1043
|
+
}
|
|
1044
|
+
if (act.writes && hasKind(on, r.kind) && act.writes.includes(r.from)) {
|
|
1045
|
+
act.writes = act.writes.map((w) => (w === r.from ? r.to : w));
|
|
1046
|
+
args.set(r.from, r.to);
|
|
1047
|
+
}
|
|
1048
|
+
// `creates` asks for every field of the kind by name: its argument follows the field.
|
|
1049
|
+
if (act.creates === r.kind)
|
|
1050
|
+
args.set(r.from, r.to);
|
|
1051
|
+
if (act.args?.[r.from] && args.has(r.from) && !act.args[r.to]) {
|
|
1052
|
+
const next = {};
|
|
1053
|
+
for (const [k, v] of Object.entries(act.args))
|
|
1054
|
+
next[k === r.from ? r.to : k] = v;
|
|
1055
|
+
act.args = next;
|
|
1056
|
+
}
|
|
1057
|
+
if (args.size) {
|
|
1058
|
+
renamedArgs.set(name, args);
|
|
1059
|
+
this.toolsMoved.add(`${name} asks for ${[...args.values()].join(", ")} where it asked for ${[...args.keys()].join(", ")}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const argRename = renamedArgs.get(name);
|
|
1063
|
+
// A guard reads the subject's fields and the act's arguments by the same bare names.
|
|
1064
|
+
const guard = exprAt(act.allowedWhen, on);
|
|
1065
|
+
if (guard !== undefined)
|
|
1066
|
+
act.allowedWhen = argRename?.has(r.from) && r.t === "field" && !hasKind(on, r.kind) ? rewriteBinding(guard, r.from, r.to) : guard;
|
|
1067
|
+
if (act.refusal !== undefined)
|
|
1068
|
+
act.refusal = tmplAt(act.refusal, on);
|
|
1069
|
+
for (const t of setTargets(act))
|
|
1070
|
+
for (const [k, v] of Object.entries(t.set))
|
|
1071
|
+
if (isObject(v) && typeof v["expr"] === "string")
|
|
1072
|
+
t.set[k] = { expr: exprAt(v["expr"], t.kinds === "*" ? on : t.kinds) };
|
|
1073
|
+
if (JSON.stringify(act) !== before) {
|
|
1074
|
+
if (prose) {
|
|
1075
|
+
if (act.title)
|
|
1076
|
+
act.title = prose(act.title);
|
|
1077
|
+
if (act.description)
|
|
1078
|
+
act.description = prose(act.description);
|
|
1079
|
+
if (act.fromTheOtherEnd)
|
|
1080
|
+
act.fromTheOtherEnd = prose(act.fromTheOtherEnd);
|
|
1081
|
+
}
|
|
1082
|
+
touched.push(`act ${name}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// Acts named for a renamed field follow it: set-quote → set-price.
|
|
1086
|
+
if (r.t === "field") {
|
|
1087
|
+
for (const name of [...renamedArgs.keys()]) {
|
|
1088
|
+
const next = swapToken(name, kebab(r.from), kebab(r.to));
|
|
1089
|
+
if (next !== name && !doc.acts[next] && NAME.test(next)) {
|
|
1090
|
+
this.renameAct(name, next);
|
|
1091
|
+
const at = touched.indexOf(`act ${name}`);
|
|
1092
|
+
if (at >= 0)
|
|
1093
|
+
touched[at] = `act ${name} (now ${next})`;
|
|
1094
|
+
renamedArgs.set(next, renamedArgs.get(name));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// Rules.
|
|
1099
|
+
for (const [name, rule] of Object.entries(doc.rules ?? {})) {
|
|
1100
|
+
const before = JSON.stringify(rule);
|
|
1101
|
+
const ctx = rule.over === "graph" ? NONE : new Set([rule.over]);
|
|
1102
|
+
if (rule.when !== undefined)
|
|
1103
|
+
rule.when = exprAt(rule.when, ctx);
|
|
1104
|
+
rule.require = exprAt(rule.require, ctx);
|
|
1105
|
+
const mentioned = JSON.stringify(rule) !== before;
|
|
1106
|
+
if (rule.says !== undefined)
|
|
1107
|
+
rule.says = tmplAt(rule.says, ctx, mentioned ? prose : undefined);
|
|
1108
|
+
if (r.t === "kind" && rule.over === r.from)
|
|
1109
|
+
rule.over = r.to;
|
|
1110
|
+
for (const rep of rule.repairs ?? []) {
|
|
1111
|
+
if (r.t === "kind")
|
|
1112
|
+
rep.act = rep.act === `edit-${r.from}` ? `edit-${r.to}` : rep.act === `remove-${r.from}` ? `remove-${r.to}` : rep.act;
|
|
1113
|
+
const args = renamedArgs.get(rep.act);
|
|
1114
|
+
if (args && rep.args)
|
|
1115
|
+
for (const [a, b] of args)
|
|
1116
|
+
if (a in rep.args) {
|
|
1117
|
+
rep.args[b] = rep.args[a];
|
|
1118
|
+
delete rep.args[a];
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (JSON.stringify(rule) !== before) {
|
|
1122
|
+
if (prose && mentioned) {
|
|
1123
|
+
if (rule.title)
|
|
1124
|
+
rule.title = prose(rule.title);
|
|
1125
|
+
if (rule.description)
|
|
1126
|
+
rule.description = prose(rule.description);
|
|
1127
|
+
}
|
|
1128
|
+
touched.push(`rule ${name}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// Policy: the kinds grants and sights name, and the derived acts of a renamed kind.
|
|
1132
|
+
if (r.t === "kind" && doc.policy) {
|
|
1133
|
+
const before = JSON.stringify(doc.policy);
|
|
1134
|
+
const swap = (k) => (k === r.from ? r.to : k);
|
|
1135
|
+
for (const g of doc.policy.grants) {
|
|
1136
|
+
if (Array.isArray(g.kinds))
|
|
1137
|
+
g.kinds = g.kinds.map(swap);
|
|
1138
|
+
if (Array.isArray(g.mutations))
|
|
1139
|
+
g.mutations = g.mutations.map((m) => (m === `edit-${r.from}` ? `edit-${r.to}` : m === `remove-${r.from}` ? `remove-${r.to}` : m));
|
|
1140
|
+
}
|
|
1141
|
+
for (const s of doc.policy.sees ?? [])
|
|
1142
|
+
s.kinds = s.kinds.map(swap);
|
|
1143
|
+
if (JSON.stringify(doc.policy) !== before)
|
|
1144
|
+
touched.push("the policy");
|
|
1145
|
+
}
|
|
1146
|
+
// Views.
|
|
1147
|
+
if (doc.views) {
|
|
1148
|
+
if (r.t === "kind" && doc.views[r.from]) {
|
|
1149
|
+
const views = {};
|
|
1150
|
+
for (const [k, v] of Object.entries(doc.views))
|
|
1151
|
+
views[k === r.from ? r.to : k] = v;
|
|
1152
|
+
doc.views = views;
|
|
1153
|
+
}
|
|
1154
|
+
for (const [kind, slots] of Object.entries(doc.views)) {
|
|
1155
|
+
const ctx = new Set([r.t === "kind" && kind === r.to ? r.from : kind]);
|
|
1156
|
+
for (const slot of VIEW_SLOTS) {
|
|
1157
|
+
if (!Array.isArray(slots[slot]))
|
|
1158
|
+
continue;
|
|
1159
|
+
const before = JSON.stringify(slots[slot]);
|
|
1160
|
+
slots[slot] = mapBlocks(slots[slot], (b) => rewriteBlock(doc, b, ctx, r, kind));
|
|
1161
|
+
if (JSON.stringify(slots[slot]) !== before)
|
|
1162
|
+
touched.push(`the ${kind} ${slot}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// Lenses bind kinds (keys) to fields (values).
|
|
1167
|
+
for (const lens of doc.lenses ?? []) {
|
|
1168
|
+
const bindings = lens["bindings"];
|
|
1169
|
+
if (!isObject(bindings))
|
|
1170
|
+
continue;
|
|
1171
|
+
const before = JSON.stringify(bindings);
|
|
1172
|
+
if (r.t === "kind" && r.from in bindings) {
|
|
1173
|
+
bindings[r.to] = bindings[r.from];
|
|
1174
|
+
delete bindings[r.from];
|
|
1175
|
+
}
|
|
1176
|
+
if (r.t === "field" && isObject(bindings[r.kind])) {
|
|
1177
|
+
const b = bindings[r.kind];
|
|
1178
|
+
for (const [k, v] of Object.entries(b))
|
|
1179
|
+
if (v === r.from)
|
|
1180
|
+
b[k] = r.to;
|
|
1181
|
+
}
|
|
1182
|
+
if (JSON.stringify(bindings) !== before)
|
|
1183
|
+
touched.push(`the lens ${String(lens["name"] ?? "")}`.trim());
|
|
1184
|
+
}
|
|
1185
|
+
// Pages name kinds.
|
|
1186
|
+
if (r.t === "kind" && doc.pages) {
|
|
1187
|
+
const before = JSON.stringify(doc.pages);
|
|
1188
|
+
doc.pages = deepSwap(doc.pages, r.from, r.to);
|
|
1189
|
+
if (JSON.stringify(doc.pages) !== before)
|
|
1190
|
+
touched.push("the pages");
|
|
1191
|
+
}
|
|
1192
|
+
return touched;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Remove what mentions a field or relation that is going: acts that write or walk
|
|
1196
|
+
* it, rules that judge it, view blocks that show it; templates that show it fall
|
|
1197
|
+
* back to Graview's own. Returns what went, as words.
|
|
1198
|
+
*/
|
|
1199
|
+
dropMentions(r) {
|
|
1200
|
+
const doc = this.doc;
|
|
1201
|
+
const gone = [];
|
|
1202
|
+
for (const [kind, spec] of Object.entries(doc.kinds)) {
|
|
1203
|
+
for (const key of ["label", "describe"]) {
|
|
1204
|
+
if (templateMentions(doc, spec[key], new Set([kind]), r)) {
|
|
1205
|
+
delete spec[key];
|
|
1206
|
+
gone.push(`${kind}'s ${key}`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
for (const [name, act] of Object.entries(doc.acts ?? {})) {
|
|
1211
|
+
const on = subjectKinds(act);
|
|
1212
|
+
let uses = exprMentions(doc, act.allowedWhen, on, r) || templateMentions(doc, act.refusal, on, r);
|
|
1213
|
+
if (r.t === "edge")
|
|
1214
|
+
uses ||= act.connects === r.from || act.severs === r.from || (act.effects ?? []).some((e) => e.connect === r.from || e.sever === r.from);
|
|
1215
|
+
if (r.t === "field") {
|
|
1216
|
+
// An act that sets other things too loses only this field; one that did nothing else goes.
|
|
1217
|
+
for (const t of setTargets(act)) {
|
|
1218
|
+
if (!hasKind(t.kinds, r.kind) || t.kinds === "*")
|
|
1219
|
+
continue;
|
|
1220
|
+
if (r.from in t.set)
|
|
1221
|
+
delete t.set[r.from];
|
|
1222
|
+
for (const v of Object.values(t.set))
|
|
1223
|
+
if (isObject(v) && typeof v["expr"] === "string" && exprMentions(doc, v["expr"], t.kinds, r))
|
|
1224
|
+
uses = true;
|
|
1225
|
+
}
|
|
1226
|
+
if (act.writes && hasKind(on, r.kind) && act.writes.includes(r.from)) {
|
|
1227
|
+
act.writes = act.writes.filter((w) => w !== r.from);
|
|
1228
|
+
if (act.writes.length === 0)
|
|
1229
|
+
delete act.writes;
|
|
1230
|
+
}
|
|
1231
|
+
if (act.args?.[r.from] && act.creates !== r.kind)
|
|
1232
|
+
delete act.args[r.from];
|
|
1233
|
+
if (act.args && Object.keys(act.args).length === 0)
|
|
1234
|
+
delete act.args;
|
|
1235
|
+
if (act.sets && Object.keys(act.sets).length === 0)
|
|
1236
|
+
delete act.sets;
|
|
1237
|
+
const doesSomething = act.creates || act.writes || act.sets || act.connects || act.severs || act.removes || (act.effects ?? []).some((e) => e.create || e.connect || e.sever || e.remove || (e.set && Object.keys(e.set).length > 0));
|
|
1238
|
+
if (!doesSomething)
|
|
1239
|
+
uses = true;
|
|
1240
|
+
else if (act.effects) {
|
|
1241
|
+
act.effects = act.effects.filter((e) => !(e.set && !e.create && Object.keys(e.set).length === 0));
|
|
1242
|
+
if (act.effects.length === 0)
|
|
1243
|
+
delete act.effects;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
if (uses) {
|
|
1247
|
+
gone.push(`act ${name}`, ...this.removeActQuietly(name));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
for (const [name, rule] of Object.entries(doc.rules ?? {})) {
|
|
1251
|
+
const ctx = rule.over === "graph" ? NONE : new Set([rule.over]);
|
|
1252
|
+
if (exprMentions(doc, rule.when, ctx, r) || exprMentions(doc, rule.require, ctx, r) || templateMentions(doc, rule.says, ctx, r)) {
|
|
1253
|
+
delete doc.rules[name];
|
|
1254
|
+
gone.push(`rule ${name}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (doc.rules && Object.keys(doc.rules).length === 0)
|
|
1258
|
+
delete doc.rules;
|
|
1259
|
+
if (doc.views) {
|
|
1260
|
+
for (const [kind, slots] of Object.entries(doc.views)) {
|
|
1261
|
+
for (const slot of VIEW_SLOTS) {
|
|
1262
|
+
if (!Array.isArray(slots[slot]))
|
|
1263
|
+
continue;
|
|
1264
|
+
const before = JSON.stringify(slots[slot]);
|
|
1265
|
+
slots[slot] = mapBlocks(slots[slot], (b) => (blockMentions(doc, b, new Set([kind]), r, kind) ? null : b));
|
|
1266
|
+
if (slots[slot].length === 0)
|
|
1267
|
+
delete slots[slot];
|
|
1268
|
+
if (JSON.stringify(slots[slot]) !== before)
|
|
1269
|
+
gone.push(`part of the ${kind} ${slot}`);
|
|
1270
|
+
}
|
|
1271
|
+
if (Object.keys(slots).length === 0)
|
|
1272
|
+
delete doc.views[kind];
|
|
1273
|
+
}
|
|
1274
|
+
if (Object.keys(doc.views).length === 0)
|
|
1275
|
+
delete doc.views;
|
|
1276
|
+
}
|
|
1277
|
+
for (const lens of doc.lenses ?? []) {
|
|
1278
|
+
const bindings = lens["bindings"];
|
|
1279
|
+
if (r.t === "field" && isObject(bindings) && isObject(bindings[r.kind])) {
|
|
1280
|
+
const b = bindings[r.kind];
|
|
1281
|
+
for (const [k, v] of Object.entries(b))
|
|
1282
|
+
if (v === r.from)
|
|
1283
|
+
delete b[k];
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return gone;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/** A `$name` reference or bare argument name in a guard, renamed (for an argument that followed its field). */
|
|
1290
|
+
function rewriteBinding(source, from, to) {
|
|
1291
|
+
try {
|
|
1292
|
+
const e = parseExpr(source);
|
|
1293
|
+
const next = mapNames({ kinds: {} }, e, NONE, (s) => (s.role === "name" && s.name === from && s.kinds === NONE ? to : s.name));
|
|
1294
|
+
return next === e ? source : printExpr(next);
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
return source;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/** Swap one kebab token run in a name: ("set-quote", "quote", "price") → "set-price". */
|
|
1301
|
+
function swapToken(name, from, to) {
|
|
1302
|
+
const parts = name.split("-");
|
|
1303
|
+
const a = from.split("-");
|
|
1304
|
+
for (let i = 0; i + a.length <= parts.length; i++) {
|
|
1305
|
+
if (a.every((t, j) => parts[i + j] === t))
|
|
1306
|
+
return [...parts.slice(0, i), ...to.split("-"), ...parts.slice(i + a.length)].join("-");
|
|
1307
|
+
}
|
|
1308
|
+
return name;
|
|
1309
|
+
}
|
|
1310
|
+
function deepSwap(v, from, to) {
|
|
1311
|
+
if (v === from)
|
|
1312
|
+
return to;
|
|
1313
|
+
if (Array.isArray(v))
|
|
1314
|
+
return v.map((x) => deepSwap(x, from, to));
|
|
1315
|
+
if (isObject(v))
|
|
1316
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k === from ? to : k, deepSwap(x, from, to)]));
|
|
1317
|
+
return v;
|
|
1318
|
+
}
|
|
1319
|
+
const listOf = (xs) => (xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
1320
|
+
/** Map every block of a view, nested ones included; a block mapped to null is dropped. */
|
|
1321
|
+
function mapBlocks(blocks, f) {
|
|
1322
|
+
const out = [];
|
|
1323
|
+
for (const raw of blocks) {
|
|
1324
|
+
if (!isObject(raw)) {
|
|
1325
|
+
out.push(raw);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
let b = raw;
|
|
1329
|
+
if (Array.isArray(b["group"]))
|
|
1330
|
+
b = { ...b, group: mapBlocks(b["group"], f) };
|
|
1331
|
+
if (Array.isArray(b["show"]))
|
|
1332
|
+
b = { ...b, show: mapBlocks(b["show"], f) };
|
|
1333
|
+
const mapped = f(b);
|
|
1334
|
+
if (mapped)
|
|
1335
|
+
out.push(mapped);
|
|
1336
|
+
}
|
|
1337
|
+
return out;
|
|
1338
|
+
}
|
|
1339
|
+
function rewriteBlock(doc, b, ctx, r, kind) {
|
|
1340
|
+
const next = { ...b };
|
|
1341
|
+
for (const key of ["title", "text", "badge"])
|
|
1342
|
+
if (typeof next[key] === "string")
|
|
1343
|
+
next[key] = rewriteTemplate(doc, next[key], ctx, r);
|
|
1344
|
+
if (isObject(next["tone"]) && typeof next["tone"]["expr"] === "string")
|
|
1345
|
+
next["tone"] = { expr: rewriteExpr(doc, next["tone"]["expr"], ctx, r) };
|
|
1346
|
+
if (typeof next["when"] === "string")
|
|
1347
|
+
next["when"] = rewriteExpr(doc, next["when"], ctx, r);
|
|
1348
|
+
if (isObject(next["progress"])) {
|
|
1349
|
+
const p = { ...next["progress"] };
|
|
1350
|
+
for (const k of ["value", "max"])
|
|
1351
|
+
if (typeof p[k] === "string")
|
|
1352
|
+
p[k] = rewriteExpr(doc, p[k], ctx, r);
|
|
1353
|
+
next["progress"] = p;
|
|
1354
|
+
}
|
|
1355
|
+
if (r.t === "field" && r.kind === kind && next["field"] === r.from)
|
|
1356
|
+
next["field"] = r.to;
|
|
1357
|
+
return next;
|
|
1358
|
+
}
|
|
1359
|
+
function blockMentions(doc, b, ctx, r, kind) {
|
|
1360
|
+
if (r.t === "field" && r.kind === kind && b["field"] === r.from)
|
|
1361
|
+
return true;
|
|
1362
|
+
for (const key of ["title", "text", "badge"])
|
|
1363
|
+
if (typeof b[key] === "string" && templateMentions(doc, b[key], ctx, r))
|
|
1364
|
+
return true;
|
|
1365
|
+
if (isObject(b["tone"]) && exprMentions(doc, b["tone"]["expr"], ctx, r))
|
|
1366
|
+
return true;
|
|
1367
|
+
if (typeof b["when"] === "string" && exprMentions(doc, b["when"], ctx, r))
|
|
1368
|
+
return true;
|
|
1369
|
+
if (isObject(b["progress"]))
|
|
1370
|
+
for (const k of ["value", "max"])
|
|
1371
|
+
if (exprMentions(doc, b["progress"][k], ctx, r))
|
|
1372
|
+
return true;
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Apply structural edits to a document, in order. Returns the new document and what
|
|
1377
|
+
* each edit did in words — or, when an edit cannot apply, the findings (paths
|
|
1378
|
+
* `edits.<i>.…`) and no document. The result is NOT compiled here: the caller
|
|
1379
|
+
* previews it like any other proposed document.
|
|
1380
|
+
*/
|
|
1381
|
+
export function editDocument(document, edits) {
|
|
1382
|
+
if (!Array.isArray(edits) || edits.length === 0)
|
|
1383
|
+
return { ok: false, findings: [error("edit", "edits", "give at least one edit")] };
|
|
1384
|
+
if (edits.length > MAX_EDITS)
|
|
1385
|
+
return { ok: false, findings: [error("edit", "edits", `at most ${MAX_EDITS} edits at once`)] };
|
|
1386
|
+
const editor = new Editor(clone(document), document);
|
|
1387
|
+
edits.forEach((raw, i) => {
|
|
1388
|
+
if (editor.findings.length > 0)
|
|
1389
|
+
return;
|
|
1390
|
+
if (!isObject(raw))
|
|
1391
|
+
return editor.findings.push(error("edit", `edits.${i}`, 'an edit is an object like {"op": "add-field", "kind": "vendor", "field": "deposit", "type": "number"}'));
|
|
1392
|
+
editor.apply(i, raw);
|
|
1393
|
+
});
|
|
1394
|
+
if (editor.findings.length > 0)
|
|
1395
|
+
return { ok: false, findings: editor.findings };
|
|
1396
|
+
/*
|
|
1397
|
+
* THE TOOL INTERFACE MOVED, AND SAYS SO (FR-34). A derived act is named for
|
|
1398
|
+
* its kind and asks for its fields by name, so a rename changes what an
|
|
1399
|
+
* agent calls; a conversation that listed the tools before sends what is
|
|
1400
|
+
* no longer there.
|
|
1401
|
+
*/
|
|
1402
|
+
if (editor.toolsMoved.size > 0) {
|
|
1403
|
+
editor.said.push(`The tools an agent is offered change: ${listOf([...editor.toolsMoved])}. A conversation that listed them before should list them again.`);
|
|
1404
|
+
}
|
|
1405
|
+
return { ok: true, document: editor.doc, said: editor.said, fills: editor.fills };
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* ONE RENAME, WHEREVER IT IS WRITTEN (FR-34). The walk `editDocument` renames
|
|
1409
|
+
* with — which kind each bare name reads from, a quoted word left alone,
|
|
1410
|
+
* another kind's field of the same name untouched — for a surface that holds
|
|
1411
|
+
* its own declaration, like the studio's. An expression comes back printed;
|
|
1412
|
+
* a template keeps the words around its braces.
|
|
1413
|
+
*/
|
|
1414
|
+
export function renameIn(kinds, over, text, change) {
|
|
1415
|
+
const doc = {
|
|
1416
|
+
kinds: Object.fromEntries(Object.entries(kinds).map(([kind, shape]) => [
|
|
1417
|
+
kind,
|
|
1418
|
+
{
|
|
1419
|
+
fields: Object.fromEntries(shape.fields.map((field) => [field, {}])),
|
|
1420
|
+
edges: Object.fromEntries(Object.entries(shape.edges ?? {}).map(([edge, to]) => [edge, { to }])),
|
|
1421
|
+
},
|
|
1422
|
+
])),
|
|
1423
|
+
};
|
|
1424
|
+
const r = change.what === "field" ? { t: "field", kind: change.kind, from: change.from, to: change.to } : change.what === "relation" ? { t: "edge", from: change.from, to: change.to } : { t: "kind", from: change.from, to: change.to };
|
|
1425
|
+
const ctx = over === "graph" ? NONE : new Set([over]);
|
|
1426
|
+
if (text.expression !== undefined)
|
|
1427
|
+
return rewriteExpr(doc, text.expression, ctx, r);
|
|
1428
|
+
if (text.template !== undefined)
|
|
1429
|
+
return rewriteTemplate(doc, text.template, ctx, r);
|
|
1430
|
+
return undefined;
|
|
1431
|
+
}
|
|
1432
|
+
//# sourceMappingURL=edit.js.map
|