@pylonsync/sync 0.3.275 → 0.3.276

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.275",
6
+ "version": "0.3.276",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1033,6 +1033,20 @@ export class SyncEngine {
1033
1033
  this.broadcastToTabs({ type: "entity-observe", entity });
1034
1034
  }
1035
1035
  },
1036
+ onReplayForwardedMutations: () => {
1037
+ // Follower path: re-forward our pending batch to the new leader.
1038
+ // If we'd forwarded an op to the previous (now-dead) leader, it
1039
+ // died before acking, so the op is still pending here as an
1040
+ // optimistic ghost with nothing pushing it. The new leader only
1041
+ // drains its OWN queue on promotion, so without this re-forward the
1042
+ // op is silently lost until our next local write. The leader's
1043
+ // `onMutationsForwarded` re-adds by op_id (idempotent) and pushes.
1044
+ if (this.isMultiTabLeader) return;
1045
+ const pending = this.mutations.pending();
1046
+ if (pending.length > 0) {
1047
+ this.broadcastToTabs({ type: "mutations", ops: pending });
1048
+ }
1049
+ },
1036
1050
  };
1037
1051
  }
1038
1052
 
@@ -1672,6 +1686,12 @@ export class SyncEngine {
1672
1686
  * reconcile sweep them too. See `observeEntity`. */
1673
1687
  private observedEntities = new Set<string>();
1674
1688
 
1689
+ /** Per-entity count of CONSECUTIVE 403s seen during reconcile, reset on
1690
+ * any successful fetch. A single 403 can be transient (a bearer caught
1691
+ * mid-refresh, a momentary policy blip) and must NOT wipe the local cache;
1692
+ * we only drop an entity's rows after two in a row. See `reconcile`. */
1693
+ private reconcile403Streak = new Map<string, number>();
1694
+
1675
1695
  /**
1676
1696
  * Reconcile the local replica against server truth.
1677
1697
  *
@@ -1819,14 +1839,41 @@ export class SyncEngine {
1819
1839
  // Network errors are expected (offline, transient 5xx). Skip
1820
1840
  // this entity; the next reconcile trigger will retry.
1821
1841
  const status = (err as { status?: number })?.status;
1822
- if (status === 403 || status === 404) {
1823
- // Entity is no longer readable (policy revoked) or removed
1824
- // from the manifest. Drop every local row for it — keeping
1825
- // them around just leaks invisible state.
1842
+ if (status === 404) {
1843
+ // Entity removed from the manifest definitive. Drop its rows.
1844
+ this.reconcile403Streak.delete(entity);
1845
+ await this.dropEntity(entity, tombstoneSeq);
1846
+ return;
1847
+ }
1848
+ if (status === 403) {
1849
+ // A 403 can be TRANSIENT — a bearer caught mid-refresh, a tenant
1850
+ // flip that raced the fetch, or a momentary server-side policy
1851
+ // blip. Nuking every local row on the first one makes the data
1852
+ // flash away and reappear next reconcile (the "rows vanish on
1853
+ // load" bug, on the error path this time). Two gates before we
1854
+ // drop:
1855
+ // 1. If the session flipped during the fetch, the 403 reflects
1856
+ // the OLD context — never drop; the next reconcile under the
1857
+ // new session decides (mirrors the success-path guard below).
1858
+ if (this.session.signature() !== sessionBeforeFetch) {
1859
+ return;
1860
+ }
1861
+ // 2. Otherwise require TWO consecutive 403s for this entity, so
1862
+ // a one-off blip can't wipe the cache. A successful fetch
1863
+ // (below) resets the streak.
1864
+ const streak = (this.reconcile403Streak.get(entity) ?? 0) + 1;
1865
+ if (streak < 2) {
1866
+ this.reconcile403Streak.set(entity, streak);
1867
+ return;
1868
+ }
1869
+ this.reconcile403Streak.delete(entity);
1826
1870
  await this.dropEntity(entity, tombstoneSeq);
1827
1871
  }
1828
1872
  return;
1829
1873
  }
1874
+ // Successful fetch — the entity is readable, so any prior 403 streak
1875
+ // is broken (even if a drift guard below makes us skip the apply).
1876
+ this.reconcile403Streak.delete(entity);
1830
1877
  if (this.cursor.last_seq !== cursorBeforeFetch) {
1831
1878
  // Cursor moved during fetch — at least one WS event for this
1832
1879
  // (or another) entity landed and might have a fresher value
@@ -37,6 +37,7 @@ function makeRig(opts: { isLeader?: boolean } = {}) {
37
37
  onMutationsFailed: record("mutationsFailed"),
38
38
  onBinaryReceived: record("binary"),
39
39
  onPeerLeft: record("peerLeft"),
40
+ onReplayForwardedMutations: record("replayForwardedMutations"),
40
41
  },
41
42
  );
42
43
  return { orch, events, serverSubs, subs };
@@ -89,6 +90,26 @@ describe("MultiTabOrchestrator dispatch", () => {
89
90
  expect(follower.events.length).toBe(0);
90
91
  });
91
92
 
93
+ test("request-sub-replay re-forwards a follower's pending mutations (#341)", () => {
94
+ // A new leader broadcasts request-sub-replay after a handoff. Followers
95
+ // must re-forward their pending mutations — an op forwarded to the
96
+ // previous (now-dead) leader is otherwise stranded, since the new leader
97
+ // only drains its OWN queue on promotion. makeRig's orchestrator never
98
+ // ran init(), so _isLeader is false → it acts as a follower here.
99
+ const { orch, events } = makeRig();
100
+ orch.handleMessage({ type: "request-sub-replay" }, "new-leader");
101
+ expect(events.map((e) => e.kind)).toContain("replayForwardedMutations");
102
+ });
103
+
104
+ test("request-sub-replay does NOT re-forward on the leader (#341)", () => {
105
+ const { orch, events } = makeRig();
106
+ // Promote this rig to leader; a leader receiving the replay request (its
107
+ // own echo, or a stale broadcast) owns the network and must not act.
108
+ (orch as unknown as { _isLeader: boolean })._isLeader = true;
109
+ orch.handleMessage({ type: "request-sub-replay" }, "x");
110
+ expect(events.map((e) => e.kind)).not.toContain("replayForwardedMutations");
111
+ });
112
+
92
113
  test("mutations-acked + mutations-failed both fire their hooks", () => {
93
114
  const { orch, events } = makeRig();
94
115
  orch.handleMessage({ type: "mutations-acked", opIds: ["a", "b"] }, "x");
@@ -125,6 +125,12 @@ export interface MultiTabOrchestratorHooks {
125
125
  /** New leader → followers: re-declare your observed entities so the
126
126
  * new leader's reconcile sweep covers them. */
127
127
  onReplayObservedEntities?(): void;
128
+ /** New leader → followers: re-forward your pending mutation batch. A
129
+ * mutation a follower forwarded to a leader that died before acking is
130
+ * otherwise stranded — the new leader only drains its OWN queue on
131
+ * promotion, and the other replay hooks cover subs/rooms/entities but
132
+ * not mutations. op_id makes the re-forward idempotent. */
133
+ onReplayForwardedMutations?(): void;
128
134
  }
129
135
 
130
136
  export interface MultiTabOrchestratorConfig {
@@ -404,6 +410,11 @@ export class MultiTabOrchestrator {
404
410
  // ...and re-declare observed entities so the new leader's
405
411
  // reconcile sweep covers them.
406
412
  this.hooks.onReplayObservedEntities?.();
413
+ // ...and re-forward any pending mutations. A mutation forwarded to
414
+ // a now-dead leader (which never acked) is otherwise stranded as an
415
+ // optimistic ghost until this follower's next write — the new
416
+ // leader only drained its own queue on promotion.
417
+ this.hooks.onReplayForwardedMutations?.();
407
418
  break;
408
419
  }
409
420
  case "entity-observe": {
@@ -247,6 +247,52 @@ describe("SyncEngine.reconcile", () => {
247
247
  await engine.reconcile(["DeletedEntity"]);
248
248
  expect(engine.store.list("DeletedEntity").length).toBe(0);
249
249
  });
250
+
251
+ test("transient 403 keeps rows; two consecutive 403s drop them (#343)", async () => {
252
+ // A single 403 can be a bearer caught mid-refresh or a momentary policy
253
+ // blip; nuking the cache on the first one made rows flash away and return
254
+ // next reconcile. The first 403 must keep the rows.
255
+ const status = 403;
256
+ restore = installFetch(async () => ({ status, body: {} }));
257
+ const engine = makeEngine();
258
+ seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
259
+
260
+ await engine.reconcile(["Recording"]);
261
+ expect(engine.store.list("Recording").length).toBe(1);
262
+
263
+ // A second consecutive 403 confirms access is really gone — now drop.
264
+ await engine.reconcile(["Recording"]);
265
+ expect(engine.store.list("Recording").length).toBe(0);
266
+ });
267
+
268
+ test("a successful fetch resets the 403 streak (#343)", async () => {
269
+ let status = 403;
270
+ restore = installFetch(async () =>
271
+ status === 200
272
+ ? {
273
+ status: 200,
274
+ body: {
275
+ data: [{ id: "r1", title: "alive" }],
276
+ next_cursor: null,
277
+ has_more: false,
278
+ },
279
+ }
280
+ : { status: 403, body: {} },
281
+ );
282
+ const engine = makeEngine();
283
+ seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
284
+
285
+ await engine.reconcile(["Recording"]); // 403 #1 — kept
286
+ expect(engine.store.list("Recording").length).toBe(1);
287
+
288
+ status = 200;
289
+ await engine.reconcile(["Recording"]); // success — streak resets
290
+ expect(engine.store.list("Recording").length).toBe(1);
291
+
292
+ status = 403;
293
+ await engine.reconcile(["Recording"]); // a fresh lone 403 must NOT drop
294
+ expect(engine.store.list("Recording").length).toBe(1);
295
+ });
250
296
  });
251
297
 
252
298
  describe("LocalStore.reconcileRemove", () => {
@@ -483,3 +483,88 @@ describe("sync hardening: multi-tab follower coverage", () => {
483
483
  expect(row?.title).toBe("keep");
484
484
  });
485
485
  });
486
+
487
+ describe("#341: leader-handoff re-forwards a follower's pending mutations", () => {
488
+ let env: TestEnv | null = null;
489
+ afterEach(async () => {
490
+ if (env) await env.dispose();
491
+ env = null;
492
+ });
493
+
494
+ // The engine-side half of the fix: a follower's onReplayForwardedMutations
495
+ // hook (fired when a new leader broadcasts request-sub-replay) re-forwards
496
+ // its pending batch. Pre-fix the hook didn't exist, so an op forwarded to a
497
+ // now-dead leader was stranded until the follower's next local write.
498
+ test("a follower re-forwards its pending batch on replay", async () => {
499
+ env = createTestEnv();
500
+ env.signIn({ userId: "u1" });
501
+ await env.start();
502
+
503
+ const engine = env.engine as unknown as {
504
+ isMultiTabLeader: boolean;
505
+ multiTabHooks(): { onReplayForwardedMutations?: () => void };
506
+ broadcastToTabs(payload: unknown): void;
507
+ mutations: { add(change: unknown): string };
508
+ };
509
+
510
+ // Become a follower holding an op we'd forwarded to a leader that died
511
+ // before acking — it's still pending here with nothing pushing it.
512
+ engine.isMultiTabLeader = false;
513
+ engine.mutations.add({
514
+ op_id: "op-stranded",
515
+ entity: "Todo",
516
+ row_id: "x",
517
+ kind: "insert",
518
+ data: { id: "x", text: "stranded" },
519
+ });
520
+
521
+ const forwarded: { ops: { change: { op_id: string } }[] }[] = [];
522
+ const orig = engine.broadcastToTabs.bind(engine);
523
+ engine.broadcastToTabs = (p: unknown) => {
524
+ if ((p as { type?: string }).type === "mutations") {
525
+ forwarded.push(p as { ops: { change: { op_id: string } }[] });
526
+ }
527
+ orig(p);
528
+ };
529
+
530
+ // New leader → followers: re-forward your subs/rooms/entities AND your
531
+ // pending mutations. This is the hook the request-sub-replay handler now
532
+ // invokes.
533
+ engine.multiTabHooks().onReplayForwardedMutations?.();
534
+
535
+ expect(forwarded.length).toBe(1);
536
+ expect(forwarded[0].ops.map((o) => o.change.op_id)).toContain("op-stranded");
537
+ });
538
+
539
+ test("the leader does NOT re-forward on replay (it owns the network)", async () => {
540
+ env = createTestEnv(); // multiTab:false → this engine is the leader
541
+ env.signIn({ userId: "u1" });
542
+ await env.start();
543
+
544
+ const engine = env.engine as unknown as {
545
+ isMultiTabLeader: boolean;
546
+ multiTabHooks(): { onReplayForwardedMutations?: () => void };
547
+ broadcastToTabs(payload: unknown): void;
548
+ mutations: { add(change: unknown): string };
549
+ };
550
+
551
+ expect(engine.isMultiTabLeader).toBe(true);
552
+ engine.mutations.add({
553
+ op_id: "op-leader",
554
+ entity: "Todo",
555
+ row_id: "y",
556
+ kind: "insert",
557
+ data: { id: "y", text: "mine" },
558
+ });
559
+
560
+ const forwarded: unknown[] = [];
561
+ const orig = engine.broadcastToTabs.bind(engine);
562
+ engine.broadcastToTabs = (p: unknown) => {
563
+ if ((p as { type?: string }).type === "mutations") forwarded.push(p);
564
+ orig(p);
565
+ };
566
+
567
+ engine.multiTabHooks().onReplayForwardedMutations?.();
568
+ expect(forwarded.length).toBe(0);
569
+ });
570
+ });