@syncular/tauri 0.30.16 → 0.32.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
@@ -9,7 +9,7 @@ bun add @syncular/tauri @tauri-apps/api
9
9
  ```
10
10
 
11
11
  The bridge works with any frontend framework. Generate the schema module using
12
- the [existing-project setup](https://syncular.dev/guide-schema/#add-syncular-to-an-existing-project),
12
+ the [existing-project setup](https://syncular.dev/add-to-existing-app/),
13
13
  then create one shared client in the webview:
14
14
 
15
15
  ```ts
@@ -307,7 +307,7 @@ The snapshot API revision removes the individual `schemaFloor`, `leaseState`,
307
307
  `upgrading`, and `syncNeeded` methods. Read those fields from
308
308
  `await client.statusSnapshot()`. Collection and outcome reads remain methods.
309
309
  React accepts the bridge directly. See the
310
- [client migration](https://syncular.dev/platform-web/#snapshot-api-migration).
310
+ [client migration](https://syncular.dev/platform-web-reads-writes/#reading-local-state).
311
311
 
312
312
 
313
313
  ## Atomic sparse writes and retained conflicts
package/dist/index.d.ts CHANGED
@@ -28,12 +28,20 @@ import type { ClientSnapshotMethods, PromiseMethods } from '@syncular/client';
28
28
  * `invoke`/`listen` either from its ESM entry points, from the ambient
29
29
  * `window.__TAURI__`, or via injected doubles (tests).
30
30
  */
31
- import type { AuthorityReadPolicy, ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataRebootstrapInput, LocalDataRebootstrapResult, MutationInput, PresencePeer, PreviousVersionAudit, PreviousVersionReadSpec, PreviousVersionSnapshot, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SecurityLifecycle, SqlRow, SqlValue, SyncClientConfig, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
31
+ import type { AuthorityReadPolicy, ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataRebootstrapInput, LocalDataRebootstrapResult, MutationInput, PresencePeer, PreviousVersionAudit, PreviousVersionReadSpec, PreviousVersionSnapshot, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SqlRow, SqlValue, SyncClientConfig, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
32
32
  /** One event pushed on `syncular://event` (the derived client-observable set). */
33
33
  interface SyncularEvent {
34
34
  readonly type: string;
35
35
  readonly [key: string]: unknown;
36
36
  }
37
+ /** §7.5: one read-only statement in an atomic sidecar snapshot read. */
38
+ export type { SnapshotStatement } from '@syncular/client';
39
+ /** §7.5: the catch-up state of one subscription from the sidecar. */
40
+ export type { SubscriptionCatchup } from '@syncular/client';
41
+ /** §7.5: delivery status for one client commit id. */
42
+ export type { CommitDelivery } from '@syncular/client';
43
+ /** §7.5: one atomic sidecar snapshot read result with decoded rows. */
44
+ export type SnapshotReadResult = SnapshotRead;
37
45
  /** The two Tauri primitives the bridge needs — injectable for tests. */
38
46
  export interface TauriApi {
39
47
  invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
@@ -136,6 +144,12 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
136
144
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
137
145
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
138
146
  localRevision(): Promise<bigint>;
147
+ /**
148
+ * §7.5: one atomic read over the file-backed sidecar. Every statement and
149
+ * every catch-up/delivery read shares one revision (a `bigint`, matching
150
+ * `querySnapshot`) and one read transaction; every query row is decoded.
151
+ */
152
+ snapshotRead(request: SnapshotReadRequest): Promise<SnapshotRead>;
139
153
  statusSnapshot(): Promise<SyncStatusSnapshot>;
140
154
  diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
141
155
  mutate(mutations: readonly MutationInput[]): Promise<string>;
@@ -200,7 +214,8 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
200
214
  export declare class TauriSyncError extends Error {
201
215
  readonly code: string;
202
216
  readonly details: Readonly<Record<string, unknown>> | undefined;
203
- constructor(code: string, message: string, details?: Readonly<Record<string, unknown>>);
217
+ readonly retryable: boolean;
218
+ constructor(code: string, message: string, details?: Readonly<Record<string, unknown>>, retryable?: boolean);
204
219
  }
205
220
  /**
206
221
  * Construct the bridge and issue the native `create` (opening/attaching the
@@ -211,4 +226,3 @@ export declare class TauriSyncError extends Error {
211
226
  export declare function createTauriSyncClient(config: TauriSyncClientConfig): Promise<TauriSyncClient>;
212
227
  /** @internal Explicit bridge extension used by the authority entry. */
213
228
  export declare function createTauriSyncClient<Client extends TauriSyncClient>(config: TauriSyncClientConfig, createClient: (...args: ConstructorParameters<typeof TauriSyncClient>) => Client): Promise<Client>;
214
- export {};
package/dist/index.js CHANGED
@@ -7,9 +7,23 @@ import { decodeLocalDataRebootstrapResult, decodePreviousVersionAuditResult, dec
7
7
  export const SYNCULAR_EVENT = 'syncular://event';
8
8
  const PLUGIN = 'plugin:syncular|';
9
9
  function isBytesEnvelope(value) {
10
- return (typeof value === 'object' &&
11
- value !== null &&
12
- typeof value.$bytes === 'string');
10
+ if (typeof value !== 'object' || value === null)
11
+ return false;
12
+ const record = value;
13
+ return (Object.keys(record).length === 1 &&
14
+ typeof record.$bytes === 'string' &&
15
+ /^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes));
16
+ }
17
+ /**
18
+ * §7.1: a host reply that carries an envelope-shaped object with a malformed
19
+ * body is refused, never padded into different bytes. Objects without a
20
+ * `$bytes` key keep the defensive JSON text round-trip below.
21
+ */
22
+ function assertBytesEnvelope(value) {
23
+ if (typeof value !== 'object' || value === null || !('$bytes' in value))
24
+ return;
25
+ if (!isBytesEnvelope(value))
26
+ throw new TauriSyncError('sync.invalid_request', 'a $bytes envelope requires exactly one $bytes key holding an even-length hexadecimal string');
13
27
  }
14
28
  function isBigIntEnvelope(value) {
15
29
  return (typeof value === 'object' &&
@@ -24,10 +38,11 @@ function bytesToHex(bytes) {
24
38
  return out;
25
39
  }
26
40
  function hexToBytes(hex) {
27
- const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
28
- const out = new Uint8Array(clean.length / 2);
41
+ if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex))
42
+ throw new TauriSyncError('sync.invalid_request', 'a $bytes envelope requires an even-length hexadecimal string');
43
+ const out = new Uint8Array(hex.length / 2);
29
44
  for (let i = 0; i < out.length; i++) {
30
- out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
45
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
31
46
  }
32
47
  return out;
33
48
  }
@@ -68,6 +83,7 @@ function encodeEncryption(config) {
68
83
  }
69
84
  /** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
70
85
  function decodeCell(value) {
86
+ assertBytesEnvelope(value);
71
87
  if (isBytesEnvelope(value))
72
88
  return hexToBytes(value.$bytes);
73
89
  if (isBigIntEnvelope(value))
@@ -82,6 +98,63 @@ function decodeCell(value) {
82
98
  // string (SQLite json columns arrive as text already; this is defensive).
83
99
  return JSON.stringify(value);
84
100
  }
101
+ /** Decode one native outcome row image. Only the `{$bytes}` envelope is
102
+ * honored; operation `values` stay `JsonRowValue` and are not passed here. */
103
+ function decodeOutcomeRow(row) {
104
+ const out = {};
105
+ for (const [key, value] of Object.entries(row)) {
106
+ // Reserved `_sync_*` columns stay engine-internal, matching `decodeRow`.
107
+ if (key.startsWith('_sync_'))
108
+ continue;
109
+ assertBytesEnvelope(value);
110
+ if (value === null ||
111
+ typeof value === 'string' ||
112
+ typeof value === 'number' ||
113
+ typeof value === 'boolean') {
114
+ out[key] = value;
115
+ }
116
+ else if (isBytesEnvelope(value)) {
117
+ out[key] = hexToBytes(value.$bytes);
118
+ }
119
+ else {
120
+ out[key] = JSON.stringify(value);
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ /** Decode the row images a native `CommitOutcome` carries: conflict server
126
+ * rows and retained-row images. */
127
+ function decodeCommitOutcome(outcome) {
128
+ return {
129
+ ...outcome,
130
+ results: outcome.results.map((result) => result.status === 'conflict'
131
+ ? {
132
+ ...result,
133
+ conflict: {
134
+ ...result.conflict,
135
+ serverRow: decodeOutcomeRow(result.conflict.serverRow),
136
+ },
137
+ }
138
+ : result),
139
+ ...(outcome.retainedRows !== undefined
140
+ ? {
141
+ retainedRows: outcome.retainedRows.map((row) => ({
142
+ ...row,
143
+ localRow: row.localRow === null ? null : decodeOutcomeRow(row.localRow),
144
+ serverRow: row.serverRow === null ? null : decodeOutcomeRow(row.serverRow),
145
+ ...(row.uniqueConflicts !== undefined
146
+ ? {
147
+ uniqueConflicts: row.uniqueConflicts.map((conflict) => ({
148
+ ...conflict,
149
+ serverRow: decodeOutcomeRow(conflict.serverRow),
150
+ })),
151
+ }
152
+ : {}),
153
+ })),
154
+ }
155
+ : {}),
156
+ };
157
+ }
85
158
  /** @internal Shared native row codec. */
86
159
  export function decodeRow(row) {
87
160
  const out = {};
@@ -179,7 +252,7 @@ export class TauriSyncClient {
179
252
  }
180
253
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
181
254
  if (reply.error !== undefined) {
182
- throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
255
+ throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
183
256
  }
184
257
  return reply.result;
185
258
  }
@@ -369,7 +442,7 @@ export class TauriSyncClient {
369
442
  this.#requireActive();
370
443
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
371
444
  if (reply.error !== undefined) {
372
- throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
445
+ throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
373
446
  }
374
447
  const rows = reply.result.rows ?? [];
375
448
  return rows.map((r) => decodeRow(r));
@@ -383,7 +456,7 @@ export class TauriSyncClient {
383
456
  ...(spec.owner !== undefined ? { owner: spec.owner } : {}),
384
457
  });
385
458
  if (reply.error !== undefined) {
386
- throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
459
+ throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
387
460
  }
388
461
  const result = reply.result;
389
462
  return {
@@ -396,6 +469,37 @@ export class TauriSyncClient {
396
469
  const result = (await this.#command('localRevision', {}));
397
470
  return BigInt(result.revision);
398
471
  }
472
+ /**
473
+ * §7.5: one atomic read over the file-backed sidecar. Every statement and
474
+ * every catch-up/delivery read shares one revision (a `bigint`, matching
475
+ * `querySnapshot`) and one read transaction; every query row is decoded.
476
+ */
477
+ async snapshotRead(request) {
478
+ this.#requireActive();
479
+ const reply = await this.#tauri.invoke(`${PLUGIN}syncular_snapshot_read`, {
480
+ statements: request.statements.map((statement) => ({
481
+ sql: statement.sql,
482
+ params: (statement.params ?? []).map(encodeParam),
483
+ })),
484
+ coverage: request.coverage ?? [],
485
+ subscriptions: request.subscriptions ?? [],
486
+ commitIds: request.commitIds ?? [],
487
+ ...(request.owner !== undefined ? { owner: request.owner } : {}),
488
+ });
489
+ if (reply.error !== undefined) {
490
+ throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
491
+ }
492
+ const result = reply.result;
493
+ return {
494
+ revision: BigInt(result.revision),
495
+ queries: result.queries.map((rows) => rows.map(decodeRow)),
496
+ coverage: result.coverage,
497
+ subscriptions: result.subscriptions,
498
+ deliveries: result.deliveries.map((delivery) => delivery.status === 'known'
499
+ ? { ...delivery, outcome: decodeCommitOutcome(delivery.outcome) }
500
+ : delivery),
501
+ };
502
+ }
399
503
  async statusSnapshot() {
400
504
  return (await this.#command('statusSnapshot', {}));
401
505
  }
@@ -461,7 +565,7 @@ export class TauriSyncClient {
461
565
  this.#requireActive();
462
566
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_set_headers`, { headers });
463
567
  if (reply.error !== undefined) {
464
- throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
568
+ throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
465
569
  }
466
570
  }
467
571
  // -- Native CRDT (SPEC.md §5.10.5; needs the plugin `crdt-yjs` feature) ------
@@ -555,17 +659,19 @@ export class TauriSyncClient {
555
659
  const result = (await this.#command('commitOutcome', {
556
660
  clientCommitId,
557
661
  }));
558
- return result.outcome;
662
+ return result.outcome === undefined
663
+ ? undefined
664
+ : decodeCommitOutcome(result.outcome);
559
665
  }
560
666
  async commitOutcomes(query = {}) {
561
667
  const result = (await this.#command('commitOutcomes', { query }));
562
- return result.outcomes;
668
+ return result.outcomes.map(decodeCommitOutcome);
563
669
  }
564
670
  async resolveCommitOutcome(input) {
565
671
  const result = (await this.#command('resolveCommitOutcome', {
566
672
  input,
567
673
  }));
568
- return result.outcome;
674
+ return decodeCommitOutcome(result.outcome);
569
675
  }
570
676
  async pendingCommits() {
571
677
  const result = (await this.#command('pendingCommitIds', {}));
@@ -620,11 +726,13 @@ function decodeDiagnosticsSnapshot(value, kind) {
620
726
  export class TauriSyncError extends Error {
621
727
  code;
622
728
  details;
623
- constructor(code, message, details) {
729
+ retryable;
730
+ constructor(code, message, details, retryable = false) {
624
731
  super(message);
625
732
  this.name = 'TauriSyncError';
626
733
  this.code = code;
627
734
  this.details = details;
735
+ this.retryable = retryable;
628
736
  }
629
737
  }
630
738
  function toStringSet(value) {
@@ -762,7 +870,7 @@ export async function createTauriSyncClient(config, createClient = (...args) =>
762
870
  });
763
871
  if (reply.error !== undefined) {
764
872
  unlisten();
765
- throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
873
+ throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
766
874
  }
767
875
  return client;
768
876
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/tauri",
3
- "version": "0.30.16",
3
+ "version": "0.32.0",
4
4
  "description": "Tauri integration for the Syncular client",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -56,13 +56,13 @@
56
56
  "test": "bun test"
57
57
  },
58
58
  "dependencies": {
59
- "@syncular/client": "0.30.16"
59
+ "@syncular/client": "0.32.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@tauri-apps/api": ">=2.0.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@syncular/react": "0.30.16",
66
- "@syncular/server": "0.30.16"
65
+ "@syncular/react": "0.32.0",
66
+ "@syncular/server": "0.32.0"
67
67
  }
68
68
  }
package/src/index.ts CHANGED
@@ -60,6 +60,8 @@ import type {
60
60
  RejectionRecord,
61
61
  ResolveCommitOutcomeInput,
62
62
  SecurityLifecycle,
63
+ SnapshotRead,
64
+ SnapshotReadRequest,
63
65
  SqlRow,
64
66
  SqlValue,
65
67
  SyncClientConfig,
@@ -86,6 +88,7 @@ interface CommandReply {
86
88
  readonly code: string;
87
89
  readonly message: string;
88
90
  readonly details?: Readonly<Record<string, unknown>> | null;
91
+ readonly retryable?: boolean;
89
92
  };
90
93
  }
91
94
 
@@ -95,6 +98,18 @@ interface SyncularEvent {
95
98
  readonly [key: string]: unknown;
96
99
  }
97
100
 
101
+ /** §7.5: one read-only statement in an atomic sidecar snapshot read. */
102
+ export type { SnapshotStatement } from '@syncular/client';
103
+
104
+ /** §7.5: the catch-up state of one subscription from the sidecar. */
105
+ export type { SubscriptionCatchup } from '@syncular/client';
106
+
107
+ /** §7.5: delivery status for one client commit id. */
108
+ export type { CommitDelivery } from '@syncular/client';
109
+
110
+ /** §7.5: one atomic sidecar snapshot read result with decoded rows. */
111
+ export type SnapshotReadResult = SnapshotRead;
112
+
98
113
  /** The two Tauri primitives the bridge needs — injectable for tests. */
99
114
  export interface TauriApi {
100
115
  invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
@@ -165,13 +180,30 @@ export type BytesEnvelope = { readonly $bytes: string };
165
180
  type BigIntEnvelope = { readonly $bigint: string };
166
181
 
167
182
  function isBytesEnvelope(value: unknown): value is BytesEnvelope {
183
+ if (typeof value !== 'object' || value === null) return false;
184
+ const record = value as Record<string, unknown>;
168
185
  return (
169
- typeof value === 'object' &&
170
- value !== null &&
171
- typeof (value as { $bytes?: unknown }).$bytes === 'string'
186
+ Object.keys(record).length === 1 &&
187
+ typeof record.$bytes === 'string' &&
188
+ /^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes)
172
189
  );
173
190
  }
174
191
 
192
+ /**
193
+ * §7.1: a host reply that carries an envelope-shaped object with a malformed
194
+ * body is refused, never padded into different bytes. Objects without a
195
+ * `$bytes` key keep the defensive JSON text round-trip below.
196
+ */
197
+ function assertBytesEnvelope(value: unknown): void {
198
+ if (typeof value !== 'object' || value === null || !('$bytes' in value))
199
+ return;
200
+ if (!isBytesEnvelope(value))
201
+ throw new TauriSyncError(
202
+ 'sync.invalid_request',
203
+ 'a $bytes envelope requires exactly one $bytes key holding an even-length hexadecimal string',
204
+ );
205
+ }
206
+
175
207
  function isBigIntEnvelope(value: unknown): value is BigIntEnvelope {
176
208
  return (
177
209
  typeof value === 'object' &&
@@ -189,10 +221,14 @@ function bytesToHex(bytes: Uint8Array): string {
189
221
  }
190
222
 
191
223
  function hexToBytes(hex: string): Uint8Array {
192
- const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
193
- const out = new Uint8Array(clean.length / 2);
224
+ if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex))
225
+ throw new TauriSyncError(
226
+ 'sync.invalid_request',
227
+ 'a $bytes envelope requires an even-length hexadecimal string',
228
+ );
229
+ const out = new Uint8Array(hex.length / 2);
194
230
  for (let i = 0; i < out.length; i++) {
195
- out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
231
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
196
232
  }
197
233
  return out;
198
234
  }
@@ -234,6 +270,7 @@ function encodeEncryption(config: EncryptionKeyringConfig): unknown {
234
270
 
235
271
  /** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
236
272
  function decodeCell(value: unknown): SqlValue {
273
+ assertBytesEnvelope(value);
237
274
  if (isBytesEnvelope(value)) return hexToBytes(value.$bytes);
238
275
  if (isBigIntEnvelope(value)) return BigInt(value.$bigint);
239
276
  if (
@@ -249,6 +286,74 @@ function decodeCell(value: unknown): SqlValue {
249
286
  return JSON.stringify(value);
250
287
  }
251
288
 
289
+ /** A decoded outcome row cell. Structurally the shared `RowValue` (bytes are
290
+ * `Uint8Array`); native outcome rows emit plain JSON numbers, never `$bigint`. */
291
+ type OutcomeRowValue = ConflictRecord['serverRow'][string];
292
+
293
+ /** Decode one native outcome row image. Only the `{$bytes}` envelope is
294
+ * honored; operation `values` stay `JsonRowValue` and are not passed here. */
295
+ function decodeOutcomeRow(
296
+ row: Readonly<Record<string, unknown>>,
297
+ ): Record<string, OutcomeRowValue> {
298
+ const out: Record<string, OutcomeRowValue> = {};
299
+ for (const [key, value] of Object.entries(row)) {
300
+ // Reserved `_sync_*` columns stay engine-internal, matching `decodeRow`.
301
+ if (key.startsWith('_sync_')) continue;
302
+ assertBytesEnvelope(value);
303
+ if (
304
+ value === null ||
305
+ typeof value === 'string' ||
306
+ typeof value === 'number' ||
307
+ typeof value === 'boolean'
308
+ ) {
309
+ out[key] = value;
310
+ } else if (isBytesEnvelope(value)) {
311
+ out[key] = hexToBytes(value.$bytes);
312
+ } else {
313
+ out[key] = JSON.stringify(value);
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /** Decode the row images a native `CommitOutcome` carries: conflict server
320
+ * rows and retained-row images. */
321
+ function decodeCommitOutcome(outcome: CommitOutcome): CommitOutcome {
322
+ return {
323
+ ...outcome,
324
+ results: outcome.results.map((result) =>
325
+ result.status === 'conflict'
326
+ ? {
327
+ ...result,
328
+ conflict: {
329
+ ...result.conflict,
330
+ serverRow: decodeOutcomeRow(result.conflict.serverRow),
331
+ },
332
+ }
333
+ : result,
334
+ ),
335
+ ...(outcome.retainedRows !== undefined
336
+ ? {
337
+ retainedRows: outcome.retainedRows.map((row) => ({
338
+ ...row,
339
+ localRow:
340
+ row.localRow === null ? null : decodeOutcomeRow(row.localRow),
341
+ serverRow:
342
+ row.serverRow === null ? null : decodeOutcomeRow(row.serverRow),
343
+ ...(row.uniqueConflicts !== undefined
344
+ ? {
345
+ uniqueConflicts: row.uniqueConflicts.map((conflict) => ({
346
+ ...conflict,
347
+ serverRow: decodeOutcomeRow(conflict.serverRow),
348
+ })),
349
+ }
350
+ : {}),
351
+ })),
352
+ }
353
+ : {}),
354
+ };
355
+ }
356
+
252
357
  /** @internal Shared native row codec. */
253
358
  export function decodeRow(row: Record<string, unknown>): SqlRow {
254
359
  const out: SqlRow = {};
@@ -373,6 +478,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
373
478
  reply.error.code,
374
479
  reply.error.message,
375
480
  reply.error.details ?? undefined,
481
+ reply.error.retryable ?? false,
376
482
  );
377
483
  }
378
484
  return reply.result;
@@ -600,6 +706,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
600
706
  reply.error.code,
601
707
  reply.error.message,
602
708
  reply.error.details ?? undefined,
709
+ reply.error.retryable ?? false,
603
710
  );
604
711
  }
605
712
  const rows = (reply.result as { rows?: unknown[] }).rows ?? [];
@@ -624,6 +731,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
624
731
  reply.error.code,
625
732
  reply.error.message,
626
733
  reply.error.details ?? undefined,
734
+ reply.error.retryable ?? false,
627
735
  );
628
736
  }
629
737
  const result = reply.result as {
@@ -645,6 +753,54 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
645
753
  return BigInt(result.revision);
646
754
  }
647
755
 
756
+ /**
757
+ * §7.5: one atomic read over the file-backed sidecar. Every statement and
758
+ * every catch-up/delivery read shares one revision (a `bigint`, matching
759
+ * `querySnapshot`) and one read transaction; every query row is decoded.
760
+ */
761
+ async snapshotRead(request: SnapshotReadRequest): Promise<SnapshotRead> {
762
+ this.#requireActive();
763
+ const reply = await this.#tauri.invoke<CommandReply>(
764
+ `${PLUGIN}syncular_snapshot_read`,
765
+ {
766
+ statements: request.statements.map((statement) => ({
767
+ sql: statement.sql,
768
+ params: (statement.params ?? []).map(encodeParam),
769
+ })),
770
+ coverage: request.coverage ?? [],
771
+ subscriptions: request.subscriptions ?? [],
772
+ commitIds: request.commitIds ?? [],
773
+ ...(request.owner !== undefined ? { owner: request.owner } : {}),
774
+ },
775
+ );
776
+ if (reply.error !== undefined) {
777
+ throw new TauriSyncError(
778
+ reply.error.code,
779
+ reply.error.message,
780
+ reply.error.details ?? undefined,
781
+ reply.error.retryable ?? false,
782
+ );
783
+ }
784
+ const result = reply.result as {
785
+ revision: string;
786
+ queries: readonly (readonly Record<string, unknown>[])[];
787
+ coverage: SnapshotRead['coverage'];
788
+ subscriptions: SnapshotRead['subscriptions'];
789
+ deliveries: SnapshotRead['deliveries'];
790
+ };
791
+ return {
792
+ revision: BigInt(result.revision),
793
+ queries: result.queries.map((rows) => rows.map(decodeRow)),
794
+ coverage: result.coverage,
795
+ subscriptions: result.subscriptions,
796
+ deliveries: result.deliveries.map((delivery) =>
797
+ delivery.status === 'known'
798
+ ? { ...delivery, outcome: decodeCommitOutcome(delivery.outcome) }
799
+ : delivery,
800
+ ),
801
+ };
802
+ }
803
+
648
804
  async statusSnapshot(): Promise<SyncStatusSnapshot> {
649
805
  return (await this.#command('statusSnapshot', {})) as SyncStatusSnapshot;
650
806
  }
@@ -754,6 +910,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
754
910
  reply.error.code,
755
911
  reply.error.message,
756
912
  reply.error.details ?? undefined,
913
+ reply.error.retryable ?? false,
757
914
  );
758
915
  }
759
916
  }
@@ -897,7 +1054,9 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
897
1054
  const result = (await this.#command('commitOutcome', {
898
1055
  clientCommitId,
899
1056
  })) as { outcome?: CommitOutcome };
900
- return result.outcome;
1057
+ return result.outcome === undefined
1058
+ ? undefined
1059
+ : decodeCommitOutcome(result.outcome);
901
1060
  }
902
1061
 
903
1062
  async commitOutcomes(
@@ -906,7 +1065,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
906
1065
  const result = (await this.#command('commitOutcomes', { query })) as {
907
1066
  outcomes: CommitOutcome[];
908
1067
  };
909
- return result.outcomes;
1068
+ return result.outcomes.map(decodeCommitOutcome);
910
1069
  }
911
1070
 
912
1071
  async resolveCommitOutcome(
@@ -915,7 +1074,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
915
1074
  const result = (await this.#command('resolveCommitOutcome', {
916
1075
  input,
917
1076
  })) as { outcome: CommitOutcome };
918
- return result.outcome;
1077
+ return decodeCommitOutcome(result.outcome);
919
1078
  }
920
1079
 
921
1080
  async pendingCommits(): Promise<unknown[]> {
@@ -984,15 +1143,18 @@ function decodeDiagnosticsSnapshot(
984
1143
  export class TauriSyncError extends Error {
985
1144
  readonly code: string;
986
1145
  readonly details: Readonly<Record<string, unknown>> | undefined;
1146
+ readonly retryable: boolean;
987
1147
  constructor(
988
1148
  code: string,
989
1149
  message: string,
990
1150
  details?: Readonly<Record<string, unknown>>,
1151
+ retryable = false,
991
1152
  ) {
992
1153
  super(message);
993
1154
  this.name = 'TauriSyncError';
994
1155
  this.code = code;
995
1156
  this.details = details;
1157
+ this.retryable = retryable;
996
1158
  }
997
1159
  }
998
1160
 
@@ -1168,6 +1330,7 @@ export async function createTauriSyncClient(
1168
1330
  reply.error.code,
1169
1331
  reply.error.message,
1170
1332
  reply.error.details ?? undefined,
1333
+ reply.error.retryable ?? false,
1171
1334
  );
1172
1335
  }
1173
1336