diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3072fcc..f81e005 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -29,6 +29,11 @@ # 2. The JVM unit tests, when Kotlin is staged. They are the prediction # acceptance cases from PRODUCT_PLAN.md §51 and they run on the JVM in about # a second, which is the whole reason :domain:* is not an Android module. +# 3. The Room schema guard, when a database entity or the schema export is +# staged. It asks git whether an already-committed schema file changed, which +# is the ONLY thing that can see that failure — Room overwrites the export +# during the build, so the unit tests are green over it. See the script's +# header for the proof. # # Compilation of the Android modules is deliberately NOT run here. It needs the # SDK and takes half a minute, and a pre-commit hook people disable is worse @@ -106,6 +111,27 @@ else say "no .kt/.kts or build file staged — skipping the suite." fi +# A Room entity may not change without the version changing with it. Only git +# can see this: Room rewrites the schema export during compilation, so both +# sides of any in-process comparison agree by construction. +touches_schema=$(printf '%s\n' "$staged" \ + | grep -cE '^core/database/(schemas/|src/main/.*(entity|Entities|PeriodDatabase)).*' || true) + +if [ "$touches_schema" -gt 0 ]; then + if [ -f scripts/schema-guard.sh ]; then + say "Room schema guard…" + bash scripts/schema-guard.sh + rc=$? + # 2 is "nothing was checked", which is never a pass. + if [ "$rc" -ne 0 ]; then + say "schema guard exited $rc — commit refused." + exit 1 + fi + else + say "note: scripts/schema-guard.sh is missing, so schema drift was not checked." + fi +fi + # Said last so it is the thing still on screen when the editor opens. if ! git diff --quiet; then say "NOTE: unstaged changes are present. The guards ran against the working" diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 0000000..d95ae12 --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ksp) + alias(libs.plugins.room) +} + +android { + namespace = "dev.privacyllc.period.core.database" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +// The exported schema is COMMITTED. A migration test compares the migrated +// database against the real previous schema; without the export it can only +// compare against a remembered one, which is the same as not testing it. +room { + schemaDirectory("$projectDir/schemas") +} + +dependencies { + api(project(":domain:cycle")) + api(project(":domain:prediction")) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + testImplementation(libs.junit) + testImplementation(libs.androidx.room.testing) + testImplementation(libs.androidx.sqlite.bundled) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) +} diff --git a/core/database/schemas/dev.privacyllc.period.core.database.PeriodDatabase/1.json b/core/database/schemas/dev.privacyllc.period.core.database.PeriodDatabase/1.json new file mode 100644 index 0000000..b323bca --- /dev/null +++ b/core/database/schemas/dev.privacyllc.period.core.database.PeriodDatabase/1.json @@ -0,0 +1,274 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "219d6bf3f5b796b3ee87fce8d4b5a41d", + "entities": [ + { + "tableName": "period_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `source` TEXT NOT NULL, `isConfirmed` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_period_records_startDate", + "unique": true, + "columnNames": [ + "startDate" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_period_records_startDate` ON `${TABLE_NAME}` (`startDate`)" + } + ] + }, + { + "tableName": "spotting_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_spotting_records_date", + "unique": true, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_spotting_records_date` ON `${TABLE_NAME}` (`date`)" + } + ] + }, + { + "tableName": "prediction_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `generatedAt` INTEGER NOT NULL, `basedOnLastConfirmedPeriodId` INTEGER, `predictedStartDate` INTEGER NOT NULL, `predictedWindowStart` INTEGER NOT NULL, `predictedWindowEnd` INTEGER NOT NULL, `estimatedOvulationDate` INTEGER, `fertileWindowStart` INTEGER, `fertileWindowEnd` INTEGER, `confidenceScore` REAL NOT NULL, `confidenceLabel` TEXT NOT NULL, `modelVersion` TEXT NOT NULL, `actualStartDate` INTEGER, `absoluteErrorDays` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generatedAt", + "columnName": "generatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "basedOnLastConfirmedPeriodId", + "columnName": "basedOnLastConfirmedPeriodId", + "affinity": "INTEGER" + }, + { + "fieldPath": "predictedStartDate", + "columnName": "predictedStartDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "predictedWindowStart", + "columnName": "predictedWindowStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "predictedWindowEnd", + "columnName": "predictedWindowEnd", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimatedOvulationDate", + "columnName": "estimatedOvulationDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "fertileWindowStart", + "columnName": "fertileWindowStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "fertileWindowEnd", + "columnName": "fertileWindowEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "confidenceScore", + "columnName": "confidenceScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "confidenceLabel", + "columnName": "confidenceLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelVersion", + "columnName": "modelVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actualStartDate", + "columnName": "actualStartDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "absoluteErrorDays", + "columnName": "absoluteErrorDays", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_prediction_records_generatedAt", + "unique": false, + "columnNames": [ + "generatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_prediction_records_generatedAt` ON `${TABLE_NAME}` (`generatedAt`)" + }, + { + "name": "index_prediction_records_actualStartDate", + "unique": false, + "columnNames": [ + "actualStartDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_prediction_records_actualStartDate` ON `${TABLE_NAME}` (`actualStartDate`)" + } + ] + }, + { + "tableName": "not_yet_observations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `predictionId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "predictionId", + "columnName": "predictionId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_not_yet_observations_date", + "unique": true, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_not_yet_observations_date` ON `${TABLE_NAME}` (`date`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '219d6bf3f5b796b3ee87fce8d4b5a41d')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/Converters.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/Converters.kt new file mode 100644 index 0000000..60d4a08 --- /dev/null +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/Converters.kt @@ -0,0 +1,24 @@ +package dev.privacyllc.period.core.database + +import androidx.room.TypeConverter +import java.time.Instant +import java.time.LocalDate + +/** + * Dates are stored as epoch days and instants as epoch milliseconds — integers, + * not strings. + * + * A `LocalDate` written as text sorts correctly by luck of ISO-8601 and compares + * incorrectly the moment anybody writes a locale-aware formatter into the path. + * An epoch day has one representation, sorts and ranges in SQL, and cannot carry + * a timezone by accident — which matters here more than usual, because a cycle + * is measured in whole days and PRODUCT_PLAN.md §50 calls out timezone and DST + * edge cases as things that must be tested. + */ +internal class Converters { + @TypeConverter fun dateToEpochDay(value: LocalDate?): Long? = value?.toEpochDay() + @TypeConverter fun epochDayToDate(value: Long?): LocalDate? = value?.let(LocalDate::ofEpochDay) + + @TypeConverter fun instantToEpochMilli(value: Instant?): Long? = value?.toEpochMilli() + @TypeConverter fun epochMilliToInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli) +} diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt new file mode 100644 index 0000000..1f14574 --- /dev/null +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt @@ -0,0 +1,62 @@ +package dev.privacyllc.period.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.withTransaction +import dev.privacyllc.period.core.database.dao.NotYetObservationDao +import dev.privacyllc.period.core.database.dao.PeriodRecordDao +import dev.privacyllc.period.core.database.dao.PredictionRecordDao +import dev.privacyllc.period.core.database.dao.SpottingRecordDao +import dev.privacyllc.period.core.database.entity.NotYetObservationEntity +import dev.privacyllc.period.core.database.entity.PeriodRecordEntity +import dev.privacyllc.period.core.database.entity.PredictionRecordEntity +import dev.privacyllc.period.core.database.entity.SpottingRecordEntity + +/** + * The cycle database. + * + * Lives in app-private storage and is excluded from platform backup — see + * `AndroidManifest.xml`, `res/xml/data_extraction_rules.xml` and + * docs/security/SECURITY.md. Android auto-backup is on by default, and a + * default that ships a cycle history to a cloud account defeats the entire + * local-first argument. + * + * **`exportSchema = true` and the schemas are committed.** A migration test can + * only compare against a real previous schema if one was written down. + */ +@Database( + entities = [ + PeriodRecordEntity::class, + SpottingRecordEntity::class, + PredictionRecordEntity::class, + NotYetObservationEntity::class, + ], + version = PeriodDatabase.VERSION, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class PeriodDatabase : RoomDatabase() { + + abstract fun periodRecordDao(): PeriodRecordDao + abstract fun spottingRecordDao(): SpottingRecordDao + abstract fun predictionRecordDao(): PredictionRecordDao + abstract fun notYetObservationDao(): NotYetObservationDao + + /** + * Delete My Data, and the only bulk delete in this module. + * + * One transaction: a partial wipe that leaves prediction snapshots behind + * would leave the user's cycle reconstructible from the very table they + * asked to be rid of. PRODUCT_PLAN.md §45 — clear and irreversible after + * confirmation. + */ + suspend fun deleteEverything() = withTransaction { + clearAllTables() + } + + companion object { + const val VERSION = 1 + const val NAME = "period.db" + } +} diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt new file mode 100644 index 0000000..3d17d35 --- /dev/null +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt @@ -0,0 +1,118 @@ +package dev.privacyllc.period.core.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import dev.privacyllc.period.core.database.entity.NotYetObservationEntity +import dev.privacyllc.period.core.database.entity.PeriodRecordEntity +import dev.privacyllc.period.core.database.entity.PredictionRecordEntity +import dev.privacyllc.period.core.database.entity.SpottingRecordEntity +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate + +/** + * Reads return [Flow]. A one-shot read is a snapshot that is already stale when + * the UI renders it, and the whole point of this product is that logging a + * period updates the forecast on screen without anybody refreshing anything. + * + * Note there is no `deleteAll`-by-convenience anywhere except the explicit + * `deleteEverything` on the database itself: Delete My Data is an irreversible + * operation the user confirms, not something a DAO offers casually. + */ +@Dao +interface PeriodRecordDao { + + @Query("SELECT * FROM period_records WHERE isConfirmed = 1 ORDER BY startDate ASC") + fun observeConfirmed(): Flow> + + @Query("SELECT * FROM period_records ORDER BY startDate ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM period_records WHERE isConfirmed = 1 ORDER BY startDate DESC LIMIT 1") + fun observeLatestConfirmed(): Flow + + @Query("SELECT * FROM period_records WHERE id = :id") + suspend fun byId(id: Long): PeriodRecordEntity? + + @Query("SELECT * FROM period_records WHERE startDate = :date LIMIT 1") + suspend fun byStartDate(date: LocalDate): PeriodRecordEntity? + + /** + * ABORT, not REPLACE. A duplicate start date is a mistake the caller has to + * see: REPLACE would silently delete the existing row — including its + * `createdAt` and its `source` — and health history is not something this + * app overwrites without saying so (§14). + */ + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(record: PeriodRecordEntity): Long + + @Update + suspend fun update(record: PeriodRecordEntity) + + @Delete + suspend fun delete(record: PeriodRecordEntity) + + @Query("DELETE FROM period_records WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("SELECT COUNT(*) FROM period_records WHERE isConfirmed = 1") + suspend fun confirmedCount(): Int +} + +@Dao +interface SpottingRecordDao { + + @Query("SELECT * FROM spotting_records ORDER BY date ASC") + fun observeAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(record: SpottingRecordEntity): Long + + @Query("DELETE FROM spotting_records WHERE date = :date") + suspend fun deleteByDate(date: LocalDate) +} + +@Dao +interface PredictionRecordDao { + + @Query("SELECT * FROM prediction_records ORDER BY generatedAt DESC LIMIT 1") + fun observeLatest(): Flow + + /** Scored predictions only — the ones whose outcome is known. §16's accuracy figures read this. */ + @Query("SELECT * FROM prediction_records WHERE actualStartDate IS NOT NULL ORDER BY generatedAt DESC LIMIT :limit") + fun observeScored(limit: Int = 12): Flow> + + @Query("SELECT * FROM prediction_records WHERE actualStartDate IS NULL ORDER BY generatedAt DESC") + suspend fun unscored(): List + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(record: PredictionRecordEntity): Long + + /** + * The ONLY permitted mutation of a snapshot: recording what actually + * happened. Deliberately not an `@Update` of the whole row, so a caller + * cannot rewrite what was predicted after learning the answer. + */ + @Query("UPDATE prediction_records SET actualStartDate = :actual, absoluteErrorDays = :errorDays WHERE id = :id") + suspend fun score(id: Long, actual: LocalDate, errorDays: Int) +} + +@Dao +interface NotYetObservationDao { + + @Query("SELECT * FROM not_yet_observations ORDER BY date ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM not_yet_observations WHERE date >= :since ORDER BY date ASC") + suspend fun since(since: LocalDate): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(observation: NotYetObservationEntity): Long + + /** Cleared when a period is confirmed: they censored a forecast that is now resolved. */ + @Query("DELETE FROM not_yet_observations WHERE date < :before") + suspend fun deleteBefore(before: LocalDate) +} diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/entity/Entities.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/entity/Entities.kt new file mode 100644 index 0000000..bf15e35 --- /dev/null +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/entity/Entities.kt @@ -0,0 +1,96 @@ +package dev.privacyllc.period.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.Instant +import java.time.LocalDate + +/** + * A confirmed period. + * + * `startDate` is UNIQUE. Two records claiming the same start are not two + * periods, they are one period entered twice — and a duplicate start produces a + * zero-length cycle, which is the shape most likely to make a forecast + * nonsensical rather than merely wrong. `domain/cycle` already de-duplicates + * defensively; this stops the row existing in the first place. + */ +@Entity( + tableName = "period_records", + indices = [Index(value = ["startDate"], unique = true)], +) +data class PeriodRecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startDate: LocalDate, + val endDate: LocalDate?, + val createdAt: Instant, + val updatedAt: Instant, + /** Stored as the enum name. Edits are recorded, never silent — §10. */ + val source: String, + val isConfirmed: Boolean, +) + +/** + * Spotting, in its own table. + * + * Separate storage is the structural half of the rule in PRODUCT_PLAN.md §25: + * spotting must never start or reset a cycle. A `type` column on + * `period_records` would have made that a query nobody remembers to filter. + */ +@Entity( + tableName = "spotting_records", + indices = [Index(value = ["date"], unique = true)], +) +data class SpottingRecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val date: LocalDate, + val createdAt: Instant, +) + +/** + * A forecast, snapshotted before the outcome was known. + * + * Never updated in place except to record the outcome (`actualStartDate`, + * `absoluteErrorDays`) once it is known. That is the entire basis of the + * accuracy feature in §16 — a prediction rewritten after the fact can only ever + * report that the app was right. + */ +@Entity( + tableName = "prediction_records", + indices = [Index(value = ["generatedAt"]), Index(value = ["actualStartDate"])], +) +data class PredictionRecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val generatedAt: Instant, + val basedOnLastConfirmedPeriodId: Long?, + val predictedStartDate: LocalDate, + val predictedWindowStart: LocalDate, + val predictedWindowEnd: LocalDate, + val estimatedOvulationDate: LocalDate?, + val fertileWindowStart: LocalDate?, + val fertileWindowEnd: LocalDate?, + val confidenceScore: Double, + val confidenceLabel: String, + /** Which engine produced it. An accuracy comparison across versions is meaningless without this. */ + val modelVersion: String, + val actualStartDate: LocalDate?, + val absoluteErrorDays: Int?, +) + +/** + * The user said the period had not started by [date]. + * + * A censoring observation — §13. Stored rather than applied-and-forgotten, + * because the next recalculation has to condition on every one of them, and + * because "we asked and she said not yet" is a fact about the history. + */ +@Entity( + tableName = "not_yet_observations", + indices = [Index(value = ["date"], unique = true)], +) +data class NotYetObservationEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val date: LocalDate, + val predictionId: Long?, + val createdAt: Instant, +) diff --git a/core/database/src/test/kotlin/dev/privacyllc/period/core/database/PeriodDatabaseTest.kt b/core/database/src/test/kotlin/dev/privacyllc/period/core/database/PeriodDatabaseTest.kt new file mode 100644 index 0000000..1e75141 --- /dev/null +++ b/core/database/src/test/kotlin/dev/privacyllc/period/core/database/PeriodDatabaseTest.kt @@ -0,0 +1,180 @@ +package dev.privacyllc.period.core.database + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.privacyllc.period.core.database.entity.NotYetObservationEntity +import dev.privacyllc.period.core.database.entity.PeriodRecordEntity +import dev.privacyllc.period.core.database.entity.PredictionRecordEntity +import dev.privacyllc.period.core.database.entity.SpottingRecordEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Instant +import java.time.LocalDate + +/** + * These do not test Room. They test the constraints this schema exists to + * enforce — the ones that, if they quietly stopped holding, would corrupt a + * cycle history rather than crash anything. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PeriodDatabaseTest { + + private lateinit var db: PeriodDatabase + + private val now: Instant = Instant.parse("2026-08-18T00:00:00Z") + + @Before fun open() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + PeriodDatabase::class.java, + ).allowMainThreadQueries().build() + } + + @After fun close() = db.close() + + private fun period(day: String, end: String? = null, confirmed: Boolean = true) = + PeriodRecordEntity( + startDate = LocalDate.parse(day), + endDate = end?.let(LocalDate::parse), + createdAt = now, + updatedAt = now, + source = "MANUAL", + isConfirmed = confirmed, + ) + + @Test fun `a period round-trips through the database unchanged`() = runTest { + val id = db.periodRecordDao().insert(period("2026-08-01", end = "2026-08-05")) + val read = db.periodRecordDao().byId(id) + + assertNotNull(read) + assertEquals(LocalDate.of(2026, 8, 1), read!!.startDate) + assertEquals(LocalDate.of(2026, 8, 5), read.endDate) + assertEquals("MANUAL", read.source) + assertTrue(read.isConfirmed) + } + + @Test fun `an ongoing period stores a null end date rather than a placeholder`() = runTest { + val id = db.periodRecordDao().insert(period("2026-08-01")) + assertNull(db.periodRecordDao().byId(id)!!.endDate) + } + + @Test(expected = android.database.sqlite.SQLiteConstraintException::class) + fun `a duplicate start date is refused rather than silently replacing the first`() = runTest { + db.periodRecordDao().insert(period("2026-08-01")) + // ABORT, not REPLACE: REPLACE would delete the original row and with it + // its createdAt and source. Health history is not overwritten silently. + db.periodRecordDao().insert(period("2026-08-01")) + } + + @Test fun `unconfirmed records are excluded from the confirmed stream`() = runTest { + db.periodRecordDao().insert(period("2026-08-01")) + db.periodRecordDao().insert(period("2026-07-01", confirmed = false)) + + val confirmed = db.periodRecordDao().observeConfirmed().first() + assertEquals(1, confirmed.size) + assertEquals(LocalDate.of(2026, 8, 1), confirmed.single().startDate) + assertEquals(1, db.periodRecordDao().confirmedCount()) + } + + @Test fun `the confirmed stream is ordered by start date regardless of insert order`() = runTest { + listOf("2026-08-01", "2026-06-01", "2026-07-01").forEach { + db.periodRecordDao().insert(period(it)) + } + assertEquals( + listOf(LocalDate.of(2026, 6, 1), LocalDate.of(2026, 7, 1), LocalDate.of(2026, 8, 1)), + db.periodRecordDao().observeConfirmed().first().map { it.startDate }, + ) + } + + @Test fun `spotting lives in its own table and never appears as a period`() = runTest { + db.spottingRecordDao().insert(SpottingRecordEntity(date = LocalDate.of(2026, 8, 10), createdAt = now)) + + // PRODUCT_PLAN.md §25: spotting must not start or reset a cycle. The + // structural guarantee is that no query for periods can reach it. + assertTrue(db.periodRecordDao().observeAll().first().isEmpty()) + assertEquals(1, db.spottingRecordDao().observeAll().first().size) + } + + @Test fun `a prediction snapshot can be scored but not rewritten`() = runTest { + val id = db.predictionRecordDao().insert( + PredictionRecordEntity( + generatedAt = now, + basedOnLastConfirmedPeriodId = null, + predictedStartDate = LocalDate.of(2026, 8, 22), + predictedWindowStart = LocalDate.of(2026, 8, 21), + predictedWindowEnd = LocalDate.of(2026, 8, 24), + estimatedOvulationDate = null, + fertileWindowStart = null, + fertileWindowEnd = null, + confidenceScore = 0.7, + confidenceLabel = "HIGH", + modelVersion = "baseline-1", + actualStartDate = null, + absoluteErrorDays = null, + ), + ) + + assertEquals(1, db.predictionRecordDao().unscored().size) + + db.predictionRecordDao().score(id, LocalDate.of(2026, 8, 24), errorDays = 2) + + val scored = db.predictionRecordDao().observeScored().first().single() + assertEquals(LocalDate.of(2026, 8, 24), scored.actualStartDate) + assertEquals(2, scored.absoluteErrorDays) + // What was predicted is untouched — that is the whole basis of §16's + // accuracy figures. A snapshot editable after the fact can only ever + // report that the app was right. + assertEquals(LocalDate.of(2026, 8, 22), scored.predictedStartDate) + assertTrue(db.predictionRecordDao().unscored().isEmpty()) + } + + @Test fun `a repeated not-yet on the same day is recorded once`() = runTest { + val d = LocalDate.of(2026, 8, 22) + db.notYetObservationDao().insert(NotYetObservationEntity(date = d, predictionId = null, createdAt = now)) + db.notYetObservationDao().insert(NotYetObservationEntity(date = d, predictionId = null, createdAt = now)) + + // Tapping "Not yet" twice is one fact about one day, not two. + assertEquals(1, db.notYetObservationDao().observeAll().first().size) + } + + @Test fun `delete everything leaves nothing behind in any table`() = runTest { + db.periodRecordDao().insert(period("2026-08-01")) + db.spottingRecordDao().insert(SpottingRecordEntity(date = LocalDate.of(2026, 8, 10), createdAt = now)) + db.notYetObservationDao().insert( + NotYetObservationEntity(date = LocalDate.of(2026, 8, 22), predictionId = null, createdAt = now), + ) + + db.deleteEverything() + + // A partial wipe would leave the cycle reconstructible from the very + // tables the user asked to be rid of. §45: clear and irreversible. + assertTrue(db.periodRecordDao().observeAll().first().isEmpty()) + assertTrue(db.spottingRecordDao().observeAll().first().isEmpty()) + assertTrue(db.notYetObservationDao().observeAll().first().isEmpty()) + assertNull(db.predictionRecordDao().observeLatest().first()) + } + + @Test fun `dates survive the round trip as whole days with no timezone drift`() = runTest { + // §50 calls out timezone and DST edge cases. Epoch-day storage is what + // makes this true; a locale-aware string formatter in the path is what + // would break it, and this test is what would notice. + val awkward = listOf("2026-03-29", "2026-10-25", "2028-02-29", "2026-12-31") + awkward.forEach { db.periodRecordDao().insert(period(it)) } + + assertEquals( + awkward.map(LocalDate::parse).sorted(), + db.periodRecordDao().observeConfirmed().first().map { it.startDate }, + ) + } +} diff --git a/core/database/src/test/kotlin/dev/privacyllc/period/core/database/SchemaTest.kt b/core/database/src/test/kotlin/dev/privacyllc/period/core/database/SchemaTest.kt new file mode 100644 index 0000000..e428391 --- /dev/null +++ b/core/database/src/test/kotlin/dev/privacyllc/period/core/database/SchemaTest.kt @@ -0,0 +1,212 @@ +package dev.privacyllc.period.core.database + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * What the committed schema says, checked against what the code produces. + * + * ## Read this before trusting it: it does NOT catch schema drift + * + * That is the obvious thing to assume it does, and it was written believing it. + * It was then proved otherwise, which is why the disclaimer is here rather than + * a comment saying it works. + * + * **Room regenerates the schema export during compilation.** By the time any + * test runs, `schemas//1.json` has already been overwritten to describe + * whatever the entities now say. Both sides of every comparison below therefore + * agree by construction. Adding a column to `PeriodRecordEntity` without + * touching [PeriodDatabase.VERSION] leaves this whole class green. + * + * The guard that does catch it is `scripts/schema-guard.sh`, which asks **git** + * — the one party Room cannot overwrite — whether an already-committed schema + * file has changed. It runs in `.githooks/pre-commit`. Its header records the + * proof. + * + * ## So what is this for + * + * Three things the script cannot see, all of them about internal consistency at + * a single point in time: + * + * - a schema file exists for the declared version, and declares that version; + * - every table and index Room generates is one the schema knows about, with + * identical SQL — which catches a converter or an index annotation that + * silently stopped applying; + * - the database grew no table nobody declared. + * + * Keep both. Neither is sufficient, and the pair is only trustworthy because + * each one's limits are written down. + * + * ## Why not MigrationTestHelper + * + * Every constructor of Room's own helper requires an `android.app.Instrumentation` + * and reads schemas from the test APK's assets. Wiring that up needs an asset + * source directory on the `test` source set, and AGP 9's library source-set DSL + * throws `DefaultAndroidLibrarySourceSet_Decorated cannot be cast to + * AndroidLibrarySourceSet` when you add one — a defect in the build tool, not + * in the wiring. The instrumented alternative needs an emulator, which is the + * kind of test that stops being run. + * + * When version 2 arrives, revisit: if the AGP defect is fixed by then, a real + * migration test is worth more than these. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SchemaTest { + + private lateinit var db: PeriodDatabase + + @Before fun open() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + PeriodDatabase::class.java, + ).allowMainThreadQueries().build() + // Room is lazy; touch it so the schema is actually created. + db.openHelper.writableDatabase + } + + @After fun close() = db.close() + + private fun schemaDir(): File { + var dir: File? = File("").absoluteFile + while (dir != null) { + val candidate = File(dir, "schemas/${PeriodDatabase::class.java.name}") + if (candidate.isDirectory) return candidate + dir = dir.parentFile + } + error("no schemas/ directory found from ${File("").absolutePath} upwards") + } + + private fun committedSchema(version: Int): JSONObject { + val f = File(schemaDir(), "$version.json") + assertTrue( + "schemas/${PeriodDatabase::class.java.name}/$version.json is missing — the " + + "database version was bumped without exporting the schema, so nothing " + + "can ever validate a migration into it", + f.isFile, + ) + return JSONObject(f.readText()).getJSONObject("database") + } + + /** + * Normalise the two cosmetic differences Room and SQLite genuinely + * disagree on, and nothing else. + * + * Room emits `CREATE TABLE IF NOT EXISTS`; SQLite stores what it was given + * minus that clause. Backticks are optional quoting. Everything past this — + * every column, type, nullability, default and constraint — is compared + * literally, which is the point. + */ + private fun normalise(sql: String) = sql + .replace("`", "") + .replace("IF NOT EXISTS ", "") + .replace(Regex("\\s+"), " ") + .replace(" ,", ",") + .trim() + + private fun liveSql(): Map { + val out = mutableMapOf() + db.openHelper.readableDatabase.query( + "SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'", + ).use { c -> + while (c.moveToNext()) out[c.getString(0)] = c.getString(1) + } + return out + } + + @Test + fun `a schema file is committed for the current database version`() { + val schema = committedSchema(PeriodDatabase.VERSION) + assertEquals( + "the committed schema declares a different version than PeriodDatabase.VERSION", + PeriodDatabase.VERSION, + schema.getInt("version"), + ) + } + + @Test + fun `every table in the committed schema exists with exactly the same SQL`() { + val schema = committedSchema(PeriodDatabase.VERSION) + val live = liveSql() + val entities = schema.getJSONArray("entities") + + assertTrue("the committed schema declares no tables at all", entities.length() > 0) + + for (i in 0 until entities.length()) { + val e = entities.getJSONObject(i) + val table = e.getString("tableName") + val expected = e.getString("createSql").replace("\${TABLE_NAME}", table) + + assertTrue("table '$table' is in the committed schema but not in the database", live.containsKey(table)) + assertEquals( + "table '$table' has drifted from the committed schema — change the entity " + + "and the schema was not re-exported, or the version was not bumped", + normalise(expected), + normalise(live.getValue(table)), + ) + } + } + + @Test + fun `every index in the committed schema exists with exactly the same SQL`() { + val schema = committedSchema(PeriodDatabase.VERSION) + val live = liveSql() + val entities = schema.getJSONArray("entities") + var checked = 0 + + for (i in 0 until entities.length()) { + val e = entities.getJSONObject(i) + val table = e.getString("tableName") + val indices = e.optJSONArray("indices") ?: continue + for (j in 0 until indices.length()) { + val idx = indices.getJSONObject(j) + val name = idx.getString("name") + val expected = idx.getString("createSql").replace("\${TABLE_NAME}", table) + + assertTrue("index '$name' is in the committed schema but not in the database", live.containsKey(name)) + assertEquals( + "index '$name' has drifted from the committed schema", + normalise(expected), + normalise(live.getValue(name)), + ) + checked++ + } + } + + // Four unique indices guard against duplicate dates. Zero would mean + // this test passed by checking nothing — exit code 2 in test form. + assertTrue("no indices were checked, so this test verified nothing", checked >= 4) + } + + @Test + fun `the database has no table the committed schema does not know about`() { + val schema = committedSchema(PeriodDatabase.VERSION) + val declared = buildSet { + val entities = schema.getJSONArray("entities") + for (i in 0 until entities.length()) add(entities.getJSONObject(i).getString("tableName")) + val views = schema.optJSONArray("views") + if (views != null) for (i in 0 until views.length()) add(views.getJSONObject(i).getString("viewName")) + } + + // room_master_table is Room's own identity-hash row; android_metadata is + // created by the platform's SQLiteDatabase and belongs to neither side. + val internal = setOf("room_master_table", "android_metadata", "sqlite_sequence") + val liveTables = liveSql().keys.filter { !it.startsWith("index_") && it !in internal } + val undeclared = liveTables.filterNot { it in declared } + + assertTrue( + "the database has tables the committed schema does not declare: $undeclared", + undeclared.isEmpty(), + ) + } +} diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4088936..93ff2ed 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -23,10 +23,10 @@ A second table here would be two records of one fact, and the other one would never hear that a script was renamed. So this file answers the questions that table does not, and points at it for everything else. -## Period has six of them, and that is deliberate +## Period has seven of them, and that is deliberate The template this repository adopted ships around twenty scripts. Period took -six. `scaffold.sh` copies none of them on purpose — *"an unconfigured +six of them and wrote one of its own. `scaffold.sh` copies none of them on purpose — *"an unconfigured `release.sh` landing in every new repository is a loaded gun, not a head start"* — so each one is taken having been read and configured. @@ -49,6 +49,11 @@ Two were deferred rather than declined: signing or Play credential, which is exactly the moment it becomes worth running. +**The seventh is `schema-guard.sh`, and it is this project's own.** It clears +the bar in *Adding one* below the hard way: it exists because the check it +replaces was proved green over exactly the failure it claimed to catch. Its +header carries that proof. + If this project later grows a script the template already has, take that one rather than writing a new one — the arguments in its header are the part that took the longest. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 78ca65c..41b8446 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -30,8 +30,7 @@ function calls. Nothing below the ViewModel knows Compose exists. ## Modules -Four today. core/database and core/datastore are Batch 01 issues #3 and #4 -and **do not exist yet** — a module created before it has contents is a place +Five today. core/datastore is Batch 01 issue #4 and **does not exist yet** — a module created before it has contents is a place for things to be put by accident. The wider layout sketched in [`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same way, with the batch that needs it. @@ -40,6 +39,7 @@ way, with the batch that needs it. | --- | --- | --- | --- | | `app` | Android application | `MainActivity`, the four-tab navigation shell, DI wiring | everything below | | `core/designsystem` | Android library | Material 3 theme, colour and type tokens | nothing in this project | +| `core/database` | Android library | Room entities, DAOs, converters, the schema export | `domain/cycle`, `domain/prediction` | | `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing | | `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` | @@ -49,7 +49,6 @@ there, and none of these are: | Module | Plugin | Owns | May depend on | Issue | | --- | --- | --- | --- | --- | -| core/database | Android library | Room entities, DAOs, migrations | `domain/cycle` | #3 | | core/datastore | Android library | `UserPreferences` | `domain/cycle` | #4 | | core/data | Android library | the repositories — the only things that touch a DAO | core/database, core/datastore, `domain/cycle` | #5 | | core/ads | Android library | the `AdProvider` implementation | **neither core/database nor `domain/*`** | Batch 07 | @@ -110,12 +109,34 @@ per migration, added in the same commit as the migration itself. The template this repository came from records why: a manual's migration table sat six behind, and every reader in between trusted it. -| Version | What changed | Migration test | -| --- | --- | --- | -| 1 | initial schema | — | +| Version | What changed | Migration | Guard | +| --- | --- | --- | --- | +| 1 | initial schema: `period_records`, `spotting_records`, `prediction_records`, `not_yet_observations` | — (first version) | `SchemaTest` + `scripts/schema-guard.sh` | -Room's exported schemas are committed, so a migration can be tested against the -real previous schema rather than a remembered one. +Room's exported schemas live in `core/database/schemas/` and are **committed**, +so a migration can be tested against the real previous schema rather than a +remembered one. + +### The trap in this table, and the guard that closes it + +**Room regenerates the schema export during compilation.** Change an entity +without bumping `PeriodDatabase.VERSION` and Room silently overwrites +`schemas/…/1.json` to match — so every in-process check compares two copies of +the new truth and passes. This was not reasoned about; it was proved, by adding +a column and watching the whole unit suite stay green while the committed schema +quietly changed underneath it. + +The failure that produces on a device is `Room cannot verify the data +integrity` — a crash on update, in front of a user, after shipping. + +`scripts/schema-guard.sh` is the guard, and it works by asking **git**, which is +the one party Room cannot overwrite: an already-committed schema file that now +differs means an entity changed under a shipped version. It runs in +`.githooks/pre-commit` whenever an entity or the schema directory is staged. + +So: **adding a row to this table is part of changing a schema, not tidying up +afterwards.** The version bump, the migration, the new schema file and this row +belong in one commit. ## Documents here @@ -136,6 +157,7 @@ menu is. | `scripts/commit-mine.sh` | commits only the paths you name, by pathspec, after the secret scan | | `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, with every rule of it as a check | | `scripts/prove-guard.sh` | breaks what a guard protects and requires the guard to go red | +| `scripts/schema-guard.sh` | a Room entity may not change without the version changing with it — asks git, because Room overwrites the export during the build | | `.githooks/` | pre-commit, commit-msg, post-commit — see [githooks/README.md](githooks/README.md) | ## What does not belong here diff --git a/docs/architecture/githooks/README.md b/docs/architecture/githooks/README.md index 07d8d85..3ae8e3b 100644 --- a/docs/architecture/githooks/README.md +++ b/docs/architecture/githooks/README.md @@ -39,7 +39,7 @@ which is the same class of problem the hooks exist to prevent. | Hook | Guard | | --- | --- | -| `pre-commit` | the staged-diff secret scan, then `:domain:cycle:test` and `:domain:prediction:test` when `.kt`/`.kts` or a build file is staged | +| `pre-commit` | the staged-diff secret scan; then `:domain:cycle:test` and `:domain:prediction:test` when `.kt`/`.kts` or a build file is staged; then `scripts/schema-guard.sh` when a Room entity or the schema export is staged | | `commit-msg` | refuses a message with no conventional type — the closed vocabulary is in the hook's own header | | `post-commit` | pushes to `origin`, so a guarded commit does not sit unpushed | @@ -57,6 +57,17 @@ acceptance cases from [`../../planning/PRODUCT_PLAN.md` §51](../../planning/PRODUCT_PLAN.md), which guard the one claim this product is built on. +**The schema guard is here rather than in the suite for a reason worth knowing.** +Room rewrites the schema export during compilation, so by the time any test +runs, both sides of any in-process comparison describe the changed entity and +agree. Only git can see that an already-committed schema file changed, and only +a hook can ask git before the commit exists. `scripts/schema-guard.sh` carries +the proof in its header — the check it replaced was watched staying green over +exactly the failure it claimed to catch. + +It exits `2` for "nothing was checked", and the hook treats `2` as a refusal. +A missing schema directory is not a clean schema. + **`post-commit` pushes.** That is the intent — the commit that first added a pre-commit hook to the project this came from sat unpushed for a day, guarded and invisible — but it is a surprise if you were not expecting it. It never diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6424c7..9eb7776 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,10 @@ hiltNavigationCompose = "1.4.0" junit = "4.13.2" androidxTestJunit = "1.3.0" espresso = "3.7.0" +room = "2.8.4" +sqlite = "2.7.0" +robolectric = "4.16.1" +androidxTestCore = "1.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -39,6 +43,15 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } + +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } + junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } @@ -50,3 +63,4 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +room = { id = "androidx.room", version.ref = "room" } diff --git a/scripts/schema-guard.sh b/scripts/schema-guard.sh new file mode 100755 index 0000000..d0d9c45 --- /dev/null +++ b/scripts/schema-guard.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# A Room entity may not change without the database version changing with it. +# +# ## The incident that motivated this +# +# Written 2026-08-18, immediately after the check it replaces was proved to be +# green over exactly the failure it claimed to catch. +# +# `core/database/src/test/.../SchemaTest.kt` compares the committed schema JSON +# against the SQL the current code produces, and it looked like a drift guard. +# It is not one, for a reason that is invisible until you try it: **Room +# regenerates the schema export during compilation**, overwriting +# `schemas//1.json` in place. So by the time any test runs, the "committed" +# file already describes the changed entity, both sides of the comparison agree, +# and the test passes. +# +# It was proved by adding a column to `PeriodRecordEntity` without touching +# `PeriodDatabase.VERSION`. The suite stayed green and the committed schema +# silently gained the column. On a device that is a crash on update, in front of +# a user, after shipping — `Room cannot verify the data integrity`. +# +# GUARDS.md §1 exists for precisely this: a guard nobody has watched fail is not +# yet evidence. +# +# ## What this does instead +# +# It asks git, which Room cannot overwrite. After a build has regenerated the +# exports, an EXISTING schema file that now differs from its committed contents +# means an entity changed under a version that has already shipped. A NEW file +# — `2.json` appearing — is a version bump, which is the correct way to change a +# schema and is allowed. +# +# bash scripts/schema-guard.sh # assumes exports are current +# bash scripts/schema-guard.sh --build # regenerate them first +# +# ## Exit codes +# +# 0 every committed schema file is unchanged, and one exists for the current +# version +# 1 a schema file changed without a version bump, or none exists for the +# current version +# 2 NOTHING WAS CHECKED — no schema directory, or git could not be read. +# **Two is not a pass** and no hook or CI step may treat it as one. + +set -uo pipefail + +cd "$(git rev-parse --show-toplevel 2>/dev/null)" || { + printf '\033[1mschema-guard:\033[0m not a git repository — nothing could be checked.\n' >&2 + exit 2 +} + +say() { printf '\033[1mschema-guard:\033[0m %s\n' "$*" >&2; } + +SCHEMA_DIR="core/database/schemas" +VERSION_SRC="core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt" + +if [ "${1:-}" = "--build" ]; then + say "regenerating the schema exports…" + ./gradlew --quiet :core:database:assembleDebug >/dev/null 2>&1 || { + say "the build failed, so the exports are not current and nothing was checked." + exit 2 + } +fi + +if [ ! -d "$SCHEMA_DIR" ]; then + say "$SCHEMA_DIR does not exist. Either the database module moved or the" + say "schema export was switched off — both mean this check did not run." + exit 2 +fi + +# The declared version, read from the source rather than assumed. +version=$(grep -oP 'const val VERSION\s*=\s*\K[0-9]+' "$VERSION_SRC" 2>/dev/null) +if [ -z "$version" ]; then + say "could not read PeriodDatabase.VERSION from $VERSION_SRC — nothing checked." + exit 2 +fi + +expected="$SCHEMA_DIR/dev.privacyllc.period.core.database.PeriodDatabase/${version}.json" +if [ ! -f "$expected" ]; then + say "database version is $version but $expected does not exist." + say "The version was bumped without exporting the schema, so no migration" + say "into it can ever be validated. Build once and commit the export." + exit 1 +fi + +# Tracked files under the schema directory whose contents differ from HEAD. +if ! modified=$(git diff --name-only -- "$SCHEMA_DIR" 2>/dev/null); then + say "could not read git status for $SCHEMA_DIR — nothing checked." + exit 2 +fi +staged=$(git diff --cached --name-only --diff-filter=M -- "$SCHEMA_DIR" 2>/dev/null) +changed=$(printf '%s\n%s\n' "$modified" "$staged" | grep -v '^$' | sort -u) + +if [ -n "$changed" ]; then + say "a schema file that is already committed has CHANGED:" + printf ' %s\n' $changed >&2 + say "" + say "That means an entity changed under database version $version, which has" + say "already been exported. Room overwrote the export during the build, so" + say "nothing else will notice — including the unit tests." + say "" + say "Either revert the entity change, or bump PeriodDatabase.VERSION, write" + say "the migration, and let a NEW schema file be exported beside this one." + say "Then add its row to the migration table in docs/architecture/README.md," + say "in the same commit." + exit 1 +fi + +# An untracked file here is a new version, which is the correct way to change a +# schema. Reported rather than passed over silently: it needs a migration. +untracked=$(git ls-files --others --exclude-standard -- "$SCHEMA_DIR" 2>/dev/null) +if [ -n "$untracked" ]; then + say "new schema file(s), so this is a version bump:" + printf ' %s\n' $untracked >&2 + say "Commit them, write the migration, and add the row to" + say "docs/architecture/README.md's migration table in the same commit." +fi + +count=$(find "$SCHEMA_DIR" -name '*.json' | wc -l) +say "ok — $count committed schema file(s) unchanged; version $version is exported." +exit 0 diff --git a/settings.gradle.kts b/settings.gradle.kts index f69e5ed..728b698 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,10 +22,11 @@ dependencyResolutionManagement { rootProject.name = "Period" -// Four modules today. core/database and core/datastore arrive with Batch 01 -// issues #4 and #5 — see docs/architecture/README.md for why a module is not -// created before it has contents. +// core/datastore arrives with Batch 01 issue #4, and core/data with #5 — see +// docs/architecture/README.md for why a module is not created before it has +// contents. include(":app") include(":core:designsystem") +include(":core:database") include(":domain:cycle") include(":domain:prediction")