@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.
- 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/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/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 +9 -18
- package/dist/src/graph/history.d.ts +6 -0
- package/dist/src/graph/history.js +13 -2
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +8 -5
- 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 +12 -7
- package/dist/src/mutate/apply.d.ts +7 -0
- package/dist/src/mutate/apply.js +17 -7
- package/dist/src/mutate/docs.d.ts +2 -0
- package/dist/src/mutate/docs.js +10 -6
- package/dist/src/mutate/known-ids.js +9 -1
- package/dist/src/mutate/macros.js +9 -2
- package/dist/src/mutate/ops.d.ts +2 -1
- package/dist/src/mutate/ops.js +24 -2
- 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 +43 -6
- 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 +3 -0
- package/dist/src/search/vector.js +21 -6
- 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 +65 -14
- package/dist/src/sync/freshness.js +107 -96
- 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 +82 -10
- 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,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,20 @@ 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
|
-
* (≤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
|
|
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
|
|
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 {
|
|
6
|
-
/**
|
|
7
|
-
export function
|
|
8
|
-
|
|
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
|
|
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
|
|
20
|
-
* counters. Idempotent; safe alongside a live watcher
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
67
|
-
|
|
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:
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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.
|
|
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
|
-
|
|
93
|
-
const cache =
|
|
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
|
|
109
|
-
const
|
|
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
|
|
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 (!
|
|
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 =
|
|
154
|
+
const paths = fs.walk(rootPath);
|
|
145
155
|
for (const path of paths) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
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
|
package/dist/src/sync/fs-util.js
CHANGED
|
@@ -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,
|
|
30
|
-
* from the stored revision, ingest it as an observed commit
|
|
31
|
-
* identity against the prior revision).
|
|
32
|
-
*
|
|
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;
|