package db import ( "context" "database/sql" _ "embed" "fmt" "net/url" _ "modernc.org/sqlite" ) //go:embed migrations/0001_init.sql var initSchema string // Open dials a sqlite db with WAL + busy timeout pragmas wired in. // dbPath is the on-disk file path; passing ":memory:" works for tests. func Open(dbPath string) (*sql.DB, error) { dsn := buildDSN(dbPath) d, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } if err := d.PingContext(context.Background()); err != nil { _ = d.Close() return nil, fmt.Errorf("ping sqlite: %w", err) } return d, nil } func buildDSN(dbPath string) string { q := url.Values{} q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "busy_timeout(5000)") q.Add("_pragma", "foreign_keys(1)") return dbPath + "?" + q.Encode() } // Bootstrap runs the embedded init schema in a single transaction. // Idempotent: every CREATE TABLE uses IF NOT EXISTS. func Bootstrap(ctx context.Context, d *sql.DB) error { tx, err := d.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin bootstrap tx: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, initSchema); err != nil { return fmt.Errorf("exec init schema: %w", err) } // Additive migration for DBs created before clipboard_state.version existed: // CREATE TABLE IF NOT EXISTS won't add the column to an existing table, and // SQLite has no ADD COLUMN IF NOT EXISTS, so guard it with a pragma check. if err := ensureColumn(ctx, tx, "clipboard_state", "version", "ALTER TABLE clipboard_state ADD COLUMN version INTEGER NOT NULL DEFAULT 0"); err != nil { return fmt.Errorf("migrate clipboard_state.version: %w", err) } if err := ensureColumn(ctx, tx, "clipboard_state", "origin_ts", "ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil { return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err) } // web_sessions gained kind/scope/granted_by for cdrop self-signed sessions // (AUTH.md §2.2). Existing rows predate these columns; backfill them so a // pre-existing browser session keeps behaving as a normal IdP-backed login. if err := ensureColumn(ctx, tx, "web_sessions", "kind", "ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'"); err != nil { return fmt.Errorf("migrate web_sessions.kind: %w", err) } if err := ensureColumn(ctx, tx, "web_sessions", "scope", "ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'"); err != nil { return fmt.Errorf("migrate web_sessions.scope: %w", err) } if err := ensureColumn(ctx, tx, "web_sessions", "granted_by", "ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil { return fmt.Errorf("migrate web_sessions.granted_by: %w", err) } if err := ensureColumn(ctx, tx, "web_sessions", "stepped_up_at", "ALTER TABLE web_sessions ADD COLUMN stepped_up_at INTEGER NOT NULL DEFAULT 0"); err != nil { return fmt.Errorf("migrate web_sessions.stepped_up_at: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit bootstrap: %w", err) } return nil } // ensureColumn runs addSQL only when table lacks the named column — an idempotent // additive migration for already-created tables. func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error { has, err := columnExists(ctx, tx, table, column) if err != nil { return err } if has { return nil } _, err = tx.ExecContext(ctx, addSQL) return err } func columnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) { // table is a trusted compile-time constant, not user input. rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")") if err != nil { return false, err } defer rows.Close() for rows.Next() { var ( cid, notnull, pk int name, ctype string dflt sql.NullString ) if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { return false, err } if name == column { return true, nil } } return false, rows.Err() }