262 lines
6.1 KiB
Go
262 lines
6.1 KiB
Go
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
|
|
}
|