@omgbase/core 0.15.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/ingest.d.ts +2 -0
- package/dist/src/core/ingest.js +13 -0
- package/dist/src/core/read/document.d.ts +9 -8
- package/dist/src/core/read/document.js +0 -2
- package/dist/src/core/store/gc.d.ts +13 -1
- package/dist/src/core/store/gc.js +24 -5
- package/dist/src/core/store/size.d.ts +79 -0
- package/dist/src/core/store/size.js +232 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +5 -1
- package/dist/src/mcp/arguments.d.ts +10 -2
- package/dist/src/mcp/arguments.js +37 -3
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +63 -10
- package/dist/src/mcp/server.d.ts +34 -1
- package/dist/src/mcp/server.js +158 -46
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- package/dist/src/mutate/effects.d.ts +58 -8
- package/dist/src/mutate/effects.js +312 -43
- package/dist/src/mutate/known-ids.d.ts +19 -0
- package/dist/src/mutate/known-ids.js +47 -7
- package/dist/src/mutate/ops.d.ts +1 -0
- package/dist/src/mutate/ops.js +20 -1
- package/dist/src/mutate/plan.d.ts +26 -16
- package/dist/src/mutate/plan.js +152 -112
- package/dist/src/mutate/select.d.ts +78 -0
- package/dist/src/mutate/select.js +903 -0
- package/dist/src/mutate/tree.d.ts +2 -2
- package/dist/src/oqx-js/context.d.ts +9 -2
- package/dist/src/oqx-js/context.js +74 -28
- package/dist/src/oqx-js/names.d.ts +40 -0
- package/dist/src/oqx-js/names.js +347 -0
- package/dist/src/oqx-js/planner.d.ts +3 -3
- package/dist/src/oqx-js/planner.js +17 -9
- package/dist/src/oqx-js/root-row.d.ts +15 -4
- package/dist/src/oqx-js/root-row.js +18 -10
- package/dist/src/oqx-js/run.d.ts +45 -1
- package/dist/src/oqx-js/run.js +114 -32
- package/dist/src/oqx-js/sql/translate.js +1 -1
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +4 -4
- package/dist/src/sync/observe.js +9 -3
- package/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- package/package.json +2 -2
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { findDoc } from "../core/read/reader.js";
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeVisibleText } from "../core/hash.js";
|
|
3
|
+
import { referencePath } from "../core/paths.js";
|
|
4
|
+
import { isValidId, withIdMinter } from "../core/ids.js";
|
|
3
5
|
import { inboundLinksTo } from "../graph/inbound-links.js";
|
|
4
6
|
import { readDocBase } from "./commit.js";
|
|
5
7
|
import { canonicalDocPath, composeFile, parseNewDoc, planInboundRetarget, splitFrontmatter } from "./docs.js";
|
|
6
8
|
import { linksRepair, nodeSet } from "./macros.js";
|
|
7
9
|
import { planUpdate } from "./plan-update.js";
|
|
8
10
|
import { MutationError, rawHashHex, renderDoc } from "./tree.js";
|
|
11
|
+
import { fixRequest, withMember } from "./canonical.js";
|
|
9
12
|
export const EFFECT_OPS = ["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"];
|
|
10
13
|
/** `$new.<n>`: an identity an earlier effect of the same request creates (§11.1). */
|
|
11
14
|
export const NEW_SYMBOL_RE = /^\$new\.(0|[1-9][0-9]*)$/;
|
|
@@ -16,10 +19,86 @@ export function isNewSymbol(s) {
|
|
|
16
19
|
function isRecord(v) {
|
|
17
20
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
18
21
|
}
|
|
19
|
-
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }
|
|
20
|
-
export function effectInvalid(message) {
|
|
21
|
-
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
|
|
22
|
+
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }` — with a `fix` (3.4, §8) when the shape check can spell the corrected call. */
|
|
23
|
+
export function effectInvalid(message, fix) {
|
|
24
|
+
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments", ...(fix !== undefined ? { fix } : {}) });
|
|
22
25
|
}
|
|
26
|
+
function fixerFor(ctx, i, v) {
|
|
27
|
+
if (ctx === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const list = Array.isArray(ctx.request.effects) ? ctx.request.effects : undefined;
|
|
30
|
+
const written = list !== undefined && isRecord(list[i]) ? list[i] : v;
|
|
31
|
+
return {
|
|
32
|
+
written,
|
|
33
|
+
build: (effect, expect) => fixRequest({
|
|
34
|
+
...ctx.request,
|
|
35
|
+
effects: withMember(ctx.request.effects, [String(i)], effect),
|
|
36
|
+
...(expect !== undefined ? { expect: expect.value } : {}),
|
|
37
|
+
}),
|
|
38
|
+
parentOf: ctx.parentOf,
|
|
39
|
+
request: ctx.request,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** A record whose every value is a string. */
|
|
43
|
+
function isStringRecord(v) {
|
|
44
|
+
return isRecord(v) && Object.values(v).every((x) => typeof x === "string");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* §8 (3.4): an effect-level `expect.max` — the bound belongs to the request's
|
|
48
|
+
* top-level `expect`, beside `select`. The fix drops it from the effect (the
|
|
49
|
+
* rest of that `expect` stays; an emptied `expect` goes) and, when the request
|
|
50
|
+
* has a `select`, moves it to the top level unless a top-level `expect.max` is
|
|
51
|
+
* already there (then the top-level one is kept as written); without a
|
|
52
|
+
* `select` a top-level `expect` would itself be refused, so the fix only drops it.
|
|
53
|
+
*/
|
|
54
|
+
function effectLevelMax(v, here, f) {
|
|
55
|
+
const message = `${here}: \`expect.max\` bounds a selection — it is the request's top-level \`expect\`, beside \`select\`, not an effect's`;
|
|
56
|
+
if (f === undefined)
|
|
57
|
+
return effectInvalid(message);
|
|
58
|
+
const { max, ...rest } = v;
|
|
59
|
+
const effect = { ...f.written, expect: Object.keys(rest).length > 0 ? rest : undefined };
|
|
60
|
+
if (f.request.select === undefined)
|
|
61
|
+
return effectInvalid(message, f.build(effect));
|
|
62
|
+
const top = isRecord(f.request.expect) ? f.request.expect : {};
|
|
63
|
+
return effectInvalid(message, f.build(effect, { value: "max" in top ? top : { ...top, max } }));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* §8 (3.4): `at` given as the bare string `"after"` / `"before"` with a block
|
|
67
|
+
* `into` — "right after block X" is `into: X's parent, at: { after: X }`. The
|
|
68
|
+
* fix needs the block's live row (its parent, or its document when it is top-
|
|
69
|
+
* level, spelled as the rooted path); a block the store does not know gets no fix.
|
|
70
|
+
*/
|
|
71
|
+
function bareAnchor(at, into, here, f) {
|
|
72
|
+
const message = `${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> } — "${at} block X" is into: X's parent (a block, or the document when X is top-level), at: { ${at}: X }`;
|
|
73
|
+
if (f === undefined || into.kind !== "block")
|
|
74
|
+
return effectInvalid(message);
|
|
75
|
+
const row = f.parentOf(into.id);
|
|
76
|
+
if (row === null)
|
|
77
|
+
return effectInvalid(message);
|
|
78
|
+
const parent = row.parent === null ? referencePath(row.path) : { kind: "block", id: row.parent };
|
|
79
|
+
return effectInvalid(message, f.build({ ...f.written, into: parent, at: { [at]: into.id } }));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* §8 (3.4): a plural-target `expect` written as `{ content_hash: { <id>: <hash>, … } }`
|
|
83
|
+
* (every key a guard name, every value a map of strings) — the fix inverts the
|
|
84
|
+
* nesting to `{ <id>: { content_hash: <hash> }, … }`.
|
|
85
|
+
*/
|
|
86
|
+
function invertedExpectMap(v, here, f) {
|
|
87
|
+
const keys = Object.keys(v);
|
|
88
|
+
if (keys.length === 0 || !keys.every((k) => k === "content_hash" || k === "parent_children_hash") || !Object.values(v).every(isStringRecord))
|
|
89
|
+
return null;
|
|
90
|
+
const message = `${here}: a plural-target \`expect\` is keyed by block id — { <block id>: { content_hash?, parent_children_hash? }, … }`;
|
|
91
|
+
if (f === undefined)
|
|
92
|
+
return effectInvalid(message);
|
|
93
|
+
const inverted = {};
|
|
94
|
+
for (const [guard, byId] of Object.entries(v)) {
|
|
95
|
+
for (const [id, hash] of Object.entries(byId))
|
|
96
|
+
inverted[id] = { ...inverted[id], [guard]: hash };
|
|
97
|
+
}
|
|
98
|
+
return effectInvalid(message, f.build({ ...f.written, expect: inverted }));
|
|
99
|
+
}
|
|
100
|
+
/** §8 (3.4): the block-field misspellings the fix corrects to `markdown`. */
|
|
101
|
+
const BLOCK_FIELD_ALIASES = new Set(["text", "content", "body", "raw"]);
|
|
23
102
|
function onlyKeys(e, allowed, here) {
|
|
24
103
|
const extra = Object.keys(e).filter((k) => !allowed.includes(k));
|
|
25
104
|
if (extra.length > 0)
|
|
@@ -39,9 +118,56 @@ function optionalString(e, key, here) {
|
|
|
39
118
|
throw effectInvalid(`${here}: \`${key}\` must be a string`);
|
|
40
119
|
return v;
|
|
41
120
|
}
|
|
42
|
-
|
|
121
|
+
/** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
|
|
122
|
+
export const EXPRESSION_LEADER = "-> ";
|
|
123
|
+
/** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
|
|
124
|
+
export function looksLikePath(s) {
|
|
125
|
+
return s.includes("/") || s.endsWith(".md");
|
|
126
|
+
}
|
|
127
|
+
/** A node id: `n_` + 12 hex (content-hash derived, `core/store/nodes.ts`; the `resolveRef` rule). */
|
|
128
|
+
const NODE_ID_RE = /^n_[0-9a-f]{12}$/;
|
|
129
|
+
/** A bare id of the three target kinds (§11.8 "Sugar"). */
|
|
130
|
+
export function isTargetId(s) {
|
|
131
|
+
return isValidId(s, "b") || isValidId(s, "d") || NODE_ID_RE.test(s);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A bare string where a target object is expected (3.2, §11.8 "Sugar"): a
|
|
135
|
+
* path-looking string → `{ kind: "document", path }`; a `b_`/`d_`/`n_` id →
|
|
136
|
+
* that kind; `$new.n` → `{ kind: "symbol", id }` (its kind is the symbol
|
|
137
|
+
* table's at resolution); a `#` locator → `filter_invalid` with a `fix`;
|
|
138
|
+
* anything else is no target. A `-> ` leader never reaches here from the
|
|
139
|
+
* planner (`select.ts` desugars it first); as a library input it is refused
|
|
140
|
+
* the same way, naming the planner.
|
|
141
|
+
*/
|
|
142
|
+
export function bareTarget(ref, here) {
|
|
143
|
+
if (ref.startsWith(EXPRESSION_LEADER)) {
|
|
144
|
+
const reason = `${here}: a \`-> \` expression target is bound by the planner (\`plan\` / \`apply\` / \`execute\`), not by the shape check`;
|
|
145
|
+
throw new MutationError("filter_invalid", reason, { reason, hint: "selection" });
|
|
146
|
+
}
|
|
147
|
+
if (ref.includes("#")) {
|
|
148
|
+
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\``;
|
|
149
|
+
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" });
|
|
150
|
+
}
|
|
151
|
+
if (isNewSymbol(ref))
|
|
152
|
+
return { kind: "symbol", id: ref };
|
|
153
|
+
if (isValidId(ref, "b"))
|
|
154
|
+
return { kind: "block", id: ref };
|
|
155
|
+
if (isValidId(ref, "d"))
|
|
156
|
+
return { kind: "document", id: ref };
|
|
157
|
+
if (NODE_ID_RE.test(ref))
|
|
158
|
+
return { kind: "node", id: ref };
|
|
159
|
+
if (looksLikePath(ref))
|
|
160
|
+
return { kind: "document", path: ref };
|
|
161
|
+
throw effectInvalid(`${here}: a target is { kind: …, … } or a bare document path, a \`b_\`/\`d_\`/\`n_\` id or \`$new.n\` (got ${JSON.stringify(ref)})`);
|
|
162
|
+
}
|
|
163
|
+
export function validateTarget(v, here, kinds) {
|
|
164
|
+
if (typeof v === "string")
|
|
165
|
+
v = bareTarget(v, here);
|
|
43
166
|
if (!isRecord(v) || typeof v.kind !== "string")
|
|
44
167
|
throw effectInvalid(`${here}: a target is { kind: ${kinds.map((k) => `"${k}"`).join(" | ")}, … }`);
|
|
168
|
+
// A bare `$new.n` defers its kind check to resolution (`resolveEffect`); the `kinds` are the slot's.
|
|
169
|
+
if (v.kind === "symbol" && typeof v.id === "string" && isNewSymbol(v.id) && Object.keys(v).length === 2)
|
|
170
|
+
return { kind: "symbol", id: v.id };
|
|
45
171
|
if (!kinds.includes(v.kind))
|
|
46
172
|
throw effectInvalid(`${here}: \`kind\` must be one of ${kinds.join(", ")} (got "${v.kind}")`);
|
|
47
173
|
switch (v.kind) {
|
|
@@ -69,7 +195,7 @@ function validateTargets(v, here) {
|
|
|
69
195
|
throw effectInvalid(`${here}: \`targets\` must be a non-empty array of block targets`);
|
|
70
196
|
return v.map((t, i) => validateTarget(t, `${here}.targets[${i}]`, ["block"]));
|
|
71
197
|
}
|
|
72
|
-
function validateAt(v, here) {
|
|
198
|
+
function validateAt(v, here, into, f) {
|
|
73
199
|
if (v === undefined)
|
|
74
200
|
return undefined;
|
|
75
201
|
if (v === "start" || v === "end")
|
|
@@ -80,13 +206,17 @@ function validateAt(v, here) {
|
|
|
80
206
|
if (typeof v.after === "string")
|
|
81
207
|
return { after: v.after };
|
|
82
208
|
}
|
|
209
|
+
if ((v === "before" || v === "after") && into !== undefined)
|
|
210
|
+
throw bareAnchor(v, into, here, f);
|
|
83
211
|
throw effectInvalid(`${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> }`);
|
|
84
212
|
}
|
|
85
|
-
function validateExpect(v, here) {
|
|
213
|
+
function validateExpect(v, here, f) {
|
|
86
214
|
if (v === undefined)
|
|
87
215
|
return undefined;
|
|
88
216
|
if (!isRecord(v))
|
|
89
217
|
throw effectInvalid(`${here}: \`expect\` must be an object`);
|
|
218
|
+
if ("max" in v)
|
|
219
|
+
throw effectLevelMax(v, here, f);
|
|
90
220
|
onlyKeys(v, ["content_hash", "parent_children_hash", "revision"], `${here}.expect`);
|
|
91
221
|
const out = {};
|
|
92
222
|
for (const k of ["content_hash", "parent_children_hash", "revision"]) {
|
|
@@ -96,11 +226,16 @@ function validateExpect(v, here) {
|
|
|
96
226
|
}
|
|
97
227
|
return out;
|
|
98
228
|
}
|
|
99
|
-
function validateExpectMap(v, here) {
|
|
229
|
+
function validateExpectMap(v, here, f) {
|
|
100
230
|
if (v === undefined)
|
|
101
231
|
return undefined;
|
|
102
232
|
if (!isRecord(v))
|
|
103
233
|
throw effectInvalid(`${here}: \`expect\` must be an object keyed by block id`);
|
|
234
|
+
if ("max" in v)
|
|
235
|
+
throw effectLevelMax(v, here, f);
|
|
236
|
+
const inverted = invertedExpectMap(v, here, f);
|
|
237
|
+
if (inverted !== null)
|
|
238
|
+
throw inverted;
|
|
104
239
|
const out = {};
|
|
105
240
|
for (const [id, x] of Object.entries(v)) {
|
|
106
241
|
if (!isRecord(x))
|
|
@@ -117,13 +252,14 @@ function validateExpectMap(v, here) {
|
|
|
117
252
|
}
|
|
118
253
|
return out;
|
|
119
254
|
}
|
|
120
|
-
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
|
|
121
|
-
export function validateEffect(v, i) {
|
|
255
|
+
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`); `fix` (3.4) lets the shape errors of §8 carry their corrected call. */
|
|
256
|
+
export function validateEffect(v, i, fix) {
|
|
122
257
|
const here = `effects[${i}]`;
|
|
123
258
|
if (!isRecord(v) || typeof v.op !== "string")
|
|
124
259
|
throw effectInvalid(`${here}: an effect is an object whose \`op\` is one of ${EFFECT_OPS.join("/")}`);
|
|
125
260
|
if (!EFFECT_OPS.includes(v.op))
|
|
126
261
|
throw effectInvalid(`${here}: \`op\` must be one of ${EFFECT_OPS.join("/")} (got "${v.op}")`);
|
|
262
|
+
const f = fixerFor(fix, i, v);
|
|
127
263
|
switch (v.op) {
|
|
128
264
|
case "create": {
|
|
129
265
|
onlyKeys(v, ["op", "path", "markdown", "frontmatter"], here);
|
|
@@ -136,9 +272,9 @@ export function validateEffect(v, i) {
|
|
|
136
272
|
case "insert": {
|
|
137
273
|
onlyKeys(v, ["op", "into", "at", "markdown", "expect"], here);
|
|
138
274
|
const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
|
|
139
|
-
const at = validateAt(v.at, here);
|
|
275
|
+
const at = validateAt(v.at, here, into, f);
|
|
140
276
|
const markdown = requireString(v, "markdown", here);
|
|
141
|
-
const expect = validateExpect(v.expect, here);
|
|
277
|
+
const expect = validateExpect(v.expect, here, f);
|
|
142
278
|
return { op: "insert", into, ...(at !== undefined ? { at } : {}), markdown, ...(expect !== undefined ? { expect } : {}) };
|
|
143
279
|
}
|
|
144
280
|
case "set": {
|
|
@@ -147,16 +283,18 @@ export function validateEffect(v, i) {
|
|
|
147
283
|
const field = requireString(v, "field", here);
|
|
148
284
|
if (!("value" in v))
|
|
149
285
|
throw effectInvalid(`${here}: \`value\` is required`);
|
|
150
|
-
|
|
151
|
-
|
|
286
|
+
if (target.kind !== "symbol")
|
|
287
|
+
checkField(target.kind, field, here, "set", f);
|
|
288
|
+
const expect = validateExpect(v.expect, here, f);
|
|
152
289
|
return { op: "set", target, field, value: v.value, ...(expect !== undefined ? { expect } : {}) };
|
|
153
290
|
}
|
|
154
291
|
case "unset": {
|
|
155
292
|
onlyKeys(v, ["op", "target", "field", "expect"], here);
|
|
156
293
|
const target = validateTarget(v.target, `${here}.target`, ["document"]);
|
|
157
294
|
const field = requireString(v, "field", here);
|
|
158
|
-
|
|
159
|
-
|
|
295
|
+
if (target.kind !== "symbol")
|
|
296
|
+
checkField(target.kind, field, here, "unset");
|
|
297
|
+
const expect = validateExpect(v.expect, here, f);
|
|
160
298
|
return { op: "unset", target, field, ...(expect !== undefined ? { expect } : {}) };
|
|
161
299
|
}
|
|
162
300
|
case "move": {
|
|
@@ -164,8 +302,8 @@ export function validateEffect(v, i) {
|
|
|
164
302
|
onlyKeys(v, ["op", "targets", "into", "at", "expect"], here);
|
|
165
303
|
const targets = validateTargets(v.targets, here);
|
|
166
304
|
const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
|
|
167
|
-
const at = validateAt(v.at, here);
|
|
168
|
-
const expect = validateExpect(v.expect, here);
|
|
305
|
+
const at = validateAt(v.at, here, into, f);
|
|
306
|
+
const expect = validateExpect(v.expect, here, f);
|
|
169
307
|
return { op: "move", targets, into, ...(at !== undefined ? { at } : {}), ...(expect !== undefined ? { expect } : {}) };
|
|
170
308
|
}
|
|
171
309
|
onlyKeys(v, ["op", "target", "to", "retarget_inbound"], here);
|
|
@@ -179,7 +317,7 @@ export function validateEffect(v, i) {
|
|
|
179
317
|
if ("targets" in v) {
|
|
180
318
|
onlyKeys(v, ["op", "targets", "expect"], here);
|
|
181
319
|
const targets = validateTargets(v.targets, here);
|
|
182
|
-
const expect = validateExpectMap(v.expect, here);
|
|
320
|
+
const expect = validateExpectMap(v.expect, here, f);
|
|
183
321
|
return { op: "remove", targets, ...(expect !== undefined ? { expect } : {}) };
|
|
184
322
|
}
|
|
185
323
|
onlyKeys(v, ["op", "target"], here);
|
|
@@ -190,14 +328,14 @@ export function validateEffect(v, i) {
|
|
|
190
328
|
const target = validateTarget(v.target, `${here}.target`, ["block"]);
|
|
191
329
|
if (!Array.isArray(v.at) || v.at.some((n) => typeof n !== "number" || !Number.isInteger(n) || n < 0))
|
|
192
330
|
throw effectInvalid(`${here}: \`at\` must be an array of byte offsets`);
|
|
193
|
-
const expect = validateExpect(v.expect, here);
|
|
331
|
+
const expect = validateExpect(v.expect, here, f);
|
|
194
332
|
return { op: "split", target, at: v.at, ...(expect !== undefined ? { expect } : {}) };
|
|
195
333
|
}
|
|
196
334
|
case "merge": {
|
|
197
335
|
onlyKeys(v, ["op", "targets", "separator", "expect"], here);
|
|
198
336
|
const targets = validateTargets(v.targets, here);
|
|
199
337
|
const separator = optionalString(v, "separator", here);
|
|
200
|
-
const expect = validateExpectMap(v.expect, here);
|
|
338
|
+
const expect = validateExpectMap(v.expect, here, f);
|
|
201
339
|
return { op: "merge", targets, ...(separator !== undefined ? { separator } : {}), ...(expect !== undefined ? { expect } : {}) };
|
|
202
340
|
}
|
|
203
341
|
case "retarget": {
|
|
@@ -216,16 +354,16 @@ export function validateEffect(v, i) {
|
|
|
216
354
|
}
|
|
217
355
|
}
|
|
218
356
|
/** The whole list; a non-array or an empty list is a shape error too. */
|
|
219
|
-
export function validateEffects(v) {
|
|
357
|
+
export function validateEffects(v, fix) {
|
|
220
358
|
if (!Array.isArray(v))
|
|
221
359
|
throw effectInvalid("`effects` must be an array of effects");
|
|
222
360
|
if (v.length === 0)
|
|
223
361
|
throw effectInvalid("`effects` must name at least one effect");
|
|
224
|
-
return v.map((e, i) => validateEffect(e, i));
|
|
362
|
+
return v.map((e, i) => validateEffect(e, i, fix));
|
|
225
363
|
}
|
|
226
364
|
// §11.2: the `field` spellings per target kind.
|
|
227
365
|
export const FRONTMATTER_FIELD = /^frontmatter\.(.+)$/;
|
|
228
|
-
function checkField(kind, field, here, op) {
|
|
366
|
+
function checkField(kind, field, here, op, f) {
|
|
229
367
|
if (kind === "document") {
|
|
230
368
|
if (FRONTMATTER_FIELD.test(field) || (op === "set" && field === "content"))
|
|
231
369
|
return;
|
|
@@ -234,7 +372,9 @@ function checkField(kind, field, here, op) {
|
|
|
234
372
|
if (kind === "block") {
|
|
235
373
|
if (field === "markdown" || field === "attrs.checked")
|
|
236
374
|
return;
|
|
237
|
-
|
|
375
|
+
// (3.4, §8) `text` / `content` / `body` / `raw` on a block: the fix spells `markdown`.
|
|
376
|
+
const fix = f !== undefined && BLOCK_FIELD_ALIASES.has(field) ? f.build({ ...f.written, field: "markdown" }) : undefined;
|
|
377
|
+
throw effectInvalid(`${here}: a block's \`field\` is \`markdown\` or \`attrs.checked\` (got "${field}")`, fix);
|
|
238
378
|
}
|
|
239
379
|
// node: the adapter's editable props (`node_not_editable` names them when unknown)
|
|
240
380
|
if (field === "" || field.includes("."))
|
|
@@ -337,10 +477,14 @@ export function resolveDocumentTarget(ctx, t) {
|
|
|
337
477
|
throw effectInvalid(`id ${JSON.stringify(t.id)} and path ${JSON.stringify(t.path)} name different documents`);
|
|
338
478
|
return { kind: "document", id: t.id, path: created.path };
|
|
339
479
|
}
|
|
340
|
-
|
|
341
|
-
|
|
480
|
+
return findDocumentTarget(ctx.store, ctx.repoId, t);
|
|
481
|
+
}
|
|
482
|
+
/** An existing document's `{ id, path }` for a literal document target (`doc_missing`; `filter_invalid` when `id` and `path` disagree). */
|
|
483
|
+
export function findDocumentTarget(store, repoId, t) {
|
|
484
|
+
const byId = t.id !== undefined ? findDoc(store, { docId: t.id }) : null;
|
|
485
|
+
if (t.id !== undefined && (!byId || byId.repoId !== repoId))
|
|
342
486
|
throw docMissing(t.id);
|
|
343
|
-
const byPath = t.path !== undefined ? findDoc(
|
|
487
|
+
const byPath = t.path !== undefined ? findDoc(store, { repoId, path: t.path }) : null;
|
|
344
488
|
if (t.path !== undefined && !byPath)
|
|
345
489
|
throw docMissing(t.path);
|
|
346
490
|
if (byId && byPath && byId.docId !== byPath.docId)
|
|
@@ -348,6 +492,15 @@ export function resolveDocumentTarget(ctx, t) {
|
|
|
348
492
|
const info = (byId ?? byPath);
|
|
349
493
|
return { kind: "document", id: info.docId, path: info.path };
|
|
350
494
|
}
|
|
495
|
+
/** A bare `$new.n` target (3.2): the kind the symbol table recorded, checked against the slot's admitted kinds. */
|
|
496
|
+
function resolveSymbolTarget(ctx, symbol, kinds, here) {
|
|
497
|
+
const kind = ctx.symbols.kindOf(symbol);
|
|
498
|
+
if (kind === undefined)
|
|
499
|
+
throw new MutationError("target_missing", `${symbol} is not created by an earlier effect`, { symbol });
|
|
500
|
+
if (!kinds.includes(kind))
|
|
501
|
+
throw effectInvalid(`${here}: \`kind\` must be one of ${kinds.join(", ")} (got "${kind}")`);
|
|
502
|
+
return { kind, id: symbol };
|
|
503
|
+
}
|
|
351
504
|
function symbolMissing(ctx, symbol, wanted) {
|
|
352
505
|
const kind = ctx.symbols.kindOf(symbol);
|
|
353
506
|
if (kind === undefined)
|
|
@@ -393,6 +546,80 @@ function blockInfo(ctx, id) {
|
|
|
393
546
|
throw new MutationError("block_missing", `block ${id} not found`, { block: id });
|
|
394
547
|
return { docId: row.doc_id, hash: row.h, block: null };
|
|
395
548
|
}
|
|
549
|
+
// ---- the summary (§11.9, 3.3) ---------------------------------------------------------
|
|
550
|
+
//
|
|
551
|
+
// Channel 3 of the teaching surfaces: the plan is a worked example of itself.
|
|
552
|
+
// One line in words per resolved effect — `preview.summary`, which the Receipt
|
|
553
|
+
// repeats as its `summary` and the CLI prints under the commit confirmation.
|
|
554
|
+
// Deterministic and byte-identical across engines (the wording table of §11.9):
|
|
555
|
+
// a document by its rooted path, a heading by its text in quotes, any other
|
|
556
|
+
// block by its type and id, counts with their nouns pluralized by an `s`.
|
|
557
|
+
/** `1 block` / `2 blocks` (an `s` on every noun of the table). */
|
|
558
|
+
export function plural(n, noun) {
|
|
559
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
560
|
+
}
|
|
561
|
+
/** The rooted path of a document the plan addresses — a created document's from its `create`. */
|
|
562
|
+
function docPathOf(ctx, docId) {
|
|
563
|
+
if (isNewSymbol(docId)) {
|
|
564
|
+
const created = ctx.creates.find((c) => c.symbol === docId);
|
|
565
|
+
if (created)
|
|
566
|
+
return referencePath(created.path);
|
|
567
|
+
}
|
|
568
|
+
const info = findDoc(ctx.store, { docId });
|
|
569
|
+
return referencePath(info?.path ?? docId);
|
|
570
|
+
}
|
|
571
|
+
/** A block's type and visible text: the live row when it has one, else the planner's tree (a `$new.k`). */
|
|
572
|
+
function blockDescriptor(ctx, id) {
|
|
573
|
+
if (!isNewSymbol(id)) {
|
|
574
|
+
const row = ctx.store.db.prepare("SELECT type, text FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(id);
|
|
575
|
+
if (row)
|
|
576
|
+
return { type: row.type, text: row.text };
|
|
577
|
+
}
|
|
578
|
+
const inTree = locateLoaded(ctx, id);
|
|
579
|
+
if (!inTree)
|
|
580
|
+
return null;
|
|
581
|
+
return { type: inTree.block.type, text: normalizeVisibleText(inTree.block.raw, inTree.block.type) };
|
|
582
|
+
}
|
|
583
|
+
/** The name of a block in a summary line: `heading "Text"` for a heading, else `<type> <id>` (`paragraph b_12`, `task b_9`). */
|
|
584
|
+
function blockName(ctx, id) {
|
|
585
|
+
const d = blockDescriptor(ctx, id);
|
|
586
|
+
if (d === null)
|
|
587
|
+
return `block ${id}`;
|
|
588
|
+
return d.type === "heading" ? `heading ${ctx.canonical(d.text)}` : `${d.type} ${id}`;
|
|
589
|
+
}
|
|
590
|
+
/** A section by its heading's text: `section "Risks"`. */
|
|
591
|
+
function sectionName(ctx, headingId) {
|
|
592
|
+
const d = blockDescriptor(ctx, headingId);
|
|
593
|
+
return `section ${ctx.canonical(d?.text ?? headingId)}`;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The placement half of an `insert` / `move blocks` line: `<verb> the end|start
|
|
597
|
+
* of <container> [in <path>]` — the container a document (its path; no `in`), a
|
|
598
|
+
* section (`section "Risks"`) or a block (its name) — or `before|after <block>
|
|
599
|
+
* in <path>`. `verb` is `at` for an insert, `to` for a move.
|
|
600
|
+
*/
|
|
601
|
+
function placeWords(ctx, into, at, verb) {
|
|
602
|
+
const path = docPathOf(ctx, targetDoc(ctx, into));
|
|
603
|
+
if (at !== "start" && at !== "end") {
|
|
604
|
+
const anchor = "before" in at ? at.before : at.after;
|
|
605
|
+
return `${"before" in at ? "before" : "after"} ${blockName(ctx, anchor)} in ${path}`;
|
|
606
|
+
}
|
|
607
|
+
const edge = `${verb} the ${at}`;
|
|
608
|
+
switch (into.kind) {
|
|
609
|
+
case "document":
|
|
610
|
+
return `${edge} of ${path}`;
|
|
611
|
+
case "section":
|
|
612
|
+
return `${edge} of ${sectionName(ctx, into.heading_id)} in ${path}`;
|
|
613
|
+
case "block":
|
|
614
|
+
return `${edge} of ${blockName(ctx, into.id)} in ${path}`;
|
|
615
|
+
case "node":
|
|
616
|
+
return `${edge} of node ${into.id} in ${path}`;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/** The document of a block list's first block (a kernel `remove`/`merge` names blocks of one document). */
|
|
620
|
+
function docOfBlocks(ctx, ids) {
|
|
621
|
+
return docPathOf(ctx, blockInfo(ctx, ids[0]).docId);
|
|
622
|
+
}
|
|
396
623
|
/** Every descendant id of a block: the planner's tree when loaded, else the store's live rows. */
|
|
397
624
|
function descendantsOf(ctx, id) {
|
|
398
625
|
const inTree = locateLoaded(ctx, id);
|
|
@@ -535,30 +762,46 @@ function describe(e) {
|
|
|
535
762
|
function targetName(t) {
|
|
536
763
|
return t.kind === "section" ? `section ${t.heading_id}` : `${t.kind} ${t.id}`;
|
|
537
764
|
}
|
|
538
|
-
/**
|
|
539
|
-
|
|
540
|
-
|
|
765
|
+
/** The target kinds each slot admits (§11.1); `validateTarget` checks a literal, `resolveEffect` a bare `$new.n`. */
|
|
766
|
+
const PLACEMENT_KINDS = ["document", "section", "block"];
|
|
767
|
+
const DOCUMENT_KIND = ["document"];
|
|
768
|
+
const BLOCK_KIND = ["block"];
|
|
769
|
+
const SET_KINDS = ["document", "block", "node"];
|
|
770
|
+
/** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit; `i` names the effect in messages. */
|
|
771
|
+
export function resolveEffect(ctx, e, i = 0) {
|
|
772
|
+
const here = `effects[${i}]`;
|
|
773
|
+
const target = (t, kinds, member) => {
|
|
774
|
+
const literal = t.kind === "symbol" ? resolveSymbolTarget(ctx, t.id, kinds, `${here}.${member}`) : t;
|
|
775
|
+
return literal.kind === "document" ? resolveDocumentTarget(ctx, literal) : literal;
|
|
776
|
+
};
|
|
777
|
+
const targets = (list) => list.map((t, k) => target(t, BLOCK_KIND, `targets[${k}]`));
|
|
541
778
|
switch (e.op) {
|
|
542
779
|
case "create":
|
|
543
780
|
return { op: "create", path: canonicalDocPath(e.path), markdown: e.markdown, ...(e.frontmatter !== undefined ? { frontmatter: e.frontmatter } : {}) };
|
|
544
781
|
case "insert":
|
|
545
|
-
return { op: "insert", into: target(e.into), at: e.at ?? "end", markdown: e.markdown, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
782
|
+
return { op: "insert", into: target(e.into, PLACEMENT_KINDS, "into"), at: e.at ?? "end", markdown: e.markdown, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
546
783
|
case "set":
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
784
|
+
case "unset": {
|
|
785
|
+
const t = target(e.target, e.op === "set" ? SET_KINDS : DOCUMENT_KIND, "target");
|
|
786
|
+
// A bare `$new.n` deferred its `field` check (§11.2) to here, where its kind is known.
|
|
787
|
+
if (e.target.kind === "symbol")
|
|
788
|
+
checkField(t.kind, e.field, here, e.op);
|
|
789
|
+
if (e.op === "set")
|
|
790
|
+
return { op: "set", target: t, field: e.field, value: e.value, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
791
|
+
return { op: "unset", target: t, field: e.field, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
792
|
+
}
|
|
550
793
|
case "move":
|
|
551
794
|
if ("targets" in e)
|
|
552
|
-
return { op: "move", targets: e.targets
|
|
553
|
-
return { op: "move", target: target(e.target), to: canonicalDocPath(e.to), retarget_inbound: e.retarget_inbound !== false };
|
|
795
|
+
return { op: "move", targets: targets(e.targets), into: target(e.into, PLACEMENT_KINDS, "into"), at: e.at ?? "end", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
796
|
+
return { op: "move", target: target(e.target, DOCUMENT_KIND, "target"), to: canonicalDocPath(e.to), retarget_inbound: e.retarget_inbound !== false };
|
|
554
797
|
case "remove":
|
|
555
798
|
if ("targets" in e)
|
|
556
|
-
return { op: "remove", targets: e.targets
|
|
557
|
-
return { op: "remove", target: target(e.target) };
|
|
799
|
+
return { op: "remove", targets: targets(e.targets), ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
800
|
+
return { op: "remove", target: target(e.target, DOCUMENT_KIND, "target") };
|
|
558
801
|
case "split":
|
|
559
|
-
return { op: "split", target: target(e.target), at: [...e.at], ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
802
|
+
return { op: "split", target: target(e.target, BLOCK_KIND, "target"), at: [...e.at], ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
560
803
|
case "merge":
|
|
561
|
-
return { op: "merge", targets: e.targets
|
|
804
|
+
return { op: "merge", targets: targets(e.targets), separator: e.separator ?? " ", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
|
|
562
805
|
case "retarget":
|
|
563
806
|
return { op: "retarget", repairs: e.repairs.map((r) => ({ from: r.from, to: r.to })), ...(e.path_glob !== undefined ? { path_glob: e.path_glob } : {}) };
|
|
564
807
|
}
|
|
@@ -640,7 +883,7 @@ export function shiftPlaceholders(op, offset) {
|
|
|
640
883
|
* resolved effect, or null when it was dropped.
|
|
641
884
|
*/
|
|
642
885
|
export function lowerEffect(ctx, i, effect) {
|
|
643
|
-
const e = resolveEffect(ctx, effect);
|
|
886
|
+
const e = resolveEffect(ctx, effect, i);
|
|
644
887
|
const info = focusOf(ctx, e);
|
|
645
888
|
if (!ctx.tracker.check(i, info, describe(e)))
|
|
646
889
|
return null;
|
|
@@ -755,9 +998,11 @@ function applyLowering(ctx, i, e) {
|
|
|
755
998
|
// document, so every later effect addresses it and its blocks like any other.
|
|
756
999
|
const symbol = ctx.symbols.alloc("document");
|
|
757
1000
|
const source = composeFile(e.markdown, e.frontmatter);
|
|
758
|
-
|
|
1001
|
+
const tree = parseNewDoc(symbol, e.path, source);
|
|
1002
|
+
ctx.planner.adoptNew(symbol, tree);
|
|
759
1003
|
ctx.creates.push({ symbol, path: e.path, source, before_op: ctx.ops.length });
|
|
760
1004
|
ctx.absentPaths.push(e.path);
|
|
1005
|
+
ctx.summary.push(`create ${referencePath(e.path)} (${plural(tree.children.length, "block")})`);
|
|
761
1006
|
return;
|
|
762
1007
|
}
|
|
763
1008
|
case "insert": {
|
|
@@ -766,7 +1011,9 @@ function applyLowering(ctx, i, e) {
|
|
|
766
1011
|
recordParentPin(ctx, doc, e.into, e.expect);
|
|
767
1012
|
const to = placement(ctx, e.into, e.at);
|
|
768
1013
|
const expect = kernelExpect(e.expect);
|
|
1014
|
+
const where = placeWords(ctx, e.into, e.at, "at");
|
|
769
1015
|
pushOp(ctx, { op: "insert", ...(e.into.kind === "document" ? { doc } : {}), to, markdown: e.markdown, ...(expect !== undefined ? { expect } : {}) });
|
|
1016
|
+
ctx.summary.push(`insert ${plural(ctx.planner.results[ctx.planner.results.length - 1].ids.length, "block")} ${where}`);
|
|
770
1017
|
return;
|
|
771
1018
|
}
|
|
772
1019
|
case "set":
|
|
@@ -780,6 +1027,7 @@ function applyLowering(ctx, i, e) {
|
|
|
780
1027
|
if (isNewSymbol(docId))
|
|
781
1028
|
throw new MutationError("target_missing", `${docId} is a document this plan creates; its content is the create's markdown`, { symbol: docId });
|
|
782
1029
|
lowerContent(ctx, docId, e.value);
|
|
1030
|
+
ctx.summary.push(`set content of ${docPathOf(ctx, docId)}`);
|
|
783
1031
|
return;
|
|
784
1032
|
}
|
|
785
1033
|
// `frontmatter.<key>` — on a created document too (3.0): its tree is loaded, the patch is a head like any other.
|
|
@@ -791,6 +1039,7 @@ function applyLowering(ctx, i, e) {
|
|
|
791
1039
|
else
|
|
792
1040
|
delete merged[key];
|
|
793
1041
|
patchFrontmatter(ctx, docId, merged, body);
|
|
1042
|
+
ctx.summary.push(e.op === "set" ? `set ${e.field} = ${ctx.canonical(e.value)} in ${docPathOf(ctx, docId)}` : `unset ${e.field} in ${docPathOf(ctx, docId)}`);
|
|
794
1043
|
return;
|
|
795
1044
|
}
|
|
796
1045
|
if (e.op !== "set")
|
|
@@ -800,15 +1049,18 @@ function applyLowering(ctx, i, e) {
|
|
|
800
1049
|
const { docId } = blockInfo(ctx, id);
|
|
801
1050
|
checkRevision(ctx, docId, e.expect);
|
|
802
1051
|
const hash = pin(ctx, id, e.expect?.content_hash);
|
|
1052
|
+
const name = blockName(ctx, id);
|
|
803
1053
|
if (e.field === "markdown") {
|
|
804
1054
|
if (typeof e.value !== "string")
|
|
805
1055
|
throw effectInvalid("`markdown` takes a string value");
|
|
806
1056
|
pushOp(ctx, { op: "update", block: kernelBlockId(ctx, id), markdown: e.value, expect: kernelExpect(e.expect, hash) });
|
|
1057
|
+
ctx.summary.push(`set markdown of ${name} in ${docPathOf(ctx, docId)}`);
|
|
807
1058
|
}
|
|
808
1059
|
else {
|
|
809
1060
|
if (typeof e.value !== "boolean")
|
|
810
1061
|
throw effectInvalid("`attrs.checked` takes a boolean value");
|
|
811
1062
|
pushOp(ctx, { op: "update", block: kernelBlockId(ctx, id), attrs: { checked: e.value }, expect: kernelExpect(e.expect, hash) });
|
|
1063
|
+
ctx.summary.push(`${e.value ? "check" : "uncheck"} ${name} in ${docPathOf(ctx, docId)}`);
|
|
812
1064
|
}
|
|
813
1065
|
return;
|
|
814
1066
|
}
|
|
@@ -822,6 +1074,7 @@ function applyLowering(ctx, i, e) {
|
|
|
822
1074
|
const hash = pin(ctx, blockId, e.expect?.content_hash ?? u.expect?.content_hash);
|
|
823
1075
|
pushOp(ctx, { ...u, expect: { ...(u.expect ?? {}), content_hash: hash } });
|
|
824
1076
|
}
|
|
1077
|
+
ctx.summary.push(`set ${e.field} = ${ctx.canonical(e.value)} on node ${node.id} in ${docPathOf(ctx, docId)}`);
|
|
825
1078
|
return;
|
|
826
1079
|
}
|
|
827
1080
|
case "move": {
|
|
@@ -832,9 +1085,12 @@ function applyLowering(ctx, i, e) {
|
|
|
832
1085
|
recordParentPin(ctx, dst, e.into, e.expect);
|
|
833
1086
|
const to = placement(ctx, e.into, e.at);
|
|
834
1087
|
const expect = kernelExpect(e.expect);
|
|
1088
|
+
const what = ids.length === 1 ? blockName(ctx, ids[0]) : plural(ids.length, "block");
|
|
1089
|
+
const where = placeWords(ctx, e.into, e.at, "to");
|
|
835
1090
|
// `doc` names the destination when `into` is a document (§2.3, 3.0): a
|
|
836
1091
|
// top-level start/end would otherwise mean the SOURCE document's.
|
|
837
1092
|
pushOp(ctx, { op: "move", blocks: ids.map((id) => kernelBlockId(ctx, id)), to, ...(e.into.kind === "document" ? { doc: dst } : {}), ...(expect !== undefined ? { expect } : {}) });
|
|
1093
|
+
ctx.summary.push(`move ${what} ${where}`);
|
|
838
1094
|
return;
|
|
839
1095
|
}
|
|
840
1096
|
const t = docTarget(e.target);
|
|
@@ -854,6 +1110,7 @@ function applyLowering(ctx, i, e) {
|
|
|
854
1110
|
ctx.docBases.set(t.id, base);
|
|
855
1111
|
const inbound = inboundLinksTo(ctx.store, ctx.repoId, t.id, t.path);
|
|
856
1112
|
let retargeted = null;
|
|
1113
|
+
let links = "";
|
|
857
1114
|
if (e.retarget_inbound && inbound.length > 0) {
|
|
858
1115
|
const plan = planInboundRetarget(ctx.store, t.id, t.path, e.to, inbound);
|
|
859
1116
|
for (const op of plan.ops) {
|
|
@@ -862,8 +1119,10 @@ function applyLowering(ctx, i, e) {
|
|
|
862
1119
|
pushOp(ctx, u);
|
|
863
1120
|
}
|
|
864
1121
|
retargeted = { blocks: plan.blocks, docs: plan.docs };
|
|
1122
|
+
links = ` (retarget ${plural(inbound.length - plan.dangling.length, "link")} in ${plural(plan.docs.length, "document")})`;
|
|
865
1123
|
}
|
|
866
1124
|
ctx.moves.push({ id: t.id, from: t.path, to: e.to, currentRev: base.currentRev, inbound, retargeted });
|
|
1125
|
+
ctx.summary.push(`move ${referencePath(t.path)} → ${referencePath(e.to)}${links}`);
|
|
867
1126
|
return;
|
|
868
1127
|
}
|
|
869
1128
|
case "remove": {
|
|
@@ -875,7 +1134,9 @@ function applyLowering(ctx, i, e) {
|
|
|
875
1134
|
const hash = pin(ctx, id, given?.content_hash);
|
|
876
1135
|
expect[kernelBlockId(ctx, id)] = { ...(given ?? {}), content_hash: hash };
|
|
877
1136
|
}
|
|
1137
|
+
const line = `remove ${ids.length === 1 ? blockName(ctx, ids[0]) : plural(ids.length, "block")} from ${docOfBlocks(ctx, ids)}`;
|
|
878
1138
|
pushOp(ctx, { op: "remove", blocks: ids.map((id) => kernelBlockId(ctx, id)), expect });
|
|
1139
|
+
ctx.summary.push(line);
|
|
879
1140
|
return;
|
|
880
1141
|
}
|
|
881
1142
|
const t = docTarget(e.target);
|
|
@@ -887,6 +1148,7 @@ function applyLowering(ctx, i, e) {
|
|
|
887
1148
|
if (!ctx.docBases.has(t.id))
|
|
888
1149
|
ctx.docBases.set(t.id, base);
|
|
889
1150
|
ctx.tombstones.push({ id: t.id, path: t.path, currentRev: base.currentRev });
|
|
1151
|
+
ctx.summary.push(`remove ${referencePath(t.path)}`);
|
|
890
1152
|
return;
|
|
891
1153
|
}
|
|
892
1154
|
case "split": {
|
|
@@ -894,7 +1156,9 @@ function applyLowering(ctx, i, e) {
|
|
|
894
1156
|
const { docId } = blockInfo(ctx, id);
|
|
895
1157
|
checkRevision(ctx, docId, e.expect);
|
|
896
1158
|
const hash = pin(ctx, id, e.expect?.content_hash);
|
|
1159
|
+
const name = blockName(ctx, id);
|
|
897
1160
|
pushOp(ctx, { op: "split", block: kernelBlockId(ctx, id), at: [...e.at], expect: kernelExpect(e.expect, hash) });
|
|
1161
|
+
ctx.summary.push(`split ${name} at offset${e.at.length === 1 ? "" : "s"} ${e.at.join(", ")} in ${docPathOf(ctx, docId)}`);
|
|
898
1162
|
return;
|
|
899
1163
|
}
|
|
900
1164
|
case "merge": {
|
|
@@ -905,7 +1169,9 @@ function applyLowering(ctx, i, e) {
|
|
|
905
1169
|
const hash = pin(ctx, id, given?.content_hash);
|
|
906
1170
|
expect[kernelBlockId(ctx, id)] = { ...(given ?? {}), content_hash: hash };
|
|
907
1171
|
}
|
|
1172
|
+
const line = `merge ${plural(ids.length, "block")} into ${blockName(ctx, ids[0])} in ${docOfBlocks(ctx, ids)}`;
|
|
908
1173
|
pushOp(ctx, { op: "merge", blocks: ids.map((id) => kernelBlockId(ctx, id)), separator: e.separator, expect });
|
|
1174
|
+
ctx.summary.push(line);
|
|
909
1175
|
return;
|
|
910
1176
|
}
|
|
911
1177
|
case "retarget": {
|
|
@@ -916,6 +1182,9 @@ function applyLowering(ctx, i, e) {
|
|
|
916
1182
|
pin(ctx, u.block, u.expect?.content_hash);
|
|
917
1183
|
pushOp(ctx, u);
|
|
918
1184
|
}
|
|
1185
|
+
const docs = new Set(plan.hits.map((h) => h.path)).size;
|
|
1186
|
+
const pair = e.repairs.length === 1 ? `${e.repairs[0].from} → ${e.repairs[0].to}` : `over ${plural(e.repairs.length, "pair")}`;
|
|
1187
|
+
ctx.summary.push(`retarget ${plural(plan.hits.length, "link")} ${pair} in ${plural(docs, "document")}`);
|
|
919
1188
|
return;
|
|
920
1189
|
}
|
|
921
1190
|
}
|