@syncular/client 0.1.2 → 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.
Files changed (233) hide show
  1. package/README.md +227 -709
  2. package/dist/apply.d.ts +80 -0
  3. package/dist/apply.js +301 -0
  4. package/dist/blob.d.ts +127 -0
  5. package/dist/blob.js +201 -0
  6. package/dist/bun-database.d.ts +22 -0
  7. package/dist/bun-database.js +61 -0
  8. package/dist/client.d.ts +320 -69
  9. package/dist/client.js +1627 -163
  10. package/dist/content-type.d.ts +2 -0
  11. package/dist/content-type.js +2 -0
  12. package/dist/database.d.ts +35 -80
  13. package/dist/database.js +32 -947
  14. package/dist/encryption.d.ts +47 -0
  15. package/dist/encryption.js +75 -0
  16. package/dist/errors.d.ts +8 -22
  17. package/dist/errors.js +10 -207
  18. package/dist/http.d.ts +32 -0
  19. package/dist/http.js +196 -0
  20. package/dist/index.d.ts +28 -16
  21. package/dist/index.js +25 -12
  22. package/dist/invalidation.d.ts +69 -0
  23. package/dist/invalidation.js +84 -0
  24. package/dist/leader-lock.d.ts +28 -0
  25. package/dist/leader-lock.js +38 -0
  26. package/dist/multi-tab.d.ts +134 -0
  27. package/dist/multi-tab.js +399 -0
  28. package/dist/node-database.d.ts +41 -0
  29. package/dist/node-database.js +133 -0
  30. package/dist/outbox.d.ts +56 -0
  31. package/dist/outbox.js +140 -0
  32. package/dist/schema.d.ts +107 -0
  33. package/dist/schema.js +267 -0
  34. package/dist/state.d.ts +40 -0
  35. package/dist/state.js +77 -0
  36. package/dist/transport.d.ts +44 -0
  37. package/dist/transport.js +7 -0
  38. package/dist/wasm-database.d.ts +30 -0
  39. package/dist/wasm-database.js +170 -0
  40. package/dist/window.d.ts +64 -0
  41. package/dist/window.js +0 -0
  42. package/dist/worker-entry.d.ts +16 -2
  43. package/dist/worker-entry.js +300 -456
  44. package/dist/worker-host.d.ts +174 -0
  45. package/dist/worker-host.js +536 -0
  46. package/dist/worker-protocol.d.ts +173 -61
  47. package/dist/worker-protocol.js +7 -16
  48. package/package.json +45 -112
  49. package/src/apply.ts +435 -0
  50. package/src/blob.ts +333 -0
  51. package/src/bun-database.ts +75 -0
  52. package/src/client.ts +2247 -277
  53. package/src/content-type.ts +2 -0
  54. package/src/database.ts +63 -1389
  55. package/src/encryption.ts +123 -0
  56. package/src/errors.ts +11 -265
  57. package/src/http.ts +254 -0
  58. package/src/index.ts +25 -20
  59. package/src/invalidation.ts +128 -0
  60. package/src/leader-lock.ts +68 -0
  61. package/src/multi-tab.ts +550 -0
  62. package/src/node-database.ts +180 -0
  63. package/src/outbox.ts +206 -0
  64. package/src/schema.ts +397 -0
  65. package/src/state.ts +126 -0
  66. package/src/transport.ts +52 -0
  67. package/src/wasm-database.ts +279 -0
  68. package/src/window.ts +0 -0
  69. package/src/worker-entry.ts +391 -545
  70. package/src/worker-host.ts +800 -0
  71. package/src/worker-protocol.ts +204 -99
  72. package/dist/auth-leases.d.ts +0 -11
  73. package/dist/auth-leases.d.ts.map +0 -1
  74. package/dist/auth-leases.js +0 -140
  75. package/dist/auth-leases.js.map +0 -1
  76. package/dist/blob-limits.d.ts +0 -15
  77. package/dist/blob-limits.d.ts.map +0 -1
  78. package/dist/blob-limits.js +0 -66
  79. package/dist/blob-limits.js.map +0 -1
  80. package/dist/bridge-client.d.ts +0 -69
  81. package/dist/bridge-client.d.ts.map +0 -1
  82. package/dist/bridge-client.js +0 -282
  83. package/dist/bridge-client.js.map +0 -1
  84. package/dist/client-config.d.ts +0 -6
  85. package/dist/client-config.d.ts.map +0 -1
  86. package/dist/client-config.js +0 -18
  87. package/dist/client-config.js.map +0 -1
  88. package/dist/client.d.ts.map +0 -1
  89. package/dist/client.js.map +0 -1
  90. package/dist/command-history.d.ts +0 -35
  91. package/dist/command-history.d.ts.map +0 -1
  92. package/dist/command-history.js +0 -378
  93. package/dist/command-history.js.map +0 -1
  94. package/dist/console-diagnostics.d.ts +0 -33
  95. package/dist/console-diagnostics.d.ts.map +0 -1
  96. package/dist/console-diagnostics.js +0 -496
  97. package/dist/console-diagnostics.js.map +0 -1
  98. package/dist/crdt-yjs/index.d.ts +0 -4
  99. package/dist/crdt-yjs/index.d.ts.map +0 -1
  100. package/dist/crdt-yjs/index.js +0 -4
  101. package/dist/crdt-yjs/index.js.map +0 -1
  102. package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
  103. package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
  104. package/dist/crdt-yjs/webview-host-facade.js +0 -284
  105. package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
  106. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
  107. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
  108. package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
  109. package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
  110. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
  111. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
  112. package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
  113. package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
  114. package/dist/database.d.ts.map +0 -1
  115. package/dist/database.js.map +0 -1
  116. package/dist/diagnostics.d.ts +0 -10
  117. package/dist/diagnostics.d.ts.map +0 -1
  118. package/dist/diagnostics.js +0 -83
  119. package/dist/diagnostics.js.map +0 -1
  120. package/dist/errors.d.ts.map +0 -1
  121. package/dist/errors.js.map +0 -1
  122. package/dist/generated-bridge.d.ts +0 -365
  123. package/dist/generated-bridge.d.ts.map +0 -1
  124. package/dist/generated-bridge.js +0 -250
  125. package/dist/generated-bridge.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/mutations.d.ts +0 -72
  129. package/dist/mutations.d.ts.map +0 -1
  130. package/dist/mutations.js +0 -63
  131. package/dist/mutations.js.map +0 -1
  132. package/dist/network.d.ts +0 -3
  133. package/dist/network.d.ts.map +0 -1
  134. package/dist/network.js +0 -17
  135. package/dist/network.js.map +0 -1
  136. package/dist/react/index.d.ts +0 -169
  137. package/dist/react/index.d.ts.map +0 -1
  138. package/dist/react/index.js +0 -628
  139. package/dist/react/index.js.map +0 -1
  140. package/dist/react-native/index.d.ts +0 -35
  141. package/dist/react-native/index.d.ts.map +0 -1
  142. package/dist/react-native/index.js +0 -49
  143. package/dist/react-native/index.js.map +0 -1
  144. package/dist/runtime-contract.d.ts +0 -13
  145. package/dist/runtime-contract.d.ts.map +0 -1
  146. package/dist/runtime-contract.js +0 -24
  147. package/dist/runtime-contract.js.map +0 -1
  148. package/dist/rust-client.d.ts +0 -106
  149. package/dist/rust-client.d.ts.map +0 -1
  150. package/dist/rust-client.js +0 -938
  151. package/dist/rust-client.js.map +0 -1
  152. package/dist/sentry.d.ts +0 -35
  153. package/dist/sentry.d.ts.map +0 -1
  154. package/dist/sentry.js +0 -155
  155. package/dist/sentry.js.map +0 -1
  156. package/dist/sql-safety.d.ts +0 -3
  157. package/dist/sql-safety.d.ts.map +0 -1
  158. package/dist/sql-safety.js +0 -62
  159. package/dist/sql-safety.js.map +0 -1
  160. package/dist/syncular-runtime-artifacts.json +0 -61
  161. package/dist/tauri/index.d.ts +0 -35
  162. package/dist/tauri/index.d.ts.map +0 -1
  163. package/dist/tauri/index.js +0 -114
  164. package/dist/tauri/index.js.map +0 -1
  165. package/dist/types.d.ts +0 -1096
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -2
  168. package/dist/types.js.map +0 -1
  169. package/dist/wasm/.syncular-wasm-profile +0 -1
  170. package/dist/wasm/syncular-runtime-artifact.json +0 -21
  171. package/dist/wasm/syncular.d.ts +0 -207
  172. package/dist/wasm/syncular.js +0 -2341
  173. package/dist/wasm/syncular_bg.wasm +0 -0
  174. package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
  175. package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
  176. package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
  177. package/dist/wasm-bindings/runtime-contract.js +0 -112
  178. package/dist/wasm-bindings/runtime-contract.js.map +0 -1
  179. package/dist/wasm-core/.syncular-wasm-profile +0 -1
  180. package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
  181. package/dist/wasm-core/syncular.d.ts +0 -162
  182. package/dist/wasm-core/syncular.js +0 -1847
  183. package/dist/wasm-core/syncular_bg.wasm +0 -0
  184. package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
  185. package/dist/wasm-perf/.syncular-wasm-profile +0 -1
  186. package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
  187. package/dist/wasm-perf/syncular.d.ts +0 -207
  188. package/dist/wasm-perf/syncular.js +0 -2341
  189. package/dist/wasm-perf/syncular_bg.wasm +0 -0
  190. package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
  191. package/dist/wasm-runtime.d.ts +0 -23
  192. package/dist/wasm-runtime.d.ts.map +0 -1
  193. package/dist/wasm-runtime.js +0 -69
  194. package/dist/wasm-runtime.js.map +0 -1
  195. package/dist/worker-client.d.ts +0 -123
  196. package/dist/worker-client.d.ts.map +0 -1
  197. package/dist/worker-client.js +0 -1735
  198. package/dist/worker-client.js.map +0 -1
  199. package/dist/worker-entry.d.ts.map +0 -1
  200. package/dist/worker-entry.js.map +0 -1
  201. package/dist/worker-protocol.d.ts.map +0 -1
  202. package/dist/worker-protocol.js.map +0 -1
  203. package/dist/worker-realtime.d.ts +0 -39
  204. package/dist/worker-realtime.d.ts.map +0 -1
  205. package/dist/worker-realtime.js +0 -677
  206. package/dist/worker-realtime.js.map +0 -1
  207. package/src/auth-leases.ts +0 -251
  208. package/src/blob-limits.ts +0 -98
  209. package/src/bridge-client.ts +0 -512
  210. package/src/client-config.ts +0 -29
  211. package/src/command-history.ts +0 -623
  212. package/src/console-diagnostics.ts +0 -617
  213. package/src/crdt-yjs/index.ts +0 -3
  214. package/src/crdt-yjs/webview-host-facade.ts +0 -477
  215. package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
  216. package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
  217. package/src/diagnostics.ts +0 -116
  218. package/src/generated-bridge.ts +0 -741
  219. package/src/mutations.ts +0 -168
  220. package/src/network.ts +0 -32
  221. package/src/react/index.ts +0 -1036
  222. package/src/react-native/index.ts +0 -152
  223. package/src/runtime-contract.ts +0 -48
  224. package/src/rust-client.ts +0 -1491
  225. package/src/sentry.ts +0 -215
  226. package/src/sql-safety.ts +0 -61
  227. package/src/tauri/index.ts +0 -211
  228. package/src/types.ts +0 -1397
  229. package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
  230. package/src/wasm-bindings/runtime-contract.ts +0 -158
  231. package/src/wasm-runtime.ts +0 -145
  232. package/src/worker-client.ts +0 -2289
  233. 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
+ }