// 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, then any incremental migrations that // add columns to a database created by an earlier release. 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 s.applyMigrations(ctx) } // applyMigrations brings an existing database up to the latest schema version. // Version 1 is the base schema (schema.sql); version 2 adds IMAP bookkeeping // columns (AGENTS.md ยง9 / Phase 2). Each step is idempotent. func (s *Store) applyMigrations(ctx context.Context) error { version, err := s.SchemaVersion(ctx) if err != nil { return fmt.Errorf("read schema version: %w", err) } if version < 2 { for _, col := range []struct{ table, name, typ string }{ {"messages", "imap_uid", "INTEGER"}, {"messages", "imap_uidvalidity", "INTEGER"}, } { if err := s.ensureColumn(ctx, col.table, col.name, col.typ); err != nil { return err } } if _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO schema_migrations (version) VALUES (2)`); err != nil { return fmt.Errorf("record schema version 2: %w", err) } } return nil } // ensureColumn adds a column when it is missing. SQLite has no // "ADD COLUMN IF NOT EXISTS", so the current shape is inspected first. func (s *Store) ensureColumn(ctx context.Context, table, name, typ string) error { rows, err := s.db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table)) if err != nil { return fmt.Errorf("inspect %s: %w", table, err) } defer rows.Close() for rows.Next() { var ( cid, notnull, pk int dflt sql.NullString colName, colType string ) if err := rows.Scan(&cid, &colName, &colType, ¬null, &dflt, &pk); err != nil { return fmt.Errorf("scan %s columns: %w", table, err) } if colName == name { return nil } } if err := rows.Err(); err != nil { return fmt.Errorf("inspect %s columns: %w", table, err) } if _, err := s.db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, name, typ)); err != nil { return fmt.Errorf("add %s.%s: %w", table, name, 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 }