@omgbase/core 0.11.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +17 -0
- package/dist/src/core/ingest.d.ts +39 -3
- package/dist/src/core/ingest.js +233 -202
- package/dist/src/core/store/schema.d.ts +3 -2
- package/dist/src/core/store/schema.js +28 -3
- package/dist/src/core/store/size.d.ts +76 -0
- package/dist/src/core/store/size.js +223 -0
- package/dist/src/core/store/store.d.ts +9 -0
- package/dist/src/core/store/store.js +11 -0
- package/dist/src/graph/history.js +15 -12
- package/dist/src/index.d.ts +12 -5
- package/dist/src/index.js +18 -7
- package/dist/src/mcp/arguments.d.ts +9 -2
- package/dist/src/mcp/arguments.js +15 -2
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/errors.js +3 -0
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +27 -6
- package/dist/src/mcp/server.d.ts +56 -1
- package/dist/src/mcp/server.js +365 -203
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- package/dist/src/mutate/commit.d.ts +150 -0
- package/dist/src/mutate/commit.js +349 -0
- package/dist/src/mutate/doc-store.d.ts +3 -3
- package/dist/src/mutate/doc-store.js +21 -6
- package/dist/src/mutate/docs.d.ts +42 -87
- package/dist/src/mutate/docs.js +50 -204
- package/dist/src/mutate/effects.d.ts +363 -0
- package/dist/src/mutate/effects.js +1161 -0
- package/dist/src/mutate/materialize.d.ts +49 -0
- package/dist/src/mutate/materialize.js +174 -0
- package/dist/src/mutate/opset.d.ts +27 -2
- package/dist/src/mutate/opset.js +12 -5
- package/dist/src/mutate/plan-update.d.ts +1 -46
- package/dist/src/mutate/plan-update.js +19 -67
- package/dist/src/mutate/plan.d.ts +210 -0
- package/dist/src/mutate/plan.js +512 -0
- package/dist/src/mutate/select.d.ts +89 -0
- package/dist/src/mutate/select.js +879 -0
- package/dist/src/mutate/sugar.d.ts +135 -0
- package/dist/src/mutate/sugar.js +167 -0
- package/dist/src/mutate/tree.d.ts +4 -2
- package/dist/src/oqx-js/planner.js +4 -0
- package/dist/src/oqx-js/run.d.ts +28 -1
- package/dist/src/oqx-js/run.js +85 -22
- package/dist/src/spec-versions.d.ts +6 -6
- package/dist/src/spec-versions.js +5 -5
- package/dist/src/surface-paths.d.ts +17 -26
- package/dist/src/surface-paths.js +27 -22
- package/dist/src/sync/checkpoint.d.ts +5 -2
- package/dist/src/sync/checkpoint.js +10 -6
- package/dist/src/sync/freshness.d.ts +10 -7
- package/dist/src/sync/freshness.js +15 -17
- package/dist/src/sync/observe.d.ts +4 -1
- package/dist/src/sync/observe.js +56 -5
- package/dist/src/sync/recovery.d.ts +4 -0
- package/dist/src/sync/recovery.js +4 -1
- package/dist/src/sync/stat-cache.d.ts +7 -0
- package/dist/src/sync/stat-cache.js +22 -0
- package/dist/src/sync/tombstone.js +23 -19
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +4 -1
- package/dist/src/sync/writer-lock.d.ts +15 -0
- package/dist/src/sync/writer-lock.js +104 -17
- package/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- package/package.json +2 -2
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
import type { ApplyResult } from "./mutate/apply.js";
|
|
2
|
-
import type { DocOpResult, DocMoveResult } from "./mutate/docs.js";
|
|
3
2
|
import type { RetargetHit } from "./mutate/macros.js";
|
|
4
|
-
import type {
|
|
5
|
-
import type { DocsUpdateResult } from "./mutate/plan-update.js";
|
|
3
|
+
import type { Plan, Receipt } from "./mutate/plan.js";
|
|
6
4
|
import type { InboundLink } from "./graph/inbound-links.js";
|
|
7
5
|
import type { ObserveResult, ObserveDeleteResult } from "./sync/observe.js";
|
|
8
6
|
import type { SweepResult } from "./sync/freshness.js";
|
|
9
7
|
import type { TextSearchResult } from "./search/text.js";
|
|
10
8
|
import type { ResolveHit } from "./search/resolve.js";
|
|
11
|
-
/** `spec/mutate` §4's result: `revisions[].path` and the dry-run `diffs` keys. */
|
|
9
|
+
/** `spec/mutate` §4's kernel result (the library's `apply`; no tool returns it since 4.0): `revisions[].path` and the dry-run `diffs` keys. */
|
|
12
10
|
export declare function surfaceApplyResult<T extends ApplyResult>(res: T): T;
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* dry-run `diffs` re-keyed; every other key (`committed`, `deleted`, …) as is. */
|
|
17
|
-
export type SurfaceDocOpResult<T extends DocOpResult> = Omit<T, "docId" | "path"> & {
|
|
18
|
-
id: string;
|
|
19
|
-
path: string;
|
|
20
|
-
};
|
|
21
|
-
/** `docs_create` / `docs_delete` / `docs_set_meta`. */
|
|
22
|
-
export declare function surfaceDocOpResult<T extends DocOpResult>(res: T): SurfaceDocOpResult<T>;
|
|
23
|
-
/** An inbound link on the wire (`docs_move.dangling[]`): an edge row under the
|
|
24
|
-
* `edges` target's names (spec/surface §1.2) — `doc_id`, `path` (rooted),
|
|
25
|
-
* `block_id`, `target` (as authored), `anchor`, `field?`. */
|
|
11
|
+
/** An inbound link on the wire (`docs_move.dangling[]`, `omg mv`): a link row
|
|
12
|
+
* under the `links` target's names (spec/surface §1.2) — `doc_id`, `path`
|
|
13
|
+
* (rooted), `block_id`, `target` (as authored), `anchor`, `field?`. */
|
|
26
14
|
export interface SurfaceInboundLink {
|
|
27
15
|
doc_id: string;
|
|
28
16
|
path: string;
|
|
@@ -32,11 +20,6 @@ export interface SurfaceInboundLink {
|
|
|
32
20
|
field?: string;
|
|
33
21
|
}
|
|
34
22
|
export declare function surfaceInboundLink(l: InboundLink): SurfaceInboundLink;
|
|
35
|
-
/** `docs_move`: the document-op fields plus each dangling link (its `target`
|
|
36
|
-
* stays as authored — that is what the link says). */
|
|
37
|
-
export declare function surfaceDocMoveResult(res: DocMoveResult): Omit<SurfaceDocOpResult<DocMoveResult>, "dangling"> & {
|
|
38
|
-
dangling: SurfaceInboundLink[];
|
|
39
|
-
};
|
|
40
23
|
/** An observe outcome on the wire (spec/surface §4, 3.0): `spec/store` §5's
|
|
41
24
|
* `{ docId, path, … }` (unchanged there) renamed to the document's row
|
|
42
25
|
* attributes `id`/`path`, the path rooted. */
|
|
@@ -58,9 +41,17 @@ export declare function surfaceTextSearch(res: TextSearchResult): TextSearchResu
|
|
|
58
41
|
export declare function surfaceResolveHits(hits: ResolveHit[]): ResolveHit[];
|
|
59
42
|
/** `links_retarget` / `links_repair` hits (`path` is the block's document). */
|
|
60
43
|
export declare function surfaceRetargetHits(hits: RetargetHit[]): RetargetHit[];
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
44
|
+
/**
|
|
45
|
+
* A Plan on the wire (spec/surface §4, 3.1): the executable core — `effects`,
|
|
46
|
+
* `opset`, `preconditions`, `new_ids`, `plan_id` — is returned VERBATIM in the
|
|
47
|
+
* storage form, because `plan_id` digests exactly those bytes and the client
|
|
48
|
+
* hands the object back to `execute` (spec/mutate §11.5; the one result the
|
|
49
|
+
* path rule exempts, like `dangling[].target`). Only the un-digested `preview`
|
|
50
|
+
* is rooted: its `revisions[].path`, `diffs` keys and `retarget[].hits[].path`
|
|
51
|
+
* (3.0; `identity` is keyed by document id and carries no path).
|
|
52
|
+
*/
|
|
53
|
+
export declare function surfacePlan(plan: Plan): Plan;
|
|
54
|
+
/** A Receipt on the wire: `revisions[].path` rooted (the same shape as an apply revision). */
|
|
55
|
+
export declare function surfaceReceipt(receipt: Receipt): Receipt;
|
|
65
56
|
/** `omg sync`'s one-shot sweep: the ingested / suppressed / deleted / conflicted paths. */
|
|
66
57
|
export declare function surfaceSweepResult(res: SweepResult): SweepResult;
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
// Row names (spec/surface §1.2, 3.0): a result that renders a document, block
|
|
12
12
|
// or edge row spells it the way the query binding does — `id`, `path`,
|
|
13
13
|
// `hash`, `raw`, `updated_at`, plain `doc_id`/`block_id` foreign keys. The
|
|
14
|
-
// shapes other specs own under their own names — `spec/
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// shapes other specs own under their own names — `spec/store`'s observe
|
|
15
|
+
// outcome, a move's dangling inbound links — are re-shaped HERE too
|
|
16
|
+
// (`SurfaceObserveResult`, `surfaceInboundLink`, …).
|
|
17
17
|
//
|
|
18
18
|
// Both the MCP server and the `omg` verbs call these, so the two clients
|
|
19
19
|
// cannot drift. Each function returns a new value; the input is not mutated.
|
|
20
20
|
import { referencePath, referenceKeyed } from "./core/paths.js";
|
|
21
|
-
/** `spec/mutate` §4's result: `revisions[].path` and the dry-run `diffs` keys. */
|
|
21
|
+
/** `spec/mutate` §4's kernel result (the library's `apply`; no tool returns it since 4.0): `revisions[].path` and the dry-run `diffs` keys. */
|
|
22
22
|
export function surfaceApplyResult(res) {
|
|
23
23
|
return {
|
|
24
24
|
...res,
|
|
@@ -26,20 +26,10 @@ export function surfaceApplyResult(res) {
|
|
|
26
26
|
...(res.diffs ? { diffs: referenceKeyed(res.diffs) } : {}),
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
/** `docs_create` / `docs_delete` / `docs_set_meta`. */
|
|
30
|
-
export function surfaceDocOpResult(res) {
|
|
31
|
-
const { docId, path, ...rest } = res;
|
|
32
|
-
return { id: docId, path: referencePath(path), ...rest, ...(res.diffs ? { diffs: referenceKeyed(res.diffs) } : {}) };
|
|
33
|
-
}
|
|
34
29
|
export function surfaceInboundLink(l) {
|
|
35
30
|
const { path, ...rest } = l;
|
|
36
31
|
return { doc_id: rest.doc_id, path: referencePath(path), block_id: rest.block_id, target: rest.target, anchor: rest.anchor, ...(rest.field !== undefined ? { field: rest.field } : {}) };
|
|
37
32
|
}
|
|
38
|
-
/** `docs_move`: the document-op fields plus each dangling link (its `target`
|
|
39
|
-
* stays as authored — that is what the link says). */
|
|
40
|
-
export function surfaceDocMoveResult(res) {
|
|
41
|
-
return { ...surfaceDocOpResult(res), dangling: res.dangling.map(surfaceInboundLink) };
|
|
42
|
-
}
|
|
43
33
|
/** `observe` / `observe_many` outcomes. */
|
|
44
34
|
export function surfaceObserveResult(res) {
|
|
45
35
|
const { docId, path, ...rest } = res;
|
|
@@ -66,17 +56,31 @@ export function surfaceResolveHits(hits) {
|
|
|
66
56
|
export function surfaceRetargetHits(hits) {
|
|
67
57
|
return hits.map((h) => ({ ...h, path: referencePath(h.path) }));
|
|
68
58
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
59
|
+
/**
|
|
60
|
+
* A Plan on the wire (spec/surface §4, 3.1): the executable core — `effects`,
|
|
61
|
+
* `opset`, `preconditions`, `new_ids`, `plan_id` — is returned VERBATIM in the
|
|
62
|
+
* storage form, because `plan_id` digests exactly those bytes and the client
|
|
63
|
+
* hands the object back to `execute` (spec/mutate §11.5; the one result the
|
|
64
|
+
* path rule exempts, like `dangling[].target`). Only the un-digested `preview`
|
|
65
|
+
* is rooted: its `revisions[].path`, `diffs` keys and `retarget[].hits[].path`
|
|
66
|
+
* (3.0; `identity` is keyed by document id and carries no path).
|
|
67
|
+
*/
|
|
68
|
+
export function surfacePlan(plan) {
|
|
69
|
+
if (!plan.preview)
|
|
70
|
+
return plan;
|
|
71
71
|
return {
|
|
72
|
-
...
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
...plan,
|
|
73
|
+
preview: {
|
|
74
|
+
...plan.preview,
|
|
75
|
+
revisions: plan.preview.revisions.map((r) => ({ ...r, path: referencePath(r.path) })),
|
|
76
|
+
diffs: referenceKeyed(plan.preview.diffs),
|
|
77
|
+
...(plan.preview.retarget !== undefined ? { retarget: plan.preview.retarget.map((r) => ({ ...r, hits: surfaceRetargetHits(r.hits) })) } : {}),
|
|
78
|
+
},
|
|
75
79
|
};
|
|
76
80
|
}
|
|
77
|
-
/** `
|
|
78
|
-
export function
|
|
79
|
-
return {
|
|
81
|
+
/** A Receipt on the wire: `revisions[].path` rooted (the same shape as an apply revision). */
|
|
82
|
+
export function surfaceReceipt(receipt) {
|
|
83
|
+
return { ...receipt, revisions: receipt.revisions.map((r) => ({ ...r, path: referencePath(r.path) })) };
|
|
80
84
|
}
|
|
81
85
|
/** `omg sync`'s one-shot sweep: the ingested / suppressed / deleted / conflicted paths. */
|
|
82
86
|
export function surfaceSweepResult(res) {
|
|
@@ -86,6 +90,7 @@ export function surfaceSweepResult(res) {
|
|
|
86
90
|
suppressed: res.suppressed.map(referencePath),
|
|
87
91
|
deleted: res.deleted.map(referencePath),
|
|
88
92
|
conflicted: res.conflicted.map(referencePath),
|
|
93
|
+
materialized: res.materialized.map(referencePath),
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
//# sourceMappingURL=surface-paths.js.map
|
|
@@ -29,8 +29,11 @@ export declare function processCheckpoint(store: Store, repoId: string, rootPath
|
|
|
29
29
|
}): CheckpointResult;
|
|
30
30
|
/**
|
|
31
31
|
* Turn a batch's outcomes into the checkpoint row + result, then sweep the
|
|
32
|
-
* resurrection pool
|
|
33
|
-
*
|
|
32
|
+
* resurrection pool — the row and the sweep in ONE transaction (spec/sync §4.1),
|
|
33
|
+
* so a checkpoint is never recorded without its sweep or swept without its row
|
|
34
|
+
* (the member commits before it are one transaction each, as before). Shared
|
|
35
|
+
* with the external-source driver (driver.ts), which obtains its bytes
|
|
36
|
+
* asynchronously but records its checkpoint the same way.
|
|
34
37
|
*/
|
|
35
38
|
export declare function finishCheckpoint(store: Store, repoId: string, outcomes: BatchOutcome[], opts: {
|
|
36
39
|
ts: string;
|
|
@@ -20,8 +20,11 @@ export function processCheckpoint(store, repoId, rootPath, changes, opts = {}) {
|
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* Turn a batch's outcomes into the checkpoint row + result, then sweep the
|
|
23
|
-
* resurrection pool
|
|
24
|
-
*
|
|
23
|
+
* resurrection pool — the row and the sweep in ONE transaction (spec/sync §4.1),
|
|
24
|
+
* so a checkpoint is never recorded without its sweep or swept without its row
|
|
25
|
+
* (the member commits before it are one transaction each, as before). Shared
|
|
26
|
+
* with the external-source driver (driver.ts), which obtains its bytes
|
|
27
|
+
* asynchronously but records its checkpoint the same way.
|
|
25
28
|
*/
|
|
26
29
|
export function finishCheckpoint(store, repoId, outcomes, opts) {
|
|
27
30
|
const checkpointId = mintId("cp");
|
|
@@ -45,10 +48,11 @@ export function finishCheckpoint(store, repoId, outcomes, opts) {
|
|
|
45
48
|
ingested.push(o.path);
|
|
46
49
|
fileEntries.push([o.path, o.oldHashHex, o.newHashHex]);
|
|
47
50
|
}
|
|
48
|
-
store.db
|
|
49
|
-
.prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
store.write((db) => {
|
|
52
|
+
db.prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
|
|
53
|
+
.run(checkpointId, repoId, opts.ts, JSON.stringify(fileEntries), opts.gitHead);
|
|
54
|
+
sweepResurrectionPool(store, opts.ts);
|
|
55
|
+
});
|
|
52
56
|
return { checkpointId, ingested, suppressed, deleted, conflicted };
|
|
53
57
|
}
|
|
54
58
|
//# sourceMappingURL=checkpoint.js.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Store } from "../core/store/store.js";
|
|
2
2
|
import { type CheckpointResult } from "./checkpoint.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type SyncFs } from "./fs-util.js";
|
|
4
|
+
import { type DocStore } from "../mutate/doc-store.js";
|
|
5
|
+
export { recordStat, recordFileStat } from "./stat-cache.js";
|
|
4
6
|
/** One `file_stats` row (spec/sync §4.3 "the repo's cache rows"). */
|
|
5
7
|
export interface StatCacheRow {
|
|
6
8
|
path: string;
|
|
@@ -44,22 +46,23 @@ export interface SweepResult extends CheckpointResult {
|
|
|
44
46
|
scanned: number;
|
|
45
47
|
candidates: number;
|
|
46
48
|
changed: boolean;
|
|
49
|
+
/** step 0 (spec/sync §4.3, 2.2): the paths whose pending materialization rows were replayed before the walk. */
|
|
50
|
+
materialized: string[];
|
|
47
51
|
}
|
|
48
|
-
/** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
|
|
49
|
-
* file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
|
|
50
|
-
export declare function recordStat(store: Store, repoId: string, path: string, st: FileStat | null, hash: Buffer): void;
|
|
51
|
-
/** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
|
|
52
|
-
export declare function recordFileStat(store: Store, repoId: string, path: string, abs: string, hash: Buffer): void;
|
|
53
52
|
/**
|
|
54
53
|
* Run the freshness sweep for a repo (spec/sync §4.3). Returns the checkpoint
|
|
55
54
|
* result plus scan counters. Idempotent; safe alongside a live watcher
|
|
56
55
|
* (hash-based echo suppression makes a double ingest a no-op). `opts.ts` pins
|
|
57
|
-
* the batch timestamp (default now); `opts.fs` the filesystem seam
|
|
56
|
+
* the batch timestamp (default now); `opts.fs` the filesystem seam;
|
|
57
|
+
* `opts.docStore` the write target step 0 materializes through (default: the
|
|
58
|
+
* filesystem store at `rootPath` — the fixtures' in-memory filesystem never
|
|
59
|
+
* holds a pending row, so the default is never exercised there).
|
|
58
60
|
*/
|
|
59
61
|
export declare function freshnessSweep(store: Store, repoId: string, rootPath: string, opts?: {
|
|
60
62
|
ts?: string;
|
|
61
63
|
gitHead?: string | null;
|
|
62
64
|
fs?: SyncFs;
|
|
65
|
+
docStore?: DocStore;
|
|
63
66
|
}): SweepResult;
|
|
64
67
|
/**
|
|
65
68
|
* Counts of ways the DB disagrees with the current filesystem. All fields are
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { sha256 } from "../core/hash.js";
|
|
2
2
|
import { processCheckpoint } from "./checkpoint.js";
|
|
3
3
|
import { nodeFs } from "./fs-util.js";
|
|
4
|
+
import { recordStat } from "./stat-cache.js";
|
|
5
|
+
import { materializePending } from "../mutate/materialize.js";
|
|
6
|
+
import { FsDocStore } from "../mutate/doc-store.js";
|
|
7
|
+
export { recordStat, recordFileStat } from "./stat-cache.js";
|
|
4
8
|
/** Step 1 of §4.3 alone: the stat-mismatched or new paths, without hashing anything. */
|
|
5
9
|
export function sweepCandidates(cache, snapshot) {
|
|
6
10
|
const out = [];
|
|
@@ -56,30 +60,23 @@ export function snapshotOf(fs, rootPath) {
|
|
|
56
60
|
}
|
|
57
61
|
return out;
|
|
58
62
|
}
|
|
59
|
-
/** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
|
|
60
|
-
* file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
|
|
61
|
-
export function recordStat(store, repoId, path, st, hash) {
|
|
62
|
-
if (!st) {
|
|
63
|
-
store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
store.db
|
|
67
|
-
.prepare(`INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?, ?, ?, ?, ?)
|
|
68
|
-
ON CONFLICT(repo_id, path) DO UPDATE SET mtime_ns = excluded.mtime_ns, size = excluded.size, hash = excluded.hash`)
|
|
69
|
-
.run(repoId, path, st.mtimeNs, st.size, hash);
|
|
70
|
-
}
|
|
71
|
-
/** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
|
|
72
|
-
export function recordFileStat(store, repoId, path, abs, hash) {
|
|
73
|
-
recordStat(store, repoId, path, nodeFs.stat(abs, ""), hash);
|
|
74
|
-
}
|
|
75
63
|
/**
|
|
76
64
|
* Run the freshness sweep for a repo (spec/sync §4.3). Returns the checkpoint
|
|
77
65
|
* result plus scan counters. Idempotent; safe alongside a live watcher
|
|
78
66
|
* (hash-based echo suppression makes a double ingest a no-op). `opts.ts` pins
|
|
79
|
-
* the batch timestamp (default now); `opts.fs` the filesystem seam
|
|
67
|
+
* the batch timestamp (default now); `opts.fs` the filesystem seam;
|
|
68
|
+
* `opts.docStore` the write target step 0 materializes through (default: the
|
|
69
|
+
* filesystem store at `rootPath` — the fixtures' in-memory filesystem never
|
|
70
|
+
* holds a pending row, so the default is never exercised there).
|
|
80
71
|
*/
|
|
81
72
|
export function freshnessSweep(store, repoId, rootPath, opts = {}) {
|
|
82
73
|
const fs = opts.fs ?? nodeFs;
|
|
74
|
+
// Step 0 (2.2; spec/store §5.7): replay the api path's recorded file intent
|
|
75
|
+
// before looking at the disk, so a file a crashed or refused write left
|
|
76
|
+
// behind the database is brought forward instead of being read back as a
|
|
77
|
+
// human edit. The production recovery point (priming sweep at `omg mcp`
|
|
78
|
+
// start, the pre-verb sweep of every CLI write).
|
|
79
|
+
const materialized = materializePending(store, repoId, opts.docStore ?? new FsDocStore(rootPath));
|
|
83
80
|
const cache = loadStatCache(store, repoId);
|
|
84
81
|
const snapshot = snapshotOf(fs, rootPath);
|
|
85
82
|
const plan = sweepPlan(cache, snapshot);
|
|
@@ -100,6 +97,7 @@ export function freshnessSweep(store, repoId, rootPath, opts = {}) {
|
|
|
100
97
|
scanned: snapshot.length,
|
|
101
98
|
candidates: plan.candidates.length,
|
|
102
99
|
changed: result.ingested.length > 0 || result.deleted.length > 0 || result.conflicted.length > 0,
|
|
100
|
+
materialized,
|
|
103
101
|
};
|
|
104
102
|
}
|
|
105
103
|
/**
|
|
@@ -46,7 +46,10 @@ export type BatchOutcome = ({
|
|
|
46
46
|
* The batch reconcile primitive — one checkpoint's worth of members, in order.
|
|
47
47
|
* Two passes with the cross-document phase between them (reconciliation-spec §8):
|
|
48
48
|
*
|
|
49
|
-
* 1. every member is
|
|
49
|
+
* 1. every member is gated against a pending materialization row at its path
|
|
50
|
+
* (spec/store §5.7: the bytes the api path owes confirm the row, an absent
|
|
51
|
+
* file is not a deletion, anything else lets the file win), echo-gated and,
|
|
52
|
+
* when it differs from the stored revision,
|
|
50
53
|
* parsed + reconciled against its own prior tree WITHOUT committing
|
|
51
54
|
* (`prepareReconcile`; members gone from the source contribute their whole
|
|
52
55
|
* live tree as deleted). All members see one resurrection-pool snapshot with
|
package/dist/src/sync/observe.js
CHANGED
|
@@ -6,11 +6,15 @@ import { tombstoneObservedDeletion } from "./tombstone.js";
|
|
|
6
6
|
import { sweepResurrectionPool } from "../core/store/gc.js";
|
|
7
7
|
import { crossDocMatch, applyCrossDocMatches } from "../reconcile/crossdoc.js";
|
|
8
8
|
import { DEFAULT_CONFIG } from "../reconcile/types.js";
|
|
9
|
+
import { deletePending, readPending } from "../mutate/materialize.js";
|
|
9
10
|
/**
|
|
10
11
|
* The batch reconcile primitive — one checkpoint's worth of members, in order.
|
|
11
12
|
* Two passes with the cross-document phase between them (reconciliation-spec §8):
|
|
12
13
|
*
|
|
13
|
-
* 1. every member is
|
|
14
|
+
* 1. every member is gated against a pending materialization row at its path
|
|
15
|
+
* (spec/store §5.7: the bytes the api path owes confirm the row, an absent
|
|
16
|
+
* file is not a deletion, anything else lets the file win), echo-gated and,
|
|
17
|
+
* when it differs from the stored revision,
|
|
14
18
|
* parsed + reconciled against its own prior tree WITHOUT committing
|
|
15
19
|
* (`prepareReconcile`; members gone from the source contribute their whole
|
|
16
20
|
* live tree as deleted). All members see one resurrection-pool snapshot with
|
|
@@ -42,6 +46,31 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
|
|
|
42
46
|
for (const it of items) {
|
|
43
47
|
const existing = liveDoc.get(repoId, it.path);
|
|
44
48
|
const oldHashHex = existing?.file_hash?.toString("hex") ?? null;
|
|
49
|
+
// The materialization gate (spec/store §5.7): an api commit still owes this
|
|
50
|
+
// path a file. The observation decides what the row meant for the disk —
|
|
51
|
+
// the bytes it named confirm it (echo; a `remove` row stays until the file
|
|
52
|
+
// is gone), an absent file is NOT a deletion (the materializer still owes
|
|
53
|
+
// it; `remove` rows are confirmed by the absence), anything else is a later
|
|
54
|
+
// human edit and the file wins: the normal reconciling ingest below, the
|
|
55
|
+
// row dropped with it.
|
|
56
|
+
const owed = readPending(db, repoId, it.path);
|
|
57
|
+
if (owed && it.content === null) {
|
|
58
|
+
if (owed.kind === "remove")
|
|
59
|
+
deletePending(db, repoId, it.path);
|
|
60
|
+
else {
|
|
61
|
+
pending.push({ kind: "echo", outcome: { kind: "deleted", path: it.path, docId: null, oldHashHex } });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (owed && sha256(it.content).equals(owed.renderedHash)) {
|
|
66
|
+
if (owed.kind !== "remove")
|
|
67
|
+
deletePending(db, repoId, it.path);
|
|
68
|
+
pending.push({
|
|
69
|
+
kind: "echo",
|
|
70
|
+
outcome: { kind: "observed", docId: owed.docId, path: it.path, rev: null, commitId: null, converged: true, echo: true, conflicted: false, dispositions: [], oldHashHex, newHashHex: sha256(it.content).toString("hex") },
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
45
74
|
if (it.content === null) {
|
|
46
75
|
const oldBlocks = existing ? loadOldMatchBlocks(db, existing.doc_id) : [];
|
|
47
76
|
const result = existing
|
|
@@ -56,6 +85,10 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
|
|
|
56
85
|
// loop-breaker the coordinator relies on (§6): re-observing what the engine
|
|
57
86
|
// already holds is a no-op.
|
|
58
87
|
if (existing && existing.file_hash && existing.file_hash.equals(diskHash)) {
|
|
88
|
+
// (A row that names other bytes than the file the database already
|
|
89
|
+
// agrees with is stale either way; the observation settles it.)
|
|
90
|
+
if (owed)
|
|
91
|
+
deletePending(db, repoId, it.path);
|
|
59
92
|
pending.push({
|
|
60
93
|
kind: "echo",
|
|
61
94
|
outcome: { kind: "observed", docId: existing.doc_id, path: it.path, rev: null, commitId: null, converged: true, echo: true, conflicted: false, dispositions: [], oldHashHex, newHashHex },
|
|
@@ -63,7 +96,7 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
|
|
|
63
96
|
continue;
|
|
64
97
|
}
|
|
65
98
|
const prepared = prepareReconcile(store, repoId, it.path, it.content, { ts, config, pool, consumed });
|
|
66
|
-
pending.push({ kind: "ingest", prepared, oldHashHex, newHashHex });
|
|
99
|
+
pending.push({ kind: "ingest", prepared, oldHashHex, newHashHex, clearPending: owed !== null });
|
|
67
100
|
}
|
|
68
101
|
// ---- cross-document phase (spec/reconcile §7) --------------------------------
|
|
69
102
|
if (pending.length > 1)
|
|
@@ -79,6 +112,15 @@ export function observeBatch(store, repoId, items, ts, opts = {}) {
|
|
|
79
112
|
tombstoneObservedDeletion(store, repoId, p.docId, ts);
|
|
80
113
|
out.push({ kind: "deleted", path: p.path, docId: p.docId, oldHashHex: p.oldHashHex });
|
|
81
114
|
}
|
|
115
|
+
else if (p.clearPending) {
|
|
116
|
+
// File wins (spec/store §5.7): the pending row and the observed commit
|
|
117
|
+
// that supersedes it go together.
|
|
118
|
+
out.push(store.write((db) => {
|
|
119
|
+
const r = commitPrepared(store, repoId, p.prepared, ts, p.oldHashHex, p.newHashHex);
|
|
120
|
+
deletePending(db, repoId, p.prepared.path);
|
|
121
|
+
return { kind: "observed", ...r };
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
82
124
|
else {
|
|
83
125
|
out.push({ kind: "observed", ...commitPrepared(store, repoId, p.prepared, ts, p.oldHashHex, p.newHashHex) });
|
|
84
126
|
}
|
|
@@ -140,13 +182,13 @@ function crossDocPhase(pending, config) {
|
|
|
140
182
|
prepared.get(m.toDoc)?.crossDocIds.push(m.carriedId);
|
|
141
183
|
}
|
|
142
184
|
// Pass 2 for one reconciled member: commit the finished result as an observed
|
|
143
|
-
// revision
|
|
185
|
+
// revision — ONE transaction, the conflict-marker flag written inside it with
|
|
186
|
+
// the revision pointers (spec/store §5.4 step 14) — then summarize dispositions.
|
|
144
187
|
function commitPrepared(store, repoId, prepared, ts, oldHashHex, newHashHex) {
|
|
145
188
|
const { path, content } = prepared;
|
|
146
189
|
const conflicted = hasConflictMarkers(content);
|
|
147
190
|
const resolveIds = makeReconcilingResolver(store, repoId, { ts, path, prepared });
|
|
148
|
-
const res = ingestFile(store, repoId, path, content, { ts, origin: "observed", resolveIds, parsed: prepared.tree });
|
|
149
|
-
store.db.prepare("UPDATE docs SET conflicted = ? WHERE repo_id = ? AND path = ?").run(conflicted ? 1 : 0, repoId, path);
|
|
191
|
+
const res = ingestFile(store, repoId, path, content, { ts, origin: "observed", resolveIds, parsed: prepared.tree, conflicted });
|
|
150
192
|
const rows = store.db
|
|
151
193
|
.prepare("SELECT kind, count(*) n FROM dispositions WHERE commit_id = ? GROUP BY kind ORDER BY kind")
|
|
152
194
|
.all(res.commitId);
|
|
@@ -213,6 +255,15 @@ export function observeMany(store, repoId, items, opts = {}) {
|
|
|
213
255
|
*/
|
|
214
256
|
export function observeDelete(store, repoId, path, opts = {}) {
|
|
215
257
|
const ts = opts.ts ?? new Date().toISOString();
|
|
258
|
+
// The §5.7 gate for a gone member: a path the api path still owes a `write`
|
|
259
|
+
// or `rename` is not tombstoned (the materializer owes the file); a `remove`
|
|
260
|
+
// row is confirmed by the absence.
|
|
261
|
+
const owed = readPending(store.db, repoId, path);
|
|
262
|
+
if (owed) {
|
|
263
|
+
if (owed.kind !== "remove")
|
|
264
|
+
return { docId: null, path, deleted: false };
|
|
265
|
+
deletePending(store.db, repoId, path);
|
|
266
|
+
}
|
|
216
267
|
const existing = store.db
|
|
217
268
|
.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
|
|
218
269
|
.get(repoId, path);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { Store } from "../core/store/store.js";
|
|
2
2
|
import { type SyncFs } from "./fs-util.js";
|
|
3
|
+
import { type DocStore } from "../mutate/doc-store.js";
|
|
3
4
|
export interface RecoveryResult {
|
|
5
|
+
/** step 0: paths whose pending materialization rows were replayed */
|
|
6
|
+
materialized: string[];
|
|
4
7
|
healed: string[];
|
|
5
8
|
missing: string[];
|
|
6
9
|
}
|
|
7
10
|
export declare function recoverRepo(store: Store, repoId: string, rootPath: string, opts?: {
|
|
8
11
|
ts?: string;
|
|
9
12
|
fs?: SyncFs;
|
|
13
|
+
docStore?: DocStore;
|
|
10
14
|
}): RecoveryResult;
|
|
@@ -2,8 +2,11 @@ import { sha256 } from "../core/hash.js";
|
|
|
2
2
|
import { ingestFile } from "../core/ingest.js";
|
|
3
3
|
import { makeReconcilingResolver } from "./reconciling-ingest.js";
|
|
4
4
|
import { nodeFs } from "./fs-util.js";
|
|
5
|
+
import { materializePending } from "../mutate/materialize.js";
|
|
6
|
+
import { FsDocStore } from "../mutate/doc-store.js";
|
|
5
7
|
export function recoverRepo(store, repoId, rootPath, opts = {}) {
|
|
6
8
|
const fs = opts.fs ?? nodeFs;
|
|
9
|
+
const materialized = materializePending(store, repoId, opts.docStore ?? new FsDocStore(rootPath));
|
|
7
10
|
const docs = store.db
|
|
8
11
|
.prepare(`SELECT d.doc_id, d.path, d.file_hash, r.rendered_hash
|
|
9
12
|
FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev
|
|
@@ -29,6 +32,6 @@ export function recoverRepo(store, repoId, rootPath, opts = {}) {
|
|
|
29
32
|
healed.push(doc.path);
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
|
-
return { healed, missing };
|
|
35
|
+
return { materialized, healed, missing };
|
|
33
36
|
}
|
|
34
37
|
//# sourceMappingURL=recovery.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Store } from "../core/store/store.js";
|
|
2
|
+
import { type FileStat } from "./fs-util.js";
|
|
3
|
+
/** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
|
|
4
|
+
* file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
|
|
5
|
+
export declare function recordStat(store: Store, repoId: string, path: string, st: FileStat | null, hash: Buffer): void;
|
|
6
|
+
/** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
|
|
7
|
+
export declare function recordFileStat(store: Store, repoId: string, path: string, abs: string, hash: Buffer): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { nodeFs } from "./fs-util.js";
|
|
2
|
+
// The `file_stats` writers (spec/sync §4.3 step 4), split out of freshness.ts
|
|
3
|
+
// so the mutation write target (`mutate/doc-store.ts`, which refreshes a row
|
|
4
|
+
// after each file it materializes) and the sweep (which imports the doc store
|
|
5
|
+
// for its step 0) do not import each other. freshness.ts re-exports both.
|
|
6
|
+
/** Write a file's stat cache row from a stat (`INSERT … ON CONFLICT DO UPDATE`); a null stat (the
|
|
7
|
+
* file vanished) deletes the row. The DB half of `record_file_stat` (spec/sync §4.3 step 4). */
|
|
8
|
+
export function recordStat(store, repoId, path, st, hash) {
|
|
9
|
+
if (!st) {
|
|
10
|
+
store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
store.db
|
|
14
|
+
.prepare(`INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?, ?, ?, ?, ?)
|
|
15
|
+
ON CONFLICT(repo_id, path) DO UPDATE SET mtime_ns = excluded.mtime_ns, size = excluded.size, hash = excluded.hash`)
|
|
16
|
+
.run(repoId, path, st.mtimeNs, st.size, hash);
|
|
17
|
+
}
|
|
18
|
+
/** Record/refresh a file's stat cache row from its absolute path. Call after any ingest of the file. */
|
|
19
|
+
export function recordFileStat(store, repoId, path, abs, hash) {
|
|
20
|
+
recordStat(store, repoId, path, nodeFs.stat(abs, ""), hash);
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=stat-cache.js.map
|
|
@@ -23,10 +23,13 @@ import { ftsDeleteDoc } from "../core/store/fts.js";
|
|
|
23
23
|
// consumes the pool to resurrect ids when the file reappears). Pooling with the
|
|
24
24
|
// standard ~30-day expiry keeps sweepResurrectionPool semantics intact.
|
|
25
25
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
26
|
+
// Transaction: the commit row, the pool snapshot, the FTS deletes and the two
|
|
27
|
+
// tombstone UPDATEs are ONE `store.write` transaction (spec/store §5.6) — a
|
|
28
|
+
// failure midway leaves the doc live with no commit minted, never a doc whose
|
|
29
|
+
// blocks are pooled but still indexed, or tombstoned under a commit that is
|
|
30
|
+
// not there. better-sqlite3 nests an inner `transaction` as a savepoint, so this
|
|
31
|
+
// still composes inside a caller that already holds one. The unlink of the
|
|
32
|
+
// on-disk file that docsDelete performs is intentionally absent here — the file
|
|
30
33
|
// is ALREADY gone (that is what triggered the tombstone).
|
|
31
34
|
/**
|
|
32
35
|
* Tombstone a doc the engine observed as gone from its source (deleted on disk /
|
|
@@ -36,20 +39,21 @@ import { ftsDeleteDoc } from "../core/store/fts.js";
|
|
|
36
39
|
* UPDATEs simply affect zero rows.
|
|
37
40
|
*/
|
|
38
41
|
export function tombstoneObservedDeletion(store, repoId, docId, ts) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
return store.write((db) => {
|
|
43
|
+
const commit = newCommit(db, { repoId, ts, origin: "observed", reason: "observed deletion" });
|
|
44
|
+
// Snapshot the doc's live blocks into the resurrection pool BEFORE tombstoning,
|
|
45
|
+
// mirroring core/ingest.ts's reconciling-delete snapshot (~30-day expiry) so a
|
|
46
|
+
// recreate of the same file can resurrect these block identities.
|
|
47
|
+
const expires = new Date(Date.parse(ts) + 30 * 24 * 3600 * 1000).toISOString();
|
|
48
|
+
db.prepare(`INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
|
|
49
|
+
SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?, ?
|
|
50
|
+
FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL`).run(commit.commitId, expires, docId);
|
|
51
|
+
// Drop FTS index rows (external-content: delete with the original text before the
|
|
52
|
+
// blocks change), then tombstone the live blocks and the doc row.
|
|
53
|
+
ftsDeleteDoc(db, docId);
|
|
54
|
+
db.prepare("UPDATE blocks SET deleted_commit = ? WHERE doc_id = ? AND deleted_commit IS NULL").run(commit.commitId, docId);
|
|
55
|
+
db.prepare("UPDATE docs SET deleted_commit = ? WHERE doc_id = ?").run(commit.commitId, docId);
|
|
56
|
+
return commit.commitId;
|
|
57
|
+
});
|
|
54
58
|
}
|
|
55
59
|
//# sourceMappingURL=tombstone.js.map
|
|
@@ -5,6 +5,8 @@ export interface WatcherOptions {
|
|
|
5
5
|
onCheckpoint?: (result: CheckpointResult) => void;
|
|
6
6
|
/** Surface a reconcile error from a background batch (the watch stays live). */
|
|
7
7
|
onError?: (err: unknown) => void;
|
|
8
|
+
/** The workspace `.omgbase/` dir: when set, every checkpoint runs under the writer lock. */
|
|
9
|
+
omgbaseDir?: string;
|
|
8
10
|
}
|
|
9
11
|
export declare class Watcher {
|
|
10
12
|
private store;
|
package/dist/src/sync/watcher.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { reconcileChanges } from "./driver.js";
|
|
2
|
+
import { withWriterLockAsync } from "./writer-lock.js";
|
|
2
3
|
export class Watcher {
|
|
3
4
|
store;
|
|
4
5
|
repoId;
|
|
@@ -30,7 +31,9 @@ export class Watcher {
|
|
|
30
31
|
async ingest(paths) {
|
|
31
32
|
if (paths.length === 0)
|
|
32
33
|
return null;
|
|
33
|
-
const
|
|
34
|
+
const reconcile = () => reconcileChanges(this.store, this.repoId, this.source, paths.map((path) => ({ path })));
|
|
35
|
+
const dir = this.opts.omgbaseDir;
|
|
36
|
+
const result = dir !== undefined ? await withWriterLockAsync(dir, reconcile) : await reconcile();
|
|
34
37
|
this.opts.onCheckpoint?.(result);
|
|
35
38
|
return result;
|
|
36
39
|
}
|
|
@@ -7,10 +7,25 @@ export interface WriterLockOptions {
|
|
|
7
7
|
export declare class WriterLockTimeout extends Error {
|
|
8
8
|
constructor(lockPath: string, holderPid: number | null);
|
|
9
9
|
}
|
|
10
|
+
/** Override the process-wide wait defaults (a test seam; the poll never outlasts the timeout). */
|
|
11
|
+
export declare function setWriterLockDefaults(opts: WriterLockOptions): void;
|
|
12
|
+
/** The process-wide wait defaults in force. */
|
|
13
|
+
export declare function writerLockDefaults(): Required<WriterLockOptions>;
|
|
10
14
|
/**
|
|
11
15
|
* Run fn while holding the workspace writer lock. Blocks (polling) until the
|
|
12
16
|
* lock is free or the timeout elapses, then releases the lock even if fn throws.
|
|
17
|
+
* Re-entrant within the process: a nested call on the same directory runs fn
|
|
18
|
+
* directly and the outermost exit releases.
|
|
13
19
|
*/
|
|
14
20
|
export declare function withWriterLock<T>(omgbaseDir: string, fn: () => T, opts?: WriterLockOptions): T;
|
|
21
|
+
/**
|
|
22
|
+
* The async form: the lock is held until the promise `fn` returns settles (a
|
|
23
|
+
* sync `withWriterLock` around an async fn would release at the first await).
|
|
24
|
+
* Acquisition still polls synchronously — the wait is the same bounded
|
|
25
|
+
* contention window as the sync form's. Same re-entrancy.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withWriterLockAsync<T>(omgbaseDir: string, fn: () => Promise<T>, opts?: WriterLockOptions): Promise<T>;
|
|
28
|
+
/** True while THIS process holds the lock (at any depth) — a test seam for "ran under the lock". */
|
|
29
|
+
export declare function writerLockHeld(omgbaseDir: string): boolean;
|
|
15
30
|
/** True if no live writer holds the lock (probe without acquiring). */
|
|
16
31
|
export declare function writerLockFree(omgbaseDir: string): boolean;
|