@syncular/client 0.7.0 → 0.9.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.
package/src/client.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  parseRealtimeServerEvent,
23
23
  REALTIME_TAG_DELTA,
24
24
  REALTIME_TAG_ROUND,
25
+ type RejectionDetails,
25
26
  type RequestFrame,
26
27
  type ResponseMessage,
27
28
  type RowColumn,
@@ -89,6 +90,20 @@ import {
89
90
  OutboxEncodeError,
90
91
  type OutboxOperation,
91
92
  } from './outbox';
93
+ import {
94
+ activeFailureRecords,
95
+ type CommitOperationOutcome,
96
+ type CommitOutcome,
97
+ type CommitOutcomeQuery,
98
+ type ConflictRecord,
99
+ listCommitOutcomes,
100
+ persistCommitOutcomeResolution,
101
+ pruneCommitOutcomes,
102
+ type RejectionRecord,
103
+ type ResolveCommitOutcomeInput,
104
+ commitOutcome as readCommitOutcome,
105
+ recordCommitOutcome,
106
+ } from './outcomes';
92
107
  import { assertReadOnlyQuery } from './query-guard';
93
108
  import {
94
109
  type ClientSchema,
@@ -159,30 +174,14 @@ export type MutationInput =
159
174
  readonly baseVersion?: number;
160
175
  };
161
176
 
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
- }
177
+ export type {
178
+ CommitOperationOutcome,
179
+ CommitOutcome,
180
+ CommitOutcomeQuery,
181
+ ConflictRecord,
182
+ RejectionRecord,
183
+ ResolveCommitOutcomeInput,
184
+ } from './outcomes';
186
185
 
187
186
  export interface SchemaFloor {
188
187
  readonly requiredSchemaVersion?: number;
@@ -246,6 +245,12 @@ export interface SyncClientLimits {
246
245
  * `withSqliteImage` and a segment downloader is configured (§5.3).
247
246
  */
248
247
  readonly accept?: number;
248
+ /**
249
+ * Maximum retained durable commit outcomes. Old applied/cached or resolved
250
+ * entries are pruned first; unresolved conflicts/rejections are never
251
+ * deleted to satisfy this cap. Defaults to 1,000.
252
+ */
253
+ readonly outcomeRetentionMaxEntries?: number;
249
254
  }
250
255
 
251
256
  export interface SyncClientConfig {
@@ -454,6 +459,7 @@ export class SyncClient {
454
459
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
455
460
  readonly #encryption: EncryptionConfig | undefined;
456
461
  readonly #now: () => number;
462
+ readonly #outcomeRetentionMaxEntries: number;
457
463
  #started = false;
458
464
  #lease: LeaderLease | undefined;
459
465
  #clientId = '';
@@ -511,6 +517,18 @@ export class SyncClient {
511
517
  this.#schema = compileClientSchema(config.schema);
512
518
  this.#encryption = config.encryption;
513
519
  this.#now = config.now ?? Date.now;
520
+ const outcomeRetentionMaxEntries =
521
+ config.limits?.outcomeRetentionMaxEntries ?? 1_000;
522
+ if (
523
+ !Number.isSafeInteger(outcomeRetentionMaxEntries) ||
524
+ outcomeRetentionMaxEntries < 1
525
+ ) {
526
+ throw new ClientSyncError(
527
+ 'sync.invalid_request',
528
+ 'outcomeRetentionMaxEntries must be a positive safe integer',
529
+ );
530
+ }
531
+ this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
514
532
  this.#hasBlobs = schemaHasBlobs(this.#schema);
515
533
  }
516
534
 
@@ -525,6 +543,14 @@ export class SyncClient {
525
543
  );
526
544
  ensureLocalSchema(this.#db, this.#schema);
527
545
  if (this.#hasBlobs) ensureBlobSchema(this.#db);
546
+ this.#db.transaction(() => {
547
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
548
+ });
549
+ const activeFailures = activeFailureRecords(
550
+ listCommitOutcomes(this.#db, { activeOnly: true }),
551
+ );
552
+ this.#conflicts = activeFailures.conflicts;
553
+ this.#rejections = activeFailures.rejections;
528
554
  const persisted = getMeta(this.#db, 'clientId');
529
555
  if (
530
556
  persisted !== undefined &&
@@ -1037,6 +1063,92 @@ export class SyncClient {
1037
1063
  return this.#rejections;
1038
1064
  }
1039
1065
 
1066
+ /** One durable final outcome by the originating client commit id. */
1067
+ commitOutcome(clientCommitId: string): CommitOutcome | undefined {
1068
+ this.#requireStarted();
1069
+ return readCommitOutcome(this.#db, clientCommitId);
1070
+ }
1071
+
1072
+ /** Newest-first durable outcome journal. */
1073
+ commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
1074
+ this.#requireStarted();
1075
+ return listCommitOutcomes(this.#db, query);
1076
+ }
1077
+
1078
+ /**
1079
+ * Mark a durable failure handled without deleting its evidence. Conflicts
1080
+ * may keep the server row or link to a replacement commit; rejections may
1081
+ * only be superseded by a named replacement. Applied/cached history may be
1082
+ * dismissed. The transition is one-way and survives restart.
1083
+ */
1084
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
1085
+ this.#requireStarted();
1086
+ const current = readCommitOutcome(this.#db, input.clientCommitId);
1087
+ if (current === undefined) {
1088
+ throw new ClientSyncError(
1089
+ 'sync.outcome_not_found',
1090
+ `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
1091
+ );
1092
+ }
1093
+ if (current.resolution !== 'active') return current;
1094
+ const replacement = input.replacementClientCommitId;
1095
+ if (input.resolution === 'superseded') {
1096
+ if (
1097
+ replacement === undefined ||
1098
+ replacement.length === 0 ||
1099
+ replacement === input.clientCommitId
1100
+ ) {
1101
+ throw new ClientSyncError(
1102
+ 'sync.invalid_request',
1103
+ 'superseded outcomes require a distinct replacementClientCommitId',
1104
+ );
1105
+ }
1106
+ } else if (replacement !== undefined) {
1107
+ throw new ClientSyncError(
1108
+ 'sync.invalid_request',
1109
+ 'replacementClientCommitId is valid only for superseded outcomes',
1110
+ );
1111
+ }
1112
+ const allowed =
1113
+ (current.status === 'conflict' &&
1114
+ (input.resolution === 'resolved_keep_server' ||
1115
+ input.resolution === 'superseded')) ||
1116
+ (current.status === 'rejected' && input.resolution === 'superseded') ||
1117
+ ((current.status === 'applied' || current.status === 'cached') &&
1118
+ input.resolution === 'dismissed');
1119
+ if (!allowed) {
1120
+ throw new ClientSyncError(
1121
+ 'sync.invalid_request',
1122
+ `resolution ${input.resolution} is invalid for ${current.status} outcome`,
1123
+ );
1124
+ }
1125
+
1126
+ return this.#applyBatch((batch) => {
1127
+ const resolved = persistCommitOutcomeResolution(
1128
+ this.#db,
1129
+ input,
1130
+ this.#now(),
1131
+ );
1132
+ if (resolved === undefined) {
1133
+ throw new ClientSyncError(
1134
+ 'sync.outcome_not_found',
1135
+ `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
1136
+ );
1137
+ }
1138
+ this.#conflicts = this.#conflicts.filter(
1139
+ (record) => record.clientCommitId !== input.clientCommitId,
1140
+ );
1141
+ this.#rejections = this.#rejections.filter(
1142
+ (record) => record.clientCommitId !== input.clientCommitId,
1143
+ );
1144
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1145
+ batch.outcomes();
1146
+ if (current.status === 'conflict') batch.conflicts();
1147
+ if (current.status === 'rejected') batch.rejections();
1148
+ return resolved;
1149
+ });
1150
+ }
1151
+
1040
1152
  /** Non-undefined once the server declared a schema floor (§1.6). */
1041
1153
  get schemaFloor(): SchemaFloor | undefined {
1042
1154
  return this.#schemaFloor;
@@ -1391,9 +1503,16 @@ export class SyncClient {
1391
1503
  * Returns the generated `clientCommitId`.
1392
1504
  */
1393
1505
  mutate(mutations: readonly MutationInput[]): string {
1506
+ return this.#recordMutations(mutations);
1507
+ }
1508
+
1509
+ #recordMutations(
1510
+ mutations: readonly MutationInput[],
1511
+ changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
1512
+ ): string {
1394
1513
  this.#requireStarted();
1395
1514
  const clientCommitId = crypto.randomUUID();
1396
- const operations: OutboxOperation[] = mutations.map((mutation) => {
1515
+ const operations: OutboxOperation[] = mutations.map((mutation, index) => {
1397
1516
  const table = this.#table(mutation.table);
1398
1517
  if (mutation.op === 'delete') {
1399
1518
  return {
@@ -1425,6 +1544,9 @@ export class SyncClient {
1425
1544
  ? { baseVersion: mutation.baseVersion }
1426
1545
  : {}),
1427
1546
  values: json,
1547
+ ...(changedFieldsByIndex[index] !== undefined
1548
+ ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
1549
+ : {}),
1428
1550
  };
1429
1551
  });
1430
1552
  this.#applyBatch((batch) => {
@@ -1477,19 +1599,23 @@ export class SyncClient {
1477
1599
  for (const column of compiled.columns as readonly RowColumn[]) {
1478
1600
  record[column.name] = fromSqlValue(column, row[column.name] ?? null);
1479
1601
  }
1480
- for (const [name, value] of normalizeRecordKeys(compiled, partial)) {
1602
+ const normalizedPartial = normalizeRecordKeys(compiled, partial);
1603
+ for (const [name, value] of normalizedPartial) {
1481
1604
  record[name] = value;
1482
1605
  }
1483
- return this.mutate([
1484
- {
1485
- table,
1486
- op: 'upsert',
1487
- values: record,
1488
- ...(options?.baseVersion !== undefined
1489
- ? { baseVersion: options.baseVersion }
1490
- : {}),
1491
- },
1492
- ]);
1606
+ return this.#recordMutations(
1607
+ [
1608
+ {
1609
+ table,
1610
+ op: 'upsert',
1611
+ values: record,
1612
+ ...(options?.baseVersion !== undefined
1613
+ ? { baseVersion: options.baseVersion }
1614
+ : {}),
1615
+ },
1616
+ ],
1617
+ [[...normalizedPartial.keys()].sort()],
1618
+ );
1493
1619
  }
1494
1620
 
1495
1621
  /** Host-facing patch result with explicit network work intent (§7.5). */
@@ -1595,7 +1721,7 @@ export class SyncClient {
1595
1721
  deleteLocalRow(this.#db, table, operation.rowId);
1596
1722
  }
1597
1723
  }
1598
- this.#rejections.push({
1724
+ const rejection: RejectionRecord = {
1599
1725
  clientCommitId: commit.clientCommitId,
1600
1726
  opIndex: 0,
1601
1727
  code: OUTBOX_INCOMPATIBLE_CODE,
@@ -1604,9 +1730,18 @@ export class SyncClient {
1604
1730
  ...(commit.operations[0] !== undefined
1605
1731
  ? { operation: commit.operations[0] }
1606
1732
  : {}),
1733
+ };
1734
+ this.#rejections.push(rejection);
1735
+ recordCommitOutcome(this.#db, {
1736
+ clientCommitId: commit.clientCommitId,
1737
+ status: 'rejected',
1738
+ recordedAtMs: this.#now(),
1739
+ results: [{ status: 'error', rejection }],
1607
1740
  });
1741
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1608
1742
  batch.status();
1609
1743
  batch.rejections();
1744
+ batch.outcomes();
1610
1745
  });
1611
1746
  }
1612
1747
 
@@ -2030,6 +2165,17 @@ export class SyncClient {
2030
2165
  const subsById = new Map(
2031
2166
  (sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]),
2032
2167
  );
2168
+ const rejectionDetailsByCommit = new Map<
2169
+ string,
2170
+ ReadonlyMap<number, RejectionDetails>
2171
+ >();
2172
+ for (const frame of message.frames) {
2173
+ if (frame.type !== 'PUSH_RESULT_DETAILS') continue;
2174
+ rejectionDetailsByCommit.set(
2175
+ frame.clientCommitId,
2176
+ new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
2177
+ );
2178
+ }
2033
2179
 
2034
2180
  const header = message.frames[0];
2035
2181
  if (header?.type !== 'RESP_HEADER') {
@@ -2078,9 +2224,18 @@ export class SyncClient {
2078
2224
  break;
2079
2225
  case 'PUSH_RESULT':
2080
2226
  this.#applyBatch((batch) =>
2081
- this.#handlePushResult(frame, commitsById, summary, batch),
2227
+ this.#handlePushResult(
2228
+ frame,
2229
+ commitsById,
2230
+ summary,
2231
+ batch,
2232
+ rejectionDetailsByCommit.get(frame.clientCommitId),
2233
+ ),
2082
2234
  );
2083
2235
  break;
2236
+ case 'PUSH_RESULT_DETAILS':
2237
+ // Pre-indexed above so companion ordering remains wire-additive.
2238
+ break;
2084
2239
  case 'SUB_START': {
2085
2240
  const sub = subsById.get(frame.id);
2086
2241
  const fresh =
@@ -2307,14 +2462,26 @@ export class SyncClient {
2307
2462
  commitsById: ReadonlyMap<string, OutboxCommit>,
2308
2463
  summary: MutableSummary,
2309
2464
  batch: ChangeAccumulator,
2465
+ rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
2310
2466
  ): void {
2311
2467
  const commit = commitsById.get(frame.clientCommitId);
2312
2468
  if (commit === undefined) return;
2313
2469
  if (frame.status === 'applied' || frame.status === 'cached') {
2314
2470
  // §6.3: applied and cached both drain the outbox — cached means
2315
2471
  // "already applied, you may have missed the ack".
2472
+ recordCommitOutcome(this.#db, {
2473
+ clientCommitId: frame.clientCommitId,
2474
+ status: frame.status,
2475
+ recordedAtMs: this.#now(),
2476
+ results: frame.results.map((result) => ({
2477
+ status: 'applied' as const,
2478
+ opIndex: result.opIndex,
2479
+ })),
2480
+ });
2316
2481
  deleteOutboxCommit(this.#db, frame.clientCommitId);
2482
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2317
2483
  batch.status();
2484
+ batch.outcomes();
2318
2485
  summary.applied.push(frame.clientCommitId);
2319
2486
  return;
2320
2487
  }
@@ -2331,6 +2498,7 @@ export class SyncClient {
2331
2498
  summary.retryable.push(frame.clientCommitId);
2332
2499
  return;
2333
2500
  }
2501
+ const outcomeResults: CommitOperationOutcome[] = [];
2334
2502
  for (const result of frame.results) {
2335
2503
  const operation = commit.operations[result.opIndex];
2336
2504
  if (result.status === 'conflict') {
@@ -2346,21 +2514,38 @@ export class SyncClient {
2346
2514
  ...(operation !== undefined ? { operation } : {}),
2347
2515
  };
2348
2516
  this.#conflicts.push(conflict);
2517
+ outcomeResults.push({ status: 'conflict', conflict });
2349
2518
  batch.conflicts();
2350
2519
  summary.conflicts.push(conflict);
2351
2520
  this.#config.onConflict?.(conflict);
2352
2521
  } else if (result.status === 'error') {
2353
- this.#rejections.push({
2522
+ const details = rejectionDetails?.get(result.opIndex);
2523
+ const rejection: RejectionRecord = {
2354
2524
  clientCommitId: frame.clientCommitId,
2355
2525
  opIndex: result.opIndex,
2356
2526
  code: result.code,
2357
2527
  message: result.message,
2358
2528
  retryable: result.retryable,
2529
+ ...(details !== undefined ? { details } : {}),
2359
2530
  ...(operation !== undefined ? { operation } : {}),
2360
- });
2531
+ };
2532
+ this.#rejections.push(rejection);
2533
+ outcomeResults.push({ status: 'error', rejection });
2361
2534
  batch.rejections();
2535
+ } else {
2536
+ outcomeResults.push({ status: 'applied', opIndex: result.opIndex });
2362
2537
  }
2363
2538
  }
2539
+ recordCommitOutcome(this.#db, {
2540
+ clientCommitId: frame.clientCommitId,
2541
+ status: outcomeResults.some((result) => result.status === 'conflict')
2542
+ ? 'conflict'
2543
+ : 'rejected',
2544
+ recordedAtMs: this.#now(),
2545
+ results: outcomeResults,
2546
+ });
2547
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2548
+ batch.outcomes();
2364
2549
  // §7.2: stop optimistic display and decide about dependents — the
2365
2550
  // commit leaves the outbox; rows it created that the server never
2366
2551
  // confirmed are undone here, rows it overwrote reconcile via the pull
@@ -2680,10 +2865,47 @@ export class SyncClient {
2680
2865
  try {
2681
2866
  deleteScopedRows(this.#db, table, lastEffective);
2682
2867
  batch.scopeMap(table, lastEffective);
2683
- if (
2684
- dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0
2685
- ) {
2868
+ const pendingById = new Map(
2869
+ listOutbox(this.#db).map((commit) => [
2870
+ commit.clientCommitId,
2871
+ commit,
2872
+ ]),
2873
+ );
2874
+ const droppedIds = dropOutboxCommitsInScope(
2875
+ this.#db,
2876
+ table,
2877
+ lastEffective,
2878
+ );
2879
+ if (droppedIds.length > 0) {
2880
+ for (const clientCommitId of droppedIds) {
2881
+ const commit = pendingById.get(clientCommitId);
2882
+ if (commit === undefined) continue;
2883
+ const results: CommitOperationOutcome[] = commit.operations.map(
2884
+ (operation, opIndex) => {
2885
+ const rejection: RejectionRecord = {
2886
+ clientCommitId,
2887
+ opIndex,
2888
+ code: 'sync.scope_revoked',
2889
+ message:
2890
+ 'the commit was dropped because its effective scope was revoked',
2891
+ retryable: false,
2892
+ operation,
2893
+ };
2894
+ this.#rejections.push(rejection);
2895
+ return { status: 'error', rejection };
2896
+ },
2897
+ );
2898
+ recordCommitOutcome(this.#db, {
2899
+ clientCommitId,
2900
+ status: 'rejected',
2901
+ recordedAtMs: this.#now(),
2902
+ results,
2903
+ });
2904
+ }
2905
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2686
2906
  batch.status();
2907
+ batch.rejections();
2908
+ batch.outcomes();
2687
2909
  }
2688
2910
  this.#reconcileBlobs(true);
2689
2911
  } 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
  }
package/src/outbox.ts CHANGED
@@ -29,6 +29,11 @@ export interface OutboxOperation {
29
29
  readonly baseVersion?: number;
30
30
  /** Full-row values keyed by column name; present iff `op` is `upsert`. */
31
31
  readonly values?: Readonly<Record<string, JsonRowValue>>;
32
+ /**
33
+ * Local-only normalized columns intentionally supplied to `patch()`.
34
+ * Absent for full-row mutate/upsert because intent is then unknown.
35
+ */
36
+ readonly changedFields?: readonly string[];
32
37
  }
33
38
 
34
39
  export interface OutboxCommit {