Files
at-container-registry/pkg/appview/db/schema.go
2026-04-19 18:01:17 -05:00

380 lines
11 KiB
Go

// Package db provides the database layer for the AppView web UI, including
// SQLite schema initialization, migrations, and query functions for OAuth
// sessions, device flows, repository metadata, stars, pull counts, and
// user profiles.
package db
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/tursodatabase/go-libsql"
"go.yaml.in/yaml/v4"
)
//go:embed migrations/*.yaml
var migrationsFS embed.FS
//go:embed schema.sql
var schemaSQL string
// LibsqlConfig holds optional libSQL sync settings for embedded replicas.
// When SyncURL is empty, the database operates in local-only mode.
type LibsqlConfig struct {
SyncURL string
AuthToken string
SyncInterval time.Duration
}
// InitDB initializes the database with the schema.
// Uses libSQL driver: local-only when cfg.SyncURL is empty,
// embedded replica when cfg.SyncURL is set.
func InitDB(path string, cfg LibsqlConfig) (*sql.DB, error) {
var db *sql.DB
if cfg.SyncURL != "" {
// Embedded replica mode: local file + sync to remote
opts := []libsql.Option{
libsql.WithAuthToken(cfg.AuthToken),
}
if cfg.SyncInterval > 0 {
opts = append(opts, libsql.WithSyncInterval(cfg.SyncInterval))
}
connector, err := libsql.NewEmbeddedReplicaConnector(path, cfg.SyncURL, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create libsql embedded replica connector: %w", err)
}
db = sql.OpenDB(connector)
slog.Info("Database opened in embedded replica mode", "path", path, "sync_url", cfg.SyncURL)
} else {
// Local-only mode: plain file via libsql driver
// Paths starting with "file:" or ":memory:" are already valid libsql URIs
dsn := path
if !strings.HasPrefix(path, "file:") && !strings.HasPrefix(path, ":memory:") {
dsn = "file:" + path
}
var err error
db, err = sql.Open("libsql", dsn)
if err != nil {
return nil, err
}
slog.Info("Database opened in local-only mode", "path", path)
}
// In local-only mode, configure WAL and busy_timeout locally.
// In embedded replica mode, the remote server manages these settings
// and PRAGMA assignments are rejected as "unsupported statement"
// (observed with Bunny Database; Turso may behave similarly).
if cfg.SyncURL == "" {
// Enable WAL mode for concurrent read/write access
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
return nil, err
}
// Retry on lock instead of failing immediately (5s timeout)
var busyTimeout int
if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
return nil, err
}
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return nil, err
}
// Bound the connection pool. With a remote target (Bunny Database), each
// idle conn is a stable libsql stream — keeping a handful warm avoids
// reconnect cost, capping the total prevents runaway contention. Short
// lifetimes ensure we recycle past any idle-side disconnects and drop any
// poisoned conn that survived IsPoisonedTxErr eviction.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
// Check if this is an existing database with migrations applied
isExisting, err := hasAppliedMigrations(db)
if err != nil {
return nil, fmt.Errorf("failed to check database state: %w", err)
}
if isExisting {
// Existing database: skip schema.sql, only run pending migrations
slog.Debug("Existing database detected, skipping schema.sql")
} else {
// Fresh database: apply schema.sql
slog.Info("Fresh database detected, applying schema")
if err := applySchema(db); err != nil {
return nil, err
}
}
// Run migrations
// For fresh databases, migrations are recorded but not executed (schema.sql is already complete)
if err := runMigrations(db, !isExisting); err != nil {
return nil, err
}
return db, nil
}
// hasAppliedMigrations checks if this is an existing database with migrations applied
func hasAppliedMigrations(db *sql.DB) (bool, error) {
// Check if schema_migrations table exists
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='schema_migrations'
`).Scan(&count)
if err != nil {
return false, err
}
if count == 0 {
return false, nil // No migrations table = fresh DB
}
// Table exists, check if it has entries
err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// applySchema executes schema.sql for fresh databases
func applySchema(db *sql.DB) error {
for _, stmt := range splitSQLStatements(schemaSQL) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("failed to apply schema: %w", err)
}
}
return nil
}
// Migration represents a database migration
type Migration struct {
Version int
Name string
Description string `yaml:"description"`
Query string `yaml:"query"`
}
// runMigrations applies any pending database migrations
// If freshDB is true, migrations are recorded but not executed (schema.sql already includes their changes)
func runMigrations(db *sql.DB, freshDB bool) error {
// Load migrations from files
migrations, err := loadMigrations()
if err != nil {
return fmt.Errorf("failed to load migrations: %w", err)
}
// Sort migrations by version
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version < migrations[j].Version
})
for _, m := range migrations {
// Check if migration already applied
var count int
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.Version).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
// Migration already applied
continue
}
if freshDB {
// Fresh database: schema.sql already has everything, just record the migration
slog.Debug("Recording migration as applied (fresh DB)", "version", m.Version, "name", m.Name)
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
}
continue
}
// Existing database: apply migration in a transaction
slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description)
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %d: %w", m.Version, err)
}
// Deferred rollback is a no-op once Commit succeeds; it guards against
// panics and any early return that forgets an explicit rollback.
defer func() { _ = tx.Rollback() }()
// Split query into individual statements and execute each
// go-sqlite3's Exec() doesn't reliably execute all statements in multi-statement queries
statements := splitSQLStatements(m.Query)
for i, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return fmt.Errorf("failed to apply migration %d (%s) statement %d: %w", m.Version, m.Name, i+1, err)
}
}
// Record migration
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %d: %w", m.Version, err)
}
slog.Info("Migration applied successfully", "version", m.Version)
}
return nil
}
// loadMigrations loads all migration files from embedded filesystem
func loadMigrations() ([]Migration, error) {
// Read all migration files from embedded FS
entries, err := fs.Glob(migrationsFS, "migrations/[0-9][0-9][0-9][0-9]_*.yaml")
if err != nil {
return nil, fmt.Errorf("failed to list migration files: %w", err)
}
var migrations []Migration
for _, file := range entries {
// Parse version and name from filename
basename := filepath.Base(file)
version, name, err := parseMigrationFilename(basename)
if err != nil {
return nil, fmt.Errorf("invalid migration filename %s: %w", basename, err)
}
// Read YAML content from embedded FS
data, err := migrationsFS.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("failed to read migration file %s: %w", file, err)
}
var m Migration
if err := yaml.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("failed to parse migration file %s: %w", file, err)
}
// Set version and name from filename
m.Version = version
m.Name = name
// Validate migration
if m.Query == "" {
return nil, fmt.Errorf("missing migration 'query' in %s", file)
}
migrations = append(migrations, m)
}
return migrations, nil
}
// splitSQLStatements splits a SQL query into individual statements.
// It splits on semicolons that are not inside -- line comments or 'string literals'.
func splitSQLStatements(query string) []string {
var statements []string
var current strings.Builder
inLineComment := false
inString := false
for i := 0; i < len(query); i++ {
ch := query[i]
if inLineComment {
current.WriteByte(ch)
if ch == '\n' {
inLineComment = false
}
continue
}
if inString {
current.WriteByte(ch)
if ch == '\'' {
// Check for escaped quote ('')
if i+1 < len(query) && query[i+1] == '\'' {
current.WriteByte(query[i+1])
i++
} else {
inString = false
}
}
continue
}
switch {
case ch == '-' && i+1 < len(query) && query[i+1] == '-':
inLineComment = true
current.WriteByte(ch)
case ch == '\'':
inString = true
current.WriteByte(ch)
case ch == ';':
// Statement boundary — flush if non-empty
stmt := strings.TrimSpace(current.String())
if stmt != "" {
statements = append(statements, stmt)
}
current.Reset()
default:
current.WriteByte(ch)
}
}
// Flush trailing statement
if stmt := strings.TrimSpace(current.String()); stmt != "" {
statements = append(statements, stmt)
}
// Filter out comment-only statements
filtered := statements[:0]
for _, stmt := range statements {
hasCode := false
for line := range strings.SplitSeq(stmt, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "--") {
hasCode = true
break
}
}
if hasCode {
filtered = append(filtered, stmt)
}
}
return filtered
}
// parseMigrationFilename extracts version and name from migration filename
// Expected format: 0001_migration_name.yaml
// Returns: version (int), name (string), error
// Note: Glob pattern ensures format is valid, so minimal validation needed
func parseMigrationFilename(filename string) (int, string, error) {
// Remove extension (.yaml or .yml)
ext := filepath.Ext(filename)
fileNameWithoutExt := filename[:len(filename)-len(ext)]
// First 4 characters are the version (glob guarantees they're digits)
version, _ := strconv.Atoi(fileNameWithoutExt[:4])
// Remainder after position 5 is the name (glob guarantees it exists)
name := strings.ReplaceAll(fileNameWithoutExt[5:], "_", " ")
name = strings.TrimSpace(name)
return version, name, nil
}