@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/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, } from './failed-overlay.js';
1
2
  import { ProgressEmitter, } from './progress.js';
2
3
  /**
3
4
  * SyncClient implements the client side of SPEC.md §§3–8.
@@ -14,7 +15,7 @@ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow,
14
15
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
15
16
  import { registerDevtools } from './devtools.js';
16
17
  import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
17
- import { ClientSyncError, classifySqliteFailure, REALTIME_LOST_CODE, RealtimeUnavailableError, } from './errors.js';
18
+ import { ClientSyncError, invalidRequest, classifySqliteFailure, REALTIME_LOST_CODE, RealtimeUnavailableError, } from './errors.js';
18
19
  import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
19
20
  import { singleOwnerLock, } from './leader-lock.js';
20
21
  import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
@@ -85,7 +86,7 @@ function boundedBudget(value, fallback, name) {
85
86
  if (value === undefined)
86
87
  return fallback;
87
88
  if (!Number.isSafeInteger(value) || value < 1) {
88
- throw new ClientSyncError('sync.invalid_request', `previousVersionContext.${name} must be a positive safe integer`);
89
+ throw invalidRequest(`previousVersionContext.${name} must be a positive safe integer`);
89
90
  }
90
91
  return value;
91
92
  }
@@ -107,7 +108,7 @@ function resolvePreviousVersionLimit(limit) {
107
108
  if (limit === undefined)
108
109
  return 50;
109
110
  if (!Number.isFinite(limit) || limit < 1) {
110
- throw new ClientSyncError('sync.invalid_request', 'previousVersionSnapshot limit must be a positive number');
111
+ throw invalidRequest('previousVersionSnapshot limit must be a positive number');
111
112
  }
112
113
  return Math.min(PREVIOUS_VERSION_MAX_LIMIT, Math.trunc(limit));
113
114
  }
@@ -133,6 +134,7 @@ export class SyncClient {
133
134
  #securityLifecycle;
134
135
  #now;
135
136
  #outcomeRetentionMaxEntries;
137
+ #retainFailedCommits;
136
138
  #started = false;
137
139
  #lease;
138
140
  #clientId = '';
@@ -219,18 +221,19 @@ export class SyncClient {
219
221
  #preflightBarrier;
220
222
  constructor(config) {
221
223
  if (config.securityPreflight === true && config.encryption !== undefined) {
222
- throw new ClientSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
224
+ throw invalidRequest('securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
223
225
  }
226
+ this.#retainFailedCommits = config.retainFailedCommits === true;
224
227
  this.#db = config.database;
225
228
  this.#schema = compileClientSchema(config.schema);
226
229
  const realtimePolicy = config.realtimePolicy ?? 'optional';
227
230
  if (realtimePolicy !== 'required' &&
228
231
  realtimePolicy !== 'optional' &&
229
232
  realtimePolicy !== 'off') {
230
- throw new ClientSyncError('sync.invalid_request', "realtimePolicy must be 'required', 'optional', or 'off'");
233
+ throw invalidRequest("realtimePolicy must be 'required', 'optional', or 'off'");
231
234
  }
232
235
  if (realtimePolicy === 'required' && config.realtime === undefined) {
233
- throw new ClientSyncError('sync.invalid_request', "realtimePolicy 'required' needs a realtime connector");
236
+ throw invalidRequest("realtimePolicy 'required' needs a realtime connector");
234
237
  }
235
238
  this.#config = config;
236
239
  this.#realtimePolicy = realtimePolicy;
@@ -247,7 +250,7 @@ export class SyncClient {
247
250
  const outcomeRetentionMaxEntries = config.limits?.outcomeRetentionMaxEntries ?? 1_000;
248
251
  if (!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
249
252
  outcomeRetentionMaxEntries < 1) {
250
- throw new ClientSyncError('sync.invalid_request', 'outcomeRetentionMaxEntries must be a positive safe integer');
253
+ throw invalidRequest('outcomeRetentionMaxEntries must be a positive safe integer');
251
254
  }
252
255
  this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
253
256
  // RFC 0005: the container needs a sibling database file. A host without
@@ -596,7 +599,7 @@ export class SyncClient {
596
599
  async activateSecurity(options = {}) {
597
600
  this.#requireStarted();
598
601
  if (this.#securityLifecycle === 'active') {
599
- throw new ClientSyncError('sync.invalid_request', 'activateSecurity requires the client to be in security preflight');
602
+ throw invalidRequest('activateSecurity requires the client to be in security preflight');
600
603
  }
601
604
  await (this.#preflightBarrier ?? this.#opChain);
602
605
  this.#encryption = options.encryption;
@@ -767,7 +770,7 @@ export class SyncClient {
767
770
  }
768
771
  const table = record.tables.find((entry) => entry.name === spec.table);
769
772
  if (table === undefined) {
770
- throw new ClientSyncError('sync.invalid_request', `previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`);
773
+ throw invalidRequest(`previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`);
771
774
  }
772
775
  const read = withPreviousVersionContainer(this.#db, (container) => readPreviousVersionRows(container, table, spec.rowIds?.map(String) ?? [], resolvePreviousVersionLimit(spec.limit)));
773
776
  if (read === undefined) {
@@ -960,7 +963,7 @@ export class SyncClient {
960
963
  this.#requireActive();
961
964
  const expected = request.expectedSubscriptions ?? [];
962
965
  if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
963
- throw new ClientSyncError('sync.invalid_request', `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
966
+ throw invalidRequest(`diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
964
967
  }
965
968
  const registered = loadSubscriptions(this.#db);
966
969
  const subscriptions = new Map();
@@ -993,7 +996,7 @@ export class SyncClient {
993
996
  item.id.length === 0 ||
994
997
  typeof item.table !== 'string' ||
995
998
  item.table.length === 0) {
996
- throw new ClientSyncError('sync.invalid_request', 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
999
+ throw invalidRequest('diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
997
1000
  }
998
1001
  const registeredSubscription = subscriptions.get(item.id);
999
1002
  if (registeredSubscription !== undefined &&
@@ -1181,6 +1184,10 @@ export class SyncClient {
1181
1184
  this.#batch = batch;
1182
1185
  try {
1183
1186
  result = fn(batch);
1187
+ if (this.#db
1188
+ .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1189
+ .some((row) => batch.hasTable(String(row.tbl))))
1190
+ batch.outcomes();
1184
1191
  if (batch.touched) {
1185
1192
  revision = bumpLocalRevision(this.#db);
1186
1193
  if (batch.statusChanged) {
@@ -1258,7 +1265,7 @@ export class SyncClient {
1258
1265
  }
1259
1266
  async #uploadBlob(bytes, options) {
1260
1267
  if (this.#config.blobs === undefined) {
1261
- throw new ClientSyncError('sync.invalid_request', 'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
1268
+ throw invalidRequest('uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
1262
1269
  }
1263
1270
  const blobId = await computeBlobId(bytes);
1264
1271
  const nowMs = this.#now();
@@ -1298,7 +1305,7 @@ export class SyncClient {
1298
1305
  return cached;
1299
1306
  const transport = this.#config.blobs;
1300
1307
  if (transport === undefined) {
1301
- throw new ClientSyncError('sync.invalid_request', 'fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
1308
+ throw invalidRequest('fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
1302
1309
  }
1303
1310
  // §5.9.5: the authorized endpoint serves bytes inline OR (always-issue,
1304
1311
  // presign configured) a signed url the client fetches directly. On a url
@@ -1308,7 +1315,7 @@ export class SyncClient {
1308
1315
  let bytes;
1309
1316
  if (response.kind === 'url') {
1310
1317
  if (transport.fetchUrl === undefined) {
1311
- throw new ClientSyncError('sync.invalid_request', 'blob download returned a url but the transport cannot fetch urls (§5.9.5)');
1318
+ throw invalidRequest('blob download returned a url but the transport cannot fetch urls (§5.9.5)');
1312
1319
  }
1313
1320
  if (response.urlExpiresAtMs !== undefined &&
1314
1321
  response.urlExpiresAtMs <= this.#now()) {
@@ -1325,14 +1332,14 @@ export class SyncClient {
1325
1332
  // §5.9.5 inherits §5.1: verify the content address, reject on mismatch.
1326
1333
  // On the url path this invalidates the fetch (no fall-through) — the
1327
1334
  // next fetchBlob re-requests the authorized endpoint (§5.9.5 recovery).
1328
- throw new ClientSyncError('sync.invalid_request', `blob content address mismatch for ${blobId} (§5.9.5)`);
1335
+ throw invalidRequest(`blob content address mismatch for ${blobId} (§5.9.5)`);
1329
1336
  }
1330
1337
  putCachedBlob(this.#db, blobId, bytes, this.#now());
1331
1338
  this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
1332
1339
  this.#enforceBlobCacheCap();
1333
1340
  const stored = getCachedBlob(this.#db, blobId);
1334
1341
  if (stored === undefined) {
1335
- throw new ClientSyncError('sync.invalid_request', 'blob cache write failed');
1342
+ throw invalidRequest('blob cache write failed');
1336
1343
  }
1337
1344
  return stored;
1338
1345
  }
@@ -1416,7 +1423,7 @@ export class SyncClient {
1416
1423
  /**
1417
1424
  * Mark a durable failure handled without deleting its evidence. Conflicts
1418
1425
  * may keep the server row or link to a replacement commit; rejections may
1419
- * only be superseded by a named replacement. Applied/cached history may be
1426
+ * also keep the server row or link to a named replacement. Applied/cached history may be
1420
1427
  * dismissed. The transition is one-way and survives restart.
1421
1428
  */
1422
1429
  resolveCommitOutcome(input) {
@@ -1432,22 +1439,30 @@ export class SyncClient {
1432
1439
  if (replacement === undefined ||
1433
1440
  replacement.length === 0 ||
1434
1441
  replacement === input.clientCommitId) {
1435
- throw new ClientSyncError('sync.invalid_request', 'superseded outcomes require a distinct replacementClientCommitId');
1442
+ throw invalidRequest('superseded outcomes require a distinct replacementClientCommitId');
1436
1443
  }
1437
1444
  }
1438
1445
  else if (replacement !== undefined) {
1439
- throw new ClientSyncError('sync.invalid_request', 'replacementClientCommitId is valid only for superseded outcomes');
1446
+ throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
1440
1447
  }
1441
1448
  const allowed = (current.status === 'conflict' &&
1442
1449
  (input.resolution === 'resolved_keep_server' ||
1443
1450
  input.resolution === 'superseded')) ||
1444
- (current.status === 'rejected' && input.resolution === 'superseded') ||
1451
+ (current.status === 'rejected' &&
1452
+ (input.resolution === 'superseded' ||
1453
+ input.resolution === 'resolved_keep_server')) ||
1445
1454
  ((current.status === 'applied' || current.status === 'cached') &&
1446
1455
  input.resolution === 'dismissed');
1447
1456
  if (!allowed) {
1448
- throw new ClientSyncError('sync.invalid_request', `resolution ${input.resolution} is invalid for ${current.status} outcome`);
1457
+ throw invalidRequest(`resolution ${input.resolution} is invalid for ${current.status} outcome`);
1449
1458
  }
1459
+ const priorConflicts = this.#conflicts;
1460
+ const priorRejections = this.#rejections;
1450
1461
  return this.#applyBatch((batch) => {
1462
+ restoreFailedBases(this.#db, this.#schema);
1463
+ this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
1464
+ input.clientCommitId,
1465
+ ]);
1451
1466
  const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
1452
1467
  if (resolved === undefined) {
1453
1468
  throw new ClientSyncError('sync.outcome_not_found', `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`);
@@ -1455,12 +1470,19 @@ export class SyncClient {
1455
1470
  this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== input.clientCommitId);
1456
1471
  this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== input.clientCommitId);
1457
1472
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1473
+ this.#replayOutbox();
1474
+ for (const operation of current.operations ?? [])
1475
+ batch.table(operation.table);
1458
1476
  batch.outcomes();
1459
1477
  if (current.status === 'conflict')
1460
1478
  batch.conflicts();
1461
1479
  if (current.status === 'rejected')
1462
1480
  batch.rejections();
1463
1481
  return resolved;
1482
+ }, undefined, (error) => {
1483
+ this.#conflicts = priorConflicts;
1484
+ this.#rejections = priorRejections;
1485
+ throw error;
1464
1486
  });
1465
1487
  }
1466
1488
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
@@ -1511,7 +1533,7 @@ export class SyncClient {
1511
1533
  this.#requireActive();
1512
1534
  const socket = this.#socket;
1513
1535
  if (socket === undefined) {
1514
- throw new ClientSyncError('sync.invalid_request', 'setPresence requires a connected realtime socket (§8.6)');
1536
+ throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
1515
1537
  }
1516
1538
  socket.send(encodePresencePublish(scopeKey, doc));
1517
1539
  }
@@ -1600,7 +1622,7 @@ export class SyncClient {
1600
1622
  this.#requireActive();
1601
1623
  const table = this.#table(base.table);
1602
1624
  if (!table.scopeColumnByVariable.has(base.variable)) {
1603
- throw new ClientSyncError('sync.invalid_request', `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
1625
+ throw invalidRequest(`setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
1604
1626
  }
1605
1627
  // Serialize the whole window edit: it spans an `await deriveSubId` between
1606
1628
  // db transactions, so without the chain a delta apply (or a concurrent
@@ -1761,7 +1783,10 @@ export class SyncClient {
1761
1783
  */
1762
1784
  #pinnedRowIds(table) {
1763
1785
  const pinned = new Set();
1764
- for (const commit of listOutbox(this.#db)) {
1786
+ for (const commit of [
1787
+ ...failedOverlayCommits(this.#db),
1788
+ ...listOutbox(this.#db),
1789
+ ]) {
1765
1790
  for (const op of commit.operations) {
1766
1791
  if (op.table === table)
1767
1792
  pinned.add(op.rowId);
@@ -1778,7 +1803,7 @@ export class SyncClient {
1778
1803
  mutate(mutations) {
1779
1804
  return this.#recordMutations(mutations);
1780
1805
  }
1781
- #recordMutations(mutations, partial = false) {
1806
+ #recordMutations(mutations) {
1782
1807
  this.#requireActive();
1783
1808
  const clientCommitId = crypto.randomUUID();
1784
1809
  const operations = mutations.map((mutation) => {
@@ -1793,7 +1818,8 @@ export class SyncClient {
1793
1818
  : {}),
1794
1819
  };
1795
1820
  }
1796
- if (partial) {
1821
+ const json = {};
1822
+ if (mutation.op === 'patch') {
1797
1823
  // §6.7 patch: the values map IS the presence set — the primary key
1798
1824
  // plus the supplied non-scope columns. Absent columns keep their
1799
1825
  // stored value; the overlay and the server both apply only the
@@ -1801,7 +1827,6 @@ export class SyncClient {
1801
1827
  const normalized = normalizeRecordKeys(table, mutation.values);
1802
1828
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
1803
1829
  const pkColumn = table.columns[table.primaryKeyIndex];
1804
- const json = {};
1805
1830
  for (const column of table.columns) {
1806
1831
  if (!normalized.has(column.name))
1807
1832
  continue;
@@ -1822,43 +1847,31 @@ export class SyncClient {
1822
1847
  : undefined;
1823
1848
  if (local === undefined ||
1824
1849
  !Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1825
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1850
+ throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1826
1851
  }
1827
1852
  continue;
1828
1853
  }
1829
1854
  const value = normalized.get(column.name);
1830
1855
  if (value === undefined || value === null) {
1831
1856
  if (!column.nullable) {
1832
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
1857
+ throw invalidRequest(`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
1833
1858
  }
1834
1859
  json[column.name] = null;
1835
1860
  continue;
1836
1861
  }
1837
1862
  json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
1838
1863
  }
1839
- const pkValue = json[pkColumn.name];
1840
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
1841
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
1842
- }
1843
- return {
1844
- table: mutation.table,
1845
- rowId: pkValue,
1846
- op: 'upsert',
1847
- ...(mutation.baseVersion !== undefined
1848
- ? { baseVersion: mutation.baseVersion }
1849
- : {}),
1850
- values: json,
1851
- };
1852
1864
  }
1853
- const values = recordToRowValues(table, mutation.values);
1854
- const pkValue = values[table.primaryKeyIndex];
1865
+ else {
1866
+ const values = recordToRowValues(table, mutation.values);
1867
+ table.columns.forEach((column, index) => {
1868
+ json[column.name] = rowValueToJson(values[index] ?? null);
1869
+ });
1870
+ }
1871
+ const pkValue = json[table.primaryKey];
1855
1872
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
1856
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
1873
+ throw invalidRequest(`table ${table.name}: upsert requires a non-empty string primary key`);
1857
1874
  }
1858
- const json = {};
1859
- table.columns.forEach((column, index) => {
1860
- json[column.name] = rowValueToJson(values[index] ?? null);
1861
- });
1862
1875
  return {
1863
1876
  table: mutation.table,
1864
1877
  rowId: pkValue,
@@ -1940,12 +1953,12 @@ export class SyncClient {
1940
1953
  const normalized = normalizeRecordKeys(compiled, partial);
1941
1954
  const pkOverride = normalized.get(pkColumn.name);
1942
1955
  if (pkOverride !== undefined && pkOverride !== rowId) {
1943
- throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: patch cannot change the primary key`);
1956
+ throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
1944
1957
  }
1945
1958
  return this.#recordMutations([
1946
1959
  {
1947
1960
  table,
1948
- op: 'upsert',
1961
+ op: 'patch',
1949
1962
  values: {
1950
1963
  ...Object.fromEntries(normalized),
1951
1964
  [pkColumn.name]: rowId,
@@ -1954,7 +1967,7 @@ export class SyncClient {
1954
1967
  ? { baseVersion: options.baseVersion }
1955
1968
  : {}),
1956
1969
  },
1957
- ], true);
1970
+ ]);
1958
1971
  }
1959
1972
  /**
1960
1973
  * Apply one host-authorized local security purge. The host must stop/gate
@@ -1973,7 +1986,7 @@ export class SyncClient {
1973
1986
  const appliedPlan = getMeta(this.#db, metaKey);
1974
1987
  if (appliedPlan !== undefined) {
1975
1988
  if (appliedPlan !== purge.canonicalPlan) {
1976
- throw new ClientSyncError('sync.invalid_request', `local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`);
1989
+ throw invalidRequest(`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`);
1977
1990
  }
1978
1991
  return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
1979
1992
  }
@@ -1985,6 +1998,13 @@ export class SyncClient {
1985
1998
  try {
1986
1999
  return this.#applyBatch((batch) => {
1987
2000
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2001
+ restoreFailedBases(this.#db, this.#schema);
2002
+ const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
2003
+ for (const commit of droppedRetained) {
2004
+ for (const operation of commit.operations)
2005
+ batch.table(operation.table);
2006
+ batch.outcomes();
2007
+ }
1988
2008
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
1989
2009
  // a commit is doomed when any of its ops touches a base-matching
1990
2010
  // row. Base values only become visible here as rollbacks restore
@@ -2067,6 +2087,7 @@ export class SyncClient {
2067
2087
  batch.rejections();
2068
2088
  batch.outcomes();
2069
2089
  }
2090
+ this.#replayOutbox();
2070
2091
  this.#deleteUnreferencedBlobs();
2071
2092
  setMeta(this.#db, metaKey, purge.canonicalPlan);
2072
2093
  return {
@@ -2107,7 +2128,7 @@ export class SyncClient {
2107
2128
  };
2108
2129
  }
2109
2130
  if (this.#schemaFloor !== undefined) {
2110
- throw new ClientSyncError('sync.invalid_request', 'local rebootstrap cannot bypass an active schema-floor stop; update the application first');
2131
+ throw invalidRequest('local rebootstrap cannot bypass an active schema-floor stop; update the application first');
2111
2132
  }
2112
2133
  const pending = listOutbox(this.#db);
2113
2134
  const resetSubscriptions = loadSubscriptions(this.#db).length;
@@ -2272,7 +2293,10 @@ export class SyncClient {
2272
2293
  */
2273
2294
  #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
2274
2295
  this.#applyBatch((batch) => {
2296
+ if (this.#retainFailedCommits && code === 'client.encrypt_failed')
2297
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
2275
2298
  this.#rollbackFailedCommit(commit, batch);
2299
+ this.#replayOutbox();
2276
2300
  const rejection = {
2277
2301
  clientCommitId: commit.clientCommitId,
2278
2302
  opIndex: 0,
@@ -2310,7 +2334,7 @@ export class SyncClient {
2310
2334
  sync() {
2311
2335
  this.#requireActive();
2312
2336
  if (this.#syncOutstanding) {
2313
- return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
2337
+ return Promise.reject(invalidRequest('sync() is already running — the core owns one loop (coalesce wake-ups)'));
2314
2338
  }
2315
2339
  this.#syncOutstanding = true;
2316
2340
  this.#beginDiagnosticsDeferral();
@@ -2327,7 +2351,8 @@ export class SyncClient {
2327
2351
  };
2328
2352
  this.#emitDiagnostics();
2329
2353
  return summary;
2330
- }, (error) => {
2354
+ }, (caught) => {
2355
+ const error = classifySqliteFailure(caught).error;
2331
2356
  const completedAtMs = this.#now();
2332
2357
  const code = error.code;
2333
2358
  this.#progress.update({
@@ -2464,7 +2489,7 @@ export class SyncClient {
2464
2489
  const responseBytes = await this.#roundTrip(requestBytes);
2465
2490
  const message = decodeMessage(responseBytes);
2466
2491
  if (message.msgKind !== 'response') {
2467
- throw new ClientSyncError('sync.invalid_request', 'transport returned a non-response message');
2492
+ throw invalidRequest('transport returned a non-response message');
2468
2493
  }
2469
2494
  const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
2470
2495
  // §4.8 E1: the push half may have drained commits that pinned rows of
@@ -2552,7 +2577,7 @@ export class SyncClient {
2552
2577
  return last;
2553
2578
  }
2554
2579
  }
2555
- throw new ClientSyncError('sync.invalid_request', 'sync did not reach idle within the round budget');
2580
+ throw invalidRequest('sync did not reach idle within the round budget');
2556
2581
  }
2557
2582
  /**
2558
2583
  * One request/response round trip (§8.7): over the socket whenever it
@@ -2635,7 +2660,7 @@ export class SyncClient {
2635
2660
  connectRealtime() {
2636
2661
  this.#requireActive();
2637
2662
  if (this.#realtimePolicy === 'off') {
2638
- return Promise.reject(new ClientSyncError('sync.invalid_request', "realtimePolicy is 'off'; realtime connect is refused"));
2663
+ return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
2639
2664
  }
2640
2665
  if (this.#socket !== undefined)
2641
2666
  return Promise.resolve();
@@ -2661,7 +2686,7 @@ export class SyncClient {
2661
2686
  async #connectRealtime(generation) {
2662
2687
  const connector = this.#config.realtime;
2663
2688
  if (connector === undefined) {
2664
- throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
2689
+ throw invalidRequest('no realtime connector configured');
2665
2690
  }
2666
2691
  let openedSocket;
2667
2692
  try {
@@ -2742,14 +2767,14 @@ export class SyncClient {
2742
2767
  }
2743
2768
  catch (error) {
2744
2769
  this.#pendingRound = undefined;
2745
- round.reject(new ClientSyncError('sync.invalid_request', `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
2770
+ round.reject(invalidRequest(`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
2746
2771
  return;
2747
2772
  }
2748
2773
  if (done === undefined)
2749
2774
  return;
2750
2775
  this.#pendingRound = undefined;
2751
2776
  if (done.excess > 0) {
2752
- round.reject(new ClientSyncError('sync.invalid_request', 'response bytes past END of the round stream (§8.7)'));
2777
+ round.reject(invalidRequest('response bytes past END of the round stream (§8.7)'));
2753
2778
  return;
2754
2779
  }
2755
2780
  round.resolve(done.message.slice());
@@ -2889,7 +2914,7 @@ export class SyncClient {
2889
2914
  }
2890
2915
  const header = message.frames[0];
2891
2916
  if (header?.type !== 'RESP_HEADER') {
2892
- throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
2917
+ throw invalidRequest('missing RESP_HEADER');
2893
2918
  }
2894
2919
  if (message.wireVersion < 2 ||
2895
2920
  header.logEpoch === undefined ||
@@ -3056,7 +3081,7 @@ export class SyncClient {
3056
3081
  // broken server — fail loud, never skip or guess.
3057
3082
  if (frame.mediaType === 'sqlite' &&
3058
3083
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3059
- throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3084
+ throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3060
3085
  }
3061
3086
  const bytes = await this.#downloadSegment(frame, section.sub);
3062
3087
  this.#progress.update({ phase: 'import' });
@@ -3065,7 +3090,7 @@ export class SyncClient {
3065
3090
  // invalid, and the image is always its table's first page.
3066
3091
  if (frame.rowCursor !== undefined ||
3067
3092
  frame.nextRowCursor !== undefined) {
3068
- throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
3093
+ throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
3069
3094
  }
3070
3095
  await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
3071
3096
  table: frame.table,
@@ -3272,6 +3297,8 @@ export class SyncClient {
3272
3297
  // validator-rejected updates even when the server emitted no new COMMIT;
3273
3298
  // later pending overlays are then replayed with rebased before-images.
3274
3299
  this.#db.transaction(() => {
3300
+ if (this.#retainFailedCommits)
3301
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3275
3302
  this.#rollbackFailedCommit(commit, batch);
3276
3303
  });
3277
3304
  batch.status();
@@ -3422,7 +3449,7 @@ export class SyncClient {
3422
3449
  async #downloadSegment(frame, sub) {
3423
3450
  const downloader = this.#config.segments;
3424
3451
  if (downloader === undefined) {
3425
- throw new ClientSyncError('sync.invalid_request', 'received SEGMENT_REF but no segment downloader is configured');
3452
+ throw invalidRequest('received SEGMENT_REF but no segment downloader is configured');
3426
3453
  }
3427
3454
  this.#progress.update({
3428
3455
  phase: 'download',
@@ -3446,7 +3473,7 @@ export class SyncClient {
3446
3473
  const fetchUrl = downloader.fetchUrl;
3447
3474
  if (fetchUrl === undefined ||
3448
3475
  (this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0) {
3449
- throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)');
3476
+ throw invalidRequest('SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)');
3450
3477
  }
3451
3478
  if (frame.urlExpiresAtMs !== undefined &&
3452
3479
  frame.urlExpiresAtMs <= this.#now()) {
@@ -3469,7 +3496,7 @@ export class SyncClient {
3469
3496
  onProgress(bytes.byteLength);
3470
3497
  const hash = await sha256Hex(bytes);
3471
3498
  if (`sha256:${hash}` !== frame.segmentId) {
3472
- throw new ClientSyncError('sync.invalid_request', `segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
3499
+ throw invalidRequest(`segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
3473
3500
  }
3474
3501
  return bytes;
3475
3502
  }
@@ -3536,6 +3563,14 @@ export class SyncClient {
3536
3563
  if (lastEffective !== undefined &&
3537
3564
  Object.keys(lastEffective).length > 0) {
3538
3565
  try {
3566
+ restoreFailedBases(this.#db, this.#schema);
3567
+ const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
3568
+ Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
3569
+ for (const commit of droppedRetained) {
3570
+ for (const operation of commit.operations)
3571
+ batch.table(operation.table);
3572
+ batch.outcomes();
3573
+ }
3539
3574
  deleteScopedRows(this.#db, table, lastEffective);
3540
3575
  batch.scopeMap(table, lastEffective);
3541
3576
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3646,7 +3681,7 @@ export class SyncClient {
3646
3681
  throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
3647
3682
  }
3648
3683
  const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
3649
- upsertLocalRow(this.#db, table, values, image.syncVersion);
3684
+ upsertLocalRow(this.#db, table, values, image.syncVersion, false);
3650
3685
  }
3651
3686
  #legacyUndoOptimisticRows(commit, batch) {
3652
3687
  for (const operation of commit.operations) {
@@ -3706,7 +3741,7 @@ export class SyncClient {
3706
3741
  if (op.op === 'delete') {
3707
3742
  if (batch !== undefined && !precise)
3708
3743
  batch.table(op.table);
3709
- deleteLocalRow(this.#db, table, op.rowId);
3744
+ deleteLocalRow(this.#db, table, op.rowId, false);
3710
3745
  continue;
3711
3746
  }
3712
3747
  // §7.1 overlay: apply the operation's PRESENT columns over the current
@@ -3746,19 +3781,23 @@ export class SyncClient {
3746
3781
  const version = local === undefined
3747
3782
  ? OPTIMISTIC_VERSION
3748
3783
  : local[SYNC_VERSION_COLUMN];
3749
- upsertLocalRow(this.#db, table, values, version);
3784
+ upsertLocalRow(this.#db, table, values, version, false);
3750
3785
  }
3751
3786
  }
3752
3787
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
3753
3788
  #replayOutbox() {
3754
- const pending = listOutbox(this.#db);
3789
+ const pending = [
3790
+ ...failedOverlayCommits(this.#db),
3791
+ ...listOutbox(this.#db),
3792
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3755
3793
  if (pending.length === 0)
3756
3794
  return;
3757
3795
  this.#applyBatch((batch) => {
3758
3796
  this.#db.transaction(() => {
3759
- for (const commit of pending) {
3797
+ restoreFailedBases(this.#db, this.#schema);
3798
+ restoreFailedBases(this.#db, this.#schema, true);
3799
+ for (const commit of pending)
3760
3800
  this.#applyOperationsLocally(commit.operations, batch);
3761
- }
3762
3801
  });
3763
3802
  });
3764
3803
  }
@@ -3777,7 +3816,7 @@ export class SyncClient {
3777
3816
  }
3778
3817
  #requireStarted() {
3779
3818
  if (!this.#started) {
3780
- throw new ClientSyncError('sync.invalid_request', 'SyncClient.start() has not completed');
3819
+ throw invalidRequest('SyncClient.start() has not completed');
3781
3820
  }
3782
3821
  }
3783
3822
  #requireActive() {
@@ -63,4 +63,7 @@ export declare function assertImageAlias(alias: string): void;
63
63
  */
64
64
  export declare function runTransaction<T>(depthHolder: {
65
65
  depth: number;
66
+ aborted?: {
67
+ error: unknown;
68
+ };
66
69
  }, run: (sql: string) => void, fn: () => T): T;