@omgbase/core 0.11.1 → 0.19.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/README.md +1 -1
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +17 -0
- package/dist/src/core/ingest.d.ts +39 -3
- package/dist/src/core/ingest.js +233 -202
- package/dist/src/core/store/schema.d.ts +3 -2
- package/dist/src/core/store/schema.js +28 -3
- package/dist/src/core/store/size.d.ts +76 -0
- package/dist/src/core/store/size.js +223 -0
- package/dist/src/core/store/store.d.ts +9 -0
- package/dist/src/core/store/store.js +11 -0
- package/dist/src/graph/history.js +15 -12
- package/dist/src/index.d.ts +12 -5
- package/dist/src/index.js +18 -7
- package/dist/src/mcp/arguments.d.ts +9 -2
- package/dist/src/mcp/arguments.js +15 -2
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/errors.js +3 -0
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +27 -6
- package/dist/src/mcp/server.d.ts +56 -1
- package/dist/src/mcp/server.js +365 -203
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- package/dist/src/mutate/commit.d.ts +150 -0
- package/dist/src/mutate/commit.js +349 -0
- package/dist/src/mutate/doc-store.d.ts +3 -3
- package/dist/src/mutate/doc-store.js +21 -6
- package/dist/src/mutate/docs.d.ts +42 -87
- package/dist/src/mutate/docs.js +50 -204
- package/dist/src/mutate/effects.d.ts +363 -0
- package/dist/src/mutate/effects.js +1161 -0
- package/dist/src/mutate/materialize.d.ts +49 -0
- package/dist/src/mutate/materialize.js +174 -0
- package/dist/src/mutate/opset.d.ts +27 -2
- package/dist/src/mutate/opset.js +12 -5
- package/dist/src/mutate/plan-update.d.ts +1 -46
- package/dist/src/mutate/plan-update.js +19 -67
- package/dist/src/mutate/plan.d.ts +210 -0
- package/dist/src/mutate/plan.js +512 -0
- package/dist/src/mutate/select.d.ts +89 -0
- package/dist/src/mutate/select.js +879 -0
- package/dist/src/mutate/sugar.d.ts +135 -0
- package/dist/src/mutate/sugar.js +167 -0
- package/dist/src/mutate/tree.d.ts +4 -2
- package/dist/src/oqx-js/planner.js +4 -0
- package/dist/src/oqx-js/run.d.ts +28 -1
- package/dist/src/oqx-js/run.js +85 -22
- package/dist/src/spec-versions.d.ts +6 -6
- package/dist/src/spec-versions.js +5 -5
- package/dist/src/surface-paths.d.ts +17 -26
- package/dist/src/surface-paths.js +27 -22
- package/dist/src/sync/checkpoint.d.ts +5 -2
- package/dist/src/sync/checkpoint.js +10 -6
- package/dist/src/sync/freshness.d.ts +10 -7
- package/dist/src/sync/freshness.js +15 -17
- package/dist/src/sync/observe.d.ts +4 -1
- package/dist/src/sync/observe.js +56 -5
- package/dist/src/sync/recovery.d.ts +4 -0
- package/dist/src/sync/recovery.js +4 -1
- package/dist/src/sync/stat-cache.d.ts +7 -0
- package/dist/src/sync/stat-cache.js +22 -0
- package/dist/src/sync/tombstone.js +23 -19
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +4 -1
- package/dist/src/sync/writer-lock.d.ts +15 -0
- package/dist/src/sync/writer-lock.js +104 -17
- package/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- package/package.json +2 -2
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
import { findDoc } from "../core/read/reader.js";
|
|
2
|
+
import { normalizeVisibleText } from "../core/hash.js";
|
|
3
|
+
import { referencePath } from "../core/paths.js";
|
|
4
|
+
import { isValidId, withIdMinter } from "../core/ids.js";
|
|
5
|
+
import { inboundLinksTo } from "../graph/inbound-links.js";
|
|
6
|
+
import { readDocBase } from "./commit.js";
|
|
7
|
+
import { canonicalDocPath, composeFile, parseNewDoc, planInboundRetarget, splitFrontmatter } from "./docs.js";
|
|
8
|
+
import { linksRepair, nodeSet } from "./macros.js";
|
|
9
|
+
import { planUpdate } from "./plan-update.js";
|
|
10
|
+
import { MutationError, rawHashHex, renderDoc } from "./tree.js";
|
|
11
|
+
export const EFFECT_OPS = ["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"];
|
|
12
|
+
/** `$new.<n>`: an identity an earlier effect of the same request creates (§11.1). */
|
|
13
|
+
export const NEW_SYMBOL_RE = /^\$new\.(0|[1-9][0-9]*)$/;
|
|
14
|
+
export function isNewSymbol(s) {
|
|
15
|
+
return NEW_SYMBOL_RE.test(s);
|
|
16
|
+
}
|
|
17
|
+
// ---- validation (§11.1; a malformed effect is `filter_invalid` with `hint: "arguments"`) ----
|
|
18
|
+
function isRecord(v) {
|
|
19
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
20
|
+
}
|
|
21
|
+
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }`. */
|
|
22
|
+
export function effectInvalid(message) {
|
|
23
|
+
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
|
|
24
|
+
}
|
|
25
|
+
function onlyKeys(e, allowed, here) {
|
|
26
|
+
const extra = Object.keys(e).filter((k) => !allowed.includes(k));
|
|
27
|
+
if (extra.length > 0)
|
|
28
|
+
throw effectInvalid(`${here}: unknown key${extra.length > 1 ? "s" : ""} ${extra.map((k) => `\`${k}\``).join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
function requireString(e, key, here) {
|
|
31
|
+
const v = e[key];
|
|
32
|
+
if (typeof v !== "string")
|
|
33
|
+
throw effectInvalid(`${here}: \`${key}\` must be a string`);
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
function optionalString(e, key, here) {
|
|
37
|
+
const v = e[key];
|
|
38
|
+
if (v === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (typeof v !== "string")
|
|
41
|
+
throw effectInvalid(`${here}: \`${key}\` must be a string`);
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
/** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
|
|
45
|
+
export const EXPRESSION_LEADER = "-> ";
|
|
46
|
+
/** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
|
|
47
|
+
export function looksLikePath(s) {
|
|
48
|
+
return s.includes("/") || s.endsWith(".md");
|
|
49
|
+
}
|
|
50
|
+
/** A node id: `n_` + 12 hex (content-hash derived, `core/store/nodes.ts`; the `resolveRef` rule). */
|
|
51
|
+
const NODE_ID_RE = /^n_[0-9a-f]{12}$/;
|
|
52
|
+
/** A bare id of the three target kinds (§11.8 "Sugar"). */
|
|
53
|
+
export function isTargetId(s) {
|
|
54
|
+
return isValidId(s, "b") || isValidId(s, "d") || NODE_ID_RE.test(s);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A bare string where a target object is expected (3.2, §11.8 "Sugar"): a
|
|
58
|
+
* path-looking string → `{ kind: "document", path }`; a `b_`/`d_`/`n_` id →
|
|
59
|
+
* that kind; `$new.n` → `{ kind: "symbol", id }` (its kind is the symbol
|
|
60
|
+
* table's at resolution); a `#` locator → `filter_invalid` with a `fix`;
|
|
61
|
+
* anything else is no target. A `-> ` leader never reaches here from the
|
|
62
|
+
* planner (`select.ts` desugars it first); as a library input it is refused
|
|
63
|
+
* the same way, naming the planner.
|
|
64
|
+
*/
|
|
65
|
+
export function bareTarget(ref, here) {
|
|
66
|
+
if (ref.startsWith(EXPRESSION_LEADER)) {
|
|
67
|
+
const reason = `${here}: a \`-> \` expression target is bound by the planner (\`plan\` / \`apply\` / \`execute\`), not by the shape check`;
|
|
68
|
+
throw new MutationError("filter_invalid", reason, { reason, hint: "selection" });
|
|
69
|
+
}
|
|
70
|
+
if (ref.includes("#")) {
|
|
71
|
+
const reason = `${here}: ${JSON.stringify(ref)} is a locator, not a target — a bare target is a document path, a \`b_\`/\`d_\`/\`n_\` id or \`$new.n\``;
|
|
72
|
+
throw new MutationError("filter_invalid", reason, { reason, hint: "arguments", fix: "resolve the locator to an id first: `blocks_resolve { locator }`, or a `query` whose hit is the block" });
|
|
73
|
+
}
|
|
74
|
+
if (isNewSymbol(ref))
|
|
75
|
+
return { kind: "symbol", id: ref };
|
|
76
|
+
if (isValidId(ref, "b"))
|
|
77
|
+
return { kind: "block", id: ref };
|
|
78
|
+
if (isValidId(ref, "d"))
|
|
79
|
+
return { kind: "document", id: ref };
|
|
80
|
+
if (NODE_ID_RE.test(ref))
|
|
81
|
+
return { kind: "node", id: ref };
|
|
82
|
+
if (looksLikePath(ref))
|
|
83
|
+
return { kind: "document", path: ref };
|
|
84
|
+
throw effectInvalid(`${here}: a target is { kind: …, … } or a bare document path, a \`b_\`/\`d_\`/\`n_\` id or \`$new.n\` (got ${JSON.stringify(ref)})`);
|
|
85
|
+
}
|
|
86
|
+
export function validateTarget(v, here, kinds) {
|
|
87
|
+
if (typeof v === "string")
|
|
88
|
+
v = bareTarget(v, here);
|
|
89
|
+
if (!isRecord(v) || typeof v.kind !== "string")
|
|
90
|
+
throw effectInvalid(`${here}: a target is { kind: ${kinds.map((k) => `"${k}"`).join(" | ")}, … }`);
|
|
91
|
+
// A bare `$new.n` defers its kind check to resolution (`resolveEffect`); the `kinds` are the slot's.
|
|
92
|
+
if (v.kind === "symbol" && typeof v.id === "string" && isNewSymbol(v.id) && Object.keys(v).length === 2)
|
|
93
|
+
return { kind: "symbol", id: v.id };
|
|
94
|
+
if (!kinds.includes(v.kind))
|
|
95
|
+
throw effectInvalid(`${here}: \`kind\` must be one of ${kinds.join(", ")} (got "${v.kind}")`);
|
|
96
|
+
switch (v.kind) {
|
|
97
|
+
case "document": {
|
|
98
|
+
onlyKeys(v, ["kind", "id", "path"], here);
|
|
99
|
+
const id = optionalString(v, "id", here);
|
|
100
|
+
const path = optionalString(v, "path", here);
|
|
101
|
+
if (id === undefined && path === undefined)
|
|
102
|
+
throw effectInvalid(`${here}: a document target needs \`id\` or \`path\``);
|
|
103
|
+
return { kind: "document", ...(id !== undefined ? { id } : {}), ...(path !== undefined ? { path } : {}) };
|
|
104
|
+
}
|
|
105
|
+
case "block":
|
|
106
|
+
onlyKeys(v, ["kind", "id"], here);
|
|
107
|
+
return { kind: "block", id: requireString(v, "id", here) };
|
|
108
|
+
case "node":
|
|
109
|
+
onlyKeys(v, ["kind", "id"], here);
|
|
110
|
+
return { kind: "node", id: requireString(v, "id", here) };
|
|
111
|
+
case "section":
|
|
112
|
+
onlyKeys(v, ["kind", "heading_id"], here);
|
|
113
|
+
return { kind: "section", heading_id: requireString(v, "heading_id", here) };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function validateTargets(v, here) {
|
|
117
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
118
|
+
throw effectInvalid(`${here}: \`targets\` must be a non-empty array of block targets`);
|
|
119
|
+
return v.map((t, i) => validateTarget(t, `${here}.targets[${i}]`, ["block"]));
|
|
120
|
+
}
|
|
121
|
+
function validateAt(v, here) {
|
|
122
|
+
if (v === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (v === "start" || v === "end")
|
|
125
|
+
return v;
|
|
126
|
+
if (isRecord(v) && Object.keys(v).length === 1) {
|
|
127
|
+
if (typeof v.before === "string")
|
|
128
|
+
return { before: v.before };
|
|
129
|
+
if (typeof v.after === "string")
|
|
130
|
+
return { after: v.after };
|
|
131
|
+
}
|
|
132
|
+
throw effectInvalid(`${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> }`);
|
|
133
|
+
}
|
|
134
|
+
function validateExpect(v, here) {
|
|
135
|
+
if (v === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (!isRecord(v))
|
|
138
|
+
throw effectInvalid(`${here}: \`expect\` must be an object`);
|
|
139
|
+
onlyKeys(v, ["content_hash", "parent_children_hash", "revision"], `${here}.expect`);
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const k of ["content_hash", "parent_children_hash", "revision"]) {
|
|
142
|
+
const s = optionalString(v, k, `${here}.expect`);
|
|
143
|
+
if (s !== undefined)
|
|
144
|
+
out[k] = s;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function validateExpectMap(v, here) {
|
|
149
|
+
if (v === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
if (!isRecord(v))
|
|
152
|
+
throw effectInvalid(`${here}: \`expect\` must be an object keyed by block id`);
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [id, x] of Object.entries(v)) {
|
|
155
|
+
if (!isRecord(x))
|
|
156
|
+
throw effectInvalid(`${here}.expect[${JSON.stringify(id)}]: must be an object`);
|
|
157
|
+
onlyKeys(x, ["content_hash", "parent_children_hash"], `${here}.expect[${JSON.stringify(id)}]`);
|
|
158
|
+
const e = {};
|
|
159
|
+
const ch = optionalString(x, "content_hash", `${here}.expect[${JSON.stringify(id)}]`);
|
|
160
|
+
const pch = optionalString(x, "parent_children_hash", `${here}.expect[${JSON.stringify(id)}]`);
|
|
161
|
+
if (ch !== undefined)
|
|
162
|
+
e.content_hash = ch;
|
|
163
|
+
if (pch !== undefined)
|
|
164
|
+
e.parent_children_hash = pch;
|
|
165
|
+
out[id] = e;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
|
|
170
|
+
export function validateEffect(v, i) {
|
|
171
|
+
const here = `effects[${i}]`;
|
|
172
|
+
if (!isRecord(v) || typeof v.op !== "string")
|
|
173
|
+
throw effectInvalid(`${here}: an effect is an object whose \`op\` is one of ${EFFECT_OPS.join("/")}`);
|
|
174
|
+
if (!EFFECT_OPS.includes(v.op))
|
|
175
|
+
throw effectInvalid(`${here}: \`op\` must be one of ${EFFECT_OPS.join("/")} (got "${v.op}")`);
|
|
176
|
+
switch (v.op) {
|
|
177
|
+
case "create": {
|
|
178
|
+
onlyKeys(v, ["op", "path", "markdown", "frontmatter"], here);
|
|
179
|
+
const path = requireString(v, "path", here);
|
|
180
|
+
const markdown = requireString(v, "markdown", here);
|
|
181
|
+
if (v.frontmatter !== undefined && !isRecord(v.frontmatter))
|
|
182
|
+
throw effectInvalid(`${here}: \`frontmatter\` must be an object`);
|
|
183
|
+
return { op: "create", path, markdown, ...(v.frontmatter !== undefined ? { frontmatter: v.frontmatter } : {}) };
|
|
184
|
+
}
|
|
185
|
+
case "insert": {
|
|
186
|
+
onlyKeys(v, ["op", "into", "at", "markdown", "expect"], here);
|
|
187
|
+
const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
|
|
188
|
+
const at = validateAt(v.at, here);
|
|
189
|
+
const markdown = requireString(v, "markdown", here);
|
|
190
|
+
const expect = validateExpect(v.expect, here);
|
|
191
|
+
return { op: "insert", into, ...(at !== undefined ? { at } : {}), markdown, ...(expect !== undefined ? { expect } : {}) };
|
|
192
|
+
}
|
|
193
|
+
case "set": {
|
|
194
|
+
onlyKeys(v, ["op", "target", "field", "value", "expect"], here);
|
|
195
|
+
const target = validateTarget(v.target, `${here}.target`, ["document", "block", "node"]);
|
|
196
|
+
const field = requireString(v, "field", here);
|
|
197
|
+
if (!("value" in v))
|
|
198
|
+
throw effectInvalid(`${here}: \`value\` is required`);
|
|
199
|
+
if (target.kind !== "symbol")
|
|
200
|
+
checkField(target.kind, field, here, "set");
|
|
201
|
+
const expect = validateExpect(v.expect, here);
|
|
202
|
+
return { op: "set", target, field, value: v.value, ...(expect !== undefined ? { expect } : {}) };
|
|
203
|
+
}
|
|
204
|
+
case "unset": {
|
|
205
|
+
onlyKeys(v, ["op", "target", "field", "expect"], here);
|
|
206
|
+
const target = validateTarget(v.target, `${here}.target`, ["document"]);
|
|
207
|
+
const field = requireString(v, "field", here);
|
|
208
|
+
if (target.kind !== "symbol")
|
|
209
|
+
checkField(target.kind, field, here, "unset");
|
|
210
|
+
const expect = validateExpect(v.expect, here);
|
|
211
|
+
return { op: "unset", target, field, ...(expect !== undefined ? { expect } : {}) };
|
|
212
|
+
}
|
|
213
|
+
case "move": {
|
|
214
|
+
if ("targets" in v) {
|
|
215
|
+
onlyKeys(v, ["op", "targets", "into", "at", "expect"], here);
|
|
216
|
+
const targets = validateTargets(v.targets, here);
|
|
217
|
+
const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
|
|
218
|
+
const at = validateAt(v.at, here);
|
|
219
|
+
const expect = validateExpect(v.expect, here);
|
|
220
|
+
return { op: "move", targets, into, ...(at !== undefined ? { at } : {}), ...(expect !== undefined ? { expect } : {}) };
|
|
221
|
+
}
|
|
222
|
+
onlyKeys(v, ["op", "target", "to", "retarget_inbound"], here);
|
|
223
|
+
const target = validateTarget(v.target, `${here}.target`, ["document"]);
|
|
224
|
+
const to = requireString(v, "to", here);
|
|
225
|
+
if (v.retarget_inbound !== undefined && typeof v.retarget_inbound !== "boolean")
|
|
226
|
+
throw effectInvalid(`${here}: \`retarget_inbound\` must be a boolean`);
|
|
227
|
+
return { op: "move", target, to, ...(v.retarget_inbound !== undefined ? { retarget_inbound: v.retarget_inbound } : {}) };
|
|
228
|
+
}
|
|
229
|
+
case "remove": {
|
|
230
|
+
if ("targets" in v) {
|
|
231
|
+
onlyKeys(v, ["op", "targets", "expect"], here);
|
|
232
|
+
const targets = validateTargets(v.targets, here);
|
|
233
|
+
const expect = validateExpectMap(v.expect, here);
|
|
234
|
+
return { op: "remove", targets, ...(expect !== undefined ? { expect } : {}) };
|
|
235
|
+
}
|
|
236
|
+
onlyKeys(v, ["op", "target"], here);
|
|
237
|
+
return { op: "remove", target: validateTarget(v.target, `${here}.target`, ["document"]) };
|
|
238
|
+
}
|
|
239
|
+
case "split": {
|
|
240
|
+
onlyKeys(v, ["op", "target", "at", "expect"], here);
|
|
241
|
+
const target = validateTarget(v.target, `${here}.target`, ["block"]);
|
|
242
|
+
if (!Array.isArray(v.at) || v.at.some((n) => typeof n !== "number" || !Number.isInteger(n) || n < 0))
|
|
243
|
+
throw effectInvalid(`${here}: \`at\` must be an array of byte offsets`);
|
|
244
|
+
const expect = validateExpect(v.expect, here);
|
|
245
|
+
return { op: "split", target, at: v.at, ...(expect !== undefined ? { expect } : {}) };
|
|
246
|
+
}
|
|
247
|
+
case "merge": {
|
|
248
|
+
onlyKeys(v, ["op", "targets", "separator", "expect"], here);
|
|
249
|
+
const targets = validateTargets(v.targets, here);
|
|
250
|
+
const separator = optionalString(v, "separator", here);
|
|
251
|
+
const expect = validateExpectMap(v.expect, here);
|
|
252
|
+
return { op: "merge", targets, ...(separator !== undefined ? { separator } : {}), ...(expect !== undefined ? { expect } : {}) };
|
|
253
|
+
}
|
|
254
|
+
case "retarget": {
|
|
255
|
+
onlyKeys(v, ["op", "repairs", "path_glob"], here);
|
|
256
|
+
if (!Array.isArray(v.repairs))
|
|
257
|
+
throw effectInvalid(`${here}: \`repairs\` must be an array of { from, to }`);
|
|
258
|
+
const repairs = v.repairs.map((r, j) => {
|
|
259
|
+
if (!isRecord(r))
|
|
260
|
+
throw effectInvalid(`${here}.repairs[${j}]: must be { from, to }`);
|
|
261
|
+
onlyKeys(r, ["from", "to"], `${here}.repairs[${j}]`);
|
|
262
|
+
return { from: requireString(r, "from", `${here}.repairs[${j}]`), to: requireString(r, "to", `${here}.repairs[${j}]`) };
|
|
263
|
+
});
|
|
264
|
+
const path_glob = optionalString(v, "path_glob", here);
|
|
265
|
+
return { op: "retarget", repairs, ...(path_glob !== undefined ? { path_glob } : {}) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** The whole list; a non-array or an empty list is a shape error too. */
|
|
270
|
+
export function validateEffects(v) {
|
|
271
|
+
if (!Array.isArray(v))
|
|
272
|
+
throw effectInvalid("`effects` must be an array of effects");
|
|
273
|
+
if (v.length === 0)
|
|
274
|
+
throw effectInvalid("`effects` must name at least one effect");
|
|
275
|
+
return v.map((e, i) => validateEffect(e, i));
|
|
276
|
+
}
|
|
277
|
+
// §11.2: the `field` spellings per target kind.
|
|
278
|
+
export const FRONTMATTER_FIELD = /^frontmatter\.(.+)$/;
|
|
279
|
+
function checkField(kind, field, here, op) {
|
|
280
|
+
if (kind === "document") {
|
|
281
|
+
if (FRONTMATTER_FIELD.test(field) || (op === "set" && field === "content"))
|
|
282
|
+
return;
|
|
283
|
+
throw effectInvalid(`${here}: a document's \`field\` is \`frontmatter.<key>\`${op === "set" ? " or `content`" : ""} (got "${field}")`);
|
|
284
|
+
}
|
|
285
|
+
if (kind === "block") {
|
|
286
|
+
if (field === "markdown" || field === "attrs.checked")
|
|
287
|
+
return;
|
|
288
|
+
throw effectInvalid(`${here}: a block's \`field\` is \`markdown\` or \`attrs.checked\` (got "${field}")`);
|
|
289
|
+
}
|
|
290
|
+
// node: the adapter's editable props (`node_not_editable` names them when unknown)
|
|
291
|
+
if (field === "" || field.includes("."))
|
|
292
|
+
throw effectInvalid(`${here}: a node's \`field\` is one of its editable properties (got "${field}")`);
|
|
293
|
+
}
|
|
294
|
+
export function conflictingEffects(effects, focus) {
|
|
295
|
+
const what = focus.field !== undefined ? `${focus.target.kind} ${focus.target.id ?? focus.target.path} ${focus.field}` : `${focus.target.kind} ${focus.target.id ?? focus.target.path}`;
|
|
296
|
+
return new MutationError("conflicting_effects", `effects ${effects[0]} and ${effects[1]} contradict each other on ${what}`, { effects, focus, retriable: false });
|
|
297
|
+
}
|
|
298
|
+
/** The focus bookkeeping over an ordered effect list (§11.3). */
|
|
299
|
+
export class ConflictTracker {
|
|
300
|
+
canonical;
|
|
301
|
+
scalar = new Map();
|
|
302
|
+
retiredDocs = new Map();
|
|
303
|
+
retiredBlocks = new Map();
|
|
304
|
+
retiredPaths = new Map();
|
|
305
|
+
touchedDocs = new Map();
|
|
306
|
+
diagnostics = [];
|
|
307
|
+
constructor(canonical) {
|
|
308
|
+
this.canonical = canonical;
|
|
309
|
+
}
|
|
310
|
+
/** Check effect `i`; returns `false` when it is an identical duplicate to drop. Throws `conflicting_effects`. */
|
|
311
|
+
check(i, info, describe) {
|
|
312
|
+
for (const d of info.docs) {
|
|
313
|
+
const by = this.retiredDocs.get(d);
|
|
314
|
+
if (by !== undefined)
|
|
315
|
+
throw conflictingEffects([by, i], { kind: "identity", target: { kind: "document", id: d } });
|
|
316
|
+
}
|
|
317
|
+
for (const b of info.blocks) {
|
|
318
|
+
const by = this.retiredBlocks.get(b);
|
|
319
|
+
if (by !== undefined)
|
|
320
|
+
throw conflictingEffects([by, i], { kind: "identity", target: { kind: "block", id: b } });
|
|
321
|
+
}
|
|
322
|
+
for (const p of info.paths) {
|
|
323
|
+
const by = this.retiredPaths.get(p);
|
|
324
|
+
if (by !== undefined)
|
|
325
|
+
throw conflictingEffects([by, i], { kind: "identity", target: { kind: "path", path: p } });
|
|
326
|
+
}
|
|
327
|
+
if (info.wholeDoc !== undefined) {
|
|
328
|
+
const by = this.touchedDocs.get(info.wholeDoc);
|
|
329
|
+
if (by !== undefined)
|
|
330
|
+
throw conflictingEffects([by, i], { kind: "identity", target: { kind: "document", id: info.wholeDoc } });
|
|
331
|
+
}
|
|
332
|
+
if (info.scalar) {
|
|
333
|
+
const prev = this.scalar.get(info.scalar.key);
|
|
334
|
+
if (prev) {
|
|
335
|
+
const identical = prev.op === info.scalar.op && (info.scalar.op === "unset" || this.canonical(prev.value) === this.canonical(info.scalar.value));
|
|
336
|
+
if (!identical)
|
|
337
|
+
throw conflictingEffects([prev.index, i], { kind: "scalar", target: info.scalar.target, field: info.scalar.field });
|
|
338
|
+
this.diagnostics.push(`effect ${i} (${describe}) duplicates effect ${prev.index}; dropped`);
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const s of info.scalars ?? []) {
|
|
343
|
+
const prev = this.scalar.get(s.key);
|
|
344
|
+
if (prev)
|
|
345
|
+
throw conflictingEffects([prev.index, i], { kind: "scalar", target: s.target, field: s.field });
|
|
346
|
+
}
|
|
347
|
+
// Record.
|
|
348
|
+
if (info.scalar)
|
|
349
|
+
this.scalar.set(info.scalar.key, { index: i, op: info.scalar.op, value: info.scalar.value, target: info.scalar.target, field: info.scalar.field });
|
|
350
|
+
for (const s of info.scalars ?? [])
|
|
351
|
+
this.scalar.set(s.key, { index: i, op: "set", value: undefined, target: s.target, field: s.field });
|
|
352
|
+
for (const d of info.docs)
|
|
353
|
+
if (!this.touchedDocs.has(d))
|
|
354
|
+
this.touchedDocs.set(d, i);
|
|
355
|
+
for (const d of info.retiresDocs ?? [])
|
|
356
|
+
this.retiredDocs.set(d, i);
|
|
357
|
+
for (const b of info.retiresBlocks ?? [])
|
|
358
|
+
this.retiredBlocks.set(b, i);
|
|
359
|
+
for (const p of info.retiresPaths ?? [])
|
|
360
|
+
this.retiredPaths.set(p, i);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ---- the lowering context ---------------------------------------------------------
|
|
365
|
+
/** `$new.n` symbols in allocation order (§11.4 `new_ids`). */
|
|
366
|
+
export class SymbolTable {
|
|
367
|
+
entries = [];
|
|
368
|
+
alloc(kind) {
|
|
369
|
+
const symbol = `$new.${this.entries.length}`;
|
|
370
|
+
this.entries.push({ symbol, kind });
|
|
371
|
+
return symbol;
|
|
372
|
+
}
|
|
373
|
+
kindOf(symbol) {
|
|
374
|
+
return this.entries.find((e) => e.symbol === symbol)?.kind;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// ---- resolution --------------------------------------------------------------------
|
|
378
|
+
function docMissing(ref) {
|
|
379
|
+
return new MutationError("doc_missing", `doc ${ref} not found`, { doc: ref });
|
|
380
|
+
}
|
|
381
|
+
/** Resolve a document target: an existing document's `{ id, path }`, or a `$new.n` document this plan creates. */
|
|
382
|
+
export function resolveDocumentTarget(ctx, t) {
|
|
383
|
+
if (t.id !== undefined && isNewSymbol(t.id)) {
|
|
384
|
+
const created = ctx.creates.find((c) => c.symbol === t.id);
|
|
385
|
+
if (!created)
|
|
386
|
+
throw symbolMissing(ctx, t.id, "document");
|
|
387
|
+
if (t.path !== undefined && canonicalDocPath(t.path) !== created.path)
|
|
388
|
+
throw effectInvalid(`id ${JSON.stringify(t.id)} and path ${JSON.stringify(t.path)} name different documents`);
|
|
389
|
+
return { kind: "document", id: t.id, path: created.path };
|
|
390
|
+
}
|
|
391
|
+
return findDocumentTarget(ctx.store, ctx.repoId, t);
|
|
392
|
+
}
|
|
393
|
+
/** An existing document's `{ id, path }` for a literal document target (`doc_missing`; `filter_invalid` when `id` and `path` disagree). */
|
|
394
|
+
export function findDocumentTarget(store, repoId, t) {
|
|
395
|
+
const byId = t.id !== undefined ? findDoc(store, { docId: t.id }) : null;
|
|
396
|
+
if (t.id !== undefined && (!byId || byId.repoId !== repoId))
|
|
397
|
+
throw docMissing(t.id);
|
|
398
|
+
const byPath = t.path !== undefined ? findDoc(store, { repoId, path: t.path }) : null;
|
|
399
|
+
if (t.path !== undefined && !byPath)
|
|
400
|
+
throw docMissing(t.path);
|
|
401
|
+
if (byId && byPath && byId.docId !== byPath.docId)
|
|
402
|
+
throw effectInvalid(`id ${JSON.stringify(t.id)} and path ${JSON.stringify(t.path)} name different documents`);
|
|
403
|
+
const info = (byId ?? byPath);
|
|
404
|
+
return { kind: "document", id: info.docId, path: info.path };
|
|
405
|
+
}
|
|
406
|
+
/** A bare `$new.n` target (3.2): the kind the symbol table recorded, checked against the slot's admitted kinds. */
|
|
407
|
+
function resolveSymbolTarget(ctx, symbol, kinds, here) {
|
|
408
|
+
const kind = ctx.symbols.kindOf(symbol);
|
|
409
|
+
if (kind === undefined)
|
|
410
|
+
throw new MutationError("target_missing", `${symbol} is not created by an earlier effect`, { symbol });
|
|
411
|
+
if (!kinds.includes(kind))
|
|
412
|
+
throw effectInvalid(`${here}: \`kind\` must be one of ${kinds.join(", ")} (got "${kind}")`);
|
|
413
|
+
return { kind, id: symbol };
|
|
414
|
+
}
|
|
415
|
+
function symbolMissing(ctx, symbol, wanted) {
|
|
416
|
+
const kind = ctx.symbols.kindOf(symbol);
|
|
417
|
+
if (kind === undefined)
|
|
418
|
+
return new MutationError("target_missing", `${symbol} is not created by an earlier effect`, { symbol });
|
|
419
|
+
return new MutationError("target_missing", `${symbol} names a ${kind}, not a ${wanted}`, { symbol });
|
|
420
|
+
}
|
|
421
|
+
/** A block id as the kernel must see it: a `$new.n` an op minted becomes the placeholder of that op;
|
|
422
|
+
* a `$new.n` of a created document's tree (3.0) stays the symbol — it IS the tree's id at plan
|
|
423
|
+
* time, and `execute` aliases it to the id its replay mints (§11.7). */
|
|
424
|
+
function kernelBlockId(ctx, id) {
|
|
425
|
+
if (!isNewSymbol(id))
|
|
426
|
+
return id;
|
|
427
|
+
const placeholder = ctx.blockSymbols.get(id);
|
|
428
|
+
if (placeholder !== undefined)
|
|
429
|
+
return placeholder;
|
|
430
|
+
if (locateLoaded(ctx, id))
|
|
431
|
+
return id;
|
|
432
|
+
throw symbolMissing(ctx, id, "block");
|
|
433
|
+
}
|
|
434
|
+
function kernelAt(ctx, at) {
|
|
435
|
+
if (at === "start" || at === "end")
|
|
436
|
+
return at;
|
|
437
|
+
if ("before" in at)
|
|
438
|
+
return { before: kernelBlockId(ctx, at.before) };
|
|
439
|
+
return { after: kernelBlockId(ctx, at.after) };
|
|
440
|
+
}
|
|
441
|
+
/** A block in the planner's trees (a `$new.n` is only there) — never loads a document. */
|
|
442
|
+
function locateLoaded(ctx, id) {
|
|
443
|
+
const f = ctx.planner.locateLoaded(id);
|
|
444
|
+
return f ? { docId: f.docId, block: f.block } : null;
|
|
445
|
+
}
|
|
446
|
+
/** The live row of a block: its document and raw hash; `$new.n` from the tree. */
|
|
447
|
+
function blockInfo(ctx, id) {
|
|
448
|
+
const inTree = locateLoaded(ctx, id);
|
|
449
|
+
if (inTree)
|
|
450
|
+
return { docId: inTree.docId, hash: rawHashHex(inTree.block.raw), block: inTree.block };
|
|
451
|
+
if (isNewSymbol(id))
|
|
452
|
+
throw symbolMissing(ctx, id, "block");
|
|
453
|
+
const row = ctx.store.db
|
|
454
|
+
.prepare("SELECT doc_id, lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL")
|
|
455
|
+
.get(id);
|
|
456
|
+
if (!row)
|
|
457
|
+
throw new MutationError("block_missing", `block ${id} not found`, { block: id });
|
|
458
|
+
return { docId: row.doc_id, hash: row.h, block: null };
|
|
459
|
+
}
|
|
460
|
+
// ---- the summary (§11.9, 3.3) ---------------------------------------------------------
|
|
461
|
+
//
|
|
462
|
+
// Channel 3 of the teaching surfaces: the plan is a worked example of itself.
|
|
463
|
+
// One line in words per resolved effect — `preview.summary`, which the Receipt
|
|
464
|
+
// repeats as its `summary` and the CLI prints under the commit confirmation.
|
|
465
|
+
// Deterministic and byte-identical across engines (the wording table of §11.9):
|
|
466
|
+
// a document by its rooted path, a heading by its text in quotes, any other
|
|
467
|
+
// block by its type and id, counts with their nouns pluralized by an `s`.
|
|
468
|
+
/** `1 block` / `2 blocks` (an `s` on every noun of the table). */
|
|
469
|
+
export function plural(n, noun) {
|
|
470
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
471
|
+
}
|
|
472
|
+
/** The rooted path of a document the plan addresses — a created document's from its `create`. */
|
|
473
|
+
function docPathOf(ctx, docId) {
|
|
474
|
+
if (isNewSymbol(docId)) {
|
|
475
|
+
const created = ctx.creates.find((c) => c.symbol === docId);
|
|
476
|
+
if (created)
|
|
477
|
+
return referencePath(created.path);
|
|
478
|
+
}
|
|
479
|
+
const info = findDoc(ctx.store, { docId });
|
|
480
|
+
return referencePath(info?.path ?? docId);
|
|
481
|
+
}
|
|
482
|
+
/** A block's type and visible text: the live row when it has one, else the planner's tree (a `$new.k`). */
|
|
483
|
+
function blockDescriptor(ctx, id) {
|
|
484
|
+
if (!isNewSymbol(id)) {
|
|
485
|
+
const row = ctx.store.db.prepare("SELECT type, text FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(id);
|
|
486
|
+
if (row)
|
|
487
|
+
return { type: row.type, text: row.text };
|
|
488
|
+
}
|
|
489
|
+
const inTree = locateLoaded(ctx, id);
|
|
490
|
+
if (!inTree)
|
|
491
|
+
return null;
|
|
492
|
+
return { type: inTree.block.type, text: normalizeVisibleText(inTree.block.raw, inTree.block.type) };
|
|
493
|
+
}
|
|
494
|
+
/** The name of a block in a summary line: `heading "Text"` for a heading, else `<type> <id>` (`paragraph b_12`, `task b_9`). */
|
|
495
|
+
function blockName(ctx, id) {
|
|
496
|
+
const d = blockDescriptor(ctx, id);
|
|
497
|
+
if (d === null)
|
|
498
|
+
return `block ${id}`;
|
|
499
|
+
return d.type === "heading" ? `heading ${ctx.canonical(d.text)}` : `${d.type} ${id}`;
|
|
500
|
+
}
|
|
501
|
+
/** A section by its heading's text: `section "Risks"`. */
|
|
502
|
+
function sectionName(ctx, headingId) {
|
|
503
|
+
const d = blockDescriptor(ctx, headingId);
|
|
504
|
+
return `section ${ctx.canonical(d?.text ?? headingId)}`;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The placement half of an `insert` / `move blocks` line: `<verb> the end|start
|
|
508
|
+
* of <container> [in <path>]` — the container a document (its path; no `in`), a
|
|
509
|
+
* section (`section "Risks"`) or a block (its name) — or `before|after <block>
|
|
510
|
+
* in <path>`. `verb` is `at` for an insert, `to` for a move.
|
|
511
|
+
*/
|
|
512
|
+
function placeWords(ctx, into, at, verb) {
|
|
513
|
+
const path = docPathOf(ctx, targetDoc(ctx, into));
|
|
514
|
+
if (at !== "start" && at !== "end") {
|
|
515
|
+
const anchor = "before" in at ? at.before : at.after;
|
|
516
|
+
return `${"before" in at ? "before" : "after"} ${blockName(ctx, anchor)} in ${path}`;
|
|
517
|
+
}
|
|
518
|
+
const edge = `${verb} the ${at}`;
|
|
519
|
+
switch (into.kind) {
|
|
520
|
+
case "document":
|
|
521
|
+
return `${edge} of ${path}`;
|
|
522
|
+
case "section":
|
|
523
|
+
return `${edge} of ${sectionName(ctx, into.heading_id)} in ${path}`;
|
|
524
|
+
case "block":
|
|
525
|
+
return `${edge} of ${blockName(ctx, into.id)} in ${path}`;
|
|
526
|
+
case "node":
|
|
527
|
+
return `${edge} of node ${into.id} in ${path}`;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** The document of a block list's first block (a kernel `remove`/`merge` names blocks of one document). */
|
|
531
|
+
function docOfBlocks(ctx, ids) {
|
|
532
|
+
return docPathOf(ctx, blockInfo(ctx, ids[0]).docId);
|
|
533
|
+
}
|
|
534
|
+
/** Every descendant id of a block: the planner's tree when loaded, else the store's live rows. */
|
|
535
|
+
function descendantsOf(ctx, id) {
|
|
536
|
+
const inTree = locateLoaded(ctx, id);
|
|
537
|
+
if (inTree) {
|
|
538
|
+
const out = [];
|
|
539
|
+
const walk = (b) => {
|
|
540
|
+
for (const c of b.children) {
|
|
541
|
+
out.push(c.id);
|
|
542
|
+
walk(c);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
walk(inTree.block);
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
if (isNewSymbol(id))
|
|
549
|
+
return [];
|
|
550
|
+
const rows = ctx.store.db
|
|
551
|
+
.prepare(`WITH RECURSIVE sub(block_id) AS (
|
|
552
|
+
SELECT block_id FROM blocks WHERE parent_block = ? AND deleted_commit IS NULL
|
|
553
|
+
UNION ALL
|
|
554
|
+
SELECT b.block_id FROM blocks b JOIN sub ON b.parent_block = sub.block_id WHERE b.deleted_commit IS NULL
|
|
555
|
+
) SELECT block_id FROM sub`)
|
|
556
|
+
.all(id);
|
|
557
|
+
return rows.map((r) => r.block_id);
|
|
558
|
+
}
|
|
559
|
+
/** The document a placement target lives in (loading nothing; `parent_missing` for an unknown heading/block). */
|
|
560
|
+
function targetDoc(ctx, t) {
|
|
561
|
+
switch (t.kind) {
|
|
562
|
+
case "document":
|
|
563
|
+
return t.id;
|
|
564
|
+
case "section": {
|
|
565
|
+
const inTree = locateLoaded(ctx, t.heading_id);
|
|
566
|
+
if (inTree)
|
|
567
|
+
return inTree.docId;
|
|
568
|
+
const row = ctx.store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(t.heading_id);
|
|
569
|
+
if (!row)
|
|
570
|
+
throw new MutationError("parent_missing", `parent ${t.heading_id} not found`);
|
|
571
|
+
return row.doc_id;
|
|
572
|
+
}
|
|
573
|
+
case "block": {
|
|
574
|
+
const inTree = locateLoaded(ctx, t.id);
|
|
575
|
+
if (inTree)
|
|
576
|
+
return inTree.docId;
|
|
577
|
+
if (isNewSymbol(t.id))
|
|
578
|
+
throw symbolMissing(ctx, t.id, "block");
|
|
579
|
+
const row = ctx.store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(t.id);
|
|
580
|
+
if (!row)
|
|
581
|
+
throw new MutationError("parent_missing", `parent ${t.id} not found`);
|
|
582
|
+
return row.doc_id;
|
|
583
|
+
}
|
|
584
|
+
case "node":
|
|
585
|
+
return nodeBlock(ctx, t.id).docId;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function nodeBlock(ctx, nodeId) {
|
|
589
|
+
const row = ctx.store.db.prepare("SELECT doc_id, block_id FROM nodes WHERE node_id = ?").get(nodeId);
|
|
590
|
+
if (!row)
|
|
591
|
+
throw new MutationError("block_missing", `node ${nodeId} not found`, {});
|
|
592
|
+
if (!row.block_id)
|
|
593
|
+
throw new MutationError("node_not_editable", `node ${nodeId} is not anchored to a block`, {});
|
|
594
|
+
return { docId: row.doc_id, blockId: row.block_id };
|
|
595
|
+
}
|
|
596
|
+
function placement(ctx, into, at) {
|
|
597
|
+
const kernel = kernelAt(ctx, at);
|
|
598
|
+
switch (into.kind) {
|
|
599
|
+
case "document":
|
|
600
|
+
return { parent: { doc: true }, at: kernel };
|
|
601
|
+
case "section":
|
|
602
|
+
return { parent: { heading: kernelBlockId(ctx, into.heading_id), scope: "section" }, at: kernel };
|
|
603
|
+
case "block":
|
|
604
|
+
return { parent: kernelBlockId(ctx, into.id), at: kernel };
|
|
605
|
+
case "node":
|
|
606
|
+
throw effectInvalid("a node is not a placement target");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** `expect.revision` against the document's current revision (§11.2): a mismatch is `stale_plan`. */
|
|
610
|
+
function checkRevision(ctx, docId, expect) {
|
|
611
|
+
if (expect?.revision === undefined)
|
|
612
|
+
return;
|
|
613
|
+
if (isNewSymbol(docId))
|
|
614
|
+
throw effectInvalid(`expect.revision cannot name a document this plan creates (${docId})`);
|
|
615
|
+
const base = readDocBase(ctx.store.db, docId);
|
|
616
|
+
if (!base)
|
|
617
|
+
throw docMissing(docId);
|
|
618
|
+
if (base.currentRev !== expect.revision) {
|
|
619
|
+
throw new MutationError("stale_plan", `document ${docId} is at revision ${base.currentRev ?? "null"}, not ${expect.revision}; re-plan`, {
|
|
620
|
+
doc: docId,
|
|
621
|
+
expected_revision: expect.revision,
|
|
622
|
+
current: { revision: base.currentRev, hash: base.fileHash },
|
|
623
|
+
retriable: true,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Pin a block's content hash: the caller's `expect.content_hash` when given, else the live row's (the sugar tools' rule). */
|
|
628
|
+
function pin(ctx, id, given) {
|
|
629
|
+
const hash = given ?? blockInfo(ctx, id).hash;
|
|
630
|
+
if (!isNewSymbol(id) && !ctx.pins.has(id))
|
|
631
|
+
ctx.pins.set(id, hash);
|
|
632
|
+
return hash;
|
|
633
|
+
}
|
|
634
|
+
function pushOp(ctx, op) {
|
|
635
|
+
ctx.ops.push(op);
|
|
636
|
+
const before = ctx.planner.results.length;
|
|
637
|
+
ctx.planner.push(op);
|
|
638
|
+
// Any `$new.n` this op minted is referenceable through the op's placeholder.
|
|
639
|
+
const result = ctx.planner.results[before];
|
|
640
|
+
result.ids.forEach((id, j) => {
|
|
641
|
+
if (isNewSymbol(id) && !ctx.blockSymbols.has(id))
|
|
642
|
+
ctx.blockSymbols.set(id, `$${before}.ids[${j}]`);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
function kernelExpect(e, contentHash) {
|
|
646
|
+
const out = {};
|
|
647
|
+
if (contentHash !== undefined)
|
|
648
|
+
out.content_hash = contentHash;
|
|
649
|
+
if (e?.parent_children_hash !== undefined)
|
|
650
|
+
out.parent_children_hash = e.parent_children_hash;
|
|
651
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
652
|
+
}
|
|
653
|
+
function recordParentPin(ctx, doc, into, expect) {
|
|
654
|
+
if (expect?.parent_children_hash === undefined)
|
|
655
|
+
return;
|
|
656
|
+
const parent = into.kind === "block" ? into.id : null;
|
|
657
|
+
ctx.parents.push({ doc, parent, parent_children_hash: expect.parent_children_hash });
|
|
658
|
+
}
|
|
659
|
+
// ---- lowering (§11.3, the table) -----------------------------------------------------
|
|
660
|
+
function describe(e) {
|
|
661
|
+
switch (e.op) {
|
|
662
|
+
case "create": return `create ${e.path}`;
|
|
663
|
+
case "insert": return `insert into ${targetName(e.into)}`;
|
|
664
|
+
case "set": return `set ${e.field} on ${targetName(e.target)}`;
|
|
665
|
+
case "unset": return `unset ${e.field} on ${targetName(e.target)}`;
|
|
666
|
+
case "move": return "targets" in e ? `move ${e.targets.map(targetName).join(",")}` : `move ${targetName(e.target)} -> ${e.to}`;
|
|
667
|
+
case "remove": return "targets" in e ? `remove ${e.targets.map(targetName).join(",")}` : `remove ${targetName(e.target)}`;
|
|
668
|
+
case "split": return `split ${targetName(e.target)}`;
|
|
669
|
+
case "merge": return `merge ${e.targets.map(targetName).join(",")}`;
|
|
670
|
+
case "retarget": return `retarget ${e.repairs.map((r) => `${r.from}->${r.to}`).join(",")}`;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function targetName(t) {
|
|
674
|
+
return t.kind === "section" ? `section ${t.heading_id}` : `${t.kind} ${t.id}`;
|
|
675
|
+
}
|
|
676
|
+
/** The target kinds each slot admits (§11.1); `validateTarget` checks a literal, `resolveEffect` a bare `$new.n`. */
|
|
677
|
+
const PLACEMENT_KINDS = ["document", "section", "block"];
|
|
678
|
+
const DOCUMENT_KIND = ["document"];
|
|
679
|
+
const BLOCK_KIND = ["block"];
|
|
680
|
+
const SET_KINDS = ["document", "block", "node"];
|
|
681
|
+
/** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit; `i` names the effect in messages. */
|
|
682
|
+
export function resolveEffect(ctx, e, i = 0) {
|
|
683
|
+
const here = `effects[${i}]`;
|
|
684
|
+
const target = (t, kinds, member) => {
|
|
685
|
+
const literal = t.kind === "symbol" ? resolveSymbolTarget(ctx, t.id, kinds, `${here}.${member}`) : t;
|
|
686
|
+
return literal.kind === "document" ? resolveDocumentTarget(ctx, literal) : literal;
|
|
687
|
+
};
|
|
688
|
+
const targets = (list) => list.map((t, k) => target(t, BLOCK_KIND, `targets[${k}]`));
|
|
689
|
+
switch (e.op) {
|
|
690
|
+
case "create":
|
|
691
|
+
return { op: "create", path: canonicalDocPath(e.path), markdown: e.markdown, ...(e.frontmatter !== undefined ? { frontmatter: e.frontmatter } : {}) };
|
|
692
|
+
case "insert":
|
|
693
|
+
return { op: "insert", into: target(e.into, PLACEMENT_KINDS, "into"), at: e.at ?? "end", markdown: e.markdown, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
694
|
+
case "set":
|
|
695
|
+
case "unset": {
|
|
696
|
+
const t = target(e.target, e.op === "set" ? SET_KINDS : DOCUMENT_KIND, "target");
|
|
697
|
+
// A bare `$new.n` deferred its `field` check (§11.2) to here, where its kind is known.
|
|
698
|
+
if (e.target.kind === "symbol")
|
|
699
|
+
checkField(t.kind, e.field, here, e.op);
|
|
700
|
+
if (e.op === "set")
|
|
701
|
+
return { op: "set", target: t, field: e.field, value: e.value, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
702
|
+
return { op: "unset", target: t, field: e.field, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
703
|
+
}
|
|
704
|
+
case "move":
|
|
705
|
+
if ("targets" in e)
|
|
706
|
+
return { op: "move", targets: targets(e.targets), into: target(e.into, PLACEMENT_KINDS, "into"), at: e.at ?? "end", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
707
|
+
return { op: "move", target: target(e.target, DOCUMENT_KIND, "target"), to: canonicalDocPath(e.to), retarget_inbound: e.retarget_inbound !== false };
|
|
708
|
+
case "remove":
|
|
709
|
+
if ("targets" in e)
|
|
710
|
+
return { op: "remove", targets: targets(e.targets), ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
711
|
+
return { op: "remove", target: target(e.target, DOCUMENT_KIND, "target") };
|
|
712
|
+
case "split":
|
|
713
|
+
return { op: "split", target: target(e.target, BLOCK_KIND, "target"), at: [...e.at], ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
714
|
+
case "merge":
|
|
715
|
+
return { op: "merge", targets: targets(e.targets), separator: e.separator ?? " ", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
716
|
+
case "retarget":
|
|
717
|
+
return { op: "retarget", repairs: e.repairs.map((r) => ({ from: r.from, to: r.to })), ...(e.path_glob !== undefined ? { path_glob: e.path_glob } : {}) };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function docTarget(t) {
|
|
721
|
+
if (t.kind !== "document")
|
|
722
|
+
throw effectInvalid(`expected a document target, got ${t.kind}`);
|
|
723
|
+
return t;
|
|
724
|
+
}
|
|
725
|
+
function blockTarget(t) {
|
|
726
|
+
if (t.kind !== "block")
|
|
727
|
+
throw effectInvalid(`expected a block target, got ${t.kind}`);
|
|
728
|
+
return t;
|
|
729
|
+
}
|
|
730
|
+
function nodeTarget(t) {
|
|
731
|
+
if (t.kind !== "node")
|
|
732
|
+
throw effectInvalid(`expected a node target, got ${t.kind}`);
|
|
733
|
+
return t;
|
|
734
|
+
}
|
|
735
|
+
/** The current frontmatter patch base of a loaded document: `(frontmatter, body)` of its render. */
|
|
736
|
+
function frontmatterOf(ctx, docId) {
|
|
737
|
+
const d = ctx.planner.ensureDoc(docId);
|
|
738
|
+
const { frontmatter, body } = splitFrontmatter(renderDoc(d));
|
|
739
|
+
return { d, frontmatter, body };
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Patch a loaded document's frontmatter: compose the whole file as
|
|
743
|
+
* `docs_set_meta` does (`composeFile(body, merged)`, §6), then set the file
|
|
744
|
+
* head so the known-id ingest renders exactly those bytes — the body blocks
|
|
745
|
+
* keep their ids and bytes (a file without a trailing newline gains one on its
|
|
746
|
+
* last block's trivia, as `composeFile` would add it).
|
|
747
|
+
*/
|
|
748
|
+
function patchFrontmatter(ctx, docId, merged, body) {
|
|
749
|
+
const d = ctx.planner.ensureDoc(docId);
|
|
750
|
+
const after = composeFile(body, merged);
|
|
751
|
+
let blocks = d.children.map((b) => renderBlockExact(d, b)).join("");
|
|
752
|
+
if (!after.endsWith(blocks)) {
|
|
753
|
+
const last = d.children[d.children.length - 1];
|
|
754
|
+
if (last && after.endsWith(blocks + "\n")) {
|
|
755
|
+
last.trivia += "\n";
|
|
756
|
+
blocks += "\n";
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
throw new MutationError("plan_not_convergent", `the frontmatter patch of ${docId} does not preserve the body bytes`, { doc: docId });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const head = after.slice(0, after.length - blocks.length);
|
|
763
|
+
ctx.planner.setHead(docId, head);
|
|
764
|
+
ctx.heads.set(docId, head);
|
|
765
|
+
}
|
|
766
|
+
// A top-level block as `renderDoc` emits it (its subtree may be dirty from an earlier op).
|
|
767
|
+
function renderBlockExact(d, b) {
|
|
768
|
+
const solo = { ...d, leadingTrivia: "", frontmatterRaw: null, children: [b] };
|
|
769
|
+
return renderDoc(solo);
|
|
770
|
+
}
|
|
771
|
+
/** Shift a kernel op's `$n.ids[i]` placeholders by `offset` (an opset planned alone, spliced into a longer op list). */
|
|
772
|
+
export function shiftPlaceholders(op, offset) {
|
|
773
|
+
const s = (v) => {
|
|
774
|
+
const m = /^\$(\d+)\.ids\[(\d+)\]$/.exec(v);
|
|
775
|
+
return m ? `$${Number(m[1]) + offset}.ids[${m[2]}]` : v;
|
|
776
|
+
};
|
|
777
|
+
const to = (t) => ({
|
|
778
|
+
parent: typeof t.parent === "string" ? s(t.parent) : t.parent,
|
|
779
|
+
at: typeof t.at === "string" ? t.at : "before" in t.at ? { before: s(t.at.before) } : { after: s(t.at.after) },
|
|
780
|
+
});
|
|
781
|
+
switch (op.op) {
|
|
782
|
+
case "insert": return { ...op, to: to(op.to) };
|
|
783
|
+
case "update": return { ...op, block: s(op.block) };
|
|
784
|
+
case "move": return { ...op, blocks: op.blocks.map(s), to: to(op.to) };
|
|
785
|
+
case "remove": return { ...op, blocks: op.blocks.map(s) };
|
|
786
|
+
case "split": return { ...op, block: s(op.block) };
|
|
787
|
+
case "merge": return { ...op, blocks: op.blocks.map(s) };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Lower effect `i` (already validated): resolve its targets, classify its
|
|
792
|
+
* focus (throwing `conflicting_effects`, or dropping an identical duplicate
|
|
793
|
+
* with a diagnostic), then push its kernel ops / document drafts. Returns the
|
|
794
|
+
* resolved effect, or null when it was dropped.
|
|
795
|
+
*/
|
|
796
|
+
export function lowerEffect(ctx, i, effect) {
|
|
797
|
+
const e = resolveEffect(ctx, effect, i);
|
|
798
|
+
const info = focusOf(ctx, e);
|
|
799
|
+
if (!ctx.tracker.check(i, info, describe(e)))
|
|
800
|
+
return null;
|
|
801
|
+
ctx.resolved.push(e);
|
|
802
|
+
applyLowering(ctx, i, e);
|
|
803
|
+
return e;
|
|
804
|
+
}
|
|
805
|
+
// The addressed/retired sets and the scalar key of an effect (§11.3 table).
|
|
806
|
+
function focusOf(ctx, e) {
|
|
807
|
+
const blocksOf = (ids) => {
|
|
808
|
+
const docs = new Set();
|
|
809
|
+
for (const id of ids)
|
|
810
|
+
docs.add(blockInfo(ctx, id).docId);
|
|
811
|
+
return { docs: [...docs], blocks: ids };
|
|
812
|
+
};
|
|
813
|
+
switch (e.op) {
|
|
814
|
+
case "create":
|
|
815
|
+
return { docs: [], blocks: [], paths: [e.path] };
|
|
816
|
+
case "insert": {
|
|
817
|
+
const doc = targetDoc(ctx, e.into);
|
|
818
|
+
const blocks = [...(e.into.kind === "block" ? [e.into.id] : e.into.kind === "section" ? [e.into.heading_id] : []), ...anchorIds(e.at)];
|
|
819
|
+
return { docs: [doc], blocks, paths: [] };
|
|
820
|
+
}
|
|
821
|
+
case "set":
|
|
822
|
+
case "unset": {
|
|
823
|
+
if (e.target.kind === "document") {
|
|
824
|
+
const field = e.field === "content" ? "content" : e.field;
|
|
825
|
+
return {
|
|
826
|
+
scalar: { key: `document:${e.target.id}:${field}`, target: { kind: "document", id: e.target.id }, field, op: e.op, value: e.op === "set" ? e.value : undefined },
|
|
827
|
+
docs: [e.target.id], blocks: [], paths: [],
|
|
828
|
+
...(field === "content" ? { wholeDoc: e.target.id, retiresDocs: [e.target.id], retiresBlocks: allBlocksOf(ctx, e.target.id) } : {}),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
if (e.target.kind === "block") {
|
|
832
|
+
const { docId } = blockInfo(ctx, e.target.id);
|
|
833
|
+
return {
|
|
834
|
+
scalar: { key: `block:${e.target.id}:${e.field}`, target: { kind: "block", id: e.target.id }, field: e.field, op: e.op, value: e.op === "set" ? e.value : undefined },
|
|
835
|
+
docs: [docId], blocks: [e.target.id], paths: [],
|
|
836
|
+
...(e.field === "markdown" ? { retiresBlocks: descendantsOf(ctx, e.target.id) } : {}),
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
const node = nodeTarget(e.target);
|
|
840
|
+
const { docId, blockId } = nodeBlock(ctx, node.id);
|
|
841
|
+
return {
|
|
842
|
+
scalar: { key: `node:${node.id}:${e.field}`, target: { kind: "node", id: node.id }, field: e.field, op: e.op, value: e.op === "set" ? e.value : undefined },
|
|
843
|
+
docs: [docId], blocks: [blockId], paths: [],
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
case "move": {
|
|
847
|
+
if ("targets" in e) {
|
|
848
|
+
const ids = e.targets.map((t) => blockTarget(t).id);
|
|
849
|
+
const { docs } = blocksOf(ids);
|
|
850
|
+
const dst = targetDoc(ctx, e.into);
|
|
851
|
+
const blocks = [...ids, ...(e.into.kind === "block" ? [e.into.id] : e.into.kind === "section" ? [e.into.heading_id] : []), ...anchorIds(e.at)];
|
|
852
|
+
return { docs: [...new Set([...docs, dst])], blocks, paths: [] };
|
|
853
|
+
}
|
|
854
|
+
const t = docTarget(e.target);
|
|
855
|
+
return {
|
|
856
|
+
scalar: { key: `document:${t.id}:path`, target: { kind: "document", id: t.id }, field: "path", op: "set", value: e.to },
|
|
857
|
+
docs: [t.id], blocks: [], paths: [e.to], retiresPaths: [t.path],
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
case "remove": {
|
|
861
|
+
if ("targets" in e) {
|
|
862
|
+
const ids = e.targets.map((t) => blockTarget(t).id);
|
|
863
|
+
const { docs } = blocksOf(ids);
|
|
864
|
+
return { docs, blocks: ids, paths: [], retiresBlocks: ids.flatMap((id) => [id, ...descendantsOf(ctx, id)]) };
|
|
865
|
+
}
|
|
866
|
+
const t = docTarget(e.target);
|
|
867
|
+
return { docs: [t.id], blocks: [], paths: [], retiresDocs: [t.id], retiresBlocks: allBlocksOf(ctx, t.id) };
|
|
868
|
+
}
|
|
869
|
+
case "split": {
|
|
870
|
+
const id = blockTarget(e.target).id;
|
|
871
|
+
return { ...blocksOf([id]), paths: [] };
|
|
872
|
+
}
|
|
873
|
+
case "merge": {
|
|
874
|
+
const ids = e.targets.map((t) => blockTarget(t).id);
|
|
875
|
+
return { ...blocksOf(ids), paths: [], retiresBlocks: ids.slice(1).flatMap((id) => [id, ...descendantsOf(ctx, id)]) };
|
|
876
|
+
}
|
|
877
|
+
case "retarget": {
|
|
878
|
+
const plan = linksRepair(ctx.store, ctx.repoId, e.repairs, e.path_glob !== undefined ? { pathGlob: e.path_glob } : {});
|
|
879
|
+
const ids = plan.ops.map((o) => o.block);
|
|
880
|
+
return {
|
|
881
|
+
scalars: ids.map((id) => ({ key: `block:${id}:markdown`, target: { kind: "block", id }, field: "markdown" })),
|
|
882
|
+
...blocksOf(ids), paths: [],
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function anchorIds(at) {
|
|
888
|
+
if (at === "start" || at === "end")
|
|
889
|
+
return [];
|
|
890
|
+
return ["before" in at ? at.before : at.after];
|
|
891
|
+
}
|
|
892
|
+
function allBlocksOf(ctx, docId) {
|
|
893
|
+
if (isNewSymbol(docId))
|
|
894
|
+
return [];
|
|
895
|
+
return ctx.store.db.prepare("SELECT block_id FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL").all(docId).map((r) => r.block_id);
|
|
896
|
+
}
|
|
897
|
+
// The lowering table (§11.3), one resolved effect → kernel ops / document entries. `i` is the effect's index.
|
|
898
|
+
function applyLowering(ctx, i, e) {
|
|
899
|
+
switch (e.op) {
|
|
900
|
+
case "create": {
|
|
901
|
+
if (findDoc(ctx.store, { repoId: ctx.repoId, path: e.path }))
|
|
902
|
+
throw new MutationError("path_taken", `document already exists at ${e.path}`, { path: e.path });
|
|
903
|
+
if (ctx.absentPaths.includes(e.path))
|
|
904
|
+
throw new MutationError("path_taken", `an earlier effect already creates or moves a document to ${e.path}`, { path: e.path });
|
|
905
|
+
if (ctx.docStore.exists(e.path))
|
|
906
|
+
throw new MutationError("path_taken", `file already exists on disk at ${e.path}`, { path: e.path });
|
|
907
|
+
// 3.0: the document symbol, then its tree parsed under the symbolic minter
|
|
908
|
+
// — its blocks are the next `$new.k` in pre-order — and adopted as a loaded
|
|
909
|
+
// document, so every later effect addresses it and its blocks like any other.
|
|
910
|
+
const symbol = ctx.symbols.alloc("document");
|
|
911
|
+
const source = composeFile(e.markdown, e.frontmatter);
|
|
912
|
+
const tree = parseNewDoc(symbol, e.path, source);
|
|
913
|
+
ctx.planner.adoptNew(symbol, tree);
|
|
914
|
+
ctx.creates.push({ symbol, path: e.path, source, before_op: ctx.ops.length });
|
|
915
|
+
ctx.absentPaths.push(e.path);
|
|
916
|
+
ctx.summary.push(`create ${referencePath(e.path)} (${plural(tree.children.length, "block")})`);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
case "insert": {
|
|
920
|
+
const doc = targetDoc(ctx, e.into);
|
|
921
|
+
checkRevision(ctx, doc, e.expect);
|
|
922
|
+
recordParentPin(ctx, doc, e.into, e.expect);
|
|
923
|
+
const to = placement(ctx, e.into, e.at);
|
|
924
|
+
const expect = kernelExpect(e.expect);
|
|
925
|
+
const where = placeWords(ctx, e.into, e.at, "at");
|
|
926
|
+
pushOp(ctx, { op: "insert", ...(e.into.kind === "document" ? { doc } : {}), to, markdown: e.markdown, ...(expect !== undefined ? { expect } : {}) });
|
|
927
|
+
ctx.summary.push(`insert ${plural(ctx.planner.results[ctx.planner.results.length - 1].ids.length, "block")} ${where}`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
case "set":
|
|
931
|
+
case "unset": {
|
|
932
|
+
if (e.target.kind === "document") {
|
|
933
|
+
const docId = e.target.id;
|
|
934
|
+
checkRevision(ctx, docId, e.expect);
|
|
935
|
+
if (e.field === "content") {
|
|
936
|
+
if (e.op !== "set" || typeof e.value !== "string")
|
|
937
|
+
throw effectInvalid("`content` takes a string value: the complete proposed document");
|
|
938
|
+
if (isNewSymbol(docId))
|
|
939
|
+
throw new MutationError("target_missing", `${docId} is a document this plan creates; its content is the create's markdown`, { symbol: docId });
|
|
940
|
+
lowerContent(ctx, docId, e.value);
|
|
941
|
+
ctx.summary.push(`set content of ${docPathOf(ctx, docId)}`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// `frontmatter.<key>` — on a created document too (3.0): its tree is loaded, the patch is a head like any other.
|
|
945
|
+
const key = FRONTMATTER_FIELD.exec(e.field)[1];
|
|
946
|
+
const { frontmatter, body } = frontmatterOf(ctx, docId);
|
|
947
|
+
const merged = { ...frontmatter };
|
|
948
|
+
if (e.op === "set")
|
|
949
|
+
merged[key] = e.value;
|
|
950
|
+
else
|
|
951
|
+
delete merged[key];
|
|
952
|
+
patchFrontmatter(ctx, docId, merged, body);
|
|
953
|
+
ctx.summary.push(e.op === "set" ? `set ${e.field} = ${ctx.canonical(e.value)} in ${docPathOf(ctx, docId)}` : `unset ${e.field} in ${docPathOf(ctx, docId)}`);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (e.op !== "set")
|
|
957
|
+
throw effectInvalid("`unset` takes a document target");
|
|
958
|
+
if (e.target.kind === "block") {
|
|
959
|
+
const id = e.target.id;
|
|
960
|
+
const { docId } = blockInfo(ctx, id);
|
|
961
|
+
checkRevision(ctx, docId, e.expect);
|
|
962
|
+
const hash = pin(ctx, id, e.expect?.content_hash);
|
|
963
|
+
const name = blockName(ctx, id);
|
|
964
|
+
if (e.field === "markdown") {
|
|
965
|
+
if (typeof e.value !== "string")
|
|
966
|
+
throw effectInvalid("`markdown` takes a string value");
|
|
967
|
+
pushOp(ctx, { op: "update", block: kernelBlockId(ctx, id), markdown: e.value, expect: kernelExpect(e.expect, hash) });
|
|
968
|
+
ctx.summary.push(`set markdown of ${name} in ${docPathOf(ctx, docId)}`);
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
if (typeof e.value !== "boolean")
|
|
972
|
+
throw effectInvalid("`attrs.checked` takes a boolean value");
|
|
973
|
+
pushOp(ctx, { op: "update", block: kernelBlockId(ctx, id), attrs: { checked: e.value }, expect: kernelExpect(e.expect, hash) });
|
|
974
|
+
ctx.summary.push(`${e.value ? "check" : "uncheck"} ${name} in ${docPathOf(ctx, docId)}`);
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
// node: the adapter's editor → one update (macros.ts nodeSet)
|
|
979
|
+
const node = nodeTarget(e.target);
|
|
980
|
+
const { docId, blockId } = nodeBlock(ctx, node.id);
|
|
981
|
+
checkRevision(ctx, docId, e.expect);
|
|
982
|
+
const ops = nodeSet(ctx.store, node.id, e.field, typeof e.value === "string" ? e.value : ctx.canonical(e.value));
|
|
983
|
+
for (const op of ops) {
|
|
984
|
+
const u = op;
|
|
985
|
+
const hash = pin(ctx, blockId, e.expect?.content_hash ?? u.expect?.content_hash);
|
|
986
|
+
pushOp(ctx, { ...u, expect: { ...(u.expect ?? {}), content_hash: hash } });
|
|
987
|
+
}
|
|
988
|
+
ctx.summary.push(`set ${e.field} = ${ctx.canonical(e.value)} on node ${node.id} in ${docPathOf(ctx, docId)}`);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
case "move": {
|
|
992
|
+
if ("targets" in e) {
|
|
993
|
+
const ids = e.targets.map((t) => blockTarget(t).id);
|
|
994
|
+
const dst = targetDoc(ctx, e.into);
|
|
995
|
+
checkRevision(ctx, dst, e.expect);
|
|
996
|
+
recordParentPin(ctx, dst, e.into, e.expect);
|
|
997
|
+
const to = placement(ctx, e.into, e.at);
|
|
998
|
+
const expect = kernelExpect(e.expect);
|
|
999
|
+
const what = ids.length === 1 ? blockName(ctx, ids[0]) : plural(ids.length, "block");
|
|
1000
|
+
const where = placeWords(ctx, e.into, e.at, "to");
|
|
1001
|
+
// `doc` names the destination when `into` is a document (§2.3, 3.0): a
|
|
1002
|
+
// top-level start/end would otherwise mean the SOURCE document's.
|
|
1003
|
+
pushOp(ctx, { op: "move", blocks: ids.map((id) => kernelBlockId(ctx, id)), to, ...(e.into.kind === "document" ? { doc: dst } : {}), ...(expect !== undefined ? { expect } : {}) });
|
|
1004
|
+
ctx.summary.push(`move ${what} ${where}`);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const t = docTarget(e.target);
|
|
1008
|
+
if (isNewSymbol(t.id))
|
|
1009
|
+
throw new MutationError("target_missing", `${t.id} is a document this plan creates; it cannot be moved in the same plan`, { symbol: t.id });
|
|
1010
|
+
const base = readDocBase(ctx.store.db, t.id);
|
|
1011
|
+
if (!base)
|
|
1012
|
+
throw docMissing(t.id);
|
|
1013
|
+
if (findDoc(ctx.store, { repoId: ctx.repoId, path: e.to }))
|
|
1014
|
+
throw new MutationError("path_taken", `a document already exists at ${e.to}`, { path: e.to });
|
|
1015
|
+
if (ctx.absentPaths.includes(e.to))
|
|
1016
|
+
throw new MutationError("path_taken", `an earlier effect already creates or moves a document to ${e.to}`, { path: e.to });
|
|
1017
|
+
if (ctx.docStore.exists(e.to))
|
|
1018
|
+
throw new MutationError("path_taken", `file already exists on disk at ${e.to}`, { path: e.to });
|
|
1019
|
+
ctx.absentPaths.push(e.to);
|
|
1020
|
+
if (!ctx.docBases.has(t.id))
|
|
1021
|
+
ctx.docBases.set(t.id, base);
|
|
1022
|
+
const inbound = inboundLinksTo(ctx.store, ctx.repoId, t.id, t.path);
|
|
1023
|
+
let retargeted = null;
|
|
1024
|
+
let links = "";
|
|
1025
|
+
if (e.retarget_inbound && inbound.length > 0) {
|
|
1026
|
+
const plan = planInboundRetarget(ctx.store, t.id, t.path, e.to, inbound);
|
|
1027
|
+
for (const op of plan.ops) {
|
|
1028
|
+
const u = op;
|
|
1029
|
+
pin(ctx, u.block, u.expect?.content_hash);
|
|
1030
|
+
pushOp(ctx, u);
|
|
1031
|
+
}
|
|
1032
|
+
retargeted = { blocks: plan.blocks, docs: plan.docs };
|
|
1033
|
+
links = ` (retarget ${plural(inbound.length - plan.dangling.length, "link")} in ${plural(plan.docs.length, "document")})`;
|
|
1034
|
+
}
|
|
1035
|
+
ctx.moves.push({ id: t.id, from: t.path, to: e.to, currentRev: base.currentRev, inbound, retargeted });
|
|
1036
|
+
ctx.summary.push(`move ${referencePath(t.path)} → ${referencePath(e.to)}${links}`);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
case "remove": {
|
|
1040
|
+
if ("targets" in e) {
|
|
1041
|
+
const ids = e.targets.map((t) => blockTarget(t).id);
|
|
1042
|
+
const expect = {};
|
|
1043
|
+
for (const id of ids) {
|
|
1044
|
+
const given = e.expect?.[id];
|
|
1045
|
+
const hash = pin(ctx, id, given?.content_hash);
|
|
1046
|
+
expect[kernelBlockId(ctx, id)] = { ...(given ?? {}), content_hash: hash };
|
|
1047
|
+
}
|
|
1048
|
+
const line = `remove ${ids.length === 1 ? blockName(ctx, ids[0]) : plural(ids.length, "block")} from ${docOfBlocks(ctx, ids)}`;
|
|
1049
|
+
pushOp(ctx, { op: "remove", blocks: ids.map((id) => kernelBlockId(ctx, id)), expect });
|
|
1050
|
+
ctx.summary.push(line);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const t = docTarget(e.target);
|
|
1054
|
+
if (isNewSymbol(t.id))
|
|
1055
|
+
throw new MutationError("target_missing", `${t.id} is a document this plan creates; it cannot be removed in the same plan`, { symbol: t.id });
|
|
1056
|
+
const base = readDocBase(ctx.store.db, t.id);
|
|
1057
|
+
if (!base)
|
|
1058
|
+
throw docMissing(t.id);
|
|
1059
|
+
if (!ctx.docBases.has(t.id))
|
|
1060
|
+
ctx.docBases.set(t.id, base);
|
|
1061
|
+
ctx.tombstones.push({ id: t.id, path: t.path, currentRev: base.currentRev });
|
|
1062
|
+
ctx.summary.push(`remove ${referencePath(t.path)}`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
case "split": {
|
|
1066
|
+
const id = blockTarget(e.target).id;
|
|
1067
|
+
const { docId } = blockInfo(ctx, id);
|
|
1068
|
+
checkRevision(ctx, docId, e.expect);
|
|
1069
|
+
const hash = pin(ctx, id, e.expect?.content_hash);
|
|
1070
|
+
const name = blockName(ctx, id);
|
|
1071
|
+
pushOp(ctx, { op: "split", block: kernelBlockId(ctx, id), at: [...e.at], expect: kernelExpect(e.expect, hash) });
|
|
1072
|
+
ctx.summary.push(`split ${name} at offset${e.at.length === 1 ? "" : "s"} ${e.at.join(", ")} in ${docPathOf(ctx, docId)}`);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
case "merge": {
|
|
1076
|
+
const ids = e.targets.map((t) => blockTarget(t).id);
|
|
1077
|
+
const expect = {};
|
|
1078
|
+
for (const id of ids) {
|
|
1079
|
+
const given = e.expect?.[id];
|
|
1080
|
+
const hash = pin(ctx, id, given?.content_hash);
|
|
1081
|
+
expect[kernelBlockId(ctx, id)] = { ...(given ?? {}), content_hash: hash };
|
|
1082
|
+
}
|
|
1083
|
+
const line = `merge ${plural(ids.length, "block")} into ${blockName(ctx, ids[0])} in ${docOfBlocks(ctx, ids)}`;
|
|
1084
|
+
pushOp(ctx, { op: "merge", blocks: ids.map((id) => kernelBlockId(ctx, id)), separator: e.separator, expect });
|
|
1085
|
+
ctx.summary.push(line);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
case "retarget": {
|
|
1089
|
+
const plan = linksRepair(ctx.store, ctx.repoId, e.repairs, e.path_glob !== undefined ? { pathGlob: e.path_glob } : {});
|
|
1090
|
+
ctx.retargets.push({ effect_index: i, hits: plan.hits, pairs: plan.pairs });
|
|
1091
|
+
for (const op of plan.ops) {
|
|
1092
|
+
const u = op;
|
|
1093
|
+
pin(ctx, u.block, u.expect?.content_hash);
|
|
1094
|
+
pushOp(ctx, u);
|
|
1095
|
+
}
|
|
1096
|
+
const docs = new Set(plan.hits.map((h) => h.path)).size;
|
|
1097
|
+
const pair = e.repairs.length === 1 ? `${e.repairs[0].from} → ${e.repairs[0].to}` : `over ${plural(e.repairs.length, "pair")}`;
|
|
1098
|
+
ctx.summary.push(`retarget ${plural(plan.hits.length, "link")} ${pair} in ${plural(docs, "document")}`);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* The symbolic minter (§11.4): `b` and `d` mints yield `$new.0, $new.1, …`
|
|
1105
|
+
* from `symbols`, so a plan consumes no real id and the kernel (id-agnostic
|
|
1106
|
+
* for rendering, ADR-002) renders exact bytes. Planning mints nothing else —
|
|
1107
|
+
* `c`/`r`/`x`/`e` are minted inside the commit transaction.
|
|
1108
|
+
*/
|
|
1109
|
+
export function symbolicMinter(symbols) {
|
|
1110
|
+
return (prefix) => {
|
|
1111
|
+
if (prefix === "b")
|
|
1112
|
+
return symbols.alloc("block");
|
|
1113
|
+
if (prefix === "d")
|
|
1114
|
+
return symbols.alloc("document");
|
|
1115
|
+
throw new Error(`the effect planner mints only b/d ids, not ${prefix}`);
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
/** `set document.content`: the whole-document planner's opset, spliced in (placeholders shifted), its frontmatter as a file head. */
|
|
1119
|
+
function lowerContent(ctx, docId, content) {
|
|
1120
|
+
// The whole-document planner verifies its candidates by dry-run applies,
|
|
1121
|
+
// which mint: give them a throwaway symbol table so the plan's own `$new.n`
|
|
1122
|
+
// sequence stays the one `execute`'s replay reproduces (the opset's ops carry
|
|
1123
|
+
// no minted ids — an insert carries markdown — so nothing leaks).
|
|
1124
|
+
const opset = withIdMinter(symbolicMinter(new SymbolTable()), () => planUpdate(ctx.store, ctx.repoId, "", docId, content));
|
|
1125
|
+
if (!opset.converges) {
|
|
1126
|
+
const why = opset.diagnostics.length > 0 ? ` — ${opset.diagnostics[opset.diagnostics.length - 1]}` : "";
|
|
1127
|
+
throw new MutationError("plan_not_convergent", `the proposed content of ${docId} cannot be reproduced by the planner; re-plan${why}`, { doc: docId, diagnostics: opset.diagnostics });
|
|
1128
|
+
}
|
|
1129
|
+
// The identity consequences, surfaced as `preview.identity[doc]` (3.0; the
|
|
1130
|
+
// 3.x `docs_update` result): each planned op by its index in the plan's ops.
|
|
1131
|
+
const offset = ctx.ops.length;
|
|
1132
|
+
ctx.identity.set(docId, {
|
|
1133
|
+
matcher_v: opset.matcherV,
|
|
1134
|
+
summary: opset.summary,
|
|
1135
|
+
ops: opset.ops.map((p, k) => ({ op_index: offset + k, disposition: p.disposition, blocks: p.blocks, confidence: p.confidence, reason: p.reason, ...(p.detail !== undefined ? { detail: p.detail } : {}) })),
|
|
1136
|
+
diagnostics: opset.diagnostics,
|
|
1137
|
+
});
|
|
1138
|
+
if (opset.ops.length === 0 && !opset.frontmatter) {
|
|
1139
|
+
// The content IS the document (the zero-effects rule, §11.7): nothing is
|
|
1140
|
+
// loaded, so nothing is ingested and `execute` records no commit — the
|
|
1141
|
+
// document's triple is still a precondition (the opset's), so the plan is
|
|
1142
|
+
// `stale_plan` once the document moves on.
|
|
1143
|
+
const base = readDocBase(ctx.store.db, docId);
|
|
1144
|
+
if (!base)
|
|
1145
|
+
throw docMissing(docId);
|
|
1146
|
+
if (!ctx.docBases.has(docId))
|
|
1147
|
+
ctx.docBases.set(docId, base);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const d = ctx.planner.ensureDoc(docId);
|
|
1151
|
+
if (opset.frontmatter) {
|
|
1152
|
+
// The whole-document planner's frontmatter keeps the leading trivia in
|
|
1153
|
+
// front (its legacy `set_frontmatter`); as a file head that is `leading + raw`.
|
|
1154
|
+
const head = d.leadingTrivia + (opset.frontmatter.raw ?? "");
|
|
1155
|
+
ctx.planner.setHead(docId, head);
|
|
1156
|
+
ctx.heads.set(docId, head);
|
|
1157
|
+
}
|
|
1158
|
+
for (const p of opset.ops)
|
|
1159
|
+
pushOp(ctx, shiftPlaceholders(p.op, offset));
|
|
1160
|
+
}
|
|
1161
|
+
//# sourceMappingURL=effects.js.map
|