@syncular/client 0.30.7 → 0.30.10

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
@@ -1,3 +1,9 @@
1
+ import {
2
+ dropFailedRows,
3
+ failedOverlayCommits,
4
+ retainFailedRows,
5
+ restoreFailedBases,
6
+ } from './failed-overlay';
1
7
  import {
2
8
  ProgressEmitter,
3
9
  type SyncProgress,
@@ -88,6 +94,7 @@ import {
88
94
  import type { EncryptionConfig } from './encryption';
89
95
  import {
90
96
  ClientSyncError,
97
+ invalidRequest,
91
98
  classifySqliteFailure,
92
99
  REALTIME_LOST_CODE,
93
100
  RealtimeUnavailableError,
@@ -244,8 +251,8 @@ import {
244
251
  export type MutationInput =
245
252
  | {
246
253
  readonly table: string;
247
- readonly op: 'upsert';
248
- /** Full-row values keyed by column name (§6.1: full row payloads). */
254
+ readonly op: 'upsert' | 'patch';
255
+ /** Column-keyed full row for upsert, or primary key plus present columns for patch. */
249
256
  readonly values: Readonly<Record<string, unknown>>;
250
257
  readonly baseVersion?: number;
251
258
  }
@@ -260,6 +267,7 @@ export type {
260
267
  CommitOperationOutcome,
261
268
  CommitOutcome,
262
269
  CommitOutcomeQuery,
270
+ RetainedCommitRow,
263
271
  ConflictRecord,
264
272
  RejectionRecord,
265
273
  ResolveCommitOutcomeInput,
@@ -336,6 +344,8 @@ export interface SyncClientLimits {
336
344
  }
337
345
 
338
346
  export interface SyncClientConfig {
347
+ /** Keep rejected/conflicting local intent visible until explicit resolution. */
348
+ readonly retainFailedCommits?: boolean;
339
349
  readonly database: ClientDatabase;
340
350
  readonly schema: ClientSchema;
341
351
  readonly transport: SyncTransport;
@@ -618,8 +628,7 @@ function boundedBudget(
618
628
  ): number {
619
629
  if (value === undefined) return fallback;
620
630
  if (!Number.isSafeInteger(value) || value < 1) {
621
- throw new ClientSyncError(
622
- 'sync.invalid_request',
631
+ throw invalidRequest(
623
632
  `previousVersionContext.${name} must be a positive safe integer`,
624
633
  );
625
634
  }
@@ -668,8 +677,7 @@ function previousVersionMaxAgeMs(
668
677
  function resolvePreviousVersionLimit(limit: number | undefined): number {
669
678
  if (limit === undefined) return 50;
670
679
  if (!Number.isFinite(limit) || limit < 1) {
671
- throw new ClientSyncError(
672
- 'sync.invalid_request',
680
+ throw invalidRequest(
673
681
  'previousVersionSnapshot limit must be a positive number',
674
682
  );
675
683
  }
@@ -736,6 +744,7 @@ export class SyncClient {
736
744
  #securityLifecycle: SecurityLifecycle;
737
745
  readonly #now: () => number;
738
746
  readonly #outcomeRetentionMaxEntries: number;
747
+ readonly #retainFailedCommits: boolean;
739
748
  #started = false;
740
749
  #lease: LeaderLease | undefined;
741
750
  #clientId = '';
@@ -825,11 +834,11 @@ export class SyncClient {
825
834
 
826
835
  constructor(config: SyncClientConfig) {
827
836
  if (config.securityPreflight === true && config.encryption !== undefined) {
828
- throw new ClientSyncError(
829
- 'sync.invalid_request',
837
+ throw invalidRequest(
830
838
  'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
831
839
  );
832
840
  }
841
+ this.#retainFailedCommits = config.retainFailedCommits === true;
833
842
  this.#db = config.database;
834
843
  this.#schema = compileClientSchema(config.schema);
835
844
  const realtimePolicy = config.realtimePolicy ?? 'optional';
@@ -838,14 +847,12 @@ export class SyncClient {
838
847
  realtimePolicy !== 'optional' &&
839
848
  realtimePolicy !== 'off'
840
849
  ) {
841
- throw new ClientSyncError(
842
- 'sync.invalid_request',
850
+ throw invalidRequest(
843
851
  "realtimePolicy must be 'required', 'optional', or 'off'",
844
852
  );
845
853
  }
846
854
  if (realtimePolicy === 'required' && config.realtime === undefined) {
847
- throw new ClientSyncError(
848
- 'sync.invalid_request',
855
+ throw invalidRequest(
849
856
  "realtimePolicy 'required' needs a realtime connector",
850
857
  );
851
858
  }
@@ -867,8 +874,7 @@ export class SyncClient {
867
874
  !Number.isSafeInteger(outcomeRetentionMaxEntries) ||
868
875
  outcomeRetentionMaxEntries < 1
869
876
  ) {
870
- throw new ClientSyncError(
871
- 'sync.invalid_request',
877
+ throw invalidRequest(
872
878
  'outcomeRetentionMaxEntries must be a positive safe integer',
873
879
  );
874
880
  }
@@ -1272,8 +1278,7 @@ export class SyncClient {
1272
1278
  async activateSecurity(options: SecurityActivation = {}): Promise<void> {
1273
1279
  this.#requireStarted();
1274
1280
  if (this.#securityLifecycle === 'active') {
1275
- throw new ClientSyncError(
1276
- 'sync.invalid_request',
1281
+ throw invalidRequest(
1277
1282
  'activateSecurity requires the client to be in security preflight',
1278
1283
  );
1279
1284
  }
@@ -1471,8 +1476,7 @@ export class SyncClient {
1471
1476
  }
1472
1477
  const table = record.tables.find((entry) => entry.name === spec.table);
1473
1478
  if (table === undefined) {
1474
- throw new ClientSyncError(
1475
- 'sync.invalid_request',
1479
+ throw invalidRequest(
1476
1480
  `previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`,
1477
1481
  );
1478
1482
  }
@@ -1707,8 +1711,7 @@ export class SyncClient {
1707
1711
  this.#requireActive();
1708
1712
  const expected = request.expectedSubscriptions ?? [];
1709
1713
  if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
1710
- throw new ClientSyncError(
1711
- 'sync.invalid_request',
1714
+ throw invalidRequest(
1712
1715
  `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`,
1713
1716
  );
1714
1717
  }
@@ -1747,8 +1750,7 @@ export class SyncClient {
1747
1750
  typeof item.table !== 'string' ||
1748
1751
  item.table.length === 0
1749
1752
  ) {
1750
- throw new ClientSyncError(
1751
- 'sync.invalid_request',
1753
+ throw invalidRequest(
1752
1754
  'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
1753
1755
  );
1754
1756
  }
@@ -1973,6 +1975,12 @@ export class SyncClient {
1973
1975
  this.#batch = batch;
1974
1976
  try {
1975
1977
  result = fn(batch);
1978
+ if (
1979
+ this.#db
1980
+ .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1981
+ .some((row) => batch.hasTable(String(row.tbl)))
1982
+ )
1983
+ batch.outcomes();
1976
1984
  if (batch.touched) {
1977
1985
  revision = bumpLocalRevision(this.#db);
1978
1986
  if (batch.statusChanged) {
@@ -2065,8 +2073,7 @@ export class SyncClient {
2065
2073
  options?: { readonly mediaType?: string; readonly name?: string },
2066
2074
  ): Promise<BlobRef> {
2067
2075
  if (this.#config.blobs === undefined) {
2068
- throw new ClientSyncError(
2069
- 'sync.invalid_request',
2076
+ throw invalidRequest(
2070
2077
  'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)',
2071
2078
  );
2072
2079
  }
@@ -2110,8 +2117,7 @@ export class SyncClient {
2110
2117
  if (cached !== undefined) return cached;
2111
2118
  const transport = this.#config.blobs;
2112
2119
  if (transport === undefined) {
2113
- throw new ClientSyncError(
2114
- 'sync.invalid_request',
2120
+ throw invalidRequest(
2115
2121
  'fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)',
2116
2122
  );
2117
2123
  }
@@ -2123,8 +2129,7 @@ export class SyncClient {
2123
2129
  let bytes: Uint8Array;
2124
2130
  if (response.kind === 'url') {
2125
2131
  if (transport.fetchUrl === undefined) {
2126
- throw new ClientSyncError(
2127
- 'sync.invalid_request',
2132
+ throw invalidRequest(
2128
2133
  'blob download returned a url but the transport cannot fetch urls (§5.9.5)',
2129
2134
  );
2130
2135
  }
@@ -2148,8 +2153,7 @@ export class SyncClient {
2148
2153
  // §5.9.5 inherits §5.1: verify the content address, reject on mismatch.
2149
2154
  // On the url path this invalidates the fetch (no fall-through) — the
2150
2155
  // next fetchBlob re-requests the authorized endpoint (§5.9.5 recovery).
2151
- throw new ClientSyncError(
2152
- 'sync.invalid_request',
2156
+ throw invalidRequest(
2153
2157
  `blob content address mismatch for ${blobId} (§5.9.5)`,
2154
2158
  );
2155
2159
  }
@@ -2158,10 +2162,7 @@ export class SyncClient {
2158
2162
  this.#enforceBlobCacheCap();
2159
2163
  const stored = getCachedBlob(this.#db, blobId);
2160
2164
  if (stored === undefined) {
2161
- throw new ClientSyncError(
2162
- 'sync.invalid_request',
2163
- 'blob cache write failed',
2164
- );
2165
+ throw invalidRequest('blob cache write failed');
2165
2166
  }
2166
2167
  return stored;
2167
2168
  }
@@ -2273,7 +2274,7 @@ export class SyncClient {
2273
2274
  /**
2274
2275
  * Mark a durable failure handled without deleting its evidence. Conflicts
2275
2276
  * may keep the server row or link to a replacement commit; rejections may
2276
- * only be superseded by a named replacement. Applied/cached history may be
2277
+ * also keep the server row or link to a named replacement. Applied/cached history may be
2277
2278
  * dismissed. The transition is one-way and survives restart.
2278
2279
  */
2279
2280
  resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
@@ -2293,14 +2294,12 @@ export class SyncClient {
2293
2294
  replacement.length === 0 ||
2294
2295
  replacement === input.clientCommitId
2295
2296
  ) {
2296
- throw new ClientSyncError(
2297
- 'sync.invalid_request',
2297
+ throw invalidRequest(
2298
2298
  'superseded outcomes require a distinct replacementClientCommitId',
2299
2299
  );
2300
2300
  }
2301
2301
  } else if (replacement !== undefined) {
2302
- throw new ClientSyncError(
2303
- 'sync.invalid_request',
2302
+ throw invalidRequest(
2304
2303
  'replacementClientCommitId is valid only for superseded outcomes',
2305
2304
  );
2306
2305
  }
@@ -2308,40 +2307,58 @@ export class SyncClient {
2308
2307
  (current.status === 'conflict' &&
2309
2308
  (input.resolution === 'resolved_keep_server' ||
2310
2309
  input.resolution === 'superseded')) ||
2311
- (current.status === 'rejected' && input.resolution === 'superseded') ||
2310
+ (current.status === 'rejected' &&
2311
+ (input.resolution === 'superseded' ||
2312
+ input.resolution === 'resolved_keep_server')) ||
2312
2313
  ((current.status === 'applied' || current.status === 'cached') &&
2313
2314
  input.resolution === 'dismissed');
2314
2315
  if (!allowed) {
2315
- throw new ClientSyncError(
2316
- 'sync.invalid_request',
2316
+ throw invalidRequest(
2317
2317
  `resolution ${input.resolution} is invalid for ${current.status} outcome`,
2318
2318
  );
2319
2319
  }
2320
2320
 
2321
- return this.#applyBatch((batch) => {
2322
- const resolved = persistCommitOutcomeResolution(
2323
- this.#db,
2324
- input,
2325
- this.#now(),
2326
- );
2327
- if (resolved === undefined) {
2328
- throw new ClientSyncError(
2329
- 'sync.outcome_not_found',
2330
- `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
2321
+ const priorConflicts = this.#conflicts;
2322
+ const priorRejections = this.#rejections;
2323
+ return this.#applyBatch(
2324
+ (batch) => {
2325
+ restoreFailedBases(this.#db, this.#schema);
2326
+ this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
2327
+ input.clientCommitId,
2328
+ ]);
2329
+ const resolved = persistCommitOutcomeResolution(
2330
+ this.#db,
2331
+ input,
2332
+ this.#now(),
2331
2333
  );
2332
- }
2333
- this.#conflicts = this.#conflicts.filter(
2334
- (record) => record.clientCommitId !== input.clientCommitId,
2335
- );
2336
- this.#rejections = this.#rejections.filter(
2337
- (record) => record.clientCommitId !== input.clientCommitId,
2338
- );
2339
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2340
- batch.outcomes();
2341
- if (current.status === 'conflict') batch.conflicts();
2342
- if (current.status === 'rejected') batch.rejections();
2343
- return resolved;
2344
- });
2334
+ if (resolved === undefined) {
2335
+ throw new ClientSyncError(
2336
+ 'sync.outcome_not_found',
2337
+ `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
2338
+ );
2339
+ }
2340
+ this.#conflicts = this.#conflicts.filter(
2341
+ (record) => record.clientCommitId !== input.clientCommitId,
2342
+ );
2343
+ this.#rejections = this.#rejections.filter(
2344
+ (record) => record.clientCommitId !== input.clientCommitId,
2345
+ );
2346
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2347
+ this.#replayOutbox();
2348
+ for (const operation of current.operations ?? [])
2349
+ batch.table(operation.table);
2350
+ batch.outcomes();
2351
+ if (current.status === 'conflict') batch.conflicts();
2352
+ if (current.status === 'rejected') batch.rejections();
2353
+ return resolved;
2354
+ },
2355
+ undefined,
2356
+ (error) => {
2357
+ this.#conflicts = priorConflicts;
2358
+ this.#rejections = priorRejections;
2359
+ throw error;
2360
+ },
2361
+ );
2345
2362
  }
2346
2363
 
2347
2364
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
@@ -2397,8 +2414,7 @@ export class SyncClient {
2397
2414
  this.#requireActive();
2398
2415
  const socket = this.#socket;
2399
2416
  if (socket === undefined) {
2400
- throw new ClientSyncError(
2401
- 'sync.invalid_request',
2417
+ throw invalidRequest(
2402
2418
  'setPresence requires a connected realtime socket (§8.6)',
2403
2419
  );
2404
2420
  }
@@ -2509,8 +2525,7 @@ export class SyncClient {
2509
2525
  this.#requireActive();
2510
2526
  const table = this.#table(base.table);
2511
2527
  if (!table.scopeColumnByVariable.has(base.variable)) {
2512
- throw new ClientSyncError(
2513
- 'sync.invalid_request',
2528
+ throw invalidRequest(
2514
2529
  `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`,
2515
2530
  );
2516
2531
  }
@@ -2689,7 +2704,10 @@ export class SyncClient {
2689
2704
  */
2690
2705
  #pinnedRowIds(table: string): Set<string> {
2691
2706
  const pinned = new Set<string>();
2692
- for (const commit of listOutbox(this.#db)) {
2707
+ for (const commit of [
2708
+ ...failedOverlayCommits(this.#db),
2709
+ ...listOutbox(this.#db),
2710
+ ]) {
2693
2711
  for (const op of commit.operations) {
2694
2712
  if (op.table === table) pinned.add(op.rowId);
2695
2713
  }
@@ -2708,10 +2726,7 @@ export class SyncClient {
2708
2726
  return this.#recordMutations(mutations);
2709
2727
  }
2710
2728
 
2711
- #recordMutations(
2712
- mutations: readonly MutationInput[],
2713
- partial = false,
2714
- ): string {
2729
+ #recordMutations(mutations: readonly MutationInput[]): string {
2715
2730
  this.#requireActive();
2716
2731
  const clientCommitId = crypto.randomUUID();
2717
2732
  const operations: OutboxOperation[] = mutations.map((mutation) => {
@@ -2726,7 +2741,8 @@ export class SyncClient {
2726
2741
  : {}),
2727
2742
  };
2728
2743
  }
2729
- if (partial) {
2744
+ const json: Record<string, JsonRowValue> = {};
2745
+ if (mutation.op === 'patch') {
2730
2746
  // §6.7 patch: the values map IS the presence set — the primary key
2731
2747
  // plus the supplied non-scope columns. Absent columns keep their
2732
2748
  // stored value; the overlay and the server both apply only the
@@ -2734,7 +2750,16 @@ export class SyncClient {
2734
2750
  const normalized = normalizeRecordKeys(table, mutation.values);
2735
2751
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2736
2752
  const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2737
- const json: Record<string, JsonRowValue> = {};
2753
+ const pkValue = requireRowId(normalized.get(pkColumn.name));
2754
+ const local = this.#db.query(
2755
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2756
+ [pkValue],
2757
+ )[0];
2758
+ if (!local)
2759
+ throw new ClientSyncError(
2760
+ 'sync.row_missing',
2761
+ 'a sparse patch requires a local row',
2762
+ );
2738
2763
  for (const column of table.columns) {
2739
2764
  if (!normalized.has(column.name)) continue;
2740
2765
  // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
@@ -2748,16 +2773,7 @@ export class SyncClient {
2748
2773
  // by construction the row id being patched (§6.1), so its value is
2749
2774
  // proven equal already.
2750
2775
  if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
2751
- const pkValue = normalized.get(pkColumn.name);
2752
- const local =
2753
- typeof pkValue === 'string' && pkValue.length > 0
2754
- ? this.#db.query(
2755
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2756
- [pkValue],
2757
- )[0]
2758
- : undefined;
2759
2776
  if (
2760
- local === undefined ||
2761
2777
  !Object.is(
2762
2778
  rowValueToJson(
2763
2779
  fromSqlValue(column, local[column.name] ?? null),
@@ -2770,8 +2786,7 @@ export class SyncClient {
2770
2786
  ),
2771
2787
  )
2772
2788
  ) {
2773
- throw new ClientSyncError(
2774
- 'sync.invalid_request',
2789
+ throw invalidRequest(
2775
2790
  `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2776
2791
  );
2777
2792
  }
@@ -2780,8 +2795,7 @@ export class SyncClient {
2780
2795
  const value = normalized.get(column.name);
2781
2796
  if (value === undefined || value === null) {
2782
2797
  if (!column.nullable) {
2783
- throw new ClientSyncError(
2784
- 'sync.invalid_request',
2798
+ throw invalidRequest(
2785
2799
  `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
2786
2800
  );
2787
2801
  }
@@ -2792,35 +2806,13 @@ export class SyncClient {
2792
2806
  coerceSqlRepresentation(column, value) as RowValue,
2793
2807
  );
2794
2808
  }
2795
- const pkValue = json[pkColumn.name];
2796
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
2797
- throw new ClientSyncError(
2798
- 'sync.invalid_request',
2799
- `table ${table.name}: upsert requires a non-empty string primary key`,
2800
- );
2801
- }
2802
- return {
2803
- table: mutation.table,
2804
- rowId: pkValue,
2805
- op: 'upsert',
2806
- ...(mutation.baseVersion !== undefined
2807
- ? { baseVersion: mutation.baseVersion }
2808
- : {}),
2809
- values: json,
2810
- };
2811
- }
2812
- const values = recordToRowValues(table, mutation.values);
2813
- const pkValue = values[table.primaryKeyIndex];
2814
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
2815
- throw new ClientSyncError(
2816
- 'sync.invalid_request',
2817
- `table ${table.name}: upsert requires a non-empty string primary key`,
2818
- );
2809
+ } else {
2810
+ const values = recordToRowValues(table, mutation.values);
2811
+ table.columns.forEach((column, index) => {
2812
+ json[column.name] = rowValueToJson(values[index] ?? null);
2813
+ });
2819
2814
  }
2820
- const json: Record<string, ReturnType<typeof rowValueToJson>> = {};
2821
- table.columns.forEach((column, index) => {
2822
- json[column.name] = rowValueToJson(values[index] ?? null);
2823
- });
2815
+ const pkValue = requireRowId(json[table.primaryKey]);
2824
2816
  return {
2825
2817
  table: mutation.table,
2826
2818
  rowId: pkValue,
@@ -2917,27 +2909,23 @@ export class SyncClient {
2917
2909
  const normalized = normalizeRecordKeys(compiled, partial);
2918
2910
  const pkOverride = normalized.get(pkColumn.name);
2919
2911
  if (pkOverride !== undefined && pkOverride !== rowId) {
2920
- throw new ClientSyncError(
2921
- 'sync.invalid_request',
2912
+ throw invalidRequest(
2922
2913
  `table ${compiled.name}: patch cannot change the primary key`,
2923
2914
  );
2924
2915
  }
2925
- return this.#recordMutations(
2926
- [
2927
- {
2928
- table,
2929
- op: 'upsert',
2930
- values: {
2931
- ...Object.fromEntries(normalized),
2932
- [pkColumn.name]: rowId,
2933
- },
2934
- ...(options?.baseVersion !== undefined
2935
- ? { baseVersion: options.baseVersion }
2936
- : {}),
2916
+ return this.#recordMutations([
2917
+ {
2918
+ table,
2919
+ op: 'patch',
2920
+ values: {
2921
+ ...Object.fromEntries(normalized),
2922
+ [pkColumn.name]: rowId,
2937
2923
  },
2938
- ],
2939
- true,
2940
- );
2924
+ ...(options?.baseVersion !== undefined
2925
+ ? { baseVersion: options.baseVersion }
2926
+ : {}),
2927
+ },
2928
+ ]);
2941
2929
  }
2942
2930
 
2943
2931
  /**
@@ -2957,8 +2945,7 @@ export class SyncClient {
2957
2945
  const appliedPlan = getMeta(this.#db, metaKey);
2958
2946
  if (appliedPlan !== undefined) {
2959
2947
  if (appliedPlan !== purge.canonicalPlan) {
2960
- throw new ClientSyncError(
2961
- 'sync.invalid_request',
2948
+ throw invalidRequest(
2962
2949
  `local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`,
2963
2950
  );
2964
2951
  }
@@ -2973,6 +2960,19 @@ export class SyncClient {
2973
2960
  try {
2974
2961
  return this.#applyBatch((batch) => {
2975
2962
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2963
+ restoreFailedBases(this.#db, this.#schema);
2964
+ const droppedRetained = dropFailedRows(this.#db, (table, values) =>
2965
+ (targetsByTable.get(table) ?? []).some((target) =>
2966
+ target.selectors.every((selector) =>
2967
+ selector.values.includes(String(values[selector.column])),
2968
+ ),
2969
+ ),
2970
+ );
2971
+ for (const commit of droppedRetained) {
2972
+ for (const operation of commit.operations)
2973
+ batch.table(operation.table);
2974
+ batch.outcomes();
2975
+ }
2976
2976
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
2977
2977
  // a commit is doomed when any of its ops touches a base-matching
2978
2978
  // row. Base values only become visible here as rollbacks restore
@@ -3074,6 +3074,7 @@ export class SyncClient {
3074
3074
  batch.rejections();
3075
3075
  batch.outcomes();
3076
3076
  }
3077
+ this.#replayOutbox();
3077
3078
  this.#deleteUnreferencedBlobs();
3078
3079
  setMeta(this.#db, metaKey, purge.canonicalPlan);
3079
3080
  return {
@@ -3116,8 +3117,7 @@ export class SyncClient {
3116
3117
  };
3117
3118
  }
3118
3119
  if (this.#schemaFloor !== undefined) {
3119
- throw new ClientSyncError(
3120
- 'sync.invalid_request',
3120
+ throw invalidRequest(
3121
3121
  'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
3122
3122
  );
3123
3123
  }
@@ -3327,7 +3327,14 @@ export class SyncClient {
3327
3327
  code: string = OUTBOX_INCOMPATIBLE_CODE,
3328
3328
  ): void {
3329
3329
  this.#applyBatch((batch) => {
3330
+ if (this.#retainFailedCommits && code === 'client.encrypt_failed')
3331
+ retainFailedRows(
3332
+ this.#db,
3333
+ commit,
3334
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
3335
+ );
3330
3336
  this.#rollbackFailedCommit(commit, batch);
3337
+ this.#replayOutbox();
3331
3338
  const rejection: RejectionRecord = {
3332
3339
  clientCommitId: commit.clientCommitId,
3333
3340
  opIndex: 0,
@@ -3368,8 +3375,7 @@ export class SyncClient {
3368
3375
  this.#requireActive();
3369
3376
  if (this.#syncOutstanding) {
3370
3377
  return Promise.reject(
3371
- new ClientSyncError(
3372
- 'sync.invalid_request',
3378
+ invalidRequest(
3373
3379
  'sync() is already running — the core owns one loop (coalesce wake-ups)',
3374
3380
  ),
3375
3381
  );
@@ -3545,10 +3551,7 @@ export class SyncClient {
3545
3551
  const responseBytes = await this.#roundTrip(requestBytes);
3546
3552
  const message = decodeMessage(responseBytes);
3547
3553
  if (message.msgKind !== 'response') {
3548
- throw new ClientSyncError(
3549
- 'sync.invalid_request',
3550
- 'transport returned a non-response message',
3551
- );
3554
+ throw invalidRequest('transport returned a non-response message');
3552
3555
  }
3553
3556
  const summary = await this.#processResponse(
3554
3557
  message,
@@ -3653,10 +3656,7 @@ export class SyncClient {
3653
3656
  return last;
3654
3657
  }
3655
3658
  }
3656
- throw new ClientSyncError(
3657
- 'sync.invalid_request',
3658
- 'sync did not reach idle within the round budget',
3659
- );
3659
+ throw invalidRequest('sync did not reach idle within the round budget');
3660
3660
  }
3661
3661
 
3662
3662
  /**
@@ -3762,10 +3762,7 @@ export class SyncClient {
3762
3762
  this.#requireActive();
3763
3763
  if (this.#realtimePolicy === 'off') {
3764
3764
  return Promise.reject(
3765
- new ClientSyncError(
3766
- 'sync.invalid_request',
3767
- "realtimePolicy is 'off'; realtime connect is refused",
3768
- ),
3765
+ invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
3769
3766
  );
3770
3767
  }
3771
3768
  if (this.#socket !== undefined) return Promise.resolve();
@@ -3797,10 +3794,7 @@ export class SyncClient {
3797
3794
  async #connectRealtime(generation: number): Promise<void> {
3798
3795
  const connector = this.#config.realtime;
3799
3796
  if (connector === undefined) {
3800
- throw new ClientSyncError(
3801
- 'sync.invalid_request',
3802
- 'no realtime connector configured',
3803
- );
3797
+ throw invalidRequest('no realtime connector configured');
3804
3798
  }
3805
3799
  let openedSocket: RealtimeSocket | undefined;
3806
3800
  try {
@@ -3886,8 +3880,7 @@ export class SyncClient {
3886
3880
  } catch (error) {
3887
3881
  this.#pendingRound = undefined;
3888
3882
  round.reject(
3889
- new ClientSyncError(
3890
- 'sync.invalid_request',
3883
+ invalidRequest(
3891
3884
  `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
3892
3885
  ),
3893
3886
  );
@@ -3897,10 +3890,7 @@ export class SyncClient {
3897
3890
  this.#pendingRound = undefined;
3898
3891
  if (done.excess > 0) {
3899
3892
  round.reject(
3900
- new ClientSyncError(
3901
- 'sync.invalid_request',
3902
- 'response bytes past END of the round stream (§8.7)',
3903
- ),
3893
+ invalidRequest('response bytes past END of the round stream (§8.7)'),
3904
3894
  );
3905
3895
  return;
3906
3896
  }
@@ -4064,7 +4054,7 @@ export class SyncClient {
4064
4054
 
4065
4055
  const header = message.frames[0];
4066
4056
  if (header?.type !== 'RESP_HEADER') {
4067
- throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
4057
+ throw invalidRequest('missing RESP_HEADER');
4068
4058
  }
4069
4059
  if (
4070
4060
  message.wireVersion < 2 ||
@@ -4291,8 +4281,7 @@ export class SyncClient {
4291
4281
  frame.mediaType === 'sqlite' &&
4292
4282
  (this.#acceptMask() & ACCEPT_SQLITE) === 0
4293
4283
  ) {
4294
- throw new ClientSyncError(
4295
- 'sync.invalid_request',
4284
+ throw invalidRequest(
4296
4285
  'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
4297
4286
  );
4298
4287
  }
@@ -4305,8 +4294,7 @@ export class SyncClient {
4305
4294
  frame.rowCursor !== undefined ||
4306
4295
  frame.nextRowCursor !== undefined
4307
4296
  ) {
4308
- throw new ClientSyncError(
4309
- 'sync.invalid_request',
4297
+ throw invalidRequest(
4310
4298
  'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
4311
4299
  );
4312
4300
  }
@@ -4570,6 +4558,12 @@ export class SyncClient {
4570
4558
  // validator-rejected updates even when the server emitted no new COMMIT;
4571
4559
  // later pending overlays are then replayed with rebased before-images.
4572
4560
  this.#db.transaction(() => {
4561
+ if (this.#retainFailedCommits)
4562
+ retainFailedRows(
4563
+ this.#db,
4564
+ commit,
4565
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
4566
+ );
4573
4567
  this.#rollbackFailedCommit(commit, batch);
4574
4568
  });
4575
4569
  batch.status();
@@ -4769,8 +4763,7 @@ export class SyncClient {
4769
4763
  ): Promise<Uint8Array> {
4770
4764
  const downloader = this.#config.segments;
4771
4765
  if (downloader === undefined) {
4772
- throw new ClientSyncError(
4773
- 'sync.invalid_request',
4766
+ throw invalidRequest(
4774
4767
  'received SEGMENT_REF but no segment downloader is configured',
4775
4768
  );
4776
4769
  }
@@ -4798,8 +4791,7 @@ export class SyncClient {
4798
4791
  fetchUrl === undefined ||
4799
4792
  (this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0
4800
4793
  ) {
4801
- throw new ClientSyncError(
4802
- 'sync.invalid_request',
4794
+ throw invalidRequest(
4803
4795
  'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)',
4804
4796
  );
4805
4797
  }
@@ -4829,8 +4821,7 @@ export class SyncClient {
4829
4821
  onProgress(bytes.byteLength);
4830
4822
  const hash = await sha256Hex(bytes);
4831
4823
  if (`sha256:${hash}` !== frame.segmentId) {
4832
- throw new ClientSyncError(
4833
- 'sync.invalid_request',
4824
+ throw invalidRequest(
4834
4825
  `segment ${frame.segmentId} failed content-address verification (§5.1)`,
4835
4826
  true,
4836
4827
  );
@@ -4912,6 +4903,22 @@ export class SyncClient {
4912
4903
  Object.keys(lastEffective).length > 0
4913
4904
  ) {
4914
4905
  try {
4906
+ restoreFailedBases(this.#db, this.#schema);
4907
+ const droppedRetained = dropFailedRows(
4908
+ this.#db,
4909
+ (name, values) =>
4910
+ name === table.name &&
4911
+ Object.entries(lastEffective).every(([variable, allowed]) =>
4912
+ allowed.includes(
4913
+ String(values[table.scopeColumnByVariable.get(variable)!]),
4914
+ ),
4915
+ ),
4916
+ );
4917
+ for (const commit of droppedRetained) {
4918
+ for (const operation of commit.operations)
4919
+ batch.table(operation.table);
4920
+ batch.outcomes();
4921
+ }
4915
4922
  deleteScopedRows(this.#db, table, lastEffective);
4916
4923
  batch.scopeMap(table, lastEffective);
4917
4924
  const pendingById = new Map(
@@ -5055,7 +5062,7 @@ export class SyncClient {
5055
5062
  const values = table.columns.map((column) =>
5056
5063
  jsonToRowValue(image.values?.[column.name] ?? null),
5057
5064
  );
5058
- upsertLocalRow(this.#db, table, values, image.syncVersion);
5065
+ upsertLocalRow(this.#db, table, values, image.syncVersion, false);
5059
5066
  }
5060
5067
 
5061
5068
  #legacyUndoOptimisticRows(
@@ -5132,7 +5139,7 @@ export class SyncClient {
5132
5139
  : this.#recordStoredRowScopes(batch, table, op.rowId);
5133
5140
  if (op.op === 'delete') {
5134
5141
  if (batch !== undefined && !precise) batch.table(op.table);
5135
- deleteLocalRow(this.#db, table, op.rowId);
5142
+ deleteLocalRow(this.#db, table, op.rowId, false);
5136
5143
  continue;
5137
5144
  }
5138
5145
  // §7.1 overlay: apply the operation's PRESENT columns over the current
@@ -5173,19 +5180,23 @@ export class SyncClient {
5173
5180
  local === undefined
5174
5181
  ? OPTIMISTIC_VERSION
5175
5182
  : (local[SYNC_VERSION_COLUMN] as number);
5176
- upsertLocalRow(this.#db, table, values, version);
5183
+ upsertLocalRow(this.#db, table, values, version, false);
5177
5184
  }
5178
5185
  }
5179
5186
 
5180
5187
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
5181
5188
  #replayOutbox(): void {
5182
- const pending = listOutbox(this.#db);
5189
+ const pending = [
5190
+ ...failedOverlayCommits(this.#db),
5191
+ ...listOutbox(this.#db),
5192
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5183
5193
  if (pending.length === 0) return;
5184
5194
  this.#applyBatch((batch) => {
5185
5195
  this.#db.transaction(() => {
5186
- for (const commit of pending) {
5196
+ restoreFailedBases(this.#db, this.#schema);
5197
+ restoreFailedBases(this.#db, this.#schema, true);
5198
+ for (const commit of pending)
5187
5199
  this.#applyOperationsLocally(commit.operations, batch);
5188
- }
5189
5200
  });
5190
5201
  });
5191
5202
  }
@@ -5210,10 +5221,7 @@ export class SyncClient {
5210
5221
 
5211
5222
  #requireStarted(): void {
5212
5223
  if (!this.#started) {
5213
- throw new ClientSyncError(
5214
- 'sync.invalid_request',
5215
- 'SyncClient.start() has not completed',
5216
- );
5224
+ throw invalidRequest('SyncClient.start() has not completed');
5217
5225
  }
5218
5226
  }
5219
5227
 
@@ -5227,3 +5235,9 @@ export class SyncClient {
5227
5235
  }
5228
5236
  }
5229
5237
  }
5238
+
5239
+ function requireRowId(value: unknown): string {
5240
+ if (typeof value !== 'string' || !value.length)
5241
+ throw invalidRequest('a mutation requires a non-empty string primary key');
5242
+ return value;
5243
+ }