@syncular/client 0.7.0 → 0.8.0

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.
@@ -385,6 +385,7 @@ export class ReactiveClientStore {
385
385
  #offChange;
386
386
  status;
387
387
  conflicts;
388
+ outcomes;
388
389
  constructor(client) {
389
390
  this.client = client;
390
391
  const status = new ValueEntry({ status: undefined, error: undefined, isLoading: true }, async () => {
@@ -421,10 +422,24 @@ export class ReactiveClientStore {
421
422
  };
422
423
  }
423
424
  });
425
+ const outcomes = new ValueEntry({ outcomes: [], error: undefined, isLoading: true }, async () => {
426
+ try {
427
+ return {
428
+ outcomes: await client.commitOutcomes(),
429
+ error: undefined,
430
+ isLoading: false,
431
+ };
432
+ }
433
+ catch (error) {
434
+ return { outcomes: [], error: errorOf(error), isLoading: false };
435
+ }
436
+ });
424
437
  this.status = status;
425
438
  this.conflicts = conflicts;
439
+ this.outcomes = outcomes;
426
440
  status.refresh();
427
441
  conflicts.refresh();
442
+ outcomes.refresh();
428
443
  this.start();
429
444
  }
430
445
  query(spec) {
@@ -563,6 +578,8 @@ export class ReactiveClientStore {
563
578
  if (batch.conflictsChanged || batch.rejectionsChanged) {
564
579
  this.conflicts.refresh();
565
580
  }
581
+ if (batch.outcomesChanged)
582
+ this.outcomes.refresh();
566
583
  });
567
584
  }
568
585
  dispose() {
package/dist/schema.js CHANGED
@@ -184,6 +184,18 @@ export function ensureLocalSchema(db, schema) {
184
184
  client_commit_id TEXT NOT NULL UNIQUE,
185
185
  created_at_ms INTEGER NOT NULL,
186
186
  operations TEXT NOT NULL)`);
187
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
188
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
189
+ client_commit_id TEXT NOT NULL UNIQUE,
190
+ status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
191
+ recorded_at_ms INTEGER NOT NULL,
192
+ results TEXT NOT NULL,
193
+ resolution TEXT NOT NULL DEFAULT 'active'
194
+ CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
195
+ resolved_at_ms INTEGER,
196
+ replacement_client_commit_id TEXT)`);
197
+ db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
198
+ ON _syncular_commit_outcomes(resolution, seq)`);
187
199
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
188
200
  id TEXT PRIMARY KEY,
189
201
  tbl TEXT NOT NULL,
@@ -286,6 +286,9 @@ export function startSyncWorker(overrides = {}) {
286
286
  statusSnapshot: () => requireClient().statusSnapshot(),
287
287
  conflicts: () => requireClient().conflicts,
288
288
  rejections: () => requireClient().rejections,
289
+ commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
290
+ commitOutcomes: (query) => requireClient().commitOutcomes(query),
291
+ resolveCommitOutcome: (input) => requireClient().resolveCommitOutcome(input),
289
292
  schemaFloor: () => requireClient().schemaFloor,
290
293
  leaseState: () => requireClient().leaseState,
291
294
  upgrading: () => requireClient().upgrading,
@@ -29,6 +29,7 @@ import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type Inv
29
29
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
30
30
  import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
31
31
  import type { OutboxCommit } from './outbox.js';
32
+ import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
32
33
  import type { ClientSchema } from './schema.js';
33
34
  import type { SubscriptionRecord } from './state.js';
34
35
  import type { WindowBase } from './window.js';
@@ -149,6 +150,9 @@ export declare class SyncClientHandle {
149
150
  statusSnapshot(): Promise<SyncStatusSnapshot>;
150
151
  conflicts(): Promise<readonly ConflictRecord[]>;
151
152
  rejections(): Promise<readonly RejectionRecord[]>;
153
+ commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
154
+ commitOutcomes(query?: CommitOutcomeQuery): Promise<readonly CommitOutcome[]>;
155
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): Promise<CommitOutcome>;
152
156
  schemaFloor(): Promise<SchemaFloor | undefined>;
153
157
  leaseState(): Promise<LeaseState | undefined>;
154
158
  /** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
@@ -186,6 +186,15 @@ export class SyncClientHandle {
186
186
  rejections() {
187
187
  return this.#call('rejections', []);
188
188
  }
189
+ commitOutcome(clientCommitId) {
190
+ return this.#call('commitOutcome', [clientCommitId]);
191
+ }
192
+ commitOutcomes(query = {}) {
193
+ return this.#call('commitOutcomes', [query]);
194
+ }
195
+ resolveCommitOutcome(input) {
196
+ return this.#call('resolveCommitOutcome', [input]);
197
+ }
189
198
  schemaFloor() {
190
199
  return this.#call('schemaFloor', []);
191
200
  }
@@ -22,6 +22,7 @@ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryRead
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
23
  import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
24
24
  import type { OutboxCommit } from './outbox.js';
25
+ import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
25
26
  import type { ClientSchema } from './schema.js';
26
27
  import type { SubscriptionRecord } from './state.js';
27
28
  import type { WindowBase } from './window.js';
@@ -93,6 +94,9 @@ export interface WorkerApi {
93
94
  statusSnapshot(): SyncStatusSnapshot;
94
95
  conflicts(): readonly ConflictRecord[];
95
96
  rejections(): readonly RejectionRecord[];
97
+ commitOutcome(clientCommitId: string): CommitOutcome | undefined;
98
+ commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
99
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
96
100
  schemaFloor(): SchemaFloor | undefined;
97
101
  /** §7.3.5: the opaque auth-lease state, or undefined. */
98
102
  leaseState(): LeaseState | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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.7.0"
84
+ "@syncular/core": "0.8.0"
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.7.0",
95
+ "@syncular/server": "0.8.0",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -89,6 +89,20 @@ import {
89
89
  OutboxEncodeError,
90
90
  type OutboxOperation,
91
91
  } from './outbox';
92
+ import {
93
+ activeFailureRecords,
94
+ type CommitOperationOutcome,
95
+ type CommitOutcome,
96
+ type CommitOutcomeQuery,
97
+ type ConflictRecord,
98
+ listCommitOutcomes,
99
+ persistCommitOutcomeResolution,
100
+ pruneCommitOutcomes,
101
+ type RejectionRecord,
102
+ type ResolveCommitOutcomeInput,
103
+ commitOutcome as readCommitOutcome,
104
+ recordCommitOutcome,
105
+ } from './outcomes';
92
106
  import { assertReadOnlyQuery } from './query-guard';
93
107
  import {
94
108
  type ClientSchema,
@@ -159,30 +173,14 @@ export type MutationInput =
159
173
  readonly baseVersion?: number;
160
174
  };
161
175
 
162
- /** A §6.3 conflict result, surfaced to the app — never auto-resolved. */
163
- export interface ConflictRecord {
164
- readonly clientCommitId: string;
165
- readonly opIndex: number;
166
- readonly table: string;
167
- readonly rowId: string;
168
- readonly code: string;
169
- readonly message: string;
170
- readonly serverVersion: number;
171
- /** The current server row, decoded — resolve without a round-trip. */
172
- readonly serverRow: Readonly<Record<string, RowValue>>;
173
- /** The losing local operation (absent only for malformed op indexes). */
174
- readonly operation?: OutboxOperation;
175
- }
176
-
177
- /** A non-conflict `error` result from a rejected commit (§6.3). */
178
- export interface RejectionRecord {
179
- readonly clientCommitId: string;
180
- readonly opIndex: number;
181
- readonly code: string;
182
- readonly message: string;
183
- readonly retryable: boolean;
184
- readonly operation?: OutboxOperation;
185
- }
176
+ export type {
177
+ CommitOperationOutcome,
178
+ CommitOutcome,
179
+ CommitOutcomeQuery,
180
+ ConflictRecord,
181
+ RejectionRecord,
182
+ ResolveCommitOutcomeInput,
183
+ } from './outcomes';
186
184
 
187
185
  export interface SchemaFloor {
188
186
  readonly requiredSchemaVersion?: number;
@@ -246,6 +244,12 @@ export interface SyncClientLimits {
246
244
  * `withSqliteImage` and a segment downloader is configured (§5.3).
247
245
  */
248
246
  readonly accept?: number;
247
+ /**
248
+ * Maximum retained durable commit outcomes. Old applied/cached or resolved
249
+ * entries are pruned first; unresolved conflicts/rejections are never
250
+ * deleted to satisfy this cap. Defaults to 1,000.
251
+ */
252
+ readonly outcomeRetentionMaxEntries?: number;
249
253
  }
250
254
 
251
255
  export interface SyncClientConfig {
@@ -454,6 +458,7 @@ export class SyncClient {
454
458
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
455
459
  readonly #encryption: EncryptionConfig | undefined;
456
460
  readonly #now: () => number;
461
+ readonly #outcomeRetentionMaxEntries: number;
457
462
  #started = false;
458
463
  #lease: LeaderLease | undefined;
459
464
  #clientId = '';
@@ -511,6 +516,18 @@ export class SyncClient {
511
516
  this.#schema = compileClientSchema(config.schema);
512
517
  this.#encryption = config.encryption;
513
518
  this.#now = config.now ?? Date.now;
519
+ const outcomeRetentionMaxEntries =
520
+ config.limits?.outcomeRetentionMaxEntries ?? 1_000;
521
+ if (
522
+ !Number.isSafeInteger(outcomeRetentionMaxEntries) ||
523
+ outcomeRetentionMaxEntries < 1
524
+ ) {
525
+ throw new ClientSyncError(
526
+ 'sync.invalid_request',
527
+ 'outcomeRetentionMaxEntries must be a positive safe integer',
528
+ );
529
+ }
530
+ this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
514
531
  this.#hasBlobs = schemaHasBlobs(this.#schema);
515
532
  }
516
533
 
@@ -525,6 +542,14 @@ export class SyncClient {
525
542
  );
526
543
  ensureLocalSchema(this.#db, this.#schema);
527
544
  if (this.#hasBlobs) ensureBlobSchema(this.#db);
545
+ this.#db.transaction(() => {
546
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
547
+ });
548
+ const activeFailures = activeFailureRecords(
549
+ listCommitOutcomes(this.#db, { activeOnly: true }),
550
+ );
551
+ this.#conflicts = activeFailures.conflicts;
552
+ this.#rejections = activeFailures.rejections;
528
553
  const persisted = getMeta(this.#db, 'clientId');
529
554
  if (
530
555
  persisted !== undefined &&
@@ -1037,6 +1062,92 @@ export class SyncClient {
1037
1062
  return this.#rejections;
1038
1063
  }
1039
1064
 
1065
+ /** One durable final outcome by the originating client commit id. */
1066
+ commitOutcome(clientCommitId: string): CommitOutcome | undefined {
1067
+ this.#requireStarted();
1068
+ return readCommitOutcome(this.#db, clientCommitId);
1069
+ }
1070
+
1071
+ /** Newest-first durable outcome journal. */
1072
+ commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
1073
+ this.#requireStarted();
1074
+ return listCommitOutcomes(this.#db, query);
1075
+ }
1076
+
1077
+ /**
1078
+ * Mark a durable failure handled without deleting its evidence. Conflicts
1079
+ * may keep the server row or link to a replacement commit; rejections may
1080
+ * only be superseded by a named replacement. Applied/cached history may be
1081
+ * dismissed. The transition is one-way and survives restart.
1082
+ */
1083
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
1084
+ this.#requireStarted();
1085
+ const current = readCommitOutcome(this.#db, input.clientCommitId);
1086
+ if (current === undefined) {
1087
+ throw new ClientSyncError(
1088
+ 'sync.outcome_not_found',
1089
+ `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
1090
+ );
1091
+ }
1092
+ if (current.resolution !== 'active') return current;
1093
+ const replacement = input.replacementClientCommitId;
1094
+ if (input.resolution === 'superseded') {
1095
+ if (
1096
+ replacement === undefined ||
1097
+ replacement.length === 0 ||
1098
+ replacement === input.clientCommitId
1099
+ ) {
1100
+ throw new ClientSyncError(
1101
+ 'sync.invalid_request',
1102
+ 'superseded outcomes require a distinct replacementClientCommitId',
1103
+ );
1104
+ }
1105
+ } else if (replacement !== undefined) {
1106
+ throw new ClientSyncError(
1107
+ 'sync.invalid_request',
1108
+ 'replacementClientCommitId is valid only for superseded outcomes',
1109
+ );
1110
+ }
1111
+ const allowed =
1112
+ (current.status === 'conflict' &&
1113
+ (input.resolution === 'resolved_keep_server' ||
1114
+ input.resolution === 'superseded')) ||
1115
+ (current.status === 'rejected' && input.resolution === 'superseded') ||
1116
+ ((current.status === 'applied' || current.status === 'cached') &&
1117
+ input.resolution === 'dismissed');
1118
+ if (!allowed) {
1119
+ throw new ClientSyncError(
1120
+ 'sync.invalid_request',
1121
+ `resolution ${input.resolution} is invalid for ${current.status} outcome`,
1122
+ );
1123
+ }
1124
+
1125
+ return this.#applyBatch((batch) => {
1126
+ const resolved = persistCommitOutcomeResolution(
1127
+ this.#db,
1128
+ input,
1129
+ this.#now(),
1130
+ );
1131
+ if (resolved === undefined) {
1132
+ throw new ClientSyncError(
1133
+ 'sync.outcome_not_found',
1134
+ `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
1135
+ );
1136
+ }
1137
+ this.#conflicts = this.#conflicts.filter(
1138
+ (record) => record.clientCommitId !== input.clientCommitId,
1139
+ );
1140
+ this.#rejections = this.#rejections.filter(
1141
+ (record) => record.clientCommitId !== input.clientCommitId,
1142
+ );
1143
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1144
+ batch.outcomes();
1145
+ if (current.status === 'conflict') batch.conflicts();
1146
+ if (current.status === 'rejected') batch.rejections();
1147
+ return resolved;
1148
+ });
1149
+ }
1150
+
1040
1151
  /** Non-undefined once the server declared a schema floor (§1.6). */
1041
1152
  get schemaFloor(): SchemaFloor | undefined {
1042
1153
  return this.#schemaFloor;
@@ -1595,7 +1706,7 @@ export class SyncClient {
1595
1706
  deleteLocalRow(this.#db, table, operation.rowId);
1596
1707
  }
1597
1708
  }
1598
- this.#rejections.push({
1709
+ const rejection: RejectionRecord = {
1599
1710
  clientCommitId: commit.clientCommitId,
1600
1711
  opIndex: 0,
1601
1712
  code: OUTBOX_INCOMPATIBLE_CODE,
@@ -1604,9 +1715,18 @@ export class SyncClient {
1604
1715
  ...(commit.operations[0] !== undefined
1605
1716
  ? { operation: commit.operations[0] }
1606
1717
  : {}),
1718
+ };
1719
+ this.#rejections.push(rejection);
1720
+ recordCommitOutcome(this.#db, {
1721
+ clientCommitId: commit.clientCommitId,
1722
+ status: 'rejected',
1723
+ recordedAtMs: this.#now(),
1724
+ results: [{ status: 'error', rejection }],
1607
1725
  });
1726
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1608
1727
  batch.status();
1609
1728
  batch.rejections();
1729
+ batch.outcomes();
1610
1730
  });
1611
1731
  }
1612
1732
 
@@ -2313,8 +2433,19 @@ export class SyncClient {
2313
2433
  if (frame.status === 'applied' || frame.status === 'cached') {
2314
2434
  // §6.3: applied and cached both drain the outbox — cached means
2315
2435
  // "already applied, you may have missed the ack".
2436
+ recordCommitOutcome(this.#db, {
2437
+ clientCommitId: frame.clientCommitId,
2438
+ status: frame.status,
2439
+ recordedAtMs: this.#now(),
2440
+ results: frame.results.map((result) => ({
2441
+ status: 'applied' as const,
2442
+ opIndex: result.opIndex,
2443
+ })),
2444
+ });
2316
2445
  deleteOutboxCommit(this.#db, frame.clientCommitId);
2446
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2317
2447
  batch.status();
2448
+ batch.outcomes();
2318
2449
  summary.applied.push(frame.clientCommitId);
2319
2450
  return;
2320
2451
  }
@@ -2331,6 +2462,7 @@ export class SyncClient {
2331
2462
  summary.retryable.push(frame.clientCommitId);
2332
2463
  return;
2333
2464
  }
2465
+ const outcomeResults: CommitOperationOutcome[] = [];
2334
2466
  for (const result of frame.results) {
2335
2467
  const operation = commit.operations[result.opIndex];
2336
2468
  if (result.status === 'conflict') {
@@ -2346,21 +2478,36 @@ export class SyncClient {
2346
2478
  ...(operation !== undefined ? { operation } : {}),
2347
2479
  };
2348
2480
  this.#conflicts.push(conflict);
2481
+ outcomeResults.push({ status: 'conflict', conflict });
2349
2482
  batch.conflicts();
2350
2483
  summary.conflicts.push(conflict);
2351
2484
  this.#config.onConflict?.(conflict);
2352
2485
  } else if (result.status === 'error') {
2353
- this.#rejections.push({
2486
+ const rejection: RejectionRecord = {
2354
2487
  clientCommitId: frame.clientCommitId,
2355
2488
  opIndex: result.opIndex,
2356
2489
  code: result.code,
2357
2490
  message: result.message,
2358
2491
  retryable: result.retryable,
2359
2492
  ...(operation !== undefined ? { operation } : {}),
2360
- });
2493
+ };
2494
+ this.#rejections.push(rejection);
2495
+ outcomeResults.push({ status: 'error', rejection });
2361
2496
  batch.rejections();
2497
+ } else {
2498
+ outcomeResults.push({ status: 'applied', opIndex: result.opIndex });
2362
2499
  }
2363
2500
  }
2501
+ recordCommitOutcome(this.#db, {
2502
+ clientCommitId: frame.clientCommitId,
2503
+ status: outcomeResults.some((result) => result.status === 'conflict')
2504
+ ? 'conflict'
2505
+ : 'rejected',
2506
+ recordedAtMs: this.#now(),
2507
+ results: outcomeResults,
2508
+ });
2509
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2510
+ batch.outcomes();
2364
2511
  // §7.2: stop optimistic display and decide about dependents — the
2365
2512
  // commit leaves the outbox; rows it created that the server never
2366
2513
  // confirmed are undone here, rows it overwrote reconcile via the pull
@@ -2680,10 +2827,47 @@ export class SyncClient {
2680
2827
  try {
2681
2828
  deleteScopedRows(this.#db, table, lastEffective);
2682
2829
  batch.scopeMap(table, lastEffective);
2683
- if (
2684
- dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0
2685
- ) {
2830
+ const pendingById = new Map(
2831
+ listOutbox(this.#db).map((commit) => [
2832
+ commit.clientCommitId,
2833
+ commit,
2834
+ ]),
2835
+ );
2836
+ const droppedIds = dropOutboxCommitsInScope(
2837
+ this.#db,
2838
+ table,
2839
+ lastEffective,
2840
+ );
2841
+ if (droppedIds.length > 0) {
2842
+ for (const clientCommitId of droppedIds) {
2843
+ const commit = pendingById.get(clientCommitId);
2844
+ if (commit === undefined) continue;
2845
+ const results: CommitOperationOutcome[] = commit.operations.map(
2846
+ (operation, opIndex) => {
2847
+ const rejection: RejectionRecord = {
2848
+ clientCommitId,
2849
+ opIndex,
2850
+ code: 'sync.scope_revoked',
2851
+ message:
2852
+ 'the commit was dropped because its effective scope was revoked',
2853
+ retryable: false,
2854
+ operation,
2855
+ };
2856
+ this.#rejections.push(rejection);
2857
+ return { status: 'error', rejection };
2858
+ },
2859
+ );
2860
+ recordCommitOutcome(this.#db, {
2861
+ clientCommitId,
2862
+ status: 'rejected',
2863
+ recordedAtMs: this.#now(),
2864
+ results,
2865
+ });
2866
+ }
2867
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2686
2868
  batch.status();
2869
+ batch.rejections();
2870
+ batch.outcomes();
2687
2871
  }
2688
2872
  this.#reconcileBlobs(true);
2689
2873
  } catch (error) {
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export * from './leader-lock';
22
22
  export * from './multi-tab';
23
23
  export * from './naming';
24
24
  export * from './outbox';
25
+ export * from './outcomes';
25
26
  export * from './query-guard';
26
27
  export * from './reactive-store';
27
28
  export * from './schema';
@@ -38,6 +38,7 @@ export interface ClientChangeBatch {
38
38
  readonly status?: SyncStatusSnapshot;
39
39
  readonly conflictsChanged: boolean;
40
40
  readonly rejectionsChanged: boolean;
41
+ readonly outcomesChanged: boolean;
41
42
  }
42
43
 
43
44
  export type ClientChangeListener = (batch: ClientChangeBatch) => void;
@@ -81,6 +82,7 @@ export class ChangeAccumulator {
81
82
  #status = false;
82
83
  #conflicts = false;
83
84
  #rejections = false;
85
+ #outcomes = false;
84
86
 
85
87
  /** Mark a whole table dirty, discarding any weaker scope-only facts. */
86
88
  table(name: string): void {
@@ -126,6 +128,10 @@ export class ChangeAccumulator {
126
128
  this.#rejections = true;
127
129
  }
128
130
 
131
+ outcomes(): void {
132
+ this.#outcomes = true;
133
+ }
134
+
129
135
  /** Add precise keys for a requested/effective scope map. */
130
136
  scopeMap(table: CompiledClientTable, scopes: ScopeMap): void {
131
137
  for (const [variable, values] of Object.entries(scopes)) {
@@ -154,7 +160,8 @@ export class ChangeAccumulator {
154
160
  this.#windows.size > 0 ||
155
161
  this.#status ||
156
162
  this.#conflicts ||
157
- this.#rejections
163
+ this.#rejections ||
164
+ this.#outcomes
158
165
  );
159
166
  }
160
167
 
@@ -192,6 +199,7 @@ export class ChangeAccumulator {
192
199
  ...(this.#status ? { status: status as SyncStatusSnapshot } : {}),
193
200
  conflictsChanged: this.#conflicts,
194
201
  rejectionsChanged: this.#rejections,
202
+ outcomesChanged: this.#outcomes,
195
203
  };
196
204
  }
197
205
  }