1933306392
- 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
276 lines
6.5 KiB
Go
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(¤tVersion); 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
|
|
}
|