feat(data): the Room database, its four tables and their DAOs

alarms, timers, world_clocks and stopwatch_laps, with the schema exported
to app/schemas and committed so migrations can be tested from v1 onwards.

The DAOs return Flows for reads and keep every multi-step write inside a
@Transaction, so a read-modify-write cannot lose a concurrent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:11 +02:00
co-authored by Claude Opus 5
parent a2beae94ec
commit 778d08af24
13 changed files with 787 additions and 0 deletions
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface AlarmDao {
@Query("SELECT * FROM alarms ORDER BY hour, minute, id")
fun observeAll(): Flow<List<AlarmEntity>>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
fun observeById(id: Long): Flow<AlarmEntity?>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
suspend fun findById(id: Long): AlarmEntity?
@Query("SELECT * FROM alarms WHERE enabled = 1 ORDER BY hour, minute, id")
suspend fun enabled(): List<AlarmEntity>
@Insert
suspend fun insert(alarm: AlarmEntity): Long
@Update
suspend fun update(alarm: AlarmEntity)
@Query("DELETE FROM alarms WHERE id = :id")
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)
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* The `alarms` row. Primitives only — every enum is stored as [Enum.name], every
* instant as epoch millis and the repeat set as a 7-bit mask, so the whole
* entity/domain translation lives in [AlarmMapper] where it can be tested.
*/
@Entity(tableName = "alarms", indices = [Index("enabled")])
data class AlarmEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "hour") val hour: Int,
@ColumnInfo(name = "minute") val minute: Int,
@ColumnInfo(name = "label") val label: String,
@ColumnInfo(name = "enabled") val enabled: Boolean,
@ColumnInfo(name = "repeat_days") val repeatDays: Int,
@ColumnInfo(name = "skip_next_occurrence") val skipNextOccurrence: Boolean,
@ColumnInfo(name = "ringtone_uri") val ringtoneUri: String?,
@ColumnInfo(name = "vibrate") val vibrate: Boolean?,
@ColumnInfo(name = "snooze_minutes") val snoozeMinutes: Int?,
@ColumnInfo(name = "snooze_limit") val snoozeLimit: Int?,
@ColumnInfo(name = "volume_ramp_seconds") val volumeRampSeconds: Int?,
@ColumnInfo(name = "dismiss_challenge") val dismissChallenge: String?,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
)
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.data.db
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.stopwatch.LapDao
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.data.timers.TimerDao
import de.jeanlucmakiola.clockula.data.timers.TimerEntity
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/`
* and committed, so every later migration is reviewable and testable.
*
* No `TypeConverters` — every column is a primitive and the whole translation
* lives in the mappers — and no destructive-migration fallback: a schema change
* that arrived without a migration would otherwise eat the user's alarms.
*/
@Database(
entities = [
AlarmEntity::class,
TimerEntity::class,
WorldClockEntity::class,
LapEntity::class,
],
version = 1,
exportSchema = true,
)
abstract class ClockulaDatabase : RoomDatabase() {
abstract fun alarmDao(): AlarmDao
abstract fun timerDao(): TimerDao
abstract fun worldClockDao(): WorldClockDao
abstract fun lapDao(): LapDao
companion object {
const val NAME: String = "clockula.db"
}
}
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
abstract class LapDao {
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index")
abstract fun observeAll(): Flow<List<LapEntity>>
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index DESC LIMIT 1")
abstract suspend fun latest(): LapEntity?
@Insert
abstract suspend fun insert(lap: LapEntity): Long
@Query("DELETE FROM stopwatch_laps")
abstract suspend fun deleteAll()
/**
* Appends a lap at [cumulativeMillis], deriving its 1-based index and its
* split from the previous lap. Transactional so two concurrent laps cannot
* collide on the unique lap_index.
*/
@Transaction
open suspend fun appendLap(cumulativeMillis: Long): LapEntity {
val previous = latest()
val entity = LapEntity(
lapIndex = (previous?.lapIndex ?: 0) + 1,
splitMillis = (cumulativeMillis - (previous?.cumulativeMillis ?: 0L)).coerceAtLeast(0L),
cumulativeMillis = cumulativeMillis.coerceAtLeast(0L),
)
val id = insert(entity)
return entity.copy(id = id)
}
}
@@ -0,0 +1,15 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** The `stopwatch_laps` row; `lap_index` carries a unique index. */
@Entity(tableName = "stopwatch_laps", indices = [Index(value = ["lap_index"], unique = true)])
data class LapEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "lap_index") val lapIndex: Int,
@ColumnInfo(name = "split_millis") val splitMillis: Long,
@ColumnInfo(name = "cumulative_millis") val cumulativeMillis: Long,
)
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.clockula.data.timers
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
abstract class TimerDao {
@Query("SELECT * FROM timers ORDER BY sort_order, id")
abstract fun observeAll(): Flow<List<TimerEntity>>
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
abstract fun observeById(id: Long): Flow<TimerEntity?>
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
abstract suspend fun findById(id: Long): TimerEntity?
@Query("SELECT MAX(sort_order) FROM timers")
abstract suspend fun maxSortOrder(): Int?
@Insert
abstract suspend fun insert(timer: TimerEntity): Long
@Update
abstract suspend fun update(timer: TimerEntity)
@Query("DELETE FROM timers WHERE id = :id")
abstract suspend fun deleteById(id: Long)
@Query("UPDATE timers SET sort_order = :sortOrder WHERE id = :id")
abstract suspend fun setSortOrder(id: Long, sortOrder: Int)
/**
* Reads the row, applies [transform] and writes the result back **inside one
* transaction**, so two writers — the ringing service marking a timer
* expired and the user adding a minute to it — cannot both read the same row
* and have one of the two writes disappear. A missing row, or a [transform]
* that returns null, writes nothing.
*
* [transform] must be pure and must not touch the database: it runs while
* the transaction is open.
*/
@Transaction
open suspend fun updateWithin(id: Long, transform: (TimerEntity) -> TimerEntity?) {
val current = findById(id) ?: return
val updated = transform(current) ?: return
update(updated)
}
/** Rewrites sort_order to the position of each id. Unknown ids match no row and are ignored. */
@Transaction
open suspend fun reorder(idsInOrder: List<Long>) {
idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) }
}
}
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.clockula.data.timers
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** The `timers` row. Primitives only; all three anchors are millis. */
@Entity(tableName = "timers", indices = [Index("sort_order")])
data class TimerEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "label") val label: String,
@ColumnInfo(name = "duration_millis") val durationMillis: Long,
@ColumnInfo(name = "state") val state: String,
@ColumnInfo(name = "remaining_millis") val remainingMillis: Long,
@ColumnInfo(name = "started_at_elapsed_realtime_millis") val startedAtElapsedRealtimeMillis: Long?,
@ColumnInfo(name = "ends_at_elapsed_realtime_millis") val endsAtElapsedRealtimeMillis: Long?,
@ColumnInfo(name = "ends_at_wall_clock_millis") val endsAtWallClockMillis: Long?,
@ColumnInfo(name = "ringtone_uri") val ringtoneUri: String?,
@ColumnInfo(name = "sort_order") val sortOrder: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
)
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
abstract class WorldClockDao {
@Query("SELECT * FROM world_clocks ORDER BY sort_order, id")
abstract fun observeAll(): Flow<List<WorldClockEntity>>
@Query("SELECT * FROM world_clocks WHERE zone_id = :zoneId LIMIT 1")
abstract suspend fun findByZoneId(zoneId: String): WorldClockEntity?
@Query("SELECT MAX(sort_order) FROM world_clocks")
abstract suspend fun maxSortOrder(): Int?
/** Returns -1 on a `zone_id` conflict. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(clock: WorldClockEntity): Long
@Query("UPDATE world_clocks SET label = :label WHERE id = :id")
abstract suspend fun setLabel(id: Long, label: String?)
@Query("UPDATE world_clocks SET sort_order = :sortOrder WHERE id = :id")
abstract suspend fun setSortOrder(id: Long, sortOrder: Int)
@Query("DELETE FROM world_clocks WHERE id = :id")
abstract suspend fun deleteById(id: Long)
/**
* The idempotent add, as one transaction: look the zone up, and only insert
* it — at the next sort order — when it really is absent. Doing the lookup,
* the sort-order read and the insert in one transaction means no other
* writer can slip a row in, or delete the row that won, between them, so
* the id this returns is always a row that exists and never the insert
* sentinel.
*/
@Transaction
open suspend fun addIfAbsent(zoneId: String, label: String?): Long {
findByZoneId(zoneId)?.let { return it.id }
val id = insert(
WorldClockEntity(zoneId = zoneId, label = label, sortOrder = (maxSortOrder() ?: -1) + 1),
)
if (id != -1L) return id
// Belt and braces: -1 means the unique index rejected the insert, which
// the lookup above has just ruled out for this transaction.
return findByZoneId(zoneId)?.id
?: error("world clock for $zoneId was neither inserted nor found")
}
@Transaction
open suspend fun reorder(idsInOrder: List<Long>) {
idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) }
}
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** The `world_clocks` row; `zone_id` carries a unique index. */
@Entity(
tableName = "world_clocks",
indices = [Index(value = ["zone_id"], unique = true), Index("sort_order")],
)
data class WorldClockEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "zone_id") val zoneId: String,
@ColumnInfo(name = "label") val label: String?,
@ColumnInfo(name = "sort_order") val sortOrder: Int,
)