@pylonsync/sync 0.22.26 → 0.22.28

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,171 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { createTestEnv, type TestEnv } from "./test-harness";
3
+ import type { PendingMutation } from "./mutation-queue";
4
+
5
+ let env: TestEnv | undefined;
6
+ afterEach(async () => { await env?.dispose(); env = undefined; });
7
+
8
+ async function start() {
9
+ env = createTestEnv({ transport: "poll" });
10
+ env.signIn({ userId: "u1" });
11
+ await env.start();
12
+ return env;
13
+ }
14
+
15
+ test("concurrent offline writes share one queue snapshot and request", async () => {
16
+ const { engine, server } = await start();
17
+ const pending = engine.mutations.pending.bind(engine.mutations);
18
+ let copies = 0;
19
+ engine.mutations.pending = () => {
20
+ const rows = pending();
21
+ copies += rows.length;
22
+ return rows;
23
+ };
24
+ server.primeNextPushOutcome({ kind: "network" });
25
+ await Promise.all(Array.from({ length: 1000 }, (_, i) => engine.insert("Note", { title: String(i) })));
26
+ expect(server.pushRequestCount).toBe(1);
27
+ expect(copies).toBe(1000);
28
+ expect(pending()).toHaveLength(1000);
29
+ expect(engine.store.list("Note")).toHaveLength(1000);
30
+ });
31
+
32
+ test("new offline writes retain one retry and explicit recovery sends the queue", async () => {
33
+ const { engine, server } = await start();
34
+ const internals = engine as unknown as { retryTimers: Set<unknown>; pushRetryTimer: unknown };
35
+ server.primeNextPushOutcome({ kind: "network" });
36
+ await engine.insert("Note", { title: "first" });
37
+ const timer = internals.pushRetryTimer;
38
+ for (let i = 1; i < 100; i++) await engine.insert("Note", { title: String(i) });
39
+ expect(server.pushRequestCount).toBe(1);
40
+ expect(internals.pushRetryTimer).toBe(timer);
41
+ expect(internals.retryTimers.size).toBe(1);
42
+ expect(engine.mutations.pending()).toHaveLength(100);
43
+ await engine.push();
44
+ expect(server.pushRequestCount).toBe(2);
45
+ expect(engine.mutations.pending()).toHaveLength(0);
46
+ expect(internals.pushRetryTimer).toBeNull();
47
+ expect(internals.retryTimers.size).toBe(0);
48
+ });
49
+
50
+ test("writes added during a request drain after it completes", async () => {
51
+ const { engine, server } = await start();
52
+ const fetch = globalThis.fetch;
53
+ let release!: () => void;
54
+ let entered!: () => void;
55
+ const started = new Promise<void>((resolve) => { entered = resolve; });
56
+ const gate = new Promise<void>((resolve) => { release = resolve; });
57
+ let first = true;
58
+ globalThis.fetch = (async (input, init) => {
59
+ if (String(input).endsWith("/api/sync/push") && first) {
60
+ first = false;
61
+ entered();
62
+ await gate;
63
+ }
64
+ return fetch(input, init);
65
+ }) as typeof fetch;
66
+ const a = engine.insert("Note", { title: "first" });
67
+ await started;
68
+ const b = engine.insert("Note", { title: "second" });
69
+ release();
70
+ await Promise.all([a, b]);
71
+ expect(server.pushRequestCount).toBe(2);
72
+ expect(engine.mutations.pending()).toHaveLength(0);
73
+ expect(server.receivedPushKeys).toHaveLength(2);
74
+ });
75
+
76
+ test("follower writes forward only new entries and explicit replay forwards all", async () => {
77
+ const { engine } = await start();
78
+ const internals = engine as unknown as {
79
+ isMultiTabLeader: boolean;
80
+ broadcastToTabs(payload: { type: string; ops?: PendingMutation[] }): void;
81
+ };
82
+ internals.isMultiTabLeader = false;
83
+ const batches: number[] = [];
84
+ internals.broadcastToTabs = (payload) => {
85
+ if (payload.type !== "mutations") return;
86
+ batches.push(payload.ops!.length);
87
+ for (const op of payload.ops!) engine.mutations.settleQueued(op.id);
88
+ };
89
+ for (let i = 0; i < 100; i++) await engine.insert("Note", { title: String(i) });
90
+ expect(batches).toEqual(Array(100).fill(1));
91
+ expect(engine.mutations.pending()).toHaveLength(100);
92
+ await engine.push();
93
+ expect(batches.at(-1)).toBe(100);
94
+ });
95
+
96
+ test("stop cancels the pending push retry", async () => {
97
+ const { engine, server } = await start();
98
+ server.primeNextPushOutcome({ kind: "network" });
99
+ await engine.insert("Note", { title: "offline" });
100
+ engine.stop();
101
+ const internals = engine as unknown as { retryTimers: Set<unknown>; pushRetryTimer: unknown };
102
+ expect(internals.pushRetryTimer).toBeNull();
103
+ expect(internals.retryTimers.size).toBe(0);
104
+ });
105
+
106
+ test("explicit replay during a request includes writes added directly to the queue", async () => {
107
+ const { engine, server } = await start();
108
+ const fetch = globalThis.fetch;
109
+ let release!: () => void;
110
+ let entered!: () => void;
111
+ const started = new Promise<void>((resolve) => { entered = resolve; });
112
+ const gate = new Promise<void>((resolve) => { release = resolve; });
113
+ let first = true;
114
+ globalThis.fetch = (async (input, init) => {
115
+ if (String(input).endsWith("/api/sync/push") && first) {
116
+ first = false;
117
+ entered();
118
+ await gate;
119
+ }
120
+ return fetch(input, init);
121
+ }) as typeof fetch;
122
+ const a = engine.insert("Note", { title: "first" });
123
+ await started;
124
+ engine.mutations.add({ entity: "Note", row_id: "direct", kind: "insert", data: { id: "direct" } }, undefined, "u1");
125
+ const replay = engine.push();
126
+ release();
127
+ await Promise.all([a, replay]);
128
+ expect(server.pushRequestCount).toBe(2);
129
+ expect(server.receivedPushKeys).toContain("Note/direct");
130
+ expect(engine.mutations.pending()).toHaveLength(0);
131
+ });
132
+
133
+ test("writes added during a failed request wait for the same retry", async () => {
134
+ const { engine, server } = await start();
135
+ const fetch = globalThis.fetch;
136
+ let release!: () => void;
137
+ let entered!: () => void;
138
+ const started = new Promise<void>((resolve) => { entered = resolve; });
139
+ const gate = new Promise<void>((resolve) => { release = resolve; });
140
+ let first = true;
141
+ globalThis.fetch = (async (input, init) => {
142
+ if (String(input).endsWith("/api/sync/push") && first) {
143
+ first = false;
144
+ entered();
145
+ await gate;
146
+ }
147
+ return fetch(input, init);
148
+ }) as typeof fetch;
149
+ server.primeNextPushOutcome({ kind: "network" });
150
+ const a = engine.insert("Note", { title: "first" });
151
+ await started;
152
+ const b = engine.insert("Note", { title: "second" });
153
+ release();
154
+ await Promise.all([a, b]);
155
+ expect(server.pushRequestCount).toBe(1);
156
+ expect(engine.mutations.pending()).toHaveLength(2);
157
+ await engine.push();
158
+ expect(server.pushRequestCount).toBe(2);
159
+ expect(engine.mutations.pending()).toHaveLength(0);
160
+ });
161
+
162
+ test("identity reset clears the previous queue's retry delay", async () => {
163
+ const { engine, server } = await start();
164
+ server.primeNextPushOutcome({ kind: "network" });
165
+ await engine.insert("Note", { title: "old" });
166
+ await engine.resetReplica({ wipeMutations: true });
167
+ await engine.insert("Note", { title: "new" });
168
+ expect(server.pushRequestCount).toBe(2);
169
+ expect(engine.mutations.pending()).toHaveLength(0);
170
+ expect(engine.store.list("Note")).toHaveLength(1);
171
+ });
@@ -248,6 +248,25 @@ describe("SyncEngine.reconcile", () => {
248
248
  expect(engine.store.list("DeletedEntity").length).toBe(0);
249
249
  });
250
250
 
251
+ test("a stale 404 cannot remove rows loaded after reset", async () => {
252
+ let respond!: (value: { status: number; body: unknown }) => void;
253
+ let started!: () => void;
254
+ const ready = new Promise<void>((resolve) => { started = resolve; });
255
+ restore = installFetch(async () => {
256
+ started();
257
+ return new Promise((resolve) => { respond = resolve; });
258
+ });
259
+ const engine = makeEngine();
260
+ seedStore(engine, "Doc", [{ id: "same", title: "old" }]);
261
+ const pending = engine.reconcile(["Doc"]);
262
+ await ready;
263
+ await (engine as unknown as { resetReplicaInner(): Promise<void> }).resetReplicaInner();
264
+ seedStore(engine, "Doc", [{ id: "same", title: "new" }]);
265
+ respond({ status: 404, body: {} });
266
+ await pending;
267
+ expect(engine.store.get("Doc", "same")?.title).toBe("new");
268
+ });
269
+
251
270
  test("transient 403 keeps rows; two consecutive 403s drop them (#343)", async () => {
252
271
  // A single 403 can be a bearer caught mid-refresh or a momentary policy
253
272
  // blip; nuking the cache on the first one made rows flash away and return
@@ -184,10 +184,7 @@ describe("codex round-6: mutations-acked broadcast filters by status", () => {
184
184
 
185
185
  // The mutation stays in the queue, status=failed, with the error
186
186
  // string preserved.
187
- const all = (engine.mutations as unknown as {
188
- queue: { id: string; status: string; error?: string }[];
189
- }).queue;
190
- const found = all.find((m) => m.id === "op-x");
187
+ const found = env.engine.mutations.get("op-x");
191
188
  expect(found).toBeDefined();
192
189
  expect(found?.status).toBe("failed");
193
190
  expect(found?.error).toBe("server rejected");
@@ -741,9 +741,7 @@ describe("sync scenarios", () => {
741
741
  // makes the standard reconnect push re-ship it. (insert() returns the
742
742
  // ROW id; match the mutation by row_id, not its op_id.)
743
743
  expect(env.engine.store.get("Note", id)).not.toBeNull();
744
- const mine = (env.engine.mutations as unknown as {
745
- queue: { change: { row_id: string }; status: string }[];
746
- }).queue.filter((m) => m.change.row_id === id);
744
+ const mine = env.engine.mutations.pending().filter((m) => m.change.row_id === id);
747
745
  expect(mine).toHaveLength(1);
748
746
  expect(mine[0]!.status).toBe("pending");
749
747
  });
@@ -811,12 +809,12 @@ describe("sync scenarios", () => {
811
809
  env.server.primeNextPushOutcome({ kind: "network" });
812
810
  await env.engine.insert("Note", { title: "draft" });
813
811
  await env.flush();
814
- const queue = (env.engine.mutations as unknown as { queue: unknown[] }).queue;
815
- expect(queue.length).toBeGreaterThan(0);
812
+ const queue = (env.engine.mutations as unknown as { queue: Map<string, unknown> }).queue;
813
+ expect(queue.size).toBeGreaterThan(0);
816
814
 
817
815
  // Identity flip → wipe.
818
816
  await env.engine.resetReplica({ wipeMutations: true });
819
- expect((env.engine.mutations as unknown as { queue: unknown[] }).queue).toHaveLength(0);
817
+ expect((env.engine.mutations as unknown as { queue: Map<string, unknown> }).queue.size).toBe(0);
820
818
  });
821
819
 
822
820
  // TRANSPORT CYCLE ON IDENTITY FLIP. The WS binds the bearer token at
@@ -869,13 +867,13 @@ describe("sync scenarios", () => {
869
867
  env.server.primeNextPushOutcome({ kind: "network" });
870
868
  await env.engine.insert("Note", { title: "draft" });
871
869
  await env.flush();
872
- const before = (env.engine.mutations as unknown as { queue: unknown[] }).queue.length;
870
+ const before = (env.engine.mutations as unknown as { queue: Map<string, unknown> }).queue.size;
873
871
  expect(before).toBeGreaterThan(0);
874
872
 
875
873
  // 410 RESYNC path is the default (wipeMutations omitted) — KEEP writes.
876
874
  await env.engine.resetReplica();
877
875
  expect(
878
- (env.engine.mutations as unknown as { queue: unknown[] }).queue.length,
876
+ (env.engine.mutations as unknown as { queue: Map<string, unknown> }).queue.size,
879
877
  ).toBe(before);
880
878
  });
881
879
 
@@ -49,6 +49,12 @@ export class ServerSubscriptions {
49
49
  return this.specs.has(key);
50
50
  }
51
51
 
52
+ /** Request fresh state for an existing subscription. */
53
+ refresh(key: string): void {
54
+ const message = this.specs.get(key);
55
+ if (message !== undefined) this.sendWs(message);
56
+ }
57
+
52
58
  /** Re-send every registered subscribe message. Called from
53
59
  * `ws.onopen` after the socket reconnects — the server purges
54
60
  * per-client subscription state on disconnect, so without this
@@ -86,10 +86,13 @@ describe("SubscriptionCoordinator CRDT subs", () => {
86
86
  "follower-a",
87
87
  );
88
88
  expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
89
- // Leader's own subscribe doesn't re-send (already alive via fwd).
89
+ // A first local consumer requests a full bootstrap.
90
90
  const before = h.wsSent.length;
91
91
  h.coord.subscribeCrdt("Todo", "r1");
92
- expect(h.wsSent.length).toBe(before);
92
+ expect(h.wsSent.length).toBe(before + 1);
93
+ h.coord.subscribeCrdt("Todo", "r1");
94
+ expect(h.wsSent.length).toBe(before + 1);
95
+ h.coord.unsubscribeCrdt("Todo", "r1");
93
96
  // Leader unsubscribe with follower still wanting it keeps the WS sub alive.
94
97
  h.coord.unsubscribeCrdt("Todo", "r1");
95
98
  expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
@@ -53,13 +53,6 @@ const OWN_TAB = "__self__";
53
53
  export interface SubscriptionCoordinatorContext {
54
54
  isLeader(): boolean;
55
55
  broadcastToTabs(payload: unknown): void;
56
- /** Leader-side replay of the cached CRDT snapshot for a row (see
57
- * SyncEngine.lastCrdtFrames). Called when a follower registers
58
- * interest in a row whose WS subscription is ALREADY alive — the
59
- * server only ships its catch-up snapshot on a fresh
60
- * `crdt-subscribe`, so without the replay the new tab's doc stays
61
- * empty until the next live edit. */
62
- replayCrdtFrame?(entity: string, rowId: string): void;
63
56
  }
64
57
 
65
58
  interface ReactiveSpec {
@@ -124,8 +117,8 @@ export class SubscriptionCoordinator {
124
117
  this.crdtSubscribers.set(key, prev + 1);
125
118
  if (prev !== 0) return;
126
119
  if (this.ctx.isLeader()) {
127
- // Leader: only send the WS subscribe if no follower had already
128
- // forwarded one (in which case the WS sub is already alive).
120
+ // A first local consumer also needs a full document if only
121
+ // followers held the existing subscription.
129
122
  const hasFwd = (this.crdtForwarders.get(key)?.size ?? 0) > 0;
130
123
  if (!hasFwd) {
131
124
  this.serverSubs.register(key, {
@@ -133,6 +126,8 @@ export class SubscriptionCoordinator {
133
126
  entity,
134
127
  rowId,
135
128
  });
129
+ } else {
130
+ this.serverSubs.refresh(key);
136
131
  }
137
132
  } else {
138
133
  // Follower: ask the leader to subscribe on our behalf. The leader
@@ -279,6 +274,7 @@ export class SubscriptionCoordinator {
279
274
  fwd = new Set();
280
275
  this.crdtForwarders.set(key, fwd);
281
276
  }
277
+ if (fwd.has(fromTabId)) return;
282
278
  fwd.add(fromTabId);
283
279
  // Register if nothing else owned this key — local count and
284
280
  // forwarder set together gate the WS subscribe.
@@ -290,11 +286,9 @@ export class SubscriptionCoordinator {
290
286
  rowId,
291
287
  });
292
288
  } else {
293
- // Subscription already alive → the server won't send a fresh
294
- // catch-up snapshot. Replay the leader's cached one so the
295
- // follower converges immediately instead of staying empty
296
- // until the next live edit.
297
- this.ctx.replayCrdtFrame?.(entity, rowId);
289
+ // Incremental frames cannot bootstrap a new document. Ask the
290
+ // server for a full snapshot and a fresh authorization check.
291
+ this.serverSubs.refresh(key);
298
292
  }
299
293
  return;
300
294
  }
@@ -474,6 +468,10 @@ export class SubscriptionCoordinator {
474
468
  hasCrdtForwarders(): boolean {
475
469
  return this.crdtForwarders.size > 0;
476
470
  }
471
+
472
+ hasCrdtForwardersFor(entity: string, rowId: string): boolean {
473
+ return (this.crdtForwarders.get(crdtKey(entity, rowId))?.size ?? 0) > 0;
474
+ }
477
475
  }
478
476
 
479
477
  /** Internal CRDT key format: `${entity}\x00${rowId}`. Centralized so