@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.
package/README.md CHANGED
@@ -179,6 +179,35 @@ The state is a discriminated union covering startup, migration,
179
179
  children; a blocked live query has `phase === 'blocked'`, never an indefinite
180
180
  loading state.
181
181
 
182
+ ## Privacy-safe support diagnostics
183
+
184
+ Every direct and Worker/multi-tab client exposes the same versioned snapshot:
185
+
186
+ ```ts
187
+ const snapshot = await client.diagnosticsSnapshot({
188
+ expectedSubscriptions: [
189
+ { id: 'membership-security', table: 'facility_memberships' },
190
+ { id: 'scheduler-window', table: 'surgeries' },
191
+ ],
192
+ });
193
+
194
+ const off = client.onDiagnostics(() => refreshSupportView());
195
+ ```
196
+
197
+ `expectedSubscriptions` contains application intent only—stable PHI-free ids
198
+ and generated table names, never scopes. It lets a support screen distinguish
199
+ an absent registration from a legitimate zero-row completed bootstrap. The
200
+ snapshot also distinguishes reset, revocation, failure, schema floor, lease
201
+ stop, pending outbox, offline transport, and storage pressure/unreadability.
202
+ Worker leaders and followers return identical evidence with their honest role.
203
+
204
+ The contract intentionally excludes scope values, rows and clinical row
205
+ counts, SQL, paths, client/actor/lease ids, auth, keys, mutation bodies, stack
206
+ traces, and arbitrary prose. It is safe to copy the JSON snapshot into a
207
+ redacted support ticket as long as the application also keeps subscription ids
208
+ free of patient/user data. Do not attach database files, console dumps, query
209
+ results, or app state alongside it. See SPEC §7.6.
210
+
182
211
  ## Durable commit outcomes
183
212
 
184
213
  `SyncClient` and every host bridge expose `commitOutcome(id)`,
package/dist/client.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { type ScopeMap, type WakeReason } from '@syncular/core';
11
11
  import { type BlobRef, type BlobTransport, type CachedBlob } from './blob.js';
12
12
  import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
13
+ import { type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
13
14
  import type { EncryptionConfig } from './encryption.js';
14
15
  import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
15
16
  import { type LeaderLock } from './leader-lock.js';
@@ -283,6 +284,13 @@ export declare class SyncClient {
283
284
  onInvalidate(listener: InvalidationListener): () => void;
284
285
  /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
285
286
  onChange(listener: ClientChangeListener): () => void;
287
+ /** Subscribe to complete, privacy-safe diagnostic snapshots. */
288
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
289
+ /**
290
+ * One atomic support/product-health view. It never returns scope values,
291
+ * rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
292
+ */
293
+ diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot;
286
294
  /** One call for the complete status domain used by reactive hosts. */
287
295
  statusSnapshot(): SyncStatusSnapshot;
288
296
  /**
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@ import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encode
11
11
  import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
12
12
  import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
13
13
  import { registerDevtools } from './devtools.js';
14
+ import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, } from './diagnostics.js';
14
15
  import { ClientSyncError } from './errors.js';
15
16
  import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
16
17
  import { singleOwnerLock, } from './leader-lock.js';
@@ -110,6 +111,9 @@ export class SyncClient {
110
111
  #invalidation = new InvalidationEmitter();
111
112
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
112
113
  #presenceListeners = new Set();
114
+ #diagnostics = new ClientDiagnosticsEmitter();
115
+ #lastRound;
116
+ #lastChange;
113
117
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
114
118
  #batch;
115
119
  /**
@@ -224,6 +228,7 @@ export class SyncClient {
224
228
  upgrading: async () => this.upgrading,
225
229
  onInvalidate: (listener) => this.onInvalidate(listener),
226
230
  });
231
+ this.#emitDiagnostics();
227
232
  }
228
233
  /**
229
234
  * §7.4.1/§7.4.2: compare the generated schema version to the persisted
@@ -359,6 +364,7 @@ export class SyncClient {
359
364
  this.#config.onSyncNeeded?.('startup');
360
365
  this.#config.onSyncIntent?.({ kind: 'interactive' });
361
366
  }
367
+ this.#emitDiagnostics();
362
368
  }
363
369
  // -- accessors ------------------------------------------------------------
364
370
  get clientId() {
@@ -452,6 +458,182 @@ export class SyncClient {
452
458
  onChange(listener) {
453
459
  return this.#changes.on(listener);
454
460
  }
461
+ /** Subscribe to complete, privacy-safe diagnostic snapshots. */
462
+ onDiagnostics(listener) {
463
+ return this.#diagnostics.on(listener);
464
+ }
465
+ /**
466
+ * One atomic support/product-health view. It never returns scope values,
467
+ * rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
468
+ */
469
+ diagnosticsSnapshot(request = {}) {
470
+ this.#requireActive();
471
+ const expected = request.expectedSubscriptions ?? [];
472
+ if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
473
+ throw new ClientSyncError('sync.invalid_request', `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
474
+ }
475
+ const registered = loadSubscriptions(this.#db);
476
+ const subscriptions = new Map();
477
+ for (const sub of registered) {
478
+ const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
479
+ const complete = sub.status === 'active' &&
480
+ sub.cursor >= 0 &&
481
+ sub.bootstrapState === undefined;
482
+ subscriptions.set(sub.id, {
483
+ id: sub.id,
484
+ table: sub.table,
485
+ state: sub.status === 'revoked'
486
+ ? 'revoked'
487
+ : sub.status === 'failed'
488
+ ? 'failed'
489
+ : reset
490
+ ? 'reset'
491
+ : complete
492
+ ? 'complete'
493
+ : 'bootstrapping',
494
+ complete,
495
+ cursor: sub.cursor,
496
+ ...(sub.reasonCode !== undefined
497
+ ? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
498
+ : {}),
499
+ });
500
+ }
501
+ for (const item of expected) {
502
+ if (typeof item.id !== 'string' ||
503
+ item.id.length === 0 ||
504
+ typeof item.table !== 'string' ||
505
+ item.table.length === 0) {
506
+ throw new ClientSyncError('sync.invalid_request', 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
507
+ }
508
+ const registeredSubscription = subscriptions.get(item.id);
509
+ if (registeredSubscription !== undefined &&
510
+ registeredSubscription.table !== item.table) {
511
+ subscriptions.set(item.id, {
512
+ id: item.id,
513
+ table: item.table,
514
+ state: 'failed',
515
+ complete: false,
516
+ reasonCode: 'client.subscription_intent_mismatch',
517
+ });
518
+ }
519
+ else if (registeredSubscription === undefined) {
520
+ subscriptions.set(item.id, {
521
+ id: item.id,
522
+ table: item.table,
523
+ state: 'unregistered',
524
+ complete: false,
525
+ });
526
+ }
527
+ }
528
+ const capturedAtMs = this.#now();
529
+ const leaseState = this.#diagnosticLease(capturedAtMs);
530
+ const connectivity = this.#lastRound?.status === 'succeeded'
531
+ ? 'online'
532
+ : this.#lastRound?.status === 'failed' &&
533
+ this.#transportFailureCode(this.#lastRound.errorCode)
534
+ ? 'offline'
535
+ : 'unknown';
536
+ const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
537
+ const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
538
+ const aExpected = expectedOrder.get(a.id);
539
+ const bExpected = expectedOrder.get(b.id);
540
+ if (aExpected !== undefined || bExpected !== undefined) {
541
+ return ((aExpected ?? Number.MAX_SAFE_INTEGER) -
542
+ (bExpected ?? Number.MAX_SAFE_INTEGER));
543
+ }
544
+ return a.id.localeCompare(b.id);
545
+ });
546
+ return {
547
+ version: CLIENT_DIAGNOSTICS_VERSION,
548
+ capturedAtMs,
549
+ host: {
550
+ kind: 'direct',
551
+ role: 'single',
552
+ connectivity,
553
+ realtime: this.#config.realtime === undefined
554
+ ? 'unsupported'
555
+ : this.#socket === undefined
556
+ ? 'disconnected'
557
+ : 'connected',
558
+ },
559
+ securityLifecycle: this.#securityLifecycle,
560
+ schema: {
561
+ currentVersion: this.#config.schema.version,
562
+ upgrading: this.#upgrading,
563
+ ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
564
+ ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
565
+ : {}),
566
+ ...(this.#schemaFloor?.latestSchemaVersion !== undefined
567
+ ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
568
+ : {}),
569
+ },
570
+ replica: {
571
+ localRevision: getLocalRevision(this.#db).toString(),
572
+ syncNeeded: this.#needsPull,
573
+ pendingOutbox: listOutbox(this.#db).length,
574
+ },
575
+ lease: leaseState,
576
+ subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
577
+ subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
578
+ ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
579
+ ...(this.#lastChange !== undefined
580
+ ? { lastChange: this.#lastChange }
581
+ : {}),
582
+ storage: this.#diagnosticStorage(),
583
+ };
584
+ }
585
+ #diagnosticLease(nowMs) {
586
+ const lease = this.#leaseState;
587
+ if (lease?.errorCode !== undefined) {
588
+ return {
589
+ state: 'stopped',
590
+ errorCode: this.#diagnosticCode(lease.errorCode),
591
+ ...(lease.expiresAtMs !== undefined
592
+ ? { expiresAtMs: lease.expiresAtMs }
593
+ : {}),
594
+ };
595
+ }
596
+ if (lease?.expiresAtMs === undefined)
597
+ return { state: 'none' };
598
+ return {
599
+ state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
600
+ expiresAtMs: lease.expiresAtMs,
601
+ };
602
+ }
603
+ #diagnosticStorage() {
604
+ try {
605
+ const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
606
+ const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
607
+ const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
608
+ const outcome = this.#db.query(`SELECT COUNT(*) AS entries,
609
+ COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
610
+ FROM _syncular_commit_outcomes`)[0];
611
+ const blobBytes = this.#hasBlobs
612
+ ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
613
+ : 0;
614
+ const pressure = this.#config.blobCacheMaxBytes !== undefined &&
615
+ blobBytes > this.#config.blobCacheMaxBytes;
616
+ return {
617
+ status: pressure ? 'pressure' : 'healthy',
618
+ databaseBytesApprox: Math.max(0, pageCount * pageSize),
619
+ pendingOutboxBytesApprox: Math.max(0, outboxBytes),
620
+ retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
621
+ retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
622
+ blobCacheBytesApprox: Math.max(0, blobBytes),
623
+ ...(pressure
624
+ ? { pressureReasonCode: 'client.blob_cache_over_limit' }
625
+ : {}),
626
+ };
627
+ }
628
+ catch {
629
+ return { status: 'unreadable' };
630
+ }
631
+ }
632
+ #emitDiagnostics() {
633
+ if (!this.#started || this.#securityLifecycle !== 'active')
634
+ return;
635
+ this.#diagnostics.emit(this.diagnosticsSnapshot());
636
+ }
455
637
  /** One call for the complete status domain used by reactive hosts. */
456
638
  statusSnapshot() {
457
639
  this.#requireStarted();
@@ -502,10 +684,25 @@ export class SyncClient {
502
684
  }
503
685
  if (revision !== undefined) {
504
686
  const event = batch.finish(revision, status);
687
+ const tables = [...new Set(event.tables.map((entry) => entry.table))];
688
+ const windows = [...new Set(event.windows.map((entry) => entry.table))];
689
+ this.#lastChange = {
690
+ revision: revision.toString(),
691
+ recordedAtMs: this.#now(),
692
+ tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
693
+ windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
694
+ domainsTruncated: tables.length > MAX_DIAGNOSTIC_DOMAINS ||
695
+ windows.length > MAX_DIAGNOSTIC_DOMAINS,
696
+ statusChanged: event.status !== undefined,
697
+ conflictsChanged: event.conflictsChanged,
698
+ rejectionsChanged: event.rejectionsChanged,
699
+ outcomesChanged: event.outcomesChanged,
700
+ };
505
701
  this.#changes.emit(event);
506
702
  const legacy = invalidationFromChange(event);
507
703
  if (legacy !== undefined)
508
704
  this.#invalidation.emit(legacy);
705
+ this.#emitDiagnostics();
509
706
  }
510
707
  return result;
511
708
  }
@@ -870,6 +1067,7 @@ export class SyncClient {
870
1067
  scopes: input.scopes,
871
1068
  ...(input.params !== undefined ? { params: input.params } : {}),
872
1069
  });
1070
+ this.#emitDiagnostics();
873
1071
  return;
874
1072
  }
875
1073
  saveSubscription(this.#db, {
@@ -880,10 +1078,12 @@ export class SyncClient {
880
1078
  cursor: -1,
881
1079
  status: 'active',
882
1080
  });
1081
+ this.#emitDiagnostics();
883
1082
  }
884
1083
  unsubscribe(id) {
885
1084
  this.#requireActive();
886
1085
  deleteSubscription(this.#db, id);
1086
+ this.#emitDiagnostics();
887
1087
  }
888
1088
  // -- windowed subscriptions (§4.8) ------------------------------------------
889
1089
  /**
@@ -1403,10 +1603,65 @@ export class SyncClient {
1403
1603
  return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
1404
1604
  }
1405
1605
  this.#syncOutstanding = true;
1406
- return this.#serialize(() => this.#runSync()).finally(() => {
1606
+ const startedAtMs = this.#now();
1607
+ return this.#serialize(() => this.#runSync())
1608
+ .then((summary) => {
1609
+ const completedAtMs = this.#now();
1610
+ this.#lastRound = {
1611
+ status: 'succeeded',
1612
+ startedAtMs,
1613
+ completedAtMs,
1614
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
1615
+ counters: this.#diagnosticRoundCounters(summary),
1616
+ };
1617
+ this.#emitDiagnostics();
1618
+ return summary;
1619
+ }, (error) => {
1620
+ const completedAtMs = this.#now();
1621
+ const code = error.code;
1622
+ this.#lastRound = {
1623
+ status: 'failed',
1624
+ startedAtMs,
1625
+ completedAtMs,
1626
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
1627
+ errorCode: typeof code === 'string'
1628
+ ? this.#diagnosticCode(code)
1629
+ : 'client.unknown_failure',
1630
+ };
1631
+ this.#emitDiagnostics();
1632
+ throw error;
1633
+ })
1634
+ .finally(() => {
1407
1635
  this.#syncOutstanding = false;
1408
1636
  });
1409
1637
  }
1638
+ #diagnosticRoundCounters(summary) {
1639
+ return {
1640
+ pushed: summary.pushed,
1641
+ applied: summary.applied.length,
1642
+ rejected: summary.rejected.length,
1643
+ retryable: summary.retryable.length,
1644
+ conflicts: summary.conflicts.length,
1645
+ commitsApplied: summary.commitsApplied,
1646
+ segmentRowsApplied: summary.segmentRowsApplied,
1647
+ bootstrapping: summary.bootstrapping.length,
1648
+ resets: summary.resets.length,
1649
+ revoked: summary.revoked.length,
1650
+ failed: summary.failed.length,
1651
+ deferredCommits: summary.deferredCommits ?? 0,
1652
+ };
1653
+ }
1654
+ #transportFailureCode(code) {
1655
+ return (code === 'transport.failed' ||
1656
+ code === 'transport.unavailable' ||
1657
+ code === 'sync.transport_failed' ||
1658
+ code === 'client.worker_failed');
1659
+ }
1660
+ #diagnosticCode(code) {
1661
+ return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
1662
+ ? code
1663
+ : 'client.unknown_failure';
1664
+ }
1410
1665
  async #runSync() {
1411
1666
  if (this.#schemaFloor !== undefined) {
1412
1667
  return {
@@ -1547,8 +1802,17 @@ export class SyncClient {
1547
1802
  */
1548
1803
  #roundTrip(request) {
1549
1804
  const socket = this.#socket;
1550
- if (socket === undefined)
1551
- return this.#config.transport(request);
1805
+ if (socket === undefined) {
1806
+ return Promise.resolve()
1807
+ .then(() => this.#config.transport(request))
1808
+ .catch((error) => {
1809
+ if (error instanceof ClientSyncError ||
1810
+ typeof error?.code === 'string') {
1811
+ throw error;
1812
+ }
1813
+ throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
1814
+ });
1815
+ }
1552
1816
  return new Promise((resolve, reject) => {
1553
1817
  // sync() already enforces one round in flight (§8.7).
1554
1818
  this.#pendingRound = {
@@ -1592,6 +1856,7 @@ export class SyncClient {
1592
1856
  this.#socket = undefined;
1593
1857
  this.#presence.clear(); // §8.6.1: presence is per-connection
1594
1858
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1859
+ this.#emitDiagnostics();
1595
1860
  },
1596
1861
  });
1597
1862
  if (this.#securityLifecycle === 'preflight') {
@@ -1599,12 +1864,14 @@ export class SyncClient {
1599
1864
  throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
1600
1865
  }
1601
1866
  this.#socket = socket;
1867
+ this.#emitDiagnostics();
1602
1868
  }
1603
1869
  disconnectRealtime() {
1604
1870
  this.#socket?.close();
1605
1871
  this.#socket = undefined;
1606
1872
  this.#presence.clear(); // §8.6.1: presence is per-connection
1607
1873
  this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
1874
+ this.#emitDiagnostics();
1608
1875
  }
1609
1876
  /**
1610
1877
  * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
@@ -0,0 +1,133 @@
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
+ on(listener: ClientDiagnosticsListener): () => void;
130
+ emit(snapshot: ClientDiagnosticsSnapshot): void;
131
+ }
132
+ /** Host wrappers replace topology facts without changing core evidence. */
133
+ export declare function withClientDiagnosticsHost(snapshot: ClientDiagnosticsSnapshot, host: ClientDiagnosticsHost): ClientDiagnosticsSnapshot;
@@ -0,0 +1,24 @@
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
+ on(listener) {
7
+ this.#listeners.add(listener);
8
+ return () => this.#listeners.delete(listener);
9
+ }
10
+ emit(snapshot) {
11
+ for (const listener of this.#listeners) {
12
+ try {
13
+ listener(snapshot);
14
+ }
15
+ catch {
16
+ // Diagnostics observers cannot alter sync correctness.
17
+ }
18
+ }
19
+ }
20
+ }
21
+ /** Host wrappers replace topology facts without changing core evidence. */
22
+ export function withClientDiagnosticsHost(snapshot, host) {
23
+ return { ...snapshot, host };
24
+ }
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;