@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/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';
@@ -68,6 +69,12 @@ function emptySummary(pushed) {
68
69
  failed: [],
69
70
  };
70
71
  }
72
+ function isFinalPushResult(frame) {
73
+ return (frame.status !== 'rejected' ||
74
+ !frame.results.some((result) => result.status === 'error' &&
75
+ result.code === 'sync.idempotency_cache_miss' &&
76
+ result.retryable));
77
+ }
71
78
  export class SyncClient {
72
79
  #config;
73
80
  #db;
@@ -110,6 +117,11 @@ export class SyncClient {
110
117
  #invalidation = new InvalidationEmitter();
111
118
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
112
119
  #presenceListeners = new Set();
120
+ #diagnostics = new ClientDiagnosticsEmitter();
121
+ #diagnosticsDeferralDepth = 0;
122
+ #diagnosticsPending = false;
123
+ #lastRound;
124
+ #lastChange;
113
125
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
114
126
  #batch;
115
127
  /**
@@ -224,6 +236,7 @@ export class SyncClient {
224
236
  upgrading: async () => this.upgrading,
225
237
  onInvalidate: (listener) => this.onInvalidate(listener),
226
238
  });
239
+ this.#emitDiagnostics();
227
240
  }
228
241
  /**
229
242
  * §7.4.1/§7.4.2: compare the generated schema version to the persisted
@@ -359,6 +372,7 @@ export class SyncClient {
359
372
  this.#config.onSyncNeeded?.('startup');
360
373
  this.#config.onSyncIntent?.({ kind: 'interactive' });
361
374
  }
375
+ this.#emitDiagnostics();
362
376
  }
363
377
  // -- accessors ------------------------------------------------------------
364
378
  get clientId() {
@@ -452,15 +466,210 @@ export class SyncClient {
452
466
  onChange(listener) {
453
467
  return this.#changes.on(listener);
454
468
  }
469
+ /** Subscribe to complete, privacy-safe diagnostic snapshots. */
470
+ onDiagnostics(listener) {
471
+ return this.#diagnostics.on(listener);
472
+ }
473
+ /**
474
+ * One atomic support/product-health view. It never returns scope values,
475
+ * rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
476
+ */
477
+ diagnosticsSnapshot(request = {}) {
478
+ this.#requireActive();
479
+ const expected = request.expectedSubscriptions ?? [];
480
+ if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
481
+ throw new ClientSyncError('sync.invalid_request', `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
482
+ }
483
+ const registered = loadSubscriptions(this.#db);
484
+ const subscriptions = new Map();
485
+ for (const sub of registered) {
486
+ const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
487
+ const complete = sub.status === 'active' &&
488
+ sub.cursor >= 0 &&
489
+ sub.bootstrapState === undefined;
490
+ subscriptions.set(sub.id, {
491
+ id: sub.id,
492
+ table: sub.table,
493
+ state: sub.status === 'revoked'
494
+ ? 'revoked'
495
+ : sub.status === 'failed'
496
+ ? 'failed'
497
+ : reset
498
+ ? 'reset'
499
+ : complete
500
+ ? 'complete'
501
+ : 'bootstrapping',
502
+ complete,
503
+ cursor: sub.cursor,
504
+ ...(sub.reasonCode !== undefined
505
+ ? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
506
+ : {}),
507
+ });
508
+ }
509
+ for (const item of expected) {
510
+ if (typeof item.id !== 'string' ||
511
+ item.id.length === 0 ||
512
+ typeof item.table !== 'string' ||
513
+ item.table.length === 0) {
514
+ throw new ClientSyncError('sync.invalid_request', 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
515
+ }
516
+ const registeredSubscription = subscriptions.get(item.id);
517
+ if (registeredSubscription !== undefined &&
518
+ registeredSubscription.table !== item.table) {
519
+ subscriptions.set(item.id, {
520
+ id: item.id,
521
+ table: item.table,
522
+ state: 'failed',
523
+ complete: false,
524
+ reasonCode: 'client.subscription_intent_mismatch',
525
+ });
526
+ }
527
+ else if (registeredSubscription === undefined) {
528
+ subscriptions.set(item.id, {
529
+ id: item.id,
530
+ table: item.table,
531
+ state: 'unregistered',
532
+ complete: false,
533
+ });
534
+ }
535
+ }
536
+ const capturedAtMs = this.#now();
537
+ const leaseState = this.#diagnosticLease(capturedAtMs);
538
+ const connectivity = this.#lastRound?.status === 'succeeded'
539
+ ? 'online'
540
+ : this.#lastRound?.status === 'failed' &&
541
+ this.#transportFailureCode(this.#lastRound.errorCode)
542
+ ? 'offline'
543
+ : 'unknown';
544
+ const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
545
+ const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
546
+ const aExpected = expectedOrder.get(a.id);
547
+ const bExpected = expectedOrder.get(b.id);
548
+ if (aExpected !== undefined || bExpected !== undefined) {
549
+ return ((aExpected ?? Number.MAX_SAFE_INTEGER) -
550
+ (bExpected ?? Number.MAX_SAFE_INTEGER));
551
+ }
552
+ return a.id.localeCompare(b.id);
553
+ });
554
+ return {
555
+ version: CLIENT_DIAGNOSTICS_VERSION,
556
+ capturedAtMs,
557
+ host: {
558
+ kind: 'direct',
559
+ role: 'single',
560
+ connectivity,
561
+ realtime: this.#config.realtime === undefined
562
+ ? 'unsupported'
563
+ : this.#socket === undefined
564
+ ? 'disconnected'
565
+ : 'connected',
566
+ },
567
+ securityLifecycle: this.#securityLifecycle,
568
+ schema: {
569
+ currentVersion: this.#config.schema.version,
570
+ upgrading: this.#upgrading,
571
+ ...(this.#schemaFloor?.requiredSchemaVersion !== undefined
572
+ ? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
573
+ : {}),
574
+ ...(this.#schemaFloor?.latestSchemaVersion !== undefined
575
+ ? { latestVersion: this.#schemaFloor.latestSchemaVersion }
576
+ : {}),
577
+ },
578
+ replica: {
579
+ localRevision: getLocalRevision(this.#db).toString(),
580
+ syncNeeded: this.#needsPull,
581
+ pendingOutbox: listOutbox(this.#db).length,
582
+ },
583
+ lease: leaseState,
584
+ subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
585
+ subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
586
+ ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
587
+ ...(this.#lastChange !== undefined
588
+ ? { lastChange: this.#lastChange }
589
+ : {}),
590
+ storage: this.#diagnosticStorage(),
591
+ };
592
+ }
593
+ #diagnosticLease(nowMs) {
594
+ const lease = this.#leaseState;
595
+ if (lease?.errorCode !== undefined) {
596
+ return {
597
+ state: 'stopped',
598
+ errorCode: this.#diagnosticCode(lease.errorCode),
599
+ ...(lease.expiresAtMs !== undefined
600
+ ? { expiresAtMs: lease.expiresAtMs }
601
+ : {}),
602
+ };
603
+ }
604
+ if (lease?.expiresAtMs === undefined)
605
+ return { state: 'none' };
606
+ return {
607
+ state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
608
+ expiresAtMs: lease.expiresAtMs,
609
+ };
610
+ }
611
+ #diagnosticStorage() {
612
+ try {
613
+ const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
614
+ const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
615
+ const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
616
+ const outcome = this.#db.query(`SELECT COUNT(*) AS entries,
617
+ COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
618
+ FROM _syncular_commit_outcomes`)[0];
619
+ const blobBytes = this.#hasBlobs
620
+ ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
621
+ : 0;
622
+ const pressure = this.#config.blobCacheMaxBytes !== undefined &&
623
+ blobBytes > this.#config.blobCacheMaxBytes;
624
+ return {
625
+ status: pressure ? 'pressure' : 'healthy',
626
+ databaseBytesApprox: Math.max(0, pageCount * pageSize),
627
+ pendingOutboxBytesApprox: Math.max(0, outboxBytes),
628
+ retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
629
+ retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
630
+ blobCacheBytesApprox: Math.max(0, blobBytes),
631
+ ...(pressure
632
+ ? { pressureReasonCode: 'client.blob_cache_over_limit' }
633
+ : {}),
634
+ };
635
+ }
636
+ catch {
637
+ return { status: 'unreadable' };
638
+ }
639
+ }
640
+ #emitDiagnostics() {
641
+ if (!this.#started ||
642
+ this.#securityLifecycle !== 'active' ||
643
+ !this.#diagnostics.observed) {
644
+ return;
645
+ }
646
+ if (this.#diagnosticsDeferralDepth > 0) {
647
+ this.#diagnosticsPending = true;
648
+ return;
649
+ }
650
+ this.#diagnostics.emit(this.diagnosticsSnapshot());
651
+ }
652
+ #beginDiagnosticsDeferral() {
653
+ this.#diagnosticsDeferralDepth += 1;
654
+ }
655
+ #endDiagnosticsDeferral() {
656
+ if (this.#diagnosticsDeferralDepth === 0)
657
+ return;
658
+ this.#diagnosticsDeferralDepth -= 1;
659
+ if (this.#diagnosticsDeferralDepth === 0 && this.#diagnosticsPending) {
660
+ this.#diagnosticsPending = false;
661
+ this.#emitDiagnostics();
662
+ }
663
+ }
455
664
  /** One call for the complete status domain used by reactive hosts. */
456
665
  statusSnapshot() {
457
666
  this.#requireStarted();
458
667
  return this.#statusSnapshot();
459
668
  }
460
- #statusSnapshot() {
669
+ #statusSnapshot(outboxCount) {
461
670
  return {
462
671
  currentSchemaVersion: this.#config.schema.version,
463
- outbox: listOutbox(this.#db).length,
672
+ outbox: outboxCount ?? listOutbox(this.#db).length,
464
673
  upgrading: this.#upgrading,
465
674
  leaseState: this.#leaseState,
466
675
  schemaFloor: this.#schemaFloor,
@@ -473,7 +682,7 @@ export class SyncClient {
473
682
  * Re-entrant calls share the outer batch so a nested apply never
474
683
  * double-emits (e.g. purge → blob reconcile → replay inside one round).
475
684
  */
476
- #applyBatch(fn) {
685
+ #applyBatch(fn, statusSnapshotOverride) {
477
686
  if (this.#batch !== undefined)
478
687
  return fn(this.#batch);
479
688
  const batch = new ChangeAccumulator();
@@ -487,8 +696,9 @@ export class SyncClient {
487
696
  result = fn(batch);
488
697
  if (batch.touched) {
489
698
  revision = bumpLocalRevision(this.#db);
490
- if (batch.statusChanged)
491
- status = this.#statusSnapshot();
699
+ if (batch.statusChanged) {
700
+ status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
701
+ }
492
702
  }
493
703
  }
494
704
  finally {
@@ -502,10 +712,25 @@ export class SyncClient {
502
712
  }
503
713
  if (revision !== undefined) {
504
714
  const event = batch.finish(revision, status);
715
+ const tables = [...new Set(event.tables.map((entry) => entry.table))];
716
+ const windows = [...new Set(event.windows.map((entry) => entry.table))];
717
+ this.#lastChange = {
718
+ revision: revision.toString(),
719
+ recordedAtMs: this.#now(),
720
+ tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
721
+ windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
722
+ domainsTruncated: tables.length > MAX_DIAGNOSTIC_DOMAINS ||
723
+ windows.length > MAX_DIAGNOSTIC_DOMAINS,
724
+ statusChanged: event.status !== undefined,
725
+ conflictsChanged: event.conflictsChanged,
726
+ rejectionsChanged: event.rejectionsChanged,
727
+ outcomesChanged: event.outcomesChanged,
728
+ };
505
729
  this.#changes.emit(event);
506
730
  const legacy = invalidationFromChange(event);
507
731
  if (legacy !== undefined)
508
732
  this.#invalidation.emit(legacy);
733
+ this.#emitDiagnostics();
509
734
  }
510
735
  return result;
511
736
  }
@@ -870,6 +1095,7 @@ export class SyncClient {
870
1095
  scopes: input.scopes,
871
1096
  ...(input.params !== undefined ? { params: input.params } : {}),
872
1097
  });
1098
+ this.#emitDiagnostics();
873
1099
  return;
874
1100
  }
875
1101
  saveSubscription(this.#db, {
@@ -880,10 +1106,12 @@ export class SyncClient {
880
1106
  cursor: -1,
881
1107
  status: 'active',
882
1108
  });
1109
+ this.#emitDiagnostics();
883
1110
  }
884
1111
  unsubscribe(id) {
885
1112
  this.#requireActive();
886
1113
  deleteSubscription(this.#db, id);
1114
+ this.#emitDiagnostics();
887
1115
  }
888
1116
  // -- windowed subscriptions (§4.8) ------------------------------------------
889
1117
  /**
@@ -1403,10 +1631,67 @@ export class SyncClient {
1403
1631
  return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
1404
1632
  }
1405
1633
  this.#syncOutstanding = true;
1406
- return this.#serialize(() => this.#runSync()).finally(() => {
1634
+ this.#beginDiagnosticsDeferral();
1635
+ const startedAtMs = this.#now();
1636
+ return this.#serialize(() => this.#runSync())
1637
+ .then((summary) => {
1638
+ const completedAtMs = this.#now();
1639
+ this.#lastRound = {
1640
+ status: 'succeeded',
1641
+ startedAtMs,
1642
+ completedAtMs,
1643
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
1644
+ counters: this.#diagnosticRoundCounters(summary),
1645
+ };
1646
+ this.#emitDiagnostics();
1647
+ return summary;
1648
+ }, (error) => {
1649
+ const completedAtMs = this.#now();
1650
+ const code = error.code;
1651
+ this.#lastRound = {
1652
+ status: 'failed',
1653
+ startedAtMs,
1654
+ completedAtMs,
1655
+ durationMs: Math.max(0, completedAtMs - startedAtMs),
1656
+ errorCode: typeof code === 'string'
1657
+ ? this.#diagnosticCode(code)
1658
+ : 'client.unknown_failure',
1659
+ };
1660
+ this.#emitDiagnostics();
1661
+ throw error;
1662
+ })
1663
+ .finally(() => {
1407
1664
  this.#syncOutstanding = false;
1665
+ this.#endDiagnosticsDeferral();
1408
1666
  });
1409
1667
  }
1668
+ #diagnosticRoundCounters(summary) {
1669
+ return {
1670
+ pushed: summary.pushed,
1671
+ applied: summary.applied.length,
1672
+ rejected: summary.rejected.length,
1673
+ retryable: summary.retryable.length,
1674
+ conflicts: summary.conflicts.length,
1675
+ commitsApplied: summary.commitsApplied,
1676
+ segmentRowsApplied: summary.segmentRowsApplied,
1677
+ bootstrapping: summary.bootstrapping.length,
1678
+ resets: summary.resets.length,
1679
+ revoked: summary.revoked.length,
1680
+ failed: summary.failed.length,
1681
+ deferredCommits: summary.deferredCommits ?? 0,
1682
+ };
1683
+ }
1684
+ #transportFailureCode(code) {
1685
+ return (code === 'transport.failed' ||
1686
+ code === 'transport.unavailable' ||
1687
+ code === 'sync.transport_failed' ||
1688
+ code === 'client.worker_failed');
1689
+ }
1690
+ #diagnosticCode(code) {
1691
+ return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
1692
+ ? code
1693
+ : 'client.unknown_failure';
1694
+ }
1410
1695
  async #runSync() {
1411
1696
  if (this.#schemaFloor !== undefined) {
1412
1697
  return {
@@ -1547,8 +1832,17 @@ export class SyncClient {
1547
1832
  */
1548
1833
  #roundTrip(request) {
1549
1834
  const socket = this.#socket;
1550
- if (socket === undefined)
1551
- return this.#config.transport(request);
1835
+ if (socket === undefined) {
1836
+ return Promise.resolve()
1837
+ .then(() => this.#config.transport(request))
1838
+ .catch((error) => {
1839
+ if (error instanceof ClientSyncError ||
1840
+ typeof error?.code === 'string') {
1841
+ throw error;
1842
+ }
1843
+ throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
1844
+ });
1845
+ }
1552
1846
  return new Promise((resolve, reject) => {
1553
1847
  // sync() already enforces one round in flight (§8.7).
1554
1848
  this.#pendingRound = {
@@ -1592,6 +1886,7 @@ export class SyncClient {
1592
1886
  this.#socket = undefined;
1593
1887
  this.#presence.clear(); // §8.6.1: presence is per-connection
1594
1888
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1889
+ this.#emitDiagnostics();
1595
1890
  },
1596
1891
  });
1597
1892
  if (this.#securityLifecycle === 'preflight') {
@@ -1599,12 +1894,14 @@ export class SyncClient {
1599
1894
  throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
1600
1895
  }
1601
1896
  this.#socket = socket;
1897
+ this.#emitDiagnostics();
1602
1898
  }
1603
1899
  disconnectRealtime() {
1604
1900
  this.#socket?.close();
1605
1901
  this.#socket = undefined;
1606
1902
  this.#presence.clear(); // §8.6.1: presence is per-connection
1607
1903
  this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
1904
+ this.#emitDiagnostics();
1608
1905
  }
1609
1906
  /**
1610
1907
  * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
@@ -1760,10 +2057,16 @@ export class SyncClient {
1760
2057
  const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
1761
2058
  const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
1762
2059
  const rejectionDetailsByCommit = new Map();
2060
+ let lastFinalPushResult;
1763
2061
  for (const frame of message.frames) {
1764
- if (frame.type !== 'PUSH_RESULT_DETAILS')
1765
- continue;
1766
- rejectionDetailsByCommit.set(frame.clientCommitId, new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])));
2062
+ if (frame.type === 'PUSH_RESULT_DETAILS') {
2063
+ rejectionDetailsByCommit.set(frame.clientCommitId, new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])));
2064
+ }
2065
+ else if (frame.type === 'PUSH_RESULT' &&
2066
+ commitsById.has(frame.clientCommitId) &&
2067
+ isFinalPushResult(frame)) {
2068
+ lastFinalPushResult = frame;
2069
+ }
1767
2070
  }
1768
2071
  const header = message.frames[0];
1769
2072
  if (header?.type !== 'RESP_HEADER') {
@@ -1793,8 +2096,10 @@ export class SyncClient {
1793
2096
  let section;
1794
2097
  let errorFrame;
1795
2098
  let deltaCursor = -1;
2099
+ let responseOutboxCount;
1796
2100
  // Each durable observer transaction emits its own revisioned batch.
1797
2101
  // Async decrypt/download work happens outside SQLite transactions.
2102
+ this.#beginDiagnosticsDeferral();
1798
2103
  try {
1799
2104
  for (const frame of message.frames.slice(1)) {
1800
2105
  switch (frame.type) {
@@ -1808,9 +2113,16 @@ export class SyncClient {
1808
2113
  expiresAtMs: frame.expiresAtMs,
1809
2114
  });
1810
2115
  break;
1811
- case 'PUSH_RESULT':
1812
- this.#applyBatch((batch) => this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId)));
2116
+ case 'PUSH_RESULT': {
2117
+ let outboxCount = responseOutboxCount ?? listOutbox(this.#db).length;
2118
+ this.#applyBatch((batch) => {
2119
+ const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
2120
+ if (drained)
2121
+ outboxCount -= 1;
2122
+ }, () => this.#statusSnapshot(outboxCount));
2123
+ responseOutboxCount = outboxCount;
1813
2124
  break;
2125
+ }
1814
2126
  case 'PUSH_RESULT_DETAILS':
1815
2127
  // Pre-indexed above so companion ordering remains wire-additive.
1816
2128
  break;
@@ -1935,10 +2247,15 @@ export class SyncClient {
1935
2247
  }
1936
2248
  }
1937
2249
  finally {
1938
- // §7.1: local reads see outbox state applied optimistically — replay
1939
- // the still-pending commits on top of the freshly applied server state.
1940
- this.#replayOutbox();
1941
- this.#reconcileBlobs(false);
2250
+ try {
2251
+ // §7.1: local reads see outbox state applied optimistically replay
2252
+ // the still-pending commits on top of the freshly applied server state.
2253
+ this.#replayOutbox();
2254
+ this.#reconcileBlobs(false);
2255
+ }
2256
+ finally {
2257
+ this.#endDiagnosticsDeferral();
2258
+ }
1942
2259
  }
1943
2260
  if (errorFrame !== undefined)
1944
2261
  throw errorFrame;
@@ -1959,10 +2276,10 @@ export class SyncClient {
1959
2276
  }
1960
2277
  return { ...summary, bootstrapping };
1961
2278
  }
1962
- #handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
2279
+ #handlePushResult(frame, commitsById, summary, batch, rejectionDetails, pruneOutcomes) {
1963
2280
  const commit = commitsById.get(frame.clientCommitId);
1964
2281
  if (commit === undefined)
1965
- return;
2282
+ return false;
1966
2283
  if (frame.status === 'applied' || frame.status === 'cached') {
1967
2284
  // §6.3: applied and cached both drain the outbox — cached means
1968
2285
  // "already applied, you may have missed the ack".
@@ -1976,11 +2293,13 @@ export class SyncClient {
1976
2293
  })),
1977
2294
  });
1978
2295
  deleteOutboxCommit(this.#db, frame.clientCommitId);
1979
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2296
+ if (pruneOutcomes) {
2297
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2298
+ }
1980
2299
  batch.status();
1981
2300
  batch.outcomes();
1982
2301
  summary.applied.push(frame.clientCommitId);
1983
- return;
2302
+ return true;
1984
2303
  }
1985
2304
  // rejected
1986
2305
  const cacheMiss = frame.results.some((result) => result.status === 'error' &&
@@ -1990,7 +2309,7 @@ export class SyncClient {
1990
2309
  // §6.3: a serving failure, not the commit's outcome — keep the
1991
2310
  // commit queued and retry the identical push later.
1992
2311
  summary.retryable.push(frame.clientCommitId);
1993
- return;
2312
+ return false;
1994
2313
  }
1995
2314
  const outcomeResults = [];
1996
2315
  for (const result of frame.results) {
@@ -2041,7 +2360,9 @@ export class SyncClient {
2041
2360
  results: outcomeResults,
2042
2361
  operations: commit.operations,
2043
2362
  });
2044
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2363
+ if (pruneOutcomes) {
2364
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2365
+ }
2045
2366
  batch.outcomes();
2046
2367
  // §7.2: remove the rejected optimistic layer. Before-images restore
2047
2368
  // validator-rejected updates even when the server emitted no new COMMIT;
@@ -2051,6 +2372,7 @@ export class SyncClient {
2051
2372
  });
2052
2373
  batch.status();
2053
2374
  summary.rejected.push(frame.clientCommitId);
2375
+ return true;
2054
2376
  }
2055
2377
  #decodeServerRow(tableName, payload) {
2056
2378
  if (tableName === undefined)