// Package store owns the SQLite database: opening it, applying the schema, and // exposing a small query surface to the rest of the service. package store import ( "context" "database/sql" _ "embed" "fmt" "net/url" "os" "path/filepath" "time" // Pure-Go SQLite driver. Keeps the container CGO-free. _ "modernc.org/sqlite" ) //go:embed schema.sql var schemaSQL string // Store wraps the SQLite connection pool. type Store struct { db *sql.DB path string } // Open creates (if needed) and connects to the SQLite database at path, then // applies the schema. The parent directory is created when missing. func Open(ctx context.Context, path string) (*Store, error) { if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create database directory %q: %w", dir, err) } } dsn := buildDSN(path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite database: %w", err) } // SQLite handles one writer at a time; keep the pool small and give the // busy handler room. Reads and writes share the connection here. db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) db.SetConnMaxLifetime(0) pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := db.PingContext(pingCtx); err != nil { _ = db.Close() return nil, fmt.Errorf("ping sqlite database: %w", err) } s := &Store{db: db, path: path} if err := s.migrate(ctx); err != nil { _ = db.Close() return nil, err } return s, nil } // buildDSN returns a modernc.org/sqlite DSN with safe pragmas applied on every // new connection. func buildDSN(path string) string { abs, err := filepath.Abs(path) if err != nil { abs = path } q := url.Values{} q.Add("_pragma", "busy_timeout(5000)") q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "foreign_keys(1)") q.Add("_pragma", "synchronous(NORMAL)") return "file:" + abs + "?" + q.Encode() } // migrate applies the embedded schema. It is safe to run on every start. func (s *Store) migrate(ctx context.Context) error { if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil { return fmt.Errorf("apply schema: %w", err) } return nil } // DB exposes the underlying pool for callers that need it. func (s *Store) DB() *sql.DB { return s.db } // Path returns the database file path. func (s *Store) Path() string { return s.path } // Ping verifies the database is reachable. func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } // Close releases the connection pool. func (s *Store) Close() error { if s == nil || s.db == nil { return nil } return s.db.Close() } // SchemaVersion returns the highest applied migration version. func (s *Store) SchemaVersion(ctx context.Context) (int, error) { var v sql.NullInt64 err := s.db.QueryRowContext(ctx, `SELECT MAX(version) FROM schema_migrations`).Scan(&v) if err != nil { return 0, err } if !v.Valid { return 0, nil } return int(v.Int64), nil }