@syncular/client 0.15.17 → 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/dist/client.js CHANGED
@@ -69,6 +69,12 @@ function emptySummary(pushed) {
69
69
  failed: [],
70
70
  };
71
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
+ }
72
78
  export class SyncClient {
73
79
  #config;
74
80
  #db;
@@ -112,6 +118,8 @@ export class SyncClient {
112
118
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
113
119
  #presenceListeners = new Set();
114
120
  #diagnostics = new ClientDiagnosticsEmitter();
121
+ #diagnosticsDeferralDepth = 0;
122
+ #diagnosticsPending = false;
115
123
  #lastRound;
116
124
  #lastChange;
117
125
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
@@ -630,19 +638,38 @@ export class SyncClient {
630
638
  }
631
639
  }
632
640
  #emitDiagnostics() {
633
- if (!this.#started || this.#securityLifecycle !== 'active')
641
+ if (!this.#started ||
642
+ this.#securityLifecycle !== 'active' ||
643
+ !this.#diagnostics.observed) {
644
+ return;
645
+ }
646
+ if (this.#diagnosticsDeferralDepth > 0) {
647
+ this.#diagnosticsPending = true;
634
648
  return;
649
+ }
635
650
  this.#diagnostics.emit(this.diagnosticsSnapshot());
636
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
+ }
637
664
  /** One call for the complete status domain used by reactive hosts. */
638
665
  statusSnapshot() {
639
666
  this.#requireStarted();
640
667
  return this.#statusSnapshot();
641
668
  }
642
- #statusSnapshot() {
669
+ #statusSnapshot(outboxCount) {
643
670
  return {
644
671
  currentSchemaVersion: this.#config.schema.version,
645
- outbox: listOutbox(this.#db).length,
672
+ outbox: outboxCount ?? listOutbox(this.#db).length,
646
673
  upgrading: this.#upgrading,
647
674
  leaseState: this.#leaseState,
648
675
  schemaFloor: this.#schemaFloor,
@@ -655,7 +682,7 @@ export class SyncClient {
655
682
  * Re-entrant calls share the outer batch so a nested apply never
656
683
  * double-emits (e.g. purge → blob reconcile → replay inside one round).
657
684
  */
658
- #applyBatch(fn) {
685
+ #applyBatch(fn, statusSnapshotOverride) {
659
686
  if (this.#batch !== undefined)
660
687
  return fn(this.#batch);
661
688
  const batch = new ChangeAccumulator();
@@ -669,8 +696,9 @@ export class SyncClient {
669
696
  result = fn(batch);
670
697
  if (batch.touched) {
671
698
  revision = bumpLocalRevision(this.#db);
672
- if (batch.statusChanged)
673
- status = this.#statusSnapshot();
699
+ if (batch.statusChanged) {
700
+ status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
701
+ }
674
702
  }
675
703
  }
676
704
  finally {
@@ -1603,6 +1631,7 @@ export class SyncClient {
1603
1631
  return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
1604
1632
  }
1605
1633
  this.#syncOutstanding = true;
1634
+ this.#beginDiagnosticsDeferral();
1606
1635
  const startedAtMs = this.#now();
1607
1636
  return this.#serialize(() => this.#runSync())
1608
1637
  .then((summary) => {
@@ -1633,6 +1662,7 @@ export class SyncClient {
1633
1662
  })
1634
1663
  .finally(() => {
1635
1664
  this.#syncOutstanding = false;
1665
+ this.#endDiagnosticsDeferral();
1636
1666
  });
1637
1667
  }
1638
1668
  #diagnosticRoundCounters(summary) {
@@ -2027,10 +2057,16 @@ export class SyncClient {
2027
2057
  const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
2028
2058
  const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
2029
2059
  const rejectionDetailsByCommit = new Map();
2060
+ let lastFinalPushResult;
2030
2061
  for (const frame of message.frames) {
2031
- if (frame.type !== 'PUSH_RESULT_DETAILS')
2032
- continue;
2033
- 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
+ }
2034
2070
  }
2035
2071
  const header = message.frames[0];
2036
2072
  if (header?.type !== 'RESP_HEADER') {
@@ -2060,8 +2096,10 @@ export class SyncClient {
2060
2096
  let section;
2061
2097
  let errorFrame;
2062
2098
  let deltaCursor = -1;
2099
+ let responseOutboxCount;
2063
2100
  // Each durable observer transaction emits its own revisioned batch.
2064
2101
  // Async decrypt/download work happens outside SQLite transactions.
2102
+ this.#beginDiagnosticsDeferral();
2065
2103
  try {
2066
2104
  for (const frame of message.frames.slice(1)) {
2067
2105
  switch (frame.type) {
@@ -2075,9 +2113,16 @@ export class SyncClient {
2075
2113
  expiresAtMs: frame.expiresAtMs,
2076
2114
  });
2077
2115
  break;
2078
- case 'PUSH_RESULT':
2079
- 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;
2080
2124
  break;
2125
+ }
2081
2126
  case 'PUSH_RESULT_DETAILS':
2082
2127
  // Pre-indexed above so companion ordering remains wire-additive.
2083
2128
  break;
@@ -2202,10 +2247,15 @@ export class SyncClient {
2202
2247
  }
2203
2248
  }
2204
2249
  finally {
2205
- // §7.1: local reads see outbox state applied optimistically — replay
2206
- // the still-pending commits on top of the freshly applied server state.
2207
- this.#replayOutbox();
2208
- 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
+ }
2209
2259
  }
2210
2260
  if (errorFrame !== undefined)
2211
2261
  throw errorFrame;
@@ -2226,10 +2276,10 @@ export class SyncClient {
2226
2276
  }
2227
2277
  return { ...summary, bootstrapping };
2228
2278
  }
2229
- #handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
2279
+ #handlePushResult(frame, commitsById, summary, batch, rejectionDetails, pruneOutcomes) {
2230
2280
  const commit = commitsById.get(frame.clientCommitId);
2231
2281
  if (commit === undefined)
2232
- return;
2282
+ return false;
2233
2283
  if (frame.status === 'applied' || frame.status === 'cached') {
2234
2284
  // §6.3: applied and cached both drain the outbox — cached means
2235
2285
  // "already applied, you may have missed the ack".
@@ -2243,11 +2293,13 @@ export class SyncClient {
2243
2293
  })),
2244
2294
  });
2245
2295
  deleteOutboxCommit(this.#db, frame.clientCommitId);
2246
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2296
+ if (pruneOutcomes) {
2297
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2298
+ }
2247
2299
  batch.status();
2248
2300
  batch.outcomes();
2249
2301
  summary.applied.push(frame.clientCommitId);
2250
- return;
2302
+ return true;
2251
2303
  }
2252
2304
  // rejected
2253
2305
  const cacheMiss = frame.results.some((result) => result.status === 'error' &&
@@ -2257,7 +2309,7 @@ export class SyncClient {
2257
2309
  // §6.3: a serving failure, not the commit's outcome — keep the
2258
2310
  // commit queued and retry the identical push later.
2259
2311
  summary.retryable.push(frame.clientCommitId);
2260
- return;
2312
+ return false;
2261
2313
  }
2262
2314
  const outcomeResults = [];
2263
2315
  for (const result of frame.results) {
@@ -2308,7 +2360,9 @@ export class SyncClient {
2308
2360
  results: outcomeResults,
2309
2361
  operations: commit.operations,
2310
2362
  });
2311
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2363
+ if (pruneOutcomes) {
2364
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2365
+ }
2312
2366
  batch.outcomes();
2313
2367
  // §7.2: remove the rejected optimistic layer. Before-images restore
2314
2368
  // validator-rejected updates even when the server emitted no new COMMIT;
@@ -2318,6 +2372,7 @@ export class SyncClient {
2318
2372
  });
2319
2373
  batch.status();
2320
2374
  summary.rejected.push(frame.clientCommitId);
2375
+ return true;
2321
2376
  }
2322
2377
  #decodeServerRow(tableName, payload) {
2323
2378
  if (tableName === undefined)
@@ -126,6 +126,7 @@ export interface ClientDiagnosticsSnapshot {
126
126
  export type ClientDiagnosticsListener = (snapshot: ClientDiagnosticsSnapshot) => void;
127
127
  export declare class ClientDiagnosticsEmitter {
128
128
  #private;
129
+ get observed(): boolean;
129
130
  on(listener: ClientDiagnosticsListener): () => void;
130
131
  emit(snapshot: ClientDiagnosticsSnapshot): void;
131
132
  }
@@ -3,6 +3,9 @@ export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
3
3
  export const MAX_DIAGNOSTIC_DOMAINS = 256;
4
4
  export class ClientDiagnosticsEmitter {
5
5
  #listeners = new Set();
6
+ get observed() {
7
+ return this.#listeners.size > 0;
8
+ }
6
9
  on(listener) {
7
10
  this.#listeners.add(listener);
8
11
  return () => this.#listeners.delete(listener);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.17",
3
+ "version": "0.15.18",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.17"
84
+ "@syncular/core": "0.15.18"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.17",
95
+ "@syncular/server": "0.15.18",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -503,6 +503,18 @@ function emptySummary(pushed: number): MutableSummary {
503
503
  };
504
504
  }
505
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
+
506
518
  export class SyncClient {
507
519
  readonly #config: SyncClientConfig;
508
520
  readonly #db: ClientDatabase;
@@ -546,6 +558,8 @@ export class SyncClient {
546
558
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
547
559
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
548
560
  readonly #diagnostics = new ClientDiagnosticsEmitter();
561
+ #diagnosticsDeferralDepth = 0;
562
+ #diagnosticsPending = false;
549
563
  #lastRound: DiagnosticLastRound | undefined;
550
564
  #lastChange: DiagnosticLastChange | undefined;
551
565
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
@@ -1159,20 +1173,43 @@ export class SyncClient {
1159
1173
  }
1160
1174
 
1161
1175
  #emitDiagnostics(): void {
1162
- if (!this.#started || this.#securityLifecycle !== 'active') return;
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
+ }
1163
1187
  this.#diagnostics.emit(this.diagnosticsSnapshot());
1164
1188
  }
1165
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
+
1166
1203
  /** One call for the complete status domain used by reactive hosts. */
1167
1204
  statusSnapshot(): SyncStatusSnapshot {
1168
1205
  this.#requireStarted();
1169
1206
  return this.#statusSnapshot();
1170
1207
  }
1171
1208
 
1172
- #statusSnapshot(): SyncStatusSnapshot {
1209
+ #statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
1173
1210
  return {
1174
1211
  currentSchemaVersion: this.#config.schema.version,
1175
- outbox: listOutbox(this.#db).length,
1212
+ outbox: outboxCount ?? listOutbox(this.#db).length,
1176
1213
  upgrading: this.#upgrading,
1177
1214
  leaseState: this.#leaseState,
1178
1215
  schemaFloor: this.#schemaFloor,
@@ -1186,7 +1223,10 @@ export class SyncClient {
1186
1223
  * Re-entrant calls share the outer batch so a nested apply never
1187
1224
  * double-emits (e.g. purge → blob reconcile → replay inside one round).
1188
1225
  */
1189
- #applyBatch<T>(fn: (batch: ChangeAccumulator) => T): T {
1226
+ #applyBatch<T>(
1227
+ fn: (batch: ChangeAccumulator) => T,
1228
+ statusSnapshotOverride?: () => SyncStatusSnapshot,
1229
+ ): T {
1190
1230
  if (this.#batch !== undefined) return fn(this.#batch);
1191
1231
  const batch = new ChangeAccumulator();
1192
1232
  let revision: LocalRevision | undefined;
@@ -1199,7 +1239,9 @@ export class SyncClient {
1199
1239
  result = fn(batch);
1200
1240
  if (batch.touched) {
1201
1241
  revision = bumpLocalRevision(this.#db);
1202
- if (batch.statusChanged) status = this.#statusSnapshot();
1242
+ if (batch.statusChanged) {
1243
+ status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
1244
+ }
1203
1245
  }
1204
1246
  } finally {
1205
1247
  this.#batch = undefined;
@@ -2346,6 +2388,7 @@ export class SyncClient {
2346
2388
  );
2347
2389
  }
2348
2390
  this.#syncOutstanding = true;
2391
+ this.#beginDiagnosticsDeferral();
2349
2392
  const startedAtMs = this.#now();
2350
2393
  return this.#serialize(() => this.#runSync())
2351
2394
  .then(
@@ -2380,6 +2423,7 @@ export class SyncClient {
2380
2423
  )
2381
2424
  .finally(() => {
2382
2425
  this.#syncOutstanding = false;
2426
+ this.#endDiagnosticsDeferral();
2383
2427
  });
2384
2428
  }
2385
2429
 
@@ -2842,12 +2886,20 @@ export class SyncClient {
2842
2886
  string,
2843
2887
  ReadonlyMap<number, RejectionDetails>
2844
2888
  >();
2889
+ let lastFinalPushResult: PushResultFrame | undefined;
2845
2890
  for (const frame of message.frames) {
2846
- if (frame.type !== 'PUSH_RESULT_DETAILS') continue;
2847
- rejectionDetailsByCommit.set(
2848
- frame.clientCommitId,
2849
- new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
2850
- );
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
+ }
2851
2903
  }
2852
2904
 
2853
2905
  const header = message.frames[0];
@@ -2879,9 +2931,11 @@ export class SyncClient {
2879
2931
  let section: OpenSection | undefined;
2880
2932
  let errorFrame: ClientSyncError | undefined;
2881
2933
  let deltaCursor = -1;
2934
+ let responseOutboxCount: number | undefined;
2882
2935
 
2883
2936
  // Each durable observer transaction emits its own revisioned batch.
2884
2937
  // Async decrypt/download work happens outside SQLite transactions.
2938
+ this.#beginDiagnosticsDeferral();
2885
2939
  try {
2886
2940
  for (const frame of message.frames.slice(1)) {
2887
2941
  switch (frame.type) {
@@ -2895,17 +2949,26 @@ export class SyncClient {
2895
2949
  expiresAtMs: frame.expiresAtMs,
2896
2950
  });
2897
2951
  break;
2898
- case 'PUSH_RESULT':
2899
- this.#applyBatch((batch) =>
2900
- this.#handlePushResult(
2901
- frame,
2902
- commitsById,
2903
- summary,
2904
- batch,
2905
- rejectionDetailsByCommit.get(frame.clientCommitId),
2906
- ),
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),
2907
2968
  );
2969
+ responseOutboxCount = outboxCount;
2908
2970
  break;
2971
+ }
2909
2972
  case 'PUSH_RESULT_DETAILS':
2910
2973
  // Pre-indexed above so companion ordering remains wire-additive.
2911
2974
  break;
@@ -3103,10 +3166,14 @@ export class SyncClient {
3103
3166
  if (errorFrame !== undefined) break;
3104
3167
  }
3105
3168
  } finally {
3106
- // §7.1: local reads see outbox state applied optimistically — replay
3107
- // the still-pending commits on top of the freshly applied server state.
3108
- this.#replayOutbox();
3109
- 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
+ }
3110
3177
  }
3111
3178
 
3112
3179
  if (errorFrame !== undefined) throw errorFrame;
@@ -3136,9 +3203,10 @@ export class SyncClient {
3136
3203
  summary: MutableSummary,
3137
3204
  batch: ChangeAccumulator,
3138
3205
  rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
3139
- ): void {
3206
+ pruneOutcomes: boolean,
3207
+ ): boolean {
3140
3208
  const commit = commitsById.get(frame.clientCommitId);
3141
- if (commit === undefined) return;
3209
+ if (commit === undefined) return false;
3142
3210
  if (frame.status === 'applied' || frame.status === 'cached') {
3143
3211
  // §6.3: applied and cached both drain the outbox — cached means
3144
3212
  // "already applied, you may have missed the ack".
@@ -3152,11 +3220,13 @@ export class SyncClient {
3152
3220
  })),
3153
3221
  });
3154
3222
  deleteOutboxCommit(this.#db, frame.clientCommitId);
3155
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3223
+ if (pruneOutcomes) {
3224
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3225
+ }
3156
3226
  batch.status();
3157
3227
  batch.outcomes();
3158
3228
  summary.applied.push(frame.clientCommitId);
3159
- return;
3229
+ return true;
3160
3230
  }
3161
3231
  // rejected
3162
3232
  const cacheMiss = frame.results.some(
@@ -3169,7 +3239,7 @@ export class SyncClient {
3169
3239
  // §6.3: a serving failure, not the commit's outcome — keep the
3170
3240
  // commit queued and retry the identical push later.
3171
3241
  summary.retryable.push(frame.clientCommitId);
3172
- return;
3242
+ return false;
3173
3243
  }
3174
3244
  const outcomeResults: CommitOperationOutcome[] = [];
3175
3245
  for (const result of frame.results) {
@@ -3218,7 +3288,9 @@ export class SyncClient {
3218
3288
  results: outcomeResults,
3219
3289
  operations: commit.operations,
3220
3290
  });
3221
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3291
+ if (pruneOutcomes) {
3292
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3293
+ }
3222
3294
  batch.outcomes();
3223
3295
  // §7.2: remove the rejected optimistic layer. Before-images restore
3224
3296
  // validator-rejected updates even when the server emitted no new COMMIT;
@@ -3228,6 +3300,7 @@ export class SyncClient {
3228
3300
  });
3229
3301
  batch.status();
3230
3302
  summary.rejected.push(frame.clientCommitId);
3303
+ return true;
3231
3304
  }
3232
3305
 
3233
3306
  #decodeServerRow(
@@ -163,6 +163,10 @@ export type ClientDiagnosticsListener = (
163
163
  export class ClientDiagnosticsEmitter {
164
164
  readonly #listeners = new Set<ClientDiagnosticsListener>();
165
165
 
166
+ get observed(): boolean {
167
+ return this.#listeners.size > 0;
168
+ }
169
+
166
170
  on(listener: ClientDiagnosticsListener): () => void {
167
171
  this.#listeners.add(listener);
168
172
  return () => this.#listeners.delete(listener);