@omgbase/core 0.4.0 → 0.4.1

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.
@@ -1,6 +1,7 @@
1
- // Error codes (06 §5). Shape: { error, message, data?, retriable }. Codes are
2
- // stable; prose is free to improve. FilterInvalid from the query compiler maps
3
- // to filter_invalid with its reason/hint in data.
1
+ import { FilterInvalid } from "../search/cel/parser.js";
2
+ import { CursorInvalid } from "../core/cursor.js";
3
+ import { MutationError } from "../mutate/tree.js";
4
+ import { RevisionNotFound } from "../graph/history.js";
4
5
  export class EngineError extends Error {
5
6
  code;
6
7
  data;
@@ -15,4 +16,17 @@ export class EngineError extends Error {
15
16
  return { error: this.code, message: this.message, data: this.data, retriable: this.retriable };
16
17
  }
17
18
  }
19
+ export function errorBody(err) {
20
+ if (err instanceof EngineError)
21
+ return { error: err.code, message: err.message, ...(err.data !== undefined ? { data: err.data } : {}), retriable: err.retriable };
22
+ if (err instanceof FilterInvalid)
23
+ return { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
24
+ if (err instanceof CursorInvalid)
25
+ return { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
26
+ if (err instanceof MutationError)
27
+ return { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
28
+ if (err instanceof RevisionNotFound)
29
+ return { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
30
+ return { error: "repo_not_found", message: String(err), retriable: false };
31
+ }
18
32
  //# sourceMappingURL=errors.js.map
@@ -4,7 +4,6 @@ import { docsOutline } from "../core/read/outline.js";
4
4
  import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
5
5
  import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
6
6
  import { findDoc, findDocByRef, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
7
- import { CursorInvalid } from "../core/cursor.js";
8
7
  import { resolveRef } from "../core/read/refs.js";
9
8
  import { isValidId } from "../core/ids.js";
10
9
  import { VERSION } from "../core/index.js";
@@ -12,18 +11,16 @@ import { normalizeText, normalizeVisibleText } from "../core/hash.js";
12
11
  import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
13
12
  import { graphNeighborhood } from "./graph.js";
14
13
  import { textSearch } from "../search/text.js";
15
- import { FilterInvalid } from "../search/cel/parser.js";
16
- import { EngineError } from "./errors.js";
14
+ import { EngineError, errorBody } from "./errors.js";
17
15
  import { apply } from "../mutate/apply.js";
18
- import { MutationError } from "../mutate/tree.js";
19
16
  import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair, nodeSet } from "../mutate/macros.js";
20
17
  import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
21
18
  import { planUpdate, docsUpdate } from "../mutate/plan-update.js";
22
19
  import { renderOpsetPlan } from "../mutate/opset.js";
23
- import { historyNode, diffBlocks, diffUnified, changesSince, docHistory, RevisionNotFound } from "../graph/history.js";
20
+ import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
24
21
  import { linksStale, linksStaleSummary } from "../graph/link-health.js";
25
22
  import { resolve as resolveThing } from "../search/resolve.js";
26
- import { reposStatus, syncStatus } from "../sync/admin.js";
23
+ import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
27
24
  import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
28
25
  import { QUERY_SYNTAX } from "./reference.js";
29
26
  // Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
@@ -70,20 +67,9 @@ function ok(payload) {
70
67
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
71
68
  }
72
69
  function fail(err) {
73
- let body;
74
- if (err instanceof EngineError)
75
- body = err.body();
76
- else if (err instanceof FilterInvalid)
77
- body = { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
78
- else if (err instanceof CursorInvalid)
79
- body = { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
80
- else if (err instanceof MutationError)
81
- body = { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
82
- else if (err instanceof RevisionNotFound)
83
- body = { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
84
- else
85
- body = { error: "repo_not_found", message: String(err), retriable: false };
86
- return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
70
+ // One mapping for every client (errors.ts `errorBody`): the CLI renders the
71
+ // same body, so a code can never differ between `omg` and `omg mcp`.
72
+ return { content: [{ type: "text", text: JSON.stringify(errorBody(err)) }], isError: true };
87
73
  }
88
74
  export function buildServer(ctx) {
89
75
  const server = new McpServer({ name: "omgbase", version: VERSION });
@@ -549,7 +535,7 @@ export function buildServer(ctx) {
549
535
  // local: the resolution that used to live in the CLI now lives next to the
550
536
  // store, so a remote client needs no local database.
551
537
  server.registerTool("blocks_insert", {
552
- description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block, or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
538
+ description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block — it must be a CONTAINER (a list, list item, task, blockquote or table); a leaf such as a heading or paragraph fails `type_mismatch` (spec/mutate §1.1) — use sections_append for \"under this heading\" or `at: {after: <ref>}` for \"right after this block\"; or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
553
539
  inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
554
540
  }, async (args) => {
555
541
  try {
@@ -594,7 +580,7 @@ export function buildServer(ctx) {
594
580
  }
595
581
  });
596
582
  server.registerTool("blocks_move", {
597
- description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref, or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. One move op through the kernel writer.",
583
+ description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. One move op through the kernel writer.",
598
584
  inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
599
585
  }, async (args) => {
600
586
  try {
@@ -802,53 +788,56 @@ export function buildServer(ctx) {
802
788
  // Doc-level operations (06 §API). The MCP server serializes writes in-process,
803
789
  // so these pass no omgbaseDir (the flock is for cross-process CLI writers);
804
790
  // MCP-originated writes are actor agent:mcp.
805
- const docCtx = (scope) => {
791
+ // `dry_run` validates and returns the per-file `diffs` without writing
792
+ // (`DocOpContext.dryRun`), the docs-verb peer of `apply`'s preview.
793
+ const docCtx = (scope, dryRun) => {
806
794
  if (!scope.rootPath)
807
795
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
808
- return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp" };
796
+ return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp", ...(dryRun ? { dryRun: true } : {}) };
809
797
  };
798
+ const docResult = (res, dryRun) => (dryRun ? ok(res) : okMutated(res));
810
799
  server.registerTool("docs_create", {
811
- description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists.",
812
- inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
800
+ description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists. dry_run:true validates and returns the would-be file under `diffs` (committed:false) without writing.",
801
+ inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
813
802
  }, async (args) => {
814
803
  try {
815
- return okMutated(docsCreate(store, docCtx(repoScope(args.repo)), args.path, args.markdown, args.frontmatter));
804
+ return docResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter), args.dry_run);
816
805
  }
817
806
  catch (e) {
818
807
  return fail(e);
819
808
  }
820
809
  });
821
810
  server.registerTool("docs_move", {
822
- description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs.",
823
- inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
811
+ description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus any rewritten sources) — without renaming or committing.",
812
+ inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
824
813
  }, async (args) => {
825
814
  try {
826
- return okMutated(docsMove(store, docCtx(repoScope(args.repo)), args.doc, args.to_path, { retargetInbound: args.retarget_inbound === true }));
815
+ return docResult(docsMove(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, args.to_path, { retargetInbound: args.retarget_inbound === true }), args.dry_run);
827
816
  }
828
817
  catch (e) {
829
818
  return fail(e);
830
819
  }
831
820
  });
832
821
  server.registerTool("docs_delete", {
833
- description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path.",
834
- inputSchema: { doc: z.string(), ...REPO_ARG },
822
+ description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path. dry_run:true validates and returns the file's removal under `diffs` without deleting.",
823
+ inputSchema: { doc: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
835
824
  }, async (args) => {
836
825
  try {
837
- return okMutated(docsDelete(store, docCtx(repoScope(args.repo)), args.doc));
826
+ return docResult(docsDelete(store, docCtx(repoScope(args.repo), args.dry_run), args.doc), args.dry_run);
838
827
  }
839
828
  catch (e) {
840
829
  return fail(e);
841
830
  }
842
831
  });
843
832
  server.registerTool("docs_set_meta", {
844
- description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved.",
845
- inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
833
+ description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved. dry_run:true validates and returns the rewritten file under `diffs` without writing.",
834
+ inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
846
835
  }, async (args) => {
847
836
  try {
848
- return okMutated(docsSetMeta(store, docCtx(repoScope(args.repo)), args.doc, {
837
+ return docResult(docsSetMeta(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, {
849
838
  ...(args.set ? { set: args.set } : {}),
850
839
  ...(args.unset ? { unset: args.unset } : {}),
851
- }));
840
+ }), args.dry_run);
852
841
  }
853
842
  catch (e) {
854
843
  return fail(e);
@@ -956,15 +945,11 @@ export function buildServer(ctx) {
956
945
  try {
957
946
  const { repoId } = repoScope(args.repo);
958
947
  const docId = resolveDocId(repoId, { doc: args.doc });
959
- const revs = store.db
960
- .prepare("SELECT rev_id FROM revisions WHERE doc_id = ? ORDER BY seq DESC LIMIT 2")
961
- .all(docId);
962
- const toRev = args.to_rev ?? revs[0]?.rev_id;
963
- const fromRev = args.from_rev ?? revs[1]?.rev_id ?? revs[0]?.rev_id;
964
- if (!toRev || !fromRev)
965
- throw new EngineError("target_missing", `no revisions to diff for ${JSON.stringify(args.doc)}`);
966
- const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path ?? "";
967
- return ok({ doc: docId, path, from: fromRev, to: toRev, diff: diffUnified(store, docId, fromRev, toRev) });
948
+ return ok(docDiffUnified(store, docId, {
949
+ ...(args.from_rev !== undefined ? { fromRev: args.from_rev } : {}),
950
+ ...(args.to_rev !== undefined ? { toRev: args.to_rev } : {}),
951
+ label: JSON.stringify(args.doc),
952
+ }));
968
953
  }
969
954
  catch (e) {
970
955
  return fail(e);
@@ -1078,10 +1063,7 @@ export function buildServer(ctx) {
1078
1063
  inputSchema: {},
1079
1064
  }, async () => {
1080
1065
  try {
1081
- const rows = store.db
1082
- .prepare("SELECT r.slug AS slug, MAX(CASE WHEN s.adapter = 'fs' THEN 1 ELSE 0 END) AS has_fs FROM repos r LEFT JOIN attachments a ON a.repo_id = r.repo_id LEFT JOIN sources s ON s.source_id = a.source_id GROUP BY r.repo_id, r.slug ORDER BY r.slug")
1083
- .all();
1084
- return ok({ repos: rows.map((r) => ({ slug: r.slug, hasSource: r.has_fs === 1 })) });
1066
+ return ok(reposList(store));
1085
1067
  }
1086
1068
  catch (e) {
1087
1069
  return fail(e);
@@ -9,6 +9,7 @@ import { renderDoc, markContainerDirty, MutationError } from "./tree.js";
9
9
  import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams, checkParentChildrenHash } from "./ops.js";
10
10
  import { withWriterLock } from "../sync/writer-lock.js";
11
11
  import { resolveDocStore } from "./doc-store.js";
12
+ import { isContainerBlock } from "../format/index.js";
12
13
  // Resolve "$n.ids[i]" placeholders in a string against prior op results.
13
14
  function resolvePlaceholder(value, results) {
14
15
  const m = /^\$(\d+)\.ids\[(\d+)\]$/.exec(value);
@@ -248,9 +249,11 @@ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
248
249
  throw new MutationError("block_missing", `block ${id} not found in source doc`, { op_index: opIndex });
249
250
  moving.push(found.block);
250
251
  }
251
- // §1.2: the destination parent's order CAS (in `dst`), before either tree moves.
252
+ // §1.1 container rule + §1.2 destination CAS (in `dst`), resolved once before
253
+ // either tree moves so a leaf destination errors with both trees untouched.
254
+ const pre = resolveDstTarget(dst, to, opIndex);
252
255
  if (expect?.parent_children_hash !== undefined)
253
- checkParentChildrenHash(resolveDstTarget(dst, to).siblings, expect, opIndex);
256
+ checkParentChildrenHash(pre.siblings, expect, opIndex);
254
257
  // remove from their source lists (marking each emptied owner dirty)
255
258
  for (const id of blockIds) {
256
259
  const f = locateMut(src, id);
@@ -260,7 +263,7 @@ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
260
263
  }
261
264
  }
262
265
  // Insert into dst at the resolved target; the destination owner re-renders.
263
- const target = resolveDstTarget(dst, to);
266
+ const target = resolveDstTarget(dst, to, opIndex);
264
267
  target.siblings.splice(target.index, 0, ...moving);
265
268
  markContainerDirty(dst, target.siblings);
266
269
  // Heal both documents' top-level seams (spec/mutate §2.3): a block that was
@@ -283,7 +286,7 @@ function locateMut(doc, blockId) {
283
286
  };
284
287
  return search(doc.children);
285
288
  }
286
- function resolveDstTarget(dst, to) {
289
+ function resolveDstTarget(dst, to, opIndex = 0) {
287
290
  if (typeof to.parent === "object" && "doc" in to.parent) {
288
291
  return { siblings: dst.children, index: resolveAt(dst.children, to.at) };
289
292
  }
@@ -291,6 +294,11 @@ function resolveDstTarget(dst, to) {
291
294
  const f = locateMut(dst, to.parent);
292
295
  if (!f)
293
296
  throw new MutationError("parent_missing", `parent ${to.parent} not found in dest`);
297
+ // spec/mutate §1.1 (1.2): a leaf destination would be re-rendered from its
298
+ // children and lose its own text — the same rule as the in-document ops.
299
+ if (!isContainerBlock(dst.format, f.block)) {
300
+ throw new MutationError("type_mismatch", `${to.parent} is a ${f.block.type}, not a container; place relative to it with at.before/at.after or append to its section`, { op_index: opIndex, block: to.parent, type: f.block.type });
301
+ }
294
302
  return { siblings: f.block.children, index: resolveAt(f.block.children, to.at) };
295
303
  }
296
304
  // heading/section: insert into top-level list at the anchor.
@@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync, mkdirS
2
2
  import { join, dirname } from "node:path";
3
3
  import { sha256 } from "../core/hash.js";
4
4
  import { recordFileStat } from "../sync/freshness.js";
5
+ import { EngineError } from "../mcp/errors.js";
5
6
  /** Filesystem-backed DocStore rooted at a working-tree directory (v1 default). */
6
7
  export class FsDocStore {
7
8
  root;
@@ -66,6 +67,8 @@ export function resolveDocStore(req) {
66
67
  return req.docStore;
67
68
  if (req.rootPath != null)
68
69
  return new FsDocStore(req.rootPath);
69
- throw new Error("mutation requires a rootPath or an explicit docStore");
70
+ // Typed, so every client renders the same `repo_not_found` the MCP server's
71
+ // pre-checks emit (spec/surface §4; spec/cli §9 "sourceless writes").
72
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
70
73
  }
71
74
  //# sourceMappingURL=doc-store.js.map
@@ -13,11 +13,30 @@ export interface DocOpContext {
13
13
  actor?: string;
14
14
  /** Commit timestamp (RFC 3339 UTC) for every commit the operation records; defaults to now (see `ApplyRequest.ts`). */
15
15
  ts?: string;
16
+ /**
17
+ * Validate and plan only: every check the real operation makes (`doc_missing`,
18
+ * `path_taken`, …) runs, nothing is written or committed, and the result
19
+ * carries `committed: false` plus the per-file `diffs` the operation would
20
+ * produce — the same shape `apply`'s `dryRun` returns. A create's `docId` is
21
+ * the id the real run would mint next (a dry run consumes it, as `apply`'s does).
22
+ */
23
+ dryRun?: boolean;
16
24
  }
17
25
  export interface DocOpResult {
18
26
  docId: string;
19
27
  path: string;
20
28
  committed: boolean;
29
+ /**
30
+ * With `dryRun`: the file changes the operation would make, keyed by
31
+ * repo-relative path — `before` the current bytes (`""` for a file that
32
+ * would be created), `after` the bytes the operation would leave (`""` for a
33
+ * file that would be removed). A rename is two entries: the old path
34
+ * emptied, the new path filled. Absent on a committed result.
35
+ */
36
+ diffs?: Record<string, {
37
+ before: string;
38
+ after: string;
39
+ }>;
21
40
  }
22
41
  export interface DocMoveOptions {
23
42
  /**
@@ -3,6 +3,7 @@ import { ingestFile } from "../core/ingest.js";
3
3
  import { findDoc, findDocByRef } from "../core/read/reader.js";
4
4
  import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
5
5
  import { newCommit } from "../core/store/writers.js";
6
+ import { mintId } from "../core/ids.js";
6
7
  import { ftsDeleteDoc } from "../core/store/fts.js";
7
8
  import { adoptPhantoms, rebuildDocEdges } from "../core/store/edges.js";
8
9
  import { withWriterLock } from "../sync/writer-lock.js";
@@ -38,6 +39,11 @@ export function docsCreate(store, ctx, path, markdown, frontmatter) {
38
39
  throw new MutationError("path_taken", `document already exists at ${rel}`);
39
40
  const content = composeFile(markdown, frontmatter);
40
41
  const docStore = resolveDocStore(ctx);
42
+ if (ctx.dryRun) {
43
+ if (docStore.exists(rel))
44
+ throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
45
+ return { docId: mintId("d"), path: rel, committed: false, diffs: { [rel]: { before: "", after: content } } };
46
+ }
41
47
  return underLock(ctx, () => {
42
48
  if (docStore.exists(rel))
43
49
  throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
@@ -67,6 +73,21 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
67
73
  // Who links here BY PATH, as written — before the path changes.
68
74
  const inbound = inboundLinksTo(store, ctx.repoId, info.docId, info.path);
69
75
  const docStore = resolveDocStore(ctx);
76
+ if (ctx.dryRun) {
77
+ if (docStore.exists(toRel))
78
+ throw new MutationError("path_taken", `file already exists on disk at ${toRel}`);
79
+ const content = docStore.read(info.path) ?? "";
80
+ const diffs = { [info.path]: { before: content, after: "" }, [toRel]: { before: "", after: content } };
81
+ const planned = { docId: info.docId, path: toRel, committed: false, diffs };
82
+ if (!opts.retargetInbound || inbound.length === 0)
83
+ return { ...planned, dangling: inbound, retargeted: null };
84
+ const plan = planInboundRetarget(store, info.docId, info.path, toRel, inbound);
85
+ if (plan.ops.length > 0) {
86
+ const preview = apply(store, { ...applyCtx(ctx), ops: plan.ops, dryRun: true, origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` } });
87
+ Object.assign(diffs, preview.diffs ?? {});
88
+ }
89
+ return { ...planned, dangling: plan.dangling, retargeted: { blocks: plan.blocks, docs: plan.docs } };
90
+ }
70
91
  const moved = underLock(ctx, () => {
71
92
  if (docStore.exists(toRel))
72
93
  throw new MutationError("path_taken", `file already exists on disk at ${toRel}`);
@@ -118,6 +139,34 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
118
139
  // Rewrite the dangling links block by block (one coalesced update per block,
119
140
  // CAS on the block's current hash) and apply as one changeset; the re-ingest
120
141
  // re-extracts each source doc, whose links now resolve to the moved doc.
142
+ const plan = planInboundRetarget(store, info.docId, info.path, toRel, inbound);
143
+ if (plan.ops.length > 0) {
144
+ apply(store, {
145
+ ...applyCtx(ctx),
146
+ ops: plan.ops,
147
+ origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
148
+ });
149
+ }
150
+ return { ...moved, dangling: plan.dangling, retargeted: { blocks: plan.blocks, docs: plan.docs } };
151
+ }
152
+ /** The `ApplyRequest` fields a document operation's context supplies to a follow-up changeset. */
153
+ function applyCtx(ctx) {
154
+ return {
155
+ repoId: ctx.repoId,
156
+ ...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
157
+ ...(ctx.docStore ? { docStore: ctx.docStore } : {}),
158
+ ...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
159
+ ...(ctx.ts ? { ts: ctx.ts } : {}),
160
+ };
161
+ }
162
+ /**
163
+ * The `retarget_inbound` plan of a move `fromPath → toRel`: one coalesced,
164
+ * CAS-pinned `update` per deepest hit block, the blocks and docs it touches,
165
+ * and the inbound links it leaves dangling. Pure over the store's current
166
+ * blocks — the move itself changes no block — so the real move and its dry run
167
+ * plan identically.
168
+ */
169
+ function planInboundRetarget(store, docId, fromPath, toRel, inbound) {
121
170
  const byBlock = new Map();
122
171
  for (const l of inbound)
123
172
  if (l.block !== null && !byBlock.has(l.block))
@@ -150,29 +199,18 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
150
199
  // Match against the directory the link was RESOLVED in (the source's path at
151
200
  // extraction time); write relative forms against the source's CURRENT
152
201
  // directory — they differ only for links inside the moved doc itself.
153
- const writeDir = src.doc === info.docId ? dirOf(toRel) : dirOf(src.path);
154
- const newRaw = retargetLinksInRaw(row.bytes.toString("utf8"), dirOf(src.path), writeDir, info.path, toRel);
202
+ const writeDir = src.doc === docId ? dirOf(toRel) : dirOf(src.path);
203
+ const newRaw = retargetLinksInRaw(row.bytes.toString("utf8"), dirOf(src.path), writeDir, fromPath, toRel);
155
204
  if (newRaw === null)
156
205
  continue;
157
206
  ops.push({ op: "update", block: blockId, markdown: newRaw, expect: { content_hash: row.raw_hash.toString("hex") } });
158
207
  blocks.push(blockId);
159
208
  docs.add(src.doc);
160
209
  }
161
- if (ops.length > 0) {
162
- apply(store, {
163
- repoId: ctx.repoId,
164
- ...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
165
- ...(ctx.docStore ? { docStore: ctx.docStore } : {}),
166
- ...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
167
- ...(ctx.ts ? { ts: ctx.ts } : {}),
168
- ops,
169
- origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
170
- });
171
- }
172
210
  // Containers whose rewritten child covered them count as rewritten too.
173
211
  const rewritten = new Set([...blocks, ...containers]);
174
212
  const dangling = inbound.filter((l) => l.block === null || !rewritten.has(l.block));
175
- return { ...moved, dangling, retargeted: { blocks, docs: [...docs] } };
213
+ return { ops, blocks, docs: [...docs], dangling };
176
214
  }
177
215
  /** docs_delete: mark a document deleted and remove its file (resurrection-poolable). */
178
216
  export function docsDelete(store, ctx, docRef) {
@@ -180,6 +218,9 @@ export function docsDelete(store, ctx, docRef) {
180
218
  if (!info)
181
219
  throw new MutationError("doc_missing", `no document ${docRef}`);
182
220
  const docStore = resolveDocStore(ctx);
221
+ if (ctx.dryRun) {
222
+ return { docId: info.docId, path: info.path, committed: false, diffs: { [info.path]: { before: docStore.read(info.path) ?? "", after: "" } } };
223
+ }
183
224
  return underLock(ctx, () => {
184
225
  const ts = nowOr(ctx);
185
226
  store.write((db) => {
@@ -201,13 +242,20 @@ export function docsSetMeta(store, ctx, docRef, patch) {
201
242
  if (!info)
202
243
  throw new MutationError("doc_missing", `no document ${docRef}`);
203
244
  const docStore = resolveDocStore(ctx);
204
- return underLock(ctx, () => {
245
+ const patched = () => {
205
246
  const original = docStore.read(info.path) ?? "";
206
247
  const { frontmatter, body } = splitFrontmatter(original);
207
248
  const merged = { ...frontmatter, ...(patch.set ?? {}) };
208
249
  for (const k of patch.unset ?? [])
209
250
  delete merged[k];
210
- const content = composeFile(body, merged);
251
+ return { original, content: composeFile(body, merged) };
252
+ };
253
+ if (ctx.dryRun) {
254
+ const { original, content } = patched();
255
+ return { docId: info.docId, path: info.path, committed: false, diffs: { [info.path]: { before: original, after: content } } };
256
+ }
257
+ return underLock(ctx, () => {
258
+ const { content } = patched();
211
259
  docStore.write(info.path, content);
212
260
  const ts = nowOr(ctx);
213
261
  const res = ingestFile(store, ctx.repoId, info.path, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `set_meta ${info.path}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: info.path }) });
@@ -1,6 +1,6 @@
1
1
  import { mintId } from "../core/ids.js";
2
2
  import { parseTree } from "../core/parse/tree.js";
3
- import { adapterForFormat } from "../format/index.js";
3
+ import { adapterForFormat, isContainerBlock } from "../format/index.js";
4
4
  import { codeUnitIndexOf } from "../core/utf8.js";
5
5
  import { MutationError, locate, rawHashHex, parentChildrenHash, markContainerDirty, ownerOf } from "./tree.js";
6
6
  function defaultTrivia(format) {
@@ -34,8 +34,10 @@ function parseContentToBlocks(content, format) {
34
34
  });
35
35
  return tree.children.filter((b) => b.type !== "frontmatter").map(toMut);
36
36
  }
37
- // Resolve a To into a target sibling list + insertion index.
38
- function resolveTarget(doc, to) {
37
+ // Resolve a To into a target sibling list + insertion index (spec/mutate §1.1).
38
+ // Shared by insert, move and the destination `parent_children_hash` CAS, so the
39
+ // container rule below is seen by all three.
40
+ function resolveTarget(doc, to, opIndex) {
39
41
  let siblings;
40
42
  if (to.parent === null || (typeof to.parent === "object" && "doc" in to.parent)) {
41
43
  siblings = doc.children;
@@ -46,9 +48,18 @@ function resolveTarget(doc, to) {
46
48
  return resolveSection(doc, to.parent.heading, to.at);
47
49
  }
48
50
  else {
49
- const found = locate(doc, to.parent);
51
+ const parentId = to.parent;
52
+ const found = locate(doc, parentId);
50
53
  if (!found)
51
- throw new MutationError("parent_missing", `parent ${String(to.parent)} not found`);
54
+ throw new MutationError("parent_missing", `parent ${parentId} not found`);
55
+ // §1.1 (since 1.2): `parent: <id>` must name a CONTAINER. A leaf's `children`
56
+ // is empty by construction, not by content — placing a block there marked
57
+ // the leaf dirty, renderBlock rebuilt it from its children, and the leaf's
58
+ // own text (a heading's line) vanished from the file. The error carries the
59
+ // two ways to say what the caller meant: next to it, or into its section.
60
+ if (!isContainerBlock(doc.format, found.block)) {
61
+ throw new MutationError("type_mismatch", `${parentId} is a ${found.block.type}, not a container; place relative to it with at.before/at.after or append to its section`, { op_index: opIndex, block: parentId, type: found.block.type });
62
+ }
52
63
  siblings = found.block.children;
53
64
  }
54
65
  return { siblings, index: resolveIndex(siblings, to.at) };
@@ -155,7 +166,7 @@ function markSubtreeClean(b) {
155
166
  }
156
167
  // ---- the six ops ------------------------------------------------------------
157
168
  export function opInsert(doc, to, markdown, expect, opIndex = 0) {
158
- const { siblings, index } = resolveTarget(doc, to);
169
+ const { siblings, index } = resolveTarget(doc, to, opIndex);
159
170
  // §1.2: the destination parent's order CAS — `siblings` IS the resolved parent
160
171
  // list (a section scope or a `{ doc: true }` anchor both name the top level).
161
172
  checkParentChildrenHash(siblings, expect, opIndex);
@@ -352,12 +363,13 @@ export function opMove(doc, blockIds, to, opIndex, expect) {
352
363
  if (typeof to.parent === "string" && movedIds.has(to.parent)) {
353
364
  throw new MutationError("cycle_move", "target is inside the moved subtree", { op_index: opIndex });
354
365
  }
355
- // §1.2: the DESTINATION parent's order CAS, resolved on the pre-removal tree
356
- // (before the op mutates anything) and checked once for the whole run. When
357
- // source and destination are one list this is the "same siblings reordered
358
- // under me" guard: the hash covers the moved blocks' current positions too.
359
- if (expect?.parent_children_hash !== undefined)
360
- checkParentChildrenHash(resolveTarget(doc, to).siblings, expect, opIndex);
366
+ // Resolve the destination on the PRE-removal tree first: §1.1's container rule
367
+ // and a missing parent/anchor fail before anything moves, and §1.2's
368
+ // DESTINATION-parent order CAS is checked once for the whole run against the
369
+ // children as they stand. When source and destination are one list this is the
370
+ // "same siblings reordered under me" guard: the hash covers the moved blocks'
371
+ // current positions too. The index is re-resolved after extraction below.
372
+ checkParentChildrenHash(resolveTarget(doc, to, opIndex).siblings, expect, opIndex);
361
373
  // Extract (preserve order) then re-insert at the target.
362
374
  const moving = located.map((l) => l.block);
363
375
  for (const m of moving) {
@@ -365,7 +377,7 @@ export function opMove(doc, blockIds, to, opIndex, expect) {
365
377
  if (idx >= 0)
366
378
  siblings.splice(idx, 1);
367
379
  }
368
- const { siblings: dstSiblings, index } = resolveTarget(doc, to);
380
+ const { siblings: dstSiblings, index } = resolveTarget(doc, to, opIndex);
369
381
  dstSiblings.splice(index, 0, ...moving);
370
382
  // A move within/into a nested container invalidates that container's cached
371
383
  // raw (and the source's) — mark them so they re-render from children. No-op
@@ -32,9 +32,22 @@ export interface Frag {
32
32
  * prop a document property read: a bare key on docs or `doc.<k>` (the scalar-in-scope subquery)
33
33
  */
34
34
  export type OperandKind = "text" | "int" | "num" | "bool" | "null" | "json" | "prop";
35
- /** A value-position fragment with the kind of value it denotes. */
35
+ /** A value-position fragment with the kind of value it denotes, plus what the
36
+ * typed comparison (`typedComparison`) needs to re-shape it: the JSON column +
37
+ * path behind a `json` read, the document alias + key behind a `prop` read, and
38
+ * the constant behind a literal/binding (`value`; `bool`/`num`/`null`/text
39
+ * constants only — a column operand has none). */
36
40
  export interface Operand extends Frag {
37
41
  kind: OperandKind;
42
+ json?: {
43
+ col: string;
44
+ path: string;
45
+ };
46
+ prop?: {
47
+ docAlias: string;
48
+ key: string;
49
+ };
50
+ value?: unknown;
38
51
  }
39
52
  /**
40
53
  * Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
@@ -61,33 +74,43 @@ export declare function translateValue(e: Expr, ctx: TranslateCtx): Frag | null;
61
74
  * comparison is pushed only when its two operand kinds are provably compared
62
75
  * the same way by SQLite and by the in-memory `equals`/`compare`.
63
76
  *
64
- * Equality (`==`, `!=`) pushes iff
77
+ * Equality (`==`, `!=`) pushes PLAINLY (`IS` / `IS NOT`) iff
65
78
  * • one operand is text — SQLite's typed `IS` and strict `===` agree that a
66
79
  * string equals nothing but an equal string (`5 IS '5'` is false);
67
80
  * • or one operand is null and the other is NOT a property read — the same
68
81
  * absence both ways, except that a list-valued or nested property has no
69
82
  * scalar row (SQL NULL) where in memory the value is an array or object;
70
83
  * • or both operands are numeric (int or num) — an exact integer comparison.
71
- * Relational (`<`, `<=`, `>`, `>=`) pushes iff both operands are text or both
72
- * are numeric.
84
+ * Relational (`<`, `<=`, `>`, `>=`) pushes plainly iff both operands are text
85
+ * or both are numeric.
86
+ * TYPED pushes (the 1.2 patch, `typedComparison`): a bool literal/binding
87
+ * against a json or prop read (`==`, `!=` only) and a num literal/binding
88
+ * against a json or prop read (every op) push with the stored type tested
89
+ * first — `json_type(x) = 'true'`, `json_type(x) IN ('integer','real') AND
90
+ * json_extract(x) <op> ?`, `p.type = 'bool' AND p.val_bool = ?`, `p.type =
91
+ * 'number' AND p.val_num <op> ?` — wrapped `(…) IS 1` (or `IS NOT 1` for
92
+ * `!=`), so an absent or differently typed value is unequal and never
93
+ * ordered, exactly as in memory.
73
94
  * Every other pair declines: SQLite sees JSON `true` and `1`, `val_bool` and
74
- * `val_num` alike (`checked == 1`, `$ordinal == checked`, `verified == 1`),
75
- * orders every integer before every text (`$ordinal < "3"`, `level < "x"`,
76
- * `$path > 5`), and two JSON or property reads carry no type at plan time
95
+ * `val_num` alike (`$ordinal == checked`, `true == 1`), orders every
96
+ * integer before every text (`$ordinal < "3"`, `level < "x"`, `$path > 5`),
97
+ * and two JSON or property reads carry no type at plan time
77
98
  * (`attrs.a == attrs.b`, `era == stages`).
78
99
  *
79
- * The resulting grid (P = pushed, D = declined; symmetric) for `==` / `!=`:
100
+ * The resulting grid (P = pushed plainly, T = typed push, D = declined;
101
+ * symmetric) for `==` / `!=`:
80
102
  *
81
103
  * text int num bool null json prop
82
104
  * text P P P P P P P
83
105
  * int P P P D P D D
84
- * num P P P D P D D
85
- * bool P D D D P D D
106
+ * num P P P D P T T
107
+ * bool P D D D P T T
86
108
  * null P P P P P P D
87
- * json P D D D P D D
88
- * prop P D D D D D D
109
+ * json P D T T P D D
110
+ * prop P D T T D D D
89
111
  *
90
- * and for `<` `<=` `>` `>=` only text×text, int×int, int×num, num×num push.
112
+ * and for `<` `<=` `>` `>=` text×text, int×int, int×num, num×num push plainly
113
+ * and num×json, num×prop push typed.
91
114
  */
92
115
  export declare function comparable(op: string, a: OperandKind, b: OperandKind): boolean;
93
116
  /** The complement of `comparable` — kept for readers of the earlier decline-list form. */