@omgbase/sync 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/dist/src/bin.js +7 -5
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/mcp-engine-client.d.ts +23 -0
- package/dist/src/mcp-engine-client.js +28 -0
- package/dist/src/mirror.d.ts +5 -2
- package/dist/src/mirror.js +5 -2
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @omgbase/sync
|
|
2
|
+
|
|
3
|
+
The standalone **store-to-store synchronizer** for [omgbase](https://github.com/omgbase/omgbase) (ADR-014), shipped as the `omgbase-sync` binary and as a library. It mirrors an external store — today a filesystem directory via `@omgbase/fs-adapter`, in principle anything speaking the source-adapter stdio protocol — into an omgbase repo, and optionally exports engine-authored changes back out.
|
|
4
|
+
|
|
5
|
+
The design has one load-bearing idea: **omgbase is just another peer behind its MCP interface.** A `Coordinator` moves whole-file bytes between a `SyncSource` (the adapter) and an `EngineClient` (the omgbase side). The `EngineClient` seam has two implementations — `InProcessEngineClient` (direct `Store`, used by tests and library callers) and `McpEngineClient` (a Model Context Protocol client over stdio or Streamable HTTP) — and the coordinator is identical against both. It contains **no reconciliation logic**: block identity, echo gating, conflict flagging and commits stay in the engine and are reached through the `observe*` tools.
|
|
6
|
+
|
|
7
|
+
## When to use it vs `omg sync`
|
|
8
|
+
|
|
9
|
+
`omg sync` is one verb with three modes (`packages/cli/src/cmd/sync.ts`):
|
|
10
|
+
|
|
11
|
+
| Command | What runs | Uses this package? |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `omg sync` | in-process `freshnessSweep` (disk → local DB) | no |
|
|
14
|
+
| `omg sync --watch` | in-process `Watcher` over the spawned fs adapter, local DB | no |
|
|
15
|
+
| `omg sync --server "<cmd>" [--root d] [--out] [--watch]` | `runFsMirror()` from `@omgbase/sync`, driving a remote engine over MCP | **yes** |
|
|
16
|
+
|
|
17
|
+
`omg sync --server … --root X` and `omgbase-sync --root X` are the same code path (`runFsMirror`). Reach for `omgbase-sync` when the machine holding the files does not have (or want) the full `omg` CLI, or when the engine is **headless**: a server with no working tree, whose repo has zero attached sources and whose DB is the content authority. Every other `omg` command with `--server` uses the `McpEngineClient` exported here too (`packages/cli/src/cmd/_remote.ts`).
|
|
18
|
+
|
|
19
|
+
## The `omgbase-sync` bin
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
usage: omgbase-sync --root <dir> [--server "<cmd>"] [--out] [--watch]
|
|
23
|
+
--root <dir> filesystem directory to mirror (required)
|
|
24
|
+
--server <cmd> MCP server command to spawn (default: omg mcp -C <root>)
|
|
25
|
+
--out also export engine-authored changes back to the directory
|
|
26
|
+
--watch stay live and mirror edits as they land
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
omgbase-sync --root ./vault # one sync in (fs → engine)
|
|
31
|
+
omgbase-sync --root ./vault --out # … plus export engine-authored changes to disk
|
|
32
|
+
omgbase-sync --root ./vault --watch # stay live until Ctrl-C
|
|
33
|
+
omgbase-sync --root ./vault --server "omg mcp -C /srv/notes" # a different server command
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
What it does, in order: spawn `--server` (split on whitespace) as a stdio MCP server and connect as client `omgbase-sync`; spawn the bundled fs adapter (`node <fs-adapter bin> --root <dir>`); `syncIn()`; if `--out`, `syncOut()`; if `--watch`, `watchIn()` until SIGINT/SIGTERM. Progress goes to stderr, prefixed `[omgbase-sync]`; a fatal error exits 1. Two limits worth knowing: the bin's `--server` is always a **command to spawn** (an `http(s)` URL is only handled by the library's `connectHttpEngine` and the CLI's `_remote.ts`, not by `runFsMirror`), and there is no repo flag — tool calls carry no `repo`, so they land on the server's default repo (the one `omg mcp -C <dir>` was started for). Note that the default `omg mcp -C <root>` requires `<root>` to be inside an initialized workspace.
|
|
37
|
+
|
|
38
|
+
## The MCP tools it drives
|
|
39
|
+
|
|
40
|
+
All registered in `packages/core/src/mcp/server.ts`; every one takes an optional `repo` slug.
|
|
41
|
+
|
|
42
|
+
- **`observe_many({files:[{path,content}]})`** → one result per file: `{docId, path, rev, commitId, converged, echo, conflicted, dispositions}`. Bytes are committed as an **observed**-origin revision (a write *around* the engine, as a human edit is recorded) with block identity threaded by the reconciler. If `sha256(content)` already equals the doc's stored `file_hash`, it is an **echo**: `echo:true`, `rev:null`, no commit. Bytes containing git conflict markers are still ingested and flagged `conflicted:true`. The batch shares one timestamp and one resurrection-pool sweep. (`observe` is the single-file form; the coordinator uses the batch.)
|
|
43
|
+
- **`observe_delete({path})`** → `{docId, path, deleted}`. Tombstones the live doc as an observed deletion (blocks pooled for resurrection, no file removed). Idempotent.
|
|
44
|
+
- **`changes_since({cursor?, origin?, limit?})`** → `{digests, cursor, truncated, head}`. The repo-wide commit feed after a commit `seq`; each digest is `{commit, seq, ts, origin, actor, summary, revisions:[{doc, path, contentHash}]}` where `contentHash` is the hex of that revision's rendered file hash — enough for a client to tell "changed vs echo" without a follow-up read. `head` is the repo's current max seq (cursor > head means the cursor came from another repo).
|
|
45
|
+
- **`docs_read({path})`** — used by `readDoc()` in the export direction to get a doc's current byte-exact content.
|
|
46
|
+
|
|
47
|
+
### Loop safety and echo suppression
|
|
48
|
+
|
|
49
|
+
A round-trip source → engine → source → engine terminates because the engine's echo gate makes ingest idempotent: re-observing bytes the engine already holds produces no commit. `syncOut()` adds a coarse filter on top — it **skips every `observed`-origin digest**, since those came from a source and there is nothing to push back — leaving only `api`/`import` (engine-authored) changes to write out. The `contentHash` in the feed and in `readDoc()` is carried for finer per-path suppression but the v1 coordinator does not yet compare it; the durable per-path hash store (`sync_state`) is the deferred piece below.
|
|
50
|
+
|
|
51
|
+
## The identity rule
|
|
52
|
+
|
|
53
|
+
**A repo owns identity and history, not a filesystem.** A directory is *one attached `fs` source* (`omg source add`), never the repo's essence; block ids live in the DB and never in files. Because the DB already holds a byte-exact representation of every document (`docs_read` reconstructs the file), a server can run with no working tree at all — a **sourceless / headless** repo, created via the library (`ensureRepo(store, slug, null)`), where mutations commit to the DB and skip the file write. Getting bytes in and out of such a repo is precisely this package's job: `observe*` in, `changes_since` + `docs_read` out.
|
|
54
|
+
|
|
55
|
+
## Library API
|
|
56
|
+
|
|
57
|
+
Exports from `src/index.ts`:
|
|
58
|
+
|
|
59
|
+
- `Coordinator` — `syncIn()`, `reconcile(paths)`, `syncOut(cursor?)`, `watchIn({onSummary, onError})`; result types `SyncInSummary` (`ingested`, `suppressed`, `conflicted`, `deleted`) and `SyncOutSummary` (`cursor`, `written`, `removed`).
|
|
60
|
+
- `EngineClient` (interface: `observeMany`, `observeDelete`, `changesSince`, `readDoc`, `close`), `ChangesPage`, `DocBytes`, `InProcessEngineClient`.
|
|
61
|
+
- `McpEngineClient` (also exposes `callTool(name, args)` for arbitrary tools), `connectStdioEngine({command, args, env})`, `connectHttpEngine({url, headers})` — the HTTP form uses the URL verbatim (a secret base path authenticates by itself) and sends `headers` on every request.
|
|
62
|
+
- `runFsMirror(opts)`, `FsMirrorOptions` — what the bin and `omg sync --server` call.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { Coordinator, InProcessEngineClient, connectStdioEngine, connectHttpEngine } from "@omgbase/sync";
|
|
66
|
+
import { createExternalSource } from "@omgbase/core";
|
|
67
|
+
import { fsAdapterBinPath } from "@omgbase/fs-adapter";
|
|
68
|
+
import { execPath } from "node:process";
|
|
69
|
+
|
|
70
|
+
const source = await createExternalSource({ command: execPath, args: [fsAdapterBinPath(), "--root", "/path/to/vault"] });
|
|
71
|
+
|
|
72
|
+
const engine = new InProcessEngineClient(store, repoId); // local Store …
|
|
73
|
+
// const engine = await connectStdioEngine({ command: "omg", args: ["mcp", "-C", "/srv/notes"] }); // … or spawned server
|
|
74
|
+
// const engine = await connectHttpEngine({ url: "https://host/k/<secret>/mcp", headers: { Authorization: "Bearer …" } });
|
|
75
|
+
|
|
76
|
+
const coord = new Coordinator(engine, source);
|
|
77
|
+
const inSummary = await coord.syncIn(); // source → engine (full scope, echo-suppressed)
|
|
78
|
+
const { cursor } = await coord.syncOut(); // engine → source (engine-authored changes only)
|
|
79
|
+
const sub = await coord.watchIn({ onSummary: (s) => console.log(s) }); // null if the source can't watch
|
|
80
|
+
// later: await sub?.stop(); await source.close(); await engine.close();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Any object implementing `SyncSource` from `@omgbase/core` works as the source — the tests use an in-memory one — so a non-filesystem store needs no adapter process to be exercised.
|
|
84
|
+
|
|
85
|
+
## Cursors, checkpoints, and what is deferred
|
|
86
|
+
|
|
87
|
+
- `syncIn()` / `reconcile()` need no cursor: the source reports paths, the engine echo-gates. On the local in-process path the engine also records a `checkpoints` row per batch; over MCP the server commits, the client keeps nothing.
|
|
88
|
+
- `syncOut(cursor?)` walks `changes_since` from `cursor` (default 0 — i.e. the whole history on a fresh start), paging while `truncated`, and returns the last `seq` as the new cursor. **The cursor is per-session**: neither the bin nor `runFsMirror` persists it, and `watchIn()` is inbound-only, so a long-running mirror does not stream engine-authored changes out. Persisting the export cursor (and per-path revision) in the reserved `sync_state` table is the deferred follow-up recorded in `docs/sync-service-design.md` §9 Stage 4 "Deferred".
|
|
89
|
+
- A push feed (`subscribe`, via MCP resource-update notifications) is also deferred; polling `changes_since` is the supported way to notice engine-side changes.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT — see [LICENSE](./LICENSE).
|
package/dist/src/bin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
+
import { parseEngineSpec } from "./mcp-engine-client.js";
|
|
4
5
|
import { runFsMirror } from "./mirror.js";
|
|
5
6
|
// `omgbase-sync` — the standalone synchronizer (ADR-014). A thin wrapper over
|
|
6
7
|
// `runFsMirror`: mirror a filesystem directory against an omgbase repo served
|
|
@@ -10,11 +11,12 @@ import { runFsMirror } from "./mirror.js";
|
|
|
10
11
|
// omgbase-sync --root ./vault # ingest the tree into the served repo
|
|
11
12
|
// omgbase-sync --root ./vault --out # also export engine-authored changes back
|
|
12
13
|
// omgbase-sync --root ./vault --watch # stay live: mirror edits as they land
|
|
13
|
-
// omgbase-sync --root ./vault --server "omg mcp -C ./vault" # custom
|
|
14
|
+
// omgbase-sync --root ./vault --server "omg mcp -C ./vault" # custom server command (spawned, stdio)
|
|
15
|
+
// omgbase-sync --root ./vault --server https://host/k/<secret>/mcp # remote server (Streamable HTTP)
|
|
14
16
|
function usage() {
|
|
15
|
-
process.stderr.write("usage: omgbase-sync --root <dir> [--server \"<cmd>\"] [--out] [--watch]\n" +
|
|
16
|
-
" --root <dir>
|
|
17
|
-
" --server <cmd> MCP server command to spawn (default: omg mcp -C <root>)\n" +
|
|
17
|
+
process.stderr.write("usage: omgbase-sync --root <dir> [--server \"<cmd>\"|<url>] [--out] [--watch]\n" +
|
|
18
|
+
" --root <dir> filesystem directory to mirror (required)\n" +
|
|
19
|
+
" --server <cmd|url> MCP server: an http(s) URL (Streamable HTTP), else a command to spawn (default: omg mcp -C <root>)\n" +
|
|
18
20
|
" --out also export engine-authored changes back to the directory\n" +
|
|
19
21
|
" --watch stay live and mirror edits as they land\n");
|
|
20
22
|
}
|
|
@@ -33,7 +35,7 @@ async function main() {
|
|
|
33
35
|
process.exit(values.help ? 0 : 2);
|
|
34
36
|
}
|
|
35
37
|
const root = resolve(values.root);
|
|
36
|
-
const server = (values.server ?? `omg mcp -C ${root}`)
|
|
38
|
+
const server = parseEngineSpec(values.server ?? `omg mcp -C ${root}`);
|
|
37
39
|
await runFsMirror({
|
|
38
40
|
server,
|
|
39
41
|
root,
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { type EngineClient, type ChangesPage, type DocBytes, InProcessEngineClient } from "./engine-client.js";
|
|
2
|
-
export { McpEngineClient, connectStdioEngine, connectHttpEngine } from "./mcp-engine-client.js";
|
|
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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// protocol) with an omgbase repo reached through an EngineClient seam:
|
|
4
4
|
// InProcessEngineClient (local, direct Store) or McpEngineClient (remote, MCP).
|
|
5
5
|
export { InProcessEngineClient } from "./engine-client.js";
|
|
6
|
-
export { McpEngineClient, connectStdioEngine, connectHttpEngine } from "./mcp-engine-client.js";
|
|
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";
|
|
9
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -37,3 +37,26 @@ export declare function connectHttpEngine(spec: {
|
|
|
37
37
|
url: string;
|
|
38
38
|
headers?: Record<string, string>;
|
|
39
39
|
}): Promise<McpEngineClient>;
|
|
40
|
+
/** Where a `--server` value points: an http(s) endpoint (Streamable HTTP, with
|
|
41
|
+
* optional extra request headers) or a command to spawn and speak stdio to. */
|
|
42
|
+
export type EngineSpec = {
|
|
43
|
+
kind: "http";
|
|
44
|
+
url: string;
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "stdio";
|
|
48
|
+
command: string;
|
|
49
|
+
args: string[];
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Classify a raw `--server` value. The rule, shared by every `--server` consumer:
|
|
53
|
+
* a value starting with `http://` or `https://` is an HTTP endpoint (used
|
|
54
|
+
* verbatim, including any secret base path; `headers` ride along); anything
|
|
55
|
+
* else is split on whitespace and spawned as a stdio MCP server command (e.g.
|
|
56
|
+
* `"omg mcp -C /vault"`). A URL is therefore never spawned. Throws on an empty
|
|
57
|
+
* value, or on headers given for a stdio command (they have nowhere to go).
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseEngineSpec(raw: string, headers?: Record<string, string>): EngineSpec;
|
|
60
|
+
/** Connect to the engine an `EngineSpec` names, over the transport its kind
|
|
61
|
+
* selects. The single dispatch point behind `omg --server` and `runFsMirror`. */
|
|
62
|
+
export declare function connectEngine(spec: EngineSpec): Promise<McpEngineClient>;
|
|
@@ -83,4 +83,32 @@ export async function connectHttpEngine(spec) {
|
|
|
83
83
|
await client.connect(transport);
|
|
84
84
|
return new McpEngineClient(client);
|
|
85
85
|
}
|
|
86
|
+
const HTTP_URL_RE = /^https?:\/\//i;
|
|
87
|
+
/**
|
|
88
|
+
* Classify a raw `--server` value. The rule, shared by every `--server` consumer:
|
|
89
|
+
* a value starting with `http://` or `https://` is an HTTP endpoint (used
|
|
90
|
+
* verbatim, including any secret base path; `headers` ride along); anything
|
|
91
|
+
* else is split on whitespace and spawned as a stdio MCP server command (e.g.
|
|
92
|
+
* `"omg mcp -C /vault"`). A URL is therefore never spawned. Throws on an empty
|
|
93
|
+
* value, or on headers given for a stdio command (they have nowhere to go).
|
|
94
|
+
*/
|
|
95
|
+
export function parseEngineSpec(raw, headers) {
|
|
96
|
+
const spec = raw.trim();
|
|
97
|
+
const hasHeaders = headers !== undefined && Object.keys(headers).length > 0;
|
|
98
|
+
if (HTTP_URL_RE.test(spec))
|
|
99
|
+
return { kind: "http", url: spec, ...(hasHeaders ? { headers } : {}) };
|
|
100
|
+
if (hasHeaders)
|
|
101
|
+
throw new Error("extra headers only apply to an http(s) server url, not a command to spawn");
|
|
102
|
+
const argv = spec.split(/\s+/).filter(Boolean);
|
|
103
|
+
if (argv.length === 0)
|
|
104
|
+
throw new Error('server spec is empty — expected a command to spawn (e.g. "omg mcp -C /vault") or an http(s) URL');
|
|
105
|
+
return { kind: "stdio", command: argv[0], args: argv.slice(1) };
|
|
106
|
+
}
|
|
107
|
+
/** Connect to the engine an `EngineSpec` names, over the transport its kind
|
|
108
|
+
* selects. The single dispatch point behind `omg --server` and `runFsMirror`. */
|
|
109
|
+
export function connectEngine(spec) {
|
|
110
|
+
return spec.kind === "http"
|
|
111
|
+
? connectHttpEngine({ url: spec.url, ...(spec.headers ? { headers: spec.headers } : {}) })
|
|
112
|
+
: connectStdioEngine({ command: spec.command, args: spec.args });
|
|
113
|
+
}
|
|
86
114
|
//# sourceMappingURL=mcp-engine-client.js.map
|
package/dist/src/mirror.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { type EngineSpec } from "./mcp-engine-client.js";
|
|
1
2
|
export interface FsMirrorOptions {
|
|
2
|
-
/**
|
|
3
|
-
|
|
3
|
+
/** The engine to mirror into: a parsed `EngineSpec` (`parseEngineSpec` — an
|
|
4
|
+
* http(s) URL over Streamable HTTP, or a command over stdio), or a bare
|
|
5
|
+
* command argv such as ["omg","mcp","-C","/vault"] (always spawned). */
|
|
6
|
+
server: EngineSpec | string[];
|
|
4
7
|
/** Filesystem directory to mirror (absolute). */
|
|
5
8
|
root: string;
|
|
6
9
|
/** Stay live after the initial sync, mirroring edits as they land. */
|
package/dist/src/mirror.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execPath } from "node:process";
|
|
2
2
|
import { createExternalSource } from "@omgbase/core";
|
|
3
3
|
import { fsAdapterBinPath } from "@omgbase/fs-adapter";
|
|
4
|
-
import {
|
|
4
|
+
import { connectEngine } from "./mcp-engine-client.js";
|
|
5
5
|
import { Coordinator } from "./coordinator.js";
|
|
6
6
|
/**
|
|
7
7
|
* Run one filesystem↔engine mirror over MCP. Resolves when the initial sync (+
|
|
@@ -10,7 +10,10 @@ import { Coordinator } from "./coordinator.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export async function runFsMirror(opts) {
|
|
12
12
|
const log = opts.log ?? (() => { });
|
|
13
|
-
const
|
|
13
|
+
const spec = Array.isArray(opts.server)
|
|
14
|
+
? { kind: "stdio", command: opts.server[0], args: opts.server.slice(1) }
|
|
15
|
+
: opts.server;
|
|
16
|
+
const engine = await connectEngine(spec);
|
|
14
17
|
const source = await createExternalSource({ command: execPath, args: [fsAdapterBinPath(), "--root", opts.root] });
|
|
15
18
|
const coord = new Coordinator(engine, source);
|
|
16
19
|
const shutdown = async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omgbase/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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.3.0",
|
|
57
57
|
"@omgbase/fs-adapter": "^0.1.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|