Drift audit 2026-06-02 — 26 findings shipped #75
+18
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,4 +37,21 @@ interface CachedLikeDao {
|
||||
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId",
|
||||
)
|
||||
suspend fun delete(userId: String, entityType: String, entityId: String)
|
||||
|
||||
@Query("DELETE FROM cached_likes WHERE userId = :userId")
|
||||
suspend fun clearForUser(userId: String)
|
||||
|
||||
/**
|
||||
* Atomically replaces the user's entire cached_likes set with
|
||||
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
|
||||
* so cross-device unlikes (a row that the server no longer
|
||||
* surfaces) get removed from the local cache — drift #570
|
||||
* caught the missing delete pass that left stale Liked tab
|
||||
* entries pointing at tracks the user had unliked elsewhere.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
||||
clearForUser(userId)
|
||||
upsertAll(rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +111,18 @@ class LikesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called
|
||||
* by the LikedTab ViewModel on init (and by the SyncController in
|
||||
* Phase 12). Adds rows for IDs the server has but we don't; the
|
||||
* delete-of-stale-rows pass lives in the SyncController where the
|
||||
* full reconciliation runs.
|
||||
* Pulls `GET /api/likes/ids` and atomically replaces the user's
|
||||
* cached_likes set with the server's canonical view. Called by
|
||||
* the LikedTab ViewModel on init (and by the SyncController in
|
||||
* Phase 12).
|
||||
*
|
||||
* Drift #570 fix: previously this called `upsertAll` only, which
|
||||
* added rows for IDs the server had but never removed rows the
|
||||
* server no longer surfaced — so a cross-device unlike (user
|
||||
* likes on web, then unlikes on web) left the Liked tab on
|
||||
* Android showing the now-unliked entry forever. The atomic
|
||||
* replaceAllForUser DAO method runs a transactional delete-then-
|
||||
* insert so the local set is exactly what the server reports.
|
||||
*/
|
||||
suspend fun refreshIds() {
|
||||
val wire = api.ids()
|
||||
@@ -123,7 +130,7 @@ class LikesRepository @Inject constructor(
|
||||
rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) }
|
||||
rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) }
|
||||
rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) }
|
||||
likeDao.upsertAll(rows)
|
||||
likeDao.replaceAllForUser(LOCAL_USER_ID, rows)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+16
-7
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -71,7 +72,7 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val (_, updated) = repository.cancel(id)
|
||||
val (outcome, updated) = repository.cancel(id)
|
||||
if (updated != null) {
|
||||
internal.update { state ->
|
||||
if (state !is UiState.Success) {
|
||||
@@ -87,12 +88,20 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh fetches the canonical list whether the cancel
|
||||
// went through live (Synced) or got enqueued (Queued).
|
||||
// The Queued case shows the optimistic removal until the
|
||||
// replayer drains; the next list fetch surfaces the
|
||||
// server's canonical view.
|
||||
refresh()
|
||||
// Drift #577: only refetch on the Synced outcome. The
|
||||
// Queued path is offline by definition — the optimistic
|
||||
// removal at the top of cancel() is correct, and a
|
||||
// refresh() call here would either (a) get the
|
||||
// not-yet-delivered cancelled row back from the server
|
||||
// and snap it into the list (confusing), or (b) fail
|
||||
// with a transport error and flip the whole screen to
|
||||
// UiState.Error, making the user think the cancel
|
||||
// failed. The mutation queue will replay the cancel
|
||||
// when connectivity returns; the next on-screen refresh
|
||||
// (pull-to-refresh, navigation back) reconciles.
|
||||
if (outcome == CancelOutcome.Synced) {
|
||||
refresh()
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user