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

@ -38,6 +38,35 @@ func Open(path string) (*Store, error) {
return store, nil
}
// OpenReadOnly opens the state DB without running migrations and with
// SQLite's mode=ro flag so no write can slip through — the file and
// its WAL sidecar stay untouched. Used by `banger doctor`, which must
// be pure inspection: running it should never mutate user state, and
// it must not trigger a schema migration the user didn't ask for.
//
// Returns the usual sql.ErrNoRows-compatible errors from the read
// queries if the DB's schema is older than the current code expects;
// doctor surfaces those as failing checks rather than a hard crash.
func OpenReadOnly(path string) (*Store, error) {
dsn, err := sqliteReadOnlyDSN(path)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Ping forces SQLite to actually open the file, so a missing or
// unreadable DB fails here rather than at first query. Match the
// existing Open contract: caller expects success to mean "ready
// to read."
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
@ -66,6 +95,28 @@ func sqliteDSN(path string) (string, error) {
}).String(), nil
}
// sqliteReadOnlyDSN builds a DSN that opens the DB in SQLite's
// read-only mode. Deliberately omits journal_mode=WAL and the other
// write-adjacent pragmas set by sqliteDSN — mode=ro refuses them
// anyway, and keeping the list minimal means the query never touches
// the file. foreign_keys and busy_timeout are the only pragmas worth
// keeping for read paths (semantics parity + lock backoff).
func sqliteReadOnlyDSN(path string) (string, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve sqlite path: %w", err)
}
query := url.Values{}
query.Set("mode", "ro")
query.Add("_pragma", "foreign_keys(1)")
query.Add("_pragma", "busy_timeout(5000)")
return (&url.URL{
Scheme: "file",
Path: filepath.ToSlash(absPath),
RawQuery: query.Encode(),
}).String(), nil
}
func (s *Store) UpsertImage(ctx context.Context, image model.Image) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()