// Package database owns the SQLite connection, pragmas, and migrations. package database import ( "database/sql" "embed" "fmt" "io/fs" "sort" "strings" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationsFS embed.FS // Open opens (creating if needed) the SQLite database at path, applies // pragmas suited for a long-running server (WAL, foreign keys, busy // timeout), and runs any pending migrations. func Open(path string) (*sql.DB, error) { dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } // SQLite only tolerates a single writer; keep the pool modest so // writers serialize instead of colliding on SQLITE_BUSY. db.SetMaxOpenConns(8) pragmas := []string{ "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "PRAGMA foreign_keys=ON", "PRAGMA busy_timeout=5000", } for _, p := range pragmas { if _, err := db.Exec(p); err != nil { db.Close() return nil, fmt.Errorf("pragma %q: %w", p, err) } } if err := migrate(db); err != nil { db.Close() return nil, fmt.Errorf("migrate: %w", err) } return db, nil } func migrate(db *sql.DB) error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )`); err != nil { return err } entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return err } var names []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { names = append(names, e.Name()) } } sort.Strings(names) for _, name := range names { var applied int if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, name).Scan(&applied); err != nil { return err } if applied > 0 { continue } content, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return err } tx, err := db.Begin() if err != nil { return err } if _, err := tx.Exec(string(content)); err != nil { tx.Rollback() return fmt.Errorf("applying %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil { tx.Rollback() return err } if err := tx.Commit(); err != nil { return err } } return nil }