@tanstack/db-sqlite-persistence-core 0.4.5 → 0.4.8

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.
@@ -49,6 +49,7 @@ export type TxCommitted = {
49
49
  latestRowVersion: number;
50
50
  } & ({
51
51
  requiresFullReload: true;
52
+ reconciliationReload?: true;
52
53
  } | {
53
54
  requiresFullReload: false;
54
55
  changedRows: Array<{
@@ -149,6 +150,41 @@ export type ApplyCommittedTxResponse = {
149
150
  sourceCode?: string | number;
150
151
  path?: string | ReadonlyArray<string | number>;
151
152
  };
153
+ /** The durable version read before a source transaction enters coordination. */
154
+ export type CommittedTxAnchor = {
155
+ latestRowVersion: number;
156
+ resetEpoch: number;
157
+ };
158
+ export type ReconcileCommittedTxResponse = {
159
+ type: `rpc:reconcileCommittedTx:res`;
160
+ rpcId: string;
161
+ ok: true;
162
+ alreadyApplied: boolean;
163
+ committed: {
164
+ term: number;
165
+ seq: number;
166
+ rowVersion: number;
167
+ };
168
+ } | {
169
+ type: `rpc:reconcileCommittedTx:res`;
170
+ rpcId: string;
171
+ ok: false;
172
+ code: `NOT_LEADER` | `UNDETERMINED` | `PERSISTENCE_ERROR`;
173
+ error: string;
174
+ sourceCode?: string | number;
175
+ path?: string | ReadonlyArray<string | number>;
176
+ };
177
+ export type ReconciledCommittedTx = {
178
+ kind: `already-applied` | `applied-now`;
179
+ committed: {
180
+ term: number;
181
+ seq: number;
182
+ rowVersion: number;
183
+ };
184
+ latestRowVersion: number;
185
+ } | {
186
+ kind: `unknown`;
187
+ };
152
188
  export type PullSinceRequest = {
153
189
  type: `rpc:pullSince:req`;
154
190
  rpcId: string;
@@ -299,6 +335,8 @@ export interface PersistenceAdapter {
299
335
  resetEpoch: number;
300
336
  }>;
301
337
  applyCommittedTx: (collectionId: string, tx: PersistedTx) => Promise<void>;
338
+ /** Atomically certify or apply one source transaction after a lost response. */
339
+ reconcileCommittedTx?: (collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor) => Promise<ReconciledCommittedTx>;
302
340
  loadCollectionMetadata?: (collectionId: string) => Promise<Array<{
303
341
  key: string;
304
342
  value: unknown;
@@ -311,6 +349,12 @@ export interface PersistenceAdapter {
311
349
  latestSeq: number;
312
350
  latestRowVersion: number;
313
351
  }>;
352
+ /** Reserve a new durable election term before announcing Browser leadership. */
353
+ reserveLeadershipTerm?: (collectionId: string, observedTerm: number) => Promise<{
354
+ latestTerm: number;
355
+ latestSeq: number;
356
+ latestRowVersion: number;
357
+ }>;
314
358
  /**
315
359
  * Runs one complete logical hydrate as a non-preemptible scheduler unit.
316
360
  * The callback must use the supplied unscheduled adapter for all nested
@@ -365,6 +409,8 @@ export interface PersistedCollectionCoordinator {
365
409
  * loan after the call; ordinary work uses the registered collection adapter.
366
410
  */
367
411
  requestApplyCommittedTx: (collectionId: string, tx: PersistedTx, scopedAdapter?: HydrationPersistenceAdapter) => Promise<ApplyCommittedTxResponse>;
412
+ /** Available only when the elected adapter can certify the durable outcome. */
413
+ reconcileCommittedTx?: (collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor, scopedAdapter?: HydrationPersistenceAdapter) => Promise<ReconcileCommittedTxResponse>;
368
414
  /** The scoped adapter is leader-local and is never serialized to a follower. */
369
415
  pullSince?: (collectionId: string, fromRowVersion: number, scopedAdapter?: HydrationPersistenceAdapter) => Promise<PullSinceResponse>;
370
416
  }
@@ -1,5 +1,5 @@
1
- import { safeRandomUUID, withCollectionConfigFactory, whenSyncAccepted, SyncTransactionAbortedError, getLoadSubsetDemandKey, compileSingleRowExpression, toBooleanPredicate, SYNC_PERSISTENCE_VERSION, SYNC_PERSISTENCE_PROTOCOL, PERSISTED_READINESS, NoPendingSyncTransactionCommitError, NoPendingSyncTransactionWriteError, withAcceptedReceipt } from "@tanstack/db";
2
- import { InvalidPersistedCollectionConfigError, DuplicateRemoteSubsetOwnerError, toPersistedCollectionDurabilityError, InvalidPersistedStorageKeyEncodingError, InvalidPersistedStorageKeyError, InvalidSyncConfigError, InvalidPersistedCollectionCoordinatorError, PersistedCollectionDurabilityError, InvalidPersistenceAdapterError } from "./errors.js";
1
+ import { safeRandomUUID, withCollectionConfigFactory, whenSyncAccepted, SyncTransactionAbortedError, getLoadSubsetDemandKey, equalPersistedSnapshotValues, compileSingleRowExpression, toBooleanPredicate, SYNC_PERSISTENCE_VERSION, SYNC_PERSISTENCE_PROTOCOL, PERSISTED_READINESS, NoPendingSyncTransactionCommitError, NoPendingSyncTransactionWriteError, withAcceptedReceipt } from "@tanstack/db";
2
+ import { InvalidPersistedCollectionConfigError, DuplicateRemoteSubsetOwnerError, toPersistedCollectionDurabilityError, InvalidPersistedStorageKeyEncodingError, InvalidPersistedStorageKeyError, InvalidSyncConfigError, InvalidPersistedCollectionCoordinatorError, PersistedCollectionDurabilityError, IndeterminateCommitError, InvalidPersistenceAdapterError } from "./errors.js";
3
3
  import { serializeSQLiteBigInt, serializeSQLiteTemporal } from "./sqlite-value.js";
4
4
  import { toTransportedLoadSubsetOptions, toProcessLocalLoadSubsetOptions, validateRemoteSubsetOptions } from "./remote-subset-wire.js";
5
5
  import { reportRemoteSubsetOwnerError, unloadRemoteSubsetOwner } from "./remote-subset-owner.js";
@@ -391,7 +391,7 @@ function isTxCommittedPayload(payload) {
391
391
  return false;
392
392
  }
393
393
  if (payload.requiresFullReload) {
394
- return true;
394
+ return payload.reconciliationReload === void 0 || payload.reconciliationReload === true;
395
395
  }
396
396
  return Array.isArray(payload.changedRows) && Array.isArray(payload.deletedKeys) && (payload.rowMetadataMutations === void 0 || Array.isArray(payload.rowMetadataMutations)) && (payload.collectionMetadataMutations === void 0 || Array.isArray(payload.collectionMetadataMutations));
397
397
  }
@@ -457,6 +457,7 @@ class PersistedCollectionRuntime {
457
457
  this.latestTerm = 0;
458
458
  this.latestSeq = 0;
459
459
  this.latestRowVersion = 0;
460
+ this.publicRowVersion = 0;
460
461
  this.localTerm = 1;
461
462
  this.localSeq = 0;
462
463
  this.localRowVersion = 0;
@@ -1134,6 +1135,8 @@ class PersistedCollectionRuntime {
1134
1135
  this.lifecycleGeneration++;
1135
1136
  this.persistedReadiness?.set({ status: `loading` });
1136
1137
  this.startupSettled = false;
1138
+ this.publicRowVersion = 0;
1139
+ this.resetTermFence = void 0;
1137
1140
  this.hydratedDemands.clear();
1138
1141
  this.startupMetadataPromise = null;
1139
1142
  this.startPromise = null;
@@ -1178,6 +1181,7 @@ class PersistedCollectionRuntime {
1178
1181
  }
1179
1182
  async hydrateSubsetUnsafe(options, config, adapter) {
1180
1183
  let rowsLoaded = false;
1184
+ let boundRowVersion;
1181
1185
  let replayFailure;
1182
1186
  const resetSequence = this.resetSequence;
1183
1187
  this.hydrationSequence++;
@@ -1197,6 +1201,7 @@ class PersistedCollectionRuntime {
1197
1201
  if (config.lifecycleGeneration !== this.lifecycleGeneration) return;
1198
1202
  if (resetSequence !== this.resetSequence) return;
1199
1203
  this.bindResumeSnapshotEvidence(snapshot);
1204
+ boundRowVersion = snapshot.latestRowVersion;
1200
1205
  } else {
1201
1206
  rows = await this.loadSubsetRowsUnsafe(options, adapter);
1202
1207
  }
@@ -1209,6 +1214,12 @@ class PersistedCollectionRuntime {
1209
1214
  }
1210
1215
  const applied = this.applyRowsToCollection(rows);
1211
1216
  await whenSyncAccepted(applied);
1217
+ if (boundRowVersion !== void 0) {
1218
+ this.publicRowVersion = Math.max(
1219
+ this.publicRowVersion,
1220
+ boundRowVersion
1221
+ );
1222
+ }
1212
1223
  }
1213
1224
  } finally {
1214
1225
  if (this.activeHydrationContext === hydrationContext && this.hydratingGeneration === config.lifecycleGeneration) {
@@ -1372,7 +1383,9 @@ class PersistedCollectionRuntime {
1372
1383
  }
1373
1384
  isExpectedResumeGeneration(generation) {
1374
1385
  const expected = this.persistedResumeGeneration;
1375
- return expected !== void 0 && expected.latestTerm === generation.latestTerm && expected.latestSeq === generation.latestSeq && expected.latestRowVersion === generation.latestRowVersion && expected.resetEpoch === generation.resetEpoch;
1386
+ return expected !== void 0 && expected.latestRowVersion === generation.latestRowVersion && expected.resetEpoch === generation.resetEpoch && (expected.latestTerm === generation.latestTerm && expected.latestSeq === generation.latestSeq || // A no-write election reserves a new term at sequence zero. It does
1387
+ // not change the persisted rows or the source resume cursor.
1388
+ generation.latestTerm > expected.latestTerm && generation.latestSeq === 0);
1376
1389
  }
1377
1390
  bindResumeSnapshotEvidence(snapshot) {
1378
1391
  const generation = this.getResumeSnapshotGeneration(snapshot);
@@ -1414,6 +1427,27 @@ class PersistedCollectionRuntime {
1414
1427
  return this.syncControls.commit?.() ?? true;
1415
1428
  });
1416
1429
  }
1430
+ matchesCollectionSnapshot(rows, collectionMetadata) {
1431
+ if (!this.collection || !this.syncControls.metadata) return false;
1432
+ const base = this.collection.base;
1433
+ if (base.size !== rows.size) return false;
1434
+ for (const [key, row] of rows) {
1435
+ if (!base.has(key) || !equalPersistedSnapshotValues(base.get(key), row.value) || !equalPersistedSnapshotValues(
1436
+ this.syncControls.metadata.row.get(key),
1437
+ row.metadata
1438
+ )) {
1439
+ return false;
1440
+ }
1441
+ }
1442
+ const currentMetadata = this.syncControls.metadata.collection.list();
1443
+ if (currentMetadata.length !== collectionMetadata.length) return false;
1444
+ const expectedMetadata = new Map(
1445
+ collectionMetadata.map(({ key, value }) => [key, value])
1446
+ );
1447
+ return currentMetadata.every(
1448
+ ({ key, value }) => expectedMetadata.has(key) && equalPersistedSnapshotValues(expectedMetadata.get(key), value)
1449
+ );
1450
+ }
1417
1451
  async flushQueuedHydrationTransactionsUnsafe(adapter) {
1418
1452
  let operationFailure;
1419
1453
  while (this.queuedHydrationTransactions.length > 0) {
@@ -1498,16 +1532,55 @@ class PersistedCollectionRuntime {
1498
1532
  if (!transaction.truncate && transaction.operations.length === 0 && transaction.rowMetadataWrites.size === 0 && transaction.collectionMetadataWrites.size === 0) {
1499
1533
  return;
1500
1534
  }
1535
+ const anchor = this.persistedResumeGeneration && {
1536
+ latestRowVersion: this.latestRowVersion,
1537
+ resetEpoch: this.persistedResumeGeneration.resetEpoch
1538
+ };
1501
1539
  const streamPosition = this.nextLocalStreamPosition();
1502
1540
  const tx = this.createPersistedTxFromOperations(transaction, streamPosition);
1503
- const response = await this.persistence.coordinator.requestApplyCommittedTx(
1504
- this.collectionId,
1505
- tx,
1506
- // A hydration-scoped adapter is already inside the shared scheduler.
1507
- // Ordinary source commits pass no scoped adapter so the coordinator
1508
- // enters that scheduler before taking the database-wide writer lock.
1509
- scopedAdapter
1510
- );
1541
+ let response;
1542
+ try {
1543
+ response = await this.persistence.coordinator.requestApplyCommittedTx(
1544
+ this.collectionId,
1545
+ tx,
1546
+ // A hydration-scoped adapter is already inside the shared scheduler.
1547
+ // Ordinary source commits enter it before taking the writer lock.
1548
+ scopedAdapter
1549
+ );
1550
+ } catch (error) {
1551
+ if (!(error instanceof IndeterminateCommitError) || !anchor || !this.persistence.coordinator.reconcileCommittedTx) {
1552
+ throw error;
1553
+ }
1554
+ let reconciled;
1555
+ try {
1556
+ reconciled = await this.persistence.coordinator.reconcileCommittedTx(
1557
+ this.collectionId,
1558
+ tx,
1559
+ anchor,
1560
+ scopedAdapter
1561
+ );
1562
+ } catch (reconcileError) {
1563
+ if (reconcileError instanceof PersistedCollectionDurabilityError) {
1564
+ throw reconcileError;
1565
+ }
1566
+ throw error;
1567
+ }
1568
+ if (!reconciled.ok) {
1569
+ if (reconciled.code === `PERSISTENCE_ERROR`) {
1570
+ throw new PersistedCollectionDurabilityError(
1571
+ `Failed to reconcile collection "${this.collectionId}": ${reconciled.error}`,
1572
+ {
1573
+ cause: reconciled,
1574
+ code: reconciled.sourceCode ?? reconciled.code,
1575
+ path: reconciled.path
1576
+ }
1577
+ );
1578
+ }
1579
+ throw error;
1580
+ }
1581
+ this.recordDurableSourceCommit(transaction, reconciled.committed);
1582
+ return;
1583
+ }
1511
1584
  if (!response.ok) {
1512
1585
  if (response.code === `PERSISTENCE_ERROR`) {
1513
1586
  const error = new PersistedCollectionDurabilityError(
@@ -1524,18 +1597,24 @@ class PersistedCollectionRuntime {
1524
1597
  `failed to apply external sync transaction through coordinator: ${response.error}`
1525
1598
  );
1526
1599
  }
1600
+ this.recordDurableSourceCommit(transaction, {
1601
+ term: response.term,
1602
+ seq: response.seq,
1603
+ rowVersion: response.latestRowVersion
1604
+ });
1605
+ }
1606
+ recordDurableSourceCommit(transaction, position) {
1527
1607
  if (transaction.lifecycleGeneration !== this.lifecycleGeneration) return;
1528
- this.observeStreamPosition(
1529
- response.term,
1530
- response.seq,
1531
- response.latestRowVersion
1532
- );
1608
+ this.observeStreamPosition(position.term, position.seq, position.rowVersion);
1609
+ if (position.rowVersion === this.publicRowVersion + 1) {
1610
+ this.publicRowVersion = position.rowVersion;
1611
+ }
1533
1612
  if (transaction.expectedResumeGenerationOwner === this.resumeGenerationOwner && this.persistedResumeGeneration !== void 0) {
1534
1613
  this.persistedResumeGeneration = {
1535
1614
  ...this.persistedResumeGeneration,
1536
- latestTerm: response.term,
1537
- latestSeq: response.seq,
1538
- latestRowVersion: response.latestRowVersion
1615
+ latestTerm: position.term,
1616
+ latestSeq: position.seq,
1617
+ latestRowVersion: position.rowVersion
1539
1618
  };
1540
1619
  }
1541
1620
  }
@@ -1898,9 +1977,17 @@ class PersistedCollectionRuntime {
1898
1977
  }
1899
1978
  if (isCollectionResetPayload(payload)) {
1900
1979
  void this.applyMutex.run(async () => {
1901
- await this.runInHydrationScope(
1902
- (adapter) => this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
1903
- );
1980
+ await this.runInHydrationScope(async (adapter) => {
1981
+ await this.truncateAndReloadUnsafe(adapter, lifecycleGeneration);
1982
+ await this.rebaseDurableStreamFromSnapshotUnsafe(
1983
+ adapter,
1984
+ lifecycleGeneration
1985
+ );
1986
+ if (lifecycleGeneration === this.lifecycleGeneration) {
1987
+ this.publicRowVersion = 0;
1988
+ this.resetTermFence = this.latestTerm;
1989
+ }
1990
+ });
1904
1991
  if (lifecycleGeneration === this.lifecycleGeneration) {
1905
1992
  await this.flushQueuedTxCommittedUnsafe();
1906
1993
  }
@@ -1923,21 +2010,42 @@ class PersistedCollectionRuntime {
1923
2010
  }
1924
2011
  async processCommittedTxUnsafe(txCommitted, lifecycleGeneration = this.lifecycleGeneration) {
1925
2012
  if (lifecycleGeneration !== this.lifecycleGeneration) return;
1926
- if (txCommitted.term < this.latestTerm) {
1927
- return;
2013
+ if (this.resetTermFence !== void 0) {
2014
+ if (txCommitted.term <= this.resetTermFence) {
2015
+ await this.runInHydrationScope(
2016
+ (adapter) => this.reloadActiveSubsetsUnsafe(adapter)
2017
+ );
2018
+ return;
2019
+ }
1928
2020
  }
1929
- if (txCommitted.term === this.latestTerm && txCommitted.seq <= this.latestSeq) {
2021
+ if (txCommitted.requiresFullReload && txCommitted.reconciliationReload) {
2022
+ await this.invalidateFromCommittedTxUnsafe(
2023
+ txCommitted,
2024
+ this.persistence.adapter
2025
+ );
2026
+ if (lifecycleGeneration === this.lifecycleGeneration) {
2027
+ this.observeStreamPosition(
2028
+ txCommitted.term,
2029
+ txCommitted.seq,
2030
+ txCommitted.latestRowVersion
2031
+ );
2032
+ this.publicRowVersion = Math.max(
2033
+ this.publicRowVersion,
2034
+ txCommitted.latestRowVersion
2035
+ );
2036
+ await this.flushQueuedTxCommittedUnsafe();
2037
+ }
1930
2038
  return;
1931
2039
  }
2040
+ if (txCommitted.latestRowVersion <= this.publicRowVersion) return;
2041
+ const observedButUnpublished = txCommitted.term < this.latestTerm || txCommitted.term === this.latestTerm && txCommitted.seq <= this.latestSeq;
1932
2042
  const hasGapInCurrentTerm = txCommitted.term === this.latestTerm && txCommitted.seq > this.latestSeq + 1;
1933
2043
  const hasGapAcrossTerms = txCommitted.term > this.latestTerm && txCommitted.seq > 1;
1934
- const hasGap = hasGapInCurrentTerm || hasGapAcrossTerms;
2044
+ const hasGap = observedButUnpublished || txCommitted.latestRowVersion > this.publicRowVersion + 1 || hasGapInCurrentTerm || hasGapAcrossTerms;
1935
2045
  if (hasGap) {
1936
2046
  await this.recoverFromSeqGapUnsafe(lifecycleGeneration);
1937
2047
  if (lifecycleGeneration !== this.lifecycleGeneration) return;
1938
- if (txCommitted.term < this.latestTerm || txCommitted.term === this.latestTerm && txCommitted.seq <= this.latestSeq) {
1939
- return;
1940
- }
2048
+ if (txCommitted.latestRowVersion <= this.publicRowVersion) return;
1941
2049
  }
1942
2050
  this.observeStreamPosition(
1943
2051
  txCommitted.term,
@@ -1949,9 +2057,27 @@ class PersistedCollectionRuntime {
1949
2057
  this.persistence.adapter
1950
2058
  );
1951
2059
  if (lifecycleGeneration === this.lifecycleGeneration) {
2060
+ this.publicRowVersion = Math.max(
2061
+ this.publicRowVersion,
2062
+ txCommitted.latestRowVersion
2063
+ );
1952
2064
  await this.flushQueuedTxCommittedUnsafe();
1953
2065
  }
1954
2066
  }
2067
+ async rebaseDurableStreamFromSnapshotUnsafe(adapter, lifecycleGeneration) {
2068
+ if (lifecycleGeneration !== this.lifecycleGeneration) return;
2069
+ const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
2070
+ includeRows: false
2071
+ });
2072
+ if (lifecycleGeneration !== this.lifecycleGeneration) return;
2073
+ this.latestRowVersion = snapshot.latestRowVersion;
2074
+ this.localRowVersion = snapshot.latestRowVersion;
2075
+ this.observeStreamPosition(
2076
+ snapshot.latestTerm,
2077
+ snapshot.latestSeq,
2078
+ snapshot.latestRowVersion
2079
+ );
2080
+ }
1955
2081
  async recoverFromSeqGapUnsafe(lifecycleGeneration) {
1956
2082
  if (lifecycleGeneration !== this.lifecycleGeneration) return;
1957
2083
  if (this.persistence.coordinator.pullSince && this.latestRowVersion >= 0) {
@@ -1959,7 +2085,7 @@ class PersistedCollectionRuntime {
1959
2085
  try {
1960
2086
  pullResponse = await this.persistence.coordinator.pullSince(
1961
2087
  this.collectionId,
1962
- this.latestRowVersion
2088
+ this.publicRowVersion
1963
2089
  );
1964
2090
  } catch (error) {
1965
2091
  console.warn(`Failed pullSince recovery attempt:`, error);
@@ -1976,6 +2102,10 @@ class PersistedCollectionRuntime {
1976
2102
  await this.runInHydrationScope(
1977
2103
  (adapter) => this.reloadActiveSubsetsUnsafe(adapter)
1978
2104
  );
2105
+ this.publicRowVersion = Math.max(
2106
+ this.publicRowVersion,
2107
+ pullResponse.latestRowVersion
2108
+ );
1979
2109
  return;
1980
2110
  }
1981
2111
  const deltas = pullResponse.deltas;
@@ -1983,6 +2113,10 @@ class PersistedCollectionRuntime {
1983
2113
  await this.runInHydrationScope(
1984
2114
  (adapter) => this.reloadActiveSubsetsUnsafe(adapter)
1985
2115
  );
2116
+ this.publicRowVersion = Math.max(
2117
+ this.publicRowVersion,
2118
+ pullResponse.latestRowVersion
2119
+ );
1986
2120
  return;
1987
2121
  }
1988
2122
  await this.runInHydrationScope(async (adapter) => {
@@ -2006,13 +2140,21 @@ class PersistedCollectionRuntime {
2006
2140
  if (lifecycleGeneration !== this.lifecycleGeneration) return;
2007
2141
  }
2008
2142
  });
2143
+ this.publicRowVersion = Math.max(
2144
+ this.publicRowVersion,
2145
+ pullResponse.latestRowVersion
2146
+ );
2009
2147
  return;
2010
2148
  }
2011
2149
  }
2012
2150
  }
2013
2151
  if (lifecycleGeneration !== this.lifecycleGeneration) return;
2014
2152
  await this.runInHydrationScope(
2015
- (adapter) => this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
2153
+ (adapter) => this.reloadActiveSubsetsUnsafe(adapter)
2154
+ );
2155
+ this.publicRowVersion = Math.max(
2156
+ this.publicRowVersion,
2157
+ this.latestRowVersion
2016
2158
  );
2017
2159
  if (this.mode === `sync-present`) {
2018
2160
  for (const options of this.activeSubsets.values()) {
@@ -2036,12 +2178,12 @@ class PersistedCollectionRuntime {
2036
2178
  await this.reloadActiveSubsetsUnsafe(adapter);
2037
2179
  }
2038
2180
  async invalidateFromCommittedTxUnsafe(txCommitted, adapter) {
2039
- const reloadActiveSubsets = () => this.runInHydrationScope(
2040
- (scopedAdapter) => this.reloadActiveSubsetsUnsafe(scopedAdapter),
2181
+ const reloadActiveSubsets = (skipUnchangedReplace = false) => this.runInHydrationScope(
2182
+ (scopedAdapter) => this.reloadActiveSubsetsUnsafe(scopedAdapter, skipUnchangedReplace),
2041
2183
  adapter
2042
2184
  );
2043
2185
  if (txCommitted.requiresFullReload) {
2044
- await reloadActiveSubsets();
2186
+ await reloadActiveSubsets(txCommitted.reconciliationReload === true);
2045
2187
  return;
2046
2188
  }
2047
2189
  const changedKeyCount = txCommitted.changedRows.length + txCommitted.deletedKeys.length;
@@ -2105,7 +2247,7 @@ class PersistedCollectionRuntime {
2105
2247
  });
2106
2248
  await whenSyncAccepted(applied);
2107
2249
  }
2108
- async reloadActiveSubsetsUnsafe(adapter) {
2250
+ async reloadActiveSubsetsUnsafe(adapter, skipUnchangedReplace = false) {
2109
2251
  const lifecycleGeneration = this.lifecycleGeneration;
2110
2252
  const truncateGeneration = this.sourceTruncateGeneration;
2111
2253
  const activeSubsetOptions = this.activeSubsets.size > 0 ? Array.from(this.activeSubsets.values()) : [{}];
@@ -2131,15 +2273,29 @@ class PersistedCollectionRuntime {
2131
2273
  for (const key of mergedRows.keys()) {
2132
2274
  hydrationContext.suppliedRowKeys.add(key);
2133
2275
  }
2134
- const applied = this.replaceCollectionSnapshot(
2135
- Array.from(mergedRows.entries()).map(([key, row]) => ({
2136
- key,
2137
- value: row.value,
2138
- metadata: row.metadata
2139
- })),
2140
- collectionMetadata
2141
- );
2142
- await whenSyncAccepted(applied);
2276
+ if (skipUnchangedReplace && this.matchesCollectionSnapshot(mergedRows, collectionMetadata)) {
2277
+ const orphanKeys = this.collection?._syncedRowMetadataKeysOutsideBase() ?? [];
2278
+ if (orphanKeys.length > 0) {
2279
+ const applied = this.withInternalApply(() => {
2280
+ this.syncControls.begin?.();
2281
+ for (const key of orphanKeys) {
2282
+ this.syncControls.metadata?.row.delete(key);
2283
+ }
2284
+ return this.syncControls.commit?.() ?? true;
2285
+ });
2286
+ await whenSyncAccepted(applied);
2287
+ }
2288
+ } else {
2289
+ const applied = this.replaceCollectionSnapshot(
2290
+ Array.from(mergedRows.entries()).map(([key, row]) => ({
2291
+ key,
2292
+ value: row.value,
2293
+ metadata: row.metadata
2294
+ })),
2295
+ collectionMetadata
2296
+ );
2297
+ await whenSyncAccepted(applied);
2298
+ }
2143
2299
  } finally {
2144
2300
  if (this.activeHydrationContext === hydrationContext && this.hydratingGeneration === lifecycleGeneration) {
2145
2301
  this.hydratingGeneration = null;