@syncular/client 0.30.6 → 0.30.9

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,6 @@ 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> = {};
2738
2753
  for (const column of table.columns) {
2739
2754
  if (!normalized.has(column.name)) continue;
2740
2755
  // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
@@ -2770,8 +2785,7 @@ export class SyncClient {
2770
2785
  ),
2771
2786
  )
2772
2787
  ) {
2773
- throw new ClientSyncError(
2774
- 'sync.invalid_request',
2788
+ throw invalidRequest(
2775
2789
  `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2776
2790
  );
2777
2791
  }
@@ -2780,8 +2794,7 @@ export class SyncClient {
2780
2794
  const value = normalized.get(column.name);
2781
2795
  if (value === undefined || value === null) {
2782
2796
  if (!column.nullable) {
2783
- throw new ClientSyncError(
2784
- 'sync.invalid_request',
2797
+ throw invalidRequest(
2785
2798
  `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
2786
2799
  );
2787
2800
  }
@@ -2792,35 +2805,18 @@ export class SyncClient {
2792
2805
  coerceSqlRepresentation(column, value) as RowValue,
2793
2806
  );
2794
2807
  }
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
- };
2808
+ } else {
2809
+ const values = recordToRowValues(table, mutation.values);
2810
+ table.columns.forEach((column, index) => {
2811
+ json[column.name] = rowValueToJson(values[index] ?? null);
2812
+ });
2811
2813
  }
2812
- const values = recordToRowValues(table, mutation.values);
2813
- const pkValue = values[table.primaryKeyIndex];
2814
+ const pkValue = json[table.primaryKey];
2814
2815
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
2815
- throw new ClientSyncError(
2816
- 'sync.invalid_request',
2816
+ throw invalidRequest(
2817
2817
  `table ${table.name}: upsert requires a non-empty string primary key`,
2818
2818
  );
2819
2819
  }
2820
- const json: Record<string, ReturnType<typeof rowValueToJson>> = {};
2821
- table.columns.forEach((column, index) => {
2822
- json[column.name] = rowValueToJson(values[index] ?? null);
2823
- });
2824
2820
  return {
2825
2821
  table: mutation.table,
2826
2822
  rowId: pkValue,
@@ -2917,27 +2913,23 @@ export class SyncClient {
2917
2913
  const normalized = normalizeRecordKeys(compiled, partial);
2918
2914
  const pkOverride = normalized.get(pkColumn.name);
2919
2915
  if (pkOverride !== undefined && pkOverride !== rowId) {
2920
- throw new ClientSyncError(
2921
- 'sync.invalid_request',
2916
+ throw invalidRequest(
2922
2917
  `table ${compiled.name}: patch cannot change the primary key`,
2923
2918
  );
2924
2919
  }
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
- : {}),
2920
+ return this.#recordMutations([
2921
+ {
2922
+ table,
2923
+ op: 'patch',
2924
+ values: {
2925
+ ...Object.fromEntries(normalized),
2926
+ [pkColumn.name]: rowId,
2937
2927
  },
2938
- ],
2939
- true,
2940
- );
2928
+ ...(options?.baseVersion !== undefined
2929
+ ? { baseVersion: options.baseVersion }
2930
+ : {}),
2931
+ },
2932
+ ]);
2941
2933
  }
2942
2934
 
2943
2935
  /**
@@ -2957,8 +2949,7 @@ export class SyncClient {
2957
2949
  const appliedPlan = getMeta(this.#db, metaKey);
2958
2950
  if (appliedPlan !== undefined) {
2959
2951
  if (appliedPlan !== purge.canonicalPlan) {
2960
- throw new ClientSyncError(
2961
- 'sync.invalid_request',
2952
+ throw invalidRequest(
2962
2953
  `local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`,
2963
2954
  );
2964
2955
  }
@@ -2973,6 +2964,19 @@ export class SyncClient {
2973
2964
  try {
2974
2965
  return this.#applyBatch((batch) => {
2975
2966
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2967
+ restoreFailedBases(this.#db, this.#schema);
2968
+ const droppedRetained = dropFailedRows(this.#db, (table, values) =>
2969
+ (targetsByTable.get(table) ?? []).some((target) =>
2970
+ target.selectors.every((selector) =>
2971
+ selector.values.includes(String(values[selector.column])),
2972
+ ),
2973
+ ),
2974
+ );
2975
+ for (const commit of droppedRetained) {
2976
+ for (const operation of commit.operations)
2977
+ batch.table(operation.table);
2978
+ batch.outcomes();
2979
+ }
2976
2980
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
2977
2981
  // a commit is doomed when any of its ops touches a base-matching
2978
2982
  // row. Base values only become visible here as rollbacks restore
@@ -3074,6 +3078,7 @@ export class SyncClient {
3074
3078
  batch.rejections();
3075
3079
  batch.outcomes();
3076
3080
  }
3081
+ this.#replayOutbox();
3077
3082
  this.#deleteUnreferencedBlobs();
3078
3083
  setMeta(this.#db, metaKey, purge.canonicalPlan);
3079
3084
  return {
@@ -3116,8 +3121,7 @@ export class SyncClient {
3116
3121
  };
3117
3122
  }
3118
3123
  if (this.#schemaFloor !== undefined) {
3119
- throw new ClientSyncError(
3120
- 'sync.invalid_request',
3124
+ throw invalidRequest(
3121
3125
  'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
3122
3126
  );
3123
3127
  }
@@ -3327,7 +3331,14 @@ export class SyncClient {
3327
3331
  code: string = OUTBOX_INCOMPATIBLE_CODE,
3328
3332
  ): void {
3329
3333
  this.#applyBatch((batch) => {
3334
+ if (this.#retainFailedCommits && code === 'client.encrypt_failed')
3335
+ retainFailedRows(
3336
+ this.#db,
3337
+ commit,
3338
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
3339
+ );
3330
3340
  this.#rollbackFailedCommit(commit, batch);
3341
+ this.#replayOutbox();
3331
3342
  const rejection: RejectionRecord = {
3332
3343
  clientCommitId: commit.clientCommitId,
3333
3344
  opIndex: 0,
@@ -3368,8 +3379,7 @@ export class SyncClient {
3368
3379
  this.#requireActive();
3369
3380
  if (this.#syncOutstanding) {
3370
3381
  return Promise.reject(
3371
- new ClientSyncError(
3372
- 'sync.invalid_request',
3382
+ invalidRequest(
3373
3383
  'sync() is already running — the core owns one loop (coalesce wake-ups)',
3374
3384
  ),
3375
3385
  );
@@ -3391,7 +3401,8 @@ export class SyncClient {
3391
3401
  this.#emitDiagnostics();
3392
3402
  return summary;
3393
3403
  },
3394
- (error: unknown) => {
3404
+ (caught: unknown) => {
3405
+ const error = classifySqliteFailure(caught).error;
3395
3406
  const completedAtMs = this.#now();
3396
3407
  const code = (error as { code?: unknown }).code;
3397
3408
  this.#progress.update({
@@ -3544,10 +3555,7 @@ export class SyncClient {
3544
3555
  const responseBytes = await this.#roundTrip(requestBytes);
3545
3556
  const message = decodeMessage(responseBytes);
3546
3557
  if (message.msgKind !== 'response') {
3547
- throw new ClientSyncError(
3548
- 'sync.invalid_request',
3549
- 'transport returned a non-response message',
3550
- );
3558
+ throw invalidRequest('transport returned a non-response message');
3551
3559
  }
3552
3560
  const summary = await this.#processResponse(
3553
3561
  message,
@@ -3652,10 +3660,7 @@ export class SyncClient {
3652
3660
  return last;
3653
3661
  }
3654
3662
  }
3655
- throw new ClientSyncError(
3656
- 'sync.invalid_request',
3657
- 'sync did not reach idle within the round budget',
3658
- );
3663
+ throw invalidRequest('sync did not reach idle within the round budget');
3659
3664
  }
3660
3665
 
3661
3666
  /**
@@ -3761,10 +3766,7 @@ export class SyncClient {
3761
3766
  this.#requireActive();
3762
3767
  if (this.#realtimePolicy === 'off') {
3763
3768
  return Promise.reject(
3764
- new ClientSyncError(
3765
- 'sync.invalid_request',
3766
- "realtimePolicy is 'off'; realtime connect is refused",
3767
- ),
3769
+ invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
3768
3770
  );
3769
3771
  }
3770
3772
  if (this.#socket !== undefined) return Promise.resolve();
@@ -3796,10 +3798,7 @@ export class SyncClient {
3796
3798
  async #connectRealtime(generation: number): Promise<void> {
3797
3799
  const connector = this.#config.realtime;
3798
3800
  if (connector === undefined) {
3799
- throw new ClientSyncError(
3800
- 'sync.invalid_request',
3801
- 'no realtime connector configured',
3802
- );
3801
+ throw invalidRequest('no realtime connector configured');
3803
3802
  }
3804
3803
  let openedSocket: RealtimeSocket | undefined;
3805
3804
  try {
@@ -3885,8 +3884,7 @@ export class SyncClient {
3885
3884
  } catch (error) {
3886
3885
  this.#pendingRound = undefined;
3887
3886
  round.reject(
3888
- new ClientSyncError(
3889
- 'sync.invalid_request',
3887
+ invalidRequest(
3890
3888
  `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
3891
3889
  ),
3892
3890
  );
@@ -3896,10 +3894,7 @@ export class SyncClient {
3896
3894
  this.#pendingRound = undefined;
3897
3895
  if (done.excess > 0) {
3898
3896
  round.reject(
3899
- new ClientSyncError(
3900
- 'sync.invalid_request',
3901
- 'response bytes past END of the round stream (§8.7)',
3902
- ),
3897
+ invalidRequest('response bytes past END of the round stream (§8.7)'),
3903
3898
  );
3904
3899
  return;
3905
3900
  }
@@ -4063,7 +4058,7 @@ export class SyncClient {
4063
4058
 
4064
4059
  const header = message.frames[0];
4065
4060
  if (header?.type !== 'RESP_HEADER') {
4066
- throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
4061
+ throw invalidRequest('missing RESP_HEADER');
4067
4062
  }
4068
4063
  if (
4069
4064
  message.wireVersion < 2 ||
@@ -4290,8 +4285,7 @@ export class SyncClient {
4290
4285
  frame.mediaType === 'sqlite' &&
4291
4286
  (this.#acceptMask() & ACCEPT_SQLITE) === 0
4292
4287
  ) {
4293
- throw new ClientSyncError(
4294
- 'sync.invalid_request',
4288
+ throw invalidRequest(
4295
4289
  'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
4296
4290
  );
4297
4291
  }
@@ -4304,8 +4298,7 @@ export class SyncClient {
4304
4298
  frame.rowCursor !== undefined ||
4305
4299
  frame.nextRowCursor !== undefined
4306
4300
  ) {
4307
- throw new ClientSyncError(
4308
- 'sync.invalid_request',
4301
+ throw invalidRequest(
4309
4302
  'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
4310
4303
  );
4311
4304
  }
@@ -4569,6 +4562,12 @@ export class SyncClient {
4569
4562
  // validator-rejected updates even when the server emitted no new COMMIT;
4570
4563
  // later pending overlays are then replayed with rebased before-images.
4571
4564
  this.#db.transaction(() => {
4565
+ if (this.#retainFailedCommits)
4566
+ retainFailedRows(
4567
+ this.#db,
4568
+ commit,
4569
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
4570
+ );
4572
4571
  this.#rollbackFailedCommit(commit, batch);
4573
4572
  });
4574
4573
  batch.status();
@@ -4768,8 +4767,7 @@ export class SyncClient {
4768
4767
  ): Promise<Uint8Array> {
4769
4768
  const downloader = this.#config.segments;
4770
4769
  if (downloader === undefined) {
4771
- throw new ClientSyncError(
4772
- 'sync.invalid_request',
4770
+ throw invalidRequest(
4773
4771
  'received SEGMENT_REF but no segment downloader is configured',
4774
4772
  );
4775
4773
  }
@@ -4797,8 +4795,7 @@ export class SyncClient {
4797
4795
  fetchUrl === undefined ||
4798
4796
  (this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0
4799
4797
  ) {
4800
- throw new ClientSyncError(
4801
- 'sync.invalid_request',
4798
+ throw invalidRequest(
4802
4799
  'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)',
4803
4800
  );
4804
4801
  }
@@ -4828,8 +4825,7 @@ export class SyncClient {
4828
4825
  onProgress(bytes.byteLength);
4829
4826
  const hash = await sha256Hex(bytes);
4830
4827
  if (`sha256:${hash}` !== frame.segmentId) {
4831
- throw new ClientSyncError(
4832
- 'sync.invalid_request',
4828
+ throw invalidRequest(
4833
4829
  `segment ${frame.segmentId} failed content-address verification (§5.1)`,
4834
4830
  true,
4835
4831
  );
@@ -4911,6 +4907,22 @@ export class SyncClient {
4911
4907
  Object.keys(lastEffective).length > 0
4912
4908
  ) {
4913
4909
  try {
4910
+ restoreFailedBases(this.#db, this.#schema);
4911
+ const droppedRetained = dropFailedRows(
4912
+ this.#db,
4913
+ (name, values) =>
4914
+ name === table.name &&
4915
+ Object.entries(lastEffective).every(([variable, allowed]) =>
4916
+ allowed.includes(
4917
+ String(values[table.scopeColumnByVariable.get(variable)!]),
4918
+ ),
4919
+ ),
4920
+ );
4921
+ for (const commit of droppedRetained) {
4922
+ for (const operation of commit.operations)
4923
+ batch.table(operation.table);
4924
+ batch.outcomes();
4925
+ }
4914
4926
  deleteScopedRows(this.#db, table, lastEffective);
4915
4927
  batch.scopeMap(table, lastEffective);
4916
4928
  const pendingById = new Map(
@@ -5054,7 +5066,7 @@ export class SyncClient {
5054
5066
  const values = table.columns.map((column) =>
5055
5067
  jsonToRowValue(image.values?.[column.name] ?? null),
5056
5068
  );
5057
- upsertLocalRow(this.#db, table, values, image.syncVersion);
5069
+ upsertLocalRow(this.#db, table, values, image.syncVersion, false);
5058
5070
  }
5059
5071
 
5060
5072
  #legacyUndoOptimisticRows(
@@ -5131,7 +5143,7 @@ export class SyncClient {
5131
5143
  : this.#recordStoredRowScopes(batch, table, op.rowId);
5132
5144
  if (op.op === 'delete') {
5133
5145
  if (batch !== undefined && !precise) batch.table(op.table);
5134
- deleteLocalRow(this.#db, table, op.rowId);
5146
+ deleteLocalRow(this.#db, table, op.rowId, false);
5135
5147
  continue;
5136
5148
  }
5137
5149
  // §7.1 overlay: apply the operation's PRESENT columns over the current
@@ -5172,19 +5184,23 @@ export class SyncClient {
5172
5184
  local === undefined
5173
5185
  ? OPTIMISTIC_VERSION
5174
5186
  : (local[SYNC_VERSION_COLUMN] as number);
5175
- upsertLocalRow(this.#db, table, values, version);
5187
+ upsertLocalRow(this.#db, table, values, version, false);
5176
5188
  }
5177
5189
  }
5178
5190
 
5179
5191
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
5180
5192
  #replayOutbox(): void {
5181
- const pending = listOutbox(this.#db);
5193
+ const pending = [
5194
+ ...failedOverlayCommits(this.#db),
5195
+ ...listOutbox(this.#db),
5196
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5182
5197
  if (pending.length === 0) return;
5183
5198
  this.#applyBatch((batch) => {
5184
5199
  this.#db.transaction(() => {
5185
- for (const commit of pending) {
5200
+ restoreFailedBases(this.#db, this.#schema);
5201
+ restoreFailedBases(this.#db, this.#schema, true);
5202
+ for (const commit of pending)
5186
5203
  this.#applyOperationsLocally(commit.operations, batch);
5187
- }
5188
5204
  });
5189
5205
  });
5190
5206
  }
@@ -5209,10 +5225,7 @@ export class SyncClient {
5209
5225
 
5210
5226
  #requireStarted(): void {
5211
5227
  if (!this.#started) {
5212
- throw new ClientSyncError(
5213
- 'sync.invalid_request',
5214
- 'SyncClient.start() has not completed',
5215
- );
5228
+ throw invalidRequest('SyncClient.start() has not completed');
5216
5229
  }
5217
5230
  }
5218
5231