@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.
- package/README.md +1 -1
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +17 -0
- package/dist/src/core/ingest.d.ts +39 -3
- package/dist/src/core/ingest.js +233 -202
- package/dist/src/core/store/schema.d.ts +3 -2
- package/dist/src/core/store/schema.js +28 -3
- package/dist/src/core/store/store.d.ts +9 -0
- package/dist/src/core/store/store.js +11 -0
- package/dist/src/graph/history.js +15 -12
- package/dist/src/index.d.ts +9 -5
- package/dist/src/index.js +14 -7
- package/dist/src/mcp/arguments.d.ts +9 -2
- package/dist/src/mcp/arguments.js +15 -2
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/errors.js +3 -0
- package/dist/src/mcp/server.d.ts +22 -0
- package/dist/src/mcp/server.js +262 -185
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/commit.d.ts +150 -0
- package/dist/src/mutate/commit.js +349 -0
- package/dist/src/mutate/doc-store.d.ts +3 -3
- package/dist/src/mutate/doc-store.js +21 -6
- package/dist/src/mutate/docs.d.ts +42 -87
- package/dist/src/mutate/docs.js +50 -204
- package/dist/src/mutate/effects.d.ts +331 -0
- package/dist/src/mutate/effects.js +981 -0
- package/dist/src/mutate/materialize.d.ts +49 -0
- package/dist/src/mutate/materialize.js +174 -0
- package/dist/src/mutate/opset.d.ts +27 -2
- package/dist/src/mutate/opset.js +12 -5
- package/dist/src/mutate/plan-update.d.ts +1 -46
- package/dist/src/mutate/plan-update.js +19 -67
- package/dist/src/mutate/plan.d.ts +200 -0
- package/dist/src/mutate/plan.js +472 -0
- package/dist/src/mutate/sugar.d.ts +135 -0
- package/dist/src/mutate/sugar.js +167 -0
- package/dist/src/mutate/tree.d.ts +4 -2
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +5 -5
- package/dist/src/surface-paths.d.ts +17 -26
- package/dist/src/surface-paths.js +27 -22
- package/dist/src/sync/checkpoint.d.ts +5 -2
- package/dist/src/sync/checkpoint.js +10 -6
- package/dist/src/sync/freshness.d.ts +10 -7
- package/dist/src/sync/freshness.js +15 -17
- package/dist/src/sync/observe.d.ts +4 -1
- package/dist/src/sync/observe.js +56 -5
- package/dist/src/sync/recovery.d.ts +4 -0
- package/dist/src/sync/recovery.js +4 -1
- package/dist/src/sync/stat-cache.d.ts +7 -0
- package/dist/src/sync/stat-cache.js +22 -0
- package/dist/src/sync/tombstone.js +23 -19
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +4 -1
- package/dist/src/sync/writer-lock.d.ts +15 -0
- package/dist/src/sync/writer-lock.js +104 -17
- 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
|
|
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
|
package/dist/src/sync/observe.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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;
|
package/dist/src/sync/watcher.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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) {
|