// Package store owns the SelfPost SQLite database: the single file under /data // that persists the administrator account, sending domains and applications, // the send log and rate-limit settings (spec 9). It exposes typed queries so // the rest of the panel never builds SQL by hand. package store import ( "database/sql" "embed" "fmt" "io/fs" "sort" _ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo), keeps the static build ) //go:embed migrations/*.sql var migrationsFS embed.FS // Store wraps the database connection pool. type Store struct { db *sql.DB } // Open opens (creating if needed) the SQLite database at path, enables WAL and // foreign keys, and applies any pending migrations. The caller owns Close. func Open(path string) (*Store, error) { // _pragma parameters are applied on every pooled connection by the driver, // so foreign-key enforcement and WAL survive connection churn. dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } // modernc's driver serializes writes anyway; a small pool avoids // "database is locked" surprises under WAL. db.SetMaxOpenConns(1) s := &Store{db: db} if err := s.migrate(); err != nil { db.Close() return nil, err } return s, nil } // Close closes the underlying database. func (s *Store) Close() error { return s.db.Close() } // migrate applies embedded migrations in filename order, tracking progress via // SQLite's PRAGMA user_version so each migration runs at most once. func (s *Store) migrate() error { entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return fmt.Errorf("read migrations: %w", err) } names := make([]string, 0, len(entries)) for _, e := range entries { if !e.IsDir() { names = append(names, e.Name()) } } sort.Strings(names) var version int if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { return fmt.Errorf("read schema version: %w", err) } for i, name := range names { target := i + 1 if target <= version { continue } sqlBytes, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin migration %s: %w", name, err) } if _, err := tx.Exec(string(sqlBytes)); err != nil { tx.Rollback() return fmt.Errorf("apply migration %s: %w", name, err) } // PRAGMA does not accept a bound parameter, and target is a trusted // loop index, so formatting it in is safe. if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil { tx.Rollback() return fmt.Errorf("bump schema version for %s: %w", name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", name, err) } } return nil }