@pylonsync/sync 0.21.0 → 0.22.1

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.
@@ -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
+ });
@@ -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(change: ClientChange, prevRow?: Row | null): string {
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
- this.queue.push({ id, change: changeWithOp, status: "pending", prevRow });
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
+ }
@@ -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> {
@@ -499,10 +499,13 @@ describe("SyncEngine replica identity guard", () => {
499
499
  expect(engine.store.list("Note").length).toBe(1);
500
500
  });
501
501
 
502
- test("guest→user preserves pending writes (merge); user→user discards them", async () => {
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], // anonymous-merge login: keep writes
505
- ["user_A", "user_B", true], // account switch: drop A's writes
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
@@ -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 env.engine.update("Note", "n1", { title: "edited" });
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
- env = createTestEnv();
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();