@pylonsync/sync 0.22.26 → 0.22.27
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 +12 -36
- package/dist/local-store.d.ts +18 -3
- package/dist/mutation-queue.d.ts +6 -2
- package/dist/persistence.d.ts +3 -4
- package/dist/server-subscriptions.d.ts +2 -0
- package/dist/subscription-coordinator.d.ts +1 -7
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +50 -51
- package/src/client-performance.test.ts +366 -0
- package/src/crdt-follower-catchup.test.ts +56 -25
- package/src/index.ts +146 -132
- package/src/local-store.ts +112 -47
- package/src/mutation-owner-token.test.ts +6 -1
- package/src/mutation-owner.test.ts +6 -1
- package/src/mutation-queue.ts +58 -35
- package/src/persistence.ts +16 -4
- package/src/push-scheduling.test.ts +171 -0
- package/src/reconcile.test.ts +19 -0
- package/src/round6-codex.test.ts +1 -4
- package/src/scenarios.test.ts +6 -8
- package/src/server-subscriptions.ts +6 -0
- package/src/subscription-coordinator.test.ts +5 -2
- package/src/subscription-coordinator.ts +12 -14
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import "fake-indexeddb/auto";
|
|
2
|
+
import { afterEach, expect, test } from "bun:test";
|
|
3
|
+
import { LocalStore } from "./local-store";
|
|
4
|
+
import { MutationQueue } from "./mutation-queue";
|
|
5
|
+
import { IndexedDBMutationPersistence, IndexedDBPersistence, persistChange } from "./persistence";
|
|
6
|
+
import { SyncEngine, type ChangeEvent, type ReplicaPersistence, type Row } from "./index";
|
|
7
|
+
|
|
8
|
+
const disks: IndexedDBPersistence[] = [];
|
|
9
|
+
afterEach(() => { for (const disk of disks.splice(0)) disk.connection?.close(); });
|
|
10
|
+
|
|
11
|
+
function change(entity: string, id: string): ChangeEvent {
|
|
12
|
+
return { seq: 1, entity, row_id: id, kind: "insert", data: { id }, timestamp: "" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function disk() {
|
|
16
|
+
const persistence = new IndexedDBPersistence(`client-perf-${crypto.randomUUID()}`);
|
|
17
|
+
await persistence.open();
|
|
18
|
+
disks.push(persistence);
|
|
19
|
+
return persistence;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function reconcileEngine(persistence: ReplicaPersistence) {
|
|
23
|
+
const engine = new SyncEngine({ baseUrl: "http://test.invalid", multiTab: false });
|
|
24
|
+
const internals = engine as unknown as {
|
|
25
|
+
persistence: ReplicaPersistence;
|
|
26
|
+
replicaEpoch: number;
|
|
27
|
+
persistDegraded: boolean;
|
|
28
|
+
applyQueue: Promise<void>;
|
|
29
|
+
dropEntity(entity: string, seq: number): Promise<void>;
|
|
30
|
+
enqueueReconcile(entity: string, rows: Row[], removed: string[], seq: number): Promise<void>;
|
|
31
|
+
enqueueApply(changes: ChangeEvent[], cursor: { last_seq: number }): Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
internals.persistence = persistence;
|
|
34
|
+
engine.store._persistFn = (ev) => persistChange(persistence, ev);
|
|
35
|
+
return { engine, internals };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("entity subscriptions run once per relevant batch; global signals reach all", () => {
|
|
39
|
+
const store = new LocalStore();
|
|
40
|
+
const calls = { a: 0, b: 0, c: 0, global: 0 };
|
|
41
|
+
store.subscribe(() => calls.a++, "A");
|
|
42
|
+
store.subscribe(() => calls.b++, "B");
|
|
43
|
+
store.subscribe(() => calls.c++, "C");
|
|
44
|
+
store.subscribe(() => calls.global++);
|
|
45
|
+
const beforeC = store.version("C");
|
|
46
|
+
store.applyInMemory([change("A", "1"), change("B", "2"), change("A", "3")]);
|
|
47
|
+
expect(calls).toEqual({ a: 1, b: 1, c: 0, global: 1 });
|
|
48
|
+
expect(store.version("C")).toBe(beforeC);
|
|
49
|
+
const beforeA = store.version("A");
|
|
50
|
+
store.notify();
|
|
51
|
+
expect(store.version("A")).toBe(beforeA);
|
|
52
|
+
expect(calls).toEqual({ a: 2, b: 2, c: 1, global: 2 });
|
|
53
|
+
store.clearAll();
|
|
54
|
+
expect(store.version("A")).toBeGreaterThan(beforeA);
|
|
55
|
+
expect(store.version("C")).toBeGreaterThan(beforeC);
|
|
56
|
+
expect(calls).toEqual({ a: 3, b: 3, c: 2, global: 3 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("every row mutation invalidates the entity version", async () => {
|
|
60
|
+
const store = new LocalStore();
|
|
61
|
+
const mutate = (fn: () => void) => {
|
|
62
|
+
const before = store.version("A");
|
|
63
|
+
fn();
|
|
64
|
+
expect(store.version("A")).toBeGreaterThan(before);
|
|
65
|
+
};
|
|
66
|
+
mutate(() => store.applyChange(change("A", "1")));
|
|
67
|
+
mutate(() => store.optimisticUpdate("A", "1", { text: "new" }));
|
|
68
|
+
mutate(() => store.optimisticDelete("A", "1"));
|
|
69
|
+
mutate(() => store.restoreRow("A", "1", { id: "1" }));
|
|
70
|
+
mutate(() => store.reconcileRemove("A", "1", 5));
|
|
71
|
+
mutate(() => store.optimisticInsertWithId("A", "2", {}));
|
|
72
|
+
mutate(() => store.rollbackOptimisticInsert("A", "2"));
|
|
73
|
+
mutate(() => store.optimisticInsertWithId("A", "3", {}));
|
|
74
|
+
mutate(() => store.revokeRow("A", "3", 5));
|
|
75
|
+
mutate(() => store.applyReconcileInMemory("A", [{ id: "4" }], [], 5));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("row versions track writes, removals, reconciliation, and reset", () => {
|
|
79
|
+
const store = new LocalStore();
|
|
80
|
+
store.optimisticInsertWithId("A", "unrelated", {});
|
|
81
|
+
const unrelated = store.rowVersion("A", "unrelated");
|
|
82
|
+
const mutate = (fn: () => void) => {
|
|
83
|
+
const before = store.rowVersion("A", "1");
|
|
84
|
+
fn();
|
|
85
|
+
expect(store.rowVersion("A", "1")).not.toBe(before);
|
|
86
|
+
expect(store.rowVersion("A", "unrelated")).toBe(unrelated);
|
|
87
|
+
};
|
|
88
|
+
mutate(() => store.applyChange(change("A", "1")));
|
|
89
|
+
mutate(() => store.optimisticUpdate("A", "1", { text: "new" }));
|
|
90
|
+
mutate(() => store.optimisticDelete("A", "1"));
|
|
91
|
+
mutate(() => store.restoreRow("A", "1", { id: "1" }));
|
|
92
|
+
mutate(() => store.rollbackOptimisticInsert("A", "1"));
|
|
93
|
+
mutate(() => store.optimisticInsertWithId("A", "1", {}));
|
|
94
|
+
mutate(() => store.reconcileRemove("A", "1", 5));
|
|
95
|
+
mutate(() => store.applyReconcileInMemory("A", [{ id: "1" }], [], 6));
|
|
96
|
+
mutate(() => store.revokeRow("A", "1", 7));
|
|
97
|
+
const missing = store.rowVersion("A", "1");
|
|
98
|
+
store.applyChange(change("A", "1"));
|
|
99
|
+
expect(store.rowVersion("A", "1")).toBe(missing);
|
|
100
|
+
store.clearAll();
|
|
101
|
+
expect(store.rowVersion("A", "1")).not.toBe(missing);
|
|
102
|
+
expect(store.rowVersion("A", "unrelated")).not.toBe(unrelated);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("1000 reconcile rows use one transaction and retain cursor and tombstone fences", async () => {
|
|
106
|
+
const persistence = await disk();
|
|
107
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
108
|
+
await persistence.saveCursor({ last_seq: 20 });
|
|
109
|
+
await persistence.saveRow("A", "old", { id: "old" });
|
|
110
|
+
engine.store.applyChange(change("A", "old"));
|
|
111
|
+
engine.store.revokeRow("A", "blocked", 30);
|
|
112
|
+
const db = persistence.connection!;
|
|
113
|
+
const transaction = db.transaction.bind(db);
|
|
114
|
+
let writes = 0;
|
|
115
|
+
db.transaction = ((...args: Parameters<typeof db.transaction>) => {
|
|
116
|
+
if (args[1] === "readwrite") writes++;
|
|
117
|
+
return transaction(...args);
|
|
118
|
+
}) as typeof db.transaction;
|
|
119
|
+
await internals.enqueueReconcile("A", [
|
|
120
|
+
...Array.from({ length: 1000 }, (_, i) => ({ id: String(i) })),
|
|
121
|
+
{ id: "blocked" },
|
|
122
|
+
], ["old"], 20);
|
|
123
|
+
expect(writes).toBe(1);
|
|
124
|
+
const saved = await persistence.loadSnapshot();
|
|
125
|
+
expect(saved.entities.A).toHaveLength(1000);
|
|
126
|
+
expect(saved.entities.A.some((row) => row.id === "blocked" || row.id === "old")).toBe(false);
|
|
127
|
+
expect(saved.cursor).toEqual({ last_seq: 20 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("reconcile failure freezes later cursor writes and reset fences queued work", async () => {
|
|
131
|
+
const persistence = await disk();
|
|
132
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
133
|
+
await persistence.saveCursor({ last_seq: 0 });
|
|
134
|
+
const saveBatch = persistence.saveBatch.bind(persistence);
|
|
135
|
+
persistence.saveBatch = async () => false;
|
|
136
|
+
await internals.enqueueReconcile("A", [{ id: "1" }], [], 1);
|
|
137
|
+
expect(engine.store.get("A", "1")).not.toBeNull();
|
|
138
|
+
expect(internals.persistDegraded).toBe(true);
|
|
139
|
+
persistence.saveBatch = saveBatch;
|
|
140
|
+
await internals.enqueueApply([{ ...change("A", "2"), seq: 2 }], { last_seq: 2 });
|
|
141
|
+
expect(engine.getCursor().last_seq).toBe(2);
|
|
142
|
+
expect((await persistence.loadSnapshot()).cursor?.last_seq).toBe(0);
|
|
143
|
+
let release!: () => void;
|
|
144
|
+
internals.applyQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
145
|
+
const pending = internals.enqueueReconcile("A", [{ id: "stale" }], [], 1);
|
|
146
|
+
internals.replicaEpoch++;
|
|
147
|
+
engine.store.clearAll();
|
|
148
|
+
release();
|
|
149
|
+
await pending;
|
|
150
|
+
expect(engine.store.list("A")).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a late reconcile failure cannot change persistence state after reset", async () => {
|
|
154
|
+
const persistence = await disk();
|
|
155
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
156
|
+
let release!: (durable: boolean) => void;
|
|
157
|
+
persistence.saveBatch = () => new Promise<boolean>((resolve) => { release = resolve; });
|
|
158
|
+
const pending = internals.enqueueReconcile("A", [{ id: "old" }], [], 1);
|
|
159
|
+
await Promise.resolve();
|
|
160
|
+
internals.replicaEpoch++;
|
|
161
|
+
engine.store.clearAll();
|
|
162
|
+
await persistence.clear();
|
|
163
|
+
release(false);
|
|
164
|
+
await pending;
|
|
165
|
+
expect(internals.persistDegraded).toBe(false);
|
|
166
|
+
expect(engine.store.list("A")).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("reconcile single-row fallback stops if reset happens during persistence", async () => {
|
|
170
|
+
const persistence = await disk();
|
|
171
|
+
Object.defineProperty(persistence, "saveBatch", { value: undefined });
|
|
172
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
173
|
+
let writes = 0;
|
|
174
|
+
engine.store._persistFn = async () => {
|
|
175
|
+
writes++;
|
|
176
|
+
internals.replicaEpoch++;
|
|
177
|
+
engine.store.clearAll();
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
180
|
+
await internals.enqueueReconcile("A", [{ id: "1" }, { id: "2" }], [], 1);
|
|
181
|
+
expect(writes).toBe(1);
|
|
182
|
+
expect(engine.store.list("A")).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("1000 queued writes and acknowledgements persist linear entry counts", async () => {
|
|
186
|
+
const persistence = new IndexedDBMutationPersistence(await disk());
|
|
187
|
+
const queue = new MutationQueue(persistence);
|
|
188
|
+
const saveChanges = persistence.saveChanges.bind(persistence);
|
|
189
|
+
let puts = 0;
|
|
190
|
+
let deletes = 0;
|
|
191
|
+
let batches = 0;
|
|
192
|
+
persistence.saveChanges = async (upserts, removed) => {
|
|
193
|
+
puts += upserts.length;
|
|
194
|
+
deletes += removed.length;
|
|
195
|
+
batches++;
|
|
196
|
+
await saveChanges(upserts, removed);
|
|
197
|
+
};
|
|
198
|
+
const ids = Array.from({ length: 1000 }, (_, i) => queue.add({
|
|
199
|
+
entity: "A", row_id: String(i), kind: "insert", data: { id: String(i) },
|
|
200
|
+
}, undefined, "user-1"));
|
|
201
|
+
expect((await persistence.loadAll())).toHaveLength(1000);
|
|
202
|
+
expect(puts).toBe(1000);
|
|
203
|
+
queue.markAppliedMany(ids);
|
|
204
|
+
expect((await persistence.loadAll()).every((m) => m.status === "applied")).toBe(true);
|
|
205
|
+
expect(puts).toBe(2000);
|
|
206
|
+
expect(batches).toBe(1001);
|
|
207
|
+
queue.clear();
|
|
208
|
+
expect(await persistence.loadAll()).toEqual([]);
|
|
209
|
+
expect(deletes).toBe(1000);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("incremental writes preserve failures and owners across hydration and reset", async () => {
|
|
213
|
+
const persistence = new IndexedDBMutationPersistence(await disk());
|
|
214
|
+
const queue = new MutationQueue(persistence);
|
|
215
|
+
const failed = queue.add({ entity: "A", row_id: "1", kind: "delete" }, { id: "1" }, undefined, true);
|
|
216
|
+
const pending = queue.add({ entity: "A", row_id: "2", kind: "delete" }, null, undefined, true);
|
|
217
|
+
queue.stampPendingOwner("owner-1");
|
|
218
|
+
queue.markFailed(failed, "denied", "POLICY_DENIED");
|
|
219
|
+
const reloaded = new MutationQueue(persistence);
|
|
220
|
+
await reloaded.hydrate();
|
|
221
|
+
expect(reloaded.get(failed)).toMatchObject({ status: "failed", owner: "owner-1", errorCode: "POLICY_DENIED", prevRow: { id: "1" } });
|
|
222
|
+
expect(reloaded.get(pending)).toMatchObject({ status: "pending", owner: "owner-1" });
|
|
223
|
+
expect(reloaded.get(pending)?.ownerPending).toBeUndefined();
|
|
224
|
+
reloaded.remove(failed);
|
|
225
|
+
expect((await persistence.loadAll()).map((m) => m.id)).toEqual([pending]);
|
|
226
|
+
reloaded.clearAll();
|
|
227
|
+
expect(await persistence.loadAll()).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("custom mutation backends keep full snapshots and batch acknowledgements", async () => {
|
|
231
|
+
const snapshots: unknown[][] = [];
|
|
232
|
+
const queue = new MutationQueue({
|
|
233
|
+
saveAll: async (rows) => { snapshots.push(rows); }, loadAll: async () => [],
|
|
234
|
+
});
|
|
235
|
+
const a = queue.add({ entity: "A", row_id: "1", kind: "delete" });
|
|
236
|
+
const b = queue.add({ entity: "A", row_id: "2", kind: "delete" });
|
|
237
|
+
queue.markAppliedMany([a, b]);
|
|
238
|
+
expect(snapshots).toHaveLength(3);
|
|
239
|
+
expect(snapshots[1]).toEqual(expect.arrayContaining([expect.objectContaining({ id: a, status: "pending" })]));
|
|
240
|
+
expect(snapshots[2]).toEqual(expect.arrayContaining([expect.objectContaining({ id: a, status: "applied" })]));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("queue ID operations do not visit unrelated mutations", () => {
|
|
244
|
+
const queue = new MutationQueue();
|
|
245
|
+
let idReads = 0;
|
|
246
|
+
for (let i = 0; i < 1000; i++) {
|
|
247
|
+
const id = `op-${i}`;
|
|
248
|
+
queue.add({ op_id: id, entity: "A", row_id: String(i), kind: "delete" });
|
|
249
|
+
Object.defineProperty(queue.get(id)!, "id", { get() { idReads++; return id; } });
|
|
250
|
+
}
|
|
251
|
+
const retained = queue.get("op-999");
|
|
252
|
+
queue.add({ op_id: "op-999", entity: "A", row_id: "duplicate", kind: "delete" });
|
|
253
|
+
queue.markFailed("op-999", "denied");
|
|
254
|
+
expect(queue.get("op-999")).toBe(retained);
|
|
255
|
+
queue.markAppliedMany(["op-999", "missing", "op-999"]);
|
|
256
|
+
expect(retained?.status).toBe("applied");
|
|
257
|
+
queue.remove("op-998");
|
|
258
|
+
queue.discard("op-997");
|
|
259
|
+
expect(idReads).toBeLessThan(10);
|
|
260
|
+
expect(queue.pending()).toHaveLength(997);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("hydration preserves live entry identity and deduplicates stored IDs in order", async () => {
|
|
264
|
+
let release!: (rows: import("./mutation-queue").PendingMutation[]) => void;
|
|
265
|
+
const snapshots: import("./mutation-queue").PendingMutation[][] = [];
|
|
266
|
+
const queue = new MutationQueue({
|
|
267
|
+
loadAll: () => new Promise((resolve) => { release = resolve; }),
|
|
268
|
+
saveAll: async (rows) => { snapshots.push(rows); },
|
|
269
|
+
});
|
|
270
|
+
const hydration = queue.hydrate();
|
|
271
|
+
const makeChange = (id: string) => ({ op_id: id, entity: "A", row_id: id, kind: "delete" as const });
|
|
272
|
+
queue.add(makeChange("live"));
|
|
273
|
+
const live = queue.get("live");
|
|
274
|
+
release([
|
|
275
|
+
{ id: "live", change: makeChange("live"), status: "failed" },
|
|
276
|
+
{ id: "saved", change: makeChange("saved"), status: "pending" },
|
|
277
|
+
{ id: "saved", change: makeChange("saved"), status: "failed" },
|
|
278
|
+
]);
|
|
279
|
+
await hydration;
|
|
280
|
+
expect(queue.get("live")).toBe(live);
|
|
281
|
+
expect(queue.pending().map((m) => m.id)).toEqual(["live", "saved"]);
|
|
282
|
+
queue.markAppliedMany(["saved", "live"]);
|
|
283
|
+
expect(snapshots.at(-1)?.map((m) => m.id)).toEqual(["live", "saved"]);
|
|
284
|
+
queue.clear();
|
|
285
|
+
expect(snapshots.at(-1)).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
test("dropping 1000 entity rows uses one transaction and preserves cursor and tombstones", async () => {
|
|
290
|
+
const persistence = await disk();
|
|
291
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
292
|
+
const rows = Array.from({ length: 1000 }, (_, i) => ({ id: String(i) }));
|
|
293
|
+
await internals.enqueueReconcile("A", rows, [], 20);
|
|
294
|
+
await persistence.saveCursor({ last_seq: 20 });
|
|
295
|
+
const db = persistence.connection!;
|
|
296
|
+
const transaction = db.transaction.bind(db);
|
|
297
|
+
let writes = 0;
|
|
298
|
+
db.transaction = ((...args: Parameters<typeof db.transaction>) => {
|
|
299
|
+
if (args[1] === "readwrite") writes++;
|
|
300
|
+
return transaction(...args);
|
|
301
|
+
}) as typeof db.transaction;
|
|
302
|
+
await internals.dropEntity("A", 20);
|
|
303
|
+
expect(writes).toBe(1);
|
|
304
|
+
expect(engine.store.list("A")).toEqual([]);
|
|
305
|
+
const saved = await persistence.loadSnapshot();
|
|
306
|
+
expect(saved.entities.A ?? []).toEqual([]);
|
|
307
|
+
expect(saved.cursor).toEqual({ last_seq: 20 });
|
|
308
|
+
engine.store.applyChange({ ...change("A", "0"), seq: 19 });
|
|
309
|
+
expect(engine.store.get("A", "0")).toBeNull();
|
|
310
|
+
engine.store.applyChange({ ...change("A", "0"), seq: 21 });
|
|
311
|
+
expect(engine.store.get("A", "0")).not.toBeNull();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("queued entity removal cannot delete rows after replica reset", async () => {
|
|
315
|
+
const persistence = await disk();
|
|
316
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
317
|
+
await internals.enqueueReconcile("A", [{ id: "same" }], [], 1);
|
|
318
|
+
let release!: () => void;
|
|
319
|
+
internals.applyQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
320
|
+
const pending = internals.dropEntity("A", 10);
|
|
321
|
+
internals.replicaEpoch++;
|
|
322
|
+
engine.store.clearAll();
|
|
323
|
+
engine.store.applyChange(change("A", "same"));
|
|
324
|
+
release();
|
|
325
|
+
await pending;
|
|
326
|
+
expect(engine.store.get("A", "same")).not.toBeNull();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
test("entity removal failure freezes persistence and fallback stops after reset", async () => {
|
|
331
|
+
const persistence = await disk();
|
|
332
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
333
|
+
engine.store.applyInMemory([change("A", "1"), change("A", "2")]);
|
|
334
|
+
persistence.saveBatch = async () => false;
|
|
335
|
+
await internals.dropEntity("A", 10);
|
|
336
|
+
expect(internals.persistDegraded).toBe(true);
|
|
337
|
+
expect(engine.store.list("A")).toEqual([]);
|
|
338
|
+
Object.defineProperty(persistence, "saveBatch", { value: undefined });
|
|
339
|
+
engine.store.applyInMemory([
|
|
340
|
+
{ ...change("A", "1"), seq: 11 },
|
|
341
|
+
{ ...change("A", "2"), seq: 11 },
|
|
342
|
+
]);
|
|
343
|
+
let deletes = 0;
|
|
344
|
+
engine.store._persistFn = async () => {
|
|
345
|
+
deletes++;
|
|
346
|
+
internals.replicaEpoch++;
|
|
347
|
+
engine.store.clearAll();
|
|
348
|
+
return true;
|
|
349
|
+
};
|
|
350
|
+
await internals.dropEntity("A", 20);
|
|
351
|
+
expect(deletes).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
test("a thrown removal batch freezes the disk cursor and still broadcasts removal", async () => {
|
|
356
|
+
const persistence = await disk();
|
|
357
|
+
const { engine, internals } = reconcileEngine(persistence);
|
|
358
|
+
engine.store.applyChange(change("A", "1"));
|
|
359
|
+
persistence.saveBatch = async () => { throw new Error("disk failed"); };
|
|
360
|
+
const messages: unknown[] = [];
|
|
361
|
+
(engine as unknown as { broadcastToTabs(message: unknown): void }).broadcastToTabs = (message) => messages.push(message);
|
|
362
|
+
await internals.dropEntity("A", 10);
|
|
363
|
+
expect(internals.persistDegraded).toBe(true);
|
|
364
|
+
expect(engine.store.list("A")).toEqual([]);
|
|
365
|
+
expect(messages).toEqual([{ type: "reconciled", entity: "A", upserts: [], removalIds: ["1"], tombstoneSeq: 10 }]);
|
|
366
|
+
});
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// registers interest in a row the leader (or another follower) already
|
|
4
|
-
// subscribed, no wire traffic happens — pre-fix the new tab's LoroDoc
|
|
5
|
-
// stayed empty until the next live edit ("open the doc in a second tab
|
|
6
|
-
// and the content doesn't render"). The leader now caches the latest
|
|
7
|
-
// snapshot frame per row and replays it over the tab channel.
|
|
1
|
+
// A new follower needs a full authorized snapshot from the server.
|
|
2
|
+
// Binary update frames cannot serve as a complete document cache.
|
|
8
3
|
import { describe, expect, test } from "bun:test";
|
|
9
4
|
import { SubscriptionCoordinator, crdtKey } from "./subscription-coordinator";
|
|
10
5
|
import { ServerSubscriptions } from "./server-subscriptions";
|
|
11
|
-
import { crdtFrameKey } from "./index";
|
|
6
|
+
import { crdtFrameKey, SyncEngine } from "./index";
|
|
12
7
|
|
|
13
8
|
function encodeSnapshotFrame(
|
|
14
9
|
type: number,
|
|
@@ -47,7 +42,6 @@ describe("crdtFrameKey", () => {
|
|
|
47
42
|
describe("forwarded register replay", () => {
|
|
48
43
|
function harness() {
|
|
49
44
|
const sent: unknown[] = [];
|
|
50
|
-
const replayed: Array<[string, string]> = [];
|
|
51
45
|
const serverSubs = new ServerSubscriptions((msg) => {
|
|
52
46
|
sent.push(msg);
|
|
53
47
|
return true;
|
|
@@ -55,36 +49,31 @@ describe("forwarded register replay", () => {
|
|
|
55
49
|
const coordinator = new SubscriptionCoordinator(serverSubs, {
|
|
56
50
|
isLeader: () => true,
|
|
57
51
|
broadcastToTabs: () => {},
|
|
58
|
-
replayCrdtFrame: (entity, rowId) => {
|
|
59
|
-
replayed.push([entity, rowId]);
|
|
60
|
-
},
|
|
61
52
|
});
|
|
62
|
-
return { coordinator, sent
|
|
53
|
+
return { coordinator, sent };
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
test("
|
|
66
|
-
const { coordinator,
|
|
56
|
+
test("an existing subscription requests a full snapshot for a new follower", () => {
|
|
57
|
+
const { coordinator, sent } = harness();
|
|
67
58
|
// Leader's own component holds the row → WS sub is alive.
|
|
68
59
|
coordinator.subscribeCrdt("Doc", "row1");
|
|
69
|
-
// A second tab
|
|
70
|
-
// crdt-subscribe goes out (the sub exists), so the ONLY way this
|
|
71
|
-
// tab gets state is the replay.
|
|
60
|
+
// A second tab needs a fresh snapshot even though the row is subscribed.
|
|
72
61
|
coordinator.handleForwardedRegister(
|
|
73
62
|
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" },
|
|
74
63
|
"tab-2",
|
|
75
64
|
);
|
|
76
|
-
expect(
|
|
65
|
+
expect(sent.filter((message) => (message as { type?: string }).type === "crdt-subscribe")).toHaveLength(2);
|
|
77
66
|
});
|
|
78
67
|
|
|
79
|
-
test("fresh subscription
|
|
80
|
-
const { coordinator,
|
|
68
|
+
test("a fresh subscription needs only its initial request", () => {
|
|
69
|
+
const { coordinator, sent } = harness();
|
|
81
70
|
coordinator.handleForwardedRegister(
|
|
82
71
|
{ kind: "crdt", key: crdtKey("Doc", "row9"), entity: "Doc", rowId: "row9" },
|
|
83
72
|
"tab-2",
|
|
84
73
|
);
|
|
85
74
|
// First interest in the row → real crdt-subscribe goes out; the
|
|
86
75
|
// server's own catch-up snapshot will be fanned to tabs.
|
|
87
|
-
expect(
|
|
76
|
+
expect(sent).toHaveLength(1);
|
|
88
77
|
expect(
|
|
89
78
|
sent.some(
|
|
90
79
|
(m) => (m as { type?: string }).type === "crdt-subscribe",
|
|
@@ -92,8 +81,8 @@ describe("forwarded register replay", () => {
|
|
|
92
81
|
).toBe(true);
|
|
93
82
|
});
|
|
94
83
|
|
|
95
|
-
test("second follower
|
|
96
|
-
const { coordinator,
|
|
84
|
+
test("a second follower refreshes state but duplicate registration does not", () => {
|
|
85
|
+
const { coordinator, sent } = harness();
|
|
97
86
|
coordinator.handleForwardedRegister(
|
|
98
87
|
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" },
|
|
99
88
|
"tab-2",
|
|
@@ -102,6 +91,48 @@ describe("forwarded register replay", () => {
|
|
|
102
91
|
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" },
|
|
103
92
|
"tab-3",
|
|
104
93
|
);
|
|
105
|
-
expect(
|
|
94
|
+
expect(sent.filter((message) => (message as { type?: string }).type === "crdt-subscribe")).toHaveLength(2);
|
|
95
|
+
coordinator.handleForwardedRegister(
|
|
96
|
+
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" }, "tab-3",
|
|
97
|
+
);
|
|
98
|
+
expect(sent).toHaveLength(2);
|
|
106
99
|
});
|
|
107
100
|
});
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
test("binary fanout follows row subscriptions and retains snapshot catch-up", () => {
|
|
104
|
+
const engine = new SyncEngine({ baseUrl: "http://test.invalid", multiTab: false, persist: false });
|
|
105
|
+
const internal = engine as unknown as {
|
|
106
|
+
subscriptions: SubscriptionCoordinator;
|
|
107
|
+
dispatchBinaryFrame(bytes: Uint8Array): void;
|
|
108
|
+
broadcastToTabs(message: { type: string; bytes?: Uint8Array }): void;
|
|
109
|
+
};
|
|
110
|
+
const sent: Uint8Array[] = [];
|
|
111
|
+
internal.broadcastToTabs = (message) => { if (message.type === "binary") sent.push(message.bytes!); };
|
|
112
|
+
const subscription = { kind: "crdt" as const, key: crdtKey("Doc", "wanted"), entity: "Doc", rowId: "wanted" };
|
|
113
|
+
internal.subscriptions.handleForwardedRegister(subscription, "follower");
|
|
114
|
+
const payload = new Uint8Array(1024 * 1024);
|
|
115
|
+
for (let i = 0; i < 10; i++) {
|
|
116
|
+
internal.dispatchBinaryFrame(encodeSnapshotFrame(i % 2 ? 0x10 : 0x11, "Doc", `other-${i}`, payload));
|
|
117
|
+
}
|
|
118
|
+
expect(sent).toHaveLength(0);
|
|
119
|
+
const snapshot = encodeSnapshotFrame(0x10, "Doc", "wanted", payload);
|
|
120
|
+
const update = encodeSnapshotFrame(0x11, "Doc", "wanted", new Uint8Array([1]));
|
|
121
|
+
internal.dispatchBinaryFrame(snapshot);
|
|
122
|
+
internal.dispatchBinaryFrame(update);
|
|
123
|
+
expect(sent).toEqual([snapshot, update]);
|
|
124
|
+
internal.subscriptions.handleForwardedRegister(subscription, "late-follower");
|
|
125
|
+
expect(sent).toHaveLength(2); // Never replay cached binary data to a new follower.
|
|
126
|
+
for (const bytes of [new Uint8Array([0x20]), new Uint8Array([0x10, 0, 9]), new Uint8Array([0x10, 0, 1, 0xff, 0, 1, 0x61])]) {
|
|
127
|
+
internal.dispatchBinaryFrame(bytes);
|
|
128
|
+
expect(sent.at(-1)).toBe(bytes);
|
|
129
|
+
}
|
|
130
|
+
for (const follower of ["follower", "late-follower"]) {
|
|
131
|
+
internal.subscriptions.handleForwardedUnregister({ type: "sub-unregister", ...subscription }, follower);
|
|
132
|
+
}
|
|
133
|
+
const count = sent.length;
|
|
134
|
+
internal.dispatchBinaryFrame(snapshot);
|
|
135
|
+
internal.dispatchBinaryFrame(update);
|
|
136
|
+
internal.dispatchBinaryFrame(new Uint8Array([0x20]));
|
|
137
|
+
expect(sent).toHaveLength(count);
|
|
138
|
+
});
|