@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
package/src/local-store.ts
CHANGED
|
@@ -318,6 +318,80 @@ export class LocalStore {
|
|
|
318
318
|
this.notify();
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Apply a reconcile result against the local replica. Reconcile is
|
|
323
|
+
* "the server says these are the canonical rows right now" — it does
|
|
324
|
+
* NOT carry per-event seqs. Distinct from `applyChange` (which
|
|
325
|
+
* threads server-issued change events through a monotonic seq
|
|
326
|
+
* guard + cursor advance).
|
|
327
|
+
*
|
|
328
|
+
* `tombstoneSeq` is the cursor at the start of the reconcile fetch.
|
|
329
|
+
* - Upserts skip rows whose tombstone is fresher (would be stale
|
|
330
|
+
* resurrections of a later delete).
|
|
331
|
+
* - Removals tombstone at `tombstoneSeq` so a later legitimate
|
|
332
|
+
* re-create (with a strictly greater seq) flows through.
|
|
333
|
+
*
|
|
334
|
+
* Cursor is NOT advanced — reconcile fabricates no real seqs, so
|
|
335
|
+
* the engine's cursor stays pinned to the change-log position the
|
|
336
|
+
* reconcile started from.
|
|
337
|
+
*/
|
|
338
|
+
async applyReconcileBatch(
|
|
339
|
+
entity: string,
|
|
340
|
+
upserts: Row[],
|
|
341
|
+
removalIds: string[],
|
|
342
|
+
tombstoneSeq: number,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
if (!this.tables.has(entity)) this.tables.set(entity, new Map());
|
|
345
|
+
const table = this.tables.get(entity)!;
|
|
346
|
+
const applied: Array<{ id: string; row: Row }> = [];
|
|
347
|
+
for (const row of upserts) {
|
|
348
|
+
const id = (row as { id?: unknown }).id;
|
|
349
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
350
|
+
// Treat the upsert as effective at `tombstoneSeq + 1`. Anything
|
|
351
|
+
// tombstoned at a HIGHER seq is fresher and wins; the upsert is
|
|
352
|
+
// a stale view that pre-dated the delete.
|
|
353
|
+
if (this.isTombstoned(entity, id, tombstoneSeq + 1)) continue;
|
|
354
|
+
const merged = { ...row, id };
|
|
355
|
+
table.set(id, merged);
|
|
356
|
+
applied.push({ id, row: merged });
|
|
357
|
+
}
|
|
358
|
+
const removed: string[] = [];
|
|
359
|
+
for (const id of removalIds) {
|
|
360
|
+
if (this.reconcileRemove(entity, id, tombstoneSeq)) {
|
|
361
|
+
removed.push(id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (applied.length > 0 || removed.length > 0) this.notify();
|
|
365
|
+
// Persist sequentially so disk order matches memory order — same
|
|
366
|
+
// discipline as applyChangesAsync.
|
|
367
|
+
if (this._persistFn) {
|
|
368
|
+
for (const { id, row } of applied) {
|
|
369
|
+
const ev: ChangeEvent = {
|
|
370
|
+
seq: tombstoneSeq + 1,
|
|
371
|
+
entity,
|
|
372
|
+
row_id: id,
|
|
373
|
+
kind: "insert",
|
|
374
|
+
data: row,
|
|
375
|
+
timestamp: "",
|
|
376
|
+
};
|
|
377
|
+
const result = this._persistFn(ev);
|
|
378
|
+
if (result instanceof Promise) await result;
|
|
379
|
+
}
|
|
380
|
+
for (const id of removed) {
|
|
381
|
+
const ev: ChangeEvent = {
|
|
382
|
+
seq: tombstoneSeq,
|
|
383
|
+
entity,
|
|
384
|
+
row_id: id,
|
|
385
|
+
kind: "delete",
|
|
386
|
+
data: undefined as unknown as Row,
|
|
387
|
+
timestamp: "",
|
|
388
|
+
};
|
|
389
|
+
const result = this._persistFn(ev);
|
|
390
|
+
if (result instanceof Promise) await result;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
321
395
|
/**
|
|
322
396
|
* Drop every table + tombstone in-place, then notify. Used by the
|
|
323
397
|
* sync engine's `resetReplica()` on identity flip (token or tenant
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Unit tests for MultiTabOrchestrator. Drives the dispatch table by
|
|
2
|
+
// calling handleMessage() directly — same path the BroadcastChannel's
|
|
3
|
+
// onmessage hits in production. The broker itself is stubbed so the
|
|
4
|
+
// election + heartbeat machinery doesn't fire during these tests.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
|
|
9
|
+
import { ServerSubscriptions } from "./server-subscriptions";
|
|
10
|
+
import { SubscriptionCoordinator } from "./subscription-coordinator";
|
|
11
|
+
import type { ChangeEvent, ResolvedSession } from "./types";
|
|
12
|
+
|
|
13
|
+
function makeRig(opts: { isLeader?: boolean } = {}) {
|
|
14
|
+
const events: { kind: string; args: unknown[] }[] = [];
|
|
15
|
+
const record = (kind: string) =>
|
|
16
|
+
(...args: unknown[]) => {
|
|
17
|
+
events.push({ kind, args });
|
|
18
|
+
};
|
|
19
|
+
const serverSubs = new ServerSubscriptions(() => {});
|
|
20
|
+
const subs = new SubscriptionCoordinator(serverSubs, {
|
|
21
|
+
isLeader: () => opts.isLeader ?? true,
|
|
22
|
+
broadcastToTabs: () => {},
|
|
23
|
+
});
|
|
24
|
+
const orch = new MultiTabOrchestrator(
|
|
25
|
+
{ enabled: false, appName: "test" },
|
|
26
|
+
subs,
|
|
27
|
+
{
|
|
28
|
+
onInitialLeader: record("initialLeader"),
|
|
29
|
+
onLatePromote: record("latePromote"),
|
|
30
|
+
onDemote: record("demote"),
|
|
31
|
+
onAppliedReceived: record("applied"),
|
|
32
|
+
onReconciledReceived: record("reconciled"),
|
|
33
|
+
onResetReceived: record("reset"),
|
|
34
|
+
onSessionReceived: record("session"),
|
|
35
|
+
onMutationsForwarded: record("mutationsForwarded"),
|
|
36
|
+
onMutationsAcked: record("mutationsAcked"),
|
|
37
|
+
onMutationsFailed: record("mutationsFailed"),
|
|
38
|
+
onBinaryReceived: record("binary"),
|
|
39
|
+
onPeerLeft: record("peerLeft"),
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
return { orch, events, serverSubs, subs };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("MultiTabOrchestrator dispatch", () => {
|
|
46
|
+
test("applied envelope fires onAppliedReceived with changes + cursor", () => {
|
|
47
|
+
const { orch, events } = makeRig();
|
|
48
|
+
const change: ChangeEvent = {
|
|
49
|
+
seq: 7,
|
|
50
|
+
entity: "Todo",
|
|
51
|
+
row_id: "r1",
|
|
52
|
+
kind: "insert",
|
|
53
|
+
data: { id: "r1" },
|
|
54
|
+
timestamp: "",
|
|
55
|
+
};
|
|
56
|
+
orch.handleMessage(
|
|
57
|
+
{ type: "applied", changes: [change], targetCursor: { last_seq: 7 } },
|
|
58
|
+
"leader-x",
|
|
59
|
+
);
|
|
60
|
+
expect(events.length).toBe(1);
|
|
61
|
+
expect(events[0].kind).toBe("applied");
|
|
62
|
+
const [changes, cursor] = events[0].args as [ChangeEvent[], { last_seq: number }];
|
|
63
|
+
expect(changes[0].seq).toBe(7);
|
|
64
|
+
expect(cursor.last_seq).toBe(7);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("session envelope fires onSessionReceived", () => {
|
|
68
|
+
const { orch, events } = makeRig();
|
|
69
|
+
const resolved: ResolvedSession = {
|
|
70
|
+
userId: "u1",
|
|
71
|
+
tenantId: "org-a",
|
|
72
|
+
isAdmin: false,
|
|
73
|
+
roles: [],
|
|
74
|
+
};
|
|
75
|
+
orch.handleMessage({ type: "session", resolved }, "leader-x");
|
|
76
|
+
expect(events.length).toBe(1);
|
|
77
|
+
expect(events[0].kind).toBe("session");
|
|
78
|
+
expect(events[0].args[0]).toEqual(resolved);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("mutations envelope only fires onMutationsForwarded on the leader", () => {
|
|
82
|
+
const follower = makeRig({ isLeader: false });
|
|
83
|
+
follower.orch.handleMessage(
|
|
84
|
+
{ type: "mutations", ops: [] },
|
|
85
|
+
"follower-x",
|
|
86
|
+
);
|
|
87
|
+
// No event because this rig isn't leader. The orchestrator's
|
|
88
|
+
// leader gate filtered it out.
|
|
89
|
+
expect(follower.events.length).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("mutations-acked + mutations-failed both fire their hooks", () => {
|
|
93
|
+
const { orch, events } = makeRig();
|
|
94
|
+
orch.handleMessage({ type: "mutations-acked", opIds: ["a", "b"] }, "x");
|
|
95
|
+
orch.handleMessage(
|
|
96
|
+
{ type: "mutations-failed", ops: [{ opId: "c", error: "bad" }] },
|
|
97
|
+
"x",
|
|
98
|
+
);
|
|
99
|
+
expect(events.map((e) => e.kind)).toEqual([
|
|
100
|
+
"mutationsAcked",
|
|
101
|
+
"mutationsFailed",
|
|
102
|
+
]);
|
|
103
|
+
expect((events[0].args[0] as string[])[0]).toBe("a");
|
|
104
|
+
expect((events[1].args[0] as { opId: string; error: string }[])[0].opId).toBe(
|
|
105
|
+
"c",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("sub-register and sub-unregister route directly to SubscriptionCoordinator", async () => {
|
|
110
|
+
const { orch, serverSubs, events } = makeRig();
|
|
111
|
+
// Sub-register / sub-unregister are leader-gated inside the
|
|
112
|
+
// orchestrator (followers can't act on a peer's request). Init
|
|
113
|
+
// the orchestrator so it observes itself as leader.
|
|
114
|
+
await orch.init();
|
|
115
|
+
events.length = 0; // drop the initialLeader event from init
|
|
116
|
+
orch.handleMessage(
|
|
117
|
+
{
|
|
118
|
+
type: "sub-register",
|
|
119
|
+
kind: "crdt",
|
|
120
|
+
key: "Todo\x00r1",
|
|
121
|
+
entity: "Todo",
|
|
122
|
+
rowId: "r1",
|
|
123
|
+
},
|
|
124
|
+
"follower-1",
|
|
125
|
+
);
|
|
126
|
+
expect(serverSubs.has("Todo\x00r1")).toBe(true);
|
|
127
|
+
// Engine hooks were NOT fired — subscription dispatch bypasses them.
|
|
128
|
+
expect(events.length).toBe(0);
|
|
129
|
+
orch.handleMessage(
|
|
130
|
+
{
|
|
131
|
+
type: "sub-unregister",
|
|
132
|
+
kind: "crdt",
|
|
133
|
+
key: "Todo\x00r1",
|
|
134
|
+
entity: "Todo",
|
|
135
|
+
rowId: "r1",
|
|
136
|
+
},
|
|
137
|
+
"follower-1",
|
|
138
|
+
);
|
|
139
|
+
expect(serverSubs.has("Todo\x00r1")).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("binary envelope fires onBinaryReceived with the bytes", () => {
|
|
143
|
+
const { orch, events } = makeRig();
|
|
144
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
145
|
+
orch.handleMessage({ type: "binary", bytes }, "leader-x");
|
|
146
|
+
expect(events.length).toBe(1);
|
|
147
|
+
expect(events[0].kind).toBe("binary");
|
|
148
|
+
expect((events[0].args[0] as Uint8Array)[0]).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("reset envelope fires onResetReceived", () => {
|
|
152
|
+
const { orch, events } = makeRig();
|
|
153
|
+
orch.handleMessage({ type: "reset" }, "leader-x");
|
|
154
|
+
expect(events.map((e) => e.kind)).toEqual(["reset"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("unknown envelope is a silent no-op", () => {
|
|
158
|
+
const { orch, events } = makeRig();
|
|
159
|
+
expect(() =>
|
|
160
|
+
orch.handleMessage({ type: "bogus-type-no-one-knows" }, "x"),
|
|
161
|
+
).not.toThrow();
|
|
162
|
+
expect(events.length).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("MultiTabOrchestrator init", () => {
|
|
167
|
+
test("multiTab:false short-circuits to sole-leader", async () => {
|
|
168
|
+
const { orch, events } = makeRig();
|
|
169
|
+
const leader = await orch.init();
|
|
170
|
+
expect(leader).toBe(true);
|
|
171
|
+
expect(events.map((e) => e.kind)).toContain("initialLeader");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// MultiTabOrchestrator — owns the cross-tab coordination protocol.
|
|
2
|
+
//
|
|
3
|
+
// Responsibilities:
|
|
4
|
+
// - Bring up the BroadcastChannel-backed broker and run the
|
|
5
|
+
// election protocol via `MultiTabBroker`.
|
|
6
|
+
// - Route inbound BroadcastChannel messages through the right hook
|
|
7
|
+
// on the engine OR directly through the SubscriptionCoordinator
|
|
8
|
+
// when the case is purely a subscription concern.
|
|
9
|
+
// - Provide typed broadcast helpers for outbound fanout (applied
|
|
10
|
+
// changes, session updates, reset, mutations, reactive results,
|
|
11
|
+
// binary frames, sub-replay requests).
|
|
12
|
+
//
|
|
13
|
+
// Why it lives outside the SyncEngine: the engine used to own the
|
|
14
|
+
// broker field, the isLeader flag, the 11-case `handleMultiTabMessage`
|
|
15
|
+
// switch, and every `broadcastToTabs` call. That bundle is its own
|
|
16
|
+
// concern — the cross-tab protocol — and pulling it out lets the
|
|
17
|
+
// engine focus on data-plane work (pull / push / reconcile / apply /
|
|
18
|
+
// subscribe). The engine still owns the data semantics behind each
|
|
19
|
+
// message; the orchestrator just decides "who sees what and when."
|
|
20
|
+
//
|
|
21
|
+
// Leader/follower bookkeeping: the orchestrator owns `isLeader` and
|
|
22
|
+
// notifies the engine via hooks when it flips. The engine keeps a
|
|
23
|
+
// mirror flag (`isMultiTabLeader`) so the existing call sites that
|
|
24
|
+
// gate on leadership don't need to round-trip into the orchestrator
|
|
25
|
+
// for every check.
|
|
26
|
+
|
|
27
|
+
import { MultiTabBroker } from "./multi-tab";
|
|
28
|
+
import type { PendingMutation } from "./mutation-queue";
|
|
29
|
+
import type { SubscriptionCoordinator } from "./subscription-coordinator";
|
|
30
|
+
import type {
|
|
31
|
+
ChangeEvent,
|
|
32
|
+
ReactiveMessage,
|
|
33
|
+
ResolvedSession,
|
|
34
|
+
Row,
|
|
35
|
+
SyncCursor,
|
|
36
|
+
} from "./types";
|
|
37
|
+
|
|
38
|
+
/** ms the orchestrator waits for the broker's first election to
|
|
39
|
+
* settle before forcing the leader role if no peer claimed it. */
|
|
40
|
+
const ELECTION_SETTLE_MAX_MS = 400;
|
|
41
|
+
|
|
42
|
+
/** Hooks the engine registers to receive inbound multi-tab events.
|
|
43
|
+
* Cases that purely concern subscriptions are NOT routed through
|
|
44
|
+
* these — the orchestrator dispatches them directly to its
|
|
45
|
+
* `SubscriptionCoordinator` reference. */
|
|
46
|
+
export interface MultiTabOrchestratorHooks {
|
|
47
|
+
/** Initial promotion (first election settles with this tab as
|
|
48
|
+
* leader). Fired before the engine's start() proceeds past
|
|
49
|
+
* `initMultiTab()`. */
|
|
50
|
+
onInitialLeader(): void;
|
|
51
|
+
/** Late promotion: the previous leader dropped while we were a
|
|
52
|
+
* follower. Engine performs recovery (replay subs, pull, drain
|
|
53
|
+
* the mutation queue, start the transport). */
|
|
54
|
+
onLatePromote(): void;
|
|
55
|
+
/** Demotion: another tab took over as leader. Engine tears down
|
|
56
|
+
* its transport. */
|
|
57
|
+
onDemote(): void;
|
|
58
|
+
/** Inbound applied-change batch from the current leader. Engine
|
|
59
|
+
* enqueues for apply with `fromBroadcast: true` so the queue
|
|
60
|
+
* doesn't re-broadcast. */
|
|
61
|
+
onAppliedReceived(
|
|
62
|
+
changes: ChangeEvent[],
|
|
63
|
+
targetCursor: SyncCursor | undefined,
|
|
64
|
+
): void;
|
|
65
|
+
/** Inbound reconcile batch from the current leader. */
|
|
66
|
+
onReconciledReceived(
|
|
67
|
+
entity: string,
|
|
68
|
+
upserts: Row[],
|
|
69
|
+
removalIds: string[],
|
|
70
|
+
tombstoneSeq: number,
|
|
71
|
+
): void;
|
|
72
|
+
/** Replica reset broadcast — identity flip happened on the leader. */
|
|
73
|
+
onResetReceived(): void;
|
|
74
|
+
/** Resolved session update from the leader. Engine funnels through
|
|
75
|
+
* its session chain so concurrent triggers commit in order. */
|
|
76
|
+
onSessionReceived(resolved: ResolvedSession): void;
|
|
77
|
+
/** Follower → leader: ops the follower wants pushed. Only fires
|
|
78
|
+
* when this tab is leader. */
|
|
79
|
+
onMutationsForwarded(ops: PendingMutation[]): void;
|
|
80
|
+
/** Leader → follower: op_ids that were successfully pushed. */
|
|
81
|
+
onMutationsAcked(opIds: string[]): void;
|
|
82
|
+
/** Leader → follower: op_ids that failed server-side validation. */
|
|
83
|
+
onMutationsFailed(ops: { opId: string; error: string }[]): void;
|
|
84
|
+
/** Leader → follower: a binary frame from the WS. Engine routes
|
|
85
|
+
* to its local binary handlers. */
|
|
86
|
+
onBinaryReceived(bytes: Uint8Array): void;
|
|
87
|
+
/** A peer tab disappeared (broker observed `bye`). Engine and
|
|
88
|
+
* SubscriptionCoordinator both clean up state for the departed tab. */
|
|
89
|
+
onPeerLeft(tabId: string): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MultiTabOrchestratorConfig {
|
|
93
|
+
/** When false, multi-tab coordination is disabled; this tab acts
|
|
94
|
+
* as a sole leader. */
|
|
95
|
+
enabled?: boolean;
|
|
96
|
+
/** App name used to derive the BroadcastChannel name. Defaults to
|
|
97
|
+
* "default" — apps that share an origin can pin their own name
|
|
98
|
+
* to avoid cross-talk between unrelated installations. */
|
|
99
|
+
appName?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class MultiTabOrchestrator {
|
|
103
|
+
private broker: MultiTabBroker | null = null;
|
|
104
|
+
private _isLeader = false;
|
|
105
|
+
private settled = false;
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly config: MultiTabOrchestratorConfig,
|
|
109
|
+
private readonly subscriptions: SubscriptionCoordinator,
|
|
110
|
+
private readonly hooks: MultiTabOrchestratorHooks,
|
|
111
|
+
) {}
|
|
112
|
+
|
|
113
|
+
// ---- Lifecycle ---------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** Bring up the broker and run the initial election. Resolves when
|
|
116
|
+
* the election has settled (either onPromote fired, or the
|
|
117
|
+
* settle timer expired without a peer claiming leadership).
|
|
118
|
+
* Returns true if this tab is now the leader. */
|
|
119
|
+
async init(): Promise<boolean> {
|
|
120
|
+
if (this.config.enabled === false) {
|
|
121
|
+
this._isLeader = true;
|
|
122
|
+
this.settled = true;
|
|
123
|
+
this.hooks.onInitialLeader();
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (!MultiTabBroker.available()) {
|
|
127
|
+
this._isLeader = true;
|
|
128
|
+
this.settled = true;
|
|
129
|
+
this.hooks.onInitialLeader();
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const channelName = `pylon:${this.config.appName ?? "default"}:multitab`;
|
|
133
|
+
this.broker = new MultiTabBroker();
|
|
134
|
+
await new Promise<void>((resolve) => {
|
|
135
|
+
const finish = () => {
|
|
136
|
+
if (this.settled) return;
|
|
137
|
+
this.settled = true;
|
|
138
|
+
resolve();
|
|
139
|
+
};
|
|
140
|
+
this.broker!.start(channelName, {
|
|
141
|
+
onPromote: () => {
|
|
142
|
+
this._isLeader = true;
|
|
143
|
+
if (!this.settled) {
|
|
144
|
+
// Initial promotion fired before the settle timer — the
|
|
145
|
+
// engine's start() proceeds straight into the leader path.
|
|
146
|
+
this.hooks.onInitialLeader();
|
|
147
|
+
finish();
|
|
148
|
+
} else {
|
|
149
|
+
// Late promotion: the previous leader dropped after our
|
|
150
|
+
// initial settle. Engine recovers (pull, replay subs,
|
|
151
|
+
// drain queued mutations, start transport).
|
|
152
|
+
this.hooks.onLatePromote();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
onDemote: () => {
|
|
156
|
+
this._isLeader = false;
|
|
157
|
+
this.hooks.onDemote();
|
|
158
|
+
},
|
|
159
|
+
onAppMessage: (payload, from) =>
|
|
160
|
+
this.handleMessage(payload, from.tabId),
|
|
161
|
+
onLeave: (tabId) => {
|
|
162
|
+
// Drop forwarded-sub state for the departed tab AND notify
|
|
163
|
+
// the engine in case it wants to do anything else (the
|
|
164
|
+
// engine's current hook just delegates to subscriptions,
|
|
165
|
+
// but the seam is here for future cleanup needs).
|
|
166
|
+
if (this._isLeader) this.subscriptions.scrubPeer(tabId);
|
|
167
|
+
this.hooks.onPeerLeft(tabId);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
// Bound the settle wait: if no peer claims leader within the
|
|
171
|
+
// election window, this tab takes the role.
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (this.settled) return;
|
|
174
|
+
if (this.broker!.isLeader()) {
|
|
175
|
+
this._isLeader = true;
|
|
176
|
+
this.hooks.onInitialLeader();
|
|
177
|
+
}
|
|
178
|
+
finish();
|
|
179
|
+
}, ELECTION_SETTLE_MAX_MS);
|
|
180
|
+
});
|
|
181
|
+
return this._isLeader;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
stop(): void {
|
|
185
|
+
if (this.broker) {
|
|
186
|
+
this.broker.stop();
|
|
187
|
+
this.broker = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- Predicates --------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
isLeader(): boolean {
|
|
194
|
+
return this._isLeader;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- Outbound broadcasts ----------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** No-op when the broker isn't running. The engine uses this for
|
|
200
|
+
* envelope shapes the orchestrator doesn't have first-class
|
|
201
|
+
* helpers for (currently none — kept as an escape hatch). */
|
|
202
|
+
broadcastRaw(payload: unknown): void {
|
|
203
|
+
this.broker?.broadcastApp(payload);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
broadcastApplied(changes: ChangeEvent[], targetCursor?: SyncCursor): void {
|
|
207
|
+
this.broadcastRaw({ type: "applied", changes, targetCursor });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
broadcastReconciled(
|
|
211
|
+
entity: string,
|
|
212
|
+
upserts: Row[],
|
|
213
|
+
removalIds: string[],
|
|
214
|
+
tombstoneSeq: number,
|
|
215
|
+
): void {
|
|
216
|
+
this.broadcastRaw({
|
|
217
|
+
type: "reconciled",
|
|
218
|
+
entity,
|
|
219
|
+
upserts,
|
|
220
|
+
removalIds,
|
|
221
|
+
tombstoneSeq,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
broadcastReset(): void {
|
|
226
|
+
this.broadcastRaw({ type: "reset" });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
broadcastSession(resolved: ResolvedSession): void {
|
|
230
|
+
this.broadcastRaw({ type: "session", resolved });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Follower → leader: forward our pending batch. The leader's
|
|
234
|
+
* engine handles it via `onMutationsForwarded`. */
|
|
235
|
+
forwardMutations(ops: PendingMutation[]): void {
|
|
236
|
+
this.broadcastRaw({ type: "mutations", ops });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Leader → followers: applied op_ids. Followers mark applied + clear. */
|
|
240
|
+
broadcastMutationsAcked(opIds: string[]): void {
|
|
241
|
+
this.broadcastRaw({ type: "mutations-acked", opIds });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Leader → followers: per-op failures with error strings. */
|
|
245
|
+
broadcastMutationsFailed(ops: { opId: string; error: string }[]): void {
|
|
246
|
+
this.broadcastRaw({ type: "mutations-failed", ops });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Leader → followers: a reactive-result / reactive-error landed on
|
|
250
|
+
* the WS. Routed by sub_id to whatever tab owns the local handler. */
|
|
251
|
+
broadcastReactiveMessage(sub_id: string, payload: ReactiveMessage): void {
|
|
252
|
+
this.broadcastRaw({ type: "reactive-msg", sub_id, payload });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Leader → followers: a binary CRDT frame from the WS. Only
|
|
256
|
+
* meaningful when at least one follower has forwarded a CRDT
|
|
257
|
+
* sub — caller (engine) gates on `subscriptions.hasCrdtForwarders()`. */
|
|
258
|
+
broadcastBinary(bytes: Uint8Array): void {
|
|
259
|
+
this.broadcastRaw({ type: "binary", bytes });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** New leader → followers: re-forward your active sub-registers. */
|
|
263
|
+
requestSubReplay(): void {
|
|
264
|
+
this.broadcastRaw({ type: "request-sub-replay" });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- Inbound dispatch --------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/** Public for tests + future debugging. Drives the same path the
|
|
270
|
+
* BroadcastChannel's onmessage hits, so a test can simulate any
|
|
271
|
+
* inbound envelope without standing up a real channel. The engine
|
|
272
|
+
* itself never calls this directly — it goes through the broker. */
|
|
273
|
+
handleMessage(payload: unknown, fromTabId: string): void {
|
|
274
|
+
if (!payload || typeof payload !== "object") return;
|
|
275
|
+
const msg = payload as { type?: string } & Record<string, unknown>;
|
|
276
|
+
switch (msg.type) {
|
|
277
|
+
case "applied": {
|
|
278
|
+
const changes = msg.changes as ChangeEvent[] | undefined;
|
|
279
|
+
const targetCursor = msg.targetCursor as SyncCursor | undefined;
|
|
280
|
+
if (Array.isArray(changes) || targetCursor) {
|
|
281
|
+
this.hooks.onAppliedReceived(changes ?? [], targetCursor);
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "reconciled": {
|
|
286
|
+
const entity = msg.entity as string | undefined;
|
|
287
|
+
const upserts = msg.upserts as Row[] | undefined;
|
|
288
|
+
const removalIds = msg.removalIds as string[] | undefined;
|
|
289
|
+
const tombstoneSeq = msg.tombstoneSeq as number | undefined;
|
|
290
|
+
if (
|
|
291
|
+
typeof entity === "string" &&
|
|
292
|
+
Array.isArray(upserts) &&
|
|
293
|
+
Array.isArray(removalIds) &&
|
|
294
|
+
typeof tombstoneSeq === "number"
|
|
295
|
+
) {
|
|
296
|
+
this.hooks.onReconciledReceived(
|
|
297
|
+
entity,
|
|
298
|
+
upserts,
|
|
299
|
+
removalIds,
|
|
300
|
+
tombstoneSeq,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "reset": {
|
|
306
|
+
this.hooks.onResetReceived();
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case "session": {
|
|
310
|
+
const resolved = msg.resolved as ResolvedSession | undefined;
|
|
311
|
+
if (resolved) this.hooks.onSessionReceived(resolved);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case "mutations": {
|
|
315
|
+
// Follower → leader. Only the leader acts on these.
|
|
316
|
+
if (!this._isLeader) return;
|
|
317
|
+
const ops = msg.ops as PendingMutation[] | undefined;
|
|
318
|
+
if (Array.isArray(ops)) this.hooks.onMutationsForwarded(ops);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "mutations-acked": {
|
|
322
|
+
const opIds = msg.opIds as string[] | undefined;
|
|
323
|
+
if (Array.isArray(opIds)) this.hooks.onMutationsAcked(opIds);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
case "mutations-failed": {
|
|
327
|
+
const ops = msg.ops as
|
|
328
|
+
| { opId: string; error: string }[]
|
|
329
|
+
| undefined;
|
|
330
|
+
if (Array.isArray(ops)) this.hooks.onMutationsFailed(ops);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "sub-register": {
|
|
334
|
+
// Follower → leader. Leader-only — no work to do on a follower
|
|
335
|
+
// because it can't act on a peer's request.
|
|
336
|
+
if (!this._isLeader) return;
|
|
337
|
+
this.subscriptions.handleForwardedRegister(msg, fromTabId);
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "sub-unregister": {
|
|
341
|
+
if (!this._isLeader) return;
|
|
342
|
+
this.subscriptions.handleForwardedUnregister(msg, fromTabId);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "reactive-msg": {
|
|
346
|
+
const sub_id = msg.sub_id as string;
|
|
347
|
+
const payload = msg.payload as ReactiveMessage;
|
|
348
|
+
this.subscriptions.handleReactiveMessage(sub_id, payload);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case "binary": {
|
|
352
|
+
const bytes = msg.bytes as Uint8Array | undefined;
|
|
353
|
+
if (bytes instanceof Uint8Array) this.hooks.onBinaryReceived(bytes);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case "request-sub-replay": {
|
|
357
|
+
// Followers respond. A leader receiving its own broadcast (or
|
|
358
|
+
// an old broadcast post-promotion) ignores — its serverSubs
|
|
359
|
+
// already has the bundle.
|
|
360
|
+
if (this._isLeader) return;
|
|
361
|
+
this.subscriptions.replayForwardedSubs();
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|