@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.
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 {
@@ -489,6 +503,18 @@ function emptySummary(pushed: number): MutableSummary {
489
503
  };
490
504
  }
491
505
 
506
+ function isFinalPushResult(frame: PushResultFrame): boolean {
507
+ return (
508
+ frame.status !== 'rejected' ||
509
+ !frame.results.some(
510
+ (result) =>
511
+ result.status === 'error' &&
512
+ result.code === 'sync.idempotency_cache_miss' &&
513
+ result.retryable,
514
+ )
515
+ );
516
+ }
517
+
492
518
  export class SyncClient {
493
519
  readonly #config: SyncClientConfig;
494
520
  readonly #db: ClientDatabase;
@@ -531,6 +557,11 @@ export class SyncClient {
531
557
  readonly #invalidation = new InvalidationEmitter();
532
558
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
533
559
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
560
+ readonly #diagnostics = new ClientDiagnosticsEmitter();
561
+ #diagnosticsDeferralDepth = 0;
562
+ #diagnosticsPending = false;
563
+ #lastRound: DiagnosticLastRound | undefined;
564
+ #lastChange: DiagnosticLastChange | undefined;
534
565
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
535
566
  #batch: ChangeAccumulator | undefined;
536
567
  /**
@@ -669,6 +700,7 @@ export class SyncClient {
669
700
  upgrading: async () => this.upgrading,
670
701
  onInvalidate: (listener) => this.onInvalidate(listener),
671
702
  });
703
+ this.#emitDiagnostics();
672
704
  }
673
705
 
674
706
  /**
@@ -819,6 +851,7 @@ export class SyncClient {
819
851
  this.#config.onSyncNeeded?.('startup');
820
852
  this.#config.onSyncIntent?.({ kind: 'interactive' });
821
853
  }
854
+ this.#emitDiagnostics();
822
855
  }
823
856
 
824
857
  // -- accessors ------------------------------------------------------------
@@ -928,16 +961,255 @@ export class SyncClient {
928
961
  return this.#changes.on(listener);
929
962
  }
930
963
 
964
+ /** Subscribe to complete, privacy-safe diagnostic snapshots. */
965
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void {
966
+ return this.#diagnostics.on(listener);
967
+ }
968
+
969
+ /**
970
+ * One atomic support/product-health view. It never returns scope values,
971
+ * rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
972
+ */
973
+ diagnosticsSnapshot(
974
+ request: ClientDiagnosticsRequest = {},
975
+ ): ClientDiagnosticsSnapshot {
976
+ this.#requireActive();
977
+ const expected = request.expectedSubscriptions ?? [];
978
+ if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
979
+ throw new ClientSyncError(
980
+ 'sync.invalid_request',
981
+ `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`,
982
+ );
983
+ }
984
+ const registered = loadSubscriptions(this.#db);
985
+ const subscriptions = new Map<string, DiagnosticSubscription>();
986
+ for (const sub of registered) {
987
+ const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
988
+ const complete =
989
+ sub.status === 'active' &&
990
+ sub.cursor >= 0 &&
991
+ sub.bootstrapState === undefined;
992
+ subscriptions.set(sub.id, {
993
+ id: sub.id,
994
+ table: sub.table,
995
+ state:
996
+ sub.status === 'revoked'
997
+ ? 'revoked'
998
+ : sub.status === 'failed'
999
+ ? 'failed'
1000
+ : reset
1001
+ ? 'reset'
1002
+ : complete
1003
+ ? 'complete'
1004
+ : 'bootstrapping',
1005
+ complete,
1006
+ cursor: sub.cursor,
1007
+ ...(sub.reasonCode !== undefined
1008
+ ? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
1009
+ : {}),
1010
+ });
1011
+ }
1012
+ for (const item of expected) {
1013
+ if (
1014
+ typeof item.id !== 'string' ||
1015
+ item.id.length === 0 ||
1016
+ typeof item.table !== 'string' ||
1017
+ item.table.length === 0
1018
+ ) {
1019
+ throw new ClientSyncError(
1020
+ 'sync.invalid_request',
1021
+ 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
1022
+ );
1023
+ }
1024
+ const registeredSubscription = subscriptions.get(item.id);
1025
+ if (
1026
+ registeredSubscription !== undefined &&
1027
+ registeredSubscription.table !== item.table
1028
+ ) {
1029
+ subscriptions.set(item.id, {
1030
+ id: item.id,
1031
+ table: item.table,
1032
+ state: 'failed',
1033
+ complete: false,
1034
+ reasonCode: 'client.subscription_intent_mismatch',
1035
+ });
1036
+ } else if (registeredSubscription === undefined) {
1037
+ subscriptions.set(item.id, {
1038
+ id: item.id,
1039
+ table: item.table,
1040
+ state: 'unregistered',
1041
+ complete: false,
1042
+ });
1043
+ }
1044
+ }
1045
+ const capturedAtMs = this.#now();
1046
+ const leaseState = this.#diagnosticLease(capturedAtMs);
1047
+ const connectivity =
1048
+ this.#lastRound?.status === 'succeeded'
1049
+ ? 'online'
1050
+ : this.#lastRound?.status === 'failed' &&
1051
+ this.#transportFailureCode(this.#lastRound.errorCode)
1052
+ ? 'offline'
1053
+ : 'unknown';
1054
+ const expectedOrder = new Map(
1055
+ expected.map((item, index) => [item.id, index] as const),
1056
+ );
1057
+ const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
1058
+ const aExpected = expectedOrder.get(a.id);
1059
+ const bExpected = expectedOrder.get(b.id);
1060
+ if (aExpected !== undefined || bExpected !== undefined) {
1061
+ return (
1062
+ (aExpected ?? Number.MAX_SAFE_INTEGER) -
1063
+ (bExpected ?? Number.MAX_SAFE_INTEGER)
1064
+ );
1065
+ }
1066
+ return a.id.localeCompare(b.id);
1067
+ });
1068
+ return {
1069
+ version: CLIENT_DIAGNOSTICS_VERSION,
1070
+ capturedAtMs,
1071
+ host: {
1072
+ kind: 'direct',
1073
+ role: 'single',
1074
+ connectivity,
1075
+ realtime:
1076
+ this.#config.realtime === undefined
1077
+ ? 'unsupported'
1078
+ : this.#socket === undefined
1079
+ ? 'disconnected'
1080
+ : 'connected',
1081
+ },
1082
+ securityLifecycle: this.#securityLifecycle,
1083
+ schema: {
1084
+ currentVersion: this.#config.schema.version,
1085
+ upgrading: this.#upgrading,
1086
+ ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
1087
+ ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
1088
+ : {}),
1089
+ ...(this.#schemaFloor?.latestSchemaVersion !== undefined
1090
+ ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
1091
+ : {}),
1092
+ },
1093
+ replica: {
1094
+ localRevision: getLocalRevision(this.#db).toString(),
1095
+ syncNeeded: this.#needsPull,
1096
+ pendingOutbox: listOutbox(this.#db).length,
1097
+ },
1098
+ lease: leaseState,
1099
+ subscriptions: allSubscriptions.slice(
1100
+ 0,
1101
+ MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1102
+ ),
1103
+ subscriptionsTruncated:
1104
+ allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1105
+ ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
1106
+ ...(this.#lastChange !== undefined
1107
+ ? { lastChange: this.#lastChange }
1108
+ : {}),
1109
+ storage: this.#diagnosticStorage(),
1110
+ };
1111
+ }
1112
+
1113
+ #diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
1114
+ const lease = this.#leaseState;
1115
+ if (lease?.errorCode !== undefined) {
1116
+ return {
1117
+ state: 'stopped',
1118
+ errorCode: this.#diagnosticCode(lease.errorCode),
1119
+ ...(lease.expiresAtMs !== undefined
1120
+ ? { expiresAtMs: lease.expiresAtMs }
1121
+ : {}),
1122
+ };
1123
+ }
1124
+ if (lease?.expiresAtMs === undefined) return { state: 'none' };
1125
+ return {
1126
+ state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
1127
+ expiresAtMs: lease.expiresAtMs,
1128
+ };
1129
+ }
1130
+
1131
+ #diagnosticStorage(): ClientDiagnosticsStorage {
1132
+ try {
1133
+ const pageCount = Number(
1134
+ this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0,
1135
+ );
1136
+ const pageSize = Number(
1137
+ this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0,
1138
+ );
1139
+ const outboxBytes = Number(
1140
+ this.#db.query(
1141
+ 'SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox',
1142
+ )[0]?.bytes ?? 0,
1143
+ );
1144
+ const outcome = this.#db.query(
1145
+ `SELECT COUNT(*) AS entries,
1146
+ COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
1147
+ FROM _syncular_commit_outcomes`,
1148
+ )[0];
1149
+ const blobBytes = this.#hasBlobs
1150
+ ? Number(
1151
+ this.#db.query(
1152
+ 'SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs',
1153
+ )[0]?.bytes ?? 0,
1154
+ )
1155
+ : 0;
1156
+ const pressure =
1157
+ this.#config.blobCacheMaxBytes !== undefined &&
1158
+ blobBytes > this.#config.blobCacheMaxBytes;
1159
+ return {
1160
+ status: pressure ? 'pressure' : 'healthy',
1161
+ databaseBytesApprox: Math.max(0, pageCount * pageSize),
1162
+ pendingOutboxBytesApprox: Math.max(0, outboxBytes),
1163
+ retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
1164
+ retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
1165
+ blobCacheBytesApprox: Math.max(0, blobBytes),
1166
+ ...(pressure
1167
+ ? { pressureReasonCode: 'client.blob_cache_over_limit' as const }
1168
+ : {}),
1169
+ };
1170
+ } catch {
1171
+ return { status: 'unreadable' };
1172
+ }
1173
+ }
1174
+
1175
+ #emitDiagnostics(): void {
1176
+ if (
1177
+ !this.#started ||
1178
+ this.#securityLifecycle !== 'active' ||
1179
+ !this.#diagnostics.observed
1180
+ ) {
1181
+ return;
1182
+ }
1183
+ if (this.#diagnosticsDeferralDepth > 0) {
1184
+ this.#diagnosticsPending = true;
1185
+ return;
1186
+ }
1187
+ this.#diagnostics.emit(this.diagnosticsSnapshot());
1188
+ }
1189
+
1190
+ #beginDiagnosticsDeferral(): void {
1191
+ this.#diagnosticsDeferralDepth += 1;
1192
+ }
1193
+
1194
+ #endDiagnosticsDeferral(): void {
1195
+ if (this.#diagnosticsDeferralDepth === 0) return;
1196
+ this.#diagnosticsDeferralDepth -= 1;
1197
+ if (this.#diagnosticsDeferralDepth === 0 && this.#diagnosticsPending) {
1198
+ this.#diagnosticsPending = false;
1199
+ this.#emitDiagnostics();
1200
+ }
1201
+ }
1202
+
931
1203
  /** One call for the complete status domain used by reactive hosts. */
932
1204
  statusSnapshot(): SyncStatusSnapshot {
933
1205
  this.#requireStarted();
934
1206
  return this.#statusSnapshot();
935
1207
  }
936
1208
 
937
- #statusSnapshot(): SyncStatusSnapshot {
1209
+ #statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
938
1210
  return {
939
1211
  currentSchemaVersion: this.#config.schema.version,
940
- outbox: listOutbox(this.#db).length,
1212
+ outbox: outboxCount ?? listOutbox(this.#db).length,
941
1213
  upgrading: this.#upgrading,
942
1214
  leaseState: this.#leaseState,
943
1215
  schemaFloor: this.#schemaFloor,
@@ -951,7 +1223,10 @@ export class SyncClient {
951
1223
  * Re-entrant calls share the outer batch so a nested apply never
952
1224
  * double-emits (e.g. purge → blob reconcile → replay inside one round).
953
1225
  */
954
- #applyBatch<T>(fn: (batch: ChangeAccumulator) => T): T {
1226
+ #applyBatch<T>(
1227
+ fn: (batch: ChangeAccumulator) => T,
1228
+ statusSnapshotOverride?: () => SyncStatusSnapshot,
1229
+ ): T {
955
1230
  if (this.#batch !== undefined) return fn(this.#batch);
956
1231
  const batch = new ChangeAccumulator();
957
1232
  let revision: LocalRevision | undefined;
@@ -964,7 +1239,9 @@ export class SyncClient {
964
1239
  result = fn(batch);
965
1240
  if (batch.touched) {
966
1241
  revision = bumpLocalRevision(this.#db);
967
- if (batch.statusChanged) status = this.#statusSnapshot();
1242
+ if (batch.statusChanged) {
1243
+ status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
1244
+ }
968
1245
  }
969
1246
  } finally {
970
1247
  this.#batch = undefined;
@@ -976,9 +1253,25 @@ export class SyncClient {
976
1253
  }
977
1254
  if (revision !== undefined) {
978
1255
  const event = batch.finish(revision, status);
1256
+ const tables = [...new Set(event.tables.map((entry) => entry.table))];
1257
+ const windows = [...new Set(event.windows.map((entry) => entry.table))];
1258
+ this.#lastChange = {
1259
+ revision: revision.toString(),
1260
+ recordedAtMs: this.#now(),
1261
+ tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
1262
+ windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
1263
+ domainsTruncated:
1264
+ tables.length > MAX_DIAGNOSTIC_DOMAINS ||
1265
+ windows.length > MAX_DIAGNOSTIC_DOMAINS,
1266
+ statusChanged: event.status !== undefined,
1267
+ conflictsChanged: event.conflictsChanged,
1268
+ rejectionsChanged: event.rejectionsChanged,
1269
+ outcomesChanged: event.outcomesChanged,
1270
+ };
979
1271
  this.#changes.emit(event);
980
1272
  const legacy = invalidationFromChange(event);
981
1273
  if (legacy !== undefined) this.#invalidation.emit(legacy);
1274
+ this.#emitDiagnostics();
982
1275
  }
983
1276
  return result;
984
1277
  }
@@ -1452,6 +1745,7 @@ export class SyncClient {
1452
1745
  scopes: input.scopes,
1453
1746
  ...(input.params !== undefined ? { params: input.params } : {}),
1454
1747
  });
1748
+ this.#emitDiagnostics();
1455
1749
  return;
1456
1750
  }
1457
1751
  saveSubscription(this.#db, {
@@ -1462,11 +1756,13 @@ export class SyncClient {
1462
1756
  cursor: -1,
1463
1757
  status: 'active',
1464
1758
  });
1759
+ this.#emitDiagnostics();
1465
1760
  }
1466
1761
 
1467
1762
  unsubscribe(id: string): void {
1468
1763
  this.#requireActive();
1469
1764
  deleteSubscription(this.#db, id);
1765
+ this.#emitDiagnostics();
1470
1766
  }
1471
1767
 
1472
1768
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -2092,9 +2388,75 @@ export class SyncClient {
2092
2388
  );
2093
2389
  }
2094
2390
  this.#syncOutstanding = true;
2095
- return this.#serialize(() => this.#runSync()).finally(() => {
2096
- this.#syncOutstanding = false;
2097
- });
2391
+ this.#beginDiagnosticsDeferral();
2392
+ const startedAtMs = this.#now();
2393
+ return this.#serialize(() => this.#runSync())
2394
+ .then(
2395
+ (summary) => {
2396
+ const completedAtMs = this.#now();
2397
+ this.#lastRound = {
2398
+ status: 'succeeded',
2399
+ startedAtMs,
2400
+ completedAtMs,
2401
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
2402
+ counters: this.#diagnosticRoundCounters(summary),
2403
+ };
2404
+ this.#emitDiagnostics();
2405
+ return summary;
2406
+ },
2407
+ (error: unknown) => {
2408
+ const completedAtMs = this.#now();
2409
+ const code = (error as { code?: unknown }).code;
2410
+ this.#lastRound = {
2411
+ status: 'failed',
2412
+ startedAtMs,
2413
+ completedAtMs,
2414
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
2415
+ errorCode:
2416
+ typeof code === 'string'
2417
+ ? this.#diagnosticCode(code)
2418
+ : 'client.unknown_failure',
2419
+ };
2420
+ this.#emitDiagnostics();
2421
+ throw error;
2422
+ },
2423
+ )
2424
+ .finally(() => {
2425
+ this.#syncOutstanding = false;
2426
+ this.#endDiagnosticsDeferral();
2427
+ });
2428
+ }
2429
+
2430
+ #diagnosticRoundCounters(summary: SyncSummary): DiagnosticRoundCounters {
2431
+ return {
2432
+ pushed: summary.pushed,
2433
+ applied: summary.applied.length,
2434
+ rejected: summary.rejected.length,
2435
+ retryable: summary.retryable.length,
2436
+ conflicts: summary.conflicts.length,
2437
+ commitsApplied: summary.commitsApplied,
2438
+ segmentRowsApplied: summary.segmentRowsApplied,
2439
+ bootstrapping: summary.bootstrapping.length,
2440
+ resets: summary.resets.length,
2441
+ revoked: summary.revoked.length,
2442
+ failed: summary.failed.length,
2443
+ deferredCommits: summary.deferredCommits ?? 0,
2444
+ };
2445
+ }
2446
+
2447
+ #transportFailureCode(code: string): boolean {
2448
+ return (
2449
+ code === 'transport.failed' ||
2450
+ code === 'transport.unavailable' ||
2451
+ code === 'sync.transport_failed' ||
2452
+ code === 'client.worker_failed'
2453
+ );
2454
+ }
2455
+
2456
+ #diagnosticCode(code: string): string {
2457
+ return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
2458
+ ? code
2459
+ : 'client.unknown_failure';
2098
2460
  }
2099
2461
 
2100
2462
  async #runSync(): Promise<SyncSummary> {
@@ -2254,7 +2616,23 @@ export class SyncClient {
2254
2616
  */
2255
2617
  #roundTrip(request: Uint8Array): Promise<Uint8Array> {
2256
2618
  const socket = this.#socket;
2257
- if (socket === undefined) return this.#config.transport(request);
2619
+ if (socket === undefined) {
2620
+ return Promise.resolve()
2621
+ .then(() => this.#config.transport(request))
2622
+ .catch((error: unknown) => {
2623
+ if (
2624
+ error instanceof ClientSyncError ||
2625
+ typeof (error as { code?: unknown })?.code === 'string'
2626
+ ) {
2627
+ throw error;
2628
+ }
2629
+ throw new ClientSyncError(
2630
+ 'sync.transport_failed',
2631
+ `transport round failed: ${error instanceof Error ? error.message : String(error)}`,
2632
+ true,
2633
+ );
2634
+ });
2635
+ }
2258
2636
  return new Promise<Uint8Array>((resolve, reject) => {
2259
2637
  // sync() already enforces one round in flight (§8.7).
2260
2638
  this.#pendingRound = {
@@ -2309,6 +2687,7 @@ export class SyncClient {
2309
2687
  this.#socket = undefined;
2310
2688
  this.#presence.clear(); // §8.6.1: presence is per-connection
2311
2689
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2690
+ this.#emitDiagnostics();
2312
2691
  },
2313
2692
  });
2314
2693
  if (this.#securityLifecycle === 'preflight') {
@@ -2319,6 +2698,7 @@ export class SyncClient {
2319
2698
  );
2320
2699
  }
2321
2700
  this.#socket = socket;
2701
+ this.#emitDiagnostics();
2322
2702
  }
2323
2703
 
2324
2704
  disconnectRealtime(): void {
@@ -2326,6 +2706,7 @@ export class SyncClient {
2326
2706
  this.#socket = undefined;
2327
2707
  this.#presence.clear(); // §8.6.1: presence is per-connection
2328
2708
  this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
2709
+ this.#emitDiagnostics();
2329
2710
  }
2330
2711
 
2331
2712
  /**
@@ -2505,12 +2886,20 @@ export class SyncClient {
2505
2886
  string,
2506
2887
  ReadonlyMap<number, RejectionDetails>
2507
2888
  >();
2889
+ let lastFinalPushResult: PushResultFrame | undefined;
2508
2890
  for (const frame of message.frames) {
2509
- if (frame.type !== 'PUSH_RESULT_DETAILS') continue;
2510
- rejectionDetailsByCommit.set(
2511
- frame.clientCommitId,
2512
- new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
2513
- );
2891
+ if (frame.type === 'PUSH_RESULT_DETAILS') {
2892
+ rejectionDetailsByCommit.set(
2893
+ frame.clientCommitId,
2894
+ new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
2895
+ );
2896
+ } else if (
2897
+ frame.type === 'PUSH_RESULT' &&
2898
+ commitsById.has(frame.clientCommitId) &&
2899
+ isFinalPushResult(frame)
2900
+ ) {
2901
+ lastFinalPushResult = frame;
2902
+ }
2514
2903
  }
2515
2904
 
2516
2905
  const header = message.frames[0];
@@ -2542,9 +2931,11 @@ export class SyncClient {
2542
2931
  let section: OpenSection | undefined;
2543
2932
  let errorFrame: ClientSyncError | undefined;
2544
2933
  let deltaCursor = -1;
2934
+ let responseOutboxCount: number | undefined;
2545
2935
 
2546
2936
  // Each durable observer transaction emits its own revisioned batch.
2547
2937
  // Async decrypt/download work happens outside SQLite transactions.
2938
+ this.#beginDiagnosticsDeferral();
2548
2939
  try {
2549
2940
  for (const frame of message.frames.slice(1)) {
2550
2941
  switch (frame.type) {
@@ -2558,17 +2949,26 @@ export class SyncClient {
2558
2949
  expiresAtMs: frame.expiresAtMs,
2559
2950
  });
2560
2951
  break;
2561
- case 'PUSH_RESULT':
2562
- this.#applyBatch((batch) =>
2563
- this.#handlePushResult(
2564
- frame,
2565
- commitsById,
2566
- summary,
2567
- batch,
2568
- rejectionDetailsByCommit.get(frame.clientCommitId),
2569
- ),
2952
+ case 'PUSH_RESULT': {
2953
+ let outboxCount =
2954
+ responseOutboxCount ?? listOutbox(this.#db).length;
2955
+ this.#applyBatch(
2956
+ (batch) => {
2957
+ const drained = this.#handlePushResult(
2958
+ frame,
2959
+ commitsById,
2960
+ summary,
2961
+ batch,
2962
+ rejectionDetailsByCommit.get(frame.clientCommitId),
2963
+ frame === lastFinalPushResult,
2964
+ );
2965
+ if (drained) outboxCount -= 1;
2966
+ },
2967
+ () => this.#statusSnapshot(outboxCount),
2570
2968
  );
2969
+ responseOutboxCount = outboxCount;
2571
2970
  break;
2971
+ }
2572
2972
  case 'PUSH_RESULT_DETAILS':
2573
2973
  // Pre-indexed above so companion ordering remains wire-additive.
2574
2974
  break;
@@ -2766,10 +3166,14 @@ export class SyncClient {
2766
3166
  if (errorFrame !== undefined) break;
2767
3167
  }
2768
3168
  } finally {
2769
- // §7.1: local reads see outbox state applied optimistically — replay
2770
- // the still-pending commits on top of the freshly applied server state.
2771
- this.#replayOutbox();
2772
- this.#reconcileBlobs(false);
3169
+ try {
3170
+ // §7.1: local reads see outbox state applied optimistically replay
3171
+ // the still-pending commits on top of the freshly applied server state.
3172
+ this.#replayOutbox();
3173
+ this.#reconcileBlobs(false);
3174
+ } finally {
3175
+ this.#endDiagnosticsDeferral();
3176
+ }
2773
3177
  }
2774
3178
 
2775
3179
  if (errorFrame !== undefined) throw errorFrame;
@@ -2799,9 +3203,10 @@ export class SyncClient {
2799
3203
  summary: MutableSummary,
2800
3204
  batch: ChangeAccumulator,
2801
3205
  rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
2802
- ): void {
3206
+ pruneOutcomes: boolean,
3207
+ ): boolean {
2803
3208
  const commit = commitsById.get(frame.clientCommitId);
2804
- if (commit === undefined) return;
3209
+ if (commit === undefined) return false;
2805
3210
  if (frame.status === 'applied' || frame.status === 'cached') {
2806
3211
  // §6.3: applied and cached both drain the outbox — cached means
2807
3212
  // "already applied, you may have missed the ack".
@@ -2815,11 +3220,13 @@ export class SyncClient {
2815
3220
  })),
2816
3221
  });
2817
3222
  deleteOutboxCommit(this.#db, frame.clientCommitId);
2818
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3223
+ if (pruneOutcomes) {
3224
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3225
+ }
2819
3226
  batch.status();
2820
3227
  batch.outcomes();
2821
3228
  summary.applied.push(frame.clientCommitId);
2822
- return;
3229
+ return true;
2823
3230
  }
2824
3231
  // rejected
2825
3232
  const cacheMiss = frame.results.some(
@@ -2832,7 +3239,7 @@ export class SyncClient {
2832
3239
  // §6.3: a serving failure, not the commit's outcome — keep the
2833
3240
  // commit queued and retry the identical push later.
2834
3241
  summary.retryable.push(frame.clientCommitId);
2835
- return;
3242
+ return false;
2836
3243
  }
2837
3244
  const outcomeResults: CommitOperationOutcome[] = [];
2838
3245
  for (const result of frame.results) {
@@ -2881,7 +3288,9 @@ export class SyncClient {
2881
3288
  results: outcomeResults,
2882
3289
  operations: commit.operations,
2883
3290
  });
2884
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3291
+ if (pruneOutcomes) {
3292
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3293
+ }
2885
3294
  batch.outcomes();
2886
3295
  // §7.2: remove the rejected optimistic layer. Before-images restore
2887
3296
  // validator-rejected updates even when the server emitted no new COMMIT;
@@ -2891,6 +3300,7 @@ export class SyncClient {
2891
3300
  });
2892
3301
  batch.status();
2893
3302
  summary.rejected.push(frame.clientCommitId);
3303
+ return true;
2894
3304
  }
2895
3305
 
2896
3306
  #decodeServerRow(