@syncular/tauri 0.30.15 → 0.31.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
@@ -154,7 +154,8 @@ available. The native database opens and migrates, but query/snapshot, mutation,
154
154
  subscription, sync, realtime, presence, blob, and automatic retry work fails
155
155
  with `client.security_preflight_required`. Status, local revision, lifecycle,
156
156
  and `purgeLocalData` remain available. A creation-time `defineAuthorityReads`
157
- policy also enables the read-only `authoritySnapshot()` described below.
157
+ policy on `createTauriAuthoritySyncClient` enables the read-only
158
+ `authoritySnapshot()` described below.
158
159
 
159
160
  ```ts
160
161
  const client = await createTauriSyncClient({
@@ -184,16 +185,17 @@ registration and local progress reads throw that code. `close()` is idempotent.
184
185
  ## Authority evidence before activation
185
186
 
186
187
  Declare authority reads at client creation with `defineAuthorityReads` from
187
- `@syncular/client`. Each table declares plain columns, including its primary
188
- key and scope columns, and concrete scope selectors. These selectors remain
188
+ `@syncular/client/authority`. Each table declares plain columns, including
189
+ its primary key and scope columns, and concrete scope selectors. These selectors remain
189
190
  fixed for that client. A declaration cannot include encrypted, bytes, blob,
190
191
  CRDT or internal columns. Choose only authority fields; clinical fields and
191
192
  credentials do not belong in this policy.
192
193
 
193
194
  ```ts
194
- import { defineAuthorityReads } from '@syncular/client';
195
+ import { defineAuthorityReads } from '@syncular/client/authority';
196
+ import { createTauriAuthoritySyncClient } from '@syncular/tauri/authority';
195
197
 
196
- const client = await createTauriSyncClient({
198
+ const client = await createTauriAuthoritySyncClient({
197
199
  schema,
198
200
  securityPreflight: true,
199
201
  transportEnabled: false,
@@ -206,6 +208,11 @@ const client = await createTauriSyncClient({
206
208
  const evidence = await client.authoritySnapshot();
207
209
  ```
208
210
 
211
+ Use `createTauriAuthoritySyncClient` for the bridge that exposes
212
+ `authoritySnapshot()`. The ordinary `createTauriSyncClient` class does not
213
+ contain that method or its reply decoder. Upgrade 0.30.15 callers to these
214
+ explicit subpaths; the snapshot and native security checks keep their semantics.
215
+
209
216
  The native application must also set an independent ceiling at plugin creation:
210
217
 
211
218
  ```rust
@@ -0,0 +1,10 @@
1
+ import type { AuthorityReadPolicy, AuthoritySnapshot } from '@syncular/client/authority';
2
+ import { TauriSyncClient, type TauriSyncClientConfig } from './index.js';
3
+ /** Explicit native bridge with the zero-argument preflight authority read. */
4
+ export declare class TauriAuthoritySyncClient extends TauriSyncClient {
5
+ authoritySnapshot(): Promise<AuthoritySnapshot>;
6
+ }
7
+ /** Construct an authority-enabled bridge; Rust still enforces its independent ceiling. */
8
+ export declare function createTauriAuthoritySyncClient(config: TauriSyncClientConfig & {
9
+ readonly authorityReads: AuthorityReadPolicy;
10
+ }): Promise<TauriAuthoritySyncClient>;
@@ -0,0 +1,26 @@
1
+ import { createTauriSyncClient, decodeRow, TauriSyncClient, TauriSyncError, } from './index.js';
2
+ /** Explicit native bridge with the zero-argument preflight authority read. */
3
+ export class TauriAuthoritySyncClient extends TauriSyncClient {
4
+ async authoritySnapshot() {
5
+ this.requireOpen();
6
+ if (arguments.length)
7
+ throw new TauriSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
8
+ const result = (await this.command('authoritySnapshot', {}));
9
+ this.requireOpen();
10
+ return {
11
+ ...result,
12
+ revision: BigInt(result.revision),
13
+ tables: result.tables.map((table) => ({
14
+ ...table,
15
+ rows: table.rows.map((row) => ({
16
+ ...row,
17
+ values: decodeRow(row.values),
18
+ })),
19
+ })),
20
+ };
21
+ }
22
+ }
23
+ /** Construct an authority-enabled bridge; Rust still enforces its independent ceiling. */
24
+ export function createTauriAuthoritySyncClient(config) {
25
+ return createTauriSyncClient(config, (...args) => new TauriAuthoritySyncClient(...args));
26
+ }
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, AuthoritySnapshot, 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>;
@@ -97,6 +105,8 @@ export interface TauriSyncClientConfig {
97
105
  export type BytesEnvelope = {
98
106
  readonly $bytes: string;
99
107
  };
108
+ /** @internal Shared native row codec. */
109
+ export declare function decodeRow(row: Record<string, unknown>): SqlRow;
100
110
  /**
101
111
  * The webview-side proxy implementing `SyncClientLike` over the plugin. Every
102
112
  * snapshot method returns a promise from an IPC round trip. React hooks accept
@@ -106,6 +116,10 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
106
116
  #private;
107
117
  /** @internal — use {@link createTauriSyncClient}. */
108
118
  constructor(tauri: TauriApi, unlisten: () => void, securityLifecycle?: SecurityLifecycle);
119
+ /** @internal Shared command gate for explicit bridge extensions. */
120
+ protected command(method: string, params: Record<string, unknown>): Promise<unknown>;
121
+ /** @internal Recheck local close after an extension's asynchronous reply. */
122
+ protected requireOpen(): void;
109
123
  /** @internal — fan an incoming plugin event out to the local listeners. */
110
124
  __dispatchEvent(event: SyncularEvent): void;
111
125
  securityLifecycle(): Promise<SecurityLifecycle>;
@@ -128,9 +142,14 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
128
142
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
129
143
  onPresence(listener: (scopeKey: string) => void): () => void;
130
144
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
131
- authoritySnapshot(): Promise<AuthoritySnapshot>;
132
145
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
133
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>;
134
153
  statusSnapshot(): Promise<SyncStatusSnapshot>;
135
154
  diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
136
155
  mutate(mutations: readonly MutationInput[]): Promise<string>;
@@ -195,7 +214,8 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
195
214
  export declare class TauriSyncError extends Error {
196
215
  readonly code: string;
197
216
  readonly details: Readonly<Record<string, unknown>> | undefined;
198
- 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);
199
219
  }
200
220
  /**
201
221
  * Construct the bridge and issue the native `create` (opening/attaching the
@@ -204,4 +224,5 @@ export declare class TauriSyncError extends Error {
204
224
  * React `<SyncProvider client={…}>`.
205
225
  */
206
226
  export declare function createTauriSyncClient(config: TauriSyncClientConfig): Promise<TauriSyncClient>;
207
- export {};
227
+ /** @internal Explicit bridge extension used by the authority entry. */
228
+ export declare function createTauriSyncClient<Client extends TauriSyncClient>(config: TauriSyncClientConfig, createClient: (...args: ConstructorParameters<typeof TauriSyncClient>) => Client): Promise<Client>;
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,7 +98,65 @@ function decodeCell(value) {
82
98
  // string (SQLite json columns arrive as text already; this is defensive).
83
99
  return JSON.stringify(value);
84
100
  }
85
- function decodeRow(row) {
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
+ }
158
+ /** @internal Shared native row codec. */
159
+ export function decodeRow(row) {
86
160
  const out = {};
87
161
  for (const [key, value] of Object.entries(row)) {
88
162
  // Reserved `_sync_*` columns stay engine-internal (parity with the
@@ -178,7 +252,7 @@ export class TauriSyncClient {
178
252
  }
179
253
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
180
254
  if (reply.error !== undefined) {
181
- 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);
182
256
  }
183
257
  return reply.result;
184
258
  }
@@ -190,6 +264,14 @@ export class TauriSyncClient {
190
264
  throw new TauriSyncError('client.closed', 'the Tauri sync client is closed');
191
265
  }
192
266
  }
267
+ /** @internal Shared command gate for explicit bridge extensions. */
268
+ command(method, params) {
269
+ return this.#command(method, params);
270
+ }
271
+ /** @internal Recheck local close after an extension's asynchronous reply. */
272
+ requireOpen() {
273
+ this.#requireOpen();
274
+ }
193
275
  #requireActive() {
194
276
  this.#requireOpen();
195
277
  if (this.#securityLifecycle === 'preflight')
@@ -360,29 +442,11 @@ export class TauriSyncClient {
360
442
  this.#requireActive();
361
443
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
362
444
  if (reply.error !== undefined) {
363
- 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);
364
446
  }
365
447
  const rows = reply.result.rows ?? [];
366
448
  return rows.map((r) => decodeRow(r));
367
449
  }
368
- async authoritySnapshot() {
369
- this.#requireOpen();
370
- if (arguments.length)
371
- throw new TauriSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
372
- const result = (await this.#command('authoritySnapshot', {}));
373
- this.#requireOpen();
374
- return {
375
- ...result,
376
- revision: BigInt(result.revision),
377
- tables: result.tables.map((table) => ({
378
- ...table,
379
- rows: table.rows.map((row) => ({
380
- ...row,
381
- values: decodeRow(row.values),
382
- })),
383
- })),
384
- };
385
- }
386
450
  async querySnapshot(spec) {
387
451
  this.#requireActive();
388
452
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query_snapshot`, {
@@ -392,7 +456,7 @@ export class TauriSyncClient {
392
456
  ...(spec.owner !== undefined ? { owner: spec.owner } : {}),
393
457
  });
394
458
  if (reply.error !== undefined) {
395
- 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);
396
460
  }
397
461
  const result = reply.result;
398
462
  return {
@@ -405,6 +469,37 @@ export class TauriSyncClient {
405
469
  const result = (await this.#command('localRevision', {}));
406
470
  return BigInt(result.revision);
407
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
+ }
408
503
  async statusSnapshot() {
409
504
  return (await this.#command('statusSnapshot', {}));
410
505
  }
@@ -470,7 +565,7 @@ export class TauriSyncClient {
470
565
  this.#requireActive();
471
566
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_set_headers`, { headers });
472
567
  if (reply.error !== undefined) {
473
- 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);
474
569
  }
475
570
  }
476
571
  // -- Native CRDT (SPEC.md §5.10.5; needs the plugin `crdt-yjs` feature) ------
@@ -564,17 +659,19 @@ export class TauriSyncClient {
564
659
  const result = (await this.#command('commitOutcome', {
565
660
  clientCommitId,
566
661
  }));
567
- return result.outcome;
662
+ return result.outcome === undefined
663
+ ? undefined
664
+ : decodeCommitOutcome(result.outcome);
568
665
  }
569
666
  async commitOutcomes(query = {}) {
570
667
  const result = (await this.#command('commitOutcomes', { query }));
571
- return result.outcomes;
668
+ return result.outcomes.map(decodeCommitOutcome);
572
669
  }
573
670
  async resolveCommitOutcome(input) {
574
671
  const result = (await this.#command('resolveCommitOutcome', {
575
672
  input,
576
673
  }));
577
- return result.outcome;
674
+ return decodeCommitOutcome(result.outcome);
578
675
  }
579
676
  async pendingCommits() {
580
677
  const result = (await this.#command('pendingCommitIds', {}));
@@ -629,11 +726,13 @@ function decodeDiagnosticsSnapshot(value, kind) {
629
726
  export class TauriSyncError extends Error {
630
727
  code;
631
728
  details;
632
- constructor(code, message, details) {
729
+ retryable;
730
+ constructor(code, message, details, retryable = false) {
633
731
  super(message);
634
732
  this.name = 'TauriSyncError';
635
733
  this.code = code;
636
734
  this.details = details;
735
+ this.retryable = retryable;
637
736
  }
638
737
  }
639
738
  function toStringSet(value) {
@@ -720,13 +819,7 @@ function encodeMutation(mutation) {
720
819
  }
721
820
  return { ...mutation, values };
722
821
  }
723
- /**
724
- * Construct the bridge and issue the native `create` (opening/attaching the
725
- * file DB on the Rust side per the plugin config). Returns a ready
726
- * `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
727
- * React `<SyncProvider client={…}>`.
728
- */
729
- export async function createTauriSyncClient(config) {
822
+ export async function createTauriSyncClient(config, createClient = (...args) => new TauriSyncClient(...args)) {
730
823
  const tauri = await resolveTauri(config.tauri);
731
824
  // Wire the event stream BEFORE create, so no early change batch is missed.
732
825
  const clientRef = {
@@ -739,7 +832,7 @@ export async function createTauriSyncClient(config) {
739
832
  unlisten();
740
833
  throw new TauriSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
741
834
  }
742
- const client = new TauriSyncClient(tauri, unlisten, config.securityPreflight === true ? 'preflight' : 'active');
835
+ const client = createClient(tauri, unlisten, config.securityPreflight === true ? 'preflight' : 'active');
743
836
  clientRef.client = client;
744
837
  // The native side owns every path (plugin config); the JS side names a
745
838
  // database and supplies the schema, clientId, and limits.
@@ -777,7 +870,7 @@ export async function createTauriSyncClient(config) {
777
870
  });
778
871
  if (reply.error !== undefined) {
779
872
  unlisten();
780
- 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);
781
874
  }
782
875
  return client;
783
876
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/tauri",
3
- "version": "0.30.15",
3
+ "version": "0.31.0",
4
4
  "description": "Tauri integration for the Syncular client",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -33,6 +33,14 @@
33
33
  "types": "./dist/index.d.ts",
34
34
  "default": "./dist/index.js"
35
35
  }
36
+ },
37
+ "./authority": {
38
+ "bun": "./src/authority.ts",
39
+ "browser": "./dist/authority.js",
40
+ "import": {
41
+ "types": "./dist/authority.d.ts",
42
+ "default": "./dist/authority.js"
43
+ }
36
44
  }
37
45
  },
38
46
  "files": [
@@ -48,13 +56,13 @@
48
56
  "test": "bun test"
49
57
  },
50
58
  "dependencies": {
51
- "@syncular/client": "0.30.15"
59
+ "@syncular/client": "0.31.0"
52
60
  },
53
61
  "peerDependencies": {
54
62
  "@tauri-apps/api": ">=2.0.0"
55
63
  },
56
64
  "devDependencies": {
57
- "@syncular/react": "0.30.15",
58
- "@syncular/server": "0.30.15"
65
+ "@syncular/react": "0.31.0",
66
+ "@syncular/server": "0.31.0"
59
67
  }
60
68
  }
@@ -0,0 +1,51 @@
1
+ import type {
2
+ AuthorityReadPolicy,
3
+ AuthoritySnapshot,
4
+ } from '@syncular/client/authority';
5
+ import {
6
+ createTauriSyncClient,
7
+ decodeRow,
8
+ TauriSyncClient,
9
+ TauriSyncError,
10
+ type TauriSyncClientConfig,
11
+ } from './index';
12
+
13
+ /** Explicit native bridge with the zero-argument preflight authority read. */
14
+ export class TauriAuthoritySyncClient extends TauriSyncClient {
15
+ async authoritySnapshot(): Promise<AuthoritySnapshot> {
16
+ this.requireOpen();
17
+ if (arguments.length)
18
+ throw new TauriSyncError(
19
+ 'client.authority_read_forbidden',
20
+ 'authoritySnapshot accepts no arguments',
21
+ );
22
+ const result = (await this.command('authoritySnapshot', {})) as Omit<
23
+ AuthoritySnapshot,
24
+ 'revision'
25
+ > & { revision: string };
26
+ this.requireOpen();
27
+ return {
28
+ ...result,
29
+ revision: BigInt(result.revision),
30
+ tables: result.tables.map((table) => ({
31
+ ...table,
32
+ rows: table.rows.map((row) => ({
33
+ ...row,
34
+ values: decodeRow(row.values),
35
+ })),
36
+ })),
37
+ };
38
+ }
39
+ }
40
+
41
+ /** Construct an authority-enabled bridge; Rust still enforces its independent ceiling. */
42
+ export function createTauriAuthoritySyncClient(
43
+ config: TauriSyncClientConfig & {
44
+ readonly authorityReads: AuthorityReadPolicy;
45
+ },
46
+ ): Promise<TauriAuthoritySyncClient> {
47
+ return createTauriSyncClient(
48
+ config,
49
+ (...args) => new TauriAuthoritySyncClient(...args),
50
+ );
51
+ }
package/src/index.ts CHANGED
@@ -35,7 +35,6 @@ import type { ClientSnapshotMethods, PromiseMethods } from '@syncular/client';
35
35
 
36
36
  import type {
37
37
  AuthorityReadPolicy,
38
- AuthoritySnapshot,
39
38
  ClientChangeBatch,
40
39
  ClientChangeListener,
41
40
  ClientDiagnosticsListener,
@@ -61,6 +60,8 @@ import type {
61
60
  RejectionRecord,
62
61
  ResolveCommitOutcomeInput,
63
62
  SecurityLifecycle,
63
+ SnapshotRead,
64
+ SnapshotReadRequest,
64
65
  SqlRow,
65
66
  SqlValue,
66
67
  SyncClientConfig,
@@ -87,6 +88,7 @@ interface CommandReply {
87
88
  readonly code: string;
88
89
  readonly message: string;
89
90
  readonly details?: Readonly<Record<string, unknown>> | null;
91
+ readonly retryable?: boolean;
90
92
  };
91
93
  }
92
94
 
@@ -96,6 +98,18 @@ interface SyncularEvent {
96
98
  readonly [key: string]: unknown;
97
99
  }
98
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
+
99
113
  /** The two Tauri primitives the bridge needs — injectable for tests. */
100
114
  export interface TauriApi {
101
115
  invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
@@ -166,13 +180,30 @@ export type BytesEnvelope = { readonly $bytes: string };
166
180
  type BigIntEnvelope = { readonly $bigint: string };
167
181
 
168
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>;
169
185
  return (
170
- typeof value === 'object' &&
171
- value !== null &&
172
- 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)
173
189
  );
174
190
  }
175
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
+
176
207
  function isBigIntEnvelope(value: unknown): value is BigIntEnvelope {
177
208
  return (
178
209
  typeof value === 'object' &&
@@ -190,10 +221,14 @@ function bytesToHex(bytes: Uint8Array): string {
190
221
  }
191
222
 
192
223
  function hexToBytes(hex: string): Uint8Array {
193
- const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
194
- 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);
195
230
  for (let i = 0; i < out.length; i++) {
196
- 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);
197
232
  }
198
233
  return out;
199
234
  }
@@ -235,6 +270,7 @@ function encodeEncryption(config: EncryptionKeyringConfig): unknown {
235
270
 
236
271
  /** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
237
272
  function decodeCell(value: unknown): SqlValue {
273
+ assertBytesEnvelope(value);
238
274
  if (isBytesEnvelope(value)) return hexToBytes(value.$bytes);
239
275
  if (isBigIntEnvelope(value)) return BigInt(value.$bigint);
240
276
  if (
@@ -250,7 +286,76 @@ function decodeCell(value: unknown): SqlValue {
250
286
  return JSON.stringify(value);
251
287
  }
252
288
 
253
- function decodeRow(row: Record<string, unknown>): SqlRow {
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
+
357
+ /** @internal Shared native row codec. */
358
+ export function decodeRow(row: Record<string, unknown>): SqlRow {
254
359
  const out: SqlRow = {};
255
360
  for (const [key, value] of Object.entries(row)) {
256
361
  // Reserved `_sync_*` columns stay engine-internal (parity with the
@@ -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;
@@ -394,6 +500,19 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
394
500
  }
395
501
  }
396
502
 
503
+ /** @internal Shared command gate for explicit bridge extensions. */
504
+ protected command(
505
+ method: string,
506
+ params: Record<string, unknown>,
507
+ ): Promise<unknown> {
508
+ return this.#command(method, params);
509
+ }
510
+
511
+ /** @internal Recheck local close after an extension's asynchronous reply. */
512
+ protected requireOpen(): void {
513
+ this.#requireOpen();
514
+ }
515
+
397
516
  #requireActive(): void {
398
517
  this.#requireOpen();
399
518
  if (this.#securityLifecycle === 'preflight') this.#throwSecurityPreflight();
@@ -587,37 +706,13 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
587
706
  reply.error.code,
588
707
  reply.error.message,
589
708
  reply.error.details ?? undefined,
709
+ reply.error.retryable ?? false,
590
710
  );
591
711
  }
592
712
  const rows = (reply.result as { rows?: unknown[] }).rows ?? [];
593
713
  return rows.map((r) => decodeRow(r as Record<string, unknown>));
594
714
  }
595
715
 
596
- async authoritySnapshot(): Promise<AuthoritySnapshot> {
597
- this.#requireOpen();
598
- if (arguments.length)
599
- throw new TauriSyncError(
600
- 'client.authority_read_forbidden',
601
- 'authoritySnapshot accepts no arguments',
602
- );
603
- const result = (await this.#command('authoritySnapshot', {})) as Omit<
604
- AuthoritySnapshot,
605
- 'revision'
606
- > & { revision: string };
607
- this.#requireOpen();
608
- return {
609
- ...result,
610
- revision: BigInt(result.revision),
611
- tables: result.tables.map((table) => ({
612
- ...table,
613
- rows: table.rows.map((row) => ({
614
- ...row,
615
- values: decodeRow(row.values),
616
- })),
617
- })),
618
- };
619
- }
620
-
621
716
  async querySnapshot<Row = SqlRow>(
622
717
  spec: QueryReadSpec,
623
718
  ): Promise<QuerySnapshot<Row>> {
@@ -636,6 +731,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
636
731
  reply.error.code,
637
732
  reply.error.message,
638
733
  reply.error.details ?? undefined,
734
+ reply.error.retryable ?? false,
639
735
  );
640
736
  }
641
737
  const result = reply.result as {
@@ -657,6 +753,54 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
657
753
  return BigInt(result.revision);
658
754
  }
659
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
+
660
804
  async statusSnapshot(): Promise<SyncStatusSnapshot> {
661
805
  return (await this.#command('statusSnapshot', {})) as SyncStatusSnapshot;
662
806
  }
@@ -766,6 +910,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
766
910
  reply.error.code,
767
911
  reply.error.message,
768
912
  reply.error.details ?? undefined,
913
+ reply.error.retryable ?? false,
769
914
  );
770
915
  }
771
916
  }
@@ -909,7 +1054,9 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
909
1054
  const result = (await this.#command('commitOutcome', {
910
1055
  clientCommitId,
911
1056
  })) as { outcome?: CommitOutcome };
912
- return result.outcome;
1057
+ return result.outcome === undefined
1058
+ ? undefined
1059
+ : decodeCommitOutcome(result.outcome);
913
1060
  }
914
1061
 
915
1062
  async commitOutcomes(
@@ -918,7 +1065,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
918
1065
  const result = (await this.#command('commitOutcomes', { query })) as {
919
1066
  outcomes: CommitOutcome[];
920
1067
  };
921
- return result.outcomes;
1068
+ return result.outcomes.map(decodeCommitOutcome);
922
1069
  }
923
1070
 
924
1071
  async resolveCommitOutcome(
@@ -927,7 +1074,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
927
1074
  const result = (await this.#command('resolveCommitOutcome', {
928
1075
  input,
929
1076
  })) as { outcome: CommitOutcome };
930
- return result.outcome;
1077
+ return decodeCommitOutcome(result.outcome);
931
1078
  }
932
1079
 
933
1080
  async pendingCommits(): Promise<unknown[]> {
@@ -996,15 +1143,18 @@ function decodeDiagnosticsSnapshot(
996
1143
  export class TauriSyncError extends Error {
997
1144
  readonly code: string;
998
1145
  readonly details: Readonly<Record<string, unknown>> | undefined;
1146
+ readonly retryable: boolean;
999
1147
  constructor(
1000
1148
  code: string,
1001
1149
  message: string,
1002
1150
  details?: Readonly<Record<string, unknown>>,
1151
+ retryable = false,
1003
1152
  ) {
1004
1153
  super(message);
1005
1154
  this.name = 'TauriSyncError';
1006
1155
  this.code = code;
1007
1156
  this.details = details;
1157
+ this.retryable = retryable;
1008
1158
  }
1009
1159
  }
1010
1160
 
@@ -1097,8 +1247,20 @@ function encodeMutation(mutation: MutationInput): unknown {
1097
1247
  * `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
1098
1248
  * React `<SyncProvider client={…}>`.
1099
1249
  */
1250
+ export function createTauriSyncClient(
1251
+ config: TauriSyncClientConfig,
1252
+ ): Promise<TauriSyncClient>;
1253
+ /** @internal Explicit bridge extension used by the authority entry. */
1254
+ export function createTauriSyncClient<Client extends TauriSyncClient>(
1255
+ config: TauriSyncClientConfig,
1256
+ createClient: (
1257
+ ...args: ConstructorParameters<typeof TauriSyncClient>
1258
+ ) => Client,
1259
+ ): Promise<Client>;
1100
1260
  export async function createTauriSyncClient(
1101
1261
  config: TauriSyncClientConfig,
1262
+ createClient = (...args: ConstructorParameters<typeof TauriSyncClient>) =>
1263
+ new TauriSyncClient(...args),
1102
1264
  ): Promise<TauriSyncClient> {
1103
1265
  const tauri = await resolveTauri(config.tauri);
1104
1266
 
@@ -1121,7 +1283,7 @@ export async function createTauriSyncClient(
1121
1283
  );
1122
1284
  }
1123
1285
 
1124
- const client = new TauriSyncClient(
1286
+ const client = createClient(
1125
1287
  tauri,
1126
1288
  unlisten,
1127
1289
  config.securityPreflight === true ? 'preflight' : 'active',
@@ -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