@pylonsync/sync 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +108 -7
- package/dist/local-store.d.ts +7 -0
- package/dist/multi-tab-orchestrator.d.ts +14 -0
- package/dist/mutation-queue.d.ts +57 -2
- package/dist/persistence.d.ts +12 -0
- package/dist/room-subscriptions.d.ts +5 -0
- package/dist/test-harness/server.d.ts +7 -0
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +9 -6
- package/src/idb-warm-load.test.ts +5 -11
- package/src/identity-reset-safety.test.ts +174 -0
- package/src/identity-resync.test.ts +120 -0
- package/src/index.ts +701 -152
- package/src/live-batch-persist.test.ts +180 -0
- package/src/local-store.ts +14 -0
- package/src/multi-tab-orchestrator.test.ts +3 -0
- package/src/multi-tab-orchestrator.ts +39 -3
- package/src/mutation-outcome.test.ts +203 -0
- package/src/mutation-owner-token.test.ts +131 -0
- package/src/mutation-owner.test.ts +217 -0
- package/src/mutation-queue.ts +177 -3
- package/src/persistence.ts +31 -0
- package/src/reconcile.test.ts +6 -3
- package/src/room-subscriptions.test.ts +32 -0
- package/src/room-subscriptions.ts +11 -0
- package/src/scenarios.test.ts +9 -3
- package/src/synced-flag.test.ts +117 -0
- package/src/test-harness/server.ts +15 -0
- package/src/test-harness/transport.ts +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Live change frames are group-committed to IndexedDB.
|
|
2
|
+
//
|
|
3
|
+
// Each WebSocket change frame used to be its own apply step: one store
|
|
4
|
+
// notify, one IndexedDB transaction for the row, and a second one for the
|
|
5
|
+
// cursor, all awaited before the next frame could apply. A busy channel
|
|
6
|
+
// capped each tab at a few hundred row changes per second. Frames that
|
|
7
|
+
// arrive while a write is in flight now join one batch: one notify and one
|
|
8
|
+
// transaction that holds the rows and the cursor.
|
|
9
|
+
|
|
10
|
+
import "fake-indexeddb/auto";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { IndexedDBPersistence, SyncEngine, type ChangeEvent } from "./index";
|
|
14
|
+
|
|
15
|
+
const realFetch = globalThis.fetch;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
19
|
+
const url = String(input);
|
|
20
|
+
const body = url.includes("/api/sync/pull")
|
|
21
|
+
? { changes: [], cursor: { last_seq: 0 }, has_more: false }
|
|
22
|
+
: url.includes("/api/auth/me")
|
|
23
|
+
? { user_id: "u1" }
|
|
24
|
+
: {};
|
|
25
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
26
|
+
}) as typeof fetch;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = realFetch;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function startEngine() {
|
|
34
|
+
const persistence = new IndexedDBPersistence(`live-batch-${Math.random().toString(36).slice(2)}`);
|
|
35
|
+
const engine = new SyncEngine({
|
|
36
|
+
baseUrl: "http://test.invalid",
|
|
37
|
+
persistence,
|
|
38
|
+
multiTab: false,
|
|
39
|
+
transport: "poll",
|
|
40
|
+
pollInterval: 1e9,
|
|
41
|
+
reconcileOnVisibility: false,
|
|
42
|
+
});
|
|
43
|
+
await engine.start();
|
|
44
|
+
const db = persistence.connection!;
|
|
45
|
+
const realTx = db.transaction.bind(db);
|
|
46
|
+
const counts = { writeTx: 0 };
|
|
47
|
+
(db as unknown as { transaction: typeof db.transaction }).transaction = ((
|
|
48
|
+
...args: Parameters<typeof db.transaction>
|
|
49
|
+
) => {
|
|
50
|
+
if (String(args[1]) === "readwrite") counts.writeTx += 1;
|
|
51
|
+
return realTx(...args);
|
|
52
|
+
}) as typeof db.transaction;
|
|
53
|
+
let notifies = 0;
|
|
54
|
+
engine.store.subscribe(() => {
|
|
55
|
+
notifies += 1;
|
|
56
|
+
});
|
|
57
|
+
const internals = engine as unknown as {
|
|
58
|
+
transportHost(): { onChangeEvent(ev: ChangeEvent): void };
|
|
59
|
+
applyQueue: Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
const host = internals.transportHost();
|
|
62
|
+
return {
|
|
63
|
+
engine,
|
|
64
|
+
persistence,
|
|
65
|
+
counts,
|
|
66
|
+
notifies: () => notifies,
|
|
67
|
+
/** Deliver frames one task each, like WebSocket messages. */
|
|
68
|
+
async deliver(frames: ChangeEvent[]) {
|
|
69
|
+
const channel = new MessageChannel();
|
|
70
|
+
await new Promise<void>((resolve) => {
|
|
71
|
+
let n = 0;
|
|
72
|
+
channel.port1.onmessage = (m) => {
|
|
73
|
+
host.onChangeEvent(frames[m.data as number]!);
|
|
74
|
+
if (++n === frames.length) resolve();
|
|
75
|
+
};
|
|
76
|
+
frames.forEach((_, i) => channel.port2.postMessage(i));
|
|
77
|
+
});
|
|
78
|
+
channel.port1.close();
|
|
79
|
+
const last = frames[frames.length - 1]!.seq;
|
|
80
|
+
while (engine.getCursor().last_seq < last) {
|
|
81
|
+
await internals.applyQueue;
|
|
82
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
83
|
+
}
|
|
84
|
+
await internals.applyQueue;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function insert(seq: number, id: string, data: Record<string, unknown>): ChangeEvent {
|
|
90
|
+
return { seq, entity: "Msg", row_id: id, kind: "insert", data: { id, ...data }, timestamp: "" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("live frame group commit", () => {
|
|
94
|
+
test("a burst of frames shares IndexedDB transactions and store notifies", async () => {
|
|
95
|
+
const t = await startEngine();
|
|
96
|
+
const frames = Array.from({ length: 300 }, (_, i) => insert(i + 1, `m${i}`, { body: `hi ${i}` }));
|
|
97
|
+
const notifiesBefore = t.notifies();
|
|
98
|
+
const t0 = performance.now();
|
|
99
|
+
await t.deliver(frames);
|
|
100
|
+
const ms = performance.now() - t0;
|
|
101
|
+
|
|
102
|
+
const snap = await t.persistence.loadSnapshot();
|
|
103
|
+
expect(snap.entities.Msg).toHaveLength(300);
|
|
104
|
+
expect(snap.cursor?.last_seq).toBe(300);
|
|
105
|
+
expect(t.engine.store.list("Msg")).toHaveLength(300);
|
|
106
|
+
// One frame per step used to cost two write transactions (row, then
|
|
107
|
+
// cursor): 600 here. Batches cost one each.
|
|
108
|
+
expect(t.counts.writeTx).toBeLessThan(60);
|
|
109
|
+
expect(t.notifies() - notifiesBefore).toBeLessThan(60);
|
|
110
|
+
console.log(
|
|
111
|
+
`[bench] 300 frames: ${Math.round(ms)} ms, ${t.counts.writeTx} write transactions, ` +
|
|
112
|
+
`${t.notifies() - notifiesBefore} notifies (fake-indexeddb)`,
|
|
113
|
+
);
|
|
114
|
+
t.engine.stop();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("order inside a batch holds on disk: insert, update, delete, re-insert", async () => {
|
|
118
|
+
const t = await startEngine();
|
|
119
|
+
await t.deliver([
|
|
120
|
+
insert(1, "a", { v: 1 }),
|
|
121
|
+
{ seq: 2, entity: "Msg", row_id: "a", kind: "update", data: { v: 2 }, timestamp: "" },
|
|
122
|
+
insert(3, "b", { v: 1 }),
|
|
123
|
+
{ seq: 4, entity: "Msg", row_id: "b", kind: "delete", timestamp: "" } as ChangeEvent,
|
|
124
|
+
insert(5, "c", { v: 1 }),
|
|
125
|
+
{ seq: 6, entity: "Msg", row_id: "c", kind: "update", data: { v: 3 }, timestamp: "" },
|
|
126
|
+
]);
|
|
127
|
+
const snap = await t.persistence.loadSnapshot();
|
|
128
|
+
const byId = Object.fromEntries((snap.entities.Msg ?? []).map((r) => [r.id, r]));
|
|
129
|
+
expect(byId.a).toMatchObject({ id: "a", v: 2 });
|
|
130
|
+
expect(byId.b).toBeUndefined();
|
|
131
|
+
expect(byId.c).toMatchObject({ id: "c", v: 3 });
|
|
132
|
+
expect(snap.cursor?.last_seq).toBe(6);
|
|
133
|
+
t.engine.stop();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a stale or repeated frame inside a batch is dropped, as before", async () => {
|
|
137
|
+
const t = await startEngine();
|
|
138
|
+
await t.deliver([
|
|
139
|
+
insert(1, "a", { v: 1 }),
|
|
140
|
+
{ seq: 3, entity: "Msg", row_id: "a", kind: "update", data: { v: 3 }, timestamp: "" },
|
|
141
|
+
// Arrives after seq 3 in the same batch: older, must not win.
|
|
142
|
+
{ seq: 2, entity: "Msg", row_id: "a", kind: "update", data: { v: 2 }, timestamp: "" },
|
|
143
|
+
{ seq: 3, entity: "Msg", row_id: "a", kind: "update", data: { v: 3 }, timestamp: "" },
|
|
144
|
+
]);
|
|
145
|
+
expect(t.engine.store.get("Msg", "a")).toMatchObject({ v: 3 });
|
|
146
|
+
const snap = await t.persistence.loadSnapshot();
|
|
147
|
+
expect(snap.entities.Msg?.[0]).toMatchObject({ v: 3 });
|
|
148
|
+
t.engine.stop();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("an aborted batch leaves the on-disk cursor behind the rows", async () => {
|
|
152
|
+
const t = await startEngine();
|
|
153
|
+
await t.deliver([insert(1, "a", { v: 1 })]);
|
|
154
|
+
const db = t.persistence.connection!;
|
|
155
|
+
const wrapped = db.transaction.bind(db);
|
|
156
|
+
let armed = true;
|
|
157
|
+
(db as unknown as { transaction: typeof db.transaction }).transaction = ((
|
|
158
|
+
...args: Parameters<typeof db.transaction>
|
|
159
|
+
) => {
|
|
160
|
+
const tx = wrapped(...args);
|
|
161
|
+
if (armed && String(args[1]) === "readwrite") {
|
|
162
|
+
armed = false;
|
|
163
|
+
queueMicrotask(() => tx.abort());
|
|
164
|
+
}
|
|
165
|
+
return tx;
|
|
166
|
+
}) as typeof db.transaction;
|
|
167
|
+
const warn = console.warn;
|
|
168
|
+
console.warn = () => {};
|
|
169
|
+
try {
|
|
170
|
+
await t.deliver([insert(2, "b", { v: 1 }), insert(3, "c", { v: 1 })]);
|
|
171
|
+
} finally {
|
|
172
|
+
console.warn = warn;
|
|
173
|
+
}
|
|
174
|
+
// Memory moved on; disk kept the cursor at the last committed batch.
|
|
175
|
+
expect(t.engine.getCursor().last_seq).toBe(3);
|
|
176
|
+
const snap = await t.persistence.loadSnapshot();
|
|
177
|
+
expect(snap.cursor?.last_seq).toBe(1);
|
|
178
|
+
t.engine.stop();
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/local-store.ts
CHANGED
|
@@ -200,6 +200,20 @@ export class LocalStore {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Apply to memory and notify once, WITHOUT persisting. Returns each
|
|
205
|
+
* change with `data` set to the full merged row (deletes unchanged),
|
|
206
|
+
* ready to write to disk. The engine uses it to write a whole batch in
|
|
207
|
+
* one transaction (`ReplicaPersistence.saveBatch`).
|
|
208
|
+
*/
|
|
209
|
+
applyInMemory(changes: ChangeEvent[]): ChangeEvent[] {
|
|
210
|
+
for (const change of changes) {
|
|
211
|
+
this.applyChange(change);
|
|
212
|
+
}
|
|
213
|
+
this.notify();
|
|
214
|
+
return changes.map((c) => this.hydrateFromMemory(c));
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
/**
|
|
204
218
|
* Apply + persist, awaiting disk writes before returning. Callers
|
|
205
219
|
* that are about to advance a cursor based on `changes` MUST use
|
|
@@ -35,6 +35,9 @@ function makeRig(opts: { isLeader?: boolean } = {}) {
|
|
|
35
35
|
onMutationsForwarded: record("mutationsForwarded"),
|
|
36
36
|
onMutationsAcked: record("mutationsAcked"),
|
|
37
37
|
onMutationsFailed: record("mutationsFailed"),
|
|
38
|
+
onMutationsQueued: record("mutationsQueued"),
|
|
39
|
+
onSyncedReceived: record("synced"),
|
|
40
|
+
onSyncStatusRequested: record("syncStatusRequested"),
|
|
38
41
|
onBinaryReceived: record("binary"),
|
|
39
42
|
onPeerLeft: record("peerLeft"),
|
|
40
43
|
onReplayForwardedMutations: record("replayForwardedMutations"),
|
|
@@ -83,7 +83,18 @@ export interface MultiTabOrchestratorHooks {
|
|
|
83
83
|
/** Leader → follower: op_ids that were successfully pushed. */
|
|
84
84
|
onMutationsAcked(opIds: string[]): void;
|
|
85
85
|
/** Leader → follower: op_ids that failed server-side validation. */
|
|
86
|
-
onMutationsFailed(
|
|
86
|
+
onMutationsFailed(
|
|
87
|
+
ops: { opId: string; error: string; code?: string }[],
|
|
88
|
+
): void;
|
|
89
|
+
/** Leader → followers: the leader completed a pull (see
|
|
90
|
+
* `SyncEngine.isSynced`). */
|
|
91
|
+
onSyncedReceived(): void;
|
|
92
|
+
/** Follower → leader: a tab that just started asks whether the leader
|
|
93
|
+
* has synced. */
|
|
94
|
+
onSyncStatusRequested(): void;
|
|
95
|
+
/** Leader → followers: the leader's push failed transiently and
|
|
96
|
+
* these op ids stay queued for retry. */
|
|
97
|
+
onMutationsQueued(opIds: string[]): void;
|
|
87
98
|
/** Leader → follower: a binary frame from the WS. Engine routes
|
|
88
99
|
* to its local binary handlers. */
|
|
89
100
|
onBinaryReceived(bytes: Uint8Array): void;
|
|
@@ -100,6 +111,9 @@ export interface MultiTabOrchestratorHooks {
|
|
|
100
111
|
* `room-unsubscribe` when both the local refcount and the forwarder
|
|
101
112
|
* set are empty. */
|
|
102
113
|
onRoomSubUnregister?(roomId: string, fromTabId: string): void;
|
|
114
|
+
/** Follower → leader: the follower rejoined a room after a
|
|
115
|
+
* `NOT_IN_ROOM` error; resend the wire `room-subscribe`. */
|
|
116
|
+
onRoomSubResubscribe?(roomId: string): void;
|
|
103
117
|
/** Leader → followers: a room-snapshot landed on the WS. Followers
|
|
104
118
|
* apply it to their local room registry so their subscribers fire. */
|
|
105
119
|
onRoomFanoutSnapshot?(roomId: string, members: unknown): void;
|
|
@@ -286,7 +300,9 @@ export class MultiTabOrchestrator {
|
|
|
286
300
|
}
|
|
287
301
|
|
|
288
302
|
/** Leader → followers: per-op failures with error strings. */
|
|
289
|
-
broadcastMutationsFailed(
|
|
303
|
+
broadcastMutationsFailed(
|
|
304
|
+
ops: { opId: string; error: string; code?: string }[],
|
|
305
|
+
): void {
|
|
290
306
|
this.broadcastRaw({ type: "mutations-failed", ops });
|
|
291
307
|
}
|
|
292
308
|
|
|
@@ -369,11 +385,25 @@ export class MultiTabOrchestrator {
|
|
|
369
385
|
}
|
|
370
386
|
case "mutations-failed": {
|
|
371
387
|
const ops = msg.ops as
|
|
372
|
-
| { opId: string; error: string }[]
|
|
388
|
+
| { opId: string; error: string; code?: string }[]
|
|
373
389
|
| undefined;
|
|
374
390
|
if (Array.isArray(ops)) this.hooks.onMutationsFailed(ops);
|
|
375
391
|
break;
|
|
376
392
|
}
|
|
393
|
+
case "synced": {
|
|
394
|
+
this.hooks.onSyncedReceived();
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "sync-status-request": {
|
|
398
|
+
if (!this._isLeader) return;
|
|
399
|
+
this.hooks.onSyncStatusRequested();
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case "mutations-queued": {
|
|
403
|
+
const opIds = msg.opIds as string[] | undefined;
|
|
404
|
+
if (Array.isArray(opIds)) this.hooks.onMutationsQueued(opIds);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
377
407
|
case "sub-register": {
|
|
378
408
|
// Follower → leader. Leader-only — no work to do on a follower
|
|
379
409
|
// because it can't act on a peer's request.
|
|
@@ -437,6 +467,12 @@ export class MultiTabOrchestrator {
|
|
|
437
467
|
}
|
|
438
468
|
break;
|
|
439
469
|
}
|
|
470
|
+
case "room-sub-resubscribe": {
|
|
471
|
+
if (!this._isLeader) return;
|
|
472
|
+
const room = msg.room as string | undefined;
|
|
473
|
+
if (typeof room === "string") this.hooks.onRoomSubResubscribe?.(room);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
440
476
|
case "room-sub-unregister": {
|
|
441
477
|
if (!this._isLeader) return;
|
|
442
478
|
const room = msg.room as string | undefined;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// insert / update / delete report the server's verdict to the caller.
|
|
2
|
+
//
|
|
3
|
+
// Before: a write the server refused (policy denial, validation) was
|
|
4
|
+
// rolled back locally and marked failed, but the promise the caller
|
|
5
|
+
// awaited still resolved, so the UI could not say "Message not sent".
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import { MutationRejectedError, SyncEngine } from "./index";
|
|
10
|
+
import type { PendingMutation } from "./mutation-queue";
|
|
11
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
12
|
+
|
|
13
|
+
describe("mutation outcome", () => {
|
|
14
|
+
let env: TestEnv | null = null;
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
if (env) await env.dispose();
|
|
17
|
+
env = null;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("insert rejects with the server's error code and removes the ghost", async () => {
|
|
21
|
+
env = createTestEnv({ transport: "poll" });
|
|
22
|
+
env.signIn({ userId: "u1" });
|
|
23
|
+
await env.start();
|
|
24
|
+
env.server.denyWrites("Message", "POLICY_DENIED", "not a member of this channel");
|
|
25
|
+
|
|
26
|
+
let caught: unknown = null;
|
|
27
|
+
try {
|
|
28
|
+
await env.engine.insert("Message", { body: "hi" });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
caught = err;
|
|
31
|
+
}
|
|
32
|
+
expect(caught).toBeInstanceOf(MutationRejectedError);
|
|
33
|
+
const err = caught as MutationRejectedError;
|
|
34
|
+
expect(err.code).toBe("POLICY_DENIED");
|
|
35
|
+
expect(err.message).toBe("not a member of this channel");
|
|
36
|
+
expect(err.entity).toBe("Message");
|
|
37
|
+
expect(err.kind).toBe("insert");
|
|
38
|
+
expect(env.engine.store.list("Message")).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("update and delete reject and restore the row", async () => {
|
|
42
|
+
env = createTestEnv({ transport: "poll" });
|
|
43
|
+
env.signIn({ userId: "u1" });
|
|
44
|
+
env.server.seed("Note", [{ id: "n1", title: "original" }]);
|
|
45
|
+
await env.start();
|
|
46
|
+
env.server.denyWrites("Note", "VALIDATION_FAILED", "title too long");
|
|
47
|
+
|
|
48
|
+
await expect(env.engine.update("Note", "n1", { title: "x" })).rejects.toMatchObject({
|
|
49
|
+
name: "MutationRejectedError",
|
|
50
|
+
code: "VALIDATION_FAILED",
|
|
51
|
+
kind: "update",
|
|
52
|
+
});
|
|
53
|
+
expect((env.engine.store.get("Note", "n1") as { title?: string }).title).toBe("original");
|
|
54
|
+
|
|
55
|
+
await expect(env.engine.delete("Note", "n1")).rejects.toMatchObject({
|
|
56
|
+
code: "VALIDATION_FAILED",
|
|
57
|
+
kind: "delete",
|
|
58
|
+
});
|
|
59
|
+
expect(env.engine.store.get("Note", "n1")).not.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a whole-request rejection (HTTP 403) rejects the caller", async () => {
|
|
63
|
+
env = createTestEnv({ transport: "poll" });
|
|
64
|
+
env.signIn({ userId: "u1" });
|
|
65
|
+
await env.start();
|
|
66
|
+
env.server.primeNextPushOutcome({ kind: "status", status: 403 });
|
|
67
|
+
await expect(env.engine.insert("Note", { title: "x" })).rejects.toMatchObject({
|
|
68
|
+
code: "PUSH_REJECTED",
|
|
69
|
+
});
|
|
70
|
+
expect(env.engine.store.list("Note")).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("an applied insert resolves with the row id", async () => {
|
|
74
|
+
env = createTestEnv({ transport: "poll" });
|
|
75
|
+
env.signIn({ userId: "u1" });
|
|
76
|
+
await env.start();
|
|
77
|
+
const id = await env.engine.insert("Note", { title: "ok" });
|
|
78
|
+
expect(env.engine.store.get("Note", id)).not.toBeNull();
|
|
79
|
+
expect(env.server.receivedPushKeys).toContain(`Note/${id}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an offline insert resolves (queued) and keeps the ghost", async () => {
|
|
83
|
+
env = createTestEnv({ transport: "poll" });
|
|
84
|
+
env.signIn({ userId: "u1" });
|
|
85
|
+
await env.start();
|
|
86
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
87
|
+
const id = await env.engine.insert("Note", { title: "offline" });
|
|
88
|
+
expect(env.engine.store.get("Note", id)).not.toBeNull();
|
|
89
|
+
expect(env.engine.pendingCount()).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a stopped engine does not retry a failed push", async () => {
|
|
93
|
+
env = createTestEnv({ transport: "poll" });
|
|
94
|
+
env.signIn({ userId: "u1" });
|
|
95
|
+
await env.start();
|
|
96
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
97
|
+
await env.engine.insert("Note", { title: "offline" });
|
|
98
|
+
env.engine.stop();
|
|
99
|
+
const before = env.transport.fetchCount();
|
|
100
|
+
// The transient failure scheduled a retry 1s out.
|
|
101
|
+
await new Promise((r) => setTimeout(r, 1300));
|
|
102
|
+
expect(env.transport.fetchCount()).toBe(before);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a write queued while a push is running still reaches the server", async () => {
|
|
106
|
+
env = createTestEnv({ transport: "poll" });
|
|
107
|
+
env.signIn({ userId: "u1" });
|
|
108
|
+
await env.start();
|
|
109
|
+
// The second insert starts while the first push waits on the
|
|
110
|
+
// server. Its push() call joins the running push, whose batch was
|
|
111
|
+
// taken before the second write was queued.
|
|
112
|
+
let release!: () => void;
|
|
113
|
+
env.server.pushGate = new Promise<void>((r) => (release = r));
|
|
114
|
+
const p1 = env.engine.insert("Note", { title: "one" });
|
|
115
|
+
await env.flush(10);
|
|
116
|
+
expect(env.server.pushRequestCount).toBe(1);
|
|
117
|
+
const p2 = env.engine.insert("Note", { title: "two" });
|
|
118
|
+
await env.flush(10);
|
|
119
|
+
env.server.pushGate = null;
|
|
120
|
+
release();
|
|
121
|
+
const [a, b] = await Promise.all([p1, p2]);
|
|
122
|
+
expect(env.server.receivedPushKeys).toContain(`Note/${a}`);
|
|
123
|
+
expect(env.server.receivedPushKeys).toContain(`Note/${b}`);
|
|
124
|
+
expect(env.engine.pendingCount()).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("an identity-flip wipe rejects writes still waiting", async () => {
|
|
128
|
+
env = createTestEnv({ transport: "poll" });
|
|
129
|
+
env.signIn({ userId: "u1" });
|
|
130
|
+
await env.start();
|
|
131
|
+
const engine = env.engine as unknown as {
|
|
132
|
+
isMultiTabLeader: boolean;
|
|
133
|
+
broadcastToTabs: (p: unknown) => void;
|
|
134
|
+
};
|
|
135
|
+
// As a follower the write waits for the leader's verdict.
|
|
136
|
+
engine.isMultiTabLeader = false;
|
|
137
|
+
engine.broadcastToTabs = () => {};
|
|
138
|
+
const p = env.engine.insert("Note", { title: "draft" });
|
|
139
|
+
await env.flush();
|
|
140
|
+
await env.engine.resetReplica({ wipeMutations: true });
|
|
141
|
+
await expect(p).rejects.toMatchObject({ code: "DISCARDED" });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("mutation outcome in a follower tab", () => {
|
|
146
|
+
function follower() {
|
|
147
|
+
const engine = new SyncEngine({
|
|
148
|
+
baseUrl: "http://test.invalid",
|
|
149
|
+
persist: false,
|
|
150
|
+
multiTab: false,
|
|
151
|
+
});
|
|
152
|
+
const sent: unknown[] = [];
|
|
153
|
+
const internals = engine as unknown as {
|
|
154
|
+
isMultiTabLeader: boolean;
|
|
155
|
+
broadcastToTabs: (p: unknown) => void;
|
|
156
|
+
multiTabHooks: () => {
|
|
157
|
+
onMutationsAcked: (ids: string[]) => void;
|
|
158
|
+
onMutationsFailed: (ops: { opId: string; error: string; code?: string }[]) => void;
|
|
159
|
+
onMutationsQueued: (ids: string[]) => void;
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
internals.isMultiTabLeader = false;
|
|
163
|
+
internals.broadcastToTabs = (p) => sent.push(p);
|
|
164
|
+
const hooks = internals.multiTabHooks();
|
|
165
|
+
const forwardedOpId = () => {
|
|
166
|
+
const msg = sent.find(
|
|
167
|
+
(m) => (m as { type?: string }).type === "mutations",
|
|
168
|
+
) as { ops: PendingMutation[] } | undefined;
|
|
169
|
+
return msg!.ops[0]!.id;
|
|
170
|
+
};
|
|
171
|
+
return { engine, hooks, forwardedOpId };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
test("the leader's rejection rejects the follower's caller and rolls back", async () => {
|
|
175
|
+
const { engine, hooks, forwardedOpId } = follower();
|
|
176
|
+
const p = engine.insert("Message", { body: "hi" });
|
|
177
|
+
await Promise.resolve();
|
|
178
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
179
|
+
expect(engine.store.list("Message")).toHaveLength(1);
|
|
180
|
+
hooks.onMutationsFailed([
|
|
181
|
+
{ opId: forwardedOpId(), error: "denied", code: "POLICY_DENIED" },
|
|
182
|
+
]);
|
|
183
|
+
await expect(p).rejects.toMatchObject({ code: "POLICY_DENIED" });
|
|
184
|
+
expect(engine.store.list("Message")).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("the leader's ack resolves the follower's caller", async () => {
|
|
188
|
+
const { engine, hooks, forwardedOpId } = follower();
|
|
189
|
+
const p = engine.insert("Message", { body: "hi" });
|
|
190
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
191
|
+
hooks.onMutationsAcked([forwardedOpId()]);
|
|
192
|
+
expect(typeof (await p)).toBe("string");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("the leader's queued notice resolves the follower's caller", async () => {
|
|
196
|
+
const { engine, hooks, forwardedOpId } = follower();
|
|
197
|
+
const p = engine.insert("Message", { body: "hi" });
|
|
198
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
199
|
+
hooks.onMutationsQueued([forwardedOpId()]);
|
|
200
|
+
expect(typeof (await p)).toBe("string");
|
|
201
|
+
expect(engine.store.list("Message")).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Owner check edge cases found in the third review: a push between a
|
|
2
|
+
// token swap and the session refresh, writes made before any identity is
|
|
3
|
+
// known, a session that expired across a reload, and guest -> user.
|
|
4
|
+
|
|
5
|
+
import "fake-indexeddb/auto";
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { IndexedDBPersistence, SyncEngine } from "./index";
|
|
9
|
+
import { IndexedDBMutationPersistence } from "./persistence";
|
|
10
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
11
|
+
|
|
12
|
+
describe("mutation owner and the bearer token", () => {
|
|
13
|
+
let env: TestEnv | null = null;
|
|
14
|
+
const realFetch = globalThis.fetch;
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
if (env) await env.dispose();
|
|
17
|
+
env = null;
|
|
18
|
+
globalThis.fetch = realFetch;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a push after a token swap and before the refresh does not send A's write with B's token", async () => {
|
|
22
|
+
env = createTestEnv({ transport: "poll" });
|
|
23
|
+
env.signIn({ userId: "uA" });
|
|
24
|
+
await env.start();
|
|
25
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
26
|
+
const id = await env.engine.insert("Note", { title: "A's draft" });
|
|
27
|
+
const pushAuth: string[] = [];
|
|
28
|
+
const inner = globalThis.fetch;
|
|
29
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
30
|
+
if (String(input).includes("/api/sync/push")) {
|
|
31
|
+
const h = (init?.headers ?? {}) as Record<string, string>;
|
|
32
|
+
pushAuth.push(h.Authorization ?? "");
|
|
33
|
+
}
|
|
34
|
+
return inner(input, init);
|
|
35
|
+
}) as typeof fetch;
|
|
36
|
+
// B's token is stored; nothing has refreshed the session yet.
|
|
37
|
+
const tokenB = env.signIn({ userId: "uB" });
|
|
38
|
+
await env.engine.push();
|
|
39
|
+
expect(pushAuth).not.toContain(`Bearer ${tokenB}`);
|
|
40
|
+
// Once the session says B, A's write is discarded, never sent.
|
|
41
|
+
await env.engine.notifySessionChanged();
|
|
42
|
+
await env.flush(50);
|
|
43
|
+
expect(env.server.receivedPushKeys.filter((k) => k === `Note/${id}`)).toHaveLength(1);
|
|
44
|
+
expect(env.engine.pendingCount()).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a guest's queued write is pushed after the guest signs in", async () => {
|
|
48
|
+
env = createTestEnv({ transport: "poll" });
|
|
49
|
+
env.signIn({ userId: "guest_1" });
|
|
50
|
+
await env.start();
|
|
51
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
52
|
+
const id = await env.engine.insert("Note", { title: "guest note" });
|
|
53
|
+
env.signIn({ userId: "u1" });
|
|
54
|
+
await env.engine.notifySessionChanged();
|
|
55
|
+
await env.flush(50);
|
|
56
|
+
expect(env.engine.pendingCount()).toBe(0);
|
|
57
|
+
expect(env.server.receivedPushKeys.filter((k) => k === `Note/${id}`)).toHaveLength(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a write made before any identity is known waits for the first session and takes its owner", async () => {
|
|
61
|
+
let meCalls = 0;
|
|
62
|
+
let pushes = 0;
|
|
63
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
64
|
+
const url = String(input);
|
|
65
|
+
if (url.includes("/api/auth/me")) {
|
|
66
|
+
meCalls += 1;
|
|
67
|
+
if (meCalls <= 2) throw new TypeError("offline");
|
|
68
|
+
return new Response(JSON.stringify({ user_id: "u1" }), { status: 200 });
|
|
69
|
+
}
|
|
70
|
+
if (url.includes("/api/sync/push")) {
|
|
71
|
+
pushes += 1;
|
|
72
|
+
return new Response(JSON.stringify({ applied: 1, errors: [], results: [], cursor: { last_seq: 0 } }), { status: 200 });
|
|
73
|
+
}
|
|
74
|
+
return new Response(JSON.stringify({ changes: [], cursor: { last_seq: 0 }, has_more: false }), { status: 200 });
|
|
75
|
+
}) as typeof fetch;
|
|
76
|
+
const engine = new SyncEngine({
|
|
77
|
+
baseUrl: "http://test.invalid",
|
|
78
|
+
persist: false,
|
|
79
|
+
multiTab: false,
|
|
80
|
+
transport: "poll",
|
|
81
|
+
pollInterval: 1e9,
|
|
82
|
+
});
|
|
83
|
+
await engine.start();
|
|
84
|
+
const insert = engine.insert("Note", { title: "offline" });
|
|
85
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
86
|
+
expect(engine.mutations.pending()[0]!.ownerPending).toBe(true);
|
|
87
|
+
await insert;
|
|
88
|
+
expect(pushes).toBe(0);
|
|
89
|
+
for (let i = 0; i < 40 && pushes === 0; i++) await new Promise((r) => setTimeout(r, 50));
|
|
90
|
+
expect(pushes).toBeGreaterThan(0);
|
|
91
|
+
engine.stop();
|
|
92
|
+
}, 10_000);
|
|
93
|
+
|
|
94
|
+
test("a session that expired across a reload keeps the user's queued writes", async () => {
|
|
95
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
96
|
+
const url = String(input);
|
|
97
|
+
if (url.includes("/api/sync/push")) throw new Error("must not push");
|
|
98
|
+
const body = url.includes("/api/auth/me")
|
|
99
|
+
? { user_id: null }
|
|
100
|
+
: { changes: [], cursor: { last_seq: 0 }, has_more: false };
|
|
101
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
102
|
+
}) as typeof fetch;
|
|
103
|
+
const name = `expired-${Math.random().toString(36).slice(2)}`;
|
|
104
|
+
const disk = new IndexedDBPersistence(name);
|
|
105
|
+
await disk.open();
|
|
106
|
+
await disk.saveIdentity("u1");
|
|
107
|
+
await disk.saveCursor({ last_seq: 1 });
|
|
108
|
+
await new IndexedDBMutationPersistence(disk).saveAll([
|
|
109
|
+
{
|
|
110
|
+
id: "op-1",
|
|
111
|
+
change: { entity: "Note", row_id: "n1", kind: "insert", data: { id: "n1" }, op_id: "op-1" },
|
|
112
|
+
status: "pending",
|
|
113
|
+
owner: "u1",
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
disk.connection?.close();
|
|
117
|
+
const reopened = new IndexedDBPersistence(name);
|
|
118
|
+
const engine = new SyncEngine({
|
|
119
|
+
baseUrl: "http://test.invalid",
|
|
120
|
+
persistence: reopened,
|
|
121
|
+
multiTab: false,
|
|
122
|
+
transport: "poll",
|
|
123
|
+
pollInterval: 1e9,
|
|
124
|
+
});
|
|
125
|
+
await engine.start();
|
|
126
|
+
await engine.push();
|
|
127
|
+
expect(engine.pendingCount()).toBe(1);
|
|
128
|
+
engine.stop();
|
|
129
|
+
reopened.connection?.close();
|
|
130
|
+
});
|
|
131
|
+
});
|