@omgbase/core 0.11.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/src/core/ids.d.ts +4 -0
  3. package/dist/src/core/ids.js +17 -0
  4. package/dist/src/core/ingest.d.ts +39 -3
  5. package/dist/src/core/ingest.js +233 -202
  6. package/dist/src/core/store/schema.d.ts +3 -2
  7. package/dist/src/core/store/schema.js +28 -3
  8. package/dist/src/core/store/size.d.ts +76 -0
  9. package/dist/src/core/store/size.js +223 -0
  10. package/dist/src/core/store/store.d.ts +9 -0
  11. package/dist/src/core/store/store.js +11 -0
  12. package/dist/src/graph/history.js +15 -12
  13. package/dist/src/index.d.ts +12 -5
  14. package/dist/src/index.js +18 -7
  15. package/dist/src/mcp/arguments.d.ts +9 -2
  16. package/dist/src/mcp/arguments.js +15 -2
  17. package/dist/src/mcp/errors.d.ts +1 -1
  18. package/dist/src/mcp/errors.js +3 -0
  19. package/dist/src/mcp/reference.d.ts +1 -1
  20. package/dist/src/mcp/reference.js +27 -6
  21. package/dist/src/mcp/server.d.ts +56 -1
  22. package/dist/src/mcp/server.js +365 -203
  23. package/dist/src/mutate/apply.d.ts +93 -12
  24. package/dist/src/mutate/apply.js +203 -113
  25. package/dist/src/mutate/canonical.d.ts +27 -0
  26. package/dist/src/mutate/canonical.js +138 -0
  27. package/dist/src/mutate/commit.d.ts +150 -0
  28. package/dist/src/mutate/commit.js +349 -0
  29. package/dist/src/mutate/doc-store.d.ts +3 -3
  30. package/dist/src/mutate/doc-store.js +21 -6
  31. package/dist/src/mutate/docs.d.ts +42 -87
  32. package/dist/src/mutate/docs.js +50 -204
  33. package/dist/src/mutate/effects.d.ts +363 -0
  34. package/dist/src/mutate/effects.js +1161 -0
  35. package/dist/src/mutate/materialize.d.ts +49 -0
  36. package/dist/src/mutate/materialize.js +174 -0
  37. package/dist/src/mutate/opset.d.ts +27 -2
  38. package/dist/src/mutate/opset.js +12 -5
  39. package/dist/src/mutate/plan-update.d.ts +1 -46
  40. package/dist/src/mutate/plan-update.js +19 -67
  41. package/dist/src/mutate/plan.d.ts +210 -0
  42. package/dist/src/mutate/plan.js +512 -0
  43. package/dist/src/mutate/select.d.ts +89 -0
  44. package/dist/src/mutate/select.js +879 -0
  45. package/dist/src/mutate/sugar.d.ts +135 -0
  46. package/dist/src/mutate/sugar.js +167 -0
  47. package/dist/src/mutate/tree.d.ts +4 -2
  48. package/dist/src/oqx-js/planner.js +4 -0
  49. package/dist/src/oqx-js/run.d.ts +28 -1
  50. package/dist/src/oqx-js/run.js +85 -22
  51. package/dist/src/spec-versions.d.ts +6 -6
  52. package/dist/src/spec-versions.js +5 -5
  53. package/dist/src/surface-paths.d.ts +17 -26
  54. package/dist/src/surface-paths.js +27 -22
  55. package/dist/src/sync/checkpoint.d.ts +5 -2
  56. package/dist/src/sync/checkpoint.js +10 -6
  57. package/dist/src/sync/freshness.d.ts +10 -7
  58. package/dist/src/sync/freshness.js +15 -17
  59. package/dist/src/sync/observe.d.ts +4 -1
  60. package/dist/src/sync/observe.js +56 -5
  61. package/dist/src/sync/recovery.d.ts +4 -0
  62. package/dist/src/sync/recovery.js +4 -1
  63. package/dist/src/sync/stat-cache.d.ts +7 -0
  64. package/dist/src/sync/stat-cache.js +22 -0
  65. package/dist/src/sync/tombstone.js +23 -19
  66. package/dist/src/sync/watcher.d.ts +2 -0
  67. package/dist/src/sync/watcher.js +4 -1
  68. package/dist/src/sync/writer-lock.d.ts +15 -0
  69. package/dist/src/sync/writer-lock.js +104 -17
  70. package/dist/src/version.d.ts +7 -2
  71. package/dist/src/version.js +5 -2
  72. package/package.json +2 -2
@@ -1,28 +1,16 @@
1
1
  import type { ApplyResult } from "./mutate/apply.js";
2
- import type { DocOpResult, DocMoveResult } from "./mutate/docs.js";
3
2
  import type { RetargetHit } from "./mutate/macros.js";
4
- import type { Opset } from "./mutate/opset.js";
5
- import type { DocsUpdateResult } from "./mutate/plan-update.js";
3
+ import type { Plan, Receipt } from "./mutate/plan.js";
6
4
  import type { InboundLink } from "./graph/inbound-links.js";
7
5
  import type { ObserveResult, ObserveDeleteResult } from "./sync/observe.js";
8
6
  import type { SweepResult } from "./sync/freshness.js";
9
7
  import type { TextSearchResult } from "./search/text.js";
10
8
  import type { ResolveHit } from "./search/resolve.js";
11
- /** `spec/mutate` §4's result: `revisions[].path` and the dry-run `diffs` keys. */
9
+ /** `spec/mutate` §4's kernel result (the library's `apply`; no tool returns it since 4.0): `revisions[].path` and the dry-run `diffs` keys. */
12
10
  export declare function surfaceApplyResult<T extends ApplyResult>(res: T): T;
13
- /** A document operation's result on the wire (spec/surface §4, 3.0): the
14
- * library's `{ docId, path, … }` (`spec/mutate` §6, unchanged there) renamed
15
- * to the document's row attributes `id`/`path`, the path rooted, the
16
- * dry-run `diffs` re-keyed; every other key (`committed`, `deleted`, …) as is. */
17
- export type SurfaceDocOpResult<T extends DocOpResult> = Omit<T, "docId" | "path"> & {
18
- id: string;
19
- path: string;
20
- };
21
- /** `docs_create` / `docs_delete` / `docs_set_meta`. */
22
- export declare function surfaceDocOpResult<T extends DocOpResult>(res: T): SurfaceDocOpResult<T>;
23
- /** An inbound link on the wire (`docs_move.dangling[]`): an edge row under the
24
- * `edges` target's names (spec/surface §1.2) — `doc_id`, `path` (rooted),
25
- * `block_id`, `target` (as authored), `anchor`, `field?`. */
11
+ /** An inbound link on the wire (`docs_move.dangling[]`, `omg mv`): a link row
12
+ * under the `links` target's names (spec/surface §1.2) — `doc_id`, `path`
13
+ * (rooted), `block_id`, `target` (as authored), `anchor`, `field?`. */
26
14
  export interface SurfaceInboundLink {
27
15
  doc_id: string;
28
16
  path: string;
@@ -32,11 +20,6 @@ export interface SurfaceInboundLink {
32
20
  field?: string;
33
21
  }
34
22
  export declare function surfaceInboundLink(l: InboundLink): SurfaceInboundLink;
35
- /** `docs_move`: the document-op fields plus each dangling link (its `target`
36
- * stays as authored — that is what the link says). */
37
- export declare function surfaceDocMoveResult(res: DocMoveResult): Omit<SurfaceDocOpResult<DocMoveResult>, "dangling"> & {
38
- dangling: SurfaceInboundLink[];
39
- };
40
23
  /** An observe outcome on the wire (spec/surface §4, 3.0): `spec/store` §5's
41
24
  * `{ docId, path, … }` (unchanged there) renamed to the document's row
42
25
  * attributes `id`/`path`, the path rooted. */
@@ -58,9 +41,17 @@ export declare function surfaceTextSearch(res: TextSearchResult): TextSearchResu
58
41
  export declare function surfaceResolveHits(hits: ResolveHit[]): ResolveHit[];
59
42
  /** `links_retarget` / `links_repair` hits (`path` is the block's document). */
60
43
  export declare function surfaceRetargetHits(hits: RetargetHit[]): RetargetHit[];
61
- /** A whole-document update plan: `target.path` and `precondition.path`. */
62
- export declare function surfaceOpset(opset: Opset): Opset;
63
- /** `docs_update`: the opset and, when committed, the apply result. */
64
- export declare function surfaceDocsUpdate(res: DocsUpdateResult): DocsUpdateResult;
44
+ /**
45
+ * A Plan on the wire (spec/surface §4, 3.1): the executable core — `effects`,
46
+ * `opset`, `preconditions`, `new_ids`, `plan_id` — is returned VERBATIM in the
47
+ * storage form, because `plan_id` digests exactly those bytes and the client
48
+ * hands the object back to `execute` (spec/mutate §11.5; the one result the
49
+ * path rule exempts, like `dangling[].target`). Only the un-digested `preview`
50
+ * is rooted: its `revisions[].path`, `diffs` keys and `retarget[].hits[].path`
51
+ * (3.0; `identity` is keyed by document id and carries no path).
52
+ */
53
+ export declare function surfacePlan(plan: Plan): Plan;
54
+ /** A Receipt on the wire: `revisions[].path` rooted (the same shape as an apply revision). */
55
+ export declare function surfaceReceipt(receipt: Receipt): Receipt;
65
56
  /** `omg sync`'s one-shot sweep: the ingested / suppressed / deleted / conflicted paths. */
66
57
  export declare function surfaceSweepResult(res: SweepResult): SweepResult;
@@ -11,14 +11,14 @@
11
11
  // Row names (spec/surface §1.2, 3.0): a result that renders a document, block
12
12
  // or edge row spells it the way the query binding does — `id`, `path`,
13
13
  // `hash`, `raw`, `updated_at`, plain `doc_id`/`block_id` foreign keys. The
14
- // shapes other specs own under their own names — `spec/mutate`'s document
15
- // operation result `{ docId, path, … }`, `spec/store`'s observe outcome — are
16
- // re-shaped HERE too (`SurfaceDocOpResult`, `SurfaceObserveResult`, …).
14
+ // shapes other specs own under their own names — `spec/store`'s observe
15
+ // outcome, a move's dangling inbound links — are re-shaped HERE too
16
+ // (`SurfaceObserveResult`, `surfaceInboundLink`, …).
17
17
  //
18
18
  // Both the MCP server and the `omg` verbs call these, so the two clients
19
19
  // cannot drift. Each function returns a new value; the input is not mutated.
20
20
  import { referencePath, referenceKeyed } from "./core/paths.js";
21
- /** `spec/mutate` §4's result: `revisions[].path` and the dry-run `diffs` keys. */
21
+ /** `spec/mutate` §4's kernel result (the library's `apply`; no tool returns it since 4.0): `revisions[].path` and the dry-run `diffs` keys. */
22
22
  export function surfaceApplyResult(res) {
23
23
  return {
24
24
  ...res,
@@ -26,20 +26,10 @@ export function surfaceApplyResult(res) {
26
26
  ...(res.diffs ? { diffs: referenceKeyed(res.diffs) } : {}),
27
27
  };
28
28
  }
29
- /** `docs_create` / `docs_delete` / `docs_set_meta`. */
30
- export function surfaceDocOpResult(res) {
31
- const { docId, path, ...rest } = res;
32
- return { id: docId, path: referencePath(path), ...rest, ...(res.diffs ? { diffs: referenceKeyed(res.diffs) } : {}) };
33
- }
34
29
  export function surfaceInboundLink(l) {
35
30
  const { path, ...rest } = l;
36
31
  return { doc_id: rest.doc_id, path: referencePath(path), block_id: rest.block_id, target: rest.target, anchor: rest.anchor, ...(rest.field !== undefined ? { field: rest.field } : {}) };
37
32
  }
38
- /** `docs_move`: the document-op fields plus each dangling link (its `target`
39
- * stays as authored — that is what the link says). */
40
- export function surfaceDocMoveResult(res) {
41
- return { ...surfaceDocOpResult(res), dangling: res.dangling.map(surfaceInboundLink) };
42
- }
43
33
  /** `observe` / `observe_many` outcomes. */
44
34
  export function surfaceObserveResult(res) {
45
35
  const { docId, path, ...rest } = res;
@@ -66,17 +56,31 @@ export function surfaceResolveHits(hits) {
66
56
  export function surfaceRetargetHits(hits) {
67
57
  return hits.map((h) => ({ ...h, path: referencePath(h.path) }));
68
58
  }
69
- /** A whole-document update plan: `target.path` and `precondition.path`. */
70
- export function surfaceOpset(opset) {
59
+ /**
60
+ * A Plan on the wire (spec/surface §4, 3.1): the executable core — `effects`,
61
+ * `opset`, `preconditions`, `new_ids`, `plan_id` — is returned VERBATIM in the
62
+ * storage form, because `plan_id` digests exactly those bytes and the client
63
+ * hands the object back to `execute` (spec/mutate §11.5; the one result the
64
+ * path rule exempts, like `dangling[].target`). Only the un-digested `preview`
65
+ * is rooted: its `revisions[].path`, `diffs` keys and `retarget[].hits[].path`
66
+ * (3.0; `identity` is keyed by document id and carries no path).
67
+ */
68
+ export function surfacePlan(plan) {
69
+ if (!plan.preview)
70
+ return plan;
71
71
  return {
72
- ...opset,
73
- target: { ...opset.target, path: referencePath(opset.target.path) },
74
- precondition: { ...opset.precondition, path: referencePath(opset.precondition.path) },
72
+ ...plan,
73
+ preview: {
74
+ ...plan.preview,
75
+ revisions: plan.preview.revisions.map((r) => ({ ...r, path: referencePath(r.path) })),
76
+ diffs: referenceKeyed(plan.preview.diffs),
77
+ ...(plan.preview.retarget !== undefined ? { retarget: plan.preview.retarget.map((r) => ({ ...r, hits: surfaceRetargetHits(r.hits) })) } : {}),
78
+ },
75
79
  };
76
80
  }
77
- /** `docs_update`: the opset and, when committed, the apply result. */
78
- export function surfaceDocsUpdate(res) {
79
- return { opset: surfaceOpset(res.opset), result: res.result ? surfaceApplyResult(res.result) : null };
81
+ /** A Receipt on the wire: `revisions[].path` rooted (the same shape as an apply revision). */
82
+ export function surfaceReceipt(receipt) {
83
+ return { ...receipt, revisions: receipt.revisions.map((r) => ({ ...r, path: referencePath(r.path) })) };
80
84
  }
81
85
  /** `omg sync`'s one-shot sweep: the ingested / suppressed / deleted / conflicted paths. */
82
86
  export function surfaceSweepResult(res) {
@@ -86,6 +90,7 @@ export function surfaceSweepResult(res) {
86
90
  suppressed: res.suppressed.map(referencePath),
87
91
  deleted: res.deleted.map(referencePath),
88
92
  conflicted: res.conflicted.map(referencePath),
93
+ materialized: res.materialized.map(referencePath),
89
94
  };
90
95
  }
91
96
  //# sourceMappingURL=surface-paths.js.map
@@ -29,8 +29,11 @@ export declare function processCheckpoint(store: Store, repoId: string, rootPath
29
29
  }): CheckpointResult;
30
30
  /**
31
31
  * Turn a batch's outcomes into the checkpoint row + result, then sweep the
32
- * resurrection pool. Shared with the external-source driver (driver.ts), which
33
- * obtains its bytes asynchronously but records its checkpoint the same way.
32
+ * resurrection pool — the row and the sweep in ONE transaction (spec/sync §4.1),
33
+ * so a checkpoint is never recorded without its sweep or swept without its row
34
+ * (the member commits before it are one transaction each, as before). Shared
35
+ * with the external-source driver (driver.ts), which obtains its bytes
36
+ * asynchronously but records its checkpoint the same way.
34
37
  */
35
38
  export declare function finishCheckpoint(store: Store, repoId: string, outcomes: BatchOutcome[], opts: {
36
39
  ts: string;
@@ -20,8 +20,11 @@ export function processCheckpoint(store, repoId, rootPath, changes, opts = {}) {
20
20
  }
21
21
  /**
22
22
  * Turn a batch's outcomes into the checkpoint row + result, then sweep the
23
- * resurrection pool. Shared with the external-source driver (driver.ts), which
24
- * obtains its bytes asynchronously but records its checkpoint the same way.
23
+ * resurrection pool — the row and the sweep in ONE transaction (spec/sync §4.1),
24
+ * so a checkpoint is never recorded without its sweep or swept without its row
25
+ * (the member commits before it are one transaction each, as before). Shared
26
+ * with the external-source driver (driver.ts), which obtains its bytes
27
+ * asynchronously but records its checkpoint the same way.
25
28
  */
26
29
  export function finishCheckpoint(store, repoId, outcomes, opts) {
27
30
  const checkpointId = mintId("cp");
@@ -45,10 +48,11 @@ export function finishCheckpoint(store, repoId, outcomes, opts) {
45
48
  ingested.push(o.path);
46
49
  fileEntries.push([o.path, o.oldHashHex, o.newHashHex]);
47
50
  }
48
- store.db
49
- .prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
50
- .run(checkpointId, repoId, opts.ts, JSON.stringify(fileEntries), opts.gitHead);
51
- sweepResurrectionPool(store, opts.ts);
51
+ store.write((db) => {
52
+ db.prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
53
+ .run(checkpointId, repoId, opts.ts, JSON.stringify(fileEntries), opts.gitHead);
54
+ sweepResurrectionPool(store, opts.ts);
55
+ });
52
56
  return { checkpointId, ingested, suppressed, deleted, conflicted };
53
57
  }
54
58
  //# sourceMappingURL=checkpoint.js.map
@@ -1,6 +1,8 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type CheckpointResult } from "./checkpoint.js";
3
- import { type FileStat, type SyncFs } from "./fs-util.js";
3
+ import { type SyncFs } from "./fs-util.js";
4
+ import { type DocStore } from "../mutate/doc-store.js";
5
+ export { recordStat, recordFileStat } from "./stat-cache.js";
4
6
  /** One `file_stats` row (spec/sync §4.3 "the repo's cache rows"). */
5
7
  export interface StatCacheRow {
6
8
  path: string;
@@ -44,22 +46,23 @@ export interface SweepResult extends CheckpointResult {
44
46
  scanned: number;
45
47
  candidates: number;
46
48
  changed: boolean;
49
+ /** step 0 (spec/sync §4.3, 2.2): the paths whose pending materialization rows were replayed before the walk. */
50
+ materialized: string[];
47
51
  }
48
- /** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
49
- * file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
50
- export declare function recordStat(store: Store, repoId: string, path: string, st: FileStat | null, hash: Buffer): void;
51
- /** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
52
- export declare function recordFileStat(store: Store, repoId: string, path: string, abs: string, hash: Buffer): void;
53
52
  /**
54
53
  * Run the freshness sweep for a repo (spec/sync §4.3). Returns the checkpoint
55
54
  * result plus scan counters. Idempotent; safe alongside a live watcher
56
55
  * (hash-based echo suppression makes a double ingest a no-op). `opts.ts` pins
57
- * the batch timestamp (default now); `opts.fs` the filesystem seam.
56
+ * the batch timestamp (default now); `opts.fs` the filesystem seam;
57
+ * `opts.docStore` the write target step 0 materializes through (default: the
58
+ * filesystem store at `rootPath` — the fixtures' in-memory filesystem never
59
+ * holds a pending row, so the default is never exercised there).
58
60
  */
59
61
  export declare function freshnessSweep(store: Store, repoId: string, rootPath: string, opts?: {
60
62
  ts?: string;
61
63
  gitHead?: string | null;
62
64
  fs?: SyncFs;
65
+ docStore?: DocStore;
63
66
  }): SweepResult;
64
67
  /**
65
68
  * Counts of ways the DB disagrees with the current filesystem. All fields are
@@ -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;