Initial commit
This commit is contained in:
140
internal/storage/db.go
Normal file
140
internal/storage/db.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
globalDB *sql.DB
|
||||
)
|
||||
|
||||
// Init opens (or creates) the SQLite database and runs migrations.
|
||||
func Init(dbPath string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if globalDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
d, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
d.SetMaxOpenConns(1)
|
||||
|
||||
if err := runMigrations(d); err != nil {
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
globalDB = d
|
||||
slog.Info("database initialized", "path", dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the global database handle. Panics if Init was not called.
|
||||
func Get() *sql.DB {
|
||||
mu.RLock()
|
||||
db := globalDB
|
||||
mu.RUnlock()
|
||||
if db == nil {
|
||||
panic("storage.Get() called before storage.Init()")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// Close closes the global database handle.
|
||||
func Close() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if globalDB != nil {
|
||||
err := globalDB.Close()
|
||||
globalDB = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMigrations(d *sql.DB) error {
|
||||
if _, err := d.Exec(schema); err != nil {
|
||||
return fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
|
||||
// Idempotent column adds for DBs created before the dedup columns existed.
|
||||
// SQLite errors with "duplicate column name" when the column is already there;
|
||||
// we swallow that specifically.
|
||||
addColumnIfMissing(d, "stories", "url_canonical", "TEXT")
|
||||
addColumnIfMissing(d, "stories", "headline_norm", "TEXT")
|
||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||
|
||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||
// Check sqlite_master before creating.
|
||||
var ftsExists int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='stories_fts'`).Scan(&ftsExists)
|
||||
if ftsExists == 0 {
|
||||
if _, err := d.Exec(ftsSchema); err != nil {
|
||||
return fmt.Errorf("create FTS5 table: %w", err)
|
||||
}
|
||||
if _, err := d.Exec(ftsTriggers); err != nil {
|
||||
return fmt.Errorf("create FTS5 triggers: %w", err)
|
||||
}
|
||||
slog.Info("created FTS5 search index")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunMaintenance prunes stale data. Called periodically.
|
||||
func RunMaintenance(recentWindowHours, classificationLogDays int) {
|
||||
recentCutoff := nowUnix() - int64(recentWindowHours*3600)
|
||||
exec("prune recent_headlines",
|
||||
`DELETE FROM recent_headlines WHERE seen_at < ?`, recentCutoff)
|
||||
|
||||
logCutoff := nowUnix() - int64(classificationLogDays*86400)
|
||||
exec("prune classification_log",
|
||||
`DELETE FROM classification_log WHERE logged_at < ?`, logCutoff)
|
||||
|
||||
// Prune old stories (30 days) and their post logs / reactions
|
||||
storyCutoff := nowUnix() - int64(30*86400)
|
||||
exec("prune old stories",
|
||||
`DELETE FROM stories WHERE seen_at < ? AND classified = 1`, storyCutoff)
|
||||
exec("prune old post_log",
|
||||
`DELETE FROM post_log WHERE posted_at < ?`, storyCutoff)
|
||||
exec("prune old reactions",
|
||||
`DELETE FROM reactions WHERE reacted_at < ?`, storyCutoff)
|
||||
|
||||
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
exec("optimize", "PRAGMA optimize")
|
||||
}
|
||||
|
||||
// exec is a fire-and-forget helper that logs errors.
|
||||
func exec(label, query string, args ...any) {
|
||||
if _, err := Get().Exec(query, args...); err != nil {
|
||||
slog.Error("db exec failed", "op", label, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
||||
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
// SQLite returns "duplicate column name" when the column already exists.
|
||||
if !strings.Contains(err.Error(), "duplicate column name") {
|
||||
slog.Error("alter table failed", "table", table, "column", column, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user