@pylonsync/sync 0.3.202 → 0.3.205
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 +1 -1
- package/src/index.ts +732 -661
- package/src/local-store.ts +74 -0
- package/src/multi-tab-orchestrator.test.ts +173 -0
- package/src/multi-tab-orchestrator.ts +366 -0
- package/src/multi-tab.test.ts +196 -0
- package/src/multi-tab.ts +366 -0
- package/src/mutation-queue.ts +12 -2
- package/src/op-queue.test.ts +91 -0
- package/src/op-queue.ts +73 -0
- package/src/reconcile.test.ts +31 -33
- package/src/round6-codex.test.ts +328 -0
- package/src/scenarios.test.ts +606 -0
- package/src/server-subscriptions.test.ts +99 -0
- package/src/server-subscriptions.ts +78 -0
- package/src/session-chain.test.ts +133 -0
- package/src/session-resolver.test.ts +94 -0
- package/src/session-resolver.ts +133 -0
- package/src/subscription-coordinator.test.ts +209 -0
- package/src/subscription-coordinator.ts +471 -0
- package/src/test-harness/env.ts +191 -0
- package/src/test-harness/index.ts +16 -0
- package/src/test-harness/server.ts +433 -0
- package/src/test-harness/transport.ts +256 -0
- package/src/transports/factory.test.ts +87 -0
- package/src/transports/index.ts +42 -0
- package/src/transports/polling.test.ts +102 -0
- package/src/transports/polling.ts +63 -0
- package/src/transports/reconnect.test.ts +57 -0
- package/src/transports/reconnect.ts +50 -0
- package/src/transports/sse.ts +140 -0
- package/src/transports/types.ts +116 -0
- package/src/transports/websocket.test.ts +310 -0
- package/src/transports/websocket.ts +222 -0
package/src/op-queue.ts
ADDED
|
@@ -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
|
+
}
|
package/src/reconcile.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
306
|
-
// the
|
|
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
|
-
|
|
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
|
+
});
|