@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.
- package/README.md +172 -0
- package/dist/src/cli/eval-matcher.d.ts +6 -1
- package/dist/src/cli/eval-matcher.js +14 -2
- package/dist/src/core/clock.d.ts +9 -0
- package/dist/src/core/clock.js +46 -0
- package/dist/src/core/hash.d.ts +25 -1
- package/dist/src/core/hash.js +110 -10
- package/dist/src/core/ids.d.ts +12 -0
- package/dist/src/core/ids.js +43 -1
- package/dist/src/core/ingest.d.ts +22 -2
- package/dist/src/core/ingest.js +92 -18
- package/dist/src/core/parse/parse.d.ts +1 -1
- package/dist/src/core/parse/parse.js +31 -9
- package/dist/src/core/store/nodes.d.ts +13 -0
- package/dist/src/core/store/nodes.js +24 -0
- package/dist/src/core/store/writers.d.ts +6 -0
- package/dist/src/core/store/writers.js +1 -0
- package/dist/src/core/utf8.d.ts +29 -0
- package/dist/src/core/utf8.js +96 -0
- package/dist/src/format/adapter.d.ts +7 -1
- package/dist/src/format/markdown.js +36 -41
- package/dist/src/graph/extract.d.ts +41 -1
- package/dist/src/graph/extract.js +59 -1
- package/dist/src/graph/history.d.ts +39 -1
- package/dist/src/graph/history.js +154 -20
- package/dist/src/index.d.ts +10 -6
- package/dist/src/index.js +11 -6
- package/dist/src/mcp/graph.js +8 -4
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +17 -6
- package/dist/src/mcp/server.js +15 -10
- package/dist/src/mutate/apply.d.ts +9 -0
- package/dist/src/mutate/apply.js +24 -11
- package/dist/src/mutate/docs.d.ts +2 -0
- package/dist/src/mutate/docs.js +10 -6
- package/dist/src/mutate/known-ids.js +11 -3
- package/dist/src/mutate/macros.js +9 -2
- package/dist/src/mutate/ops.d.ts +5 -4
- package/dist/src/mutate/ops.js +62 -15
- package/dist/src/mutate/plan-update.d.ts +7 -1
- package/dist/src/mutate/plan-update.js +3 -1
- package/dist/src/oqx-js/context.js +57 -7
- package/dist/src/reconcile/eval/generator.d.ts +24 -1
- package/dist/src/reconcile/eval/generator.js +248 -0
- package/dist/src/reconcile/eval/metrics.d.ts +33 -9
- package/dist/src/reconcile/eval/metrics.js +55 -32
- package/dist/src/reconcile/flatten.js +7 -5
- package/dist/src/reconcile/phase5.d.ts +2 -1
- package/dist/src/reconcile/phase5.js +27 -10
- package/dist/src/reconcile/phase6a.js +17 -5
- package/dist/src/reconcile/phases.d.ts +44 -6
- package/dist/src/reconcile/phases.js +0 -0
- package/dist/src/reconcile/reconcile.js +16 -9
- package/dist/src/reconcile/types.d.ts +4 -1
- package/dist/src/reconcile/types.js +11 -2
- package/dist/src/search/external.d.ts +10 -0
- package/dist/src/search/external.js +18 -1
- package/dist/src/search/provider.d.ts +7 -3
- package/dist/src/search/provider.js +8 -7
- package/dist/src/search/rrf.js +4 -0
- package/dist/src/search/tasks.d.ts +28 -0
- package/dist/src/search/tasks.js +36 -23
- package/dist/src/search/vector.js +30 -14
- package/dist/src/sync/attach.d.ts +7 -2
- package/dist/src/sync/attach.js +10 -8
- package/dist/src/sync/checkpoint.d.ts +18 -1
- package/dist/src/sync/checkpoint.js +31 -29
- package/dist/src/sync/driver.d.ts +15 -4
- package/dist/src/sync/driver.js +18 -43
- package/dist/src/sync/external-source.js +7 -1
- package/dist/src/sync/freshness.d.ts +73 -15
- package/dist/src/sync/freshness.js +128 -97
- package/dist/src/sync/fs-util.d.ts +19 -0
- package/dist/src/sync/fs-util.js +24 -1
- package/dist/src/sync/observe.d.ts +53 -5
- package/dist/src/sync/observe.js +162 -21
- package/dist/src/sync/reconciling-ingest.d.ts +42 -2
- package/dist/src/sync/reconciling-ingest.js +87 -13
- package/dist/src/sync/recovery.d.ts +5 -1
- package/dist/src/sync/recovery.js +8 -8
- package/dist/src/sync/workspace.d.ts +11 -3
- package/dist/src/sync/workspace.js +39 -30
- package/dist/src/sync/writer-lock.js +16 -26
- 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
|
/**
|
package/dist/src/search/tasks.js
CHANGED
|
@@ -7,31 +7,31 @@ function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
|
|
|
7
7
|
return t;
|
|
8
8
|
return firstHeadingByDoc.get(docId) ?? path;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const scored =
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[]
|
|
15
|
+
export declare function ingestDirectory(store: Store, slug: string, rootPath: string, files?: string[], opts?: {
|
|
16
|
+
ts?: string;
|
|
17
|
+
fs?: SyncFs;
|
|
18
|
+
}): IngestDirResult;
|
package/dist/src/sync/attach.js
CHANGED
|
@@ -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 {
|
|
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
|
|
16
|
-
const
|
|
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 =
|
|
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 {
|
|
2
|
+
import { observeBatch } from "./observe.js";
|
|
5
3
|
import { sweepResurrectionPool } from "../core/store/gc.js";
|
|
6
|
-
import {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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(
|
|
44
|
-
fileEntries.push([
|
|
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
|
|
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
|
|
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
|
|
34
|
+
export declare function attachSource(store: Store, slug: string, rootPath: string | null, source: SyncSource, opts?: {
|
|
35
|
+
ts?: string;
|
|
36
|
+
}): Promise<AttachResult>;
|
package/dist/src/sync/driver.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
12
|
-
* counters. Idempotent; safe alongside a live watcher
|
|
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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
98
|
+
export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string, opts?: {
|
|
99
|
+
fs?: SyncFs;
|
|
100
|
+
}): number;
|