Privacy-Period-Tracker/scripts/schema-guard.sh

123 lines
5.1 KiB
Bash
Raw Permalink Normal View History

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
2026-08-18 02:32:07 -05:00
#!/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