cursedbelt 2.6.0 → 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.
Files changed (66) hide show
  1. package/dist/server/sync/alarm.d.ts +35 -0
  2. package/dist/server/sync/alarm.d.ts.map +1 -0
  3. package/dist/server/sync/alarm.js +92 -0
  4. package/dist/server/sync/alarm.js.map +1 -0
  5. package/dist/server/sync/commands.d.ts +88 -0
  6. package/dist/server/sync/commands.d.ts.map +1 -0
  7. package/dist/server/sync/commands.js +242 -0
  8. package/dist/server/sync/commands.js.map +1 -0
  9. package/dist/server/sync/engine.d.ts +63 -0
  10. package/dist/server/sync/engine.d.ts.map +1 -0
  11. package/dist/server/sync/engine.js +185 -0
  12. package/dist/server/sync/engine.js.map +1 -0
  13. package/dist/server/sync/http.d.ts +107 -0
  14. package/dist/server/sync/http.d.ts.map +1 -0
  15. package/dist/server/sync/http.js +244 -0
  16. package/dist/server/sync/http.js.map +1 -0
  17. package/dist/server/sync/index.d.ts +42 -0
  18. package/dist/server/sync/index.d.ts.map +1 -0
  19. package/dist/server/sync/index.js +42 -0
  20. package/dist/server/sync/index.js.map +1 -0
  21. package/dist/server/sync/opLog.d.ts +62 -0
  22. package/dist/server/sync/opLog.d.ts.map +1 -0
  23. package/dist/server/sync/opLog.js +97 -0
  24. package/dist/server/sync/opLog.js.map +1 -0
  25. package/dist/server/sync/planner.d.ts +32 -0
  26. package/dist/server/sync/planner.d.ts.map +1 -0
  27. package/dist/server/sync/planner.js +31 -0
  28. package/dist/server/sync/planner.js.map +1 -0
  29. package/dist/server/sync/status.d.ts +64 -0
  30. package/dist/server/sync/status.d.ts.map +1 -0
  31. package/dist/server/sync/status.js +50 -0
  32. package/dist/server/sync/status.js.map +1 -0
  33. package/dist/server/sync/timer.d.ts +53 -0
  34. package/dist/server/sync/timer.d.ts.map +1 -0
  35. package/dist/server/sync/timer.js +171 -0
  36. package/dist/server/sync/timer.js.map +1 -0
  37. package/dist/server/sync/tokens.d.ts +26 -0
  38. package/dist/server/sync/tokens.d.ts.map +1 -0
  39. package/dist/server/sync/tokens.js +52 -0
  40. package/dist/server/sync/tokens.js.map +1 -0
  41. package/dist/server/sync/types.d.ts +102 -0
  42. package/dist/server/sync/types.d.ts.map +1 -0
  43. package/dist/server/sync/types.js +30 -0
  44. package/dist/server/sync/types.js.map +1 -0
  45. package/package.json +19 -7
  46. package/src/leafSubpathsImportNothing.spec.ts +59 -3
  47. package/src/server/sync/alarm.spec.ts +149 -0
  48. package/src/server/sync/alarm.ts +137 -0
  49. package/src/server/sync/commands.spec.ts +145 -0
  50. package/src/server/sync/commands.ts +361 -0
  51. package/src/server/sync/engine.spec.ts +496 -0
  52. package/src/server/sync/engine.ts +255 -0
  53. package/src/server/sync/http.ts +316 -0
  54. package/src/server/sync/httpRemote.spec.ts +158 -0
  55. package/src/server/sync/index.ts +90 -0
  56. package/src/server/sync/opLog.spec.ts +110 -0
  57. package/src/server/sync/opLog.ts +189 -0
  58. package/src/server/sync/planner.spec.ts +53 -0
  59. package/src/server/sync/planner.ts +62 -0
  60. package/src/server/sync/status.spec.ts +92 -0
  61. package/src/server/sync/status.ts +108 -0
  62. package/src/server/sync/timer.spec.ts +150 -0
  63. package/src/server/sync/timer.ts +207 -0
  64. package/src/server/sync/tokens.spec.ts +38 -0
  65. package/src/server/sync/tokens.ts +94 -0
  66. package/src/server/sync/types.ts +108 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The dialer half (push-then-pull) and the batch applier. Pure over {@link RemoteApi}
3
+ * and {@link OpLog} so tests drive two REAL stores through the real receiver — no
4
+ * HTTP mocks (the notes/vault discipline).
5
+ *
6
+ * Push-before-pull is load-bearing: the push cursor must be current before the pull
7
+ * computes concurrency, so exactly ONE side materializes any given conflict artifact
8
+ * (vault's rule). Cursors advance only past durably-processed ops; a stopped batch
9
+ * advances exactly to the failure and the next run resumes there.
10
+ *
11
+ * Version skew (notes' hardest-won lesson): the peer's `/info` advertises `opKinds`;
12
+ * the push stops BEFORE the first op the peer cannot take and reports `blockedBy`
13
+ * instead of wedging with an error that reads like corruption. Nothing is dropped —
14
+ * the moment the peer is upgraded, the next push resumes from exactly that op.
15
+ */
16
+ import type { OpLog } from "./opLog";
17
+ import {
18
+ type ApplyContext,
19
+ type ApplyOne,
20
+ type ApplyReport,
21
+ type PeerInfo,
22
+ type RemoteApi,
23
+ StopBatch,
24
+ type SyncOp,
25
+ type SyncSummary,
26
+ } from "./types";
27
+
28
+ const BATCH = 500;
29
+
30
+ /** Kit-owned dedupe + ledger insert around the app's per-op merge logic. Everything
31
+ * for one op happens in one transaction: state change AND ledger insert, so a crash
32
+ * can never apply without recording (double-apply) or record without applying
33
+ * (silent drop). */
34
+ export function createSyncApplier(log: OpLog, applyOne: ApplyOne) {
35
+ return (ops: SyncOp[], ctx: ApplyContext): ApplyReport => {
36
+ let applied = 0;
37
+ let seen = 0;
38
+ let conflicts = 0;
39
+ let cursor = ops.length > 0 ? (ops[0] as SyncOp).seq - 1 : 0;
40
+ let error: string | null = null;
41
+ for (const op of ops) {
42
+ if (log.has(op.origin, op.originSeq)) {
43
+ seen++;
44
+ cursor = op.seq;
45
+ continue;
46
+ }
47
+ try {
48
+ const outcome = log.transaction(() => {
49
+ const result = applyOne(op, ctx);
50
+ log.recordRemote(op);
51
+ return result;
52
+ });
53
+ if (outcome === "applied") applied++;
54
+ else if (outcome === "conflict") {
55
+ applied++;
56
+ conflicts++;
57
+ }
58
+ cursor = op.seq;
59
+ } catch (err) {
60
+ if (err instanceof StopBatch) {
61
+ error = err.message;
62
+ break;
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+ return { applied, seen, conflicts, cursor, error };
68
+ };
69
+ }
70
+
71
+ /** Hold back ops the far side must never see (vault values, local-only kinds).
72
+ * Return null to hold; return a (possibly redacted) op to send. */
73
+ export type ExportPolicy = (op: SyncOp) => SyncOp | null;
74
+
75
+ /**
76
+ * A refusal the peer's `opKinds` cannot express — the SAME "stop before it, never drop
77
+ * it" contract, one layer deeper. Return a reason to block, or null to send.
78
+ *
79
+ * The kind check answers "can the peer take this SHAPE". It is not always the whole
80
+ * compatibility surface: notes negotiates `meta.set` a second time on the KEY, because
81
+ * `meta.set` has been a known kind since the log existed, so a NEW key rides a kind the
82
+ * peer recognizes and fails one layer deeper — as a skip on the receiver that looks like
83
+ * a delivered op, or, against a build predating that skip, as a wedged push.
84
+ *
85
+ * 🔴 This is deliberately NOT the {@link ExportPolicy}. A policy HOLDS an op back
86
+ * forever (the far side must never see it); a refusal PAUSES in front of it and keeps
87
+ * the cursor there, so the moment the peer is upgraded the next run resumes from exactly
88
+ * that op. Expressing "the peer is too old for this" as a hold would silently lose the
89
+ * mutation.
90
+ */
91
+ export type PeerRefusal = (op: SyncOp, info: PeerInfo) => string | null;
92
+
93
+ export interface EngineOptions {
94
+ log: OpLog;
95
+ /** The op kinds THIS build understands — advertised via /info and used to stop a
96
+ * push into an older peer before the first op it cannot take. */
97
+ opKinds: readonly string[];
98
+ policy?: ExportPolicy;
99
+ /** Optional second compatibility check, finer than `opKinds`. See {@link PeerRefusal}. */
100
+ peerRefusal?: PeerRefusal;
101
+ /** Byte budget per push request (default ~1 MiB). A backlog of large ops is
102
+ * sent as several requests instead of one that trips the proxy's body cap —
103
+ * the 413 WEDGES otherwise: the batch never shrinks, the cursor never moves.
104
+ * A single op larger than the budget still goes alone. */
105
+ maxPushBytes?: number;
106
+ }
107
+
108
+ const DEFAULT_MAX_PUSH_BYTES = 1_000_000;
109
+
110
+ /** Split ops into request-sized chunks by serialized payload weight. */
111
+ export function chunkByBytes(ops: SyncOp[], maxBytes: number): SyncOp[][] {
112
+ const chunks: SyncOp[][] = [];
113
+ let current: SyncOp[] = [];
114
+ let weight = 0;
115
+ for (const op of ops) {
116
+ const size = JSON.stringify(op).length + 1;
117
+ if (current.length > 0 && weight + size > maxBytes) {
118
+ chunks.push(current);
119
+ current = [];
120
+ weight = 0;
121
+ }
122
+ current.push(op);
123
+ weight += size;
124
+ }
125
+ if (current.length > 0) chunks.push(current);
126
+ return chunks;
127
+ }
128
+
129
+ export async function pushToRemote(
130
+ opts: EngineOptions,
131
+ remote: RemoteApi,
132
+ ): Promise<Pick<SyncSummary, "remoteId" | "pushed" | "held" | "conflicts" | "blockedBy">> {
133
+ const info = await remote.info();
134
+ const remoteId = info.instanceId;
135
+ const remoteKinds = new Set(info.opKinds);
136
+ // One predicate for both compatibility layers, so the "stop before it, leave the
137
+ // cursor there" path is shared and a caller cannot accidentally give the finer
138
+ // check the weaker (drop-it) semantics.
139
+ const refusedBecause = (op: SyncOp): string | null => {
140
+ if (!remoteKinds.has(op.kind)) return op.kind;
141
+ return opts.peerRefusal?.(op, info) ?? null;
142
+ };
143
+ const { log } = opts;
144
+ const peerHas = log.cursor(remoteId, "pull");
145
+ let cursor = log.cursor(remoteId, "push");
146
+ let pushed = 0;
147
+ let held = 0;
148
+ // Conflicts the RECEIVER detected while applying our push — push-before-pull means
149
+ // exactly one side detects any given concurrency, and when that side is the
150
+ // receiver the count would otherwise vanish from the dialer's summary.
151
+ let conflicts = 0;
152
+ let blockedBy: string | undefined;
153
+ for (;;) {
154
+ const page = log.listAfter(cursor, remoteId, BATCH);
155
+ if (page.ops.length === 0) {
156
+ // Nothing sendable in the window — advance past the peer's own reflected ops.
157
+ if (page.cursor > cursor) {
158
+ cursor = page.cursor;
159
+ log.setCursor(remoteId, "push", cursor);
160
+ }
161
+ break;
162
+ }
163
+ const blockedAt = page.ops.findIndex((op) => refusedBecause(op) !== null);
164
+ const sendableRaw = blockedAt === -1 ? page.ops : page.ops.slice(0, blockedAt);
165
+ if (blockedAt !== -1) {
166
+ const op = page.ops[blockedAt] as SyncOp;
167
+ blockedBy = refusedBecause(op) ?? op.kind;
168
+ }
169
+
170
+ const outbound: SyncOp[] = [];
171
+ for (const op of sendableRaw) {
172
+ const filtered = opts.policy ? opts.policy(op) : op;
173
+ if (filtered === null) held++;
174
+ else outbound.push(filtered);
175
+ }
176
+ if (outbound.length > 0) {
177
+ // Chunked by bytes so a large backlog cannot trip the proxy's body cap;
178
+ // the cursor advances per DELIVERED chunk, so an interrupted push resumes
179
+ // at the failure with nothing lost or re-sent.
180
+ for (const chunk of chunkByBytes(outbound, opts.maxPushBytes ?? DEFAULT_MAX_PUSH_BYTES)) {
181
+ const report = await remote.push({ ops: chunk, peerHas });
182
+ pushed += report.applied;
183
+ conflicts += report.conflicts;
184
+ if (report.error !== null) {
185
+ // The remote stopped inside the chunk (its own ordering guard). Advance
186
+ // to what it durably took and stop; the next run retries from there.
187
+ const processed = report.applied + report.seen;
188
+ if (processed > 0) {
189
+ cursor = (chunk[processed - 1] as SyncOp).seq;
190
+ log.setCursor(remoteId, "push", cursor);
191
+ }
192
+ return { remoteId, pushed, held, conflicts, blockedBy: report.error };
193
+ }
194
+ cursor = (chunk[chunk.length - 1] as SyncOp).seq;
195
+ log.setCursor(remoteId, "push", cursor);
196
+ }
197
+ }
198
+ if (sendableRaw.length === 0) break; // the very next op is blocked — nothing to advance past
199
+ cursor = (sendableRaw[sendableRaw.length - 1] as SyncOp).seq;
200
+ log.setCursor(remoteId, "push", cursor);
201
+ if (blockedBy) break;
202
+ if (page.cursor >= page.head && blockedAt === -1) {
203
+ // Window served through head with nothing blocked — take the page cursor so
204
+ // trailing excluded ops don't re-page forever.
205
+ cursor = page.cursor;
206
+ log.setCursor(remoteId, "push", cursor);
207
+ break;
208
+ }
209
+ }
210
+ return { remoteId, pushed, held, conflicts, blockedBy };
211
+ }
212
+
213
+ export async function pullFromRemote(
214
+ opts: EngineOptions,
215
+ remote: RemoteApi,
216
+ applyBatch: (ops: SyncOp[], ctx: ApplyContext) => ApplyReport,
217
+ ): Promise<Pick<SyncSummary, "remoteId" | "pulled" | "conflicts">> {
218
+ const info = await remote.info();
219
+ const remoteId = info.instanceId;
220
+ const { log } = opts;
221
+ const self = log.instanceId();
222
+ const peerReceivedUpTo = log.cursor(remoteId, "push");
223
+ let cursor = log.cursor(remoteId, "pull");
224
+ let pulled = 0;
225
+ let conflicts = 0;
226
+ for (;;) {
227
+ const page = await remote.pull(cursor, self);
228
+ if (page.ops.length === 0 && page.cursor <= cursor) break;
229
+ const report = applyBatch(page.ops, { peerId: remoteId, peerReceivedUpTo });
230
+ pulled += report.applied;
231
+ conflicts += report.conflicts;
232
+ if (report.error !== null) {
233
+ if (report.cursor > cursor) {
234
+ cursor = report.cursor;
235
+ log.setCursor(remoteId, "pull", cursor);
236
+ }
237
+ break;
238
+ }
239
+ cursor = page.ops.length === 0 ? page.cursor : Math.max(report.cursor, page.cursor);
240
+ log.setCursor(remoteId, "pull", cursor);
241
+ if (page.cursor >= page.head) break;
242
+ }
243
+ return { remoteId, pulled, conflicts };
244
+ }
245
+
246
+ /** One full reconciliation: push, then pull. */
247
+ export async function runSync(
248
+ opts: EngineOptions,
249
+ remote: RemoteApi,
250
+ applyBatch: (ops: SyncOp[], ctx: ApplyContext) => ApplyReport,
251
+ ): Promise<SyncSummary> {
252
+ const push = await pushToRemote(opts, remote);
253
+ const pull = await pullFromRemote(opts, remote, applyBatch);
254
+ return { ...push, ...pull, conflicts: push.conflicts + pull.conflicts, remoteId: push.remoteId };
255
+ }
@@ -0,0 +1,316 @@
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 { ApplyContext, ApplyOne, ApplyReport, PeerInfo, RemoteApi, SyncOp } from "./types";
14
+ import { createSyncApplier } from "./engine";
15
+
16
+ const noStore = { "cache-control": "no-store" } as const;
17
+ const SYNC_PAGE = 500;
18
+
19
+ /**
20
+ * Byte budget for ONE pull page — the mirror of `maxPushBytes` on the push side,
21
+ * and it was missing.
22
+ *
23
+ * 🔴 Measured against live prod on 2026-08-01: `GET /pull?after=0` against
24
+ * station's 709-op log served **37 MB at 309 ops** and then OOM-killed the unit
25
+ * (`memoryMax=300M` on a t3.micro). `SYNC_PAGE = 500` is a COUNT cap, and these
26
+ * ops are not small — a 130 KB overview snapshot, a 68 KB fleet CSV — so 500 of
27
+ * them is tens of megabytes serialized into one response buffer.
28
+ *
29
+ * What makes it worse than a slow request is that it does not converge: the unit
30
+ * dies, systemd restarts it, the dialer retries the identical oversized pull, and
31
+ * the pair never advances. Push already learned this exact lesson (a 413 wedges
32
+ * because the batch never shrinks); the receiving half kept only the count cap
33
+ * because a fully-caught-up peer never asks for enough rows to notice.
34
+ *
35
+ * A single op larger than the budget is still served alone — refusing it would
36
+ * wedge the log permanently on that op, which is worse than one big response.
37
+ */
38
+ const SYNC_PAGE_BYTES = 4_000_000;
39
+
40
+ /**
41
+ * Trim a page to the byte budget, keeping it a PREFIX so `cursor` stays the seq
42
+ * of the last op actually returned. Anything else silently drops ops: the caller
43
+ * advances past rows it never received and no later pull will offer them again.
44
+ */
45
+ export function capPageBytes<T extends { seq: number }>(
46
+ ops: T[],
47
+ maxBytes: number,
48
+ ): { ops: T[]; truncated: boolean } {
49
+ let weight = 0;
50
+ for (let i = 0; i < ops.length; i++) {
51
+ weight += JSON.stringify(ops[i]).length + 1;
52
+ if (weight > maxBytes && i > 0) return { ops: ops.slice(0, i), truncated: true };
53
+ }
54
+ return { ops, truncated: false };
55
+ }
56
+
57
+ /** Coerce and clamp the caller's peer id — a cursor key and a label, never trusted
58
+ * further than that. */
59
+ function peerIdOf(header: string | undefined, query: string | undefined): string {
60
+ const raw = query ?? header ?? "";
61
+ return /^[0-9A-Za-z_-]{1,64}$/.test(raw) ? raw : "peer";
62
+ }
63
+
64
+ export interface ReceiverHooks {
65
+ /** Every authenticated hit — the only evidence a non-dialing half has that it is
66
+ * in step (vault's `lastPeerContactAt` lesson). */
67
+ onPeerContact?: (peerId: string) => void;
68
+ /** The "Sync now was pressed HERE" stamp the dialer polls. */
69
+ readRequest?: () => number | null;
70
+ }
71
+
72
+ export interface ReceiverOptions {
73
+ log: OpLog;
74
+ opKinds: readonly string[];
75
+ /** Verify a presented bearer token for the sync scope. Return false to refuse.
76
+ * Bind this to a TokenStore (`(t) => tokens.verify(t, "sync") !== null`) or a
77
+ * constant-time static compare — the kit does not care which. */
78
+ verifyToken: (presented: string) => boolean;
79
+ /** The app's merge logic, when it is stateless across a batch (vault, station).
80
+ * Give this OR {@link beginBatch}, never both. */
81
+ applyOne?: ApplyOne;
82
+ /**
83
+ * The app's merge logic when the batch needs state of its own — called once per
84
+ * `POST /push`, so what it closes over belongs to that batch alone.
85
+ *
86
+ * 🔴 A single shared `ApplyOne` closing over a mutable accumulator is the bug this
87
+ * exists to make unrepresentable: two pushes in flight would pour into one counter,
88
+ * and the second response would carry the first's leftovers. notes needs it — its
89
+ * applier collects the attachment blobs a purge orphaned and the items whose search
90
+ * index must be rebuilt, neither of which can happen inside the op's transaction.
91
+ */
92
+ beginBatch?: () => ApplyOne;
93
+ /**
94
+ * Post-commit work, awaited BEFORE the response goes out — an index rebuild, a blob
95
+ * delete that is a network call. Anything that must not run inside the applier's
96
+ * transaction but must be done by the time the caller sees a 200, or the caller will
97
+ * advance its cursor past work that never happened.
98
+ */
99
+ afterBatch?: (report: ApplyReport) => void | Promise<void>;
100
+ hooks?: ReceiverHooks;
101
+ }
102
+
103
+ /** Build the receiver: GET /info, GET /pull, POST /push, GET /requested. */
104
+ export function createSyncReceiver(options: ReceiverOptions): Hono {
105
+ const { log, verifyToken } = options;
106
+ if ((options.applyOne === undefined) === (options.beginBatch === undefined)) {
107
+ // Loud at construction, because both mistakes are silent at runtime: neither
108
+ // given means every op vanishes as "ignored"; both given means one of the two
109
+ // is quietly never called.
110
+ throw new Error("[vault sync] receiver: give exactly one of `applyOne` or `beginBatch`");
111
+ }
112
+ const beginBatch = options.beginBatch ?? (() => options.applyOne as ApplyOne);
113
+ const api = new Hono();
114
+
115
+ api.use("*", async (c, next) => {
116
+ const header = c.req.header("authorization") ?? "";
117
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : "";
118
+ if (presented.length === 0 || !verifyToken(presented)) {
119
+ return c.json({ error: "unauthorized" }, 401, noStore);
120
+ }
121
+ await next();
122
+ });
123
+ // AFTER the gate — an unauthenticated caller must never look like peer contact.
124
+ api.use("*", async (c, next) => {
125
+ options.hooks?.onPeerContact?.(peerIdOf(c.req.header("x-sync-peer"), c.req.query("peer")));
126
+ await next();
127
+ });
128
+
129
+ api.get("/info", (c) => {
130
+ const body: PeerInfo = {
131
+ instanceId: log.instanceId(),
132
+ head: log.head(),
133
+ opKinds: [...options.opKinds],
134
+ };
135
+ return c.json(body, 200, noStore);
136
+ });
137
+
138
+ api.get("/requested", (c) =>
139
+ c.json({ requestedAt: options.hooks?.readRequest?.() ?? null }, 200, noStore),
140
+ );
141
+
142
+ api.get("/pull", (c) => {
143
+ const afterRaw = c.req.query("after");
144
+ const after = afterRaw !== undefined && /^\d+$/.test(afterRaw) ? Number(afterRaw) : 0;
145
+ const excludeRaw = c.req.query("exclude") ?? "";
146
+ const exclude = /^[0-9A-Za-z_-]{1,64}$/.test(excludeRaw) ? excludeRaw : "";
147
+ const page = log.listAfter(after, exclude, SYNC_PAGE);
148
+ // Count cap first (cheap, bounds the serialize below), then the byte cap.
149
+ const capped = capPageBytes(page.ops, SYNC_PAGE_BYTES);
150
+ // The cursor must name the last op ACTUALLY served, or the peer skips the
151
+ // remainder forever. `head` is untouched — it is how the caller knows to
152
+ // come back for another page.
153
+ const cursor = capped.truncated
154
+ ? ((capped.ops.at(-1) as SyncOp | undefined)?.seq ?? after)
155
+ : page.cursor;
156
+ log.setCursor(peerIdOf(c.req.header("x-sync-peer"), c.req.query("peer")), "served", cursor);
157
+ return c.json({ ops: capped.ops, cursor, head: page.head }, 200, noStore);
158
+ });
159
+
160
+ api.post("/push", async (c) => {
161
+ let body: unknown;
162
+ try {
163
+ body = await c.req.json();
164
+ } catch {
165
+ return c.json({ error: "bad_request" }, 400, noStore);
166
+ }
167
+ if (body === null || typeof body !== "object")
168
+ return c.json({ error: "bad_request" }, 400, noStore);
169
+ const { ops, peerHas } = body as { ops?: unknown; peerHas?: unknown };
170
+ if (!Array.isArray(ops)) return c.json({ error: "bad_request" }, 400, noStore);
171
+ const peerId = peerIdOf(c.req.header("x-sync-peer"), c.req.query("peer"));
172
+ // The client's DURABLE pull cursor for us is the crash-safe concurrency bound;
173
+ // fall back to our served cursor if an older caller omits it (vault's rule).
174
+ const bound =
175
+ typeof peerHas === "number" && Number.isInteger(peerHas) && peerHas >= 0
176
+ ? peerHas
177
+ : log.cursor(peerId, "served");
178
+ const ctx: ApplyContext = { peerId, peerReceivedUpTo: bound };
179
+ const report: ApplyReport = createSyncApplier(log, beginBatch())(ops as SyncOp[], ctx);
180
+ // Awaited, not fire-and-forget: the caller advances its push cursor on this
181
+ // response, so work still outstanding when it lands is work nobody will redo.
182
+ await options.afterBatch?.(report);
183
+ return c.json(report, report.error === null ? 200 : 409, noStore);
184
+ });
185
+
186
+ return api;
187
+ }
188
+
189
+ /**
190
+ * The fetch-backed remote the dialer binds to. `basePath` is where the peer
191
+ * mounted its receiver (e.g. `https://station.cursedalchemy.com/api/sync`).
192
+ *
193
+ * 🔴 **Redirects are REFUSED, never followed** (2026-08-21). `fetch` follows by
194
+ * default, and that turned a moved hostname into eight days of silence: the
195
+ * console's subdomain cutover made `station.cursedalchemy.com` a **301** to
196
+ * `station.cursedalchemy.com`, the dialer followed it, Cloudflare Access answered
197
+ * the un-credentialed redirect with its **login page — HTTP 200, text/html**,
198
+ * and `r.json()` threw `Failed to parse JSON`. The dialer logs that as
199
+ * `[sync] failed (ignored)` and retries forever, so:
200
+ *
201
+ * · every mirrored report on prod froze at the moment of the cutover, while
202
+ * each one still rendered a confident `capturedAt` age;
203
+ * · nothing was red anywhere — not a gate, not a smoke, not `/healthz`;
204
+ * · the error named JSON, which is three layers away from the cause.
205
+ *
206
+ * A 3xx here can only ever mean the peer address is wrong, so it is reported as
207
+ * exactly that, with the destination in the message. Following a redirect
208
+ * silently is how a sync survives its own misconfiguration.
209
+ *
210
+ * The same reasoning covers a 2xx that is not JSON: an Access login page, an
211
+ * nginx error page or a captive portal all arrive as `200 text/html`, and
212
+ * "Failed to parse JSON" is the least useful sentence available about any of
213
+ * them. {@link readJson} names the status, the content type and a snippet.
214
+ */
215
+ export function createHttpRemote(options: {
216
+ basePath: string;
217
+ token: string;
218
+ selfId: string;
219
+ fetchImpl?: typeof fetch;
220
+ timeoutMs?: number;
221
+ /**
222
+ * Extra headers for THIS peer's host — the Cloudflare Access service token,
223
+ * where the receiver sits behind an Access application. Host-scoped BY THE
224
+ * CALLER — never a blanket wrapper: a credential sent where it is not needed is a
225
+ * credential leaked. The retired generation had a `satellite-kit/cloudflare-access`
226
+ * helper that did the host-scoping; no caller in this app passes this today
227
+ * (`syncClient.ts` handles Access by refusing to follow its 302 instead), so a
228
+ * future one owns the scoping itself.
229
+ */
230
+ extraHeaders?: Record<string, string>;
231
+ }): RemoteApi {
232
+ const base = options.basePath.replace(/\/$/, "");
233
+ const doFetch = options.fetchImpl ?? fetch;
234
+ const timeout = options.timeoutMs ?? 60_000;
235
+ const peer = encodeURIComponent(options.selfId);
236
+ const request = async (path: string, init?: RequestInit): Promise<Response> => {
237
+ const response = await doFetch(`${base}${path}`, {
238
+ ...init,
239
+ // 🔴 See the header note: a followed redirect is how this sync spent
240
+ // eight days reporting a JSON parse error about an Access login page.
241
+ redirect: "manual",
242
+ headers: {
243
+ authorization: `Bearer ${options.token}`,
244
+ "x-sync-peer": options.selfId,
245
+ ...(options.extraHeaders ?? {}),
246
+ ...(init?.headers ?? {}),
247
+ },
248
+ signal: AbortSignal.timeout(timeout),
249
+ });
250
+ if (response.status >= 300 && response.status < 400) {
251
+ throw new Error(
252
+ `${path}: ${response.status} — the peer address redirects to ` +
253
+ `${response.headers.get("location") ?? "an unnamed destination"}. ` +
254
+ `The sync URL points at a host that has moved; point it at the real one.`,
255
+ );
256
+ }
257
+ if (response.status === 401) {
258
+ throw new Error(`${path}: 401 — the sync token was refused.`);
259
+ }
260
+ return response;
261
+ };
262
+
263
+ /**
264
+ * Read a JSON body, or say what actually arrived.
265
+ *
266
+ * A 200 carrying `text/html` is a login page or an error page, and it is the
267
+ * shape every "the address is wrong" failure takes once a redirect has been
268
+ * followed. Naming the content type and the first line turns an unactionable
269
+ * `Failed to parse JSON` into the cause.
270
+ */
271
+ const readJson = async <T>(r: Response, path: string): Promise<T> => {
272
+ const text = await r.text();
273
+ try {
274
+ return JSON.parse(text) as T;
275
+ } catch {
276
+ const type = r.headers.get("content-type") ?? "no content-type";
277
+ throw new Error(
278
+ `${path}: ${r.status} answered ${type}, not JSON — ` +
279
+ `${text.trim().slice(0, 120).replace(/\s+/g, " ") || "(empty body)"}`,
280
+ );
281
+ }
282
+ };
283
+ return {
284
+ async info() {
285
+ const r = await request("/info");
286
+ if (!r.ok) throw new Error(`sync/info: ${r.status}`);
287
+ return await readJson<PeerInfo>(r, "sync/info");
288
+ },
289
+ async pull(after, excludeOrigin) {
290
+ const r = await request(
291
+ `/pull?after=${after}&exclude=${encodeURIComponent(excludeOrigin)}&peer=${peer}`,
292
+ );
293
+ if (!r.ok) throw new Error(`sync/pull: ${r.status}`);
294
+ return await readJson<{ ops: SyncOp[]; cursor: number; head: number }>(r, "sync/pull");
295
+ },
296
+ async push(payload) {
297
+ const r = await request(`/push?peer=${peer}`, {
298
+ method: "POST",
299
+ headers: { "content-type": "application/json" },
300
+ body: JSON.stringify(payload),
301
+ });
302
+ if (!r.ok && r.status !== 409) throw new Error(`sync/push: ${r.status}`);
303
+ return await readJson<ApplyReport>(r, "sync/push");
304
+ },
305
+ async requestedAt() {
306
+ try {
307
+ const r = await request("/requested");
308
+ if (!r.ok) return null;
309
+ const body = await readJson<{ requestedAt?: unknown }>(r, "sync/requested");
310
+ return typeof body.requestedAt === "number" ? body.requestedAt : null;
311
+ } catch {
312
+ return null;
313
+ }
314
+ },
315
+ };
316
+ }