@syncular/client 0.1.3 → 0.2.0
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/README.md +227 -709
- package/dist/apply.d.ts +80 -0
- package/dist/apply.js +301 -0
- package/dist/blob.d.ts +127 -0
- package/dist/blob.js +201 -0
- package/dist/bun-database.d.ts +22 -0
- package/dist/bun-database.js +61 -0
- package/dist/client.d.ts +320 -69
- package/dist/client.js +1627 -163
- package/dist/content-type.d.ts +2 -0
- package/dist/content-type.js +2 -0
- package/dist/database.d.ts +35 -80
- package/dist/database.js +32 -947
- package/dist/encryption.d.ts +47 -0
- package/dist/encryption.js +75 -0
- package/dist/errors.d.ts +8 -22
- package/dist/errors.js +10 -207
- package/dist/http.d.ts +32 -0
- package/dist/http.js +196 -0
- package/dist/index.d.ts +28 -16
- package/dist/index.js +25 -12
- package/dist/invalidation.d.ts +69 -0
- package/dist/invalidation.js +84 -0
- package/dist/leader-lock.d.ts +28 -0
- package/dist/leader-lock.js +38 -0
- package/dist/multi-tab.d.ts +134 -0
- package/dist/multi-tab.js +399 -0
- package/dist/node-database.d.ts +41 -0
- package/dist/node-database.js +133 -0
- package/dist/outbox.d.ts +56 -0
- package/dist/outbox.js +140 -0
- package/dist/schema.d.ts +107 -0
- package/dist/schema.js +267 -0
- package/dist/state.d.ts +40 -0
- package/dist/state.js +77 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +7 -0
- package/dist/wasm-database.d.ts +30 -0
- package/dist/wasm-database.js +170 -0
- package/dist/window.d.ts +64 -0
- package/dist/window.js +0 -0
- package/dist/worker-entry.d.ts +16 -2
- package/dist/worker-entry.js +300 -456
- package/dist/worker-host.d.ts +174 -0
- package/dist/worker-host.js +536 -0
- package/dist/worker-protocol.d.ts +173 -61
- package/dist/worker-protocol.js +7 -16
- package/package.json +45 -112
- package/src/apply.ts +435 -0
- package/src/blob.ts +333 -0
- package/src/bun-database.ts +75 -0
- package/src/client.ts +2247 -277
- package/src/content-type.ts +2 -0
- package/src/database.ts +63 -1389
- package/src/encryption.ts +123 -0
- package/src/errors.ts +11 -265
- package/src/http.ts +254 -0
- package/src/index.ts +25 -20
- package/src/invalidation.ts +128 -0
- package/src/leader-lock.ts +68 -0
- package/src/multi-tab.ts +550 -0
- package/src/node-database.ts +180 -0
- package/src/outbox.ts +206 -0
- package/src/schema.ts +397 -0
- package/src/state.ts +126 -0
- package/src/transport.ts +52 -0
- package/src/wasm-database.ts +279 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +391 -545
- package/src/worker-host.ts +800 -0
- package/src/worker-protocol.ts +204 -99
- package/dist/auth-leases.d.ts +0 -11
- package/dist/auth-leases.d.ts.map +0 -1
- package/dist/auth-leases.js +0 -140
- package/dist/auth-leases.js.map +0 -1
- package/dist/blob-limits.d.ts +0 -15
- package/dist/blob-limits.d.ts.map +0 -1
- package/dist/blob-limits.js +0 -66
- package/dist/blob-limits.js.map +0 -1
- package/dist/bridge-client.d.ts +0 -69
- package/dist/bridge-client.d.ts.map +0 -1
- package/dist/bridge-client.js +0 -282
- package/dist/bridge-client.js.map +0 -1
- package/dist/client-config.d.ts +0 -6
- package/dist/client-config.d.ts.map +0 -1
- package/dist/client-config.js +0 -18
- package/dist/client-config.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/command-history.d.ts +0 -35
- package/dist/command-history.d.ts.map +0 -1
- package/dist/command-history.js +0 -378
- package/dist/command-history.js.map +0 -1
- package/dist/console-diagnostics.d.ts +0 -33
- package/dist/console-diagnostics.d.ts.map +0 -1
- package/dist/console-diagnostics.js +0 -496
- package/dist/console-diagnostics.js.map +0 -1
- package/dist/crdt-yjs/index.d.ts +0 -4
- package/dist/crdt-yjs/index.d.ts.map +0 -1
- package/dist/crdt-yjs/index.js +0 -4
- package/dist/crdt-yjs/index.js.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
- package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.js +0 -284
- package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
- package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
- package/dist/database.d.ts.map +0 -1
- package/dist/database.js.map +0 -1
- package/dist/diagnostics.d.ts +0 -10
- package/dist/diagnostics.d.ts.map +0 -1
- package/dist/diagnostics.js +0 -83
- package/dist/diagnostics.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/generated-bridge.d.ts +0 -365
- package/dist/generated-bridge.d.ts.map +0 -1
- package/dist/generated-bridge.js +0 -250
- package/dist/generated-bridge.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mutations.d.ts +0 -72
- package/dist/mutations.d.ts.map +0 -1
- package/dist/mutations.js +0 -63
- package/dist/mutations.js.map +0 -1
- package/dist/network.d.ts +0 -3
- package/dist/network.d.ts.map +0 -1
- package/dist/network.js +0 -17
- package/dist/network.js.map +0 -1
- package/dist/react/index.d.ts +0 -169
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/index.js +0 -628
- package/dist/react/index.js.map +0 -1
- package/dist/react-native/index.d.ts +0 -35
- package/dist/react-native/index.d.ts.map +0 -1
- package/dist/react-native/index.js +0 -49
- package/dist/react-native/index.js.map +0 -1
- package/dist/runtime-contract.d.ts +0 -13
- package/dist/runtime-contract.d.ts.map +0 -1
- package/dist/runtime-contract.js +0 -24
- package/dist/runtime-contract.js.map +0 -1
- package/dist/rust-client.d.ts +0 -106
- package/dist/rust-client.d.ts.map +0 -1
- package/dist/rust-client.js +0 -938
- package/dist/rust-client.js.map +0 -1
- package/dist/sentry.d.ts +0 -35
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js +0 -155
- package/dist/sentry.js.map +0 -1
- package/dist/sql-safety.d.ts +0 -3
- package/dist/sql-safety.d.ts.map +0 -1
- package/dist/sql-safety.js +0 -62
- package/dist/sql-safety.js.map +0 -1
- package/dist/syncular-runtime-artifacts.json +0 -61
- package/dist/tauri/index.d.ts +0 -35
- package/dist/tauri/index.d.ts.map +0 -1
- package/dist/tauri/index.js +0 -114
- package/dist/tauri/index.js.map +0 -1
- package/dist/types.d.ts +0 -1096
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/wasm/.syncular-wasm-profile +0 -1
- package/dist/wasm/syncular-runtime-artifact.json +0 -21
- package/dist/wasm/syncular.d.ts +0 -207
- package/dist/wasm/syncular.js +0 -2341
- package/dist/wasm/syncular_bg.wasm +0 -0
- package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
- package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
- package/dist/wasm-bindings/runtime-contract.js +0 -112
- package/dist/wasm-bindings/runtime-contract.js.map +0 -1
- package/dist/wasm-core/.syncular-wasm-profile +0 -1
- package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
- package/dist/wasm-core/syncular.d.ts +0 -162
- package/dist/wasm-core/syncular.js +0 -1847
- package/dist/wasm-core/syncular_bg.wasm +0 -0
- package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
- package/dist/wasm-perf/.syncular-wasm-profile +0 -1
- package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
- package/dist/wasm-perf/syncular.d.ts +0 -207
- package/dist/wasm-perf/syncular.js +0 -2341
- package/dist/wasm-perf/syncular_bg.wasm +0 -0
- package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-runtime.d.ts +0 -23
- package/dist/wasm-runtime.d.ts.map +0 -1
- package/dist/wasm-runtime.js +0 -69
- package/dist/wasm-runtime.js.map +0 -1
- package/dist/worker-client.d.ts +0 -123
- package/dist/worker-client.d.ts.map +0 -1
- package/dist/worker-client.js +0 -1735
- package/dist/worker-client.js.map +0 -1
- package/dist/worker-entry.d.ts.map +0 -1
- package/dist/worker-entry.js.map +0 -1
- package/dist/worker-protocol.d.ts.map +0 -1
- package/dist/worker-protocol.js.map +0 -1
- package/dist/worker-realtime.d.ts +0 -39
- package/dist/worker-realtime.d.ts.map +0 -1
- package/dist/worker-realtime.js +0 -677
- package/dist/worker-realtime.js.map +0 -1
- package/src/auth-leases.ts +0 -251
- package/src/blob-limits.ts +0 -98
- package/src/bridge-client.ts +0 -512
- package/src/client-config.ts +0 -29
- package/src/command-history.ts +0 -623
- package/src/console-diagnostics.ts +0 -617
- package/src/crdt-yjs/index.ts +0 -3
- package/src/crdt-yjs/webview-host-facade.ts +0 -477
- package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
- package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
- package/src/diagnostics.ts +0 -116
- package/src/generated-bridge.ts +0 -741
- package/src/mutations.ts +0 -168
- package/src/network.ts +0 -32
- package/src/react/index.ts +0 -1036
- package/src/react-native/index.ts +0 -152
- package/src/runtime-contract.ts +0 -48
- package/src/rust-client.ts +0 -1491
- package/src/sentry.ts +0 -215
- package/src/sql-safety.ts +0 -61
- package/src/tauri/index.ts +0 -211
- package/src/types.ts +0 -1397
- package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
- package/src/wasm-bindings/runtime-contract.ts +0 -158
- package/src/wasm-runtime.ts +0 -145
- package/src/worker-client.ts +0 -2289
- package/src/worker-realtime.ts +0 -843
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread side of the worker mode (Direction decision 2) and the
|
|
3
|
+
* multi-tab topology (TODO 3.2, REVISE B3).
|
|
4
|
+
*
|
|
5
|
+
* `createSyncClientHandle` acquires the Web Locks leader lock and, when it
|
|
6
|
+
* wins, spawns the worker running the WHOLE core — so exactly one core runs
|
|
7
|
+
* per origin (the lock IS the invariant: a worker is NEVER spawned without
|
|
8
|
+
* holding the lock). The returned {@link SyncClientHandle} is a thin, fully
|
|
9
|
+
* async proxy over the `worker-protocol` RPC.
|
|
10
|
+
*
|
|
11
|
+
* With `multiTab: true`, a tab that LOSES the election does not resolve to a
|
|
12
|
+
* dead not-leader handle: it becomes a FOLLOWER (`role === 'follower'`) that
|
|
13
|
+
* proxies every call to the leader tab over a BroadcastChannel (see
|
|
14
|
+
* `multi-tab.ts`). When the leader tab closes, its lock releases; the
|
|
15
|
+
* followers contest, the winner PROMOTES in place — spawns the worker over
|
|
16
|
+
* the persisted OPFS database and re-announces — and the handle's `role`
|
|
17
|
+
* flips to `'leader'` with `onRoleChange` firing. The same handle object is
|
|
18
|
+
* kept across the transition so React bindings hold a stable reference.
|
|
19
|
+
*
|
|
20
|
+
* With `multiTab` off (default), behavior is unchanged: the loser is an
|
|
21
|
+
* `isLeader === false` handle whose calls reject with `client.not_leader`.
|
|
22
|
+
*/
|
|
23
|
+
import type { WakeReason } from '@syncular/core';
|
|
24
|
+
import type { BlobRef, CachedBlob } from './blob';
|
|
25
|
+
import type {
|
|
26
|
+
ConflictRecord,
|
|
27
|
+
LeaseState,
|
|
28
|
+
MutationInput,
|
|
29
|
+
PresencePeer,
|
|
30
|
+
RejectionRecord,
|
|
31
|
+
SchemaFloor,
|
|
32
|
+
SubscribeInput,
|
|
33
|
+
SyncClientLimits,
|
|
34
|
+
SyncSummary,
|
|
35
|
+
WindowState,
|
|
36
|
+
} from './client';
|
|
37
|
+
import type { SqlRow, SqlValue } from './database';
|
|
38
|
+
import { ClientSyncError } from './errors';
|
|
39
|
+
import { InvalidationEmitter, type InvalidationListener } from './invalidation';
|
|
40
|
+
import {
|
|
41
|
+
type LeaderLease,
|
|
42
|
+
type LeaderLock,
|
|
43
|
+
singleOwnerLock,
|
|
44
|
+
webLocksLeaderLock,
|
|
45
|
+
} from './leader-lock';
|
|
46
|
+
import {
|
|
47
|
+
broadcastChannelFactory,
|
|
48
|
+
type CrossTabChannel,
|
|
49
|
+
FollowerLink,
|
|
50
|
+
LeaderBridge,
|
|
51
|
+
multiTabChannelName,
|
|
52
|
+
newTabId,
|
|
53
|
+
} from './multi-tab';
|
|
54
|
+
import type { OutboxCommit } from './outbox';
|
|
55
|
+
import type { ClientSchema } from './schema';
|
|
56
|
+
import type { SubscriptionRecord } from './state';
|
|
57
|
+
import type { WindowBase } from './window';
|
|
58
|
+
import {
|
|
59
|
+
type MainToWorkerMessage,
|
|
60
|
+
NOT_LEADER_CODE,
|
|
61
|
+
type SyncWorkerEvent,
|
|
62
|
+
WORKER_FAILED_CODE,
|
|
63
|
+
type WorkerApi,
|
|
64
|
+
type WorkerDatabaseInit,
|
|
65
|
+
type WorkerEndpoints,
|
|
66
|
+
type WorkerErrorShape,
|
|
67
|
+
type WorkerInitConfig,
|
|
68
|
+
type WorkerInitResult,
|
|
69
|
+
type WorkerMethod,
|
|
70
|
+
type WorkerToMainMessage,
|
|
71
|
+
} from './worker-protocol';
|
|
72
|
+
|
|
73
|
+
export type HandleRole = 'leader' | 'follower';
|
|
74
|
+
|
|
75
|
+
export interface SyncClientHandleConfig {
|
|
76
|
+
/**
|
|
77
|
+
* Spawns the worker running `startSyncWorker()` (a factory so bundlers
|
|
78
|
+
* see `new Worker(new URL(...))` at the call site, and so no worker is
|
|
79
|
+
* spawned when this tab loses the leader election). Called again on a
|
|
80
|
+
* follower's promotion.
|
|
81
|
+
*/
|
|
82
|
+
readonly worker: () => Worker;
|
|
83
|
+
readonly schema: ClientSchema;
|
|
84
|
+
readonly database: WorkerDatabaseInit;
|
|
85
|
+
readonly endpoints: WorkerEndpoints;
|
|
86
|
+
readonly clientId?: string;
|
|
87
|
+
readonly limits?: SyncClientLimits;
|
|
88
|
+
/** Worker-side host loop (§8.4); default true. */
|
|
89
|
+
readonly autoSync?: boolean;
|
|
90
|
+
readonly wakeJitterMs?: number;
|
|
91
|
+
/** Default: Web Locks when available, else single-owner. */
|
|
92
|
+
readonly leaderLock?: LeaderLock;
|
|
93
|
+
readonly lockName?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Multi-tab followers (TODO 3.2). When true, a tab that loses the leader
|
|
96
|
+
* election becomes a FOLLOWER that proxies to the leader over a
|
|
97
|
+
* BroadcastChannel, and contests + promotes when the leader closes. When
|
|
98
|
+
* false (default), the loser is a dead `isLeader === false` handle.
|
|
99
|
+
*/
|
|
100
|
+
readonly multiTab?: boolean;
|
|
101
|
+
/** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
|
|
102
|
+
readonly channelFactory?: (name: string) => CrossTabChannel;
|
|
103
|
+
/** Deadline for a follower call (covers the leader-handover gap). */
|
|
104
|
+
readonly followerCallTimeoutMs?: number;
|
|
105
|
+
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
106
|
+
readonly onRoleChange?: (role: HandleRole) => void;
|
|
107
|
+
readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
|
|
108
|
+
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
109
|
+
/** A worker-side autoSync round finished (or failed). */
|
|
110
|
+
readonly onSynced?: (result: {
|
|
111
|
+
readonly summary?: SyncSummary;
|
|
112
|
+
readonly error?: WorkerErrorShape;
|
|
113
|
+
}) => void;
|
|
114
|
+
/** §7.4.5: schema-bump `upgrading` state changed (reset began/completed). */
|
|
115
|
+
readonly onUpgrading?: (upgrading: boolean) => void;
|
|
116
|
+
/** §8.6: presence on a scope key changed. */
|
|
117
|
+
readonly onPresence?: (scopeKey: string) => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface Pending {
|
|
121
|
+
resolve(value: unknown): void;
|
|
122
|
+
reject(error: unknown): void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function defaultLeaderLock(): LeaderLock {
|
|
126
|
+
const nav = (globalThis as { navigator?: { locks?: LockManager } }).navigator;
|
|
127
|
+
return nav?.locks !== undefined
|
|
128
|
+
? webLocksLeaderLock(nav.locks)
|
|
129
|
+
: singleOwnerLock();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A running worker core owned by THIS tab (the leader). Wraps the worker,
|
|
134
|
+
* the RPC pending map, and (in multi-tab mode) the `LeaderBridge` relaying
|
|
135
|
+
* follower requests. Torn down on `close()` or on a graceful demotion.
|
|
136
|
+
*/
|
|
137
|
+
interface LeaderCore {
|
|
138
|
+
readonly clientId: string;
|
|
139
|
+
readonly invoke: (
|
|
140
|
+
method: string,
|
|
141
|
+
args: readonly unknown[],
|
|
142
|
+
) => Promise<unknown>;
|
|
143
|
+
readonly bridge: LeaderBridge | undefined;
|
|
144
|
+
readonly lease: LeaderLease;
|
|
145
|
+
close(terminate: boolean): Promise<void>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The main-thread proxy: the same logical API as `SyncClient`, every method a
|
|
150
|
+
* promise. `role` is `'leader'` (owns the worker) or `'follower'` (proxies to
|
|
151
|
+
* the leader over the channel). Constructed via {@link createSyncClientHandle}.
|
|
152
|
+
*/
|
|
153
|
+
export class SyncClientHandle {
|
|
154
|
+
/** True only for a leader handle. Kept for the pre-multiTab contract. */
|
|
155
|
+
get isLeader(): boolean {
|
|
156
|
+
return this.#role === 'leader';
|
|
157
|
+
}
|
|
158
|
+
get role(): HandleRole {
|
|
159
|
+
return this.#role;
|
|
160
|
+
}
|
|
161
|
+
/** Resolved client id — the leader's; shared by all tabs on this origin. */
|
|
162
|
+
get clientId(): string {
|
|
163
|
+
return this.#clientId;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#role: HandleRole;
|
|
167
|
+
#clientId: string;
|
|
168
|
+
#core: LeaderCore | undefined;
|
|
169
|
+
#follower: FollowerLink | undefined;
|
|
170
|
+
readonly #invalidation: InvalidationEmitter;
|
|
171
|
+
readonly #presence: Set<(scopeKey: string) => void>;
|
|
172
|
+
readonly #roleListeners: Set<(role: HandleRole) => void>;
|
|
173
|
+
#closed = false;
|
|
174
|
+
|
|
175
|
+
/** @internal — use {@link createSyncClientHandle}. */
|
|
176
|
+
constructor(internals: {
|
|
177
|
+
role: HandleRole;
|
|
178
|
+
clientId: string;
|
|
179
|
+
core?: LeaderCore;
|
|
180
|
+
follower?: FollowerLink;
|
|
181
|
+
invalidation: InvalidationEmitter;
|
|
182
|
+
presence: Set<(scopeKey: string) => void>;
|
|
183
|
+
roleListeners?: Set<(role: HandleRole) => void>;
|
|
184
|
+
}) {
|
|
185
|
+
this.#role = internals.role;
|
|
186
|
+
this.#clientId = internals.clientId;
|
|
187
|
+
this.#core = internals.core;
|
|
188
|
+
this.#follower = internals.follower;
|
|
189
|
+
this.#invalidation = internals.invalidation;
|
|
190
|
+
this.#presence = internals.presence;
|
|
191
|
+
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @internal — swap this handle from follower to leader (promotion). */
|
|
195
|
+
__becomeLeader(core: LeaderCore): void {
|
|
196
|
+
this.#follower?.close();
|
|
197
|
+
this.#follower = undefined;
|
|
198
|
+
this.#core = core;
|
|
199
|
+
this.#clientId = core.clientId;
|
|
200
|
+
this.#role = 'leader';
|
|
201
|
+
for (const listener of this.#roleListeners) {
|
|
202
|
+
try {
|
|
203
|
+
listener('leader');
|
|
204
|
+
} catch {
|
|
205
|
+
/* a UI listener must never break promotion */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
211
|
+
__dispatchEvent(event: SyncWorkerEvent): void {
|
|
212
|
+
if (event.kind === 'presence') {
|
|
213
|
+
for (const listener of this.#presence) {
|
|
214
|
+
try {
|
|
215
|
+
listener(event.scopeKey);
|
|
216
|
+
} catch {
|
|
217
|
+
/* a UI listener must never break event dispatch */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else if (event.kind === 'invalidate') {
|
|
221
|
+
this.#invalidation.emit(event.event);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* TODO 3.1 / I1: subscribe to fine-grained invalidation — the identical
|
|
227
|
+
* surface as `SyncClient.onInvalidate`, so React bindings target one
|
|
228
|
+
* interface across direct, worker-leader, and follower modes. Returns an
|
|
229
|
+
* unsubscribe function.
|
|
230
|
+
*/
|
|
231
|
+
onInvalidate(listener: InvalidationListener): () => void {
|
|
232
|
+
return this.#invalidation.on(listener);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* §8.6: subscribe to presence changes — the identical surface as
|
|
237
|
+
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
238
|
+
*/
|
|
239
|
+
onPresence(listener: (scopeKey: string) => void): () => void {
|
|
240
|
+
this.#presence.add(listener);
|
|
241
|
+
return () => {
|
|
242
|
+
this.#presence.delete(listener);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Subscribe to role transitions (follower → leader on promotion). */
|
|
247
|
+
onRoleChange(listener: (role: HandleRole) => void): () => void {
|
|
248
|
+
this.#roleListeners.add(listener);
|
|
249
|
+
return () => {
|
|
250
|
+
this.#roleListeners.delete(listener);
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#call<M extends WorkerMethod>(
|
|
255
|
+
method: M,
|
|
256
|
+
args: Parameters<WorkerApi[M]>,
|
|
257
|
+
): Promise<Awaited<ReturnType<WorkerApi[M]>>> {
|
|
258
|
+
if (this.#closed) {
|
|
259
|
+
return Promise.reject(
|
|
260
|
+
new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (this.#role === 'follower') {
|
|
264
|
+
if (this.#follower === undefined) {
|
|
265
|
+
return Promise.reject(
|
|
266
|
+
new ClientSyncError(
|
|
267
|
+
NOT_LEADER_CODE,
|
|
268
|
+
'this tab is not the leader — another tab owns the syncular ' +
|
|
269
|
+
'core for this origin (enable multiTab for follower proxying)',
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return this.#follower.call(method, args) as Promise<
|
|
274
|
+
Awaited<ReturnType<WorkerApi[M]>>
|
|
275
|
+
>;
|
|
276
|
+
}
|
|
277
|
+
if (this.#core === undefined) {
|
|
278
|
+
return Promise.reject(
|
|
279
|
+
new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return this.#core.invoke(method, args) as Promise<
|
|
283
|
+
Awaited<ReturnType<WorkerApi[M]>>
|
|
284
|
+
>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
subscribe(input: SubscribeInput): Promise<void> {
|
|
288
|
+
return this.#call('subscribe', [input]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
unsubscribe(id: string): Promise<void> {
|
|
292
|
+
return this.#call('unsubscribe', [id]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
setWindow(base: WindowBase, units: readonly string[]): Promise<void> {
|
|
296
|
+
return this.#call('setWindow', [base, units]);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
windowState(base: WindowBase): Promise<WindowState> {
|
|
300
|
+
return this.#call('windowState', [base]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
mutate(mutations: readonly MutationInput[]): Promise<string> {
|
|
304
|
+
return this.#call('mutate', [mutations]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
sync(): Promise<SyncSummary> {
|
|
308
|
+
return this.#call('sync', []);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
|
|
312
|
+
return this.#call('syncUntilIdle', [maxRounds]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]> {
|
|
316
|
+
return this.#call('query', [sql, params]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
conflicts(): Promise<readonly ConflictRecord[]> {
|
|
320
|
+
return this.#call('conflicts', []);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
rejections(): Promise<readonly RejectionRecord[]> {
|
|
324
|
+
return this.#call('rejections', []);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
schemaFloor(): Promise<SchemaFloor | undefined> {
|
|
328
|
+
return this.#call('schemaFloor', []);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
leaseState(): Promise<LeaseState | undefined> {
|
|
332
|
+
return this.#call('leaseState', []);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
|
|
336
|
+
upgrading(): Promise<boolean> {
|
|
337
|
+
return this.#call('upgrading', []);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
syncNeeded(): Promise<boolean> {
|
|
341
|
+
return this.#call('syncNeeded', []);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
pendingCommits(): Promise<OutboxCommit[]> {
|
|
345
|
+
return this.#call('pendingCommits', []);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
subscriptions(): Promise<SubscriptionRecord[]> {
|
|
349
|
+
return this.#call('subscriptions', []);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
subscription(id: string): Promise<SubscriptionRecord | undefined> {
|
|
353
|
+
return this.#call('subscription', [id]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
connectRealtime(): Promise<void> {
|
|
357
|
+
return this.#call('connectRealtime', []);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
disconnectRealtime(): Promise<void> {
|
|
361
|
+
return this.#call('disconnectRealtime', []);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** §8.6: publish/clear a scope-keyed presence document. */
|
|
365
|
+
setPresence(
|
|
366
|
+
scopeKey: string,
|
|
367
|
+
doc: Record<string, unknown> | null,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
return this.#call('setPresence', [scopeKey, doc]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** §8.6: the peers currently present on a scope key. */
|
|
373
|
+
presence(scopeKey: string): Promise<readonly PresencePeer[]> {
|
|
374
|
+
return this.#call('presence', [scopeKey]);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
uploadBlob(
|
|
378
|
+
bytes: Uint8Array,
|
|
379
|
+
options?: { readonly mediaType?: string; readonly name?: string },
|
|
380
|
+
): Promise<BlobRef> {
|
|
381
|
+
return this.#call('uploadBlob', [bytes, options]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
|
|
385
|
+
return this.#call('fetchBlob', [blobIdOrRef]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Sever/restore transport + realtime inside the worker (demos). */
|
|
389
|
+
setOffline(offline: boolean): Promise<void> {
|
|
390
|
+
return this.#call('setOffline', [offline]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Close the core (leader) or unbind the link (follower), release leadership. */
|
|
394
|
+
async close(): Promise<void> {
|
|
395
|
+
if (this.#closed) return;
|
|
396
|
+
this.#closed = true;
|
|
397
|
+
if (this.#follower !== undefined) {
|
|
398
|
+
this.#follower.close();
|
|
399
|
+
this.#follower = undefined;
|
|
400
|
+
}
|
|
401
|
+
if (this.#core !== undefined) {
|
|
402
|
+
const core = this.#core;
|
|
403
|
+
this.#core = undefined;
|
|
404
|
+
await core.close(true);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Spawn the worker, run the init handshake, and return a running leader core.
|
|
411
|
+
* `dispatchEvent` receives every worker event; `bridge` (when supplied) is
|
|
412
|
+
* the follower relay whose lifetime is tied to this core.
|
|
413
|
+
*/
|
|
414
|
+
async function startWorkerCore(options: {
|
|
415
|
+
config: SyncClientHandleConfig;
|
|
416
|
+
initConfig: WorkerInitConfig;
|
|
417
|
+
lease: LeaderLease;
|
|
418
|
+
dispatchEvent: (event: SyncWorkerEvent) => void;
|
|
419
|
+
makeBridge?: (
|
|
420
|
+
clientId: string,
|
|
421
|
+
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>,
|
|
422
|
+
) => LeaderBridge;
|
|
423
|
+
}): Promise<LeaderCore> {
|
|
424
|
+
const { config, initConfig, lease } = options;
|
|
425
|
+
const worker = config.worker();
|
|
426
|
+
const pending = new Map<number, Pending>();
|
|
427
|
+
const nextId = { value: 1 };
|
|
428
|
+
|
|
429
|
+
const invoke = (
|
|
430
|
+
method: string,
|
|
431
|
+
args: readonly unknown[],
|
|
432
|
+
): Promise<unknown> => {
|
|
433
|
+
const id = nextId.value++;
|
|
434
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
435
|
+
pending.set(id, { resolve, reject });
|
|
436
|
+
worker.postMessage({
|
|
437
|
+
t: 'call',
|
|
438
|
+
id,
|
|
439
|
+
method,
|
|
440
|
+
args,
|
|
441
|
+
} as MainToWorkerMessage);
|
|
442
|
+
});
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
let bridge: LeaderBridge | undefined;
|
|
446
|
+
|
|
447
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
448
|
+
const onMessage = (event: MessageEvent) => {
|
|
449
|
+
const message = event.data as WorkerToMainMessage;
|
|
450
|
+
switch (message.t) {
|
|
451
|
+
case 'ready':
|
|
452
|
+
resolve();
|
|
453
|
+
break;
|
|
454
|
+
case 'result': {
|
|
455
|
+
const entry = pending.get(message.id);
|
|
456
|
+
pending.delete(message.id);
|
|
457
|
+
entry?.resolve(message.value);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case 'error': {
|
|
461
|
+
const entry = pending.get(message.id);
|
|
462
|
+
pending.delete(message.id);
|
|
463
|
+
entry?.reject(
|
|
464
|
+
new ClientSyncError(
|
|
465
|
+
message.error.code,
|
|
466
|
+
message.error.message,
|
|
467
|
+
message.error.retryable,
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case 'event':
|
|
473
|
+
options.dispatchEvent(message.event);
|
|
474
|
+
bridge?.broadcastEvent(message.event);
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const onError = (event: ErrorEvent) => {
|
|
479
|
+
const error = new ClientSyncError(
|
|
480
|
+
WORKER_FAILED_CODE,
|
|
481
|
+
`the sync worker failed: ${event.message ?? 'unknown error'}`,
|
|
482
|
+
);
|
|
483
|
+
reject(error);
|
|
484
|
+
for (const entry of pending.values()) entry.reject(error);
|
|
485
|
+
pending.clear();
|
|
486
|
+
};
|
|
487
|
+
worker.addEventListener('message', onMessage);
|
|
488
|
+
worker.addEventListener('error', onError as EventListener);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
await ready;
|
|
493
|
+
const initResult = await new Promise<WorkerInitResult>(
|
|
494
|
+
(resolve, reject) => {
|
|
495
|
+
const id = nextId.value++;
|
|
496
|
+
pending.set(id, {
|
|
497
|
+
resolve: (value) => resolve(value as WorkerInitResult),
|
|
498
|
+
reject,
|
|
499
|
+
});
|
|
500
|
+
worker.postMessage({
|
|
501
|
+
t: 'init',
|
|
502
|
+
id,
|
|
503
|
+
config: initConfig,
|
|
504
|
+
} as MainToWorkerMessage);
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
if (options.makeBridge !== undefined) {
|
|
508
|
+
bridge = options.makeBridge(initResult.clientId, invoke);
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
clientId: initResult.clientId,
|
|
512
|
+
invoke,
|
|
513
|
+
bridge,
|
|
514
|
+
lease,
|
|
515
|
+
close: async (terminate) => {
|
|
516
|
+
bridge?.close();
|
|
517
|
+
try {
|
|
518
|
+
await invoke('close', []);
|
|
519
|
+
} catch {
|
|
520
|
+
// Closing a wedged worker still terminates it below.
|
|
521
|
+
}
|
|
522
|
+
if (terminate) worker.terminate();
|
|
523
|
+
const closedError = new ClientSyncError(
|
|
524
|
+
WORKER_FAILED_CODE,
|
|
525
|
+
'the handle was closed',
|
|
526
|
+
);
|
|
527
|
+
for (const entry of pending.values()) entry.reject(closedError);
|
|
528
|
+
pending.clear();
|
|
529
|
+
await lease.release();
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
} catch (error) {
|
|
533
|
+
worker.terminate();
|
|
534
|
+
await lease.release();
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
|
|
540
|
+
return {
|
|
541
|
+
schema: config.schema,
|
|
542
|
+
database: config.database,
|
|
543
|
+
endpoints: config.endpoints,
|
|
544
|
+
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
545
|
+
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
546
|
+
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
547
|
+
...(config.wakeJitterMs !== undefined
|
|
548
|
+
? { wakeJitterMs: config.wakeJitterMs }
|
|
549
|
+
: {}),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Route a worker event to the config-level callbacks (leader visibility). */
|
|
554
|
+
function fireConfigCallbacks(
|
|
555
|
+
config: SyncClientHandleConfig,
|
|
556
|
+
event: SyncWorkerEvent,
|
|
557
|
+
): void {
|
|
558
|
+
if (event.kind === 'sync-needed') {
|
|
559
|
+
config.onSyncNeeded?.(event.reason);
|
|
560
|
+
} else if (event.kind === 'conflict') {
|
|
561
|
+
config.onConflict?.(event.conflict);
|
|
562
|
+
} else if (event.kind === 'upgrading') {
|
|
563
|
+
config.onUpgrading?.(event.upgrading);
|
|
564
|
+
} else if (event.kind === 'presence') {
|
|
565
|
+
config.onPresence?.(event.scopeKey);
|
|
566
|
+
} else if (event.kind === 'synced') {
|
|
567
|
+
config.onSynced?.({
|
|
568
|
+
...(event.summary !== undefined ? { summary: event.summary } : {}),
|
|
569
|
+
...(event.error !== undefined ? { error: event.error } : {}),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Acquire leadership, spawn the worker, initialize the core inside it.
|
|
576
|
+
*
|
|
577
|
+
* With `multiTab` off: a losing tab resolves to a dead not-leader handle.
|
|
578
|
+
* With `multiTab` on: a losing tab becomes a FOLLOWER proxying to the leader,
|
|
579
|
+
* and promotes itself if the leader later closes.
|
|
580
|
+
*/
|
|
581
|
+
export async function createSyncClientHandle(
|
|
582
|
+
config: SyncClientHandleConfig,
|
|
583
|
+
): Promise<SyncClientHandle> {
|
|
584
|
+
const lock = config.leaderLock ?? defaultLeaderLock();
|
|
585
|
+
const lockName = config.lockName ?? 'syncular-leader';
|
|
586
|
+
const invalidation = new InvalidationEmitter();
|
|
587
|
+
const presence = new Set<(scopeKey: string) => void>();
|
|
588
|
+
const roleListeners = new Set<(role: HandleRole) => void>();
|
|
589
|
+
if (config.onRoleChange !== undefined) roleListeners.add(config.onRoleChange);
|
|
590
|
+
|
|
591
|
+
// Leadership BEFORE the worker exists: one core per origin, and a losing
|
|
592
|
+
// tab never boots a database it must not own.
|
|
593
|
+
const lease =
|
|
594
|
+
lock.tryAcquire !== undefined
|
|
595
|
+
? await lock.tryAcquire(lockName)
|
|
596
|
+
: await lock.acquire(lockName);
|
|
597
|
+
|
|
598
|
+
// ---- Won the election: leader. ----
|
|
599
|
+
if (lease !== undefined) {
|
|
600
|
+
// Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
|
|
601
|
+
// the highest epoch it has seen and adds one, so leaders monotonically
|
|
602
|
+
// increase it across handovers.
|
|
603
|
+
return await bootLeader(config, lockName, lease, {
|
|
604
|
+
epoch: 0,
|
|
605
|
+
invalidation,
|
|
606
|
+
presence,
|
|
607
|
+
roleListeners,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---- Lost the election. ----
|
|
612
|
+
if (config.multiTab !== true) {
|
|
613
|
+
// Legacy single-tab contract: a dead not-leader handle.
|
|
614
|
+
return new SyncClientHandle({
|
|
615
|
+
role: 'follower',
|
|
616
|
+
clientId: '',
|
|
617
|
+
invalidation,
|
|
618
|
+
presence,
|
|
619
|
+
roleListeners,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
624
|
+
return await bootFollower(config, lockName, lock, {
|
|
625
|
+
invalidation,
|
|
626
|
+
presence,
|
|
627
|
+
roleListeners,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
interface HandleParts {
|
|
632
|
+
epoch?: number;
|
|
633
|
+
invalidation: InvalidationEmitter;
|
|
634
|
+
presence: Set<(scopeKey: string) => void>;
|
|
635
|
+
roleListeners: Set<(role: HandleRole) => void>;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
|
|
639
|
+
async function bootLeader(
|
|
640
|
+
config: SyncClientHandleConfig,
|
|
641
|
+
lockName: string,
|
|
642
|
+
lease: LeaderLease,
|
|
643
|
+
parts: HandleParts,
|
|
644
|
+
): Promise<SyncClientHandle> {
|
|
645
|
+
const handleRef: { handle: SyncClientHandle | undefined } = {
|
|
646
|
+
handle: undefined,
|
|
647
|
+
};
|
|
648
|
+
const dispatchEvent = (event: SyncWorkerEvent): void => {
|
|
649
|
+
fireConfigCallbacks(config, event);
|
|
650
|
+
handleRef.handle?.__dispatchEvent(event);
|
|
651
|
+
};
|
|
652
|
+
const makeBridge =
|
|
653
|
+
config.multiTab === true
|
|
654
|
+
? (
|
|
655
|
+
clientId: string,
|
|
656
|
+
invoke: (
|
|
657
|
+
method: string,
|
|
658
|
+
args: readonly unknown[],
|
|
659
|
+
) => Promise<unknown>,
|
|
660
|
+
): LeaderBridge => {
|
|
661
|
+
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
662
|
+
const channel = factory(multiTabChannelName(lockName));
|
|
663
|
+
return new LeaderBridge({
|
|
664
|
+
channel,
|
|
665
|
+
epoch: parts.epoch ?? 0,
|
|
666
|
+
clientId,
|
|
667
|
+
invoke,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
: undefined;
|
|
671
|
+
|
|
672
|
+
const core = await startWorkerCore({
|
|
673
|
+
config,
|
|
674
|
+
initConfig: buildInitConfig(config),
|
|
675
|
+
lease,
|
|
676
|
+
dispatchEvent,
|
|
677
|
+
...(makeBridge !== undefined ? { makeBridge } : {}),
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const handle = new SyncClientHandle({
|
|
681
|
+
role: 'leader',
|
|
682
|
+
clientId: core.clientId,
|
|
683
|
+
core,
|
|
684
|
+
invalidation: parts.invalidation,
|
|
685
|
+
presence: parts.presence,
|
|
686
|
+
roleListeners: parts.roleListeners,
|
|
687
|
+
});
|
|
688
|
+
handleRef.handle = handle;
|
|
689
|
+
return handle;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Boot a follower: open the channel, bind to the leader, and race the lock in
|
|
694
|
+
* the background so this tab promotes itself the instant the leader closes.
|
|
695
|
+
*/
|
|
696
|
+
async function bootFollower(
|
|
697
|
+
config: SyncClientHandleConfig,
|
|
698
|
+
lockName: string,
|
|
699
|
+
lock: LeaderLock,
|
|
700
|
+
parts: HandleParts,
|
|
701
|
+
): Promise<SyncClientHandle> {
|
|
702
|
+
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
703
|
+
const channel = factory(multiTabChannelName(lockName));
|
|
704
|
+
const handleRef: { handle: SyncClientHandle | undefined } = {
|
|
705
|
+
handle: undefined,
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const follower = new FollowerLink({
|
|
709
|
+
channel,
|
|
710
|
+
fromId: newTabId(),
|
|
711
|
+
onEvent: (event) => handleRef.handle?.__dispatchEvent(event),
|
|
712
|
+
onLeaderChange: (clientId) => {
|
|
713
|
+
// Learn the leader's shared client id (best-effort; the handle exposes
|
|
714
|
+
// it after binding). Nothing else to do — calls already flush.
|
|
715
|
+
void clientId;
|
|
716
|
+
},
|
|
717
|
+
...(config.followerCallTimeoutMs !== undefined
|
|
718
|
+
? { callTimeoutMs: config.followerCallTimeoutMs }
|
|
719
|
+
: {}),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// A follower waits (blocking) on the exclusive lock: it resolves ONLY when
|
|
723
|
+
// the current leader releases it (tab close). Winning it triggers promotion.
|
|
724
|
+
// We do NOT hold this promise — it settles asynchronously.
|
|
725
|
+
void lock.acquire(lockName).then(async (lease) => {
|
|
726
|
+
const handle = handleRef.handle;
|
|
727
|
+
if (handle === undefined) {
|
|
728
|
+
// Handle was never assembled (shouldn't happen); drop the lease.
|
|
729
|
+
await lease.release();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
// The follower saw the departing leader's epoch; the new leader must
|
|
733
|
+
// strictly exceed it so stale replies/events are discarded everywhere.
|
|
734
|
+
const nextEpoch = follower.maxEpochSeen + 1;
|
|
735
|
+
// Unbind the link so any late leader traffic is ignored, then promote.
|
|
736
|
+
follower.unbind();
|
|
737
|
+
try {
|
|
738
|
+
const core = await startWorkerCore({
|
|
739
|
+
config,
|
|
740
|
+
initConfig: buildInitConfig(config),
|
|
741
|
+
lease,
|
|
742
|
+
dispatchEvent: (event) => {
|
|
743
|
+
fireConfigCallbacks(config, event);
|
|
744
|
+
handle.__dispatchEvent(event);
|
|
745
|
+
},
|
|
746
|
+
...(config.multiTab === true
|
|
747
|
+
? {
|
|
748
|
+
makeBridge: (
|
|
749
|
+
clientId: string,
|
|
750
|
+
invoke: (
|
|
751
|
+
method: string,
|
|
752
|
+
args: readonly unknown[],
|
|
753
|
+
) => Promise<unknown>,
|
|
754
|
+
): LeaderBridge => {
|
|
755
|
+
const promoteChannel = factory(multiTabChannelName(lockName));
|
|
756
|
+
return new LeaderBridge({
|
|
757
|
+
channel: promoteChannel,
|
|
758
|
+
epoch: nextEpoch,
|
|
759
|
+
clientId,
|
|
760
|
+
invoke,
|
|
761
|
+
});
|
|
762
|
+
},
|
|
763
|
+
}
|
|
764
|
+
: {}),
|
|
765
|
+
});
|
|
766
|
+
handle.__becomeLeader(core);
|
|
767
|
+
} catch {
|
|
768
|
+
// Promotion failed to spawn a worker — release so the next tab tries.
|
|
769
|
+
await lease.release();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const handle = new SyncClientHandle({
|
|
774
|
+
role: 'follower',
|
|
775
|
+
// The follower learns the leader's shared client id once bound; expose it
|
|
776
|
+
// lazily via `clientId` is not possible on a getter over the link, so we
|
|
777
|
+
// leave '' until promotion (the shared id is the leader's — hooks that
|
|
778
|
+
// need it read it after a round). Followers rarely need clientId directly.
|
|
779
|
+
clientId: '',
|
|
780
|
+
follower,
|
|
781
|
+
invalidation: parts.invalidation,
|
|
782
|
+
presence: parts.presence,
|
|
783
|
+
roleListeners: parts.roleListeners,
|
|
784
|
+
});
|
|
785
|
+
handleRef.handle = handle;
|
|
786
|
+
// Do not hand back a follower until its link has bound to the leader (the
|
|
787
|
+
// hello→announce round trip completed). Before binding, epoch is -1 and any
|
|
788
|
+
// event the leader fans out is dropped, so a caller that subscribes and then
|
|
789
|
+
// relies on push invalidation would silently miss events emitted in the
|
|
790
|
+
// binding window — a real multi-tab race, not just a test flake. Binding is
|
|
791
|
+
// fast (the leader answers every hello with an announce); on the off chance
|
|
792
|
+
// it times out we still return the handle (queued calls flush and events
|
|
793
|
+
// resume on the next announce) rather than failing handle construction.
|
|
794
|
+
try {
|
|
795
|
+
await follower.waitUntilBound();
|
|
796
|
+
} catch {
|
|
797
|
+
/* bind timed out — return the (degraded but functional) handle anyway */
|
|
798
|
+
}
|
|
799
|
+
return handle;
|
|
800
|
+
}
|