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:
2026-09-11 16:04:26 +02:00
co-authored by Claude Opus 5
parent 5ea3c798b4
commit f4b7c1a16c
17 changed files with 1105 additions and 8 deletions
@@ -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