Skip to content

Commit 57a7d0d

Browse files
goatrenterguyclaude
andcommitted
fix(db): clean up optimistic state when server returns a different key
When an onInsert/onUpdate/onDelete handler syncs server data back to the collection (via writeInsert, writeUpdate, writeUpsert, writeDelete, or refetch), the optimistic state under the original client key is now correctly removed if the server returns a different key. Previously, pendingOptimisticDirectUpserts unconditionally re-added client keys on every recomputeOptimisticState call, causing permanent duplication when the server used a different key, and stale $synced: false when using the same key. The fix introduces two tracking sets: - directTransactionsWithSyncWrites: prevents re-adding keys for transactions whose handlers committed immediate sync writes - processedCompletedTransactions: add-once guard ensuring keys are only added on first transaction completion, never re-added after commitPendingTransactions clears them Also adds orphan cleanup in commitPendingTransactions for the refetch- with-different-key case, and fixes ordering in scheduleTransactionCleanup to process queued syncs before deleting the transaction. Closes #1442 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc9c019 commit 57a7d0d

3 files changed

Lines changed: 411 additions & 7 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
fix: clean up optimistic state when server returns a different key than the optimistic insert
6+
7+
When an `onInsert`/`onUpdate`/`onDelete` handler syncs server data back to the collection (via `writeInsert`, `writeUpdate`, `writeUpsert`, `writeDelete`, or `refetch`), the optimistic state under the original client key is now correctly removed if the server returns a different key. Previously, the client-key item persisted forever alongside the server-key item, causing duplication and stale `$synced: false` state.

packages/db/src/collection/state.ts

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export class CollectionStateManager<
8383
public pendingOptimisticDeletes = new Set<TKey>()
8484
public pendingOptimisticDirectUpserts = new Set<TKey>()
8585
public pendingOptimisticDirectDeletes = new Set<TKey>()
86+
public directTransactionsWithSyncWrites = new Set<string>()
87+
public processedCompletedTransactions = new Set<string>()
8688

8789
/**
8890
* Tracks the origin of confirmed changes for each row.
@@ -483,6 +485,17 @@ export class CollectionStateManager<
483485
const isDirectTransaction =
484486
transaction.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true
485487
if (transaction.state === `completed`) {
488+
// Add-once guard: only process direct transactions for pendingOptimisticDirect*
489+
// on their first completion. This prevents re-adding keys that were already
490+
// cleaned up by commitPendingTransactions (e.g., after writeInsert with same key).
491+
const isFirstProcessing =
492+
isDirectTransaction &&
493+
!this.processedCompletedTransactions.has(transaction.id) &&
494+
!this.directTransactionsWithSyncWrites.has(transaction.id)
495+
if (isDirectTransaction && !this.processedCompletedTransactions.has(transaction.id)) {
496+
this.processedCompletedTransactions.add(transaction.id)
497+
}
498+
486499
for (const mutation of transaction.mutations) {
487500
if (!this.isThisCollection(mutation.collection)) {
488501
continue
@@ -499,21 +512,26 @@ export class CollectionStateManager<
499512
mutation.modified as TOutput,
500513
)
501514
this.pendingOptimisticDeletes.delete(mutation.key)
502-
if (isDirectTransaction) {
515+
if (isFirstProcessing) {
516+
// First time seeing this direct transaction — seed the pending direct set
503517
this.pendingOptimisticDirectUpserts.add(mutation.key)
504518
this.pendingOptimisticDirectDeletes.delete(mutation.key)
505-
} else {
519+
} else if (!isDirectTransaction) {
520+
// Non-direct completed transaction — clear pending direct state for this key
506521
this.pendingOptimisticDirectUpserts.delete(mutation.key)
507522
this.pendingOptimisticDirectDeletes.delete(mutation.key)
508523
}
524+
// else: direct but already processed or had sync writes — leave
525+
// pendingOptimisticDirect* unchanged to avoid clobbering entries
526+
// from other concurrent direct transactions
509527
break
510528
case `delete`:
511529
this.pendingOptimisticUpserts.delete(mutation.key)
512530
this.pendingOptimisticDeletes.add(mutation.key)
513-
if (isDirectTransaction) {
531+
if (isFirstProcessing) {
514532
this.pendingOptimisticDirectUpserts.delete(mutation.key)
515533
this.pendingOptimisticDirectDeletes.add(mutation.key)
516-
} else {
534+
} else if (!isDirectTransaction) {
517535
this.pendingOptimisticDirectUpserts.delete(mutation.key)
518536
this.pendingOptimisticDirectDeletes.delete(mutation.key)
519537
}
@@ -854,6 +872,22 @@ export class CollectionStateManager<
854872
// non-immediate transactions would be applied later and could overwrite newer state.
855873
// Processing all committed transactions together preserves causal ordering.
856874
if (!hasPersistingTransaction || hasTruncateSync || hasImmediateSync) {
875+
// Track which direct transactions had sync writes committed during their handler.
876+
// When an immediate sync (from writeInsert/writeUpdate/writeDelete) is processed,
877+
// mark all persisting direct transactions. This prevents recomputeOptimisticState
878+
// from adding their mutation keys to pendingOptimisticDirectUpserts (via the
879+
// isFirstProcessing guard), since the sync already confirmed the data.
880+
if (hasImmediateSync) {
881+
for (const tx of this.transactions.values()) {
882+
if (
883+
tx.state === `persisting` &&
884+
tx.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true
885+
) {
886+
this.directTransactionsWithSyncWrites.add(tx.id)
887+
}
888+
}
889+
}
890+
857891
// Set flag to prevent redundant optimistic state recalculations
858892
this.isCommittingSyncTransactions = true
859893

@@ -1288,6 +1322,58 @@ export class CollectionStateManager<
12881322
this.recentlySyncedKeys.clear()
12891323
})
12901324

1325+
// Clean up orphaned pendingOptimisticDirect entries after sync processing.
1326+
// A key is orphaned when it belongs to a completed direct transaction
1327+
// (handler has run) but the key is not in syncedData (sync confirmed it under
1328+
// a different key). This handles the refetch-with-different-key case where the
1329+
// handler called refetch() and the server returned the item under a new key.
1330+
if (committedSyncedTransactions.length > 0) {
1331+
for (const key of [...this.pendingOptimisticDirectUpserts]) {
1332+
if (!this.syncedData.has(key)) {
1333+
// Check if this key belongs to a completed direct transaction
1334+
let belongsToCompletedDirect = false
1335+
for (const tx of this.transactions.values()) {
1336+
if (
1337+
tx.state === `completed` &&
1338+
tx.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true
1339+
) {
1340+
for (const m of tx.mutations) {
1341+
if (this.isThisCollection(m.collection) && m.key === key) {
1342+
belongsToCompletedDirect = true
1343+
break
1344+
}
1345+
}
1346+
}
1347+
if (belongsToCompletedDirect) break
1348+
}
1349+
if (belongsToCompletedDirect) {
1350+
this.pendingOptimisticDirectUpserts.delete(key)
1351+
}
1352+
}
1353+
}
1354+
for (const key of [...this.pendingOptimisticDirectDeletes]) {
1355+
if (this.syncedData.has(key)) continue
1356+
let belongsToCompletedDirect = false
1357+
for (const tx of this.transactions.values()) {
1358+
if (
1359+
tx.state === `completed` &&
1360+
tx.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true
1361+
) {
1362+
for (const m of tx.mutations) {
1363+
if (this.isThisCollection(m.collection) && m.key === key) {
1364+
belongsToCompletedDirect = true
1365+
break
1366+
}
1367+
}
1368+
}
1369+
if (belongsToCompletedDirect) break
1370+
}
1371+
if (belongsToCompletedDirect) {
1372+
this.pendingOptimisticDirectDeletes.delete(key)
1373+
}
1374+
}
1375+
}
1376+
12911377
// Mark that we've received the first commit (for tracking purposes)
12921378
if (!this.hasReceivedFirstCommit) {
12931379
this.hasReceivedFirstCommit = true
@@ -1308,13 +1394,28 @@ export class CollectionStateManager<
13081394
// Schedule cleanup when the transaction completes
13091395
transaction.isPersisted.promise
13101396
.then(() => {
1311-
// Transaction completed successfully, remove it immediately
1397+
// Process any queued sync transactions BEFORE deleting the transaction.
1398+
// This ordering is critical: the orphan cleanup inside commitPendingTransactions
1399+
// needs to find this transaction (state=completed) in this.transactions to
1400+
// identify orphaned keys from the refetch-with-different-key case.
1401+
if (this.pendingSyncedTransactions.length > 0) {
1402+
this.commitPendingTransactions()
1403+
}
1404+
1405+
// Now remove the transaction and its tracking entries
13121406
this.transactions.delete(transaction.id)
1407+
this.directTransactionsWithSyncWrites.delete(transaction.id)
1408+
this.processedCompletedTransactions.delete(transaction.id)
1409+
1410+
// Recompute to pick up any orphan cleanup done by commitPendingTransactions
1411+
// during touchCollection (before this cleanup ran) or above.
1412+
this.recomputeOptimisticState(false)
13131413
})
13141414
.catch(() => {
1315-
// Transaction failed, but we want to keep failed transactions for reference
1316-
// so don't remove it.
1415+
// Transaction failed — clean up tracking state.
13171416
// Rollback already triggers state recomputation via touchCollection().
1417+
this.directTransactionsWithSyncWrites.delete(transaction.id)
1418+
this.processedCompletedTransactions.delete(transaction.id)
13181419
})
13191420
}
13201421

@@ -1380,6 +1481,8 @@ export class CollectionStateManager<
13801481
this.pendingOptimisticDeletes.clear()
13811482
this.pendingOptimisticDirectUpserts.clear()
13821483
this.pendingOptimisticDirectDeletes.clear()
1484+
this.directTransactionsWithSyncWrites.clear()
1485+
this.processedCompletedTransactions.clear()
13831486
this.clearOriginTrackingState()
13841487
this.isLocalOnly = false
13851488
this.size = 0

0 commit comments

Comments
 (0)