@syncular/client 0.15.16 → 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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Privacy-safe client diagnostics shared by direct, Worker, Tauri, React
3
+ * Native, and normalized React hosts. The snapshot deliberately excludes
4
+ * requested/effective scope values, row/cardinality data, SQL, database paths,
5
+ * auth material, lease ids, encryption keys, mutation bodies, and arbitrary
6
+ * diagnostic prose.
7
+ */
8
+ import type { SecurityLifecycle } from './client';
9
+
10
+ export const CLIENT_DIAGNOSTICS_VERSION = 1 as const;
11
+ export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
12
+ export const MAX_DIAGNOSTIC_DOMAINS = 256;
13
+
14
+ export type ClientDiagnosticsHostKind =
15
+ | 'direct'
16
+ | 'worker'
17
+ | 'tauri'
18
+ | 'react-native';
19
+ export type ClientDiagnosticsHostRole =
20
+ | 'single'
21
+ | 'leader'
22
+ | 'follower'
23
+ | 'unknown';
24
+ export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
25
+ export type ClientDiagnosticsRealtime =
26
+ | 'connected'
27
+ | 'disconnected'
28
+ | 'unsupported'
29
+ | 'unknown';
30
+
31
+ export interface ClientDiagnosticsHost {
32
+ readonly kind: ClientDiagnosticsHostKind;
33
+ readonly role: ClientDiagnosticsHostRole;
34
+ readonly connectivity: ClientDiagnosticsConnectivity;
35
+ readonly realtime: ClientDiagnosticsRealtime;
36
+ }
37
+
38
+ export interface ExpectedDiagnosticSubscription {
39
+ /** Application-owned stable identifier. It must never contain PHI. */
40
+ readonly id: string;
41
+ /** Generated schema table name. */
42
+ readonly table: string;
43
+ }
44
+
45
+ export interface ClientDiagnosticsRequest {
46
+ /**
47
+ * Optional bounded intent list. Missing registrations are returned as
48
+ * `unregistered`, allowing a zero-row security scope to fail closed without
49
+ * reading private Syncular tables. Values/scopes are intentionally absent.
50
+ */
51
+ readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
52
+ }
53
+
54
+ export type DiagnosticSubscriptionState =
55
+ | 'unregistered'
56
+ | 'bootstrapping'
57
+ | 'complete'
58
+ | 'reset'
59
+ | 'revoked'
60
+ | 'failed';
61
+
62
+ export interface DiagnosticSubscription {
63
+ readonly id: string;
64
+ readonly table: string;
65
+ readonly state: DiagnosticSubscriptionState;
66
+ readonly complete: boolean;
67
+ /** Last fully applied local commit sequence; absent when unregistered. */
68
+ readonly cursor?: number;
69
+ readonly reasonCode?: string;
70
+ }
71
+
72
+ export interface DiagnosticRoundCounters {
73
+ readonly pushed: number;
74
+ readonly applied: number;
75
+ readonly rejected: number;
76
+ readonly retryable: number;
77
+ readonly conflicts: number;
78
+ readonly commitsApplied: number;
79
+ readonly segmentRowsApplied: number;
80
+ readonly bootstrapping: number;
81
+ readonly resets: number;
82
+ readonly revoked: number;
83
+ readonly failed: number;
84
+ readonly deferredCommits: number;
85
+ }
86
+
87
+ export type DiagnosticLastRound =
88
+ | {
89
+ readonly status: 'succeeded';
90
+ readonly startedAtMs: number;
91
+ readonly completedAtMs: number;
92
+ readonly durationMs: number;
93
+ readonly counters: DiagnosticRoundCounters;
94
+ }
95
+ | {
96
+ readonly status: 'failed';
97
+ readonly startedAtMs: number;
98
+ readonly completedAtMs: number;
99
+ readonly durationMs: number;
100
+ /** Stable code only; never arbitrary transport/server prose. */
101
+ readonly errorCode: string;
102
+ };
103
+
104
+ export interface DiagnosticLastChange {
105
+ /** Decimal u64 for JSON/IPC parity. */
106
+ readonly revision: string;
107
+ readonly recordedAtMs: number;
108
+ /** Generated table names only; no scope keys or row ids. */
109
+ readonly tables: readonly string[];
110
+ /** Generated table names for changed window registrations/completeness. */
111
+ readonly windows: readonly string[];
112
+ readonly domainsTruncated: boolean;
113
+ readonly statusChanged: boolean;
114
+ readonly conflictsChanged: boolean;
115
+ readonly rejectionsChanged: boolean;
116
+ readonly outcomesChanged: boolean;
117
+ }
118
+
119
+ export interface ClientDiagnosticsStorage {
120
+ readonly status: 'healthy' | 'pressure' | 'unreadable';
121
+ /** SQLite page estimate. No path, filename, or per-domain row counts. */
122
+ readonly databaseBytesApprox?: number;
123
+ readonly pendingOutboxBytesApprox?: number;
124
+ readonly retainedOutcomeBytesApprox?: number;
125
+ readonly retainedOutcomeEntries?: number;
126
+ readonly blobCacheBytesApprox?: number;
127
+ readonly pressureReasonCode?: 'client.blob_cache_over_limit';
128
+ }
129
+
130
+ export interface ClientDiagnosticsSnapshot {
131
+ readonly version: typeof CLIENT_DIAGNOSTICS_VERSION;
132
+ readonly capturedAtMs: number;
133
+ readonly host: ClientDiagnosticsHost;
134
+ readonly securityLifecycle: SecurityLifecycle;
135
+ readonly schema: {
136
+ readonly currentVersion: number;
137
+ readonly upgrading: boolean;
138
+ readonly requiredVersion?: number;
139
+ readonly latestVersion?: number;
140
+ };
141
+ readonly replica: {
142
+ /** Decimal u64 for JSON/IPC parity. */
143
+ readonly localRevision: string;
144
+ readonly syncNeeded: boolean;
145
+ readonly pendingOutbox: number;
146
+ };
147
+ readonly lease: {
148
+ readonly state: 'none' | 'active' | 'expired' | 'stopped';
149
+ readonly expiresAtMs?: number;
150
+ readonly errorCode?: string;
151
+ };
152
+ readonly subscriptions: readonly DiagnosticSubscription[];
153
+ readonly subscriptionsTruncated: boolean;
154
+ readonly lastRound?: DiagnosticLastRound;
155
+ readonly lastChange?: DiagnosticLastChange;
156
+ readonly storage: ClientDiagnosticsStorage;
157
+ }
158
+
159
+ export type ClientDiagnosticsListener = (
160
+ snapshot: ClientDiagnosticsSnapshot,
161
+ ) => void;
162
+
163
+ export class ClientDiagnosticsEmitter {
164
+ readonly #listeners = new Set<ClientDiagnosticsListener>();
165
+
166
+ on(listener: ClientDiagnosticsListener): () => void {
167
+ this.#listeners.add(listener);
168
+ return () => this.#listeners.delete(listener);
169
+ }
170
+
171
+ emit(snapshot: ClientDiagnosticsSnapshot): void {
172
+ for (const listener of this.#listeners) {
173
+ try {
174
+ listener(snapshot);
175
+ } catch {
176
+ // Diagnostics observers cannot alter sync correctness.
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Host wrappers replace topology facts without changing core evidence. */
183
+ export function withClientDiagnosticsHost(
184
+ snapshot: ClientDiagnosticsSnapshot,
185
+ host: ClientDiagnosticsHost,
186
+ ): ClientDiagnosticsSnapshot {
187
+ return { ...snapshot, host };
188
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export * from './client';
15
15
  export * from './content-type';
16
16
  export * from './database';
17
17
  export * from './devtools';
18
+ export * from './diagnostics';
18
19
  export * from './encryption';
19
20
  export * from './errors';
20
21
  export * from './http';
@@ -15,6 +15,10 @@
15
15
  */
16
16
  import { SyncClient } from './client';
17
17
  import type { ClientDatabase } from './database';
18
+ import {
19
+ type ClientDiagnosticsSnapshot,
20
+ withClientDiagnosticsHost,
21
+ } from './diagnostics';
18
22
  import { encryptionConfigFromKeyring } from './encryption';
19
23
  import { ClientSyncError } from './errors';
20
24
  import {
@@ -338,6 +342,22 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
338
342
  started.onChange((batch) => {
339
343
  if (!closed) post({ t: 'event', event: { kind: 'change', batch } });
340
344
  });
345
+ const postDiagnostics = (snapshot: ClientDiagnosticsSnapshot): void => {
346
+ if (closed) return;
347
+ post({
348
+ t: 'event',
349
+ event: {
350
+ kind: 'diagnostics',
351
+ snapshot: withClientDiagnosticsHost(snapshot, {
352
+ kind: 'worker',
353
+ role: 'leader',
354
+ connectivity: offline ? 'offline' : snapshot.host.connectivity,
355
+ realtime: snapshot.host.realtime,
356
+ }),
357
+ },
358
+ });
359
+ };
360
+ started.onDiagnostics(postDiagnostics);
341
361
  await started.start();
342
362
  realtimeConnector =
343
363
  overrides.createRealtime !== undefined
@@ -411,6 +431,15 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
411
431
  querySnapshot: (spec) => requireClient().querySnapshot(spec),
412
432
  localRevision: () => requireClient().localRevision,
413
433
  statusSnapshot: () => requireClient().statusSnapshot(),
434
+ diagnosticsSnapshot: (request) => {
435
+ const snapshot = requireClient().diagnosticsSnapshot(request);
436
+ return withClientDiagnosticsHost(snapshot, {
437
+ kind: 'worker',
438
+ role: 'leader',
439
+ connectivity: offline ? 'offline' : snapshot.host.connectivity,
440
+ realtime: snapshot.host.realtime,
441
+ });
442
+ },
414
443
  conflicts: () => requireClient().conflicts,
415
444
  rejections: () => requireClient().rejections,
416
445
  commitOutcome: (clientCommitId) =>
@@ -434,6 +463,21 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
434
463
  setOffline: (value) => {
435
464
  offline = value;
436
465
  if (offline) client?.disconnectRealtime();
466
+ else if (client !== undefined) {
467
+ const snapshot = client.diagnosticsSnapshot();
468
+ post({
469
+ t: 'event',
470
+ event: {
471
+ kind: 'diagnostics',
472
+ snapshot: withClientDiagnosticsHost(snapshot, {
473
+ kind: 'worker',
474
+ role: 'leader',
475
+ connectivity: snapshot.host.connectivity,
476
+ realtime: snapshot.host.realtime,
477
+ }),
478
+ },
479
+ });
480
+ }
437
481
  },
438
482
  close: async () => {
439
483
  closed = true;
@@ -40,6 +40,13 @@ import type {
40
40
  } from './client';
41
41
  import type { SqlRow, SqlValue } from './database';
42
42
  import { registerDevtools } from './devtools';
43
+ import {
44
+ ClientDiagnosticsEmitter,
45
+ type ClientDiagnosticsListener,
46
+ type ClientDiagnosticsRequest,
47
+ type ClientDiagnosticsSnapshot,
48
+ withClientDiagnosticsHost,
49
+ } from './diagnostics';
43
50
  import type { EncryptionKeyringConfig } from './encryption';
44
51
  import { ClientSyncError } from './errors';
45
52
  import {
@@ -180,6 +187,8 @@ export interface SyncClientHandleConfig {
180
187
  readonly onUpgrading?: (upgrading: boolean) => void;
181
188
  /** §8.6: presence on a scope key changed. */
182
189
  readonly onPresence?: (scopeKey: string) => void;
190
+ /** Atomic privacy-safe diagnostics changed. */
191
+ readonly onDiagnostics?: (snapshot: ClientDiagnosticsSnapshot) => void;
183
192
  }
184
193
 
185
194
  interface Pending {
@@ -247,6 +256,7 @@ export class SyncClientHandle {
247
256
  readonly #invalidation: InvalidationEmitter;
248
257
  readonly #changes: ChangeEmitter;
249
258
  readonly #presence: Set<(scopeKey: string) => void>;
259
+ readonly #diagnostics: ClientDiagnosticsEmitter;
250
260
  readonly #roleListeners: Set<(role: HandleRole) => void>;
251
261
  readonly #leadershipListeners: Set<(state: LeadershipState) => void>;
252
262
  readonly #devtoolsUnregister: () => void;
@@ -262,6 +272,7 @@ export class SyncClientHandle {
262
272
  invalidation: InvalidationEmitter;
263
273
  changes: ChangeEmitter;
264
274
  presence: Set<(scopeKey: string) => void>;
275
+ diagnostics: ClientDiagnosticsEmitter;
265
276
  roleListeners?: Set<(role: HandleRole) => void>;
266
277
  leadershipListeners?: Set<(state: LeadershipState) => void>;
267
278
  leadership?: LeadershipState;
@@ -284,6 +295,7 @@ export class SyncClientHandle {
284
295
  this.#invalidation = internals.invalidation;
285
296
  this.#changes = internals.changes;
286
297
  this.#presence = internals.presence;
298
+ this.#diagnostics = internals.diagnostics;
287
299
  this.#roleListeners = internals.roleListeners ?? new Set();
288
300
  this.#leadershipListeners = internals.leadershipListeners ?? new Set();
289
301
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
@@ -346,6 +358,14 @@ export class SyncClientHandle {
346
358
  this.#changes.emit(event.batch);
347
359
  const legacy = invalidationFromChange(event.batch);
348
360
  if (legacy !== undefined) this.#invalidation.emit(legacy);
361
+ } else if (event.kind === 'diagnostics') {
362
+ this.#diagnostics.emit(
363
+ withClientDiagnosticsHost(event.snapshot, {
364
+ ...event.snapshot.host,
365
+ kind: 'worker',
366
+ role: this.#role,
367
+ }),
368
+ );
349
369
  }
350
370
  }
351
371
 
@@ -363,6 +383,10 @@ export class SyncClientHandle {
363
383
  return this.#changes.on(listener);
364
384
  }
365
385
 
386
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void {
387
+ return this.#diagnostics.on(listener);
388
+ }
389
+
366
390
  /**
367
391
  * §8.6: subscribe to presence changes — the identical surface as
368
392
  * `SyncClient.onPresence`. Returns an unsubscribe function.
@@ -495,6 +519,17 @@ export class SyncClientHandle {
495
519
  return this.#call('statusSnapshot', []);
496
520
  }
497
521
 
522
+ async diagnosticsSnapshot(
523
+ request: ClientDiagnosticsRequest = {},
524
+ ): Promise<ClientDiagnosticsSnapshot> {
525
+ const snapshot = await this.#call('diagnosticsSnapshot', [request]);
526
+ return withClientDiagnosticsHost(snapshot, {
527
+ ...snapshot.host,
528
+ kind: 'worker',
529
+ role: this.#role,
530
+ });
531
+ }
532
+
498
533
  conflicts(): Promise<readonly ConflictRecord[]> {
499
534
  return this.#call('conflicts', []);
500
535
  }
@@ -767,6 +802,8 @@ function fireConfigCallbacks(
767
802
  ...(event.summary !== undefined ? { summary: event.summary } : {}),
768
803
  ...(event.error !== undefined ? { error: event.error } : {}),
769
804
  });
805
+ } else if (event.kind === 'diagnostics') {
806
+ config.onDiagnostics?.(event.snapshot);
770
807
  }
771
808
  }
772
809
 
@@ -786,6 +823,7 @@ export async function createSyncClientHandle(
786
823
  const invalidation = new InvalidationEmitter();
787
824
  const changes = new ChangeEmitter();
788
825
  const presence = new Set<(scopeKey: string) => void>();
826
+ const diagnostics = new ClientDiagnosticsEmitter();
789
827
  const roleListeners = new Set<(role: HandleRole) => void>();
790
828
  if (resolvedConfig.onRoleChange !== undefined)
791
829
  roleListeners.add(resolvedConfig.onRoleChange);
@@ -811,6 +849,7 @@ export async function createSyncClientHandle(
811
849
  invalidation,
812
850
  changes,
813
851
  presence,
852
+ diagnostics,
814
853
  roleListeners,
815
854
  leadershipListeners,
816
855
  });
@@ -826,6 +865,7 @@ export async function createSyncClientHandle(
826
865
  invalidation,
827
866
  changes,
828
867
  presence,
868
+ diagnostics,
829
869
  roleListeners,
830
870
  leadershipListeners,
831
871
  });
@@ -836,6 +876,7 @@ export async function createSyncClientHandle(
836
876
  invalidation,
837
877
  changes,
838
878
  presence,
879
+ diagnostics,
839
880
  roleListeners,
840
881
  leadershipListeners,
841
882
  });
@@ -846,6 +887,7 @@ interface HandleParts {
846
887
  invalidation: InvalidationEmitter;
847
888
  changes: ChangeEmitter;
848
889
  presence: Set<(scopeKey: string) => void>;
890
+ diagnostics: ClientDiagnosticsEmitter;
849
891
  roleListeners: Set<(role: HandleRole) => void>;
850
892
  leadershipListeners: Set<(state: LeadershipState) => void>;
851
893
  }
@@ -904,6 +946,7 @@ async function bootLeader(
904
946
  invalidation: parts.invalidation,
905
947
  changes: parts.changes,
906
948
  presence: parts.presence,
949
+ diagnostics: parts.diagnostics,
907
950
  roleListeners: parts.roleListeners,
908
951
  leadershipListeners: parts.leadershipListeners,
909
952
  });
@@ -1009,6 +1052,7 @@ async function bootFollower(
1009
1052
  invalidation: parts.invalidation,
1010
1053
  changes: parts.changes,
1011
1054
  presence: parts.presence,
1055
+ diagnostics: parts.diagnostics,
1012
1056
  roleListeners: parts.roleListeners,
1013
1057
  leadershipListeners: parts.leadershipListeners,
1014
1058
  });
@@ -34,6 +34,10 @@ import type {
34
34
  WindowState,
35
35
  } from './client';
36
36
  import type { SqlRow, SqlValue } from './database';
37
+ import type {
38
+ ClientDiagnosticsRequest,
39
+ ClientDiagnosticsSnapshot,
40
+ } from './diagnostics';
37
41
  import type { EncryptionKeyringConfig } from './encryption';
38
42
  import type {
39
43
  ClientChangeBatch,
@@ -153,6 +157,9 @@ export interface WorkerApi {
153
157
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
154
158
  localRevision(): LocalRevision;
155
159
  statusSnapshot(): SyncStatusSnapshot;
160
+ diagnosticsSnapshot(
161
+ request?: ClientDiagnosticsRequest,
162
+ ): ClientDiagnosticsSnapshot;
156
163
  conflicts(): readonly ConflictRecord[];
157
164
  rejections(): readonly RejectionRecord[];
158
165
  commitOutcome(clientCommitId: string): CommitOutcome | undefined;
@@ -240,6 +247,11 @@ export type SyncWorkerEvent =
240
247
  /** Exact revisioned core transaction; Sets and bigint clone directly. */
241
248
  readonly kind: 'change';
242
249
  readonly batch: ClientChangeBatch;
250
+ }
251
+ | {
252
+ /** Atomic privacy-safe health/support evidence from the worker core. */
253
+ readonly kind: 'diagnostics';
254
+ readonly snapshot: ClientDiagnosticsSnapshot;
243
255
  };
244
256
 
245
257
  export type WorkerToMainMessage =