@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.
Files changed (47) hide show
  1. package/dist/src/core/ingest.d.ts +2 -0
  2. package/dist/src/core/ingest.js +13 -0
  3. package/dist/src/core/read/document.d.ts +9 -8
  4. package/dist/src/core/read/document.js +0 -2
  5. package/dist/src/core/store/gc.d.ts +13 -1
  6. package/dist/src/core/store/gc.js +24 -5
  7. package/dist/src/core/store/size.d.ts +79 -0
  8. package/dist/src/core/store/size.js +232 -0
  9. package/dist/src/index.d.ts +4 -1
  10. package/dist/src/index.js +5 -1
  11. package/dist/src/mcp/arguments.d.ts +10 -2
  12. package/dist/src/mcp/arguments.js +37 -3
  13. package/dist/src/mcp/errors.d.ts +1 -1
  14. package/dist/src/mcp/reference.d.ts +1 -1
  15. package/dist/src/mcp/reference.js +63 -10
  16. package/dist/src/mcp/server.d.ts +34 -1
  17. package/dist/src/mcp/server.js +158 -46
  18. package/dist/src/mutate/canonical.d.ts +27 -0
  19. package/dist/src/mutate/canonical.js +138 -0
  20. package/dist/src/mutate/effects.d.ts +58 -8
  21. package/dist/src/mutate/effects.js +312 -43
  22. package/dist/src/mutate/known-ids.d.ts +19 -0
  23. package/dist/src/mutate/known-ids.js +47 -7
  24. package/dist/src/mutate/ops.d.ts +1 -0
  25. package/dist/src/mutate/ops.js +20 -1
  26. package/dist/src/mutate/plan.d.ts +26 -16
  27. package/dist/src/mutate/plan.js +152 -112
  28. package/dist/src/mutate/select.d.ts +78 -0
  29. package/dist/src/mutate/select.js +903 -0
  30. package/dist/src/mutate/tree.d.ts +2 -2
  31. package/dist/src/oqx-js/context.d.ts +9 -2
  32. package/dist/src/oqx-js/context.js +74 -28
  33. package/dist/src/oqx-js/names.d.ts +40 -0
  34. package/dist/src/oqx-js/names.js +347 -0
  35. package/dist/src/oqx-js/planner.d.ts +3 -3
  36. package/dist/src/oqx-js/planner.js +17 -9
  37. package/dist/src/oqx-js/root-row.d.ts +15 -4
  38. package/dist/src/oqx-js/root-row.js +18 -10
  39. package/dist/src/oqx-js/run.d.ts +45 -1
  40. package/dist/src/oqx-js/run.js +114 -32
  41. package/dist/src/oqx-js/sql/translate.js +1 -1
  42. package/dist/src/spec-versions.d.ts +5 -5
  43. package/dist/src/spec-versions.js +4 -4
  44. package/dist/src/sync/observe.js +9 -3
  45. package/dist/src/version.d.ts +7 -2
  46. package/dist/src/version.js +5 -2
  47. package/package.json +2 -2
@@ -1,11 +1,14 @@
1
1
  import { findDoc } from "../core/read/reader.js";
2
- import { withIdMinter } from "../core/ids.js";
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
- function validateTarget(v, here, kinds) {
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
- checkField(target.kind, field, here, "set");
151
- const expect = validateExpect(v.expect, here);
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
- checkField(target.kind, field, here, "unset");
159
- const expect = validateExpect(v.expect, here);
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
- throw effectInvalid(`${here}: a block's \`field\` is \`markdown\` or \`attrs.checked\` (got "${field}")`);
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
- const byId = t.id !== undefined ? findDoc(ctx.store, { docId: t.id }) : null;
341
- if (t.id !== undefined && (!byId || byId.repoId !== ctx.repoId))
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(ctx.store, { repoId: ctx.repoId, path: t.path }) : null;
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
- /** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit. */
539
- export function resolveEffect(ctx, e) {
540
- const target = (t) => (t.kind === "document" ? resolveDocumentTarget(ctx, t) : t);
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
- return { op: "set", target: target(e.target), field: e.field, value: e.value, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
548
- case "unset":
549
- return { op: "unset", target: target(e.target), field: e.field, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
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.map(target), into: target(e.into), at: e.at ?? "end", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
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.map(target), ...(e.expect !== undefined ? { expect: e.expect } : {}) };
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.map(target), separator: e.separator ?? " ", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
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
- ctx.planner.adoptNew(symbol, parseNewDoc(symbol, e.path, source));
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
  }