@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
@@ -0,0 +1,138 @@
1
+ import { MutationError } from "./tree.js";
2
+ /** The argument-shape error of the surface (`effects.ts` `effectInvalid`), spelled here to avoid the module cycle. */
3
+ function effectInvalid(message) {
4
+ return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
5
+ }
6
+ // Canonical JSON (spec/mutate §11.5): the text a plan's digest is taken over,
7
+ // and since 3.3 the text every error `fix` and every `summary` value is spelled
8
+ // in — one serialization both engines produce byte for byte. Kept apart from
9
+ // `plan.ts` so the compile (`select.ts`) and the lowering (`effects.ts`) can use
10
+ // it without a module cycle.
11
+ function cmpUtf8(a, b) {
12
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
13
+ }
14
+ // A lone surrogate has no UTF-8 encoding: it cannot be digested (nor sent as JSON text).
15
+ function isWellFormed(s) {
16
+ for (let i = 0; i < s.length; i++) {
17
+ const c = s.charCodeAt(i);
18
+ if (c >= 0xd800 && c <= 0xdbff) {
19
+ const n = s.charCodeAt(i + 1);
20
+ if (!(n >= 0xdc00 && n <= 0xdfff))
21
+ return false;
22
+ i++;
23
+ }
24
+ else if (c >= 0xdc00 && c <= 0xdfff)
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ function canonicalString(s) {
30
+ if (!isWellFormed(s))
31
+ throw effectInvalid("strings must be well-formed Unicode (a lone surrogate cannot be digested)");
32
+ let out = '"';
33
+ for (let i = 0; i < s.length; i++) {
34
+ const c = s.charCodeAt(i);
35
+ switch (c) {
36
+ case 0x22:
37
+ out += '\\"';
38
+ break;
39
+ case 0x5c:
40
+ out += "\\\\";
41
+ break;
42
+ case 0x08:
43
+ out += "\\b";
44
+ break;
45
+ case 0x0c:
46
+ out += "\\f";
47
+ break;
48
+ case 0x0a:
49
+ out += "\\n";
50
+ break;
51
+ case 0x0d:
52
+ out += "\\r";
53
+ break;
54
+ case 0x09:
55
+ out += "\\t";
56
+ break;
57
+ default:
58
+ out += c < 0x20 ? `\\u${c.toString(16).padStart(4, "0")}` : s[i];
59
+ }
60
+ }
61
+ return out + '"';
62
+ }
63
+ function canonicalNumber(n) {
64
+ if (!Number.isFinite(n))
65
+ throw effectInvalid("a number in a plan must be finite");
66
+ if (Number.isInteger(n) && Math.abs(n) < 2 ** 53)
67
+ return String(n === 0 ? 0 : n);
68
+ return String(n);
69
+ }
70
+ /**
71
+ * Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
72
+ * bytes; members whose value is `undefined` omitted (an optional key absent is
73
+ * absent, never `null`; a JSON `null` value stays `null`); strings escaped only
74
+ * as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
75
+ * `\u00xx` lower-case) — everything else literal UTF-8; a number that is
76
+ * integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
77
+ * finite number by ECMAScript `Number::toString` (shortest round-trip digits);
78
+ * `true`/`false`/`null`.
79
+ */
80
+ export function canonicalJson(v) {
81
+ if (v === null)
82
+ return "null";
83
+ switch (typeof v) {
84
+ case "boolean":
85
+ return v ? "true" : "false";
86
+ case "number":
87
+ return canonicalNumber(v);
88
+ case "string":
89
+ return canonicalString(v);
90
+ case "object": {
91
+ if (Array.isArray(v))
92
+ return `[${v.map((x) => canonicalJson(x === undefined ? null : x)).join(",")}]`;
93
+ const o = v;
94
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(cmpUtf8);
95
+ return `{${keys.map((k) => `${canonicalString(k)}:${canonicalJson(o[k])}`).join(",")}}`;
96
+ }
97
+ default:
98
+ throw effectInvalid(`a ${typeof v} cannot be digested`);
99
+ }
100
+ }
101
+ /** The `fix` for a request to the SAME tool: the corrected arguments, canonical JSON. */
102
+ export function fixRequest(req) {
103
+ return canonicalJson({
104
+ ...(req.select !== undefined ? { select: req.select } : {}),
105
+ effects: req.effects,
106
+ ...(req.expect !== undefined ? { expect: req.expect } : {}),
107
+ });
108
+ }
109
+ /** The `fix` naming another tool: `<tool> <canonical args>`. */
110
+ export function fixTool(tool, args) {
111
+ return `${tool} ${canonicalJson(args)}`;
112
+ }
113
+ /** The selector that names exactly one row: the request narrowed to `from <root> where id == "<id>"` with `expect.max: 1` (the fix of a cross-hit collision or an ambiguous target under a selector). */
114
+ export function narrowedTo(root, id) {
115
+ return `from ${root} where id == ${JSON.stringify(id)}`;
116
+ }
117
+ /** The root a row id belongs to, by its prefix (`d_` / `b_` / `n_`). */
118
+ export function rootOfId(id) {
119
+ if (id.startsWith("d_"))
120
+ return "docs";
121
+ if (id.startsWith("n_"))
122
+ return "nodes";
123
+ return "blocks";
124
+ }
125
+ /** `value` with the member at `path` (keys and array indices) replaced; a copy, the input untouched. */
126
+ export function withMember(value, path, replacement) {
127
+ if (path.length === 0)
128
+ return replacement;
129
+ const [head, ...rest] = path;
130
+ if (Array.isArray(value)) {
131
+ const copy = [...value];
132
+ copy[Number(head)] = withMember(copy[Number(head)], rest, replacement);
133
+ return copy;
134
+ }
135
+ const o = (typeof value === "object" && value !== null ? value : {});
136
+ return { ...o, [head]: withMember(o[head], rest, replacement) };
137
+ }
138
+ //# sourceMappingURL=canonical.js.map
@@ -0,0 +1,150 @@
1
+ import type { Database } from "better-sqlite3";
2
+ import type { Store } from "../core/store/store.js";
3
+ import { type IngestResult } from "../core/ingest.js";
4
+ import type { InboundLink } from "../graph/inbound-links.js";
5
+ import type { DocStore } from "./doc-store.js";
6
+ import { type MutDoc } from "./tree.js";
7
+ /** The row triple a document was planned against (spec/mutate §4 step 5, 2.2):
8
+ * `docs.path`, `docs.current_rev` and `docs.file_hash` (lower-case hex, `null`
9
+ * when the row has none) as one `SELECT` read them at plan time. Invariant I3
10
+ * (spec/store §8) makes `current_rev` the version vector of every live block,
11
+ * so the commit need not re-read blocks: a block cannot change without a new
12
+ * revision. The same triple the opset precondition `{ id, path, base_revision,
13
+ * hash }` freezes. */
14
+ export interface DocBase {
15
+ path: string;
16
+ currentRev: string | null;
17
+ fileHash: string | null;
18
+ }
19
+ /** One `SELECT path, current_rev, file_hash` of a live document: the triple
20
+ * the plan captures and the commit re-validates. `null` when no live row. */
21
+ export declare function readDocBase(db: Database, docId: string): DocBase | null;
22
+ export type Effect =
23
+ /** The api ingest of a mutated working tree: `rendered` is `renderDoc(mutDoc)`,
24
+ * the known-id resolver threads `mutDoc`'s ids onto the re-parse and labels
25
+ * dispositions against `priorIds`. `path` is where the bytes are written and
26
+ * the path the revision carries — the moved document of a `docs_move` is
27
+ * loaded under its OLD path and overridden to the new one; `base.path` is
28
+ * the path it was loaded under (where its bytes are on disk at phase A).
29
+ * `base` is the triple the document was planned against — or `null` for a
30
+ * document the request CREATES (spec/mutate §11.3, 3.0: the planner adopted
31
+ * its parsed tree, so its blocks commit with known ids and `inserted / api`
32
+ * dispositions); the guard is then `path_taken` — no live document at `path`
33
+ * when the transaction validates — and `docId` is the plan's symbol, the
34
+ * ingest minting the real `d`. */
35
+ {
36
+ kind: "ingest_known";
37
+ docId: string;
38
+ path: string;
39
+ rendered: string;
40
+ mutDoc: MutDoc;
41
+ priorIds: Set<string>;
42
+ base: DocBase | null;
43
+ }
44
+ /** A whole-file ingest through the probabilistic reconciler: identity is
45
+ * re-derived from the bytes. `precondition` is `null` for `docs_create` (no
46
+ * live document may exist at `path`, else `path_taken`) and, for
47
+ * `docs_set_meta`, the patched document's triple plus `original` — the bytes
48
+ * the patch was computed from, which must still be what the row's
49
+ * `file_hash` names (2.2 closes the unchecked gap: a human edit under a
50
+ * `set_meta` is a `sync_conflict`, not an api commit of their bytes);
51
+ * `original: null` when the doc store had no bytes (headless: no disk check). */
52
+ | {
53
+ kind: "ingest_reconciled";
54
+ path: string;
55
+ source: string;
56
+ precondition: {
57
+ docId: string;
58
+ base: DocBase;
59
+ original: string | null;
60
+ } | null;
61
+ }
62
+ /** `docs_move`'s row half: `docs.path` and the current revision's `path`,
63
+ * the open inbound edges re-pointed at `phantom:<from>`, phantoms at `to`
64
+ * adopted. The file half (a `rename` row, spec/store §5.7) renames `from` →
65
+ * `to`, or writes the revision's bytes at `to` when the source file is
66
+ * absent; `bytes` is the source file as read at plan time (the hash the row
67
+ * falls back to for a document with no revision). Precondition: the document is live at
68
+ * `from` with `currentRev` (else `stale_plan`; gone → `doc_missing`) and no
69
+ * live document is at `to` (else `path_taken`). */
70
+ | {
71
+ kind: "move_path";
72
+ docId: string;
73
+ from: string;
74
+ to: string;
75
+ currentRev: string | null;
76
+ inbound: InboundLink[];
77
+ bytes: string;
78
+ }
79
+ /** Tombstone the document and its live blocks under the commit. `pool: false`
80
+ * is `docs_delete` (an intentional removal pools nothing, spec/mutate §10);
81
+ * `pool: true` is the observed deletion's snapshot (spec/store §5.6 — the
82
+ * observe path still commits through `sync/tombstone.ts`; the flag is here
83
+ * for the day `observe_many` runs through this executor). The file at
84
+ * `path` is unlinked by B' iff it still holds the deleted revision's bytes
85
+ * (a `remove` row, spec/store §5.7). Precondition: the document is
86
+ * live at `path` with `currentRev` (`doc_missing` / `stale_plan`). */
87
+ | {
88
+ kind: "tombstone";
89
+ docId: string;
90
+ path: string;
91
+ currentRev: string | null;
92
+ pool: boolean;
93
+ }
94
+ /** Freshness stat cache (spec/sync §4.3): refresh the row for `path` after
95
+ * `bytes` were written there / drop it. Added only when the request named a
96
+ * workspace (`omgbaseDir`), as before. */
97
+ | {
98
+ kind: "record_stat";
99
+ path: string;
100
+ bytes: string;
101
+ } | {
102
+ kind: "clear_stat";
103
+ path: string;
104
+ };
105
+ /** The one `commits` row a request records. `ops` is threaded, never populated
106
+ * yet (ADR-003's intent record waits for the planner step). */
107
+ export interface CommitHead {
108
+ origin: "api";
109
+ actor: string | null;
110
+ reason: string | null;
111
+ checkpointId?: string | null;
112
+ ops?: string | null;
113
+ }
114
+ /** What a committed request returns: the shared commit id (`null` when the
115
+ * request had no effect at all — a changeset that touched no document records
116
+ * nothing, as before) and, in effect order, one `IngestResult` per
117
+ * `ingest_known`/`ingest_reconciled` effect. */
118
+ export interface CommitOutcome {
119
+ commitId: string | null;
120
+ ingests: IngestResult[];
121
+ }
122
+ /** Validation's finding: the document whose on-disk bytes moved from under the request. */
123
+ export interface FileConflict {
124
+ docId: string;
125
+ path: string;
126
+ bytes: string;
127
+ }
128
+ export type CommitPhasesResult = {
129
+ committed: CommitOutcome;
130
+ } | {
131
+ conflict: FileConflict;
132
+ };
133
+ export interface CommitTargets {
134
+ docStore: DocStore;
135
+ /** Set when the request runs in a workspace: the conflict path refreshes the
136
+ * stat cache for the re-ingested file (the effects carry their own). */
137
+ omgbaseDir?: string;
138
+ }
139
+ /**
140
+ * Commit `effects` as one request under `head` at `ts`. Throws `sync_conflict`
141
+ * (retriable) when a document changed on disk — after ingesting those bytes as
142
+ * an `observed` commit so a retry sees them — `stale_plan` (retriable, `{ doc,
143
+ * expected_revision, current: { revision, hash } }`) when a document's row
144
+ * moved since the plan, `doc_missing` / `path_taken` per effect, and otherwise
145
+ * returns the shared commit id with the ingest results. The caller holds the
146
+ * writer lock when it has a workspace.
147
+ */
148
+ export declare function commitChangeset(store: Store, repoId: string, head: CommitHead, effects: Effect[], ts: string, targets: CommitTargets): CommitOutcome;
149
+ /** Phases A → C → B' over the effects; a disk conflict is returned, not thrown. */
150
+ export declare function runPhases(store: Store, repoId: string, head: CommitHead, effects: Effect[], ts: string, docStore: DocStore): CommitPhasesResult;
@@ -0,0 +1,349 @@
1
+ import { sha256 } from "../core/hash.js";
2
+ import { ingestFile, ingestInto } from "../core/ingest.js";
3
+ import { deletePending, insertPending, materializeRow, readPending } from "./materialize.js";
4
+ import { newCommit } from "../core/store/writers.js";
5
+ import { ftsDeleteDoc } from "../core/store/fts.js";
6
+ import { adoptPhantoms, rebuildDocEdges } from "../core/store/edges.js";
7
+ import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
8
+ import { makeKnownIdResolver } from "./known-ids.js";
9
+ import { MutationError } from "./tree.js";
10
+ /** One `SELECT path, current_rev, file_hash` of a live document: the triple
11
+ * the plan captures and the commit re-validates. `null` when no live row. */
12
+ export function readDocBase(db, docId) {
13
+ const row = db.prepare("SELECT path, current_rev, lower(hex(file_hash)) hash FROM docs WHERE doc_id = ? AND deleted_commit IS NULL").get(docId);
14
+ return row ? { path: row.path, currentRev: row.current_rev, fileHash: row.hash } : null;
15
+ }
16
+ /**
17
+ * Commit `effects` as one request under `head` at `ts`. Throws `sync_conflict`
18
+ * (retriable) when a document changed on disk — after ingesting those bytes as
19
+ * an `observed` commit so a retry sees them — `stale_plan` (retriable, `{ doc,
20
+ * expected_revision, current: { revision, hash } }`) when a document's row
21
+ * moved since the plan, `doc_missing` / `path_taken` per effect, and otherwise
22
+ * returns the shared commit id with the ingest results. The caller holds the
23
+ * writer lock when it has a workspace.
24
+ */
25
+ export function commitChangeset(store, repoId, head, effects, ts, targets) {
26
+ const result = runPhases(store, repoId, head, effects, ts, targets.docStore);
27
+ if ("conflict" in result) {
28
+ const { path, bytes } = result.conflict;
29
+ // A human edit landed first: ingest it (its own observed commit, after the
30
+ // rollback), then the caller must retry against the new revision. The file
31
+ // won, so an intent still pending at the path is superseded (spec/store
32
+ // §5.7) — dropped in the same transaction as the observed commit.
33
+ store.write((db) => {
34
+ ingestFile(store, repoId, path, bytes, { ts, resolveIds: makeReconcilingResolver(store, repoId, { ts, path }) });
35
+ deletePending(db, repoId, path);
36
+ });
37
+ if (targets.omgbaseDir)
38
+ targets.docStore.recordStat(store, repoId, path, bytes);
39
+ throw new MutationError("sync_conflict", `file ${path} changed on disk; re-ingested — retry`, { retriable: true });
40
+ }
41
+ return result.committed;
42
+ }
43
+ // The disk half of a failed validation travels as this private signal — thrown
44
+ // inside the transaction it rolls the transaction back (nothing minted), and
45
+ // `runPhases` turns it into the `{ conflict }` return so the observed ingest
46
+ // can run in a transaction of its own. The row-side findings (`stale_plan`,
47
+ // `doc_missing`, `path_taken`) are thrown as `MutationError`s directly: they
48
+ // need no ingest, and a throw out of the transaction is its rollback.
49
+ class ConflictSignal {
50
+ conflict;
51
+ constructor(conflict) {
52
+ this.conflict = conflict;
53
+ }
54
+ }
55
+ /** Phases A → C → B' over the effects; a disk conflict is returned, not thrown. */
56
+ export function runPhases(store, repoId, head, effects, ts, docStore) {
57
+ // A request with nothing to do (a macro whose plan found no block, a
58
+ // `links_repair` with no hit) writes no file and mints no commit row — an
59
+ // `api` commit with no revision would read as a move or a deletion in the
60
+ // change feed. `macros::links-repair-path-glob-no-hits` pins it.
61
+ if (effects.length === 0)
62
+ return { committed: { commitId: null, ingests: [] } };
63
+ // Phase A — reads only. First the intent an earlier request may have left
64
+ // at a path this one touches (its file is behind the database until some
65
+ // materializer runs; this is one): replayed through the DocStore so the
66
+ // bytes read next are the engine's, not a stale file mistaken for a human
67
+ // edit (a foreign file keeps its row and is read as it is). Then the bytes
68
+ // on disk of every `ingest_known` document (at the path it was loaded under
69
+ // — the moved document of a `docs_move` is still at `from` here); a
70
+ // `docs_set_meta` carries the bytes it patched. A path with no file (a
71
+ // headless NullDocStore, a deleted file) has no disk check. The I/O stays
72
+ // outside the transaction; phase C compares these hashes.
73
+ for (const path of touchedPaths(effects)) {
74
+ const row = readPending(store.db, repoId, path);
75
+ if (row)
76
+ materializeRow(store, repoId, row, docStore);
77
+ }
78
+ const onDisk = new Map();
79
+ for (const e of effects) {
80
+ if (e.kind === "ingest_known" && e.base) {
81
+ const bytes = docStore.read(e.base.path);
82
+ if (bytes !== null)
83
+ onDisk.set(e.docId, bytes);
84
+ }
85
+ else if (e.kind === "ingest_reconciled" && e.precondition?.original !== null && e.precondition?.original !== undefined) {
86
+ onDisk.set(e.precondition.docId, e.precondition.original);
87
+ }
88
+ }
89
+ // Phase C — one IMMEDIATE transaction: validate under the write lock, THEN
90
+ // one commit row, then the database half of every effect in order, then the
91
+ // file intent. Mint order: one `c`, then per document `r`, `x`, `e` (and `d`
92
+ // for a created document) — nothing before validation passes.
93
+ let committed;
94
+ try {
95
+ committed = store.writeImmediate((db) => {
96
+ validate(db, repoId, effects, onDisk);
97
+ const commit = newCommit(db, {
98
+ repoId,
99
+ ts,
100
+ origin: head.origin,
101
+ actor: head.actor,
102
+ reason: head.reason,
103
+ checkpointId: head.checkpointId ?? null,
104
+ ops: head.ops ?? null,
105
+ });
106
+ const commitId = commit.commitId;
107
+ const ingests = [];
108
+ // The rows this request will materialize, keyed by the path the file
109
+ // must end up at (spec/store §5.7): one per path, in effect order.
110
+ const pending = new Map();
111
+ for (const e of effects) {
112
+ switch (e.kind) {
113
+ case "ingest_known": {
114
+ // INTENT path: persist the ops' tree with its KNOWN block ids — never
115
+ // re-derive identity from the rendered bytes (node-editability).
116
+ const res = ingestInto(db, repoId, e.path, e.rendered, {
117
+ ts,
118
+ commitId,
119
+ resolveIds: makeKnownIdResolver(store, repoId, e.mutDoc, e.priorIds, { path: e.path }),
120
+ });
121
+ ingests.push(res);
122
+ recordWrite(pending, e.path, res, sha256(e.rendered), commitId);
123
+ break;
124
+ }
125
+ case "ingest_reconciled": {
126
+ const res = ingestInto(db, repoId, e.path, e.source, {
127
+ ts,
128
+ commitId,
129
+ resolveIds: makeReconcilingResolver(store, repoId, { ts, path: e.path }),
130
+ });
131
+ ingests.push(res);
132
+ recordWrite(pending, e.path, res, sha256(e.source), commitId);
133
+ break;
134
+ }
135
+ case "move_path": {
136
+ movePath(db, e);
137
+ // The bytes expected at `to`: the moved document's current revision
138
+ // (a self-linking source's retarget below replaces them with its own).
139
+ const rev = e.currentRev === null ? undefined : db.prepare("SELECT rendered_hash FROM revisions WHERE rev_id = ?").get(e.currentRev);
140
+ pending.set(e.to, { path: e.to, kind: "rename", fromPath: e.from, docId: e.docId, revId: e.currentRev, renderedHash: rev?.rendered_hash ?? sha256(e.bytes), commitId });
141
+ break;
142
+ }
143
+ case "tombstone": {
144
+ // The bytes it is safe to unlink: what the document's last revision
145
+ // rendered (or the last observed bytes when it has no revision).
146
+ const row = db
147
+ .prepare("SELECT r.rendered_hash, d.file_hash FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev WHERE d.doc_id = ?")
148
+ .get(e.docId);
149
+ tombstone(db, commitId, ts, e);
150
+ pending.set(e.path, { path: e.path, kind: "remove", fromPath: null, docId: e.docId, revId: null, renderedHash: row?.rendered_hash ?? row?.file_hash ?? sha256(""), commitId });
151
+ break;
152
+ }
153
+ default:
154
+ // record_stat / clear_stat: the stat cache reads the real file,
155
+ // which does not exist in its new form yet — phase B'.
156
+ break;
157
+ }
158
+ }
159
+ for (const row of pending.values())
160
+ insertPending(db, repoId, row);
161
+ return { commitId, ingests };
162
+ });
163
+ }
164
+ catch (err) {
165
+ if (err instanceof ConflictSignal)
166
+ return { conflict: err.conflict };
167
+ throw err;
168
+ }
169
+ // Phase B' — the file half, in effect order, each row deleted as its file
170
+ // lands. The stat cache follows only a file that landed: a path whose row was
171
+ // kept (foreign bytes) must stay a candidate for the next sweep, which is
172
+ // what lets the human's edit win.
173
+ const landed = new Set();
174
+ const land = (path, priorHash) => {
175
+ const row = readPending(store.db, repoId, path);
176
+ if (!row)
177
+ return;
178
+ if (materializeRow(store, repoId, row, docStore, priorHash === undefined ? {} : { priorHash }))
179
+ landed.add(path);
180
+ };
181
+ for (const e of effects) {
182
+ switch (e.kind) {
183
+ case "ingest_known":
184
+ land(e.path, e.base ? fromHex(e.base.fileHash) : null);
185
+ break;
186
+ case "ingest_reconciled":
187
+ land(e.path, e.precondition ? fromHex(e.precondition.base.fileHash) : null);
188
+ break;
189
+ case "move_path":
190
+ land(e.to, undefined);
191
+ break;
192
+ case "tombstone":
193
+ land(e.path, undefined);
194
+ break;
195
+ case "record_stat":
196
+ if (landed.has(e.path))
197
+ docStore.recordStat(store, repoId, e.path, e.bytes);
198
+ break;
199
+ case "clear_stat":
200
+ docStore.clearStat(store, repoId, e.path);
201
+ break;
202
+ }
203
+ }
204
+ return { committed };
205
+ }
206
+ // The row the file at `path` must become after an ingest: a `write` of the
207
+ // revision's bytes — unless a `rename` already lands the moved document there
208
+ // (the self-linking source of a `docs_move`), which then renders THESE bytes.
209
+ function recordWrite(pending, path, res, renderedHash, commitId) {
210
+ const existing = pending.get(path);
211
+ if (existing && existing.kind === "rename") {
212
+ existing.revId = res.revId;
213
+ existing.renderedHash = renderedHash;
214
+ return;
215
+ }
216
+ pending.set(path, { path, kind: "write", fromPath: null, docId: res.docId, revId: res.revId, renderedHash, commitId });
217
+ }
218
+ // Every path whose file an effect reads or leaves: where a pending row of an
219
+ // earlier request would stand between the request and the disk.
220
+ function touchedPaths(effects) {
221
+ const out = new Set();
222
+ for (const e of effects) {
223
+ switch (e.kind) {
224
+ case "ingest_known":
225
+ if (e.base)
226
+ out.add(e.base.path);
227
+ out.add(e.path);
228
+ break;
229
+ case "ingest_reconciled":
230
+ out.add(e.path);
231
+ break;
232
+ case "move_path":
233
+ out.add(e.from);
234
+ out.add(e.to);
235
+ break;
236
+ case "tombstone":
237
+ out.add(e.path);
238
+ break;
239
+ default:
240
+ break;
241
+ }
242
+ }
243
+ return [...out];
244
+ }
245
+ function fromHex(hex) {
246
+ return hex === null ? null : Buffer.from(hex, "hex");
247
+ }
248
+ // Every effect's precondition against the rows `db` sees now (spec/mutate §4
249
+ // step 5, 2.2), inside the transaction. Row first — a document whose row moved
250
+ // is `stale_plan` whatever its bytes — then the disk: `file_hash` (when the
251
+ // row has one) must name the bytes phase A read.
252
+ function validate(db, repoId, effects, onDisk) {
253
+ const liveAt = db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL");
254
+ const pathTaken = (path) => liveAt.get(repoId, path) !== undefined;
255
+ const checkRow = (docId, expected) => {
256
+ const row = readDocBase(db, docId);
257
+ if (!row)
258
+ throw new MutationError("doc_missing", `doc ${docId} no longer exists`, { doc: docId });
259
+ const moved = row.path !== expected.path || row.currentRev !== expected.currentRev || (expected.fileHash !== undefined && row.fileHash !== expected.fileHash);
260
+ if (moved) {
261
+ throw new MutationError("stale_plan", `document ${docId} changed since the request was planned; re-plan`, {
262
+ doc: docId,
263
+ expected_revision: expected.currentRev,
264
+ current: { revision: row.currentRev, hash: row.fileHash },
265
+ retriable: true,
266
+ });
267
+ }
268
+ return row;
269
+ };
270
+ const checkDisk = (docId, row) => {
271
+ const bytes = onDisk.get(docId);
272
+ if (bytes === undefined || row.fileHash === null)
273
+ return;
274
+ if (sha256(bytes).toString("hex") !== row.fileHash)
275
+ throw new ConflictSignal({ docId, path: row.path, bytes });
276
+ };
277
+ for (const e of effects) {
278
+ switch (e.kind) {
279
+ case "ingest_known":
280
+ // A created document (`base: null`, the `Absent` guard): no live document
281
+ // may be at the path — the planner's check re-run through the transaction.
282
+ if (e.base)
283
+ checkDisk(e.docId, checkRow(e.docId, e.base));
284
+ else if (pathTaken(e.path))
285
+ throw new MutationError("path_taken", `document already exists at ${e.path}`, { path: e.path });
286
+ break;
287
+ case "ingest_reconciled":
288
+ if (e.precondition)
289
+ checkDisk(e.precondition.docId, checkRow(e.precondition.docId, e.precondition.base));
290
+ else if (pathTaken(e.path))
291
+ throw new MutationError("path_taken", `document already exists at ${e.path}`, { path: e.path });
292
+ break;
293
+ case "move_path":
294
+ checkRow(e.docId, { path: e.from, currentRev: e.currentRev });
295
+ if (pathTaken(e.to))
296
+ throw new MutationError("path_taken", `a document already exists at ${e.to}`, { path: e.to });
297
+ break;
298
+ case "tombstone":
299
+ checkRow(e.docId, { path: e.path, currentRev: e.currentRev });
300
+ break;
301
+ default:
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ // `docs_move`'s rows (spec/mutate §6): the document row's path + its current
307
+ // revision's path pointer; then the edge index. Every open edge from ANOTHER
308
+ // doc into this one resolved via the old path, so it dangles now → re-point it
309
+ // at the old path's phantom (in place, mirroring adoptPhantoms). A self-doc
310
+ // edge dangles only when the link names the path (`[x](/old.md#H)`); a pure
311
+ // fragment (`#H`) resolves to the doc regardless of path and is left alone —
312
+ // the inbound scan distinguishes them from the raw. Runs BEFORE the retarget's
313
+ // ingests so each rewritten source's `maintainEdges` resolves `[x](new)` to the
314
+ // document (adopted here), not to a phantom.
315
+ function movePath(db, e) {
316
+ db.prepare("UPDATE docs SET path = ? WHERE doc_id = ?").run(e.to, e.docId);
317
+ db.prepare("UPDATE revisions SET path = ? WHERE doc_id = ? AND rev_id = ?").run(e.to, e.docId, e.currentRev);
318
+ const phantom = `phantom:${e.from}`;
319
+ const affected = new Set();
320
+ const others = db.prepare("SELECT DISTINCT src_doc FROM edges WHERE dst_node = ? AND to_commit IS NULL AND src_doc != ?").all(e.docId, e.docId);
321
+ for (const r of others)
322
+ affected.add(r.src_doc);
323
+ db.prepare("UPDATE edges SET dst_node = ? WHERE dst_node = ? AND to_commit IS NULL AND src_doc != ?").run(phantom, e.docId, e.docId);
324
+ 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 ?");
325
+ for (const l of e.inbound) {
326
+ if (l.doc_id !== e.docId || l.block_id === null)
327
+ continue;
328
+ selfEdge.run(phantom, e.docId, e.docId, l.block_id, l.anchor);
329
+ affected.add(e.docId);
330
+ }
331
+ for (const d of affected)
332
+ rebuildDocEdges(db, d);
333
+ // Links already written against the NEW path resolve to this doc now.
334
+ adoptPhantoms(db, e.to, e.docId);
335
+ }
336
+ // Tombstone the document and its live blocks under the commit; FTS rows drop
337
+ // with the blocks. `pool` snapshots the live blocks first (spec/store §5.6).
338
+ function tombstone(db, commitId, ts, e) {
339
+ if (e.pool) {
340
+ const expires = new Date(Date.parse(ts) + 30 * 24 * 3600 * 1000).toISOString();
341
+ db.prepare(`INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
342
+ SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?, ?
343
+ FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL`).run(commitId, expires, e.docId);
344
+ }
345
+ ftsDeleteDoc(db, e.docId);
346
+ db.prepare("UPDATE blocks SET deleted_commit = ? WHERE doc_id = ? AND deleted_commit IS NULL").run(commitId, e.docId);
347
+ db.prepare("UPDATE docs SET deleted_commit = ? WHERE doc_id = ?").run(commitId, e.docId);
348
+ }
349
+ //# sourceMappingURL=commit.js.map
@@ -33,9 +33,9 @@ export declare class FsDocStore implements DocStore {
33
33
  export declare class NullDocStore implements DocStore {
34
34
  exists(): boolean;
35
35
  read(): string | null;
36
- write(): void;
37
- rename(): void;
38
- remove(): void;
36
+ write(_path: string, _bytes: string): void;
37
+ rename(_from: string, _to: string): void;
38
+ remove(_path: string): void;
39
39
  recordStat(): void;
40
40
  clearStat(): void;
41
41
  }