@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
package/README.md CHANGED
@@ -1,725 +1,243 @@
1
1
  # @syncular/client
2
2
 
3
- Rust-owned SQLite browser client for Syncular.
4
-
5
- This package is the TypeScript host binding over the Rust client. The browser
6
- runtime is a dedicated Worker that owns the Rust WASM module and SQLite handle.
7
- TypeScript keeps Kysely as the type-safe query builder; generated app code
8
- supplies the DB type, schema installer, mutation helpers, subscriptions, and
9
- runtime assertions.
10
-
11
- ## Generated App Entry
12
-
13
- Configure Rust codegen to emit your browser helper into your app package:
14
-
15
- ```json
16
- {
17
- "typescriptOutputPath": "src/generated/syncular.browser.ts",
18
- "typescriptRuntimeImportPath": "@syncular/client",
19
- "tables": {
20
- "profiles": {
21
- "serverVersionColumn": "server_version",
22
- "blobColumns": ["avatar"]
23
- }
24
- }
25
- }
26
- ```
27
-
28
- App code imports the generated helper, not a table-specific API from this
29
- package:
30
-
31
- ```ts
32
- import { createSyncularAppDatabase } from './generated/syncular.browser';
33
-
34
- const syncular = await createSyncularAppDatabase({
35
- config: {
36
- baseUrl: '/sync',
37
- actorId: 'user-1',
38
- clientId: 'client-1',
39
- projectId: 'project-1',
40
- fileName: 'app.sqlite',
41
- },
42
- requestTimeoutMs: 30_000,
43
- getHeaders: async () => ({
44
- authorization: `Bearer ${await auth.currentAccessToken()}`,
45
- }),
46
- authLifecycle: {
47
- refreshToken: () => auth.refreshAccessToken(),
48
- },
49
- });
50
-
51
- const rows = await syncular.db
52
- .selectFrom('tasks')
53
- .select(['id', 'title'])
54
- .where('project_id', '=', 'project-1')
55
- .execute();
56
-
57
- await syncular.mutations.tasks.insert({
58
- title: 'Typed Rust-owned write',
59
- completed: 0,
60
- user_id: 'user-1',
61
- project_id: 'project-1',
62
- });
63
-
64
- await syncular.client.issueAuthLease({
65
- schemaVersion: 1,
66
- scopes: [
67
- {
68
- subscriptionId: 'tasks:user-1',
69
- table: 'tasks',
70
- values: { user_id: 'user-1' },
71
- operations: ['upsert'],
72
- },
73
- ],
74
- });
75
-
76
- await syncular.leasedMutations.tasks.update('task-1', {
77
- title: 'Queued while offline',
78
- });
3
+ The TypeScript client protocol core (SPEC.md §§3–8, client side) plus its
4
+ browser platform bindings.
79
5
 
80
- // Local mutations sync automatically by default. To opt out, pass:
81
- // sync: { autoSyncAfterMutation: false }
82
-
83
- const live = await syncular.live(
84
- syncular.db
85
- .selectFrom('tasks')
86
- .select(['id', 'title'])
87
- .where('project_id', '=', 'project-1'),
88
- {
89
- onChange(rows) {
90
- console.log(rows);
91
- },
92
- }
93
- );
94
-
95
- live.unsubscribe();
96
- await syncular.close();
97
- ```
98
-
99
- Generated helpers intentionally do not emit table constants, column constants,
100
- or canned queries. Reads stay plain Kysely. Sync-aware writes go through
101
- `syncular.mutations` or generated operation helpers.
6
+ ## Browser modes there are exactly two
102
7
 
103
- Use `bootstrapPhases` when the app should become usable before every
104
- subscription has finished its first snapshot. Keys can be generated table names
105
- or subscription ids. Phase `0` is critical by default, phase `1` is
106
- interactive by default, and higher phases continue in the background:
8
+ **Persistent worker mode is THE mode** (REVISE Direction decision 2,
9
+ 2026-07-03). The whole client core `SyncClient`, the fetch/WebSocket
10
+ transports, and SQLite on the `opfs-sahpool` VFS runs inside a Web
11
+ Worker. The UI thread talks to it through a thin postMessage RPC:
107
12
 
108
13
  ```ts
109
- const syncular = await createSyncularAppDatabase({
110
- config: {
111
- baseUrl: '/sync',
112
- actorId: 'user-1',
113
- clientId: 'client-1',
114
- pull: {
115
- criticalBootstrapPhase: 0,
116
- interactiveBootstrapPhase: 1,
117
- },
118
- },
119
- bootstrapPhases: {
120
- projects: 0,
121
- tasks: 1,
122
- comments: 2,
123
- },
124
- });
125
-
126
- const result = await syncular.client.syncOnce();
127
-
128
- if (result.bootstrap.criticalReady) {
129
- renderShell();
130
- }
131
-
132
- const unsubscribeBootstrap = syncular.client.addEventListener(
133
- 'bootstrapChanged',
134
- (bootstrap) => {
135
- if (bootstrap.interactiveReady) enableMainViews();
136
- if (bootstrap.complete) enableFullDataViews();
137
- }
138
- );
14
+ // worker.ts the worker entry your bundler emits as its own script
15
+ import { startSyncWorker } from '@syncular/client/worker';
16
+ startSyncWorker();
139
17
  ```
140
18
 
141
- Do not treat missing scopes as empty data while `bootstrap.complete` is false.
142
- Use `bootstrap.pendingSubscriptionIds`, `bootstrap.phases`, or generated
143
- subscription ids to decide which views can render complete results.
144
-
145
- The database owns the sync lifecycle. `createSyncularDatabase` registers
146
- subscriptions, runs the initial sync, starts realtime, schedules reconnect
147
- catchup, and coordinates shutdown through `close()`:
148
-
149
19
  ```ts
150
- import { createSyncularDatabase } from '@syncular/client';
20
+ // main thread
21
+ import { createSyncClientHandle } from '@syncular/client';
151
22
 
152
- const syncular = await createSyncularDatabase<AppDb>({
153
- config: {
154
- baseUrl: '/sync',
155
- actorId: 'user-1',
156
- clientId: 'client-1',
23
+ const handle = await createSyncClientHandle({
24
+ worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
25
+ schema,
26
+ database: { mode: 'persistent', name: 'app' }, // OPFS, survives reloads
27
+ endpoints: {
28
+ syncUrl: '/sync',
29
+ segmentsUrl: '/segments',
30
+ realtimeUrl: 'wss://example.com/realtime?clientId={clientId}',
157
31
  },
158
- subscriptions: [
159
- {
160
- id: 'tasks:user-1',
161
- table: 'tasks',
162
- scopes: { user_id: 'user-1' },
163
- },
164
- ],
165
32
  });
166
-
167
- const unsubscribe = syncular.on('rowsChanged', (event) => {
168
- console.log(event.changedTables);
169
- });
170
-
171
- const status = syncular.getStatus();
172
- if (status.hasPendingMutations) showSavingIndicator();
173
-
174
- await syncular.resumeFromBackground();
175
- await syncular.close();
176
- ```
177
-
178
- The database starts realtime by default. Pass `realtime: false` only for a
179
- host policy that cannot hold a websocket, and `lifecycle: { autoStart: false }`
180
- to open without starting sync at all. Interval polling is still off by
181
- default: websocket reconnects trigger HTTP catchup sync, and failed
182
- websocket binary sync-pack applies recover through HTTP pull. Use
183
- `pollIntervalMs` only for environments that explicitly need polling.
184
-
185
- ## React
186
-
187
- React apps can import the `@syncular/client/react` subpath.
188
- The adapter owns the Rust browser client lifecycle when passed `options`, or
189
- can wrap an already-created managed client:
190
-
191
- ```ts
192
- import { createSyncularReact } from '@syncular/client/react';
193
-
194
- const {
195
- SyncProvider,
196
- useSyncQuery,
197
- useMutations,
198
- useLeasedMutations,
199
- useMutation,
200
- useOutboxStats,
201
- usePresenceWithJoin,
202
- useSyncConnection,
203
- } = createSyncularReact<AppDb>();
204
-
205
- function AppShell({ children }: { children: React.ReactNode }) {
206
- return (
207
- <SyncProvider
208
- options={{
209
- config: {
210
- baseUrl: '/sync',
211
- actorId: 'user-1',
212
- clientId: 'client-1',
213
- },
214
- subscriptions: [
215
- {
216
- id: 'tasks:user-1',
217
- table: 'tasks',
218
- scopes: { user_id: 'user-1' },
219
- },
220
- ],
221
- realtime: true,
222
- }}
223
- >
224
- {children}
225
- </SyncProvider>
226
- );
227
- }
228
-
229
- function TaskList() {
230
- const { data: tasks } = useSyncQuery(
231
- ({ selectFrom }) =>
232
- selectFrom('tasks')
233
- .select(['id', 'title'])
234
- .where('user_id', '=', 'user-1'),
235
- {
236
- tables: ['tasks'],
237
- deps: ['user-1'],
238
- }
239
- );
240
-
241
- const presence = usePresenceWithJoin('user:user-1', {
242
- metadata: { view: 'tasks' },
243
- });
244
-
245
- const m = useMutations();
246
- const leased = useLeasedMutations();
247
- const createTask = (title: string) =>
248
- m.tasks.insert({
249
- title,
250
- completed: 0,
251
- user_id: 'user-1',
252
- });
253
-
254
- const completeTask = useMutation({ table: 'tasks' });
255
- const markDone = (id: string) =>
256
- completeTask.mutate.update(id, { completed: 1 });
257
-
258
- const renameOffline = (id: string, title: string) =>
259
- leased.tasks.update(id, { title });
260
-
261
- const connection = useSyncConnection();
262
- const outbox = useOutboxStats();
33
+ if (handle.role === 'follower') {
34
+ // Another tab owns the core for this origin. With `multiTab: true`
35
+ // (below) this handle transparently proxies to that leader; without it,
36
+ // every call rejects with `client.not_leader` (a clear state, not a
37
+ // broken client).
263
38
  }
264
- ```
265
-
266
- The React entrypoint is intentionally ergonomic and Rust-backed: reads use typed
267
- Kysely selectors through `useSyncQuery`, writes use generated mutations through
268
- `useMutations` / `useLeasedMutations` or table-scoped
269
- `useMutation` / `useLeasedMutation`, and presence stays scoped to server scope
270
- keys. When the query is a Kysely builder, `useSyncQuery` uses the runtime
271
- live-query observer; promise-only queries fall back to conservative row-change
272
- refresh. `SyncProvider` does not recreate an owned client just because an inline
273
- `options` object changed identity; pass `optionsKey` when the app intentionally
274
- needs to tear down and reopen the Rust client for a new identity or database.
275
-
276
- Generated apps also get typed row-delta helpers for realtime/UI routing. The
277
- runtime event stays generic, while app code can branch on real table columns:
278
-
279
- ```ts
280
- import { syncularChangedRows } from './generated/syncular.browser';
281
-
282
- const unsubscribe = syncular.on('rowsChanged', (event) => {
283
- for (const task of syncularChangedRows.tasks(event)) {
284
- if (task.isDelete) {
285
- removeTaskFromList(task.rowId);
286
- continue;
287
- }
288
- if (task.changed.title || task.changed.completed) {
289
- refreshTaskRow(task.rowId);
290
- }
291
- if (task.crdt.title_yjs_state) {
292
- refreshActiveEditorState(task.rowId);
293
- }
294
- }
295
- });
296
- ```
297
-
298
- The returned `syncular.db` is a read/query-builder surface. Public SQL execution
299
- rejects app-table and internal-table writes, including Kysely `insertInto`,
300
- `updateTable`, `deleteFrom`, schema DDL, and raw mutating SQL. This prevents
301
- local rows from bypassing Syncular's outbox, conflict, encryption, blob, and
302
- realtime semantics. Generated app setup uses an internal schema-write path
303
- before the database handle is returned; application writes should use
304
- `syncular.mutations`.
305
-
306
- Mutations schedule `client.syncOnce()` automatically after a successful local
307
- commit. The scheduler coalesces repeated writes with a short debounce and queues
308
- one follow-up sync if another mutation lands while sync is already running:
309
-
310
- ```ts
311
- const syncular = await createSyncularAppDatabase({
312
- config: {
313
- baseUrl: '/sync',
314
- actorId: 'user-1',
315
- clientId: 'client-1',
316
- },
317
- sync: {
318
- autoSyncAfterMutation: true, // default
319
- mutationSyncDebounceMs: 25,
320
- rowsChangedDebounceMs: 16,
321
- autoProcessBlobUploadsAfterStore: false, // default
322
- blobUploadDebounceMs: 25,
323
- },
324
- });
325
- ```
326
-
327
- Set `autoSyncAfterMutation: false` when an app wants to batch its own sync
328
- cycles explicitly. Set `autoProcessBlobUploadsAfterStore: true` when the
329
- browser should process queued blob uploads after `blobs.store()` with the same
330
- debounce/backpressure model. It is disabled by default so mobile/background
331
- hosts can choose when network blob work is allowed.
332
-
333
- ## Blobs
334
-
335
- Blobs are a sidecar API on the same Rust-owned SQLite client. App data still
336
- uses typed Kysely queries; binary payloads are content-addressed and staged in
337
- Syncular internal blob tables:
338
-
339
- ```ts
340
- const avatar = await syncular.blobs.store(file, {
341
- mimeType: file.type,
342
- });
343
-
344
- await syncular.mutations.profiles.upsert(userId, {
345
- avatar,
346
- });
347
-
348
- await syncular.blobs.processUploadQueue();
349
-
350
- const bytes = await syncular.blobs.retrieve(avatar);
351
- ```
352
-
353
- `store()` hashes and caches bytes in Rust/WASM SQLite, then queues upload unless
354
- `immediate: true` is passed. Upload/download requests use the same auth header
355
- lifecycle as sync and talk to the server blob routes under `${baseUrl}/blobs`.
356
- Apps can call `processUploadQueue()` manually or opt into
357
- `sync.autoProcessBlobUploadsAfterStore`.
358
- Columns listed in `blobColumns` are typed as `BlobRef` in generated Kysely
359
- types and use generated codecs so SQLite stores JSON text while app code reads
360
- and writes structured blob refs.
361
-
362
- ## Auth
363
-
364
- App code owns authentication. Pass `getHeaders` to the generated app database
365
- factory when sync requests need bearer tokens, session headers, or tenant
366
- headers:
367
-
368
- ```ts
369
- const syncular = await createSyncularAppDatabase({
370
- config: {
371
- baseUrl: '/sync',
372
- actorId: 'user-1',
373
- clientId: 'client-1',
374
- },
375
- getHeaders: async () => ({
376
- authorization: `Bearer ${await auth.currentAccessToken()}`,
377
- }),
378
- });
379
- ```
380
-
381
- The Worker refreshes those headers after opening and before `syncPull`,
382
- `syncPush`, and `syncOnce`, then forwards them into Rust. The `actorId` config
383
- is used for sync identity and generated default scopes; it is not sent as an
384
- implicit auth credential.
385
- If Rust reports HTTP 401/403 during sync, `authLifecycle` can refresh
386
- credentials and the Worker retries that sync operation once with fresh headers.
387
-
388
- Offline auth leases are explicit. They capture bounded local intent and audit
389
- provenance, but the server still rechecks current authorization when queued
390
- commits replay:
391
-
392
- ```ts
393
- await syncular.client.issueAuthLease({
394
- schemaVersion: 1,
395
- scopes: [
396
- {
397
- subscriptionId: 'tasks:user-1',
398
- table: 'tasks',
399
- values: { user_id: 'user-1' },
400
- operations: ['upsert', 'delete'],
401
- },
402
- ],
403
- });
404
-
405
- const active = await syncular.client.activeAuthLeases('user-1');
406
- await syncular.leasedMutations.tasks.update('task-1', {
407
- title: 'Offline edit',
408
- });
409
- ```
410
-
411
- Use normal `syncular.mutations` unless the app intentionally needs lease-backed
412
- offline writes.
413
-
414
- ## Platform Bridges
415
-
416
- `@syncular/client/tauri` and `@syncular/client/react-native` expose TypeScript
417
- host bindings over a native Rust runtime. They are bridge adapters, not separate
418
- JavaScript sync clients:
419
-
420
- ```ts
421
- import { createSyncularTauriClient } from '@syncular/client/tauri';
422
-
423
- const client = await createSyncularTauriClient<AppDb>({
424
- invoke,
425
- listen,
426
- });
427
-
428
- const rows = await client.db.selectFrom('tasks').selectAll().execute();
429
- await client.leasedMutations.tasks.update('task-1', { title: 'Offline edit' });
430
- await client.resumeFromBackground();
431
- ```
39
+ await handle.subscribe({ id: 'todos', table: 'todos', scopes: { list_id: ['l1'] } });
40
+ await handle.syncUntilIdle();
41
+ const rows = await handle.query('SELECT * FROM todos');
42
+ ```
43
+
44
+ The handle exposes the same logical API as `SyncClient` (subscribe /
45
+ mutate / sync / query / conflicts / …), every method a promise. It
46
+ acquires the Web Locks leader lock *before* spawning the worker — one
47
+ core per origin. Wake-ups are handled inside the worker (`autoSync`,
48
+ SPEC §8.4: the sync-needed signal is host-driven and the worker IS the
49
+ host); the main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
50
+ events for rendering.
51
+
52
+ **Ephemeral in-memory mode is EXPLICIT.** `openWasmDatabase()` returns an
53
+ in-memory sqlite-wasm database for tests, demos and SSR. Nothing
54
+ persists, on purpose, and that is the only main-thread mode.
55
+
56
+ ## Multi-tab followers (TODO 3.2, REVISE B3)
57
+
58
+ Pass `multiTab: true` and every tab of the same origin shares ONE core:
59
+ one sync loop, one WebSocket, one OPFS database, N tabs.
60
+
61
+ ```ts
62
+ const handle = await createSyncClientHandle({
63
+ worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
64
+ schema, database: { mode: 'persistent', name: 'app' }, endpoints,
65
+ multiTab: true,
66
+ onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
67
+ });
68
+ // handle.role is 'leader' or 'follower'; the API is identical either way.
69
+ ```
70
+
71
+ **Topology.** The tab that wins the Web Locks election is the **leader**:
72
+ it holds the lock, spawns the worker, and runs the core (the single-tab
73
+ path, unchanged the lock IS the exactly-one-core invariant, and a worker
74
+ is *never* spawned without it). Every other tab is a **follower**: it opens
75
+ a `BroadcastChannel` to the leader and proxies the whole logical API over
76
+ it (`req`/`res`), while the leader fans its worker events
77
+ invalidate / presence / conflict / sync-needed / synced / upgrading — out
78
+ to all followers (`event`). Queries forward to the leader's one DB; rows
79
+ (including `bytes` columns) ride back through structured clone.
80
+
81
+ **Promotion.** When the leader tab closes, its lock releases. Followers are
82
+ already blocked on `lock.acquire`; Web Locks grants it to exactly one, and
83
+ that tab **promotes in place** spawns the worker over the *same* OPFS
84
+ database (which persisted; the server is the source of truth, so nothing is
85
+ replayed beyond the outbox the core already holds), re-announces on a new
86
+ **epoch**, and flips its `role` to `'leader'`. Remaining followers rebind
87
+ to the new leader on its announce. The handle object is kept across the
88
+ transition, so a React `SyncProvider` holds a stable reference.
89
+
90
+ **Epoch (generation token).** Each leader announces a monotonically
91
+ increasing epoch. Followers stamp requests with the epoch they last heard;
92
+ a leader ignores stale-epoch requests, and a follower discards any
93
+ `res`/`event` from an epoch other than its current one — so a late reply
94
+ from a tab that has since died can never be mistaken for a live answer.
95
+ Calls made during the handover gap are **queued with a deadline** and
96
+ flushed to the new leader on its announce; past the deadline they fail
97
+ loudly with `client.follower_timeout` (never a silent hang), and an
98
+ overflowing queue rejects rather than growing unbounded.
99
+
100
+ **Presence semantics — one device, one peer.** All tabs share the leader's
101
+ single connection, so a device is exactly ONE presence peer collectively:
102
+ identity is `(actorId, leaderClientId)`. A follower's `setPresence`
103
+ forwards to the leader's single publisher; there is no per-tab presence
104
+ peer. This is the honest model the wire only ever sees one connection per
105
+ device.
106
+
107
+ With `multiTab` off (the default) the single-tab contract is unchanged: a
108
+ losing tab is an `isLeader === false` handle whose calls reject with
109
+ `client.not_leader`.
110
+
111
+ ## The support floor (no fallback ladder)
112
+
113
+ - Persistence is **OPFS via `opfs-sahpool`, only**. No COOP/COEP headers
114
+ and no SharedArrayBuffer are required (sahpool is built on
115
+ `FileSystemSyncAccessHandle`, unlike the Atomics-based `opfs` VFS).
116
+ - Browsers without OPFS (~pre-2023) are **unsupported**:
117
+ `openPersistentWasmDatabase` fails loud instead of degrading.
118
+ - **Never IndexedDB.** There is no wa-sqlite/absurd-sql style fallback
119
+ and none is planned.
120
+ - `openPersistentWasmDatabase` refuses to run on the main thread — not a
121
+ sahpool limitation, an enforcement of whole-core-in-a-worker.
122
+
123
+ ## Blob attachments (§5.9) — the client storage model
124
+
125
+ File attachments (`blob_ref` columns) ride the `uploadBlob` / `fetchBlob` API
126
+ and are cached locally. **Blob bytes live as `BLOB` columns in the client's own
127
+ SQLite database** (a `_syncular_blobs` cache table), not in a separate OPFS
128
+ directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
129
+
130
+ - **One storage system.** The bytes are transactional with the refcount rows
131
+ that pin them a refcount adjust and a body insert/delete commit atomically,
132
+ so a crash never strands a body against a stale count.
133
+ - **Survives restarts for free.** The client DB already rides OPFS via the
134
+ sahpool VFS in the browser (and a plain file under `rusqlite`/better-sqlite3
135
+ on native/Node), so there is no second persistence surface and no second
136
+ eviction policy to keep coherent. Close the app, reopen it: `fetchBlob` serves
137
+ the cached body with no network.
138
+ - **SQLite handles multi-MB images fine.** A page-cached `BLOB` read is a memory
139
+ copy, well within the image/document envelope this targets.
140
+
141
+ ### Size cap + LRU eviction
142
+
143
+ Pass `blobCacheMaxBytes` to cap the on-device cache. When the sum of cached body
144
+ sizes exceeds the cap, the client evicts **zero-ref, non-pinned** bodies in
145
+ least-recently-used order until back under the cap:
146
+
147
+ ```ts
148
+ new SyncClient({ /* … */, blobCacheMaxBytes: 256 * 1024 * 1024 }); // 256 MiB
149
+ ```
150
+
151
+ - A body **referenced by a live row** (refcount > 0) is **never** evicted — it
152
+ stays resolvable without a re-download.
153
+ - A body **pinned by a pending upload** (not yet pushed) is never evicted — its
154
+ bytes are the only copy until the commit drains.
155
+ - Evicting a zero-ref body only costs a future re-download, never correctness:
156
+ any surviving `blob_ref` value re-enables the fetch (§5.9.7 B3). If every
157
+ over-cap body is referenced or pinned, the cache stays over the cap
158
+ (correctness beats the cap). A cache-hit read touches "recently used", so a
159
+ hot image survives a trim. Absent `blobCacheMaxBytes` ⇒ retain until storage
160
+ pressure (the default).
161
+
162
+ ### Very large media — the escape hatch
163
+
164
+ SQLite is **not** the store for gigabyte video: a single `BLOB` must fit the
165
+ client's memory and the SQLite row-size envelope. For very large media, run the
166
+ server with presigned downloads (`blobSignedUrls`) and hand the presigned URL
167
+ straight to a media element instead of pulling bytes through the cache — the
168
+ image-app default (refcounted `BLOB` cache) and the large-media path (presigned
169
+ URL, no byte cache) coexist per attachment.
170
+
171
+ ## Node / Electron-main backend (`./node`)
172
+
173
+ Hosts that run outside a browser — an **Electron main process**, a plain
174
+ **Node** service, a CLI — get a native SQLite backend through
175
+ `openNodeDatabase`, a `ClientDatabase` over
176
+ [better-sqlite3](https://github.com/WiseLibs/better-sqlite3):
177
+
178
+ ```ts
179
+ import { openNodeDatabase } from '@syncular/client/node';
180
+ import { SyncClient } from '@syncular/client';
181
+
182
+ const database = openNodeDatabase('app.db'); // or ':memory:' (default)
183
+ const client = new SyncClient({ database, schema, /* … */ });
184
+ ```
185
+
186
+ It mirrors the bun:sqlite adapter exactly: synchronous `exec` / `query` /
187
+ `transaction` (nested calls are savepoints — an inner failure rolls back only
188
+ the inner scope), the same boolean→0/1 bind coercion, `null` round-trips, and
189
+ BLOB columns handed back as plain `Uint8Array`s. The §5.3 `withSqliteImage`
190
+ attach path is supported too, so a Node host can accept sqlite-image segments.
191
+
192
+ **better-sqlite3 is an OPTIONAL peer dependency, not a hard one.** The package
193
+ installs cleanly without it (browser-only apps never pay for a native build);
194
+ `openNodeDatabase()` loads it lazily on first call and throws a clear,
195
+ actionable error if the peer is missing. Add it in your app:
196
+
197
+ ```sh
198
+ npm install better-sqlite3 # or: bun add better-sqlite3
199
+ ```
200
+
201
+ **Verifying the Node adapter — and why not under bun.** bun **cannot** dlopen
202
+ better-sqlite3 (`ERR_DLOPEN_FAILED`,
203
+ [oven-sh/bun#4290](https://github.com/oven-sh/bun/issues/4290)); calling
204
+ `openNodeDatabase()` under bun deliberately raises the same helpful error and
205
+ points you at `./bun` instead. So the bun test suite
206
+ (`test/node-database.test.ts`) proves what it can under bun — type/subpath
207
+ conformance, the missing-peer error, and that the shared behavioral contract
208
+ (`test/node-database/adapter-contract.ts`) passes on the reference bun:sqlite
209
+ backend — while the better-sqlite3 adapter's real behavior is proven under
210
+ **Node** against the actual native module by running that same contract:
211
+
212
+ ```sh
213
+ cd packages/web-client
214
+ bun run verify:node
215
+ ```
216
+
217
+ That bundles the verifier with bun (transpile + resolve only — bun never
218
+ executes the native module) and runs the plain-JS bundle under Node, which
219
+ exercises `openNodeDatabase` against real better-sqlite3 and exits non-zero on
220
+ any divergence from the contract.
432
221
 
433
- Bridge subpaths preserve row/field metadata on `rowsChanged` events and expose
434
- the same leased mutation, auth lease, lifecycle, presence, conflict, and blob
435
- client shape where the native module provides those commands. They do not
436
- pretend to support live-query registration by rerunning table-level events; app
437
- bridges can either use row/field metadata directly or wait for a native
438
- observed-query stream. Command history remains generated-client owned, not a
439
- generic bridge-level JavaScript undo stack.
440
-
441
- ## Diagnostics
442
-
443
- Pass `diagnostics` to observe structured client, worker, auth, realtime,
444
- storage, sync, and blob events. Header values and websocket URLs are not emitted.
445
-
446
- ```ts
447
- const syncular = await createSyncularAppDatabase({
448
- config: {
449
- baseUrl: '/sync',
450
- actorId: 'user-1',
451
- clientId: 'client-1',
452
- },
453
- diagnostics(event) {
454
- logger.debug(event.code, event);
455
- },
456
- });
457
- ```
458
-
459
- `requestTimeoutMs` is enforced in the Worker. For long sync/blob requests the
460
- Worker also aborts the Rust-owned browser fetches, including snapshot chunk
461
- downloads, before dropping the timed-out response.
462
-
463
- UI code can poll `syncular.client.connectionState()` for a cheap snapshot of the
464
- Worker state: closed flag, pending request count, realtime connection state,
465
- storage fallback, and the latest diagnostic/error.
466
-
467
- ## Realtime
468
-
469
- Realtime is optional and runs inside the same dedicated Worker as Rust-owned
470
- SQLite. Enable it with `realtime`:
471
-
472
- ```ts
473
- const syncular = await createSyncularAppDatabase({
474
- config: {
475
- baseUrl: '/sync',
476
- actorId: 'user-1',
477
- clientId: 'client-1',
478
- },
479
- realtime: true,
480
- });
481
- ```
482
-
483
- The Worker connects to `${baseUrl}/realtime`, listens for server `sync`
484
- wakeups, runs `syncPull()` in Rust, then emits affected live-query snapshots to
485
- JS listeners. Browser WebSockets cannot send custom headers; use same-origin
486
- cookie auth when possible, or pass non-sensitive server-supported params:
487
-
488
- ```ts
489
- await createSyncularAppDatabase({
490
- config: {
491
- baseUrl: '/sync',
492
- actorId: 'user-1',
493
- clientId: 'client-1',
494
- },
495
- realtime: {
496
- wsUrl: 'wss://api.example.com/sync/realtime',
497
- getParams: async () => ({ token: await auth.realtimeToken() }),
498
- },
499
- });
500
- ```
501
-
502
- Realtime also carries presence. Scope keys match the sync scope keys exposed by
503
- the server, for example `user:user-1` for a `user:{user_id}` handler scope:
504
-
505
- ```ts
506
- const unsubscribePresence = syncular.client.addPresenceListener((event) => {
507
- renderCollaborators(event.scopeKey, event.presence);
508
- });
509
-
510
- syncular.client.joinPresence('user:user-1', {
511
- editingTaskId: 'task-1',
512
- });
513
-
514
- syncular.client.updatePresenceMetadata('user:user-1', {
515
- editingTaskId: 'task-2',
516
- });
517
-
518
- const currentPresence = syncular.client.getPresence('user:user-1');
519
- renderCollaborators('user:user-1', currentPresence);
520
-
521
- syncular.client.leavePresence('user:user-1');
522
- unsubscribePresence();
523
- ```
524
-
525
- `getPresence(scopeKey)` returns the latest in-memory snapshot for that scope.
526
- The server authorizes presence against the websocket connection's current
527
- subscriptions, so call `syncular.client.setSubscriptions()` and complete an
528
- initial sync before joining presence.
529
-
530
- Operational events are available on the same client surface:
531
-
532
- ```ts
533
- syncular.client.addEventListener('outboxChanged', (stats) => {
534
- updateSyncBadge(stats.pending + stats.sending);
535
- });
536
-
537
- syncular.client.addEventListener('conflictsChanged', (stats) => {
538
- showConflictCount(stats.unresolved);
539
- });
540
-
541
- syncular.client.addEventListener('blobUploadFailed', ({ hash, error }) => {
542
- reportBlobUploadFailure(hash, error);
543
- });
544
- ```
545
-
546
- Browser event names intentionally use the Rust-native vocabulary shared with
547
- native event payloads: `rowsChanged`, `outboxChanged`, `conflictsChanged`,
548
- `presenceChanged`, `blobUploadCompleted`, and `blobUploadFailed`.
549
-
550
- ## Runtime Contract
551
-
552
- The default API always uses a Worker. `createSyncularAppDatabase()` validates
553
- the runtime before returning:
554
-
555
- - package name/version must match `@syncular/client`
556
- - Worker protocol version must match the generated helper
557
- - generated app schema version must match the local SQLite schema state
558
- - Rust runtime must include the generated schema's required feature list
559
-
560
- `client.runtimeInfo()` exposes the package identity, Worker protocol, resolved
561
- storage mode, fallback details, Worker/WASM asset URLs, Rust crate version,
562
- generated schema version, and Rust feature list.
563
-
564
- Generated clients emit `syncularGeneratedRequiredRuntimeFeatures` from schema
565
- metadata. A basic app only needs `web-owned-sqlite-core`; apps using blob
566
- columns, CRDT/Yjs, or field encryption add `blobs`, `crdt-yjs`, and/or `e2ee`.
567
- `createSyncularAppDatabase()` passes those requirements into the Worker open
568
- path automatically.
569
-
570
- ## Storage
571
-
572
- Omitting `config.storage` defaults to `opfsSahPool`. If that default OPFS open
573
- fails because the browser cannot create the sync access handle, the Worker
574
- client retries with `indexedDb` and reports the fallback via
575
- `runtimeInfo().storageFallback`.
576
-
577
- Explicit storage is never silently changed:
578
-
579
- ```ts
580
- await createSyncularAppDatabase({
581
- config: {
582
- baseUrl: '/sync',
583
- actorId: 'user-1',
584
- clientId: 'client-1',
585
- storage: 'indexedDb',
586
- },
587
- });
588
- ```
589
-
590
- `client.compactStorage()` performs bounded local cleanup in Rust-owned SQLite:
591
- acked outbox commits and resolved conflicts by age, optional failed blob upload
592
- rows and inactive subscription state by age, blob cache pruning by byte budget,
593
- and tombstones only when the caller supplies `maxTombstoneServerVersion`.
594
-
595
- ```ts
596
- await syncular.client.compactStorage({
597
- olderThanMs: 7 * 24 * 60 * 60 * 1000,
598
- maxBlobCacheBytes: 256 * 1024 * 1024,
599
- pruneFailedBlobUploads: true,
600
- maxTombstoneServerVersion: lastServerVersionKnownSafeToDrop,
601
- });
602
- ```
603
-
604
- Tombstone cleanup is intentionally not enabled by age alone; deleting
605
- soft-deleted app rows before the server/version contract says they are safe can
606
- break later sync repair.
607
-
608
- ## CRDT Document Fields
609
-
610
- Generated app clients expose schema-derived CRDT field helpers, and the
611
- low-level client exposes generic `openCrdtField`, `applyCrdtFieldYjsUpdate`,
612
- `materializeCrdtField`, `snapshotCrdtFieldStateVector`, and `compactCrdtField`
613
- methods. Keep editor-specific code above this package: TipTap schemas,
614
- ProseMirror transforms, Excalidraw save policy, selection, undo, and WebView
615
- messages belong in app code or optional app adapters.
616
-
617
- Use `@syncular/client/crdt-yjs` for app-layer editor glue above this
618
- package. It connects Yjs binary update streams to Syncular's durable CRDT field
619
- API, preserves pending updates across failed writes, exposes backpressure,
620
- prefers queued native host writes when available, and refreshes app view models
621
- from materialized Syncular state after changed-row events.
622
-
623
- For rich editors, keep Yjs as the canonical field state. ProseMirror JSON,
624
- title, preview, outline, search text, and similar values are projections that
625
- apps should rebuild after a CRDT changed-row event, remote apply, or compaction.
626
- The Rust-owned client persists a compact binary Yjs state and state vector per
627
- document field, plus an append-only binary Yjs update log with `pending`,
628
- `flushed`, and `acked` status. Use `crdtDocumentSnapshot` to inspect the
629
- current compacted state/vector and queue counts, `crdtUpdateLog` for adapter
630
- diagnostics, and `compactStorage({ olderThanMs, pruneCrdtUpdateLog: true })` to
631
- prune old acked log entries without touching the canonical compact state.
632
-
633
- ## Assets
634
-
635
- The `@syncular/client` package writes the full Rust WASM artifact to
636
- `dist/wasm`:
637
-
638
- - `syncular.js`
639
- - `syncular_bg.wasm`
640
- - `syncular-runtime-artifact.json`
641
-
642
- It also writes the core artifact to `dist/wasm-core` and the ordered catalog to
643
- `dist/syncular-runtime-artifacts.json`.
644
-
645
- The default Worker resolves those assets relative to the package runtime.
646
- Generated app code can select from that catalog without changing the public
647
- query/mutation API:
648
-
649
- ```ts
650
- import { resolveSyncularRuntimeArtifactCatalog } from '@syncular/client';
651
-
652
- const catalogUrl = '/syncular/syncular-runtime-artifacts.json';
653
- const catalog = await fetch(catalogUrl).then((response) => response.json());
654
-
655
- await createSyncularAppDatabase({
656
- config,
657
- runtimeArtifacts: resolveSyncularRuntimeArtifactCatalog(catalog, {
658
- baseUrl: catalogUrl,
659
- }),
660
- });
661
- ```
662
-
663
- The first artifact containing every generated required feature is used. Custom
664
- asset serving can still pass a custom `worker`; the lower-level direct Rust
665
- client accepts advanced `runtime`, `module`, `wasmGlueUrl`, and `wasmUrl`
666
- options. Normal generated app code should not need those lower-level paths.
667
-
668
- Release WASM builds run a size budget check after `wasm-opt -Oz` and custom
669
- section stripping. The current checked budgets are `3.25 MiB` raw and
670
- `1.35 MiB` gzip. Override them only for an intentional release-size decision:
671
-
672
- ```bash
673
- SYNCULAR_WASM_RAW_BUDGET_BYTES=3407872 \
674
- SYNCULAR_WASM_GZIP_BUDGET_BYTES=1415578 \
675
- bun --cwd packages/client run size:wasm:check
676
- ```
677
-
678
- The check writes an attribution report to
679
- `.context/wasm-size/syncular-wasm-size.txt` when run through
680
- `packages/client` `build:wasm` or `size:wasm:check`. Release builds
681
- also write a non-shipping optimized profile WASM to
682
- `.context/wasm-size/syncular_bg.profile.wasm` before final custom section
683
- stripping so attribution can keep symbol names when available.
684
-
685
- The current browser client is the canonical Rust-owned SQLite runtime wrapper
686
- for generated clients. The no-CRDT/no-E2EE core binding artifact has measured
687
- byte savings; the current core artifact also omits blob upload/cache helpers.
688
- The client package `build` runs `build:wasm:variants`, which writes both
689
- artifacts plus the catalog, and generated loading can select the smallest
690
- matching artifact when an app serves the catalog. Publishing separate wrapper
691
- packages around the same WASM would not remove bytes.
692
-
693
- For local measurement or app experiments:
694
-
695
- ```bash
696
- bun --cwd packages/client run build:wasm:core
697
- bun --cwd packages/client run build:wasm:variants
698
- bun --cwd packages/client run catalog:wasm
699
- bun --cwd packages/client run size:wasm:core
700
- ```
701
-
702
- `build:wasm:core` writes `dist/wasm-core/syncular.js` and
703
- `dist/wasm-core/syncular_bg.wasm` with `web-owned-sqlite-core` only. That
704
- artifact does not include blob, CRDT/Yjs, or E2EE support. `catalog:wasm`
705
- combines `dist/wasm-core/syncular-runtime-artifact.json` and
706
- `dist/wasm/syncular-runtime-artifact.json` into the top-level
707
- `dist/syncular-runtime-artifacts.json` catalog.
708
-
709
- ## Package Scripts
710
-
711
- ```bash
712
- bun run build
713
- bun run test
714
- bun run test:wasm:auth
715
- bun run test:wasm:hono
716
- bun run test:wasm:variants
717
- ```
222
+ ## RPC protocol (6 message types)
223
+
224
+ `init`, `call`, `ready`, `result`, `error`, `event` every API method
225
+ multiplexes over `call` (typed end-to-end from the single `WorkerApi`
226
+ shape in `worker-protocol.ts`); `event` carries `sync-needed`,
227
+ `conflict` and `synced`. Query-result blobs transfer (not copy) when
228
+ they own their buffer.
718
229
 
719
- `test:wasm:hono` builds the dev WASM artifact and runs the Hono-backed browser
720
- smokes for auth retry, sync protocol edge cases, realtime wakeups, and blob
721
- transport behavior.
230
+ ## Package layout
231
+
232
+ | Entry | Contents |
233
+ |---|---|
234
+ | `.` | protocol core, transports, handle + RPC protocol (browser-safe, no SQLite) |
235
+ | `./worker` | `startSyncWorker` — worker-side bootstrap (pulls sqlite-wasm) |
236
+ | `./wasm` | sqlite-wasm bindings: `openPersistentWasmDatabase`, `openWasmDatabase` |
237
+ | `./bun` | bun:sqlite binding for tests |
238
+ | `./node` | better-sqlite3 binding: `openNodeDatabase` (Electron-main / plain Node) |
722
239
 
723
- `packages/client` `build:wasm`, `size:wasm:check`, and the conformance
724
- gates are the current browser runtime validation path. The old JS/wa-sqlite
725
- comparison benchmark was removed with the legacy TypeScript client runtime.
240
+ Tests drive the real worker entry in a bun `Worker` with bun:sqlite
241
+ injected through the bootstrap's database-factory override
242
+ (`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
243
+ exercised by `apps/demo`.