doctor: open the state DB read-only so inspection never mutates it

`banger doctor` used to call store.Open, which unconditionally runs
migrations on the way up. Diagnostics mutating persistent state is a
surprise — particularly now that migration 2 drops a column, so a
plain `doctor` invocation against an old DB would silently schema-
evolve it.

Add store.OpenReadOnly: separate DSN builder with mode=ro and a
minimal pragma set (foreign_keys, busy_timeout — no journal_mode=WAL,
no wal_autocheckpoint), skips runMigrations, and pings on open so a
missing DB fails up front rather than at first query. doctor.go now
uses OpenReadOnly; the existing storeErr fallback path surfaces any
failure as a failing check, unchanged.

Tests pin two invariants:
- OpenReadOnly against a DB whose migration 2 marker was removed and
  packages_path re-added must leave both alone (i.e. no drift is
  applied behind the user's back).
- Any write attempted through the read-only handle is rejected at
  the driver layer (belt-and-braces for future refactors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-22 11:05:23 -03:00
parent 129475be20
commit 2685bc73f8
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 141 additions and 1 deletions

View file

@ -24,7 +24,12 @@ func Doctor(ctx context.Context) (system.Report, error) {
if err != nil {
return system.Report{}, err
}
db, storeErr := store.Open(layout.DBPath)
// Doctor must be read-only: running it should never mutate the
// state DB (no migrations, no WAL checkpoint, no pragma writes).
// If the DB is missing or unreadable the storeErr path surfaces
// it as a failing check rather than half-opening a writable
// handle.
db, storeErr := store.OpenReadOnly(layout.DBPath)
d := &Daemon{
layout: layout,
config: cfg,