@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,217 @@
|
|
|
1
|
+
// Every queued write carries the user it was made as, and push only sends
|
|
2
|
+
// it as that user. Found in the second review of the identity-reset fix:
|
|
3
|
+
// a follower tab could forward user A's write for the leader to push as
|
|
4
|
+
// user B, a token change whose /api/auth/me failed held every write
|
|
5
|
+
// forever, and a reload while signed out pushed the held writes as the
|
|
6
|
+
// anonymous caller.
|
|
7
|
+
|
|
8
|
+
import "fake-indexeddb/auto";
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import { IndexedDBPersistence, SyncEngine, type ChangeEvent } from "./index";
|
|
12
|
+
import type { PendingMutation } from "./mutation-queue";
|
|
13
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
14
|
+
|
|
15
|
+
const session = (userId: string | null) => ({
|
|
16
|
+
userId,
|
|
17
|
+
tenantId: null,
|
|
18
|
+
isAdmin: false,
|
|
19
|
+
roles: [],
|
|
20
|
+
avatarUrl: null,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("mutation owner", () => {
|
|
24
|
+
let env: TestEnv | null = null;
|
|
25
|
+
const realFetch = globalThis.fetch;
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
if (env) await env.dispose();
|
|
28
|
+
env = null;
|
|
29
|
+
globalThis.fetch = realFetch;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a write is tagged with the user who made it", async () => {
|
|
33
|
+
env = createTestEnv({ transport: "poll" });
|
|
34
|
+
env.signIn({ userId: "u1" });
|
|
35
|
+
await env.start();
|
|
36
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
37
|
+
await env.engine.insert("Note", { title: "x" });
|
|
38
|
+
expect(env.engine.mutations.pending()[0]!.owner).toBe("u1");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the leader discards a forwarded write of another user; the follower drops it", async () => {
|
|
42
|
+
// Leader, signed in as B.
|
|
43
|
+
env = createTestEnv({ transport: "poll" });
|
|
44
|
+
env.signIn({ userId: "uB" });
|
|
45
|
+
await env.start();
|
|
46
|
+
const leaderSent: unknown[] = [];
|
|
47
|
+
(env.engine as unknown as { broadcastToTabs: (p: unknown) => void }).broadcastToTabs = (p) =>
|
|
48
|
+
leaderSent.push(p);
|
|
49
|
+
const forwarded: PendingMutation = {
|
|
50
|
+
id: "op-a",
|
|
51
|
+
change: { entity: "Note", row_id: "n-a", kind: "insert", data: { id: "n-a" }, op_id: "op-a" },
|
|
52
|
+
status: "pending",
|
|
53
|
+
owner: "uA",
|
|
54
|
+
};
|
|
55
|
+
(env.engine as unknown as {
|
|
56
|
+
multiTabHooks: () => { onMutationsForwarded: (ops: PendingMutation[]) => void };
|
|
57
|
+
})
|
|
58
|
+
.multiTabHooks()
|
|
59
|
+
.onMutationsForwarded([forwarded]);
|
|
60
|
+
await env.flush(20);
|
|
61
|
+
expect(env.server.receivedPushKeys).not.toContain("Note/n-a");
|
|
62
|
+
expect(env.engine.pendingCount()).toBe(0);
|
|
63
|
+
expect(leaderSent).toContainEqual({
|
|
64
|
+
type: "mutations-failed",
|
|
65
|
+
ops: [{ opId: "op-a", error: "discarded: another user is signed in", code: "DISCARDED" }],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Follower that made the write: it drops the op without restoring rows.
|
|
69
|
+
const follower = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
|
|
70
|
+
const f = follower as unknown as {
|
|
71
|
+
isMultiTabLeader: boolean;
|
|
72
|
+
broadcastToTabs: (p: unknown) => void;
|
|
73
|
+
multiTabHooks: () => {
|
|
74
|
+
onMutationsFailed: (ops: { opId: string; error: string; code?: string }[]) => void;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
f.isMultiTabLeader = false;
|
|
78
|
+
f.broadcastToTabs = () => {};
|
|
79
|
+
follower.store.applyChange({ seq: 1, entity: "Note", row_id: "n0", kind: "insert", data: { id: "n0", title: "A's row" }, timestamp: "" });
|
|
80
|
+
const p = follower.update("Note", "n0", { title: "A edit" });
|
|
81
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
82
|
+
// The leader's reset for B already wiped this tab's rows.
|
|
83
|
+
follower.store.clearAll();
|
|
84
|
+
const opId = follower.mutations.pending()[0]!.id;
|
|
85
|
+
f.multiTabHooks().onMutationsFailed([{ opId, error: "discarded", code: "DISCARDED" }]);
|
|
86
|
+
await expect(p).rejects.toMatchObject({ code: "DISCARDED" });
|
|
87
|
+
expect(follower.pendingCount()).toBe(0);
|
|
88
|
+
expect(follower.store.get("Note", "n0")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a token change whose session refresh fails retries until the writes push", async () => {
|
|
92
|
+
env = createTestEnv({ transport: "poll" });
|
|
93
|
+
env.signIn({ userId: "u1" });
|
|
94
|
+
await env.start();
|
|
95
|
+
const inner = globalThis.fetch;
|
|
96
|
+
let failMe = true;
|
|
97
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
98
|
+
if (failMe && String(input).includes("/api/auth/me")) throw new TypeError("network down");
|
|
99
|
+
return inner(input, init);
|
|
100
|
+
}) as typeof fetch;
|
|
101
|
+
env.signIn({ userId: "u1" }); // token rotation
|
|
102
|
+
await env.engine.pull();
|
|
103
|
+
await env.flush(20);
|
|
104
|
+
const id = await env.engine.insert("Note", { title: "after rotation" });
|
|
105
|
+
expect(env.engine.pendingCount()).toBe(1);
|
|
106
|
+
failMe = false;
|
|
107
|
+
for (let i = 0; i < 60 && env.engine.pendingCount() > 0; i++) {
|
|
108
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
109
|
+
}
|
|
110
|
+
expect(env.engine.pendingCount()).toBe(0);
|
|
111
|
+
expect(env.server.receivedPushKeys).toContain(`Note/${id}`);
|
|
112
|
+
}, 10_000);
|
|
113
|
+
|
|
114
|
+
test("after a reload while signed out, the previous user's queued writes are not pushed", async () => {
|
|
115
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
116
|
+
const url = String(input);
|
|
117
|
+
if (url.includes("/api/sync/push")) throw new Error("must not push");
|
|
118
|
+
const body = url.includes("/api/sync/pull")
|
|
119
|
+
? { changes: [], cursor: { last_seq: 0 }, has_more: false }
|
|
120
|
+
: url.includes("/api/auth/me")
|
|
121
|
+
? { user_id: null }
|
|
122
|
+
: {};
|
|
123
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
124
|
+
}) as typeof fetch;
|
|
125
|
+
const name = `owner-${Math.random().toString(36).slice(2)}`;
|
|
126
|
+
// What the previous run left on disk: the replica tagged anonymous
|
|
127
|
+
// (u1's session expired) and u1's queued write.
|
|
128
|
+
const disk = new IndexedDBPersistence(name);
|
|
129
|
+
await disk.open();
|
|
130
|
+
await disk.saveIdentity(null);
|
|
131
|
+
await disk.saveCursor({ last_seq: 1 });
|
|
132
|
+
const { IndexedDBMutationPersistence } = await import("./persistence");
|
|
133
|
+
await new IndexedDBMutationPersistence(disk).saveAll([
|
|
134
|
+
{
|
|
135
|
+
id: "op-1",
|
|
136
|
+
change: { entity: "Note", row_id: "n1", kind: "insert", data: { id: "n1" }, op_id: "op-1" },
|
|
137
|
+
status: "pending",
|
|
138
|
+
owner: "u1",
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
disk.connection?.close();
|
|
142
|
+
|
|
143
|
+
const reopened = new IndexedDBPersistence(name);
|
|
144
|
+
const engine = new SyncEngine({
|
|
145
|
+
baseUrl: "http://test.invalid",
|
|
146
|
+
persistence: reopened,
|
|
147
|
+
multiTab: false,
|
|
148
|
+
transport: "poll",
|
|
149
|
+
pollInterval: 1e9,
|
|
150
|
+
});
|
|
151
|
+
await engine.start();
|
|
152
|
+
await engine.push();
|
|
153
|
+
expect(engine.pendingCount()).toBe(1);
|
|
154
|
+
expect(engine.mutations.pending()[0]!.owner).toBe("u1");
|
|
155
|
+
engine.stop();
|
|
156
|
+
reopened.connection?.close();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a follower applies the leader's rows that arrive while its own reset runs", async () => {
|
|
160
|
+
const persistence = new IndexedDBPersistence(`fence-${Math.random().toString(36).slice(2)}`);
|
|
161
|
+
await persistence.open();
|
|
162
|
+
const engine = new SyncEngine({ baseUrl: "http://test.invalid", persistence, multiTab: false });
|
|
163
|
+
const internals = engine as unknown as {
|
|
164
|
+
persistence: IndexedDBPersistence;
|
|
165
|
+
isMultiTabLeader: boolean;
|
|
166
|
+
broadcastToTabs: (p: unknown) => void;
|
|
167
|
+
multiTabHooks: () => {
|
|
168
|
+
onResetReceived: (wipe: boolean) => void;
|
|
169
|
+
onAppliedReceived: (c: ChangeEvent[], t?: { last_seq: number }) => void;
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
internals.persistence = persistence;
|
|
173
|
+
internals.isMultiTabLeader = false;
|
|
174
|
+
internals.broadcastToTabs = () => {};
|
|
175
|
+
const hooks = internals.multiTabHooks();
|
|
176
|
+
let release!: () => void;
|
|
177
|
+
const gate = new Promise<void>((r) => (release = r));
|
|
178
|
+
let started = false;
|
|
179
|
+
const realClear = persistence.clear.bind(persistence);
|
|
180
|
+
persistence.clear = async () => {
|
|
181
|
+
started = true;
|
|
182
|
+
await gate;
|
|
183
|
+
return realClear();
|
|
184
|
+
};
|
|
185
|
+
hooks.onResetReceived(false);
|
|
186
|
+
while (!started) await new Promise((r) => setTimeout(r, 1));
|
|
187
|
+
hooks.onAppliedReceived(
|
|
188
|
+
[{ seq: 3, entity: "Deal", row_id: "d1", kind: "insert", data: { id: "d1" }, timestamp: "" }],
|
|
189
|
+
{ last_seq: 3 },
|
|
190
|
+
);
|
|
191
|
+
release();
|
|
192
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
193
|
+
expect(engine.store.list("Deal")).toHaveLength(1);
|
|
194
|
+
expect(engine.getCursor().last_seq).toBe(3);
|
|
195
|
+
engine.stop();
|
|
196
|
+
persistence.connection?.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an update from a follower tab keeps its session owner when forwarded", async () => {
|
|
200
|
+
const engine = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
|
|
201
|
+
const internals = engine as unknown as {
|
|
202
|
+
isMultiTabLeader: boolean;
|
|
203
|
+
broadcastToTabs: (p: unknown) => void;
|
|
204
|
+
multiTabHooks: () => { onSessionReceived: (s: unknown) => void };
|
|
205
|
+
sessionChain: Promise<void>;
|
|
206
|
+
};
|
|
207
|
+
internals.isMultiTabLeader = false;
|
|
208
|
+
const sent: { type?: string; ops?: PendingMutation[] }[] = [];
|
|
209
|
+
internals.broadcastToTabs = (p) => sent.push(p as { type?: string });
|
|
210
|
+
internals.multiTabHooks().onSessionReceived(session("uA"));
|
|
211
|
+
await internals.sessionChain;
|
|
212
|
+
void engine.insert("Note", { title: "draft" });
|
|
213
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
214
|
+
const fwd = sent.find((m) => m.type === "mutations");
|
|
215
|
+
expect(fwd?.ops?.[0]?.owner).toBe("uA");
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/mutation-queue.ts
CHANGED
|
@@ -12,11 +12,57 @@
|
|
|
12
12
|
|
|
13
13
|
import type { ClientChange, Row } from "./types";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by `SyncEngine.insert` / `update` / `delete` when the server
|
|
17
|
+
* rejects the write (policy denial, validation, conflict). The optimistic
|
|
18
|
+
* change is rolled back before the promise rejects.
|
|
19
|
+
*
|
|
20
|
+
* `code` is the server's error code when it sent one (`POLICY_DENIED`,
|
|
21
|
+
* `VALIDATION_FAILED`, ...). `DISCARDED` means the write never reached the
|
|
22
|
+
* server: an identity change dropped it from the queue.
|
|
23
|
+
*/
|
|
24
|
+
export class MutationRejectedError extends Error {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly opId: string;
|
|
27
|
+
readonly entity: string;
|
|
28
|
+
readonly rowId: string;
|
|
29
|
+
readonly kind: "insert" | "update" | "delete";
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
opts: {
|
|
34
|
+
code?: string;
|
|
35
|
+
opId: string;
|
|
36
|
+
entity: string;
|
|
37
|
+
rowId: string;
|
|
38
|
+
kind: "insert" | "update" | "delete";
|
|
39
|
+
},
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "MutationRejectedError";
|
|
43
|
+
this.code = opts.code ?? "REJECTED";
|
|
44
|
+
this.opId = opts.opId;
|
|
45
|
+
this.entity = opts.entity;
|
|
46
|
+
this.rowId = opts.rowId;
|
|
47
|
+
this.kind = opts.kind;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
15
51
|
export interface PendingMutation {
|
|
16
52
|
id: string;
|
|
17
53
|
change: ClientChange;
|
|
18
54
|
status: "pending" | "applied" | "failed";
|
|
19
55
|
error?: string;
|
|
56
|
+
/** Server error code for a failed mutation, when the server sent one. */
|
|
57
|
+
errorCode?: string;
|
|
58
|
+
/** User id the write was made as (`null` = anonymous). Persisted with
|
|
59
|
+
* the queue so a write is only ever pushed as the user who made it.
|
|
60
|
+
* `undefined` = unknown (queued by an older client). */
|
|
61
|
+
owner?: string | null;
|
|
62
|
+
/** The write was made before this run knew who is signed in (no
|
|
63
|
+
* resolved session and no saved identity). It is held until the first
|
|
64
|
+
* session resolves, which stamps `owner`. */
|
|
65
|
+
ownerPending?: boolean;
|
|
20
66
|
/** Pre-mutation snapshot of the affected row, captured at optimistic-
|
|
21
67
|
* apply time for `update`/`delete`. On a server rejection,
|
|
22
68
|
* `failPushedMutation` restores this so the local replica reverts to
|
|
@@ -35,9 +81,17 @@ export interface MutationQueuePersistence {
|
|
|
35
81
|
loadAll(): Promise<PendingMutation[]>;
|
|
36
82
|
}
|
|
37
83
|
|
|
84
|
+
interface OutcomeWaiter {
|
|
85
|
+
resolve: () => void;
|
|
86
|
+
reject: (err: MutationRejectedError) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
export class MutationQueue {
|
|
39
90
|
private queue: PendingMutation[] = [];
|
|
40
91
|
private persistence?: MutationQueuePersistence;
|
|
92
|
+
/** Callers waiting for a mutation's outcome, by op id. Memory-only:
|
|
93
|
+
* a promise cannot outlive the page that created it. */
|
|
94
|
+
private waiters = new Map<string, OutcomeWaiter[]>();
|
|
41
95
|
|
|
42
96
|
constructor(persistence?: MutationQueuePersistence) {
|
|
43
97
|
this.persistence = persistence;
|
|
@@ -86,14 +140,22 @@ export class MutationQueue {
|
|
|
86
140
|
* entry with the same op_id is already queued — a follower
|
|
87
141
|
* retrying its forward of the same op shouldn't double-queue on
|
|
88
142
|
* the leader. */
|
|
89
|
-
add(
|
|
143
|
+
add(
|
|
144
|
+
change: ClientChange,
|
|
145
|
+
prevRow?: Row | null,
|
|
146
|
+
owner?: string | null,
|
|
147
|
+
ownerPending?: boolean,
|
|
148
|
+
): string {
|
|
90
149
|
const id =
|
|
91
150
|
typeof change.op_id === "string" && change.op_id.length > 0
|
|
92
151
|
? change.op_id
|
|
93
152
|
: `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
94
153
|
if (this.queue.some((m) => m.id === id)) return id;
|
|
95
154
|
const changeWithOp: ClientChange = { ...change, op_id: id };
|
|
96
|
-
|
|
155
|
+
const entry: PendingMutation = { id, change: changeWithOp, status: "pending", prevRow };
|
|
156
|
+
if (owner !== undefined) entry.owner = owner;
|
|
157
|
+
if (ownerPending) entry.ownerPending = true;
|
|
158
|
+
this.queue.push(entry);
|
|
97
159
|
this.flush();
|
|
98
160
|
return id;
|
|
99
161
|
}
|
|
@@ -132,19 +194,65 @@ export class MutationQueue {
|
|
|
132
194
|
return out;
|
|
133
195
|
}
|
|
134
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Resolve once the server applies the mutation or the mutation is
|
|
199
|
+
* queued behind a transient failure (`settleQueued`); reject with a
|
|
200
|
+
* `MutationRejectedError` once the server rejects it. Settles at once
|
|
201
|
+
* when the mutation already reached a terminal state.
|
|
202
|
+
*/
|
|
203
|
+
waitForOutcome(id: string): Promise<void> {
|
|
204
|
+
const m = this.get(id);
|
|
205
|
+
if (m?.status === "applied") return Promise.resolve();
|
|
206
|
+
if (m?.status === "failed") return Promise.reject(rejectionFor(m));
|
|
207
|
+
return new Promise<void>((resolve, reject) => {
|
|
208
|
+
const list = this.waiters.get(id) ?? [];
|
|
209
|
+
list.push({ resolve, reject });
|
|
210
|
+
this.waiters.set(id, list);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Resolve the waiters of a mutation that stays queued after a
|
|
215
|
+
* transient push failure (offline, 5xx). The mutation stays pending
|
|
216
|
+
* and is retried; a later rejection shows up as `status: "failed"`. */
|
|
217
|
+
settleQueued(id: string): void {
|
|
218
|
+
this.resolveWaiters(id);
|
|
219
|
+
}
|
|
220
|
+
|
|
135
221
|
markApplied(id: string): void {
|
|
136
222
|
const m = this.queue.find((m) => m.id === id);
|
|
137
223
|
if (m) m.status = "applied";
|
|
138
224
|
this.flush();
|
|
225
|
+
this.resolveWaiters(id);
|
|
139
226
|
}
|
|
140
227
|
|
|
141
|
-
markFailed(id: string, error: string): void {
|
|
228
|
+
markFailed(id: string, error: string, errorCode?: string): void {
|
|
142
229
|
const m = this.queue.find((m) => m.id === id);
|
|
143
230
|
if (m) {
|
|
144
231
|
m.status = "failed";
|
|
145
232
|
m.error = error;
|
|
233
|
+
if (errorCode) m.errorCode = errorCode;
|
|
146
234
|
}
|
|
147
235
|
this.flush();
|
|
236
|
+
const waiters = this.waiters.get(id);
|
|
237
|
+
if (!waiters) return;
|
|
238
|
+
this.waiters.delete(id);
|
|
239
|
+
const err = m
|
|
240
|
+
? rejectionFor(m)
|
|
241
|
+
: new MutationRejectedError(error, {
|
|
242
|
+
code: errorCode,
|
|
243
|
+
opId: id,
|
|
244
|
+
entity: "",
|
|
245
|
+
rowId: "",
|
|
246
|
+
kind: "insert",
|
|
247
|
+
});
|
|
248
|
+
for (const w of waiters) w.reject(err);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private resolveWaiters(id: string): void {
|
|
252
|
+
const waiters = this.waiters.get(id);
|
|
253
|
+
if (!waiters) return;
|
|
254
|
+
this.waiters.delete(id);
|
|
255
|
+
for (const w of waiters) w.resolve();
|
|
148
256
|
}
|
|
149
257
|
|
|
150
258
|
/**
|
|
@@ -158,6 +266,43 @@ export class MutationQueue {
|
|
|
158
266
|
this.flush();
|
|
159
267
|
}
|
|
160
268
|
|
|
269
|
+
/** Stamp every write whose owner was pending with the user the first
|
|
270
|
+
* resolved session names. */
|
|
271
|
+
stampPendingOwner(owner: string | null): void {
|
|
272
|
+
let changed = false;
|
|
273
|
+
for (const m of this.queue) {
|
|
274
|
+
if (m.ownerPending) {
|
|
275
|
+
m.owner = owner;
|
|
276
|
+
delete m.ownerPending;
|
|
277
|
+
changed = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (changed) this.flush();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Drop one mutation that must never be pushed (it belongs to another
|
|
284
|
+
* identity) and reject its waiters with code `DISCARDED`. Does not
|
|
285
|
+
* touch the local store. */
|
|
286
|
+
discard(id: string): void {
|
|
287
|
+
const m = this.get(id);
|
|
288
|
+
this.queue = this.queue.filter((q) => q.id !== id);
|
|
289
|
+
this.flush();
|
|
290
|
+
const waiters = this.waiters.get(id);
|
|
291
|
+
if (!waiters) return;
|
|
292
|
+
this.waiters.delete(id);
|
|
293
|
+
const err = new MutationRejectedError(
|
|
294
|
+
"The write was discarded because the signed-in identity changed.",
|
|
295
|
+
{
|
|
296
|
+
code: "DISCARDED",
|
|
297
|
+
opId: id,
|
|
298
|
+
entity: m?.change.entity ?? "",
|
|
299
|
+
rowId: m?.change.row_id ?? "",
|
|
300
|
+
kind: m?.change.kind ?? "insert",
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
for (const w of waiters) w.reject(err);
|
|
304
|
+
}
|
|
305
|
+
|
|
161
306
|
/** Remove a specific mutation by id. Used by the UI after user
|
|
162
307
|
* ack of failures. */
|
|
163
308
|
remove(id: string): void {
|
|
@@ -177,8 +322,27 @@ export class MutationQueue {
|
|
|
177
322
|
* offline writes survive a snapshot refresh.
|
|
178
323
|
*/
|
|
179
324
|
clearAll(): void {
|
|
325
|
+
const dropped = this.queue;
|
|
180
326
|
this.queue = [];
|
|
181
327
|
this.flush();
|
|
328
|
+
// Nobody will push these writes any more. Reject their waiters so a
|
|
329
|
+
// caller awaiting the outcome does not hang.
|
|
330
|
+
for (const m of dropped) {
|
|
331
|
+
const waiters = this.waiters.get(m.id);
|
|
332
|
+
if (!waiters) continue;
|
|
333
|
+
this.waiters.delete(m.id);
|
|
334
|
+
const err = new MutationRejectedError(
|
|
335
|
+
"The write was discarded because the signed-in identity changed.",
|
|
336
|
+
{
|
|
337
|
+
code: "DISCARDED",
|
|
338
|
+
opId: m.id,
|
|
339
|
+
entity: m.change.entity,
|
|
340
|
+
rowId: m.change.row_id,
|
|
341
|
+
kind: m.change.kind,
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
for (const w of waiters) w.reject(err);
|
|
345
|
+
}
|
|
182
346
|
}
|
|
183
347
|
|
|
184
348
|
/** Fire-and-forget persistence write. */
|
|
@@ -190,3 +354,13 @@ export class MutationQueue {
|
|
|
190
354
|
});
|
|
191
355
|
}
|
|
192
356
|
}
|
|
357
|
+
|
|
358
|
+
function rejectionFor(m: PendingMutation): MutationRejectedError {
|
|
359
|
+
return new MutationRejectedError(m.error ?? "The server rejected the write.", {
|
|
360
|
+
code: m.errorCode,
|
|
361
|
+
opId: m.id,
|
|
362
|
+
entity: m.change.entity,
|
|
363
|
+
rowId: m.change.row_id,
|
|
364
|
+
kind: m.change.kind,
|
|
365
|
+
});
|
|
366
|
+
}
|
package/src/persistence.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface ReplicaPersistence {
|
|
|
31
31
|
saveRow(entity: string, id: string, data: Row): Promise<boolean>;
|
|
32
32
|
deleteRow(entity: string, id: string): Promise<boolean>;
|
|
33
33
|
clear(): Promise<boolean>;
|
|
34
|
+
/**
|
|
35
|
+
* Optional: write a batch of row changes, in order, and (when given) the
|
|
36
|
+
* cursor in ONE atomic transaction. Resolves `true` when the whole batch
|
|
37
|
+
* committed, `false` when it did not (nothing, cursor included, may be
|
|
38
|
+
* assumed on disk). The engine uses it to commit many live change frames
|
|
39
|
+
* at once; without it the engine writes row by row, then the cursor.
|
|
40
|
+
*/
|
|
41
|
+
saveBatch?(changes: ChangeEvent[], cursor: SyncCursor | null): Promise<boolean>;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
// ---------------------------------------------------------------------------
|
|
@@ -156,6 +164,29 @@ export class IndexedDBPersistence implements ReplicaPersistence {
|
|
|
156
164
|
return this.commit(tx, "saveRow");
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
/** Write row puts/deletes in order plus the cursor in ONE readwrite
|
|
168
|
+
* transaction. Rows and cursor commit or abort together, so the
|
|
169
|
+
* on-disk cursor can never get ahead of the on-disk rows. */
|
|
170
|
+
async saveBatch(changes: ChangeEvent[], cursor: SyncCursor | null): Promise<boolean> {
|
|
171
|
+
if (!this.db) return false;
|
|
172
|
+
const tx = this.db.transaction([STORE_NAME, CURSOR_STORE], "readwrite");
|
|
173
|
+
const rows = tx.objectStore(STORE_NAME);
|
|
174
|
+
for (const change of changes) {
|
|
175
|
+
if (change.kind === "delete") {
|
|
176
|
+
rows.delete(`${change.entity}:${change.row_id}`);
|
|
177
|
+
} else if (change.data) {
|
|
178
|
+
rows.put({
|
|
179
|
+
_key: `${change.entity}:${change.row_id}`,
|
|
180
|
+
entity: change.entity,
|
|
181
|
+
id: change.row_id,
|
|
182
|
+
data: change.data,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (cursor) tx.objectStore(CURSOR_STORE).put({ key: "cursor", ...cursor });
|
|
187
|
+
return this.commit(tx, "saveBatch");
|
|
188
|
+
}
|
|
189
|
+
|
|
159
190
|
/** Fetch a row from IndexedDB by key. Used by `persistChange` on update
|
|
160
191
|
* events to merge the patch against what's already on disk. */
|
|
161
192
|
async getRow(entity: string, id: string): Promise<Row | null> {
|
package/src/reconcile.test.ts
CHANGED
|
@@ -499,10 +499,13 @@ describe("SyncEngine replica identity guard", () => {
|
|
|
499
499
|
expect(engine.store.list("Note").length).toBe(1);
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
|
|
502
|
+
// The guard wipes rows but keeps queued writes: each write carries its
|
|
503
|
+
// owner, and push sends a guest's writes after the guest signs in and
|
|
504
|
+
// discards another user's (mutation-owner.test.ts).
|
|
505
|
+
test("an identity change across a reload keeps queued writes for the owner check", async () => {
|
|
503
506
|
const cases: ReadonlyArray<readonly [string, string, boolean]> = [
|
|
504
|
-
["guest_123", "user_B", false],
|
|
505
|
-
["user_A", "user_B",
|
|
507
|
+
["guest_123", "user_B", false],
|
|
508
|
+
["user_A", "user_B", false],
|
|
506
509
|
];
|
|
507
510
|
for (const [prev, now, expectWipeMutations] of cases) {
|
|
508
511
|
const engine = makeEngine();
|
|
@@ -267,3 +267,35 @@ describe("RoomSubscriptions: replay on reconnect", () => {
|
|
|
267
267
|
expect(h.sent).toEqual([{ type: "room-subscribe", room: "channel:b" }]);
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("RoomSubscriptions: resubscribe after a rejoin", () => {
|
|
272
|
+
test("resubscribe() clears the error and resends room-subscribe", () => {
|
|
273
|
+
const h = makeHarness();
|
|
274
|
+
h.rooms.register("channel:a", () => {});
|
|
275
|
+
h.rooms.applyError("channel:a", { code: "NOT_IN_ROOM" });
|
|
276
|
+
h.sent.length = 0;
|
|
277
|
+
h.rooms.resubscribe("channel:a");
|
|
278
|
+
expect(h.rooms.error("channel:a")).toBeNull();
|
|
279
|
+
expect(h.sent).toEqual([{ type: "room-subscribe", room: "channel:a" }]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("resubscribe() is a no-op for a room with no subscriber", () => {
|
|
283
|
+
const h = makeHarness();
|
|
284
|
+
h.rooms.resubscribe("channel:none");
|
|
285
|
+
expect(h.sent).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a follower engine asks the leader to resubscribe", async () => {
|
|
289
|
+
const { SyncEngine } = await import("./index");
|
|
290
|
+
const engine = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
|
|
291
|
+
const sent: unknown[] = [];
|
|
292
|
+
const internals = engine as unknown as {
|
|
293
|
+
isMultiTabLeader: boolean;
|
|
294
|
+
broadcastToTabs: (p: unknown) => void;
|
|
295
|
+
};
|
|
296
|
+
internals.isMultiTabLeader = false;
|
|
297
|
+
internals.broadcastToTabs = (p) => sent.push(p);
|
|
298
|
+
engine.resubscribeRoom("channel:a");
|
|
299
|
+
expect(sent).toEqual([{ type: "room-sub-resubscribe", room: "channel:a" }]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -344,6 +344,17 @@ export class RoomSubscriptions {
|
|
|
344
344
|
return Array.from(this.rooms.keys());
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/** Clear a room's error and resend its `room-subscribe`. Used after
|
|
348
|
+
* the caller rejoined the room over HTTP following a `NOT_IN_ROOM`
|
|
349
|
+
* reply: the server answers the new subscribe with a snapshot. No-op
|
|
350
|
+
* for a room with no local subscriber. */
|
|
351
|
+
resubscribe(roomId: string): void {
|
|
352
|
+
const entry = this.rooms.get(roomId);
|
|
353
|
+
if (!entry) return;
|
|
354
|
+
entry.error = null;
|
|
355
|
+
this.sendWs({ type: "room-subscribe", room: roomId });
|
|
356
|
+
}
|
|
357
|
+
|
|
347
358
|
/** Resend `room-subscribe` for every active room. Called by the
|
|
348
359
|
* engine's `onConnected` hook after the WS reopens — the server
|
|
349
360
|
* forgets per-client subs across disconnects, so without this
|
package/src/scenarios.test.ts
CHANGED
|
@@ -760,7 +760,9 @@ describe("sync scenarios", () => {
|
|
|
760
760
|
await env.flush();
|
|
761
761
|
|
|
762
762
|
env.server.primeNextPushOutcome({ kind: "status", status: 403 });
|
|
763
|
-
await
|
|
763
|
+
await expect(
|
|
764
|
+
env.engine.update("Note", "n1", { title: "edited" }),
|
|
765
|
+
).rejects.toMatchObject({ name: "MutationRejectedError" });
|
|
764
766
|
await env.flush();
|
|
765
767
|
|
|
766
768
|
const row = env.engine.store.get("Note", "n1") as { title?: string } | null;
|
|
@@ -780,7 +782,9 @@ describe("sync scenarios", () => {
|
|
|
780
782
|
await env.flush();
|
|
781
783
|
|
|
782
784
|
env.server.primeNextPushOutcome({ kind: "status", status: 403 });
|
|
783
|
-
await env.engine.delete("Note", "n1")
|
|
785
|
+
await expect(env.engine.delete("Note", "n1")).rejects.toMatchObject({
|
|
786
|
+
name: "MutationRejectedError",
|
|
787
|
+
});
|
|
784
788
|
await env.flush();
|
|
785
789
|
expect(env.engine.store.get("Note", "n1")).not.toBeNull();
|
|
786
790
|
|
|
@@ -969,7 +973,9 @@ describe("sync scenarios", () => {
|
|
|
969
973
|
// the previous "<= 2" bound, which would have passed even if
|
|
970
974
|
// coalescing was broken. Pins the inFlightReconcile dedupe.
|
|
971
975
|
test("back-to-back reconcile calls coalesce to a single fetch", async () => {
|
|
972
|
-
|
|
976
|
+
// Poll transport: the WebSocket's on-connect full reconcile is a
|
|
977
|
+
// separate op and could land inside the measured window.
|
|
978
|
+
env = createTestEnv({ transport: "poll" });
|
|
973
979
|
env.signIn({ userId: "u1" });
|
|
974
980
|
env.server.seed("Note", [{ id: "n1" }]);
|
|
975
981
|
await env.start();
|