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) } // devices moved to a stable opaque device_id primary key (Auth Broker path A); // the old table was keyed (user_id, name). Device rows are ephemeral registrations // re-created on the next request / scan, so a legacy table is dropped and recreated // rather than column-migrated (SQLite can't ALTER a primary key in place). if err := recreateDevicesIfLegacy(ctx, tx); err != nil { return fmt.Errorf("migrate devices to device_id: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit bootstrap: %w", err) } return nil } // recreateDevicesIfLegacy drops and recreates the devices table when it predates the // device_id primary key (the legacy (user_id, name) shape). On a fresh DB the init // schema already created the new shape, so device_id is present and this no-ops. The // dropped rows are ephemeral device registrations, re-created on the next request or // scan-login, so no durable data is lost. func recreateDevicesIfLegacy(ctx context.Context, tx *sql.Tx) error { has, err := columnExists(ctx, tx, "devices", "device_id") if err != nil { return err } if has { return nil } stmts := []string{ "DROP TABLE IF EXISTS devices", `CREATE TABLE devices ( device_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'full', broker_sid TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL )`, "CREATE INDEX IF NOT EXISTS idx_devices_user ON devices (user_id)", } for _, s := range stmts { if _, err := tx.ExecContext(ctx, s); err != nil { return 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() }