@pylonsync/sync 0.22.47 → 0.22.58
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/index.d.ts +171 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +104 -0
- package/dist/types.d.ts +10 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +598 -58
- package/src/multi-tab-orchestrator.test.ts +2 -0
- package/src/multi-tab-orchestrator.ts +13 -0
- package/src/mutation-queue.ts +162 -7
- package/src/offline-conflict.test.ts +110 -0
- package/src/outbox.test.ts +333 -0
- package/src/session-before-pull.test.ts +51 -0
- package/src/session-dedupe.test.ts +77 -0
- package/src/snapshot-catch-up.test.ts +101 -0
- package/src/test-harness/env.ts +3 -0
- package/src/test-harness/server.ts +145 -0
- package/src/test-harness/transport.ts +26 -0
- package/src/types.ts +10 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The outbox: list pending and failed writes, retry a failed write (with
|
|
2
|
+
// corrected fields), dismiss one, and notify subscribers on every change.
|
|
3
|
+
//
|
|
4
|
+
// Before: a write the server rejected was rolled back and kept in the queue
|
|
5
|
+
// as `failed`, but nothing listed it, so the user never learned it was lost.
|
|
6
|
+
|
|
7
|
+
import "fake-indexeddb/auto";
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
IndexedDBPersistence,
|
|
12
|
+
MutationRejectedError,
|
|
13
|
+
OUTBOX_CHANNEL,
|
|
14
|
+
SyncEngine,
|
|
15
|
+
} from "./index";
|
|
16
|
+
import {
|
|
17
|
+
MutationQueue,
|
|
18
|
+
type MutationQueuePersistence,
|
|
19
|
+
type PendingMutation,
|
|
20
|
+
} from "./mutation-queue";
|
|
21
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
22
|
+
|
|
23
|
+
/** In-memory stand-in for the IndexedDB queue store. */
|
|
24
|
+
class MemoryQueueStore implements MutationQueuePersistence {
|
|
25
|
+
rows = new Map<string, PendingMutation>();
|
|
26
|
+
async saveAll(mutations: PendingMutation[]) {
|
|
27
|
+
this.rows = new Map(mutations.map((m) => [m.id, structuredClone(m)]));
|
|
28
|
+
}
|
|
29
|
+
async loadAll() {
|
|
30
|
+
return Array.from(this.rows.values(), (m) => structuredClone(m));
|
|
31
|
+
}
|
|
32
|
+
async saveChanges(upserts: PendingMutation[], removedIds: string[]) {
|
|
33
|
+
for (const m of upserts) this.rows.set(m.id, structuredClone(m));
|
|
34
|
+
for (const id of removedIds) this.rows.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
39
|
+
|
|
40
|
+
describe("MutationQueue", () => {
|
|
41
|
+
test("a failed write survives clear() and a reload", async () => {
|
|
42
|
+
const disk = new MemoryQueueStore();
|
|
43
|
+
const q = new MutationQueue(disk);
|
|
44
|
+
const applied = q.add({ entity: "Knock", row_id: "k1", kind: "insert", data: { id: "k1" } }, undefined, "u1");
|
|
45
|
+
const failed = q.add({ entity: "Knock", row_id: "k2", kind: "insert", data: { id: "k2" } }, undefined, "u1");
|
|
46
|
+
q.markApplied(applied);
|
|
47
|
+
q.markFailed(failed, "door number is required", "VALIDATION_FAILED");
|
|
48
|
+
q.clear();
|
|
49
|
+
await tick();
|
|
50
|
+
expect(q.failed().map((m) => m.id)).toEqual([failed]);
|
|
51
|
+
|
|
52
|
+
const reloaded = new MutationQueue(disk);
|
|
53
|
+
await reloaded.hydrate();
|
|
54
|
+
const [m] = reloaded.failed();
|
|
55
|
+
expect(m).toMatchObject({
|
|
56
|
+
id: failed,
|
|
57
|
+
status: "failed",
|
|
58
|
+
error: "door number is required",
|
|
59
|
+
errorCode: "VALIDATION_FAILED",
|
|
60
|
+
owner: "u1",
|
|
61
|
+
});
|
|
62
|
+
expect(typeof m!.createdAt).toBe("number");
|
|
63
|
+
expect(reloaded.get(applied)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("retry re-queues as pending under a new op id, with the owner and merged fields", async () => {
|
|
67
|
+
const disk = new MemoryQueueStore();
|
|
68
|
+
const q = new MutationQueue(disk);
|
|
69
|
+
const id = q.add(
|
|
70
|
+
{ entity: "Knock", row_id: "k1", kind: "insert", data: { id: "k1", door: "", outcome: "no answer" } },
|
|
71
|
+
undefined,
|
|
72
|
+
"u1",
|
|
73
|
+
);
|
|
74
|
+
const createdAt = q.get(id)!.createdAt;
|
|
75
|
+
q.markFailed(id, "door number is required", "VALIDATION_FAILED");
|
|
76
|
+
const newId = q.retry(id, { door: "14B", id: "attempt-to-change-id" }, undefined);
|
|
77
|
+
await tick();
|
|
78
|
+
|
|
79
|
+
expect(newId).not.toBe(id);
|
|
80
|
+
expect(q.get(id)).toBeUndefined();
|
|
81
|
+
const m = q.get(newId)!;
|
|
82
|
+
expect(m.status).toBe("pending");
|
|
83
|
+
expect(m.owner).toBe("u1");
|
|
84
|
+
expect(m.createdAt).toBe(createdAt);
|
|
85
|
+
expect(m.change.op_id).toBe(newId);
|
|
86
|
+
expect(m.change.data).toEqual({ id: "k1", door: "14B", outcome: "no answer" });
|
|
87
|
+
// One persistence write removed the old entry and added the new one.
|
|
88
|
+
expect(Array.from(disk.rows.keys())).toEqual([newId]);
|
|
89
|
+
expect(() => q.retry(newId, undefined, undefined)).toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("dismiss removes a failed write from memory and persistence; a pending one stays", async () => {
|
|
93
|
+
const disk = new MemoryQueueStore();
|
|
94
|
+
const q = new MutationQueue(disk);
|
|
95
|
+
const failed = q.add({ entity: "Knock", row_id: "k1", kind: "insert", data: { id: "k1" } });
|
|
96
|
+
const pending = q.add({ entity: "Knock", row_id: "k2", kind: "insert", data: { id: "k2" } });
|
|
97
|
+
q.markFailed(failed, "no");
|
|
98
|
+
expect(q.dismiss(pending)).toBe(false);
|
|
99
|
+
expect(q.dismiss(failed)).toBe(true);
|
|
100
|
+
await tick();
|
|
101
|
+
expect(q.get(failed)).toBeUndefined();
|
|
102
|
+
expect(Array.from(disk.rows.keys())).toEqual([pending]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("writes from an older client (no createdAt) sort first", () => {
|
|
106
|
+
const q = new MutationQueue();
|
|
107
|
+
const newer = q.add({ entity: "K", row_id: "a", kind: "insert", data: { id: "a" } });
|
|
108
|
+
const older = q.add({ entity: "K", row_id: "b", kind: "insert", data: { id: "b" } });
|
|
109
|
+
delete q.get(older)!.createdAt;
|
|
110
|
+
q.markFailed(newer, "x");
|
|
111
|
+
q.markFailed(older, "y");
|
|
112
|
+
expect(q.failed().map((m) => m.id)).toEqual([older, newer]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("SyncEngine outbox", () => {
|
|
117
|
+
let env: TestEnv | null = null;
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
if (env) await env.dispose();
|
|
120
|
+
env = null;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("lists pending and failed writes with their fields and errors", async () => {
|
|
124
|
+
env = createTestEnv({ transport: "poll" });
|
|
125
|
+
env.signIn({ userId: "u1" });
|
|
126
|
+
await env.start();
|
|
127
|
+
env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "door number is required");
|
|
128
|
+
await expect(env.engine.insert("Knock", { door: "", outcome: "no answer" })).rejects.toThrow();
|
|
129
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
130
|
+
await env.engine.insert("Knock", { door: "12", outcome: "sold" });
|
|
131
|
+
|
|
132
|
+
const { pending, failed } = env.engine.outbox();
|
|
133
|
+
expect(pending).toHaveLength(1);
|
|
134
|
+
expect(pending[0]).toMatchObject({ entity: "Knock", kind: "insert", data: { door: "12", outcome: "sold" } });
|
|
135
|
+
expect(pending[0]!.error).toBeUndefined();
|
|
136
|
+
expect(failed).toHaveLength(1);
|
|
137
|
+
expect(failed[0]).toMatchObject({
|
|
138
|
+
entity: "Knock",
|
|
139
|
+
kind: "insert",
|
|
140
|
+
data: { door: "", outcome: "no answer" },
|
|
141
|
+
error: "door number is required",
|
|
142
|
+
errorCode: "VALIDATION_FAILED",
|
|
143
|
+
});
|
|
144
|
+
// Same object until the queue changes.
|
|
145
|
+
expect(env.engine.outbox()).toBe(env.engine.outbox());
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("subscribers hear add, applied, failed, retry and dismiss", async () => {
|
|
149
|
+
env = createTestEnv({ transport: "poll" });
|
|
150
|
+
env.signIn({ userId: "u1" });
|
|
151
|
+
await env.start();
|
|
152
|
+
let calls = 0;
|
|
153
|
+
env.engine.store.subscribe(() => {
|
|
154
|
+
calls += 1;
|
|
155
|
+
}, OUTBOX_CHANNEL);
|
|
156
|
+
const heard = async (fn: () => Promise<unknown> | unknown) => {
|
|
157
|
+
const before = calls;
|
|
158
|
+
try {
|
|
159
|
+
await fn();
|
|
160
|
+
} catch {
|
|
161
|
+
// A rejected write still changes the outbox.
|
|
162
|
+
}
|
|
163
|
+
await env!.flush(5);
|
|
164
|
+
return calls > before;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// add + applied
|
|
168
|
+
expect(await heard(() => env!.engine.insert("Knock", { door: "1" }))).toBe(true);
|
|
169
|
+
// add + failed
|
|
170
|
+
env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "required");
|
|
171
|
+
expect(await heard(() => env!.engine.insert("Knock", { door: "" }))).toBe(true);
|
|
172
|
+
const failedId = env.engine.outbox().failed[0]!.id;
|
|
173
|
+
// retry (fails again)
|
|
174
|
+
expect(await heard(() => env!.engine.retryWrite(failedId))).toBe(true);
|
|
175
|
+
// dismiss
|
|
176
|
+
const again = env.engine.outbox().failed[0]!.id;
|
|
177
|
+
expect(await heard(() => env!.engine.dismissWrite(again))).toBe(true);
|
|
178
|
+
expect(env.engine.outbox().failed).toHaveLength(0);
|
|
179
|
+
|
|
180
|
+
// Entity listeners are not woken by outbox-only changes.
|
|
181
|
+
let entityCalls = 0;
|
|
182
|
+
env.engine.store.subscribe(() => {
|
|
183
|
+
entityCalls += 1;
|
|
184
|
+
}, "Other");
|
|
185
|
+
env.engine.mutations.markFailed("nope", "x");
|
|
186
|
+
env.engine.store.notify(OUTBOX_CHANNEL);
|
|
187
|
+
expect(entityCalls).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("retry with corrected data is applied; the rejected op is never applied", async () => {
|
|
191
|
+
env = createTestEnv({ transport: "poll" });
|
|
192
|
+
env.signIn({ userId: "u1" });
|
|
193
|
+
await env.start();
|
|
194
|
+
env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "door number is required");
|
|
195
|
+
|
|
196
|
+
let rowId = "";
|
|
197
|
+
try {
|
|
198
|
+
await env.engine.insert("Knock", { door: "", outcome: "sold" });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
rowId = (err as MutationRejectedError).rowId;
|
|
201
|
+
}
|
|
202
|
+
// The rejection rolled the row back.
|
|
203
|
+
expect(env.engine.store.get("Knock", rowId)).toBeNull();
|
|
204
|
+
const failed = env.engine.outbox().failed[0]!;
|
|
205
|
+
|
|
206
|
+
const newOp = await env.engine.retryWrite(failed.id, { door: "14B" });
|
|
207
|
+
expect(newOp).not.toBe(failed.id);
|
|
208
|
+
expect(env.engine.store.get("Knock", rowId)).toMatchObject({ door: "14B", outcome: "sold" });
|
|
209
|
+
expect(env.engine.outbox()).toEqual({ pending: [], failed: [] });
|
|
210
|
+
const onServer = await env.server.listEntityRows("Knock", env.token);
|
|
211
|
+
expect(onServer).toEqual([{ id: rowId, door: "14B", outcome: "sold" }]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a retry the server rejects again is failed again with the new error", async () => {
|
|
215
|
+
env = createTestEnv({ transport: "poll" });
|
|
216
|
+
env.signIn({ userId: "u1" });
|
|
217
|
+
await env.start();
|
|
218
|
+
env.server.seed("Knock", [{ id: "k1", door: "3", outcome: "no answer" }]);
|
|
219
|
+
await env.engine.pull();
|
|
220
|
+
env.server.rejectWritesWhere("Knock", (d) => d.outcome === "bogus", "VALIDATION_FAILED", "unknown outcome");
|
|
221
|
+
env.server.rejectWritesWhere("Knock", (d) => d.outcome === "worse", "POLICY_DENIED", "not allowed");
|
|
222
|
+
await expect(env.engine.update("Knock", "k1", { outcome: "bogus" })).rejects.toThrow();
|
|
223
|
+
expect(env.engine.store.get("Knock", "k1")).toMatchObject({ outcome: "no answer" });
|
|
224
|
+
|
|
225
|
+
const id = env.engine.outbox().failed[0]!.id;
|
|
226
|
+
await expect(env.engine.retryWrite(id, { outcome: "worse" })).rejects.toMatchObject({
|
|
227
|
+
code: "POLICY_DENIED",
|
|
228
|
+
});
|
|
229
|
+
const [failed] = env.engine.outbox().failed;
|
|
230
|
+
expect(failed).toMatchObject({ errorCode: "POLICY_DENIED", error: "not allowed", data: { outcome: "worse" } });
|
|
231
|
+
expect(failed!.id).not.toBe(id);
|
|
232
|
+
// Rolled back to the server's value again.
|
|
233
|
+
expect(env.engine.store.get("Knock", "k1")).toMatchObject({ outcome: "no answer" });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a failed write is never retried as another user", async () => {
|
|
237
|
+
env = createTestEnv({ transport: "poll" });
|
|
238
|
+
env.signIn({ userId: "u1" });
|
|
239
|
+
await env.start();
|
|
240
|
+
env.server.denyWrites("Knock", "POLICY_DENIED", "no");
|
|
241
|
+
await expect(env.engine.insert("Knock", { door: "1" })).rejects.toThrow();
|
|
242
|
+
const id = env.engine.outbox().failed[0]!.id;
|
|
243
|
+
|
|
244
|
+
env.signIn({ userId: "u2" });
|
|
245
|
+
await env.engine.refreshResolvedSession();
|
|
246
|
+
await env.flush(10);
|
|
247
|
+
await expect(env.engine.retryWrite(id)).rejects.toMatchObject({ code: "OWNER_MISMATCH" });
|
|
248
|
+
expect(env.server.receivedPushKeys.filter((k) => k.startsWith("Knock/"))).toHaveLength(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("retryWrite and dismissWrite refuse a write that is not failed", async () => {
|
|
252
|
+
env = createTestEnv({ transport: "poll" });
|
|
253
|
+
env.signIn({ userId: "u1" });
|
|
254
|
+
await env.start();
|
|
255
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
256
|
+
await env.engine.insert("Knock", { door: "1" });
|
|
257
|
+
const id = env.engine.outbox().pending[0]!.id;
|
|
258
|
+
await expect(env.engine.retryWrite(id)).rejects.toThrow("no failed write");
|
|
259
|
+
expect(env.engine.dismissWrite(id)).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("pending writes after a reload", () => {
|
|
264
|
+
const realFetch = globalThis.fetch;
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
globalThis.fetch = realFetch;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("rows written offline are on screen again before the network answers", async () => {
|
|
270
|
+
// Offline: every request fails.
|
|
271
|
+
globalThis.fetch = (async () => {
|
|
272
|
+
throw new TypeError("Failed to fetch");
|
|
273
|
+
}) as unknown as typeof fetch;
|
|
274
|
+
const name = `reload-${Math.random().toString(36).slice(2)}`;
|
|
275
|
+
const disk = new IndexedDBPersistence(name);
|
|
276
|
+
await disk.open();
|
|
277
|
+
await disk.saveIdentity("u1");
|
|
278
|
+
await disk.saveCursor({ last_seq: 5 });
|
|
279
|
+
await disk.saveRow("Knock", "k-old", { id: "k-old", door: "1", outcome: "no answer" });
|
|
280
|
+
const { IndexedDBMutationPersistence } = await import("./persistence");
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
await new IndexedDBMutationPersistence(disk).saveAll([
|
|
283
|
+
{
|
|
284
|
+
id: "op-insert",
|
|
285
|
+
change: { entity: "Knock", row_id: "k-new", kind: "insert", data: { id: "k-new", door: "2", outcome: "sold" }, op_id: "op-insert" },
|
|
286
|
+
status: "pending",
|
|
287
|
+
owner: "u1",
|
|
288
|
+
createdAt: now - 2000,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "op-update",
|
|
292
|
+
change: { entity: "Knock", row_id: "k-old", kind: "update", data: { outcome: "callback" }, op_id: "op-update" },
|
|
293
|
+
status: "pending",
|
|
294
|
+
owner: "u1",
|
|
295
|
+
prevRow: { id: "k-old", door: "1", outcome: "no answer" },
|
|
296
|
+
createdAt: now - 1000,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "op-failed",
|
|
300
|
+
change: { entity: "Knock", row_id: "k-bad", kind: "insert", data: { id: "k-bad" }, op_id: "op-failed" },
|
|
301
|
+
status: "failed",
|
|
302
|
+
error: "rejected",
|
|
303
|
+
owner: "u1",
|
|
304
|
+
createdAt: now - 500,
|
|
305
|
+
},
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
disk.connection?.close();
|
|
309
|
+
|
|
310
|
+
const reopened = new IndexedDBPersistence(name);
|
|
311
|
+
const engine = new SyncEngine({
|
|
312
|
+
baseUrl: "http://test.invalid",
|
|
313
|
+
appName: name,
|
|
314
|
+
persistence: reopened,
|
|
315
|
+
multiTab: false,
|
|
316
|
+
transport: "poll",
|
|
317
|
+
pollInterval: 1e9,
|
|
318
|
+
});
|
|
319
|
+
const started = engine.start().catch(() => {});
|
|
320
|
+
for (let i = 0; i < 50 && !engine.isHydrated(); i++) await new Promise((r) => setTimeout(r, 10));
|
|
321
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
322
|
+
expect(engine.store.get("Knock", "k-new")).toMatchObject({ door: "2", outcome: "sold" });
|
|
323
|
+
expect(engine.store.get("Knock", "k-old")).toMatchObject({ outcome: "callback" });
|
|
324
|
+
// A failed write was rolled back; it is not re-applied.
|
|
325
|
+
expect(engine.store.get("Knock", "k-bad")).toBeNull();
|
|
326
|
+
expect(engine.outbox().pending.map((m) => m.id)).toEqual(["op-insert", "op-update"]);
|
|
327
|
+
expect(engine.outbox().failed.map((m) => m.id)).toEqual(["op-failed"]);
|
|
328
|
+
engine.stop();
|
|
329
|
+
await started;
|
|
330
|
+
// An open connection blocks other suites' deleteDatabase.
|
|
331
|
+
reopened.connection?.close();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A sign-in resolves the session as soon as /api/auth/me answers. The
|
|
2
|
+
// re-pull of the new identity's rows (seconds on a slow phone) does not
|
|
3
|
+
// hold `sessionResolved()` or `useSession` back; the app renders from the
|
|
4
|
+
// session and reads progress from `syncProgress()`.
|
|
5
|
+
|
|
6
|
+
import { afterEach, expect, test } from "bun:test";
|
|
7
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
8
|
+
|
|
9
|
+
let env: TestEnv | null = null;
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
if (env) {
|
|
12
|
+
await env.dispose();
|
|
13
|
+
env = null;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("the session commits before the new identity's snapshot finishes", async () => {
|
|
18
|
+
let release: () => void = () => {};
|
|
19
|
+
const gate = new Promise<void>((r) => (release = r));
|
|
20
|
+
let holding = false;
|
|
21
|
+
env = await createTestEnv({
|
|
22
|
+
transport: "poll",
|
|
23
|
+
beforePull: async (auth) => {
|
|
24
|
+
if (auth.userId === "u1") {
|
|
25
|
+
holding = true;
|
|
26
|
+
await gate;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
env.server.seed("Lead", [{ id: "a", name: "A" }]);
|
|
31
|
+
await env.engine.start();
|
|
32
|
+
expect(env.engine.resolvedSession().userId).toBeNull();
|
|
33
|
+
|
|
34
|
+
env.signIn({ userId: "u1" });
|
|
35
|
+
const refreshed = env.engine.notifySessionChanged();
|
|
36
|
+
// Wait until the re-pull is stuck at the server.
|
|
37
|
+
for (let i = 0; i < 200 && !holding; i++) await new Promise((r) => setTimeout(r, 1));
|
|
38
|
+
expect(holding).toBe(true);
|
|
39
|
+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
|
|
40
|
+
|
|
41
|
+
expect(env.engine.sessionResolved()).toBe(true);
|
|
42
|
+
expect(env.engine.resolvedSession().userId).toBe("u1");
|
|
43
|
+
// The old identity's rows are gone; the new ones have not arrived.
|
|
44
|
+
expect(env.engine.store.list("Lead")).toEqual([]);
|
|
45
|
+
expect(env.engine.syncProgress().pulling).toBe(true);
|
|
46
|
+
|
|
47
|
+
release();
|
|
48
|
+
await refreshed;
|
|
49
|
+
expect(env.engine.store.list("Lead").map((r) => r.id)).toEqual(["a"]);
|
|
50
|
+
expect(env.engine.syncProgress().pulling).toBe(false);
|
|
51
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// `refreshSessionIfStale` (what useSession calls on mount) asks
|
|
2
|
+
// /api/auth/me at most once per session resolve: a screen that mounts many
|
|
3
|
+
// session readers at once must not send one request each.
|
|
4
|
+
|
|
5
|
+
import { afterEach, expect, test } from "bun:test";
|
|
6
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
7
|
+
|
|
8
|
+
let env: TestEnv | null = null;
|
|
9
|
+
let restoreFetch: (() => void) | null = null;
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
restoreFetch?.();
|
|
13
|
+
restoreFetch = null;
|
|
14
|
+
if (env) {
|
|
15
|
+
await env.dispose();
|
|
16
|
+
env = null;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Count /api/auth/me requests made through the harness's fetch. */
|
|
21
|
+
function countAuthMe(): () => number {
|
|
22
|
+
let n = 0;
|
|
23
|
+
const inner = globalThis.fetch;
|
|
24
|
+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
25
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
26
|
+
if (url.endsWith("/api/auth/me")) n++;
|
|
27
|
+
return inner(input, init);
|
|
28
|
+
}) as typeof fetch;
|
|
29
|
+
restoreFetch = () => {
|
|
30
|
+
globalThis.fetch = inner;
|
|
31
|
+
};
|
|
32
|
+
return () => n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("readers that mount during start join its request", async () => {
|
|
36
|
+
env = await createTestEnv({ transport: "poll" });
|
|
37
|
+
env.signIn({ userId: "u1" });
|
|
38
|
+
const count = countAuthMe();
|
|
39
|
+
const started = env.engine.start();
|
|
40
|
+
const mounts = Array.from({ length: 8 }, () => env!.engine.refreshSessionIfStale());
|
|
41
|
+
await Promise.all([started, ...mounts]);
|
|
42
|
+
expect(env.engine.resolvedSession().userId).toBe("u1");
|
|
43
|
+
expect(count()).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("readers that mount after the session resolved send nothing", async () => {
|
|
47
|
+
env = await createTestEnv({ transport: "poll" });
|
|
48
|
+
env.signIn({ userId: "u1" });
|
|
49
|
+
await env.engine.start();
|
|
50
|
+
const count = countAuthMe();
|
|
51
|
+
await Promise.all(Array.from({ length: 8 }, () => env!.engine.refreshSessionIfStale()));
|
|
52
|
+
expect(count()).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a new token is resolved once, however many readers mount", async () => {
|
|
56
|
+
env = await createTestEnv({ transport: "poll" });
|
|
57
|
+
env.signIn({ userId: "u1" });
|
|
58
|
+
await env.engine.start();
|
|
59
|
+
const count = countAuthMe();
|
|
60
|
+
env.signIn({ userId: "u2" });
|
|
61
|
+
await Promise.all(Array.from({ length: 8 }, () => env!.engine.refreshSessionIfStale()));
|
|
62
|
+
expect(env.engine.resolvedSession().userId).toBe("u2");
|
|
63
|
+
expect(count()).toBe(1);
|
|
64
|
+
// Resolved for the new token: no more requests.
|
|
65
|
+
await env.engine.refreshSessionIfStale();
|
|
66
|
+
expect(count()).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("notifySessionChanged still always asks the server", async () => {
|
|
70
|
+
env = await createTestEnv({ transport: "poll" });
|
|
71
|
+
env.signIn({ userId: "u1" });
|
|
72
|
+
await env.engine.start();
|
|
73
|
+
const count = countAuthMe();
|
|
74
|
+
await env.engine.notifySessionChanged();
|
|
75
|
+
await env.engine.notifySessionChanged();
|
|
76
|
+
expect(count()).toBe(2);
|
|
77
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Pull pages and far-behind catch-up.
|
|
2
|
+
//
|
|
3
|
+
// - Pages grow: 1000 rows, then double per full page up to 10,000, so a
|
|
4
|
+
// large first sync costs a few round trips instead of hundreds.
|
|
5
|
+
// - A far-behind cursor is answered with a snapshot of current rows
|
|
6
|
+
// (`snapshot_ok=1`). The replica is not wiped: rows stay on screen, the
|
|
7
|
+
// snapshot's rows apply in place, rows it no longer holds are deleted
|
|
8
|
+
// (writes not yet on the server are kept), and the log resumes from the
|
|
9
|
+
// snapshot's seq.
|
|
10
|
+
|
|
11
|
+
import { afterEach, expect, test } from "bun:test";
|
|
12
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
13
|
+
|
|
14
|
+
let env: TestEnv | null = null;
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
if (env) {
|
|
17
|
+
await env.dispose();
|
|
18
|
+
env = null;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const limits = (urls: string[]) =>
|
|
23
|
+
urls.map((u) => Number(new URL(u, "http://test").searchParams.get("limit")));
|
|
24
|
+
|
|
25
|
+
test("a first sync of 25,000 rows takes 5 pages, growing from 1000", async () => {
|
|
26
|
+
env = await createTestEnv({ transport: "poll" });
|
|
27
|
+
env.server.snapshotMode = true;
|
|
28
|
+
env.signIn({ userId: "u1" });
|
|
29
|
+
env.server.seed(
|
|
30
|
+
"Property",
|
|
31
|
+
Array.from({ length: 25_000 }, (_, i) => ({ id: `p${String(i).padStart(5, "0")}`, n: i })),
|
|
32
|
+
);
|
|
33
|
+
await env.engine.start();
|
|
34
|
+
await env.engine.pull();
|
|
35
|
+
expect(env.engine.store.list("Property").length).toBe(25_000);
|
|
36
|
+
const pulls = env.server.pullUrls.filter((u) => u.includes("since=0") || u.includes("snapshot_after"));
|
|
37
|
+
expect(limits(pulls).slice(0, 5)).toEqual([1000, 2000, 4000, 8000, 10000]);
|
|
38
|
+
expect(pulls.length).toBe(5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a far-behind client applies a snapshot in place and resumes the log", async () => {
|
|
42
|
+
env = await createTestEnv({ transport: "poll" });
|
|
43
|
+
env.server.snapshotMode = true;
|
|
44
|
+
env.signIn({ userId: "u1" });
|
|
45
|
+
env.server.seed("Lead", [
|
|
46
|
+
{ id: "a", name: "A" },
|
|
47
|
+
{ id: "b", name: "B" },
|
|
48
|
+
{ id: "c", name: "C" },
|
|
49
|
+
]);
|
|
50
|
+
await env.engine.start();
|
|
51
|
+
await env.engine.pull();
|
|
52
|
+
expect(env.engine.store.list("Lead").map((r) => r.id).sort()).toEqual(["a", "b", "c"]);
|
|
53
|
+
|
|
54
|
+
// While the client is away: b is deleted, c renamed, d added.
|
|
55
|
+
env.server.delete("Lead", "b");
|
|
56
|
+
env.server.update("Lead", "c", { name: "C2" });
|
|
57
|
+
env.server.insert("Lead", { id: "d", name: "D" });
|
|
58
|
+
env.server.farBehind = () => true;
|
|
59
|
+
|
|
60
|
+
// Watch for the replica going empty mid-catch-up (a wipe).
|
|
61
|
+
let emptied = false;
|
|
62
|
+
const unsubscribe = env.engine.store.subscribe(() => {
|
|
63
|
+
if (env!.engine.store.list("Lead").length === 0) emptied = true;
|
|
64
|
+
});
|
|
65
|
+
const before = env.server.pullUrls.length;
|
|
66
|
+
await env.engine.pull();
|
|
67
|
+
unsubscribe();
|
|
68
|
+
|
|
69
|
+
const rows = env.engine.store.list("Lead");
|
|
70
|
+
expect(rows.map((r) => r.id).sort()).toEqual(["a", "c", "d"]);
|
|
71
|
+
expect(rows.find((r) => r.id === "c")?.name).toBe("C2");
|
|
72
|
+
expect(emptied).toBe(false);
|
|
73
|
+
const catchUp = env.server.pullUrls.slice(before);
|
|
74
|
+
expect(catchUp[0]).toContain("snapshot_ok=1");
|
|
75
|
+
|
|
76
|
+
// The log resumes from the snapshot's seq: the next pull is a delta.
|
|
77
|
+
env.server.farBehind = () => false;
|
|
78
|
+
env.server.insert("Lead", { id: "e", name: "E" });
|
|
79
|
+
await env.engine.pull();
|
|
80
|
+
expect(env.engine.store.list("Lead").map((r) => r.id).sort()).toEqual(["a", "c", "d", "e"]);
|
|
81
|
+
const last = env.server.pullUrls[env.server.pullUrls.length - 1]!;
|
|
82
|
+
expect(last).not.toContain("since=0");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the sweep keeps rows with writes the server has not seen", async () => {
|
|
86
|
+
env = await createTestEnv({ transport: "poll" });
|
|
87
|
+
env.server.snapshotMode = true;
|
|
88
|
+
env.signIn({ userId: "u1" });
|
|
89
|
+
env.server.seed("Lead", [{ id: "a", name: "A" }]);
|
|
90
|
+
await env.engine.start();
|
|
91
|
+
await env.engine.pull();
|
|
92
|
+
|
|
93
|
+
// Offline write: queued, not on the server.
|
|
94
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
95
|
+
const localId = await env.engine.insert("Lead", { name: "made offline" });
|
|
96
|
+
env.server.farBehind = () => true;
|
|
97
|
+
await env.engine.pull();
|
|
98
|
+
const ids = env.engine.store.list("Lead").map((r) => r.id);
|
|
99
|
+
expect(ids).toContain("a");
|
|
100
|
+
expect(ids).toContain(localId);
|
|
101
|
+
});
|
package/src/test-harness/env.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface CreateTestEnvOptions {
|
|
|
71
71
|
* wall-clock waiting on every reconnect scenario; pass a small
|
|
72
72
|
* value when the test specifically exercises the reconnect path. */
|
|
73
73
|
reconnectDelay?: number;
|
|
74
|
+
/** Passed through to the engine config (SyncEngineConfig). */
|
|
75
|
+
autoReconcileIntervalMs?: number;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export interface TestEnv {
|
|
@@ -127,6 +129,7 @@ export function createTestEnv(opts: CreateTestEnvOptions = {}): TestEnv {
|
|
|
127
129
|
transport: opts.transport ?? "websocket",
|
|
128
130
|
reconnectDelay: opts.reconnectDelay,
|
|
129
131
|
resetOnTenantFlip: opts.resetOnTenantFlip,
|
|
132
|
+
autoReconcileIntervalMs: opts.autoReconcileIntervalMs,
|
|
130
133
|
// Tight timings so scenarios don't have to sleep seconds. The
|
|
131
134
|
// engine's reconcile debounce is also relaxed so back-to-back
|
|
132
135
|
// visibility-change triggers don't get coalesced away in tests.
|