@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 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 (`invalidate` / `presence` / `sync-needed` / `conflict` / …) arrive on
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 PEER dependency: the bridge takes `invoke`/`listen`
25
- * either from the ambient `window.__TAURI__` or via injected doubles (tests).
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, 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).
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: string;
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 (`invalidate` / `presence` / `sync-needed` / `conflict` / …) arrive on
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 PEER dependency: the bridge takes `invoke`/`listen`
25
- * either from the ambient `window.__TAURI__` or via injected doubles (tests).
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 Number(value);
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). 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'));
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 '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
- };
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
- // 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.
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
- return mutation;
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 invalidate is missed.
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.4.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.3.1"
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.3.1"
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 (`invalidate` / `presence` / `sync-needed` / `conflict` / …) arrive on
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 PEER dependency: the bridge takes `invoke`/`listen`
25
- * either from the ambient `window.__TAURI__` or via injected doubles (tests).
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, 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).
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: string;
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 Number(value);
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). 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
- };
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 '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
- };
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
- // 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.
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 invalidate is missed.
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
  },