@omgbase/core 0.1.0 → 0.2.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 (76) hide show
  1. package/README.md +172 -0
  2. package/dist/src/cli/eval-matcher.d.ts +6 -1
  3. package/dist/src/cli/eval-matcher.js +14 -2
  4. package/dist/src/core/hash.d.ts +25 -1
  5. package/dist/src/core/hash.js +110 -10
  6. package/dist/src/core/ids.d.ts +12 -0
  7. package/dist/src/core/ids.js +43 -1
  8. package/dist/src/core/ingest.d.ts +22 -2
  9. package/dist/src/core/ingest.js +92 -18
  10. package/dist/src/core/parse/parse.d.ts +1 -1
  11. package/dist/src/core/parse/parse.js +31 -9
  12. package/dist/src/core/store/nodes.d.ts +13 -0
  13. package/dist/src/core/store/nodes.js +24 -0
  14. package/dist/src/core/utf8.d.ts +29 -0
  15. package/dist/src/core/utf8.js +96 -0
  16. package/dist/src/format/adapter.d.ts +7 -1
  17. package/dist/src/format/markdown.js +9 -18
  18. package/dist/src/graph/history.d.ts +6 -0
  19. package/dist/src/graph/history.js +13 -2
  20. package/dist/src/index.d.ts +8 -5
  21. package/dist/src/index.js +8 -5
  22. package/dist/src/mcp/graph.js +8 -4
  23. package/dist/src/mcp/reference.d.ts +1 -1
  24. package/dist/src/mcp/reference.js +17 -6
  25. package/dist/src/mcp/server.js +12 -7
  26. package/dist/src/mutate/apply.d.ts +7 -0
  27. package/dist/src/mutate/apply.js +17 -7
  28. package/dist/src/mutate/docs.d.ts +2 -0
  29. package/dist/src/mutate/docs.js +10 -6
  30. package/dist/src/mutate/known-ids.js +9 -1
  31. package/dist/src/mutate/macros.js +9 -2
  32. package/dist/src/mutate/ops.d.ts +2 -1
  33. package/dist/src/mutate/ops.js +24 -2
  34. package/dist/src/mutate/plan-update.d.ts +7 -1
  35. package/dist/src/mutate/plan-update.js +3 -1
  36. package/dist/src/oqx-js/context.js +43 -6
  37. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  38. package/dist/src/reconcile/eval/generator.js +248 -0
  39. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  40. package/dist/src/reconcile/eval/metrics.js +55 -32
  41. package/dist/src/reconcile/flatten.js +7 -5
  42. package/dist/src/reconcile/phase5.d.ts +2 -1
  43. package/dist/src/reconcile/phase5.js +27 -10
  44. package/dist/src/reconcile/phase6a.js +17 -5
  45. package/dist/src/reconcile/phases.d.ts +44 -6
  46. package/dist/src/reconcile/phases.js +0 -0
  47. package/dist/src/reconcile/reconcile.js +16 -9
  48. package/dist/src/reconcile/types.d.ts +4 -1
  49. package/dist/src/reconcile/types.js +11 -2
  50. package/dist/src/search/external.d.ts +10 -0
  51. package/dist/src/search/external.js +18 -1
  52. package/dist/src/search/provider.d.ts +7 -3
  53. package/dist/src/search/provider.js +8 -7
  54. package/dist/src/search/rrf.js +3 -0
  55. package/dist/src/search/vector.js +21 -6
  56. package/dist/src/sync/attach.d.ts +7 -2
  57. package/dist/src/sync/attach.js +10 -8
  58. package/dist/src/sync/checkpoint.d.ts +18 -1
  59. package/dist/src/sync/checkpoint.js +31 -29
  60. package/dist/src/sync/driver.d.ts +15 -4
  61. package/dist/src/sync/driver.js +18 -43
  62. package/dist/src/sync/external-source.js +7 -1
  63. package/dist/src/sync/freshness.d.ts +65 -14
  64. package/dist/src/sync/freshness.js +107 -96
  65. package/dist/src/sync/fs-util.d.ts +19 -0
  66. package/dist/src/sync/fs-util.js +24 -1
  67. package/dist/src/sync/observe.d.ts +53 -5
  68. package/dist/src/sync/observe.js +162 -21
  69. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  70. package/dist/src/sync/reconciling-ingest.js +82 -10
  71. package/dist/src/sync/recovery.d.ts +5 -1
  72. package/dist/src/sync/recovery.js +8 -8
  73. package/dist/src/sync/workspace.d.ts +11 -3
  74. package/dist/src/sync/workspace.js +39 -30
  75. package/dist/src/sync/writer-lock.js +16 -26
  76. package/package.json +3 -3
@@ -1,12 +1,19 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import type { SyncSource } from "./plugin.js";
3
- import type { FileChange, CheckpointResult } from "./checkpoint.js";
3
+ import { type FileChange, type CheckpointResult } from "./checkpoint.js";
4
4
  export type { FileChange, CheckpointResult };
5
5
  /**
6
6
  * Reconcile a batch of changed members against a repo as one checkpoint.
7
7
  * `source.fetch(path)` supplies current bytes (or null = left the scope). The
8
8
  * engine hashes the bytes itself: a hash equal to the stored file_hash is an
9
- * echo (no commit); otherwise the member is ingested as an observed commit.
9
+ * echo (no commit); otherwise the member is ingested as an observed commit; a
10
+ * member that left the scope tombstones its live doc (drop FTS, tombstone
11
+ * blocks + doc, pool blocks) so it stops being served. Every member is fetched
12
+ * first, then the whole batch goes through `observeBatch` — the one
13
+ * implementation of echo gate + identity threading + conflict flagging (D2),
14
+ * including the cross-document move phase over the batch — so a live watcher
15
+ * batch that cuts a block from one member and pastes it into another keeps the
16
+ * id. (v1 sources are identity-inferred; borne identity is deferred, ADR-010/D4.)
10
17
  */
11
18
  export declare function reconcileChanges(store: Store, repoId: string, source: SyncSource, changes: FileChange[], opts?: {
12
19
  ts?: string;
@@ -20,6 +27,10 @@ export interface AttachResult {
20
27
  /**
21
28
  * Attach a source scope as a repo: create/reuse the repo row, then ingest every
22
29
  * member the source enumerates (identity-inferred sources thread the reconciling
23
- * resolver so edges + identity are established on the initial walk).
30
+ * resolver so edges + identity are established on the initial walk; a `borne`
31
+ * source takes the plain re-minting ingest — spec/sync §9). `opts.ts` pins the
32
+ * commit timestamp (default now).
24
33
  */
25
- export declare function attachSource(store: Store, slug: string, rootPath: string | null, source: SyncSource): Promise<AttachResult>;
34
+ export declare function attachSource(store: Store, slug: string, rootPath: string | null, source: SyncSource, opts?: {
35
+ ts?: string;
36
+ }): Promise<AttachResult>;
@@ -1,66 +1,41 @@
1
- import { mintId } from "../core/ids.js";
2
1
  import { ingestFile } from "../core/ingest.js";
3
2
  import { ensureRepo } from "../core/attach.js";
4
3
  import { makeReconcilingResolver } from "./reconciling-ingest.js";
5
- import { observeOne } from "./observe.js";
6
- import { sweepResurrectionPool } from "../core/store/gc.js";
7
- import { tombstoneObservedDeletion } from "./tombstone.js";
4
+ import { observeBatch } from "./observe.js";
5
+ import { finishCheckpoint } from "./checkpoint.js";
8
6
  /**
9
7
  * Reconcile a batch of changed members against a repo as one checkpoint.
10
8
  * `source.fetch(path)` supplies current bytes (or null = left the scope). The
11
9
  * engine hashes the bytes itself: a hash equal to the stored file_hash is an
12
- * echo (no commit); otherwise the member is ingested as an observed commit.
10
+ * echo (no commit); otherwise the member is ingested as an observed commit; a
11
+ * member that left the scope tombstones its live doc (drop FTS, tombstone
12
+ * blocks + doc, pool blocks) so it stops being served. Every member is fetched
13
+ * first, then the whole batch goes through `observeBatch` — the one
14
+ * implementation of echo gate + identity threading + conflict flagging (D2),
15
+ * including the cross-document move phase over the batch — so a live watcher
16
+ * batch that cuts a block from one member and pastes it into another keeps the
17
+ * id. (v1 sources are identity-inferred; borne identity is deferred, ADR-010/D4.)
13
18
  */
14
19
  export async function reconcileChanges(store, repoId, source, changes, opts = {}) {
15
20
  const ts = opts.ts ?? new Date().toISOString();
16
- const checkpointId = mintId("cp");
17
- const ingested = [];
18
- const suppressed = [];
19
- const deleted = [];
20
- const conflicted = [];
21
- const fileEntries = [];
21
+ const items = [];
22
22
  for (const change of changes) {
23
23
  const item = await source.fetch(change.path);
24
- if (item === null) {
25
- // Member left the source scope (deleted/moved out): tombstone the live doc
26
- // (drop FTS, tombstone blocks + doc, pool blocks) so it stops being served.
27
- const existing = store.db
28
- .prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
29
- .get(repoId, change.path);
30
- if (existing) {
31
- tombstoneObservedDeletion(store, repoId, existing.doc_id, ts);
32
- deleted.push(change.path);
33
- }
34
- fileEntries.push([change.path, existing?.file_hash?.toString("hex") ?? null, null]);
35
- continue;
36
- }
37
- // Reconcile the fetched bytes through the shared observe primitive — the one
38
- // implementation of echo gate + identity threading + conflict flagging (D2).
39
- // (v1 sources are identity-inferred; borne identity is deferred, ADR-010/D4.)
40
- const r = observeOne(store, repoId, change.path, item.content, ts);
41
- if (r.echo)
42
- suppressed.push(change.path);
43
- else if (r.conflicted)
44
- conflicted.push(change.path);
45
- else
46
- ingested.push(change.path);
47
- fileEntries.push([change.path, r.oldHashHex, r.newHashHex]);
24
+ items.push({ path: change.path, content: item === null ? null : item.content });
48
25
  }
49
- store.db
50
- .prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
51
- .run(checkpointId, repoId, ts, JSON.stringify(fileEntries), opts.gitHead ?? null);
52
- sweepResurrectionPool(store, ts);
53
- return { checkpointId, ingested, suppressed, deleted, conflicted };
26
+ return finishCheckpoint(store, repoId, observeBatch(store, repoId, items, ts), { ts, gitHead: opts.gitHead ?? null });
54
27
  }
55
28
  /**
56
29
  * Attach a source scope as a repo: create/reuse the repo row, then ingest every
57
30
  * member the source enumerates (identity-inferred sources thread the reconciling
58
- * resolver so edges + identity are established on the initial walk).
31
+ * resolver so edges + identity are established on the initial walk; a `borne`
32
+ * source takes the plain re-minting ingest — spec/sync §9). `opts.ts` pins the
33
+ * commit timestamp (default now).
59
34
  */
60
- export async function attachSource(store, slug, rootPath, source) {
35
+ export async function attachSource(store, slug, rootPath, source, opts = {}) {
61
36
  const repoId = ensureRepo(store, slug, rootPath);
62
37
  const inferred = source.capabilities().identity === "inferred";
63
- const ts = new Date().toISOString();
38
+ const ts = opts.ts ?? new Date().toISOString();
64
39
  let fileCount = 0;
65
40
  let allConverged = true;
66
41
  for (const entry of await source.enumerate()) {
@@ -22,11 +22,14 @@ export async function createExternalSource(spec) {
22
22
  child.once("error", (err) => reject(new Error(`sync adapter '${spec.command}' failed to spawn: ${err.message}`)));
23
23
  child.once("exit", (code) => reject(new Error(`sync adapter '${spec.command}' exited early (code ${code ?? "?"})`)));
24
24
  });
25
- // Handshake: the first line declares protocol + capabilities.
25
+ // Handshake: the first line declares protocol + capabilities. A missing or
26
+ // non-1 `protocol` is as invalid as a line that does not parse (spec/sync §5).
26
27
  const handshakeRaw = await Promise.race([router.nextResponse(), died]);
27
28
  let caps;
28
29
  try {
29
30
  const hs = JSON.parse(handshakeRaw);
31
+ if (hs.protocol !== 1)
32
+ throw new Error("protocol");
30
33
  const c = hs.capabilities ?? {};
31
34
  caps = {
32
35
  identity: c.identity === "borne" ? "borne" : "inferred",
@@ -35,6 +38,9 @@ export async function createExternalSource(spec) {
35
38
  };
36
39
  }
37
40
  catch {
41
+ rl.close();
42
+ child.stdin.end();
43
+ child.kill("SIGTERM");
38
44
  throw new Error(`sync adapter '${spec.command}' sent an invalid handshake: ${handshakeRaw.slice(0, 120)}`);
39
45
  }
40
46
  let nextId = 1;
@@ -1,18 +1,66 @@
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";
4
+ /** One `file_stats` row (spec/sync §4.3 "the repo's cache rows"). */
5
+ export interface StatCacheRow {
6
+ path: string;
7
+ mtime_ns: bigint;
8
+ size: number;
9
+ hash: Buffer;
10
+ }
11
+ /** One walked file in the current snapshot; `hash()` reads and hashes its bytes on demand (memoized). */
12
+ export interface SnapshotEntry {
13
+ path: string;
14
+ mtime_ns: bigint;
15
+ size: number;
16
+ hash: () => Buffer;
17
+ }
18
+ /**
19
+ * The §4.3 decisions over a cache and a snapshot:
20
+ * - `candidates`: snapshot paths not in the cache or whose `(mtime_ns, size)` differs (snapshot order);
21
+ * - `deletions`: cached paths not in the snapshot (cache order);
22
+ * - `changed`: candidates whose bytes' hash differs from the cached hash, or that have no cache row;
23
+ * - `refreshed`: candidates whose hash equals the cached one (a touch without an edit — the
24
+ * cache's stat is refreshed, nothing is ingested);
25
+ * - `hashes`: the hash computed for every candidate (the sweep records them).
26
+ */
27
+ export interface SweepPlan {
28
+ candidates: string[];
29
+ changed: string[];
30
+ deletions: string[];
31
+ refreshed: string[];
32
+ hashes: Map<string, Buffer>;
33
+ }
34
+ /** Step 1 of §4.3 alone: the stat-mismatched or new paths, without hashing anything. */
35
+ export declare function sweepCandidates(cache: ReadonlyMap<string, StatCacheRow>, snapshot: readonly SnapshotEntry[]): string[];
36
+ /** spec/sync §4.3 steps 1–2 as a pure function (README §8 `sweep_plan`). */
37
+ export declare function sweepPlan(cache: ReadonlyMap<string, StatCacheRow>, snapshot: readonly SnapshotEntry[]): SweepPlan;
38
+ /** The repo's `file_stats` rows keyed by path. Read with safe integers: an `mtime_ns` is a
39
+ * 64-bit count a JS number cannot hold exactly, and a rounded value would never equal a stat. */
40
+ export declare function loadStatCache(store: Store, repoId: string): Map<string, StatCacheRow>;
41
+ /** The current filesystem snapshot: the §4.2 walk with each file's stat and a lazy hash. */
42
+ export declare function snapshotOf(fs: SyncFs, rootPath: string): SnapshotEntry[];
3
43
  export interface SweepResult extends CheckpointResult {
4
44
  scanned: number;
5
45
  candidates: number;
6
46
  changed: boolean;
7
47
  }
8
- /** Record/refresh a file's stat cache row. Call after any ingest of the file. */
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. */
9
52
  export declare function recordFileStat(store: Store, repoId: string, path: string, abs: string, hash: Buffer): void;
10
53
  /**
11
- * Run the freshness sweep for a repo. Returns the checkpoint result plus scan
12
- * counters. Idempotent; safe alongside a live watcher (hash-based echo
13
- * suppression makes a double ingest a no-op).
54
+ * Run the freshness sweep for a repo (spec/sync §4.3). Returns the checkpoint
55
+ * result plus scan counters. Idempotent; safe alongside a live watcher
56
+ * (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.
14
58
  */
15
- export declare function freshnessSweep(store: Store, repoId: string, rootPath: string): SweepResult;
59
+ export declare function freshnessSweep(store: Store, repoId: string, rootPath: string, opts?: {
60
+ ts?: string;
61
+ gitHead?: string | null;
62
+ fs?: SyncFs;
63
+ }): SweepResult;
16
64
  /**
17
65
  * Counts of ways the DB disagrees with the current filesystem. All fields are
18
66
  * "not yet reconciled" states a freshnessSweep/ingest would resolve.
@@ -26,17 +74,20 @@ export interface DiskDrift {
26
74
  untracked: number;
27
75
  }
28
76
  /**
29
- * READ-ONLY disk-drift detector. Mirrors freshnessSweep's staged cheap approach
30
- * — stat-compare against file_stats first, hash only the stat-mismatched
31
- * candidates — but never ingests, commits, or touches file_stats. It answers a
32
- * single question: does the DB still agree with what's on disk right now?
77
+ * READ-ONLY disk-drift detector (spec/sync §4.3 "Disk drift"). Mirrors
78
+ * freshnessSweep's staged cheap approach — the same `sweepCandidates` over the
79
+ * same cache and snapshot, hashing only the stat-mismatched candidates — but
80
+ * never ingests, commits, or touches file_stats. It answers a single question:
81
+ * does the DB still agree with what's on disk right now?
33
82
  *
34
83
  * `changed` is decided by comparing a candidate's content hash to the doc's
35
84
  * stored `file_hash` (the same sha256(bytes) checkpoint writes), so a stat
36
- * change without a content change is NOT counted as drift. At the envelope
37
- * (≤10⁴ docs) the clean case is a directory walk plus stats (tens of ms);
38
- * hashing is bounded to files whose (mtime_ns, size) moved.
85
+ * change without a content change is NOT counted as drift.
39
86
  */
40
- export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string): DiskDrift;
87
+ export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string, opts?: {
88
+ fs?: SyncFs;
89
+ }): DiskDrift;
41
90
  /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
42
- export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string): number;
91
+ export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string, opts?: {
92
+ fs?: SyncFs;
93
+ }): number;
@@ -1,101 +1,121 @@
1
- import { statSync, readFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { sha256 } from "../core/hash.js";
4
2
  import { processCheckpoint } from "./checkpoint.js";
5
- import { walkMarkdown } from "./fs-util.js";
6
- /** Record/refresh a file's stat cache row. Call after any ingest of the file. */
7
- export function recordFileStat(store, repoId, path, abs, hash) {
8
- if (!existsSync(abs)) {
3
+ import { nodeFs } from "./fs-util.js";
4
+ /** Step 1 of §4.3 alone: the stat-mismatched or new paths, without hashing anything. */
5
+ export function sweepCandidates(cache, snapshot) {
6
+ const out = [];
7
+ for (const e of snapshot) {
8
+ const cached = cache.get(e.path);
9
+ if (!cached || cached.mtime_ns !== e.mtime_ns || cached.size !== e.size)
10
+ out.push(e.path);
11
+ }
12
+ return out;
13
+ }
14
+ /** spec/sync §4.3 steps 1–2 as a pure function (README §8 `sweep_plan`). */
15
+ export function sweepPlan(cache, snapshot) {
16
+ const byPath = new Map(snapshot.map((e) => [e.path, e]));
17
+ const candidates = sweepCandidates(cache, snapshot);
18
+ const deletions = [...cache.keys()].filter((p) => !byPath.has(p));
19
+ const changed = [];
20
+ const refreshed = [];
21
+ const hashes = new Map();
22
+ for (const path of candidates) {
23
+ const hash = byPath.get(path).hash();
24
+ hashes.set(path, hash);
25
+ const cached = cache.get(path);
26
+ if (!cached || !cached.hash.equals(hash))
27
+ changed.push(path);
28
+ else
29
+ refreshed.push(path);
30
+ }
31
+ return { candidates, changed, deletions, refreshed, hashes };
32
+ }
33
+ /** The repo's `file_stats` rows keyed by path. Read with safe integers: an `mtime_ns` is a
34
+ * 64-bit count a JS number cannot hold exactly, and a rounded value would never equal a stat. */
35
+ export function loadStatCache(store, repoId) {
36
+ const cache = new Map();
37
+ const rows = store.db.prepare("SELECT path, mtime_ns, size, hash FROM file_stats WHERE repo_id = ?").safeIntegers(true).all(repoId);
38
+ for (const row of rows)
39
+ cache.set(row.path, { path: row.path, mtime_ns: row.mtime_ns, size: Number(row.size), hash: row.hash });
40
+ return cache;
41
+ }
42
+ /** The current filesystem snapshot: the §4.2 walk with each file's stat and a lazy hash. */
43
+ export function snapshotOf(fs, rootPath) {
44
+ const out = [];
45
+ for (const path of fs.walk(rootPath)) {
46
+ const st = fs.stat(rootPath, path);
47
+ if (!st)
48
+ continue; // vanished between the walk and the stat
49
+ let memo = null;
50
+ out.push({
51
+ path,
52
+ mtime_ns: st.mtimeNs,
53
+ size: st.size,
54
+ hash: () => (memo ??= sha256(fs.read(rootPath, path) ?? "")),
55
+ });
56
+ }
57
+ return out;
58
+ }
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) {
9
63
  store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
10
64
  return;
11
65
  }
12
- const st = statSync(abs, { bigint: true });
13
66
  store.db
14
67
  .prepare(`INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?, ?, ?, ?, ?)
15
68
  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.toString(), Number(st.size), 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);
17
74
  }
18
75
  /**
19
- * Run the freshness sweep for a repo. Returns the checkpoint result plus scan
20
- * counters. Idempotent; safe alongside a live watcher (hash-based echo
21
- * suppression makes a double ingest a no-op).
76
+ * Run the freshness sweep for a repo (spec/sync §4.3). Returns the checkpoint
77
+ * result plus scan counters. Idempotent; safe alongside a live watcher
78
+ * (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.
22
80
  */
23
- export function freshnessSweep(store, repoId, rootPath) {
24
- const cache = new Map();
25
- for (const row of store.db
26
- .prepare("SELECT path, mtime_ns, size, hash FROM file_stats WHERE repo_id = ?")
27
- .all(repoId)) {
28
- cache.set(row.path, { mtime_ns: BigInt(row.mtime_ns), size: row.size, hash: row.hash });
81
+ export function freshnessSweep(store, repoId, rootPath, opts = {}) {
82
+ const fs = opts.fs ?? nodeFs;
83
+ const cache = loadStatCache(store, repoId);
84
+ const snapshot = snapshotOf(fs, rootPath);
85
+ const plan = sweepPlan(cache, snapshot);
86
+ const statOf = new Map(snapshot.map((e) => [e.path, { mtimeNs: e.mtime_ns, size: e.size }]));
87
+ // Content unchanged; refresh the stat cache so we don't re-hash next time.
88
+ for (const path of plan.refreshed) {
89
+ const st = statOf.get(path);
90
+ store.db.prepare("UPDATE file_stats SET mtime_ns = ?, size = ? WHERE repo_id = ? AND path = ?").run(st.mtimeNs, st.size, repoId, path);
29
91
  }
30
- const paths = walkMarkdown(rootPath);
31
- const seen = new Set(paths);
32
- // Candidates: files whose (mtime_ns, size) differs from cache, or are new.
33
- const candidates = [];
34
- const freshStats = new Map();
35
- for (const path of paths) {
36
- const st = statSync(join(rootPath, path), { bigint: true });
37
- freshStats.set(path, { mtimeNs: st.mtimeNs, size: Number(st.size) });
38
- const cached = cache.get(path);
39
- if (!cached || cached.mtime_ns !== st.mtimeNs || cached.size !== Number(st.size)) {
40
- candidates.push(path);
41
- }
42
- }
43
- // Deletions: cached paths no longer on disk.
44
- const deletedPaths = [...cache.keys()].filter((p) => !seen.has(p));
45
- // Hash candidates; only those whose content hash actually differs from the
46
- // cached hash need ingesting (mtime can change without content changing).
47
- const changedPaths = [];
48
- const candHashes = new Map();
49
- for (const path of candidates) {
50
- const hash = sha256(readFileSync(join(rootPath, path), "utf8"));
51
- candHashes.set(path, hash);
52
- const cached = cache.get(path);
53
- if (!cached || !cached.hash.equals(hash))
54
- changedPaths.push(path);
55
- else {
56
- // Content unchanged; refresh the stat cache so we don't re-hash next time.
57
- const fs = freshStats.get(path);
58
- store.db
59
- .prepare("UPDATE file_stats SET mtime_ns = ?, size = ? WHERE repo_id = ? AND path = ?")
60
- .run(fs.mtimeNs.toString(), fs.size, repoId, path);
61
- }
62
- }
63
- const toIngest = [...changedPaths, ...deletedPaths];
64
- const result = processCheckpoint(store, repoId, rootPath, toIngest.map((path) => ({ path })));
92
+ const result = processCheckpoint(store, repoId, rootPath, [...plan.changed, ...plan.deletions].map((path) => ({ path })), { fs, ...(opts.ts !== undefined ? { ts: opts.ts } : {}), ...(opts.gitHead !== undefined ? { gitHead: opts.gitHead } : {}) });
65
93
  // Refresh stat cache for everything we touched (ingested or echo-suppressed).
66
- for (const path of changedPaths) {
67
- recordFileStat(store, repoId, path, join(rootPath, path), candHashes.get(path));
68
- }
69
- for (const path of deletedPaths) {
94
+ for (const path of plan.changed)
95
+ recordStat(store, repoId, path, fs.stat(rootPath, path), plan.hashes.get(path));
96
+ for (const path of plan.deletions)
70
97
  store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
71
- }
72
98
  return {
73
99
  ...result,
74
- scanned: paths.length,
75
- candidates: candidates.length,
100
+ scanned: snapshot.length,
101
+ candidates: plan.candidates.length,
76
102
  changed: result.ingested.length > 0 || result.deleted.length > 0 || result.conflicted.length > 0,
77
103
  };
78
104
  }
79
105
  /**
80
- * READ-ONLY disk-drift detector. Mirrors freshnessSweep's staged cheap approach
81
- * — stat-compare against file_stats first, hash only the stat-mismatched
82
- * candidates — but never ingests, commits, or touches file_stats. It answers a
83
- * single question: does the DB still agree with what's on disk right now?
106
+ * READ-ONLY disk-drift detector (spec/sync §4.3 "Disk drift"). Mirrors
107
+ * freshnessSweep's staged cheap approach — the same `sweepCandidates` over the
108
+ * same cache and snapshot, hashing only the stat-mismatched candidates — but
109
+ * never ingests, commits, or touches file_stats. It answers a single question:
110
+ * does the DB still agree with what's on disk right now?
84
111
  *
85
112
  * `changed` is decided by comparing a candidate's content hash to the doc's
86
113
  * stored `file_hash` (the same sha256(bytes) checkpoint writes), so a stat
87
- * change without a content change is NOT counted as drift. At the envelope
88
- * (≤10⁴ docs) the clean case is a directory walk plus stats (tens of ms);
89
- * hashing is bounded to files whose (mtime_ns, size) moved.
114
+ * change without a content change is NOT counted as drift.
90
115
  */
91
- export function detectDiskDrift(store, repoId, rootPath) {
92
- // Stat cache keyed by path, for cheap change detection.
93
- const cache = new Map();
94
- for (const row of store.db
95
- .prepare("SELECT path, mtime_ns, size, hash FROM file_stats WHERE repo_id = ?")
96
- .all(repoId)) {
97
- cache.set(row.path, { mtime_ns: BigInt(row.mtime_ns), size: row.size, hash: row.hash });
98
- }
116
+ export function detectDiskDrift(store, repoId, rootPath, opts = {}) {
117
+ const fs = opts.fs ?? nodeFs;
118
+ const cache = loadStatCache(store, repoId);
99
119
  // Live docs keyed by path, with their durable file_hash (the convergence
100
120
  // signal). This is the authority for changed/deleted/untracked, independent
101
121
  // of the derived file_stats cache.
@@ -105,47 +125,38 @@ export function detectDiskDrift(store, repoId, rootPath) {
105
125
  .all(repoId)) {
106
126
  docs.set(row.path, row.file_hash);
107
127
  }
108
- const paths = walkMarkdown(rootPath);
109
- const seen = new Set(paths);
110
- // Candidates whose (mtime_ns, size) differs from the stat cache (or are new).
111
- const candidates = [];
112
- for (const path of paths) {
113
- const st = statSync(join(rootPath, path), { bigint: true });
114
- const cached = cache.get(path);
115
- if (!cached || cached.mtime_ns !== st.mtimeNs || cached.size !== Number(st.size)) {
116
- candidates.push(path);
117
- }
118
- }
128
+ const snapshot = snapshotOf(fs, rootPath);
129
+ const byPath = new Map(snapshot.map((e) => [e.path, e]));
119
130
  let changed = 0;
120
131
  let untracked = 0;
121
- for (const path of candidates) {
132
+ for (const path of sweepCandidates(cache, snapshot)) {
122
133
  const doc = docs.get(path);
123
134
  if (doc === undefined) {
124
135
  // No non-deleted doc for an on-disk *.md ⇒ untracked (new file).
125
136
  untracked += 1;
126
137
  continue;
127
138
  }
128
- const hash = sha256(readFileSync(join(rootPath, path), "utf8"));
129
139
  // Genuinely drifted only if content hash differs from the doc's file_hash.
130
- if (!doc || !doc.equals(hash))
140
+ if (!doc || !doc.equals(byPath.get(path).hash()))
131
141
  changed += 1;
132
142
  }
133
143
  // A doc present in the DB whose path isn't on disk anymore is a pending delete.
134
144
  let deleted = 0;
135
- for (const path of docs.keys()) {
136
- if (!seen.has(path))
145
+ for (const path of docs.keys())
146
+ if (!byPath.has(path))
137
147
  deleted += 1;
138
- }
139
148
  return { changed, deleted, untracked };
140
149
  }
141
150
  /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
142
- export function rebuildFileStats(store, repoId, rootPath) {
151
+ export function rebuildFileStats(store, repoId, rootPath, opts = {}) {
152
+ const fs = opts.fs ?? nodeFs;
143
153
  store.db.prepare("DELETE FROM file_stats WHERE repo_id = ?").run(repoId);
144
- const paths = walkMarkdown(rootPath);
154
+ const paths = fs.walk(rootPath);
145
155
  for (const path of paths) {
146
- const abs = join(rootPath, path);
147
- const hash = sha256(readFileSync(abs, "utf8"));
148
- recordFileStat(store, repoId, path, abs, hash);
156
+ const bytes = fs.read(rootPath, path);
157
+ if (bytes === null)
158
+ continue;
159
+ recordStat(store, repoId, path, fs.stat(rootPath, path), sha256(bytes));
149
160
  }
150
161
  return paths.length;
151
162
  }
@@ -1,5 +1,24 @@
1
1
  /** Walk `root` for *.md files, returning repo-relative canonical paths. */
2
2
  export declare function walkMarkdown(root: string): string[];
3
+ /** The two stat fields the freshness cache keys on (spec/sync §4.3). */
4
+ export interface FileStat {
5
+ mtimeNs: bigint;
6
+ size: number;
7
+ }
8
+ /**
9
+ * The filesystem as the sync paths see it — a walk, a stat and a read, all
10
+ * keyed by (root, repo-relative path). `nodeFs` is the production default.
11
+ */
12
+ export interface SyncFs {
13
+ /** spec/sync §4.2: every `*.md` under `root`, repo-relative, `/` separators, readdir order, depth-first. */
14
+ walk(root: string): string[];
15
+ /** `(mtime_ns, size)` of `root/path`, or null when the file is absent. */
16
+ stat(root: string, path: string): FileStat | null;
17
+ /** The UTF-8 bytes of `root/path`, or null when the file is absent. */
18
+ read(root: string, path: string): string | null;
19
+ }
20
+ /** The `node:fs` filesystem. */
21
+ export declare const nodeFs: SyncFs;
3
22
  /**
4
23
  * Async variant of {@link walkMarkdown}: identical result, but yields to the
5
24
  * event loop between directories and invokes `onFound(count)` as matches
@@ -1,10 +1,15 @@
1
- import { readdirSync, statSync } from "node:fs";
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { readdir, stat } from "node:fs/promises";
3
3
  import { join, relative, sep } from "node:path";
4
4
  // Shared filesystem walk for the in-process one-shot/reconcile paths (attach,
5
5
  // checkpoint, freshness). This is NOT the live watcher — chokidar lives only in
6
6
  // the external @omgbase/fs-adapter (sync-plugins). node:fs (a builtin) reads
7
7
  // are retained here for the one-shot ingest/freshness fast-path.
8
+ //
9
+ // `SyncFs` is the seam those paths read the filesystem through (spec/sync §4,
10
+ // §8): the sweep, the checkpoint and recovery take an optional `fs` and default
11
+ // to `nodeFs`, so a conformance runner can supply an in-memory filesystem with
12
+ // explicit `mtime_ns` and the fixtures stay free of real I/O.
8
13
  const IGNORED_DIRS = new Set([".omgbase", ".git", "node_modules"]);
9
14
  /** Walk `root` for *.md files, returning repo-relative canonical paths. */
10
15
  export function walkMarkdown(root) {
@@ -23,6 +28,24 @@ export function walkMarkdown(root) {
23
28
  recur(root);
24
29
  return out;
25
30
  }
31
+ /** The `node:fs` filesystem. */
32
+ export const nodeFs = {
33
+ walk: walkMarkdown,
34
+ stat(root, path) {
35
+ const st = statSync(join(root, path), { bigint: true, throwIfNoEntry: false });
36
+ return st ? { mtimeNs: st.mtimeNs, size: Number(st.size) } : null;
37
+ },
38
+ read(root, path) {
39
+ try {
40
+ return readFileSync(join(root, path), "utf8");
41
+ }
42
+ catch (err) {
43
+ if (err.code === "ENOENT")
44
+ return null;
45
+ throw err;
46
+ }
47
+ },
48
+ };
26
49
  /**
27
50
  * Async variant of {@link walkMarkdown}: identical result, but yields to the
28
51
  * event loop between directories and invokes `onFound(count)` as matches
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import { type ReconcileConfig } from "../reconcile/types.js";
2
3
  export interface ObserveResult {
3
4
  docId: string;
4
5
  path: string;
@@ -25,11 +26,56 @@ export interface ObserveOneResult extends ObserveResult {
25
26
  /** sha256(content) (hex) — the new file hash. */
26
27
  newHashHex: string;
27
28
  }
29
+ /** One member of a batch: its current bytes, or `null` when it left the source
30
+ * scope (deleted on disk / adapter fetch returned null). */
31
+ export interface BatchItem {
32
+ path: string;
33
+ content: string | null;
34
+ }
35
+ /** Outcome of one batch member: an observation (echo or commit) or a deletion. */
36
+ export type BatchOutcome = ({
37
+ kind: "observed";
38
+ } & ObserveOneResult) | {
39
+ kind: "deleted";
40
+ path: string;
41
+ /** the tombstoned doc, or null when no live doc existed at `path` (no-op). */
42
+ docId: string | null;
43
+ oldHashHex: string | null;
44
+ };
45
+ /**
46
+ * The batch reconcile primitive — one checkpoint's worth of members, in order.
47
+ * Two passes with the cross-document phase between them (reconciliation-spec §8):
48
+ *
49
+ * 1. every member is echo-gated and, when it differs from the stored revision,
50
+ * parsed + reconciled against its own prior tree WITHOUT committing
51
+ * (`prepareReconcile`; members gone from the source contribute their whole
52
+ * live tree as deleted). All members see one resurrection-pool snapshot with
53
+ * a shared consumed-set, so no pooled id resurrects twice in a batch.
54
+ * 2. the per-doc leftovers are pooled — deleted old blocks × inserted new
55
+ * blocks, across documents only — and matched at theta_xdoc
56
+ * (`crossDocMatch`). Each accepted pair is applied to both results: the
57
+ * destination's minted id becomes the carried id (`moved`/`edited_moved`,
58
+ * `detail.fromDoc`), the source's `deleted` disposition and pool entry for
59
+ * it vanish (`applyCrossDocMatches`).
60
+ * 3. every member commits, in batch order, each in its own transaction:
61
+ * `ingestFile` with the finished result (a carried-in id evicts the row the
62
+ * source doc may still hold — see `IdResolver.crossDocIds` — so the order
63
+ * of A and B in the batch does not matter), or `tombstoneObservedDeletion`.
64
+ *
65
+ * A moved block is therefore never `deleted` + `inserted`, never pooled, and
66
+ * never `resurrected` inside one batch. Across batches the pool still does that
67
+ * job (spec/reconcile §5 phase 6b). Does NOT sweep the pool — the caller does,
68
+ * once per batch. Does NOT write a file.
69
+ */
70
+ export declare function observeBatch(store: Store, repoId: string, items: BatchItem[], ts: string, opts?: {
71
+ config?: ReconcileConfig;
72
+ }): BatchOutcome[];
28
73
  /**
29
- * The reconcile primitive: echo-gate `content` for `path` and, when it differs
30
- * from the stored revision, ingest it as an observed commit (reconciling block
31
- * identity against the prior revision). Does NOT sweep the resurrection pool —
32
- * the caller does that once per batch. Does NOT write a file.
74
+ * The single-member reconcile primitive: echo-gate `content` for `path` and,
75
+ * when it differs from the stored revision, ingest it as an observed commit
76
+ * (reconciling block identity against the prior revision). A batch of one — so
77
+ * no cross-document matching; use `observeBatch` for that. Does NOT sweep the
78
+ * resurrection pool — the caller does that once per batch. Does NOT write a file.
33
79
  */
34
80
  export declare function observeOne(store: Store, repoId: string, path: string, content: string, ts: string): ObserveOneResult;
35
81
  /** Observe one file's authoritative bytes as an observed commit (echo-suppressed). */
@@ -37,7 +83,9 @@ export declare function observeFile(store: Store, repoId: string, path: string,
37
83
  ts?: string;
38
84
  }): ObserveResult;
39
85
  /** Observe a batch of files under one timestamp + one resurrection sweep. The
40
- * batch form of `observeFile`; each item is echo-gated independently. */
86
+ * batch form of `observeFile`; each item is echo-gated independently, and a
87
+ * block cut from one file and pasted into another in the same batch keeps its
88
+ * id (`moved`/`edited_moved`) via the cross-document phase of `observeBatch`. */
41
89
  export declare function observeMany(store: Store, repoId: string, items: {
42
90
  path: string;
43
91
  content: string;