@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,536 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { InvalidationEmitter } from './invalidation.js';
|
|
3
|
+
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
4
|
+
import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
5
|
+
import { NOT_LEADER_CODE, WORKER_FAILED_CODE, } from './worker-protocol.js';
|
|
6
|
+
function defaultLeaderLock() {
|
|
7
|
+
const nav = globalThis.navigator;
|
|
8
|
+
return nav?.locks !== undefined
|
|
9
|
+
? webLocksLeaderLock(nav.locks)
|
|
10
|
+
: singleOwnerLock();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The main-thread proxy: the same logical API as `SyncClient`, every method a
|
|
14
|
+
* promise. `role` is `'leader'` (owns the worker) or `'follower'` (proxies to
|
|
15
|
+
* the leader over the channel). Constructed via {@link createSyncClientHandle}.
|
|
16
|
+
*/
|
|
17
|
+
export class SyncClientHandle {
|
|
18
|
+
/** True only for a leader handle. Kept for the pre-multiTab contract. */
|
|
19
|
+
get isLeader() {
|
|
20
|
+
return this.#role === 'leader';
|
|
21
|
+
}
|
|
22
|
+
get role() {
|
|
23
|
+
return this.#role;
|
|
24
|
+
}
|
|
25
|
+
/** Resolved client id — the leader's; shared by all tabs on this origin. */
|
|
26
|
+
get clientId() {
|
|
27
|
+
return this.#clientId;
|
|
28
|
+
}
|
|
29
|
+
#role;
|
|
30
|
+
#clientId;
|
|
31
|
+
#core;
|
|
32
|
+
#follower;
|
|
33
|
+
#invalidation;
|
|
34
|
+
#presence;
|
|
35
|
+
#roleListeners;
|
|
36
|
+
#closed = false;
|
|
37
|
+
/** @internal — use {@link createSyncClientHandle}. */
|
|
38
|
+
constructor(internals) {
|
|
39
|
+
this.#role = internals.role;
|
|
40
|
+
this.#clientId = internals.clientId;
|
|
41
|
+
this.#core = internals.core;
|
|
42
|
+
this.#follower = internals.follower;
|
|
43
|
+
this.#invalidation = internals.invalidation;
|
|
44
|
+
this.#presence = internals.presence;
|
|
45
|
+
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
46
|
+
}
|
|
47
|
+
/** @internal — swap this handle from follower to leader (promotion). */
|
|
48
|
+
__becomeLeader(core) {
|
|
49
|
+
this.#follower?.close();
|
|
50
|
+
this.#follower = undefined;
|
|
51
|
+
this.#core = core;
|
|
52
|
+
this.#clientId = core.clientId;
|
|
53
|
+
this.#role = 'leader';
|
|
54
|
+
for (const listener of this.#roleListeners) {
|
|
55
|
+
try {
|
|
56
|
+
listener('leader');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* a UI listener must never break promotion */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
64
|
+
__dispatchEvent(event) {
|
|
65
|
+
if (event.kind === 'presence') {
|
|
66
|
+
for (const listener of this.#presence) {
|
|
67
|
+
try {
|
|
68
|
+
listener(event.scopeKey);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* a UI listener must never break event dispatch */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (event.kind === 'invalidate') {
|
|
76
|
+
this.#invalidation.emit(event.event);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* TODO 3.1 / I1: subscribe to fine-grained invalidation — the identical
|
|
81
|
+
* surface as `SyncClient.onInvalidate`, so React bindings target one
|
|
82
|
+
* interface across direct, worker-leader, and follower modes. Returns an
|
|
83
|
+
* unsubscribe function.
|
|
84
|
+
*/
|
|
85
|
+
onInvalidate(listener) {
|
|
86
|
+
return this.#invalidation.on(listener);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* §8.6: subscribe to presence changes — the identical surface as
|
|
90
|
+
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
91
|
+
*/
|
|
92
|
+
onPresence(listener) {
|
|
93
|
+
this.#presence.add(listener);
|
|
94
|
+
return () => {
|
|
95
|
+
this.#presence.delete(listener);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Subscribe to role transitions (follower → leader on promotion). */
|
|
99
|
+
onRoleChange(listener) {
|
|
100
|
+
this.#roleListeners.add(listener);
|
|
101
|
+
return () => {
|
|
102
|
+
this.#roleListeners.delete(listener);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
#call(method, args) {
|
|
106
|
+
if (this.#closed) {
|
|
107
|
+
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
|
|
108
|
+
}
|
|
109
|
+
if (this.#role === 'follower') {
|
|
110
|
+
if (this.#follower === undefined) {
|
|
111
|
+
return Promise.reject(new ClientSyncError(NOT_LEADER_CODE, 'this tab is not the leader — another tab owns the syncular ' +
|
|
112
|
+
'core for this origin (enable multiTab for follower proxying)'));
|
|
113
|
+
}
|
|
114
|
+
return this.#follower.call(method, args);
|
|
115
|
+
}
|
|
116
|
+
if (this.#core === undefined) {
|
|
117
|
+
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
|
|
118
|
+
}
|
|
119
|
+
return this.#core.invoke(method, args);
|
|
120
|
+
}
|
|
121
|
+
subscribe(input) {
|
|
122
|
+
return this.#call('subscribe', [input]);
|
|
123
|
+
}
|
|
124
|
+
unsubscribe(id) {
|
|
125
|
+
return this.#call('unsubscribe', [id]);
|
|
126
|
+
}
|
|
127
|
+
setWindow(base, units) {
|
|
128
|
+
return this.#call('setWindow', [base, units]);
|
|
129
|
+
}
|
|
130
|
+
windowState(base) {
|
|
131
|
+
return this.#call('windowState', [base]);
|
|
132
|
+
}
|
|
133
|
+
mutate(mutations) {
|
|
134
|
+
return this.#call('mutate', [mutations]);
|
|
135
|
+
}
|
|
136
|
+
sync() {
|
|
137
|
+
return this.#call('sync', []);
|
|
138
|
+
}
|
|
139
|
+
syncUntilIdle(maxRounds) {
|
|
140
|
+
return this.#call('syncUntilIdle', [maxRounds]);
|
|
141
|
+
}
|
|
142
|
+
query(sql, params) {
|
|
143
|
+
return this.#call('query', [sql, params]);
|
|
144
|
+
}
|
|
145
|
+
conflicts() {
|
|
146
|
+
return this.#call('conflicts', []);
|
|
147
|
+
}
|
|
148
|
+
rejections() {
|
|
149
|
+
return this.#call('rejections', []);
|
|
150
|
+
}
|
|
151
|
+
schemaFloor() {
|
|
152
|
+
return this.#call('schemaFloor', []);
|
|
153
|
+
}
|
|
154
|
+
leaseState() {
|
|
155
|
+
return this.#call('leaseState', []);
|
|
156
|
+
}
|
|
157
|
+
/** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
|
|
158
|
+
upgrading() {
|
|
159
|
+
return this.#call('upgrading', []);
|
|
160
|
+
}
|
|
161
|
+
syncNeeded() {
|
|
162
|
+
return this.#call('syncNeeded', []);
|
|
163
|
+
}
|
|
164
|
+
pendingCommits() {
|
|
165
|
+
return this.#call('pendingCommits', []);
|
|
166
|
+
}
|
|
167
|
+
subscriptions() {
|
|
168
|
+
return this.#call('subscriptions', []);
|
|
169
|
+
}
|
|
170
|
+
subscription(id) {
|
|
171
|
+
return this.#call('subscription', [id]);
|
|
172
|
+
}
|
|
173
|
+
connectRealtime() {
|
|
174
|
+
return this.#call('connectRealtime', []);
|
|
175
|
+
}
|
|
176
|
+
disconnectRealtime() {
|
|
177
|
+
return this.#call('disconnectRealtime', []);
|
|
178
|
+
}
|
|
179
|
+
/** §8.6: publish/clear a scope-keyed presence document. */
|
|
180
|
+
setPresence(scopeKey, doc) {
|
|
181
|
+
return this.#call('setPresence', [scopeKey, doc]);
|
|
182
|
+
}
|
|
183
|
+
/** §8.6: the peers currently present on a scope key. */
|
|
184
|
+
presence(scopeKey) {
|
|
185
|
+
return this.#call('presence', [scopeKey]);
|
|
186
|
+
}
|
|
187
|
+
uploadBlob(bytes, options) {
|
|
188
|
+
return this.#call('uploadBlob', [bytes, options]);
|
|
189
|
+
}
|
|
190
|
+
fetchBlob(blobIdOrRef) {
|
|
191
|
+
return this.#call('fetchBlob', [blobIdOrRef]);
|
|
192
|
+
}
|
|
193
|
+
/** Sever/restore transport + realtime inside the worker (demos). */
|
|
194
|
+
setOffline(offline) {
|
|
195
|
+
return this.#call('setOffline', [offline]);
|
|
196
|
+
}
|
|
197
|
+
/** Close the core (leader) or unbind the link (follower), release leadership. */
|
|
198
|
+
async close() {
|
|
199
|
+
if (this.#closed)
|
|
200
|
+
return;
|
|
201
|
+
this.#closed = true;
|
|
202
|
+
if (this.#follower !== undefined) {
|
|
203
|
+
this.#follower.close();
|
|
204
|
+
this.#follower = undefined;
|
|
205
|
+
}
|
|
206
|
+
if (this.#core !== undefined) {
|
|
207
|
+
const core = this.#core;
|
|
208
|
+
this.#core = undefined;
|
|
209
|
+
await core.close(true);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Spawn the worker, run the init handshake, and return a running leader core.
|
|
215
|
+
* `dispatchEvent` receives every worker event; `bridge` (when supplied) is
|
|
216
|
+
* the follower relay whose lifetime is tied to this core.
|
|
217
|
+
*/
|
|
218
|
+
async function startWorkerCore(options) {
|
|
219
|
+
const { config, initConfig, lease } = options;
|
|
220
|
+
const worker = config.worker();
|
|
221
|
+
const pending = new Map();
|
|
222
|
+
const nextId = { value: 1 };
|
|
223
|
+
const invoke = (method, args) => {
|
|
224
|
+
const id = nextId.value++;
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
pending.set(id, { resolve, reject });
|
|
227
|
+
worker.postMessage({
|
|
228
|
+
t: 'call',
|
|
229
|
+
id,
|
|
230
|
+
method,
|
|
231
|
+
args,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
let bridge;
|
|
236
|
+
const ready = new Promise((resolve, reject) => {
|
|
237
|
+
const onMessage = (event) => {
|
|
238
|
+
const message = event.data;
|
|
239
|
+
switch (message.t) {
|
|
240
|
+
case 'ready':
|
|
241
|
+
resolve();
|
|
242
|
+
break;
|
|
243
|
+
case 'result': {
|
|
244
|
+
const entry = pending.get(message.id);
|
|
245
|
+
pending.delete(message.id);
|
|
246
|
+
entry?.resolve(message.value);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case 'error': {
|
|
250
|
+
const entry = pending.get(message.id);
|
|
251
|
+
pending.delete(message.id);
|
|
252
|
+
entry?.reject(new ClientSyncError(message.error.code, message.error.message, message.error.retryable));
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case 'event':
|
|
256
|
+
options.dispatchEvent(message.event);
|
|
257
|
+
bridge?.broadcastEvent(message.event);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const onError = (event) => {
|
|
262
|
+
const error = new ClientSyncError(WORKER_FAILED_CODE, `the sync worker failed: ${event.message ?? 'unknown error'}`);
|
|
263
|
+
reject(error);
|
|
264
|
+
for (const entry of pending.values())
|
|
265
|
+
entry.reject(error);
|
|
266
|
+
pending.clear();
|
|
267
|
+
};
|
|
268
|
+
worker.addEventListener('message', onMessage);
|
|
269
|
+
worker.addEventListener('error', onError);
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
await ready;
|
|
273
|
+
const initResult = await new Promise((resolve, reject) => {
|
|
274
|
+
const id = nextId.value++;
|
|
275
|
+
pending.set(id, {
|
|
276
|
+
resolve: (value) => resolve(value),
|
|
277
|
+
reject,
|
|
278
|
+
});
|
|
279
|
+
worker.postMessage({
|
|
280
|
+
t: 'init',
|
|
281
|
+
id,
|
|
282
|
+
config: initConfig,
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
if (options.makeBridge !== undefined) {
|
|
286
|
+
bridge = options.makeBridge(initResult.clientId, invoke);
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
clientId: initResult.clientId,
|
|
290
|
+
invoke,
|
|
291
|
+
bridge,
|
|
292
|
+
lease,
|
|
293
|
+
close: async (terminate) => {
|
|
294
|
+
bridge?.close();
|
|
295
|
+
try {
|
|
296
|
+
await invoke('close', []);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// Closing a wedged worker still terminates it below.
|
|
300
|
+
}
|
|
301
|
+
if (terminate)
|
|
302
|
+
worker.terminate();
|
|
303
|
+
const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the handle was closed');
|
|
304
|
+
for (const entry of pending.values())
|
|
305
|
+
entry.reject(closedError);
|
|
306
|
+
pending.clear();
|
|
307
|
+
await lease.release();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
worker.terminate();
|
|
313
|
+
await lease.release();
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function buildInitConfig(config) {
|
|
318
|
+
return {
|
|
319
|
+
schema: config.schema,
|
|
320
|
+
database: config.database,
|
|
321
|
+
endpoints: config.endpoints,
|
|
322
|
+
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
323
|
+
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
324
|
+
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
325
|
+
...(config.wakeJitterMs !== undefined
|
|
326
|
+
? { wakeJitterMs: config.wakeJitterMs }
|
|
327
|
+
: {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** Route a worker event to the config-level callbacks (leader visibility). */
|
|
331
|
+
function fireConfigCallbacks(config, event) {
|
|
332
|
+
if (event.kind === 'sync-needed') {
|
|
333
|
+
config.onSyncNeeded?.(event.reason);
|
|
334
|
+
}
|
|
335
|
+
else if (event.kind === 'conflict') {
|
|
336
|
+
config.onConflict?.(event.conflict);
|
|
337
|
+
}
|
|
338
|
+
else if (event.kind === 'upgrading') {
|
|
339
|
+
config.onUpgrading?.(event.upgrading);
|
|
340
|
+
}
|
|
341
|
+
else if (event.kind === 'presence') {
|
|
342
|
+
config.onPresence?.(event.scopeKey);
|
|
343
|
+
}
|
|
344
|
+
else if (event.kind === 'synced') {
|
|
345
|
+
config.onSynced?.({
|
|
346
|
+
...(event.summary !== undefined ? { summary: event.summary } : {}),
|
|
347
|
+
...(event.error !== undefined ? { error: event.error } : {}),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Acquire leadership, spawn the worker, initialize the core inside it.
|
|
353
|
+
*
|
|
354
|
+
* With `multiTab` off: a losing tab resolves to a dead not-leader handle.
|
|
355
|
+
* With `multiTab` on: a losing tab becomes a FOLLOWER proxying to the leader,
|
|
356
|
+
* and promotes itself if the leader later closes.
|
|
357
|
+
*/
|
|
358
|
+
export async function createSyncClientHandle(config) {
|
|
359
|
+
const lock = config.leaderLock ?? defaultLeaderLock();
|
|
360
|
+
const lockName = config.lockName ?? 'syncular-leader';
|
|
361
|
+
const invalidation = new InvalidationEmitter();
|
|
362
|
+
const presence = new Set();
|
|
363
|
+
const roleListeners = new Set();
|
|
364
|
+
if (config.onRoleChange !== undefined)
|
|
365
|
+
roleListeners.add(config.onRoleChange);
|
|
366
|
+
// Leadership BEFORE the worker exists: one core per origin, and a losing
|
|
367
|
+
// tab never boots a database it must not own.
|
|
368
|
+
const lease = lock.tryAcquire !== undefined
|
|
369
|
+
? await lock.tryAcquire(lockName)
|
|
370
|
+
: await lock.acquire(lockName);
|
|
371
|
+
// ---- Won the election: leader. ----
|
|
372
|
+
if (lease !== undefined) {
|
|
373
|
+
// Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
|
|
374
|
+
// the highest epoch it has seen and adds one, so leaders monotonically
|
|
375
|
+
// increase it across handovers.
|
|
376
|
+
return await bootLeader(config, lockName, lease, {
|
|
377
|
+
epoch: 0,
|
|
378
|
+
invalidation,
|
|
379
|
+
presence,
|
|
380
|
+
roleListeners,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// ---- Lost the election. ----
|
|
384
|
+
if (config.multiTab !== true) {
|
|
385
|
+
// Legacy single-tab contract: a dead not-leader handle.
|
|
386
|
+
return new SyncClientHandle({
|
|
387
|
+
role: 'follower',
|
|
388
|
+
clientId: '',
|
|
389
|
+
invalidation,
|
|
390
|
+
presence,
|
|
391
|
+
roleListeners,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
395
|
+
return await bootFollower(config, lockName, lock, {
|
|
396
|
+
invalidation,
|
|
397
|
+
presence,
|
|
398
|
+
roleListeners,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
|
|
402
|
+
async function bootLeader(config, lockName, lease, parts) {
|
|
403
|
+
const handleRef = {
|
|
404
|
+
handle: undefined,
|
|
405
|
+
};
|
|
406
|
+
const dispatchEvent = (event) => {
|
|
407
|
+
fireConfigCallbacks(config, event);
|
|
408
|
+
handleRef.handle?.__dispatchEvent(event);
|
|
409
|
+
};
|
|
410
|
+
const makeBridge = config.multiTab === true
|
|
411
|
+
? (clientId, invoke) => {
|
|
412
|
+
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
413
|
+
const channel = factory(multiTabChannelName(lockName));
|
|
414
|
+
return new LeaderBridge({
|
|
415
|
+
channel,
|
|
416
|
+
epoch: parts.epoch ?? 0,
|
|
417
|
+
clientId,
|
|
418
|
+
invoke,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
: undefined;
|
|
422
|
+
const core = await startWorkerCore({
|
|
423
|
+
config,
|
|
424
|
+
initConfig: buildInitConfig(config),
|
|
425
|
+
lease,
|
|
426
|
+
dispatchEvent,
|
|
427
|
+
...(makeBridge !== undefined ? { makeBridge } : {}),
|
|
428
|
+
});
|
|
429
|
+
const handle = new SyncClientHandle({
|
|
430
|
+
role: 'leader',
|
|
431
|
+
clientId: core.clientId,
|
|
432
|
+
core,
|
|
433
|
+
invalidation: parts.invalidation,
|
|
434
|
+
presence: parts.presence,
|
|
435
|
+
roleListeners: parts.roleListeners,
|
|
436
|
+
});
|
|
437
|
+
handleRef.handle = handle;
|
|
438
|
+
return handle;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Boot a follower: open the channel, bind to the leader, and race the lock in
|
|
442
|
+
* the background so this tab promotes itself the instant the leader closes.
|
|
443
|
+
*/
|
|
444
|
+
async function bootFollower(config, lockName, lock, parts) {
|
|
445
|
+
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
446
|
+
const channel = factory(multiTabChannelName(lockName));
|
|
447
|
+
const handleRef = {
|
|
448
|
+
handle: undefined,
|
|
449
|
+
};
|
|
450
|
+
const follower = new FollowerLink({
|
|
451
|
+
channel,
|
|
452
|
+
fromId: newTabId(),
|
|
453
|
+
onEvent: (event) => handleRef.handle?.__dispatchEvent(event),
|
|
454
|
+
onLeaderChange: (clientId) => {
|
|
455
|
+
// Learn the leader's shared client id (best-effort; the handle exposes
|
|
456
|
+
// it after binding). Nothing else to do — calls already flush.
|
|
457
|
+
void clientId;
|
|
458
|
+
},
|
|
459
|
+
...(config.followerCallTimeoutMs !== undefined
|
|
460
|
+
? { callTimeoutMs: config.followerCallTimeoutMs }
|
|
461
|
+
: {}),
|
|
462
|
+
});
|
|
463
|
+
// A follower waits (blocking) on the exclusive lock: it resolves ONLY when
|
|
464
|
+
// the current leader releases it (tab close). Winning it triggers promotion.
|
|
465
|
+
// We do NOT hold this promise — it settles asynchronously.
|
|
466
|
+
void lock.acquire(lockName).then(async (lease) => {
|
|
467
|
+
const handle = handleRef.handle;
|
|
468
|
+
if (handle === undefined) {
|
|
469
|
+
// Handle was never assembled (shouldn't happen); drop the lease.
|
|
470
|
+
await lease.release();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// The follower saw the departing leader's epoch; the new leader must
|
|
474
|
+
// strictly exceed it so stale replies/events are discarded everywhere.
|
|
475
|
+
const nextEpoch = follower.maxEpochSeen + 1;
|
|
476
|
+
// Unbind the link so any late leader traffic is ignored, then promote.
|
|
477
|
+
follower.unbind();
|
|
478
|
+
try {
|
|
479
|
+
const core = await startWorkerCore({
|
|
480
|
+
config,
|
|
481
|
+
initConfig: buildInitConfig(config),
|
|
482
|
+
lease,
|
|
483
|
+
dispatchEvent: (event) => {
|
|
484
|
+
fireConfigCallbacks(config, event);
|
|
485
|
+
handle.__dispatchEvent(event);
|
|
486
|
+
},
|
|
487
|
+
...(config.multiTab === true
|
|
488
|
+
? {
|
|
489
|
+
makeBridge: (clientId, invoke) => {
|
|
490
|
+
const promoteChannel = factory(multiTabChannelName(lockName));
|
|
491
|
+
return new LeaderBridge({
|
|
492
|
+
channel: promoteChannel,
|
|
493
|
+
epoch: nextEpoch,
|
|
494
|
+
clientId,
|
|
495
|
+
invoke,
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
: {}),
|
|
500
|
+
});
|
|
501
|
+
handle.__becomeLeader(core);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Promotion failed to spawn a worker — release so the next tab tries.
|
|
505
|
+
await lease.release();
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
const handle = new SyncClientHandle({
|
|
509
|
+
role: 'follower',
|
|
510
|
+
// The follower learns the leader's shared client id once bound; expose it
|
|
511
|
+
// lazily via `clientId` is not possible on a getter over the link, so we
|
|
512
|
+
// leave '' until promotion (the shared id is the leader's — hooks that
|
|
513
|
+
// need it read it after a round). Followers rarely need clientId directly.
|
|
514
|
+
clientId: '',
|
|
515
|
+
follower,
|
|
516
|
+
invalidation: parts.invalidation,
|
|
517
|
+
presence: parts.presence,
|
|
518
|
+
roleListeners: parts.roleListeners,
|
|
519
|
+
});
|
|
520
|
+
handleRef.handle = handle;
|
|
521
|
+
// Do not hand back a follower until its link has bound to the leader (the
|
|
522
|
+
// hello→announce round trip completed). Before binding, epoch is -1 and any
|
|
523
|
+
// event the leader fans out is dropped, so a caller that subscribes and then
|
|
524
|
+
// relies on push invalidation would silently miss events emitted in the
|
|
525
|
+
// binding window — a real multi-tab race, not just a test flake. Binding is
|
|
526
|
+
// fast (the leader answers every hello with an announce); on the off chance
|
|
527
|
+
// it times out we still return the handle (queued calls flush and events
|
|
528
|
+
// resume on the next announce) rather than failing handle construction.
|
|
529
|
+
try {
|
|
530
|
+
await follower.waitUntilBound();
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
/* bind timed out — return the (degraded but functional) handle anyway */
|
|
534
|
+
}
|
|
535
|
+
return handle;
|
|
536
|
+
}
|