@syncular/client 0.30.7 → 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();
@@ -2465,7 +2489,7 @@ export class SyncClient {
2465
2489
  const responseBytes = await this.#roundTrip(requestBytes);
2466
2490
  const message = decodeMessage(responseBytes);
2467
2491
  if (message.msgKind !== 'response') {
2468
- throw new ClientSyncError('sync.invalid_request', 'transport returned a non-response message');
2492
+ throw invalidRequest('transport returned a non-response message');
2469
2493
  }
2470
2494
  const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
2471
2495
  // §4.8 E1: the push half may have drained commits that pinned rows of
@@ -2553,7 +2577,7 @@ export class SyncClient {
2553
2577
  return last;
2554
2578
  }
2555
2579
  }
2556
- 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');
2557
2581
  }
2558
2582
  /**
2559
2583
  * One request/response round trip (§8.7): over the socket whenever it
@@ -2636,7 +2660,7 @@ export class SyncClient {
2636
2660
  connectRealtime() {
2637
2661
  this.#requireActive();
2638
2662
  if (this.#realtimePolicy === 'off') {
2639
- 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"));
2640
2664
  }
2641
2665
  if (this.#socket !== undefined)
2642
2666
  return Promise.resolve();
@@ -2662,7 +2686,7 @@ export class SyncClient {
2662
2686
  async #connectRealtime(generation) {
2663
2687
  const connector = this.#config.realtime;
2664
2688
  if (connector === undefined) {
2665
- throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
2689
+ throw invalidRequest('no realtime connector configured');
2666
2690
  }
2667
2691
  let openedSocket;
2668
2692
  try {
@@ -2743,14 +2767,14 @@ export class SyncClient {
2743
2767
  }
2744
2768
  catch (error) {
2745
2769
  this.#pendingRound = undefined;
2746
- 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)}`));
2747
2771
  return;
2748
2772
  }
2749
2773
  if (done === undefined)
2750
2774
  return;
2751
2775
  this.#pendingRound = undefined;
2752
2776
  if (done.excess > 0) {
2753
- 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)'));
2754
2778
  return;
2755
2779
  }
2756
2780
  round.resolve(done.message.slice());
@@ -2890,7 +2914,7 @@ export class SyncClient {
2890
2914
  }
2891
2915
  const header = message.frames[0];
2892
2916
  if (header?.type !== 'RESP_HEADER') {
2893
- throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
2917
+ throw invalidRequest('missing RESP_HEADER');
2894
2918
  }
2895
2919
  if (message.wireVersion < 2 ||
2896
2920
  header.logEpoch === undefined ||
@@ -3057,7 +3081,7 @@ export class SyncClient {
3057
3081
  // broken server — fail loud, never skip or guess.
3058
3082
  if (frame.mediaType === 'sqlite' &&
3059
3083
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3060
- 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)');
3061
3085
  }
3062
3086
  const bytes = await this.#downloadSegment(frame, section.sub);
3063
3087
  this.#progress.update({ phase: 'import' });
@@ -3066,7 +3090,7 @@ export class SyncClient {
3066
3090
  // invalid, and the image is always its table's first page.
3067
3091
  if (frame.rowCursor !== undefined ||
3068
3092
  frame.nextRowCursor !== undefined) {
3069
- 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)');
3070
3094
  }
3071
3095
  await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
3072
3096
  table: frame.table,
@@ -3273,6 +3297,8 @@ export class SyncClient {
3273
3297
  // validator-rejected updates even when the server emitted no new COMMIT;
3274
3298
  // later pending overlays are then replayed with rebased before-images.
3275
3299
  this.#db.transaction(() => {
3300
+ if (this.#retainFailedCommits)
3301
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3276
3302
  this.#rollbackFailedCommit(commit, batch);
3277
3303
  });
3278
3304
  batch.status();
@@ -3423,7 +3449,7 @@ export class SyncClient {
3423
3449
  async #downloadSegment(frame, sub) {
3424
3450
  const downloader = this.#config.segments;
3425
3451
  if (downloader === undefined) {
3426
- 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');
3427
3453
  }
3428
3454
  this.#progress.update({
3429
3455
  phase: 'download',
@@ -3447,7 +3473,7 @@ export class SyncClient {
3447
3473
  const fetchUrl = downloader.fetchUrl;
3448
3474
  if (fetchUrl === undefined ||
3449
3475
  (this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0) {
3450
- 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)');
3451
3477
  }
3452
3478
  if (frame.urlExpiresAtMs !== undefined &&
3453
3479
  frame.urlExpiresAtMs <= this.#now()) {
@@ -3470,7 +3496,7 @@ export class SyncClient {
3470
3496
  onProgress(bytes.byteLength);
3471
3497
  const hash = await sha256Hex(bytes);
3472
3498
  if (`sha256:${hash}` !== frame.segmentId) {
3473
- 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);
3474
3500
  }
3475
3501
  return bytes;
3476
3502
  }
@@ -3537,6 +3563,14 @@ export class SyncClient {
3537
3563
  if (lastEffective !== undefined &&
3538
3564
  Object.keys(lastEffective).length > 0) {
3539
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
+ }
3540
3574
  deleteScopedRows(this.#db, table, lastEffective);
3541
3575
  batch.scopeMap(table, lastEffective);
3542
3576
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3647,7 +3681,7 @@ export class SyncClient {
3647
3681
  throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
3648
3682
  }
3649
3683
  const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
3650
- upsertLocalRow(this.#db, table, values, image.syncVersion);
3684
+ upsertLocalRow(this.#db, table, values, image.syncVersion, false);
3651
3685
  }
3652
3686
  #legacyUndoOptimisticRows(commit, batch) {
3653
3687
  for (const operation of commit.operations) {
@@ -3707,7 +3741,7 @@ export class SyncClient {
3707
3741
  if (op.op === 'delete') {
3708
3742
  if (batch !== undefined && !precise)
3709
3743
  batch.table(op.table);
3710
- deleteLocalRow(this.#db, table, op.rowId);
3744
+ deleteLocalRow(this.#db, table, op.rowId, false);
3711
3745
  continue;
3712
3746
  }
3713
3747
  // §7.1 overlay: apply the operation's PRESENT columns over the current
@@ -3747,19 +3781,23 @@ export class SyncClient {
3747
3781
  const version = local === undefined
3748
3782
  ? OPTIMISTIC_VERSION
3749
3783
  : local[SYNC_VERSION_COLUMN];
3750
- upsertLocalRow(this.#db, table, values, version);
3784
+ upsertLocalRow(this.#db, table, values, version, false);
3751
3785
  }
3752
3786
  }
3753
3787
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
3754
3788
  #replayOutbox() {
3755
- 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);
3756
3793
  if (pending.length === 0)
3757
3794
  return;
3758
3795
  this.#applyBatch((batch) => {
3759
3796
  this.#db.transaction(() => {
3760
- for (const commit of pending) {
3797
+ restoreFailedBases(this.#db, this.#schema);
3798
+ restoreFailedBases(this.#db, this.#schema, true);
3799
+ for (const commit of pending)
3761
3800
  this.#applyOperationsLocally(commit.operations, batch);
3762
- }
3763
3801
  });
3764
3802
  });
3765
3803
  }
@@ -3778,7 +3816,7 @@ export class SyncClient {
3778
3816
  }
3779
3817
  #requireStarted() {
3780
3818
  if (!this.#started) {
3781
- throw new ClientSyncError('sync.invalid_request', 'SyncClient.start() has not completed');
3819
+ throw invalidRequest('SyncClient.start() has not completed');
3782
3820
  }
3783
3821
  }
3784
3822
  #requireActive() {
package/dist/errors.d.ts CHANGED
@@ -25,6 +25,8 @@ export declare class ClientSyncError extends Error {
25
25
  readonly details: Readonly<Record<string, unknown>> | undefined;
26
26
  constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>);
27
27
  }
28
+ /** Shared invalid-request construction for client validators and transport guards. */
29
+ export declare function invalidRequest(message: string, retryable?: boolean): ClientSyncError;
28
30
  /**
29
31
  * SPEC §7.5: the latest sync attempt failed while a live query still waited
30
32
  * for its required coverage. `code` is the attempt's static progress error
package/dist/errors.js CHANGED
@@ -29,6 +29,10 @@ export class ClientSyncError extends Error {
29
29
  this.details = details;
30
30
  }
31
31
  }
32
+ /** Shared invalid-request construction for client validators and transport guards. */
33
+ export function invalidRequest(message, retryable = false) {
34
+ return new ClientSyncError('sync.invalid_request', message, retryable);
35
+ }
32
36
  /**
33
37
  * SPEC §7.5: the latest sync attempt failed while a live query still waited
34
38
  * for its required coverage. `code` is the attempt's static progress error
@@ -0,0 +1,10 @@
1
+ import type { ClientDatabase } from './database.js';
2
+ import type { CompiledClientSchema, JsonRowValue } from './schema.js';
3
+ import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
4
+ /** Protected failure state. The server base advances independently of intent. */
5
+ export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
6
+ export declare function retainedBaseWrite(db: ClientDatabase, table: string, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
7
+ export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
8
+ export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
9
+ export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
10
+ export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];