173 lines
3.9 KiB
Go
173 lines
3.9 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed schema/*.sql
|
|
var migrations embed.FS
|
|
|
|
// ApplySchema reads the current database version from PRAGMA user_version
|
|
// and applies any pending migrations in a single transaction. Each migration
|
|
// file runs in order; if any fail the entire transaction rolls back and the
|
|
// version is unchanged. log must be non-nil; pass
|
|
// slog.New(slog.DiscardHandler) to run without logs.
|
|
//
|
|
// Migration files are named <version>.<description>. The version
|
|
// must be a non-negative integer. On success, PRAGMA user_version is set
|
|
// to the highest migration version within the same transaction.
|
|
//
|
|
// If any migration fails the application should refuse to start, since
|
|
// the application code expects a schema that matches the latest migration.
|
|
func (s *SQLite) ApplySchema(ctx context.Context, log *slog.Logger) error {
|
|
if log == nil {
|
|
return fmt.Errorf("missing logger")
|
|
}
|
|
|
|
var currentVersion int
|
|
if err := s.Writer.QueryRowContext(
|
|
ctx,
|
|
"PRAGMA user_version",
|
|
).Scan(¤tVersion); err != nil {
|
|
return fmt.Errorf("read user_version: %w", err)
|
|
}
|
|
log.InfoContext(
|
|
ctx,
|
|
"current schema version",
|
|
slog.Int("user_version", currentVersion),
|
|
)
|
|
|
|
entries, err := migrations.ReadDir("schema")
|
|
if err != nil {
|
|
return fmt.Errorf("read schema directory: %w", err)
|
|
}
|
|
|
|
var pending []struct {
|
|
name string
|
|
sql []byte
|
|
version int
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(e.Name(), ".sql") {
|
|
continue
|
|
}
|
|
version, err := parseVersion(e.Name())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if version <= currentVersion {
|
|
log.InfoContext(
|
|
ctx,
|
|
"skipping migration",
|
|
slog.String("file", e.Name()),
|
|
slog.Int("version", version),
|
|
)
|
|
continue
|
|
}
|
|
sql, err := migrations.ReadFile(
|
|
filepath.Join("schema", e.Name()),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"read migration %q: %w", e.Name(), err,
|
|
)
|
|
}
|
|
pending = append(pending, struct {
|
|
name string
|
|
sql []byte
|
|
version int
|
|
}{name: e.Name(), sql: sql, version: version})
|
|
}
|
|
sort.Slice(pending, func(i, j int) bool {
|
|
return pending[i].version < pending[j].version
|
|
})
|
|
if len(pending) == 0 {
|
|
log.InfoContext(ctx, "no pending migrations")
|
|
return nil
|
|
}
|
|
log.InfoContext(
|
|
ctx,
|
|
"pending migrations",
|
|
slog.Int("count", len(pending)),
|
|
)
|
|
|
|
tx, err := s.Writer.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
for _, m := range pending {
|
|
if _, err := tx.ExecContext(ctx, string(m.sql)); err != nil {
|
|
if err := tx.Rollback(); err != nil {
|
|
log.ErrorContext(
|
|
ctx,
|
|
"rollback failed after apply error",
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
return fmt.Errorf(
|
|
"apply migration %q (v%d): %w",
|
|
m.name, m.version, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Set version to the latest migration within the transaction.
|
|
latestVersion := pending[len(pending)-1].version
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
fmt.Sprintf("PRAGMA user_version = %d", latestVersion),
|
|
); err != nil {
|
|
if err := tx.Rollback(); err != nil {
|
|
log.ErrorContext(
|
|
ctx,
|
|
"rollback failed after version set error",
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
return fmt.Errorf(
|
|
"set user_version to %d: %w",
|
|
latestVersion, err,
|
|
)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit migrations: %w", err)
|
|
}
|
|
log.InfoContext(
|
|
ctx,
|
|
"schema up to date",
|
|
slog.Int("user_version", latestVersion),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseVersion extracts the leading integer from a migration filename
|
|
// such as "1.init.sql" -> 1 or "12.add-foo.sql" -> 12.
|
|
func parseVersion(name string) (int, error) {
|
|
dot := strings.Index(name, ".")
|
|
if dot <= 0 {
|
|
return 0, fmt.Errorf(
|
|
"invalid filename %q: no dot separator", name,
|
|
)
|
|
}
|
|
v, err := strconv.Atoi(name[:dot])
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"invalid filename %q: version is not an integer: %w",
|
|
name, err,
|
|
)
|
|
}
|
|
return v, nil
|
|
}
|