@omgbase/sync 0.6.3 → 0.7.1

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.
@@ -1,5 +1,5 @@
1
1
  import { type SyncSource, type SourceWatch } from "@omgbase/core";
2
- import type { EngineClient } from "./engine-client.js";
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
- * commit feed from `cursor` (paging through `truncated`), skipping
28
- * observed-origin commits (they originated from a source — §6), and writes
29
- * each changed doc's current bytes to the source (or removes a tombstoned one).
30
- * Requires a write-through source; a read-only source exports nothing.
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
@@ -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
- * commit feed from `cursor` (paging through `truncated`), skipping
52
- * observed-origin commits (they originated from a source — §6), and writes
53
- * each changed doc's current bytes to the source (or removes a tombstoned one).
54
- * Requires a write-through source; a read-only source exports nothing.
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
- const page = await this.engine.changesSince(cur);
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 revision under the row's engine names
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 if (this.source.remove) {
78
- await this.source.remove(path);
79
- removed.push(path);
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 repo's current max commit seq — cursor > head means the cursor came from another repo/server */
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 commit feed after a cursor (for the export direction). */
23
- changesSince(cursor?: number, opts?: {
24
- origin?: "api" | "observed" | "import";
25
- limit?: number;
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
- origin?: "api" | "observed" | "import";
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
- return Promise.resolve(changesSince(this.store, this.repoId, {
19
- ...(cursor !== undefined ? { cursor } : {}),
20
- ...(opts?.origin ? { origin: opts.origin } : {}),
21
- ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
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 deleted_commit IS NULL")
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);
@@ -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 { EngineClient, ChangesPage, DocBytes } from "./engine-client.js";
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
- origin?: "api" | "observed" | "import";
31
- limit?: number;
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.6.3",
3
+ "version": "0.7.1",
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.29.2",
56
+ "@omgbase/core": "^0.31.0",
57
57
  "@omgbase/fs-adapter": "^0.2.0"
58
58
  },
59
59
  "scripts": {