@syncular/client 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -709
- package/dist/apply.d.ts +80 -0
- package/dist/apply.js +301 -0
- package/dist/blob.d.ts +127 -0
- package/dist/blob.js +201 -0
- package/dist/bun-database.d.ts +22 -0
- package/dist/bun-database.js +61 -0
- package/dist/client.d.ts +320 -69
- package/dist/client.js +1627 -163
- package/dist/content-type.d.ts +2 -0
- package/dist/content-type.js +2 -0
- package/dist/database.d.ts +35 -80
- package/dist/database.js +32 -947
- package/dist/encryption.d.ts +47 -0
- package/dist/encryption.js +75 -0
- package/dist/errors.d.ts +8 -22
- package/dist/errors.js +10 -207
- package/dist/http.d.ts +32 -0
- package/dist/http.js +196 -0
- package/dist/index.d.ts +28 -16
- package/dist/index.js +25 -12
- package/dist/invalidation.d.ts +69 -0
- package/dist/invalidation.js +84 -0
- package/dist/leader-lock.d.ts +28 -0
- package/dist/leader-lock.js +38 -0
- package/dist/multi-tab.d.ts +134 -0
- package/dist/multi-tab.js +399 -0
- package/dist/node-database.d.ts +41 -0
- package/dist/node-database.js +133 -0
- package/dist/outbox.d.ts +56 -0
- package/dist/outbox.js +140 -0
- package/dist/schema.d.ts +107 -0
- package/dist/schema.js +267 -0
- package/dist/state.d.ts +40 -0
- package/dist/state.js +77 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +7 -0
- package/dist/wasm-database.d.ts +30 -0
- package/dist/wasm-database.js +170 -0
- package/dist/window.d.ts +64 -0
- package/dist/window.js +0 -0
- package/dist/worker-entry.d.ts +16 -2
- package/dist/worker-entry.js +300 -456
- package/dist/worker-host.d.ts +174 -0
- package/dist/worker-host.js +536 -0
- package/dist/worker-protocol.d.ts +173 -61
- package/dist/worker-protocol.js +7 -16
- package/package.json +45 -112
- package/src/apply.ts +435 -0
- package/src/blob.ts +333 -0
- package/src/bun-database.ts +75 -0
- package/src/client.ts +2247 -277
- package/src/content-type.ts +2 -0
- package/src/database.ts +63 -1389
- package/src/encryption.ts +123 -0
- package/src/errors.ts +11 -265
- package/src/http.ts +254 -0
- package/src/index.ts +25 -20
- package/src/invalidation.ts +128 -0
- package/src/leader-lock.ts +68 -0
- package/src/multi-tab.ts +550 -0
- package/src/node-database.ts +180 -0
- package/src/outbox.ts +206 -0
- package/src/schema.ts +397 -0
- package/src/state.ts +126 -0
- package/src/transport.ts +52 -0
- package/src/wasm-database.ts +279 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +391 -545
- package/src/worker-host.ts +800 -0
- package/src/worker-protocol.ts +204 -99
- package/dist/auth-leases.d.ts +0 -11
- package/dist/auth-leases.d.ts.map +0 -1
- package/dist/auth-leases.js +0 -140
- package/dist/auth-leases.js.map +0 -1
- package/dist/blob-limits.d.ts +0 -15
- package/dist/blob-limits.d.ts.map +0 -1
- package/dist/blob-limits.js +0 -66
- package/dist/blob-limits.js.map +0 -1
- package/dist/bridge-client.d.ts +0 -69
- package/dist/bridge-client.d.ts.map +0 -1
- package/dist/bridge-client.js +0 -282
- package/dist/bridge-client.js.map +0 -1
- package/dist/client-config.d.ts +0 -6
- package/dist/client-config.d.ts.map +0 -1
- package/dist/client-config.js +0 -18
- package/dist/client-config.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/command-history.d.ts +0 -35
- package/dist/command-history.d.ts.map +0 -1
- package/dist/command-history.js +0 -378
- package/dist/command-history.js.map +0 -1
- package/dist/console-diagnostics.d.ts +0 -33
- package/dist/console-diagnostics.d.ts.map +0 -1
- package/dist/console-diagnostics.js +0 -496
- package/dist/console-diagnostics.js.map +0 -1
- package/dist/crdt-yjs/index.d.ts +0 -4
- package/dist/crdt-yjs/index.d.ts.map +0 -1
- package/dist/crdt-yjs/index.js +0 -4
- package/dist/crdt-yjs/index.js.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
- package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.js +0 -284
- package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
- package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
- package/dist/database.d.ts.map +0 -1
- package/dist/database.js.map +0 -1
- package/dist/diagnostics.d.ts +0 -10
- package/dist/diagnostics.d.ts.map +0 -1
- package/dist/diagnostics.js +0 -83
- package/dist/diagnostics.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/generated-bridge.d.ts +0 -365
- package/dist/generated-bridge.d.ts.map +0 -1
- package/dist/generated-bridge.js +0 -250
- package/dist/generated-bridge.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mutations.d.ts +0 -72
- package/dist/mutations.d.ts.map +0 -1
- package/dist/mutations.js +0 -63
- package/dist/mutations.js.map +0 -1
- package/dist/network.d.ts +0 -3
- package/dist/network.d.ts.map +0 -1
- package/dist/network.js +0 -17
- package/dist/network.js.map +0 -1
- package/dist/react/index.d.ts +0 -169
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/index.js +0 -628
- package/dist/react/index.js.map +0 -1
- package/dist/react-native/index.d.ts +0 -35
- package/dist/react-native/index.d.ts.map +0 -1
- package/dist/react-native/index.js +0 -49
- package/dist/react-native/index.js.map +0 -1
- package/dist/runtime-contract.d.ts +0 -13
- package/dist/runtime-contract.d.ts.map +0 -1
- package/dist/runtime-contract.js +0 -24
- package/dist/runtime-contract.js.map +0 -1
- package/dist/rust-client.d.ts +0 -106
- package/dist/rust-client.d.ts.map +0 -1
- package/dist/rust-client.js +0 -938
- package/dist/rust-client.js.map +0 -1
- package/dist/sentry.d.ts +0 -35
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js +0 -155
- package/dist/sentry.js.map +0 -1
- package/dist/sql-safety.d.ts +0 -3
- package/dist/sql-safety.d.ts.map +0 -1
- package/dist/sql-safety.js +0 -62
- package/dist/sql-safety.js.map +0 -1
- package/dist/syncular-runtime-artifacts.json +0 -61
- package/dist/tauri/index.d.ts +0 -35
- package/dist/tauri/index.d.ts.map +0 -1
- package/dist/tauri/index.js +0 -114
- package/dist/tauri/index.js.map +0 -1
- package/dist/types.d.ts +0 -1096
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/wasm/.syncular-wasm-profile +0 -1
- package/dist/wasm/syncular-runtime-artifact.json +0 -21
- package/dist/wasm/syncular.d.ts +0 -207
- package/dist/wasm/syncular.js +0 -2341
- package/dist/wasm/syncular_bg.wasm +0 -0
- package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
- package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
- package/dist/wasm-bindings/runtime-contract.js +0 -112
- package/dist/wasm-bindings/runtime-contract.js.map +0 -1
- package/dist/wasm-core/.syncular-wasm-profile +0 -1
- package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
- package/dist/wasm-core/syncular.d.ts +0 -162
- package/dist/wasm-core/syncular.js +0 -1847
- package/dist/wasm-core/syncular_bg.wasm +0 -0
- package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
- package/dist/wasm-perf/.syncular-wasm-profile +0 -1
- package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
- package/dist/wasm-perf/syncular.d.ts +0 -207
- package/dist/wasm-perf/syncular.js +0 -2341
- package/dist/wasm-perf/syncular_bg.wasm +0 -0
- package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-runtime.d.ts +0 -23
- package/dist/wasm-runtime.d.ts.map +0 -1
- package/dist/wasm-runtime.js +0 -69
- package/dist/wasm-runtime.js.map +0 -1
- package/dist/worker-client.d.ts +0 -123
- package/dist/worker-client.d.ts.map +0 -1
- package/dist/worker-client.js +0 -1735
- package/dist/worker-client.js.map +0 -1
- package/dist/worker-entry.d.ts.map +0 -1
- package/dist/worker-entry.js.map +0 -1
- package/dist/worker-protocol.d.ts.map +0 -1
- package/dist/worker-protocol.js.map +0 -1
- package/dist/worker-realtime.d.ts +0 -39
- package/dist/worker-realtime.d.ts.map +0 -1
- package/dist/worker-realtime.js +0 -677
- package/dist/worker-realtime.js.map +0 -1
- package/src/auth-leases.ts +0 -251
- package/src/blob-limits.ts +0 -98
- package/src/bridge-client.ts +0 -512
- package/src/client-config.ts +0 -29
- package/src/command-history.ts +0 -623
- package/src/console-diagnostics.ts +0 -617
- package/src/crdt-yjs/index.ts +0 -3
- package/src/crdt-yjs/webview-host-facade.ts +0 -477
- package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
- package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
- package/src/diagnostics.ts +0 -116
- package/src/generated-bridge.ts +0 -741
- package/src/mutations.ts +0 -168
- package/src/network.ts +0 -32
- package/src/react/index.ts +0 -1036
- package/src/react-native/index.ts +0 -152
- package/src/runtime-contract.ts +0 -48
- package/src/rust-client.ts +0 -1491
- package/src/sentry.ts +0 -215
- package/src/sql-safety.ts +0 -61
- package/src/tauri/index.ts +0 -211
- package/src/types.ts +0 -1397
- package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
- package/src/wasm-bindings/runtime-contract.ts +0 -158
- package/src/wasm-runtime.ts +0 -145
- package/src/worker-client.ts +0 -2289
- package/src/worker-realtime.ts +0 -843
package/README.md
CHANGED
|
@@ -1,725 +1,243 @@
|
|
|
1
1
|
# @syncular/client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
20
|
+
// main thread
|
|
21
|
+
import { createSyncClientHandle } from '@syncular/client';
|
|
151
22
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
`
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
});
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
`
|
|
724
|
-
|
|
725
|
-
|
|
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`.
|