@syncular/tauri 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @syncular/tauri
2
+
3
+ Tauri integration for the Syncular client.
4
+
5
+ Part of [Syncular](https://syncular.dev) — an offline-first sync framework.
6
+ See the [Syncular repository](https://github.com/syncular/syncular) for docs.
7
+
8
+ ## License
9
+
10
+ Apache-2.0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @syncular/tauri — the JS bridge to the native syncular instance running
3
+ * inside the Tauri process (see `tauri-plugin-syncular`).
4
+ *
5
+ * The Tauri host runs a REAL Rust syncular client (file DB + native HTTP+WS
6
+ * transport). This module is a thin webview-side proxy that implements the SAME
7
+ * `SyncClientLike` interface the React package normalizes — so the hooks
8
+ * (`useSyncQuery`, `useMutation`, `usePresence`, …) work UNCHANGED against a
9
+ * Tauri app. It is the fourth host of one interface, after the direct
10
+ * `SyncClient`, the worker-leader `SyncClientHandle`, and the multi-tab
11
+ * follower (ROADMAP.md block 1).
12
+ *
13
+ * Every method forwards to the plugin's `syncular_command` command (the whole
14
+ * command surface in one JSON envelope — `{method, params}`), mirroring the FFI
15
+ * and the conformance shim. `query` uses the dedicated `syncular_query` fast
16
+ * path (one IPC round trip per live-query run — fine at Tauri IPC latency; see
17
+ * the README's pagination note for very large result sets). Client-observable
18
+ * events (`invalidate` / `presence` / `sync-needed` / `conflict` / …) arrive on
19
+ * the `syncular://event` Tauri event and fan out to the registered listeners.
20
+ *
21
+ * Bytes cross the command JSON as the established `{$bytes: hex}` envelope, the
22
+ * same convention the Rust command router and the driver protocol use.
23
+ *
24
+ * `@tauri-apps/api` is a PEER dependency: the bridge takes `invoke`/`listen`
25
+ * either from the ambient `window.__TAURI__` or via injected doubles (tests).
26
+ */
27
+ import type { ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SqlRow, SqlValue, WindowBase, WindowState } from '@syncular/client';
28
+ /** One event pushed on `syncular://event` (the derived client-observable set). */
29
+ interface SyncularEvent {
30
+ readonly type: string;
31
+ readonly [key: string]: unknown;
32
+ }
33
+ /** The two Tauri primitives the bridge needs — injectable for tests. */
34
+ export interface TauriApi {
35
+ invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
36
+ listen<T>(event: string, handler: (event: {
37
+ payload: T;
38
+ }) => void): Promise<() => void>;
39
+ }
40
+ /** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
41
+ export declare const SYNCULAR_EVENT = "syncular://event";
42
+ /** Config for {@link createTauriSyncClient}. */
43
+ export interface TauriSyncClientConfig {
44
+ /** The generated schema JSON (the app passes `schema` from typegen). */
45
+ readonly schema: unknown;
46
+ /**
47
+ * Client id for this device/actor. If omitted, a stable random id is
48
+ * generated and persisted by the caller (the bridge does not persist it —
49
+ * the native side owns the database, so pass the same id across launches).
50
+ */
51
+ readonly clientId: string;
52
+ /** §4.2 client limits, forwarded to the native `create`. */
53
+ readonly limits?: Record<string, unknown>;
54
+ /**
55
+ * The Tauri primitives. Omit in a real Tauri webview to auto-resolve from
56
+ * `@tauri-apps/api` (peer dep) or the ambient `window.__TAURI__`; inject in
57
+ * tests. Resolution is async, so construction is a factory (below).
58
+ */
59
+ readonly tauri?: TauriApi;
60
+ }
61
+ /** The bytes envelope both sides share. */
62
+ export type BytesEnvelope = {
63
+ readonly $bytes: string;
64
+ };
65
+ /**
66
+ * The webview-side proxy implementing `SyncClientLike` over the plugin. Every
67
+ * method is a promise (an IPC round trip); the React `normalizeClient` already
68
+ * wraps sync and async members uniformly, so the hooks accept it directly.
69
+ */
70
+ export declare class TauriSyncClient {
71
+ #private;
72
+ /** @internal — use {@link createTauriSyncClient}. */
73
+ constructor(tauri: TauriApi, unlisten: () => void);
74
+ /** @internal — fan an incoming plugin event out to the local listeners. */
75
+ __dispatchEvent(event: SyncularEvent): void;
76
+ onInvalidate(listener: InvalidationListener): () => void;
77
+ onPresence(listener: (scopeKey: string) => void): () => void;
78
+ query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
79
+ mutate(mutations: readonly MutationInput[]): Promise<string>;
80
+ /** Materialize a `crdt` column's collaborative text — decoded from the
81
+ * stored (server-merged) Yjs bytes. `name` selects the shared text
82
+ * (default `"text"`). An absent row / NULL column is the empty document. */
83
+ crdtText(table: string, rowId: string, column: string, name?: string): Promise<string>;
84
+ /** Insert `value` at UTF-16 offset `index` in a `crdt` column's text and
85
+ * push the resulting Yjs update (baseVersion-less). Returns the commit id. */
86
+ crdtInsertText(table: string, rowId: string, column: string, index: number, value: string, name?: string): Promise<string>;
87
+ /** Delete `len` UTF-16 code units at `index` in a `crdt` column's text. */
88
+ crdtDeleteText(table: string, rowId: string, column: string, index: number, len: number, name?: string): Promise<string>;
89
+ /** Escape hatch: apply an arbitrary Yjs update onto a `crdt` column. */
90
+ crdtApplyUpdate(table: string, rowId: string, column: string, update: Uint8Array): Promise<string>;
91
+ subscribe(input: {
92
+ readonly id: string;
93
+ readonly table: string;
94
+ readonly scopes?: Record<string, readonly string[]>;
95
+ readonly params?: string;
96
+ }): Promise<void>;
97
+ unsubscribe(id: string): Promise<void>;
98
+ setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
99
+ windowState(base: WindowBase): Promise<WindowState>;
100
+ sync(): Promise<unknown>;
101
+ syncUntilIdle(maxRounds?: number): Promise<unknown>;
102
+ conflicts(): Promise<readonly ConflictRecord[]>;
103
+ rejections(): Promise<readonly RejectionRecord[]>;
104
+ schemaFloor(): Promise<SchemaFloor | undefined>;
105
+ leaseState(): Promise<LeaseState | undefined>;
106
+ upgrading(): Promise<boolean>;
107
+ syncNeeded(): Promise<boolean>;
108
+ pendingCommits(): Promise<unknown[]>;
109
+ presence(scopeKey: string): Promise<readonly PresencePeer[]>;
110
+ setPresence(scopeKey: string, doc: Record<string, unknown> | null): Promise<void>;
111
+ connectRealtime(): Promise<void>;
112
+ disconnectRealtime(): Promise<void>;
113
+ /** Detach the event listener; the native core keeps running (host process). */
114
+ close(): Promise<void>;
115
+ }
116
+ /** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
117
+ export declare class TauriSyncError extends Error {
118
+ readonly code: string;
119
+ constructor(code: string, message: string);
120
+ }
121
+ /**
122
+ * Construct the bridge and issue the native `create` (opening/attaching the
123
+ * file DB on the Rust side per the plugin config). Returns a ready
124
+ * `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
125
+ * React `<SyncProvider client={…}>`.
126
+ */
127
+ export declare function createTauriSyncClient(config: TauriSyncClientConfig): Promise<TauriSyncClient>;
128
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * @syncular/tauri — the JS bridge to the native syncular instance running
3
+ * inside the Tauri process (see `tauri-plugin-syncular`).
4
+ *
5
+ * The Tauri host runs a REAL Rust syncular client (file DB + native HTTP+WS
6
+ * transport). This module is a thin webview-side proxy that implements the SAME
7
+ * `SyncClientLike` interface the React package normalizes — so the hooks
8
+ * (`useSyncQuery`, `useMutation`, `usePresence`, …) work UNCHANGED against a
9
+ * Tauri app. It is the fourth host of one interface, after the direct
10
+ * `SyncClient`, the worker-leader `SyncClientHandle`, and the multi-tab
11
+ * follower (ROADMAP.md block 1).
12
+ *
13
+ * Every method forwards to the plugin's `syncular_command` command (the whole
14
+ * command surface in one JSON envelope — `{method, params}`), mirroring the FFI
15
+ * and the conformance shim. `query` uses the dedicated `syncular_query` fast
16
+ * path (one IPC round trip per live-query run — fine at Tauri IPC latency; see
17
+ * the README's pagination note for very large result sets). Client-observable
18
+ * events (`invalidate` / `presence` / `sync-needed` / `conflict` / …) arrive on
19
+ * the `syncular://event` Tauri event and fan out to the registered listeners.
20
+ *
21
+ * Bytes cross the command JSON as the established `{$bytes: hex}` envelope, the
22
+ * same convention the Rust command router and the driver protocol use.
23
+ *
24
+ * `@tauri-apps/api` is a PEER dependency: the bridge takes `invoke`/`listen`
25
+ * either from the ambient `window.__TAURI__` or via injected doubles (tests).
26
+ */
27
+ /** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
28
+ export const SYNCULAR_EVENT = 'syncular://event';
29
+ const PLUGIN = 'plugin:syncular|';
30
+ function isBytesEnvelope(value) {
31
+ return (typeof value === 'object' &&
32
+ value !== null &&
33
+ typeof value.$bytes === 'string');
34
+ }
35
+ function bytesToHex(bytes) {
36
+ let out = '';
37
+ for (const b of bytes) {
38
+ out += b.toString(16).padStart(2, '0');
39
+ }
40
+ return out;
41
+ }
42
+ function hexToBytes(hex) {
43
+ const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
44
+ const out = new Uint8Array(clean.length / 2);
45
+ for (let i = 0; i < out.length; i++) {
46
+ out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
47
+ }
48
+ return out;
49
+ }
50
+ /** Encode an SQL param for the command JSON (bytes → `{$bytes: hex}`). */
51
+ function encodeParam(value) {
52
+ if (value instanceof Uint8Array)
53
+ return { $bytes: bytesToHex(value) };
54
+ if (typeof value === 'bigint')
55
+ return Number(value);
56
+ return value;
57
+ }
58
+ /** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
59
+ function decodeCell(value) {
60
+ if (isBytesEnvelope(value))
61
+ return hexToBytes(value.$bytes);
62
+ if (value === null ||
63
+ typeof value === 'string' ||
64
+ typeof value === 'number' ||
65
+ typeof value === 'boolean') {
66
+ return value;
67
+ }
68
+ // Objects/arrays that are not the bytes envelope round-trip as their JSON
69
+ // string (SQLite json columns arrive as text already; this is defensive).
70
+ return JSON.stringify(value);
71
+ }
72
+ function decodeRow(row) {
73
+ const out = {};
74
+ for (const [key, value] of Object.entries(row)) {
75
+ out[key] = decodeCell(value);
76
+ }
77
+ return out;
78
+ }
79
+ /** Resolve the Tauri primitives from the ambient environment when not injected. */
80
+ async function resolveTauri(injected) {
81
+ if (injected !== undefined)
82
+ return injected;
83
+ // Prefer the ambient global (present when `withGlobalTauri` is enabled).
84
+ const ambient = globalThis.__TAURI__;
85
+ if (ambient?.core?.invoke && ambient.event?.listen) {
86
+ return {
87
+ invoke: ambient.core.invoke.bind(ambient.core),
88
+ listen: ambient.event.listen.bind(ambient.event),
89
+ };
90
+ }
91
+ // Fall back to the ESM package (the common path). The specifiers are built
92
+ // indirectly so this optional peer dependency is not a hard compile-time
93
+ // module resolution — apps that inject `tauri` (or use the ambient global)
94
+ // never need `@tauri-apps/api` type-resolvable at build.
95
+ const dynamicImport = (specifier) => import(/* @vite-ignore */ specifier);
96
+ const core = (await dynamicImport('@tauri-apps/api/core'));
97
+ const event = (await dynamicImport('@tauri-apps/api/event'));
98
+ return { invoke: core.invoke, listen: event.listen };
99
+ }
100
+ /**
101
+ * The webview-side proxy implementing `SyncClientLike` over the plugin. Every
102
+ * method is a promise (an IPC round trip); the React `normalizeClient` already
103
+ * wraps sync and async members uniformly, so the hooks accept it directly.
104
+ */
105
+ export class TauriSyncClient {
106
+ #tauri;
107
+ #invalidationListeners = new Set();
108
+ #presenceListeners = new Set();
109
+ #unlisten;
110
+ #closed = false;
111
+ /** @internal — use {@link createTauriSyncClient}. */
112
+ constructor(tauri, unlisten) {
113
+ this.#tauri = tauri;
114
+ this.#unlisten = unlisten;
115
+ }
116
+ /** Dispatch a `syncular_command` and unwrap `{result}` / throw on `{error}`. */
117
+ async #command(method, params) {
118
+ const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
119
+ if (reply.error !== undefined) {
120
+ throw new TauriSyncError(reply.error.code, reply.error.message);
121
+ }
122
+ return reply.result;
123
+ }
124
+ /** @internal — fan an incoming plugin event out to the local listeners. */
125
+ __dispatchEvent(event) {
126
+ switch (event.type) {
127
+ case 'invalidate': {
128
+ // The native side derives a coarse invalidate (table granularity is the
129
+ // honest floor — the plugin does not ship per-row scope keys over IPC).
130
+ const payload = {
131
+ tables: toStringSet(event.tables),
132
+ scopeKeys: toStringSet(event.scopeKeys),
133
+ };
134
+ for (const listener of this.#invalidationListeners) {
135
+ try {
136
+ listener(payload);
137
+ }
138
+ catch {
139
+ /* a UI listener must never break event dispatch */
140
+ }
141
+ }
142
+ break;
143
+ }
144
+ case 'presence': {
145
+ const scopeKey = typeof event.scopeKey === 'string' ? event.scopeKey : '';
146
+ for (const listener of this.#presenceListeners) {
147
+ try {
148
+ listener(scopeKey);
149
+ }
150
+ catch {
151
+ /* never break dispatch */
152
+ }
153
+ }
154
+ break;
155
+ }
156
+ default:
157
+ // sync-needed / conflict / rejection / schema-floor / lease / error:
158
+ // observable via the accessor methods; a coarse invalidate already
159
+ // accompanies data-changing events, so nothing else to fan out here.
160
+ break;
161
+ }
162
+ }
163
+ // -- SyncClientLike --------------------------------------------------------
164
+ onInvalidate(listener) {
165
+ this.#invalidationListeners.add(listener);
166
+ return () => this.#invalidationListeners.delete(listener);
167
+ }
168
+ onPresence(listener) {
169
+ this.#presenceListeners.add(listener);
170
+ return () => this.#presenceListeners.delete(listener);
171
+ }
172
+ async query(sql, params) {
173
+ const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
174
+ if (reply.error !== undefined) {
175
+ throw new TauriSyncError(reply.error.code, reply.error.message);
176
+ }
177
+ const rows = reply.result.rows ?? [];
178
+ return rows.map((r) => decodeRow(r));
179
+ }
180
+ async mutate(mutations) {
181
+ const result = (await this.#command('mutate', {
182
+ mutations: mutations.map(encodeMutation),
183
+ }));
184
+ return result.clientCommitId;
185
+ }
186
+ // -- Native CRDT (SPEC.md §5.10.5; needs the plugin `crdt-yjs` feature) ------
187
+ /** Materialize a `crdt` column's collaborative text — decoded from the
188
+ * stored (server-merged) Yjs bytes. `name` selects the shared text
189
+ * (default `"text"`). An absent row / NULL column is the empty document. */
190
+ async crdtText(table, rowId, column, name = 'text') {
191
+ const result = (await this.#command('crdtText', {
192
+ table,
193
+ rowId,
194
+ column,
195
+ name,
196
+ }));
197
+ return result.text;
198
+ }
199
+ /** Insert `value` at UTF-16 offset `index` in a `crdt` column's text and
200
+ * push the resulting Yjs update (baseVersion-less). Returns the commit id. */
201
+ async crdtInsertText(table, rowId, column, index, value, name = 'text') {
202
+ const result = (await this.#command('crdtInsertText', {
203
+ table,
204
+ rowId,
205
+ column,
206
+ name,
207
+ index,
208
+ value,
209
+ }));
210
+ return result.clientCommitId;
211
+ }
212
+ /** Delete `len` UTF-16 code units at `index` in a `crdt` column's text. */
213
+ async crdtDeleteText(table, rowId, column, index, len, name = 'text') {
214
+ const result = (await this.#command('crdtDeleteText', {
215
+ table,
216
+ rowId,
217
+ column,
218
+ name,
219
+ index,
220
+ len,
221
+ }));
222
+ return result.clientCommitId;
223
+ }
224
+ /** Escape hatch: apply an arbitrary Yjs update onto a `crdt` column. */
225
+ async crdtApplyUpdate(table, rowId, column, update) {
226
+ const result = (await this.#command('crdtApplyUpdate', {
227
+ table,
228
+ rowId,
229
+ column,
230
+ update: { $bytes: bytesToHex(update) },
231
+ }));
232
+ return result.clientCommitId;
233
+ }
234
+ async subscribe(input) {
235
+ await this.#command('subscribe', {
236
+ id: input.id,
237
+ table: input.table,
238
+ scopes: input.scopes ?? {},
239
+ ...(input.params !== undefined ? { params: input.params } : {}),
240
+ });
241
+ }
242
+ async unsubscribe(id) {
243
+ await this.#command('unsubscribe', { id });
244
+ }
245
+ async setWindow(base, units) {
246
+ await this.#command('setWindow', {
247
+ base: base,
248
+ units: units,
249
+ });
250
+ }
251
+ async windowState(base) {
252
+ const result = (await this.#command('windowState', {
253
+ base: base,
254
+ }));
255
+ return { units: result.units };
256
+ }
257
+ async sync() {
258
+ return this.#command('sync', {});
259
+ }
260
+ async syncUntilIdle(maxRounds) {
261
+ return this.#command('syncUntilIdle', {
262
+ ...(maxRounds !== undefined ? { maxRounds } : {}),
263
+ });
264
+ }
265
+ async conflicts() {
266
+ const result = (await this.#command('conflicts', {}));
267
+ return result.conflicts;
268
+ }
269
+ async rejections() {
270
+ const result = (await this.#command('rejections', {}));
271
+ return result.rejections;
272
+ }
273
+ async schemaFloor() {
274
+ const result = (await this.#command('schemaFloor', {}));
275
+ return result.floor ?? undefined;
276
+ }
277
+ async leaseState() {
278
+ const result = (await this.#command('leaseState', {}));
279
+ return result.lease ?? undefined;
280
+ }
281
+ async upgrading() {
282
+ const result = (await this.#command('upgrading', {}));
283
+ return result.value;
284
+ }
285
+ async syncNeeded() {
286
+ const result = (await this.#command('syncNeeded', {}));
287
+ return result.value;
288
+ }
289
+ async pendingCommits() {
290
+ const result = (await this.#command('pendingCommitIds', {}));
291
+ return result.ids;
292
+ }
293
+ async presence(scopeKey) {
294
+ const result = (await this.#command('presence', { scopeKey }));
295
+ return result.peers;
296
+ }
297
+ async setPresence(scopeKey, doc) {
298
+ await this.#command('setPresence', { scopeKey, doc });
299
+ }
300
+ async connectRealtime() {
301
+ await this.#command('connectRealtime', {});
302
+ }
303
+ async disconnectRealtime() {
304
+ await this.#command('disconnectRealtime', {});
305
+ }
306
+ /** Detach the event listener; the native core keeps running (host process). */
307
+ async close() {
308
+ if (this.#closed)
309
+ return;
310
+ this.#closed = true;
311
+ this.#unlisten?.();
312
+ this.#unlisten = undefined;
313
+ this.#invalidationListeners.clear();
314
+ this.#presenceListeners.clear();
315
+ }
316
+ }
317
+ /** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
318
+ export class TauriSyncError extends Error {
319
+ code;
320
+ constructor(code, message) {
321
+ super(message);
322
+ this.name = 'TauriSyncError';
323
+ this.code = code;
324
+ }
325
+ }
326
+ function toStringSet(value) {
327
+ if (Array.isArray(value)) {
328
+ return new Set(value.filter((v) => typeof v === 'string'));
329
+ }
330
+ return new Set();
331
+ }
332
+ /** Encode one mutation for the command JSON (bytes inside `values` handled by
333
+ * the native side; the driver form is already JSON-able). */
334
+ function encodeMutation(mutation) {
335
+ return mutation;
336
+ }
337
+ /**
338
+ * Construct the bridge and issue the native `create` (opening/attaching the
339
+ * file DB on the Rust side per the plugin config). Returns a ready
340
+ * `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
341
+ * React `<SyncProvider client={…}>`.
342
+ */
343
+ export async function createTauriSyncClient(config) {
344
+ const tauri = await resolveTauri(config.tauri);
345
+ // Wire the event stream BEFORE create, so no early invalidate is missed.
346
+ const clientRef = {
347
+ client: undefined,
348
+ };
349
+ const unlisten = await tauri.listen(SYNCULAR_EVENT, (event) => {
350
+ clientRef.client?.__dispatchEvent(event.payload);
351
+ });
352
+ const client = new TauriSyncClient(tauri, unlisten);
353
+ clientRef.client = client;
354
+ // The native side owns the db path (plugin config); the JS side supplies the
355
+ // schema, clientId, and limits. `dbPath` is injected by the plugin.
356
+ const reply = await tauri.invoke(`${PLUGIN}syncular_command`, {
357
+ command: {
358
+ method: 'create',
359
+ params: {
360
+ clientId: config.clientId,
361
+ schema: config.schema,
362
+ ...(config.limits !== undefined ? { limits: config.limits } : {}),
363
+ },
364
+ },
365
+ });
366
+ if (reply.error !== undefined) {
367
+ unlisten();
368
+ throw new TauriSyncError(reply.error.code, reply.error.message);
369
+ }
370
+ return client;
371
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@syncular/tauri",
3
+ "version": "0.2.0",
4
+ "description": "Tauri integration for the Syncular client",
5
+ "license": "Apache-2.0",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/tauri"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "realtime",
20
+ "database",
21
+ "typescript"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "bun": "./src/index.ts",
31
+ "browser": "./src/index.ts",
32
+ "import": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md",
42
+ "!src/**/*.test.ts",
43
+ "!src/**/*.test.tsx",
44
+ "!dist/**/*.test.js",
45
+ "!dist/**/*.test.d.ts"
46
+ ],
47
+ "scripts": {
48
+ "test": "bun test"
49
+ },
50
+ "dependencies": {
51
+ "@syncular/client": "0.2.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@tauri-apps/api": ">=2.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@tauri-apps/api": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@syncular/react": "0.2.0"
63
+ }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,552 @@
1
+ /**
2
+ * @syncular/tauri — the JS bridge to the native syncular instance running
3
+ * inside the Tauri process (see `tauri-plugin-syncular`).
4
+ *
5
+ * The Tauri host runs a REAL Rust syncular client (file DB + native HTTP+WS
6
+ * transport). This module is a thin webview-side proxy that implements the SAME
7
+ * `SyncClientLike` interface the React package normalizes — so the hooks
8
+ * (`useSyncQuery`, `useMutation`, `usePresence`, …) work UNCHANGED against a
9
+ * Tauri app. It is the fourth host of one interface, after the direct
10
+ * `SyncClient`, the worker-leader `SyncClientHandle`, and the multi-tab
11
+ * follower (ROADMAP.md block 1).
12
+ *
13
+ * Every method forwards to the plugin's `syncular_command` command (the whole
14
+ * command surface in one JSON envelope — `{method, params}`), mirroring the FFI
15
+ * and the conformance shim. `query` uses the dedicated `syncular_query` fast
16
+ * path (one IPC round trip per live-query run — fine at Tauri IPC latency; see
17
+ * the README's pagination note for very large result sets). Client-observable
18
+ * events (`invalidate` / `presence` / `sync-needed` / `conflict` / …) arrive on
19
+ * the `syncular://event` Tauri event and fan out to the registered listeners.
20
+ *
21
+ * Bytes cross the command JSON as the established `{$bytes: hex}` envelope, the
22
+ * same convention the Rust command router and the driver protocol use.
23
+ *
24
+ * `@tauri-apps/api` is a PEER dependency: the bridge takes `invoke`/`listen`
25
+ * either from the ambient `window.__TAURI__` or via injected doubles (tests).
26
+ */
27
+
28
+ // -- Types the bridge speaks (structurally the web-client's) -----------------
29
+ // Imported as types only, so the bridge has no runtime dependency on
30
+ // @syncular/client (the app already carries it via @syncular/react).
31
+ import type {
32
+ ConflictRecord,
33
+ InvalidationEvent,
34
+ InvalidationListener,
35
+ LeaseState,
36
+ MutationInput,
37
+ PresencePeer,
38
+ RejectionRecord,
39
+ SchemaFloor,
40
+ SqlRow,
41
+ SqlValue,
42
+ WindowBase,
43
+ WindowState,
44
+ } from '@syncular/client';
45
+
46
+ /** A driver-protocol reply: `{result}` on success or `{error}` on failure. */
47
+ interface CommandReply {
48
+ readonly result?: unknown;
49
+ readonly error?: { readonly code: string; readonly message: string };
50
+ }
51
+
52
+ /** One event pushed on `syncular://event` (the derived client-observable set). */
53
+ interface SyncularEvent {
54
+ readonly type: string;
55
+ readonly [key: string]: unknown;
56
+ }
57
+
58
+ /** The two Tauri primitives the bridge needs — injectable for tests. */
59
+ export interface TauriApi {
60
+ invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
61
+ listen<T>(
62
+ event: string,
63
+ handler: (event: { payload: T }) => void,
64
+ ): Promise<() => void>;
65
+ }
66
+
67
+ /** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
68
+ export const SYNCULAR_EVENT = 'syncular://event';
69
+
70
+ const PLUGIN = 'plugin:syncular|';
71
+
72
+ /** Config for {@link createTauriSyncClient}. */
73
+ export interface TauriSyncClientConfig {
74
+ /** The generated schema JSON (the app passes `schema` from typegen). */
75
+ readonly schema: unknown;
76
+ /**
77
+ * Client id for this device/actor. If omitted, a stable random id is
78
+ * generated and persisted by the caller (the bridge does not persist it —
79
+ * the native side owns the database, so pass the same id across launches).
80
+ */
81
+ readonly clientId: string;
82
+ /** §4.2 client limits, forwarded to the native `create`. */
83
+ readonly limits?: Record<string, unknown>;
84
+ /**
85
+ * The Tauri primitives. Omit in a real Tauri webview to auto-resolve from
86
+ * `@tauri-apps/api` (peer dep) or the ambient `window.__TAURI__`; inject in
87
+ * tests. Resolution is async, so construction is a factory (below).
88
+ */
89
+ readonly tauri?: TauriApi;
90
+ }
91
+
92
+ /** The bytes envelope both sides share. */
93
+ export type BytesEnvelope = { readonly $bytes: string };
94
+
95
+ function isBytesEnvelope(value: unknown): value is BytesEnvelope {
96
+ return (
97
+ typeof value === 'object' &&
98
+ value !== null &&
99
+ typeof (value as { $bytes?: unknown }).$bytes === 'string'
100
+ );
101
+ }
102
+
103
+ function bytesToHex(bytes: Uint8Array): string {
104
+ let out = '';
105
+ for (const b of bytes) {
106
+ out += b.toString(16).padStart(2, '0');
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function hexToBytes(hex: string): Uint8Array {
112
+ const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
113
+ const out = new Uint8Array(clean.length / 2);
114
+ for (let i = 0; i < out.length; i++) {
115
+ out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Encode an SQL param for the command JSON (bytes → `{$bytes: hex}`). */
121
+ function encodeParam(value: SqlValue): unknown {
122
+ if (value instanceof Uint8Array) return { $bytes: bytesToHex(value) };
123
+ if (typeof value === 'bigint') return Number(value);
124
+ return value;
125
+ }
126
+
127
+ /** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
128
+ function decodeCell(value: unknown): SqlValue {
129
+ if (isBytesEnvelope(value)) return hexToBytes(value.$bytes);
130
+ if (
131
+ value === null ||
132
+ typeof value === 'string' ||
133
+ typeof value === 'number' ||
134
+ typeof value === 'boolean'
135
+ ) {
136
+ return value;
137
+ }
138
+ // Objects/arrays that are not the bytes envelope round-trip as their JSON
139
+ // string (SQLite json columns arrive as text already; this is defensive).
140
+ return JSON.stringify(value);
141
+ }
142
+
143
+ function decodeRow(row: Record<string, unknown>): SqlRow {
144
+ const out: SqlRow = {};
145
+ for (const [key, value] of Object.entries(row)) {
146
+ out[key] = decodeCell(value);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /** Resolve the Tauri primitives from the ambient environment when not injected. */
152
+ async function resolveTauri(injected: TauriApi | undefined): Promise<TauriApi> {
153
+ if (injected !== undefined) return injected;
154
+ // Prefer the ambient global (present when `withGlobalTauri` is enabled).
155
+ const ambient = (
156
+ globalThis as {
157
+ __TAURI__?: {
158
+ core?: { invoke?: TauriApi['invoke'] };
159
+ event?: { listen?: TauriApi['listen'] };
160
+ };
161
+ }
162
+ ).__TAURI__;
163
+ if (ambient?.core?.invoke && ambient.event?.listen) {
164
+ return {
165
+ invoke: ambient.core.invoke.bind(ambient.core),
166
+ listen: ambient.event.listen.bind(ambient.event),
167
+ };
168
+ }
169
+ // Fall back to the ESM package (the common path). The specifiers are built
170
+ // indirectly so this optional peer dependency is not a hard compile-time
171
+ // module resolution — apps that inject `tauri` (or use the ambient global)
172
+ // never need `@tauri-apps/api` type-resolvable at build.
173
+ const dynamicImport = (specifier: string): Promise<unknown> =>
174
+ import(/* @vite-ignore */ specifier);
175
+ const core = (await dynamicImport('@tauri-apps/api/core')) as {
176
+ invoke: TauriApi['invoke'];
177
+ };
178
+ const event = (await dynamicImport('@tauri-apps/api/event')) as {
179
+ listen: TauriApi['listen'];
180
+ };
181
+ return { invoke: core.invoke, listen: event.listen };
182
+ }
183
+
184
+ /**
185
+ * The webview-side proxy implementing `SyncClientLike` over the plugin. Every
186
+ * method is a promise (an IPC round trip); the React `normalizeClient` already
187
+ * wraps sync and async members uniformly, so the hooks accept it directly.
188
+ */
189
+ export class TauriSyncClient {
190
+ readonly #tauri: TauriApi;
191
+ readonly #invalidationListeners = new Set<InvalidationListener>();
192
+ readonly #presenceListeners = new Set<(scopeKey: string) => void>();
193
+ #unlisten: (() => void) | undefined;
194
+ #closed = false;
195
+
196
+ /** @internal — use {@link createTauriSyncClient}. */
197
+ constructor(tauri: TauriApi, unlisten: () => void) {
198
+ this.#tauri = tauri;
199
+ this.#unlisten = unlisten;
200
+ }
201
+
202
+ /** Dispatch a `syncular_command` and unwrap `{result}` / throw on `{error}`. */
203
+ async #command(
204
+ method: string,
205
+ params: Record<string, unknown>,
206
+ ): Promise<unknown> {
207
+ const reply = await this.#tauri.invoke<CommandReply>(
208
+ `${PLUGIN}syncular_command`,
209
+ { command: { method, params } },
210
+ );
211
+ if (reply.error !== undefined) {
212
+ throw new TauriSyncError(reply.error.code, reply.error.message);
213
+ }
214
+ return reply.result;
215
+ }
216
+
217
+ /** @internal — fan an incoming plugin event out to the local listeners. */
218
+ __dispatchEvent(event: SyncularEvent): void {
219
+ switch (event.type) {
220
+ case 'invalidate': {
221
+ // The native side derives a coarse invalidate (table granularity is the
222
+ // honest floor — the plugin does not ship per-row scope keys over IPC).
223
+ const payload: InvalidationEvent = {
224
+ tables: toStringSet(event.tables),
225
+ scopeKeys: toStringSet(event.scopeKeys),
226
+ };
227
+ for (const listener of this.#invalidationListeners) {
228
+ try {
229
+ listener(payload);
230
+ } catch {
231
+ /* a UI listener must never break event dispatch */
232
+ }
233
+ }
234
+ break;
235
+ }
236
+ case 'presence': {
237
+ const scopeKey =
238
+ typeof event.scopeKey === 'string' ? event.scopeKey : '';
239
+ for (const listener of this.#presenceListeners) {
240
+ try {
241
+ listener(scopeKey);
242
+ } catch {
243
+ /* never break dispatch */
244
+ }
245
+ }
246
+ break;
247
+ }
248
+ default:
249
+ // sync-needed / conflict / rejection / schema-floor / lease / error:
250
+ // observable via the accessor methods; a coarse invalidate already
251
+ // accompanies data-changing events, so nothing else to fan out here.
252
+ break;
253
+ }
254
+ }
255
+
256
+ // -- SyncClientLike --------------------------------------------------------
257
+
258
+ onInvalidate(listener: InvalidationListener): () => void {
259
+ this.#invalidationListeners.add(listener);
260
+ return () => this.#invalidationListeners.delete(listener);
261
+ }
262
+
263
+ onPresence(listener: (scopeKey: string) => void): () => void {
264
+ this.#presenceListeners.add(listener);
265
+ return () => this.#presenceListeners.delete(listener);
266
+ }
267
+
268
+ async query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]> {
269
+ const reply = await this.#tauri.invoke<CommandReply>(
270
+ `${PLUGIN}syncular_query`,
271
+ { sql, params: (params ?? []).map(encodeParam) },
272
+ );
273
+ if (reply.error !== undefined) {
274
+ throw new TauriSyncError(reply.error.code, reply.error.message);
275
+ }
276
+ const rows = (reply.result as { rows?: unknown[] }).rows ?? [];
277
+ return rows.map((r) => decodeRow(r as Record<string, unknown>));
278
+ }
279
+
280
+ async mutate(mutations: readonly MutationInput[]): Promise<string> {
281
+ const result = (await this.#command('mutate', {
282
+ mutations: mutations.map(encodeMutation),
283
+ })) as { clientCommitId: string };
284
+ return result.clientCommitId;
285
+ }
286
+
287
+ // -- Native CRDT (SPEC.md §5.10.5; needs the plugin `crdt-yjs` feature) ------
288
+
289
+ /** Materialize a `crdt` column's collaborative text — decoded from the
290
+ * stored (server-merged) Yjs bytes. `name` selects the shared text
291
+ * (default `"text"`). An absent row / NULL column is the empty document. */
292
+ async crdtText(
293
+ table: string,
294
+ rowId: string,
295
+ column: string,
296
+ name = 'text',
297
+ ): Promise<string> {
298
+ const result = (await this.#command('crdtText', {
299
+ table,
300
+ rowId,
301
+ column,
302
+ name,
303
+ })) as { text: string };
304
+ return result.text;
305
+ }
306
+
307
+ /** Insert `value` at UTF-16 offset `index` in a `crdt` column's text and
308
+ * push the resulting Yjs update (baseVersion-less). Returns the commit id. */
309
+ async crdtInsertText(
310
+ table: string,
311
+ rowId: string,
312
+ column: string,
313
+ index: number,
314
+ value: string,
315
+ name = 'text',
316
+ ): Promise<string> {
317
+ const result = (await this.#command('crdtInsertText', {
318
+ table,
319
+ rowId,
320
+ column,
321
+ name,
322
+ index,
323
+ value,
324
+ })) as { clientCommitId: string };
325
+ return result.clientCommitId;
326
+ }
327
+
328
+ /** Delete `len` UTF-16 code units at `index` in a `crdt` column's text. */
329
+ async crdtDeleteText(
330
+ table: string,
331
+ rowId: string,
332
+ column: string,
333
+ index: number,
334
+ len: number,
335
+ name = 'text',
336
+ ): Promise<string> {
337
+ const result = (await this.#command('crdtDeleteText', {
338
+ table,
339
+ rowId,
340
+ column,
341
+ name,
342
+ index,
343
+ len,
344
+ })) as { clientCommitId: string };
345
+ return result.clientCommitId;
346
+ }
347
+
348
+ /** Escape hatch: apply an arbitrary Yjs update onto a `crdt` column. */
349
+ async crdtApplyUpdate(
350
+ table: string,
351
+ rowId: string,
352
+ column: string,
353
+ update: Uint8Array,
354
+ ): Promise<string> {
355
+ const result = (await this.#command('crdtApplyUpdate', {
356
+ table,
357
+ rowId,
358
+ column,
359
+ update: { $bytes: bytesToHex(update) },
360
+ })) as { clientCommitId: string };
361
+ return result.clientCommitId;
362
+ }
363
+
364
+ async subscribe(input: {
365
+ readonly id: string;
366
+ readonly table: string;
367
+ readonly scopes?: Record<string, readonly string[]>;
368
+ readonly params?: string;
369
+ }): Promise<void> {
370
+ await this.#command('subscribe', {
371
+ id: input.id,
372
+ table: input.table,
373
+ scopes: input.scopes ?? {},
374
+ ...(input.params !== undefined ? { params: input.params } : {}),
375
+ });
376
+ }
377
+
378
+ async unsubscribe(id: string): Promise<void> {
379
+ await this.#command('unsubscribe', { id });
380
+ }
381
+
382
+ async setWindow(base: WindowBase, units: readonly string[]): Promise<void> {
383
+ await this.#command('setWindow', {
384
+ base: base as unknown as Record<string, unknown>,
385
+ units: units as string[],
386
+ });
387
+ }
388
+
389
+ async windowState(base: WindowBase): Promise<WindowState> {
390
+ const result = (await this.#command('windowState', {
391
+ base: base as unknown as Record<string, unknown>,
392
+ })) as { units: string[] };
393
+ return { units: result.units };
394
+ }
395
+
396
+ async sync(): Promise<unknown> {
397
+ return this.#command('sync', {});
398
+ }
399
+
400
+ async syncUntilIdle(maxRounds?: number): Promise<unknown> {
401
+ return this.#command('syncUntilIdle', {
402
+ ...(maxRounds !== undefined ? { maxRounds } : {}),
403
+ });
404
+ }
405
+
406
+ async conflicts(): Promise<readonly ConflictRecord[]> {
407
+ const result = (await this.#command('conflicts', {})) as {
408
+ conflicts: ConflictRecord[];
409
+ };
410
+ return result.conflicts;
411
+ }
412
+
413
+ async rejections(): Promise<readonly RejectionRecord[]> {
414
+ const result = (await this.#command('rejections', {})) as {
415
+ rejections: RejectionRecord[];
416
+ };
417
+ return result.rejections;
418
+ }
419
+
420
+ async schemaFloor(): Promise<SchemaFloor | undefined> {
421
+ const result = (await this.#command('schemaFloor', {})) as {
422
+ floor?: SchemaFloor;
423
+ };
424
+ return result.floor ?? undefined;
425
+ }
426
+
427
+ async leaseState(): Promise<LeaseState | undefined> {
428
+ const result = (await this.#command('leaseState', {})) as {
429
+ lease?: LeaseState;
430
+ };
431
+ return result.lease ?? undefined;
432
+ }
433
+
434
+ async upgrading(): Promise<boolean> {
435
+ const result = (await this.#command('upgrading', {})) as { value: boolean };
436
+ return result.value;
437
+ }
438
+
439
+ async syncNeeded(): Promise<boolean> {
440
+ const result = (await this.#command('syncNeeded', {})) as {
441
+ value: boolean;
442
+ };
443
+ return result.value;
444
+ }
445
+
446
+ async pendingCommits(): Promise<unknown[]> {
447
+ const result = (await this.#command('pendingCommitIds', {})) as {
448
+ ids: string[];
449
+ };
450
+ return result.ids;
451
+ }
452
+
453
+ async presence(scopeKey: string): Promise<readonly PresencePeer[]> {
454
+ const result = (await this.#command('presence', { scopeKey })) as {
455
+ peers: PresencePeer[];
456
+ };
457
+ return result.peers;
458
+ }
459
+
460
+ async setPresence(
461
+ scopeKey: string,
462
+ doc: Record<string, unknown> | null,
463
+ ): Promise<void> {
464
+ await this.#command('setPresence', { scopeKey, doc });
465
+ }
466
+
467
+ async connectRealtime(): Promise<void> {
468
+ await this.#command('connectRealtime', {});
469
+ }
470
+
471
+ async disconnectRealtime(): Promise<void> {
472
+ await this.#command('disconnectRealtime', {});
473
+ }
474
+
475
+ /** Detach the event listener; the native core keeps running (host process). */
476
+ async close(): Promise<void> {
477
+ if (this.#closed) return;
478
+ this.#closed = true;
479
+ this.#unlisten?.();
480
+ this.#unlisten = undefined;
481
+ this.#invalidationListeners.clear();
482
+ this.#presenceListeners.clear();
483
+ }
484
+ }
485
+
486
+ /** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
487
+ export class TauriSyncError extends Error {
488
+ readonly code: string;
489
+ constructor(code: string, message: string) {
490
+ super(message);
491
+ this.name = 'TauriSyncError';
492
+ this.code = code;
493
+ }
494
+ }
495
+
496
+ function toStringSet(value: unknown): ReadonlySet<string> {
497
+ if (Array.isArray(value)) {
498
+ return new Set(value.filter((v): v is string => typeof v === 'string'));
499
+ }
500
+ return new Set<string>();
501
+ }
502
+
503
+ /** Encode one mutation for the command JSON (bytes inside `values` handled by
504
+ * the native side; the driver form is already JSON-able). */
505
+ function encodeMutation(mutation: MutationInput): unknown {
506
+ return mutation;
507
+ }
508
+
509
+ /**
510
+ * Construct the bridge and issue the native `create` (opening/attaching the
511
+ * file DB on the Rust side per the plugin config). Returns a ready
512
+ * `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
513
+ * React `<SyncProvider client={…}>`.
514
+ */
515
+ export async function createTauriSyncClient(
516
+ config: TauriSyncClientConfig,
517
+ ): Promise<TauriSyncClient> {
518
+ const tauri = await resolveTauri(config.tauri);
519
+
520
+ // Wire the event stream BEFORE create, so no early invalidate is missed.
521
+ const clientRef: { client: TauriSyncClient | undefined } = {
522
+ client: undefined,
523
+ };
524
+ const unlisten = await tauri.listen<SyncularEvent>(
525
+ SYNCULAR_EVENT,
526
+ (event) => {
527
+ clientRef.client?.__dispatchEvent(event.payload);
528
+ },
529
+ );
530
+
531
+ const client = new TauriSyncClient(tauri, unlisten);
532
+ clientRef.client = client;
533
+
534
+ // The native side owns the db path (plugin config); the JS side supplies the
535
+ // schema, clientId, and limits. `dbPath` is injected by the plugin.
536
+ const reply = await tauri.invoke<CommandReply>(`${PLUGIN}syncular_command`, {
537
+ command: {
538
+ method: 'create',
539
+ params: {
540
+ clientId: config.clientId,
541
+ schema: config.schema,
542
+ ...(config.limits !== undefined ? { limits: config.limits } : {}),
543
+ },
544
+ },
545
+ });
546
+ if (reply.error !== undefined) {
547
+ unlisten();
548
+ throw new TauriSyncError(reply.error.code, reply.error.message);
549
+ }
550
+
551
+ return client;
552
+ }