@pylonsync/sync 0.22.25 → 0.22.27
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 +12 -36
- package/dist/local-store.d.ts +18 -3
- package/dist/mutation-queue.d.ts +6 -2
- package/dist/persistence.d.ts +3 -4
- package/dist/server-subscriptions.d.ts +2 -0
- package/dist/subscription-coordinator.d.ts +1 -7
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +50 -51
- package/src/client-performance.test.ts +366 -0
- package/src/crdt-follower-catchup.test.ts +56 -25
- package/src/index.ts +146 -132
- package/src/local-store.ts +112 -47
- package/src/mutation-owner-token.test.ts +6 -1
- package/src/mutation-owner.test.ts +6 -1
- package/src/mutation-queue.ts +58 -35
- package/src/persistence.ts +16 -4
- package/src/push-scheduling.test.ts +171 -0
- package/src/reconcile.test.ts +19 -0
- package/src/round6-codex.test.ts +1 -4
- package/src/scenarios.test.ts +6 -8
- package/src/server-subscriptions.ts +6 -0
- package/src/subscription-coordinator.test.ts +5 -2
- package/src/subscription-coordinator.ts +12 -14
|
@@ -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
|
+
});
|
package/src/reconcile.test.ts
CHANGED
|
@@ -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
|
package/src/round6-codex.test.ts
CHANGED
|
@@ -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
|
|
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");
|
package/src/scenarios.test.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
815
|
-
expect(queue.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
128
|
-
//
|
|
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
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
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
|