@syncular/client 0.30.7 → 0.30.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,10 @@ 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 = {};
1830
+ const pkValue = requireRowId(normalized.get(pkColumn.name));
1831
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0];
1832
+ if (!local)
1833
+ throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
1805
1834
  for (const column of table.columns) {
1806
1835
  if (!normalized.has(column.name))
1807
1836
  continue;
@@ -1816,49 +1845,29 @@ export class SyncClient {
1816
1845
  // by construction the row id being patched (§6.1), so its value is
1817
1846
  // proven equal already.
1818
1847
  if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
1819
- const pkValue = normalized.get(pkColumn.name);
1820
- const local = typeof pkValue === 'string' && pkValue.length > 0
1821
- ? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
1822
- : undefined;
1823
- if (local === undefined ||
1824
- !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)`);
1848
+ if (!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1849
+ throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1826
1850
  }
1827
1851
  continue;
1828
1852
  }
1829
1853
  const value = normalized.get(column.name);
1830
1854
  if (value === undefined || value === null) {
1831
1855
  if (!column.nullable) {
1832
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
1856
+ throw invalidRequest(`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
1833
1857
  }
1834
1858
  json[column.name] = null;
1835
1859
  continue;
1836
1860
  }
1837
1861
  json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
1838
1862
  }
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
1863
  }
1853
- const values = recordToRowValues(table, mutation.values);
1854
- const pkValue = values[table.primaryKeyIndex];
1855
- 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`);
1864
+ else {
1865
+ const values = recordToRowValues(table, mutation.values);
1866
+ table.columns.forEach((column, index) => {
1867
+ json[column.name] = rowValueToJson(values[index] ?? null);
1868
+ });
1857
1869
  }
1858
- const json = {};
1859
- table.columns.forEach((column, index) => {
1860
- json[column.name] = rowValueToJson(values[index] ?? null);
1861
- });
1870
+ const pkValue = requireRowId(json[table.primaryKey]);
1862
1871
  return {
1863
1872
  table: mutation.table,
1864
1873
  rowId: pkValue,
@@ -1940,12 +1949,12 @@ export class SyncClient {
1940
1949
  const normalized = normalizeRecordKeys(compiled, partial);
1941
1950
  const pkOverride = normalized.get(pkColumn.name);
1942
1951
  if (pkOverride !== undefined && pkOverride !== rowId) {
1943
- throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: patch cannot change the primary key`);
1952
+ throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
1944
1953
  }
1945
1954
  return this.#recordMutations([
1946
1955
  {
1947
1956
  table,
1948
- op: 'upsert',
1957
+ op: 'patch',
1949
1958
  values: {
1950
1959
  ...Object.fromEntries(normalized),
1951
1960
  [pkColumn.name]: rowId,
@@ -1954,7 +1963,7 @@ export class SyncClient {
1954
1963
  ? { baseVersion: options.baseVersion }
1955
1964
  : {}),
1956
1965
  },
1957
- ], true);
1966
+ ]);
1958
1967
  }
1959
1968
  /**
1960
1969
  * Apply one host-authorized local security purge. The host must stop/gate
@@ -1973,7 +1982,7 @@ export class SyncClient {
1973
1982
  const appliedPlan = getMeta(this.#db, metaKey);
1974
1983
  if (appliedPlan !== undefined) {
1975
1984
  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`);
1985
+ throw invalidRequest(`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`);
1977
1986
  }
1978
1987
  return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
1979
1988
  }
@@ -1985,6 +1994,13 @@ export class SyncClient {
1985
1994
  try {
1986
1995
  return this.#applyBatch((batch) => {
1987
1996
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
1997
+ restoreFailedBases(this.#db, this.#schema);
1998
+ const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
1999
+ for (const commit of droppedRetained) {
2000
+ for (const operation of commit.operations)
2001
+ batch.table(operation.table);
2002
+ batch.outcomes();
2003
+ }
1988
2004
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
1989
2005
  // a commit is doomed when any of its ops touches a base-matching
1990
2006
  // row. Base values only become visible here as rollbacks restore
@@ -2067,6 +2083,7 @@ export class SyncClient {
2067
2083
  batch.rejections();
2068
2084
  batch.outcomes();
2069
2085
  }
2086
+ this.#replayOutbox();
2070
2087
  this.#deleteUnreferencedBlobs();
2071
2088
  setMeta(this.#db, metaKey, purge.canonicalPlan);
2072
2089
  return {
@@ -2107,7 +2124,7 @@ export class SyncClient {
2107
2124
  };
2108
2125
  }
2109
2126
  if (this.#schemaFloor !== undefined) {
2110
- throw new ClientSyncError('sync.invalid_request', 'local rebootstrap cannot bypass an active schema-floor stop; update the application first');
2127
+ throw invalidRequest('local rebootstrap cannot bypass an active schema-floor stop; update the application first');
2111
2128
  }
2112
2129
  const pending = listOutbox(this.#db);
2113
2130
  const resetSubscriptions = loadSubscriptions(this.#db).length;
@@ -2272,7 +2289,10 @@ export class SyncClient {
2272
2289
  */
2273
2290
  #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
2274
2291
  this.#applyBatch((batch) => {
2292
+ if (this.#retainFailedCommits && code === 'client.encrypt_failed')
2293
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
2275
2294
  this.#rollbackFailedCommit(commit, batch);
2295
+ this.#replayOutbox();
2276
2296
  const rejection = {
2277
2297
  clientCommitId: commit.clientCommitId,
2278
2298
  opIndex: 0,
@@ -2310,7 +2330,7 @@ export class SyncClient {
2310
2330
  sync() {
2311
2331
  this.#requireActive();
2312
2332
  if (this.#syncOutstanding) {
2313
- return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
2333
+ return Promise.reject(invalidRequest('sync() is already running — the core owns one loop (coalesce wake-ups)'));
2314
2334
  }
2315
2335
  this.#syncOutstanding = true;
2316
2336
  this.#beginDiagnosticsDeferral();
@@ -2465,7 +2485,7 @@ export class SyncClient {
2465
2485
  const responseBytes = await this.#roundTrip(requestBytes);
2466
2486
  const message = decodeMessage(responseBytes);
2467
2487
  if (message.msgKind !== 'response') {
2468
- throw new ClientSyncError('sync.invalid_request', 'transport returned a non-response message');
2488
+ throw invalidRequest('transport returned a non-response message');
2469
2489
  }
2470
2490
  const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
2471
2491
  // §4.8 E1: the push half may have drained commits that pinned rows of
@@ -2553,7 +2573,7 @@ export class SyncClient {
2553
2573
  return last;
2554
2574
  }
2555
2575
  }
2556
- throw new ClientSyncError('sync.invalid_request', 'sync did not reach idle within the round budget');
2576
+ throw invalidRequest('sync did not reach idle within the round budget');
2557
2577
  }
2558
2578
  /**
2559
2579
  * One request/response round trip (§8.7): over the socket whenever it
@@ -2636,7 +2656,7 @@ export class SyncClient {
2636
2656
  connectRealtime() {
2637
2657
  this.#requireActive();
2638
2658
  if (this.#realtimePolicy === 'off') {
2639
- return Promise.reject(new ClientSyncError('sync.invalid_request', "realtimePolicy is 'off'; realtime connect is refused"));
2659
+ return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
2640
2660
  }
2641
2661
  if (this.#socket !== undefined)
2642
2662
  return Promise.resolve();
@@ -2662,7 +2682,7 @@ export class SyncClient {
2662
2682
  async #connectRealtime(generation) {
2663
2683
  const connector = this.#config.realtime;
2664
2684
  if (connector === undefined) {
2665
- throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
2685
+ throw invalidRequest('no realtime connector configured');
2666
2686
  }
2667
2687
  let openedSocket;
2668
2688
  try {
@@ -2743,14 +2763,14 @@ export class SyncClient {
2743
2763
  }
2744
2764
  catch (error) {
2745
2765
  this.#pendingRound = undefined;
2746
- round.reject(new ClientSyncError('sync.invalid_request', `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
2766
+ round.reject(invalidRequest(`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
2747
2767
  return;
2748
2768
  }
2749
2769
  if (done === undefined)
2750
2770
  return;
2751
2771
  this.#pendingRound = undefined;
2752
2772
  if (done.excess > 0) {
2753
- round.reject(new ClientSyncError('sync.invalid_request', 'response bytes past END of the round stream (§8.7)'));
2773
+ round.reject(invalidRequest('response bytes past END of the round stream (§8.7)'));
2754
2774
  return;
2755
2775
  }
2756
2776
  round.resolve(done.message.slice());
@@ -2890,7 +2910,7 @@ export class SyncClient {
2890
2910
  }
2891
2911
  const header = message.frames[0];
2892
2912
  if (header?.type !== 'RESP_HEADER') {
2893
- throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
2913
+ throw invalidRequest('missing RESP_HEADER');
2894
2914
  }
2895
2915
  if (message.wireVersion < 2 ||
2896
2916
  header.logEpoch === undefined ||
@@ -3057,7 +3077,7 @@ export class SyncClient {
3057
3077
  // broken server — fail loud, never skip or guess.
3058
3078
  if (frame.mediaType === 'sqlite' &&
3059
3079
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3060
- throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3080
+ throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3061
3081
  }
3062
3082
  const bytes = await this.#downloadSegment(frame, section.sub);
3063
3083
  this.#progress.update({ phase: 'import' });
@@ -3066,7 +3086,7 @@ export class SyncClient {
3066
3086
  // invalid, and the image is always its table's first page.
3067
3087
  if (frame.rowCursor !== undefined ||
3068
3088
  frame.nextRowCursor !== undefined) {
3069
- throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
3089
+ throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
3070
3090
  }
3071
3091
  await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
3072
3092
  table: frame.table,
@@ -3273,6 +3293,8 @@ export class SyncClient {
3273
3293
  // validator-rejected updates even when the server emitted no new COMMIT;
3274
3294
  // later pending overlays are then replayed with rebased before-images.
3275
3295
  this.#db.transaction(() => {
3296
+ if (this.#retainFailedCommits)
3297
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3276
3298
  this.#rollbackFailedCommit(commit, batch);
3277
3299
  });
3278
3300
  batch.status();
@@ -3423,7 +3445,7 @@ export class SyncClient {
3423
3445
  async #downloadSegment(frame, sub) {
3424
3446
  const downloader = this.#config.segments;
3425
3447
  if (downloader === undefined) {
3426
- throw new ClientSyncError('sync.invalid_request', 'received SEGMENT_REF but no segment downloader is configured');
3448
+ throw invalidRequest('received SEGMENT_REF but no segment downloader is configured');
3427
3449
  }
3428
3450
  this.#progress.update({
3429
3451
  phase: 'download',
@@ -3447,7 +3469,7 @@ export class SyncClient {
3447
3469
  const fetchUrl = downloader.fetchUrl;
3448
3470
  if (fetchUrl === undefined ||
3449
3471
  (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)');
3472
+ throw invalidRequest('SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)');
3451
3473
  }
3452
3474
  if (frame.urlExpiresAtMs !== undefined &&
3453
3475
  frame.urlExpiresAtMs <= this.#now()) {
@@ -3470,7 +3492,7 @@ export class SyncClient {
3470
3492
  onProgress(bytes.byteLength);
3471
3493
  const hash = await sha256Hex(bytes);
3472
3494
  if (`sha256:${hash}` !== frame.segmentId) {
3473
- throw new ClientSyncError('sync.invalid_request', `segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
3495
+ throw invalidRequest(`segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
3474
3496
  }
3475
3497
  return bytes;
3476
3498
  }
@@ -3537,6 +3559,14 @@ export class SyncClient {
3537
3559
  if (lastEffective !== undefined &&
3538
3560
  Object.keys(lastEffective).length > 0) {
3539
3561
  try {
3562
+ restoreFailedBases(this.#db, this.#schema);
3563
+ const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
3564
+ Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
3565
+ for (const commit of droppedRetained) {
3566
+ for (const operation of commit.operations)
3567
+ batch.table(operation.table);
3568
+ batch.outcomes();
3569
+ }
3540
3570
  deleteScopedRows(this.#db, table, lastEffective);
3541
3571
  batch.scopeMap(table, lastEffective);
3542
3572
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3647,7 +3677,7 @@ export class SyncClient {
3647
3677
  throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
3648
3678
  }
3649
3679
  const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
3650
- upsertLocalRow(this.#db, table, values, image.syncVersion);
3680
+ upsertLocalRow(this.#db, table, values, image.syncVersion, false);
3651
3681
  }
3652
3682
  #legacyUndoOptimisticRows(commit, batch) {
3653
3683
  for (const operation of commit.operations) {
@@ -3707,7 +3737,7 @@ export class SyncClient {
3707
3737
  if (op.op === 'delete') {
3708
3738
  if (batch !== undefined && !precise)
3709
3739
  batch.table(op.table);
3710
- deleteLocalRow(this.#db, table, op.rowId);
3740
+ deleteLocalRow(this.#db, table, op.rowId, false);
3711
3741
  continue;
3712
3742
  }
3713
3743
  // §7.1 overlay: apply the operation's PRESENT columns over the current
@@ -3747,19 +3777,23 @@ export class SyncClient {
3747
3777
  const version = local === undefined
3748
3778
  ? OPTIMISTIC_VERSION
3749
3779
  : local[SYNC_VERSION_COLUMN];
3750
- upsertLocalRow(this.#db, table, values, version);
3780
+ upsertLocalRow(this.#db, table, values, version, false);
3751
3781
  }
3752
3782
  }
3753
3783
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
3754
3784
  #replayOutbox() {
3755
- const pending = listOutbox(this.#db);
3785
+ const pending = [
3786
+ ...failedOverlayCommits(this.#db),
3787
+ ...listOutbox(this.#db),
3788
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3756
3789
  if (pending.length === 0)
3757
3790
  return;
3758
3791
  this.#applyBatch((batch) => {
3759
3792
  this.#db.transaction(() => {
3760
- for (const commit of pending) {
3793
+ restoreFailedBases(this.#db, this.#schema);
3794
+ restoreFailedBases(this.#db, this.#schema, true);
3795
+ for (const commit of pending)
3761
3796
  this.#applyOperationsLocally(commit.operations, batch);
3762
- }
3763
3797
  });
3764
3798
  });
3765
3799
  }
@@ -3778,7 +3812,7 @@ export class SyncClient {
3778
3812
  }
3779
3813
  #requireStarted() {
3780
3814
  if (!this.#started) {
3781
- throw new ClientSyncError('sync.invalid_request', 'SyncClient.start() has not completed');
3815
+ throw invalidRequest('SyncClient.start() has not completed');
3782
3816
  }
3783
3817
  }
3784
3818
  #requireActive() {
@@ -3788,3 +3822,8 @@ export class SyncClient {
3788
3822
  }
3789
3823
  }
3790
3824
  }
3825
+ function requireRowId(value) {
3826
+ if (typeof value !== 'string' || !value.length)
3827
+ throw invalidRequest('a mutation requires a non-empty string primary key');
3828
+ return value;
3829
+ }
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[];