@syncular/tauri 0.15.15 → 0.15.17

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
@@ -54,6 +54,26 @@ detaching listeners, so disposing a resource does not leave a key-bearing core
54
54
  behind. The Rust core overwrites owned key buffers on replacement/drop; the app
55
55
  still owns OS secure-store deletion and any key buffers it supplied.
56
56
 
57
+ Runtime `setHeaders()` is an active-session operation and is rejected during
58
+ preflight at both the JavaScript and native command boundaries. Supply bootstrap
59
+ headers through trusted plugin configuration; rotate them only after successful
60
+ activation.
61
+
62
+ ## Privacy-safe diagnostics
63
+
64
+ `diagnosticsSnapshot({ expectedSubscriptions })` and `onDiagnostics(listener)`
65
+ carry the native Rust core's versioned support evidence through the Tauri event
66
+ channel. The bridge marks the host as `{ kind: 'tauri', role: 'single' }`; it
67
+ does not infer state from IPC commands. Expected subscriptions accept only
68
+ stable PHI-free ids and generated table names, never scope values.
69
+
70
+ The snapshot is suitable for a redacted “copy diagnostics” workflow: it omits
71
+ rows, clinical row counts, scopes, SQL, paths, client/actor/lease ids, auth,
72
+ keys, mutations, stack traces, and arbitrary prose. Do not supplement it with
73
+ the SQLite file, WebView console dump, or application state. Diagnostics stays
74
+ blocked during security preflight because subscription/table evidence is
75
+ protected. See SPEC §7.6 and `@syncular/react`'s `useDiagnostics`.
76
+
57
77
  ## React availability guard
58
78
 
59
79
  The Tauri bridge carries `currentSchemaVersion`, `schemaFloor`, and migration
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * `invoke`/`listen` either from its ESM entry points, from the ambient
27
27
  * `window.__TAURI__`, or via injected doubles (tests).
28
28
  */
29
- import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
29
+ import type { ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
30
30
  /** One event pushed on `syncular://event` (the derived client-observable set). */
31
31
  interface SyncularEvent {
32
32
  readonly type: string;
@@ -89,11 +89,13 @@ export declare class TauriSyncClient {
89
89
  }): Promise<void>;
90
90
  onInvalidate(listener: InvalidationListener): () => void;
91
91
  onChange(listener: ClientChangeListener): () => void;
92
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
92
93
  onPresence(listener: (scopeKey: string) => void): () => void;
93
94
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
94
95
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
95
96
  localRevision(): Promise<bigint>;
96
97
  statusSnapshot(): Promise<SyncStatusSnapshot>;
98
+ diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
97
99
  mutate(mutations: readonly MutationInput[]): Promise<string>;
98
100
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
99
101
  readonly baseVersion?: number;
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@
29
29
  // -- Types the bridge speaks (structurally the web-client's) -----------------
30
30
  // Most imports stay type-only; the stable preflight error code is shared at
31
31
  // runtime so every host surfaces byte-identical policy evidence.
32
- import { SECURITY_PREFLIGHT_REQUIRED_CODE } from '@syncular/client';
32
+ import { SECURITY_PREFLIGHT_REQUIRED_CODE, withClientDiagnosticsHost, } from '@syncular/client';
33
33
  /** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
34
34
  export const SYNCULAR_EVENT = 'syncular://event';
35
35
  const PLUGIN = 'plugin:syncular|';
@@ -152,6 +152,7 @@ export class TauriSyncClient {
152
152
  #tauri;
153
153
  #invalidationListeners = new Set();
154
154
  #changeListeners = new Set();
155
+ #diagnosticsListeners = new Set();
155
156
  #presenceListeners = new Set();
156
157
  #unlisten;
157
158
  #closed = false;
@@ -233,6 +234,20 @@ export class TauriSyncClient {
233
234
  }
234
235
  break;
235
236
  }
237
+ case 'diagnostics': {
238
+ const snapshot = decodeDiagnosticsSnapshot(event.snapshot, 'tauri');
239
+ if (snapshot === undefined)
240
+ break;
241
+ for (const listener of this.#diagnosticsListeners) {
242
+ try {
243
+ listener(snapshot);
244
+ }
245
+ catch {
246
+ /* a diagnostics observer must never break event dispatch */
247
+ }
248
+ }
249
+ break;
250
+ }
236
251
  default:
237
252
  // Unknown extension events are deliberately ignored. All durable
238
253
  // observable state arrives in the revisioned `change` batch.
@@ -282,6 +297,10 @@ export class TauriSyncClient {
282
297
  this.#changeListeners.add(listener);
283
298
  return () => this.#changeListeners.delete(listener);
284
299
  }
300
+ onDiagnostics(listener) {
301
+ this.#diagnosticsListeners.add(listener);
302
+ return () => this.#diagnosticsListeners.delete(listener);
303
+ }
285
304
  onPresence(listener) {
286
305
  this.#presenceListeners.add(listener);
287
306
  return () => this.#presenceListeners.delete(listener);
@@ -319,6 +338,16 @@ export class TauriSyncClient {
319
338
  async statusSnapshot() {
320
339
  return (await this.#command('statusSnapshot', {}));
321
340
  }
341
+ async diagnosticsSnapshot(request = {}) {
342
+ const snapshot = (await this.#command('diagnosticsSnapshot', {
343
+ expectedSubscriptions: request.expectedSubscriptions ?? [],
344
+ }));
345
+ return withClientDiagnosticsHost(snapshot, {
346
+ ...snapshot.host,
347
+ kind: 'tauri',
348
+ role: 'single',
349
+ });
350
+ }
322
351
  async mutate(mutations) {
323
352
  const result = (await this.#command('mutate', {
324
353
  mutations: mutations.map(encodeMutation),
@@ -348,6 +377,7 @@ export class TauriSyncClient {
348
377
  * the realtime socket applies it on its next (re)connect.
349
378
  */
350
379
  async setHeaders(headers) {
380
+ this.#requireActive();
351
381
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_set_headers`, { headers });
352
382
  if (reply.error !== undefined) {
353
383
  throw new TauriSyncError(reply.error.code, reply.error.message);
@@ -503,9 +533,22 @@ export class TauriSyncClient {
503
533
  this.#unlisten = undefined;
504
534
  this.#invalidationListeners.clear();
505
535
  this.#changeListeners.clear();
536
+ this.#diagnosticsListeners.clear();
506
537
  this.#presenceListeners.clear();
507
538
  }
508
539
  }
540
+ function decodeDiagnosticsSnapshot(value, kind) {
541
+ if (value === null || typeof value !== 'object')
542
+ return undefined;
543
+ const snapshot = value;
544
+ if (snapshot.version !== 1 || snapshot.host === undefined)
545
+ return undefined;
546
+ return withClientDiagnosticsHost(snapshot, {
547
+ ...snapshot.host,
548
+ kind,
549
+ role: 'single',
550
+ });
551
+ }
509
552
  /** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
510
553
  export class TauriSyncError extends Error {
511
554
  code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/tauri",
3
- "version": "0.15.15",
3
+ "version": "0.15.17",
4
4
  "description": "Tauri integration for the Syncular client",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,12 +48,12 @@
48
48
  "test": "bun test"
49
49
  },
50
50
  "dependencies": {
51
- "@syncular/client": "0.15.15"
51
+ "@syncular/client": "0.15.17"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@tauri-apps/api": ">=2.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@syncular/react": "0.15.15"
57
+ "@syncular/react": "0.15.17"
58
58
  }
59
59
  }
package/src/index.ts CHANGED
@@ -30,6 +30,9 @@
30
30
  import type {
31
31
  ClientChangeBatch,
32
32
  ClientChangeListener,
33
+ ClientDiagnosticsListener,
34
+ ClientDiagnosticsRequest,
35
+ ClientDiagnosticsSnapshot,
33
36
  CommitOutcome,
34
37
  CommitOutcomeQuery,
35
38
  ConflictRecord,
@@ -56,7 +59,10 @@ import type {
56
59
  // -- Types the bridge speaks (structurally the web-client's) -----------------
57
60
  // Most imports stay type-only; the stable preflight error code is shared at
58
61
  // runtime so every host surfaces byte-identical policy evidence.
59
- import { SECURITY_PREFLIGHT_REQUIRED_CODE } from '@syncular/client';
62
+ import {
63
+ SECURITY_PREFLIGHT_REQUIRED_CODE,
64
+ withClientDiagnosticsHost,
65
+ } from '@syncular/client';
60
66
 
61
67
  /** A driver-protocol reply: `{result}` on success or `{error}` on failure. */
62
68
  interface CommandReply {
@@ -250,6 +256,7 @@ export class TauriSyncClient {
250
256
  readonly #tauri: TauriApi;
251
257
  readonly #invalidationListeners = new Set<InvalidationListener>();
252
258
  readonly #changeListeners = new Set<ClientChangeListener>();
259
+ readonly #diagnosticsListeners = new Set<ClientDiagnosticsListener>();
253
260
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
254
261
  #unlisten: (() => void) | undefined;
255
262
  #closed = false;
@@ -349,6 +356,18 @@ export class TauriSyncClient {
349
356
  }
350
357
  break;
351
358
  }
359
+ case 'diagnostics': {
360
+ const snapshot = decodeDiagnosticsSnapshot(event.snapshot, 'tauri');
361
+ if (snapshot === undefined) break;
362
+ for (const listener of this.#diagnosticsListeners) {
363
+ try {
364
+ listener(snapshot);
365
+ } catch {
366
+ /* a diagnostics observer must never break event dispatch */
367
+ }
368
+ }
369
+ break;
370
+ }
352
371
  default:
353
372
  // Unknown extension events are deliberately ignored. All durable
354
373
  // observable state arrives in the revisioned `change` batch.
@@ -414,6 +433,11 @@ export class TauriSyncClient {
414
433
  return () => this.#changeListeners.delete(listener);
415
434
  }
416
435
 
436
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void {
437
+ this.#diagnosticsListeners.add(listener);
438
+ return () => this.#diagnosticsListeners.delete(listener);
439
+ }
440
+
417
441
  onPresence(listener: (scopeKey: string) => void): () => void {
418
442
  this.#presenceListeners.add(listener);
419
443
  return () => this.#presenceListeners.delete(listener);
@@ -470,6 +494,19 @@ export class TauriSyncClient {
470
494
  return (await this.#command('statusSnapshot', {})) as SyncStatusSnapshot;
471
495
  }
472
496
 
497
+ async diagnosticsSnapshot(
498
+ request: ClientDiagnosticsRequest = {},
499
+ ): Promise<ClientDiagnosticsSnapshot> {
500
+ const snapshot = (await this.#command('diagnosticsSnapshot', {
501
+ expectedSubscriptions: request.expectedSubscriptions ?? [],
502
+ })) as ClientDiagnosticsSnapshot;
503
+ return withClientDiagnosticsHost(snapshot, {
504
+ ...snapshot.host,
505
+ kind: 'tauri',
506
+ role: 'single',
507
+ });
508
+ }
509
+
473
510
  async mutate(mutations: readonly MutationInput[]): Promise<string> {
474
511
  const result = (await this.#command('mutate', {
475
512
  mutations: mutations.map(encodeMutation),
@@ -509,6 +546,7 @@ export class TauriSyncClient {
509
546
  * the realtime socket applies it on its next (re)connect.
510
547
  */
511
548
  async setHeaders(headers: Readonly<Record<string, string>>): Promise<void> {
549
+ this.#requireActive();
512
550
  const reply = await this.#tauri.invoke<CommandReply>(
513
551
  `${PLUGIN}syncular_set_headers`,
514
552
  { headers },
@@ -745,10 +783,25 @@ export class TauriSyncClient {
745
783
  this.#unlisten = undefined;
746
784
  this.#invalidationListeners.clear();
747
785
  this.#changeListeners.clear();
786
+ this.#diagnosticsListeners.clear();
748
787
  this.#presenceListeners.clear();
749
788
  }
750
789
  }
751
790
 
791
+ function decodeDiagnosticsSnapshot(
792
+ value: unknown,
793
+ kind: 'tauri',
794
+ ): ClientDiagnosticsSnapshot | undefined {
795
+ if (value === null || typeof value !== 'object') return undefined;
796
+ const snapshot = value as ClientDiagnosticsSnapshot;
797
+ if (snapshot.version !== 1 || snapshot.host === undefined) return undefined;
798
+ return withClientDiagnosticsHost(snapshot, {
799
+ ...snapshot.host,
800
+ kind,
801
+ role: 'single',
802
+ });
803
+ }
804
+
752
805
  /** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
753
806
  export class TauriSyncError extends Error {
754
807
  readonly code: string;