@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
@@ -1,6 +1,10 @@
1
1
  import { sha256 } from "../core/hash.js";
2
2
  import { processCheckpoint } from "./checkpoint.js";
3
3
  import { nodeFs } from "./fs-util.js";
4
+ import { recordStat } from "./stat-cache.js";
5
+ import { materializePending } from "../mutate/materialize.js";
6
+ import { FsDocStore } from "../mutate/doc-store.js";
7
+ export { recordStat, recordFileStat } from "./stat-cache.js";
4
8
  /** Step 1 of §4.3 alone: the stat-mismatched or new paths, without hashing anything. */
5
9
  export function sweepCandidates(cache, snapshot) {
6
10
  const out = [];
@@ -56,30 +60,23 @@ export function snapshotOf(fs, rootPath) {
56
60
  }
57
61
  return out;
58
62
  }
59
- /** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
60
- * file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
61
- export function recordStat(store, repoId, path, st, hash) {
62
- if (!st) {
63
- store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
64
- return;
65
- }
66
- store.db
67
- .prepare(`INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?, ?, ?, ?, ?)
68
- ON CONFLICT(repo_id, path) DO UPDATE SET mtime_ns = excluded.mtime_ns, size = excluded.size, hash = excluded.hash`)
69
- .run(repoId, path, st.mtimeNs, st.size, hash);
70
- }
71
- /** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
72
- export function recordFileStat(store, repoId, path, abs, hash) {
73
- recordStat(store, repoId, path, nodeFs.stat(abs, ""), hash);
74
- }
75
63
  /**
76
64
  * Run the freshness sweep for a repo (spec/sync §4.3). Returns the checkpoint
77
65
  * result plus scan counters. Idempotent; safe alongside a live watcher
78
66
  * (hash-based echo suppression makes a double ingest a no-op). `opts.ts` pins
79
- * the batch timestamp (default now); `opts.fs` the filesystem seam.
67
+ * the batch timestamp (default now); `opts.fs` the filesystem seam;
68
+ * `opts.docStore` the write target step 0 materializes through (default: the
69
+ * filesystem store at `rootPath` — the fixtures' in-memory filesystem never
70
+ * holds a pending row, so the default is never exercised there).
80
71
  */
81
72
  export function freshnessSweep(store, repoId, rootPath, opts = {}) {
82
73
  const fs = opts.fs ?? nodeFs;
74
+ // Step 0 (2.2; spec/store §5.7): replay the api path's recorded file intent
75
+ // before looking at the disk, so a file a crashed or refused write left
76
+ // behind the database is brought forward instead of being read back as a
77
+ // human edit. The production recovery point (priming sweep at `omg mcp`
78
+ // start, the pre-verb sweep of every CLI write).
79
+ const materialized = materializePending(store, repoId, opts.docStore ?? new FsDocStore(rootPath));
83
80
  const cache = loadStatCache(store, repoId);
84
81
  const snapshot = snapshotOf(fs, rootPath);
85
82
  const plan = sweepPlan(cache, snapshot);
@@ -100,6 +97,7 @@ export function freshnessSweep(store, repoId, rootPath, opts = {}) {
100
97
  scanned: snapshot.length,
101
98
  candidates: plan.candidates.length,
102
99
  changed: result.ingested.length > 0 || result.deleted.length > 0 || result.conflicted.length > 0,
100
+ materialized,
103
101
  };
104
102
  }
105
103
  /**
@@ -46,7 +46,10 @@ export type BatchOutcome = ({
46
46
  * The batch reconcile primitive — one checkpoint's worth of members, in order.
47
47
  * Two passes with the cross-document phase between them (reconciliation-spec §8):
48
48
  *
49
- * 1. every member is echo-gated and, when it differs from the stored revision,
49
+ * 1. every member is gated against a pending materialization row at its path
50
+ * (spec/store §5.7: the bytes the api path owes confirm the row, an absent
51
+ * file is not a deletion, anything else lets the file win), echo-gated and,
52
+ * when it differs from the stored revision,
50
53
  * parsed + reconciled against its own prior tree WITHOUT committing
51
54
  * (`prepareReconcile`; members gone from the source contribute their whole
52
55
  * live tree as deleted). All members see one resurrection-pool snapshot with
@@ -6,11 +6,15 @@ import { tombstoneObservedDeletion } from "./tombstone.js";
6
6
  import { sweepResurrectionPool } from "../core/store/gc.js";
7
7
  import { crossDocMatch, applyCrossDocMatches } from "../reconcile/crossdoc.js";
8
8
  import { DEFAULT_CONFIG } from "../reconcile/types.js";
9
+ import { deletePending, readPending } from "../mutate/materialize.js";
9
10
  /**
10
11
  * The batch reconcile primitive — one checkpoint's worth of members, in order.
11
12
  * Two passes with the cross-document phase between them (reconciliation-spec §8):
12
13
  *
13
- * 1. every member is echo-gated and, when it differs from the stored revision,
14
+ * 1. every member is gated against a pending materialization row at its path
15
+ * (spec/store §5.7: the bytes the api path owes confirm the row, an absent
16
+ * file is not a deletion, anything else lets the file win), echo-gated and,
17
+ * when it differs from the stored revision,
14
18
  * parsed + reconciled against its own prior tree WITHOUT committing
15
19
  * (`prepareReconcile`; members gone from the source contribute their whole
16
20
  * live tree as deleted). All members see one resurrection-pool snapshot with
@@ -42,6 +46,31 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
42
46
  for (const it of items) {
43
47
  const existing = liveDoc.get(repoId, it.path);
44
48
  const oldHashHex = existing?.file_hash?.toString("hex") ?? null;
49
+ // The materialization gate (spec/store §5.7): an api commit still owes this
50
+ // path a file. The observation decides what the row meant for the disk —
51
+ // the bytes it named confirm it (echo; a `remove` row stays until the file
52
+ // is gone), an absent file is NOT a deletion (the materializer still owes
53
+ // it; `remove` rows are confirmed by the absence), anything else is a later
54
+ // human edit and the file wins: the normal reconciling ingest below, the
55
+ // row dropped with it.
56
+ const owed = readPending(db, repoId, it.path);
57
+ if (owed && it.content === null) {
58
+ if (owed.kind === "remove")
59
+ deletePending(db, repoId, it.path);
60
+ else {
61
+ pending.push({ kind: "echo", outcome: { kind: "deleted", path: it.path, docId: null, oldHashHex } });
62
+ continue;
63
+ }
64
+ }
65
+ else if (owed && sha256(it.content).equals(owed.renderedHash)) {
66
+ if (owed.kind !== "remove")
67
+ deletePending(db, repoId, it.path);
68
+ pending.push({
69
+ kind: "echo",
70
+ outcome: { kind: "observed", docId: owed.docId, path: it.path, rev: null, commitId: null, converged: true, echo: true, conflicted: false, dispositions: [], oldHashHex, newHashHex: sha256(it.content).toString("hex") },
71
+ });
72
+ continue;
73
+ }
45
74
  if (it.content === null) {
46
75
  const oldBlocks = existing ? loadOldMatchBlocks(db, existing.doc_id) : [];
47
76
  const result = existing
@@ -56,6 +85,10 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
56
85
  // loop-breaker the coordinator relies on (§6): re-observing what the engine
57
86
  // already holds is a no-op.
58
87
  if (existing && existing.file_hash && existing.file_hash.equals(diskHash)) {
88
+ // (A row that names other bytes than the file the database already
89
+ // agrees with is stale either way; the observation settles it.)
90
+ if (owed)
91
+ deletePending(db, repoId, it.path);
59
92
  pending.push({
60
93
  kind: "echo",
61
94
  outcome: { kind: "observed", docId: existing.doc_id, path: it.path, rev: null, commitId: null, converged: true, echo: true, conflicted: false, dispositions: [], oldHashHex, newHashHex },
@@ -63,7 +96,7 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
63
96
  continue;
64
97
  }
65
98
  const prepared = prepareReconcile(store, repoId, it.path, it.content, { ts, config, pool, consumed });
66
- pending.push({ kind: "ingest", prepared, oldHashHex, newHashHex });
99
+ pending.push({ kind: "ingest", prepared, oldHashHex, newHashHex, clearPending: owed !== null });
67
100
  }
68
101
  // ---- cross-document phase (spec/reconcile §7) --------------------------------
69
102
  if (pending.length > 1)
@@ -79,6 +112,15 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
79
112
  tombstoneObservedDeletion(store, repoId, p.docId, ts);
80
113
  out.push({ kind: "deleted", path: p.path, docId: p.docId, oldHashHex: p.oldHashHex });
81
114
  }
115
+ else if (p.clearPending) {
116
+ // File wins (spec/store §5.7): the pending row and the observed commit
117
+ // that supersedes it go together.
118
+ out.push(store.write((db) => {
119
+ const r = commitPrepared(store, repoId, p.prepared, ts, p.oldHashHex, p.newHashHex);
120
+ deletePending(db, repoId, p.prepared.path);
121
+ return { kind: "observed", ...r };
122
+ }));
123
+ }
82
124
  else {
83
125
  out.push({ kind: "observed", ...commitPrepared(store, repoId, p.prepared, ts, p.oldHashHex, p.newHashHex) });
84
126
  }
@@ -140,13 +182,13 @@ function crossDocPhase(pending, config) {
140
182
  prepared.get(m.toDoc)?.crossDocIds.push(m.carriedId);
141
183
  }
142
184
  // Pass 2 for one reconciled member: commit the finished result as an observed
143
- // revision (one transaction), flag conflict markers, summarize dispositions.
185
+ // revision — ONE transaction, the conflict-marker flag written inside it with
186
+ // the revision pointers (spec/store §5.4 step 14) — then summarize dispositions.
144
187
  function commitPrepared(store, repoId, prepared, ts, oldHashHex, newHashHex) {
145
188
  const { path, content } = prepared;
146
189
  const conflicted = hasConflictMarkers(content);
147
190
  const resolveIds = makeReconcilingResolver(store, repoId, { ts, path, prepared });
148
- const res = ingestFile(store, repoId, path, content, { ts, origin: "observed", resolveIds, parsed: prepared.tree });
149
- store.db.prepare("UPDATE docs SET conflicted = ? WHERE repo_id = ? AND path = ?").run(conflicted ? 1 : 0, repoId, path);
191
+ const res = ingestFile(store, repoId, path, content, { ts, origin: "observed", resolveIds, parsed: prepared.tree, conflicted });
150
192
  const rows = store.db
151
193
  .prepare("SELECT kind, count(*) n FROM dispositions WHERE commit_id = ? GROUP BY kind ORDER BY kind")
152
194
  .all(res.commitId);
@@ -213,6 +255,15 @@ export function observeMany(store, repoId, items, opts = {}) {
213
255
  */
214
256
  export function observeDelete(store, repoId, path, opts = {}) {
215
257
  const ts = opts.ts ?? new Date().toISOString();
258
+ // The §5.7 gate for a gone member: a path the api path still owes a `write`
259
+ // or `rename` is not tombstoned (the materializer owes the file); a `remove`
260
+ // row is confirmed by the absence.
261
+ const owed = readPending(store.db, repoId, path);
262
+ if (owed) {
263
+ if (owed.kind !== "remove")
264
+ return { docId: null, path, deleted: false };
265
+ deletePending(store.db, repoId, path);
266
+ }
216
267
  const existing = store.db
217
268
  .prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
218
269
  .get(repoId, path);
@@ -1,10 +1,14 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type SyncFs } from "./fs-util.js";
3
+ import { type DocStore } from "../mutate/doc-store.js";
3
4
  export interface RecoveryResult {
5
+ /** step 0: paths whose pending materialization rows were replayed */
6
+ materialized: string[];
4
7
  healed: string[];
5
8
  missing: string[];
6
9
  }
7
10
  export declare function recoverRepo(store: Store, repoId: string, rootPath: string, opts?: {
8
11
  ts?: string;
9
12
  fs?: SyncFs;
13
+ docStore?: DocStore;
10
14
  }): RecoveryResult;
@@ -2,8 +2,11 @@ import { sha256 } from "../core/hash.js";
2
2
  import { ingestFile } from "../core/ingest.js";
3
3
  import { makeReconcilingResolver } from "./reconciling-ingest.js";
4
4
  import { nodeFs } from "./fs-util.js";
5
+ import { materializePending } from "../mutate/materialize.js";
6
+ import { FsDocStore } from "../mutate/doc-store.js";
5
7
  export function recoverRepo(store, repoId, rootPath, opts = {}) {
6
8
  const fs = opts.fs ?? nodeFs;
9
+ const materialized = materializePending(store, repoId, opts.docStore ?? new FsDocStore(rootPath));
7
10
  const docs = store.db
8
11
  .prepare(`SELECT d.doc_id, d.path, d.file_hash, r.rendered_hash
9
12
  FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev
@@ -29,6 +32,6 @@ export function recoverRepo(store, repoId, rootPath, opts = {}) {
29
32
  healed.push(doc.path);
30
33
  }
31
34
  }
32
- return { healed, missing };
35
+ return { materialized, healed, missing };
33
36
  }
34
37
  //# sourceMappingURL=recovery.js.map
@@ -0,0 +1,7 @@
1
+ import type { Store } from "../core/store/store.js";
2
+ import { type FileStat } from "./fs-util.js";
3
+ /** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
4
+ * file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
5
+ export declare function recordStat(store: Store, repoId: string, path: string, st: FileStat | null, hash: Buffer): void;
6
+ /** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
7
+ export declare function recordFileStat(store: Store, repoId: string, path: string, abs: string, hash: Buffer): void;
@@ -0,0 +1,22 @@
1
+ import { nodeFs } from "./fs-util.js";
2
+ // The `file_stats` writers (spec/sync §4.3 step 4), split out of freshness.ts
3
+ // so the mutation write target (`mutate/doc-store.ts`, which refreshes a row
4
+ // after each file it materializes) and the sweep (which imports the doc store
5
+ // for its step 0) do not import each other. freshness.ts re-exports both.
6
+ /** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
7
+ * file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
8
+ export function recordStat(store, repoId, path, st, hash) {
9
+ if (!st) {
10
+ store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
11
+ return;
12
+ }
13
+ store.db
14
+ .prepare(`INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?, ?, ?, ?, ?)
15
+ ON CONFLICT(repo_id, path) DO UPDATE SET mtime_ns = excluded.mtime_ns, size = excluded.size, hash = excluded.hash`)
16
+ .run(repoId, path, st.mtimeNs, st.size, hash);
17
+ }
18
+ /** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
19
+ export function recordFileStat(store, repoId, path, abs, hash) {
20
+ recordStat(store, repoId, path, nodeFs.stat(abs, ""), hash);
21
+ }
22
+ //# sourceMappingURL=stat-cache.js.map
@@ -23,10 +23,13 @@ import { ftsDeleteDoc } from "../core/store/fts.js";
23
23
  // consumes the pool to resurrect ids when the file reappears). Pooling with the
24
24
  // standard ~30-day expiry keeps sweepResurrectionPool semantics intact.
25
25
  //
26
- // Convention: like processCheckpoint/reconcileChanges, this runs bare db.prepare
27
- // statements on store.db (each auto-commits) rather than opening its own
28
- // store.write transaction, so it composes with either caller's loop. The unlink of
29
- // the on-disk file that docsDelete performs is intentionally absent here — the file
26
+ // Transaction: the commit row, the pool snapshot, the FTS deletes and the two
27
+ // tombstone UPDATEs are ONE `store.write` transaction (spec/store §5.6) — a
28
+ // failure midway leaves the doc live with no commit minted, never a doc whose
29
+ // blocks are pooled but still indexed, or tombstoned under a commit that is
30
+ // not there. better-sqlite3 nests an inner `transaction` as a savepoint, so this
31
+ // still composes inside a caller that already holds one. The unlink of the
32
+ // on-disk file that docsDelete performs is intentionally absent here — the file
30
33
  // is ALREADY gone (that is what triggered the tombstone).
31
34
  /**
32
35
  * Tombstone a doc the engine observed as gone from its source (deleted on disk /
@@ -36,20 +39,21 @@ import { ftsDeleteDoc } from "../core/store/fts.js";
36
39
  * UPDATEs simply affect zero rows.
37
40
  */
38
41
  export function tombstoneObservedDeletion(store, repoId, docId, ts) {
39
- const db = store.db;
40
- const commit = newCommit(db, { repoId, ts, origin: "observed", reason: "observed deletion" });
41
- // Snapshot the doc's live blocks into the resurrection pool BEFORE tombstoning,
42
- // mirroring core/ingest.ts's reconciling-delete snapshot (~30-day expiry) so a
43
- // recreate of the same file can resurrect these block identities.
44
- const expires = new Date(Date.parse(ts) + 30 * 24 * 3600 * 1000).toISOString();
45
- db.prepare(`INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
46
- SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?, ?
47
- FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL`).run(commit.commitId, expires, docId);
48
- // Drop FTS index rows (external-content: delete with the original text before the
49
- // blocks change), then tombstone the live blocks and the doc row.
50
- ftsDeleteDoc(db, docId);
51
- db.prepare("UPDATE blocks SET deleted_commit = ? WHERE doc_id = ? AND deleted_commit IS NULL").run(commit.commitId, docId);
52
- db.prepare("UPDATE docs SET deleted_commit = ? WHERE doc_id = ?").run(commit.commitId, docId);
53
- return commit.commitId;
42
+ return store.write((db) => {
43
+ const commit = newCommit(db, { repoId, ts, origin: "observed", reason: "observed deletion" });
44
+ // Snapshot the doc's live blocks into the resurrection pool BEFORE tombstoning,
45
+ // mirroring core/ingest.ts's reconciling-delete snapshot (~30-day expiry) so a
46
+ // recreate of the same file can resurrect these block identities.
47
+ const expires = new Date(Date.parse(ts) + 30 * 24 * 3600 * 1000).toISOString();
48
+ db.prepare(`INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
49
+ SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?, ?
50
+ FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL`).run(commit.commitId, expires, docId);
51
+ // Drop FTS index rows (external-content: delete with the original text before the
52
+ // blocks change), then tombstone the live blocks and the doc row.
53
+ ftsDeleteDoc(db, docId);
54
+ db.prepare("UPDATE blocks SET deleted_commit = ? WHERE doc_id = ? AND deleted_commit IS NULL").run(commit.commitId, docId);
55
+ db.prepare("UPDATE docs SET deleted_commit = ? WHERE doc_id = ?").run(commit.commitId, docId);
56
+ return commit.commitId;
57
+ });
54
58
  }
55
59
  //# sourceMappingURL=tombstone.js.map
@@ -5,6 +5,8 @@ export interface WatcherOptions {
5
5
  onCheckpoint?: (result: CheckpointResult) => void;
6
6
  /** Surface a reconcile error from a background batch (the watch stays live). */
7
7
  onError?: (err: unknown) => void;
8
+ /** The workspace `.omgbase/` dir: when set, every checkpoint runs under the writer lock. */
9
+ omgbaseDir?: string;
8
10
  }
9
11
  export declare class Watcher {
10
12
  private store;
@@ -1,4 +1,5 @@
1
1
  import { reconcileChanges } from "./driver.js";
2
+ import { withWriterLockAsync } from "./writer-lock.js";
2
3
  export class Watcher {
3
4
  store;
4
5
  repoId;
@@ -30,7 +31,9 @@ export class Watcher {
30
31
  async ingest(paths) {
31
32
  if (paths.length === 0)
32
33
  return null;
33
- const result = await reconcileChanges(this.store, this.repoId, this.source, paths.map((path) => ({ path })));
34
+ const reconcile = () => reconcileChanges(this.store, this.repoId, this.source, paths.map((path) => ({ path })));
35
+ const dir = this.opts.omgbaseDir;
36
+ const result = dir !== undefined ? await withWriterLockAsync(dir, reconcile) : await reconcile();
34
37
  this.opts.onCheckpoint?.(result);
35
38
  return result;
36
39
  }
@@ -7,10 +7,25 @@ export interface WriterLockOptions {
7
7
  export declare class WriterLockTimeout extends Error {
8
8
  constructor(lockPath: string, holderPid: number | null);
9
9
  }
10
+ /** Override the process-wide wait defaults (a test seam; the poll never outlasts the timeout). */
11
+ export declare function setWriterLockDefaults(opts: WriterLockOptions): void;
12
+ /** The process-wide wait defaults in force. */
13
+ export declare function writerLockDefaults(): Required<WriterLockOptions>;
10
14
  /**
11
15
  * Run fn while holding the workspace writer lock. Blocks (polling) until the
12
16
  * lock is free or the timeout elapses, then releases the lock even if fn throws.
17
+ * Re-entrant within the process: a nested call on the same directory runs fn
18
+ * directly and the outermost exit releases.
13
19
  */
14
20
  export declare function withWriterLock<T>(omgbaseDir: string, fn: () => T, opts?: WriterLockOptions): T;
21
+ /**
22
+ * The async form: the lock is held until the promise `fn` returns settles (a
23
+ * sync `withWriterLock` around an async fn would release at the first await).
24
+ * Acquisition still polls synchronously — the wait is the same bounded
25
+ * contention window as the sync form's. Same re-entrancy.
26
+ */
27
+ export declare function withWriterLockAsync<T>(omgbaseDir: string, fn: () => Promise<T>, opts?: WriterLockOptions): Promise<T>;
28
+ /** True while THIS process holds the lock (at any depth) — a test seam for "ran under the lock". */
29
+ export declare function writerLockHeld(omgbaseDir: string): boolean;
15
30
  /** True if no live writer holds the lock (probe without acquiring). */
16
31
  export declare function writerLockFree(omgbaseDir: string): boolean;
@@ -1,5 +1,5 @@
1
- import { openSync, closeSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { openSync, closeSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, realpathSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
3
  // Cross-process writer lock (11 §3.2). The write protocol's per-repo writer
4
4
  // mutex (04 §6 step 1) generalizes to an advisory lock on
5
5
  // <workspace>/.omgbase/writer.lock, held for steps 2–7. All writers — one-shot
@@ -72,33 +72,120 @@ const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
72
72
  function sleepSync(ms) {
73
73
  Atomics.wait(SLEEP_CELL, 0, 0, ms);
74
74
  }
75
- /**
76
- * Run fn while holding the workspace writer lock. Blocks (polling) until the
77
- * lock is free or the timeout elapses, then releases the lock even if fn throws.
78
- */
79
- export function withWriterLock(omgbaseDir, fn, opts = {}) {
75
+ // Re-entrancy (one acquire per host write, spec/sync §7). The lock is a
76
+ // per-PROCESS claim — the record names a pid and liveness is the pid's — so a
77
+ // second `withWriterLock` on the same directory from the holder must not poll
78
+ // against itself (it would see its own live pid and time out). The process
79
+ // keeps a depth per lock path: the first entry acquires the file, nested
80
+ // entries run their fn directly, and the exit that brings the depth back to
81
+ // zero releases. Nesting happens by design — a tool-boundary lock around a
82
+ // whole MCP call (`mcp/server.ts`) encloses the kernel's own commit-phase lock
83
+ // (`mutate/apply.ts`), and a watcher checkpoint may run while a verb holds the
84
+ // lock — and since the file is one claim per process, the depth also lets an
85
+ // async holder (a checkpoint awaiting the adapter) and a tool call interleave
86
+ // on the event loop without either releasing the other's claim. Cross-process
87
+ // semantics are unchanged: a foreign live holder is waited for, never stolen.
88
+ const held = new Map();
89
+ // The depth is keyed by the directory's real path, so two spellings of one
90
+ // workspace (relative/absolute, a symlinked tree) are one claim; a directory
91
+ // that does not exist yet (a probe before the first write) keys by its resolved path.
92
+ function lockPathOf(omgbaseDir) {
93
+ let dir;
94
+ try {
95
+ dir = realpathSync.native(omgbaseDir);
96
+ }
97
+ catch {
98
+ dir = resolve(omgbaseDir);
99
+ }
100
+ return join(dir, LOCK_NAME);
101
+ }
102
+ // Process-wide defaults for the wait (spec/sync §7: 5 s, polled every 25 ms).
103
+ // A host may override them for the whole process — the CLI's
104
+ // `OMGBASE_WRITER_LOCK_TIMEOUT_MS` test seam does, so a verb's kernel-level lock
105
+ // and `omg mcp`'s tool-boundary lock shorten together, like the Rust binary's.
106
+ // Per-call `opts` still win.
107
+ let defaults = { timeoutMs: 5000, pollMs: 25 };
108
+ /** Override the process-wide wait defaults (a test seam; the poll never outlasts the timeout). */
109
+ export function setWriterLockDefaults(opts) {
80
110
  const timeoutMs = opts.timeoutMs ?? 5000;
81
- const pollMs = opts.pollMs ?? 25;
82
- mkdirSync(omgbaseDir, { recursive: true });
83
- const lockPath = join(omgbaseDir, LOCK_NAME);
111
+ defaults = { timeoutMs, pollMs: Math.min(opts.pollMs ?? 25, timeoutMs) };
112
+ }
113
+ /** The process-wide wait defaults in force. */
114
+ export function writerLockDefaults() {
115
+ return { ...defaults };
116
+ }
117
+ function acquire(lockPath, opts) {
118
+ const timeoutMs = opts.timeoutMs ?? defaults.timeoutMs;
119
+ const pollMs = opts.pollMs ?? defaults.pollMs;
84
120
  const deadline = Date.now() + timeoutMs;
85
121
  while (!tryAcquire(lockPath)) {
86
122
  if (Date.now() >= deadline)
87
123
  throw new WriterLockTimeout(lockPath, readHolderPid(lockPath));
88
124
  sleepSync(pollMs);
89
125
  }
126
+ }
127
+ function release(lockPath) {
128
+ try {
129
+ if (existsSync(lockPath))
130
+ unlinkSync(lockPath);
131
+ }
132
+ catch {
133
+ /* best-effort release */
134
+ }
135
+ }
136
+ /** Enter the lock: acquire the file at depth 0, otherwise just deepen. */
137
+ function enter(omgbaseDir, opts) {
138
+ mkdirSync(omgbaseDir, { recursive: true });
139
+ const lockPath = lockPathOf(omgbaseDir);
140
+ const depth = held.get(lockPath) ?? 0;
141
+ if (depth === 0)
142
+ acquire(lockPath, opts);
143
+ held.set(lockPath, depth + 1);
144
+ return lockPath;
145
+ }
146
+ /** Leave the lock: shallow, and release the file when the depth reaches 0. */
147
+ function exit(lockPath) {
148
+ const depth = (held.get(lockPath) ?? 1) - 1;
149
+ if (depth > 0) {
150
+ held.set(lockPath, depth);
151
+ return;
152
+ }
153
+ held.delete(lockPath);
154
+ release(lockPath);
155
+ }
156
+ /**
157
+ * Run fn while holding the workspace writer lock. Blocks (polling) until the
158
+ * lock is free or the timeout elapses, then releases the lock even if fn throws.
159
+ * Re-entrant within the process: a nested call on the same directory runs fn
160
+ * directly and the outermost exit releases.
161
+ */
162
+ export function withWriterLock(omgbaseDir, fn, opts = {}) {
163
+ const lockPath = enter(omgbaseDir, opts);
90
164
  try {
91
165
  return fn();
92
166
  }
93
167
  finally {
94
- try {
95
- if (existsSync(lockPath))
96
- unlinkSync(lockPath);
97
- }
98
- catch {
99
- /* best-effort release */
100
- }
168
+ exit(lockPath);
169
+ }
170
+ }
171
+ /**
172
+ * The async form: the lock is held until the promise `fn` returns settles (a
173
+ * sync `withWriterLock` around an async fn would release at the first await).
174
+ * Acquisition still polls synchronously — the wait is the same bounded
175
+ * contention window as the sync form's. Same re-entrancy.
176
+ */
177
+ export async function withWriterLockAsync(omgbaseDir, fn, opts = {}) {
178
+ const lockPath = enter(omgbaseDir, opts);
179
+ try {
180
+ return await fn();
101
181
  }
182
+ finally {
183
+ exit(lockPath);
184
+ }
185
+ }
186
+ /** True while THIS process holds the lock (at any depth) — a test seam for "ran under the lock". */
187
+ export function writerLockHeld(omgbaseDir) {
188
+ return (held.get(lockPathOf(omgbaseDir)) ?? 0) > 0;
102
189
  }
103
190
  /** True if no live writer holds the lock (probe without acquiring). */
104
191
  export function writerLockFree(omgbaseDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/core",
3
- "version": "0.11.1",
3
+ "version": "0.15.0",
4
4
  "description": "Open Markdown Graph Base — embedded engine",
5
5
  "license": "MIT",
6
6
  "repository": {