@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/src/client.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  failedOverlayCommits,
4
4
  retainFailedRows,
5
5
  restoreFailedBases,
6
+ uniqueConflicts,
6
7
  } from './failed-overlay';
7
8
  import {
8
9
  ProgressEmitter,
@@ -219,6 +220,7 @@ import {
219
220
  getSubscription,
220
221
  loadSubscriptions,
221
222
  pruneUnknownSubscriptions,
223
+ saveSubscriptionScopeSchema,
222
224
  resetSubscriptionsForBump,
223
225
  type SubscriptionRecord,
224
226
  saveSubscription,
@@ -947,7 +949,7 @@ export class SyncClient {
947
949
  const subscriptions = pruneUnknownSubscriptions(
948
950
  this.#db,
949
951
  loadSubscriptions(this.#db),
950
- new Set(this.#schema.tables.keys()),
952
+ this.#schema,
951
953
  );
952
954
  this.#started = true;
953
955
  // A persisted active subscription needs one catch-up round on every open:
@@ -1000,6 +1002,7 @@ export class SyncClient {
1000
1002
  String(this.#schema.version),
1001
1003
  );
1002
1004
  setLocalSchemaDescriptor(this.#db, this.#schema);
1005
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
1003
1006
  });
1004
1007
  return;
1005
1008
  }
@@ -1011,6 +1014,7 @@ export class SyncClient {
1011
1014
  this.#db.transaction(() => {
1012
1015
  ensureLocalSyncedSchema(this.#db, this.#schema);
1013
1016
  setLocalSchemaDescriptor(this.#db, this.#schema);
1017
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
1014
1018
  });
1015
1019
  return;
1016
1020
  }
@@ -1050,6 +1054,12 @@ export class SyncClient {
1050
1054
  ),
1051
1055
  );
1052
1056
  }
1057
+ pruneUnknownSubscriptions(
1058
+ this.#db,
1059
+ loadSubscriptions(this.#db),
1060
+ this.#schema,
1061
+ true,
1062
+ );
1053
1063
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1054
1064
  resetSubscriptionsForBump(this.#db);
1055
1065
  setMeta(
@@ -1058,6 +1068,7 @@ export class SyncClient {
1058
1068
  String(this.#schema.version),
1059
1069
  );
1060
1070
  setLocalSchemaDescriptor(this.#db, this.#schema);
1071
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
1061
1072
  });
1062
1073
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
1063
1074
  for (const table of this.#schema.tables.values()) batch.table(table.name);
@@ -1403,13 +1414,16 @@ export class SyncClient {
1403
1414
  previous.tables.join('\0') !== tables.join('\0')
1404
1415
  ) {
1405
1416
  this.#queryFailures.delete(owner.id);
1406
- this.#queryFailures.set(owner.id, {
1407
- id: owner.id,
1408
- tables,
1409
- code,
1410
- ...(sqliteCode !== undefined ? { sqliteCode } : {}),
1411
- atMs: this.#now(),
1412
- });
1417
+ this.#queryFailures.set(
1418
+ owner.id,
1419
+ definedObject({
1420
+ id: owner.id,
1421
+ tables,
1422
+ code,
1423
+ sqliteCode: sqliteCode,
1424
+ atMs: this.#now(),
1425
+ }),
1426
+ );
1413
1427
  for (const id of this.#queryFailures.keys()) {
1414
1428
  if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
1415
1429
  break;
@@ -1798,22 +1812,18 @@ export class SyncClient {
1798
1812
  }
1799
1813
  return a.id.localeCompare(b.id);
1800
1814
  });
1801
- return {
1815
+ return definedObject({
1802
1816
  version: CLIENT_DIAGNOSTICS_VERSION,
1803
1817
  capturedAtMs,
1804
- host: {
1818
+ host: definedObject({
1805
1819
  kind: 'direct',
1806
1820
  role: 'single',
1807
1821
  connectivity,
1808
1822
  realtime: this.#realtimeState,
1809
1823
  realtimePolicy: this.#realtimePolicy,
1810
- ...(this.#realtimeReasonCode !== undefined
1811
- ? { realtimeReasonCode: this.#realtimeReasonCode }
1812
- : {}),
1813
- ...(this.#realtimeRetryDelayMs !== undefined
1814
- ? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
1815
- : {}),
1816
- },
1824
+ realtimeReasonCode: this.#realtimeReasonCode,
1825
+ realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1826
+ }),
1817
1827
  securityLifecycle: this.#securityLifecycle,
1818
1828
  schema: {
1819
1829
  currentVersion: this.#config.schema.version,
@@ -1837,25 +1847,21 @@ export class SyncClient {
1837
1847
  ),
1838
1848
  subscriptionsTruncated:
1839
1849
  allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1840
- ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
1841
- ...(this.#lastChange !== undefined
1842
- ? { lastChange: this.#lastChange }
1843
- : {}),
1850
+ lastRound: this.#lastRound,
1851
+ lastChange: this.#lastChange,
1844
1852
  storage: this.#diagnosticStorage(),
1845
1853
  queryFailures: [...this.#queryFailures.values()],
1846
- };
1854
+ });
1847
1855
  }
1848
1856
 
1849
1857
  #diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
1850
1858
  const lease = this.#leaseState;
1851
1859
  if (lease?.errorCode !== undefined) {
1852
- return {
1860
+ return definedObject({
1853
1861
  state: 'stopped',
1854
1862
  errorCode: this.#diagnosticCode(lease.errorCode),
1855
- ...(lease.expiresAtMs !== undefined
1856
- ? { expiresAtMs: lease.expiresAtMs }
1857
- : {}),
1858
- };
1863
+ expiresAtMs: lease.expiresAtMs,
1864
+ });
1859
1865
  }
1860
1866
  if (lease?.expiresAtMs === undefined) return { state: 'none' };
1861
1867
  return {
@@ -1874,18 +1880,16 @@ export class SyncClient {
1874
1880
  );
1875
1881
  const outboxBytes = Number(
1876
1882
  this.#db.query(
1877
- 'SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox',
1883
+ 'SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox',
1878
1884
  )[0]?.bytes ?? 0,
1879
1885
  );
1880
1886
  const outcome = this.#db.query(
1881
- `SELECT COUNT(*) AS entries,
1882
- COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
1883
- FROM _syncular_commit_outcomes`,
1887
+ 'SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes',
1884
1888
  )[0];
1885
1889
  const blobBytes = this.#hasBlobs
1886
1890
  ? Number(
1887
1891
  this.#db.query(
1888
- 'SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs',
1892
+ 'SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs',
1889
1893
  )[0]?.bytes ?? 0,
1890
1894
  )
1891
1895
  : 0;
@@ -2323,7 +2327,7 @@ export class SyncClient {
2323
2327
  return this.#applyBatch(
2324
2328
  (batch) => {
2325
2329
  restoreFailedBases(this.#db, this.#schema);
2326
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
2330
+ this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
2327
2331
  input.clientCommitId,
2328
2332
  ]);
2329
2333
  const resolved = persistCommitOutcomeResolution(
@@ -2476,14 +2480,17 @@ export class SyncClient {
2476
2480
  }
2477
2481
  return;
2478
2482
  }
2479
- saveSubscription(this.#db, {
2480
- id: input.id,
2481
- table: input.table,
2482
- scopes: input.scopes,
2483
- ...(input.params !== undefined ? { params: input.params } : {}),
2484
- cursor: -1,
2485
- status: 'active',
2486
- });
2483
+ saveSubscription(
2484
+ this.#db,
2485
+ definedObject({
2486
+ id: input.id,
2487
+ table: input.table,
2488
+ scopes: input.scopes,
2489
+ params: input.params,
2490
+ cursor: -1,
2491
+ status: 'active',
2492
+ }),
2493
+ );
2487
2494
  this.#setSyncNeeded(true);
2488
2495
  this.#emitSyncIntent({ kind: 'interactive' });
2489
2496
  this.#emitDiagnostics();
@@ -2549,14 +2556,17 @@ export class SyncClient {
2549
2556
  // Re-entry cancels any deferred eviction for this sub id.
2550
2557
  deletePendingEviction(this.#db, subId);
2551
2558
  insertWindowUnit(this.#db, baseKey, unit, subId);
2552
- saveSubscription(this.#db, {
2553
- id: subId,
2554
- table: base.table,
2555
- scopes: unitScopes(base, unit),
2556
- ...(base.params !== undefined ? { params: base.params } : {}),
2557
- cursor: -1,
2558
- status: 'active',
2559
- });
2559
+ saveSubscription(
2560
+ this.#db,
2561
+ definedObject({
2562
+ id: subId,
2563
+ table: base.table,
2564
+ scopes: unitScopes(base, unit),
2565
+ params: base.params,
2566
+ cursor: -1,
2567
+ status: 'active',
2568
+ }),
2569
+ );
2560
2570
  });
2561
2571
  this.#needsPull = true;
2562
2572
  batch.window(baseKey, base.table, unit);
@@ -2732,14 +2742,12 @@ export class SyncClient {
2732
2742
  const operations: OutboxOperation[] = mutations.map((mutation) => {
2733
2743
  const table = this.#table(mutation.table);
2734
2744
  if (mutation.op === 'delete') {
2735
- return {
2745
+ return definedObject({
2736
2746
  table: mutation.table,
2737
2747
  rowId: mutation.rowId,
2738
2748
  op: 'delete',
2739
- ...(mutation.baseVersion !== undefined
2740
- ? { baseVersion: mutation.baseVersion }
2741
- : {}),
2742
- };
2749
+ baseVersion: mutation.baseVersion,
2750
+ });
2743
2751
  }
2744
2752
  const json: Record<string, JsonRowValue> = {};
2745
2753
  if (mutation.op === 'patch') {
@@ -2750,6 +2758,16 @@ export class SyncClient {
2750
2758
  const normalized = normalizeRecordKeys(table, mutation.values);
2751
2759
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2752
2760
  const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2761
+ const pkValue = requireRowId(normalized.get(pkColumn.name));
2762
+ const local = this.#db.query(
2763
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
2764
+ [pkValue],
2765
+ )[0];
2766
+ if (!local)
2767
+ throw new ClientSyncError(
2768
+ 'sync.row_missing',
2769
+ 'a sparse patch requires a local row',
2770
+ );
2753
2771
  for (const column of table.columns) {
2754
2772
  if (!normalized.has(column.name)) continue;
2755
2773
  // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
@@ -2763,16 +2781,7 @@ export class SyncClient {
2763
2781
  // by construction the row id being patched (§6.1), so its value is
2764
2782
  // proven equal already.
2765
2783
  if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
2766
- const pkValue = normalized.get(pkColumn.name);
2767
- const local =
2768
- typeof pkValue === 'string' && pkValue.length > 0
2769
- ? this.#db.query(
2770
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2771
- [pkValue],
2772
- )[0]
2773
- : undefined;
2774
2784
  if (
2775
- local === undefined ||
2776
2785
  !Object.is(
2777
2786
  rowValueToJson(
2778
2787
  fromSqlValue(column, local[column.name] ?? null),
@@ -2811,21 +2820,14 @@ export class SyncClient {
2811
2820
  json[column.name] = rowValueToJson(values[index] ?? null);
2812
2821
  });
2813
2822
  }
2814
- const pkValue = json[table.primaryKey];
2815
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
2816
- throw invalidRequest(
2817
- `table ${table.name}: upsert requires a non-empty string primary key`,
2818
- );
2819
- }
2820
- return {
2823
+ const pkValue = requireRowId(json[table.primaryKey]);
2824
+ return definedObject({
2821
2825
  table: mutation.table,
2822
2826
  rowId: pkValue,
2823
2827
  op: 'upsert',
2824
- ...(mutation.baseVersion !== undefined
2825
- ? { baseVersion: mutation.baseVersion }
2826
- : {}),
2828
+ baseVersion: mutation.baseVersion,
2827
2829
  values: json,
2828
- };
2830
+ });
2829
2831
  });
2830
2832
  this.#applyBatch((batch) => {
2831
2833
  this.#db.transaction(() => {
@@ -2851,10 +2853,7 @@ export class SyncClient {
2851
2853
  continue;
2852
2854
  }
2853
2855
  this.#db.exec(
2854
- `INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
2855
- SELECT ?, ? WHERE EXISTS (
2856
- SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
2857
- )`,
2856
+ 'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
2858
2857
  [clientCommitId, blobId, blobId],
2859
2858
  );
2860
2859
  }
@@ -2960,7 +2959,8 @@ export class SyncClient {
2960
2959
  // mirror row is touched and unconditionally.
2961
2960
  this.#dropPreviousVersion();
2962
2961
 
2963
- const rejectionCount = this.#rejections.length;
2962
+ const priorRejections = [...this.#rejections];
2963
+ const priorConflicts = [...this.#conflicts];
2964
2964
  try {
2965
2965
  return this.#applyBatch((batch) => {
2966
2966
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
@@ -2973,6 +2973,14 @@ export class SyncClient {
2973
2973
  ),
2974
2974
  );
2975
2975
  for (const commit of droppedRetained) {
2976
+ batch.conflicts();
2977
+ batch.rejections();
2978
+ this.#conflicts = this.#conflicts.filter(
2979
+ (record) => record.clientCommitId !== commit.clientCommitId,
2980
+ );
2981
+ this.#rejections = this.#rejections.filter(
2982
+ (record) => record.clientCommitId !== commit.clientCommitId,
2983
+ );
2976
2984
  for (const operation of commit.operations)
2977
2985
  batch.table(operation.table);
2978
2986
  batch.outcomes();
@@ -3042,7 +3050,7 @@ export class SyncClient {
3042
3050
  for (let offset = 0; offset < values.length; offset += 400) {
3043
3051
  const chunk = values.slice(offset, offset + 400);
3044
3052
  this.#db.exec(
3045
- `DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN (${chunk.map(() => '?').join(', ')})`,
3053
+ `DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`,
3046
3054
  chunk,
3047
3055
  );
3048
3056
  }
@@ -3090,7 +3098,8 @@ export class SyncClient {
3090
3098
  } catch (error) {
3091
3099
  // SQLite rolls back through #applyBatch; mirror that rollback for the
3092
3100
  // in-memory rejection cache before surfacing the storage failure.
3093
- this.#rejections.length = rejectionCount;
3101
+ this.#rejections = priorRejections;
3102
+ this.#conflicts = priorConflicts;
3094
3103
  throw error;
3095
3104
  }
3096
3105
  }
@@ -3196,7 +3205,7 @@ export class SyncClient {
3196
3205
  params.push(...selector.values);
3197
3206
  }
3198
3207
  for (const row of this.#db.query(
3199
- `SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT) AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`,
3208
+ `SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`,
3200
3209
  params,
3201
3210
  )) {
3202
3211
  if (typeof row.id === 'string') ids.add(row.id);
@@ -3255,7 +3264,7 @@ export class SyncClient {
3255
3264
  // Pin before the first encryption await: mutations can append while a
3256
3265
  // round is encoding, and belong to the next request.
3257
3266
  const bounds = this.#db.query(
3258
- 'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
3267
+ 'SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox',
3259
3268
  )[0]!;
3260
3269
  const pendingCount = bounds.count as number;
3261
3270
  const throughSeq = (bounds.last_seq as number | null) ?? 0;
@@ -3292,7 +3301,7 @@ export class SyncClient {
3292
3301
  const selector = this.#encryption?.keyIdColumns?.[table.name];
3293
3302
  if (selector === undefined) return undefined;
3294
3303
  const row = this.#db.query(
3295
- `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
3304
+ `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
3296
3305
  [rowId],
3297
3306
  )[0];
3298
3307
  const value = row?.value;
@@ -3405,16 +3414,16 @@ export class SyncClient {
3405
3414
  const error = classifySqliteFailure(caught).error;
3406
3415
  const completedAtMs = this.#now();
3407
3416
  const code = (error as { code?: unknown }).code;
3408
- this.#progress.update({
3409
- state: 'failed',
3410
- errorCode:
3411
- typeof code === 'string'
3412
- ? this.#diagnosticCode(code)
3413
- : 'client.unknown_failure',
3414
- ...(this.#roundRetryDelayMs !== undefined
3415
- ? { retryDelayMs: this.#roundRetryDelayMs }
3416
- : {}),
3417
- });
3417
+ this.#progress.update(
3418
+ definedObject({
3419
+ state: 'failed',
3420
+ errorCode:
3421
+ typeof code === 'string'
3422
+ ? this.#diagnosticCode(code)
3423
+ : 'client.unknown_failure',
3424
+ retryDelayMs: this.#roundRetryDelayMs,
3425
+ }),
3426
+ );
3418
3427
  this.#lastRound = {
3419
3428
  status: 'failed',
3420
3429
  startedAtMs,
@@ -3519,12 +3528,12 @@ export class SyncClient {
3519
3528
  );
3520
3529
  const limits = this.#config.limits;
3521
3530
  const frames: RequestFrame[] = [
3522
- {
3531
+ definedObject({
3523
3532
  type: 'REQ_HEADER',
3524
3533
  clientId: this.#clientId,
3525
3534
  schemaVersion: this.#schema.version,
3526
- ...(logEpoch !== undefined ? { logEpoch } : {}),
3527
- },
3535
+ logEpoch: logEpoch,
3536
+ }),
3528
3537
  ...pushFrames,
3529
3538
  {
3530
3539
  type: 'PULL_HEADER',
@@ -3534,17 +3543,16 @@ export class SyncClient {
3534
3543
  accept: this.#acceptMask(),
3535
3544
  },
3536
3545
  ...subs.map(
3537
- (sub): RequestFrame => ({
3538
- type: 'SUBSCRIPTION',
3539
- id: sub.id,
3540
- table: sub.table,
3541
- scopes: sub.scopes,
3542
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3543
- cursor: sub.cursor,
3544
- ...(sub.bootstrapState !== undefined
3545
- ? { bootstrapState: sub.bootstrapState }
3546
- : {}),
3547
- }),
3546
+ (sub): RequestFrame =>
3547
+ definedObject({
3548
+ type: 'SUBSCRIPTION',
3549
+ id: sub.id,
3550
+ table: sub.table,
3551
+ scopes: sub.scopes,
3552
+ params: sub.params,
3553
+ cursor: sub.cursor,
3554
+ bootstrapState: sub.bootstrapState,
3555
+ }),
3548
3556
  ),
3549
3557
  ];
3550
3558
  const requestBytes = encodeMessage({
@@ -4078,12 +4086,10 @@ export class SyncClient {
4078
4086
  // version this client sends. The §7.4.2 trigger-2 convergence runs
4079
4087
  // when the APP updates (recreating the client with a new generated
4080
4088
  // schema), which fires the boot-time §7.4.1 marker check instead.
4081
- const schemaFloor: SchemaFloor = {
4089
+ const schemaFloor: SchemaFloor = definedObject({
4082
4090
  requiredSchemaVersion: header.requiredSchemaVersion,
4083
- ...(header.latestSchemaVersion !== undefined
4084
- ? { latestSchemaVersion: header.latestSchemaVersion }
4085
- : {}),
4086
- };
4091
+ latestSchemaVersion: header.latestSchemaVersion,
4092
+ });
4087
4093
  this.#setSchemaFloor(schemaFloor);
4088
4094
  return {
4089
4095
  ...summary,
@@ -4511,7 +4517,7 @@ export class SyncClient {
4511
4517
  for (const result of frame.results) {
4512
4518
  const operation = commit.operations[result.opIndex];
4513
4519
  if (result.status === 'conflict') {
4514
- const conflict: ConflictRecord = {
4520
+ const conflict: ConflictRecord = definedObject({
4515
4521
  clientCommitId: frame.clientCommitId,
4516
4522
  opIndex: result.opIndex,
4517
4523
  table: operation?.table ?? '',
@@ -4524,23 +4530,23 @@ export class SyncClient {
4524
4530
  operation?.table,
4525
4531
  result.conflictColumns,
4526
4532
  ),
4527
- ...(operation !== undefined ? { operation } : {}),
4528
- };
4533
+ operation: operation,
4534
+ });
4529
4535
  this.#conflicts.push(conflict);
4530
4536
  outcomeResults.push({ status: 'conflict', conflict });
4531
4537
  batch.conflicts();
4532
4538
  summary.conflicts.push(conflict);
4533
4539
  } else if (result.status === 'error') {
4534
4540
  const details = rejectionDetails?.get(result.opIndex);
4535
- const rejection: RejectionRecord = {
4541
+ const rejection: RejectionRecord = definedObject({
4536
4542
  clientCommitId: frame.clientCommitId,
4537
4543
  opIndex: result.opIndex,
4538
4544
  code: result.code,
4539
4545
  message: result.message,
4540
4546
  retryable: result.retryable,
4541
- ...(details !== undefined ? { details } : {}),
4542
- ...(operation !== undefined ? { operation } : {}),
4543
- };
4547
+ details: details,
4548
+ operation: operation,
4549
+ });
4544
4550
  this.#rejections.push(rejection);
4545
4551
  outcomeResults.push({ status: 'error', rejection });
4546
4552
  batch.rejections();
@@ -4578,7 +4584,7 @@ export class SyncClient {
4578
4584
  #outboxCommitExists(clientCommitId: string): boolean {
4579
4585
  return (
4580
4586
  this.#db.query(
4581
- 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1',
4587
+ 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
4582
4588
  [clientCommitId],
4583
4589
  ).length > 0
4584
4590
  );
@@ -4631,7 +4637,14 @@ export class SyncClient {
4631
4637
  (fn) =>
4632
4638
  this.#applyBatch((batch) => {
4633
4639
  this.#recordCommitChanges(batch, frame);
4634
- return fn();
4640
+ const result = fn();
4641
+ if (
4642
+ this.#retainFailedCommits &&
4643
+ this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
4644
+ .length > 0
4645
+ )
4646
+ this.#replayOutbox();
4647
+ return result;
4635
4648
  }),
4636
4649
  );
4637
4650
  summary.commitsApplied += 1;
@@ -4664,9 +4677,7 @@ export class SyncClient {
4664
4677
  );
4665
4678
  if (mappings.length === 0) return false;
4666
4679
  const row = this.#db.query(
4667
- `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
4668
- FROM ${quoteIdent(table.name)}
4669
- WHERE ${quoteIdent(table.primaryKey)} = ?`,
4680
+ `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
4670
4681
  [rowId],
4671
4682
  )[0];
4672
4683
  if (row === undefined) return false;
@@ -4698,8 +4709,7 @@ export class SyncClient {
4698
4709
  }
4699
4710
  return (
4700
4711
  this.#db.query(
4701
- `SELECT 1 FROM ${quoteIdent(table.name)}
4702
- WHERE ${clauses.join(' AND ')} LIMIT 1`,
4712
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`,
4703
4713
  params,
4704
4714
  ).length > 0
4705
4715
  );
@@ -4737,18 +4747,19 @@ export class SyncClient {
4737
4747
  ) {
4738
4748
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4739
4749
  this.#applyBatch((batch) => {
4740
- saveSubscription(this.#db, {
4741
- id: sub.id,
4742
- table: sub.table,
4743
- scopes: sub.scopes,
4744
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4745
- cursor: sub.cursor,
4746
- ...(sub.effectiveScopes !== undefined
4747
- ? { effectiveScopes: sub.effectiveScopes }
4748
- : {}),
4749
- status: 'failed',
4750
- reasonCode: 'sync.scope_revoked',
4751
- });
4750
+ saveSubscription(
4751
+ this.#db,
4752
+ definedObject({
4753
+ id: sub.id,
4754
+ table: sub.table,
4755
+ scopes: sub.scopes,
4756
+ params: sub.params,
4757
+ cursor: sub.cursor,
4758
+ effectiveScopes: sub.effectiveScopes,
4759
+ status: 'failed',
4760
+ reasonCode: 'sync.scope_revoked',
4761
+ }),
4762
+ );
4752
4763
  if (registered !== undefined) {
4753
4764
  batch.window(registered.baseKey, sub.table, registered.unit);
4754
4765
  }
@@ -4851,16 +4862,19 @@ export class SyncClient {
4851
4862
  wasPending && nextCursor >= 0 && bootstrapState === undefined;
4852
4863
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4853
4864
  this.#applyBatch((batch) => {
4854
- saveSubscription(this.#db, {
4855
- id: sub.id,
4856
- table: sub.table,
4857
- scopes: sub.scopes,
4858
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4859
- cursor: nextCursor,
4860
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
4861
- effectiveScopes: start.effectiveScopes,
4862
- status: 'active',
4863
- });
4865
+ saveSubscription(
4866
+ this.#db,
4867
+ definedObject({
4868
+ id: sub.id,
4869
+ table: sub.table,
4870
+ scopes: sub.scopes,
4871
+ params: sub.params,
4872
+ cursor: nextCursor,
4873
+ bootstrapState: bootstrapState,
4874
+ effectiveScopes: start.effectiveScopes,
4875
+ status: 'active',
4876
+ }),
4877
+ );
4864
4878
  if (completed && registered !== undefined) {
4865
4879
  // A zero-row bootstrap is a window-domain transition, not a fake
4866
4880
  // row/table change (SPEC §4.8 / §7.5).
@@ -4875,18 +4889,19 @@ export class SyncClient {
4875
4889
  // with cursor = -1 on the next pull. Staleness, not a purge.
4876
4890
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4877
4891
  this.#applyBatch((batch) => {
4878
- saveSubscription(this.#db, {
4879
- id: sub.id,
4880
- table: sub.table,
4881
- scopes: sub.scopes,
4882
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4883
- cursor: -1,
4884
- ...(sub.effectiveScopes !== undefined
4885
- ? { effectiveScopes: sub.effectiveScopes }
4886
- : {}),
4887
- status: 'active',
4888
- reasonCode: start.reasonCode,
4889
- });
4892
+ saveSubscription(
4893
+ this.#db,
4894
+ definedObject({
4895
+ id: sub.id,
4896
+ table: sub.table,
4897
+ scopes: sub.scopes,
4898
+ params: sub.params,
4899
+ cursor: -1,
4900
+ effectiveScopes: sub.effectiveScopes,
4901
+ status: 'active',
4902
+ reasonCode: start.reasonCode,
4903
+ }),
4904
+ );
4890
4905
  if (registered !== undefined) {
4891
4906
  batch.window(registered.baseKey, sub.table, registered.unit);
4892
4907
  }
@@ -4919,6 +4934,14 @@ export class SyncClient {
4919
4934
  ),
4920
4935
  );
4921
4936
  for (const commit of droppedRetained) {
4937
+ batch.conflicts();
4938
+ batch.rejections();
4939
+ this.#conflicts = this.#conflicts.filter(
4940
+ (record) => record.clientCommitId !== commit.clientCommitId,
4941
+ );
4942
+ this.#rejections = this.#rejections.filter(
4943
+ (record) => record.clientCommitId !== commit.clientCommitId,
4944
+ );
4922
4945
  for (const operation of commit.operations)
4923
4946
  batch.table(operation.table);
4924
4947
  batch.outcomes();
@@ -4980,18 +5003,19 @@ export class SyncClient {
4980
5003
  }
4981
5004
  }
4982
5005
  }
4983
- saveSubscription(this.#db, {
4984
- id: sub.id,
4985
- table: sub.table,
4986
- scopes: sub.scopes,
4987
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4988
- cursor: nextCursor,
4989
- ...(lastEffective !== undefined
4990
- ? { effectiveScopes: lastEffective }
4991
- : {}),
4992
- status: failed ? 'failed' : 'revoked',
4993
- reasonCode: start.reasonCode,
4994
- });
5006
+ saveSubscription(
5007
+ this.#db,
5008
+ definedObject({
5009
+ id: sub.id,
5010
+ table: sub.table,
5011
+ scopes: sub.scopes,
5012
+ params: sub.params,
5013
+ cursor: nextCursor,
5014
+ effectiveScopes: lastEffective,
5015
+ status: failed ? 'failed' : 'revoked',
5016
+ reasonCode: start.reasonCode,
5017
+ }),
5018
+ );
4995
5019
  if (registered !== undefined) {
4996
5020
  batch.window(registered.baseKey, sub.table, registered.unit);
4997
5021
  }
@@ -5014,7 +5038,7 @@ export class SyncClient {
5014
5038
  quoteIdent(SYNC_VERSION_COLUMN),
5015
5039
  ];
5016
5040
  const row = this.#db.query(
5017
- `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5041
+ `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5018
5042
  [operation.rowId],
5019
5043
  )[0];
5020
5044
  if (row === undefined) return { opIndex, existed: false };
@@ -5078,7 +5102,7 @@ export class SyncClient {
5078
5102
  const table = this.#schema.tables.get(operation.table);
5079
5103
  if (table === undefined) continue;
5080
5104
  const row = this.#db.query(
5081
- `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5105
+ `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5082
5106
  [operation.rowId],
5083
5107
  )[0];
5084
5108
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
@@ -5134,6 +5158,7 @@ export class SyncClient {
5134
5158
  #applyOperationsLocally(
5135
5159
  operations: readonly OutboxOperation[],
5136
5160
  batch?: ChangeAccumulator,
5161
+ replay = false,
5137
5162
  ): void {
5138
5163
  for (const op of operations) {
5139
5164
  const table = this.#table(op.table);
@@ -5153,7 +5178,7 @@ export class SyncClient {
5153
5178
  const opValues = op.values ?? {};
5154
5179
  const full = table.columns.every((column) => column.name in opValues);
5155
5180
  const local = this.#db.query(
5156
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5181
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5157
5182
  [op.rowId],
5158
5183
  )[0];
5159
5184
  if (local === undefined && !full) {
@@ -5184,6 +5209,21 @@ export class SyncClient {
5184
5209
  local === undefined
5185
5210
  ? OPTIMISTIC_VERSION
5186
5211
  : (local[SYNC_VERSION_COLUMN] as number);
5212
+ if (
5213
+ replay &&
5214
+ table.indexes.some((index) => index.unique) &&
5215
+ uniqueConflicts(
5216
+ this.#db,
5217
+ table,
5218
+ Object.fromEntries(
5219
+ table.columns.map((column, index) => [
5220
+ column.name,
5221
+ values[index] ?? null,
5222
+ ]),
5223
+ ),
5224
+ ).length
5225
+ )
5226
+ continue;
5187
5227
  upsertLocalRow(this.#db, table, values, version, false);
5188
5228
  }
5189
5229
  }
@@ -5200,7 +5240,7 @@ export class SyncClient {
5200
5240
  restoreFailedBases(this.#db, this.#schema);
5201
5241
  restoreFailedBases(this.#db, this.#schema, true);
5202
5242
  for (const commit of pending)
5203
- this.#applyOperationsLocally(commit.operations, batch);
5243
+ this.#applyOperationsLocally(commit.operations, batch, true);
5204
5244
  });
5205
5245
  });
5206
5246
  }
@@ -5239,3 +5279,24 @@ export class SyncClient {
5239
5279
  }
5240
5280
  }
5241
5281
  }
5282
+
5283
+ type DefinedObject<T> = {
5284
+ [K in keyof T as undefined extends T[K] ? never : K]: T[K];
5285
+ } & {
5286
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<
5287
+ T[K],
5288
+ undefined
5289
+ >;
5290
+ };
5291
+ function definedObject<const T extends object>(record: T): DefinedObject<T> {
5292
+ for (const key of Object.keys(record) as (keyof T)[]) {
5293
+ if (record[key] === undefined) delete record[key];
5294
+ }
5295
+ return record as DefinedObject<T>;
5296
+ }
5297
+
5298
+ function requireRowId(value: unknown): string {
5299
+ if (typeof value !== 'string' || !value.length)
5300
+ throw invalidRequest('a mutation requires a non-empty string primary key');
5301
+ return value;
5302
+ }