@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.
- package/package.json +1 -1
- package/src/index.ts +733 -642
- 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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Unit tests for the SubscriptionCoordinator in isolation. Engine
|
|
2
|
+
// integration is covered by `round6-codex.test.ts` and the scenarios
|
|
3
|
+
// suite — these tests just pin the contract:
|
|
4
|
+
// - CRDT add/remove is refcounted; only the first add + last remove
|
|
5
|
+
// ship a WS frame
|
|
6
|
+
// - leader path registers via serverSubs; follower path broadcasts
|
|
7
|
+
// - forwarded sub-register / sub-unregister update the leader's
|
|
8
|
+
// forwarder + owner sets
|
|
9
|
+
// - scrubPeer unregisters when the last owner leaves
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { ServerSubscriptions } from "./server-subscriptions";
|
|
14
|
+
import { SubscriptionCoordinator } from "./subscription-coordinator";
|
|
15
|
+
|
|
16
|
+
function makeHarness(initialLeader = true) {
|
|
17
|
+
let isLeader = initialLeader;
|
|
18
|
+
const wsSent: unknown[] = [];
|
|
19
|
+
const broadcasts: unknown[] = [];
|
|
20
|
+
const serverSubs = new ServerSubscriptions((msg) => wsSent.push(msg));
|
|
21
|
+
const coord = new SubscriptionCoordinator(serverSubs, {
|
|
22
|
+
isLeader: () => isLeader,
|
|
23
|
+
broadcastToTabs: (payload) => broadcasts.push(payload),
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
coord,
|
|
27
|
+
serverSubs,
|
|
28
|
+
wsSent,
|
|
29
|
+
broadcasts,
|
|
30
|
+
setLeader(v: boolean) {
|
|
31
|
+
isLeader = v;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("SubscriptionCoordinator CRDT subs", () => {
|
|
37
|
+
test("leader: first subscribeCrdt registers WS sub; second is a no-op", () => {
|
|
38
|
+
const h = makeHarness(true);
|
|
39
|
+
h.coord.subscribeCrdt("Todo", "r1");
|
|
40
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
|
|
41
|
+
const before = h.wsSent.length;
|
|
42
|
+
h.coord.subscribeCrdt("Todo", "r1"); // refcount → 2
|
|
43
|
+
expect(h.wsSent.length).toBe(before); // no new WS frame
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("leader: unsubscribe matches refcount; last call ships WS unsubscribe", () => {
|
|
47
|
+
const h = makeHarness(true);
|
|
48
|
+
h.coord.subscribeCrdt("Todo", "r1");
|
|
49
|
+
h.coord.subscribeCrdt("Todo", "r1");
|
|
50
|
+
h.coord.unsubscribeCrdt("Todo", "r1"); // refcount 2 → 1
|
|
51
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
|
|
52
|
+
h.coord.unsubscribeCrdt("Todo", "r1"); // refcount 1 → 0
|
|
53
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("unsubscribe more times than subscribe is a no-op", () => {
|
|
57
|
+
const h = makeHarness(true);
|
|
58
|
+
expect(() => h.coord.unsubscribeCrdt("Todo", "ghost")).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("follower: subscribeCrdt broadcasts sub-register; no serverSubs entry", () => {
|
|
62
|
+
const h = makeHarness(false);
|
|
63
|
+
h.coord.subscribeCrdt("Todo", "r1");
|
|
64
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(false);
|
|
65
|
+
expect(h.broadcasts).toEqual([
|
|
66
|
+
{
|
|
67
|
+
type: "sub-register",
|
|
68
|
+
kind: "crdt",
|
|
69
|
+
key: "Todo\x00r1",
|
|
70
|
+
entity: "Todo",
|
|
71
|
+
rowId: "r1",
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("leader keeps WS sub alive while a follower still has it forwarded", () => {
|
|
77
|
+
const h = makeHarness(true);
|
|
78
|
+
h.coord.handleForwardedRegister(
|
|
79
|
+
{
|
|
80
|
+
type: "sub-register",
|
|
81
|
+
kind: "crdt",
|
|
82
|
+
key: "Todo\x00r1",
|
|
83
|
+
entity: "Todo",
|
|
84
|
+
rowId: "r1",
|
|
85
|
+
},
|
|
86
|
+
"follower-a",
|
|
87
|
+
);
|
|
88
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
|
|
89
|
+
// Leader's own subscribe doesn't re-send (already alive via fwd).
|
|
90
|
+
const before = h.wsSent.length;
|
|
91
|
+
h.coord.subscribeCrdt("Todo", "r1");
|
|
92
|
+
expect(h.wsSent.length).toBe(before);
|
|
93
|
+
// Leader unsubscribe with follower still wanting it keeps the WS sub alive.
|
|
94
|
+
h.coord.unsubscribeCrdt("Todo", "r1");
|
|
95
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("SubscriptionCoordinator reactive subs", () => {
|
|
100
|
+
test("leader: subscribeReactive registers + tracks self in owner set", () => {
|
|
101
|
+
const h = makeHarness(true);
|
|
102
|
+
h.coord.subscribeReactive("sub-1", "myFn", { v: 1 }, () => {});
|
|
103
|
+
expect(h.serverSubs.has("sub-1")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("leader: unsubscribeReactive drops self; only unregisters when no other owner", () => {
|
|
107
|
+
const h = makeHarness(true);
|
|
108
|
+
h.coord.subscribeReactive("sub-1", "myFn", { v: 1 }, () => {});
|
|
109
|
+
// Follower also forwarded the same sub_id (different mounts in different tabs).
|
|
110
|
+
h.coord.handleForwardedRegister(
|
|
111
|
+
{
|
|
112
|
+
type: "sub-register",
|
|
113
|
+
kind: "reactive",
|
|
114
|
+
key: "sub-1",
|
|
115
|
+
sub_id: "sub-1",
|
|
116
|
+
fn_name: "myFn",
|
|
117
|
+
args: { v: 1 },
|
|
118
|
+
},
|
|
119
|
+
"follower-a",
|
|
120
|
+
);
|
|
121
|
+
h.coord.unsubscribeReactive("sub-1");
|
|
122
|
+
// Follower still owns it → still registered.
|
|
123
|
+
expect(h.serverSubs.has("sub-1")).toBe(true);
|
|
124
|
+
h.coord.handleForwardedUnregister(
|
|
125
|
+
{
|
|
126
|
+
type: "sub-unregister",
|
|
127
|
+
kind: "reactive",
|
|
128
|
+
key: "sub-1",
|
|
129
|
+
sub_id: "sub-1",
|
|
130
|
+
},
|
|
131
|
+
"follower-a",
|
|
132
|
+
);
|
|
133
|
+
// Now last owner left → unregistered.
|
|
134
|
+
expect(h.serverSubs.has("sub-1")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("inbound reactive-msg routes to the local handler", () => {
|
|
138
|
+
const h = makeHarness(true);
|
|
139
|
+
const received: unknown[] = [];
|
|
140
|
+
h.coord.subscribeReactive("sub-1", "fn", { v: 1 }, (msg) =>
|
|
141
|
+
received.push(msg),
|
|
142
|
+
);
|
|
143
|
+
h.coord.handleReactiveMessage("sub-1", { kind: "result", result: 42 });
|
|
144
|
+
expect(received).toEqual([{ kind: "result", result: 42 }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("follower: subscribeReactive broadcasts sub-register", () => {
|
|
148
|
+
const h = makeHarness(false);
|
|
149
|
+
h.coord.subscribeReactive("sub-1", "fn", { v: 1 }, () => {});
|
|
150
|
+
expect(h.broadcasts).toEqual([
|
|
151
|
+
{
|
|
152
|
+
type: "sub-register",
|
|
153
|
+
kind: "reactive",
|
|
154
|
+
key: "sub-1",
|
|
155
|
+
sub_id: "sub-1",
|
|
156
|
+
fn_name: "fn",
|
|
157
|
+
args: { v: 1 },
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("SubscriptionCoordinator scrubPeer + replays", () => {
|
|
164
|
+
test("scrubPeer removes a follower's CRDT entries and unregisters when last", () => {
|
|
165
|
+
const h = makeHarness(true);
|
|
166
|
+
h.coord.handleForwardedRegister(
|
|
167
|
+
{
|
|
168
|
+
type: "sub-register",
|
|
169
|
+
kind: "crdt",
|
|
170
|
+
key: "Todo\x00r1",
|
|
171
|
+
entity: "Todo",
|
|
172
|
+
rowId: "r1",
|
|
173
|
+
},
|
|
174
|
+
"follower-a",
|
|
175
|
+
);
|
|
176
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(true);
|
|
177
|
+
h.coord.scrubPeer("follower-a");
|
|
178
|
+
expect(h.serverSubs.has("Todo\x00r1")).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("seedFromLocalInterest re-registers wanted subs on the new leader", () => {
|
|
182
|
+
const h = makeHarness(false);
|
|
183
|
+
// Mount a reactive sub as follower (broadcasts only).
|
|
184
|
+
h.coord.subscribeReactive("sub-1", "fn", { v: 1 }, () => {});
|
|
185
|
+
expect(h.serverSubs.has("sub-1")).toBe(false);
|
|
186
|
+
// Promotion: now we're leader; seed should register via serverSubs.
|
|
187
|
+
h.setLeader(true);
|
|
188
|
+
h.coord.seedFromLocalInterest();
|
|
189
|
+
expect(h.serverSubs.has("sub-1")).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("hasCrdtForwarders reflects forwarder presence", () => {
|
|
193
|
+
const h = makeHarness(true);
|
|
194
|
+
expect(h.coord.hasCrdtForwarders()).toBe(false);
|
|
195
|
+
h.coord.handleForwardedRegister(
|
|
196
|
+
{
|
|
197
|
+
type: "sub-register",
|
|
198
|
+
kind: "crdt",
|
|
199
|
+
key: "Todo\x00r1",
|
|
200
|
+
entity: "Todo",
|
|
201
|
+
rowId: "r1",
|
|
202
|
+
},
|
|
203
|
+
"follower-a",
|
|
204
|
+
);
|
|
205
|
+
expect(h.coord.hasCrdtForwarders()).toBe(true);
|
|
206
|
+
h.coord.scrubPeer("follower-a");
|
|
207
|
+
expect(h.coord.hasCrdtForwarders()).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// SubscriptionCoordinator — owns every "this tab wants live updates"
|
|
2
|
+
// subscription, regardless of kind (CRDT row, reactive query) or role
|
|
3
|
+
// (this tab is leader vs follower).
|
|
4
|
+
//
|
|
5
|
+
// Two kinds of subscriptions live here:
|
|
6
|
+
//
|
|
7
|
+
// 1. CRDT row subs (`subscribeCrdt(entity, rowId)`) — refcounted
|
|
8
|
+
// per-tab because N `useLoroDoc` components on the same row share
|
|
9
|
+
// one server subscription. The first add and last remove ship a
|
|
10
|
+
// WS frame; the in-between calls just bump the count.
|
|
11
|
+
//
|
|
12
|
+
// 2. Reactive query subs (`subscribeReactive(sub_id, fn_name, args, handler)`)
|
|
13
|
+
// — Convex-shape. The sub_id is per-mount; multiple mounts of the
|
|
14
|
+
// same query get different sub_ids. Each sub_id has at most one
|
|
15
|
+
// handler that fires for inbound `reactive-result` / `reactive-error`.
|
|
16
|
+
//
|
|
17
|
+
// Leader / follower split:
|
|
18
|
+
//
|
|
19
|
+
// - LEADER tabs own a real WebSocket and forward each sub to it via
|
|
20
|
+
// `serverSubs.register`. They also track which FOLLOWER tabs are
|
|
21
|
+
// interested in each key (the forwarder/owner sets), so the WS
|
|
22
|
+
// sub stays alive as long as any tab in the origin still wants it.
|
|
23
|
+
//
|
|
24
|
+
// - FOLLOWER tabs don't have a WS. They broadcast `sub-register` /
|
|
25
|
+
// `sub-unregister` envelopes over the BroadcastChannel; the leader
|
|
26
|
+
// picks those up and routes them through `handleForwardedRegister`
|
|
27
|
+
// / `handleForwardedUnregister`. Inbound `reactive-result` lands
|
|
28
|
+
// on the leader's WS, the leader broadcasts `reactive-msg` back
|
|
29
|
+
// across tabs, and `handleReactiveMessage` routes to the local
|
|
30
|
+
// handler on whichever tab originally subscribed.
|
|
31
|
+
//
|
|
32
|
+
// Lifecycle hooks (called by the engine):
|
|
33
|
+
//
|
|
34
|
+
// - `seedFromLocalInterest()` — when this tab settles as leader, register
|
|
35
|
+
// every locally-wanted sub with `serverSubs` so the next WS connect
|
|
36
|
+
// replays them.
|
|
37
|
+
// - `replayForwardedSubs()` — when a fresh leader asks for replay, every
|
|
38
|
+
// follower re-broadcasts its currently-wanted subs.
|
|
39
|
+
// - `scrubPeer(tabId)` — when a peer tab disappears (`bye`), drop its
|
|
40
|
+
// entries from forwarder/owner sets and unregister WS subs that
|
|
41
|
+
// are no longer wanted by anyone.
|
|
42
|
+
|
|
43
|
+
import type { ServerSubscriptions } from "./server-subscriptions";
|
|
44
|
+
import type { ReactiveMessage } from "./types";
|
|
45
|
+
|
|
46
|
+
/** Sentinel for "this leader tab subscribed locally" — used as a
|
|
47
|
+
* refcount-bearer key in reactive owner sets so the leader's own
|
|
48
|
+
* subs don't get conflated with forwarded follower subs. */
|
|
49
|
+
const OWN_TAB = "__self__";
|
|
50
|
+
|
|
51
|
+
/** Engine-side surface the coordinator depends on. Kept narrow so the
|
|
52
|
+
* coordinator's contract with the engine is explicit. */
|
|
53
|
+
export interface SubscriptionCoordinatorContext {
|
|
54
|
+
isLeader(): boolean;
|
|
55
|
+
broadcastToTabs(payload: unknown): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ReactiveSpec {
|
|
59
|
+
fn_name: string;
|
|
60
|
+
args: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class SubscriptionCoordinator {
|
|
64
|
+
/** Per-row local refcount for CRDT subscriptions — N `useLoroDoc`
|
|
65
|
+
* consumers on the same `(entity, rowId)` in THIS tab share a
|
|
66
|
+
* single server subscription. Distinct from `reactiveSubOwners`:
|
|
67
|
+
* CRDT keys are per-row (many consumers per tab) while reactive
|
|
68
|
+
* sub_ids are per-consumer-instance (typically one). StrictMode
|
|
69
|
+
* double-subscribe still bumps the count by one each call; the
|
|
70
|
+
* matching double-unsubscribe early-returns when the count is
|
|
71
|
+
* already zero, so the math balances. */
|
|
72
|
+
private crdtSubscribers: Map<string, number> = new Map();
|
|
73
|
+
|
|
74
|
+
/** Per-row set of FOLLOWER tabIds that have forwarded a
|
|
75
|
+
* `sub-register` for this key. Leader-only. Used to:
|
|
76
|
+
* (a) skip the WS unsubscribe until both `crdtSubscribers`
|
|
77
|
+
* and this set are empty, and
|
|
78
|
+
* (b) skip the cross-tab binary broadcast when no follower
|
|
79
|
+
* cares about CRDT (saves bandwidth when the leader is
|
|
80
|
+
* the only CRDT consumer). */
|
|
81
|
+
private crdtForwarders: Map<string, Set<string>> = new Map();
|
|
82
|
+
|
|
83
|
+
/** Per-sub_id ownership set for reactive subscriptions on the
|
|
84
|
+
* leader: which tabs (self + forwarders) want this sub alive.
|
|
85
|
+
* A SET, not a count, so a follower crash + late `sub-unregister`
|
|
86
|
+
* storm can't underflow the count, and a remount/StrictMode
|
|
87
|
+
* double-subscribe from one tab still counts as one owner. */
|
|
88
|
+
private reactiveSubOwners: Map<string, Set<string>> = new Map();
|
|
89
|
+
|
|
90
|
+
/** Inbound message routing for reactive subscriptions — server pushes
|
|
91
|
+
* `reactive-result` / `reactive-error` envelopes keyed by sub_id and
|
|
92
|
+
* the hook's handler lives here. */
|
|
93
|
+
private reactiveHandlers: Map<string, (msg: ReactiveMessage) => void> =
|
|
94
|
+
new Map();
|
|
95
|
+
|
|
96
|
+
/** Specs (fn_name + args) for every reactive subscription this tab
|
|
97
|
+
* has registered, regardless of role. On promotion the new leader
|
|
98
|
+
* uses these to register with serverSubs; on a leader change while
|
|
99
|
+
* we stay a follower we use them to re-forward `sub-register` to
|
|
100
|
+
* the new leader. */
|
|
101
|
+
private wantedReactiveSpecs: Map<string, ReactiveSpec> = new Map();
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly serverSubs: ServerSubscriptions,
|
|
105
|
+
private readonly ctx: SubscriptionCoordinatorContext,
|
|
106
|
+
) {}
|
|
107
|
+
|
|
108
|
+
// ---- CRDT subscriptions ------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Refcounted CRDT row subscribe. First subscriber for the row sends
|
|
111
|
+
* the WS frame (leader) or forwards to the leader (follower).
|
|
112
|
+
* Idempotent at the WS level: re-calling with no intervening
|
|
113
|
+
* unsubscribe just bumps the count. */
|
|
114
|
+
subscribeCrdt(entity: string, rowId: string): void {
|
|
115
|
+
const key = crdtKey(entity, rowId);
|
|
116
|
+
const prev = this.crdtSubscribers.get(key) ?? 0;
|
|
117
|
+
this.crdtSubscribers.set(key, prev + 1);
|
|
118
|
+
if (prev !== 0) return;
|
|
119
|
+
if (this.ctx.isLeader()) {
|
|
120
|
+
// Leader: only send the WS subscribe if no follower had already
|
|
121
|
+
// forwarded one (in which case the WS sub is already alive).
|
|
122
|
+
const hasFwd = (this.crdtForwarders.get(key)?.size ?? 0) > 0;
|
|
123
|
+
if (!hasFwd) {
|
|
124
|
+
this.serverSubs.register(key, {
|
|
125
|
+
type: "crdt-subscribe",
|
|
126
|
+
entity,
|
|
127
|
+
rowId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
// Follower: ask the leader to subscribe on our behalf. The leader
|
|
132
|
+
// echoes binary frames back over the broadcast channel so our
|
|
133
|
+
// local binaryHandlers fire normally.
|
|
134
|
+
this.ctx.broadcastToTabs({
|
|
135
|
+
type: "sub-register",
|
|
136
|
+
kind: "crdt",
|
|
137
|
+
key,
|
|
138
|
+
entity,
|
|
139
|
+
rowId,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Refcount-aware CRDT row unsubscribe. Last unsubscribe ships the
|
|
145
|
+
* WS frame; intermediate calls just decrement. Calling more times
|
|
146
|
+
* than `subscribeCrdt` is a no-op (StrictMode-safe). */
|
|
147
|
+
unsubscribeCrdt(entity: string, rowId: string): void {
|
|
148
|
+
const key = crdtKey(entity, rowId);
|
|
149
|
+
const prev = this.crdtSubscribers.get(key) ?? 0;
|
|
150
|
+
if (prev <= 0) return;
|
|
151
|
+
if (prev > 1) {
|
|
152
|
+
this.crdtSubscribers.set(key, prev - 1);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.crdtSubscribers.delete(key);
|
|
156
|
+
if (this.ctx.isLeader()) {
|
|
157
|
+
const remainingFwd = this.crdtForwarders.get(key)?.size ?? 0;
|
|
158
|
+
if (remainingFwd === 0 && this.serverSubs.has(key)) {
|
|
159
|
+
this.serverSubs.unregister(key, {
|
|
160
|
+
type: "crdt-unsubscribe",
|
|
161
|
+
entity,
|
|
162
|
+
rowId,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
this.ctx.broadcastToTabs({
|
|
167
|
+
type: "sub-unregister",
|
|
168
|
+
kind: "crdt",
|
|
169
|
+
key,
|
|
170
|
+
entity,
|
|
171
|
+
rowId,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Reactive query subscriptions --------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Register a reactive query subscription. The caller-minted `sub_id`
|
|
179
|
+
* is used by the React hook to dispatch result/error pushes to the
|
|
180
|
+
* right component.
|
|
181
|
+
*
|
|
182
|
+
* Idempotent: re-calling with the same `sub_id` replaces the prior
|
|
183
|
+
* handler + spec. Useful when args change and the hook re-registers
|
|
184
|
+
* — ServerSubscriptions re-sends on payload change, so the WS sees
|
|
185
|
+
* the new args. */
|
|
186
|
+
subscribeReactive(
|
|
187
|
+
sub_id: string,
|
|
188
|
+
fn_name: string,
|
|
189
|
+
args: unknown,
|
|
190
|
+
handler: (msg: ReactiveMessage) => void,
|
|
191
|
+
): void {
|
|
192
|
+
this.reactiveHandlers.set(sub_id, handler);
|
|
193
|
+
this.wantedReactiveSpecs.set(sub_id, { fn_name, args });
|
|
194
|
+
if (!this.ctx.isLeader()) {
|
|
195
|
+
// Follower path: the WS lives on the leader. Forward the spec
|
|
196
|
+
// there; the leader registers with its own serverSubs and echoes
|
|
197
|
+
// inbound envelopes back to us via the channel.
|
|
198
|
+
this.ctx.broadcastToTabs({
|
|
199
|
+
type: "sub-register",
|
|
200
|
+
kind: "reactive",
|
|
201
|
+
key: sub_id,
|
|
202
|
+
sub_id,
|
|
203
|
+
fn_name,
|
|
204
|
+
args,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let owners = this.reactiveSubOwners.get(sub_id);
|
|
209
|
+
if (!owners) {
|
|
210
|
+
owners = new Set();
|
|
211
|
+
this.reactiveSubOwners.set(sub_id, owners);
|
|
212
|
+
}
|
|
213
|
+
owners.add(OWN_TAB);
|
|
214
|
+
this.serverSubs.register(sub_id, {
|
|
215
|
+
type: "reactive-subscribe",
|
|
216
|
+
sub_id,
|
|
217
|
+
fn_name,
|
|
218
|
+
args,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Tear down a reactive subscription. No-op for unknown sub_ids —
|
|
223
|
+
* React StrictMode double-unmount doesn't error. */
|
|
224
|
+
unsubscribeReactive(sub_id: string): void {
|
|
225
|
+
if (!this.reactiveHandlers.has(sub_id)) return;
|
|
226
|
+
this.reactiveHandlers.delete(sub_id);
|
|
227
|
+
this.wantedReactiveSpecs.delete(sub_id);
|
|
228
|
+
if (!this.ctx.isLeader()) {
|
|
229
|
+
this.ctx.broadcastToTabs({
|
|
230
|
+
type: "sub-unregister",
|
|
231
|
+
kind: "reactive",
|
|
232
|
+
key: sub_id,
|
|
233
|
+
sub_id,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const owners = this.reactiveSubOwners.get(sub_id);
|
|
238
|
+
if (owners) {
|
|
239
|
+
owners.delete(OWN_TAB);
|
|
240
|
+
if (owners.size === 0) {
|
|
241
|
+
this.reactiveSubOwners.delete(sub_id);
|
|
242
|
+
if (this.serverSubs.has(sub_id)) {
|
|
243
|
+
this.serverSubs.unregister(sub_id, {
|
|
244
|
+
type: "reactive-unsubscribe",
|
|
245
|
+
sub_id,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- Inbound from the multi-tab broker ---------------------------------
|
|
253
|
+
|
|
254
|
+
/** Follower → leader: register a WS subscription on the follower's
|
|
255
|
+
* behalf. Caller (engine) has already gated on `isLeader()`. */
|
|
256
|
+
handleForwardedRegister(
|
|
257
|
+
msg: Record<string, unknown>,
|
|
258
|
+
fromTabId: string,
|
|
259
|
+
): void {
|
|
260
|
+
const kind = msg.kind as string;
|
|
261
|
+
if (kind === "crdt") {
|
|
262
|
+
const key = msg.key as string;
|
|
263
|
+
const entity = msg.entity as string;
|
|
264
|
+
const rowId = msg.rowId as string;
|
|
265
|
+
// Track the forwarding follower in a separate set (NOT the
|
|
266
|
+
// local refcount) — repeated sub-register from the same
|
|
267
|
+
// follower stays idempotent, and a follower crash before
|
|
268
|
+
// sub-unregister is cleaned up by `scrubPeer` when the
|
|
269
|
+
// broker's `onLeave` fires.
|
|
270
|
+
let fwd = this.crdtForwarders.get(key);
|
|
271
|
+
if (!fwd) {
|
|
272
|
+
fwd = new Set();
|
|
273
|
+
this.crdtForwarders.set(key, fwd);
|
|
274
|
+
}
|
|
275
|
+
fwd.add(fromTabId);
|
|
276
|
+
// Register if nothing else owned this key — local count and
|
|
277
|
+
// forwarder set together gate the WS subscribe.
|
|
278
|
+
const localCount = this.crdtSubscribers.get(key) ?? 0;
|
|
279
|
+
if (localCount === 0 && fwd.size === 1) {
|
|
280
|
+
this.serverSubs.register(key, {
|
|
281
|
+
type: "crdt-subscribe",
|
|
282
|
+
entity,
|
|
283
|
+
rowId,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (kind === "reactive") {
|
|
289
|
+
const sub_id = msg.sub_id as string;
|
|
290
|
+
const fn_name = msg.fn_name as string;
|
|
291
|
+
const args = msg.args;
|
|
292
|
+
let owners = this.reactiveSubOwners.get(sub_id);
|
|
293
|
+
if (!owners) {
|
|
294
|
+
owners = new Set();
|
|
295
|
+
this.reactiveSubOwners.set(sub_id, owners);
|
|
296
|
+
}
|
|
297
|
+
owners.add(fromTabId);
|
|
298
|
+
this.serverSubs.register(sub_id, {
|
|
299
|
+
type: "reactive-subscribe",
|
|
300
|
+
sub_id,
|
|
301
|
+
fn_name,
|
|
302
|
+
args,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Follower → leader: unregister a WS subscription on the follower's
|
|
308
|
+
* behalf. */
|
|
309
|
+
handleForwardedUnregister(
|
|
310
|
+
msg: Record<string, unknown>,
|
|
311
|
+
fromTabId: string,
|
|
312
|
+
): void {
|
|
313
|
+
const kind = msg.kind as string;
|
|
314
|
+
if (kind === "crdt") {
|
|
315
|
+
const key = msg.key as string;
|
|
316
|
+
const entity = msg.entity as string;
|
|
317
|
+
const rowId = msg.rowId as string;
|
|
318
|
+
const fwd = this.crdtForwarders.get(key);
|
|
319
|
+
if (fwd) {
|
|
320
|
+
fwd.delete(fromTabId);
|
|
321
|
+
if (fwd.size === 0) this.crdtForwarders.delete(key);
|
|
322
|
+
}
|
|
323
|
+
const localCount = this.crdtSubscribers.get(key) ?? 0;
|
|
324
|
+
const remainingFwd = this.crdtForwarders.get(key)?.size ?? 0;
|
|
325
|
+
if (localCount === 0 && remainingFwd === 0 && this.serverSubs.has(key)) {
|
|
326
|
+
this.serverSubs.unregister(key, {
|
|
327
|
+
type: "crdt-unsubscribe",
|
|
328
|
+
entity,
|
|
329
|
+
rowId,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (kind === "reactive") {
|
|
335
|
+
const sub_id = msg.sub_id as string;
|
|
336
|
+
const owners = this.reactiveSubOwners.get(sub_id);
|
|
337
|
+
if (!owners) return;
|
|
338
|
+
owners.delete(fromTabId);
|
|
339
|
+
if (owners.size === 0) {
|
|
340
|
+
this.reactiveSubOwners.delete(sub_id);
|
|
341
|
+
if (this.serverSubs.has(sub_id)) {
|
|
342
|
+
this.serverSubs.unregister(sub_id, {
|
|
343
|
+
type: "reactive-unsubscribe",
|
|
344
|
+
sub_id,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Leader → follower: a `reactive-result` / `reactive-error` envelope
|
|
352
|
+
* arrived on the leader's WS. Route to the local handler if we
|
|
353
|
+
* registered the sub. */
|
|
354
|
+
handleReactiveMessage(sub_id: string, payload: ReactiveMessage): void {
|
|
355
|
+
const handler = this.reactiveHandlers.get(sub_id);
|
|
356
|
+
if (handler) handler(payload);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- Leader / follower transitions -------------------------------------
|
|
360
|
+
|
|
361
|
+
/** Seed serverSubs with every subscription this tab currently wants.
|
|
362
|
+
* Called when this tab settles as leader (either at start() or via
|
|
363
|
+
* late promotion) so the next WS connect replays the bundle.
|
|
364
|
+
* Idempotent: serverSubs.register dedupes by payload equality. */
|
|
365
|
+
seedFromLocalInterest(): void {
|
|
366
|
+
for (const [sub_id, spec] of this.wantedReactiveSpecs) {
|
|
367
|
+
this.serverSubs.register(sub_id, {
|
|
368
|
+
type: "reactive-subscribe",
|
|
369
|
+
sub_id,
|
|
370
|
+
fn_name: spec.fn_name,
|
|
371
|
+
args: spec.args,
|
|
372
|
+
});
|
|
373
|
+
let owners = this.reactiveSubOwners.get(sub_id);
|
|
374
|
+
if (!owners) {
|
|
375
|
+
owners = new Set();
|
|
376
|
+
this.reactiveSubOwners.set(sub_id, owners);
|
|
377
|
+
}
|
|
378
|
+
owners.add(OWN_TAB);
|
|
379
|
+
}
|
|
380
|
+
for (const [key] of this.crdtSubscribers) {
|
|
381
|
+
const [entity, rowId] = key.split("\x00");
|
|
382
|
+
if (entity && rowId !== undefined) {
|
|
383
|
+
this.serverSubs.register(key, {
|
|
384
|
+
type: "crdt-subscribe",
|
|
385
|
+
entity,
|
|
386
|
+
rowId,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Re-broadcast every locally-wanted sub to the (new) leader.
|
|
393
|
+
* Triggered by `request-sub-replay` after a leader change while we
|
|
394
|
+
* stayed a follower. */
|
|
395
|
+
replayForwardedSubs(): void {
|
|
396
|
+
for (const [sub_id, spec] of this.wantedReactiveSpecs) {
|
|
397
|
+
this.ctx.broadcastToTabs({
|
|
398
|
+
type: "sub-register",
|
|
399
|
+
kind: "reactive",
|
|
400
|
+
key: sub_id,
|
|
401
|
+
sub_id,
|
|
402
|
+
fn_name: spec.fn_name,
|
|
403
|
+
args: spec.args,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
for (const [key] of this.crdtSubscribers) {
|
|
407
|
+
const [entity, rowId] = key.split("\x00");
|
|
408
|
+
if (entity && rowId !== undefined) {
|
|
409
|
+
this.ctx.broadcastToTabs({
|
|
410
|
+
type: "sub-register",
|
|
411
|
+
kind: "crdt",
|
|
412
|
+
key,
|
|
413
|
+
entity,
|
|
414
|
+
rowId,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A peer tab disappeared (broker `bye` fired). Drop it from every
|
|
421
|
+
* forwarder/owner set; if a key drops to zero remaining owners
|
|
422
|
+
* (no local consumer, no forwarder) we unregister the WS sub so
|
|
423
|
+
* the server stops fanning at a tab that no longer exists. */
|
|
424
|
+
scrubPeer(tabId: string): void {
|
|
425
|
+
for (const [key, fwd] of this.crdtForwarders) {
|
|
426
|
+
if (!fwd.delete(tabId)) continue;
|
|
427
|
+
if (fwd.size === 0) {
|
|
428
|
+
this.crdtForwarders.delete(key);
|
|
429
|
+
const localCount = this.crdtSubscribers.get(key) ?? 0;
|
|
430
|
+
if (localCount === 0 && this.serverSubs.has(key)) {
|
|
431
|
+
const [entity, rowId] = key.split("\x00");
|
|
432
|
+
if (entity && rowId !== undefined) {
|
|
433
|
+
this.serverSubs.unregister(key, {
|
|
434
|
+
type: "crdt-unsubscribe",
|
|
435
|
+
entity,
|
|
436
|
+
rowId,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const [sub_id, owners] of this.reactiveSubOwners) {
|
|
443
|
+
if (!owners.delete(tabId)) continue;
|
|
444
|
+
if (owners.size === 0) {
|
|
445
|
+
this.reactiveSubOwners.delete(sub_id);
|
|
446
|
+
if (this.serverSubs.has(sub_id)) {
|
|
447
|
+
this.serverSubs.unregister(sub_id, {
|
|
448
|
+
type: "reactive-unsubscribe",
|
|
449
|
+
sub_id,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---- Predicates used by the engine -------------------------------------
|
|
457
|
+
|
|
458
|
+
/** True when at least one follower tab has forwarded a CRDT sub.
|
|
459
|
+
* The engine gates its binary-broadcast on this so we don't fan
|
|
460
|
+
* binary frames to every tab when no follower cares about CRDT. */
|
|
461
|
+
hasCrdtForwarders(): boolean {
|
|
462
|
+
return this.crdtForwarders.size > 0;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Internal CRDT key format: `${entity}\x00${rowId}`. Centralized so
|
|
467
|
+
* the engine's binary-frame parser (and any future routing logic)
|
|
468
|
+
* agrees with the coordinator on the wire shape. */
|
|
469
|
+
export function crdtKey(entity: string, rowId: string): string {
|
|
470
|
+
return `${entity}\x00${rowId}`;
|
|
471
|
+
}
|