Files
kindexr/internal/db/db.go
T
enki 1933306392 cleanup: pre-phase-2 fixes
- fix two stale nzbstr comment refs
- migration 002: drop api_keys.visibility and curation_set
- remove Visibility from APIKey struct, GetAPIKey, CreateAPIKey, CLI
- omit torznab size/seeders/peers attrs when data is absent
- reset relay backoff on successful connection (>= 30s uptime)
- use last_event from relays table as since on reconnect
- fix TestSearchWithResults to actually query the test server DB
2026-05-16 23:21:22 -07:00

276 lines
6.5 KiB
Go

// Package db provides SQLite database access and migration management for kindexr.
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
_ "modernc.org/sqlite" // pure-Go SQLite driver, no CGO
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// DB wraps *sql.DB with kindexr-specific helpers.
type DB struct {
*sql.DB
}
// Open opens (or creates) the SQLite database at path, applies pending
// migrations, and returns a ready-to-use *DB. The parent directory is created
// if it does not exist.
func Open(path string) (*DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("db: create parent directory: %w", err)
}
dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("db: open: %w", err)
}
// SQLite WAL allows concurrent reads but serialises writes; one writer.
sqlDB.SetMaxOpenConns(1)
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("db: ping: %w", err)
}
d := &DB{sqlDB}
if err := d.migrate(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("db: migrate: %w", err)
}
return d, nil
}
// migrate creates the schema_migrations table and applies any unapplied
// migration files found in the embedded migrations/ directory.
func (d *DB) migrate() error {
// Ensure the migrations tracking table exists.
_, err := d.DB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// Find the current applied version.
var currentVersion int
row := d.DB.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`)
if err := row.Scan(&currentVersion); err != nil {
return fmt.Errorf("query current version: %w", err)
}
// Read embedded migration files.
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
// Sort by filename so they apply in order.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".sql") {
continue
}
// Parse version from prefix: "001_initial.sql" → 1
parts := strings.SplitN(name, "_", 2)
if len(parts) < 1 {
continue
}
version, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("parse version from %q: %w", name, err)
}
if version <= currentVersion {
continue // already applied
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %q: %w", name, err)
}
statements := splitStatements(string(sqlBytes))
tx, err := d.DB.Begin()
if err != nil {
return fmt.Errorf("begin transaction for migration %d: %w", version, err)
}
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d, statement %q: %w", version, stmt[:min(len(stmt), 60)], err)
}
}
_, err = tx.Exec(
`INSERT INTO schema_migrations(version, applied_at) VALUES (?, datetime('now'))`,
version,
)
if err != nil {
tx.Rollback()
return fmt.Errorf("record migration %d: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %d: %w", version, err)
}
}
return nil
}
// splitStatements splits a SQL script into individual statements, handling
// single-quoted strings, -- line comments, and BEGIN...END trigger bodies so
// that semicolons inside them are not treated as statement boundaries.
func splitStatements(sql string) []string {
var statements []string
var current strings.Builder
var word strings.Builder
inString := false
depth := 0 // tracks BEGIN...END nesting for trigger bodies
i := 0
flushWord := func() {
switch strings.ToUpper(word.String()) {
case "BEGIN":
depth++
case "END":
depth--
}
word.Reset()
}
for i < len(sql) {
ch := sql[i]
if inString {
current.WriteByte(ch)
if ch == '\'' {
if i+1 < len(sql) && sql[i+1] == '\'' {
current.WriteByte(sql[i+1])
i += 2
continue
}
inString = false
}
i++
continue
}
// Outside a string.
if ch == '\'' {
flushWord()
inString = true
current.WriteByte(ch)
i++
continue
}
// Line comment: skip to end of line.
if ch == '-' && i+1 < len(sql) && sql[i+1] == '-' {
flushWord()
for i < len(sql) && sql[i] != '\n' {
i++
}
continue
}
// Accumulate identifier/keyword characters.
if isWordChar(ch) {
word.WriteByte(ch)
current.WriteByte(ch)
i++
continue
}
// Non-word character: finalize any pending keyword.
flushWord()
if ch == ';' {
if depth == 0 {
stmt := strings.TrimSpace(current.String())
if stmt != "" {
statements = append(statements, stmt)
}
current.Reset()
} else {
current.WriteByte(ch)
}
i++
continue
}
current.WriteByte(ch)
i++
}
// Catch any trailing statement without a trailing semicolon.
flushWord()
if stmt := strings.TrimSpace(current.String()); stmt != "" {
statements = append(statements, stmt)
}
return statements
}
func isWordChar(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
}
// Stats holds aggregate statistics about the indexed content.
type Stats struct {
EventsTotal int64
LastEventAt *int64
}
// GetStats returns aggregate statistics from the torrents table.
func (d *DB) GetStats(ctx context.Context) (Stats, error) {
row := d.DB.QueryRowContext(ctx, `SELECT COUNT(*), MAX(created_at) FROM torrents`)
var s Stats
var lastEventAt sql.NullInt64
if err := row.Scan(&s.EventsTotal, &lastEventAt); err != nil {
return Stats{}, fmt.Errorf("get stats: %w", err)
}
if lastEventAt.Valid {
s.LastEventAt = &lastEventAt.Int64
}
return s, nil
}
// MigrationVersion returns the highest applied migration version.
func (d *DB) MigrationVersion(ctx context.Context) (int, error) {
var version int
row := d.DB.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`)
if err := row.Scan(&version); err != nil {
return 0, fmt.Errorf("migration version: %w", err)
}
return version, nil
}