@@ -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