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