@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 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 (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
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 PEER dependency: the bridge takes `invoke`/`listen`
25
- * either from the ambient `window.__TAURI__` or via injected doubles (tests).
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, 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).
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: string;
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 (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
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 PEER dependency: the bridge takes `invoke`/`listen`
25
- * either from the ambient `window.__TAURI__` or via injected doubles (tests).
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 Number(value);
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). The specifiers are built
97
- // indirectly so this optional peer dependency is not a hard compile-time
98
- // module resolution apps that inject `tauri` (or use the ambient global)
99
- // never need `@tauri-apps/api` type-resolvable at build.
100
- const dynamicImport = (specifier) => import(/* @vite-ignore */ specifier);
101
- const core = (await dynamicImport('@tauri-apps/api/core'));
102
- const event = (await dynamicImport('@tauri-apps/api/event'));
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 'invalidate': {
133
- // The native side derives a coarse invalidate (table granularity is the
134
- // honest floor — the plugin does not ship per-row scope keys over IPC).
135
- const payload = {
136
- tables: toStringSet(event.tables),
137
- scopeKeys: toStringSet(event.scopeKeys),
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
- // sync-needed / conflict / rejection / schema-floor / lease / error:
163
- // observable via the accessor methods; a coarse invalidate already
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
- return mutation;
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 invalidate is missed.
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.4.1",
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.4.1"
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.4.1"
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 (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
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 PEER dependency: the bridge takes `invoke`/`listen`
25
- * either from the ambient `window.__TAURI__` or via injected doubles (tests).
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, 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).
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: string;
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 Number(value);
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). The specifiers are built
174
- // indirectly so this optional peer dependency is not a hard compile-time
175
- // module resolution apps that inject `tauri` (or use the ambient global)
176
- // never need `@tauri-apps/api` type-resolvable at build.
177
- const dynamicImport = (specifier: string): Promise<unknown> =>
178
- import(/* @vite-ignore */ specifier);
179
- const core = (await dynamicImport('@tauri-apps/api/core')) as {
180
- invoke: TauriApi['invoke'];
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 'invalidate': {
225
- // The native side derives a coarse invalidate (table granularity is the
226
- // honest floor the plugin does not ship per-row scope keys over IPC).
227
- const payload: InvalidationEvent = {
228
- tables: toStringSet(event.tables),
229
- scopeKeys: toStringSet(event.scopeKeys),
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
- // sync-needed / conflict / rejection / schema-floor / lease / error:
254
- // observable via the accessor methods; a coarse invalidate already
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 invalidate is missed.
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
  },