cursedbelt 2.6.1 → 2.7.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/dist/server/sync/alarm.d.ts +35 -0
- package/dist/server/sync/alarm.d.ts.map +1 -0
- package/dist/server/sync/alarm.js +92 -0
- package/dist/server/sync/alarm.js.map +1 -0
- package/dist/server/sync/commands.d.ts +88 -0
- package/dist/server/sync/commands.d.ts.map +1 -0
- package/dist/server/sync/commands.js +242 -0
- package/dist/server/sync/commands.js.map +1 -0
- package/dist/server/sync/engine.d.ts +63 -0
- package/dist/server/sync/engine.d.ts.map +1 -0
- package/dist/server/sync/engine.js +185 -0
- package/dist/server/sync/engine.js.map +1 -0
- package/dist/server/sync/http.d.ts +107 -0
- package/dist/server/sync/http.d.ts.map +1 -0
- package/dist/server/sync/http.js +244 -0
- package/dist/server/sync/http.js.map +1 -0
- package/dist/server/sync/index.d.ts +42 -0
- package/dist/server/sync/index.d.ts.map +1 -0
- package/dist/server/sync/index.js +42 -0
- package/dist/server/sync/index.js.map +1 -0
- package/dist/server/sync/opLog.d.ts +62 -0
- package/dist/server/sync/opLog.d.ts.map +1 -0
- package/dist/server/sync/opLog.js +97 -0
- package/dist/server/sync/opLog.js.map +1 -0
- package/dist/server/sync/planner.d.ts +32 -0
- package/dist/server/sync/planner.d.ts.map +1 -0
- package/dist/server/sync/planner.js +31 -0
- package/dist/server/sync/planner.js.map +1 -0
- package/dist/server/sync/status.d.ts +64 -0
- package/dist/server/sync/status.d.ts.map +1 -0
- package/dist/server/sync/status.js +50 -0
- package/dist/server/sync/status.js.map +1 -0
- package/dist/server/sync/timer.d.ts +53 -0
- package/dist/server/sync/timer.d.ts.map +1 -0
- package/dist/server/sync/timer.js +171 -0
- package/dist/server/sync/timer.js.map +1 -0
- package/dist/server/sync/tokens.d.ts +26 -0
- package/dist/server/sync/tokens.d.ts.map +1 -0
- package/dist/server/sync/tokens.js +52 -0
- package/dist/server/sync/tokens.js.map +1 -0
- package/dist/server/sync/types.d.ts +102 -0
- package/dist/server/sync/types.d.ts.map +1 -0
- package/dist/server/sync/types.js +30 -0
- package/dist/server/sync/types.js.map +1 -0
- package/package.json +19 -13
- package/src/server/sync/alarm.spec.ts +149 -0
- package/src/server/sync/alarm.ts +137 -0
- package/src/server/sync/commands.spec.ts +145 -0
- package/src/server/sync/commands.ts +361 -0
- package/src/server/sync/engine.spec.ts +496 -0
- package/src/server/sync/engine.ts +255 -0
- package/src/server/sync/http.ts +316 -0
- package/src/server/sync/httpRemote.spec.ts +158 -0
- package/src/server/sync/index.ts +90 -0
- package/src/server/sync/opLog.spec.ts +110 -0
- package/src/server/sync/opLog.ts +189 -0
- package/src/server/sync/planner.spec.ts +53 -0
- package/src/server/sync/planner.ts +62 -0
- package/src/server/sync/status.spec.ts +92 -0
- package/src/server/sync/status.ts +108 -0
- package/src/server/sync/timer.spec.ts +150 -0
- package/src/server/sync/timer.ts +207 -0
- package/src/server/sync/tokens.spec.ts +38 -0
- package/src/server/sync/tokens.ts +94 -0
- package/src/server/sync/types.ts +108 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createHttpRemote` — the dialer's transport, and the three properties that
|
|
3
|
+
* came out of an eight-day silent outage.
|
|
4
|
+
*
|
|
5
|
+
* ── What happened (2026-08-21) ───────────────────────────────────────────────
|
|
6
|
+
* The console merge on 2026-08-20 made `station.cursedalchemy.com` a **301** to
|
|
7
|
+
* `station.cursedalchemy.com`. The Mac's `STATION_SYNC_URL` still named the old
|
|
8
|
+
* host. `fetch` follows redirects by default, so every sync round went:
|
|
9
|
+
*
|
|
10
|
+
* POST station…/api/sync/push → 301
|
|
11
|
+
* POST admin…/api/sync/push → 302 (Cloudflare Access, no credential)
|
|
12
|
+
* GET cloudflareaccess.com/… → 200 text/html ← the login page
|
|
13
|
+
* r.json() → "Failed to parse JSON"
|
|
14
|
+
*
|
|
15
|
+
* The timer logs that as `[sync] failed (ignored)` and retries forever. Nothing
|
|
16
|
+
* was red — not a gate, not a smoke, not `/healthz` — while every mirrored
|
|
17
|
+
* report on the prod console froze at the moment of the cutover and each one
|
|
18
|
+
* kept rendering a confident `capturedAt` age.
|
|
19
|
+
*
|
|
20
|
+
* Three separate defenses, because any one of them alone would have left this
|
|
21
|
+
* failure recoverable-but-invisible:
|
|
22
|
+
*
|
|
23
|
+
* 1. redirects are REFUSED and name their destination;
|
|
24
|
+
* 2. a non-JSON body reports what actually arrived;
|
|
25
|
+
* 3. the peer's Access credential is carried, so the console's own receiver is
|
|
26
|
+
* reachable at all.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, expect, test } from "bun:test";
|
|
29
|
+
import { createHttpRemote } from "./http";
|
|
30
|
+
|
|
31
|
+
/** A fetch that records what it was asked and answers a canned response. */
|
|
32
|
+
function fakeFetch(reply: (url: string, init?: RequestInit) => Response) {
|
|
33
|
+
const seen: Array<{ url: string; init?: RequestInit }> = [];
|
|
34
|
+
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
35
|
+
seen.push({ url: String(url), ...(init ? { init } : {}) });
|
|
36
|
+
return reply(String(url), init);
|
|
37
|
+
}) as unknown as typeof fetch;
|
|
38
|
+
return { impl, seen };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The one request the fake saw. Throws rather than optional-chaining into a
|
|
42
|
+
* cast: a missing request means the remote never called out, which is a
|
|
43
|
+
* different failure from the one under test and must say so. */
|
|
44
|
+
const onlyRequest = (seen: Array<{ url: string; init?: RequestInit }>): RequestInit => {
|
|
45
|
+
const first = seen[0];
|
|
46
|
+
if (!first?.init) throw new Error(`expected one request, saw ${seen.length}`);
|
|
47
|
+
return first.init;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const remoteWith = (impl: typeof fetch, extraHeaders?: Record<string, string>) =>
|
|
51
|
+
createHttpRemote({
|
|
52
|
+
basePath: "https://admin.example.com/api/sync",
|
|
53
|
+
token: "tok",
|
|
54
|
+
selfId: "mac",
|
|
55
|
+
fetchImpl: impl,
|
|
56
|
+
...(extraHeaders ? { extraHeaders } : {}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("a redirect is a refusal, never something to follow", () => {
|
|
60
|
+
test("a 301 names the destination and says the address has moved", async () => {
|
|
61
|
+
const { impl } = fakeFetch(
|
|
62
|
+
() =>
|
|
63
|
+
new Response("", {
|
|
64
|
+
status: 301,
|
|
65
|
+
headers: { location: "https://admin.example.com/api/sync/info" },
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const err = await remoteWith(impl)
|
|
69
|
+
.info()
|
|
70
|
+
.catch((e: unknown) => e as Error);
|
|
71
|
+
expect((err as Error).message).toContain("301");
|
|
72
|
+
expect((err as Error).message).toContain("https://admin.example.com/api/sync/info");
|
|
73
|
+
expect((err as Error).message).toContain("host that has moved");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("`redirect: manual` is actually requested — the default would follow", async () => {
|
|
77
|
+
// This is the whole fix. Asserting the message alone would pass against an
|
|
78
|
+
// implementation that follows and then happens to get a 3xx back.
|
|
79
|
+
const { impl, seen } = fakeFetch(() => new Response("{}", { status: 200 }));
|
|
80
|
+
await remoteWith(impl).info();
|
|
81
|
+
expect(onlyRequest(seen).redirect).toBe("manual");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a 302 with no Location still refuses, rather than throwing about undefined", async () => {
|
|
85
|
+
const { impl } = fakeFetch(() => new Response("", { status: 302 }));
|
|
86
|
+
const err = await remoteWith(impl)
|
|
87
|
+
.info()
|
|
88
|
+
.catch((e: unknown) => e as Error);
|
|
89
|
+
expect((err as Error).message).toContain("an unnamed destination");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("a non-JSON body says what actually arrived", () => {
|
|
94
|
+
test("an Access login page reports its status and content type, not a parse error", async () => {
|
|
95
|
+
const { impl } = fakeFetch(
|
|
96
|
+
() =>
|
|
97
|
+
new Response("<!DOCTYPE html><html><head><title>Sign in</title>", {
|
|
98
|
+
status: 200,
|
|
99
|
+
headers: { "content-type": "text/html; charset=UTF-8" },
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
const err = await remoteWith(impl)
|
|
103
|
+
.info()
|
|
104
|
+
.catch((e: unknown) => e as Error);
|
|
105
|
+
const message = (err as Error).message;
|
|
106
|
+
// 🔴 The old message was exactly "Failed to parse JSON", which names the
|
|
107
|
+
// last layer and none of the cause.
|
|
108
|
+
expect(message).not.toBe("Failed to parse JSON");
|
|
109
|
+
expect(message).toContain("text/html");
|
|
110
|
+
expect(message).toContain("sync/info");
|
|
111
|
+
expect(message).toContain("Sign in");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("an empty 200 body says so rather than showing nothing", async () => {
|
|
115
|
+
const { impl } = fakeFetch(() => new Response("", { status: 200 }));
|
|
116
|
+
const err = await remoteWith(impl)
|
|
117
|
+
.info()
|
|
118
|
+
.catch((e: unknown) => e as Error);
|
|
119
|
+
expect((err as Error).message).toContain("(empty body)");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("real JSON still parses, on every verb", async () => {
|
|
123
|
+
const { impl } = fakeFetch((url) =>
|
|
124
|
+
url.includes("/push")
|
|
125
|
+
? new Response(JSON.stringify({ applied: 1, error: null }), { status: 200 })
|
|
126
|
+
: new Response(JSON.stringify({ ops: [], cursor: 0, head: 0 }), { status: 200 }),
|
|
127
|
+
);
|
|
128
|
+
const remote = remoteWith(impl);
|
|
129
|
+
expect(await remote.pull(0, "mac")).toEqual({ ops: [], cursor: 0, head: 0 });
|
|
130
|
+
expect((await remote.push({ ops: [] } as never)).applied).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("the peer's own credential travels with the request", () => {
|
|
135
|
+
test("extraHeaders are sent alongside the bearer", async () => {
|
|
136
|
+
const { impl, seen } = fakeFetch(() => new Response("{}", { status: 200 }));
|
|
137
|
+
await remoteWith(impl, { "CF-Access-Client-Id": "id", "CF-Access-Client-Secret": "s" }).info();
|
|
138
|
+
const headers = onlyRequest(seen).headers as Record<string, string>;
|
|
139
|
+
expect(headers["CF-Access-Client-Id"]).toBe("id");
|
|
140
|
+
expect(headers.authorization).toBe("Bearer tok");
|
|
141
|
+
expect(headers["x-sync-peer"]).toBe("mac");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("no extraHeaders is inert — nothing extra is sent", async () => {
|
|
145
|
+
const { impl, seen } = fakeFetch(() => new Response("{}", { status: 200 }));
|
|
146
|
+
await remoteWith(impl).info();
|
|
147
|
+
const headers = onlyRequest(seen).headers as Record<string, string>;
|
|
148
|
+
expect(Object.keys(headers).sort()).toEqual(["authorization", "x-sync-peer"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a 401 still reports the token refusal, not a redirect", async () => {
|
|
152
|
+
const { impl } = fakeFetch(() => new Response("", { status: 401 }));
|
|
153
|
+
const err = await remoteWith(impl)
|
|
154
|
+
.info()
|
|
155
|
+
.catch((e: unknown) => e as Error);
|
|
156
|
+
expect((err as Error).message).toContain("the sync token was refused");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt/sync` — the op-log replication engine, shared rather than forked.
|
|
3
|
+
*
|
|
4
|
+
* ## Why it is here and not in the app
|
|
5
|
+
*
|
|
6
|
+
* The retired tree had FOUR consumers of this engine: `apps/vault` (8 files),
|
|
7
|
+
* `apps/station` (6), `apps/notes` (4) and `packages/feedback-kit` (5). `vault`
|
|
8
|
+
* graduated first and carried all 3,245 lines in whole, as
|
|
9
|
+
* `apps/vault/src/kit/sync/`, because a Phase A that also publishes a library is a
|
|
10
|
+
* Phase A that does not land. This is the other half of that trade, and it had a
|
|
11
|
+
* DEADLINE rather than a backlog slot: the moment `apps/station` graduates with the
|
|
12
|
+
* copy still in `vault`, the generation owns two divergent forks of a replication
|
|
13
|
+
* engine — the exact shape the owner ruled against on 2026-09-13, *"if we don't
|
|
14
|
+
* adapt to this at the right time then it will be hard to remember to keep updating
|
|
15
|
+
* multiple copies as the apps move and grow."*
|
|
16
|
+
*
|
|
17
|
+
* ## 🔴 A LEAF export, and it must stay one
|
|
18
|
+
*
|
|
19
|
+
* `cursedbelt/sync` resolves straight to this directory. It is deliberately NOT
|
|
20
|
+
* reachable through the `cursedbelt/server` barrel, which drags `kysely` and
|
|
21
|
+
* `otplib` behind it — an app that wants an op-log should not install two
|
|
22
|
+
* databases to get one. The only runtime dependency beneath this subpath is
|
|
23
|
+
* `hono` (for the receiver routes in `http.ts` and `commands.ts`); everything else
|
|
24
|
+
* is `bun:sqlite`, `node:crypto` and `node:fs`.
|
|
25
|
+
*
|
|
26
|
+
* It lives under `src/server/` because that is where this package's two typecheck
|
|
27
|
+
* projects put Bun's ambient types: the root `tsconfig.json` runs `types: []` to
|
|
28
|
+
* keep the browser/React surface pristine and EXCLUDES `src/server`, so
|
|
29
|
+
* `bun:sqlite` and `node:crypto` only resolve on this side of the split. The
|
|
30
|
+
* directory decides the types; the `exports` key decides the spelling.
|
|
31
|
+
*/
|
|
32
|
+
export {
|
|
33
|
+
StopBatch,
|
|
34
|
+
type ApplyContext,
|
|
35
|
+
type ApplyOne,
|
|
36
|
+
type ApplyReport,
|
|
37
|
+
type PeerInfo,
|
|
38
|
+
type RemoteApi,
|
|
39
|
+
type SyncOp,
|
|
40
|
+
type SyncSummary,
|
|
41
|
+
} from "./types";
|
|
42
|
+
export { createOpLog, type OpLog } from "./opLog";
|
|
43
|
+
export { createTokenStore, type DeviceToken, type TokenStore } from "./tokens";
|
|
44
|
+
export {
|
|
45
|
+
createSyncApplier,
|
|
46
|
+
pushToRemote,
|
|
47
|
+
pullFromRemote,
|
|
48
|
+
runSync,
|
|
49
|
+
type EngineOptions,
|
|
50
|
+
type ExportPolicy,
|
|
51
|
+
type PeerRefusal,
|
|
52
|
+
} from "./engine";
|
|
53
|
+
export {
|
|
54
|
+
capPageBytes,
|
|
55
|
+
createHttpRemote,
|
|
56
|
+
createSyncReceiver,
|
|
57
|
+
type ReceiverHooks,
|
|
58
|
+
type ReceiverOptions,
|
|
59
|
+
} from "./http";
|
|
60
|
+
export {
|
|
61
|
+
DEFAULT_LOOP,
|
|
62
|
+
isUnreachable,
|
|
63
|
+
planNextSync,
|
|
64
|
+
type SyncLoopConfig,
|
|
65
|
+
type SyncLoopState,
|
|
66
|
+
} from "./planner";
|
|
67
|
+
export {
|
|
68
|
+
OFFLINE_STATUS,
|
|
69
|
+
PEER_CONTACT_WRITE_MS,
|
|
70
|
+
createSyncStatusReporter,
|
|
71
|
+
type SyncStatus,
|
|
72
|
+
type SyncStatusReporter,
|
|
73
|
+
type SyncStatusStore,
|
|
74
|
+
} from "./status";
|
|
75
|
+
export { REQUEST_POLL_MS, startSyncTimer, type SyncTimerDeps, type SyncTimerHandle } from "./timer";
|
|
76
|
+
export { createSyncAlarm, type AlarmOutcome, type SyncAlarm, type SyncAlarmOptions } from "./alarm";
|
|
77
|
+
export {
|
|
78
|
+
DEFAULT_HOLD_MS,
|
|
79
|
+
createCommandQueue,
|
|
80
|
+
createCommandReceiver,
|
|
81
|
+
createCommandRemote,
|
|
82
|
+
startCommandWorker,
|
|
83
|
+
type CommandQueue,
|
|
84
|
+
type CommandRemote,
|
|
85
|
+
type CommandRoutesOptions,
|
|
86
|
+
type CommandStatus,
|
|
87
|
+
type CommandWorkerDeps,
|
|
88
|
+
type CommandWorkerHandle,
|
|
89
|
+
type SyncCommand,
|
|
90
|
+
} from "./commands";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { createOpLog } from "./opLog";
|
|
4
|
+
|
|
5
|
+
describe("opLog", () => {
|
|
6
|
+
test("instance id is minted once and stable across reopen", () => {
|
|
7
|
+
const db = new Database(":memory:");
|
|
8
|
+
const a = createOpLog(db);
|
|
9
|
+
const b = createOpLog(db);
|
|
10
|
+
expect(a.instanceId()).toBe(b.instanceId());
|
|
11
|
+
expect(a.instanceId()).toMatch(/^i-/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("recordLocal assigns monotonic seq + originSeq and round-trips payloads", () => {
|
|
15
|
+
const log = createOpLog(new Database(":memory:"));
|
|
16
|
+
const one = log.recordLocal("doc.set", { path: "a.md", body: "x" });
|
|
17
|
+
const two = log.recordLocal("doc.set", { path: "b.md" });
|
|
18
|
+
expect(two.seq).toBeGreaterThan(one.seq);
|
|
19
|
+
expect(one.originSeq).toBe(1);
|
|
20
|
+
expect(two.originSeq).toBe(2);
|
|
21
|
+
const page = log.listAfter(0, "someone-else", 10);
|
|
22
|
+
expect(page.ops.map((o) => (o.payload as { path: string }).path)).toEqual(["a.md", "b.md"]);
|
|
23
|
+
expect(page.head).toBe(two.seq);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("listAfter excludes the peer's own ops and still advances the cursor past them", () => {
|
|
27
|
+
const log = createOpLog(new Database(":memory:"));
|
|
28
|
+
log.recordRemote({ seq: 0, origin: "peer-1", originSeq: 1, ts: 1, kind: "k", payload: null });
|
|
29
|
+
log.recordRemote({ seq: 0, origin: "peer-1", originSeq: 2, ts: 2, kind: "k", payload: null });
|
|
30
|
+
const page = log.listAfter(0, "peer-1", 10);
|
|
31
|
+
expect(page.ops).toHaveLength(0);
|
|
32
|
+
// The window was all the peer's own reflections — cursor jumps to head so the
|
|
33
|
+
// peer never re-pages a stream made of itself.
|
|
34
|
+
expect(page.cursor).toBe(log.head());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("(origin, originSeq) is unique — a duplicate remote insert throws", () => {
|
|
38
|
+
const log = createOpLog(new Database(":memory:"));
|
|
39
|
+
const op = { seq: 0, origin: "p", originSeq: 7, ts: 1, kind: "k", payload: null };
|
|
40
|
+
log.recordRemote(op);
|
|
41
|
+
expect(log.has("p", 7)).toBe(true);
|
|
42
|
+
expect(() => log.recordRemote(op)).toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("cursors are per-peer, per-direction, durable", () => {
|
|
46
|
+
const db = new Database(":memory:");
|
|
47
|
+
const log = createOpLog(db);
|
|
48
|
+
expect(log.cursor("p", "push")).toBe(0);
|
|
49
|
+
log.setCursor("p", "push", 41);
|
|
50
|
+
log.setCursor("p", "pull", 7);
|
|
51
|
+
expect(createOpLog(db).cursor("p", "push")).toBe(41);
|
|
52
|
+
expect(log.cursor("p", "pull")).toBe(7);
|
|
53
|
+
expect(log.cursor("q", "push")).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// The receiver-side leak that put 963 MB of superseded `overview` snapshots on the
|
|
57
|
+
// receiving half (then the `prod-ec2` box): compactOwn is scoped to
|
|
58
|
+
// `origin = instanceId`, so an instance that
|
|
59
|
+
// only RECEIVES a state kind never compacts it.
|
|
60
|
+
const snap = (originSeq: number, key: string) => ({
|
|
61
|
+
seq: 0,
|
|
62
|
+
origin: "mac",
|
|
63
|
+
originSeq,
|
|
64
|
+
ts: originSeq,
|
|
65
|
+
kind: "snapshot.set",
|
|
66
|
+
payload: { key, data: `v${originSeq}` },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("compactOwn does NOT touch a peer's ops — the leak this reproduces", () => {
|
|
70
|
+
const log = createOpLog(new Database(":memory:"));
|
|
71
|
+
for (const n of [1, 2, 3]) log.recordRemote(snap(n, "overview"));
|
|
72
|
+
expect(log.compactOwn("snapshot.set", 99, '%"key":"overview"%')).toBe(0);
|
|
73
|
+
expect(log.listAfter(0, "nobody", 10).ops).toHaveLength(3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("compactOrigin drops a peer's superseded state ops, keeping the newest", () => {
|
|
77
|
+
const log = createOpLog(new Database(":memory:"));
|
|
78
|
+
for (const n of [1, 2, 3]) log.recordRemote(snap(n, "overview"));
|
|
79
|
+
expect(log.compactOrigin("mac", "snapshot.set", 3, '%"key":"overview"%')).toBe(2);
|
|
80
|
+
const ops = log.listAfter(0, "nobody", 10).ops;
|
|
81
|
+
expect(ops).toHaveLength(1);
|
|
82
|
+
expect(ops[0]?.originSeq).toBe(3);
|
|
83
|
+
expect((ops[0]?.payload as { data: string } | undefined)?.data).toBe("v3");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("compactOrigin is scoped to the origin, the kind and the payload key", () => {
|
|
87
|
+
const log = createOpLog(new Database(":memory:"));
|
|
88
|
+
log.recordRemote(snap(1, "overview"));
|
|
89
|
+
log.recordRemote(snap(2, "overview"));
|
|
90
|
+
log.recordRemote(snap(3, "health")); // different key
|
|
91
|
+
log.recordRemote({ ...snap(4, "overview"), kind: "doc.set" }); // different kind
|
|
92
|
+
log.recordRemote({ ...snap(5, "overview"), origin: "other" }); // different origin
|
|
93
|
+
expect(log.compactOrigin("mac", "snapshot.set", 2, '%"key":"overview"%')).toBe(1);
|
|
94
|
+
const kept = log.listAfter(0, "nobody", 10).ops;
|
|
95
|
+
expect(kept).toHaveLength(4);
|
|
96
|
+
expect(
|
|
97
|
+
kept.some((o) => o.origin === "mac" && o.kind === "snapshot.set" && o.originSeq === 1),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a compacted-away op leaves a seq gap that paging tolerates", () => {
|
|
102
|
+
const log = createOpLog(new Database(":memory:"));
|
|
103
|
+
for (const n of [1, 2, 3]) log.recordRemote(snap(n, "overview"));
|
|
104
|
+
log.compactOrigin("mac", "snapshot.set", 3, '%"key":"overview"%');
|
|
105
|
+
// A peer still parked on the oldest cursor converges on the newest state.
|
|
106
|
+
const page = log.listAfter(0, "nobody", 10);
|
|
107
|
+
expect(page.ops).toHaveLength(1);
|
|
108
|
+
expect(page.cursor).toBe(page.head);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable op log + cursor bookkeeping, over the app's own `bun:sqlite` handle.
|
|
3
|
+
* Fixed table names (`sync_ops`, `sync_cursors`, `sync_meta`) — a consumer of this
|
|
4
|
+
* engine is expected to give sync its own tables rather than remap; the notes/vault
|
|
5
|
+
* retrofit maps their legacy tables behind this interface, not the other way round.
|
|
6
|
+
*
|
|
7
|
+
* Identity: `(origin, origin_seq)` UNIQUE — exactly-once application with no peer
|
|
8
|
+
* coordination (the notes ledger shape). `seq` is this instance's own monotonic
|
|
9
|
+
* stream every peer pages over.
|
|
10
|
+
*/
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import type { SyncOp } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface OpLog {
|
|
16
|
+
/** This instance's stable id (minted once, persisted in sync_meta). */
|
|
17
|
+
instanceId(): string;
|
|
18
|
+
/** Record a locally-born op; returns it with seq/originSeq assigned. */
|
|
19
|
+
recordLocal(kind: string, payload: unknown, ts?: number): SyncOp;
|
|
20
|
+
/** Record a remote op into the ledger (dedupe identity kept). Caller applies state
|
|
21
|
+
* FIRST in the same transaction scope; see `createSyncApplier`. */
|
|
22
|
+
recordRemote(op: SyncOp): void;
|
|
23
|
+
/** Is this (origin, originSeq) already in the ledger? */
|
|
24
|
+
has(origin: string, originSeq: number): boolean;
|
|
25
|
+
/** Page of ops with seq > after, excluding one origin (the peer's own — it already
|
|
26
|
+
* has them), oldest first. `cursor` = last row's seq served (or `after`). */
|
|
27
|
+
listAfter(
|
|
28
|
+
after: number,
|
|
29
|
+
excludeOrigin: string,
|
|
30
|
+
limit: number,
|
|
31
|
+
): { ops: SyncOp[]; cursor: number; head: number };
|
|
32
|
+
/** Highest local seq (0 when empty). */
|
|
33
|
+
head(): number;
|
|
34
|
+
/** Durable per-peer cursors. `direction`: what WE track about the peer. */
|
|
35
|
+
cursor(peerId: string, direction: "push" | "pull" | "served"): number;
|
|
36
|
+
setCursor(peerId: string, direction: "push" | "pull" | "served", seq: number): void;
|
|
37
|
+
/** Compaction for STATE-shaped kinds (a snapshot is not history): delete this
|
|
38
|
+
* origin's OWN earlier ops of `kind` matching `payloadWhere` below `keepSeq`.
|
|
39
|
+
* Safe under the exactly-once ledger — peers dedupe by (origin, originSeq),
|
|
40
|
+
* pull pages tolerate seq gaps, and a deleted op is simply never sent. */
|
|
41
|
+
compactOwn(kind: string, keepSeq: number, payloadLike: string): number;
|
|
42
|
+
/** The same compaction, for a state kind that arrived from a PEER.
|
|
43
|
+
*
|
|
44
|
+
* `compactOwn` is scoped to `origin = instanceId`, so a pure RECEIVER of a
|
|
45
|
+
* state kind matches zero rows and its log grows without bound — prod
|
|
46
|
+
* station reached 5,537 revisions of one `overview` snapshot (963 MB, ~160
|
|
47
|
+
* MB/day) because every one of them originated on the Mac. The safety
|
|
48
|
+
* argument on `compactOwn` never depended on who minted the op, only on the
|
|
49
|
+
* kind being STATE rather than history, so it carries over verbatim.
|
|
50
|
+
*
|
|
51
|
+
* Keyed on `origin_seq`, not local `seq`: a receiver compacts while
|
|
52
|
+
* applying, which happens BEFORE the incoming row is inserted, so there is
|
|
53
|
+
* no local seq yet to compare against. `(origin, origin_seq)` is the
|
|
54
|
+
* ledger's own identity and is monotonic per origin, so it orders the
|
|
55
|
+
* origin's stream exactly as well. */
|
|
56
|
+
compactOrigin(origin: string, kind: string, keepOriginSeq: number, payloadLike: string): number;
|
|
57
|
+
/** Arbitrary sync-scoped metadata (instance id lives here too). */
|
|
58
|
+
getMeta(key: string): string | null;
|
|
59
|
+
setMeta(key: string, value: string): void;
|
|
60
|
+
/** Run `fn` inside one sqlite transaction (apply + ledger-insert atomicity). */
|
|
61
|
+
transaction<T>(fn: () => T): T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const INSTANCE_KEY = "sync.instance_id";
|
|
65
|
+
|
|
66
|
+
export function createOpLog(db: Database): OpLog {
|
|
67
|
+
db.exec(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS sync_ops (
|
|
69
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
70
|
+
origin TEXT NOT NULL,
|
|
71
|
+
origin_seq INTEGER NOT NULL,
|
|
72
|
+
ts INTEGER NOT NULL,
|
|
73
|
+
kind TEXT NOT NULL,
|
|
74
|
+
payload TEXT NOT NULL,
|
|
75
|
+
UNIQUE(origin, origin_seq)
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_sync_ops_origin ON sync_ops(origin, seq);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS sync_cursors (
|
|
79
|
+
peer_id TEXT NOT NULL,
|
|
80
|
+
direction TEXT NOT NULL,
|
|
81
|
+
seq INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
PRIMARY KEY(peer_id, direction)
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
85
|
+
key TEXT PRIMARY KEY,
|
|
86
|
+
value TEXT NOT NULL
|
|
87
|
+
);
|
|
88
|
+
`);
|
|
89
|
+
|
|
90
|
+
const getMetaStmt = db.prepare("SELECT value FROM sync_meta WHERE key = ?");
|
|
91
|
+
const setMetaStmt = db.prepare(
|
|
92
|
+
"INSERT INTO sync_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
93
|
+
);
|
|
94
|
+
const getMeta = (key: string): string | null =>
|
|
95
|
+
(getMetaStmt.get(key) as { value: string } | null)?.value ?? null;
|
|
96
|
+
const setMeta = (key: string, value: string): void => {
|
|
97
|
+
setMetaStmt.run(key, value);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let cachedId = getMeta(INSTANCE_KEY);
|
|
101
|
+
if (cachedId === null) {
|
|
102
|
+
// Short + url/label safe; uniqueness across a two-or-three instance fleet is
|
|
103
|
+
// what matters, not global collision resistance.
|
|
104
|
+
cachedId = `i-${randomUUID().slice(0, 12)}`;
|
|
105
|
+
setMeta(INSTANCE_KEY, cachedId);
|
|
106
|
+
}
|
|
107
|
+
const instanceId = cachedId;
|
|
108
|
+
|
|
109
|
+
const insertOp = db.prepare(
|
|
110
|
+
"INSERT INTO sync_ops (origin, origin_seq, ts, kind, payload) VALUES (?, ?, ?, ?, ?)",
|
|
111
|
+
);
|
|
112
|
+
const hasStmt = db.prepare("SELECT 1 FROM sync_ops WHERE origin = ? AND origin_seq = ? LIMIT 1");
|
|
113
|
+
const headStmt = db.prepare("SELECT COALESCE(MAX(seq), 0) AS head FROM sync_ops");
|
|
114
|
+
const maxOriginSeqStmt = db.prepare(
|
|
115
|
+
"SELECT COALESCE(MAX(origin_seq), 0) AS m FROM sync_ops WHERE origin = ?",
|
|
116
|
+
);
|
|
117
|
+
const listStmt = db.prepare(
|
|
118
|
+
"SELECT seq, origin, origin_seq, ts, kind, payload FROM sync_ops WHERE seq > ? AND origin != ? ORDER BY seq ASC LIMIT ?",
|
|
119
|
+
);
|
|
120
|
+
const compactStmt = db.prepare(
|
|
121
|
+
"DELETE FROM sync_ops WHERE origin = ? AND kind = ? AND seq < ? AND payload LIKE ?",
|
|
122
|
+
);
|
|
123
|
+
const compactOriginStmt = db.prepare(
|
|
124
|
+
"DELETE FROM sync_ops WHERE origin = ? AND kind = ? AND origin_seq < ? AND payload LIKE ?",
|
|
125
|
+
);
|
|
126
|
+
const cursorStmt = db.prepare("SELECT seq FROM sync_cursors WHERE peer_id = ? AND direction = ?");
|
|
127
|
+
const setCursorStmt = db.prepare(
|
|
128
|
+
"INSERT INTO sync_cursors (peer_id, direction, seq) VALUES (?, ?, ?) ON CONFLICT(peer_id, direction) DO UPDATE SET seq = excluded.seq",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
interface OpRow {
|
|
132
|
+
seq: number;
|
|
133
|
+
origin: string;
|
|
134
|
+
origin_seq: number;
|
|
135
|
+
ts: number;
|
|
136
|
+
kind: string;
|
|
137
|
+
payload: string;
|
|
138
|
+
}
|
|
139
|
+
const rowToOp = (row: OpRow): SyncOp => ({
|
|
140
|
+
seq: row.seq,
|
|
141
|
+
origin: row.origin,
|
|
142
|
+
originSeq: row.origin_seq,
|
|
143
|
+
ts: row.ts,
|
|
144
|
+
kind: row.kind,
|
|
145
|
+
payload: JSON.parse(row.payload) as unknown,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
instanceId: () => instanceId,
|
|
150
|
+
recordLocal(kind, payload, ts) {
|
|
151
|
+
const at = ts ?? Date.now();
|
|
152
|
+
const originSeq = (maxOriginSeqStmt.get(instanceId) as { m: number }).m + 1;
|
|
153
|
+
insertOp.run(instanceId, originSeq, at, kind, JSON.stringify(payload ?? null));
|
|
154
|
+
const seq = (headStmt.get() as { head: number }).head;
|
|
155
|
+
return { seq, origin: instanceId, originSeq, ts: at, kind, payload };
|
|
156
|
+
},
|
|
157
|
+
recordRemote(op) {
|
|
158
|
+
insertOp.run(op.origin, op.originSeq, op.ts, op.kind, JSON.stringify(op.payload ?? null));
|
|
159
|
+
},
|
|
160
|
+
has: (origin, originSeq) => hasStmt.get(origin, originSeq) !== null,
|
|
161
|
+
listAfter(after, excludeOrigin, limit) {
|
|
162
|
+
const rows = listStmt.all(after, excludeOrigin, limit) as OpRow[];
|
|
163
|
+
const ops = rows.map(rowToOp);
|
|
164
|
+
const head = (headStmt.get() as { head: number }).head;
|
|
165
|
+
// An all-excluded page must still advance the peer past what it skipped, or a
|
|
166
|
+
// log dominated by the peer's own reflected ops would page forever. Serve the
|
|
167
|
+
// true high-water mark of the WINDOW scanned: when the page is short, we
|
|
168
|
+
// reached head; otherwise the last row's seq.
|
|
169
|
+
const cursor =
|
|
170
|
+
rows.length === 0 ? Math.max(after, head) : (rows[rows.length - 1] as OpRow).seq;
|
|
171
|
+
return { ops, cursor, head };
|
|
172
|
+
},
|
|
173
|
+
head: () => (headStmt.get() as { head: number }).head,
|
|
174
|
+
compactOwn(kind, keepSeq, payloadLike) {
|
|
175
|
+
return compactStmt.run(instanceId, kind, keepSeq, payloadLike).changes;
|
|
176
|
+
},
|
|
177
|
+
compactOrigin(origin, kind, keepOriginSeq, payloadLike) {
|
|
178
|
+
return compactOriginStmt.run(origin, kind, keepOriginSeq, payloadLike).changes;
|
|
179
|
+
},
|
|
180
|
+
cursor: (peerId, direction) =>
|
|
181
|
+
(cursorStmt.get(peerId, direction) as { seq: number } | null)?.seq ?? 0,
|
|
182
|
+
setCursor(peerId, direction, seq) {
|
|
183
|
+
setCursorStmt.run(peerId, direction, seq);
|
|
184
|
+
},
|
|
185
|
+
getMeta,
|
|
186
|
+
setMeta,
|
|
187
|
+
transaction: <T>(fn: () => T): T => db.transaction(fn)(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isUnreachable, planNextSync, type SyncLoopConfig } from "./planner";
|
|
3
|
+
|
|
4
|
+
const cfg: SyncLoopConfig = {
|
|
5
|
+
intervalMs: 300_000,
|
|
6
|
+
debounceMs: 3_000,
|
|
7
|
+
backoffBaseMs: 30_000,
|
|
8
|
+
backoffMaxMs: 1_800_000,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("planNextSync", () => {
|
|
12
|
+
test("steady interval when clean", () => {
|
|
13
|
+
expect(
|
|
14
|
+
planNextSync({ lastAttempt: 0, consecutiveFailures: 0, dirtySince: null }, 300_000, cfg)
|
|
15
|
+
.runNow,
|
|
16
|
+
).toBe(true);
|
|
17
|
+
const wait = planNextSync(
|
|
18
|
+
{ lastAttempt: 100_000, consecutiveFailures: 0, dirtySince: null },
|
|
19
|
+
200_000,
|
|
20
|
+
cfg,
|
|
21
|
+
);
|
|
22
|
+
expect(wait.runNow).toBe(false);
|
|
23
|
+
expect(wait.waitMs).toBe(200_000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a dirty flag debounces then wins over the interval", () => {
|
|
27
|
+
const state = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: 10_000 };
|
|
28
|
+
expect(planNextSync(state, 11_000, cfg)).toEqual({ runNow: false, waitMs: 2_000 });
|
|
29
|
+
expect(planNextSync(state, 13_000, cfg).runNow).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("failures back off exponentially and cap at backoffMaxMs", () => {
|
|
33
|
+
const at = (failures: number, now: number) =>
|
|
34
|
+
planNextSync({ lastAttempt: 0, consecutiveFailures: failures, dirtySince: null }, now, cfg);
|
|
35
|
+
expect(at(1, 29_999).runNow).toBe(false);
|
|
36
|
+
expect(at(1, 30_000).runNow).toBe(true);
|
|
37
|
+
expect(at(2, 59_999).runNow).toBe(false);
|
|
38
|
+
expect(at(2, 60_000).runNow).toBe(true);
|
|
39
|
+
// 2^10 * 30s would be ~8.5h — capped at the ceiling.
|
|
40
|
+
expect(at(11, 1_800_000).runNow).toBe(true);
|
|
41
|
+
expect(at(11, 1_799_999).waitMs).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("isUnreachable", () => {
|
|
46
|
+
test("network-shaped failures are unreachable; logic failures are not", () => {
|
|
47
|
+
expect(isUnreachable(new Error("fetch failed"))).toBe(true);
|
|
48
|
+
expect(isUnreachable(new Error("connect ECONNREFUSED 1.2.3.4:443"))).toBe(true);
|
|
49
|
+
expect(isUnreachable(new Error("The operation was aborted"))).toBe(true);
|
|
50
|
+
expect(isUnreachable(new Error("sync/push: 500"))).toBe(false);
|
|
51
|
+
expect(isUnreachable(new Error("401 — the sync token was refused."))).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure loop planner — vault's `planNextSync`, unchanged in spirit: back off after
|
|
3
|
+
* a failure, else honor a debounced dirty flag, else the steady interval. Pure so
|
|
4
|
+
* the timer is a thin wrapper and this is fake-clock testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SyncLoopConfig {
|
|
8
|
+
/** Steady poll cadence when reachable and idle. */
|
|
9
|
+
intervalMs: number;
|
|
10
|
+
/** How long after a local write to wait before syncing, so a burst coalesces. */
|
|
11
|
+
debounceMs: number;
|
|
12
|
+
/** First backoff step after a failure. */
|
|
13
|
+
backoffBaseMs: number;
|
|
14
|
+
/** Backoff ceiling — an unreachable peer (the nightly window) settles here. */
|
|
15
|
+
backoffMaxMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_LOOP: SyncLoopConfig = {
|
|
19
|
+
intervalMs: 5 * 60_000,
|
|
20
|
+
debounceMs: 3_000,
|
|
21
|
+
backoffBaseMs: 30_000,
|
|
22
|
+
backoffMaxMs: 30 * 60_000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface SyncLoopState {
|
|
26
|
+
/** When the last sync attempt STARTED (0 if never). */
|
|
27
|
+
lastAttempt: number;
|
|
28
|
+
/** Consecutive failed attempts — drives exponential backoff. */
|
|
29
|
+
consecutiveFailures: number;
|
|
30
|
+
/** When the oldest un-synced local write happened, or null if clean. */
|
|
31
|
+
dirtySince: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function planNextSync(
|
|
35
|
+
state: SyncLoopState,
|
|
36
|
+
now: number,
|
|
37
|
+
cfg: SyncLoopConfig = DEFAULT_LOOP,
|
|
38
|
+
): { runNow: boolean; waitMs: number } {
|
|
39
|
+
if (state.consecutiveFailures > 0) {
|
|
40
|
+
const step = Math.min(
|
|
41
|
+
cfg.backoffMaxMs,
|
|
42
|
+
cfg.backoffBaseMs * 2 ** (state.consecutiveFailures - 1),
|
|
43
|
+
);
|
|
44
|
+
const due = state.lastAttempt + step;
|
|
45
|
+
return now >= due ? { runNow: true, waitMs: 0 } : { runNow: false, waitMs: due - now };
|
|
46
|
+
}
|
|
47
|
+
if (state.dirtySince !== null) {
|
|
48
|
+
const due = state.dirtySince + cfg.debounceMs;
|
|
49
|
+
return now >= due ? { runNow: true, waitMs: 0 } : { runNow: false, waitMs: due - now };
|
|
50
|
+
}
|
|
51
|
+
const due = state.lastAttempt + cfg.intervalMs;
|
|
52
|
+
return now >= due ? { runNow: true, waitMs: 0 } : { runNow: false, waitMs: due - now };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A peer that is asleep (the nightly EC2 window) or an internet-less Mac is NORMAL,
|
|
56
|
+
* not an error. It still feeds the backoff. */
|
|
57
|
+
export function isUnreachable(err: unknown): boolean {
|
|
58
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
+
return /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|timed out|The operation was aborted|network/i.test(
|
|
60
|
+
msg,
|
|
61
|
+
);
|
|
62
|
+
}
|