@syncular/client 0.30.10 → 0.30.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { RETAINED_ROWS } from './failed-overlay';
1
2
  import {
2
3
  dropFailedRows,
3
4
  failedOverlayCommits,
4
5
  retainFailedRows,
5
6
  restoreFailedBases,
7
+ uniqueConflicts,
6
8
  } from './failed-overlay';
7
9
  import {
8
10
  ProgressEmitter,
@@ -50,6 +52,7 @@ import {
50
52
  applySqliteSegment,
51
53
  deleteLocalRow,
52
54
  deleteScopedRows,
55
+ localScopePredicate,
53
56
  evictScopedRows,
54
57
  yieldToHost,
55
58
  upsertLocalRow,
@@ -195,6 +198,7 @@ import {
195
198
  type CompiledClientSchema,
196
199
  type CompiledClientTable,
197
200
  compileClientSchema,
201
+ matchesLocalScopes,
198
202
  dropAndRecreateSyncedTables,
199
203
  ensureLocalBookkeepingSchema,
200
204
  ensureLocalSyncedSchema,
@@ -219,6 +223,7 @@ import {
219
223
  getSubscription,
220
224
  loadSubscriptions,
221
225
  pruneUnknownSubscriptions,
226
+ saveSubscriptionScopeSchema,
222
227
  resetSubscriptionsForBump,
223
228
  type SubscriptionRecord,
224
229
  saveSubscription,
@@ -835,7 +840,7 @@ export class SyncClient {
835
840
  constructor(config: SyncClientConfig) {
836
841
  if (config.securityPreflight === true && config.encryption !== undefined) {
837
842
  throw invalidRequest(
838
- 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
843
+ 'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
839
844
  );
840
845
  }
841
846
  this.#retainFailedCommits = config.retainFailedCommits === true;
@@ -921,7 +926,7 @@ export class SyncClient {
921
926
  this.#lease = undefined;
922
927
  throw new ClientSyncError(
923
928
  'client.identity_mismatch',
924
- `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`,
929
+ `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
925
930
  );
926
931
  }
927
932
  this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
@@ -947,7 +952,7 @@ export class SyncClient {
947
952
  const subscriptions = pruneUnknownSubscriptions(
948
953
  this.#db,
949
954
  loadSubscriptions(this.#db),
950
- new Set(this.#schema.tables.keys()),
955
+ this.#schema,
951
956
  );
952
957
  this.#started = true;
953
958
  // A persisted active subscription needs one catch-up round on every open:
@@ -959,6 +964,9 @@ export class SyncClient {
959
964
  const startupWork =
960
965
  this.#schemaFloor === undefined &&
961
966
  (countOutbox(this.#db) > 0 ||
967
+ this.#db.query(
968
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
969
+ ).length > 0 ||
962
970
  loadPendingEvictions(this.#db).length > 0 ||
963
971
  subscriptions.some((sub) => sub.status === 'active'));
964
972
  if (startupWork && this.#securityLifecycle === 'active') {
@@ -1000,6 +1008,7 @@ export class SyncClient {
1000
1008
  String(this.#schema.version),
1001
1009
  );
1002
1010
  setLocalSchemaDescriptor(this.#db, this.#schema);
1011
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
1003
1012
  });
1004
1013
  return;
1005
1014
  }
@@ -1011,6 +1020,7 @@ export class SyncClient {
1011
1020
  this.#db.transaction(() => {
1012
1021
  ensureLocalSyncedSchema(this.#db, this.#schema);
1013
1022
  setLocalSchemaDescriptor(this.#db, this.#schema);
1023
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
1014
1024
  });
1015
1025
  return;
1016
1026
  }
@@ -1050,6 +1060,13 @@ export class SyncClient {
1050
1060
  ),
1051
1061
  );
1052
1062
  }
1063
+ pruneUnknownSubscriptions(
1064
+ this.#db,
1065
+ loadSubscriptions(this.#db),
1066
+ this.#schema,
1067
+ true,
1068
+ );
1069
+ this.#discardAcknowledgedRows();
1053
1070
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1054
1071
  resetSubscriptionsForBump(this.#db);
1055
1072
  setMeta(
@@ -1058,6 +1075,7 @@ export class SyncClient {
1058
1075
  String(this.#schema.version),
1059
1076
  );
1060
1077
  setLocalSchemaDescriptor(this.#db, this.#schema);
1078
+ saveSubscriptionScopeSchema(this.#db, this.#schema);
1061
1079
  });
1062
1080
  // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
1063
1081
  for (const table of this.#schema.tables.values()) batch.table(table.name);
@@ -1149,6 +1167,7 @@ export class SyncClient {
1149
1167
  this.#sweepPreviousVersionContainer();
1150
1168
  this.#applyBatch((batch) => {
1151
1169
  this.#db.transaction(() => {
1170
+ this.#discardAcknowledgedRows();
1152
1171
  dropAndRecreateSyncedTables(this.#db, this.#schema);
1153
1172
  resetSubscriptionsForBump(this.#db);
1154
1173
  setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
@@ -1403,13 +1422,16 @@ export class SyncClient {
1403
1422
  previous.tables.join('\0') !== tables.join('\0')
1404
1423
  ) {
1405
1424
  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
- });
1425
+ this.#queryFailures.set(
1426
+ owner.id,
1427
+ definedObject({
1428
+ id: owner.id,
1429
+ tables,
1430
+ code,
1431
+ sqliteCode: sqliteCode,
1432
+ atMs: this.#now(),
1433
+ }),
1434
+ );
1413
1435
  for (const id of this.#queryFailures.keys()) {
1414
1436
  if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
1415
1437
  break;
@@ -1798,22 +1820,18 @@ export class SyncClient {
1798
1820
  }
1799
1821
  return a.id.localeCompare(b.id);
1800
1822
  });
1801
- return {
1823
+ return definedObject({
1802
1824
  version: CLIENT_DIAGNOSTICS_VERSION,
1803
1825
  capturedAtMs,
1804
- host: {
1826
+ host: definedObject({
1805
1827
  kind: 'direct',
1806
1828
  role: 'single',
1807
1829
  connectivity,
1808
1830
  realtime: this.#realtimeState,
1809
1831
  realtimePolicy: this.#realtimePolicy,
1810
- ...(this.#realtimeReasonCode !== undefined
1811
- ? { realtimeReasonCode: this.#realtimeReasonCode }
1812
- : {}),
1813
- ...(this.#realtimeRetryDelayMs !== undefined
1814
- ? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
1815
- : {}),
1816
- },
1832
+ realtimeReasonCode: this.#realtimeReasonCode,
1833
+ realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
1834
+ }),
1817
1835
  securityLifecycle: this.#securityLifecycle,
1818
1836
  schema: {
1819
1837
  currentVersion: this.#config.schema.version,
@@ -1837,25 +1855,21 @@ export class SyncClient {
1837
1855
  ),
1838
1856
  subscriptionsTruncated:
1839
1857
  allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
1840
- ...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
1841
- ...(this.#lastChange !== undefined
1842
- ? { lastChange: this.#lastChange }
1843
- : {}),
1858
+ lastRound: this.#lastRound,
1859
+ lastChange: this.#lastChange,
1844
1860
  storage: this.#diagnosticStorage(),
1845
1861
  queryFailures: [...this.#queryFailures.values()],
1846
- };
1862
+ });
1847
1863
  }
1848
1864
 
1849
1865
  #diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
1850
1866
  const lease = this.#leaseState;
1851
1867
  if (lease?.errorCode !== undefined) {
1852
- return {
1868
+ return definedObject({
1853
1869
  state: 'stopped',
1854
1870
  errorCode: this.#diagnosticCode(lease.errorCode),
1855
- ...(lease.expiresAtMs !== undefined
1856
- ? { expiresAtMs: lease.expiresAtMs }
1857
- : {}),
1858
- };
1871
+ expiresAtMs: lease.expiresAtMs,
1872
+ });
1859
1873
  }
1860
1874
  if (lease?.expiresAtMs === undefined) return { state: 'none' };
1861
1875
  return {
@@ -1874,18 +1888,16 @@ export class SyncClient {
1874
1888
  );
1875
1889
  const outboxBytes = Number(
1876
1890
  this.#db.query(
1877
- 'SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox',
1891
+ 'SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox',
1878
1892
  )[0]?.bytes ?? 0,
1879
1893
  );
1880
1894
  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`,
1895
+ 'SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes',
1884
1896
  )[0];
1885
1897
  const blobBytes = this.#hasBlobs
1886
1898
  ? Number(
1887
1899
  this.#db.query(
1888
- 'SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs',
1900
+ 'SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs',
1889
1901
  )[0]?.bytes ?? 0,
1890
1902
  )
1891
1903
  : 0;
@@ -1977,7 +1989,9 @@ export class SyncClient {
1977
1989
  result = fn(batch);
1978
1990
  if (
1979
1991
  this.#db
1980
- .query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
1992
+ .query(
1993
+ `SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`,
1994
+ )
1981
1995
  .some((row) => batch.hasTable(String(row.tbl)))
1982
1996
  )
1983
1997
  batch.outcomes();
@@ -2323,7 +2337,7 @@ export class SyncClient {
2323
2337
  return this.#applyBatch(
2324
2338
  (batch) => {
2325
2339
  restoreFailedBases(this.#db, this.#schema);
2326
- this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
2340
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
2327
2341
  input.clientCommitId,
2328
2342
  ]);
2329
2343
  const resolved = persistCommitOutcomeResolution(
@@ -2471,19 +2485,22 @@ export class SyncClient {
2471
2485
  if (!sameIntent) {
2472
2486
  throw new ClientSyncError(
2473
2487
  'client.subscription_intent_mismatch',
2474
- 'subscribe: the subscription id is already registered for a different table, scopes, or params',
2488
+ 'subscription id already names different table, scopes, or params',
2475
2489
  );
2476
2490
  }
2477
2491
  return;
2478
2492
  }
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
- });
2493
+ saveSubscription(
2494
+ this.#db,
2495
+ definedObject({
2496
+ id: input.id,
2497
+ table: input.table,
2498
+ scopes: input.scopes,
2499
+ params: input.params,
2500
+ cursor: -1,
2501
+ status: 'active',
2502
+ }),
2503
+ );
2487
2504
  this.#setSyncNeeded(true);
2488
2505
  this.#emitSyncIntent({ kind: 'interactive' });
2489
2506
  this.#emitDiagnostics();
@@ -2526,7 +2543,7 @@ export class SyncClient {
2526
2543
  const table = this.#table(base.table);
2527
2544
  if (!table.scopeColumnByVariable.has(base.variable)) {
2528
2545
  throw invalidRequest(
2529
- `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`,
2546
+ `${base.table}: unknown scope variable ${base.variable} (§4.8)`,
2530
2547
  );
2531
2548
  }
2532
2549
  // Serialize the whole window edit: it spans an `await deriveSubId` between
@@ -2549,14 +2566,17 @@ export class SyncClient {
2549
2566
  // Re-entry cancels any deferred eviction for this sub id.
2550
2567
  deletePendingEviction(this.#db, subId);
2551
2568
  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
- });
2569
+ saveSubscription(
2570
+ this.#db,
2571
+ definedObject({
2572
+ id: subId,
2573
+ table: base.table,
2574
+ scopes: unitScopes(base, unit),
2575
+ params: base.params,
2576
+ cursor: -1,
2577
+ status: 'active',
2578
+ }),
2579
+ );
2560
2580
  });
2561
2581
  this.#needsPull = true;
2562
2582
  batch.window(baseKey, base.table, unit);
@@ -2732,14 +2752,12 @@ export class SyncClient {
2732
2752
  const operations: OutboxOperation[] = mutations.map((mutation) => {
2733
2753
  const table = this.#table(mutation.table);
2734
2754
  if (mutation.op === 'delete') {
2735
- return {
2755
+ return definedObject({
2736
2756
  table: mutation.table,
2737
2757
  rowId: mutation.rowId,
2738
2758
  op: 'delete',
2739
- ...(mutation.baseVersion !== undefined
2740
- ? { baseVersion: mutation.baseVersion }
2741
- : {}),
2742
- };
2759
+ baseVersion: mutation.baseVersion,
2760
+ });
2743
2761
  }
2744
2762
  const json: Record<string, JsonRowValue> = {};
2745
2763
  if (mutation.op === 'patch') {
@@ -2752,7 +2770,7 @@ export class SyncClient {
2752
2770
  const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2753
2771
  const pkValue = requireRowId(normalized.get(pkColumn.name));
2754
2772
  const local = this.#db.query(
2755
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2773
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
2756
2774
  [pkValue],
2757
2775
  )[0];
2758
2776
  if (!local)
@@ -2813,15 +2831,13 @@ export class SyncClient {
2813
2831
  });
2814
2832
  }
2815
2833
  const pkValue = requireRowId(json[table.primaryKey]);
2816
- return {
2834
+ return definedObject({
2817
2835
  table: mutation.table,
2818
2836
  rowId: pkValue,
2819
2837
  op: 'upsert',
2820
- ...(mutation.baseVersion !== undefined
2821
- ? { baseVersion: mutation.baseVersion }
2822
- : {}),
2838
+ baseVersion: mutation.baseVersion,
2823
2839
  values: json,
2824
- };
2840
+ });
2825
2841
  });
2826
2842
  this.#applyBatch((batch) => {
2827
2843
  this.#db.transaction(() => {
@@ -2847,10 +2863,7 @@ export class SyncClient {
2847
2863
  continue;
2848
2864
  }
2849
2865
  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
- )`,
2866
+ 'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
2854
2867
  [clientCommitId, blobId, blobId],
2855
2868
  );
2856
2869
  }
@@ -2956,23 +2969,19 @@ export class SyncClient {
2956
2969
  // mirror row is touched and unconditionally.
2957
2970
  this.#dropPreviousVersion();
2958
2971
 
2959
- const rejectionCount = this.#rejections.length;
2972
+ const priorRejections = [...this.#rejections];
2973
+ const priorConflicts = [...this.#conflicts];
2960
2974
  try {
2961
2975
  return this.#applyBatch((batch) => {
2962
2976
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2963
2977
  restoreFailedBases(this.#db, this.#schema);
2964
- const droppedRetained = dropFailedRows(this.#db, (table, values) =>
2978
+ this.#dropRetainedRows(batch, (table, values) =>
2965
2979
  (targetsByTable.get(table) ?? []).some((target) =>
2966
2980
  target.selectors.every((selector) =>
2967
2981
  selector.values.includes(String(values[selector.column])),
2968
2982
  ),
2969
2983
  ),
2970
2984
  );
2971
- for (const commit of droppedRetained) {
2972
- for (const operation of commit.operations)
2973
- batch.table(operation.table);
2974
- batch.outcomes();
2975
- }
2976
2985
  // Doomed detection runs to fixpoint, matching the Rust core's rule:
2977
2986
  // a commit is doomed when any of its ops touches a base-matching
2978
2987
  // row. Base values only become visible here as rollbacks restore
@@ -3038,7 +3047,7 @@ export class SyncClient {
3038
3047
  for (let offset = 0; offset < values.length; offset += 400) {
3039
3048
  const chunk = values.slice(offset, offset + 400);
3040
3049
  this.#db.exec(
3041
- `DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN (${chunk.map(() => '?').join(', ')})`,
3050
+ `DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`,
3042
3051
  chunk,
3043
3052
  );
3044
3053
  }
@@ -3086,7 +3095,8 @@ export class SyncClient {
3086
3095
  } catch (error) {
3087
3096
  // SQLite rolls back through #applyBatch; mirror that rollback for the
3088
3097
  // in-memory rejection cache before surfacing the storage failure.
3089
- this.#rejections.length = rejectionCount;
3098
+ this.#rejections = priorRejections;
3099
+ this.#conflicts = priorConflicts;
3090
3100
  throw error;
3091
3101
  }
3092
3102
  }
@@ -3131,6 +3141,7 @@ export class SyncClient {
3131
3141
  this.#upgrading = true;
3132
3142
  this.#needsPull = true;
3133
3143
  batch.status();
3144
+ this.#discardAcknowledgedRows();
3134
3145
  dropAndRecreateSyncedTables(this.#db, this.#schema);
3135
3146
  resetSubscriptionsForBump(this.#db);
3136
3147
  for (const table of this.#schema.tables.values()) {
@@ -3192,7 +3203,7 @@ export class SyncClient {
3192
3203
  params.push(...selector.values);
3193
3204
  }
3194
3205
  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 ')}`,
3206
+ `SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`,
3196
3207
  params,
3197
3208
  )) {
3198
3209
  if (typeof row.id === 'string') ids.add(row.id);
@@ -3251,7 +3262,7 @@ export class SyncClient {
3251
3262
  // Pin before the first encryption await: mutations can append while a
3252
3263
  // round is encoding, and belong to the next request.
3253
3264
  const bounds = this.#db.query(
3254
- 'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
3265
+ 'SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox',
3255
3266
  )[0]!;
3256
3267
  const pendingCount = bounds.count as number;
3257
3268
  const throughSeq = (bounds.last_seq as number | null) ?? 0;
@@ -3288,7 +3299,7 @@ export class SyncClient {
3288
3299
  const selector = this.#encryption?.keyIdColumns?.[table.name];
3289
3300
  if (selector === undefined) return undefined;
3290
3301
  const row = this.#db.query(
3291
- `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
3302
+ `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
3292
3303
  [rowId],
3293
3304
  )[0];
3294
3305
  const value = row?.value;
@@ -3401,16 +3412,16 @@ export class SyncClient {
3401
3412
  const error = classifySqliteFailure(caught).error;
3402
3413
  const completedAtMs = this.#now();
3403
3414
  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
- });
3415
+ this.#progress.update(
3416
+ definedObject({
3417
+ state: 'failed',
3418
+ errorCode:
3419
+ typeof code === 'string'
3420
+ ? this.#diagnosticCode(code)
3421
+ : 'client.unknown_failure',
3422
+ retryDelayMs: this.#roundRetryDelayMs,
3423
+ }),
3424
+ );
3414
3425
  this.#lastRound = {
3415
3426
  status: 'failed',
3416
3427
  startedAtMs,
@@ -3515,12 +3526,12 @@ export class SyncClient {
3515
3526
  );
3516
3527
  const limits = this.#config.limits;
3517
3528
  const frames: RequestFrame[] = [
3518
- {
3529
+ definedObject({
3519
3530
  type: 'REQ_HEADER',
3520
3531
  clientId: this.#clientId,
3521
3532
  schemaVersion: this.#schema.version,
3522
- ...(logEpoch !== undefined ? { logEpoch } : {}),
3523
- },
3533
+ logEpoch: logEpoch,
3534
+ }),
3524
3535
  ...pushFrames,
3525
3536
  {
3526
3537
  type: 'PULL_HEADER',
@@ -3530,17 +3541,16 @@ export class SyncClient {
3530
3541
  accept: this.#acceptMask(),
3531
3542
  },
3532
3543
  ...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
- }),
3544
+ (sub): RequestFrame =>
3545
+ definedObject({
3546
+ type: 'SUBSCRIPTION',
3547
+ id: sub.id,
3548
+ table: sub.table,
3549
+ scopes: sub.scopes,
3550
+ params: sub.params,
3551
+ cursor: sub.cursor,
3552
+ bootstrapState: sub.bootstrapState,
3553
+ }),
3544
3554
  ),
3545
3555
  ];
3546
3556
  const requestBytes = encodeMessage({
@@ -4074,12 +4084,10 @@ export class SyncClient {
4074
4084
  // version this client sends. The §7.4.2 trigger-2 convergence runs
4075
4085
  // when the APP updates (recreating the client with a new generated
4076
4086
  // schema), which fires the boot-time §7.4.1 marker check instead.
4077
- const schemaFloor: SchemaFloor = {
4087
+ const schemaFloor: SchemaFloor = definedObject({
4078
4088
  requiredSchemaVersion: header.requiredSchemaVersion,
4079
- ...(header.latestSchemaVersion !== undefined
4080
- ? { latestSchemaVersion: header.latestSchemaVersion }
4081
- : {}),
4082
- };
4089
+ latestSchemaVersion: header.latestSchemaVersion,
4090
+ });
4083
4091
  this.#setSchemaFloor(schemaFloor);
4084
4092
  return {
4085
4093
  ...summary,
@@ -4141,6 +4149,7 @@ export class SyncClient {
4141
4149
  index += 1;
4142
4150
  }
4143
4151
  }
4152
+ const previousNeedsPull = this.#needsPull;
4144
4153
  const conflictCount = this.#conflicts.length;
4145
4154
  const rejectionCount = this.#rejections.length;
4146
4155
  let outboxCount = countOutbox(this.#db);
@@ -4173,11 +4182,12 @@ export class SyncClient {
4173
4182
  },
4174
4183
  () => this.#statusSnapshot(outboxCount),
4175
4184
  (cause) => {
4185
+ this.#needsPull = previousNeedsPull;
4176
4186
  this.#conflicts.length = conflictCount;
4177
4187
  this.#rejections.length = rejectionCount;
4178
4188
  const error = new ClientSyncError(
4179
4189
  'client.outcome_persistence_failed',
4180
- 'local commit outcome could not be persisted',
4190
+ 'outcome persistence failed',
4181
4191
  );
4182
4192
  error.cause = cause;
4183
4193
  throw error;
@@ -4475,6 +4485,18 @@ export class SyncClient {
4475
4485
  if (frame.status === 'applied' || frame.status === 'cached') {
4476
4486
  // §6.3: applied and cached both drain the outbox — cached means
4477
4487
  // "already applied, you may have missed the ack".
4488
+ if (frame.commitSeq === undefined)
4489
+ throw new ClientSyncError(
4490
+ 'client.invalid_host_response',
4491
+ 'ACK lacks a commit sequence',
4492
+ );
4493
+ retainFailedRows(
4494
+ this.#db,
4495
+ commit,
4496
+ listOutboxBeforeImages(this.#db, commit.clientCommitId),
4497
+ frame.commitSeq,
4498
+ );
4499
+ this.#setSyncNeeded(true);
4478
4500
  recordCommitOutcome(this.#db, {
4479
4501
  clientCommitId: frame.clientCommitId,
4480
4502
  status: frame.status,
@@ -4507,7 +4529,7 @@ export class SyncClient {
4507
4529
  for (const result of frame.results) {
4508
4530
  const operation = commit.operations[result.opIndex];
4509
4531
  if (result.status === 'conflict') {
4510
- const conflict: ConflictRecord = {
4532
+ const conflict: ConflictRecord = definedObject({
4511
4533
  clientCommitId: frame.clientCommitId,
4512
4534
  opIndex: result.opIndex,
4513
4535
  table: operation?.table ?? '',
@@ -4520,23 +4542,23 @@ export class SyncClient {
4520
4542
  operation?.table,
4521
4543
  result.conflictColumns,
4522
4544
  ),
4523
- ...(operation !== undefined ? { operation } : {}),
4524
- };
4545
+ operation: operation,
4546
+ });
4525
4547
  this.#conflicts.push(conflict);
4526
4548
  outcomeResults.push({ status: 'conflict', conflict });
4527
4549
  batch.conflicts();
4528
4550
  summary.conflicts.push(conflict);
4529
4551
  } else if (result.status === 'error') {
4530
4552
  const details = rejectionDetails?.get(result.opIndex);
4531
- const rejection: RejectionRecord = {
4553
+ const rejection: RejectionRecord = definedObject({
4532
4554
  clientCommitId: frame.clientCommitId,
4533
4555
  opIndex: result.opIndex,
4534
4556
  code: result.code,
4535
4557
  message: result.message,
4536
4558
  retryable: result.retryable,
4537
- ...(details !== undefined ? { details } : {}),
4538
- ...(operation !== undefined ? { operation } : {}),
4539
- };
4559
+ details: details,
4560
+ operation: operation,
4561
+ });
4540
4562
  this.#rejections.push(rejection);
4541
4563
  outcomeResults.push({ status: 'error', rejection });
4542
4564
  batch.rejections();
@@ -4574,7 +4596,7 @@ export class SyncClient {
4574
4596
  #outboxCommitExists(clientCommitId: string): boolean {
4575
4597
  return (
4576
4598
  this.#db.query(
4577
- 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1',
4599
+ 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
4578
4600
  [clientCommitId],
4579
4601
  ).length > 0
4580
4602
  );
@@ -4627,7 +4649,9 @@ export class SyncClient {
4627
4649
  (fn) =>
4628
4650
  this.#applyBatch((batch) => {
4629
4651
  this.#recordCommitChanges(batch, frame);
4630
- return fn();
4652
+ const result = fn();
4653
+ this.#replayOutbox(result);
4654
+ return result;
4631
4655
  }),
4632
4656
  );
4633
4657
  summary.commitsApplied += 1;
@@ -4660,9 +4684,7 @@ export class SyncClient {
4660
4684
  );
4661
4685
  if (mappings.length === 0) return false;
4662
4686
  const row = this.#db.query(
4663
- `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
4664
- FROM ${quoteIdent(table.name)}
4665
- WHERE ${quoteIdent(table.primaryKey)} = ?`,
4687
+ `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
4666
4688
  [rowId],
4667
4689
  )[0];
4668
4690
  if (row === undefined) return false;
@@ -4680,23 +4702,12 @@ export class SyncClient {
4680
4702
 
4681
4703
  /** Whether a fresh-bootstrap clear would remove at least one local row. */
4682
4704
  #scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
4683
- const entries = Object.entries(effective);
4684
- if (entries.length === 0) return false;
4685
- const clauses: string[] = [];
4686
- const params: string[] = [];
4687
- for (const [variable, values] of entries) {
4688
- const column = table.scopeColumnByVariable.get(variable);
4689
- if (column === undefined || values.length === 0) return false;
4690
- clauses.push(
4691
- `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
4692
- );
4693
- params.push(...values);
4694
- }
4705
+ const scope = localScopePredicate(table, effective);
4695
4706
  return (
4707
+ scope !== undefined &&
4696
4708
  this.#db.query(
4697
- `SELECT 1 FROM ${quoteIdent(table.name)}
4698
- WHERE ${clauses.join(' AND ')} LIMIT 1`,
4699
- params,
4709
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`,
4710
+ scope.params,
4700
4711
  ).length > 0
4701
4712
  );
4702
4713
  }
@@ -4733,18 +4744,19 @@ export class SyncClient {
4733
4744
  ) {
4734
4745
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4735
4746
  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
- });
4747
+ saveSubscription(
4748
+ this.#db,
4749
+ definedObject({
4750
+ id: sub.id,
4751
+ table: sub.table,
4752
+ scopes: sub.scopes,
4753
+ params: sub.params,
4754
+ cursor: sub.cursor,
4755
+ effectiveScopes: sub.effectiveScopes,
4756
+ status: 'failed',
4757
+ reasonCode: 'sync.scope_revoked',
4758
+ }),
4759
+ );
4748
4760
  if (registered !== undefined) {
4749
4761
  batch.window(registered.baseKey, sub.table, registered.unit);
4750
4762
  }
@@ -4802,7 +4814,7 @@ export class SyncClient {
4802
4814
  // §5.4: MUST NOT start a fetch at/past expiry.
4803
4815
  throw new ClientSyncError(
4804
4816
  'sync.segment_expired',
4805
- `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`,
4817
+ `segment download grant expired; pull again (§5.4)`,
4806
4818
  true,
4807
4819
  );
4808
4820
  }
@@ -4847,16 +4859,47 @@ export class SyncClient {
4847
4859
  wasPending && nextCursor >= 0 && bootstrapState === undefined;
4848
4860
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4849
4861
  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
- });
4862
+ saveSubscription(
4863
+ this.#db,
4864
+ definedObject({
4865
+ id: sub.id,
4866
+ table: sub.table,
4867
+ scopes: sub.scopes,
4868
+ params: sub.params,
4869
+ cursor: nextCursor,
4870
+ bootstrapState: bootstrapState,
4871
+ effectiveScopes: start.effectiveScopes,
4872
+ status: 'active',
4873
+ }),
4874
+ );
4875
+ if (completed) {
4876
+ const table = this.#table(sub.table);
4877
+ const effective = start.effectiveScopes;
4878
+ const covered = this.#db
4879
+ .query(
4880
+ `SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`,
4881
+ [sub.table, nextCursor],
4882
+ )
4883
+ .filter((row) =>
4884
+ [row.intent, row.base].some((json) => {
4885
+ if (json === null) return false;
4886
+ const values: Record<string, JsonRowValue> = JSON.parse(
4887
+ String(json),
4888
+ );
4889
+ return matchesLocalScopes(table, effective, values);
4890
+ }),
4891
+ );
4892
+ if (covered.length) {
4893
+ restoreFailedBases(this.#db, this.#schema);
4894
+ for (const row of covered)
4895
+ this.#db.exec(
4896
+ `DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
4897
+ [sub.table, String(row.id), nextCursor],
4898
+ );
4899
+ batch.table(sub.table);
4900
+ this.#replayOutbox();
4901
+ }
4902
+ }
4860
4903
  if (completed && registered !== undefined) {
4861
4904
  // A zero-row bootstrap is a window-domain transition, not a fake
4862
4905
  // row/table change (SPEC §4.8 / §7.5).
@@ -4871,18 +4914,19 @@ export class SyncClient {
4871
4914
  // with cursor = -1 on the next pull. Staleness, not a purge.
4872
4915
  const registered = getWindowUnitBySubId(this.#db, sub.id);
4873
4916
  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
- });
4917
+ saveSubscription(
4918
+ this.#db,
4919
+ definedObject({
4920
+ id: sub.id,
4921
+ table: sub.table,
4922
+ scopes: sub.scopes,
4923
+ params: sub.params,
4924
+ cursor: -1,
4925
+ effectiveScopes: sub.effectiveScopes,
4926
+ status: 'active',
4927
+ reasonCode: start.reasonCode,
4928
+ }),
4929
+ );
4886
4930
  if (registered !== undefined) {
4887
4931
  batch.window(registered.baseKey, sub.table, registered.unit);
4888
4932
  }
@@ -4904,21 +4948,12 @@ export class SyncClient {
4904
4948
  ) {
4905
4949
  try {
4906
4950
  restoreFailedBases(this.#db, this.#schema);
4907
- const droppedRetained = dropFailedRows(
4908
- this.#db,
4951
+ this.#dropRetainedRows(
4952
+ batch,
4909
4953
  (name, values) =>
4910
4954
  name === table.name &&
4911
- Object.entries(lastEffective).every(([variable, allowed]) =>
4912
- allowed.includes(
4913
- String(values[table.scopeColumnByVariable.get(variable)!]),
4914
- ),
4915
- ),
4955
+ matchesLocalScopes(table, lastEffective, values),
4916
4956
  );
4917
- for (const commit of droppedRetained) {
4918
- for (const operation of commit.operations)
4919
- batch.table(operation.table);
4920
- batch.outcomes();
4921
- }
4922
4957
  deleteScopedRows(this.#db, table, lastEffective);
4923
4958
  batch.scopeMap(table, lastEffective);
4924
4959
  const pendingById = new Map(
@@ -4976,18 +5011,19 @@ export class SyncClient {
4976
5011
  }
4977
5012
  }
4978
5013
  }
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
- });
5014
+ saveSubscription(
5015
+ this.#db,
5016
+ definedObject({
5017
+ id: sub.id,
5018
+ table: sub.table,
5019
+ scopes: sub.scopes,
5020
+ params: sub.params,
5021
+ cursor: nextCursor,
5022
+ effectiveScopes: lastEffective,
5023
+ status: failed ? 'failed' : 'revoked',
5024
+ reasonCode: start.reasonCode,
5025
+ }),
5026
+ );
4991
5027
  if (registered !== undefined) {
4992
5028
  batch.window(registered.baseKey, sub.table, registered.unit);
4993
5029
  }
@@ -5010,7 +5046,7 @@ export class SyncClient {
5010
5046
  quoteIdent(SYNC_VERSION_COLUMN),
5011
5047
  ];
5012
5048
  const row = this.#db.query(
5013
- `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5049
+ `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5014
5050
  [operation.rowId],
5015
5051
  )[0];
5016
5052
  if (row === undefined) return { opIndex, existed: false };
@@ -5074,7 +5110,7 @@ export class SyncClient {
5074
5110
  const table = this.#schema.tables.get(operation.table);
5075
5111
  if (table === undefined) continue;
5076
5112
  const row = this.#db.query(
5077
- `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5113
+ `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5078
5114
  [operation.rowId],
5079
5115
  )[0];
5080
5116
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
@@ -5130,6 +5166,7 @@ export class SyncClient {
5130
5166
  #applyOperationsLocally(
5131
5167
  operations: readonly OutboxOperation[],
5132
5168
  batch?: ChangeAccumulator,
5169
+ replay = false,
5133
5170
  ): void {
5134
5171
  for (const op of operations) {
5135
5172
  const table = this.#table(op.table);
@@ -5149,7 +5186,7 @@ export class SyncClient {
5149
5186
  const opValues = op.values ?? {};
5150
5187
  const full = table.columns.every((column) => column.name in opValues);
5151
5188
  const local = this.#db.query(
5152
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
5189
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5153
5190
  [op.rowId],
5154
5191
  )[0];
5155
5192
  if (local === undefined && !full) {
@@ -5180,23 +5217,72 @@ export class SyncClient {
5180
5217
  local === undefined
5181
5218
  ? OPTIMISTIC_VERSION
5182
5219
  : (local[SYNC_VERSION_COLUMN] as number);
5220
+ if (
5221
+ replay &&
5222
+ table.indexes.some((index) => index.unique) &&
5223
+ uniqueConflicts(
5224
+ this.#db,
5225
+ table,
5226
+ Object.fromEntries(
5227
+ table.columns.map((column, index) => [
5228
+ column.name,
5229
+ values[index] ?? null,
5230
+ ]),
5231
+ ),
5232
+ ).length
5233
+ )
5234
+ continue;
5183
5235
  upsertLocalRow(this.#db, table, values, version, false);
5184
5236
  }
5185
5237
  }
5186
5238
 
5187
5239
  /** Re-apply every pending outbox commit on top of server state (§7.1). */
5188
- #replayOutbox(): void {
5240
+ #discardAcknowledgedRows(): void {
5241
+ this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
5242
+ }
5243
+
5244
+ #dropRetainedRows(
5245
+ batch: ChangeAccumulator,
5246
+ matches: (
5247
+ table: string,
5248
+ values: Readonly<Record<string, JsonRowValue>>,
5249
+ ) => boolean,
5250
+ ): void {
5251
+ for (const commit of dropFailedRows(this.#db, matches)) {
5252
+ batch.conflicts();
5253
+ batch.rejections();
5254
+ batch.outcomes();
5255
+ this.#conflicts = this.#conflicts.filter(
5256
+ (record) => record.clientCommitId !== commit.clientCommitId,
5257
+ );
5258
+ this.#rejections = this.#rejections.filter(
5259
+ (record) => record.clientCommitId !== commit.clientCommitId,
5260
+ );
5261
+ for (const op of commit.operations) batch.table(op.table);
5262
+ }
5263
+ }
5264
+
5265
+ #replayOutbox(rowKeys?: readonly string[]): void {
5266
+ const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
5189
5267
  const pending = [
5190
- ...failedOverlayCommits(this.#db),
5191
- ...listOutbox(this.#db),
5268
+ ...failedOverlayCommits(this.#db, false, rowKeys),
5269
+ ...listOutbox(this.#db, rowKeys),
5192
5270
  ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
5193
- if (pending.length === 0) return;
5271
+ if (pending.length === 0 && acknowledged.length === 0) return;
5194
5272
  this.#applyBatch((batch) => {
5195
5273
  this.#db.transaction(() => {
5196
- restoreFailedBases(this.#db, this.#schema);
5197
- restoreFailedBases(this.#db, this.#schema, true);
5198
- for (const commit of pending)
5199
- this.#applyOperationsLocally(commit.operations, batch);
5274
+ restoreFailedBases(this.#db, this.#schema, false, rowKeys);
5275
+ restoreFailedBases(this.#db, this.#schema, true, rowKeys);
5276
+ for (const commit of [...acknowledged, ...pending])
5277
+ this.#applyOperationsLocally(
5278
+ rowKeys === undefined
5279
+ ? commit.operations
5280
+ : commit.operations.filter((op) =>
5281
+ rowKeys.includes(JSON.stringify([op.table, op.rowId])),
5282
+ ),
5283
+ batch,
5284
+ true,
5285
+ );
5200
5286
  });
5201
5287
  });
5202
5288
  }
@@ -5211,17 +5297,14 @@ export class SyncClient {
5211
5297
  #table(name: string): CompiledClientTable {
5212
5298
  const table = this.#schema.tables.get(name);
5213
5299
  if (table === undefined) {
5214
- throw new ClientSyncError(
5215
- 'sync.unknown_table',
5216
- `unknown local table ${JSON.stringify(name)}`,
5217
- );
5300
+ throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
5218
5301
  }
5219
5302
  return table;
5220
5303
  }
5221
5304
 
5222
5305
  #requireStarted(): void {
5223
5306
  if (!this.#started) {
5224
- throw invalidRequest('SyncClient.start() has not completed');
5307
+ throw invalidRequest('call SyncClient.start() first');
5225
5308
  }
5226
5309
  }
5227
5310
 
@@ -5230,12 +5313,27 @@ export class SyncClient {
5230
5313
  if (this.#securityLifecycle === 'preflight') {
5231
5314
  throw new ClientSyncError(
5232
5315
  SECURITY_PREFLIGHT_REQUIRED_CODE,
5233
- 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
5316
+ 'security preflight must complete before accessing protected data; call activateSecurity',
5234
5317
  );
5235
5318
  }
5236
5319
  }
5237
5320
  }
5238
5321
 
5322
+ type DefinedObject<T> = {
5323
+ [K in keyof T as undefined extends T[K] ? never : K]: T[K];
5324
+ } & {
5325
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<
5326
+ T[K],
5327
+ undefined
5328
+ >;
5329
+ };
5330
+ function definedObject<const T extends object>(record: T): DefinedObject<T> {
5331
+ for (const key of Object.keys(record) as (keyof T)[]) {
5332
+ if (record[key] === undefined) delete record[key];
5333
+ }
5334
+ return record as DefinedObject<T>;
5335
+ }
5336
+
5239
5337
  function requireRowId(value: unknown): string {
5240
5338
  if (typeof value !== 'string' || !value.length)
5241
5339
  throw invalidRequest('a mutation requires a non-empty string primary key');