@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.
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,399 @@
1
+ /**
2
+ * Multi-tab followers (TODO 3.2, REVISE B3): one core per origin, N tabs.
3
+ *
4
+ * The leader tab holds the Web Locks lease and runs the worker core (the
5
+ * existing worker-host path, unchanged). Every OTHER tab is a FOLLOWER: it
6
+ * loses the lock election and, instead of a dead not-leader handle, opens a
7
+ * BroadcastChannel to the leader and proxies the whole logical API over it —
8
+ * one sync loop, one WebSocket, one DB, N tabs.
9
+ *
10
+ * Wire (all messages structured-clone-safe; `Set`/`Uint8Array`/`ArrayBuffer`
11
+ * survive `postMessage` on a BroadcastChannel):
12
+ *
13
+ * follower → leader
14
+ * hello {t,epoch?,fromId} — "who's the leader?" on join
15
+ * req {t,epoch,fromId,reqId,method,args}
16
+ * bye {t,fromId} — follower leaving (best effort)
17
+ * leader → all
18
+ * announce {t,epoch,clientId} — "I am the leader, this epoch"
19
+ * res {t,epoch,reqId,ok,value|error} — reply to one req
20
+ * event {t,epoch,event} — fan-out (invalidate/presence/…)
21
+ *
22
+ * Epoch (leader generation token): a monotonic counter carried in a shared
23
+ * BroadcastChannel and bumped by every promotion. Followers stamp requests
24
+ * with the epoch they last heard in an `announce`; a leader ignores requests
25
+ * from a stale epoch, and a follower discards any `res`/`event` that does not
26
+ * match its current epoch — so a late reply from a tab that has since died
27
+ * (or a duplicate from a previous leader) can never be mistaken for a live
28
+ * one. Epoch is derived deterministically from a per-origin clock: each
29
+ * promoter reads the highest epoch it has seen and adds one, so successive
30
+ * leaders always strictly increase it even across the lock-handover gap.
31
+ *
32
+ * Presence identity: all tabs share the leader's one connection, so a device
33
+ * is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
34
+ * A follower's `setPresence` forwards to the leader's single publisher; there
35
+ * is no per-tab presence peer (documented in the web-client README).
36
+ */
37
+ import { ClientSyncError } from './errors.js';
38
+ import { WORKER_FAILED_CODE } from './worker-protocol.js';
39
+ /** Client-local: a follower call could not reach a leader before its deadline. */
40
+ export const FOLLOWER_TIMEOUT_CODE = 'client.follower_timeout';
41
+ /** Default deadline for a follower request (covers a leader-handover gap). */
42
+ export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
43
+ /** Max follower calls queued across a handover before we fail loudly. */
44
+ export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
45
+ /** Default channel factory: a real `BroadcastChannel` named per lock. */
46
+ export function broadcastChannelFactory() {
47
+ return (name) => new BroadcastChannel(name);
48
+ }
49
+ /** The channel name a leader and its followers rendezvous on. */
50
+ export function multiTabChannelName(lockName) {
51
+ return `syncular-mt:${lockName}`;
52
+ }
53
+ let uniqueCounter = 0;
54
+ /** A per-tab identity for addressing (not a presence identity). */
55
+ export function newTabId() {
56
+ const rand = typeof crypto !== 'undefined' && crypto.randomUUID !== undefined
57
+ ? crypto.randomUUID()
58
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
59
+ return `tab-${rand}-${(uniqueCounter++).toString(36)}`;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Leader side: bridge follower requests into the worker, fan-out events
63
+ // ---------------------------------------------------------------------------
64
+ /**
65
+ * Runs on the leader tab. Announces leadership, answers follower `req`s by
66
+ * invoking the (already-running) worker via `invoke`, and rebroadcasts every
67
+ * worker event to followers. Owns nothing about the worker lifecycle — the
68
+ * worker-host stays the single core owner; this is a relay.
69
+ */
70
+ export class LeaderBridge {
71
+ #channel;
72
+ #epoch;
73
+ #clientId;
74
+ #invoke;
75
+ #onMessage;
76
+ #closed = false;
77
+ constructor(options) {
78
+ this.#channel = options.channel;
79
+ this.#epoch = options.epoch;
80
+ this.#clientId = options.clientId;
81
+ this.#invoke = options.invoke;
82
+ this.#onMessage = (event) => this.#handle(event.data);
83
+ this.#channel.addEventListener('message', this.#onMessage);
84
+ this.announce();
85
+ }
86
+ /** Re-announce leadership (on promotion and on a follower `hello`). */
87
+ announce() {
88
+ if (this.#closed)
89
+ return;
90
+ this.#channel.postMessage({
91
+ t: 'announce',
92
+ epoch: this.#epoch,
93
+ clientId: this.#clientId,
94
+ });
95
+ }
96
+ /** Fan a worker event out to all followers on the current epoch. */
97
+ broadcastEvent(event) {
98
+ if (this.#closed)
99
+ return;
100
+ this.#channel.postMessage({ t: 'event', epoch: this.#epoch, event });
101
+ }
102
+ #handle(message) {
103
+ if (this.#closed)
104
+ return;
105
+ if (message.t === 'hello') {
106
+ // A follower joined (or is contesting) — tell it who leads.
107
+ this.announce();
108
+ return;
109
+ }
110
+ if (message.t !== 'req')
111
+ return;
112
+ // Ignore requests stamped for a different (dead/older) leader; that
113
+ // follower will re-stamp once it sees our announce.
114
+ if (message.epoch !== this.#epoch)
115
+ return;
116
+ const { reqId, method, args } = message;
117
+ this.#invoke(method, args).then((value) => {
118
+ this.#channel.postMessage({
119
+ t: 'res',
120
+ epoch: this.#epoch,
121
+ reqId,
122
+ ok: true,
123
+ value,
124
+ });
125
+ }, (error) => {
126
+ const shape = error instanceof ClientSyncError
127
+ ? {
128
+ code: error.code,
129
+ message: error.message,
130
+ retryable: error.retryable,
131
+ }
132
+ : {
133
+ code: WORKER_FAILED_CODE,
134
+ message: error instanceof Error ? error.message : 'unknown error',
135
+ retryable: false,
136
+ };
137
+ this.#channel.postMessage({
138
+ t: 'res',
139
+ epoch: this.#epoch,
140
+ reqId,
141
+ ok: false,
142
+ error: shape,
143
+ });
144
+ });
145
+ }
146
+ close() {
147
+ if (this.#closed)
148
+ return;
149
+ this.#closed = true;
150
+ this.#channel.removeEventListener('message', this.#onMessage);
151
+ }
152
+ }
153
+ /**
154
+ * Runs on a follower tab. Sends `req`s to the leader and settles them on the
155
+ * matching `res`; queues calls (bounded, timed) while no leader is bound (the
156
+ * handover gap) and flushes them once an `announce` binds a new leader.
157
+ * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
158
+ * the resolved leader `clientId` back through `onLeaderChange`.
159
+ */
160
+ export class FollowerLink {
161
+ #channel;
162
+ #fromId;
163
+ #onEvent;
164
+ #onLeaderChange;
165
+ #callTimeoutMs;
166
+ #queueLimit;
167
+ #onMessage;
168
+ /** -1 until the first `announce` binds us to a leader. */
169
+ #epoch = -1;
170
+ /** Highest epoch ever seen (survives a leader gap for monotonic promotion). */
171
+ #maxEpochSeen = -1;
172
+ #leaderClientId = '';
173
+ #nextReqId = 1;
174
+ #inFlight = new Map();
175
+ #queue = [];
176
+ #closed = false;
177
+ /** Resolvers waiting for the first `announce` to bind a leader. */
178
+ #bindWaiters = [];
179
+ constructor(options) {
180
+ this.#channel = options.channel;
181
+ this.#fromId = options.fromId;
182
+ this.#onEvent = options.onEvent;
183
+ this.#onLeaderChange = options.onLeaderChange;
184
+ this.#callTimeoutMs =
185
+ options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
186
+ this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
187
+ this.#onMessage = (event) => this.#handle(event.data);
188
+ this.#channel.addEventListener('message', this.#onMessage);
189
+ // Ask the current leader to announce itself.
190
+ this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
191
+ }
192
+ get epoch() {
193
+ return this.#epoch;
194
+ }
195
+ get maxEpochSeen() {
196
+ return this.#maxEpochSeen;
197
+ }
198
+ get leaderClientId() {
199
+ return this.#leaderClientId;
200
+ }
201
+ /** Whether a leader is currently bound (an announce has been heard). */
202
+ get bound() {
203
+ return this.#epoch >= 0;
204
+ }
205
+ /**
206
+ * Resolve once the link is bound to a leader (the first `announce` arrived),
207
+ * or reject if `timeoutMs` elapses first. A caller awaits this before
208
+ * treating the follower as ready: until an announce is processed the link's
209
+ * epoch is -1, so any fanned-out `event` in that window is dropped (an
210
+ * unbound follower cannot match the leader's epoch). Awaiting binding closes
211
+ * that gap — a just-opened follower tab never misses an invalidation emitted
212
+ * between its `hello` and the leader's `announce`. Resolves synchronously
213
+ * when already bound.
214
+ */
215
+ waitUntilBound(timeoutMs = this.#callTimeoutMs) {
216
+ if (this.#epoch >= 0)
217
+ return Promise.resolve();
218
+ if (this.#closed) {
219
+ return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
220
+ }
221
+ return new Promise((resolve, reject) => {
222
+ const timer = setTimeout(() => {
223
+ this.#dropBindWaiter(settle);
224
+ reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout'));
225
+ }, timeoutMs);
226
+ const settle = () => {
227
+ clearTimeout(timer);
228
+ resolve();
229
+ };
230
+ this.#bindWaiters.push(settle);
231
+ });
232
+ }
233
+ #dropBindWaiter(waiter) {
234
+ const index = this.#bindWaiters.indexOf(waiter);
235
+ if (index >= 0)
236
+ this.#bindWaiters.splice(index, 1);
237
+ }
238
+ #resolveBindWaiters() {
239
+ if (this.#bindWaiters.length === 0)
240
+ return;
241
+ const waiters = this.#bindWaiters;
242
+ this.#bindWaiters = [];
243
+ for (const settle of waiters)
244
+ settle();
245
+ }
246
+ /** Forward one logical API call to the leader (queued if unbound). */
247
+ call(method, args) {
248
+ if (this.#closed) {
249
+ return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
250
+ }
251
+ return new Promise((resolve, reject) => {
252
+ const queued = {
253
+ method,
254
+ args,
255
+ resolve,
256
+ reject,
257
+ timer: undefined,
258
+ };
259
+ if (this.#epoch < 0) {
260
+ // No leader bound yet — queue with a deadline so we never hang.
261
+ if (this.#queue.length >= this.#queueLimit) {
262
+ reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader'));
263
+ return;
264
+ }
265
+ queued.timer = setTimeout(() => {
266
+ this.#dropQueued(queued);
267
+ reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout'));
268
+ }, this.#callTimeoutMs);
269
+ this.#queue.push(queued);
270
+ return;
271
+ }
272
+ this.#send(queued);
273
+ });
274
+ }
275
+ #send(queued) {
276
+ const reqId = this.#nextReqId++;
277
+ const inflight = {
278
+ resolve: queued.resolve,
279
+ reject: queued.reject,
280
+ timer: setTimeout(() => {
281
+ this.#inFlight.delete(reqId);
282
+ queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout'));
283
+ }, this.#callTimeoutMs),
284
+ };
285
+ this.#inFlight.set(reqId, inflight);
286
+ this.#channel.postMessage({
287
+ t: 'req',
288
+ epoch: this.#epoch,
289
+ fromId: this.#fromId,
290
+ reqId,
291
+ method: queued.method,
292
+ args: queued.args,
293
+ });
294
+ }
295
+ #dropQueued(queued) {
296
+ const index = this.#queue.indexOf(queued);
297
+ if (index >= 0)
298
+ this.#queue.splice(index, 1);
299
+ }
300
+ #handle(message) {
301
+ if (this.#closed)
302
+ return;
303
+ if (message.t === 'announce') {
304
+ if (message.epoch > this.#maxEpochSeen) {
305
+ this.#maxEpochSeen = message.epoch;
306
+ }
307
+ // Ignore a stale announce (older than the leader we already track).
308
+ if (message.epoch < this.#epoch)
309
+ return;
310
+ const changed = message.epoch !== this.#epoch ||
311
+ message.clientId !== this.#leaderClientId;
312
+ this.#epoch = message.epoch;
313
+ this.#leaderClientId = message.clientId;
314
+ if (changed)
315
+ this.#onLeaderChange(message.clientId);
316
+ this.#resolveBindWaiters();
317
+ this.#flushQueue();
318
+ return;
319
+ }
320
+ if (message.t === 'res') {
321
+ // Discard a reply for a stale epoch — the leader that produced it is
322
+ // gone; the request (if still pending) is being retried elsewhere.
323
+ if (message.epoch !== this.#epoch)
324
+ return;
325
+ const inflight = this.#inFlight.get(message.reqId);
326
+ if (inflight === undefined)
327
+ return;
328
+ this.#inFlight.delete(message.reqId);
329
+ if (inflight.timer !== undefined)
330
+ clearTimeout(inflight.timer);
331
+ if (message.ok) {
332
+ inflight.resolve(message.value);
333
+ }
334
+ else {
335
+ const err = message.error;
336
+ inflight.reject(new ClientSyncError(err?.code ?? WORKER_FAILED_CODE, err?.message ?? 'follower request failed', err?.retryable ?? false));
337
+ }
338
+ return;
339
+ }
340
+ if (message.t === 'event') {
341
+ if (message.epoch !== this.#epoch)
342
+ return;
343
+ this.#onEvent(message.event);
344
+ return;
345
+ }
346
+ }
347
+ /** A new leader bound: re-send everything that was waiting for one. */
348
+ #flushQueue() {
349
+ const pending = this.#queue;
350
+ this.#queue = [];
351
+ for (const queued of pending) {
352
+ if (queued.timer !== undefined)
353
+ clearTimeout(queued.timer);
354
+ queued.timer = undefined;
355
+ this.#send(queued);
356
+ }
357
+ }
358
+ /**
359
+ * The leader we were bound to went away (its lock released). Un-bind so new
360
+ * calls queue again; re-request an announce so the next leader finds us.
361
+ * In-flight calls stay pending — their per-call timeout is the backstop,
362
+ * and the winner's announce will flush any that were queued.
363
+ */
364
+ unbind() {
365
+ if (this.#closed)
366
+ return;
367
+ this.#epoch = -1;
368
+ this.#leaderClientId = '';
369
+ this.#channel.postMessage({
370
+ t: 'hello',
371
+ fromId: this.#fromId,
372
+ epoch: this.#maxEpochSeen,
373
+ });
374
+ }
375
+ close() {
376
+ if (this.#closed)
377
+ return;
378
+ this.#closed = true;
379
+ this.#channel.removeEventListener('message', this.#onMessage);
380
+ this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
381
+ const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
382
+ for (const inflight of this.#inFlight.values()) {
383
+ if (inflight.timer !== undefined)
384
+ clearTimeout(inflight.timer);
385
+ inflight.reject(closedError);
386
+ }
387
+ this.#inFlight.clear();
388
+ for (const queued of this.#queue) {
389
+ if (queued.timer !== undefined)
390
+ clearTimeout(queued.timer);
391
+ queued.reject(closedError);
392
+ }
393
+ this.#queue = [];
394
+ // Bind waiters carry their own timeout timers; resolving them here lets an
395
+ // awaiting boot path settle promptly instead of hanging until that timeout.
396
+ this.#resolveBindWaiters();
397
+ this.#channel.close();
398
+ }
399
+ }
@@ -0,0 +1,41 @@
1
+ import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
2
+ /**
3
+ * Structural view of the tiny better-sqlite3 surface this binding uses. We
4
+ * type it locally (rather than importing `better-sqlite3`'s types) so the
5
+ * package typechecks without the optional peer installed.
6
+ */
7
+ interface BetterSqliteStatement {
8
+ run(...params: NodeParam[]): unknown;
9
+ all(...params: NodeParam[]): unknown[];
10
+ }
11
+ interface BetterSqliteDatabase {
12
+ readonly inTransaction: boolean;
13
+ prepare(sql: string): BetterSqliteStatement;
14
+ exec(sql: string): unknown;
15
+ close(): void;
16
+ }
17
+ /**
18
+ * better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
19
+ * bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
20
+ * coerce booleans to 0/1 exactly like the bun adapter so callers see one
21
+ * uniform bind contract across every backend.
22
+ */
23
+ type NodeParam = string | number | bigint | Uint8Array | null;
24
+ export declare class NodeClientDatabase implements ClientDatabase {
25
+ #private;
26
+ readonly db: BetterSqliteDatabase;
27
+ constructor(path?: string);
28
+ exec(sql: string, params?: readonly SqlValue[]): void;
29
+ query(sql: string, params?: readonly SqlValue[]): SqlRow[];
30
+ transaction<T>(fn: () => T): T;
31
+ /**
32
+ * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
33
+ * buffers, so the image lands in a private temp file for the duration of
34
+ * the ATTACH. Must be called outside any open transaction (SQLite cannot
35
+ * ATTACH inside one).
36
+ */
37
+ withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
38
+ close(): void;
39
+ }
40
+ export declare function openNodeDatabase(path?: string): ClientDatabase;
41
+ export {};
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `ClientDatabase` on better-sqlite3 — the Electron-main / plain-Node
3
+ * backend (TODO 2: Node ClientDatabase). Semantics mirror `./bun-database`
4
+ * exactly (synchronous exec/query/transaction with the shared savepoint
5
+ * helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
6
+ * identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
7
+ * or better-sqlite3 (Node/Electron-main).
8
+ *
9
+ * better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
10
+ * installs cleanly without it and this module errors helpfully only when a
11
+ * host actually calls `openNodeDatabase()` without having installed the peer.
12
+ * Not exported from the package root, so browser/bun entries never resolve
13
+ * the native module. Subpath export: `@syncular/client/node`.
14
+ *
15
+ * bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
16
+ * this adapter is verified under real Node — see the README "Electron-main /
17
+ * plain-Node" section for the one-command recipe and `test/node-database`.
18
+ */
19
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { assertImageAlias, runTransaction, } from './database.js';
24
+ function coerceParams(params) {
25
+ return params.map((value) => {
26
+ if (typeof value === 'boolean')
27
+ return value ? 1 : 0;
28
+ return value;
29
+ });
30
+ }
31
+ /**
32
+ * better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
33
+ * Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
34
+ * normalize to a standalone Uint8Array — matching what bun:sqlite hands back
35
+ * and keeping the buffer-ownership assumptions elsewhere (worker transfer,
36
+ * structured clone) honest.
37
+ */
38
+ function normalizeRow(row) {
39
+ const out = {};
40
+ for (const key in row) {
41
+ const value = row[key];
42
+ if (Buffer.isBuffer(value)) {
43
+ out[key] = new Uint8Array(value); // copies out of the pool
44
+ }
45
+ else {
46
+ out[key] = value;
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Load the optional peer AND open the database in one guarded step, so BOTH
53
+ * failure modes are turned into a clear, actionable error rather than a raw
54
+ * one:
55
+ *
56
+ * - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
57
+ * installed (the common browser-only-host case), and
58
+ * - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
59
+ * native addon cannot load, which is exactly what bun does for
60
+ * better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
61
+ */
62
+ function openBetterSqlite(path) {
63
+ const require = createRequire(import.meta.url);
64
+ try {
65
+ const mod = require('better-sqlite3');
66
+ const Database = mod.default ??
67
+ mod;
68
+ return new Database(path);
69
+ }
70
+ catch (error) {
71
+ const code = error?.code;
72
+ if (code === 'ERR_DLOPEN_FAILED') {
73
+ throw new Error("openNodeDatabase() requires the 'better-sqlite3' native module, but " +
74
+ 'it failed to load. This most commonly means you are running under ' +
75
+ 'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
76
+ "the bun:sqlite backend ('@syncular/client/bun') under bun, " +
77
+ "and reserve '@syncular/client/node' for Node/Electron-main. " +
78
+ `Underlying error: ${String(error)}`);
79
+ }
80
+ throw new Error('openNodeDatabase() requires the optional peer dependency ' +
81
+ "'better-sqlite3', which is not installed. Add it to your app " +
82
+ '(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
83
+ 'kept optional so @syncular/client installs without a native ' +
84
+ `build for browser-only hosts. Underlying error: ${String(error)}`);
85
+ }
86
+ }
87
+ export class NodeClientDatabase {
88
+ db;
89
+ #tx = { depth: 0 };
90
+ constructor(path = ':memory:') {
91
+ this.db = openBetterSqlite(path);
92
+ }
93
+ exec(sql, params = []) {
94
+ this.db.prepare(sql).run(...coerceParams(params));
95
+ }
96
+ query(sql, params = []) {
97
+ const rows = this.db.prepare(sql).all(...coerceParams(params));
98
+ return rows.map(normalizeRow);
99
+ }
100
+ transaction(fn) {
101
+ return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
102
+ }
103
+ /**
104
+ * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
105
+ * buffers, so the image lands in a private temp file for the duration of
106
+ * the ATTACH. Must be called outside any open transaction (SQLite cannot
107
+ * ATTACH inside one).
108
+ */
109
+ withSqliteImage(bytes, alias, fn) {
110
+ assertImageAlias(alias);
111
+ const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
112
+ const path = join(dir, 'segment.db');
113
+ try {
114
+ writeFileSync(path, bytes);
115
+ this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
116
+ try {
117
+ return fn();
118
+ }
119
+ finally {
120
+ this.db.prepare(`DETACH DATABASE ${alias}`).run();
121
+ }
122
+ }
123
+ finally {
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ }
127
+ close() {
128
+ this.db.close();
129
+ }
130
+ }
131
+ export function openNodeDatabase(path = ':memory:') {
132
+ return new NodeClientDatabase(path);
133
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
3
+ * outbox rule): local mutations are persisted in a schema-agnostic JSON
4
+ * form and encoded with the *current* generated row codec only when a push
5
+ * request is built — a commit recorded under schema N replays after an
6
+ * upgrade to N+1 by re-encoding.
7
+ */
8
+ import { type PushCommitFrame, type ScopeMap } from '@syncular/core';
9
+ import type { ClientDatabase } from './database.js';
10
+ import type { EncryptionConfig } from './encryption.js';
11
+ import { ClientSyncError } from './errors.js';
12
+ import { type CompiledClientSchema, type CompiledClientTable, type JsonRowValue } from './schema.js';
13
+ export interface OutboxOperation {
14
+ readonly table: string;
15
+ readonly rowId: string;
16
+ readonly op: 'upsert' | 'delete';
17
+ /** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
18
+ readonly baseVersion?: number;
19
+ /** Full-row values keyed by column name; present iff `op` is `upsert`. */
20
+ readonly values?: Readonly<Record<string, JsonRowValue>>;
21
+ }
22
+ export interface OutboxCommit {
23
+ readonly seq: number;
24
+ readonly clientCommitId: string;
25
+ readonly createdAtMs: number;
26
+ readonly operations: readonly OutboxOperation[];
27
+ }
28
+ export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number): void;
29
+ /** Pending commits in FIFO creation order (§7.1). */
30
+ export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
31
+ export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
32
+ /**
33
+ * §7.4.4: after a schema bump, a persisted upsert may name a column the new
34
+ * schema no longer has. The value has nowhere to go and there is no
35
+ * migration — surface it as `sync.outbox_incompatible` (client-local, §10.3)
36
+ * so the caller can drop the commit through the rejection channel.
37
+ */
38
+ export declare class OutboxEncodeError extends ClientSyncError {
39
+ constructor(message: string);
40
+ }
41
+ /**
42
+ * Encode one outbox commit as a `PUSH_COMMIT` frame with the current
43
+ * schema's row codec (§6.1). When `encryption` is configured, encrypted
44
+ * columns (§5.11) are encrypted here — the encode-at-send seam — before the
45
+ * row codec serializes them as ciphertext-envelope `bytes`. Async because
46
+ * WebCrypto is async.
47
+ */
48
+ export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig): Promise<PushCommitFrame>;
49
+ /**
50
+ * §3.3: drop pending commits that write into a revoked scope instead of
51
+ * replaying them into guaranteed rejections. A whole commit is dropped when
52
+ * any of its upserts provably lands in the revoked effective scopes —
53
+ * commits are atomic and their content is pinned by the idempotency key,
54
+ * so ops are never removed individually. Returns dropped commit ids.
55
+ */
56
+ export declare function dropOutboxCommitsInScope(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): string[];