@omgbase/core 0.11.1 → 0.15.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 (59) 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/store.d.ts +9 -0
  9. package/dist/src/core/store/store.js +11 -0
  10. package/dist/src/graph/history.js +15 -12
  11. package/dist/src/index.d.ts +9 -5
  12. package/dist/src/index.js +14 -7
  13. package/dist/src/mcp/arguments.d.ts +9 -2
  14. package/dist/src/mcp/arguments.js +15 -2
  15. package/dist/src/mcp/errors.d.ts +1 -1
  16. package/dist/src/mcp/errors.js +3 -0
  17. package/dist/src/mcp/server.d.ts +22 -0
  18. package/dist/src/mcp/server.js +262 -185
  19. package/dist/src/mutate/apply.d.ts +93 -12
  20. package/dist/src/mutate/apply.js +203 -113
  21. package/dist/src/mutate/commit.d.ts +150 -0
  22. package/dist/src/mutate/commit.js +349 -0
  23. package/dist/src/mutate/doc-store.d.ts +3 -3
  24. package/dist/src/mutate/doc-store.js +21 -6
  25. package/dist/src/mutate/docs.d.ts +42 -87
  26. package/dist/src/mutate/docs.js +50 -204
  27. package/dist/src/mutate/effects.d.ts +331 -0
  28. package/dist/src/mutate/effects.js +981 -0
  29. package/dist/src/mutate/materialize.d.ts +49 -0
  30. package/dist/src/mutate/materialize.js +174 -0
  31. package/dist/src/mutate/opset.d.ts +27 -2
  32. package/dist/src/mutate/opset.js +12 -5
  33. package/dist/src/mutate/plan-update.d.ts +1 -46
  34. package/dist/src/mutate/plan-update.js +19 -67
  35. package/dist/src/mutate/plan.d.ts +200 -0
  36. package/dist/src/mutate/plan.js +472 -0
  37. package/dist/src/mutate/sugar.d.ts +135 -0
  38. package/dist/src/mutate/sugar.js +167 -0
  39. package/dist/src/mutate/tree.d.ts +4 -2
  40. package/dist/src/spec-versions.d.ts +5 -5
  41. package/dist/src/spec-versions.js +5 -5
  42. package/dist/src/surface-paths.d.ts +17 -26
  43. package/dist/src/surface-paths.js +27 -22
  44. package/dist/src/sync/checkpoint.d.ts +5 -2
  45. package/dist/src/sync/checkpoint.js +10 -6
  46. package/dist/src/sync/freshness.d.ts +10 -7
  47. package/dist/src/sync/freshness.js +15 -17
  48. package/dist/src/sync/observe.d.ts +4 -1
  49. package/dist/src/sync/observe.js +56 -5
  50. package/dist/src/sync/recovery.d.ts +4 -0
  51. package/dist/src/sync/recovery.js +4 -1
  52. package/dist/src/sync/stat-cache.d.ts +7 -0
  53. package/dist/src/sync/stat-cache.js +22 -0
  54. package/dist/src/sync/tombstone.js +23 -19
  55. package/dist/src/sync/watcher.d.ts +2 -0
  56. package/dist/src/sync/watcher.js +4 -1
  57. package/dist/src/sync/writer-lock.d.ts +15 -0
  58. package/dist/src/sync/writer-lock.js +104 -17
  59. package/package.json +1 -1
@@ -0,0 +1,49 @@
1
+ import type { Database } from "better-sqlite3";
2
+ import type { Store } from "../core/store/store.js";
3
+ import type { DocStore } from "./doc-store.js";
4
+ export type PendingKind = "write" | "rename" | "remove";
5
+ /** One `pending_materializations` row (spec/store §3.1), hash as the 32 raw bytes. */
6
+ export interface PendingRow {
7
+ path: string;
8
+ kind: PendingKind;
9
+ fromPath: string | null;
10
+ docId: string;
11
+ revId: string | null;
12
+ renderedHash: Buffer;
13
+ commitId: string;
14
+ }
15
+ /** The pending row at `(repo, path)`, or null. */
16
+ export declare function readPending(db: Database, repoId: string, path: string): PendingRow | null;
17
+ /** Every pending row of a repo, in insertion order (a replaced row moves to the end). */
18
+ export declare function listPending(db: Database, repoId: string): PendingRow[];
19
+ /** Record an intent (`INSERT OR REPLACE`: one row per path — a later request for the same path supersedes the earlier). */
20
+ export declare function insertPending(db: Database, repoId: string, row: PendingRow): void;
21
+ /** Drop the intent at `(repo, path)`: the file is as recorded, or a later observation superseded it. */
22
+ export declare function deletePending(db: Database, repoId: string, path: string): void;
23
+ /** The rendered hash of the revision BEFORE `revId` of the same document (what
24
+ * the file held before the commit that recorded the row), or null for a first
25
+ * revision / an unknown one. */
26
+ export declare function priorRenderedHash(db: Database, revId: string | null): Buffer | null;
27
+ export interface MaterializeOptions {
28
+ /** The hash of the bytes the materializer may overwrite at the row's path (the
29
+ * engine's own previous bytes). Defaults to the rendered hash of the revision
30
+ * before the row's `rev_id`. `null` = only an absent or already-equal file is
31
+ * acceptable (a created document has no previous bytes). */
32
+ priorHash?: Buffer | null;
33
+ }
34
+ /**
35
+ * Materialize one row through `docStore` and, when the file is as recorded,
36
+ * delete the row. Returns true when the row was cleared, false when it was kept
37
+ * (the path holds foreign bytes — the next observation decides — or the bytes
38
+ * could not be rendered). Never throws for a foreign file; a doc store write
39
+ * failure propagates with the row intact.
40
+ */
41
+ export declare function materializeRow(store: Store, repoId: string, row: PendingRow, docStore: DocStore, opts?: MaterializeOptions): boolean;
42
+ /**
43
+ * Replay every pending row of a repo through `docStore`, in insertion order.
44
+ * Returns the paths whose rows were cleared (materialized or confirmed); rows
45
+ * kept for a foreign file are not listed. The recovery point of the freshness
46
+ * sweep (step 0) and of `recoverRepo`; the commit phase materializes its own
47
+ * rows per effect instead, interleaved with the stat-cache refreshes.
48
+ */
49
+ export declare function materializePending(store: Store, repoId: string, docStore: DocStore): string[];
@@ -0,0 +1,174 @@
1
+ import { sha256 } from "../core/hash.js";
2
+ import { readDocumentAtRevision } from "../core/read/document.js";
3
+ const COLUMNS = "path, kind, from_path, doc_id, rev_id, rendered_hash, commit_id";
4
+ function fromRaw(r) {
5
+ return { path: r.path, kind: r.kind, fromPath: r.from_path, docId: r.doc_id, revId: r.rev_id, renderedHash: r.rendered_hash, commitId: r.commit_id };
6
+ }
7
+ /** The pending row at `(repo, path)`, or null. */
8
+ export function readPending(db, repoId, path) {
9
+ const r = db.prepare(`SELECT ${COLUMNS} FROM pending_materializations WHERE repo_id = ? AND path = ?`).get(repoId, path);
10
+ return r ? fromRaw(r) : null;
11
+ }
12
+ /** Every pending row of a repo, in insertion order (a replaced row moves to the end). */
13
+ export function listPending(db, repoId) {
14
+ return db.prepare(`SELECT ${COLUMNS} FROM pending_materializations WHERE repo_id = ? ORDER BY rowid`).all(repoId).map(fromRaw);
15
+ }
16
+ /** Record an intent (`INSERT OR REPLACE`: one row per path — a later request for the same path supersedes the earlier). */
17
+ export function insertPending(db, repoId, row) {
18
+ db.prepare(`INSERT OR REPLACE INTO pending_materializations (repo_id, ${COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(repoId, row.path, row.kind, row.fromPath, row.docId, row.revId, row.renderedHash, row.commitId);
19
+ }
20
+ /** Drop the intent at `(repo, path)`: the file is as recorded, or a later observation superseded it. */
21
+ export function deletePending(db, repoId, path) {
22
+ db.prepare("DELETE FROM pending_materializations WHERE repo_id = ? AND path = ?").run(repoId, path);
23
+ }
24
+ /** The rendered hash of the revision BEFORE `revId` of the same document (what
25
+ * the file held before the commit that recorded the row), or null for a first
26
+ * revision / an unknown one. */
27
+ export function priorRenderedHash(db, revId) {
28
+ if (revId === null)
29
+ return null;
30
+ const r = db
31
+ .prepare("SELECT p.rendered_hash FROM revisions r JOIN revisions p ON p.doc_id = r.doc_id AND p.seq = r.seq - 1 WHERE r.rev_id = ?")
32
+ .get(revId);
33
+ return r?.rendered_hash ?? null;
34
+ }
35
+ // The bytes a `write`/`rename` row materializes: spec/store §6.2 reconstruct at
36
+ // the row's revision, which must hash to `rendered_hash` (it does for the
37
+ // current revision; a row always names the document's current revision because
38
+ // a later commit to the same path replaces the row). Null when nothing can be
39
+ // rendered — the row is then left for a later materializer rather than guessed.
40
+ function renderedBytes(store, row) {
41
+ if (row.revId === null)
42
+ return null;
43
+ const at = readDocumentAtRevision(store, row.docId, row.revId);
44
+ if (!at || !sha256(at.raw).equals(row.renderedHash))
45
+ return null;
46
+ return at.raw;
47
+ }
48
+ /**
49
+ * Materialize one row through `docStore` and, when the file is as recorded,
50
+ * delete the row. Returns true when the row was cleared, false when it was kept
51
+ * (the path holds foreign bytes — the next observation decides — or the bytes
52
+ * could not be rendered). Never throws for a foreign file; a doc store write
53
+ * failure propagates with the row intact.
54
+ */
55
+ export function materializeRow(store, repoId, row, docStore, opts = {}) {
56
+ const db = store.db;
57
+ const prior = opts.priorHash !== undefined ? opts.priorHash : priorRenderedHash(db, row.revId);
58
+ const own = (bytes) => {
59
+ const h = sha256(bytes);
60
+ return h.equals(row.renderedHash) || (prior !== null && h.equals(prior));
61
+ };
62
+ let done;
63
+ switch (row.kind) {
64
+ case "write": {
65
+ const current = docStore.read(row.path);
66
+ if (current !== null && sha256(current).equals(row.renderedHash))
67
+ done = true;
68
+ else if (current !== null && !own(current))
69
+ done = false;
70
+ else {
71
+ const bytes = renderedBytes(store, row);
72
+ if (bytes === null)
73
+ done = false;
74
+ else {
75
+ docStore.write(row.path, bytes);
76
+ done = true;
77
+ }
78
+ }
79
+ break;
80
+ }
81
+ case "rename": {
82
+ const at = docStore.read(row.path);
83
+ if (at !== null) {
84
+ // The destination is occupied: by the moved bytes (done — a stale copy of
85
+ // the engine's own bytes left at `from` goes) or by a foreign file (the
86
+ // row stays; the sweep sees `to` win and `from` become untracked).
87
+ if (!sha256(at).equals(row.renderedHash))
88
+ done = false;
89
+ else {
90
+ const from = row.fromPath === null ? null : docStore.read(row.fromPath);
91
+ if (from !== null && own(from))
92
+ docStore.remove(row.fromPath);
93
+ done = true;
94
+ }
95
+ }
96
+ else if (row.fromPath !== null && docStore.exists(row.fromPath)) {
97
+ let renamed = true;
98
+ try {
99
+ docStore.rename(row.fromPath, row.path);
100
+ }
101
+ catch {
102
+ // EXDEV, a destination that appeared, a vanished source: fall back
103
+ // to writing the revision's bytes at `to` (below) when `to` is still
104
+ // free; a foreign file that appeared keeps the row.
105
+ renamed = false;
106
+ }
107
+ if (renamed) {
108
+ // A self-linking moved document renders new bytes under its new path:
109
+ // the renamed file holds the previous revision, so it is rewritten.
110
+ const moved = docStore.read(row.path);
111
+ if (moved !== null && !sha256(moved).equals(row.renderedHash) && own(moved)) {
112
+ const bytes = renderedBytes(store, row);
113
+ if (bytes !== null)
114
+ docStore.write(row.path, bytes);
115
+ }
116
+ done = true;
117
+ }
118
+ else if (docStore.read(row.path) !== null) {
119
+ done = false;
120
+ }
121
+ else {
122
+ const bytes = renderedBytes(store, row);
123
+ if (bytes === null)
124
+ done = false;
125
+ else {
126
+ docStore.write(row.path, bytes);
127
+ const from = docStore.read(row.fromPath);
128
+ if (from !== null && own(from))
129
+ docStore.remove(row.fromPath);
130
+ done = true;
131
+ }
132
+ }
133
+ }
134
+ else {
135
+ const bytes = renderedBytes(store, row);
136
+ if (bytes === null)
137
+ done = false;
138
+ else {
139
+ docStore.write(row.path, bytes);
140
+ done = true;
141
+ }
142
+ }
143
+ break;
144
+ }
145
+ case "remove": {
146
+ // Unlink only the bytes the deleted revision rendered; a file a human
147
+ // rewrote meanwhile stays (untracked now — the next sweep re-creates it).
148
+ const at = docStore.read(row.path);
149
+ if (at !== null && sha256(at).equals(row.renderedHash))
150
+ docStore.remove(row.path);
151
+ done = true;
152
+ break;
153
+ }
154
+ }
155
+ if (done)
156
+ deletePending(db, repoId, row.path);
157
+ return done;
158
+ }
159
+ /**
160
+ * Replay every pending row of a repo through `docStore`, in insertion order.
161
+ * Returns the paths whose rows were cleared (materialized or confirmed); rows
162
+ * kept for a foreign file are not listed. The recovery point of the freshness
163
+ * sweep (step 0) and of `recoverRepo`; the commit phase materializes its own
164
+ * rows per effect instead, interleaved with the stat-cache refreshes.
165
+ */
166
+ export function materializePending(store, repoId, docStore) {
167
+ const out = [];
168
+ for (const row of listPending(store.db, repoId)) {
169
+ if (materializeRow(store, repoId, row, docStore))
170
+ out.push(row.path);
171
+ }
172
+ return out;
173
+ }
174
+ //# sourceMappingURL=materialize.js.map
@@ -65,8 +65,9 @@ export interface Opset {
65
65
  /**
66
66
  * Document-level frontmatter change, when the proposed content changes it.
67
67
  * `raw` is the new frontmatter incl. fences + trailing separator (null drops
68
- * it). Frontmatter is a materialization unit, not a block op; apply sets it
69
- * via ApplyRequest.setFrontmatter.
68
+ * it). Frontmatter is a materialization unit, not a block op; the `set
69
+ * content` lowering sets it as the document's file head (`setHead`,
70
+ * spec/mutate §11.3 — the kernel request's `set_frontmatter` through 2.x).
70
71
  */
71
72
  frontmatter?: {
72
73
  raw: string | null;
@@ -81,6 +82,28 @@ export interface Opset {
81
82
  converges: boolean;
82
83
  diagnostics: string[];
83
84
  }
85
+ /**
86
+ * The identity consequences of a `set content` effect as a Plan surfaces them
87
+ * (spec/mutate §7/§11.4, 3.0: `preview.identity[<doc id>]`): the Opset's
88
+ * `matcher_v`, `summary`, `diagnostics`, and per planned op its position in
89
+ * the plan's `opset.ops` with the `PlanOp` annotation — what `docs_update`'s
90
+ * `{ opset, plan }` result carried through 3.x, minus the kernel ops (they are
91
+ * the plan's) and `converges` (a returned plan converges; the failure is
92
+ * `plan_not_convergent`).
93
+ */
94
+ export interface IdentityPreview {
95
+ matcher_v: string;
96
+ summary: OpsetSummary;
97
+ ops: {
98
+ op_index: number;
99
+ disposition: PlanDisposition;
100
+ blocks: string[];
101
+ confidence: number | null;
102
+ reason: string | null;
103
+ detail?: Record<string, unknown>;
104
+ }[];
105
+ diagnostics: string[];
106
+ }
84
107
  /** The bare kernel ops, in order — what `apply` actually replays. */
85
108
  export declare function opsetKernelOps(opset: Opset): Op[];
86
109
  /** Compute the summary from the planned ops + carried/ambiguous counts. */
@@ -93,3 +116,5 @@ export declare function serializeOpset(opset: Opset): string;
93
116
  /** Parse a serialized opset, validating the version tag. */
94
117
  export declare function parseOpset(text: string): Opset;
95
118
  export declare function renderOpsetPlan(opset: Opset): string;
119
+ /** The same text over a Plan's `preview.identity[<doc>]` (3.0; `omg update --dry-run`): one line per op, an empty line, the summary. */
120
+ export declare function renderIdentityPlan(identity: IdentityPreview): string;
@@ -60,20 +60,27 @@ const VERB = {
60
60
  copied_from: "COPY ", resurrected: "RESURR", bulk_rewrite: "REWRITE", retiled: "RETILE",
61
61
  };
62
62
  export function renderOpsetPlan(opset) {
63
+ const lines = planLines(opset.ops, opset.summary);
64
+ if (!opset.converges)
65
+ lines.push(`WARNING: plan does not reproduce the proposed content exactly — will not apply.`);
66
+ return lines.join("\n");
67
+ }
68
+ /** The same text over a Plan's `preview.identity[<doc>]` (3.0; `omg update --dry-run`): one line per op, an empty line, the summary. */
69
+ export function renderIdentityPlan(identity) {
70
+ return planLines(identity.ops, identity.summary).join("\n");
71
+ }
72
+ function planLines(ops, s) {
63
73
  const lines = [];
64
- for (const p of opset.ops) {
74
+ for (const p of ops) {
65
75
  const subject = p.blocks[0] ?? "(new)";
66
76
  const conf = p.confidence !== null ? ` ~${p.confidence.toFixed(2)}` : "";
67
77
  const why = p.reason ? ` [${p.reason}]` : "";
68
78
  lines.push(`${VERB[p.disposition]} ${subject.padEnd(9)} ${p.disposition}${conf}${why}`);
69
79
  }
70
- const s = opset.summary;
71
80
  lines.push("");
72
81
  lines.push(`preserved: ${s.preserved} updated: ${s.updated} moved: ${s.moved} ` +
73
82
  `created: ${s.created} removed: ${s.removed} split: ${s.split} ` +
74
83
  `merged: ${s.merged} ambiguous: ${s.ambiguous}`);
75
- if (!opset.converges)
76
- lines.push(`WARNING: plan does not reproduce the proposed content exactly — will not apply.`);
77
- return lines.join("\n");
84
+ return lines;
78
85
  }
79
86
  //# sourceMappingURL=opset.js.map
@@ -1,7 +1,5 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type ReconcileConfig } from "../reconcile/types.js";
3
- import { type ApplyResult } from "./apply.js";
4
- import type { DocStore } from "./doc-store.js";
5
3
  import { type Opset } from "./opset.js";
6
4
  export interface PlanUpdateOptions {
7
5
  config?: ReconcileConfig;
@@ -9,49 +7,6 @@ export interface PlanUpdateOptions {
9
7
  /**
10
8
  * Plan a whole-document update. `docRef` is a doc id or repo-relative path;
11
9
  * `content` is the proposed complete document. Throws doc_missing if the
12
- * document does not exist (a create is docsCreate's job). Never writes.
10
+ * document does not exist (a create is the `create` effect's job). Never writes.
13
11
  */
14
12
  export declare function planUpdate(store: Store, repoId: string, rootPath: string, docRef: string, content: string, opts?: PlanUpdateOptions): Opset;
15
- export interface ApplyOpsetRequest {
16
- repoId: string;
17
- /** Working-tree root for the default filesystem write target; omit only with an explicit `docStore`. */
18
- rootPath?: string;
19
- /** Write target (ADR-014 §5); defaults to a filesystem store at `rootPath`. */
20
- docStore?: DocStore;
21
- opset: Opset;
22
- origin: {
23
- actor: string;
24
- reason?: string;
25
- };
26
- dryRun?: boolean;
27
- omgbaseDir?: string;
28
- /** Commit timestamp (RFC 3339 UTC); defaults to now (see `ApplyRequest.ts`). */
29
- ts?: string;
30
- }
31
- /**
32
- * Apply an opset through the kernel write path. Validates the plan's
33
- * preconditions against current state first (a stale plan fails, carrying
34
- * current truth, rather than silently reinterpreting) and refuses a plan that
35
- * did not verify byte-convergent.
36
- */
37
- export declare function applyOpset(store: Store, req: ApplyOpsetRequest): ApplyResult;
38
- export interface DocsUpdateContext {
39
- repoId: string;
40
- rootPath: string;
41
- omgbaseDir?: string;
42
- actor?: string;
43
- }
44
- export interface DocsUpdateResult {
45
- opset: Opset;
46
- /** null on dry_run (plan only) */
47
- result: ApplyResult | null;
48
- }
49
- /**
50
- * Whole-document update convenience: plan then apply. `dryRun` returns the
51
- * opset without executing (the note's docs.update(..., dry_run=true)).
52
- */
53
- export declare function docsUpdate(store: Store, ctx: DocsUpdateContext, docRef: string, content: string, opts?: {
54
- dryRun?: boolean;
55
- config?: ReconcileConfig;
56
- reason?: string;
57
- }): DocsUpdateResult;
@@ -8,9 +8,9 @@ import { DEFAULT_CONFIG } from "../reconcile/types.js";
8
8
  import { loadOldMatchBlocks } from "../sync/reconciling-ingest.js";
9
9
  import { loadMutDoc } from "./load.js";
10
10
  import { renderDoc, MutationError } from "./tree.js";
11
- import { apply } from "./apply.js";
11
+ import { ChangesetPlanner } from "./apply.js";
12
12
  import { lowerTopLevel, lowerReplace } from "./lower.js";
13
- import { summarizeOps, opsetKernelOps } from "./opset.js";
13
+ import { summarizeOps } from "./opset.js";
14
14
  function toFlatSource(b) {
15
15
  return { type: b.type, raw: b.raw, children: b.children.map(toFlatSource) };
16
16
  }
@@ -22,9 +22,10 @@ function extractFrontmatter(blocks) {
22
22
  /**
23
23
  * Plan a whole-document update. `docRef` is a doc id or repo-relative path;
24
24
  * `content` is the proposed complete document. Throws doc_missing if the
25
- * document does not exist (a create is docsCreate's job). Never writes.
25
+ * document does not exist (a create is the `create` effect's job). Never writes.
26
26
  */
27
27
  export function planUpdate(store, repoId, rootPath, docRef, content, opts = {}) {
28
+ void rootPath; // kept for the call shape; the planner never writes (3.0: the verify runs the in-memory planner, no doc store)
28
29
  const config = opts.config ?? DEFAULT_CONFIG;
29
30
  const info = findDocByRef(store, repoId, docRef);
30
31
  if (!info)
@@ -51,14 +52,15 @@ export function planUpdate(store, repoId, rootPath, docRef, content, opts = {})
51
52
  // builds frontmatterRaw as `bytes + separator`; mirror that for the target.
52
53
  const targetFm = fmBlock ? fmBlock.raw + fmBlock.trivia : null;
53
54
  const fmChanged = targetFm !== oldDoc.frontmatterRaw;
54
- const setFrontmatter = fmChanged ? [{ doc: docId, raw: targetFm }] : undefined;
55
+ // The verify's file head (spec/mutate §11.3: `leading_trivia + raw`), when the frontmatter changes.
56
+ const head = fmChanged ? oldDoc.leadingTrivia + (targetFm ?? "") : undefined;
55
57
  const diagnostics = [];
56
58
  // Try lowerings in order of decreasing identity preservation; take the first
57
59
  // that reproduces the proposed content exactly.
58
60
  let chosen = null;
59
61
  let converges = false;
60
62
  const t2 = lowerTopLevel(oldDoc, rest, assignmentOf(result), dispositionsOf(result));
61
- const v2 = verify(store, repoId, rootPath, path, opsOf(t2), setFrontmatter, content, currentContent);
63
+ const v2 = verify(store, repoId, docId, path, opsOf(t2), head, content, currentContent);
62
64
  if (v2.ok) {
63
65
  chosen = t2;
64
66
  converges = true;
@@ -66,7 +68,7 @@ export function planUpdate(store, repoId, rootPath, docRef, content, opts = {})
66
68
  else {
67
69
  diagnostics.push(`top-level lowering did not reproduce the proposed content byte-for-byte (${describeDivergence(v2, tree.children, content)}); falling back to full replace`);
68
70
  const t3 = lowerReplace(oldDoc, content);
69
- const v3 = verify(store, repoId, rootPath, path, opsOf(t3), setFrontmatter, content, currentContent);
71
+ const v3 = verify(store, repoId, docId, path, opsOf(t3), head, content, currentContent);
70
72
  if (v3.ok) {
71
73
  chosen = t3;
72
74
  converges = true;
@@ -99,14 +101,20 @@ function dispositionsOf(r) {
99
101
  return r.dispositions;
100
102
  }
101
103
  function opsOf(l) { return l.ops.map((p) => p.op); }
102
- // Simulate a candidate op list and check the rendered result equals the exact
103
- // proposed content. An invalid script throws in apply → treated as non-convergent.
104
- function verify(store, repoId, rootPath, path, ops, setFrontmatter, expected, currentContent) {
104
+ // Simulate a candidate op list (§4 steps 1–4 through the incremental planner,
105
+ // the file head set first) and check the rendered result equals the exact
106
+ // proposed content. An invalid script throws → treated as non-convergent.
107
+ function verify(store, repoId, docId, path, ops, head, expected, currentContent) {
105
108
  try {
106
- const res = apply(store, { repoId, rootPath, ops, origin: { actor: "plan:verify" }, dryRun: true, ...(setFrontmatter ? { setFrontmatter } : {}) });
109
+ const planner = new ChangesetPlanner(store, repoId);
110
+ if (head !== undefined)
111
+ planner.setHead(docId, head);
112
+ for (const op of ops)
113
+ planner.push(op);
114
+ const res = planner.finish();
107
115
  // A doc untouched by any op (e.g. an empty plan) is absent from diffs; its
108
116
  // effective result is the unchanged current content.
109
- const after = res.diffs?.[path]?.after ?? currentContent;
117
+ const after = res.diffs[path]?.after ?? currentContent;
110
118
  return after === expected ? { ok: true } : { ok: false, actual: after, error: null };
111
119
  }
112
120
  catch (e) {
@@ -131,60 +139,4 @@ function describeDivergence(v, proposed, expected) {
131
139
  const snippet = (s) => JSON.stringify(s.slice(off, off + 24));
132
140
  return `first divergence at byte ${off} in ${where}: expected ${snippet(expected)}, rendered ${snippet(actual)}`;
133
141
  }
134
- /**
135
- * Apply an opset through the kernel write path. Validates the plan's
136
- * preconditions against current state first (a stale plan fails, carrying
137
- * current truth, rather than silently reinterpreting) and refuses a plan that
138
- * did not verify byte-convergent.
139
- */
140
- export function applyOpset(store, req) {
141
- const { opset } = req;
142
- if (!opset.converges) {
143
- const why = opset.diagnostics.length > 0 ? ` — ${opset.diagnostics[opset.diagnostics.length - 1]}` : "";
144
- throw new MutationError("plan_not_convergent", `opset does not reproduce the proposed content; re-plan${why}`, { diagnostics: opset.diagnostics });
145
- }
146
- // Precondition: the document must still be at the revision/content the plan
147
- // was computed against.
148
- const docId = opset.precondition.id;
149
- const row = store.db.prepare("SELECT current_rev, lower(hex(file_hash)) fh FROM docs WHERE doc_id = ? AND deleted_commit IS NULL").get(docId);
150
- if (!row)
151
- throw new MutationError("doc_missing", `doc ${docId} not found`, { doc: docId });
152
- const stale = (opset.precondition.baseRevision !== null && row.current_rev !== opset.precondition.baseRevision) || (row.fh !== null && row.fh !== opset.precondition.hash);
153
- if (stale) {
154
- throw new MutationError("stale_plan", "document changed since the plan was computed; re-plan", {
155
- doc: docId,
156
- expected_revision: opset.precondition.baseRevision,
157
- current: { revision: row.current_rev, hash: row.fh },
158
- retriable: true,
159
- });
160
- }
161
- return apply(store, {
162
- repoId: req.repoId,
163
- ...(req.rootPath !== undefined ? { rootPath: req.rootPath } : {}),
164
- ...(req.docStore !== undefined ? { docStore: req.docStore } : {}),
165
- ops: opsetKernelOps(opset),
166
- origin: req.origin,
167
- ...(req.dryRun !== undefined ? { dryRun: req.dryRun } : {}),
168
- ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}),
169
- ...(req.ts !== undefined ? { ts: req.ts } : {}),
170
- ...(opset.frontmatter ? { setFrontmatter: [{ doc: docId, raw: opset.frontmatter.raw }] } : {}),
171
- });
172
- }
173
- /**
174
- * Whole-document update convenience: plan then apply. `dryRun` returns the
175
- * opset without executing (the note's docs.update(..., dry_run=true)).
176
- */
177
- export function docsUpdate(store, ctx, docRef, content, opts = {}) {
178
- const opset = planUpdate(store, ctx.repoId, ctx.rootPath, docRef, content, opts.config ? { config: opts.config } : {});
179
- if (opts.dryRun)
180
- return { opset, result: null };
181
- const result = applyOpset(store, {
182
- repoId: ctx.repoId,
183
- rootPath: ctx.rootPath,
184
- opset,
185
- origin: { actor: ctx.actor ?? "agent:update", ...(opts.reason !== undefined ? { reason: opts.reason } : {}) },
186
- ...(ctx.omgbaseDir !== undefined ? { omgbaseDir: ctx.omgbaseDir } : {}),
187
- });
188
- return { opset, result };
189
- }
190
142
  //# sourceMappingURL=plan-update.js.map