@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/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') {
@@ -2752,7 +2760,7 @@ export class SyncClient {
2752
2760
  const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2753
2761
  const pkValue = requireRowId(normalized.get(pkColumn.name));
2754
2762
  const local = this.#db.query(
2755
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2763
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
2756
2764
  [pkValue],
2757
2765
  )[0];
2758
2766
  if (!local)
@@ -2813,15 +2821,13 @@ export class SyncClient {
2813
2821
  });
2814
2822
  }
2815
2823
  const pkValue = requireRowId(json[table.primaryKey]);
2816
- return {
2824
+ return definedObject({
2817
2825
  table: mutation.table,
2818
2826
  rowId: pkValue,
2819
2827
  op: 'upsert',
2820
- ...(mutation.baseVersion !== undefined
2821
- ? { baseVersion: mutation.baseVersion }
2822
- : {}),
2828
+ baseVersion: mutation.baseVersion,
2823
2829
  values: json,
2824
- };
2830
+ });
2825
2831
  });
2826
2832
  this.#applyBatch((batch) => {
2827
2833
  this.#db.transaction(() => {
@@ -2847,10 +2853,7 @@ export class SyncClient {
2847
2853
  continue;
2848
2854
  }
2849
2855
  this.#db.exec(
2850
- `INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
2851
- SELECT ?, ? WHERE EXISTS (
2852
- SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
2853
- )`,
2856
+ 'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
2854
2857
  [clientCommitId, blobId, blobId],
2855
2858
  );
2856
2859
  }
@@ -2956,7 +2959,8 @@ export class SyncClient {
2956
2959
  // mirror row is touched and unconditionally.
2957
2960
  this.#dropPreviousVersion();
2958
2961
 
2959
- const rejectionCount = this.#rejections.length;
2962
+ const priorRejections = [...this.#rejections];
2963
+ const priorConflicts = [...this.#conflicts];
2960
2964
  try {
2961
2965
  return this.#applyBatch((batch) => {
2962
2966
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
@@ -2969,6 +2973,14 @@ export class SyncClient {
2969
2973
  ),
2970
2974
  );
2971
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
+ );
2972
2984
  for (const operation of commit.operations)
2973
2985
  batch.table(operation.table);
2974
2986
  batch.outcomes();
@@ -3038,7 +3050,7 @@ export class SyncClient {
3038
3050
  for (let offset = 0; offset < values.length; offset += 400) {
3039
3051
  const chunk = values.slice(offset, offset + 400);
3040
3052
  this.#db.exec(
3041
- `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(', ')})`,
3042
3054
  chunk,
3043
3055
  );
3044
3056
  }
@@ -3086,7 +3098,8 @@ export class SyncClient {
3086
3098
  } catch (error) {
3087
3099
  // SQLite rolls back through #applyBatch; mirror that rollback for the
3088
3100
  // in-memory rejection cache before surfacing the storage failure.
3089
- this.#rejections.length = rejectionCount;
3101
+ this.#rejections = priorRejections;
3102
+ this.#conflicts = priorConflicts;
3090
3103
  throw error;
3091
3104
  }
3092
3105
  }
@@ -3192,7 +3205,7 @@ export class SyncClient {
3192
3205
  params.push(...selector.values);
3193
3206
  }
3194
3207
  for (const row of this.#db.query(
3195
- `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 ')}`,
3196
3209
  params,
3197
3210
  )) {
3198
3211
  if (typeof row.id === 'string') ids.add(row.id);
@@ -3251,7 +3264,7 @@ export class SyncClient {
3251
3264
  // Pin before the first encryption await: mutations can append while a
3252
3265
  // round is encoding, and belong to the next request.
3253
3266
  const bounds = this.#db.query(
3254
- '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',
3255
3268
  )[0]!;
3256
3269
  const pendingCount = bounds.count as number;
3257
3270
  const throughSeq = (bounds.last_seq as number | null) ?? 0;
@@ -3288,7 +3301,7 @@ export class SyncClient {
3288
3301
  const selector = this.#encryption?.keyIdColumns?.[table.name];
3289
3302
  if (selector === undefined) return undefined;
3290
3303
  const row = this.#db.query(
3291
- `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)}=?`,
3292
3305
  [rowId],
3293
3306
  )[0];
3294
3307
  const value = row?.value;
@@ -3401,16 +3414,16 @@ export class SyncClient {
3401
3414
  const error = classifySqliteFailure(caught).error;
3402
3415
  const completedAtMs = this.#now();
3403
3416
  const code = (error as { code?: unknown }).code;
3404
- this.#progress.update({
3405
- state: 'failed',
3406
- errorCode:
3407
- typeof code === 'string'
3408
- ? this.#diagnosticCode(code)
3409
- : 'client.unknown_failure',
3410
- ...(this.#roundRetryDelayMs !== undefined
3411
- ? { retryDelayMs: this.#roundRetryDelayMs }
3412
- : {}),
3413
- });
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
+ );
3414
3427
  this.#lastRound = {
3415
3428
  status: 'failed',
3416
3429
  startedAtMs,
@@ -3515,12 +3528,12 @@ export class SyncClient {
3515
3528
  );
3516
3529
  const limits = this.#config.limits;
3517
3530
  const frames: RequestFrame[] = [
3518
- {
3531
+ definedObject({
3519
3532
  type: 'REQ_HEADER',
3520
3533
  clientId: this.#clientId,
3521
3534
  schemaVersion: this.#schema.version,
3522
- ...(logEpoch !== undefined ? { logEpoch } : {}),
3523
- },
3535
+ logEpoch: logEpoch,
3536
+ }),
3524
3537
  ...pushFrames,
3525
3538
  {
3526
3539
  type: 'PULL_HEADER',
@@ -3530,17 +3543,16 @@ export class SyncClient {
3530
3543
  accept: this.#acceptMask(),
3531
3544
  },
3532
3545
  ...subs.map(
3533
- (sub): RequestFrame => ({
3534
- type: 'SUBSCRIPTION',
3535
- id: sub.id,
3536
- table: sub.table,
3537
- scopes: sub.scopes,
3538
- ...(sub.params !== undefined ? { params: sub.params } : {}),
3539
- cursor: sub.cursor,
3540
- ...(sub.bootstrapState !== undefined
3541
- ? { bootstrapState: sub.bootstrapState }
3542
- : {}),
3543
- }),
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
+ }),
3544
3556
  ),
3545
3557
  ];
3546
3558
  const requestBytes = encodeMessage({
@@ -4074,12 +4086,10 @@ export class SyncClient {
4074
4086
  // version this client sends. The §7.4.2 trigger-2 convergence runs
4075
4087
  // when the APP updates (recreating the client with a new generated
4076
4088
  // schema), which fires the boot-time §7.4.1 marker check instead.
4077
- const schemaFloor: SchemaFloor = {
4089
+ const schemaFloor: SchemaFloor = definedObject({
4078
4090
  requiredSchemaVersion: header.requiredSchemaVersion,
4079
- ...(header.latestSchemaVersion !== undefined
4080
- ? { latestSchemaVersion: header.latestSchemaVersion }
4081
- : {}),
4082
- };
4091
+ latestSchemaVersion: header.latestSchemaVersion,
4092
+ });
4083
4093
  this.#setSchemaFloor(schemaFloor);
4084
4094
  return {
4085
4095
  ...summary,
@@ -4507,7 +4517,7 @@ export class SyncClient {
4507
4517
  for (const result of frame.results) {
4508
4518
  const operation = commit.operations[result.opIndex];
4509
4519
  if (result.status === 'conflict') {
4510
- const conflict: ConflictRecord = {
4520
+ const conflict: ConflictRecord = definedObject({
4511
4521
  clientCommitId: frame.clientCommitId,
4512
4522
  opIndex: result.opIndex,
4513
4523
  table: operation?.table ?? '',
@@ -4520,23 +4530,23 @@ export class SyncClient {
4520
4530
  operation?.table,
4521
4531
  result.conflictColumns,
4522
4532
  ),
4523
- ...(operation !== undefined ? { operation } : {}),
4524
- };
4533
+ operation: operation,
4534
+ });
4525
4535
  this.#conflicts.push(conflict);
4526
4536
  outcomeResults.push({ status: 'conflict', conflict });
4527
4537
  batch.conflicts();
4528
4538
  summary.conflicts.push(conflict);
4529
4539
  } else if (result.status === 'error') {
4530
4540
  const details = rejectionDetails?.get(result.opIndex);
4531
- const rejection: RejectionRecord = {
4541
+ const rejection: RejectionRecord = definedObject({
4532
4542
  clientCommitId: frame.clientCommitId,
4533
4543
  opIndex: result.opIndex,
4534
4544
  code: result.code,
4535
4545
  message: result.message,
4536
4546
  retryable: result.retryable,
4537
- ...(details !== undefined ? { details } : {}),
4538
- ...(operation !== undefined ? { operation } : {}),
4539
- };
4547
+ details: details,
4548
+ operation: operation,
4549
+ });
4540
4550
  this.#rejections.push(rejection);
4541
4551
  outcomeResults.push({ status: 'error', rejection });
4542
4552
  batch.rejections();
@@ -4574,7 +4584,7 @@ export class SyncClient {
4574
4584
  #outboxCommitExists(clientCommitId: string): boolean {
4575
4585
  return (
4576
4586
  this.#db.query(
4577
- 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1',
4587
+ 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
4578
4588
  [clientCommitId],
4579
4589
  ).length > 0
4580
4590
  );
@@ -4627,7 +4637,14 @@ export class SyncClient {
4627
4637
  (fn) =>
4628
4638
  this.#applyBatch((batch) => {
4629
4639
  this.#recordCommitChanges(batch, frame);
4630
- 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;
4631
4648
  }),
4632
4649
  );
4633
4650
  summary.commitsApplied += 1;
@@ -4660,9 +4677,7 @@ export class SyncClient {
4660
4677
  );
4661
4678
  if (mappings.length === 0) return false;
4662
4679
  const row = this.#db.query(
4663
- `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
4664
- FROM ${quoteIdent(table.name)}
4665
- WHERE ${quoteIdent(table.primaryKey)} = ?`,
4680
+ `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
4666
4681
  [rowId],
4667
4682
  )[0];
4668
4683
  if (row === undefined) return false;
@@ -4694,8 +4709,7 @@ export class SyncClient {
4694
4709
  }
4695
4710
  return (
4696
4711
  this.#db.query(
4697
- `SELECT 1 FROM ${quoteIdent(table.name)}
4698
- WHERE ${clauses.join(' AND ')} LIMIT 1`,
4712
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`,
4699
4713
  params,
4700
4714
  ).length > 0
4701
4715
  );
@@ -4733,18 +4747,19 @@ export class SyncClient {
4733
4747
  ) {
4734
4748
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4735
4749
  this.#applyBatch((batch) => {
4736
- saveSubscription(this.#db, {
4737
- id: sub.id,
4738
- table: sub.table,
4739
- scopes: sub.scopes,
4740
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4741
- cursor: sub.cursor,
4742
- ...(sub.effectiveScopes !== undefined
4743
- ? { effectiveScopes: sub.effectiveScopes }
4744
- : {}),
4745
- status: 'failed',
4746
- reasonCode: 'sync.scope_revoked',
4747
- });
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
+ );
4748
4763
  if (registered !== undefined) {
4749
4764
  batch.window(registered.baseKey, sub.table, registered.unit);
4750
4765
  }
@@ -4847,16 +4862,19 @@ export class SyncClient {
4847
4862
  wasPending && nextCursor >= 0 && bootstrapState === undefined;
4848
4863
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4849
4864
  this.#applyBatch((batch) => {
4850
- saveSubscription(this.#db, {
4851
- id: sub.id,
4852
- table: sub.table,
4853
- scopes: sub.scopes,
4854
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4855
- cursor: nextCursor,
4856
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
4857
- effectiveScopes: start.effectiveScopes,
4858
- status: 'active',
4859
- });
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
+ );
4860
4878
  if (completed && registered !== undefined) {
4861
4879
  // A zero-row bootstrap is a window-domain transition, not a fake
4862
4880
  // row/table change (SPEC §4.8 / §7.5).
@@ -4871,18 +4889,19 @@ export class SyncClient {
4871
4889
  // with cursor = -1 on the next pull. Staleness, not a purge.
4872
4890
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4873
4891
  this.#applyBatch((batch) => {
4874
- saveSubscription(this.#db, {
4875
- id: sub.id,
4876
- table: sub.table,
4877
- scopes: sub.scopes,
4878
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4879
- cursor: -1,
4880
- ...(sub.effectiveScopes !== undefined
4881
- ? { effectiveScopes: sub.effectiveScopes }
4882
- : {}),
4883
- status: 'active',
4884
- reasonCode: start.reasonCode,
4885
- });
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
+ );
4886
4905
  if (registered !== undefined) {
4887
4906
  batch.window(registered.baseKey, sub.table, registered.unit);
4888
4907
  }
@@ -4915,6 +4934,14 @@ export class SyncClient {
4915
4934
  ),
4916
4935
  );
4917
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
+ );
4918
4945
  for (const operation of commit.operations)
4919
4946
  batch.table(operation.table);
4920
4947
  batch.outcomes();
@@ -4976,18 +5003,19 @@ export class SyncClient {
4976
5003
  }
4977
5004
  }
4978
5005
  }
4979
- saveSubscription(this.#db, {
4980
- id: sub.id,
4981
- table: sub.table,
4982
- scopes: sub.scopes,
4983
- ...(sub.params !== undefined ? { params: sub.params } : {}),
4984
- cursor: nextCursor,
4985
- ...(lastEffective !== undefined
4986
- ? { effectiveScopes: lastEffective }
4987
- : {}),
4988
- status: failed ? 'failed' : 'revoked',
4989
- reasonCode: start.reasonCode,
4990
- });
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
+ );
4991
5019
  if (registered !== undefined) {
4992
5020
  batch.window(registered.baseKey, sub.table, registered.unit);
4993
5021
  }
@@ -5010,7 +5038,7 @@ export class SyncClient {
5010
5038
  quoteIdent(SYNC_VERSION_COLUMN),
5011
5039
  ];
5012
5040
  const row = this.#db.query(
5013
- `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5041
+ `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5014
5042
  [operation.rowId],
5015
5043
  )[0];
5016
5044
  if (row === undefined) return { opIndex, existed: false };
@@ -5074,7 +5102,7 @@ export class SyncClient {
5074
5102
  const table = this.#schema.tables.get(operation.table);
5075
5103
  if (table === undefined) continue;
5076
5104
  const row = this.#db.query(
5077
- `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)}=?`,
5078
5106
  [operation.rowId],
5079
5107
  )[0];
5080
5108
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
@@ -5130,6 +5158,7 @@ export class SyncClient {
5130
5158
  #applyOperationsLocally(
5131
5159
  operations: readonly OutboxOperation[],
5132
5160
  batch?: ChangeAccumulator,
5161
+ replay = false,
5133
5162
  ): void {
5134
5163
  for (const op of operations) {
5135
5164
  const table = this.#table(op.table);
@@ -5149,7 +5178,7 @@ export class SyncClient {
5149
5178
  const opValues = op.values ?? {};
5150
5179
  const full = table.columns.every((column) => column.name in opValues);
5151
5180
  const local = this.#db.query(
5152
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5181
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5153
5182
  [op.rowId],
5154
5183
  )[0];
5155
5184
  if (local === undefined && !full) {
@@ -5180,6 +5209,21 @@ export class SyncClient {
5180
5209
  local === undefined
5181
5210
  ? OPTIMISTIC_VERSION
5182
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;
5183
5227
  upsertLocalRow(this.#db, table, values, version, false);
5184
5228
  }
5185
5229
  }
@@ -5196,7 +5240,7 @@ export class SyncClient {
5196
5240
  restoreFailedBases(this.#db, this.#schema);
5197
5241
  restoreFailedBases(this.#db, this.#schema, true);
5198
5242
  for (const commit of pending)
5199
- this.#applyOperationsLocally(commit.operations, batch);
5243
+ this.#applyOperationsLocally(commit.operations, batch, true);
5200
5244
  });
5201
5245
  });
5202
5246
  }
@@ -5236,6 +5280,21 @@ export class SyncClient {
5236
5280
  }
5237
5281
  }
5238
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
+
5239
5298
  function requireRowId(value: unknown): string {
5240
5299
  if (typeof value !== 'string' || !value.length)
5241
5300
  throw invalidRequest('a mutation requires a non-empty string primary key');