feat: Room database for cycle history, and the schema guard that actually works
core/database holds the four entities from PRODUCT_PLAN.md §10 —
period_records, spotting_records, prediction_records, not_yet_observations —
with DAOs returning Flow, epoch-day/epoch-milli converters, and the schema
exported to core/database/schemas and committed.
Three constraints are structural rather than remembered:
- startDate is UNIQUE and inserts ABORT rather than REPLACE. REPLACE would
delete the original row with its createdAt and source; §14 says health
history is never modified silently.
- spotting has its own table, so no query for periods can reach it. §25: it
must never start or reset a cycle.
- a prediction snapshot can be scored but not rewritten — score() sets only
actualStartDate and absoluteErrorDays. A snapshot editable after the fact
can only ever report that the app was right, which would make §16's whole
accuracy feature a lie.
deleteEverything() is one transaction and the only bulk delete in the module: a
partial wipe leaves the cycle reconstructible from the tables the user asked to
be rid of.
14 tests, on the JVM under Robolectric — no emulator.
THE SCHEMA GUARD, AND WHY IT IS A SCRIPT
SchemaTest was written as a drift guard and proved not to be one. Room
regenerates the schema export during compilation, so adding a column to
PeriodRecordEntity without bumping VERSION leaves the suite green while the
committed schema quietly changes underneath it. That was not reasoned about, it
was run: the column was added, 1.json gained it, and every test passed. On a
device that is "Room cannot verify the data integrity" — a crash on update,
after shipping.
scripts/schema-guard.sh asks git instead, which Room cannot overwrite. Proved
both ways before being trusted: green on a clean tree, exit 1 on the injected
drift. It runs in pre-commit when an entity or the schema directory is staged,
and the hook treats its exit 2 as a refusal.
SchemaTest keeps its four tests and now documents what it does not catch.
Room's own MigrationTestHelper is not used: every constructor needs an
Instrumentation and schema assets, and AGP 9's library source-set DSL throws
DefaultAndroidLibrarySourceSet_Decorated cannot be cast to
AndroidLibrarySourceSet when you add an asset directory. Recorded so the next
person does not spend the afternoon on it.
Docs updated in this commit, as their triggers required: the migration table
now has its version 1 row and the trap that makes such tables go stale, TOOLS
explains the seventh script, and the hooks README lists the new guard.
closes #3
This commit is contained in:
parent
d03eecde31
commit
67b3c45002
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<PeriodRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM period_records ORDER BY startDate ASC")
|
||||
fun observeAll(): Flow<List<PeriodRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM period_records WHERE isConfirmed = 1 ORDER BY startDate DESC LIMIT 1")
|
||||
fun observeLatestConfirmed(): Flow<PeriodRecordEntity?>
|
||||
|
||||
@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<List<SpottingRecordEntity>>
|
||||
|
||||
@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<PredictionRecordEntity?>
|
||||
|
||||
/** 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<List<PredictionRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM prediction_records WHERE actualStartDate IS NULL ORDER BY generatedAt DESC")
|
||||
suspend fun unscored(): List<PredictionRecordEntity>
|
||||
|
||||
@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<List<NotYetObservationEntity>>
|
||||
|
||||
@Query("SELECT * FROM not_yet_observations WHERE date >= :since ORDER BY date ASC")
|
||||
suspend fun since(since: LocalDate): List<NotYetObservationEntity>
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<db>/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<String, String> {
|
||||
val out = mutableMapOf<String, String>()
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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/<db>/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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue