@syncular/client 0.15.16 → 0.15.18

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,134 @@
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.js';
9
+ export declare const CLIENT_DIAGNOSTICS_VERSION: 1;
10
+ export declare const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
11
+ export declare const MAX_DIAGNOSTIC_DOMAINS = 256;
12
+ export type ClientDiagnosticsHostKind = 'direct' | 'worker' | 'tauri' | 'react-native';
13
+ export type ClientDiagnosticsHostRole = 'single' | 'leader' | 'follower' | 'unknown';
14
+ export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
15
+ export type ClientDiagnosticsRealtime = 'connected' | 'disconnected' | 'unsupported' | 'unknown';
16
+ export interface ClientDiagnosticsHost {
17
+ readonly kind: ClientDiagnosticsHostKind;
18
+ readonly role: ClientDiagnosticsHostRole;
19
+ readonly connectivity: ClientDiagnosticsConnectivity;
20
+ readonly realtime: ClientDiagnosticsRealtime;
21
+ }
22
+ export interface ExpectedDiagnosticSubscription {
23
+ /** Application-owned stable identifier. It must never contain PHI. */
24
+ readonly id: string;
25
+ /** Generated schema table name. */
26
+ readonly table: string;
27
+ }
28
+ export interface ClientDiagnosticsRequest {
29
+ /**
30
+ * Optional bounded intent list. Missing registrations are returned as
31
+ * `unregistered`, allowing a zero-row security scope to fail closed without
32
+ * reading private Syncular tables. Values/scopes are intentionally absent.
33
+ */
34
+ readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
35
+ }
36
+ export type DiagnosticSubscriptionState = 'unregistered' | 'bootstrapping' | 'complete' | 'reset' | 'revoked' | 'failed';
37
+ export interface DiagnosticSubscription {
38
+ readonly id: string;
39
+ readonly table: string;
40
+ readonly state: DiagnosticSubscriptionState;
41
+ readonly complete: boolean;
42
+ /** Last fully applied local commit sequence; absent when unregistered. */
43
+ readonly cursor?: number;
44
+ readonly reasonCode?: string;
45
+ }
46
+ export interface DiagnosticRoundCounters {
47
+ readonly pushed: number;
48
+ readonly applied: number;
49
+ readonly rejected: number;
50
+ readonly retryable: number;
51
+ readonly conflicts: number;
52
+ readonly commitsApplied: number;
53
+ readonly segmentRowsApplied: number;
54
+ readonly bootstrapping: number;
55
+ readonly resets: number;
56
+ readonly revoked: number;
57
+ readonly failed: number;
58
+ readonly deferredCommits: number;
59
+ }
60
+ export type DiagnosticLastRound = {
61
+ readonly status: 'succeeded';
62
+ readonly startedAtMs: number;
63
+ readonly completedAtMs: number;
64
+ readonly durationMs: number;
65
+ readonly counters: DiagnosticRoundCounters;
66
+ } | {
67
+ readonly status: 'failed';
68
+ readonly startedAtMs: number;
69
+ readonly completedAtMs: number;
70
+ readonly durationMs: number;
71
+ /** Stable code only; never arbitrary transport/server prose. */
72
+ readonly errorCode: string;
73
+ };
74
+ export interface DiagnosticLastChange {
75
+ /** Decimal u64 for JSON/IPC parity. */
76
+ readonly revision: string;
77
+ readonly recordedAtMs: number;
78
+ /** Generated table names only; no scope keys or row ids. */
79
+ readonly tables: readonly string[];
80
+ /** Generated table names for changed window registrations/completeness. */
81
+ readonly windows: readonly string[];
82
+ readonly domainsTruncated: boolean;
83
+ readonly statusChanged: boolean;
84
+ readonly conflictsChanged: boolean;
85
+ readonly rejectionsChanged: boolean;
86
+ readonly outcomesChanged: boolean;
87
+ }
88
+ export interface ClientDiagnosticsStorage {
89
+ readonly status: 'healthy' | 'pressure' | 'unreadable';
90
+ /** SQLite page estimate. No path, filename, or per-domain row counts. */
91
+ readonly databaseBytesApprox?: number;
92
+ readonly pendingOutboxBytesApprox?: number;
93
+ readonly retainedOutcomeBytesApprox?: number;
94
+ readonly retainedOutcomeEntries?: number;
95
+ readonly blobCacheBytesApprox?: number;
96
+ readonly pressureReasonCode?: 'client.blob_cache_over_limit';
97
+ }
98
+ export interface ClientDiagnosticsSnapshot {
99
+ readonly version: typeof CLIENT_DIAGNOSTICS_VERSION;
100
+ readonly capturedAtMs: number;
101
+ readonly host: ClientDiagnosticsHost;
102
+ readonly securityLifecycle: SecurityLifecycle;
103
+ readonly schema: {
104
+ readonly currentVersion: number;
105
+ readonly upgrading: boolean;
106
+ readonly requiredVersion?: number;
107
+ readonly latestVersion?: number;
108
+ };
109
+ readonly replica: {
110
+ /** Decimal u64 for JSON/IPC parity. */
111
+ readonly localRevision: string;
112
+ readonly syncNeeded: boolean;
113
+ readonly pendingOutbox: number;
114
+ };
115
+ readonly lease: {
116
+ readonly state: 'none' | 'active' | 'expired' | 'stopped';
117
+ readonly expiresAtMs?: number;
118
+ readonly errorCode?: string;
119
+ };
120
+ readonly subscriptions: readonly DiagnosticSubscription[];
121
+ readonly subscriptionsTruncated: boolean;
122
+ readonly lastRound?: DiagnosticLastRound;
123
+ readonly lastChange?: DiagnosticLastChange;
124
+ readonly storage: ClientDiagnosticsStorage;
125
+ }
126
+ export type ClientDiagnosticsListener = (snapshot: ClientDiagnosticsSnapshot) => void;
127
+ export declare class ClientDiagnosticsEmitter {
128
+ #private;
129
+ get observed(): boolean;
130
+ on(listener: ClientDiagnosticsListener): () => void;
131
+ emit(snapshot: ClientDiagnosticsSnapshot): void;
132
+ }
133
+ /** Host wrappers replace topology facts without changing core evidence. */
134
+ export declare function withClientDiagnosticsHost(snapshot: ClientDiagnosticsSnapshot, host: ClientDiagnosticsHost): ClientDiagnosticsSnapshot;
@@ -0,0 +1,27 @@
1
+ export const CLIENT_DIAGNOSTICS_VERSION = 1;
2
+ export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
3
+ export const MAX_DIAGNOSTIC_DOMAINS = 256;
4
+ export class ClientDiagnosticsEmitter {
5
+ #listeners = new Set();
6
+ get observed() {
7
+ return this.#listeners.size > 0;
8
+ }
9
+ on(listener) {
10
+ this.#listeners.add(listener);
11
+ return () => this.#listeners.delete(listener);
12
+ }
13
+ emit(snapshot) {
14
+ for (const listener of this.#listeners) {
15
+ try {
16
+ listener(snapshot);
17
+ }
18
+ catch {
19
+ // Diagnostics observers cannot alter sync correctness.
20
+ }
21
+ }
22
+ }
23
+ }
24
+ /** Host wrappers replace topology facts without changing core evidence. */
25
+ export function withClientDiagnosticsHost(snapshot, host) {
26
+ return { ...snapshot, host };
27
+ }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from './client.js';
15
15
  export * from './content-type.js';
16
16
  export * from './database.js';
17
17
  export * from './devtools.js';
18
+ export * from './diagnostics.js';
18
19
  export * from './encryption.js';
19
20
  export * from './errors.js';
20
21
  export * from './http.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * from './client.js';
15
15
  export * from './content-type.js';
16
16
  export * from './database.js';
17
17
  export * from './devtools.js';
18
+ export * from './diagnostics.js';
18
19
  export * from './encryption.js';
19
20
  export * from './errors.js';
20
21
  export * from './http.js';
@@ -14,6 +14,7 @@
14
14
  * the core owns exactly one loop.
15
15
  */
16
16
  import { SyncClient } from './client.js';
17
+ import { withClientDiagnosticsHost, } from './diagnostics.js';
17
18
  import { encryptionConfigFromKeyring } from './encryption.js';
18
19
  import { ClientSyncError } from './errors.js';
19
20
  import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
@@ -251,6 +252,23 @@ export function startSyncWorker(overrides = {}) {
251
252
  if (!closed)
252
253
  post({ t: 'event', event: { kind: 'change', batch } });
253
254
  });
255
+ const postDiagnostics = (snapshot) => {
256
+ if (closed)
257
+ return;
258
+ post({
259
+ t: 'event',
260
+ event: {
261
+ kind: 'diagnostics',
262
+ snapshot: withClientDiagnosticsHost(snapshot, {
263
+ kind: 'worker',
264
+ role: 'leader',
265
+ connectivity: offline ? 'offline' : snapshot.host.connectivity,
266
+ realtime: snapshot.host.realtime,
267
+ }),
268
+ },
269
+ });
270
+ };
271
+ started.onDiagnostics(postDiagnostics);
254
272
  await started.start();
255
273
  realtimeConnector =
256
274
  overrides.createRealtime !== undefined
@@ -315,6 +333,15 @@ export function startSyncWorker(overrides = {}) {
315
333
  querySnapshot: (spec) => requireClient().querySnapshot(spec),
316
334
  localRevision: () => requireClient().localRevision,
317
335
  statusSnapshot: () => requireClient().statusSnapshot(),
336
+ diagnosticsSnapshot: (request) => {
337
+ const snapshot = requireClient().diagnosticsSnapshot(request);
338
+ return withClientDiagnosticsHost(snapshot, {
339
+ kind: 'worker',
340
+ role: 'leader',
341
+ connectivity: offline ? 'offline' : snapshot.host.connectivity,
342
+ realtime: snapshot.host.realtime,
343
+ });
344
+ },
318
345
  conflicts: () => requireClient().conflicts,
319
346
  rejections: () => requireClient().rejections,
320
347
  commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
@@ -337,6 +364,21 @@ export function startSyncWorker(overrides = {}) {
337
364
  offline = value;
338
365
  if (offline)
339
366
  client?.disconnectRealtime();
367
+ else if (client !== undefined) {
368
+ const snapshot = client.diagnosticsSnapshot();
369
+ post({
370
+ t: 'event',
371
+ event: {
372
+ kind: 'diagnostics',
373
+ snapshot: withClientDiagnosticsHost(snapshot, {
374
+ kind: 'worker',
375
+ role: 'leader',
376
+ connectivity: snapshot.host.connectivity,
377
+ realtime: snapshot.host.realtime,
378
+ }),
379
+ },
380
+ });
381
+ }
340
382
  },
341
383
  close: async () => {
342
384
  closed = true;
@@ -25,6 +25,7 @@ import type { WakeReason } from '@syncular/core';
25
25
  import type { BlobRef, CachedBlob } from './blob.js';
26
26
  import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
27
27
  import type { SqlRow, SqlValue } from './database.js';
28
+ import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
28
29
  import type { EncryptionKeyringConfig } from './encryption.js';
29
30
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
30
31
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
@@ -107,6 +108,8 @@ export interface SyncClientHandleConfig {
107
108
  readonly onUpgrading?: (upgrading: boolean) => void;
108
109
  /** §8.6: presence on a scope key changed. */
109
110
  readonly onPresence?: (scopeKey: string) => void;
111
+ /** Atomic privacy-safe diagnostics changed. */
112
+ readonly onDiagnostics?: (snapshot: ClientDiagnosticsSnapshot) => void;
110
113
  }
111
114
  /**
112
115
  * A running worker core owned by THIS tab (the leader). Wraps the worker,
@@ -145,6 +148,7 @@ export declare class SyncClientHandle {
145
148
  invalidation: InvalidationEmitter;
146
149
  changes: ChangeEmitter;
147
150
  presence: Set<(scopeKey: string) => void>;
151
+ diagnostics: ClientDiagnosticsEmitter;
148
152
  roleListeners?: Set<(role: HandleRole) => void>;
149
153
  leadershipListeners?: Set<(state: LeadershipState) => void>;
150
154
  leadership?: LeadershipState;
@@ -163,6 +167,7 @@ export declare class SyncClientHandle {
163
167
  */
164
168
  onInvalidate(listener: InvalidationListener): () => void;
165
169
  onChange(listener: ClientChangeListener): () => void;
170
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
166
171
  /**
167
172
  * §8.6: subscribe to presence changes — the identical surface as
168
173
  * `SyncClient.onPresence`. Returns an unsubscribe function.
@@ -190,6 +195,7 @@ export declare class SyncClientHandle {
190
195
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
191
196
  localRevision(): Promise<LocalRevision>;
192
197
  statusSnapshot(): Promise<SyncStatusSnapshot>;
198
+ diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
193
199
  conflicts(): Promise<readonly ConflictRecord[]>;
194
200
  rejections(): Promise<readonly RejectionRecord[]>;
195
201
  commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
@@ -1,4 +1,5 @@
1
1
  import { registerDevtools } from './devtools.js';
2
+ import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
2
3
  import { ClientSyncError } from './errors.js';
3
4
  import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
4
5
  import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
@@ -60,6 +61,7 @@ export class SyncClientHandle {
60
61
  #invalidation;
61
62
  #changes;
62
63
  #presence;
64
+ #diagnostics;
63
65
  #roleListeners;
64
66
  #leadershipListeners;
65
67
  #devtoolsUnregister;
@@ -84,6 +86,7 @@ export class SyncClientHandle {
84
86
  this.#invalidation = internals.invalidation;
85
87
  this.#changes = internals.changes;
86
88
  this.#presence = internals.presence;
89
+ this.#diagnostics = internals.diagnostics;
87
90
  this.#roleListeners = internals.roleListeners ?? new Set();
88
91
  this.#leadershipListeners = internals.leadershipListeners ?? new Set();
89
92
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
@@ -150,6 +153,13 @@ export class SyncClientHandle {
150
153
  if (legacy !== undefined)
151
154
  this.#invalidation.emit(legacy);
152
155
  }
156
+ else if (event.kind === 'diagnostics') {
157
+ this.#diagnostics.emit(withClientDiagnosticsHost(event.snapshot, {
158
+ ...event.snapshot.host,
159
+ kind: 'worker',
160
+ role: this.#role,
161
+ }));
162
+ }
153
163
  }
154
164
  /**
155
165
  * TODO 3.1 / I1: subscribe to fine-grained invalidation — the identical
@@ -163,6 +173,9 @@ export class SyncClientHandle {
163
173
  onChange(listener) {
164
174
  return this.#changes.on(listener);
165
175
  }
176
+ onDiagnostics(listener) {
177
+ return this.#diagnostics.on(listener);
178
+ }
166
179
  /**
167
180
  * §8.6: subscribe to presence changes — the identical surface as
168
181
  * `SyncClient.onPresence`. Returns an unsubscribe function.
@@ -252,6 +265,14 @@ export class SyncClientHandle {
252
265
  statusSnapshot() {
253
266
  return this.#call('statusSnapshot', []);
254
267
  }
268
+ async diagnosticsSnapshot(request = {}) {
269
+ const snapshot = await this.#call('diagnosticsSnapshot', [request]);
270
+ return withClientDiagnosticsHost(snapshot, {
271
+ ...snapshot.host,
272
+ kind: 'worker',
273
+ role: this.#role,
274
+ });
275
+ }
255
276
  conflicts() {
256
277
  return this.#call('conflicts', []);
257
278
  }
@@ -470,6 +491,9 @@ function fireConfigCallbacks(config, event) {
470
491
  ...(event.error !== undefined ? { error: event.error } : {}),
471
492
  });
472
493
  }
494
+ else if (event.kind === 'diagnostics') {
495
+ config.onDiagnostics?.(event.snapshot);
496
+ }
473
497
  }
474
498
  /**
475
499
  * Acquire leadership, spawn the worker, initialize the core inside it.
@@ -485,6 +509,7 @@ export async function createSyncClientHandle(config) {
485
509
  const invalidation = new InvalidationEmitter();
486
510
  const changes = new ChangeEmitter();
487
511
  const presence = new Set();
512
+ const diagnostics = new ClientDiagnosticsEmitter();
488
513
  const roleListeners = new Set();
489
514
  if (resolvedConfig.onRoleChange !== undefined)
490
515
  roleListeners.add(resolvedConfig.onRoleChange);
@@ -507,6 +532,7 @@ export async function createSyncClientHandle(config) {
507
532
  invalidation,
508
533
  changes,
509
534
  presence,
535
+ diagnostics,
510
536
  roleListeners,
511
537
  leadershipListeners,
512
538
  });
@@ -521,6 +547,7 @@ export async function createSyncClientHandle(config) {
521
547
  invalidation,
522
548
  changes,
523
549
  presence,
550
+ diagnostics,
524
551
  roleListeners,
525
552
  leadershipListeners,
526
553
  });
@@ -530,6 +557,7 @@ export async function createSyncClientHandle(config) {
530
557
  invalidation,
531
558
  changes,
532
559
  presence,
560
+ diagnostics,
533
561
  roleListeners,
534
562
  leadershipListeners,
535
563
  });
@@ -571,6 +599,7 @@ async function bootLeader(config, lockName, lease, parts) {
571
599
  invalidation: parts.invalidation,
572
600
  changes: parts.changes,
573
601
  presence: parts.presence,
602
+ diagnostics: parts.diagnostics,
574
603
  roleListeners: parts.roleListeners,
575
604
  leadershipListeners: parts.leadershipListeners,
576
605
  });
@@ -659,6 +688,7 @@ async function bootFollower(config, lockName, lock, parts) {
659
688
  invalidation: parts.invalidation,
660
689
  changes: parts.changes,
661
690
  presence: parts.presence,
691
+ diagnostics: parts.diagnostics,
662
692
  roleListeners: parts.roleListeners,
663
693
  leadershipListeners: parts.leadershipListeners,
664
694
  });
@@ -20,6 +20,7 @@ import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob.js';
21
21
  import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
+ import type { ClientDiagnosticsRequest, ClientDiagnosticsSnapshot } from './diagnostics.js';
23
24
  import type { EncryptionKeyringConfig } from './encryption.js';
24
25
  import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
25
26
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
@@ -107,6 +108,7 @@ export interface WorkerApi {
107
108
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
108
109
  localRevision(): LocalRevision;
109
110
  statusSnapshot(): SyncStatusSnapshot;
111
+ diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot;
110
112
  conflicts(): readonly ConflictRecord[];
111
113
  rejections(): readonly RejectionRecord[];
112
114
  commitOutcome(clientCommitId: string): CommitOutcome | undefined;
@@ -180,6 +182,10 @@ export type SyncWorkerEvent = {
180
182
  /** Exact revisioned core transaction; Sets and bigint clone directly. */
181
183
  readonly kind: 'change';
182
184
  readonly batch: ClientChangeBatch;
185
+ } | {
186
+ /** Atomic privacy-safe health/support evidence from the worker core. */
187
+ readonly kind: 'diagnostics';
188
+ readonly snapshot: ClientDiagnosticsSnapshot;
183
189
  };
184
190
  export type WorkerToMainMessage = {
185
191
  readonly t: 'ready';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.16",
3
+ "version": "0.15.18",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.16"
84
+ "@syncular/core": "0.15.18"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.16",
95
+ "@syncular/server": "0.15.18",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }