// Package database provides SQLite database access. package database import ( "context" "database/sql" "embed" "fmt" "log/slog" "path/filepath" "sort" "strings" "go.uber.org/fx" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/logger" _ "modernc.org/sqlite" // SQLite driver registration ) //go:embed schema/*.sql var schemaFS embed.FS // bootstrapVersion is the migration that creates the schema_migrations // table itself. It is applied before the normal migration loop. const bootstrapVersion = "000" // Params defines dependencies for Database. type Params struct { fx.In Logger *logger.Logger Config *config.Config } // Database wraps the SQL database connection. type Database struct { db *sql.DB log *slog.Logger config *config.Config } // New creates a new Database instance. func New(lc fx.Lifecycle, params Params) (*Database, error) { s := &Database{ log: params.Logger.Get(), config: params.Config, } s.log.Info("Database instantiated") lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { s.log.Info("Database OnStart Hook") return s.connect(ctx) }, OnStop: func(_ context.Context) error { s.log.Info("Database OnStop Hook") if s.db != nil { return s.db.Close() } return nil }, }) return s, nil } func (s *Database) connect(ctx context.Context) error { dbURL := s.config.DBURL s.log.Info("connecting to database", "url", dbURL) db, err := sql.Open("sqlite", dbURL) if err != nil { s.log.Error("failed to open database", "error", err) return err } if err := db.PingContext(ctx); err != nil { s.log.Error("failed to ping database", "error", err) return err } s.db = db s.log.Info("database connected") return applyMigrations(ctx, s.db, s.log) } // applyMigrations bootstraps the migrations table from 000.sql and then // applies every remaining migration that has not been recorded yet. func applyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error { if err := bootstrapMigrationsTable(ctx, db, log); err != nil { return err } entries, err := schemaFS.ReadDir("schema") if err != nil { return fmt.Errorf("failed to read schema directory: %w", err) } var migrations []string for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { migrations = append(migrations, entry.Name()) } } sort.Strings(migrations) for _, migration := range migrations { version := strings.TrimSuffix(migration, filepath.Ext(migration)) var count int err := 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 { logDebug(log, "migration already applied", "version", version) continue } content, err := schemaFS.ReadFile(filepath.Join("schema", migration)) if err != nil { return fmt.Errorf("failed to read migration %s: %w", migration, err) } logInfo(log, "applying migration", "version", version) _, err = db.ExecContext(ctx, string(content)) if err != nil { return fmt.Errorf("failed to apply migration %s: %w", migration, err) } _, err = db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version, ) if err != nil { return fmt.Errorf("failed to record migration %s: %w", migration, err) } logInfo(log, "migration applied successfully", "version", version) } return nil } // bootstrapMigrationsTable ensures the schema_migrations table exists // by applying 000.sql directly. For databases that already have the // table (created by older code), it records version "000" for // consistency. func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error { var tableExists int err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'", ).Scan(&tableExists) if err != nil { return fmt.Errorf("failed to check for migrations table: %w", err) } if tableExists > 0 { // Table already exists (from older inline-SQL code or a // previous run). Make sure version "000" is recorded so the // normal loop skips the bootstrap file. var recorded int err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", bootstrapVersion, ).Scan(&recorded) if err != nil { return fmt.Errorf("failed to check bootstrap migration status: %w", err) } if recorded == 0 { _, err = db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", bootstrapVersion, ) if err != nil { return fmt.Errorf("failed to record bootstrap migration: %w", err) } logInfo(log, "recorded bootstrap migration for existing table", "version", bootstrapVersion) } return nil } // Table does not exist — apply 000.sql to create it. content, err := schemaFS.ReadFile("schema/000.sql") if err != nil { return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err) } logInfo(log, "applying bootstrap migration", "version", bootstrapVersion) _, err = db.ExecContext(ctx, string(content)) if err != nil { return fmt.Errorf("failed to apply bootstrap migration: %w", err) } _, err = db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", bootstrapVersion, ) if err != nil { return fmt.Errorf("failed to record bootstrap migration: %w", err) } logInfo(log, "bootstrap migration applied successfully", "version", bootstrapVersion) return nil } // DB returns the underlying sql.DB. func (s *Database) DB() *sql.DB { return s.db } // ApplyMigrations applies all migrations to the given database. // This is useful for testing where you want to use the real schema // without the full fx lifecycle. func ApplyMigrations(db *sql.DB) error { return applyMigrations(context.Background(), db, nil) } // logInfo logs at info level when a logger is available. func logInfo(log *slog.Logger, msg string, args ...any) { if log != nil { log.Info(msg, args...) } } // logDebug logs at debug level when a logger is available. func logDebug(log *slog.Logger, msg string, args ...any) { if log != nil { log.Debug(msg, args...) } }