@pylonsync/sync 0.3.201 → 0.3.203

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,196 @@
1
+ // Multi-tab broker unit tests. These exercise election + promotion
2
+ // across multiple in-process MultiTabBroker instances sharing a real
3
+ // BroadcastChannel (Bun provides one in its test runtime). No SyncEngine
4
+ // here — the engine-level integration is covered by separate scenario
5
+ // tests that pair this broker with a TestServer.
6
+
7
+ import { afterEach, describe, expect, test } from "bun:test";
8
+
9
+ import { MultiTabBroker } from "./multi-tab";
10
+
11
+ const channel = (suffix: string) => `pylon-test:${suffix}:${Math.random()}`;
12
+
13
+ interface Tab {
14
+ broker: MultiTabBroker;
15
+ events: { type: string; data: unknown }[];
16
+ }
17
+
18
+ function makeTab(channelName: string): Promise<Tab> {
19
+ return new Promise((resolve) => {
20
+ const events: Tab["events"][number][] = [];
21
+ const broker = new MultiTabBroker();
22
+ let promoted = false;
23
+ let demoted = false;
24
+ broker.start(channelName, {
25
+ onPromote: () => {
26
+ events.push({ type: "promote", data: broker.self.tabId });
27
+ promoted = true;
28
+ },
29
+ onDemote: () => {
30
+ events.push({ type: "demote", data: broker.self.tabId });
31
+ demoted = true;
32
+ },
33
+ onAppMessage: (payload, from) => {
34
+ events.push({ type: "app", data: { payload, from } });
35
+ },
36
+ onLeave: (tabId) => {
37
+ events.push({ type: "leave", data: tabId });
38
+ },
39
+ });
40
+ // Give the election a beat to settle.
41
+ setTimeout(() => resolve({ broker, events }), 350);
42
+ // Silence unused-var lint.
43
+ void promoted;
44
+ void demoted;
45
+ });
46
+ }
47
+
48
+ describe("MultiTabBroker", () => {
49
+ const tabs: Tab[] = [];
50
+ afterEach(() => {
51
+ for (const t of tabs) t.broker.stop();
52
+ tabs.length = 0;
53
+ });
54
+
55
+ test("a single tab promotes itself", async () => {
56
+ const ch = channel("single");
57
+ const a = await makeTab(ch);
58
+ tabs.push(a);
59
+ expect(a.broker.isLeader()).toBe(true);
60
+ expect(a.events.some((e) => e.type === "promote")).toBe(true);
61
+ });
62
+
63
+ test("two tabs converge on one leader (earlier startTime wins)", async () => {
64
+ const ch = channel("two-tabs");
65
+ const a = await makeTab(ch);
66
+ tabs.push(a);
67
+ // Wait a beat so b's startTime is strictly greater.
68
+ await new Promise((r) => setTimeout(r, 20));
69
+ const b = await makeTab(ch);
70
+ tabs.push(b);
71
+
72
+ // Give the second tab a moment to see the first's hello/here.
73
+ await new Promise((r) => setTimeout(r, 200));
74
+
75
+ expect(a.broker.isLeader()).toBe(true);
76
+ expect(b.broker.isLeader()).toBe(false);
77
+ });
78
+
79
+ test("leader broadcasts reach the other tab as app events", async () => {
80
+ const ch = channel("broadcast");
81
+ const a = await makeTab(ch);
82
+ tabs.push(a);
83
+ await new Promise((r) => setTimeout(r, 20));
84
+ const b = await makeTab(ch);
85
+ tabs.push(b);
86
+ await new Promise((r) => setTimeout(r, 100));
87
+
88
+ a.broker.broadcastApp({ type: "applied", n: 1 });
89
+ a.broker.broadcastApp({ type: "applied", n: 2 });
90
+
91
+ await new Promise((r) => setTimeout(r, 80));
92
+
93
+ const appEvents = b.events.filter((e) => e.type === "app");
94
+ expect(appEvents.length).toBe(2);
95
+ expect((appEvents[0].data as { payload: { n: number } }).payload.n).toBe(1);
96
+ expect((appEvents[1].data as { payload: { n: number } }).payload.n).toBe(2);
97
+ });
98
+
99
+ test("a tab does not receive its own broadcasts", async () => {
100
+ const ch = channel("no-echo");
101
+ const a = await makeTab(ch);
102
+ tabs.push(a);
103
+ a.events.length = 0;
104
+
105
+ a.broker.broadcastApp({ type: "applied", n: 1 });
106
+ await new Promise((r) => setTimeout(r, 50));
107
+
108
+ const appEvents = a.events.filter((e) => e.type === "app");
109
+ expect(appEvents.length).toBe(0);
110
+ });
111
+
112
+ test("here.leaderTabId is rejected when sender isn't the computed winner", async () => {
113
+ // A tab with a (legitimately) larger startTime broadcasts `here`
114
+ // claiming itself as leader. A late joiner with a smaller-start
115
+ // peer in its roster must NOT honor the bogus claim.
116
+ const ch = channel("here-validation");
117
+ const a = await makeTab(ch);
118
+ tabs.push(a);
119
+ await new Promise((r) => setTimeout(r, 20));
120
+ const b = await makeTab(ch);
121
+ tabs.push(b);
122
+ await new Promise((r) => setTimeout(r, 200));
123
+
124
+ // A is the legitimate leader (earlier startTime). Have B forge
125
+ // a `here` claiming itself as leader. The internal channel send
126
+ // is private; we reach into the broker.
127
+ const bInternal = b.broker as unknown as {
128
+ send(msg: unknown): void;
129
+ self: { tabId: string; startTime: number };
130
+ };
131
+ bInternal.send({
132
+ type: "here",
133
+ tab: bInternal.self,
134
+ leaderTabId: bInternal.self.tabId,
135
+ roster: [bInternal.self],
136
+ });
137
+
138
+ await new Promise((r) => setTimeout(r, 80));
139
+
140
+ // A remains leader because its lead validation rejects B's claim
141
+ // (B is not the smallest startTime in A's roster).
142
+ expect(a.broker.isLeader()).toBe(true);
143
+ });
144
+
145
+ test("a follower's bye fires onLeave on every other tab", async () => {
146
+ // Regression for codex round-6 P2: when a follower closes, the
147
+ // leader (and any other tab) needs to know which tabId left so it
148
+ // can scrub forwarded-sub state for that peer. Pre-fix the broker
149
+ // dropped the bye-sender from its roster silently and the engine
150
+ // kept fanning WS traffic at a dead tab forever.
151
+ const ch = channel("bye-fires-onleave");
152
+ const a = await makeTab(ch);
153
+ tabs.push(a);
154
+ await new Promise((r) => setTimeout(r, 20));
155
+ const b = await makeTab(ch);
156
+ tabs.push(b);
157
+ await new Promise((r) => setTimeout(r, 200));
158
+
159
+ const bId = b.broker.self.tabId;
160
+
161
+ // b stops gracefully — broadcasts `bye`.
162
+ b.broker.stop();
163
+ // Remove b from tabs so afterEach doesn't double-stop it.
164
+ tabs.splice(tabs.indexOf(b), 1);
165
+ await new Promise((r) => setTimeout(r, 80));
166
+
167
+ const leaveEvents = a.events.filter((e) => e.type === "leave");
168
+ expect(leaveEvents.length).toBe(1);
169
+ expect(leaveEvents[0].data).toBe(bId);
170
+ });
171
+
172
+ test("when the leader stops, a follower is promoted", async () => {
173
+ const ch = channel("promote-on-stop");
174
+ const a = await makeTab(ch);
175
+ tabs.push(a);
176
+ await new Promise((r) => setTimeout(r, 20));
177
+ const b = await makeTab(ch);
178
+ tabs.push(b);
179
+
180
+ await new Promise((r) => setTimeout(r, 200));
181
+ expect(a.broker.isLeader()).toBe(true);
182
+ expect(b.broker.isLeader()).toBe(false);
183
+
184
+ // Leader exits. The `bye` message lets the follower re-elect
185
+ // without waiting for the heartbeat-timeout.
186
+ a.broker.stop();
187
+
188
+ // Re-election needs an election-settle window after the `bye`.
189
+ await new Promise((r) => setTimeout(r, 400));
190
+
191
+ expect(b.broker.isLeader()).toBe(true);
192
+ expect(
193
+ b.events.filter((e) => e.type === "promote").length,
194
+ ).toBeGreaterThanOrEqual(1);
195
+ });
196
+ });
@@ -0,0 +1,366 @@
1
+ // MultiTabBroker — leader-elect coordination between SyncEngine
2
+ // instances in different browser tabs of the same origin.
3
+ //
4
+ // Without this, every tab opens its own WS, runs its own pull,
5
+ // pushes its own copy of each hydrated offline mutation, and writes
6
+ // duplicates to IndexedDB. The server dedupes mutations by op_id and
7
+ // fanout fires WS events to every connected tab — so it isn't
8
+ // strictly broken, just wasteful (N tabs = N sockets, N pulls). And
9
+ // optimistic mutations made in tab A aren't visible in tab B until
10
+ // the server's WS broadcast lands, which is a real coherence gap.
11
+ //
12
+ // Design: one tab is the leader. The leader owns the WS connection,
13
+ // drives pull/push/reconcile, and broadcasts every applied change
14
+ // over a BroadcastChannel. Followers mirror the changes locally,
15
+ // forward their mutations to the leader for pushing, and forward
16
+ // their subscribe/unsubscribe to the leader for WS registration.
17
+ //
18
+ // Election protocol:
19
+ // - Each tab generates a (startTime, tabId) on construction.
20
+ // - On `start`, every tab broadcasts a `hello`. Tabs respond with
21
+ // `here` listing their own (startTime, tabId).
22
+ // - After a 250ms settle window, each tab knows every other tab's
23
+ // identity. The smallest (startTime, tabId) is the leader.
24
+ // - The leader broadcasts `lead` every LEADER_HEARTBEAT_MS so
25
+ // followers know it's still alive.
26
+ // - If a follower doesn't see a `lead` for LEADER_TIMEOUT_MS, it
27
+ // declares the leader dead, drops it from the roster, and re-elects.
28
+ // - Election is purely deterministic (smallest tuple) — no voting,
29
+ // no split-brain. Two tabs can't both think they're leader unless
30
+ // one is on a clock that drifted by minutes, in which case op_id
31
+ // dedupe at the server still catches the dup writes.
32
+ //
33
+ // All messages flow through a single BroadcastChannel scoped by
34
+ // appName so two apps on the same origin don't cross-talk. The
35
+ // broker is silent (no-op leader = self) when BroadcastChannel is
36
+ // unavailable (Node, jsdom without polyfill, very old Safari).
37
+
38
+ import { generateId } from "./ids";
39
+
40
+ /** ms between heartbeats from the leader. */
41
+ const LEADER_HEARTBEAT_MS = 1_000;
42
+ /** ms a follower waits without a heartbeat before re-electing. */
43
+ const LEADER_TIMEOUT_MS = 3_000;
44
+ /** ms a tab waits after `hello` to collect responses before deciding. */
45
+ const ELECTION_SETTLE_MS = 250;
46
+
47
+ interface TabIdentity {
48
+ tabId: string;
49
+ startTime: number;
50
+ }
51
+
52
+ type BrokerMessage =
53
+ | { type: "hello"; tab: TabIdentity }
54
+ | {
55
+ type: "here";
56
+ tab: TabIdentity;
57
+ leaderTabId: string | null;
58
+ /** Full known roster, including self. Late joiners pick up
59
+ * every tab they need to consider in the election from a
60
+ * single `here` rather than waiting for N separate replies. */
61
+ roster: TabIdentity[];
62
+ }
63
+ | { type: "lead"; tab: TabIdentity }
64
+ | { type: "bye"; tabId: string }
65
+ | { type: "app"; tab: TabIdentity; payload: unknown };
66
+
67
+ export interface MultiTabHandlers {
68
+ /** Fired when this tab becomes the leader (initial election or
69
+ * promotion after the previous leader dropped). Idempotent —
70
+ * called once per promotion. */
71
+ onPromote: () => void;
72
+ /** Fired when this tab transitions from leader to follower. Only
73
+ * relevant if leader migration is supported (currently no-op
74
+ * because the leader stays leader until it dies). */
75
+ onDemote: () => void;
76
+ /** Fired for every application-level message from another tab. */
77
+ onAppMessage: (payload: unknown, from: TabIdentity) => void;
78
+ /** Fired when another tab leaves the coordination group gracefully
79
+ * (its `bye` was observed). The leader uses this to scrub forwarded
80
+ * subscription state for the departed tab so it stops fanning WS
81
+ * traffic at a dead peer. Does NOT fire for crashed tabs — those
82
+ * have no `bye` and currently leak in the roster until either a
83
+ * new election runs or this tab itself goes away. */
84
+ onLeave?: (tabId: string) => void;
85
+ }
86
+
87
+ export class MultiTabBroker {
88
+ readonly self: TabIdentity = {
89
+ tabId: generateId(),
90
+ startTime: Date.now(),
91
+ };
92
+ private channel: BroadcastChannel | null = null;
93
+ /** Roster of currently-known tabs (including self). Maintained via
94
+ * `hello` / `here` / `bye`. The smallest entry by
95
+ * (startTime, tabId) is the leader. */
96
+ private roster: Map<string, TabIdentity> = new Map();
97
+ private leaderTabId: string | null = null;
98
+ private lastLeaderHeartbeat = 0;
99
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
100
+ private monitorTimer: ReturnType<typeof setInterval> | null = null;
101
+ private electionTimer: ReturnType<typeof setTimeout> | null = null;
102
+ private handlers: MultiTabHandlers | null = null;
103
+ private started = false;
104
+ private stopped = false;
105
+
106
+ /** Whether the broker can run on this platform — `BroadcastChannel`
107
+ * is missing in Node/jsdom and old Safari. When false the engine
108
+ * treats this tab as the implicit sole leader. */
109
+ static available(): boolean {
110
+ return typeof BroadcastChannel !== "undefined";
111
+ }
112
+
113
+ /** Begin participating in multi-tab coordination. The handlers
114
+ * fire as elections settle. Idempotent — calling twice is a no-op
115
+ * so the engine's `start()` can call freely. */
116
+ start(channelName: string, handlers: MultiTabHandlers): void {
117
+ if (this.started || this.stopped) return;
118
+ this.started = true;
119
+ this.handlers = handlers;
120
+ if (!MultiTabBroker.available()) {
121
+ // No BroadcastChannel — declare self leader and stop. The engine
122
+ // runs normally; this just makes the no-multi-tab case identical
123
+ // to "I'm the only tab".
124
+ this.leaderTabId = this.self.tabId;
125
+ this.roster.set(this.self.tabId, this.self);
126
+ handlers.onPromote();
127
+ return;
128
+ }
129
+ this.channel = new BroadcastChannel(channelName);
130
+ this.channel.onmessage = (ev: MessageEvent) =>
131
+ this.handle(ev.data as BrokerMessage);
132
+ this.roster.set(this.self.tabId, this.self);
133
+ // Announce; existing tabs reply with `here`, fresh tabs see this.
134
+ this.send({ type: "hello", tab: this.self });
135
+ // After the settle window, run a deterministic election against
136
+ // whatever roster we collected.
137
+ this.electionTimer = setTimeout(() => {
138
+ this.electionTimer = null;
139
+ this.electLeader();
140
+ }, ELECTION_SETTLE_MS);
141
+ // Monitor: catch a dead leader.
142
+ this.monitorTimer = setInterval(
143
+ () => this.checkLeaderLiveness(),
144
+ LEADER_HEARTBEAT_MS,
145
+ );
146
+ // beforeunload: tell other tabs we're going so they can re-elect
147
+ // immediately instead of waiting for the timeout.
148
+ if (typeof window !== "undefined") {
149
+ window.addEventListener("beforeunload", this.handleBeforeUnload);
150
+ }
151
+ }
152
+
153
+ /** Tear down. Sends a `bye` so other tabs can re-elect right away. */
154
+ stop(): void {
155
+ if (this.stopped) return;
156
+ this.stopped = true;
157
+ this.started = false;
158
+ if (this.channel) {
159
+ try {
160
+ this.send({ type: "bye", tabId: this.self.tabId });
161
+ } catch {
162
+ /* channel may already be closed */
163
+ }
164
+ this.channel.close();
165
+ this.channel = null;
166
+ }
167
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
168
+ if (this.monitorTimer) clearInterval(this.monitorTimer);
169
+ if (this.electionTimer) clearTimeout(this.electionTimer);
170
+ this.heartbeatTimer = null;
171
+ this.monitorTimer = null;
172
+ this.electionTimer = null;
173
+ if (typeof window !== "undefined") {
174
+ window.removeEventListener("beforeunload", this.handleBeforeUnload);
175
+ }
176
+ }
177
+
178
+ /** True if this tab is currently the leader. Follower paths use
179
+ * this to decide whether to run their own network ops. */
180
+ isLeader(): boolean {
181
+ return this.leaderTabId === this.self.tabId;
182
+ }
183
+
184
+ /** Send an application-level payload to every other tab. The
185
+ * engine uses this for applied-change / mutation / session
186
+ * broadcasts. */
187
+ broadcastApp(payload: unknown): void {
188
+ this.send({ type: "app", tab: this.self, payload });
189
+ }
190
+
191
+ // ---- internals ----------------------------------------------------------
192
+
193
+ private handleBeforeUnload = (): void => {
194
+ if (this.channel) {
195
+ try {
196
+ this.send({ type: "bye", tabId: this.self.tabId });
197
+ } catch {
198
+ /* best-effort */
199
+ }
200
+ }
201
+ };
202
+
203
+ private send(msg: BrokerMessage): void {
204
+ this.channel?.postMessage(msg);
205
+ }
206
+
207
+ private handle(msg: BrokerMessage): void {
208
+ if (!this.handlers) return;
209
+ switch (msg.type) {
210
+ case "hello": {
211
+ // Someone new showed up. Add to roster and reply with `here`
212
+ // carrying the full roster + current leader so the joiner can
213
+ // converge from a single message rather than collecting N
214
+ // separate replies.
215
+ this.roster.set(msg.tab.tabId, msg.tab);
216
+ this.send({
217
+ type: "here",
218
+ tab: this.self,
219
+ leaderTabId: this.leaderTabId,
220
+ roster: Array.from(this.roster.values()),
221
+ });
222
+ // The new tab may have a smaller (startTime, tabId) — re-elect
223
+ // after a settle window so we converge on the same winner.
224
+ this.scheduleElection();
225
+ break;
226
+ }
227
+ case "here": {
228
+ // Absorb the full roster so we elect against the same set as
229
+ // the responder, not just self + responder.
230
+ for (const t of msg.roster) {
231
+ if (!this.roster.has(t.tabId)) this.roster.set(t.tabId, t);
232
+ }
233
+ this.roster.set(msg.tab.tabId, msg.tab);
234
+ // Don't accept `here.leaderTabId` blindly — a clock-skewed
235
+ // tab can claim leadership via its own `here` reply, and the
236
+ // late joiner would treat it as canonical. Validate against
237
+ // our (now larger) roster: only honor the claim if the
238
+ // sender is the current computed winner.
239
+ if (msg.leaderTabId) {
240
+ const winner = this.computeWinner();
241
+ if (winner && winner.tabId === msg.leaderTabId) {
242
+ this.leaderTabId = msg.leaderTabId;
243
+ }
244
+ }
245
+ break;
246
+ }
247
+ case "lead": {
248
+ this.roster.set(msg.tab.tabId, msg.tab);
249
+ // Don't trust a `lead` claim blindly. If the sender isn't the
250
+ // minimum (startTime, tabId) in OUR known roster, ignore it —
251
+ // a tab whose clock jumped backwards (macOS sleep/wake on a
252
+ // VM) could otherwise convince its peers it's leader and
253
+ // produce a ping-pong demote/promote loop. The next election
254
+ // will reconcile. Crucially, do NOT update lastLeaderHeartbeat
255
+ // for an ignored claim: otherwise the misbehaving tab would
256
+ // suppress our liveness check and stay "leader" indefinitely.
257
+ const winner = this.computeWinner();
258
+ if (winner && winner.tabId !== msg.tab.tabId) {
259
+ this.scheduleElection();
260
+ break;
261
+ }
262
+ this.lastLeaderHeartbeat = Date.now();
263
+ const wasLeader = this.isLeader();
264
+ this.leaderTabId = msg.tab.tabId;
265
+ if (wasLeader && !this.isLeader()) {
266
+ this.demote();
267
+ }
268
+ break;
269
+ }
270
+ case "bye": {
271
+ // Only fire onLeave if the tab was actually in the roster.
272
+ // A `bye` for a tab we never heard of (race on hello/bye) is
273
+ // a no-op and shouldn't trigger spurious cleanup callbacks.
274
+ const wasKnown = this.roster.delete(msg.tabId);
275
+ if (wasKnown && msg.tabId !== this.self.tabId) {
276
+ this.handlers.onLeave?.(msg.tabId);
277
+ }
278
+ if (this.leaderTabId === msg.tabId) {
279
+ this.leaderTabId = null;
280
+ this.scheduleElection();
281
+ }
282
+ break;
283
+ }
284
+ case "app": {
285
+ // Don't echo our own broadcasts back to ourselves.
286
+ if (msg.tab.tabId === this.self.tabId) return;
287
+ this.handlers.onAppMessage(msg.payload, msg.tab);
288
+ break;
289
+ }
290
+ }
291
+ }
292
+
293
+ private scheduleElection(): void {
294
+ if (this.electionTimer) return;
295
+ this.electionTimer = setTimeout(() => {
296
+ this.electionTimer = null;
297
+ this.electLeader();
298
+ }, ELECTION_SETTLE_MS);
299
+ }
300
+
301
+ private electLeader(): void {
302
+ const winner = this.computeWinner() ?? this.self;
303
+ const wasLeader = this.isLeader();
304
+ this.leaderTabId = winner.tabId;
305
+ if (this.isLeader() && !wasLeader) {
306
+ this.promote();
307
+ } else if (!this.isLeader() && wasLeader) {
308
+ this.demote();
309
+ }
310
+ }
311
+
312
+ /** Smallest (startTime, tabId) wins. Deterministic across every
313
+ * tab that has the same roster, so peers converge without voting.
314
+ * Used both for the initial election and for sanity-checking
315
+ * `lead` claims against our local view. */
316
+ private computeWinner(): TabIdentity | null {
317
+ let winner: TabIdentity | null = null;
318
+ for (const t of this.roster.values()) {
319
+ if (!winner) {
320
+ winner = t;
321
+ continue;
322
+ }
323
+ if (
324
+ t.startTime < winner.startTime ||
325
+ (t.startTime === winner.startTime && t.tabId < winner.tabId)
326
+ ) {
327
+ winner = t;
328
+ }
329
+ }
330
+ return winner;
331
+ }
332
+
333
+ private promote(): void {
334
+ if (this.heartbeatTimer) return; // already promoted
335
+ this.lastLeaderHeartbeat = Date.now();
336
+ this.heartbeatTimer = setInterval(() => {
337
+ this.send({ type: "lead", tab: this.self });
338
+ }, LEADER_HEARTBEAT_MS);
339
+ // Send one immediately so followers don't think we died during
340
+ // the first heartbeat interval.
341
+ this.send({ type: "lead", tab: this.self });
342
+ this.handlers?.onPromote();
343
+ }
344
+
345
+ private demote(): void {
346
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
347
+ this.heartbeatTimer = null;
348
+ this.handlers?.onDemote();
349
+ }
350
+
351
+ private checkLeaderLiveness(): void {
352
+ if (this.isLeader()) return;
353
+ if (!this.leaderTabId) {
354
+ // No known leader — trigger an election.
355
+ this.scheduleElection();
356
+ return;
357
+ }
358
+ const elapsed = Date.now() - this.lastLeaderHeartbeat;
359
+ if (elapsed > LEADER_TIMEOUT_MS) {
360
+ // Leader hasn't checked in. Drop from roster and re-elect.
361
+ this.roster.delete(this.leaderTabId);
362
+ this.leaderTabId = null;
363
+ this.scheduleElection();
364
+ }
365
+ }
366
+ }
@@ -73,9 +73,19 @@ export class MutationQueue {
73
73
  }
74
74
 
75
75
  /** Add a pending mutation. Returns the op_id used for server
76
- * idempotency. */
76
+ * idempotency. If the change ALREADY carries an op_id (e.g., it
77
+ * was forwarded from another tab via multi-tab broadcast), reuse
78
+ * that id so the leader and follower agree on the identifier the
79
+ * server will dedupe against. Likewise we skip the add when an
80
+ * entry with the same op_id is already queued — a follower
81
+ * retrying its forward of the same op shouldn't double-queue on
82
+ * the leader. */
77
83
  add(change: ClientChange): string {
78
- const id = `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
84
+ const id =
85
+ typeof change.op_id === "string" && change.op_id.length > 0
86
+ ? change.op_id
87
+ : `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
88
+ if (this.queue.some((m) => m.id === id)) return id;
79
89
  const changeWithOp: ClientChange = { ...change, op_id: id };
80
90
  this.queue.push({ id, change: changeWithOp, status: "pending" });
81
91
  this.flush();
@@ -0,0 +1,91 @@
1
+ // Unit tests for OpQueue. The engine relies on three guarantees:
2
+ // 1. ops keyed by string serialize against ops keyed by ANY other
3
+ // string (single FIFO chain).
4
+ // 2. concurrent enqueues with the SAME key share the running op's
5
+ // promise (no double-fire).
6
+ // 3. once an op settles, its key is free for the next enqueue —
7
+ // including a follow-up triggered from the settled op's `.then()`.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+
11
+ import { OpQueue } from "./op-queue";
12
+
13
+ describe("OpQueue", () => {
14
+ test("serializes ops across different keys", async () => {
15
+ const q = new OpQueue();
16
+ const order: string[] = [];
17
+ const a = q.enqueue("a", async () => {
18
+ order.push("a:start");
19
+ await new Promise((r) => setTimeout(r, 5));
20
+ order.push("a:end");
21
+ });
22
+ const b = q.enqueue("b", async () => {
23
+ order.push("b:start");
24
+ await new Promise((r) => setTimeout(r, 1));
25
+ order.push("b:end");
26
+ });
27
+ await Promise.all([a, b]);
28
+ expect(order).toEqual(["a:start", "a:end", "b:start", "b:end"]);
29
+ });
30
+
31
+ test("coalesces concurrent enqueues under the same key", async () => {
32
+ const q = new OpQueue();
33
+ let calls = 0;
34
+ const fn = async () => {
35
+ calls += 1;
36
+ await new Promise((r) => setTimeout(r, 5));
37
+ return calls;
38
+ };
39
+ const p1 = q.enqueue("x", fn);
40
+ const p2 = q.enqueue("x", fn);
41
+ const p3 = q.enqueue("x", fn);
42
+ const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
43
+ expect(calls).toBe(1);
44
+ expect(r1).toBe(1);
45
+ expect(r2).toBe(1);
46
+ expect(r3).toBe(1);
47
+ });
48
+
49
+ test("a settled key can be enqueued again", async () => {
50
+ const q = new OpQueue();
51
+ let calls = 0;
52
+ const fn = async () => {
53
+ calls += 1;
54
+ return calls;
55
+ };
56
+ await q.enqueue("y", fn);
57
+ await q.enqueue("y", fn);
58
+ expect(calls).toBe(2);
59
+ });
60
+
61
+ test("rejection propagates to all coalesced callers and frees the key", async () => {
62
+ const q = new OpQueue();
63
+ const err = new Error("boom");
64
+ const p1 = q.enqueue("z", async () => {
65
+ throw err;
66
+ });
67
+ const p2 = q.enqueue("z", async () => {
68
+ throw err;
69
+ });
70
+ await expect(p1).rejects.toThrow("boom");
71
+ await expect(p2).rejects.toThrow("boom");
72
+
73
+ // Same key is free again after rejection.
74
+ const ok = await q.enqueue("z", async () => "ok");
75
+ expect(ok).toBe("ok");
76
+ });
77
+
78
+ test("epoch increments per op", async () => {
79
+ const q = new OpQueue();
80
+ expect(q.currentEpoch()).toBe(0);
81
+ await q.enqueue("e1", async () => {});
82
+ expect(q.currentEpoch()).toBe(1);
83
+ await q.enqueue("e2", async () => {});
84
+ expect(q.currentEpoch()).toBe(2);
85
+ // Coalesced enqueues share the same op, so epoch only bumps once.
86
+ const a = q.enqueue("e3", async () => {});
87
+ const b = q.enqueue("e3", async () => {});
88
+ await Promise.all([a, b]);
89
+ expect(q.currentEpoch()).toBe(3);
90
+ });
91
+ });