@omgbase/core 0.1.0 → 0.3.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 (84) 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/clock.d.ts +9 -0
  5. package/dist/src/core/clock.js +46 -0
  6. package/dist/src/core/hash.d.ts +25 -1
  7. package/dist/src/core/hash.js +110 -10
  8. package/dist/src/core/ids.d.ts +12 -0
  9. package/dist/src/core/ids.js +43 -1
  10. package/dist/src/core/ingest.d.ts +22 -2
  11. package/dist/src/core/ingest.js +92 -18
  12. package/dist/src/core/parse/parse.d.ts +1 -1
  13. package/dist/src/core/parse/parse.js +31 -9
  14. package/dist/src/core/store/nodes.d.ts +13 -0
  15. package/dist/src/core/store/nodes.js +24 -0
  16. package/dist/src/core/store/writers.d.ts +6 -0
  17. package/dist/src/core/store/writers.js +1 -0
  18. package/dist/src/core/utf8.d.ts +29 -0
  19. package/dist/src/core/utf8.js +96 -0
  20. package/dist/src/format/adapter.d.ts +7 -1
  21. package/dist/src/format/markdown.js +36 -41
  22. package/dist/src/graph/extract.d.ts +41 -1
  23. package/dist/src/graph/extract.js +59 -1
  24. package/dist/src/graph/history.d.ts +39 -1
  25. package/dist/src/graph/history.js +154 -20
  26. package/dist/src/index.d.ts +10 -6
  27. package/dist/src/index.js +11 -6
  28. package/dist/src/mcp/graph.js +8 -4
  29. package/dist/src/mcp/reference.d.ts +1 -1
  30. package/dist/src/mcp/reference.js +17 -6
  31. package/dist/src/mcp/server.js +15 -10
  32. package/dist/src/mutate/apply.d.ts +9 -0
  33. package/dist/src/mutate/apply.js +24 -11
  34. package/dist/src/mutate/docs.d.ts +2 -0
  35. package/dist/src/mutate/docs.js +10 -6
  36. package/dist/src/mutate/known-ids.js +11 -3
  37. package/dist/src/mutate/macros.js +9 -2
  38. package/dist/src/mutate/ops.d.ts +5 -4
  39. package/dist/src/mutate/ops.js +62 -15
  40. package/dist/src/mutate/plan-update.d.ts +7 -1
  41. package/dist/src/mutate/plan-update.js +3 -1
  42. package/dist/src/oqx-js/context.js +57 -7
  43. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  44. package/dist/src/reconcile/eval/generator.js +248 -0
  45. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  46. package/dist/src/reconcile/eval/metrics.js +55 -32
  47. package/dist/src/reconcile/flatten.js +7 -5
  48. package/dist/src/reconcile/phase5.d.ts +2 -1
  49. package/dist/src/reconcile/phase5.js +27 -10
  50. package/dist/src/reconcile/phase6a.js +17 -5
  51. package/dist/src/reconcile/phases.d.ts +44 -6
  52. package/dist/src/reconcile/phases.js +0 -0
  53. package/dist/src/reconcile/reconcile.js +16 -9
  54. package/dist/src/reconcile/types.d.ts +4 -1
  55. package/dist/src/reconcile/types.js +11 -2
  56. package/dist/src/search/external.d.ts +10 -0
  57. package/dist/src/search/external.js +18 -1
  58. package/dist/src/search/provider.d.ts +7 -3
  59. package/dist/src/search/provider.js +8 -7
  60. package/dist/src/search/rrf.js +4 -0
  61. package/dist/src/search/tasks.d.ts +28 -0
  62. package/dist/src/search/tasks.js +36 -23
  63. package/dist/src/search/vector.js +30 -14
  64. package/dist/src/sync/attach.d.ts +7 -2
  65. package/dist/src/sync/attach.js +10 -8
  66. package/dist/src/sync/checkpoint.d.ts +18 -1
  67. package/dist/src/sync/checkpoint.js +31 -29
  68. package/dist/src/sync/driver.d.ts +15 -4
  69. package/dist/src/sync/driver.js +18 -43
  70. package/dist/src/sync/external-source.js +7 -1
  71. package/dist/src/sync/freshness.d.ts +73 -15
  72. package/dist/src/sync/freshness.js +128 -97
  73. package/dist/src/sync/fs-util.d.ts +19 -0
  74. package/dist/src/sync/fs-util.js +24 -1
  75. package/dist/src/sync/observe.d.ts +53 -5
  76. package/dist/src/sync/observe.js +162 -21
  77. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  78. package/dist/src/sync/reconciling-ingest.js +87 -13
  79. package/dist/src/sync/recovery.d.ts +5 -1
  80. package/dist/src/sync/recovery.js +8 -8
  81. package/dist/src/sync/workspace.d.ts +11 -3
  82. package/dist/src/sync/workspace.js +39 -30
  83. package/dist/src/sync/writer-lock.js +16 -26
  84. package/package.json +3 -3
@@ -1,5 +1,33 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type EmbedTask, type DocEmbedTask } from "./embeddings.js";
3
+ /** The columns a block's current context (spec/search §2.2) is a function of. */
4
+ export interface BlockContextInput {
5
+ doc_id: string;
6
+ path: string;
7
+ ordinal: number;
8
+ type: string;
9
+ }
10
+ /**
11
+ * Resolves a block's *current* embedding context (spec/search §2.2) — the one
12
+ * rule every reader of the `embeddings` cache keys with: the drain (tasks), the
13
+ * pooled document vector, `vector_search` (§3) and `semantic()` (spec/surface
14
+ * §1.3). Built once per repo (or per doc) and reused across blocks: one scan of
15
+ * the frontmatter titles and one of the sections, then a per-block walk of the
16
+ * doc's sections for the heading chain.
17
+ */
18
+ export interface BlockContextResolver {
19
+ ctx(block: BlockContextInput): string;
20
+ }
21
+ /**
22
+ * Load the title/section facts the context prefix depends on. `scope` narrows
23
+ * the load to one repo (every doc — the drain and vector search) or one doc
24
+ * (a single block's lookup).
25
+ */
26
+ export declare function makeBlockContextResolver(store: Store, scope: {
27
+ repoId: string;
28
+ } | {
29
+ docId: string;
30
+ }): BlockContextResolver;
3
31
  /** Build EmbedTask[] for every embeddable live block in the repo. */
4
32
  export declare function buildEmbedTasks(store: Store, repoId: string): EmbedTask[];
5
33
  /**
@@ -7,31 +7,31 @@ function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
7
7
  return t;
8
8
  return firstHeadingByDoc.get(docId) ?? path;
9
9
  }
10
- /** Build EmbedTask[] for every embeddable live block in the repo. */
11
- export function buildEmbedTasks(store, repoId) {
12
- const blocks = store.db
13
- .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
14
- b.ordinal, b.type, b.text, b.raw_hash
15
- FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
16
- WHERE b.repo_id = ? AND b.deleted_commit IS NULL
17
- ORDER BY d.path, b.ordinal`)
18
- .all(repoId);
19
- // Frontmatter title per doc (scalar), for the embed context prefix. One scan
20
- // of the properties table instead of a per-block metadata parse.
10
+ /**
11
+ * Load the title/section facts the context prefix depends on. `scope` narrows
12
+ * the load to one repo (every doc — the drain and vector search) or one doc
13
+ * (a single block's lookup).
14
+ */
15
+ export function makeBlockContextResolver(store, scope) {
16
+ const byRepo = "repoId" in scope;
17
+ // Frontmatter title per doc (scalar string), for the embed context prefix.
18
+ // One scan of the properties table instead of a per-block metadata parse.
21
19
  const titleByDoc = new Map();
22
20
  for (const r of store.db.prepare(`SELECT p.doc_id AS doc_id, p.val_text AS title FROM properties p
23
- WHERE p.repo_id = ? AND p.source = 'frontmatter' AND p.key = 'title'
24
- AND p.card = 'scalar' AND p.type = 'string' AND p.deleted_commit IS NULL`).all(repoId)) {
21
+ WHERE ${byRepo ? "p.repo_id = ?" : "p.doc_id = ?"} AND p.source = 'frontmatter' AND p.key = 'title'
22
+ AND p.card = 'scalar' AND p.type = 'string' AND p.deleted_commit IS NULL`).all(byRepo ? scope.repoId : scope.docId)) {
25
23
  if (r.title)
26
24
  titleByDoc.set(r.doc_id, r.title);
27
25
  }
28
- // All sections in the repo, with their heading text, for chain lookup.
26
+ // The sections in scope with their heading text, for chain lookup. Ordered by
27
+ // first_ordinal so the first per doc is the title fallback (§2.2).
29
28
  const sections = store.db
30
29
  .prepare(`SELECT s.doc_id AS doc_id, hb.text AS heading_text, s.level AS level,
31
30
  s.first_ordinal AS first_ordinal, s.last_ordinal AS last_ordinal
32
31
  FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
33
- WHERE hb.repo_id = ?`)
34
- .all(repoId);
32
+ WHERE ${byRepo ? "hb.repo_id = ?" : "s.doc_id = ?"}
33
+ ORDER BY s.doc_id, s.first_ordinal, s.rowid`)
34
+ .all(byRepo ? scope.repoId : scope.docId);
35
35
  const sectionsByDoc = new Map();
36
36
  for (const s of sections) {
37
37
  (sectionsByDoc.get(s.doc_id) ?? sectionsByDoc.set(s.doc_id, []).get(s.doc_id)).push(s);
@@ -52,17 +52,30 @@ export function buildEmbedTasks(store, repoId) {
52
52
  .sort((a, b) => a.level - b.level)
53
53
  .map((s) => s.heading_text);
54
54
  };
55
- const tasks = [];
56
- for (const b of blocks) {
57
- if (!shouldEmbed(b.text))
58
- continue;
59
- const ctx = contextPrefix({
55
+ return {
56
+ ctx: (b) => contextPrefix({
60
57
  docTitle: docTitle(titleByDoc, firstHeadingByDoc, b.doc_id, b.path),
61
58
  path: b.path,
62
59
  headingChain: headingChain(b.doc_id, b.ordinal),
63
60
  blockType: b.type,
64
- });
65
- tasks.push({ blockId: b.block_id, contentHashHex: b.raw_hash.toString("hex"), ctx, text: b.text });
61
+ }),
62
+ };
63
+ }
64
+ /** Build EmbedTask[] for every embeddable live block in the repo. */
65
+ export function buildEmbedTasks(store, repoId) {
66
+ const blocks = store.db
67
+ .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
68
+ b.ordinal, b.type, b.text, b.raw_hash
69
+ FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
70
+ WHERE b.repo_id = ? AND b.deleted_commit IS NULL
71
+ ORDER BY d.path, b.ordinal`)
72
+ .all(repoId);
73
+ const contexts = makeBlockContextResolver(store, { repoId });
74
+ const tasks = [];
75
+ for (const b of blocks) {
76
+ if (!shouldEmbed(b.text))
77
+ continue;
78
+ tasks.push({ blockId: b.block_id, contentHashHex: b.raw_hash.toString("hex"), ctx: contexts.ctx(b), text: b.text });
66
79
  }
67
80
  return tasks;
68
81
  }
@@ -1,3 +1,5 @@
1
+ import { ctxHashHex } from "./embeddings.js";
2
+ import { buildEmbedTasks } from "./tasks.js";
1
3
  function cosine(a, b) {
2
4
  let dot = 0, na = 0, nb = 0;
3
5
  const n = Math.min(a.length, b.length);
@@ -12,17 +14,24 @@ function cosine(a, b) {
12
14
  }
13
15
  export function vectorSearch(store, repoId, model, queryVec, opts = {}) {
14
16
  const limit = opts.limit ?? 50;
15
- const rows = store.db
16
- .prepare(`SELECT b.block_id AS blockId, b.doc_id AS docId, d.path AS path, e.vec AS vec
17
- FROM embeddings e
18
- JOIN blocks b ON b.raw_hash = e.content_hash AND b.deleted_commit IS NULL
19
- JOIN docs d ON d.doc_id = b.doc_id
20
- WHERE b.repo_id = ? AND e.model = ?`)
21
- .all(repoId, model);
22
- const scored = rows.map((r) => {
23
- const v = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
24
- return { blockId: r.blockId, docId: r.docId, path: r.path, cosine: cosine(queryVec, v) };
25
- });
17
+ // The candidates are exactly the drain's tasks: every live embeddable block
18
+ // with its current (content_hash, ctx). Reusing buildEmbedTasks keeps the
19
+ // context rule in one place; its cost is one walk of the repo's blocks and
20
+ // sections per query (fine at v1 scale, see the ceiling above).
21
+ const tasks = buildEmbedTasks(store, repoId);
22
+ const docPath = store.db.prepare("SELECT doc_id AS docId, path FROM blocks JOIN docs USING (doc_id) WHERE block_id = ?");
23
+ const vecFor = store.db.prepare("SELECT vec FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?");
24
+ const scored = [];
25
+ for (const t of tasks) {
26
+ const row = vecFor.get(Buffer.from(t.contentHashHex, "hex"), Buffer.from(ctxHashHex(t.ctx), "hex"), model);
27
+ if (!row)
28
+ continue; // stale (§2.3): no vector until the next drain
29
+ const meta = docPath.get(t.blockId);
30
+ if (!meta)
31
+ continue;
32
+ const v = new Float32Array(row.vec.buffer, row.vec.byteOffset, row.vec.byteLength / 4);
33
+ scored.push({ blockId: t.blockId, docId: meta.docId, path: meta.path, cosine: cosine(queryVec, v) });
34
+ }
26
35
  scored.sort((a, b) => b.cosine - a.cosine || (a.blockId < b.blockId ? -1 : 1));
27
36
  return scored.slice(0, limit);
28
37
  }
@@ -39,10 +48,17 @@ export function docVectorSearch(store, repoId, model, queryVec, opts = {}) {
39
48
  JOIN docs d ON d.doc_id = e.doc_id AND d.deleted_commit IS NULL
40
49
  WHERE d.repo_id = ? AND e.model = ?`)
41
50
  .all(repoId, model);
42
- const scored = rows.map((r) => {
51
+ // (doc_id, model) is the primary key, so this is one row per doc already;
52
+ // keep the best-cosine rule for symmetry with vectorSearch.
53
+ const best = new Map();
54
+ for (const r of rows) {
43
55
  const v = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
44
- return { docId: r.docId, path: r.path, cosine: cosine(queryVec, v) };
45
- });
56
+ const c = cosine(queryVec, v);
57
+ const prev = best.get(r.docId);
58
+ if (!prev || c > prev.cosine)
59
+ best.set(r.docId, { docId: r.docId, path: r.path, cosine: c });
60
+ }
61
+ const scored = [...best.values()];
46
62
  scored.sort((a, b) => b.cosine - a.cosine || (a.docId < b.docId ? -1 : 1));
47
63
  return scored.slice(0, limit);
48
64
  }
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import { type SyncFs } from "./fs-util.js";
2
3
  export interface IngestDirResult {
3
4
  repoId: string;
4
5
  fileCount: number;
@@ -8,6 +9,10 @@ export interface IngestDirResult {
8
9
  * Ingest a directory as a repo: create/reuse the repo (registering its fs
9
10
  * source) + ingest all Markdown, reconciling identity and extracting edges.
10
11
  * `files` (repo-relative paths) may be supplied by a caller that already walked
11
- * the tree, to avoid walking twice; omit it to walk here.
12
+ * the tree, to avoid walking twice; omit it to walk here. `opts.ts` pins the
13
+ * commit timestamp (default now); `opts.fs` the filesystem seam (default node).
12
14
  */
13
- export declare function ingestDirectory(store: Store, slug: string, rootPath: string, files?: string[]): IngestDirResult;
15
+ export declare function ingestDirectory(store: Store, slug: string, rootPath: string, files?: string[], opts?: {
16
+ ts?: string;
17
+ fs?: SyncFs;
18
+ }): IngestDirResult;
@@ -1,22 +1,24 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { ensureRepo } from "../core/attach.js";
4
2
  import { ingestFile } from "../core/ingest.js";
5
3
  import { makeReconcilingResolver } from "./reconciling-ingest.js";
6
- import { walkMarkdown } from "./fs-util.js";
4
+ import { nodeFs } from "./fs-util.js";
7
5
  /**
8
6
  * Ingest a directory as a repo: create/reuse the repo (registering its fs
9
7
  * source) + ingest all Markdown, reconciling identity and extracting edges.
10
8
  * `files` (repo-relative paths) may be supplied by a caller that already walked
11
- * the tree, to avoid walking twice; omit it to walk here.
9
+ * the tree, to avoid walking twice; omit it to walk here. `opts.ts` pins the
10
+ * commit timestamp (default now); `opts.fs` the filesystem seam (default node).
12
11
  */
13
- export function ingestDirectory(store, slug, rootPath, files) {
12
+ export function ingestDirectory(store, slug, rootPath, files, opts = {}) {
14
13
  const repoId = ensureRepo(store, slug, rootPath);
15
- const walked = files ?? walkMarkdown(rootPath);
16
- const ts = new Date().toISOString();
14
+ const fs = opts.fs ?? nodeFs;
15
+ const walked = files ?? fs.walk(rootPath);
16
+ const ts = opts.ts ?? new Date().toISOString();
17
17
  let allConverged = true;
18
18
  for (const rel of walked) {
19
- const content = readFileSync(join(rootPath, rel), "utf8");
19
+ const content = fs.read(rootPath, rel);
20
+ if (content === null)
21
+ continue;
20
22
  const res = ingestFile(store, repoId, rel, content, {
21
23
  ts,
22
24
  resolveIds: makeReconcilingResolver(store, repoId, { ts, path: rel }),
@@ -1,4 +1,6 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import { type BatchOutcome } from "./observe.js";
3
+ import { type SyncFs } from "./fs-util.js";
2
4
  export interface FileChange {
3
5
  /** repo-relative canonical path */
4
6
  path: string;
@@ -13,9 +15,24 @@ export interface CheckpointResult {
13
15
  /**
14
16
  * Process a batch of changed paths against a repo as one checkpoint. On-disk
15
17
  * bytes are read directly; a hash equal to the stored file_hash is an engine
16
- * echo (no commit). Otherwise ingest as an observed commit.
18
+ * echo (no commit). Otherwise ingest as an observed commit. A path gone from
19
+ * disk tombstones its live doc (drop FTS, tombstone blocks + doc, pool blocks
20
+ * for resurrection) so it stops being served as a ghost. Every member is
21
+ * reconciled before any commits, so a block cut from one file and pasted into
22
+ * another in the same checkpoint keeps its id (`observeBatch`). spec/sync §4.1
23
+ * `process_checkpoint(repo, root, paths, ts)`.
17
24
  */
18
25
  export declare function processCheckpoint(store: Store, repoId: string, rootPath: string, changes: FileChange[], opts?: {
19
26
  ts?: string;
20
27
  gitHead?: string | null;
28
+ fs?: SyncFs;
29
+ }): CheckpointResult;
30
+ /**
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.
34
+ */
35
+ export declare function finishCheckpoint(store: Store, repoId: string, outcomes: BatchOutcome[], opts: {
36
+ ts: string;
37
+ gitHead: string | null;
21
38
  }): CheckpointResult;
@@ -1,52 +1,54 @@
1
- import { readFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { mintId } from "../core/ids.js";
4
- import { observeOne } from "./observe.js";
2
+ import { observeBatch } from "./observe.js";
5
3
  import { sweepResurrectionPool } from "../core/store/gc.js";
6
- import { tombstoneObservedDeletion } from "./tombstone.js";
4
+ import { nodeFs } from "./fs-util.js";
7
5
  /**
8
6
  * Process a batch of changed paths against a repo as one checkpoint. On-disk
9
7
  * bytes are read directly; a hash equal to the stored file_hash is an engine
10
- * echo (no commit). Otherwise ingest as an observed commit.
8
+ * echo (no commit). Otherwise ingest as an observed commit. A path gone from
9
+ * disk tombstones its live doc (drop FTS, tombstone blocks + doc, pool blocks
10
+ * for resurrection) so it stops being served as a ghost. Every member is
11
+ * reconciled before any commits, so a block cut from one file and pasted into
12
+ * another in the same checkpoint keeps its id (`observeBatch`). spec/sync §4.1
13
+ * `process_checkpoint(repo, root, paths, ts)`.
11
14
  */
12
15
  export function processCheckpoint(store, repoId, rootPath, changes, opts = {}) {
13
16
  const ts = opts.ts ?? new Date().toISOString();
17
+ const fs = opts.fs ?? nodeFs;
18
+ const items = changes.map((change) => ({ path: change.path, content: fs.read(rootPath, change.path) }));
19
+ return finishCheckpoint(store, repoId, observeBatch(store, repoId, items, ts), { ts, gitHead: opts.gitHead ?? null });
20
+ }
21
+ /**
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.
25
+ */
26
+ export function finishCheckpoint(store, repoId, outcomes, opts) {
14
27
  const checkpointId = mintId("cp");
15
28
  const ingested = [];
16
29
  const suppressed = [];
17
30
  const deleted = [];
18
31
  const conflicted = [];
19
32
  const fileEntries = [];
20
- for (const change of changes) {
21
- const abs = join(rootPath, change.path);
22
- if (!existsSync(abs)) {
23
- // File gone from disk: tombstone the live doc (drop FTS, tombstone blocks +
24
- // doc, pool blocks for resurrection) so it stops being served as a ghost.
25
- const existing = store.db
26
- .prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
27
- .get(repoId, change.path);
28
- if (existing) {
29
- tombstoneObservedDeletion(store, repoId, existing.doc_id, ts);
30
- deleted.push(change.path);
31
- }
32
- fileEntries.push([change.path, existing?.file_hash?.toString("hex") ?? null, null]);
33
+ for (const o of outcomes) {
34
+ if (o.kind === "deleted") {
35
+ if (o.docId)
36
+ deleted.push(o.path);
37
+ fileEntries.push([o.path, o.oldHashHex, null]);
33
38
  continue;
34
39
  }
35
- // Reconcile the disk bytes through the shared observe primitive (echo gate,
36
- // identity threading, conflict-marker flagging — the one implementation).
37
- const r = observeOne(store, repoId, change.path, readFileSync(abs, "utf8"), ts);
38
- if (r.echo)
39
- suppressed.push(change.path);
40
- else if (r.conflicted)
41
- conflicted.push(change.path);
40
+ if (o.echo)
41
+ suppressed.push(o.path);
42
+ else if (o.conflicted)
43
+ conflicted.push(o.path);
42
44
  else
43
- ingested.push(change.path);
44
- fileEntries.push([change.path, r.oldHashHex, r.newHashHex]);
45
+ ingested.push(o.path);
46
+ fileEntries.push([o.path, o.oldHashHex, o.newHashHex]);
45
47
  }
46
48
  store.db
47
49
  .prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
48
- .run(checkpointId, repoId, ts, JSON.stringify(fileEntries), opts.gitHead ?? null);
49
- sweepResurrectionPool(store, ts);
50
+ .run(checkpointId, repoId, opts.ts, JSON.stringify(fileEntries), opts.gitHead);
51
+ sweepResurrectionPool(store, opts.ts);
50
52
  return { checkpointId, ingested, suppressed, deleted, conflicted };
51
53
  }
52
54
  //# sourceMappingURL=checkpoint.js.map
@@ -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,27 @@ 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.
86
+ */
87
+ export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string, opts?: {
88
+ fs?: SyncFs;
89
+ }): DiskDrift;
90
+ /**
91
+ * Rebuild `file_stats` from scratch (spec/sync §4.3, since 1.1): delete the repo's rows, then
92
+ * walk the tree and record a row only for a file whose bytes' hash equals its live doc's
93
+ * `file_hash` — the cache is allowed to say "known" only about bytes the store already holds.
94
+ * A file with no live doc, or whose bytes differ from what was ingested, gets no row, so the
95
+ * next sweep still treats it as a candidate and drift still reports it (a cache rebuild must
96
+ * never change what the next sweep does). Returns the number of files walked, recorded or not.
39
97
  */
40
- export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string): DiskDrift;
41
- /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
42
- export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string): number;
98
+ export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string, opts?: {
99
+ fs?: SyncFs;
100
+ }): number;