package storage import ( "database/sql" "fmt" "os" "path/filepath" "strings" "time" _ "github.com/mattn/go-sqlite3" ) // DB wraps sql.DB with Verstak-specific operations. type DB struct { *sql.DB path string } // Open opens or creates a SQLite database at path and runs pending migrations. func Open(path string) (*DB, error) { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create db dir: %w", err) } db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", path)) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } db.SetMaxOpenConns(1) w := &DB{DB: db, path: path} if err := w.runInitialSchema(); err != nil { db.Close() return nil, err } if err := w.runMigrations(); err != nil { db.Close() return nil, err } if err := w.BackfillTitleLower(); err != nil { db.Close() return nil, err } if err := w.BackfillLinksLower(); err != nil { db.Close() return nil, err } if err := w.BackfillActionsLower(); err != nil { db.Close() return nil, err } // Rebuild FTS5 index to pick up lowercased indexing changes _ = w.RebuildFTS() return w, nil } // Path returns the on-disk path of the database file. func (db *DB) Path() string { return db.path } // --- internals --- const schemaVersionDDL = ` CREATE TABLE IF NOT EXISTS _schema_ver ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL ); ` var migrationFiles = map[int]string{ 1: migration001, 2: migration002, 3: migration003, 4: migration004, 5: migration005, 6: migration006, // 7: migration007 (FTS5) — created lazily by search.Rebuild() 8: migration008, 9: migration009, 10: migration010, 11: migration011, 12: migration012, 13: migration013, 14: migration014, 15: migration015, 16: migration016, 17: migration017, 18: migration018, 19: migration019, 20: migration020, } func (db *DB) runInitialSchema() error { _, err := db.Exec(schemaVersionDDL) return err } func (db *DB) runMigrations() error { var currentVer int if err := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM _schema_ver").Scan(¤tVer); err != nil { return err } // Build sorted version list. versions := make([]int, 0, len(migrationFiles)) for v := range migrationFiles { versions = append(versions, v) } for i := 0; i < len(versions); i++ { for j := i + 1; j < len(versions); j++ { if versions[j] < versions[i] { versions[i], versions[j] = versions[j], versions[i] } } } for _, v := range versions { if v <= currentVer { continue } if err := db.applyMigration(v, migrationFiles[v]); err != nil { return fmt.Errorf("migrate v%d: %w", v, err) } } return nil } func (db *DB) applyMigration(version int, raw string) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() for _, stmt := range strings.Split(raw, ";") { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } if _, err := tx.Exec(stmt); err != nil { return fmt.Errorf("exec stat: %w; sql=%.200s", err, stmt) } } if _, err := tx.Exec("INSERT INTO _schema_ver (version, applied_at) VALUES (?, ?)", version, time.Now().UTC().Format(time.RFC3339)); err != nil { return err } return tx.Commit() } // BackfillTitleLower populates title_lower for rows where it is still empty. // Uses Go strings.ToLower for Unicode-aware case folding. func (db *DB) BackfillTitleLower() error { rows, err := db.Query("SELECT id, title FROM nodes WHERE title_lower = '' AND deleted_at IS NULL") if err != nil { return err } defer rows.Close() type pair struct { id string lower string } var pairs []pair for rows.Next() { var id, title string if err := rows.Scan(&id, &title); err != nil { return err } pairs = append(pairs, pair{id: id, lower: strings.ToLower(title)}) } if err := rows.Err(); err != nil { return err } for _, p := range pairs { if _, err := db.Exec("UPDATE nodes SET title_lower = ? WHERE id = ?", p.lower, p.id); err != nil { return err } } return nil } // BackfillLinksLower populates lowercased columns for links where they are empty. func (db *DB) BackfillLinksLower() error { rows, err := db.Query("SELECT id, title, url, hostname, COALESCE(note,'') FROM links WHERE title_lower = ''") if err != nil { return err } defer rows.Close() type linkLower struct { id, title, url, hostname, note string } var items []linkLower for rows.Next() { var l linkLower if err := rows.Scan(&l.id, &l.title, &l.url, &l.hostname, &l.note); err != nil { return err } items = append(items, l) } if err := rows.Err(); err != nil { return err } for _, l := range items { if _, err := db.Exec( "UPDATE links SET title_lower=?, url_lower=?, hostname_lower=?, note_lower=? WHERE id=?", strings.ToLower(l.title), strings.ToLower(l.url), strings.ToLower(l.hostname), strings.ToLower(l.note), l.id, ); err != nil { return err } } return nil } // BackfillActionsLower populates lowercased columns for actions where they are empty. func (db *DB) BackfillActionsLower() error { rows, err := db.Query("SELECT id, title, kind, COALESCE(url,''), COALESCE(command,'') FROM actions WHERE title_lower = ''") if err != nil { return err } defer rows.Close() type actionLower struct { id, title, kind, url, command string } var items []actionLower for rows.Next() { var a actionLower if err := rows.Scan(&a.id, &a.title, &a.kind, &a.url, &a.command); err != nil { return err } items = append(items, a) } if err := rows.Err(); err != nil { return err } for _, a := range items { if _, err := db.Exec( "UPDATE actions SET title_lower=?, kind_lower=?, url_lower=?, command_lower=? WHERE id=?", strings.ToLower(a.title), strings.ToLower(a.kind), strings.ToLower(a.url), strings.ToLower(a.command), a.id, ); err != nil { return err } } return nil } // RebuildFTS rebuilds the FTS5 search index (e.g. after schema changes). func (db *DB) RebuildFTS() error { _, _ = db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( node_id UNINDEXED, title, content, path, tags, type)`) _, err := db.Exec("DELETE FROM search_index") return err }