@pylonsync/sync 0.3.202 → 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,73 @@
1
+ // OpQueue — single serialized channel for the engine's outbound
2
+ // network operations (pull, push, reconcile, refresh, resetReplica).
3
+ //
4
+ // Why this exists: before unification, each op had its own ad-hoc
5
+ // dedupe primitive (inFlightPush, inFlightReconcile, "void refresh()"
6
+ // fire-and-forget). The combinations leaked races — pull mid-refresh,
7
+ // reconcile mid-pull, session-changed-mid-reconcile — that each
8
+ // required their own inline guard. Putting every op on one FIFO
9
+ // reduces the race surface to "did a prior op execute between when I
10
+ // scheduled and when I ran" — which is just a counter check, not a
11
+ // snapshot comparison.
12
+ //
13
+ // Ops are keyed; concurrent enqueues with the same key share the
14
+ // running promise. That preserves the old "callers always get the
15
+ // same promise while X is running" coalescing semantics without
16
+ // needing a separate inFlightX field per op.
17
+ //
18
+ // Apply queue (change-event applies) stays SEPARATE. WS events that
19
+ // arrive while a pull is in flight should land in the local replica
20
+ // without waiting for the pull's HTTP round-trip. That separation is
21
+ // the only reason we don't put `applyChanges` on this queue too.
22
+
23
+ export class OpQueue {
24
+ private chain: Promise<void> = Promise.resolve();
25
+ private pending = new Map<string, Promise<unknown>>();
26
+ /** Monotonic op counter — incremented as each op begins running.
27
+ * Lets a caller stash a snapshot ("epoch when I scheduled") and
28
+ * detect after `await` whether another op ran in between. */
29
+ private epoch = 0;
30
+
31
+ /** Schedule an op behind any currently running/queued ops.
32
+ *
33
+ * Coalescing: if `key` matches a pending op, the existing promise
34
+ * is returned — the new caller does NOT re-enqueue. This preserves
35
+ * the "many concurrent callers share one in-flight op" semantics
36
+ * that pull/push/reconcile had via their respective mutexes. */
37
+ enqueue<T>(key: string, fn: () => Promise<T>): Promise<T> {
38
+ const existing = this.pending.get(key);
39
+ if (existing) return existing as Promise<T>;
40
+
41
+ let resolveOuter!: (v: T) => void;
42
+ let rejectOuter!: (e: unknown) => void;
43
+ const outer = new Promise<T>((res, rej) => {
44
+ resolveOuter = res;
45
+ rejectOuter = rej;
46
+ });
47
+ this.pending.set(key, outer);
48
+
49
+ const prev = this.chain;
50
+ this.chain = prev.then(async () => {
51
+ this.epoch += 1;
52
+ try {
53
+ const v = await fn();
54
+ resolveOuter(v);
55
+ } catch (e) {
56
+ rejectOuter(e);
57
+ } finally {
58
+ // Clear key BEFORE outer settles so a downstream `.then()` on
59
+ // the returned promise can re-enqueue without colliding with
60
+ // our own pending entry.
61
+ this.pending.delete(key);
62
+ }
63
+ });
64
+
65
+ return outer;
66
+ }
67
+
68
+ /** Current epoch — read before scheduling, compare after `await` to
69
+ * detect "another op ran while I was waiting." */
70
+ currentEpoch(): number {
71
+ return this.epoch;
72
+ }
73
+ }
@@ -34,10 +34,16 @@ function makeEngine(): SyncEngine {
34
34
  // run on the Bun runtime which has no `indexedDB` global. Reconcile
35
35
  // itself only touches `this.persistence` defensively, so disabling
36
36
  // the layer is harmless here.
37
+ //
38
+ // `multiTab: false` opts out of the broker entirely so the engine
39
+ // acts as the sole leader from construction. Without this the tests
40
+ // would skip the leader-gated reconcile path because start() (which
41
+ // is what flips the leader bit in normal use) is never called here.
37
42
  return new SyncEngine({
38
43
  baseUrl: "http://stub.invalid",
39
44
  persist: false,
40
45
  reconcileMinIntervalMs: 0,
46
+ multiTab: false,
41
47
  });
42
48
  }
43
49
 
@@ -224,6 +230,7 @@ describe("SyncEngine.reconcile", () => {
224
230
  baseUrl: "http://stub.invalid",
225
231
  persist: false,
226
232
  reconcileMinIntervalMs: 5_000,
233
+ multiTab: false,
227
234
  });
228
235
  seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
229
236
 
@@ -300,10 +307,29 @@ describe("SyncEngine.reconcile session guard", () => {
300
307
  test("skips apply when tenant flips during entity fetch", async () => {
301
308
  let restore: (() => void) | null = null;
302
309
  try {
310
+ const engine = makeEngine();
311
+ // Seed the resolver with a tenant=null session — every call to
312
+ // session.signature() through the rest of this test reflects
313
+ // this value until the fetch handler flips it below.
314
+ engine.session.observeSession({
315
+ userId: "u1",
316
+ tenantId: null,
317
+ isAdmin: false,
318
+ roles: [],
319
+ });
320
+
303
321
  restore = installFetch(async (url) => {
304
322
  if (url.includes("/api/entities/Recording/cursor")) {
305
- // Server filtered under stale tenant returns empty. Without
306
- // the guard, applyEntityReconcile would tombstone r1.
323
+ // Flip the session signature WHILE the fetch is "in flight"
324
+ // — between the engine's `sessionBeforeFetch` capture and
325
+ // the apply pass. Models a WS session-changed envelope
326
+ // landing in the gap.
327
+ engine.session.observeSession({
328
+ userId: "u1",
329
+ tenantId: "org-42",
330
+ isAdmin: false,
331
+ roles: [],
332
+ });
307
333
  return {
308
334
  status: 200,
309
335
  body: { data: [], next_cursor: null, has_more: false },
@@ -312,41 +338,13 @@ describe("SyncEngine.reconcile session guard", () => {
312
338
  return { status: 404, body: {} };
313
339
  });
314
340
 
315
- const engine = makeEngine();
316
341
  seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
317
342
  expect(engine.store.list("Recording").length).toBe(1);
318
343
 
319
- // Simulate the app calling select-org WHILE reconcile is mid-flight.
320
- // The implementation reads `_resolvedSession` directly; we override
321
- // via the engine's internal store mutation surface to match how the
322
- // session-changed handler would update it during a real flip.
323
- const engineWithSession = engine as unknown as {
324
- _resolvedSession: {
325
- userId: string | null;
326
- tenantId: string | null;
327
- isAdmin: boolean;
328
- roles: string[];
329
- };
330
- };
331
- engineWithSession._resolvedSession = {
332
- userId: "u1",
333
- tenantId: null,
334
- isAdmin: false,
335
- roles: [],
336
- };
337
- const reconcilePromise = engine.reconcile(["Recording"]);
338
- // Flip the tenant before the fetch resolves. The microtask queue
339
- // already has the in-flight fetch; this just changes the field
340
- // they'll compare against.
341
- engineWithSession._resolvedSession = {
342
- userId: "u1",
343
- tenantId: "org-42",
344
- isAdmin: false,
345
- roles: [],
346
- };
347
- await reconcilePromise;
344
+ await engine.reconcile(["Recording"]);
348
345
 
349
- // Row must survive — the stale fetch result was discarded.
346
+ // Row must survive — the stale fetch result was discarded by
347
+ // the session-signature guard.
350
348
  expect(engine.store.list("Recording").length).toBe(1);
351
349
  expect(engine.store.get("Recording", "r1")).not.toBeNull();
352
350
  } finally {
@@ -0,0 +1,328 @@
1
+ // Regression tests for codex review round-6 findings on the
2
+ // multi-tab leader/follower integration glue in SyncEngine.
3
+ //
4
+ // Each test pins one finding so a future revert surfaces immediately:
5
+ // 1. `isMultiTabLeader` defaults to false (P1)
6
+ // 2. mutations-acked broadcast filters to actually-applied op_ids,
7
+ // and mutations-failed carries the rest (P1)
8
+ // 3. A peer leaving (`bye` → onLeave) scrubs forwarded subs and
9
+ // unregisters the WS subscription when no owner remains (P2)
10
+
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+
13
+ import { SyncEngine } from "./index";
14
+ import { createTestEnv, type TestEnv } from "./test-harness";
15
+
16
+ describe("codex round-6: isMultiTabLeader default", () => {
17
+ test("a fresh engine WITHOUT multiTab:false is NOT leader", () => {
18
+ // Pre-fix the default was `true`, so a tab that joined an
19
+ // established election would never receive onPromote (it wasn't
20
+ // promoted) or onDemote (it was never leader), and the engine's
21
+ // leader-gated paths (WS, pull, push, poll) all ran on every tab.
22
+ const engine = new SyncEngine({
23
+ baseUrl: "http://stub.invalid",
24
+ persist: false,
25
+ });
26
+ const internal = engine as unknown as { isMultiTabLeader: boolean };
27
+ expect(internal.isMultiTabLeader).toBe(false);
28
+ });
29
+
30
+ test("multiTab:false in config promotes to leader from construction", () => {
31
+ // Sanity for the constructor escape hatch: tests + apps that
32
+ // explicitly disable multi-tab need to act as their own leader
33
+ // immediately, before start().
34
+ const engine = new SyncEngine({
35
+ baseUrl: "http://stub.invalid",
36
+ persist: false,
37
+ multiTab: false,
38
+ });
39
+ const internal = engine as unknown as { isMultiTabLeader: boolean };
40
+ expect(internal.isMultiTabLeader).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("codex round-6: mutations-acked broadcast filters by status", () => {
45
+ let env: TestEnv | null = null;
46
+ afterEach(async () => {
47
+ if (env) await env.dispose();
48
+ env = null;
49
+ });
50
+
51
+ test("only applied op_ids broadcast as acks; failed ones broadcast separately", async () => {
52
+ env = createTestEnv();
53
+ env.signIn({ userId: "u1" });
54
+ await env.start();
55
+
56
+ const broadcasts: { type: string; payload: unknown }[] = [];
57
+ const engine = env.engine as unknown as {
58
+ broadcastToTabs(payload: unknown): void;
59
+ request<T>(method: string, path: string, body?: unknown): Promise<T>;
60
+ mutations: {
61
+ add(change: unknown): string;
62
+ };
63
+ push(): Promise<void>;
64
+ };
65
+
66
+ // Spy on broadcastToTabs by replacing it. Save the original so
67
+ // the engine's internal calls still hit the recorder.
68
+ const originalBroadcast = engine.broadcastToTabs.bind(engine);
69
+ engine.broadcastToTabs = (payload: unknown) => {
70
+ const p = payload as { type: string };
71
+ if (p.type === "mutations-acked" || p.type === "mutations-failed") {
72
+ broadcasts.push({ type: p.type, payload });
73
+ }
74
+ originalBroadcast(payload);
75
+ };
76
+
77
+ // Stub the engine's HTTP request so /api/sync/push returns a
78
+ // mixed-status response: op-good applied, op-bad error. Other
79
+ // request paths fall through to the real harness fetch.
80
+ const originalRequest = engine.request.bind(engine);
81
+ engine.request = (async <T,>(
82
+ method: string,
83
+ path: string,
84
+ body?: unknown,
85
+ ): Promise<T> => {
86
+ if (path === "/api/sync/push") {
87
+ return {
88
+ applied: 1,
89
+ deduped: 0,
90
+ errors: ["rejected by validation"],
91
+ results: [
92
+ { op_id: "op-good", status: "applied", seq: 1 },
93
+ {
94
+ op_id: "op-bad",
95
+ status: "error",
96
+ error: { code: "VALIDATION_ERROR", message: "rejected by validation" },
97
+ },
98
+ ],
99
+ cursor: { last_seq: 1 },
100
+ } as unknown as T;
101
+ }
102
+ return originalRequest(method, path, body);
103
+ }) as typeof engine.request;
104
+
105
+ // Two mutations, one good one bad. add() preserves op_id from
106
+ // the change envelope (this is the multi-tab leader's behavior
107
+ // when it forwards a follower's batch).
108
+ engine.mutations.add({
109
+ op_id: "op-good",
110
+ entity: "Todo",
111
+ row_id: "good",
112
+ kind: "insert",
113
+ data: { id: "good", text: "ok" },
114
+ });
115
+ engine.mutations.add({
116
+ op_id: "op-bad",
117
+ entity: "Todo",
118
+ row_id: "bad",
119
+ kind: "insert",
120
+ data: { id: "bad", text: "boom" },
121
+ });
122
+
123
+ await engine.push();
124
+ await env.flush();
125
+
126
+ const acks = broadcasts.filter((b) => b.type === "mutations-acked");
127
+ const fails = broadcasts.filter((b) => b.type === "mutations-failed");
128
+
129
+ expect(acks.length).toBe(1);
130
+ expect(fails.length).toBe(1);
131
+
132
+ const ackedIds = (acks[0].payload as { opIds: string[] }).opIds;
133
+ expect(ackedIds).toEqual(["op-good"]);
134
+
135
+ const failedOps = (fails[0].payload as {
136
+ ops: { opId: string; error: string }[];
137
+ }).ops;
138
+ expect(failedOps.length).toBe(1);
139
+ expect(failedOps[0].opId).toBe("op-bad");
140
+ expect(failedOps[0].error.length).toBeGreaterThan(0);
141
+ });
142
+
143
+ test("follower receiving mutations-failed marks the op failed (not applied)", async () => {
144
+ // Receiving side: confirms the new follower handler doesn't lose
145
+ // the failure. Pre-fix the leader sent every op_id as acked and
146
+ // the follower silently clear()ed them; now the follower stays
147
+ // failed so UI / retry can act.
148
+ env = createTestEnv();
149
+ env.signIn({ userId: "u1" });
150
+ await env.start();
151
+
152
+ const engine = env.engine as unknown as {
153
+ handleMultiTabMessage(msg: unknown, from: string): void;
154
+ mutations: {
155
+ add(change: unknown): string;
156
+ pending(): { id: string; status: string; error?: string }[];
157
+ };
158
+ };
159
+
160
+ // Seed a pending mutation locally — simulates the follower's
161
+ // optimistic queue.
162
+ engine.mutations.add({
163
+ op_id: "op-x",
164
+ entity: "Todo",
165
+ row_id: "x",
166
+ kind: "insert",
167
+ data: { id: "x", text: "y" },
168
+ });
169
+
170
+ // Leader → follower envelope. Drive through the orchestrator's
171
+ // public message dispatch — same path the BroadcastChannel
172
+ // onmessage hits in production.
173
+ (engine as unknown as {
174
+ orchestrator: {
175
+ handleMessage(msg: unknown, from: string): void;
176
+ };
177
+ }).orchestrator.handleMessage(
178
+ {
179
+ type: "mutations-failed",
180
+ ops: [{ opId: "op-x", error: "server rejected" }],
181
+ },
182
+ "leader-tab-id",
183
+ );
184
+
185
+ // The mutation stays in the queue, status=failed, with the error
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");
191
+ expect(found).toBeDefined();
192
+ expect(found?.status).toBe("failed");
193
+ expect(found?.error).toBe("server rejected");
194
+ });
195
+ });
196
+
197
+ describe("codex round-6: peer leaving scrubs forwarded subs", () => {
198
+ let env: TestEnv | null = null;
199
+ afterEach(async () => {
200
+ if (env) await env.dispose();
201
+ env = null;
202
+ });
203
+
204
+ // Helper: reach into the SubscriptionCoordinator that the engine
205
+ // delegates to. The state these tests pin lives on the coordinator
206
+ // now, not the engine itself. The orchestrator's inbound dispatch
207
+ // is bypassed by calling subscriptions.handleForwardedRegister /
208
+ // scrubPeer directly — that's the same path the orchestrator takes
209
+ // when a real broker message arrives, just driven by the test.
210
+ function internals(env: TestEnv) {
211
+ return env.engine as unknown as {
212
+ serverSubs: { has(k: string): boolean };
213
+ subscriptions: {
214
+ crdtForwarders: Map<string, Set<string>>;
215
+ reactiveSubOwners: Map<string, Set<string>>;
216
+ handleForwardedRegister(msg: unknown, fromTabId: string): void;
217
+ scrubPeer(tabId: string): void;
218
+ };
219
+ };
220
+ }
221
+
222
+ test("onMultiTabPeerLeft removes the tab from crdtForwarders + unregisters WS sub", async () => {
223
+ env = createTestEnv();
224
+ env.signIn({ userId: "u1" });
225
+ await env.start();
226
+
227
+ const engine = internals(env);
228
+
229
+ // Simulate a follower forwarding a CRDT sub via the broker
230
+ // app-message path. The leader's handler creates an entry in
231
+ // crdtForwarders and registers with serverSubs.
232
+ engine.subscriptions.handleForwardedRegister(
233
+ {
234
+ type: "sub-register",
235
+ kind: "crdt",
236
+ key: "Todo\x00row-1",
237
+ entity: "Todo",
238
+ rowId: "row-1",
239
+ },
240
+ "follower-1",
241
+ );
242
+
243
+ expect(
244
+ engine.subscriptions.crdtForwarders.get("Todo\x00row-1")?.has("follower-1"),
245
+ ).toBe(true);
246
+ expect(engine.serverSubs.has("Todo\x00row-1")).toBe(true);
247
+
248
+ // Now the follower disappears (broker fires onLeave).
249
+ engine.subscriptions.scrubPeer("follower-1");
250
+
251
+ expect(engine.subscriptions.crdtForwarders.has("Todo\x00row-1")).toBe(false);
252
+ expect(engine.serverSubs.has("Todo\x00row-1")).toBe(false);
253
+ });
254
+
255
+ test("onMultiTabPeerLeft keeps the sub alive if another tab still owns it", async () => {
256
+ env = createTestEnv();
257
+ env.signIn({ userId: "u1" });
258
+ await env.start();
259
+
260
+ const engine = internals(env);
261
+
262
+ // Two followers forward the SAME crdt key. The leader keeps a
263
+ // single server sub with two entries in the forwarder set.
264
+ engine.subscriptions.handleForwardedRegister(
265
+ {
266
+ type: "sub-register",
267
+ kind: "crdt",
268
+ key: "Todo\x00row-1",
269
+ entity: "Todo",
270
+ rowId: "row-1",
271
+ },
272
+ "follower-a",
273
+ );
274
+ engine.subscriptions.handleForwardedRegister(
275
+ {
276
+ type: "sub-register",
277
+ kind: "crdt",
278
+ key: "Todo\x00row-1",
279
+ entity: "Todo",
280
+ rowId: "row-1",
281
+ },
282
+ "follower-b",
283
+ );
284
+
285
+ expect(engine.subscriptions.crdtForwarders.get("Todo\x00row-1")?.size).toBe(2);
286
+ expect(engine.serverSubs.has("Todo\x00row-1")).toBe(true);
287
+
288
+ // Only follower-a leaves. The sub stays alive because follower-b
289
+ // still owns it.
290
+ engine.subscriptions.scrubPeer("follower-a");
291
+
292
+ expect(engine.subscriptions.crdtForwarders.get("Todo\x00row-1")?.size).toBe(1);
293
+ expect(
294
+ engine.subscriptions.crdtForwarders.get("Todo\x00row-1")?.has("follower-b"),
295
+ ).toBe(true);
296
+ expect(engine.serverSubs.has("Todo\x00row-1")).toBe(true);
297
+ });
298
+
299
+ test("onMultiTabPeerLeft scrubs reactive owner sets and unregisters when empty", async () => {
300
+ env = createTestEnv();
301
+ env.signIn({ userId: "u1" });
302
+ await env.start();
303
+
304
+ const engine = internals(env);
305
+
306
+ engine.subscriptions.handleForwardedRegister(
307
+ {
308
+ type: "sub-register",
309
+ kind: "reactive",
310
+ key: "sub-1",
311
+ sub_id: "sub-1",
312
+ fn_name: "q",
313
+ args: { v: 1 },
314
+ },
315
+ "follower-x",
316
+ );
317
+
318
+ expect(engine.subscriptions.reactiveSubOwners.get("sub-1")?.has("follower-x")).toBe(
319
+ true,
320
+ );
321
+ expect(engine.serverSubs.has("sub-1")).toBe(true);
322
+
323
+ engine.subscriptions.scrubPeer("follower-x");
324
+
325
+ expect(engine.subscriptions.reactiveSubOwners.has("sub-1")).toBe(false);
326
+ expect(engine.serverSubs.has("sub-1")).toBe(false);
327
+ });
328
+ });