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.
Files changed (65) 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 -13
  46. package/src/server/sync/alarm.spec.ts +149 -0
  47. package/src/server/sync/alarm.ts +137 -0
  48. package/src/server/sync/commands.spec.ts +145 -0
  49. package/src/server/sync/commands.ts +361 -0
  50. package/src/server/sync/engine.spec.ts +496 -0
  51. package/src/server/sync/engine.ts +255 -0
  52. package/src/server/sync/http.ts +316 -0
  53. package/src/server/sync/httpRemote.spec.ts +158 -0
  54. package/src/server/sync/index.ts +90 -0
  55. package/src/server/sync/opLog.spec.ts +110 -0
  56. package/src/server/sync/opLog.ts +189 -0
  57. package/src/server/sync/planner.spec.ts +53 -0
  58. package/src/server/sync/planner.ts +62 -0
  59. package/src/server/sync/status.spec.ts +92 -0
  60. package/src/server/sync/status.ts +108 -0
  61. package/src/server/sync/timer.spec.ts +150 -0
  62. package/src/server/sync/timer.ts +207 -0
  63. package/src/server/sync/tokens.spec.ts +38 -0
  64. package/src/server/sync/tokens.ts +94 -0
  65. package/src/server/sync/types.ts +108 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The engine drives TWO REAL stores through the REAL receiver over Hono's
3
+ * `app.request` — no HTTP mocks (the notes/vault discipline). The app-side state is
4
+ * a deliberately tiny LWW key/value store; the assertions are about the KIT's
5
+ * seams: cursor discipline, exactly-once, version-skew stop-before, StopBatch
6
+ * resumability, conflict-context plumbing.
7
+ */
8
+ import { describe, expect, test } from "bun:test";
9
+ import { Database } from "bun:sqlite";
10
+ import { Hono } from "hono";
11
+ import { createOpLog } from "./opLog";
12
+ import { capPageBytes, createHttpRemote, createSyncReceiver } from "./http";
13
+ import { chunkByBytes, createSyncApplier, pushToRemote, runSync } from "./engine";
14
+ import { StopBatch, type ApplyOne, type RemoteApi, type SyncOp } from "./types";
15
+
16
+ interface Entry {
17
+ value: string;
18
+ ts: number;
19
+ origin: string;
20
+ }
21
+
22
+ /** One instance: an op log + a tiny LWW map + concurrency bookkeeping. */
23
+ function makeInstance(opKinds: readonly string[] = ["kv.set"]) {
24
+ const db = new Database(":memory:");
25
+ const log = createOpLog(db);
26
+ const state = new Map<string, Entry>();
27
+ const lastLocalSeqByKey = new Map<string, number>();
28
+ let conflictCopies = 0;
29
+ let stopOnKey: string | null = null;
30
+
31
+ const applyOne: ApplyOne = (op, ctx) => {
32
+ if (op.kind !== "kv.set") return "ignored";
33
+ const { key, value } = op.payload as { key: string; value: string };
34
+ if (stopOnKey === key) throw new StopBatch(`deferred: ${key}`);
35
+ const incoming: Entry = { value, ts: op.ts, origin: op.origin };
36
+ const local = state.get(key);
37
+ if (!local) {
38
+ state.set(key, incoming);
39
+ return "applied";
40
+ }
41
+ const incomingWins =
42
+ incoming.ts !== local.ts ? incoming.ts > local.ts : incoming.origin > local.origin;
43
+ const concurrent =
44
+ (lastLocalSeqByKey.get(key) ?? 0) > ctx.peerReceivedUpTo && local.value !== value;
45
+ if (incomingWins) state.set(key, incoming);
46
+ if (concurrent) {
47
+ conflictCopies++;
48
+ return "conflict";
49
+ }
50
+ return incomingWins ? "applied" : "ignored";
51
+ };
52
+
53
+ const set = (key: string, value: string, ts?: number): SyncOp => {
54
+ const op = log.recordLocal("kv.set", { key, value }, ts);
55
+ state.set(key, { value, ts: op.ts, origin: op.origin });
56
+ lastLocalSeqByKey.set(key, op.seq);
57
+ return op;
58
+ };
59
+
60
+ const applyBatch = createSyncApplier(log, applyOne);
61
+ return {
62
+ db,
63
+ log,
64
+ state,
65
+ set,
66
+ applyOne,
67
+ applyBatch,
68
+ opKinds,
69
+ conflicts: () => conflictCopies,
70
+ deferKey: (key: string | null) => {
71
+ stopOnKey = key;
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Mount a receiver for an instance and hand back a RemoteApi that dials it. */
77
+ function link(
78
+ dialer: ReturnType<typeof makeInstance>,
79
+ receiver: ReturnType<typeof makeInstance>,
80
+ token = "tok",
81
+ ) {
82
+ const app = new Hono();
83
+ app.route(
84
+ "/api/sync",
85
+ createSyncReceiver({
86
+ log: receiver.log,
87
+ opKinds: receiver.opKinds,
88
+ verifyToken: (t) => t === token,
89
+ applyOne: receiver.applyOne,
90
+ }),
91
+ );
92
+ const remote: RemoteApi = createHttpRemote({
93
+ basePath: "http://peer/api/sync",
94
+ token,
95
+ selfId: dialer.log.instanceId(),
96
+ fetchImpl: ((input: RequestInfo | URL, init?: RequestInit) =>
97
+ app.request(input as string, init)) as typeof fetch,
98
+ });
99
+ return { app, remote };
100
+ }
101
+
102
+ const engineOpts = (i: ReturnType<typeof makeInstance>) => ({ log: i.log, opKinds: i.opKinds });
103
+
104
+ describe("sync engine over the real receiver", () => {
105
+ test("round trip: both sides converge, cursors advance, second run is a no-op", async () => {
106
+ const mac = makeInstance();
107
+ const prod = makeInstance();
108
+ const { remote } = link(mac, prod);
109
+
110
+ mac.set("a", "1");
111
+ mac.set("b", "2");
112
+ const s1 = await runSync(engineOpts(mac), remote, mac.applyBatch);
113
+ expect(s1.pushed).toBe(2);
114
+ expect(prod.state.get("a")?.value).toBe("1");
115
+
116
+ // Writes born on the receiver come back on the next pull.
117
+ prod.set("c", "3");
118
+ const s2 = await runSync(engineOpts(mac), remote, mac.applyBatch);
119
+ expect(s2.pulled).toBe(1);
120
+ expect(mac.state.get("c")?.value).toBe("3");
121
+
122
+ // Fully converged — nothing moves, nothing re-applies.
123
+ const s3 = await runSync(engineOpts(mac), remote, mac.applyBatch);
124
+ expect(s3.pushed).toBe(0);
125
+ expect(s3.pulled).toBe(0);
126
+ });
127
+
128
+ test("exactly-once: a re-push after a lost cursor is `seen`, never re-applied", async () => {
129
+ const mac = makeInstance();
130
+ const prod = makeInstance();
131
+ const { remote } = link(mac, prod);
132
+ mac.set("a", "1");
133
+ await runSync(engineOpts(mac), remote, mac.applyBatch);
134
+ // Simulate a lost push cursor (crash before persist) — the ledger dedupes.
135
+ mac.log.setCursor(prod.log.instanceId(), "push", 0);
136
+ const report = await pushToRemote(engineOpts(mac), remote);
137
+ expect(report.pushed).toBe(0); // applied nothing new
138
+ expect(prod.state.get("a")?.value).toBe("1");
139
+ });
140
+
141
+ test("wrong token is a uniform 401", async () => {
142
+ const mac = makeInstance();
143
+ const prod = makeInstance();
144
+ const { remote } = link(mac, prod, "right");
145
+ void remote;
146
+ const { remote: bad } = (() => {
147
+ const app = new Hono();
148
+ app.route(
149
+ "/api/sync",
150
+ createSyncReceiver({
151
+ log: prod.log,
152
+ opKinds: prod.opKinds,
153
+ verifyToken: (t) => t === "right",
154
+ applyOne: prod.applyOne,
155
+ }),
156
+ );
157
+ return {
158
+ remote: createHttpRemote({
159
+ basePath: "http://peer/api/sync",
160
+ token: "wrong",
161
+ selfId: mac.log.instanceId(),
162
+ fetchImpl: ((input: RequestInfo | URL, init?: RequestInit) =>
163
+ app.request(input as string, init)) as typeof fetch,
164
+ }),
165
+ };
166
+ })();
167
+ mac.set("a", "1");
168
+ await expect(runSync(engineOpts(mac), bad, mac.applyBatch)).rejects.toThrow("401");
169
+ });
170
+
171
+ test("version skew: push stops BEFORE the first unknown kind, resumes after upgrade", async () => {
172
+ const mac = makeInstance(["kv.set", "kv.fancy"]);
173
+ const oldProd = makeInstance(["kv.set"]); // an older build: no kv.fancy
174
+ const { remote: oldRemote } = link(mac, oldProd);
175
+
176
+ mac.set("a", "1");
177
+ mac.log.recordLocal("kv.fancy", { key: "f" });
178
+ mac.set("b", "2"); // born AFTER the blocked op — must NOT leapfrog it
179
+
180
+ const r1 = await pushToRemote(engineOpts(mac), oldRemote);
181
+ expect(r1.pushed).toBe(1); // only "a"
182
+ expect(r1.blockedBy).toBe("kv.fancy");
183
+ expect(oldProd.state.has("b")).toBe(false);
184
+
185
+ // A second run against the old peer stays paused at the same op, sends nothing.
186
+ const r2 = await pushToRemote(engineOpts(mac), oldRemote);
187
+ expect(r2.pushed).toBe(0);
188
+ expect(r2.blockedBy).toBe("kv.fancy");
189
+
190
+ // "Deploy" the peer: same store, now advertising the new kind.
191
+ const upgraded = { ...oldProd, opKinds: ["kv.set", "kv.fancy"] as const };
192
+ const { remote: newRemote } = link(mac, upgraded as unknown as ReturnType<typeof makeInstance>);
193
+ const r3 = await pushToRemote(engineOpts(mac), newRemote);
194
+ expect(r3.blockedBy).toBeUndefined();
195
+ expect(oldProd.state.get("b")?.value).toBe("2"); // nothing was lost or skipped
196
+ });
197
+
198
+ test("peerRefusal blocks FINER than opKinds, with the same stop-before-it contract", async () => {
199
+ // notes' metaKeys case in miniature: the kind is one the peer has always known,
200
+ // so `opKinds` cannot see the incompatibility — it lives in the payload.
201
+ const mac = makeInstance();
202
+ const oldProd = makeInstance();
203
+ const { remote } = link(mac, oldProd);
204
+ const acceptedKeys = new Set(["known"]);
205
+ const opts = {
206
+ log: mac.log,
207
+ opKinds: mac.opKinds,
208
+ peerRefusal: (op: SyncOp) => {
209
+ const { key } = op.payload as { key: string };
210
+ return acceptedKeys.has(key) ? null : `kv.set ${key}`;
211
+ },
212
+ };
213
+
214
+ // Explicit ts: the LWW tiebreak is by (ts, origin), and two same-origin writes
215
+ // in the same millisecond would tie — a test artifact, not the thing under test.
216
+ mac.set("known", "1", 1000);
217
+ mac.set("newish", "2", 2000);
218
+ mac.set("known", "3", 3000); // born AFTER the refused op — must NOT leapfrog it
219
+
220
+ const r1 = await pushToRemote(opts, remote);
221
+ expect(r1.pushed).toBe(1);
222
+ expect(r1.blockedBy).toBe("kv.set newish"); // the REASON, not just the kind
223
+ expect(oldProd.state.get("known")?.value).toBe("1");
224
+
225
+ // Paused, not dropped: a second run against the same peer sends nothing more.
226
+ expect((await pushToRemote(opts, remote)).pushed).toBe(0);
227
+
228
+ // "Deploy" the peer: it now accepts the key, and the push resumes from exactly
229
+ // the op it stopped in front of.
230
+ acceptedKeys.add("newish");
231
+ const r3 = await pushToRemote(opts, remote);
232
+ expect(r3.blockedBy).toBeUndefined();
233
+ expect(r3.pushed).toBe(2);
234
+ expect(oldProd.state.get("newish")?.value).toBe("2");
235
+ expect(oldProd.state.get("known")?.value).toBe("3");
236
+ });
237
+
238
+ test("StopBatch: pull stops at the deferred op, cursor holds, retry resumes there", async () => {
239
+ const mac = makeInstance();
240
+ const prod = makeInstance();
241
+ const { remote } = link(mac, prod);
242
+ prod.set("x", "1");
243
+ prod.set("y", "2");
244
+ prod.set("z", "3");
245
+
246
+ mac.deferKey("y");
247
+ const s1 = await runSync(engineOpts(mac), remote, mac.applyBatch);
248
+ expect(s1.pulled).toBe(1); // only x landed
249
+ expect(mac.state.has("y")).toBe(false);
250
+ expect(mac.state.has("z")).toBe(false);
251
+
252
+ mac.deferKey(null); // the world changed; the deferral clears
253
+ const s2 = await runSync(engineOpts(mac), remote, mac.applyBatch);
254
+ expect(s2.pulled).toBe(2); // y then z — resumed exactly at the failure
255
+ expect(mac.state.get("z")?.value).toBe("3");
256
+ });
257
+
258
+ test("true concurrent edit surfaces via the peerReceivedUpTo bound", async () => {
259
+ const mac = makeInstance();
260
+ const prod = makeInstance();
261
+ const { remote } = link(mac, prod);
262
+ mac.set("k", "base", 1000);
263
+ await runSync(engineOpts(mac), remote, mac.applyBatch);
264
+
265
+ // Both sides edit while apart. Prod's is newer, so it wins everywhere; the
266
+ // mac's applier sees its own unacked local edit and reports the conflict.
267
+ mac.set("k", "mac-edit", 2000);
268
+ prod.set("k", "prod-edit", 3000);
269
+ const s = await runSync(engineOpts(mac), remote, mac.applyBatch);
270
+ expect(s.conflicts).toBe(1);
271
+ expect(mac.state.get("k")?.value).toBe("prod-edit");
272
+ // And after one more round both sides are byte-identical.
273
+ await runSync(engineOpts(mac), remote, mac.applyBatch);
274
+ expect(prod.state.get("k")?.value).toBe("prod-edit");
275
+ });
276
+
277
+ test("export policy holds ops back and counts them", async () => {
278
+ const mac = makeInstance();
279
+ const prod = makeInstance();
280
+ const { remote } = link(mac, prod);
281
+ mac.set("public", "yes");
282
+ mac.set("secret", "no");
283
+ const summary = await runSync(
284
+ {
285
+ ...engineOpts(mac),
286
+ policy: (op) =>
287
+ ((op.payload as { key: string }).key ?? "").startsWith("secret") ? null : op,
288
+ },
289
+ remote,
290
+ mac.applyBatch,
291
+ );
292
+ expect(summary.held).toBe(1);
293
+ expect(prod.state.has("public")).toBe(true);
294
+ expect(prod.state.has("secret")).toBe(false);
295
+ // Held is held FOREVER (cursor passed it) — the policy is a boundary, not a delay.
296
+ const again = await runSync(engineOpts(mac), remote, mac.applyBatch);
297
+ expect(prod.state.has("secret")).toBe(false);
298
+ expect(again.pushed).toBe(0);
299
+ });
300
+ });
301
+
302
+ describe("byte-capped push batches (the 413 wedge)", () => {
303
+ test("a backlog larger than the cap ships as several requests and converges", async () => {
304
+ const mac = makeInstance();
305
+ const prod = makeInstance();
306
+ const pushes: number[] = [];
307
+ const { remote } = (() => {
308
+ const linked = link(mac, prod);
309
+ const inner = linked.remote;
310
+ return {
311
+ remote: {
312
+ ...inner,
313
+ push: (payload: { ops: SyncOp[]; peerHas: number }) => {
314
+ pushes.push(JSON.stringify(payload.ops).length);
315
+ return inner.push(payload);
316
+ },
317
+ } as RemoteApi,
318
+ };
319
+ })();
320
+ // ~40 KB per op, 12 ops ≈ 480 KB backlog against a 100 KB cap.
321
+ for (let i = 0; i < 12; i++) mac.set(`k${i}`, "x".repeat(40_000));
322
+ const summary = await runSync(
323
+ { ...engineOpts(mac), maxPushBytes: 100_000 },
324
+ remote,
325
+ mac.applyBatch,
326
+ );
327
+ expect(summary.pushed).toBe(12);
328
+ expect(pushes.length).toBeGreaterThanOrEqual(5);
329
+ for (const bytes of pushes) expect(bytes).toBeLessThan(120_000);
330
+ expect(prod.state.get("k11")?.value.length).toBe(40_000);
331
+ });
332
+
333
+ test("chunkByBytes: a single oversized op still travels alone", () => {
334
+ const big = {
335
+ seq: 1,
336
+ origin: "o",
337
+ originSeq: 1,
338
+ ts: 1,
339
+ kind: "k",
340
+ payload: "y".repeat(50_000),
341
+ };
342
+ const small = { seq: 2, origin: "o", originSeq: 2, ts: 1, kind: "k", payload: "z" };
343
+ const chunks = chunkByBytes([big as SyncOp, small as SyncOp], 10_000);
344
+ expect(chunks.length).toBe(2);
345
+ expect(chunks[0]).toHaveLength(1);
346
+ });
347
+ });
348
+
349
+ describe("byte-capped PULL pages (the OOM the push cap's mirror was missing)", () => {
350
+ // 🔴 Measured against live prod on 2026-08-01, while proving station's sync
351
+ // link: `GET /pull?after=0` over a 709-op log served 37 MB at 309 ops and then
352
+ // OOM-killed the unit (memoryMax=300M, t3.micro). `SYNC_PAGE` is a COUNT cap
353
+ // and station's ops are not small — a 130 KB overview snapshot, a 68 KB fleet
354
+ // CSV — so 500 of them is tens of megabytes in one response buffer.
355
+ //
356
+ // It does not converge on its own, which is what makes it a wedge rather than
357
+ // a slow request: the unit dies, systemd restarts it, the dialer retries the
358
+ // identical oversized pull. Push learned this from a 413; the receiving half
359
+ // kept only the count cap because a caught-up peer never asks for enough rows.
360
+
361
+ test("a huge backlog is served as several bounded pages, and still converges", async () => {
362
+ const mac = makeInstance();
363
+ const prod = makeInstance();
364
+ // 60 ops × ~100 KB ≈ 6 MB, well under SYNC_PAGE's 500 — the count cap alone
365
+ // would hand all of it over in one response.
366
+ for (let i = 0; i < 60; i++) prod.set(`big${i}`, "z".repeat(100_000));
367
+
368
+ const { remote } = link(mac, prod);
369
+ const pages: number[] = [];
370
+ const counted: RemoteApi = {
371
+ ...remote,
372
+ pull: async (after: number, self: string) => {
373
+ const page = await remote.pull(after, self);
374
+ pages.push(JSON.stringify(page.ops).length);
375
+ return page;
376
+ },
377
+ };
378
+
379
+ const summary = await runSync(engineOpts(mac), counted, mac.applyBatch);
380
+
381
+ expect(summary.pulled).toBe(60);
382
+ expect(pages.length).toBeGreaterThan(1); // it was ONE page before the cap
383
+ for (const bytes of pages) expect(bytes).toBeLessThan(5_000_000);
384
+ // Convergence is the point: bounding the page must not lose an op.
385
+ expect(mac.state.get("big59")?.value.length).toBe(100_000);
386
+ expect(mac.state.get("big0")?.value.length).toBe(100_000);
387
+ });
388
+
389
+ test("capPageBytes keeps a PREFIX, so the cursor never skips an op", () => {
390
+ const ops = Array.from({ length: 5 }, (_, i) => ({
391
+ seq: i + 1,
392
+ origin: "o",
393
+ originSeq: i + 1,
394
+ ts: i,
395
+ kind: "doc.set",
396
+ payload: "q".repeat(1_000),
397
+ }));
398
+ const { ops: kept, truncated } = capPageBytes(ops, 2_500);
399
+ expect(truncated).toBe(true);
400
+ // A prefix — dropping from the middle would advance the peer past rows it
401
+ // never received, and no later pull offers them again.
402
+ expect(kept.map((o) => o.seq)).toEqual([1, 2]);
403
+ });
404
+
405
+ test("a single op over the budget still travels alone", () => {
406
+ // Refusing it would wedge the log on that op permanently, which is strictly
407
+ // worse than one oversized response. Same rule as chunkByBytes.
408
+ const ops = [
409
+ { seq: 1, origin: "o", originSeq: 1, ts: 1, kind: "k", payload: "y".repeat(9_000) },
410
+ ];
411
+ const { ops: kept, truncated } = capPageBytes(ops, 1_000);
412
+ expect(kept.length).toBe(1);
413
+ expect(truncated).toBe(false);
414
+ });
415
+ });
416
+
417
+ describe("receiver: per-batch state and post-commit work", () => {
418
+ /** Mount a receiver with the batch seams and dial it. */
419
+ function linkWithBatchSeams(
420
+ dialer: ReturnType<typeof makeInstance>,
421
+ receiver: ReturnType<typeof makeInstance>,
422
+ ) {
423
+ const batches: number[] = [];
424
+ const finished: number[] = [];
425
+ let seenThisBatch = 0;
426
+ const app = new Hono();
427
+ app.route(
428
+ "/api/sync",
429
+ createSyncReceiver({
430
+ log: receiver.log,
431
+ opKinds: receiver.opKinds,
432
+ verifyToken: () => true,
433
+ beginBatch: () => {
434
+ seenThisBatch = 0;
435
+ batches.push(0);
436
+ return (op, ctx) => {
437
+ seenThisBatch++;
438
+ batches[batches.length - 1] = seenThisBatch;
439
+ return receiver.applyOne(op, ctx);
440
+ };
441
+ },
442
+ afterBatch: async (report) => {
443
+ // Async on purpose: the response must not go out before it resolves.
444
+ await Promise.resolve();
445
+ finished.push(report.applied);
446
+ },
447
+ }),
448
+ );
449
+ const remote: RemoteApi = createHttpRemote({
450
+ basePath: "http://peer/api/sync",
451
+ token: "tok",
452
+ selfId: dialer.log.instanceId(),
453
+ fetchImpl: ((input: RequestInfo | URL, init?: RequestInit) =>
454
+ app.request(input as string, init)) as typeof fetch,
455
+ });
456
+ return { remote, batches, finished };
457
+ }
458
+
459
+ test("beginBatch is called once per push, so two pushes never share a counter", async () => {
460
+ const mac = makeInstance();
461
+ const prod = makeInstance();
462
+ const { remote, batches } = linkWithBatchSeams(mac, prod);
463
+
464
+ mac.set("a", "1", 1000);
465
+ await pushToRemote(engineOpts(mac), remote);
466
+ mac.set("b", "2", 2000);
467
+ await pushToRemote(engineOpts(mac), remote);
468
+
469
+ // Two batches of one — not one batch that saw two, and not a counter that
470
+ // carried the first push's op into the second's report.
471
+ expect(batches).toEqual([1, 1]);
472
+ });
473
+
474
+ test("afterBatch is AWAITED before the response — the cursor must not outrun the work", async () => {
475
+ const mac = makeInstance();
476
+ const prod = makeInstance();
477
+ const { remote, finished } = linkWithBatchSeams(mac, prod);
478
+
479
+ mac.set("a", "1", 1000);
480
+ const report = await pushToRemote(engineOpts(mac), remote);
481
+
482
+ // By the time the dialer has its answer (and has advanced its push cursor),
483
+ // the post-commit work has already run.
484
+ expect(report.pushed).toBe(1);
485
+ expect(finished).toEqual([1]);
486
+ });
487
+
488
+ test("a receiver given neither applier — or both — refuses to be built", () => {
489
+ const prod = makeInstance();
490
+ const base = { log: prod.log, opKinds: prod.opKinds, verifyToken: () => true };
491
+ expect(() => createSyncReceiver({ ...base })).toThrow(/exactly one/);
492
+ expect(() =>
493
+ createSyncReceiver({ ...base, applyOne: prod.applyOne, beginBatch: () => prod.applyOne }),
494
+ ).toThrow(/exactly one/);
495
+ });
496
+ });