@syncular/client 0.30.10 → 0.30.11

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,4 @@
1
- import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, } from './failed-overlay.js';
1
+ import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
2
2
  import { ProgressEmitter, } from './progress.js';
3
3
  /**
4
4
  * SyncClient implements the client side of SPEC.md §§3–8.
@@ -26,7 +26,7 @@ import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolutio
26
26
  import { assertReadOnlyQuery } from './query-guard.js';
27
27
  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
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 { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
30
30
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
31
31
  /** Stable client-local error while protected operations are preflight-gated. */
32
32
  export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
@@ -309,7 +309,7 @@ export class SyncClient {
309
309
  this.#reconcilePreviousVersionAtBoot();
310
310
  // A registration remains app intent only while its table exists in the
311
311
  // running schema. Removed-table registrations would poison every pull.
312
- const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), new Set(this.#schema.tables.keys()));
312
+ const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
313
313
  this.#started = true;
314
314
  // A persisted active subscription needs one catch-up round on every open:
315
315
  // realtime only covers changes after the socket connects, and an
@@ -355,6 +355,7 @@ export class SyncClient {
355
355
  ensureLocalSyncedSchema(this.#db, this.#schema);
356
356
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
357
357
  setLocalSchemaDescriptor(this.#db, this.#schema);
358
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
358
359
  });
359
360
  return;
360
361
  }
@@ -366,6 +367,7 @@ export class SyncClient {
366
367
  this.#db.transaction(() => {
367
368
  ensureLocalSyncedSchema(this.#db, this.#schema);
368
369
  setLocalSchemaDescriptor(this.#db, this.#schema);
370
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
369
371
  });
370
372
  return;
371
373
  }
@@ -393,10 +395,12 @@ export class SyncClient {
393
395
  if (this.#previousVersion !== undefined) {
394
396
  writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
395
397
  }
398
+ pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
396
399
  dropAndRecreateSyncedTables(this.#db, this.#schema);
397
400
  resetSubscriptionsForBump(this.#db);
398
401
  setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
399
402
  setLocalSchemaDescriptor(this.#db, this.#schema);
403
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
400
404
  });
401
405
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
402
406
  for (const table of this.#schema.tables.values())
@@ -709,13 +713,13 @@ export class SyncClient {
709
713
  previous.sqliteCode !== sqliteCode ||
710
714
  previous.tables.join('\0') !== tables.join('\0')) {
711
715
  this.#queryFailures.delete(owner.id);
712
- this.#queryFailures.set(owner.id, {
716
+ this.#queryFailures.set(owner.id, definedObject({
713
717
  id: owner.id,
714
718
  tables,
715
719
  code,
716
- ...(sqliteCode !== undefined ? { sqliteCode } : {}),
720
+ sqliteCode: sqliteCode,
717
721
  atMs: this.#now(),
718
- });
722
+ }));
719
723
  for (const id of this.#queryFailures.keys()) {
720
724
  if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
721
725
  break;
@@ -1036,22 +1040,18 @@ export class SyncClient {
1036
1040
  }
1037
1041
  return a.id.localeCompare(b.id);
1038
1042
  });
1039
- return {
1043
+ return definedObject({
1040
1044
  version: CLIENT_DIAGNOSTICS_VERSION,
1041
1045
  capturedAtMs,
1042
- host: {
1046
+ host: definedObject({
1043
1047
  kind: 'direct',
1044
1048
  role: 'single',
1045
1049
  connectivity,
1046
1050
  realtime: this.#realtimeState,
1047
1051
  realtimePolicy: this.#realtimePolicy,
1048
- ...(this.#realtimeReasonCode !== undefined
1049
- ? { realtimeReasonCode: this.#realtimeReasonCode }
1050
- : {}),
1051
- ...(this.#realtimeRetryDelayMs !== undefined
1052
- ? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
1053
- : {}),
1054
- },
1052
+ realtimeReasonCode: this.#realtimeReasonCode,
1053
+ realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1054
+ }),
1055
1055
  securityLifecycle: this.#securityLifecycle,
1056
1056
  schema: {
1057
1057
  currentVersion: this.#config.schema.version,
@@ -1071,24 +1071,20 @@ export class SyncClient {
1071
1071
  lease: leaseState,
1072
1072
  subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
1073
1073
  subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1074
- ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
1075
- ...(this.#lastChange !== undefined
1076
- ? { lastChange: this.#lastChange }
1077
- : {}),
1074
+ lastRound: this.#lastRound,
1075
+ lastChange: this.#lastChange,
1078
1076
  storage: this.#diagnosticStorage(),
1079
1077
  queryFailures: [...this.#queryFailures.values()],
1080
- };
1078
+ });
1081
1079
  }
1082
1080
  #diagnosticLease(nowMs) {
1083
1081
  const lease = this.#leaseState;
1084
1082
  if (lease?.errorCode !== undefined) {
1085
- return {
1083
+ return definedObject({
1086
1084
  state: 'stopped',
1087
1085
  errorCode: this.#diagnosticCode(lease.errorCode),
1088
- ...(lease.expiresAtMs !== undefined
1089
- ? { expiresAtMs: lease.expiresAtMs }
1090
- : {}),
1091
- };
1086
+ expiresAtMs: lease.expiresAtMs,
1087
+ });
1092
1088
  }
1093
1089
  if (lease?.expiresAtMs === undefined)
1094
1090
  return { state: 'none' };
@@ -1101,12 +1097,10 @@ export class SyncClient {
1101
1097
  try {
1102
1098
  const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
1103
1099
  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];
1100
+ const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
1101
+ 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
1102
  const blobBytes = this.#hasBlobs
1109
- ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1103
+ ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1110
1104
  : 0;
1111
1105
  const pressure = this.#config.blobCacheMaxBytes !== undefined &&
1112
1106
  blobBytes > this.#config.blobCacheMaxBytes;
@@ -1460,7 +1454,7 @@ export class SyncClient {
1460
1454
  const priorRejections = this.#rejections;
1461
1455
  return this.#applyBatch((batch) => {
1462
1456
  restoreFailedBases(this.#db, this.#schema);
1463
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
1457
+ this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
1464
1458
  input.clientCommitId,
1465
1459
  ]);
1466
1460
  const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
@@ -1579,14 +1573,14 @@ export class SyncClient {
1579
1573
  }
1580
1574
  return;
1581
1575
  }
1582
- saveSubscription(this.#db, {
1576
+ saveSubscription(this.#db, definedObject({
1583
1577
  id: input.id,
1584
1578
  table: input.table,
1585
1579
  scopes: input.scopes,
1586
- ...(input.params !== undefined ? { params: input.params } : {}),
1580
+ params: input.params,
1587
1581
  cursor: -1,
1588
1582
  status: 'active',
1589
- });
1583
+ }));
1590
1584
  this.#setSyncNeeded(true);
1591
1585
  this.#emitSyncIntent({ kind: 'interactive' });
1592
1586
  this.#emitDiagnostics();
@@ -1644,14 +1638,14 @@ export class SyncClient {
1644
1638
  // Re-entry cancels any deferred eviction for this sub id.
1645
1639
  deletePendingEviction(this.#db, subId);
1646
1640
  insertWindowUnit(this.#db, baseKey, unit, subId);
1647
- saveSubscription(this.#db, {
1641
+ saveSubscription(this.#db, definedObject({
1648
1642
  id: subId,
1649
1643
  table: base.table,
1650
1644
  scopes: unitScopes(base, unit),
1651
- ...(base.params !== undefined ? { params: base.params } : {}),
1645
+ params: base.params,
1652
1646
  cursor: -1,
1653
1647
  status: 'active',
1654
- });
1648
+ }));
1655
1649
  });
1656
1650
  this.#needsPull = true;
1657
1651
  batch.window(baseKey, base.table, unit);
@@ -1809,14 +1803,12 @@ export class SyncClient {
1809
1803
  const operations = mutations.map((mutation) => {
1810
1804
  const table = this.#table(mutation.table);
1811
1805
  if (mutation.op === 'delete') {
1812
- return {
1806
+ return definedObject({
1813
1807
  table: mutation.table,
1814
1808
  rowId: mutation.rowId,
1815
1809
  op: 'delete',
1816
- ...(mutation.baseVersion !== undefined
1817
- ? { baseVersion: mutation.baseVersion }
1818
- : {}),
1819
- };
1810
+ baseVersion: mutation.baseVersion,
1811
+ });
1820
1812
  }
1821
1813
  const json = {};
1822
1814
  if (mutation.op === 'patch') {
@@ -1828,7 +1820,7 @@ export class SyncClient {
1828
1820
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
1829
1821
  const pkColumn = table.columns[table.primaryKeyIndex];
1830
1822
  const pkValue = requireRowId(normalized.get(pkColumn.name));
1831
- const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0];
1823
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
1832
1824
  if (!local)
1833
1825
  throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
1834
1826
  for (const column of table.columns) {
@@ -1868,15 +1860,13 @@ export class SyncClient {
1868
1860
  });
1869
1861
  }
1870
1862
  const pkValue = requireRowId(json[table.primaryKey]);
1871
- return {
1863
+ return definedObject({
1872
1864
  table: mutation.table,
1873
1865
  rowId: pkValue,
1874
1866
  op: 'upsert',
1875
- ...(mutation.baseVersion !== undefined
1876
- ? { baseVersion: mutation.baseVersion }
1877
- : {}),
1867
+ baseVersion: mutation.baseVersion,
1878
1868
  values: json,
1879
- };
1869
+ });
1880
1870
  });
1881
1871
  this.#applyBatch((batch) => {
1882
1872
  this.#db.transaction(() => {
@@ -1900,10 +1890,7 @@ export class SyncClient {
1900
1890
  catch {
1901
1891
  continue;
1902
1892
  }
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]);
1893
+ 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
1894
  }
1908
1895
  }
1909
1896
  try {
@@ -1990,13 +1977,18 @@ export class SyncClient {
1990
1977
  // in the running schema), so purge drops it as a fixed step, before any
1991
1978
  // mirror row is touched and unconditionally.
1992
1979
  this.#dropPreviousVersion();
1993
- const rejectionCount = this.#rejections.length;
1980
+ const priorRejections = [...this.#rejections];
1981
+ const priorConflicts = [...this.#conflicts];
1994
1982
  try {
1995
1983
  return this.#applyBatch((batch) => {
1996
1984
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
1997
1985
  restoreFailedBases(this.#db, this.#schema);
1998
1986
  const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
1999
1987
  for (const commit of droppedRetained) {
1988
+ batch.conflicts();
1989
+ batch.rejections();
1990
+ this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
1991
+ this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
2000
1992
  for (const operation of commit.operations)
2001
1993
  batch.table(operation.table);
2002
1994
  batch.outcomes();
@@ -2053,7 +2045,7 @@ export class SyncClient {
2053
2045
  batch.table(tableName);
2054
2046
  for (let offset = 0; offset < values.length; offset += 400) {
2055
2047
  const chunk = values.slice(offset, offset + 400);
2056
- this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN (${chunk.map(() => '?').join(', ')})`, chunk);
2048
+ this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`, chunk);
2057
2049
  }
2058
2050
  }
2059
2051
  if (doomed.length > 0) {
@@ -2096,7 +2088,8 @@ export class SyncClient {
2096
2088
  catch (error) {
2097
2089
  // SQLite rolls back through #applyBatch; mirror that rollback for the
2098
2090
  // in-memory rejection cache before surfacing the storage failure.
2099
- this.#rejections.length = rejectionCount;
2091
+ this.#rejections = priorRejections;
2092
+ this.#conflicts = priorConflicts;
2100
2093
  throw error;
2101
2094
  }
2102
2095
  }
@@ -2186,7 +2179,7 @@ export class SyncClient {
2186
2179
  clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
2187
2180
  params.push(...selector.values);
2188
2181
  }
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)) {
2182
+ 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
2183
  if (typeof row.id === 'string')
2191
2184
  ids.add(row.id);
2192
2185
  }
@@ -2228,7 +2221,7 @@ export class SyncClient {
2228
2221
  async #encodeOutboxForPush() {
2229
2222
  // Pin before the first encryption await: mutations can append while a
2230
2223
  // 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];
2224
+ const bounds = this.#db.query('SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox')[0];
2232
2225
  const pendingCount = bounds.count;
2233
2226
  const throughSeq = bounds.last_seq ?? 0;
2234
2227
  const pushFrames = [];
@@ -2258,7 +2251,7 @@ export class SyncClient {
2258
2251
  const selector = this.#encryption?.keyIdColumns?.[table.name];
2259
2252
  if (selector === undefined)
2260
2253
  return undefined;
2261
- const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
2254
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
2262
2255
  const value = row?.value;
2263
2256
  return typeof value === 'string' && value.length > 0
2264
2257
  ? value
@@ -2351,15 +2344,13 @@ export class SyncClient {
2351
2344
  const error = classifySqliteFailure(caught).error;
2352
2345
  const completedAtMs = this.#now();
2353
2346
  const code = error.code;
2354
- this.#progress.update({
2347
+ this.#progress.update(definedObject({
2355
2348
  state: 'failed',
2356
2349
  errorCode: typeof code === 'string'
2357
2350
  ? this.#diagnosticCode(code)
2358
2351
  : 'client.unknown_failure',
2359
- ...(this.#roundRetryDelayMs !== undefined
2360
- ? { retryDelayMs: this.#roundRetryDelayMs }
2361
- : {}),
2362
- });
2352
+ retryDelayMs: this.#roundRetryDelayMs,
2353
+ }));
2363
2354
  this.#lastRound = {
2364
2355
  status: 'failed',
2365
2356
  startedAtMs,
@@ -2451,12 +2442,12 @@ export class SyncClient {
2451
2442
  const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
2452
2443
  const limits = this.#config.limits;
2453
2444
  const frames = [
2454
- {
2445
+ definedObject({
2455
2446
  type: 'REQ_HEADER',
2456
2447
  clientId: this.#clientId,
2457
2448
  schemaVersion: this.#schema.version,
2458
- ...(logEpoch !== undefined ? { logEpoch } : {}),
2459
- },
2449
+ logEpoch: logEpoch,
2450
+ }),
2460
2451
  ...pushFrames,
2461
2452
  {
2462
2453
  type: 'PULL_HEADER',
@@ -2465,16 +2456,14 @@ export class SyncClient {
2465
2456
  maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
2466
2457
  accept: this.#acceptMask(),
2467
2458
  },
2468
- ...subs.map((sub) => ({
2459
+ ...subs.map((sub) => definedObject({
2469
2460
  type: 'SUBSCRIPTION',
2470
2461
  id: sub.id,
2471
2462
  table: sub.table,
2472
2463
  scopes: sub.scopes,
2473
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2464
+ params: sub.params,
2474
2465
  cursor: sub.cursor,
2475
- ...(sub.bootstrapState !== undefined
2476
- ? { bootstrapState: sub.bootstrapState }
2477
- : {}),
2466
+ bootstrapState: sub.bootstrapState,
2478
2467
  })),
2479
2468
  ];
2480
2469
  const requestBytes = encodeMessage({
@@ -2925,12 +2914,10 @@ export class SyncClient {
2925
2914
  // version this client sends. The §7.4.2 trigger-2 convergence runs
2926
2915
  // when the APP updates (recreating the client with a new generated
2927
2916
  // schema), which fires the boot-time §7.4.1 marker check instead.
2928
- const schemaFloor = {
2917
+ const schemaFloor = definedObject({
2929
2918
  requiredSchemaVersion: header.requiredSchemaVersion,
2930
- ...(header.latestSchemaVersion !== undefined
2931
- ? { latestSchemaVersion: header.latestSchemaVersion }
2932
- : {}),
2933
- };
2919
+ latestSchemaVersion: header.latestSchemaVersion,
2920
+ });
2934
2921
  this.#setSchemaFloor(schemaFloor);
2935
2922
  return {
2936
2923
  ...summary,
@@ -3243,7 +3230,7 @@ export class SyncClient {
3243
3230
  for (const result of frame.results) {
3244
3231
  const operation = commit.operations[result.opIndex];
3245
3232
  if (result.status === 'conflict') {
3246
- const conflict = {
3233
+ const conflict = definedObject({
3247
3234
  clientCommitId: frame.clientCommitId,
3248
3235
  opIndex: result.opIndex,
3249
3236
  table: operation?.table ?? '',
@@ -3253,8 +3240,8 @@ export class SyncClient {
3253
3240
  serverVersion: result.serverVersion,
3254
3241
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
3255
3242
  conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
3256
- ...(operation !== undefined ? { operation } : {}),
3257
- };
3243
+ operation: operation,
3244
+ });
3258
3245
  this.#conflicts.push(conflict);
3259
3246
  outcomeResults.push({ status: 'conflict', conflict });
3260
3247
  batch.conflicts();
@@ -3262,15 +3249,15 @@ export class SyncClient {
3262
3249
  }
3263
3250
  else if (result.status === 'error') {
3264
3251
  const details = rejectionDetails?.get(result.opIndex);
3265
- const rejection = {
3252
+ const rejection = definedObject({
3266
3253
  clientCommitId: frame.clientCommitId,
3267
3254
  opIndex: result.opIndex,
3268
3255
  code: result.code,
3269
3256
  message: result.message,
3270
3257
  retryable: result.retryable,
3271
- ...(details !== undefined ? { details } : {}),
3272
- ...(operation !== undefined ? { operation } : {}),
3273
- };
3258
+ details: details,
3259
+ operation: operation,
3260
+ });
3274
3261
  this.#rejections.push(rejection);
3275
3262
  outcomeResults.push({ status: 'error', rejection });
3276
3263
  batch.rejections();
@@ -3302,7 +3289,7 @@ export class SyncClient {
3302
3289
  return true;
3303
3290
  }
3304
3291
  #outboxCommitExists(clientCommitId) {
3305
- return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1', [clientCommitId]).length > 0);
3292
+ return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1', [clientCommitId]).length > 0);
3306
3293
  }
3307
3294
  #decodeServerRow(tableName, payload) {
3308
3295
  if (tableName === undefined)
@@ -3339,7 +3326,12 @@ export class SyncClient {
3339
3326
  async #applyCommit(frame, summary) {
3340
3327
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3341
3328
  this.#recordCommitChanges(batch, frame);
3342
- return fn();
3329
+ const result = fn();
3330
+ if (this.#retainFailedCommits &&
3331
+ this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
3332
+ .length > 0)
3333
+ this.#replayOutbox();
3334
+ return result;
3343
3335
  }));
3344
3336
  summary.commitsApplied += 1;
3345
3337
  }
@@ -3367,9 +3359,7 @@ export class SyncClient {
3367
3359
  const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
3368
3360
  if (mappings.length === 0)
3369
3361
  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];
3362
+ const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
3373
3363
  if (row === undefined)
3374
3364
  return false;
3375
3365
  let recorded = false;
@@ -3397,8 +3387,7 @@ export class SyncClient {
3397
3387
  clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
3398
3388
  params.push(...values);
3399
3389
  }
3400
- return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
3401
- WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
3390
+ return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
3402
3391
  }
3403
3392
  /**
3404
3393
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
@@ -3419,18 +3408,16 @@ export class SyncClient {
3419
3408
  error.code === 'sync.scope_revoked') {
3420
3409
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3421
3410
  this.#applyBatch((batch) => {
3422
- saveSubscription(this.#db, {
3411
+ saveSubscription(this.#db, definedObject({
3423
3412
  id: sub.id,
3424
3413
  table: sub.table,
3425
3414
  scopes: sub.scopes,
3426
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3415
+ params: sub.params,
3427
3416
  cursor: sub.cursor,
3428
- ...(sub.effectiveScopes !== undefined
3429
- ? { effectiveScopes: sub.effectiveScopes }
3430
- : {}),
3417
+ effectiveScopes: sub.effectiveScopes,
3431
3418
  status: 'failed',
3432
3419
  reasonCode: 'sync.scope_revoked',
3433
- });
3420
+ }));
3434
3421
  if (registered !== undefined) {
3435
3422
  batch.window(registered.baseKey, sub.table, registered.unit);
3436
3423
  }
@@ -3507,16 +3494,16 @@ export class SyncClient {
3507
3494
  const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
3508
3495
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3509
3496
  this.#applyBatch((batch) => {
3510
- saveSubscription(this.#db, {
3497
+ saveSubscription(this.#db, definedObject({
3511
3498
  id: sub.id,
3512
3499
  table: sub.table,
3513
3500
  scopes: sub.scopes,
3514
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3501
+ params: sub.params,
3515
3502
  cursor: nextCursor,
3516
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
3503
+ bootstrapState: bootstrapState,
3517
3504
  effectiveScopes: start.effectiveScopes,
3518
3505
  status: 'active',
3519
- });
3506
+ }));
3520
3507
  if (completed && registered !== undefined) {
3521
3508
  // A zero-row bootstrap is a window-domain transition, not a fake
3522
3509
  // row/table change (SPEC §4.8 / §7.5).
@@ -3530,18 +3517,16 @@ export class SyncClient {
3530
3517
  // with cursor = -1 on the next pull. Staleness, not a purge.
3531
3518
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3532
3519
  this.#applyBatch((batch) => {
3533
- saveSubscription(this.#db, {
3520
+ saveSubscription(this.#db, definedObject({
3534
3521
  id: sub.id,
3535
3522
  table: sub.table,
3536
3523
  scopes: sub.scopes,
3537
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3524
+ params: sub.params,
3538
3525
  cursor: -1,
3539
- ...(sub.effectiveScopes !== undefined
3540
- ? { effectiveScopes: sub.effectiveScopes }
3541
- : {}),
3526
+ effectiveScopes: sub.effectiveScopes,
3542
3527
  status: 'active',
3543
3528
  reasonCode: start.reasonCode,
3544
- });
3529
+ }));
3545
3530
  if (registered !== undefined) {
3546
3531
  batch.window(registered.baseKey, sub.table, registered.unit);
3547
3532
  }
@@ -3563,6 +3548,10 @@ export class SyncClient {
3563
3548
  const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
3564
3549
  Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
3565
3550
  for (const commit of droppedRetained) {
3551
+ batch.conflicts();
3552
+ batch.rejections();
3553
+ this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3554
+ this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3566
3555
  for (const operation of commit.operations)
3567
3556
  batch.table(operation.table);
3568
3557
  batch.outcomes();
@@ -3616,18 +3605,16 @@ export class SyncClient {
3616
3605
  }
3617
3606
  }
3618
3607
  }
3619
- saveSubscription(this.#db, {
3608
+ saveSubscription(this.#db, definedObject({
3620
3609
  id: sub.id,
3621
3610
  table: sub.table,
3622
3611
  scopes: sub.scopes,
3623
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3612
+ params: sub.params,
3624
3613
  cursor: nextCursor,
3625
- ...(lastEffective !== undefined
3626
- ? { effectiveScopes: lastEffective }
3627
- : {}),
3614
+ effectiveScopes: lastEffective,
3628
3615
  status: failed ? 'failed' : 'revoked',
3629
3616
  reasonCode: start.reasonCode,
3630
- });
3617
+ }));
3631
3618
  if (registered !== undefined) {
3632
3619
  batch.window(registered.baseKey, sub.table, registered.unit);
3633
3620
  }
@@ -3646,7 +3633,7 @@ export class SyncClient {
3646
3633
  ...table.columns.map((column) => quoteIdent(column.name)),
3647
3634
  quoteIdent(SYNC_VERSION_COLUMN),
3648
3635
  ];
3649
- const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
3636
+ const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
3650
3637
  if (row === undefined)
3651
3638
  return { opIndex, existed: false };
3652
3639
  const values = {};
@@ -3686,7 +3673,7 @@ export class SyncClient {
3686
3673
  const table = this.#schema.tables.get(operation.table);
3687
3674
  if (table === undefined)
3688
3675
  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];
3676
+ const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
3690
3677
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
3691
3678
  batch.table(table.name);
3692
3679
  deleteLocalRow(this.#db, table, operation.rowId);
@@ -3728,7 +3715,7 @@ export class SyncClient {
3728
3715
  this.#applyOperationsLocally(affectedOperations, batch);
3729
3716
  }
3730
3717
  }
3731
- #applyOperationsLocally(operations, batch) {
3718
+ #applyOperationsLocally(operations, batch, replay = false) {
3732
3719
  for (const op of operations) {
3733
3720
  const table = this.#table(op.table);
3734
3721
  let precise = batch === undefined
@@ -3746,7 +3733,7 @@ export class SyncClient {
3746
3733
  // sync.row_missing, and the local mirror never invents a base.
3747
3734
  const opValues = op.values ?? {};
3748
3735
  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];
3736
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [op.rowId])[0];
3750
3737
  if (local === undefined && !full) {
3751
3738
  if (batch !== undefined && !precise)
3752
3739
  batch.table(op.table);
@@ -3777,6 +3764,13 @@ export class SyncClient {
3777
3764
  const version = local === undefined
3778
3765
  ? OPTIMISTIC_VERSION
3779
3766
  : local[SYNC_VERSION_COLUMN];
3767
+ if (replay &&
3768
+ table.indexes.some((index) => index.unique) &&
3769
+ uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
3770
+ column.name,
3771
+ values[index] ?? null,
3772
+ ]))).length)
3773
+ continue;
3780
3774
  upsertLocalRow(this.#db, table, values, version, false);
3781
3775
  }
3782
3776
  }
@@ -3793,7 +3787,7 @@ export class SyncClient {
3793
3787
  restoreFailedBases(this.#db, this.#schema);
3794
3788
  restoreFailedBases(this.#db, this.#schema, true);
3795
3789
  for (const commit of pending)
3796
- this.#applyOperationsLocally(commit.operations, batch);
3790
+ this.#applyOperationsLocally(commit.operations, batch, true);
3797
3791
  });
3798
3792
  });
3799
3793
  }
@@ -3822,6 +3816,13 @@ export class SyncClient {
3822
3816
  }
3823
3817
  }
3824
3818
  }
3819
+ function definedObject(record) {
3820
+ for (const key of Object.keys(record)) {
3821
+ if (record[key] === undefined)
3822
+ delete record[key];
3823
+ }
3824
+ return record;
3825
+ }
3825
3826
  function requireRowId(value) {
3826
3827
  if (typeof value !== 'string' || !value.length)
3827
3828
  throw invalidRequest('a mutation requires a non-empty string primary key');
@@ -1,10 +1,14 @@
1
1
  import type { ClientDatabase } from './database.js';
2
- import type { CompiledClientSchema, JsonRowValue } from './schema.js';
2
+ import type { CompiledClientSchema, CompiledClientTable, JsonRowValue } from './schema.js';
3
+ import type { RowValue } from '@syncular/core';
4
+ import type { RetainedCommitRow } from './outcomes.js';
3
5
  import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
4
6
  /** Protected failure state. The server base advances independently of intent. */
5
7
  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;
8
+ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledClientTable, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
7
9
  export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
10
+ /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
11
+ export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
8
12
  export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
9
13
  export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
10
14
  export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];