@omgbase/sync 0.6.4 → 0.7.2
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/dist/src/coordinator.d.ts +31 -6
- package/dist/src/coordinator.js +97 -12
- package/dist/src/engine-client.d.ts +38 -10
- package/dist/src/engine-client.js +41 -7
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/mcp-engine-client.d.ts +4 -5
- package/dist/src/mcp-engine-client.js +25 -0
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SyncSource, type SourceWatch } from "@omgbase/core";
|
|
2
|
-
import type
|
|
2
|
+
import { type EngineClient } from "./engine-client.js";
|
|
3
3
|
export interface SyncInSummary {
|
|
4
4
|
ingested: string[];
|
|
5
5
|
suppressed: string[];
|
|
@@ -8,8 +8,12 @@ export interface SyncInSummary {
|
|
|
8
8
|
}
|
|
9
9
|
export interface SyncOutSummary {
|
|
10
10
|
cursor: number;
|
|
11
|
+
/** the feed's epoch the cursor belongs to (spec/sync §6, 4.0); a consumer stores both */
|
|
12
|
+
epoch: string | null;
|
|
11
13
|
written: string[];
|
|
12
14
|
removed: string[];
|
|
15
|
+
/** set when the feed had expired the cursor and the rescan procedure (spec/sync §6) ran before resuming */
|
|
16
|
+
rescanned?: boolean;
|
|
13
17
|
}
|
|
14
18
|
export declare class Coordinator {
|
|
15
19
|
private readonly engine;
|
|
@@ -24,12 +28,33 @@ export declare class Coordinator {
|
|
|
24
28
|
reconcile(paths: string[]): Promise<SyncInSummary>;
|
|
25
29
|
/**
|
|
26
30
|
* engine → source: write engine-authored changes out to the source. Walks the
|
|
27
|
-
*
|
|
28
|
-
* observed-origin commits (they originated from a source — §6), and
|
|
29
|
-
* each
|
|
30
|
-
*
|
|
31
|
+
* change feed from `(epoch, cursor)` (paging through `truncated`), skipping
|
|
32
|
+
* observed-origin commits (they originated from a source — §6), and applies
|
|
33
|
+
* each digest's documents by `kind`: `created`/`changed`/`moved` → read the
|
|
34
|
+
* doc by `_id` and `write` at `_path` (a `moved` also `remove`s `from_path`);
|
|
35
|
+
* `deleted` → `remove`. When the feed has **expired** the cursor (another
|
|
36
|
+
* epoch, past the head, pruned behind the oldest retained row — spec/surface
|
|
37
|
+
* §3 `expired_cursor`) the rescan procedure of spec/sync §6 runs — the
|
|
38
|
+
* server's live documents are listed and the source reconverged to them —
|
|
39
|
+
* and the walk resumes from the error's `head` under its `epoch`. Requires a
|
|
40
|
+
* write-through source; a read-only source exports nothing.
|
|
31
41
|
*/
|
|
32
|
-
syncOut(cursor?: number): Promise<SyncOutSummary>;
|
|
42
|
+
syncOut(cursor?: number, epoch?: string): Promise<SyncOutSummary>;
|
|
43
|
+
/** `remove` a source path once per `syncOut` call (a tombstoned document's own digest and its earlier digests all name it). */
|
|
44
|
+
private removeOnce;
|
|
45
|
+
/**
|
|
46
|
+
* The rescan procedure, steps 2–3 (spec/sync §6, 4.0): page the server's
|
|
47
|
+
* live documents into `L = { (id, path, hash) }`; for every document whose
|
|
48
|
+
* hash differs from the source's copy (hashed as the engine hashes a file —
|
|
49
|
+
* sha256 of the bytes), pull the bytes and write them at `path`; every
|
|
50
|
+
* source-side path not in `L` is removed (absence from `L` is deletion — the
|
|
51
|
+
* source has paths, not ids, so a moved document is a write at the new path
|
|
52
|
+
* and a removal of the old one). The caller resumes the feed (step 4).
|
|
53
|
+
*/
|
|
54
|
+
rescan(): Promise<{
|
|
55
|
+
written: string[];
|
|
56
|
+
removed: string[];
|
|
57
|
+
}>;
|
|
33
58
|
/**
|
|
34
59
|
* Live source→engine sync: subscribe to the source's change batches and
|
|
35
60
|
* reconcile each. Returns the subscription (`.ready` resolves once the source
|
package/dist/src/coordinator.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { storagePath } from "@omgbase/core";
|
|
3
|
+
import { FeedExpired } from "./engine-client.js";
|
|
2
4
|
export class Coordinator {
|
|
3
5
|
engine;
|
|
4
6
|
source;
|
|
@@ -48,43 +50,126 @@ export class Coordinator {
|
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
52
|
* engine → source: write engine-authored changes out to the source. Walks the
|
|
51
|
-
*
|
|
52
|
-
* observed-origin commits (they originated from a source — §6), and
|
|
53
|
-
* each
|
|
54
|
-
*
|
|
53
|
+
* change feed from `(epoch, cursor)` (paging through `truncated`), skipping
|
|
54
|
+
* observed-origin commits (they originated from a source — §6), and applies
|
|
55
|
+
* each digest's documents by `kind`: `created`/`changed`/`moved` → read the
|
|
56
|
+
* doc by `_id` and `write` at `_path` (a `moved` also `remove`s `from_path`);
|
|
57
|
+
* `deleted` → `remove`. When the feed has **expired** the cursor (another
|
|
58
|
+
* epoch, past the head, pruned behind the oldest retained row — spec/surface
|
|
59
|
+
* §3 `expired_cursor`) the rescan procedure of spec/sync §6 runs — the
|
|
60
|
+
* server's live documents are listed and the source reconverged to them —
|
|
61
|
+
* and the walk resumes from the error's `head` under its `epoch`. Requires a
|
|
62
|
+
* write-through source; a read-only source exports nothing.
|
|
55
63
|
*/
|
|
56
|
-
async syncOut(cursor) {
|
|
64
|
+
async syncOut(cursor, epoch) {
|
|
57
65
|
const written = [];
|
|
58
66
|
const removed = [];
|
|
59
67
|
let cur = cursor ?? 0;
|
|
68
|
+
let ep = epoch ?? null;
|
|
60
69
|
if (!this.source.write)
|
|
61
|
-
return { cursor: cur, written, removed }; // read-only source
|
|
70
|
+
return { cursor: cur, epoch: ep, written, removed }; // read-only source
|
|
71
|
+
let rescanned = false;
|
|
62
72
|
for (;;) {
|
|
63
|
-
|
|
73
|
+
let page;
|
|
74
|
+
try {
|
|
75
|
+
page = await this.engine.changesSince(cur, ep !== null ? { epoch: ep } : {});
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (!(e instanceof FeedExpired) || rescanned)
|
|
79
|
+
throw e;
|
|
80
|
+
// The rescan (spec/sync §6): record S = head and E = epoch from the
|
|
81
|
+
// error, reconverge the source to the server's live documents, resume
|
|
82
|
+
// from (E, S). Once per call: a second expiry means the bootstrap lease
|
|
83
|
+
// (the retention floor) did not hold and the caller retries.
|
|
84
|
+
const r = await this.rescan();
|
|
85
|
+
written.push(...r.written);
|
|
86
|
+
removed.push(...r.removed);
|
|
87
|
+
cur = e.head;
|
|
88
|
+
ep = e.epoch;
|
|
89
|
+
rescanned = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
64
92
|
for (const digest of page.digests) {
|
|
65
93
|
if (digest.origin === "observed")
|
|
66
94
|
continue; // came from a source; don't echo back
|
|
67
95
|
for (const rev of digest.revisions) {
|
|
68
|
-
// `changes_since` reports a
|
|
96
|
+
// `changes_since` reports a document under the row's engine names
|
|
69
97
|
// (`_id`, `_path`, `_hash` — spec/surface §1.2, 5.0) in the
|
|
70
98
|
// reference form; the source takes storage paths.
|
|
71
99
|
const path = storagePath(rev._path);
|
|
100
|
+
if (rev.kind === "moved" && rev.from_path !== undefined) {
|
|
101
|
+
const from = storagePath(rev.from_path);
|
|
102
|
+
if (from !== path)
|
|
103
|
+
await this.removeOnce(from, removed);
|
|
104
|
+
}
|
|
105
|
+
if (rev.kind === "deleted") {
|
|
106
|
+
await this.removeOnce(path, removed);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
72
109
|
const doc = await this.engine.readDoc(path);
|
|
73
110
|
if (doc) {
|
|
74
111
|
await this.source.write(path, doc.content);
|
|
75
112
|
written.push(path);
|
|
76
113
|
}
|
|
77
|
-
else
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
else {
|
|
115
|
+
// The document no longer reads (tombstoned later in the feed): its
|
|
116
|
+
// `deleted` digest follows and would remove the same path again.
|
|
117
|
+
await this.removeOnce(path, removed);
|
|
80
118
|
}
|
|
81
119
|
}
|
|
82
120
|
}
|
|
83
121
|
cur = page.cursor;
|
|
122
|
+
ep = page.epoch ?? ep;
|
|
84
123
|
if (!page.truncated)
|
|
85
124
|
break;
|
|
86
125
|
}
|
|
87
|
-
return { cursor: cur, written, removed };
|
|
126
|
+
return { cursor: cur, epoch: ep, written, removed, ...(rescanned ? { rescanned } : {}) };
|
|
127
|
+
}
|
|
128
|
+
/** `remove` a source path once per `syncOut` call (a tombstoned document's own digest and its earlier digests all name it). */
|
|
129
|
+
async removeOnce(path, removed) {
|
|
130
|
+
if (!this.source.remove || removed.includes(path))
|
|
131
|
+
return;
|
|
132
|
+
await this.source.remove(path);
|
|
133
|
+
removed.push(path);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The rescan procedure, steps 2–3 (spec/sync §6, 4.0): page the server's
|
|
137
|
+
* live documents into `L = { (id, path, hash) }`; for every document whose
|
|
138
|
+
* hash differs from the source's copy (hashed as the engine hashes a file —
|
|
139
|
+
* sha256 of the bytes), pull the bytes and write them at `path`; every
|
|
140
|
+
* source-side path not in `L` is removed (absence from `L` is deletion — the
|
|
141
|
+
* source has paths, not ids, so a moved document is a write at the new path
|
|
142
|
+
* and a removal of the old one). The caller resumes the feed (step 4).
|
|
143
|
+
*/
|
|
144
|
+
async rescan() {
|
|
145
|
+
const written = [];
|
|
146
|
+
const removed = [];
|
|
147
|
+
if (!this.source.write)
|
|
148
|
+
return { written, removed };
|
|
149
|
+
const listed = await this.engine.listDocs();
|
|
150
|
+
const live = new Set();
|
|
151
|
+
for (const d of listed) {
|
|
152
|
+
const path = storagePath(d._path);
|
|
153
|
+
live.add(path);
|
|
154
|
+
const have = await this.source.fetch(path);
|
|
155
|
+
const haveHash = have ? createHash("sha256").update(have.content, "utf8").digest("hex") : null;
|
|
156
|
+
if (haveHash !== null && haveHash === d._hash)
|
|
157
|
+
continue;
|
|
158
|
+
const doc = await this.engine.readDoc(path);
|
|
159
|
+
if (!doc)
|
|
160
|
+
continue;
|
|
161
|
+
await this.source.write(path, doc.content);
|
|
162
|
+
written.push(path);
|
|
163
|
+
}
|
|
164
|
+
if (this.source.remove) {
|
|
165
|
+
for (const entry of await this.source.enumerate()) {
|
|
166
|
+
if (live.has(entry.path))
|
|
167
|
+
continue;
|
|
168
|
+
await this.source.remove(entry.path);
|
|
169
|
+
removed.push(entry.path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { written, removed };
|
|
88
173
|
}
|
|
89
174
|
/**
|
|
90
175
|
* Live source→engine sync: subscribe to the source's change batches and
|
|
@@ -3,9 +3,40 @@ export interface ChangesPage {
|
|
|
3
3
|
digests: CommitDigest[];
|
|
4
4
|
cursor: number;
|
|
5
5
|
truncated: boolean;
|
|
6
|
-
/** the
|
|
6
|
+
/** the feed's epoch (spec/store 17.0, D7): a cursor is the pair `(epoch, seq)` — a consumer stores and sends both */
|
|
7
|
+
epoch?: string;
|
|
8
|
+
/** the oldest `seq` the feed still holds (0 when empty); a gap between a consumer's cursor and `oldest − 1` is a rescan */
|
|
9
|
+
oldest?: number;
|
|
10
|
+
/** the repo's current max seq — `cursor == head` means nothing new */
|
|
7
11
|
head?: number;
|
|
8
12
|
}
|
|
13
|
+
/** A live document as the rescan lists it (spec/sync §6, 4.0): `query docs { select _id, _path, _hash }`. */
|
|
14
|
+
export interface DocListing {
|
|
15
|
+
_id: string;
|
|
16
|
+
/** reference form (`/a.md`) */
|
|
17
|
+
_path: string;
|
|
18
|
+
_hash: string | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `expired_cursor` (spec/surface §3, 6.0; spec/sync §6, 4.0): the feed no
|
|
22
|
+
* longer serves the consumer's `(epoch, cursor)` — the repository's current
|
|
23
|
+
* position is `{ epoch, oldest, head }` and the consumer runs the rescan
|
|
24
|
+
* procedure, then resumes from `head` under `epoch`. Raised by both engine
|
|
25
|
+
* clients alike (the in-process one from the core's `ExpiredCursor`, the MCP
|
|
26
|
+
* one from the error envelope), so the coordinator sees one shape.
|
|
27
|
+
*/
|
|
28
|
+
export declare class FeedExpired extends Error {
|
|
29
|
+
readonly epoch: string;
|
|
30
|
+
readonly oldest: number;
|
|
31
|
+
readonly head: number;
|
|
32
|
+
constructor(epoch: string, oldest: number, head: number, message?: string);
|
|
33
|
+
}
|
|
34
|
+
export interface ChangesSinceOpts {
|
|
35
|
+
/** the cursor's epoch (default: the repository's current one) */
|
|
36
|
+
epoch?: string;
|
|
37
|
+
origin?: "api" | "observed" | "import";
|
|
38
|
+
limit?: number;
|
|
39
|
+
}
|
|
9
40
|
export interface DocBytes {
|
|
10
41
|
content: string;
|
|
11
42
|
/** hex of the doc's current rendered/file hash (matches a changes_since revision's `_hash`). */
|
|
@@ -19,11 +50,10 @@ export interface EngineClient {
|
|
|
19
50
|
}[]): Promise<ObserveResult[]>;
|
|
20
51
|
/** Mirror a source-side deletion: tombstone the doc (observed). */
|
|
21
52
|
observeDelete(path: string): Promise<ObserveDeleteResult>;
|
|
22
|
-
/** The repo-wide
|
|
23
|
-
changesSince(cursor?: number, opts?:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}): Promise<ChangesPage>;
|
|
53
|
+
/** The repo-wide change feed after a cursor (for the export direction); throws `FeedExpired` when the feed no longer serves the cursor. */
|
|
54
|
+
changesSince(cursor?: number, opts?: ChangesSinceOpts): Promise<ChangesPage>;
|
|
55
|
+
/** Every live document of the repo as `{ _id, _path, _hash }` (the rescan's step 2, spec/sync §6). */
|
|
56
|
+
listDocs(): Promise<DocListing[]>;
|
|
27
57
|
/** Current whole-file bytes + content hash for a path, or null if absent. */
|
|
28
58
|
readDoc(path: string): Promise<DocBytes | null>;
|
|
29
59
|
close(): Promise<void>;
|
|
@@ -39,10 +69,8 @@ export declare class InProcessEngineClient implements EngineClient {
|
|
|
39
69
|
content: string;
|
|
40
70
|
}[]): Promise<ObserveResult[]>;
|
|
41
71
|
observeDelete(path: string): Promise<ObserveDeleteResult>;
|
|
42
|
-
changesSince(cursor?: number, opts?:
|
|
43
|
-
|
|
44
|
-
limit?: number;
|
|
45
|
-
}): Promise<ChangesPage>;
|
|
72
|
+
changesSince(cursor?: number, opts?: ChangesSinceOpts): Promise<ChangesPage>;
|
|
73
|
+
listDocs(): Promise<DocListing[]>;
|
|
46
74
|
readDoc(path: string): Promise<DocBytes | null>;
|
|
47
75
|
close(): Promise<void>;
|
|
48
76
|
}
|
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
import { observeMany, observeDelete, changesSince, docsRead, storagePath, } from "@omgbase/core";
|
|
1
|
+
import { observeMany, observeDelete, changesSince, ExpiredCursor, docsRead, storagePath, referencePath, } from "@omgbase/core";
|
|
2
|
+
/**
|
|
3
|
+
* `expired_cursor` (spec/surface §3, 6.0; spec/sync §6, 4.0): the feed no
|
|
4
|
+
* longer serves the consumer's `(epoch, cursor)` — the repository's current
|
|
5
|
+
* position is `{ epoch, oldest, head }` and the consumer runs the rescan
|
|
6
|
+
* procedure, then resumes from `head` under `epoch`. Raised by both engine
|
|
7
|
+
* clients alike (the in-process one from the core's `ExpiredCursor`, the MCP
|
|
8
|
+
* one from the error envelope), so the coordinator sees one shape.
|
|
9
|
+
*/
|
|
10
|
+
export class FeedExpired extends Error {
|
|
11
|
+
epoch;
|
|
12
|
+
oldest;
|
|
13
|
+
head;
|
|
14
|
+
constructor(epoch, oldest, head, message) {
|
|
15
|
+
super(message ?? `expired_cursor — the feed now begins at (${epoch}, ${oldest}); rescan and resume from head ${head}`);
|
|
16
|
+
this.epoch = epoch;
|
|
17
|
+
this.oldest = oldest;
|
|
18
|
+
this.head = head;
|
|
19
|
+
this.name = "FeedExpired";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/** In-process EngineClient: calls @omgbase/core directly against an open Store.
|
|
3
23
|
* Used by the local watcher/sync (no self-MCP-loop) and by tests. */
|
|
4
24
|
export class InProcessEngineClient {
|
|
@@ -15,15 +35,29 @@ export class InProcessEngineClient {
|
|
|
15
35
|
return Promise.resolve(observeDelete(this.store, this.repoId, path));
|
|
16
36
|
}
|
|
17
37
|
changesSince(cursor, opts) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
try {
|
|
39
|
+
return Promise.resolve(changesSince(this.store, this.repoId, {
|
|
40
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
41
|
+
...(opts?.epoch !== undefined ? { epoch: opts.epoch } : {}),
|
|
42
|
+
...(opts?.origin ? { origin: opts.origin } : {}),
|
|
43
|
+
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e instanceof ExpiredCursor)
|
|
48
|
+
return Promise.reject(new FeedExpired(e.epoch, e.oldest, e.head, e.message));
|
|
49
|
+
return Promise.reject(e instanceof Error ? e : new Error(String(e)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
listDocs() {
|
|
53
|
+
const rows = this.store.db
|
|
54
|
+
.prepare("SELECT doc_id, path, file_hash FROM docs WHERE repo_id = ? AND deleted_at IS NULL ORDER BY path")
|
|
55
|
+
.all(this.repoId);
|
|
56
|
+
return Promise.resolve(rows.map((r) => ({ _id: r.doc_id, _path: referencePath(r.path), _hash: r.file_hash ? r.file_hash.toString("hex") : null })));
|
|
23
57
|
}
|
|
24
58
|
readDoc(path) {
|
|
25
59
|
const row = this.store.db
|
|
26
|
-
.prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND
|
|
60
|
+
.prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_at IS NULL")
|
|
27
61
|
.get(this.repoId, storagePath(path));
|
|
28
62
|
if (!row)
|
|
29
63
|
return Promise.resolve(null);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type EngineClient, type ChangesPage, type DocBytes, InProcessEngineClient } from "./engine-client.js";
|
|
1
|
+
export { type EngineClient, type ChangesPage, type ChangesSinceOpts, type DocBytes, type DocListing, FeedExpired, InProcessEngineClient } from "./engine-client.js";
|
|
2
2
|
export { McpEngineClient, connectStdioEngine, connectHttpEngine, connectEngine, parseEngineSpec, type EngineSpec } from "./mcp-engine-client.js";
|
|
3
3
|
export { Coordinator, type SyncInSummary, type SyncOutSummary } from "./coordinator.js";
|
|
4
4
|
export { runFsMirror, type FsMirrorOptions } from "./mirror.js";
|
package/dist/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The Coordinator reconciles a SyncSource (external store, via the adapter
|
|
3
3
|
// protocol) with an omgbase repo reached through an EngineClient seam:
|
|
4
4
|
// InProcessEngineClient (local, direct Store) or McpEngineClient (remote, MCP).
|
|
5
|
-
export { InProcessEngineClient } from "./engine-client.js";
|
|
5
|
+
export { FeedExpired, InProcessEngineClient } from "./engine-client.js";
|
|
6
6
|
export { McpEngineClient, connectStdioEngine, connectHttpEngine, connectEngine, parseEngineSpec } from "./mcp-engine-client.js";
|
|
7
7
|
export { Coordinator } from "./coordinator.js";
|
|
8
8
|
export { runFsMirror } from "./mirror.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
2
|
import { type ObserveResult, type ObserveDeleteResult, type SurfaceObserveResult } from "@omgbase/core";
|
|
3
|
-
import type
|
|
3
|
+
import { type EngineClient, type ChangesPage, type ChangesSinceOpts, type DocBytes, type DocListing } from "./engine-client.js";
|
|
4
4
|
/** Wraps a connected MCP SDK Client, calling the engine's sync tools. */
|
|
5
5
|
export declare class McpEngineClient implements EngineClient {
|
|
6
6
|
private readonly client;
|
|
@@ -26,10 +26,9 @@ export declare class McpEngineClient implements EngineClient {
|
|
|
26
26
|
content: string;
|
|
27
27
|
}[]): Promise<ObserveResult[]>;
|
|
28
28
|
observeDelete(path: string): Promise<ObserveDeleteResult>;
|
|
29
|
-
changesSince(cursor?: number, opts?:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}): Promise<ChangesPage>;
|
|
29
|
+
changesSince(cursor?: number, opts?: ChangesSinceOpts): Promise<ChangesPage>;
|
|
30
|
+
/** The rescan's listing (spec/sync §6 step 2): `query docs { select _id, _path, _hash }` on the minimal profile, paged through its cursor. */
|
|
31
|
+
listDocs(): Promise<DocListing[]>;
|
|
33
32
|
readDoc(path: string): Promise<DocBytes | null>;
|
|
34
33
|
close(): Promise<void>;
|
|
35
34
|
}
|
|
@@ -3,6 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
3
3
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
5
|
import { VERSION } from "@omgbase/core";
|
|
6
|
+
import { FeedExpired } from "./engine-client.js";
|
|
6
7
|
/** Wraps a connected MCP SDK Client, calling the engine's sync tools. */
|
|
7
8
|
export class McpEngineClient {
|
|
8
9
|
client;
|
|
@@ -44,6 +45,11 @@ export class McpEngineClient {
|
|
|
44
45
|
}
|
|
45
46
|
if (res.isError) {
|
|
46
47
|
const err = body;
|
|
48
|
+
// spec/surface §3 (6.0): the one error a feed consumer acts on — the
|
|
49
|
+
// coordinator rescans (spec/sync §6) — surfaces with its position.
|
|
50
|
+
if (err.error === "expired_cursor" && err.data && typeof err.data.epoch === "string" && typeof err.data.oldest === "number" && typeof err.data.head === "number") {
|
|
51
|
+
throw new FeedExpired(err.data.epoch, err.data.oldest, err.data.head, err.message);
|
|
52
|
+
}
|
|
47
53
|
throw new Error(`tool ${name} failed: ${err.error ?? "error"} — ${err.message ?? text.slice(0, 200)}`);
|
|
48
54
|
}
|
|
49
55
|
return body;
|
|
@@ -64,10 +70,29 @@ export class McpEngineClient {
|
|
|
64
70
|
changesSince(cursor, opts) {
|
|
65
71
|
return this.callTool("changes_since", {
|
|
66
72
|
...(cursor !== undefined ? { cursor } : {}),
|
|
73
|
+
...(opts?.epoch !== undefined ? { epoch: opts.epoch } : {}),
|
|
67
74
|
...(opts?.origin ? { origin: opts.origin } : {}),
|
|
68
75
|
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
69
76
|
});
|
|
70
77
|
}
|
|
78
|
+
/** The rescan's listing (spec/sync §6 step 2): `query docs { select _id, _path, _hash }` on the minimal profile, paged through its cursor. */
|
|
79
|
+
async listDocs() {
|
|
80
|
+
const out = [];
|
|
81
|
+
let cursor;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const page = await this.callTool("query", {
|
|
84
|
+
query: "docs { select _id, _path, _hash }",
|
|
85
|
+
bare: true,
|
|
86
|
+
limit: 500,
|
|
87
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
88
|
+
});
|
|
89
|
+
out.push(...page.hits);
|
|
90
|
+
if (!page.truncated || !page.cursor)
|
|
91
|
+
break;
|
|
92
|
+
cursor = page.cursor;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
71
96
|
async readDoc(path) {
|
|
72
97
|
try {
|
|
73
98
|
// `docs_read { _path }` — the argument addresses the row's `_path`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omgbase/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Open Markdown Graph Base — standalone store-to-store synchronizer: a coordinator over an EngineClient seam (in-process or MCP-client) and source adapters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
56
|
-
"@omgbase/core": "^0.
|
|
56
|
+
"@omgbase/core": "^0.32.0",
|
|
57
57
|
"@omgbase/fs-adapter": "^0.2.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|