@syncular/client 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.
@@ -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.15",
3
+ "version": "0.15.17",
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.15"
84
+ "@syncular/core": "0.15.17"
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.15",
95
+ "@syncular/server": "0.15.17",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -60,6 +60,20 @@ import {
60
60
  } from './blob';
61
61
  import type { ClientDatabase, SqlRow, SqlValue } from './database';
62
62
  import { registerDevtools } from './devtools';
63
+ import {
64
+ CLIENT_DIAGNOSTICS_VERSION,
65
+ ClientDiagnosticsEmitter,
66
+ type ClientDiagnosticsListener,
67
+ type ClientDiagnosticsRequest,
68
+ type ClientDiagnosticsSnapshot,
69
+ type ClientDiagnosticsStorage,
70
+ type DiagnosticLastChange,
71
+ type DiagnosticLastRound,
72
+ type DiagnosticRoundCounters,
73
+ type DiagnosticSubscription,
74
+ MAX_DIAGNOSTIC_DOMAINS,
75
+ MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
76
+ } from './diagnostics';
63
77
  import type { EncryptionConfig } from './encryption';
64
78
  import { ClientSyncError } from './errors';
65
79
  import {
@@ -531,6 +545,9 @@ export class SyncClient {
531
545
  readonly #invalidation = new InvalidationEmitter();
532
546
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
533
547
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
548
+ readonly #diagnostics = new ClientDiagnosticsEmitter();
549
+ #lastRound: DiagnosticLastRound | undefined;
550
+ #lastChange: DiagnosticLastChange | undefined;
534
551
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
535
552
  #batch: ChangeAccumulator | undefined;
536
553
  /**
@@ -669,6 +686,7 @@ export class SyncClient {
669
686
  upgrading: async () => this.upgrading,
670
687
  onInvalidate: (listener) => this.onInvalidate(listener),
671
688
  });
689
+ this.#emitDiagnostics();
672
690
  }
673
691
 
674
692
  /**
@@ -819,6 +837,7 @@ export class SyncClient {
819
837
  this.#config.onSyncNeeded?.('startup');
820
838
  this.#config.onSyncIntent?.({ kind: 'interactive' });
821
839
  }
840
+ this.#emitDiagnostics();
822
841
  }
823
842
 
824
843
  // -- accessors ------------------------------------------------------------
@@ -928,6 +947,222 @@ export class SyncClient {
928
947
  return this.#changes.on(listener);
929
948
  }
930
949
 
950
+ /** Subscribe to complete, privacy-safe diagnostic snapshots. */
951
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void {
952
+ return this.#diagnostics.on(listener);
953
+ }
954
+
955
+ /**
956
+ * One atomic support/product-health view. It never returns scope values,
957
+ * rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
958
+ */
959
+ diagnosticsSnapshot(
960
+ request: ClientDiagnosticsRequest = {},
961
+ ): ClientDiagnosticsSnapshot {
962
+ this.#requireActive();
963
+ const expected = request.expectedSubscriptions ?? [];
964
+ if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
965
+ throw new ClientSyncError(
966
+ 'sync.invalid_request',
967
+ `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`,
968
+ );
969
+ }
970
+ const registered = loadSubscriptions(this.#db);
971
+ const subscriptions = new Map<string, DiagnosticSubscription>();
972
+ for (const sub of registered) {
973
+ const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
974
+ const complete =
975
+ sub.status === 'active' &&
976
+ sub.cursor >= 0 &&
977
+ sub.bootstrapState === undefined;
978
+ subscriptions.set(sub.id, {
979
+ id: sub.id,
980
+ table: sub.table,
981
+ state:
982
+ sub.status === 'revoked'
983
+ ? 'revoked'
984
+ : sub.status === 'failed'
985
+ ? 'failed'
986
+ : reset
987
+ ? 'reset'
988
+ : complete
989
+ ? 'complete'
990
+ : 'bootstrapping',
991
+ complete,
992
+ cursor: sub.cursor,
993
+ ...(sub.reasonCode !== undefined
994
+ ? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
995
+ : {}),
996
+ });
997
+ }
998
+ for (const item of expected) {
999
+ if (
1000
+ typeof item.id !== 'string' ||
1001
+ item.id.length === 0 ||
1002
+ typeof item.table !== 'string' ||
1003
+ item.table.length === 0
1004
+ ) {
1005
+ throw new ClientSyncError(
1006
+ 'sync.invalid_request',
1007
+ 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
1008
+ );
1009
+ }
1010
+ const registeredSubscription = subscriptions.get(item.id);
1011
+ if (
1012
+ registeredSubscription !== undefined &&
1013
+ registeredSubscription.table !== item.table
1014
+ ) {
1015
+ subscriptions.set(item.id, {
1016
+ id: item.id,
1017
+ table: item.table,
1018
+ state: 'failed',
1019
+ complete: false,
1020
+ reasonCode: 'client.subscription_intent_mismatch',
1021
+ });
1022
+ } else if (registeredSubscription === undefined) {
1023
+ subscriptions.set(item.id, {
1024
+ id: item.id,
1025
+ table: item.table,
1026
+ state: 'unregistered',
1027
+ complete: false,
1028
+ });
1029
+ }
1030
+ }
1031
+ const capturedAtMs = this.#now();
1032
+ const leaseState = this.#diagnosticLease(capturedAtMs);
1033
+ const connectivity =
1034
+ this.#lastRound?.status === 'succeeded'
1035
+ ? 'online'
1036
+ : this.#lastRound?.status === 'failed' &&
1037
+ this.#transportFailureCode(this.#lastRound.errorCode)
1038
+ ? 'offline'
1039
+ : 'unknown';
1040
+ const expectedOrder = new Map(
1041
+ expected.map((item, index) => [item.id, index] as const),
1042
+ );
1043
+ const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
1044
+ const aExpected = expectedOrder.get(a.id);
1045
+ const bExpected = expectedOrder.get(b.id);
1046
+ if (aExpected !== undefined || bExpected !== undefined) {
1047
+ return (
1048
+ (aExpected ?? Number.MAX_SAFE_INTEGER) -
1049
+ (bExpected ?? Number.MAX_SAFE_INTEGER)
1050
+ );
1051
+ }
1052
+ return a.id.localeCompare(b.id);
1053
+ });
1054
+ return {
1055
+ version: CLIENT_DIAGNOSTICS_VERSION,
1056
+ capturedAtMs,
1057
+ host: {
1058
+ kind: 'direct',
1059
+ role: 'single',
1060
+ connectivity,
1061
+ realtime:
1062
+ this.#config.realtime === undefined
1063
+ ? 'unsupported'
1064
+ : this.#socket === undefined
1065
+ ? 'disconnected'
1066
+ : 'connected',
1067
+ },
1068
+ securityLifecycle: this.#securityLifecycle,
1069
+ schema: {
1070
+ currentVersion: this.#config.schema.version,
1071
+ upgrading: this.#upgrading,
1072
+ ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
1073
+ ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
1074
+ : {}),
1075
+ ...(this.#schemaFloor?.latestSchemaVersion !== undefined
1076
+ ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
1077
+ : {}),
1078
+ },
1079
+ replica: {
1080
+ localRevision: getLocalRevision(this.#db).toString(),
1081
+ syncNeeded: this.#needsPull,
1082
+ pendingOutbox: listOutbox(this.#db).length,
1083
+ },
1084
+ lease: leaseState,
1085
+ subscriptions: allSubscriptions.slice(
1086
+ 0,
1087
+ MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1088
+ ),
1089
+ subscriptionsTruncated:
1090
+ allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1091
+ ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
1092
+ ...(this.#lastChange !== undefined
1093
+ ? { lastChange: this.#lastChange }
1094
+ : {}),
1095
+ storage: this.#diagnosticStorage(),
1096
+ };
1097
+ }
1098
+
1099
+ #diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
1100
+ const lease = this.#leaseState;
1101
+ if (lease?.errorCode !== undefined) {
1102
+ return {
1103
+ state: 'stopped',
1104
+ errorCode: this.#diagnosticCode(lease.errorCode),
1105
+ ...(lease.expiresAtMs !== undefined
1106
+ ? { expiresAtMs: lease.expiresAtMs }
1107
+ : {}),
1108
+ };
1109
+ }
1110
+ if (lease?.expiresAtMs === undefined) return { state: 'none' };
1111
+ return {
1112
+ state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
1113
+ expiresAtMs: lease.expiresAtMs,
1114
+ };
1115
+ }
1116
+
1117
+ #diagnosticStorage(): ClientDiagnosticsStorage {
1118
+ try {
1119
+ const pageCount = Number(
1120
+ this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0,
1121
+ );
1122
+ const pageSize = Number(
1123
+ this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0,
1124
+ );
1125
+ const outboxBytes = Number(
1126
+ this.#db.query(
1127
+ 'SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox',
1128
+ )[0]?.bytes ?? 0,
1129
+ );
1130
+ const outcome = this.#db.query(
1131
+ `SELECT COUNT(*) AS entries,
1132
+ COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
1133
+ FROM _syncular_commit_outcomes`,
1134
+ )[0];
1135
+ const blobBytes = this.#hasBlobs
1136
+ ? Number(
1137
+ this.#db.query(
1138
+ 'SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs',
1139
+ )[0]?.bytes ?? 0,
1140
+ )
1141
+ : 0;
1142
+ const pressure =
1143
+ this.#config.blobCacheMaxBytes !== undefined &&
1144
+ blobBytes > this.#config.blobCacheMaxBytes;
1145
+ return {
1146
+ status: pressure ? 'pressure' : 'healthy',
1147
+ databaseBytesApprox: Math.max(0, pageCount * pageSize),
1148
+ pendingOutboxBytesApprox: Math.max(0, outboxBytes),
1149
+ retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
1150
+ retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
1151
+ blobCacheBytesApprox: Math.max(0, blobBytes),
1152
+ ...(pressure
1153
+ ? { pressureReasonCode: 'client.blob_cache_over_limit' as const }
1154
+ : {}),
1155
+ };
1156
+ } catch {
1157
+ return { status: 'unreadable' };
1158
+ }
1159
+ }
1160
+
1161
+ #emitDiagnostics(): void {
1162
+ if (!this.#started || this.#securityLifecycle !== 'active') return;
1163
+ this.#diagnostics.emit(this.diagnosticsSnapshot());
1164
+ }
1165
+
931
1166
  /** One call for the complete status domain used by reactive hosts. */
932
1167
  statusSnapshot(): SyncStatusSnapshot {
933
1168
  this.#requireStarted();
@@ -976,9 +1211,25 @@ export class SyncClient {
976
1211
  }
977
1212
  if (revision !== undefined) {
978
1213
  const event = batch.finish(revision, status);
1214
+ const tables = [...new Set(event.tables.map((entry) => entry.table))];
1215
+ const windows = [...new Set(event.windows.map((entry) => entry.table))];
1216
+ this.#lastChange = {
1217
+ revision: revision.toString(),
1218
+ recordedAtMs: this.#now(),
1219
+ tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
1220
+ windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
1221
+ domainsTruncated:
1222
+ tables.length > MAX_DIAGNOSTIC_DOMAINS ||
1223
+ windows.length > MAX_DIAGNOSTIC_DOMAINS,
1224
+ statusChanged: event.status !== undefined,
1225
+ conflictsChanged: event.conflictsChanged,
1226
+ rejectionsChanged: event.rejectionsChanged,
1227
+ outcomesChanged: event.outcomesChanged,
1228
+ };
979
1229
  this.#changes.emit(event);
980
1230
  const legacy = invalidationFromChange(event);
981
1231
  if (legacy !== undefined) this.#invalidation.emit(legacy);
1232
+ this.#emitDiagnostics();
982
1233
  }
983
1234
  return result;
984
1235
  }
@@ -1452,6 +1703,7 @@ export class SyncClient {
1452
1703
  scopes: input.scopes,
1453
1704
  ...(input.params !== undefined ? { params: input.params } : {}),
1454
1705
  });
1706
+ this.#emitDiagnostics();
1455
1707
  return;
1456
1708
  }
1457
1709
  saveSubscription(this.#db, {
@@ -1462,11 +1714,13 @@ export class SyncClient {
1462
1714
  cursor: -1,
1463
1715
  status: 'active',
1464
1716
  });
1717
+ this.#emitDiagnostics();
1465
1718
  }
1466
1719
 
1467
1720
  unsubscribe(id: string): void {
1468
1721
  this.#requireActive();
1469
1722
  deleteSubscription(this.#db, id);
1723
+ this.#emitDiagnostics();
1470
1724
  }
1471
1725
 
1472
1726
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -2092,9 +2346,73 @@ export class SyncClient {
2092
2346
  );
2093
2347
  }
2094
2348
  this.#syncOutstanding = true;
2095
- return this.#serialize(() => this.#runSync()).finally(() => {
2096
- this.#syncOutstanding = false;
2097
- });
2349
+ const startedAtMs = this.#now();
2350
+ return this.#serialize(() => this.#runSync())
2351
+ .then(
2352
+ (summary) => {
2353
+ const completedAtMs = this.#now();
2354
+ this.#lastRound = {
2355
+ status: 'succeeded',
2356
+ startedAtMs,
2357
+ completedAtMs,
2358
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
2359
+ counters: this.#diagnosticRoundCounters(summary),
2360
+ };
2361
+ this.#emitDiagnostics();
2362
+ return summary;
2363
+ },
2364
+ (error: unknown) => {
2365
+ const completedAtMs = this.#now();
2366
+ const code = (error as { code?: unknown }).code;
2367
+ this.#lastRound = {
2368
+ status: 'failed',
2369
+ startedAtMs,
2370
+ completedAtMs,
2371
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
2372
+ errorCode:
2373
+ typeof code === 'string'
2374
+ ? this.#diagnosticCode(code)
2375
+ : 'client.unknown_failure',
2376
+ };
2377
+ this.#emitDiagnostics();
2378
+ throw error;
2379
+ },
2380
+ )
2381
+ .finally(() => {
2382
+ this.#syncOutstanding = false;
2383
+ });
2384
+ }
2385
+
2386
+ #diagnosticRoundCounters(summary: SyncSummary): DiagnosticRoundCounters {
2387
+ return {
2388
+ pushed: summary.pushed,
2389
+ applied: summary.applied.length,
2390
+ rejected: summary.rejected.length,
2391
+ retryable: summary.retryable.length,
2392
+ conflicts: summary.conflicts.length,
2393
+ commitsApplied: summary.commitsApplied,
2394
+ segmentRowsApplied: summary.segmentRowsApplied,
2395
+ bootstrapping: summary.bootstrapping.length,
2396
+ resets: summary.resets.length,
2397
+ revoked: summary.revoked.length,
2398
+ failed: summary.failed.length,
2399
+ deferredCommits: summary.deferredCommits ?? 0,
2400
+ };
2401
+ }
2402
+
2403
+ #transportFailureCode(code: string): boolean {
2404
+ return (
2405
+ code === 'transport.failed' ||
2406
+ code === 'transport.unavailable' ||
2407
+ code === 'sync.transport_failed' ||
2408
+ code === 'client.worker_failed'
2409
+ );
2410
+ }
2411
+
2412
+ #diagnosticCode(code: string): string {
2413
+ return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
2414
+ ? code
2415
+ : 'client.unknown_failure';
2098
2416
  }
2099
2417
 
2100
2418
  async #runSync(): Promise<SyncSummary> {
@@ -2254,7 +2572,23 @@ export class SyncClient {
2254
2572
  */
2255
2573
  #roundTrip(request: Uint8Array): Promise<Uint8Array> {
2256
2574
  const socket = this.#socket;
2257
- if (socket === undefined) return this.#config.transport(request);
2575
+ if (socket === undefined) {
2576
+ return Promise.resolve()
2577
+ .then(() => this.#config.transport(request))
2578
+ .catch((error: unknown) => {
2579
+ if (
2580
+ error instanceof ClientSyncError ||
2581
+ typeof (error as { code?: unknown })?.code === 'string'
2582
+ ) {
2583
+ throw error;
2584
+ }
2585
+ throw new ClientSyncError(
2586
+ 'sync.transport_failed',
2587
+ `transport round failed: ${error instanceof Error ? error.message : String(error)}`,
2588
+ true,
2589
+ );
2590
+ });
2591
+ }
2258
2592
  return new Promise<Uint8Array>((resolve, reject) => {
2259
2593
  // sync() already enforces one round in flight (§8.7).
2260
2594
  this.#pendingRound = {
@@ -2309,6 +2643,7 @@ export class SyncClient {
2309
2643
  this.#socket = undefined;
2310
2644
  this.#presence.clear(); // §8.6.1: presence is per-connection
2311
2645
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2646
+ this.#emitDiagnostics();
2312
2647
  },
2313
2648
  });
2314
2649
  if (this.#securityLifecycle === 'preflight') {
@@ -2319,6 +2654,7 @@ export class SyncClient {
2319
2654
  );
2320
2655
  }
2321
2656
  this.#socket = socket;
2657
+ this.#emitDiagnostics();
2322
2658
  }
2323
2659
 
2324
2660
  disconnectRealtime(): void {
@@ -2326,6 +2662,7 @@ export class SyncClient {
2326
2662
  this.#socket = undefined;
2327
2663
  this.#presence.clear(); // §8.6.1: presence is per-connection
2328
2664
  this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
2665
+ this.#emitDiagnostics();
2329
2666
  }
2330
2667
 
2331
2668
  /**