@syncular/tauri 0.4.1 → 0.5.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 +11 -0
- package/dist/index.d.ts +19 -10
- package/dist/index.js +171 -26
- package/package.json +3 -8
- package/src/index.ts +196 -35
package/README.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Tauri integration for the Syncular client.
|
|
4
4
|
|
|
5
|
+
Install the bridge together with its required Tauri JavaScript API peer:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @syncular/tauri @tauri-apps/api
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Reactive snapshots use the plugin's independent read-only SQLite path, so
|
|
12
|
+
local Tauri views remain responsive while the native client is syncing over
|
|
13
|
+
HTTP/WebSocket. Mutations, sync, and all durable writes remain serialized on
|
|
14
|
+
the single mutable core owner.
|
|
15
|
+
|
|
5
16
|
Part of [Syncular](https://syncular.dev) — an offline-first sync framework.
|
|
6
17
|
See the [Syncular repository](https://github.com/syncular/syncular) for docs.
|
|
7
18
|
|
package/dist/index.d.ts
CHANGED
|
@@ -13,18 +13,20 @@
|
|
|
13
13
|
* Every method forwards to the plugin's `syncular_command` command (the whole
|
|
14
14
|
* command surface in one JSON envelope — `{method, params}`), mirroring the FFI
|
|
15
15
|
* and the conformance shim. `query` uses the dedicated `syncular_query` fast
|
|
16
|
-
* path
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* path; atomic reactive reads use `syncular_query_snapshot`, backed by an
|
|
17
|
+
* independent read-only SQLite connection so network sync cannot stall local
|
|
18
|
+
* UI reads. Client-observable
|
|
19
|
+
* events (`change` / `presence`) arrive on
|
|
19
20
|
* the `syncular://event` Tauri event and fan out to the registered listeners.
|
|
20
21
|
*
|
|
21
22
|
* Bytes cross the command JSON as the established `{$bytes: hex}` envelope, the
|
|
22
23
|
* same convention the Rust command router and the driver protocol use.
|
|
23
24
|
*
|
|
24
|
-
* `@tauri-apps/api` is a
|
|
25
|
-
* either from
|
|
25
|
+
* `@tauri-apps/api` is a required peer dependency: the bridge takes
|
|
26
|
+
* `invoke`/`listen` either from its ESM entry points, from the ambient
|
|
27
|
+
* `window.__TAURI__`, or via injected doubles (tests).
|
|
26
28
|
*/
|
|
27
|
-
import type { ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SqlRow, SqlValue, WindowBase, WindowState } from '@syncular/client';
|
|
29
|
+
import type { ClientChangeListener, ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
28
30
|
/** One event pushed on `syncular://event` (the derived client-observable set). */
|
|
29
31
|
interface SyncularEvent {
|
|
30
32
|
readonly type: string;
|
|
@@ -44,11 +46,11 @@ export interface TauriSyncClientConfig {
|
|
|
44
46
|
/** The generated schema JSON (the app passes `schema` from typegen). */
|
|
45
47
|
readonly schema: unknown;
|
|
46
48
|
/**
|
|
47
|
-
* Client id for this device/actor. If omitted,
|
|
48
|
-
*
|
|
49
|
-
*
|
|
49
|
+
* Client id for this device/actor. If omitted, the native client generates
|
|
50
|
+
* one and persists it in the database. Supplying a different id when opening
|
|
51
|
+
* an existing database fails with `client.identity_mismatch`.
|
|
50
52
|
*/
|
|
51
|
-
readonly clientId
|
|
53
|
+
readonly clientId?: string;
|
|
52
54
|
/** §4.2 client limits, forwarded to the native `create`. */
|
|
53
55
|
readonly limits?: Record<string, unknown>;
|
|
54
56
|
/**
|
|
@@ -74,9 +76,16 @@ export declare class TauriSyncClient {
|
|
|
74
76
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
75
77
|
__dispatchEvent(event: SyncularEvent): void;
|
|
76
78
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
79
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
77
80
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
78
81
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
82
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
83
|
+
localRevision(): Promise<bigint>;
|
|
84
|
+
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
79
85
|
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
86
|
+
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
87
|
+
readonly baseVersion?: number;
|
|
88
|
+
}): Promise<string>;
|
|
80
89
|
/**
|
|
81
90
|
* Replace the native transport's request headers at runtime — the auth
|
|
82
91
|
* rotation path (RFC 0002 §2.3). Pass the FULL header set (it replaces,
|
package/dist/index.js
CHANGED
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
* Every method forwards to the plugin's `syncular_command` command (the whole
|
|
14
14
|
* command surface in one JSON envelope — `{method, params}`), mirroring the FFI
|
|
15
15
|
* and the conformance shim. `query` uses the dedicated `syncular_query` fast
|
|
16
|
-
* path
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* path; atomic reactive reads use `syncular_query_snapshot`, backed by an
|
|
17
|
+
* independent read-only SQLite connection so network sync cannot stall local
|
|
18
|
+
* UI reads. Client-observable
|
|
19
|
+
* events (`change` / `presence`) arrive on
|
|
19
20
|
* the `syncular://event` Tauri event and fan out to the registered listeners.
|
|
20
21
|
*
|
|
21
22
|
* Bytes cross the command JSON as the established `{$bytes: hex}` envelope, the
|
|
22
23
|
* same convention the Rust command router and the driver protocol use.
|
|
23
24
|
*
|
|
24
|
-
* `@tauri-apps/api` is a
|
|
25
|
-
* either from
|
|
25
|
+
* `@tauri-apps/api` is a required peer dependency: the bridge takes
|
|
26
|
+
* `invoke`/`listen` either from its ESM entry points, from the ambient
|
|
27
|
+
* `window.__TAURI__`, or via injected doubles (tests).
|
|
26
28
|
*/
|
|
27
29
|
/** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
|
|
28
30
|
export const SYNCULAR_EVENT = 'syncular://event';
|
|
@@ -32,6 +34,11 @@ function isBytesEnvelope(value) {
|
|
|
32
34
|
value !== null &&
|
|
33
35
|
typeof value.$bytes === 'string');
|
|
34
36
|
}
|
|
37
|
+
function isBigIntEnvelope(value) {
|
|
38
|
+
return (typeof value === 'object' &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
typeof value.$bigint === 'string');
|
|
41
|
+
}
|
|
35
42
|
function bytesToHex(bytes) {
|
|
36
43
|
let out = '';
|
|
37
44
|
for (const b of bytes) {
|
|
@@ -52,13 +59,31 @@ function encodeParam(value) {
|
|
|
52
59
|
if (value instanceof Uint8Array)
|
|
53
60
|
return { $bytes: bytesToHex(value) };
|
|
54
61
|
if (typeof value === 'bigint')
|
|
55
|
-
return
|
|
62
|
+
return { $bigint: value.toString() };
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function encodeJsonValue(value) {
|
|
66
|
+
if (value instanceof Uint8Array)
|
|
67
|
+
return { $bytes: bytesToHex(value) };
|
|
68
|
+
if (typeof value === 'bigint')
|
|
69
|
+
return { $bigint: value.toString() };
|
|
70
|
+
if (Array.isArray(value))
|
|
71
|
+
return value.map(encodeJsonValue);
|
|
72
|
+
if (value !== null && typeof value === 'object') {
|
|
73
|
+
const encoded = {};
|
|
74
|
+
for (const [key, member] of Object.entries(value)) {
|
|
75
|
+
encoded[key] = encodeJsonValue(member);
|
|
76
|
+
}
|
|
77
|
+
return encoded;
|
|
78
|
+
}
|
|
56
79
|
return value;
|
|
57
80
|
}
|
|
58
81
|
/** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
|
|
59
82
|
function decodeCell(value) {
|
|
60
83
|
if (isBytesEnvelope(value))
|
|
61
84
|
return hexToBytes(value.$bytes);
|
|
85
|
+
if (isBigIntEnvelope(value))
|
|
86
|
+
return BigInt(value.$bigint);
|
|
62
87
|
if (value === null ||
|
|
63
88
|
typeof value === 'string' ||
|
|
64
89
|
typeof value === 'number' ||
|
|
@@ -93,13 +118,14 @@ async function resolveTauri(injected) {
|
|
|
93
118
|
listen: ambient.event.listen.bind(ambient.event),
|
|
94
119
|
};
|
|
95
120
|
}
|
|
96
|
-
// Fall back to the ESM package (the common path).
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
// Fall back to the ESM package (the common path). These must remain literal,
|
|
122
|
+
// bundler-visible specifiers: hiding a bare package import behind
|
|
123
|
+
// `@vite-ignore` makes WebKit resolve it as a URL at runtime instead of
|
|
124
|
+
// letting Vite/Bun include the Tauri API in the webview bundle.
|
|
125
|
+
const [core, event] = await Promise.all([
|
|
126
|
+
import('@tauri-apps/api/core'),
|
|
127
|
+
import('@tauri-apps/api/event'),
|
|
128
|
+
]);
|
|
103
129
|
return { invoke: core.invoke, listen: event.listen };
|
|
104
130
|
}
|
|
105
131
|
/**
|
|
@@ -110,6 +136,7 @@ async function resolveTauri(injected) {
|
|
|
110
136
|
export class TauriSyncClient {
|
|
111
137
|
#tauri;
|
|
112
138
|
#invalidationListeners = new Set();
|
|
139
|
+
#changeListeners = new Set();
|
|
113
140
|
#presenceListeners = new Set();
|
|
114
141
|
#unlisten;
|
|
115
142
|
#closed = false;
|
|
@@ -129,13 +156,21 @@ export class TauriSyncClient {
|
|
|
129
156
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
130
157
|
__dispatchEvent(event) {
|
|
131
158
|
switch (event.type) {
|
|
132
|
-
case '
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
case 'change': {
|
|
160
|
+
const batch = decodeChangeBatch(event.batch);
|
|
161
|
+
if (batch === undefined)
|
|
162
|
+
break;
|
|
163
|
+
for (const listener of this.#changeListeners) {
|
|
164
|
+
try {
|
|
165
|
+
listener(batch);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* a UI listener must never break event dispatch */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const payload = invalidationFromChange(batch);
|
|
172
|
+
if (payload === undefined)
|
|
173
|
+
break;
|
|
139
174
|
for (const listener of this.#invalidationListeners) {
|
|
140
175
|
try {
|
|
141
176
|
listener(payload);
|
|
@@ -159,9 +194,8 @@ export class TauriSyncClient {
|
|
|
159
194
|
break;
|
|
160
195
|
}
|
|
161
196
|
default:
|
|
162
|
-
//
|
|
163
|
-
// observable
|
|
164
|
-
// accompanies data-changing events, so nothing else to fan out here.
|
|
197
|
+
// Unknown extension events are deliberately ignored. All durable
|
|
198
|
+
// observable state arrives in the revisioned `change` batch.
|
|
165
199
|
break;
|
|
166
200
|
}
|
|
167
201
|
}
|
|
@@ -170,6 +204,10 @@ export class TauriSyncClient {
|
|
|
170
204
|
this.#invalidationListeners.add(listener);
|
|
171
205
|
return () => this.#invalidationListeners.delete(listener);
|
|
172
206
|
}
|
|
207
|
+
onChange(listener) {
|
|
208
|
+
this.#changeListeners.add(listener);
|
|
209
|
+
return () => this.#changeListeners.delete(listener);
|
|
210
|
+
}
|
|
173
211
|
onPresence(listener) {
|
|
174
212
|
this.#presenceListeners.add(listener);
|
|
175
213
|
return () => this.#presenceListeners.delete(listener);
|
|
@@ -182,12 +220,46 @@ export class TauriSyncClient {
|
|
|
182
220
|
const rows = reply.result.rows ?? [];
|
|
183
221
|
return rows.map((r) => decodeRow(r));
|
|
184
222
|
}
|
|
223
|
+
async querySnapshot(spec) {
|
|
224
|
+
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query_snapshot`, {
|
|
225
|
+
sql: spec.sql,
|
|
226
|
+
params: (spec.params ?? []).map(encodeParam),
|
|
227
|
+
coverage: spec.coverage ?? [],
|
|
228
|
+
});
|
|
229
|
+
if (reply.error !== undefined) {
|
|
230
|
+
throw new TauriSyncError(reply.error.code, reply.error.message);
|
|
231
|
+
}
|
|
232
|
+
const result = reply.result;
|
|
233
|
+
return {
|
|
234
|
+
revision: BigInt(result.revision),
|
|
235
|
+
rows: result.rows.map(decodeRow),
|
|
236
|
+
coverage: result.coverage,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async localRevision() {
|
|
240
|
+
const result = (await this.#command('localRevision', {}));
|
|
241
|
+
return BigInt(result.revision);
|
|
242
|
+
}
|
|
243
|
+
async statusSnapshot() {
|
|
244
|
+
return (await this.#command('statusSnapshot', {}));
|
|
245
|
+
}
|
|
185
246
|
async mutate(mutations) {
|
|
186
247
|
const result = (await this.#command('mutate', {
|
|
187
248
|
mutations: mutations.map(encodeMutation),
|
|
188
249
|
}));
|
|
189
250
|
return result.clientCommitId;
|
|
190
251
|
}
|
|
252
|
+
async patch(table, rowId, partial, options) {
|
|
253
|
+
const result = (await this.#command('patch', {
|
|
254
|
+
table,
|
|
255
|
+
rowId,
|
|
256
|
+
partial: encodeJsonValue(partial),
|
|
257
|
+
...(options?.baseVersion !== undefined
|
|
258
|
+
? { baseVersion: options.baseVersion }
|
|
259
|
+
: {}),
|
|
260
|
+
}));
|
|
261
|
+
return result.clientCommitId;
|
|
262
|
+
}
|
|
191
263
|
/**
|
|
192
264
|
* Replace the native transport's request headers at runtime — the auth
|
|
193
265
|
* rotation path (RFC 0002 §2.3). Pass the FULL header set (it replaces,
|
|
@@ -328,6 +400,7 @@ export class TauriSyncClient {
|
|
|
328
400
|
this.#unlisten?.();
|
|
329
401
|
this.#unlisten = undefined;
|
|
330
402
|
this.#invalidationListeners.clear();
|
|
403
|
+
this.#changeListeners.clear();
|
|
331
404
|
this.#presenceListeners.clear();
|
|
332
405
|
}
|
|
333
406
|
}
|
|
@@ -346,10 +419,82 @@ function toStringSet(value) {
|
|
|
346
419
|
}
|
|
347
420
|
return new Set();
|
|
348
421
|
}
|
|
422
|
+
function decodeChangeBatch(value) {
|
|
423
|
+
if (value === null || typeof value !== 'object')
|
|
424
|
+
return undefined;
|
|
425
|
+
const raw = value;
|
|
426
|
+
if (typeof raw.revision !== 'string')
|
|
427
|
+
return undefined;
|
|
428
|
+
const tables = Array.isArray(raw.tables)
|
|
429
|
+
? raw.tables.flatMap((entry) => {
|
|
430
|
+
if (entry === null || typeof entry !== 'object')
|
|
431
|
+
return [];
|
|
432
|
+
const item = entry;
|
|
433
|
+
if (typeof item.table !== 'string')
|
|
434
|
+
return [];
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
table: item.table,
|
|
438
|
+
...(Array.isArray(item.scopeKeys)
|
|
439
|
+
? { scopeKeys: toStringSet(item.scopeKeys) }
|
|
440
|
+
: {}),
|
|
441
|
+
},
|
|
442
|
+
];
|
|
443
|
+
})
|
|
444
|
+
: [];
|
|
445
|
+
const windows = Array.isArray(raw.windows)
|
|
446
|
+
? raw.windows.flatMap((entry) => {
|
|
447
|
+
if (entry === null || typeof entry !== 'object')
|
|
448
|
+
return [];
|
|
449
|
+
const item = entry;
|
|
450
|
+
if (typeof item.baseKey !== 'string' ||
|
|
451
|
+
typeof item.table !== 'string') {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
baseKey: item.baseKey,
|
|
457
|
+
table: item.table,
|
|
458
|
+
units: toStringSet(item.units),
|
|
459
|
+
},
|
|
460
|
+
];
|
|
461
|
+
})
|
|
462
|
+
: [];
|
|
463
|
+
return {
|
|
464
|
+
revision: BigInt(raw.revision),
|
|
465
|
+
tables,
|
|
466
|
+
windows,
|
|
467
|
+
...(raw.status !== undefined
|
|
468
|
+
? { status: raw.status }
|
|
469
|
+
: {}),
|
|
470
|
+
conflictsChanged: raw.conflictsChanged === true,
|
|
471
|
+
rejectionsChanged: raw.rejectionsChanged === true,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function invalidationFromChange(batch) {
|
|
475
|
+
if (batch.tables.length === 0 && batch.windows.length === 0)
|
|
476
|
+
return undefined;
|
|
477
|
+
const tables = new Set();
|
|
478
|
+
const scopeKeys = new Set();
|
|
479
|
+
for (const table of batch.tables) {
|
|
480
|
+
tables.add(table.table);
|
|
481
|
+
for (const key of table.scopeKeys ?? [])
|
|
482
|
+
scopeKeys.add(key);
|
|
483
|
+
}
|
|
484
|
+
for (const window of batch.windows)
|
|
485
|
+
tables.add(window.table);
|
|
486
|
+
return { tables, scopeKeys };
|
|
487
|
+
}
|
|
349
488
|
/** Encode one mutation for the command JSON (bytes inside `values` handled by
|
|
350
489
|
* the native side; the driver form is already JSON-able). */
|
|
351
490
|
function encodeMutation(mutation) {
|
|
352
|
-
|
|
491
|
+
if (mutation.op === 'delete')
|
|
492
|
+
return mutation;
|
|
493
|
+
const values = {};
|
|
494
|
+
for (const [key, value] of Object.entries(mutation.values)) {
|
|
495
|
+
values[key] = encodeJsonValue(value);
|
|
496
|
+
}
|
|
497
|
+
return { ...mutation, values };
|
|
353
498
|
}
|
|
354
499
|
/**
|
|
355
500
|
* Construct the bridge and issue the native `create` (opening/attaching the
|
|
@@ -359,7 +504,7 @@ function encodeMutation(mutation) {
|
|
|
359
504
|
*/
|
|
360
505
|
export async function createTauriSyncClient(config) {
|
|
361
506
|
const tauri = await resolveTauri(config.tauri);
|
|
362
|
-
// Wire the event stream BEFORE create, so no early
|
|
507
|
+
// Wire the event stream BEFORE create, so no early change batch is missed.
|
|
363
508
|
const clientRef = {
|
|
364
509
|
client: undefined,
|
|
365
510
|
};
|
|
@@ -374,7 +519,7 @@ export async function createTauriSyncClient(config) {
|
|
|
374
519
|
command: {
|
|
375
520
|
method: 'create',
|
|
376
521
|
params: {
|
|
377
|
-
clientId: config.clientId,
|
|
522
|
+
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
378
523
|
schema: config.schema,
|
|
379
524
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
380
525
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/tauri",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Tauri integration for the Syncular client",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -48,17 +48,12 @@
|
|
|
48
48
|
"test": "bun test"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@syncular/client": "0.
|
|
51
|
+
"@syncular/client": "0.5.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@tauri-apps/api": ">=2.0.0"
|
|
55
55
|
},
|
|
56
|
-
"peerDependenciesMeta": {
|
|
57
|
-
"@tauri-apps/api": {
|
|
58
|
-
"optional": true
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
56
|
"devDependencies": {
|
|
62
|
-
"@syncular/react": "0.
|
|
57
|
+
"@syncular/react": "0.5.1"
|
|
63
58
|
}
|
|
64
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,32 +13,39 @@
|
|
|
13
13
|
* Every method forwards to the plugin's `syncular_command` command (the whole
|
|
14
14
|
* command surface in one JSON envelope — `{method, params}`), mirroring the FFI
|
|
15
15
|
* and the conformance shim. `query` uses the dedicated `syncular_query` fast
|
|
16
|
-
* path
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* path; atomic reactive reads use `syncular_query_snapshot`, backed by an
|
|
17
|
+
* independent read-only SQLite connection so network sync cannot stall local
|
|
18
|
+
* UI reads. Client-observable
|
|
19
|
+
* events (`change` / `presence`) arrive on
|
|
19
20
|
* the `syncular://event` Tauri event and fan out to the registered listeners.
|
|
20
21
|
*
|
|
21
22
|
* Bytes cross the command JSON as the established `{$bytes: hex}` envelope, the
|
|
22
23
|
* same convention the Rust command router and the driver protocol use.
|
|
23
24
|
*
|
|
24
|
-
* `@tauri-apps/api` is a
|
|
25
|
-
* either from
|
|
25
|
+
* `@tauri-apps/api` is a required peer dependency: the bridge takes
|
|
26
|
+
* `invoke`/`listen` either from its ESM entry points, from the ambient
|
|
27
|
+
* `window.__TAURI__`, or via injected doubles (tests).
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
// -- Types the bridge speaks (structurally the web-client's) -----------------
|
|
29
31
|
// Imported as types only, so the bridge has no runtime dependency on
|
|
30
32
|
// @syncular/client (the app already carries it via @syncular/react).
|
|
31
33
|
import type {
|
|
34
|
+
ClientChangeBatch,
|
|
35
|
+
ClientChangeListener,
|
|
32
36
|
ConflictRecord,
|
|
33
37
|
InvalidationEvent,
|
|
34
38
|
InvalidationListener,
|
|
35
39
|
LeaseState,
|
|
36
40
|
MutationInput,
|
|
37
41
|
PresencePeer,
|
|
42
|
+
QueryReadSpec,
|
|
43
|
+
QuerySnapshot,
|
|
38
44
|
RejectionRecord,
|
|
39
45
|
SchemaFloor,
|
|
40
46
|
SqlRow,
|
|
41
47
|
SqlValue,
|
|
48
|
+
SyncStatusSnapshot,
|
|
42
49
|
WindowBase,
|
|
43
50
|
WindowState,
|
|
44
51
|
} from '@syncular/client';
|
|
@@ -74,11 +81,11 @@ export interface TauriSyncClientConfig {
|
|
|
74
81
|
/** The generated schema JSON (the app passes `schema` from typegen). */
|
|
75
82
|
readonly schema: unknown;
|
|
76
83
|
/**
|
|
77
|
-
* Client id for this device/actor. If omitted,
|
|
78
|
-
*
|
|
79
|
-
*
|
|
84
|
+
* Client id for this device/actor. If omitted, the native client generates
|
|
85
|
+
* one and persists it in the database. Supplying a different id when opening
|
|
86
|
+
* an existing database fails with `client.identity_mismatch`.
|
|
80
87
|
*/
|
|
81
|
-
readonly clientId
|
|
88
|
+
readonly clientId?: string;
|
|
82
89
|
/** §4.2 client limits, forwarded to the native `create`. */
|
|
83
90
|
readonly limits?: Record<string, unknown>;
|
|
84
91
|
/**
|
|
@@ -91,6 +98,7 @@ export interface TauriSyncClientConfig {
|
|
|
91
98
|
|
|
92
99
|
/** The bytes envelope both sides share. */
|
|
93
100
|
export type BytesEnvelope = { readonly $bytes: string };
|
|
101
|
+
type BigIntEnvelope = { readonly $bigint: string };
|
|
94
102
|
|
|
95
103
|
function isBytesEnvelope(value: unknown): value is BytesEnvelope {
|
|
96
104
|
return (
|
|
@@ -100,6 +108,14 @@ function isBytesEnvelope(value: unknown): value is BytesEnvelope {
|
|
|
100
108
|
);
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
function isBigIntEnvelope(value: unknown): value is BigIntEnvelope {
|
|
112
|
+
return (
|
|
113
|
+
typeof value === 'object' &&
|
|
114
|
+
value !== null &&
|
|
115
|
+
typeof (value as { $bigint?: unknown }).$bigint === 'string'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
function bytesToHex(bytes: Uint8Array): string {
|
|
104
120
|
let out = '';
|
|
105
121
|
for (const b of bytes) {
|
|
@@ -120,13 +136,28 @@ function hexToBytes(hex: string): Uint8Array {
|
|
|
120
136
|
/** Encode an SQL param for the command JSON (bytes → `{$bytes: hex}`). */
|
|
121
137
|
function encodeParam(value: SqlValue): unknown {
|
|
122
138
|
if (value instanceof Uint8Array) return { $bytes: bytesToHex(value) };
|
|
123
|
-
if (typeof value === 'bigint') return
|
|
139
|
+
if (typeof value === 'bigint') return { $bigint: value.toString() };
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function encodeJsonValue(value: unknown): unknown {
|
|
144
|
+
if (value instanceof Uint8Array) return { $bytes: bytesToHex(value) };
|
|
145
|
+
if (typeof value === 'bigint') return { $bigint: value.toString() };
|
|
146
|
+
if (Array.isArray(value)) return value.map(encodeJsonValue);
|
|
147
|
+
if (value !== null && typeof value === 'object') {
|
|
148
|
+
const encoded: Record<string, unknown> = {};
|
|
149
|
+
for (const [key, member] of Object.entries(value)) {
|
|
150
|
+
encoded[key] = encodeJsonValue(member);
|
|
151
|
+
}
|
|
152
|
+
return encoded;
|
|
153
|
+
}
|
|
124
154
|
return value;
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
/** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
|
|
128
158
|
function decodeCell(value: unknown): SqlValue {
|
|
129
159
|
if (isBytesEnvelope(value)) return hexToBytes(value.$bytes);
|
|
160
|
+
if (isBigIntEnvelope(value)) return BigInt(value.$bigint);
|
|
130
161
|
if (
|
|
131
162
|
value === null ||
|
|
132
163
|
typeof value === 'string' ||
|
|
@@ -170,18 +201,14 @@ async function resolveTauri(injected: TauriApi | undefined): Promise<TauriApi> {
|
|
|
170
201
|
listen: ambient.event.listen.bind(ambient.event),
|
|
171
202
|
};
|
|
172
203
|
}
|
|
173
|
-
// Fall back to the ESM package (the common path).
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
const
|
|
178
|
-
import(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
};
|
|
182
|
-
const event = (await dynamicImport('@tauri-apps/api/event')) as {
|
|
183
|
-
listen: TauriApi['listen'];
|
|
184
|
-
};
|
|
204
|
+
// Fall back to the ESM package (the common path). These must remain literal,
|
|
205
|
+
// bundler-visible specifiers: hiding a bare package import behind
|
|
206
|
+
// `@vite-ignore` makes WebKit resolve it as a URL at runtime instead of
|
|
207
|
+
// letting Vite/Bun include the Tauri API in the webview bundle.
|
|
208
|
+
const [core, event] = await Promise.all([
|
|
209
|
+
import('@tauri-apps/api/core'),
|
|
210
|
+
import('@tauri-apps/api/event'),
|
|
211
|
+
]);
|
|
185
212
|
return { invoke: core.invoke, listen: event.listen };
|
|
186
213
|
}
|
|
187
214
|
|
|
@@ -193,6 +220,7 @@ async function resolveTauri(injected: TauriApi | undefined): Promise<TauriApi> {
|
|
|
193
220
|
export class TauriSyncClient {
|
|
194
221
|
readonly #tauri: TauriApi;
|
|
195
222
|
readonly #invalidationListeners = new Set<InvalidationListener>();
|
|
223
|
+
readonly #changeListeners = new Set<ClientChangeListener>();
|
|
196
224
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
197
225
|
#unlisten: (() => void) | undefined;
|
|
198
226
|
#closed = false;
|
|
@@ -221,13 +249,18 @@ export class TauriSyncClient {
|
|
|
221
249
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
222
250
|
__dispatchEvent(event: SyncularEvent): void {
|
|
223
251
|
switch (event.type) {
|
|
224
|
-
case '
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
252
|
+
case 'change': {
|
|
253
|
+
const batch = decodeChangeBatch(event.batch);
|
|
254
|
+
if (batch === undefined) break;
|
|
255
|
+
for (const listener of this.#changeListeners) {
|
|
256
|
+
try {
|
|
257
|
+
listener(batch);
|
|
258
|
+
} catch {
|
|
259
|
+
/* a UI listener must never break event dispatch */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const payload = invalidationFromChange(batch);
|
|
263
|
+
if (payload === undefined) break;
|
|
231
264
|
for (const listener of this.#invalidationListeners) {
|
|
232
265
|
try {
|
|
233
266
|
listener(payload);
|
|
@@ -250,9 +283,8 @@ export class TauriSyncClient {
|
|
|
250
283
|
break;
|
|
251
284
|
}
|
|
252
285
|
default:
|
|
253
|
-
//
|
|
254
|
-
// observable
|
|
255
|
-
// accompanies data-changing events, so nothing else to fan out here.
|
|
286
|
+
// Unknown extension events are deliberately ignored. All durable
|
|
287
|
+
// observable state arrives in the revisioned `change` batch.
|
|
256
288
|
break;
|
|
257
289
|
}
|
|
258
290
|
}
|
|
@@ -264,6 +296,11 @@ export class TauriSyncClient {
|
|
|
264
296
|
return () => this.#invalidationListeners.delete(listener);
|
|
265
297
|
}
|
|
266
298
|
|
|
299
|
+
onChange(listener: ClientChangeListener): () => void {
|
|
300
|
+
this.#changeListeners.add(listener);
|
|
301
|
+
return () => this.#changeListeners.delete(listener);
|
|
302
|
+
}
|
|
303
|
+
|
|
267
304
|
onPresence(listener: (scopeKey: string) => void): () => void {
|
|
268
305
|
this.#presenceListeners.add(listener);
|
|
269
306
|
return () => this.#presenceListeners.delete(listener);
|
|
@@ -281,6 +318,43 @@ export class TauriSyncClient {
|
|
|
281
318
|
return rows.map((r) => decodeRow(r as Record<string, unknown>));
|
|
282
319
|
}
|
|
283
320
|
|
|
321
|
+
async querySnapshot<Row = SqlRow>(
|
|
322
|
+
spec: QueryReadSpec,
|
|
323
|
+
): Promise<QuerySnapshot<Row>> {
|
|
324
|
+
const reply = await this.#tauri.invoke<CommandReply>(
|
|
325
|
+
`${PLUGIN}syncular_query_snapshot`,
|
|
326
|
+
{
|
|
327
|
+
sql: spec.sql,
|
|
328
|
+
params: (spec.params ?? []).map(encodeParam),
|
|
329
|
+
coverage: spec.coverage ?? [],
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
if (reply.error !== undefined) {
|
|
333
|
+
throw new TauriSyncError(reply.error.code, reply.error.message);
|
|
334
|
+
}
|
|
335
|
+
const result = reply.result as {
|
|
336
|
+
revision: string;
|
|
337
|
+
rows: Record<string, unknown>[];
|
|
338
|
+
coverage: QuerySnapshot['coverage'];
|
|
339
|
+
};
|
|
340
|
+
return {
|
|
341
|
+
revision: BigInt(result.revision),
|
|
342
|
+
rows: result.rows.map(decodeRow) as unknown as readonly Row[],
|
|
343
|
+
coverage: result.coverage,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async localRevision(): Promise<bigint> {
|
|
348
|
+
const result = (await this.#command('localRevision', {})) as {
|
|
349
|
+
revision: string;
|
|
350
|
+
};
|
|
351
|
+
return BigInt(result.revision);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async statusSnapshot(): Promise<SyncStatusSnapshot> {
|
|
355
|
+
return (await this.#command('statusSnapshot', {})) as SyncStatusSnapshot;
|
|
356
|
+
}
|
|
357
|
+
|
|
284
358
|
async mutate(mutations: readonly MutationInput[]): Promise<string> {
|
|
285
359
|
const result = (await this.#command('mutate', {
|
|
286
360
|
mutations: mutations.map(encodeMutation),
|
|
@@ -288,6 +362,23 @@ export class TauriSyncClient {
|
|
|
288
362
|
return result.clientCommitId;
|
|
289
363
|
}
|
|
290
364
|
|
|
365
|
+
async patch(
|
|
366
|
+
table: string,
|
|
367
|
+
rowId: string,
|
|
368
|
+
partial: Readonly<Record<string, unknown>>,
|
|
369
|
+
options?: { readonly baseVersion?: number },
|
|
370
|
+
): Promise<string> {
|
|
371
|
+
const result = (await this.#command('patch', {
|
|
372
|
+
table,
|
|
373
|
+
rowId,
|
|
374
|
+
partial: encodeJsonValue(partial),
|
|
375
|
+
...(options?.baseVersion !== undefined
|
|
376
|
+
? { baseVersion: options.baseVersion }
|
|
377
|
+
: {}),
|
|
378
|
+
})) as { clientCommitId: string };
|
|
379
|
+
return result.clientCommitId;
|
|
380
|
+
}
|
|
381
|
+
|
|
291
382
|
/**
|
|
292
383
|
* Replace the native transport's request headers at runtime — the auth
|
|
293
384
|
* rotation path (RFC 0002 §2.3). Pass the FULL header set (it replaces,
|
|
@@ -499,6 +590,7 @@ export class TauriSyncClient {
|
|
|
499
590
|
this.#unlisten?.();
|
|
500
591
|
this.#unlisten = undefined;
|
|
501
592
|
this.#invalidationListeners.clear();
|
|
593
|
+
this.#changeListeners.clear();
|
|
502
594
|
this.#presenceListeners.clear();
|
|
503
595
|
}
|
|
504
596
|
}
|
|
@@ -520,10 +612,79 @@ function toStringSet(value: unknown): ReadonlySet<string> {
|
|
|
520
612
|
return new Set<string>();
|
|
521
613
|
}
|
|
522
614
|
|
|
615
|
+
function decodeChangeBatch(value: unknown): ClientChangeBatch | undefined {
|
|
616
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
617
|
+
const raw = value as Record<string, unknown>;
|
|
618
|
+
if (typeof raw.revision !== 'string') return undefined;
|
|
619
|
+
const tables = Array.isArray(raw.tables)
|
|
620
|
+
? raw.tables.flatMap((entry) => {
|
|
621
|
+
if (entry === null || typeof entry !== 'object') return [];
|
|
622
|
+
const item = entry as Record<string, unknown>;
|
|
623
|
+
if (typeof item.table !== 'string') return [];
|
|
624
|
+
return [
|
|
625
|
+
{
|
|
626
|
+
table: item.table,
|
|
627
|
+
...(Array.isArray(item.scopeKeys)
|
|
628
|
+
? { scopeKeys: toStringSet(item.scopeKeys) }
|
|
629
|
+
: {}),
|
|
630
|
+
},
|
|
631
|
+
];
|
|
632
|
+
})
|
|
633
|
+
: [];
|
|
634
|
+
const windows = Array.isArray(raw.windows)
|
|
635
|
+
? raw.windows.flatMap((entry) => {
|
|
636
|
+
if (entry === null || typeof entry !== 'object') return [];
|
|
637
|
+
const item = entry as Record<string, unknown>;
|
|
638
|
+
if (
|
|
639
|
+
typeof item.baseKey !== 'string' ||
|
|
640
|
+
typeof item.table !== 'string'
|
|
641
|
+
) {
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
return [
|
|
645
|
+
{
|
|
646
|
+
baseKey: item.baseKey,
|
|
647
|
+
table: item.table,
|
|
648
|
+
units: toStringSet(item.units),
|
|
649
|
+
},
|
|
650
|
+
];
|
|
651
|
+
})
|
|
652
|
+
: [];
|
|
653
|
+
return {
|
|
654
|
+
revision: BigInt(raw.revision),
|
|
655
|
+
tables,
|
|
656
|
+
windows,
|
|
657
|
+
...(raw.status !== undefined
|
|
658
|
+
? { status: raw.status as SyncStatusSnapshot }
|
|
659
|
+
: {}),
|
|
660
|
+
conflictsChanged: raw.conflictsChanged === true,
|
|
661
|
+
rejectionsChanged: raw.rejectionsChanged === true,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function invalidationFromChange(
|
|
666
|
+
batch: ClientChangeBatch,
|
|
667
|
+
): InvalidationEvent | undefined {
|
|
668
|
+
if (batch.tables.length === 0 && batch.windows.length === 0) return undefined;
|
|
669
|
+
const tables = new Set<string>();
|
|
670
|
+
const scopeKeys = new Set<string>();
|
|
671
|
+
for (const table of batch.tables) {
|
|
672
|
+
tables.add(table.table);
|
|
673
|
+
for (const key of table.scopeKeys ?? []) scopeKeys.add(key);
|
|
674
|
+
}
|
|
675
|
+
for (const window of batch.windows) tables.add(window.table);
|
|
676
|
+
return { tables, scopeKeys };
|
|
677
|
+
}
|
|
678
|
+
|
|
523
679
|
/** Encode one mutation for the command JSON (bytes inside `values` handled by
|
|
524
680
|
* the native side; the driver form is already JSON-able). */
|
|
525
681
|
function encodeMutation(mutation: MutationInput): unknown {
|
|
526
|
-
return mutation;
|
|
682
|
+
if (mutation.op === 'delete') return mutation;
|
|
683
|
+
const values: Record<string, unknown> = {};
|
|
684
|
+
for (const [key, value] of Object.entries(mutation.values)) {
|
|
685
|
+
values[key] = encodeJsonValue(value);
|
|
686
|
+
}
|
|
687
|
+
return { ...mutation, values };
|
|
527
688
|
}
|
|
528
689
|
|
|
529
690
|
/**
|
|
@@ -537,7 +698,7 @@ export async function createTauriSyncClient(
|
|
|
537
698
|
): Promise<TauriSyncClient> {
|
|
538
699
|
const tauri = await resolveTauri(config.tauri);
|
|
539
700
|
|
|
540
|
-
// Wire the event stream BEFORE create, so no early
|
|
701
|
+
// Wire the event stream BEFORE create, so no early change batch is missed.
|
|
541
702
|
const clientRef: { client: TauriSyncClient | undefined } = {
|
|
542
703
|
client: undefined,
|
|
543
704
|
};
|
|
@@ -557,7 +718,7 @@ export async function createTauriSyncClient(
|
|
|
557
718
|
command: {
|
|
558
719
|
method: 'create',
|
|
559
720
|
params: {
|
|
560
|
-
clientId: config.clientId,
|
|
721
|
+
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
561
722
|
schema: config.schema,
|
|
562
723
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
563
724
|
},
|