@omgbase/core 0.11.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/src/core/ids.d.ts +4 -0
  3. package/dist/src/core/ids.js +17 -0
  4. package/dist/src/core/ingest.d.ts +39 -3
  5. package/dist/src/core/ingest.js +233 -202
  6. package/dist/src/core/store/schema.d.ts +3 -2
  7. package/dist/src/core/store/schema.js +28 -3
  8. package/dist/src/core/store/size.d.ts +76 -0
  9. package/dist/src/core/store/size.js +223 -0
  10. package/dist/src/core/store/store.d.ts +9 -0
  11. package/dist/src/core/store/store.js +11 -0
  12. package/dist/src/graph/history.js +15 -12
  13. package/dist/src/index.d.ts +12 -5
  14. package/dist/src/index.js +18 -7
  15. package/dist/src/mcp/arguments.d.ts +9 -2
  16. package/dist/src/mcp/arguments.js +15 -2
  17. package/dist/src/mcp/errors.d.ts +1 -1
  18. package/dist/src/mcp/errors.js +3 -0
  19. package/dist/src/mcp/reference.d.ts +1 -1
  20. package/dist/src/mcp/reference.js +27 -6
  21. package/dist/src/mcp/server.d.ts +56 -1
  22. package/dist/src/mcp/server.js +365 -203
  23. package/dist/src/mutate/apply.d.ts +93 -12
  24. package/dist/src/mutate/apply.js +203 -113
  25. package/dist/src/mutate/canonical.d.ts +27 -0
  26. package/dist/src/mutate/canonical.js +138 -0
  27. package/dist/src/mutate/commit.d.ts +150 -0
  28. package/dist/src/mutate/commit.js +349 -0
  29. package/dist/src/mutate/doc-store.d.ts +3 -3
  30. package/dist/src/mutate/doc-store.js +21 -6
  31. package/dist/src/mutate/docs.d.ts +42 -87
  32. package/dist/src/mutate/docs.js +50 -204
  33. package/dist/src/mutate/effects.d.ts +363 -0
  34. package/dist/src/mutate/effects.js +1161 -0
  35. package/dist/src/mutate/materialize.d.ts +49 -0
  36. package/dist/src/mutate/materialize.js +174 -0
  37. package/dist/src/mutate/opset.d.ts +27 -2
  38. package/dist/src/mutate/opset.js +12 -5
  39. package/dist/src/mutate/plan-update.d.ts +1 -46
  40. package/dist/src/mutate/plan-update.js +19 -67
  41. package/dist/src/mutate/plan.d.ts +210 -0
  42. package/dist/src/mutate/plan.js +512 -0
  43. package/dist/src/mutate/select.d.ts +89 -0
  44. package/dist/src/mutate/select.js +879 -0
  45. package/dist/src/mutate/sugar.d.ts +135 -0
  46. package/dist/src/mutate/sugar.js +167 -0
  47. package/dist/src/mutate/tree.d.ts +4 -2
  48. package/dist/src/oqx-js/planner.js +4 -0
  49. package/dist/src/oqx-js/run.d.ts +28 -1
  50. package/dist/src/oqx-js/run.js +85 -22
  51. package/dist/src/spec-versions.d.ts +6 -6
  52. package/dist/src/spec-versions.js +5 -5
  53. package/dist/src/surface-paths.d.ts +17 -26
  54. package/dist/src/surface-paths.js +27 -22
  55. package/dist/src/sync/checkpoint.d.ts +5 -2
  56. package/dist/src/sync/checkpoint.js +10 -6
  57. package/dist/src/sync/freshness.d.ts +10 -7
  58. package/dist/src/sync/freshness.js +15 -17
  59. package/dist/src/sync/observe.d.ts +4 -1
  60. package/dist/src/sync/observe.js +56 -5
  61. package/dist/src/sync/recovery.d.ts +4 -0
  62. package/dist/src/sync/recovery.js +4 -1
  63. package/dist/src/sync/stat-cache.d.ts +7 -0
  64. package/dist/src/sync/stat-cache.js +22 -0
  65. package/dist/src/sync/tombstone.js +23 -19
  66. package/dist/src/sync/watcher.d.ts +2 -0
  67. package/dist/src/sync/watcher.js +4 -1
  68. package/dist/src/sync/writer-lock.d.ts +15 -0
  69. package/dist/src/sync/writer-lock.js +104 -17
  70. package/dist/src/version.d.ts +7 -2
  71. package/dist/src/version.js +5 -2
  72. package/package.json +2 -2
@@ -1,7 +1,7 @@
1
- import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
1
+ import { openSync, writeSync, fsyncSync, closeSync, readFileSync, existsSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { sha256 } from "../core/hash.js";
4
- import { recordFileStat } from "../sync/freshness.js";
4
+ import { recordFileStat } from "../sync/stat-cache.js";
5
5
  import { EngineError } from "../mcp/errors.js";
6
6
  /** Filesystem-backed DocStore rooted at a working-tree directory (v1 default). */
7
7
  export class FsDocStore {
@@ -22,8 +22,20 @@ export class FsDocStore {
22
22
  write(path, bytes) {
23
23
  const abs = this.abs(path);
24
24
  mkdirSync(dirname(abs), { recursive: true });
25
+ // Temp file + rename for atomicity; the temp file is fsynced before the
26
+ // rename (store 14.0) so a power loss after the commit cannot leave a
27
+ // truncated file under the final name — the same loss class as SQLite's
28
+ // `synchronous = NORMAL`. No directory fsync: the rename itself may be lost,
29
+ // which the pending row then replays.
25
30
  const tmp = `${abs}.omgtmp`;
26
- writeFileSync(tmp, bytes);
31
+ const fd = openSync(tmp, "w");
32
+ try {
33
+ writeSync(fd, bytes);
34
+ fsyncSync(fd);
35
+ }
36
+ finally {
37
+ closeSync(fd);
38
+ }
27
39
  renameSync(tmp, abs);
28
40
  }
29
41
  rename(from, to) {
@@ -52,9 +64,12 @@ export class NullDocStore {
52
64
  read() {
53
65
  return null;
54
66
  }
55
- write() { }
56
- rename() { }
57
- remove() { }
67
+ // Parameters spelled out so a subclass may override with the interface's
68
+ // signature (a zero-parameter base method makes `write(path, bytes)` an
69
+ // incompatible override under TS2416).
70
+ write(_path, _bytes) { }
71
+ rename(_from, _to) { }
72
+ remove(_path) { }
58
73
  recordStat() { }
59
74
  clearStat() { }
60
75
  }
@@ -1,91 +1,46 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type InboundLink } from "../graph/inbound-links.js";
3
- import { type DocStore } from "./doc-store.js";
4
- export interface DocOpContext {
5
- repoId: string;
6
- /** Working-tree root for the default filesystem write target. Omit only when
7
- * supplying an explicit `docStore` (e.g. a headless NullDocStore). */
8
- rootPath?: string;
9
- /** Write target (ADR-014 §5); defaults to a filesystem store at `rootPath`. */
10
- docStore?: DocStore;
11
- /** workspace .omgbase/ dir; when set, the write runs under the writer flock. */
12
- omgbaseDir?: string;
13
- actor?: string;
14
- /** Commit timestamp (RFC 3339 UTC) for every commit the operation records; defaults to now (see `ApplyRequest.ts`). */
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;
24
- }
25
- export interface DocOpResult {
26
- docId: string;
27
- path: string;
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
- }>;
40
- }
41
- export interface DocMoveOptions {
42
- /**
43
- * Rewrite the inbound links that named the OLD path so they point at the new
44
- * one — a destination-aware rewrite (anchors, link text, titles, and code
45
- * spans preserved; absolute/relative style kept) applied as a CAS-checked
46
- * `update` per source block in one follow-up api changeset. **Default `true`**
47
- * (spec/mutate 1.3): a move means "same document, new place", so the authored
48
- * links follow it; pass `false` for the rarer intent that the old path become
49
- * unbound. Frontmatter-level relations are never rewritten (reported in
50
- * `dangling`).
51
- */
52
- retargetInbound?: boolean;
53
- }
54
- export interface DocMoveResult extends DocOpResult {
55
- /**
56
- * Inbound links still pointing at the OLD path after this call — the source
57
- * blocks `links_stale` will now report (with the default retarget: only the
58
- * frontmatter relations, which are never rewritten). Computed from the open
59
- * edge index before the move.
60
- */
61
- dangling: InboundLink[];
62
- /**
63
- * The source blocks rewritten and the docs re-ingested (empty lists when the
64
- * rewrite ran but only frontmatter relations linked in); `null` when the
65
- * retarget was opted out (`retargetInbound: false`) or nothing linked in.
66
- */
67
- retargeted: {
68
- blocks: string[];
69
- docs: string[];
70
- } | null;
71
- }
72
- /** docs_create: create a new document from complete file bytes (frontmatter incl.). */
73
- export declare function docsCreate(store: Store, ctx: DocOpContext, path: string, markdown: string, frontmatter?: Record<string, unknown>): DocOpResult;
3
+ import type { Op } from "./apply.js";
4
+ import type { MutDoc } from "./tree.js";
5
+ /** spec/mutate §6: the storage form of a document path — leading `/` stripped, `\` → `/`. */
6
+ export declare function canonicalDocPath(path: string): string;
7
+ /** Compose a full document's bytes from optional frontmatter + body markdown
8
+ * (spec/mutate §6 `docs_create`): the body with a trailing `\n` ensured; a
9
+ * non-empty frontmatter mapping becomes `---\n<yaml>\n---\n` followed by a
10
+ * blank line unless the body already starts with one. The effect planner's
11
+ * `set frontmatter.<k>` composes through this same function (§11.3), so its
12
+ * bytes equal `docs_set_meta`'s for the same patch. */
13
+ export declare function composeFile(markdown: string, frontmatter?: Record<string, unknown>): string;
14
+ /**
15
+ * A document this request CREATES as the planner's working tree (spec/mutate
16
+ * §11.3, 3.0): `source` (the composed file) parsed per the path's format —
17
+ * the frontmatter block becomes `frontmatterRaw` (its raw + its trailing
18
+ * trivia, as `loadMutDoc` builds it), the bytes before it the leading trivia,
19
+ * every body block a `MutBlock` with its exact trivia and an id minted NOW in
20
+ * pre-order under the current minter (the symbolic one at plan time, so the
21
+ * ids are `$new.k`; the production one at execute, so the replay mints the
22
+ * same positions). `docId` is the plan's document symbol. The round-trip law
23
+ * makes `renderDoc` of the result equal `source` byte for byte.
24
+ */
25
+ export declare function parseNewDoc(symbol: string, path: string, source: string): MutDoc;
74
26
  /**
75
- * docs_move: rename a document to a new path (identity preserved). The edge
76
- * index follows the path, not the identity: links written against the OLD path
77
- * now dangle (their open edges are re-pointed at `phantom:<old>`, exactly what
78
- * re-extracting the source would produce, so `links_stale` sees them), and
79
- * phantom edges already written against the NEW path are adopted. By default
80
- * the dangling inbound links are then rewritten to the new path in one
81
- * follow-up changeset (spec/mutate 1.3); `retargetInbound: false` leaves them
82
- * as written and the result lists them.
27
+ * The `retarget_inbound` plan of a move `fromPath → toRel`: one coalesced,
28
+ * CAS-pinned `update` per deepest hit block, the blocks and docs it touches,
29
+ * and the inbound links it leaves dangling. Pure over the store's current
30
+ * blocks — the move itself changes no block — so the effect planner's `move
31
+ * document` (§11.3) and the tool's `dangling`/`retargeted` report (computed
32
+ * before the commit) plan identically.
83
33
  */
84
- export declare function docsMove(store: Store, ctx: DocOpContext, docRef: string, toPath: string, opts?: DocMoveOptions): DocMoveResult;
85
- /** docs_delete: mark a document deleted and remove its file (resurrection-poolable). */
86
- export declare function docsDelete(store: Store, ctx: DocOpContext, docRef: string): DocOpResult;
87
- /** docs_set_meta: surgical frontmatter patch (set/unset keys), re-ingested. */
88
- export declare function docsSetMeta(store: Store, ctx: DocOpContext, docRef: string, patch: {
89
- set?: Record<string, unknown>;
90
- unset?: string[];
91
- }): DocOpResult;
34
+ export declare function planInboundRetarget(store: Store, docId: string, fromPath: string, toRel: string, inbound: InboundLink[]): {
35
+ ops: Op[];
36
+ blocks: string[];
37
+ docs: string[];
38
+ dangling: InboundLink[];
39
+ };
40
+ /** Split a file's bytes into (frontmatter object, body markdown) with
41
+ * `^---\r?\n([\s\S]*?)\r?\n---\r?\n?` (spec/mutate §6). Missing or malformed
42
+ * frontmatter yields an empty object and the whole content as body. */
43
+ export declare function splitFrontmatter(content: string): {
44
+ frontmatter: Record<string, unknown>;
45
+ body: string;
46
+ };
@@ -1,175 +1,72 @@
1
1
  import { stringify as stringifyYaml, parse as parseYaml } from "yaml";
2
- import { ingestFile } from "../core/ingest.js";
3
- import { findDoc, findDocByRef } from "../core/read/reader.js";
4
- import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
5
- import { newCommit } from "../core/store/writers.js";
2
+ import { parseTree } from "../core/parse/tree.js";
3
+ import { adapterForPath } from "../format/index.js";
6
4
  import { mintId } from "../core/ids.js";
7
- import { ftsDeleteDoc } from "../core/store/fts.js";
8
- import { adoptPhantoms, rebuildDocEdges } from "../core/store/edges.js";
9
- import { withWriterLock } from "../sync/writer-lock.js";
10
- import { inboundLinksTo, retargetLinksInRaw } from "../graph/inbound-links.js";
11
- import { apply } from "./apply.js";
12
- import { resolveDocStore } from "./doc-store.js";
13
- import { MutationError } from "./tree.js";
14
- function nowOr(ctx) {
15
- return ctx.ts ?? new Date().toISOString();
16
- }
5
+ import { retargetLinksInRaw } from "../graph/inbound-links.js";
6
+ // The document operations' shared pieces (spec/mutate §6, 3.0). Through 2.x this
7
+ // module held `docsCreate` / `docsMove` / `docsDelete` / `docsSetMeta`, library
8
+ // functions that composed the internal commit effects themselves; since 3.0 the
9
+ // operations are the effect builders of `sugar.ts` (`create`, `move document`,
10
+ // `remove document`, `set`/`unset frontmatter.<k>`) planned and executed by the
11
+ // Effect layer (`plan.ts`), and what remains here is what the planner's
12
+ // lowerings share with them: the path canonicalization, the file composition,
13
+ // the parse of a created document, the inbound-link retarget plan and the
14
+ // frontmatter split.
17
15
  function dirOf(path) {
18
16
  return path.replace(/[^/]*$/, "");
19
17
  }
20
- function underLock(ctx, fn) {
21
- return ctx.omgbaseDir ? withWriterLock(ctx.omgbaseDir, fn) : fn();
22
- }
23
- function canonical(path) {
18
+ /** spec/mutate §6: the storage form of a document path — leading `/` stripped, `\` → `/`. */
19
+ export function canonicalDocPath(path) {
24
20
  return path.replace(/^\/+/, "").split("\\").join("/");
25
21
  }
26
- // Compose a full document's bytes from optional frontmatter + body markdown.
27
- function composeFile(markdown, frontmatter) {
22
+ /** Compose a full document's bytes from optional frontmatter + body markdown
23
+ * (spec/mutate §6 `docs_create`): the body with a trailing `\n` ensured; a
24
+ * non-empty frontmatter mapping becomes `---\n<yaml>\n---\n` followed by a
25
+ * blank line unless the body already starts with one. The effect planner's
26
+ * `set frontmatter.<k>` composes through this same function (§11.3), so its
27
+ * bytes equal `docs_set_meta`'s for the same patch. */
28
+ export function composeFile(markdown, frontmatter) {
28
29
  const body = markdown.endsWith("\n") || markdown.length === 0 ? markdown : markdown + "\n";
29
30
  if (!frontmatter || Object.keys(frontmatter).length === 0)
30
31
  return body;
31
32
  const yaml = stringifyYaml(frontmatter).replace(/\n$/, "");
32
33
  return `---\n${yaml}\n---\n${body.startsWith("\n") ? body : "\n" + body}`;
33
34
  }
34
- /** docs_create: create a new document from complete file bytes (frontmatter incl.). */
35
- export function docsCreate(store, ctx, path, markdown, frontmatter) {
36
- const rel = canonical(path);
37
- const existing = findDoc(store, { repoId: ctx.repoId, path: rel });
38
- if (existing)
39
- throw new MutationError("path_taken", `document already exists at ${rel}`);
40
- const content = composeFile(markdown, frontmatter);
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
- }
47
- return underLock(ctx, () => {
48
- if (docStore.exists(rel))
49
- throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
50
- docStore.write(rel, content);
51
- const ts = nowOr(ctx);
52
- const res = ingestFile(store, ctx.repoId, rel, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `create ${rel}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: rel }) });
53
- if (ctx.omgbaseDir)
54
- docStore.recordStat(store, ctx.repoId, rel, content);
55
- return { docId: res.docId, path: rel, committed: true };
56
- });
57
- }
58
35
  /**
59
- * docs_move: rename a document to a new path (identity preserved). The edge
60
- * index follows the path, not the identity: links written against the OLD path
61
- * now dangle (their open edges are re-pointed at `phantom:<old>`, exactly what
62
- * re-extracting the source would produce, so `links_stale` sees them), and
63
- * phantom edges already written against the NEW path are adopted. By default
64
- * the dangling inbound links are then rewritten to the new path in one
65
- * follow-up changeset (spec/mutate 1.3); `retargetInbound: false` leaves them
66
- * as written and the result lists them.
36
+ * A document this request CREATES as the planner's working tree (spec/mutate
37
+ * §11.3, 3.0): `source` (the composed file) parsed per the path's format —
38
+ * the frontmatter block becomes `frontmatterRaw` (its raw + its trailing
39
+ * trivia, as `loadMutDoc` builds it), the bytes before it the leading trivia,
40
+ * every body block a `MutBlock` with its exact trivia and an id minted NOW in
41
+ * pre-order under the current minter (the symbolic one at plan time, so the
42
+ * ids are `$new.k`; the production one at execute, so the replay mints the
43
+ * same positions). `docId` is the plan's document symbol. The round-trip law
44
+ * makes `renderDoc` of the result equal `source` byte for byte.
67
45
  */
68
- export function docsMove(store, ctx, docRef, toPath, opts = {}) {
69
- const retargetInbound = opts.retargetInbound !== false;
70
- const info = findDocByRef(store, ctx.repoId, docRef);
71
- if (!info)
72
- throw new MutationError("doc_missing", `no document ${docRef}`);
73
- const toRel = canonical(toPath);
74
- if (findDoc(store, { repoId: ctx.repoId, path: toRel }))
75
- throw new MutationError("path_taken", `a document already exists at ${toRel}`);
76
- // Who links here BY PATH, as written — before the path changes.
77
- const inbound = inboundLinksTo(store, ctx.repoId, info.docId, info.path);
78
- const docStore = resolveDocStore(ctx);
79
- if (ctx.dryRun) {
80
- if (docStore.exists(toRel))
81
- throw new MutationError("path_taken", `file already exists on disk at ${toRel}`);
82
- const content = docStore.read(info.path) ?? "";
83
- const diffs = { [info.path]: { before: content, after: "" }, [toRel]: { before: "", after: content } };
84
- const planned = { docId: info.docId, path: toRel, committed: false, diffs };
85
- if (!retargetInbound || inbound.length === 0)
86
- return { ...planned, dangling: inbound, retargeted: null };
87
- const plan = planInboundRetarget(store, info.docId, info.path, toRel, inbound);
88
- if (plan.ops.length > 0) {
89
- const preview = apply(store, { ...applyCtx(ctx), ops: plan.ops, dryRun: true, origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` } });
90
- Object.assign(diffs, preview.diffs ?? {});
91
- }
92
- return { ...planned, dangling: plan.dangling, retargeted: { blocks: plan.blocks, docs: plan.docs } };
93
- }
94
- const moved = underLock(ctx, () => {
95
- if (docStore.exists(toRel))
96
- throw new MutationError("path_taken", `file already exists on disk at ${toRel}`);
97
- const content = docStore.read(info.path) ?? "";
98
- if (docStore.exists(info.path))
99
- docStore.rename(info.path, toRel);
100
- else
101
- docStore.write(toRel, content);
102
- const ts = nowOr(ctx);
103
- store.write((db) => {
104
- // Update the document row's path + its open revisions' path pointer, and
105
- // record an api commit noting the move.
106
- const commit = newCommit(db, { repoId: ctx.repoId, ts, origin: "api", actor: ctx.actor ?? null, reason: `move ${info.path} -> ${toRel}` });
107
- db.prepare("UPDATE docs SET path = ? WHERE doc_id = ?").run(toRel, info.docId);
108
- db.prepare("UPDATE revisions SET path = ? WHERE doc_id = ? AND rev_id = ?").run(toRel, info.docId, info.currentRev);
109
- void commit;
110
- // Edge index. Every open edge from ANOTHER doc into this one resolved
111
- // via the old path, so it dangles now → re-point it at the old path's
112
- // phantom (in place, mirroring adoptPhantoms). A self-doc edge dangles
113
- // only when the link names the path (`[x](/old.md#H)`); a pure fragment
114
- // (`#H`) resolves to the doc regardless of path and is left alone — the
115
- // inbound scan distinguishes them from the raw.
116
- const phantom = `phantom:${info.path}`;
117
- const affected = new Set();
118
- const others = db.prepare("SELECT DISTINCT src_doc FROM edges WHERE dst_node = ? AND to_commit IS NULL AND src_doc != ?").all(info.docId, info.docId);
119
- for (const r of others)
120
- affected.add(r.src_doc);
121
- db.prepare("UPDATE edges SET dst_node = ? WHERE dst_node = ? AND to_commit IS NULL AND src_doc != ?").run(phantom, info.docId, info.docId);
122
- const selfEdge = db.prepare("UPDATE edges SET dst_node = ? WHERE dst_node = ? AND to_commit IS NULL AND src_doc = ? AND src_block = ? AND anchor IS ?");
123
- for (const l of inbound) {
124
- if (l.doc_id !== info.docId || l.block_id === null)
125
- continue;
126
- selfEdge.run(phantom, info.docId, info.docId, l.block_id, l.anchor);
127
- affected.add(info.docId);
128
- }
129
- for (const d of affected)
130
- rebuildDocEdges(db, d);
131
- // Links already written against the NEW path resolve to this doc now.
132
- adoptPhantoms(db, toRel, info.docId);
133
- });
134
- if (ctx.omgbaseDir) {
135
- docStore.clearStat(store, ctx.repoId, info.path); // drop the old path's row
136
- docStore.recordStat(store, ctx.repoId, toRel, content);
137
- }
138
- return { docId: info.docId, path: toRel, committed: true };
139
- });
140
- if (!retargetInbound || inbound.length === 0)
141
- return { ...moved, dangling: inbound, retargeted: null };
142
- // Rewrite the dangling links block by block (one coalesced update per block,
143
- // CAS on the block's current hash) and apply as one changeset; the re-ingest
144
- // re-extracts each source doc, whose links now resolve to the moved doc.
145
- const plan = planInboundRetarget(store, info.docId, info.path, toRel, inbound);
146
- if (plan.ops.length > 0) {
147
- apply(store, {
148
- ...applyCtx(ctx),
149
- ops: plan.ops,
150
- origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
151
- });
152
- }
153
- return { ...moved, dangling: plan.dangling, retargeted: { blocks: plan.blocks, docs: plan.docs } };
154
- }
155
- /** The `ApplyRequest` fields a document operation's context supplies to a follow-up changeset. */
156
- function applyCtx(ctx) {
46
+ export function parseNewDoc(symbol, path, source) {
47
+ const adapter = adapterForPath(path);
48
+ const tree = adapter ? adapter.parse(source) : parseTree(source);
49
+ const fm = tree.children[0]?.type === "frontmatter" ? tree.children[0] : null;
50
+ const rest = fm ? tree.children.slice(1) : tree.children;
51
+ const toMut = (b) => ({ id: mintId("b"), type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: b.children.map(toMut) });
157
52
  return {
158
- repoId: ctx.repoId,
159
- ...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
160
- ...(ctx.docStore ? { docStore: ctx.docStore } : {}),
161
- ...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
162
- ...(ctx.ts ? { ts: ctx.ts } : {}),
53
+ docId: symbol,
54
+ path,
55
+ format: adapter?.format ?? "markdown",
56
+ leadingTrivia: tree.leadingTrivia,
57
+ frontmatterRaw: fm ? fm.raw + fm.trivia : null,
58
+ children: rest.map(toMut),
163
59
  };
164
60
  }
165
61
  /**
166
62
  * The `retarget_inbound` plan of a move `fromPath → toRel`: one coalesced,
167
63
  * CAS-pinned `update` per deepest hit block, the blocks and docs it touches,
168
64
  * and the inbound links it leaves dangling. Pure over the store's current
169
- * blocks — the move itself changes no block — so the real move and its dry run
170
- * plan identically.
65
+ * blocks — the move itself changes no block — so the effect planner's `move
66
+ * document` (§11.3) and the tool's `dangling`/`retargeted` report (computed
67
+ * before the commit) plan identically.
171
68
  */
172
- function planInboundRetarget(store, docId, fromPath, toRel, inbound) {
69
+ export function planInboundRetarget(store, docId, fromPath, toRel, inbound) {
173
70
  const byBlock = new Map();
174
71
  for (const l of inbound)
175
72
  if (l.block_id !== null && !byBlock.has(l.block_id))
@@ -215,61 +112,10 @@ function planInboundRetarget(store, docId, fromPath, toRel, inbound) {
215
112
  const dangling = inbound.filter((l) => l.block_id === null || !rewritten.has(l.block_id));
216
113
  return { ops, blocks, docs: [...docs], dangling };
217
114
  }
218
- /** docs_delete: mark a document deleted and remove its file (resurrection-poolable). */
219
- export function docsDelete(store, ctx, docRef) {
220
- const info = findDocByRef(store, ctx.repoId, docRef);
221
- if (!info)
222
- throw new MutationError("doc_missing", `no document ${docRef}`);
223
- const docStore = resolveDocStore(ctx);
224
- if (ctx.dryRun) {
225
- return { docId: info.docId, path: info.path, committed: false, diffs: { [info.path]: { before: docStore.read(info.path) ?? "", after: "" } } };
226
- }
227
- return underLock(ctx, () => {
228
- const ts = nowOr(ctx);
229
- store.write((db) => {
230
- const commit = newCommit(db, { repoId: ctx.repoId, ts, origin: "api", actor: ctx.actor ?? null, reason: `delete ${info.path}` });
231
- // Tombstone the document and its live blocks; FTS rows drop with the blocks.
232
- ftsDeleteDoc(db, info.docId);
233
- db.prepare("UPDATE blocks SET deleted_commit = ? WHERE doc_id = ? AND deleted_commit IS NULL").run(commit.commitId, info.docId);
234
- db.prepare("UPDATE docs SET deleted_commit = ? WHERE doc_id = ?").run(commit.commitId, info.docId);
235
- });
236
- docStore.remove(info.path);
237
- if (ctx.omgbaseDir)
238
- docStore.clearStat(store, ctx.repoId, info.path);
239
- return { docId: info.docId, path: info.path, committed: true };
240
- });
241
- }
242
- /** docs_set_meta: surgical frontmatter patch (set/unset keys), re-ingested. */
243
- export function docsSetMeta(store, ctx, docRef, patch) {
244
- const info = findDocByRef(store, ctx.repoId, docRef);
245
- if (!info)
246
- throw new MutationError("doc_missing", `no document ${docRef}`);
247
- const docStore = resolveDocStore(ctx);
248
- const patched = () => {
249
- const original = docStore.read(info.path) ?? "";
250
- const { frontmatter, body } = splitFrontmatter(original);
251
- const merged = { ...frontmatter, ...(patch.set ?? {}) };
252
- for (const k of patch.unset ?? [])
253
- delete merged[k];
254
- return { original, content: composeFile(body, merged) };
255
- };
256
- if (ctx.dryRun) {
257
- const { original, content } = patched();
258
- return { docId: info.docId, path: info.path, committed: false, diffs: { [info.path]: { before: original, after: content } } };
259
- }
260
- return underLock(ctx, () => {
261
- const { content } = patched();
262
- docStore.write(info.path, content);
263
- const ts = nowOr(ctx);
264
- 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 }) });
265
- if (ctx.omgbaseDir)
266
- docStore.recordStat(store, ctx.repoId, info.path, content);
267
- return { docId: res.docId, path: info.path, committed: true };
268
- });
269
- }
270
- // Split a file's bytes into (frontmatter object, body markdown). Missing or
271
- // malformed frontmatter yields an empty object and the whole content as body.
272
- function splitFrontmatter(content) {
115
+ /** Split a file's bytes into (frontmatter object, body markdown) with
116
+ * `^---\r?\n([\s\S]*?)\r?\n---\r?\n?` (spec/mutate §6). Missing or malformed
117
+ * frontmatter yields an empty object and the whole content as body. */
118
+ export function splitFrontmatter(content) {
273
119
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
274
120
  if (!m)
275
121
  return { frontmatter: {}, body: content };