Add embedded SQL migrations system

Migrations are stored in schema/*.sql and embedded via go:embed.
Applied migrations are tracked in schema_migrations table.
Initial schema includes source_content, source_metadata, output_content,
request_cache, negative_cache, and cache_stats tables.
This commit is contained in:
2026-01-08 03:35:43 -08:00
parent 27eb9fb513
commit 4595929275
2 changed files with 168 additions and 22 deletions

View File

@@ -4,7 +4,12 @@ package database
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"path/filepath"
"sort"
"strings"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
@@ -13,6 +18,9 @@ import (
_ "modernc.org/sqlite" // SQLite driver registration
)
//go:embed schema/*.sql
var schemaFS embed.FS
// Params defines dependencies for Database.
type Params struct {
fx.In
@@ -76,33 +84,80 @@ func (s *Database) connect(ctx context.Context) error {
s.db = db
s.log.Info("database connected")
return s.createSchema(ctx)
return s.runMigrations(ctx)
}
func (s *Database) createSchema(ctx context.Context) error {
// FIXME: Add actual schema for cache metadata
schema := `
CREATE TABLE IF NOT EXISTS cache_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_url TEXT NOT NULL UNIQUE,
source_hash TEXT NOT NULL,
content_type TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_cache_source_url ON cache_metadata(source_url);
CREATE INDEX IF NOT EXISTS idx_cache_source_hash ON cache_metadata(source_hash);
`
_, err := s.db.ExecContext(ctx, schema)
func (s *Database) runMigrations(ctx context.Context) error {
// Create migrations tracking table
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
s.log.Error("failed to create schema", "error", err)
return err
return fmt.Errorf("failed to create migrations table: %w", err)
}
s.log.Info("database schema initialized")
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err)
}
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
// Apply each migration that hasn't been applied yet
for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied
var count int
err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
s.log.Debug("migration already applied", "version", version)
continue
}
// Read and apply migration
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
s.log.Info("applying migration", "version", version)
_, err = s.db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
// Record migration as applied
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
s.log.Info("migration applied successfully", "version", version)
}
return nil
}