feat(data): a transactional alarm edit, and the audio source policy

The editor and the engine write to the same row from different threads, so an
edit is a transaction: read, transform, write back, with the id and the
creation stamp forced from the stored row. Without it the editor would
re-enable an alarm the engine had just resolved as disabled, or move a row's
history.

`AudioSourcePolicy` is where the silent sentinel becomes an empty source list
— the audio player then fails to start by design, and the ring falls through
to vibration instead of to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:43:33 +02:00
co-authored by Claude Opus 5
parent 52204d3e9c
commit 8002651e3f
5 changed files with 118 additions and 10 deletions
@@ -3,35 +3,48 @@ package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface AlarmDao {
abstract class AlarmDao {
@Query("SELECT * FROM alarms ORDER BY hour, minute, id")
fun observeAll(): Flow<List<AlarmEntity>>
abstract fun observeAll(): Flow<List<AlarmEntity>>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
fun observeById(id: Long): Flow<AlarmEntity?>
abstract fun observeById(id: Long): Flow<AlarmEntity?>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
suspend fun findById(id: Long): AlarmEntity?
abstract suspend fun findById(id: Long): AlarmEntity?
@Query("SELECT * FROM alarms WHERE enabled = 1 ORDER BY hour, minute, id")
suspend fun enabled(): List<AlarmEntity>
abstract suspend fun enabled(): List<AlarmEntity>
@Insert
suspend fun insert(alarm: AlarmEntity): Long
abstract suspend fun insert(alarm: AlarmEntity): Long
@Update
suspend fun update(alarm: AlarmEntity)
abstract suspend fun update(alarm: AlarmEntity)
@Query("DELETE FROM alarms WHERE id = :id")
suspend fun deleteById(id: Long)
abstract suspend fun deleteById(id: Long)
@Query("UPDATE alarms SET enabled = :enabled, updated_at = :updatedAt WHERE id = :id")
suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long)
abstract suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long)
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
abstract suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
/**
* Reads the row, applies [transform] and writes the result back inside one
* transaction. A missing row, or a null result, writes nothing. [transform]
* must be pure and must not touch the database.
*/
@Transaction
open suspend fun updateWithin(id: Long, transform: (AlarmEntity) -> AlarmEntity?) {
val current = findById(id) ?: return
val next = transform(current) ?: return
update(next)
}
}
@@ -14,4 +14,11 @@ interface AlarmRepository {
suspend fun delete(id: Long)
suspend fun setEnabled(id: Long, enabled: Boolean)
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean)
/**
* Transactional read-modify-write. Stamps `updated_at` from the wall clock
* and keeps the id, so a concurrent `setEnabled` from the engine cannot be
* clobbered by a stale whole-row write. A missing id writes nothing.
*/
suspend fun edit(id: Long, transform: (Alarm) -> Alarm)
}
@@ -41,4 +41,15 @@ class AlarmRepositoryImpl @Inject constructor(
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds())
override suspend fun edit(id: Long, transform: (Alarm) -> Alarm) {
val updatedAt = wallClock.now().toEpochMilliseconds()
dao.updateWithin(id) { entity ->
// The id is forced back on, so a transform can change a field but
// never move the row; the read and the write are one transaction, so
// a concurrent `setEnabled` from the engine is not clobbered.
AlarmMapper.toEntity(transform(AlarmMapper.toDomain(entity)))
.copy(id = entity.id, createdAt = entity.createdAt, updatedAt = updatedAt)
}
}
}