@syncular/client 0.30.9 → 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') {
@@ -1827,6 +1819,10 @@ export class SyncClient {
1827
1819
  const normalized = normalizeRecordKeys(table, mutation.values);
1828
1820
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
1829
1821
  const pkColumn = table.columns[table.primaryKeyIndex];
1822
+ const pkValue = requireRowId(normalized.get(pkColumn.name));
1823
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
1824
+ if (!local)
1825
+ throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
1830
1826
  for (const column of table.columns) {
1831
1827
  if (!normalized.has(column.name))
1832
1828
  continue;
@@ -1841,12 +1837,7 @@ export class SyncClient {
1841
1837
  // by construction the row id being patched (§6.1), so its value is
1842
1838
  // proven equal already.
1843
1839
  if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
1844
- const pkValue = normalized.get(pkColumn.name);
1845
- const local = typeof pkValue === 'string' && pkValue.length > 0
1846
- ? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
1847
- : undefined;
1848
- if (local === undefined ||
1849
- !Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1840
+ if (!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1850
1841
  throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1851
1842
  }
1852
1843
  continue;
@@ -1868,19 +1859,14 @@ export class SyncClient {
1868
1859
  json[column.name] = rowValueToJson(values[index] ?? null);
1869
1860
  });
1870
1861
  }
1871
- const pkValue = json[table.primaryKey];
1872
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
1873
- throw invalidRequest(`table ${table.name}: upsert requires a non-empty string primary key`);
1874
- }
1875
- return {
1862
+ const pkValue = requireRowId(json[table.primaryKey]);
1863
+ return definedObject({
1876
1864
  table: mutation.table,
1877
1865
  rowId: pkValue,
1878
1866
  op: 'upsert',
1879
- ...(mutation.baseVersion !== undefined
1880
- ? { baseVersion: mutation.baseVersion }
1881
- : {}),
1867
+ baseVersion: mutation.baseVersion,
1882
1868
  values: json,
1883
- };
1869
+ });
1884
1870
  });
1885
1871
  this.#applyBatch((batch) => {
1886
1872
  this.#db.transaction(() => {
@@ -1904,10 +1890,7 @@ export class SyncClient {
1904
1890
  catch {
1905
1891
  continue;
1906
1892
  }
1907
- this.#db.exec(`INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
1908
- SELECT ?, ? WHERE EXISTS (
1909
- SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
1910
- )`, [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]);
1911
1894
  }
1912
1895
  }
1913
1896
  try {
@@ -1994,13 +1977,18 @@ export class SyncClient {
1994
1977
  // in the running schema), so purge drops it as a fixed step, before any
1995
1978
  // mirror row is touched and unconditionally.
1996
1979
  this.#dropPreviousVersion();
1997
- const rejectionCount = this.#rejections.length;
1980
+ const priorRejections = [...this.#rejections];
1981
+ const priorConflicts = [...this.#conflicts];
1998
1982
  try {
1999
1983
  return this.#applyBatch((batch) => {
2000
1984
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2001
1985
  restoreFailedBases(this.#db, this.#schema);
2002
1986
  const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
2003
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);
2004
1992
  for (const operation of commit.operations)
2005
1993
  batch.table(operation.table);
2006
1994
  batch.outcomes();
@@ -2057,7 +2045,7 @@ export class SyncClient {
2057
2045
  batch.table(tableName);
2058
2046
  for (let offset = 0; offset < values.length; offset += 400) {
2059
2047
  const chunk = values.slice(offset, offset + 400);
2060
- 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);
2061
2049
  }
2062
2050
  }
2063
2051
  if (doomed.length > 0) {
@@ -2100,7 +2088,8 @@ export class SyncClient {
2100
2088
  catch (error) {
2101
2089
  // SQLite rolls back through #applyBatch; mirror that rollback for the
2102
2090
  // in-memory rejection cache before surfacing the storage failure.
2103
- this.#rejections.length = rejectionCount;
2091
+ this.#rejections = priorRejections;
2092
+ this.#conflicts = priorConflicts;
2104
2093
  throw error;
2105
2094
  }
2106
2095
  }
@@ -2190,7 +2179,7 @@ export class SyncClient {
2190
2179
  clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
2191
2180
  params.push(...selector.values);
2192
2181
  }
2193
- 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)) {
2194
2183
  if (typeof row.id === 'string')
2195
2184
  ids.add(row.id);
2196
2185
  }
@@ -2232,7 +2221,7 @@ export class SyncClient {
2232
2221
  async #encodeOutboxForPush() {
2233
2222
  // Pin before the first encryption await: mutations can append while a
2234
2223
  // round is encoding, and belong to the next request.
2235
- 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];
2236
2225
  const pendingCount = bounds.count;
2237
2226
  const throughSeq = bounds.last_seq ?? 0;
2238
2227
  const pushFrames = [];
@@ -2262,7 +2251,7 @@ export class SyncClient {
2262
2251
  const selector = this.#encryption?.keyIdColumns?.[table.name];
2263
2252
  if (selector === undefined)
2264
2253
  return undefined;
2265
- 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];
2266
2255
  const value = row?.value;
2267
2256
  return typeof value === 'string' && value.length > 0
2268
2257
  ? value
@@ -2355,15 +2344,13 @@ export class SyncClient {
2355
2344
  const error = classifySqliteFailure(caught).error;
2356
2345
  const completedAtMs = this.#now();
2357
2346
  const code = error.code;
2358
- this.#progress.update({
2347
+ this.#progress.update(definedObject({
2359
2348
  state: 'failed',
2360
2349
  errorCode: typeof code === 'string'
2361
2350
  ? this.#diagnosticCode(code)
2362
2351
  : 'client.unknown_failure',
2363
- ...(this.#roundRetryDelayMs !== undefined
2364
- ? { retryDelayMs: this.#roundRetryDelayMs }
2365
- : {}),
2366
- });
2352
+ retryDelayMs: this.#roundRetryDelayMs,
2353
+ }));
2367
2354
  this.#lastRound = {
2368
2355
  status: 'failed',
2369
2356
  startedAtMs,
@@ -2455,12 +2442,12 @@ export class SyncClient {
2455
2442
  const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
2456
2443
  const limits = this.#config.limits;
2457
2444
  const frames = [
2458
- {
2445
+ definedObject({
2459
2446
  type: 'REQ_HEADER',
2460
2447
  clientId: this.#clientId,
2461
2448
  schemaVersion: this.#schema.version,
2462
- ...(logEpoch !== undefined ? { logEpoch } : {}),
2463
- },
2449
+ logEpoch: logEpoch,
2450
+ }),
2464
2451
  ...pushFrames,
2465
2452
  {
2466
2453
  type: 'PULL_HEADER',
@@ -2469,16 +2456,14 @@ export class SyncClient {
2469
2456
  maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
2470
2457
  accept: this.#acceptMask(),
2471
2458
  },
2472
- ...subs.map((sub) => ({
2459
+ ...subs.map((sub) => definedObject({
2473
2460
  type: 'SUBSCRIPTION',
2474
2461
  id: sub.id,
2475
2462
  table: sub.table,
2476
2463
  scopes: sub.scopes,
2477
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2464
+ params: sub.params,
2478
2465
  cursor: sub.cursor,
2479
- ...(sub.bootstrapState !== undefined
2480
- ? { bootstrapState: sub.bootstrapState }
2481
- : {}),
2466
+ bootstrapState: sub.bootstrapState,
2482
2467
  })),
2483
2468
  ];
2484
2469
  const requestBytes = encodeMessage({
@@ -2929,12 +2914,10 @@ export class SyncClient {
2929
2914
  // version this client sends. The §7.4.2 trigger-2 convergence runs
2930
2915
  // when the APP updates (recreating the client with a new generated
2931
2916
  // schema), which fires the boot-time §7.4.1 marker check instead.
2932
- const schemaFloor = {
2917
+ const schemaFloor = definedObject({
2933
2918
  requiredSchemaVersion: header.requiredSchemaVersion,
2934
- ...(header.latestSchemaVersion !== undefined
2935
- ? { latestSchemaVersion: header.latestSchemaVersion }
2936
- : {}),
2937
- };
2919
+ latestSchemaVersion: header.latestSchemaVersion,
2920
+ });
2938
2921
  this.#setSchemaFloor(schemaFloor);
2939
2922
  return {
2940
2923
  ...summary,
@@ -3247,7 +3230,7 @@ export class SyncClient {
3247
3230
  for (const result of frame.results) {
3248
3231
  const operation = commit.operations[result.opIndex];
3249
3232
  if (result.status === 'conflict') {
3250
- const conflict = {
3233
+ const conflict = definedObject({
3251
3234
  clientCommitId: frame.clientCommitId,
3252
3235
  opIndex: result.opIndex,
3253
3236
  table: operation?.table ?? '',
@@ -3257,8 +3240,8 @@ export class SyncClient {
3257
3240
  serverVersion: result.serverVersion,
3258
3241
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
3259
3242
  conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
3260
- ...(operation !== undefined ? { operation } : {}),
3261
- };
3243
+ operation: operation,
3244
+ });
3262
3245
  this.#conflicts.push(conflict);
3263
3246
  outcomeResults.push({ status: 'conflict', conflict });
3264
3247
  batch.conflicts();
@@ -3266,15 +3249,15 @@ export class SyncClient {
3266
3249
  }
3267
3250
  else if (result.status === 'error') {
3268
3251
  const details = rejectionDetails?.get(result.opIndex);
3269
- const rejection = {
3252
+ const rejection = definedObject({
3270
3253
  clientCommitId: frame.clientCommitId,
3271
3254
  opIndex: result.opIndex,
3272
3255
  code: result.code,
3273
3256
  message: result.message,
3274
3257
  retryable: result.retryable,
3275
- ...(details !== undefined ? { details } : {}),
3276
- ...(operation !== undefined ? { operation } : {}),
3277
- };
3258
+ details: details,
3259
+ operation: operation,
3260
+ });
3278
3261
  this.#rejections.push(rejection);
3279
3262
  outcomeResults.push({ status: 'error', rejection });
3280
3263
  batch.rejections();
@@ -3306,7 +3289,7 @@ export class SyncClient {
3306
3289
  return true;
3307
3290
  }
3308
3291
  #outboxCommitExists(clientCommitId) {
3309
- 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);
3310
3293
  }
3311
3294
  #decodeServerRow(tableName, payload) {
3312
3295
  if (tableName === undefined)
@@ -3343,7 +3326,12 @@ export class SyncClient {
3343
3326
  async #applyCommit(frame, summary) {
3344
3327
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3345
3328
  this.#recordCommitChanges(batch, frame);
3346
- 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;
3347
3335
  }));
3348
3336
  summary.commitsApplied += 1;
3349
3337
  }
@@ -3371,9 +3359,7 @@ export class SyncClient {
3371
3359
  const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
3372
3360
  if (mappings.length === 0)
3373
3361
  return false;
3374
- const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
3375
- FROM ${quoteIdent(table.name)}
3376
- 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];
3377
3363
  if (row === undefined)
3378
3364
  return false;
3379
3365
  let recorded = false;
@@ -3401,8 +3387,7 @@ export class SyncClient {
3401
3387
  clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
3402
3388
  params.push(...values);
3403
3389
  }
3404
- return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
3405
- 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);
3406
3391
  }
3407
3392
  /**
3408
3393
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
@@ -3423,18 +3408,16 @@ export class SyncClient {
3423
3408
  error.code === 'sync.scope_revoked') {
3424
3409
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3425
3410
  this.#applyBatch((batch) => {
3426
- saveSubscription(this.#db, {
3411
+ saveSubscription(this.#db, definedObject({
3427
3412
  id: sub.id,
3428
3413
  table: sub.table,
3429
3414
  scopes: sub.scopes,
3430
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3415
+ params: sub.params,
3431
3416
  cursor: sub.cursor,
3432
- ...(sub.effectiveScopes !== undefined
3433
- ? { effectiveScopes: sub.effectiveScopes }
3434
- : {}),
3417
+ effectiveScopes: sub.effectiveScopes,
3435
3418
  status: 'failed',
3436
3419
  reasonCode: 'sync.scope_revoked',
3437
- });
3420
+ }));
3438
3421
  if (registered !== undefined) {
3439
3422
  batch.window(registered.baseKey, sub.table, registered.unit);
3440
3423
  }
@@ -3511,16 +3494,16 @@ export class SyncClient {
3511
3494
  const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
3512
3495
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3513
3496
  this.#applyBatch((batch) => {
3514
- saveSubscription(this.#db, {
3497
+ saveSubscription(this.#db, definedObject({
3515
3498
  id: sub.id,
3516
3499
  table: sub.table,
3517
3500
  scopes: sub.scopes,
3518
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3501
+ params: sub.params,
3519
3502
  cursor: nextCursor,
3520
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
3503
+ bootstrapState: bootstrapState,
3521
3504
  effectiveScopes: start.effectiveScopes,
3522
3505
  status: 'active',
3523
- });
3506
+ }));
3524
3507
  if (completed && registered !== undefined) {
3525
3508
  // A zero-row bootstrap is a window-domain transition, not a fake
3526
3509
  // row/table change (SPEC §4.8 / §7.5).
@@ -3534,18 +3517,16 @@ export class SyncClient {
3534
3517
  // with cursor = -1 on the next pull. Staleness, not a purge.
3535
3518
  const registered = getWindowUnitBySubId(this.#db, sub.id);
3536
3519
  this.#applyBatch((batch) => {
3537
- saveSubscription(this.#db, {
3520
+ saveSubscription(this.#db, definedObject({
3538
3521
  id: sub.id,
3539
3522
  table: sub.table,
3540
3523
  scopes: sub.scopes,
3541
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3524
+ params: sub.params,
3542
3525
  cursor: -1,
3543
- ...(sub.effectiveScopes !== undefined
3544
- ? { effectiveScopes: sub.effectiveScopes }
3545
- : {}),
3526
+ effectiveScopes: sub.effectiveScopes,
3546
3527
  status: 'active',
3547
3528
  reasonCode: start.reasonCode,
3548
- });
3529
+ }));
3549
3530
  if (registered !== undefined) {
3550
3531
  batch.window(registered.baseKey, sub.table, registered.unit);
3551
3532
  }
@@ -3567,6 +3548,10 @@ export class SyncClient {
3567
3548
  const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
3568
3549
  Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
3569
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);
3570
3555
  for (const operation of commit.operations)
3571
3556
  batch.table(operation.table);
3572
3557
  batch.outcomes();
@@ -3620,18 +3605,16 @@ export class SyncClient {
3620
3605
  }
3621
3606
  }
3622
3607
  }
3623
- saveSubscription(this.#db, {
3608
+ saveSubscription(this.#db, definedObject({
3624
3609
  id: sub.id,
3625
3610
  table: sub.table,
3626
3611
  scopes: sub.scopes,
3627
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3612
+ params: sub.params,
3628
3613
  cursor: nextCursor,
3629
- ...(lastEffective !== undefined
3630
- ? { effectiveScopes: lastEffective }
3631
- : {}),
3614
+ effectiveScopes: lastEffective,
3632
3615
  status: failed ? 'failed' : 'revoked',
3633
3616
  reasonCode: start.reasonCode,
3634
- });
3617
+ }));
3635
3618
  if (registered !== undefined) {
3636
3619
  batch.window(registered.baseKey, sub.table, registered.unit);
3637
3620
  }
@@ -3650,7 +3633,7 @@ export class SyncClient {
3650
3633
  ...table.columns.map((column) => quoteIdent(column.name)),
3651
3634
  quoteIdent(SYNC_VERSION_COLUMN),
3652
3635
  ];
3653
- 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];
3654
3637
  if (row === undefined)
3655
3638
  return { opIndex, existed: false };
3656
3639
  const values = {};
@@ -3690,7 +3673,7 @@ export class SyncClient {
3690
3673
  const table = this.#schema.tables.get(operation.table);
3691
3674
  if (table === undefined)
3692
3675
  continue;
3693
- 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];
3694
3677
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
3695
3678
  batch.table(table.name);
3696
3679
  deleteLocalRow(this.#db, table, operation.rowId);
@@ -3732,7 +3715,7 @@ export class SyncClient {
3732
3715
  this.#applyOperationsLocally(affectedOperations, batch);
3733
3716
  }
3734
3717
  }
3735
- #applyOperationsLocally(operations, batch) {
3718
+ #applyOperationsLocally(operations, batch, replay = false) {
3736
3719
  for (const op of operations) {
3737
3720
  const table = this.#table(op.table);
3738
3721
  let precise = batch === undefined
@@ -3750,7 +3733,7 @@ export class SyncClient {
3750
3733
  // sync.row_missing, and the local mirror never invents a base.
3751
3734
  const opValues = op.values ?? {};
3752
3735
  const full = table.columns.every((column) => column.name in opValues);
3753
- 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];
3754
3737
  if (local === undefined && !full) {
3755
3738
  if (batch !== undefined && !precise)
3756
3739
  batch.table(op.table);
@@ -3781,6 +3764,13 @@ export class SyncClient {
3781
3764
  const version = local === undefined
3782
3765
  ? OPTIMISTIC_VERSION
3783
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;
3784
3774
  upsertLocalRow(this.#db, table, values, version, false);
3785
3775
  }
3786
3776
  }
@@ -3797,7 +3787,7 @@ export class SyncClient {
3797
3787
  restoreFailedBases(this.#db, this.#schema);
3798
3788
  restoreFailedBases(this.#db, this.#schema, true);
3799
3789
  for (const commit of pending)
3800
- this.#applyOperationsLocally(commit.operations, batch);
3790
+ this.#applyOperationsLocally(commit.operations, batch, true);
3801
3791
  });
3802
3792
  });
3803
3793
  }
@@ -3826,3 +3816,15 @@ export class SyncClient {
3826
3816
  }
3827
3817
  }
3828
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
+ }
3826
+ function requireRowId(value) {
3827
+ if (typeof value !== 'string' || !value.length)
3828
+ throw invalidRequest('a mutation requires a non-empty string primary key');
3829
+ return value;
3830
+ }