@syncular/client 0.30.10 → 0.30.12

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,4 +1,5 @@
1
- import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, } from './failed-overlay.js';
1
+ import { RETAINED_ROWS } from './failed-overlay.js';
2
+ import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
2
3
  import { ProgressEmitter, } from './progress.js';
3
4
  /**
4
5
  * SyncClient implements the client side of SPEC.md §§3–8.
@@ -11,7 +12,7 @@ import { ProgressEmitter, } from './progress.js';
11
12
  */
12
13
  import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
13
14
  import { EncryptError } from '@syncular/core';
14
- import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
15
+ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
15
16
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
16
17
  import { registerDevtools } from './devtools.js';
17
18
  import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
@@ -25,8 +26,8 @@ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encod
25
26
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
26
27
  import { assertReadOnlyQuery } from './query-guard.js';
27
28
  import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
28
- import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
29
- import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
29
+ import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
30
+ import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
30
31
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
31
32
  /** Stable client-local error while protected operations are preflight-gated. */
32
33
  export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
@@ -221,7 +222,7 @@ export class SyncClient {
221
222
  #preflightBarrier;
222
223
  constructor(config) {
223
224
  if (config.securityPreflight === true && config.encryption !== undefined) {
224
- throw invalidRequest('securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
225
+ throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
225
226
  }
226
227
  this.#retainFailedCommits = config.retainFailedCommits === true;
227
228
  this.#db = config.database;
@@ -287,7 +288,7 @@ export class SyncClient {
287
288
  persisted !== this.#config.clientId) {
288
289
  await this.#lease.release();
289
290
  this.#lease = undefined;
290
- throw new ClientSyncError('client.identity_mismatch', `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`);
291
+ throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
291
292
  }
292
293
  this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
293
294
  if (persisted === undefined) {
@@ -309,7 +310,7 @@ export class SyncClient {
309
310
  this.#reconcilePreviousVersionAtBoot();
310
311
  // A registration remains app intent only while its table exists in the
311
312
  // running schema. Removed-table registrations would poison every pull.
312
- const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), new Set(this.#schema.tables.keys()));
313
+ const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
313
314
  this.#started = true;
314
315
  // A persisted active subscription needs one catch-up round on every open:
315
316
  // realtime only covers changes after the socket connects, and an
@@ -319,6 +320,7 @@ export class SyncClient {
319
320
  // an application-issued sync() call.
320
321
  const startupWork = this.#schemaFloor === undefined &&
321
322
  (countOutbox(this.#db) > 0 ||
323
+ this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
322
324
  loadPendingEvictions(this.#db).length > 0 ||
323
325
  subscriptions.some((sub) => sub.status === 'active'));
324
326
  if (startupWork && this.#securityLifecycle === 'active') {
@@ -355,6 +357,7 @@ export class SyncClient {
355
357
  ensureLocalSyncedSchema(this.#db, this.#schema);
356
358
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
357
359
  setLocalSchemaDescriptor(this.#db, this.#schema);
360
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
358
361
  });
359
362
  return;
360
363
  }
@@ -366,6 +369,7 @@ export class SyncClient {
366
369
  this.#db.transaction(() => {
367
370
  ensureLocalSyncedSchema(this.#db, this.#schema);
368
371
  setLocalSchemaDescriptor(this.#db, this.#schema);
372
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
369
373
  });
370
374
  return;
371
375
  }
@@ -393,10 +397,13 @@ export class SyncClient {
393
397
  if (this.#previousVersion !== undefined) {
394
398
  writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
395
399
  }
400
+ pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
401
+ this.#discardAcknowledgedRows();
396
402
  dropAndRecreateSyncedTables(this.#db, this.#schema);
397
403
  resetSubscriptionsForBump(this.#db);
398
404
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
399
405
  setLocalSchemaDescriptor(this.#db, this.#schema);
406
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
400
407
  });
401
408
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
402
409
  for (const table of this.#schema.tables.values())
@@ -473,6 +480,7 @@ export class SyncClient {
473
480
  this.#sweepPreviousVersionContainer();
474
481
  this.#applyBatch((batch) => {
475
482
  this.#db.transaction(() => {
483
+ this.#discardAcknowledgedRows();
476
484
  dropAndRecreateSyncedTables(this.#db, this.#schema);
477
485
  resetSubscriptionsForBump(this.#db);
478
486
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
@@ -709,13 +717,13 @@ export class SyncClient {
709
717
  previous.sqliteCode !== sqliteCode ||
710
718
  previous.tables.join('\0') !== tables.join('\0')) {
711
719
  this.#queryFailures.delete(owner.id);
712
- this.#queryFailures.set(owner.id, {
720
+ this.#queryFailures.set(owner.id, definedObject({
713
721
  id: owner.id,
714
722
  tables,
715
723
  code,
716
- ...(sqliteCode !== undefined ? { sqliteCode } : {}),
724
+ sqliteCode: sqliteCode,
717
725
  atMs: this.#now(),
718
- });
726
+ }));
719
727
  for (const id of this.#queryFailures.keys()) {
720
728
  if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
721
729
  break;
@@ -1036,22 +1044,18 @@ export class SyncClient {
1036
1044
  }
1037
1045
  return a.id.localeCompare(b.id);
1038
1046
  });
1039
- return {
1047
+ return definedObject({
1040
1048
  version: CLIENT_DIAGNOSTICS_VERSION,
1041
1049
  capturedAtMs,
1042
- host: {
1050
+ host: definedObject({
1043
1051
  kind: 'direct',
1044
1052
  role: 'single',
1045
1053
  connectivity,
1046
1054
  realtime: this.#realtimeState,
1047
1055
  realtimePolicy: this.#realtimePolicy,
1048
- ...(this.#realtimeReasonCode !== undefined
1049
- ? { realtimeReasonCode: this.#realtimeReasonCode }
1050
- : {}),
1051
- ...(this.#realtimeRetryDelayMs !== undefined
1052
- ? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
1053
- : {}),
1054
- },
1056
+ realtimeReasonCode: this.#realtimeReasonCode,
1057
+ realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1058
+ }),
1055
1059
  securityLifecycle: this.#securityLifecycle,
1056
1060
  schema: {
1057
1061
  currentVersion: this.#config.schema.version,
@@ -1071,24 +1075,20 @@ export class SyncClient {
1071
1075
  lease: leaseState,
1072
1076
  subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
1073
1077
  subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1074
- ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
1075
- ...(this.#lastChange !== undefined
1076
- ? { lastChange: this.#lastChange }
1077
- : {}),
1078
+ lastRound: this.#lastRound,
1079
+ lastChange: this.#lastChange,
1078
1080
  storage: this.#diagnosticStorage(),
1079
1081
  queryFailures: [...this.#queryFailures.values()],
1080
- };
1082
+ });
1081
1083
  }
1082
1084
  #diagnosticLease(nowMs) {
1083
1085
  const lease = this.#leaseState;
1084
1086
  if (lease?.errorCode !== undefined) {
1085
- return {
1087
+ return definedObject({
1086
1088
  state: 'stopped',
1087
1089
  errorCode: this.#diagnosticCode(lease.errorCode),
1088
- ...(lease.expiresAtMs !== undefined
1089
- ? { expiresAtMs: lease.expiresAtMs }
1090
- : {}),
1091
- };
1090
+ expiresAtMs: lease.expiresAtMs,
1091
+ });
1092
1092
  }
1093
1093
  if (lease?.expiresAtMs === undefined)
1094
1094
  return { state: 'none' };
@@ -1101,12 +1101,10 @@ export class SyncClient {
1101
1101
  try {
1102
1102
  const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
1103
1103
  const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
1104
- const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
1105
- const outcome = this.#db.query(`SELECT COUNT(*) AS entries,
1106
- COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
1107
- FROM _syncular_commit_outcomes`)[0];
1104
+ const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
1105
+ const outcome = this.#db.query('SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes')[0];
1108
1106
  const blobBytes = this.#hasBlobs
1109
- ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1107
+ ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1110
1108
  : 0;
1111
1109
  const pressure = this.#config.blobCacheMaxBytes !== undefined &&
1112
1110
  blobBytes > this.#config.blobCacheMaxBytes;
@@ -1185,7 +1183,7 @@ export class SyncClient {
1185
1183
  try {
1186
1184
  result = fn(batch);
1187
1185
  if (this.#db
1188
- .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1186
+ .query(`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`)
1189
1187
  .some((row) => batch.hasTable(String(row.tbl))))
1190
1188
  batch.outcomes();
1191
1189
  if (batch.touched) {
@@ -1460,7 +1458,7 @@ export class SyncClient {
1460
1458
  const priorRejections = this.#rejections;
1461
1459
  return this.#applyBatch((batch) => {
1462
1460
  restoreFailedBases(this.#db, this.#schema);
1463
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
1461
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
1464
1462
  input.clientCommitId,
1465
1463
  ]);
1466
1464
  const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
@@ -1575,18 +1573,18 @@ export class SyncClient {
1575
1573
  canonicalScopeJson(input.scopes) &&
1576
1574
  existing.params === input.params;
1577
1575
  if (!sameIntent) {
1578
- throw new ClientSyncError('client.subscription_intent_mismatch', 'subscribe: the subscription id is already registered for a different table, scopes, or params');
1576
+ throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1579
1577
  }
1580
1578
  return;
1581
1579
  }
1582
- saveSubscription(this.#db, {
1580
+ saveSubscription(this.#db, definedObject({
1583
1581
  id: input.id,
1584
1582
  table: input.table,
1585
1583
  scopes: input.scopes,
1586
- ...(input.params !== undefined ? { params: input.params } : {}),
1584
+ params: input.params,
1587
1585
  cursor: -1,
1588
1586
  status: 'active',
1589
- });
1587
+ }));
1590
1588
  this.#setSyncNeeded(true);
1591
1589
  this.#emitSyncIntent({ kind: 'interactive' });
1592
1590
  this.#emitDiagnostics();
@@ -1622,7 +1620,7 @@ export class SyncClient {
1622
1620
  this.#requireActive();
1623
1621
  const table = this.#table(base.table);
1624
1622
  if (!table.scopeColumnByVariable.has(base.variable)) {
1625
- throw invalidRequest(`setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
1623
+ throw invalidRequest(`${base.table}: unknown scope variable ${base.variable} (§4.8)`);
1626
1624
  }
1627
1625
  // Serialize the whole window edit: it spans an `await deriveSubId` between
1628
1626
  // db transactions, so without the chain a delta apply (or a concurrent
@@ -1644,14 +1642,14 @@ export class SyncClient {
1644
1642
  // Re-entry cancels any deferred eviction for this sub id.
1645
1643
  deletePendingEviction(this.#db, subId);
1646
1644
  insertWindowUnit(this.#db, baseKey, unit, subId);
1647
- saveSubscription(this.#db, {
1645
+ saveSubscription(this.#db, definedObject({
1648
1646
  id: subId,
1649
1647
  table: base.table,
1650
1648
  scopes: unitScopes(base, unit),
1651
- ...(base.params !== undefined ? { params: base.params } : {}),
1649
+ params: base.params,
1652
1650
  cursor: -1,
1653
1651
  status: 'active',
1654
- });
1652
+ }));
1655
1653
  });
1656
1654
  this.#needsPull = true;
1657
1655
  batch.window(baseKey, base.table, unit);
@@ -1809,14 +1807,12 @@ export class SyncClient {
1809
1807
  const operations = mutations.map((mutation) => {
1810
1808
  const table = this.#table(mutation.table);
1811
1809
  if (mutation.op === 'delete') {
1812
- return {
1810
+ return definedObject({
1813
1811
  table: mutation.table,
1814
1812
  rowId: mutation.rowId,
1815
1813
  op: 'delete',
1816
- ...(mutation.baseVersion !== undefined
1817
- ? { baseVersion: mutation.baseVersion }
1818
- : {}),
1819
- };
1814
+ baseVersion: mutation.baseVersion,
1815
+ });
1820
1816
  }
1821
1817
  const json = {};
1822
1818
  if (mutation.op === 'patch') {
@@ -1828,7 +1824,7 @@ export class SyncClient {
1828
1824
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
1829
1825
  const pkColumn = table.columns[table.primaryKeyIndex];
1830
1826
  const pkValue = requireRowId(normalized.get(pkColumn.name));
1831
- const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0];
1827
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
1832
1828
  if (!local)
1833
1829
  throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
1834
1830
  for (const column of table.columns) {
@@ -1868,15 +1864,13 @@ export class SyncClient {
1868
1864
  });
1869
1865
  }
1870
1866
  const pkValue = requireRowId(json[table.primaryKey]);
1871
- return {
1867
+ return definedObject({
1872
1868
  table: mutation.table,
1873
1869
  rowId: pkValue,
1874
1870
  op: 'upsert',
1875
- ...(mutation.baseVersion !== undefined
1876
- ? { baseVersion: mutation.baseVersion }
1877
- : {}),
1871
+ baseVersion: mutation.baseVersion,
1878
1872
  values: json,
1879
- };
1873
+ });
1880
1874
  });
1881
1875
  this.#applyBatch((batch) => {
1882
1876
  this.#db.transaction(() => {
@@ -1900,10 +1894,7 @@ export class SyncClient {
1900
1894
  catch {
1901
1895
  continue;
1902
1896
  }
1903
- this.#db.exec(`INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
1904
- SELECT ?, ? WHERE EXISTS (
1905
- SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
1906
- )`, [clientCommitId, blobId, blobId]);
1897
+ this.#db.exec('INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)', [clientCommitId, blobId, blobId]);
1907
1898
  }
1908
1899
  }
1909
1900
  try {
@@ -1990,17 +1981,13 @@ export class SyncClient {
1990
1981
  // in the running schema), so purge drops it as a fixed step, before any
1991
1982
  // mirror row is touched and unconditionally.
1992
1983
  this.#dropPreviousVersion();
1993
- const rejectionCount = this.#rejections.length;
1984
+ const priorRejections = [...this.#rejections];
1985
+ const priorConflicts = [...this.#conflicts];
1994
1986
  try {
1995
1987
  return this.#applyBatch((batch) => {
1996
1988
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
1997
1989
  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
- }
1990
+ this.#dropRetainedRows(batch, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
2004
1991
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
2005
1992
  // a commit is doomed when any of its ops touches a base-matching
2006
1993
  // row. Base values only become visible here as rollbacks restore
@@ -2053,7 +2040,7 @@ export class SyncClient {
2053
2040
  batch.table(tableName);
2054
2041
  for (let offset = 0; offset < values.length; offset += 400) {
2055
2042
  const chunk = values.slice(offset, offset + 400);
2056
- this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN (${chunk.map(() => '?').join(', ')})`, chunk);
2043
+ this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`, chunk);
2057
2044
  }
2058
2045
  }
2059
2046
  if (doomed.length > 0) {
@@ -2096,7 +2083,8 @@ export class SyncClient {
2096
2083
  catch (error) {
2097
2084
  // SQLite rolls back through #applyBatch; mirror that rollback for the
2098
2085
  // in-memory rejection cache before surfacing the storage failure.
2099
- this.#rejections.length = rejectionCount;
2086
+ this.#rejections = priorRejections;
2087
+ this.#conflicts = priorConflicts;
2100
2088
  throw error;
2101
2089
  }
2102
2090
  }
@@ -2135,6 +2123,7 @@ export class SyncClient {
2135
2123
  this.#upgrading = true;
2136
2124
  this.#needsPull = true;
2137
2125
  batch.status();
2126
+ this.#discardAcknowledgedRows();
2138
2127
  dropAndRecreateSyncedTables(this.#db, this.#schema);
2139
2128
  resetSubscriptionsForBump(this.#db);
2140
2129
  for (const table of this.#schema.tables.values()) {
@@ -2186,7 +2175,7 @@ export class SyncClient {
2186
2175
  clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
2187
2176
  params.push(...selector.values);
2188
2177
  }
2189
- for (const row of this.#db.query(`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT) AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
2178
+ for (const row of this.#db.query(`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
2190
2179
  if (typeof row.id === 'string')
2191
2180
  ids.add(row.id);
2192
2181
  }
@@ -2228,7 +2217,7 @@ export class SyncClient {
2228
2217
  async #encodeOutboxForPush() {
2229
2218
  // Pin before the first encryption await: mutations can append while a
2230
2219
  // round is encoding, and belong to the next request.
2231
- const bounds = this.#db.query('SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox')[0];
2220
+ const bounds = this.#db.query('SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox')[0];
2232
2221
  const pendingCount = bounds.count;
2233
2222
  const throughSeq = bounds.last_seq ?? 0;
2234
2223
  const pushFrames = [];
@@ -2258,7 +2247,7 @@ export class SyncClient {
2258
2247
  const selector = this.#encryption?.keyIdColumns?.[table.name];
2259
2248
  if (selector === undefined)
2260
2249
  return undefined;
2261
- const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
2250
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
2262
2251
  const value = row?.value;
2263
2252
  return typeof value === 'string' && value.length > 0
2264
2253
  ? value
@@ -2351,15 +2340,13 @@ export class SyncClient {
2351
2340
  const error = classifySqliteFailure(caught).error;
2352
2341
  const completedAtMs = this.#now();
2353
2342
  const code = error.code;
2354
- this.#progress.update({
2343
+ this.#progress.update(definedObject({
2355
2344
  state: 'failed',
2356
2345
  errorCode: typeof code === 'string'
2357
2346
  ? this.#diagnosticCode(code)
2358
2347
  : 'client.unknown_failure',
2359
- ...(this.#roundRetryDelayMs !== undefined
2360
- ? { retryDelayMs: this.#roundRetryDelayMs }
2361
- : {}),
2362
- });
2348
+ retryDelayMs: this.#roundRetryDelayMs,
2349
+ }));
2363
2350
  this.#lastRound = {
2364
2351
  status: 'failed',
2365
2352
  startedAtMs,
@@ -2451,12 +2438,12 @@ export class SyncClient {
2451
2438
  const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
2452
2439
  const limits = this.#config.limits;
2453
2440
  const frames = [
2454
- {
2441
+ definedObject({
2455
2442
  type: 'REQ_HEADER',
2456
2443
  clientId: this.#clientId,
2457
2444
  schemaVersion: this.#schema.version,
2458
- ...(logEpoch !== undefined ? { logEpoch } : {}),
2459
- },
2445
+ logEpoch: logEpoch,
2446
+ }),
2460
2447
  ...pushFrames,
2461
2448
  {
2462
2449
  type: 'PULL_HEADER',
@@ -2465,16 +2452,14 @@ export class SyncClient {
2465
2452
  maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
2466
2453
  accept: this.#acceptMask(),
2467
2454
  },
2468
- ...subs.map((sub) => ({
2455
+ ...subs.map((sub) => definedObject({
2469
2456
  type: 'SUBSCRIPTION',
2470
2457
  id: sub.id,
2471
2458
  table: sub.table,
2472
2459
  scopes: sub.scopes,
2473
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2460
+ params: sub.params,
2474
2461
  cursor: sub.cursor,
2475
- ...(sub.bootstrapState !== undefined
2476
- ? { bootstrapState: sub.bootstrapState }
2477
- : {}),
2462
+ bootstrapState: sub.bootstrapState,
2478
2463
  })),
2479
2464
  ];
2480
2465
  const requestBytes = encodeMessage({
@@ -2925,12 +2910,10 @@ export class SyncClient {
2925
2910
  // version this client sends. The §7.4.2 trigger-2 convergence runs
2926
2911
  // when the APP updates (recreating the client with a new generated
2927
2912
  // schema), which fires the boot-time §7.4.1 marker check instead.
2928
- const schemaFloor = {
2913
+ const schemaFloor = definedObject({
2929
2914
  requiredSchemaVersion: header.requiredSchemaVersion,
2930
- ...(header.latestSchemaVersion !== undefined
2931
- ? { latestSchemaVersion: header.latestSchemaVersion }
2932
- : {}),
2933
- };
2915
+ latestSchemaVersion: header.latestSchemaVersion,
2916
+ });
2934
2917
  this.#setSchemaFloor(schemaFloor);
2935
2918
  return {
2936
2919
  ...summary,
@@ -2985,6 +2968,7 @@ export class SyncClient {
2985
2968
  index += 1;
2986
2969
  }
2987
2970
  }
2971
+ const previousNeedsPull = this.#needsPull;
2988
2972
  const conflictCount = this.#conflicts.length;
2989
2973
  const rejectionCount = this.#rejections.length;
2990
2974
  let outboxCount = countOutbox(this.#db);
@@ -3001,9 +2985,10 @@ export class SyncClient {
3001
2985
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3002
2986
  }
3003
2987
  }, () => this.#statusSnapshot(outboxCount), (cause) => {
2988
+ this.#needsPull = previousNeedsPull;
3004
2989
  this.#conflicts.length = conflictCount;
3005
2990
  this.#rejections.length = rejectionCount;
3006
- const error = new ClientSyncError('client.outcome_persistence_failed', 'local commit outcome could not be persisted');
2991
+ const error = new ClientSyncError('client.outcome_persistence_failed', 'outcome persistence failed');
3007
2992
  error.cause = cause;
3008
2993
  throw error;
3009
2994
  });
@@ -3214,6 +3199,10 @@ export class SyncClient {
3214
3199
  if (frame.status === 'applied' || frame.status === 'cached') {
3215
3200
  // §6.3: applied and cached both drain the outbox — cached means
3216
3201
  // "already applied, you may have missed the ack".
3202
+ if (frame.commitSeq === undefined)
3203
+ throw new ClientSyncError('client.invalid_host_response', 'ACK lacks a commit sequence');
3204
+ retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId), frame.commitSeq);
3205
+ this.#setSyncNeeded(true);
3217
3206
  recordCommitOutcome(this.#db, {
3218
3207
  clientCommitId: frame.clientCommitId,
3219
3208
  status: frame.status,
@@ -3243,7 +3232,7 @@ export class SyncClient {
3243
3232
  for (const result of frame.results) {
3244
3233
  const operation = commit.operations[result.opIndex];
3245
3234
  if (result.status === 'conflict') {
3246
- const conflict = {
3235
+ const conflict = definedObject({
3247
3236
  clientCommitId: frame.clientCommitId,
3248
3237
  opIndex: result.opIndex,
3249
3238
  table: operation?.table ?? '',
@@ -3253,8 +3242,8 @@ export class SyncClient {
3253
3242
  serverVersion: result.serverVersion,
3254
3243
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
3255
3244
  conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
3256
- ...(operation !== undefined ? { operation } : {}),
3257
- };
3245
+ operation: operation,
3246
+ });
3258
3247
  this.#conflicts.push(conflict);
3259
3248
  outcomeResults.push({ status: 'conflict', conflict });
3260
3249
  batch.conflicts();
@@ -3262,15 +3251,15 @@ export class SyncClient {
3262
3251
  }
3263
3252
  else if (result.status === 'error') {
3264
3253
  const details = rejectionDetails?.get(result.opIndex);
3265
- const rejection = {
3254
+ const rejection = definedObject({
3266
3255
  clientCommitId: frame.clientCommitId,
3267
3256
  opIndex: result.opIndex,
3268
3257
  code: result.code,
3269
3258
  message: result.message,
3270
3259
  retryable: result.retryable,
3271
- ...(details !== undefined ? { details } : {}),
3272
- ...(operation !== undefined ? { operation } : {}),
3273
- };
3260
+ details: details,
3261
+ operation: operation,
3262
+ });
3274
3263
  this.#rejections.push(rejection);
3275
3264
  outcomeResults.push({ status: 'error', rejection });
3276
3265
  batch.rejections();
@@ -3302,7 +3291,7 @@ export class SyncClient {
3302
3291
  return true;
3303
3292
  }
3304
3293
  #outboxCommitExists(clientCommitId) {
3305
- return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1', [clientCommitId]).length > 0);
3294
+ return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1', [clientCommitId]).length > 0);
3306
3295
  }
3307
3296
  #decodeServerRow(tableName, payload) {
3308
3297
  if (tableName === undefined)
@@ -3339,7 +3328,9 @@ export class SyncClient {
3339
3328
  async #applyCommit(frame, summary) {
3340
3329
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3341
3330
  this.#recordCommitChanges(batch, frame);
3342
- return fn();
3331
+ const result = fn();
3332
+ this.#replayOutbox(result);
3333
+ return result;
3343
3334
  }));
3344
3335
  summary.commitsApplied += 1;
3345
3336
  }
@@ -3367,9 +3358,7 @@ export class SyncClient {
3367
3358
  const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
3368
3359
  if (mappings.length === 0)
3369
3360
  return false;
3370
- const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
3371
- FROM ${quoteIdent(table.name)}
3372
- WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
3361
+ const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
3373
3362
  if (row === undefined)
3374
3363
  return false;
3375
3364
  let recorded = false;
@@ -3385,20 +3374,9 @@ export class SyncClient {
3385
3374
  }
3386
3375
  /** Whether a fresh-bootstrap clear would remove at least one local row. */
3387
3376
  #scopedRowsExist(table, effective) {
3388
- const entries = Object.entries(effective);
3389
- if (entries.length === 0)
3390
- return false;
3391
- const clauses = [];
3392
- const params = [];
3393
- for (const [variable, values] of entries) {
3394
- const column = table.scopeColumnByVariable.get(variable);
3395
- if (column === undefined || values.length === 0)
3396
- return false;
3397
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
3398
- params.push(...values);
3399
- }
3400
- return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
3401
- WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
3377
+ const scope = localScopePredicate(table, effective);
3378
+ return (scope !== undefined &&
3379
+ this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
3402
3380
  }
3403
3381
  /**
3404
3382
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
@@ -3419,18 +3397,16 @@ export class SyncClient {
3419
3397
  error.code === 'sync.scope_revoked') {
3420
3398
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3421
3399
  this.#applyBatch((batch) => {
3422
- saveSubscription(this.#db, {
3400
+ saveSubscription(this.#db, definedObject({
3423
3401
  id: sub.id,
3424
3402
  table: sub.table,
3425
3403
  scopes: sub.scopes,
3426
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3404
+ params: sub.params,
3427
3405
  cursor: sub.cursor,
3428
- ...(sub.effectiveScopes !== undefined
3429
- ? { effectiveScopes: sub.effectiveScopes }
3430
- : {}),
3406
+ effectiveScopes: sub.effectiveScopes,
3431
3407
  status: 'failed',
3432
3408
  reasonCode: 'sync.scope_revoked',
3433
- });
3409
+ }));
3434
3410
  if (registered !== undefined) {
3435
3411
  batch.window(registered.baseKey, sub.table, registered.unit);
3436
3412
  }
@@ -3474,7 +3450,7 @@ export class SyncClient {
3474
3450
  if (frame.urlExpiresAtMs !== undefined &&
3475
3451
  frame.urlExpiresAtMs <= this.#now()) {
3476
3452
  // §5.4: MUST NOT start a fetch at/past expiry.
3477
- throw new ClientSyncError('sync.segment_expired', `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`, true);
3453
+ throw new ClientSyncError('sync.segment_expired', `segment download grant expired; pull again (§5.4)`, true);
3478
3454
  }
3479
3455
  bytes = await fetchUrl(frame.url, onProgress);
3480
3456
  }
@@ -3507,16 +3483,35 @@ export class SyncClient {
3507
3483
  const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
3508
3484
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3509
3485
  this.#applyBatch((batch) => {
3510
- saveSubscription(this.#db, {
3486
+ saveSubscription(this.#db, definedObject({
3511
3487
  id: sub.id,
3512
3488
  table: sub.table,
3513
3489
  scopes: sub.scopes,
3514
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3490
+ params: sub.params,
3515
3491
  cursor: nextCursor,
3516
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
3492
+ bootstrapState: bootstrapState,
3517
3493
  effectiveScopes: start.effectiveScopes,
3518
3494
  status: 'active',
3519
- });
3495
+ }));
3496
+ if (completed) {
3497
+ const table = this.#table(sub.table);
3498
+ const effective = start.effectiveScopes;
3499
+ const covered = this.#db
3500
+ .query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`, [sub.table, nextCursor])
3501
+ .filter((row) => [row.intent, row.base].some((json) => {
3502
+ if (json === null)
3503
+ return false;
3504
+ const values = JSON.parse(String(json));
3505
+ return matchesLocalScopes(table, effective, values);
3506
+ }));
3507
+ if (covered.length) {
3508
+ restoreFailedBases(this.#db, this.#schema);
3509
+ for (const row of covered)
3510
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
3511
+ batch.table(sub.table);
3512
+ this.#replayOutbox();
3513
+ }
3514
+ }
3520
3515
  if (completed && registered !== undefined) {
3521
3516
  // A zero-row bootstrap is a window-domain transition, not a fake
3522
3517
  // row/table change (SPEC §4.8 / §7.5).
@@ -3530,18 +3525,16 @@ export class SyncClient {
3530
3525
  // with cursor = -1 on the next pull. Staleness, not a purge.
3531
3526
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3532
3527
  this.#applyBatch((batch) => {
3533
- saveSubscription(this.#db, {
3528
+ saveSubscription(this.#db, definedObject({
3534
3529
  id: sub.id,
3535
3530
  table: sub.table,
3536
3531
  scopes: sub.scopes,
3537
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3532
+ params: sub.params,
3538
3533
  cursor: -1,
3539
- ...(sub.effectiveScopes !== undefined
3540
- ? { effectiveScopes: sub.effectiveScopes }
3541
- : {}),
3534
+ effectiveScopes: sub.effectiveScopes,
3542
3535
  status: 'active',
3543
3536
  reasonCode: start.reasonCode,
3544
- });
3537
+ }));
3545
3538
  if (registered !== undefined) {
3546
3539
  batch.window(registered.baseKey, sub.table, registered.unit);
3547
3540
  }
@@ -3560,13 +3553,8 @@ export class SyncClient {
3560
3553
  Object.keys(lastEffective).length > 0) {
3561
3554
  try {
3562
3555
  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
- }
3556
+ this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3557
+ matchesLocalScopes(table, lastEffective, values));
3570
3558
  deleteScopedRows(this.#db, table, lastEffective);
3571
3559
  batch.scopeMap(table, lastEffective);
3572
3560
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3616,18 +3604,16 @@ export class SyncClient {
3616
3604
  }
3617
3605
  }
3618
3606
  }
3619
- saveSubscription(this.#db, {
3607
+ saveSubscription(this.#db, definedObject({
3620
3608
  id: sub.id,
3621
3609
  table: sub.table,
3622
3610
  scopes: sub.scopes,
3623
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3611
+ params: sub.params,
3624
3612
  cursor: nextCursor,
3625
- ...(lastEffective !== undefined
3626
- ? { effectiveScopes: lastEffective }
3627
- : {}),
3613
+ effectiveScopes: lastEffective,
3628
3614
  status: failed ? 'failed' : 'revoked',
3629
3615
  reasonCode: start.reasonCode,
3630
- });
3616
+ }));
3631
3617
  if (registered !== undefined) {
3632
3618
  batch.window(registered.baseKey, sub.table, registered.unit);
3633
3619
  }
@@ -3646,7 +3632,7 @@ export class SyncClient {
3646
3632
  ...table.columns.map((column) => quoteIdent(column.name)),
3647
3633
  quoteIdent(SYNC_VERSION_COLUMN),
3648
3634
  ];
3649
- const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
3635
+ const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
3650
3636
  if (row === undefined)
3651
3637
  return { opIndex, existed: false };
3652
3638
  const values = {};
@@ -3686,7 +3672,7 @@ export class SyncClient {
3686
3672
  const table = this.#schema.tables.get(operation.table);
3687
3673
  if (table === undefined)
3688
3674
  continue;
3689
- const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
3675
+ const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
3690
3676
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
3691
3677
  batch.table(table.name);
3692
3678
  deleteLocalRow(this.#db, table, operation.rowId);
@@ -3728,7 +3714,7 @@ export class SyncClient {
3728
3714
  this.#applyOperationsLocally(affectedOperations, batch);
3729
3715
  }
3730
3716
  }
3731
- #applyOperationsLocally(operations, batch) {
3717
+ #applyOperationsLocally(operations, batch, replay = false) {
3732
3718
  for (const op of operations) {
3733
3719
  const table = this.#table(op.table);
3734
3720
  let precise = batch === undefined
@@ -3746,7 +3732,7 @@ export class SyncClient {
3746
3732
  // sync.row_missing, and the local mirror never invents a base.
3747
3733
  const opValues = op.values ?? {};
3748
3734
  const full = table.columns.every((column) => column.name in opValues);
3749
- const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
3735
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [op.rowId])[0];
3750
3736
  if (local === undefined && !full) {
3751
3737
  if (batch !== undefined && !precise)
3752
3738
  batch.table(op.table);
@@ -3777,23 +3763,47 @@ export class SyncClient {
3777
3763
  const version = local === undefined
3778
3764
  ? OPTIMISTIC_VERSION
3779
3765
  : local[SYNC_VERSION_COLUMN];
3766
+ if (replay &&
3767
+ table.indexes.some((index) => index.unique) &&
3768
+ uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
3769
+ column.name,
3770
+ values[index] ?? null,
3771
+ ]))).length)
3772
+ continue;
3780
3773
  upsertLocalRow(this.#db, table, values, version, false);
3781
3774
  }
3782
3775
  }
3783
3776
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
3784
- #replayOutbox() {
3777
+ #discardAcknowledgedRows() {
3778
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
3779
+ }
3780
+ #dropRetainedRows(batch, matches) {
3781
+ for (const commit of dropFailedRows(this.#db, matches)) {
3782
+ batch.conflicts();
3783
+ batch.rejections();
3784
+ batch.outcomes();
3785
+ this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3786
+ this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3787
+ for (const op of commit.operations)
3788
+ batch.table(op.table);
3789
+ }
3790
+ }
3791
+ #replayOutbox(rowKeys) {
3792
+ const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
3785
3793
  const pending = [
3786
- ...failedOverlayCommits(this.#db),
3787
- ...listOutbox(this.#db),
3794
+ ...failedOverlayCommits(this.#db, false, rowKeys),
3795
+ ...listOutbox(this.#db, rowKeys),
3788
3796
  ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3789
- if (pending.length === 0)
3797
+ if (pending.length === 0 && acknowledged.length === 0)
3790
3798
  return;
3791
3799
  this.#applyBatch((batch) => {
3792
3800
  this.#db.transaction(() => {
3793
- restoreFailedBases(this.#db, this.#schema);
3794
- restoreFailedBases(this.#db, this.#schema, true);
3795
- for (const commit of pending)
3796
- this.#applyOperationsLocally(commit.operations, batch);
3801
+ restoreFailedBases(this.#db, this.#schema, false, rowKeys);
3802
+ restoreFailedBases(this.#db, this.#schema, true, rowKeys);
3803
+ for (const commit of [...acknowledged, ...pending])
3804
+ this.#applyOperationsLocally(rowKeys === undefined
3805
+ ? commit.operations
3806
+ : commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
3797
3807
  });
3798
3808
  });
3799
3809
  }
@@ -3806,22 +3816,29 @@ export class SyncClient {
3806
3816
  #table(name) {
3807
3817
  const table = this.#schema.tables.get(name);
3808
3818
  if (table === undefined) {
3809
- throw new ClientSyncError('sync.unknown_table', `unknown local table ${JSON.stringify(name)}`);
3819
+ throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
3810
3820
  }
3811
3821
  return table;
3812
3822
  }
3813
3823
  #requireStarted() {
3814
3824
  if (!this.#started) {
3815
- throw invalidRequest('SyncClient.start() has not completed');
3825
+ throw invalidRequest('call SyncClient.start() first');
3816
3826
  }
3817
3827
  }
3818
3828
  #requireActive() {
3819
3829
  this.#requireStarted();
3820
3830
  if (this.#securityLifecycle === 'preflight') {
3821
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
3831
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
3822
3832
  }
3823
3833
  }
3824
3834
  }
3835
+ function definedObject(record) {
3836
+ for (const key of Object.keys(record)) {
3837
+ if (record[key] === undefined)
3838
+ delete record[key];
3839
+ }
3840
+ return record;
3841
+ }
3825
3842
  function requireRowId(value) {
3826
3843
  if (typeof value !== 'string' || !value.length)
3827
3844
  throw invalidRequest('a mutation requires a non-empty string primary key');