186 lines
5.0 KiB
Go
186 lines
5.0 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// Database is the interface for database operations
|
|
type Database interface {
|
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
|
|
Close() error
|
|
}
|
|
|
|
type database struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func New(dbPath string, logger *slog.Logger) (Database, error) {
|
|
// Ensure data directory exists
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Open database
|
|
// Note: Using modernc.org/sqlite (pure Go, no CGO) instead of mattn/go-sqlite3
|
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
// Test connection
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
dbInstance := &database{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
|
|
// Run migrations
|
|
if err := dbInstance.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to migrate: %w", err)
|
|
}
|
|
|
|
return dbInstance, nil
|
|
}
|
|
|
|
func (d *database) migrate() error {
|
|
d.logger.Info("running database migrations")
|
|
|
|
// Create schema_migrations table if not exists
|
|
_, err := d.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create schema_migrations table: %w", err)
|
|
}
|
|
|
|
// Get already applied migrations
|
|
appliedMigrations := make(map[int]bool)
|
|
rows, err := d.db.Query("SELECT version FROM schema_migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query schema_migrations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var version int
|
|
if err := rows.Scan(&version); err != nil {
|
|
return fmt.Errorf("failed to scan version: %w", err)
|
|
}
|
|
appliedMigrations[version] = true
|
|
}
|
|
|
|
// Read migrations from embedded filesystem
|
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read embedded migrations: %w", err)
|
|
}
|
|
|
|
// Sort migration files by name
|
|
var files []string
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
|
|
files = append(files, entry.Name())
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
|
|
// Execute each migration
|
|
for _, filename := range files {
|
|
// Extract version from filename (e.g., "001_init.sql" -> 1)
|
|
var version int
|
|
if _, err := fmt.Sscanf(filename, "%d_", &version); err != nil {
|
|
d.logger.Warn("skipping migration with invalid name format", slog.String("file", filename))
|
|
continue
|
|
}
|
|
|
|
// Skip if already applied
|
|
if appliedMigrations[version] {
|
|
d.logger.Debug("skipping already applied migration", slog.String("file", filename), slog.Int("version", version))
|
|
continue
|
|
}
|
|
|
|
// Read migration file from embedded FS
|
|
content, err := migrationsFS.ReadFile(filepath.Join("migrations", filename))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
|
}
|
|
|
|
// Execute migration in a transaction
|
|
tx, err := d.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(string(content)); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
|
}
|
|
|
|
// Record migration
|
|
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("failed to record migration %s: %w", filename, err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
|
|
}
|
|
|
|
d.logger.Info("applied migration", slog.String("file", filename), slog.Int("version", version))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *database) Close() error {
|
|
if d.db != nil {
|
|
return d.db.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExecContext implements Database interface
|
|
func (d *database) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
|
return d.db.ExecContext(ctx, query, args...)
|
|
}
|
|
|
|
// QueryContext implements Database interface
|
|
func (d *database) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
|
return d.db.QueryContext(ctx, query, args...)
|
|
}
|
|
|
|
// QueryRowContext implements Database interface
|
|
func (d *database) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
|
return d.db.QueryRowContext(ctx, query, args...)
|
|
}
|
|
|
|
// BeginTx implements Database interface
|
|
func (d *database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
|
return d.db.BeginTx(ctx, opts)
|
|
}
|