feat(data): ring state in a new alarm_states table at schema v2
Snooze, the handled-occurrence watermark and the skip watermark need somewhere to live that survives a process death. They go in their own table rather than as columns on alarms: a missing row is the initial record, and the FK cascade means deleting an alarm cannot leave orphaned ring state behind. Schema v2 is exported and committed alongside v1, so MIGRATION_1_2 is reviewable and testable rather than taken on faith. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AlarmStateDao {
|
||||
@Query("SELECT * FROM alarm_states")
|
||||
fun observeAll(): Flow<List<AlarmStateEntity>>
|
||||
|
||||
@Query("SELECT * FROM alarm_states WHERE alarm_id = :alarmId LIMIT 1")
|
||||
suspend fun findByAlarmId(alarmId: Long): AlarmStateEntity?
|
||||
|
||||
@Query("SELECT * FROM alarm_states WHERE ringing_since IS NOT NULL ORDER BY ringing_since DESC LIMIT 1")
|
||||
suspend fun ringing(): AlarmStateEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(state: AlarmStateEntity)
|
||||
|
||||
@Query("DELETE FROM alarm_states WHERE alarm_id = :alarmId")
|
||||
suspend fun deleteByAlarmId(alarmId: Long)
|
||||
|
||||
@Query("DELETE FROM alarm_states")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* The `alarm_states` row (schema v2): one row per alarm, volatile ring state
|
||||
* only. Cascades away with its alarm, so deleting an alarm takes its ring state
|
||||
* with it and M10's backup never sees it.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "alarm_states",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AlarmEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["alarm_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class AlarmStateEntity(
|
||||
@PrimaryKey @ColumnInfo(name = "alarm_id") val alarmId: Long,
|
||||
@ColumnInfo(name = "snoozed_until") val snoozedUntil: Long?,
|
||||
@ColumnInfo(name = "snooze_count") val snoozeCount: Int,
|
||||
@ColumnInfo(name = "ringing_since") val ringingSince: Long?,
|
||||
@ColumnInfo(name = "handled_occurrence") val handledOccurrence: Long?,
|
||||
@ColumnInfo(name = "skipped_occurrence") val skippedOccurrence: Long?,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never
|
||||
* throws. An absent column and a stored epoch-zero are different facts and stay
|
||||
* different — null means "never happened".
|
||||
*/
|
||||
object AlarmStateMapper {
|
||||
fun toDomain(entity: AlarmStateEntity): AlarmRingState = AlarmRingState(
|
||||
alarmId = entity.alarmId,
|
||||
snoozedUntil = entity.snoozedUntil?.toInstant(),
|
||||
snoozeCount = entity.snoozeCount.coerceAtLeast(0),
|
||||
ringingSince = entity.ringingSince?.toInstant(),
|
||||
handledOccurrence = entity.handledOccurrence?.toInstant(),
|
||||
skippedOccurrence = entity.skippedOccurrence?.toInstant(),
|
||||
)
|
||||
|
||||
fun toDomain(entities: List<AlarmStateEntity>): List<AlarmRingState> = entities.map(::toDomain)
|
||||
|
||||
fun toEntity(state: AlarmRingState): AlarmStateEntity = AlarmStateEntity(
|
||||
alarmId = state.alarmId,
|
||||
snoozedUntil = state.snoozedUntil?.toEpochMilliseconds(),
|
||||
snoozeCount = state.snoozeCount,
|
||||
ringingSince = state.ringingSince?.toEpochMilliseconds(),
|
||||
handledOccurrence = state.handledOccurrence?.toEpochMilliseconds(),
|
||||
skippedOccurrence = state.skippedOccurrence?.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
private fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AlarmStateRepository {
|
||||
fun states(): Flow<List<AlarmRingState>>
|
||||
|
||||
/** Never null: an alarm with no row reads back as [AlarmRingState.initial]. Writes nothing. */
|
||||
suspend fun state(alarmId: Long): AlarmRingState
|
||||
|
||||
suspend fun save(state: AlarmRingState)
|
||||
|
||||
/** The single ringing alarm, if any. Most-recent `ringingSince` wins if two rows disagree. */
|
||||
suspend fun ringing(): AlarmRingState?
|
||||
|
||||
suspend fun clear(alarmId: Long)
|
||||
|
||||
suspend fun clearAll()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The ring-state table as the engine sees it. A missing row is a fact rather
|
||||
* than an error — an alarm that has never rung reads back as the initial record
|
||||
* and nothing is written until there is something to write.
|
||||
*/
|
||||
@Singleton
|
||||
class AlarmStateRepositoryImpl @Inject constructor(
|
||||
private val dao: AlarmStateDao,
|
||||
) : AlarmStateRepository {
|
||||
|
||||
override fun states(): Flow<List<AlarmRingState>> = dao.observeAll().map(AlarmStateMapper::toDomain)
|
||||
|
||||
override suspend fun state(alarmId: Long): AlarmRingState =
|
||||
dao.findByAlarmId(alarmId)?.let(AlarmStateMapper::toDomain) ?: AlarmRingState.initial(alarmId)
|
||||
|
||||
override suspend fun save(state: AlarmRingState) = dao.upsert(AlarmStateMapper.toEntity(state))
|
||||
|
||||
override suspend fun ringing(): AlarmRingState? = dao.ringing()?.let(AlarmStateMapper::toDomain)
|
||||
|
||||
override suspend fun clear(alarmId: Long) = dao.deleteByAlarmId(alarmId)
|
||||
|
||||
override suspend fun clearAll() = dao.deleteAll()
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerDao
|
||||
@@ -12,7 +14,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
|
||||
/**
|
||||
* Clockula's own storage. Version 1; the schema is exported to `app/schemas/`
|
||||
* Clockula's own storage. Version 2; the schema is exported to `app/schemas/`
|
||||
* and committed, so every later migration is reviewable and testable.
|
||||
*
|
||||
* No `TypeConverters` — every column is a primitive and the whole translation
|
||||
@@ -22,15 +24,17 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
@Database(
|
||||
entities = [
|
||||
AlarmEntity::class,
|
||||
AlarmStateEntity::class,
|
||||
TimerEntity::class,
|
||||
WorldClockEntity::class,
|
||||
LapEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class ClockulaDatabase : RoomDatabase() {
|
||||
abstract fun alarmDao(): AlarmDao
|
||||
abstract fun alarmStateDao(): AlarmStateDao
|
||||
abstract fun timerDao(): TimerDao
|
||||
abstract fun worldClockDao(): WorldClockDao
|
||||
abstract fun lapDao(): LapDao
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.data.db
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
/**
|
||||
* Every migration this database has ever needed, in one place, so the list a
|
||||
* builder is handed is the list the tests assert on. The statements are copied
|
||||
* from the exported schema's `createSql` verbatim: `runMigrationsAndValidate`
|
||||
* compares the migrated database against that JSON column for column.
|
||||
*/
|
||||
object Migrations {
|
||||
/** v1 → v2: `CREATE TABLE alarm_states` — the snooze and ring state (M3). */
|
||||
val MIGRATION_1_2: Migration = object : Migration(1, 2) {
|
||||
override fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `alarm_states` (" +
|
||||
"`alarm_id` INTEGER NOT NULL, " +
|
||||
"`snoozed_until` INTEGER, " +
|
||||
"`snooze_count` INTEGER NOT NULL, " +
|
||||
"`ringing_since` INTEGER, " +
|
||||
"`handled_occurrence` INTEGER, " +
|
||||
"`skipped_occurrence` INTEGER, " +
|
||||
"PRIMARY KEY(`alarm_id`), " +
|
||||
"FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2)
|
||||
}
|
||||
@@ -8,14 +8,16 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao
|
||||
import de.jeanlucmakiola.clockula.data.db.ClockulaDatabase
|
||||
import de.jeanlucmakiola.clockula.data.db.Migrations
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerDao
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The database and its four DAOs. No destructive-migration fallback: docs/PLAN.md
|
||||
* The database and its five DAOs. No destructive-migration fallback: docs/PLAN.md
|
||||
* §12 wants every schema change to arrive with a tested migration, and a fallback
|
||||
* would quietly drop a user's alarms instead of failing loudly in review.
|
||||
*/
|
||||
@@ -26,11 +28,16 @@ object DatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): ClockulaDatabase =
|
||||
Room.databaseBuilder(context, ClockulaDatabase::class.java, ClockulaDatabase.NAME).build()
|
||||
Room.databaseBuilder(context, ClockulaDatabase::class.java, ClockulaDatabase.NAME)
|
||||
.addMigrations(*Migrations.ALL)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
fun provideAlarmDao(database: ClockulaDatabase): AlarmDao = database.alarmDao()
|
||||
|
||||
@Provides
|
||||
fun provideAlarmStateDao(database: ClockulaDatabase): AlarmStateDao = database.alarmStateDao()
|
||||
|
||||
@Provides
|
||||
fun provideTimerDao(database: ClockulaDatabase): TimerDao = database.timerDao()
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
@@ -14,7 +16,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepositoryImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The four interfaces the rest of the app sees — and the only ones it may see. */
|
||||
/** The five interfaces the rest of the app sees — and the only ones it may see. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
@@ -23,6 +25,10 @@ abstract class RepositoryModule {
|
||||
@Singleton
|
||||
abstract fun bindAlarmRepository(impl: AlarmRepositoryImpl): AlarmRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAlarmStateRepository(impl: AlarmStateRepositoryImpl): AlarmStateRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTimerRepository(impl: TimerRepositoryImpl): TimerRepository
|
||||
|
||||
Reference in New Issue
Block a user