@pylonsync/sync 0.3.228 → 0.3.230

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.
@@ -199,12 +199,20 @@ export class LocalStore {
199
199
  * between the memory apply and the eventual disk write can persist
200
200
  * a cursor that's ahead of the replica, skipping those rows
201
201
  * forever on restart.
202
+ *
203
+ * Returns `true` when every persist write reached disk durably,
204
+ * `false` when at least one degraded (quota / abort). The engine
205
+ * uses the result to hold the PERSISTED cursor back: a row that
206
+ * didn't reach disk must not be skipped by an advanced on-disk
207
+ * cursor on the next cold start. The in-memory replica always
208
+ * reflects the change regardless.
202
209
  */
203
- async applyChangesAsync(changes: ChangeEvent[]): Promise<void> {
210
+ async applyChangesAsync(changes: ChangeEvent[]): Promise<boolean> {
204
211
  for (const change of changes) {
205
212
  this.applyChange(change);
206
213
  }
207
214
  this.notify();
215
+ let allDurable = true;
208
216
  if (this._persistFn) {
209
217
  // Sequential await — concurrent IDB writes can resolve out of
210
218
  // order, racing an update behind its own delete on disk. The
@@ -213,10 +221,12 @@ export class LocalStore {
213
221
  for (const change of changes) {
214
222
  const result = this._persistFn(this.hydrateFromMemory(change));
215
223
  if (result instanceof Promise) {
216
- await result;
224
+ const durable = await result;
225
+ if (durable === false) allDurable = false;
217
226
  }
218
227
  }
219
228
  }
229
+ return allDurable;
220
230
  }
221
231
 
222
232
  /**
@@ -234,9 +244,11 @@ export class LocalStore {
234
244
  }
235
245
 
236
246
  /** Persistence callback for auto-saving changes. Returns
237
- * `Promise<void>` so callers can await. Void-returning callbacks
238
- * are accepted for backwards compatibility (just not awaitable). */
239
- _persistFn: ((change: ChangeEvent) => void | Promise<void>) | null = null;
247
+ * `Promise<boolean>` (true = durable, false = degraded) so
248
+ * `applyChangesAsync` can gate the on-disk cursor on durability.
249
+ * Void-returning callbacks are accepted for backwards compatibility
250
+ * (treated as durable / fire-and-forget). */
251
+ _persistFn: ((change: ChangeEvent) => void | Promise<boolean>) | null = null;
240
252
 
241
253
  /** Subscribe to store changes. Returns unsubscribe function. */
242
254
  subscribe(listener: () => void): () => void {
@@ -307,6 +319,46 @@ export class LocalStore {
307
319
  }
308
320
  }
309
321
 
322
+ /**
323
+ * Undo a rejected optimistic update/delete by restoring the row to its
324
+ * captured pre-mutation value and clearing any optimistic tombstone
325
+ * for it. `failPushedMutation` calls this when the server rejects an
326
+ * update (restore the prior field values) or a delete (bring the row
327
+ * back AND un-fence it so the row — and any future server insert of
328
+ * the id — isn't blocked by the lingering optimistic tombstone).
329
+ *
330
+ * `prev === null` means the row didn't exist before the mutation
331
+ * (e.g. an update on a row that was itself an un-acked insert) — in
332
+ * that case we just remove it + clear the fence.
333
+ *
334
+ * A REAL (server-issued) tombstone wins over the restore: if an
335
+ * authoritative delete/revocation for this id landed on the applyQueue
336
+ * while the rejected push was in flight (the opQueue and applyQueue run
337
+ * independently), resurrecting `prev` here would briefly un-delete a row
338
+ * the server says is gone — healed only at the next reconcile. So when a
339
+ * server tombstone is present we drop the row and let the canonical
340
+ * state stand; the failed mutation's own optimistic fence is cleared
341
+ * regardless so a later legitimate re-create of the id isn't blocked.
342
+ */
343
+ restoreRow(entity: string, id: string, prev: Row | null): void {
344
+ // The failed mutation's own optimistic fence always clears.
345
+ this.optimisticTombstones.get(entity)?.delete(id);
346
+ if (this.tombstones.get(entity)?.has(id)) {
347
+ // Server authoritatively removed this row mid-flight — its
348
+ // deletion outranks our local rollback.
349
+ this.tables.get(entity)?.delete(id);
350
+ this.notify();
351
+ return;
352
+ }
353
+ if (prev) {
354
+ if (!this.tables.has(entity)) this.tables.set(entity, new Map());
355
+ this.tables.get(entity)!.set(id, prev);
356
+ } else {
357
+ this.tables.get(entity)?.delete(id);
358
+ }
359
+ this.notify();
360
+ }
361
+
310
362
  /** Apply an optimistic delete. Block any incoming insert/update
311
363
  * for this id until the server's authoritative delete arrives. */
312
364
  optimisticDelete(entity: string, id: string): void {
@@ -69,8 +69,11 @@ export interface MultiTabOrchestratorHooks {
69
69
  removalIds: string[],
70
70
  tombstoneSeq: number,
71
71
  ): void;
72
- /** Replica reset broadcast identity flip happened on the leader. */
73
- onResetReceived(): void;
72
+ /** Replica reset broadcast from the leader. `wipeMutations` is true
73
+ * when the reset was an identity flip (drop the outgoing identity's
74
+ * pending offline writes), false for a same-user 410 RESYNC (keep
75
+ * them — they survive the snapshot refresh). */
76
+ onResetReceived(wipeMutations: boolean): void;
74
77
  /** Resolved session update from the leader. Engine funnels through
75
78
  * its session chain so concurrent triggers commit in order. */
76
79
  onSessionReceived(resolved: ResolvedSession): void;
@@ -112,6 +115,16 @@ export interface MultiTabOrchestratorHooks {
112
115
  /** New leader → followers: re-forward your locally-wanted room subs.
113
116
  * Triggered alongside CRDT/reactive replay after a leader change. */
114
117
  onReplayRoomSubs?(): void;
118
+
119
+ /** Follower → leader: a follower's `useQuery` observed an entity.
120
+ * Leader-only. The leader adds it to its reconcile sweep and fetches
121
+ * it so a server row the follower never cached is discovered and
122
+ * broadcast back as a `reconciled` batch. Without this, a follower's
123
+ * view on a never-cached entity stays empty forever. */
124
+ onEntityObserve?(entity: string, fromTabId: string): void;
125
+ /** New leader → followers: re-declare your observed entities so the
126
+ * new leader's reconcile sweep covers them. */
127
+ onReplayObservedEntities?(): void;
115
128
  }
116
129
 
117
130
  export interface MultiTabOrchestratorConfig {
@@ -247,8 +260,8 @@ export class MultiTabOrchestrator {
247
260
  });
248
261
  }
249
262
 
250
- broadcastReset(): void {
251
- this.broadcastRaw({ type: "reset" });
263
+ broadcastReset(wipeMutations: boolean): void {
264
+ this.broadcastRaw({ type: "reset", wipeMutations });
252
265
  }
253
266
 
254
267
  broadcastSession(resolved: ResolvedSession): void {
@@ -328,7 +341,7 @@ export class MultiTabOrchestrator {
328
341
  break;
329
342
  }
330
343
  case "reset": {
331
- this.hooks.onResetReceived();
344
+ this.hooks.onResetReceived(msg.wipeMutations === true);
332
345
  break;
333
346
  }
334
347
  case "session": {
@@ -388,6 +401,19 @@ export class MultiTabOrchestrator {
388
401
  // so the new leader can rebuild its forwarder sets and resend
389
402
  // `room-subscribe` on the WS.
390
403
  this.hooks.onReplayRoomSubs?.();
404
+ // ...and re-declare observed entities so the new leader's
405
+ // reconcile sweep covers them.
406
+ this.hooks.onReplayObservedEntities?.();
407
+ break;
408
+ }
409
+ case "entity-observe": {
410
+ // Follower → leader. Leader-only — a follower receiving this
411
+ // (own broadcast echo, or stale leader transition) ignores.
412
+ if (!this._isLeader) return;
413
+ const entity = msg.entity as string | undefined;
414
+ if (typeof entity === "string") {
415
+ this.hooks.onEntityObserve?.(entity, fromTabId);
416
+ }
391
417
  break;
392
418
  }
393
419
  case "room-sub-register": {
@@ -10,13 +10,19 @@
10
10
  // instead of re-applying.
11
11
  // ---------------------------------------------------------------------------
12
12
 
13
- import type { ClientChange } from "./types";
13
+ import type { ClientChange, Row } from "./types";
14
14
 
15
15
  export interface PendingMutation {
16
16
  id: string;
17
17
  change: ClientChange;
18
18
  status: "pending" | "applied" | "failed";
19
19
  error?: string;
20
+ /** Pre-mutation snapshot of the affected row, captured at optimistic-
21
+ * apply time for `update`/`delete`. On a server rejection,
22
+ * `failPushedMutation` restores this so the local replica reverts to
23
+ * the value the server actually holds. `null` = the row didn't exist
24
+ * before the mutation; `undefined` = not captured (inserts). */
25
+ prevRow?: Row | null;
20
26
  }
21
27
 
22
28
  /**
@@ -80,14 +86,14 @@ export class MutationQueue {
80
86
  * entry with the same op_id is already queued — a follower
81
87
  * retrying its forward of the same op shouldn't double-queue on
82
88
  * the leader. */
83
- add(change: ClientChange): string {
89
+ add(change: ClientChange, prevRow?: Row | null): string {
84
90
  const id =
85
91
  typeof change.op_id === "string" && change.op_id.length > 0
86
92
  ? change.op_id
87
93
  : `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
88
94
  if (this.queue.some((m) => m.id === id)) return id;
89
95
  const changeWithOp: ClientChange = { ...change, op_id: id };
90
- this.queue.push({ id, change: changeWithOp, status: "pending" });
96
+ this.queue.push({ id, change: changeWithOp, status: "pending", prevRow });
91
97
  this.flush();
92
98
  return id;
93
99
  }
@@ -96,6 +102,13 @@ export class MutationQueue {
96
102
  return this.queue.filter((m) => m.status === "pending");
97
103
  }
98
104
 
105
+ /** Look up a queued mutation by op id (any status). Used by the
106
+ * follower's mutations-failed handler to reach the captured
107
+ * `prevRow` for rollback. */
108
+ get(id: string): PendingMutation | undefined {
109
+ return this.queue.find((m) => m.id === id);
110
+ }
111
+
99
112
  /**
100
113
  * Set of `${entity}/${row_id}` keys for every mutation currently
101
114
  * in Pending or Failed state. Used by reconcile() to skip rows
@@ -152,6 +165,22 @@ export class MutationQueue {
152
165
  this.flush();
153
166
  }
154
167
 
168
+ /**
169
+ * Drop EVERY mutation — pending, failed, and applied — then flush the
170
+ * empty queue to disk. Used by the engine's identity-flip reset
171
+ * (`resetReplica({ wipeMutations: true })`): the queued offline writes
172
+ * belong to the OUTGOING identity, and replaying them under the new
173
+ * session would attribute one user's writes to another (or get
174
+ * policy-rejected on the server). Distinct from `clear()`, which only
175
+ * prunes already-applied entries and deliberately PRESERVES pending +
176
+ * failed writes — the 410-RESYNC same-user path relies on that so
177
+ * offline writes survive a snapshot refresh.
178
+ */
179
+ clearAll(): void {
180
+ this.queue = [];
181
+ this.flush();
182
+ }
183
+
155
184
  /** Fire-and-forget persistence write. */
156
185
  private flush(): void {
157
186
  if (!this.persistence) return;
@@ -86,15 +86,47 @@ export class IndexedDBPersistence {
86
86
  });
87
87
  }
88
88
 
89
- /** Save a row to IndexedDB. */
90
- async saveRow(entity: string, id: string, data: Row): Promise<void> {
91
- if (!this.db) return;
89
+ /** Resolve when a write transaction settles, reporting durability:
90
+ * `true` on COMMIT, `false` if it aborts/errors e.g.
91
+ * QuotaExceededError on a storage-pressured device. NEVER rejects or
92
+ * hangs: registering only `oncomplete` (the original shape) meant an
93
+ * aborted tx never settled, and the engine awaits the persist before
94
+ * advancing the cursor in `enqueueApply` — so ONE hung write wedged
95
+ * the whole applyQueue and live sync silently died for the session.
96
+ *
97
+ * But "resolve unconditionally" (the first fix) was also wrong: on an
98
+ * abort the row never reached disk, yet `enqueueApply` still advanced
99
+ * the PERSISTED cursor past it — so on restart the warm-load skipped
100
+ * those rows forever (cursor ahead of replica). The boolean lets the
101
+ * caller hold the on-disk cursor back when a row write fails, so the
102
+ * next cold start re-pulls the gap. Best-effort: log once, keep the
103
+ * in-memory replica authoritative, but never persist a cursor ahead of
104
+ * what's actually durable. Mirrors the read paths (`getRow`/
105
+ * `loadCursor`) which already degrade via `onerror`. */
106
+ private commit(tx: IDBTransaction, op: string): Promise<boolean> {
107
+ return new Promise((resolve) => {
108
+ tx.oncomplete = () => resolve(true);
109
+ tx.onerror = () => {
110
+ // eslint-disable-next-line no-console
111
+ console.warn(`[pylon-sync] IndexedDB ${op} failed; degrading to memory`, tx.error);
112
+ resolve(false);
113
+ };
114
+ tx.onabort = () => {
115
+ // eslint-disable-next-line no-console
116
+ console.warn(`[pylon-sync] IndexedDB ${op} aborted; degrading to memory`, tx.error);
117
+ resolve(false);
118
+ };
119
+ });
120
+ }
121
+
122
+ /** Save a row to IndexedDB. Resolves `true` when the write is durable,
123
+ * `false` if it degraded (no DB / aborted) — see `commit`. */
124
+ async saveRow(entity: string, id: string, data: Row): Promise<boolean> {
125
+ if (!this.db) return false;
92
126
  const tx = this.db.transaction(STORE_NAME, "readwrite");
93
127
  const store = tx.objectStore(STORE_NAME);
94
128
  store.put({ _key: `${entity}:${id}`, entity, id, data });
95
- return new Promise((resolve) => {
96
- tx.oncomplete = () => resolve();
97
- });
129
+ return this.commit(tx, "saveRow");
98
130
  }
99
131
 
100
132
  /** Fetch a row from IndexedDB by key. Used by `persistChange` on update
@@ -113,15 +145,14 @@ export class IndexedDBPersistence {
113
145
  });
114
146
  }
115
147
 
116
- /** Delete a row from IndexedDB. */
117
- async deleteRow(entity: string, id: string): Promise<void> {
118
- if (!this.db) return;
148
+ /** Delete a row from IndexedDB. Resolves `true` when durable, `false`
149
+ * if it degraded see `commit`. */
150
+ async deleteRow(entity: string, id: string): Promise<boolean> {
151
+ if (!this.db) return false;
119
152
  const tx = this.db.transaction(STORE_NAME, "readwrite");
120
153
  const store = tx.objectStore(STORE_NAME);
121
154
  store.delete(`${entity}:${id}`);
122
- return new Promise((resolve) => {
123
- tx.oncomplete = () => resolve();
124
- });
155
+ return this.commit(tx, "deleteRow");
125
156
  }
126
157
 
127
158
  /** Load all rows for an entity from IndexedDB. */
@@ -217,15 +248,14 @@ export class IndexedDBPersistence {
217
248
  });
218
249
  }
219
250
 
220
- /** Save the sync cursor. */
221
- async saveCursor(cursor: SyncCursor): Promise<void> {
222
- if (!this.db) return;
251
+ /** Save the sync cursor. Resolves `true` when durable, `false` if it
252
+ * degraded see `commit`. */
253
+ async saveCursor(cursor: SyncCursor): Promise<boolean> {
254
+ if (!this.db) return false;
223
255
  const tx = this.db.transaction(CURSOR_STORE, "readwrite");
224
256
  const store = tx.objectStore(CURSOR_STORE);
225
257
  store.put({ key: "cursor", ...cursor });
226
- return new Promise((resolve) => {
227
- tx.oncomplete = () => resolve();
228
- });
258
+ return this.commit(tx, "saveCursor");
229
259
  }
230
260
 
231
261
  /** Load the sync cursor. */
@@ -247,25 +277,34 @@ export class IndexedDBPersistence {
247
277
  });
248
278
  }
249
279
 
250
- /** Clear all stored data. */
251
- async clear(): Promise<void> {
252
- if (!this.db) return;
280
+ /** Clear stored rows + cursor. Deliberately does NOT touch the
281
+ * durable mutation queue: `resetReplica` calls this on a 410 RESYNC
282
+ * (same user, needs a fresh snapshot) where pending offline writes
283
+ * MUST survive. On an IDENTITY flip the old identity's pending
284
+ * mutations must instead be discarded — that wipe runs through
285
+ * `MutationQueue.clearAll()` (which persists an empty `MUTATIONS_STORE`
286
+ * via its own backend), gated on `resetReplica({ wipeMutations })` at
287
+ * the token/tenant-flip call sites. Splitting the two stores keeps each
288
+ * reset path honest: 410 keeps writes, identity flip drops them. */
289
+ async clear(): Promise<boolean> {
290
+ if (!this.db) return false;
253
291
  const tx = this.db.transaction([STORE_NAME, CURSOR_STORE], "readwrite");
254
292
  tx.objectStore(STORE_NAME).clear();
255
293
  tx.objectStore(CURSOR_STORE).clear();
256
- return new Promise((resolve) => {
257
- tx.oncomplete = () => resolve();
258
- });
294
+ return this.commit(tx, "clear");
259
295
  }
260
296
  }
261
297
 
262
298
  /**
263
- * Apply a change event to IndexedDB persistence.
299
+ * Apply a change event to IndexedDB persistence. Returns `true` when the
300
+ * write reached disk durably, `false` when it degraded (so `enqueueApply`
301
+ * can hold the persisted cursor back rather than skip the row on restart).
302
+ * A change with no `data` is a no-op and counts as durable.
264
303
  */
265
304
  export async function persistChange(
266
305
  persistence: IndexedDBPersistence,
267
306
  change: ChangeEvent
268
- ): Promise<void> {
307
+ ): Promise<boolean> {
269
308
  switch (change.kind) {
270
309
  case "insert":
271
310
  case "update":
@@ -274,13 +313,13 @@ export async function persistChange(
274
313
  // change's `data` with the post-merge row from memory — so even
275
314
  // on update the full row lands here. Overwriting is correct;
276
315
  // pre-merge patches would have dropped unpatched columns.
277
- await persistence.saveRow(change.entity, change.row_id, change.data);
316
+ return persistence.saveRow(change.entity, change.row_id, change.data);
278
317
  }
279
- break;
318
+ return true;
280
319
  case "delete":
281
- await persistence.deleteRow(change.entity, change.row_id);
282
- break;
320
+ return persistence.deleteRow(change.entity, change.row_id);
283
321
  }
322
+ return true;
284
323
  }
285
324
 
286
325
  // ---------------------------------------------------------------------------
@@ -326,3 +326,160 @@ describe("codex round-6: peer leaving scrubs forwarded subs", () => {
326
326
  expect(engine.serverSubs.has("sub-1")).toBe(false);
327
327
  });
328
328
  });
329
+
330
+ describe("sync hardening: multi-tab follower coverage", () => {
331
+ let env: TestEnv | null = null;
332
+ afterEach(async () => {
333
+ if (env) await env.dispose();
334
+ env = null;
335
+ });
336
+
337
+ // FOLLOWER GHOST ROLLBACK. The leader broadcasts `mutations-failed`;
338
+ // the follower must roll back its OWN optimistic ghost, not just mark
339
+ // the queue entry failed. Pre-fix onMutationsFailed called markFailed
340
+ // directly, so the ghost row stuck around in the tab the user sees.
341
+ test("a mutations-failed for an optimistic insert removes the follower's ghost", async () => {
342
+ env = createTestEnv();
343
+ env.signIn({ userId: "u1" });
344
+ await env.start();
345
+ const engine = env.engine;
346
+
347
+ // Optimistic ghost in the store + a matching queued mutation (as if
348
+ // this follower had forwarded the insert to the leader).
349
+ engine.store.optimisticInsertWithId("Todo", "t1", { id: "t1", text: "ghost" });
350
+ engine.mutations.add({
351
+ op_id: "op-1",
352
+ entity: "Todo",
353
+ row_id: "t1",
354
+ kind: "insert",
355
+ data: { id: "t1", text: "ghost" },
356
+ });
357
+ expect(engine.store.get("Todo", "t1")).not.toBeNull();
358
+
359
+ (engine as unknown as {
360
+ orchestrator: { handleMessage(msg: unknown, from: string): void };
361
+ }).orchestrator.handleMessage(
362
+ { type: "mutations-failed", ops: [{ opId: "op-1", error: "rejected" }] },
363
+ "leader-tab",
364
+ );
365
+
366
+ // Ghost gone (rolled back), mutation marked failed.
367
+ expect(engine.store.get("Todo", "t1")).toBeNull();
368
+ });
369
+
370
+ // ENTITY-OBSERVE FORWARDING. A leader that receives a forwarded
371
+ // `entity-observe` from a follower must add the entity to its reconcile
372
+ // sweep and fetch it — so a server row the follower never cached is
373
+ // discovered + broadcast back. Without this a follower's useQuery on a
374
+ // never-cached entity renders empty forever.
375
+ test("a leader fetches an entity a follower forwarded via entity-observe", async () => {
376
+ env = createTestEnv({ transport: "poll" });
377
+ env.signIn({ userId: "u1" });
378
+ await env.start(); // solo → this engine is the leader
379
+ const engine = env.engine;
380
+
381
+ // A server row exists for an entity this engine never cached.
382
+ env.server.insert("Domain", { id: "d1", host: "x.com" });
383
+ expect(engine.store.list("Domain")).toHaveLength(0);
384
+
385
+ (engine as unknown as {
386
+ orchestrator: { handleMessage(msg: unknown, from: string): void };
387
+ }).orchestrator.handleMessage(
388
+ { type: "entity-observe", entity: "Domain" },
389
+ "follower-tab",
390
+ );
391
+ await env.flush();
392
+
393
+ expect(engine.store.get("Domain", "d1")).not.toBeNull();
394
+ });
395
+
396
+ // FORWARDED-MUTATION ROLLBACK (pins onMutationsForwarded prevRow
397
+ // threading). When a follower forwards an update/delete and the leader's
398
+ // push of it is PERMANENTLY rejected, the leader must restore the
399
+ // canonical row — NOT delete it. Pre-fix the leader queued the forwarded
400
+ // op via mutations.add(op.change) WITHOUT prevRow, so failPushedMutation
401
+ // ran restoreRow(undefined ?? null) → restoreRow(null) → DELETED the
402
+ // leader's still-valid canonical row. Data loss on every rejected
403
+ // forwarded edit.
404
+ test("a rejected forwarded UPDATE keeps the leader's canonical row", async () => {
405
+ env = createTestEnv({ transport: "poll" });
406
+ env.signIn({ userId: "u1" });
407
+ env.server.seed("Note", [{ id: "n1", title: "canonical" }]);
408
+ await env.start(); // solo → leader, holds the canonical row
409
+ await env.flush();
410
+ expect(env.engine.store.get("Note", "n1")).not.toBeNull();
411
+
412
+ // A follower optimistically edited n1, captured the prior value as
413
+ // prevRow, and forwarded the update. The leader's push will 403.
414
+ env.server.primeNextPushOutcome({ kind: "status", status: 403 });
415
+ (env.engine as unknown as {
416
+ orchestrator: { handleMessage(msg: unknown, from: string): void };
417
+ }).orchestrator.handleMessage(
418
+ {
419
+ type: "mutations",
420
+ ops: [
421
+ {
422
+ id: "op-u",
423
+ change: {
424
+ op_id: "op-u",
425
+ entity: "Note",
426
+ row_id: "n1",
427
+ kind: "update",
428
+ data: { title: "edited" },
429
+ },
430
+ status: "pending",
431
+ prevRow: { id: "n1", title: "canonical" },
432
+ },
433
+ ],
434
+ },
435
+ "follower-tab",
436
+ );
437
+ // Wait behind the forwarded push (opQueue serializes), then drain.
438
+ await env.engine.push();
439
+ await env.flush();
440
+
441
+ const row = env.engine.store.get("Note", "n1") as { title?: string } | null;
442
+ expect(row).not.toBeNull();
443
+ expect(row?.title).toBe("canonical");
444
+ });
445
+
446
+ test("a rejected forwarded DELETE keeps the leader's canonical row", async () => {
447
+ env = createTestEnv({ transport: "poll" });
448
+ env.signIn({ userId: "u1" });
449
+ env.server.seed("Note", [{ id: "n1", title: "keep" }]);
450
+ await env.start();
451
+ await env.flush();
452
+ expect(env.engine.store.get("Note", "n1")).not.toBeNull();
453
+
454
+ env.server.primeNextPushOutcome({ kind: "status", status: 403 });
455
+ (env.engine as unknown as {
456
+ orchestrator: { handleMessage(msg: unknown, from: string): void };
457
+ }).orchestrator.handleMessage(
458
+ {
459
+ type: "mutations",
460
+ ops: [
461
+ {
462
+ id: "op-d",
463
+ change: {
464
+ op_id: "op-d",
465
+ entity: "Note",
466
+ row_id: "n1",
467
+ kind: "delete",
468
+ },
469
+ status: "pending",
470
+ prevRow: { id: "n1", title: "keep" },
471
+ },
472
+ ],
473
+ },
474
+ "follower-tab",
475
+ );
476
+ await env.engine.push();
477
+ await env.flush();
478
+
479
+ // Pre-fix the leader deleted its canonical row on the rejected
480
+ // forwarded delete; with prevRow threaded it survives.
481
+ const row = env.engine.store.get("Note", "n1") as { title?: string } | null;
482
+ expect(row).not.toBeNull();
483
+ expect(row?.title).toBe("keep");
484
+ });
485
+ });