#!/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