cursedbelt 2.6.1 → 2.8.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/core/file-tree/fileTreeModel.d.ts +18 -0
- package/dist/core/file-tree/fileTreeModel.d.ts.map +1 -1
- package/dist/core/file-tree/fileTreeModel.js +24 -0
- package/dist/core/file-tree/fileTreeModel.js.map +1 -1
- package/dist/react/components/TagsInput.d.ts +8 -1
- package/dist/react/components/TagsInput.d.ts.map +1 -1
- package/dist/react/components/TagsInput.js +10 -4
- package/dist/react/components/TagsInput.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +14 -2
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +3 -9
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +1 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +1 -1
- package/dist/react/file-tree/index.js.map +1 -1
- 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 +25 -15
- package/scripts/verify.ts +292 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +23 -0
- package/src/core/file-tree/fileTreeModel.ts +33 -0
- package/src/demoFixture.spec.ts +11 -1
- package/src/react/components/TagsInput.spec.tsx +34 -0
- package/src/react/components/TagsInput.tsx +23 -1
- package/src/react/file-tree/FileTree.tsx +19 -11
- package/src/react/file-tree/index.ts +2 -0
- 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
- package/src/verifyGraph.spec.ts +181 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP transport: the receiver sub-app the internet-facing instance mounts, and
|
|
3
|
+
* the fetch-backed {@link RemoteApi} the dialer binds to.
|
|
4
|
+
*
|
|
5
|
+
* Mount the receiver OUTSIDE any browser-session gate (a machine peer has no session;
|
|
6
|
+
* notes' 401 trap: a token route registered after the session gate answers 401 with a
|
|
7
|
+
* valid token) but behind the token gate built here. An unconfigured receiver simply
|
|
8
|
+
* does not exist — the orch-companion idiom: no token verifier, no routes, nothing to
|
|
9
|
+
* probe.
|
|
10
|
+
*/
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import type { OpLog } from "./opLog";
|
|
13
|
+
import type { ApplyOne, ApplyReport, RemoteApi } from "./types";
|
|
14
|
+
/**
|
|
15
|
+
* Trim a page to the byte budget, keeping it a PREFIX so `cursor` stays the seq
|
|
16
|
+
* of the last op actually returned. Anything else silently drops ops: the caller
|
|
17
|
+
* advances past rows it never received and no later pull will offer them again.
|
|
18
|
+
*/
|
|
19
|
+
export declare function capPageBytes<T extends {
|
|
20
|
+
seq: number;
|
|
21
|
+
}>(ops: T[], maxBytes: number): {
|
|
22
|
+
ops: T[];
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
};
|
|
25
|
+
export interface ReceiverHooks {
|
|
26
|
+
/** Every authenticated hit — the only evidence a non-dialing half has that it is
|
|
27
|
+
* in step (vault's `lastPeerContactAt` lesson). */
|
|
28
|
+
onPeerContact?: (peerId: string) => void;
|
|
29
|
+
/** The "Sync now was pressed HERE" stamp the dialer polls. */
|
|
30
|
+
readRequest?: () => number | null;
|
|
31
|
+
}
|
|
32
|
+
export interface ReceiverOptions {
|
|
33
|
+
log: OpLog;
|
|
34
|
+
opKinds: readonly string[];
|
|
35
|
+
/** Verify a presented bearer token for the sync scope. Return false to refuse.
|
|
36
|
+
* Bind this to a TokenStore (`(t) => tokens.verify(t, "sync") !== null`) or a
|
|
37
|
+
* constant-time static compare — the kit does not care which. */
|
|
38
|
+
verifyToken: (presented: string) => boolean;
|
|
39
|
+
/** The app's merge logic, when it is stateless across a batch (vault, station).
|
|
40
|
+
* Give this OR {@link beginBatch}, never both. */
|
|
41
|
+
applyOne?: ApplyOne;
|
|
42
|
+
/**
|
|
43
|
+
* The app's merge logic when the batch needs state of its own — called once per
|
|
44
|
+
* `POST /push`, so what it closes over belongs to that batch alone.
|
|
45
|
+
*
|
|
46
|
+
* 🔴 A single shared `ApplyOne` closing over a mutable accumulator is the bug this
|
|
47
|
+
* exists to make unrepresentable: two pushes in flight would pour into one counter,
|
|
48
|
+
* and the second response would carry the first's leftovers. notes needs it — its
|
|
49
|
+
* applier collects the attachment blobs a purge orphaned and the items whose search
|
|
50
|
+
* index must be rebuilt, neither of which can happen inside the op's transaction.
|
|
51
|
+
*/
|
|
52
|
+
beginBatch?: () => ApplyOne;
|
|
53
|
+
/**
|
|
54
|
+
* Post-commit work, awaited BEFORE the response goes out — an index rebuild, a blob
|
|
55
|
+
* delete that is a network call. Anything that must not run inside the applier's
|
|
56
|
+
* transaction but must be done by the time the caller sees a 200, or the caller will
|
|
57
|
+
* advance its cursor past work that never happened.
|
|
58
|
+
*/
|
|
59
|
+
afterBatch?: (report: ApplyReport) => void | Promise<void>;
|
|
60
|
+
hooks?: ReceiverHooks;
|
|
61
|
+
}
|
|
62
|
+
/** Build the receiver: GET /info, GET /pull, POST /push, GET /requested. */
|
|
63
|
+
export declare function createSyncReceiver(options: ReceiverOptions): Hono;
|
|
64
|
+
/**
|
|
65
|
+
* The fetch-backed remote the dialer binds to. `basePath` is where the peer
|
|
66
|
+
* mounted its receiver (e.g. `https://station.cursedalchemy.com/api/sync`).
|
|
67
|
+
*
|
|
68
|
+
* 🔴 **Redirects are REFUSED, never followed** (2026-08-21). `fetch` follows by
|
|
69
|
+
* default, and that turned a moved hostname into eight days of silence: the
|
|
70
|
+
* console's subdomain cutover made `station.cursedalchemy.com` a **301** to
|
|
71
|
+
* `station.cursedalchemy.com`, the dialer followed it, Cloudflare Access answered
|
|
72
|
+
* the un-credentialed redirect with its **login page — HTTP 200, text/html**,
|
|
73
|
+
* and `r.json()` threw `Failed to parse JSON`. The dialer logs that as
|
|
74
|
+
* `[sync] failed (ignored)` and retries forever, so:
|
|
75
|
+
*
|
|
76
|
+
* · every mirrored report on prod froze at the moment of the cutover, while
|
|
77
|
+
* each one still rendered a confident `capturedAt` age;
|
|
78
|
+
* · nothing was red anywhere — not a gate, not a smoke, not `/healthz`;
|
|
79
|
+
* · the error named JSON, which is three layers away from the cause.
|
|
80
|
+
*
|
|
81
|
+
* A 3xx here can only ever mean the peer address is wrong, so it is reported as
|
|
82
|
+
* exactly that, with the destination in the message. Following a redirect
|
|
83
|
+
* silently is how a sync survives its own misconfiguration.
|
|
84
|
+
*
|
|
85
|
+
* The same reasoning covers a 2xx that is not JSON: an Access login page, an
|
|
86
|
+
* nginx error page or a captive portal all arrive as `200 text/html`, and
|
|
87
|
+
* "Failed to parse JSON" is the least useful sentence available about any of
|
|
88
|
+
* them. {@link readJson} names the status, the content type and a snippet.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createHttpRemote(options: {
|
|
91
|
+
basePath: string;
|
|
92
|
+
token: string;
|
|
93
|
+
selfId: string;
|
|
94
|
+
fetchImpl?: typeof fetch;
|
|
95
|
+
timeoutMs?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Extra headers for THIS peer's host — the Cloudflare Access service token,
|
|
98
|
+
* where the receiver sits behind an Access application. Host-scoped BY THE
|
|
99
|
+
* CALLER — never a blanket wrapper: a credential sent where it is not needed is a
|
|
100
|
+
* credential leaked. The retired generation had a `satellite-kit/cloudflare-access`
|
|
101
|
+
* helper that did the host-scoping; no caller in this app passes this today
|
|
102
|
+
* (`syncClient.ts` handles Access by refusing to follow its 302 instead), so a
|
|
103
|
+
* future one owns the scoping itself.
|
|
104
|
+
*/
|
|
105
|
+
extraHeaders?: Record<string, string>;
|
|
106
|
+
}): RemoteApi;
|
|
107
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../../src/server/sync/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAgB,QAAQ,EAAE,WAAW,EAAY,SAAS,EAAU,MAAM,SAAS,CAAC;AA2BhG;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,EACrD,GAAG,EAAE,CAAC,EAAE,EACR,QAAQ,EAAE,MAAM,GACd;IAAE,GAAG,EAAE,CAAC,EAAE,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAOlC;AASD,MAAM,WAAW,aAAa;IAC7B;wDACoD;IACpD,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,KAAK,CAAC;IACX,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;sEAEkE;IAClE,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C;uDACmD;IACnD,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,MAAM,QAAQ,CAAC;IAC5B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,KAAK,CAAC,EAAE,aAAa,CAAC;CACtB;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAmFjE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC,GAAG,SAAS,CAqFZ"}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP transport: the receiver sub-app the internet-facing instance mounts, and
|
|
3
|
+
* the fetch-backed {@link RemoteApi} the dialer binds to.
|
|
4
|
+
*
|
|
5
|
+
* Mount the receiver OUTSIDE any browser-session gate (a machine peer has no session;
|
|
6
|
+
* notes' 401 trap: a token route registered after the session gate answers 401 with a
|
|
7
|
+
* valid token) but behind the token gate built here. An unconfigured receiver simply
|
|
8
|
+
* does not exist — the orch-companion idiom: no token verifier, no routes, nothing to
|
|
9
|
+
* probe.
|
|
10
|
+
*/
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { createSyncApplier } from "./engine";
|
|
13
|
+
const noStore = { "cache-control": "no-store" };
|
|
14
|
+
const SYNC_PAGE = 500;
|
|
15
|
+
/**
|
|
16
|
+
* Byte budget for ONE pull page — the mirror of `maxPushBytes` on the push side,
|
|
17
|
+
* and it was missing.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 Measured against live prod on 2026-08-01: `GET /pull?after=0` against
|
|
20
|
+
* station's 709-op log served **37 MB at 309 ops** and then OOM-killed the unit
|
|
21
|
+
* (`memoryMax=300M` on a t3.micro). `SYNC_PAGE = 500` is a COUNT cap, and these
|
|
22
|
+
* ops are not small — a 130 KB overview snapshot, a 68 KB fleet CSV — so 500 of
|
|
23
|
+
* them is tens of megabytes serialized into one response buffer.
|
|
24
|
+
*
|
|
25
|
+
* What makes it worse than a slow request is that it does not converge: the unit
|
|
26
|
+
* dies, systemd restarts it, the dialer retries the identical oversized pull, and
|
|
27
|
+
* the pair never advances. Push already learned this exact lesson (a 413 wedges
|
|
28
|
+
* because the batch never shrinks); the receiving half kept only the count cap
|
|
29
|
+
* because a fully-caught-up peer never asks for enough rows to notice.
|
|
30
|
+
*
|
|
31
|
+
* A single op larger than the budget is still served alone — refusing it would
|
|
32
|
+
* wedge the log permanently on that op, which is worse than one big response.
|
|
33
|
+
*/
|
|
34
|
+
const SYNC_PAGE_BYTES = 4_000_000;
|
|
35
|
+
/**
|
|
36
|
+
* Trim a page to the byte budget, keeping it a PREFIX so `cursor` stays the seq
|
|
37
|
+
* of the last op actually returned. Anything else silently drops ops: the caller
|
|
38
|
+
* advances past rows it never received and no later pull will offer them again.
|
|
39
|
+
*/
|
|
40
|
+
export function capPageBytes(ops, maxBytes) {
|
|
41
|
+
let weight = 0;
|
|
42
|
+
for (let i = 0; i < ops.length; i++) {
|
|
43
|
+
weight += JSON.stringify(ops[i]).length + 1;
|
|
44
|
+
if (weight > maxBytes && i > 0)
|
|
45
|
+
return { ops: ops.slice(0, i), truncated: true };
|
|
46
|
+
}
|
|
47
|
+
return { ops, truncated: false };
|
|
48
|
+
}
|
|
49
|
+
/** Coerce and clamp the caller's peer id — a cursor key and a label, never trusted
|
|
50
|
+
* further than that. */
|
|
51
|
+
function peerIdOf(header, query) {
|
|
52
|
+
const raw = query ?? header ?? "";
|
|
53
|
+
return /^[0-9A-Za-z_-]{1,64}$/.test(raw) ? raw : "peer";
|
|
54
|
+
}
|
|
55
|
+
/** Build the receiver: GET /info, GET /pull, POST /push, GET /requested. */
|
|
56
|
+
export function createSyncReceiver(options) {
|
|
57
|
+
const { log, verifyToken } = options;
|
|
58
|
+
if ((options.applyOne === undefined) === (options.beginBatch === undefined)) {
|
|
59
|
+
// Loud at construction, because both mistakes are silent at runtime: neither
|
|
60
|
+
// given means every op vanishes as "ignored"; both given means one of the two
|
|
61
|
+
// is quietly never called.
|
|
62
|
+
throw new Error("[vault sync] receiver: give exactly one of `applyOne` or `beginBatch`");
|
|
63
|
+
}
|
|
64
|
+
const beginBatch = options.beginBatch ?? (() => options.applyOne);
|
|
65
|
+
const api = new Hono();
|
|
66
|
+
api.use("*", async (c, next) => {
|
|
67
|
+
const header = c.req.header("authorization") ?? "";
|
|
68
|
+
const presented = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
69
|
+
if (presented.length === 0 || !verifyToken(presented)) {
|
|
70
|
+
return c.json({ error: "unauthorized" }, 401, noStore);
|
|
71
|
+
}
|
|
72
|
+
await next();
|
|
73
|
+
});
|
|
74
|
+
// AFTER the gate — an unauthenticated caller must never look like peer contact.
|
|
75
|
+
api.use("*", async (c, next) => {
|
|
76
|
+
options.hooks?.onPeerContact?.(peerIdOf(c.req.header("x-sync-peer"), c.req.query("peer")));
|
|
77
|
+
await next();
|
|
78
|
+
});
|
|
79
|
+
api.get("/info", (c) => {
|
|
80
|
+
const body = {
|
|
81
|
+
instanceId: log.instanceId(),
|
|
82
|
+
head: log.head(),
|
|
83
|
+
opKinds: [...options.opKinds],
|
|
84
|
+
};
|
|
85
|
+
return c.json(body, 200, noStore);
|
|
86
|
+
});
|
|
87
|
+
api.get("/requested", (c) => c.json({ requestedAt: options.hooks?.readRequest?.() ?? null }, 200, noStore));
|
|
88
|
+
api.get("/pull", (c) => {
|
|
89
|
+
const afterRaw = c.req.query("after");
|
|
90
|
+
const after = afterRaw !== undefined && /^\d+$/.test(afterRaw) ? Number(afterRaw) : 0;
|
|
91
|
+
const excludeRaw = c.req.query("exclude") ?? "";
|
|
92
|
+
const exclude = /^[0-9A-Za-z_-]{1,64}$/.test(excludeRaw) ? excludeRaw : "";
|
|
93
|
+
const page = log.listAfter(after, exclude, SYNC_PAGE);
|
|
94
|
+
// Count cap first (cheap, bounds the serialize below), then the byte cap.
|
|
95
|
+
const capped = capPageBytes(page.ops, SYNC_PAGE_BYTES);
|
|
96
|
+
// The cursor must name the last op ACTUALLY served, or the peer skips the
|
|
97
|
+
// remainder forever. `head` is untouched — it is how the caller knows to
|
|
98
|
+
// come back for another page.
|
|
99
|
+
const cursor = capped.truncated
|
|
100
|
+
? (capped.ops.at(-1)?.seq ?? after)
|
|
101
|
+
: page.cursor;
|
|
102
|
+
log.setCursor(peerIdOf(c.req.header("x-sync-peer"), c.req.query("peer")), "served", cursor);
|
|
103
|
+
return c.json({ ops: capped.ops, cursor, head: page.head }, 200, noStore);
|
|
104
|
+
});
|
|
105
|
+
api.post("/push", async (c) => {
|
|
106
|
+
let body;
|
|
107
|
+
try {
|
|
108
|
+
body = await c.req.json();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return c.json({ error: "bad_request" }, 400, noStore);
|
|
112
|
+
}
|
|
113
|
+
if (body === null || typeof body !== "object")
|
|
114
|
+
return c.json({ error: "bad_request" }, 400, noStore);
|
|
115
|
+
const { ops, peerHas } = body;
|
|
116
|
+
if (!Array.isArray(ops))
|
|
117
|
+
return c.json({ error: "bad_request" }, 400, noStore);
|
|
118
|
+
const peerId = peerIdOf(c.req.header("x-sync-peer"), c.req.query("peer"));
|
|
119
|
+
// The client's DURABLE pull cursor for us is the crash-safe concurrency bound;
|
|
120
|
+
// fall back to our served cursor if an older caller omits it (vault's rule).
|
|
121
|
+
const bound = typeof peerHas === "number" && Number.isInteger(peerHas) && peerHas >= 0
|
|
122
|
+
? peerHas
|
|
123
|
+
: log.cursor(peerId, "served");
|
|
124
|
+
const ctx = { peerId, peerReceivedUpTo: bound };
|
|
125
|
+
const report = createSyncApplier(log, beginBatch())(ops, ctx);
|
|
126
|
+
// Awaited, not fire-and-forget: the caller advances its push cursor on this
|
|
127
|
+
// response, so work still outstanding when it lands is work nobody will redo.
|
|
128
|
+
await options.afterBatch?.(report);
|
|
129
|
+
return c.json(report, report.error === null ? 200 : 409, noStore);
|
|
130
|
+
});
|
|
131
|
+
return api;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The fetch-backed remote the dialer binds to. `basePath` is where the peer
|
|
135
|
+
* mounted its receiver (e.g. `https://station.cursedalchemy.com/api/sync`).
|
|
136
|
+
*
|
|
137
|
+
* 🔴 **Redirects are REFUSED, never followed** (2026-08-21). `fetch` follows by
|
|
138
|
+
* default, and that turned a moved hostname into eight days of silence: the
|
|
139
|
+
* console's subdomain cutover made `station.cursedalchemy.com` a **301** to
|
|
140
|
+
* `station.cursedalchemy.com`, the dialer followed it, Cloudflare Access answered
|
|
141
|
+
* the un-credentialed redirect with its **login page — HTTP 200, text/html**,
|
|
142
|
+
* and `r.json()` threw `Failed to parse JSON`. The dialer logs that as
|
|
143
|
+
* `[sync] failed (ignored)` and retries forever, so:
|
|
144
|
+
*
|
|
145
|
+
* · every mirrored report on prod froze at the moment of the cutover, while
|
|
146
|
+
* each one still rendered a confident `capturedAt` age;
|
|
147
|
+
* · nothing was red anywhere — not a gate, not a smoke, not `/healthz`;
|
|
148
|
+
* · the error named JSON, which is three layers away from the cause.
|
|
149
|
+
*
|
|
150
|
+
* A 3xx here can only ever mean the peer address is wrong, so it is reported as
|
|
151
|
+
* exactly that, with the destination in the message. Following a redirect
|
|
152
|
+
* silently is how a sync survives its own misconfiguration.
|
|
153
|
+
*
|
|
154
|
+
* The same reasoning covers a 2xx that is not JSON: an Access login page, an
|
|
155
|
+
* nginx error page or a captive portal all arrive as `200 text/html`, and
|
|
156
|
+
* "Failed to parse JSON" is the least useful sentence available about any of
|
|
157
|
+
* them. {@link readJson} names the status, the content type and a snippet.
|
|
158
|
+
*/
|
|
159
|
+
export function createHttpRemote(options) {
|
|
160
|
+
const base = options.basePath.replace(/\/$/, "");
|
|
161
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
162
|
+
const timeout = options.timeoutMs ?? 60_000;
|
|
163
|
+
const peer = encodeURIComponent(options.selfId);
|
|
164
|
+
const request = async (path, init) => {
|
|
165
|
+
const response = await doFetch(`${base}${path}`, {
|
|
166
|
+
...init,
|
|
167
|
+
// 🔴 See the header note: a followed redirect is how this sync spent
|
|
168
|
+
// eight days reporting a JSON parse error about an Access login page.
|
|
169
|
+
redirect: "manual",
|
|
170
|
+
headers: {
|
|
171
|
+
authorization: `Bearer ${options.token}`,
|
|
172
|
+
"x-sync-peer": options.selfId,
|
|
173
|
+
...(options.extraHeaders ?? {}),
|
|
174
|
+
...(init?.headers ?? {}),
|
|
175
|
+
},
|
|
176
|
+
signal: AbortSignal.timeout(timeout),
|
|
177
|
+
});
|
|
178
|
+
if (response.status >= 300 && response.status < 400) {
|
|
179
|
+
throw new Error(`${path}: ${response.status} — the peer address redirects to ` +
|
|
180
|
+
`${response.headers.get("location") ?? "an unnamed destination"}. ` +
|
|
181
|
+
`The sync URL points at a host that has moved; point it at the real one.`);
|
|
182
|
+
}
|
|
183
|
+
if (response.status === 401) {
|
|
184
|
+
throw new Error(`${path}: 401 — the sync token was refused.`);
|
|
185
|
+
}
|
|
186
|
+
return response;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Read a JSON body, or say what actually arrived.
|
|
190
|
+
*
|
|
191
|
+
* A 200 carrying `text/html` is a login page or an error page, and it is the
|
|
192
|
+
* shape every "the address is wrong" failure takes once a redirect has been
|
|
193
|
+
* followed. Naming the content type and the first line turns an unactionable
|
|
194
|
+
* `Failed to parse JSON` into the cause.
|
|
195
|
+
*/
|
|
196
|
+
const readJson = async (r, path) => {
|
|
197
|
+
const text = await r.text();
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(text);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
const type = r.headers.get("content-type") ?? "no content-type";
|
|
203
|
+
throw new Error(`${path}: ${r.status} answered ${type}, not JSON — ` +
|
|
204
|
+
`${text.trim().slice(0, 120).replace(/\s+/g, " ") || "(empty body)"}`);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
async info() {
|
|
209
|
+
const r = await request("/info");
|
|
210
|
+
if (!r.ok)
|
|
211
|
+
throw new Error(`sync/info: ${r.status}`);
|
|
212
|
+
return await readJson(r, "sync/info");
|
|
213
|
+
},
|
|
214
|
+
async pull(after, excludeOrigin) {
|
|
215
|
+
const r = await request(`/pull?after=${after}&exclude=${encodeURIComponent(excludeOrigin)}&peer=${peer}`);
|
|
216
|
+
if (!r.ok)
|
|
217
|
+
throw new Error(`sync/pull: ${r.status}`);
|
|
218
|
+
return await readJson(r, "sync/pull");
|
|
219
|
+
},
|
|
220
|
+
async push(payload) {
|
|
221
|
+
const r = await request(`/push?peer=${peer}`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: { "content-type": "application/json" },
|
|
224
|
+
body: JSON.stringify(payload),
|
|
225
|
+
});
|
|
226
|
+
if (!r.ok && r.status !== 409)
|
|
227
|
+
throw new Error(`sync/push: ${r.status}`);
|
|
228
|
+
return await readJson(r, "sync/push");
|
|
229
|
+
},
|
|
230
|
+
async requestedAt() {
|
|
231
|
+
try {
|
|
232
|
+
const r = await request("/requested");
|
|
233
|
+
if (!r.ok)
|
|
234
|
+
return null;
|
|
235
|
+
const body = await readJson(r, "sync/requested");
|
|
236
|
+
return typeof body.requestedAt === "number" ? body.requestedAt : null;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../../src/server/sync/http.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { StopBatch, type ApplyContext, type ApplyOne, type ApplyReport, type PeerInfo, type RemoteApi, type SyncOp, type SyncSummary, } from "./types";
|
|
33
|
+
export { createOpLog, type OpLog } from "./opLog";
|
|
34
|
+
export { createTokenStore, type DeviceToken, type TokenStore } from "./tokens";
|
|
35
|
+
export { createSyncApplier, pushToRemote, pullFromRemote, runSync, type EngineOptions, type ExportPolicy, type PeerRefusal, } from "./engine";
|
|
36
|
+
export { capPageBytes, createHttpRemote, createSyncReceiver, type ReceiverHooks, type ReceiverOptions, } from "./http";
|
|
37
|
+
export { DEFAULT_LOOP, isUnreachable, planNextSync, type SyncLoopConfig, type SyncLoopState, } from "./planner";
|
|
38
|
+
export { OFFLINE_STATUS, PEER_CONTACT_WRITE_MS, createSyncStatusReporter, type SyncStatus, type SyncStatusReporter, type SyncStatusStore, } from "./status";
|
|
39
|
+
export { REQUEST_POLL_MS, startSyncTimer, type SyncTimerDeps, type SyncTimerHandle } from "./timer";
|
|
40
|
+
export { createSyncAlarm, type AlarmOutcome, type SyncAlarm, type SyncAlarmOptions } from "./alarm";
|
|
41
|
+
export { DEFAULT_HOLD_MS, createCommandQueue, createCommandReceiver, createCommandRemote, startCommandWorker, type CommandQueue, type CommandRemote, type CommandRoutesOptions, type CommandStatus, type CommandWorkerDeps, type CommandWorkerHandle, type SyncCommand, } from "./commands";
|
|
42
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/sync/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EACN,SAAS,EACT,KAAK,YAAY,EACjB,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,KAAK,MAAM,EACX,KAAK,WAAW,GAChB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,OAAO,EACP,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,WAAW,GAChB,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,eAAe,GACpB,MAAM,QAAQ,CAAC;AAChB,OAAO,EACN,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,cAAc,EACd,qBAAqB,EACrB,wBAAwB,EACxB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,eAAe,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,eAAe,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EACN,eAAe,EACf,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,EAClB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,WAAW,GAChB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { StopBatch, } from "./types";
|
|
33
|
+
export { createOpLog } from "./opLog";
|
|
34
|
+
export { createTokenStore } from "./tokens";
|
|
35
|
+
export { createSyncApplier, pushToRemote, pullFromRemote, runSync, } from "./engine";
|
|
36
|
+
export { capPageBytes, createHttpRemote, createSyncReceiver, } from "./http";
|
|
37
|
+
export { DEFAULT_LOOP, isUnreachable, planNextSync, } from "./planner";
|
|
38
|
+
export { OFFLINE_STATUS, PEER_CONTACT_WRITE_MS, createSyncStatusReporter, } from "./status";
|
|
39
|
+
export { REQUEST_POLL_MS, startSyncTimer } from "./timer";
|
|
40
|
+
export { createSyncAlarm } from "./alarm";
|
|
41
|
+
export { DEFAULT_HOLD_MS, createCommandQueue, createCommandReceiver, createCommandRemote, startCommandWorker, } from "./commands";
|
|
42
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/server/sync/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EACN,SAAS,GAQT,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,WAAW,EAAc,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAqC,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,OAAO,GAIP,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,GAGlB,MAAM,QAAQ,CAAC;AAChB,OAAO,EACN,YAAY,EACZ,aAAa,EACb,YAAY,GAGZ,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,cAAc,EACd,qBAAqB,EACrB,wBAAwB,GAIxB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,eAAe,EAAE,cAAc,EAA4C,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,eAAe,EAA4D,MAAM,SAAS,CAAC;AACpG,OAAO,EACN,eAAe,EACf,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,GAQlB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,62 @@
|
|
|
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 type { SyncOp } from "./types";
|
|
13
|
+
export interface OpLog {
|
|
14
|
+
/** This instance's stable id (minted once, persisted in sync_meta). */
|
|
15
|
+
instanceId(): string;
|
|
16
|
+
/** Record a locally-born op; returns it with seq/originSeq assigned. */
|
|
17
|
+
recordLocal(kind: string, payload: unknown, ts?: number): SyncOp;
|
|
18
|
+
/** Record a remote op into the ledger (dedupe identity kept). Caller applies state
|
|
19
|
+
* FIRST in the same transaction scope; see `createSyncApplier`. */
|
|
20
|
+
recordRemote(op: SyncOp): void;
|
|
21
|
+
/** Is this (origin, originSeq) already in the ledger? */
|
|
22
|
+
has(origin: string, originSeq: number): boolean;
|
|
23
|
+
/** Page of ops with seq > after, excluding one origin (the peer's own — it already
|
|
24
|
+
* has them), oldest first. `cursor` = last row's seq served (or `after`). */
|
|
25
|
+
listAfter(after: number, excludeOrigin: string, limit: number): {
|
|
26
|
+
ops: SyncOp[];
|
|
27
|
+
cursor: number;
|
|
28
|
+
head: number;
|
|
29
|
+
};
|
|
30
|
+
/** Highest local seq (0 when empty). */
|
|
31
|
+
head(): number;
|
|
32
|
+
/** Durable per-peer cursors. `direction`: what WE track about the peer. */
|
|
33
|
+
cursor(peerId: string, direction: "push" | "pull" | "served"): number;
|
|
34
|
+
setCursor(peerId: string, direction: "push" | "pull" | "served", seq: number): void;
|
|
35
|
+
/** Compaction for STATE-shaped kinds (a snapshot is not history): delete this
|
|
36
|
+
* origin's OWN earlier ops of `kind` matching `payloadWhere` below `keepSeq`.
|
|
37
|
+
* Safe under the exactly-once ledger — peers dedupe by (origin, originSeq),
|
|
38
|
+
* pull pages tolerate seq gaps, and a deleted op is simply never sent. */
|
|
39
|
+
compactOwn(kind: string, keepSeq: number, payloadLike: string): number;
|
|
40
|
+
/** The same compaction, for a state kind that arrived from a PEER.
|
|
41
|
+
*
|
|
42
|
+
* `compactOwn` is scoped to `origin = instanceId`, so a pure RECEIVER of a
|
|
43
|
+
* state kind matches zero rows and its log grows without bound — prod
|
|
44
|
+
* station reached 5,537 revisions of one `overview` snapshot (963 MB, ~160
|
|
45
|
+
* MB/day) because every one of them originated on the Mac. The safety
|
|
46
|
+
* argument on `compactOwn` never depended on who minted the op, only on the
|
|
47
|
+
* kind being STATE rather than history, so it carries over verbatim.
|
|
48
|
+
*
|
|
49
|
+
* Keyed on `origin_seq`, not local `seq`: a receiver compacts while
|
|
50
|
+
* applying, which happens BEFORE the incoming row is inserted, so there is
|
|
51
|
+
* no local seq yet to compare against. `(origin, origin_seq)` is the
|
|
52
|
+
* ledger's own identity and is monotonic per origin, so it orders the
|
|
53
|
+
* origin's stream exactly as well. */
|
|
54
|
+
compactOrigin(origin: string, kind: string, keepOriginSeq: number, payloadLike: string): number;
|
|
55
|
+
/** Arbitrary sync-scoped metadata (instance id lives here too). */
|
|
56
|
+
getMeta(key: string): string | null;
|
|
57
|
+
setMeta(key: string, value: string): void;
|
|
58
|
+
/** Run `fn` inside one sqlite transaction (apply + ledger-insert atomicity). */
|
|
59
|
+
transaction<T>(fn: () => T): T;
|
|
60
|
+
}
|
|
61
|
+
export declare function createOpLog(db: Database): OpLog;
|
|
62
|
+
//# sourceMappingURL=opLog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opLog.d.ts","sourceRoot":"","sources":["../../../src/server/sync/opLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC,MAAM,WAAW,KAAK;IACrB,uEAAuE;IACvE,UAAU,IAAI,MAAM,CAAC;IACrB,wEAAwE;IACxE,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACjE;wEACoE;IACpE,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,yDAAyD;IACzD,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAChD;kFAC8E;IAC9E,SAAS,CACR,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,MAAM,GACX;QAAE,GAAG,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,wCAAwC;IACxC,IAAI,IAAI,MAAM,CAAC;IACf,2EAA2E;IAC3E,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;IACtE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACpF;;;+EAG2E;IAC3E,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;IACvE;;;;;;;;;;;;;2CAauC;IACvC,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;IAChG,mEAAmE;IACnE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,gFAAgF;IAChF,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;CAC/B;AAID,wBAAgB,WAAW,CAAC,EAAE,EAAE,QAAQ,GAAG,KAAK,CA2H/C"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const INSTANCE_KEY = "sync.instance_id";
|
|
3
|
+
export function createOpLog(db) {
|
|
4
|
+
db.exec(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS sync_ops (
|
|
6
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
origin TEXT NOT NULL,
|
|
8
|
+
origin_seq INTEGER NOT NULL,
|
|
9
|
+
ts INTEGER NOT NULL,
|
|
10
|
+
kind TEXT NOT NULL,
|
|
11
|
+
payload TEXT NOT NULL,
|
|
12
|
+
UNIQUE(origin, origin_seq)
|
|
13
|
+
);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_sync_ops_origin ON sync_ops(origin, seq);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS sync_cursors (
|
|
16
|
+
peer_id TEXT NOT NULL,
|
|
17
|
+
direction TEXT NOT NULL,
|
|
18
|
+
seq INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
PRIMARY KEY(peer_id, direction)
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
`);
|
|
26
|
+
const getMetaStmt = db.prepare("SELECT value FROM sync_meta WHERE key = ?");
|
|
27
|
+
const setMetaStmt = db.prepare("INSERT INTO sync_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
|
28
|
+
const getMeta = (key) => getMetaStmt.get(key)?.value ?? null;
|
|
29
|
+
const setMeta = (key, value) => {
|
|
30
|
+
setMetaStmt.run(key, value);
|
|
31
|
+
};
|
|
32
|
+
let cachedId = getMeta(INSTANCE_KEY);
|
|
33
|
+
if (cachedId === null) {
|
|
34
|
+
// Short + url/label safe; uniqueness across a two-or-three instance fleet is
|
|
35
|
+
// what matters, not global collision resistance.
|
|
36
|
+
cachedId = `i-${randomUUID().slice(0, 12)}`;
|
|
37
|
+
setMeta(INSTANCE_KEY, cachedId);
|
|
38
|
+
}
|
|
39
|
+
const instanceId = cachedId;
|
|
40
|
+
const insertOp = db.prepare("INSERT INTO sync_ops (origin, origin_seq, ts, kind, payload) VALUES (?, ?, ?, ?, ?)");
|
|
41
|
+
const hasStmt = db.prepare("SELECT 1 FROM sync_ops WHERE origin = ? AND origin_seq = ? LIMIT 1");
|
|
42
|
+
const headStmt = db.prepare("SELECT COALESCE(MAX(seq), 0) AS head FROM sync_ops");
|
|
43
|
+
const maxOriginSeqStmt = db.prepare("SELECT COALESCE(MAX(origin_seq), 0) AS m FROM sync_ops WHERE origin = ?");
|
|
44
|
+
const listStmt = db.prepare("SELECT seq, origin, origin_seq, ts, kind, payload FROM sync_ops WHERE seq > ? AND origin != ? ORDER BY seq ASC LIMIT ?");
|
|
45
|
+
const compactStmt = db.prepare("DELETE FROM sync_ops WHERE origin = ? AND kind = ? AND seq < ? AND payload LIKE ?");
|
|
46
|
+
const compactOriginStmt = db.prepare("DELETE FROM sync_ops WHERE origin = ? AND kind = ? AND origin_seq < ? AND payload LIKE ?");
|
|
47
|
+
const cursorStmt = db.prepare("SELECT seq FROM sync_cursors WHERE peer_id = ? AND direction = ?");
|
|
48
|
+
const setCursorStmt = db.prepare("INSERT INTO sync_cursors (peer_id, direction, seq) VALUES (?, ?, ?) ON CONFLICT(peer_id, direction) DO UPDATE SET seq = excluded.seq");
|
|
49
|
+
const rowToOp = (row) => ({
|
|
50
|
+
seq: row.seq,
|
|
51
|
+
origin: row.origin,
|
|
52
|
+
originSeq: row.origin_seq,
|
|
53
|
+
ts: row.ts,
|
|
54
|
+
kind: row.kind,
|
|
55
|
+
payload: JSON.parse(row.payload),
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
instanceId: () => instanceId,
|
|
59
|
+
recordLocal(kind, payload, ts) {
|
|
60
|
+
const at = ts ?? Date.now();
|
|
61
|
+
const originSeq = maxOriginSeqStmt.get(instanceId).m + 1;
|
|
62
|
+
insertOp.run(instanceId, originSeq, at, kind, JSON.stringify(payload ?? null));
|
|
63
|
+
const seq = headStmt.get().head;
|
|
64
|
+
return { seq, origin: instanceId, originSeq, ts: at, kind, payload };
|
|
65
|
+
},
|
|
66
|
+
recordRemote(op) {
|
|
67
|
+
insertOp.run(op.origin, op.originSeq, op.ts, op.kind, JSON.stringify(op.payload ?? null));
|
|
68
|
+
},
|
|
69
|
+
has: (origin, originSeq) => hasStmt.get(origin, originSeq) !== null,
|
|
70
|
+
listAfter(after, excludeOrigin, limit) {
|
|
71
|
+
const rows = listStmt.all(after, excludeOrigin, limit);
|
|
72
|
+
const ops = rows.map(rowToOp);
|
|
73
|
+
const head = headStmt.get().head;
|
|
74
|
+
// An all-excluded page must still advance the peer past what it skipped, or a
|
|
75
|
+
// log dominated by the peer's own reflected ops would page forever. Serve the
|
|
76
|
+
// true high-water mark of the WINDOW scanned: when the page is short, we
|
|
77
|
+
// reached head; otherwise the last row's seq.
|
|
78
|
+
const cursor = rows.length === 0 ? Math.max(after, head) : rows[rows.length - 1].seq;
|
|
79
|
+
return { ops, cursor, head };
|
|
80
|
+
},
|
|
81
|
+
head: () => headStmt.get().head,
|
|
82
|
+
compactOwn(kind, keepSeq, payloadLike) {
|
|
83
|
+
return compactStmt.run(instanceId, kind, keepSeq, payloadLike).changes;
|
|
84
|
+
},
|
|
85
|
+
compactOrigin(origin, kind, keepOriginSeq, payloadLike) {
|
|
86
|
+
return compactOriginStmt.run(origin, kind, keepOriginSeq, payloadLike).changes;
|
|
87
|
+
},
|
|
88
|
+
cursor: (peerId, direction) => cursorStmt.get(peerId, direction)?.seq ?? 0,
|
|
89
|
+
setCursor(peerId, direction, seq) {
|
|
90
|
+
setCursorStmt.run(peerId, direction, seq);
|
|
91
|
+
},
|
|
92
|
+
getMeta,
|
|
93
|
+
setMeta,
|
|
94
|
+
transaction: (fn) => db.transaction(fn)(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=opLog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opLog.js","sourceRoot":"","sources":["../../../src/server/sync/opLog.ts"],"names":[],"mappings":"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"}
|