Files
kandev/apps/backend/internal/db/migratelog.go
T
2026-09-12 16:29:38 +01:00

132 lines
4.3 KiB
Go

package db
import (
"context"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// MigrateLogger wraps a DB connection with per-statement migration logging.
// It preserves the existing "swallow-error" contract of legacy `_, _ = db.Exec(...)`
// calls while adding observability: applied migrations log at INFO, idempotent
// no-ops are silent, and unexpected failures log at WARN.
type MigrateLogger struct {
db *sqlx.DB
log *logger.Logger
strict bool
ctx context.Context
firstErr error
}
// NewMigrateLogger creates a MigrateLogger for the given writer connection.
// log may be nil, in which case all output is suppressed (matches the existing
// no-op pattern used in tests).
func NewMigrateLogger(db *sqlx.DB, log *logger.Logger) *MigrateLogger {
return newMigrateLogger(db, log, false)
}
// NewRequiredMigrateLogger creates a migration logger whose unexpected
// failures are fatal to the owning required store.
func NewRequiredMigrateLogger(db *sqlx.DB, log *logger.Logger) *MigrateLogger {
return newMigrateLogger(db, log, true)
}
// NewRequiredMigrateLoggerContext creates a required migration logger whose
// statements observe ctx. The context is checked between migration steps and
// by the SQL driver while a statement is running. Once a required statement
// fails, later Apply calls become admission no-ops until the owning
// constructor returns the first error.
func NewRequiredMigrateLoggerContext(db *sqlx.DB, log *logger.Logger, ctx context.Context) *MigrateLogger {
if ctx == nil {
ctx = context.Background()
}
return newMigrateLoggerContext(db, log, true, ctx)
}
func newMigrateLogger(db *sqlx.DB, log *logger.Logger, strict bool) *MigrateLogger {
return newMigrateLoggerContext(db, log, strict, context.Background())
}
func newMigrateLoggerContext(db *sqlx.DB, log *logger.Logger, strict bool, ctx context.Context) *MigrateLogger {
return &MigrateLogger{db: db, log: log, strict: strict, ctx: ctx}
}
// Apply executes stmt and classifies the result:
// - success: logs "migration applied" at INFO
// - "already exists" error: silent (idempotent re-run)
// - anything else: logs "migration failed" at WARN
//
// In strict mode, the first unexpected error is retained and returned. In
// compatibility mode, unexpected errors are logged and swallowed as before.
func (m *MigrateLogger) Apply(name, stmt string) error {
return m.ApplyContext(m.ctx, name, stmt)
}
// ApplyContext executes a migration statement with an explicit context.
func (m *MigrateLogger) ApplyContext(ctx context.Context, name, stmt string) error {
if m.strict && m.firstErr != nil {
return m.firstErr
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return m.recordFailure(name, err)
}
if _, err := m.db.ExecContext(ctx, stmt); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return m.recordFailure(name, errors.Join(err, ctxErr))
}
if IsAlreadyExistsError(err) {
return nil
}
return m.recordFailure(name, err)
}
// SQLite can finish an interruptible statement successfully at the same
// time that its context is canceled. Treat cancellation observed at this
// boundary as a failed migration so the next store is never admitted.
if err := ctx.Err(); err != nil {
return m.recordFailure(name, err)
}
if m.log != nil {
m.log.Info("migration applied", zap.String("name", name))
}
return nil
}
func (m *MigrateLogger) recordFailure(name string, err error) error {
wrapped := fmt.Errorf("migration %q failed: %w", name, err)
if m.strict && m.firstErr == nil {
m.firstErr = wrapped
}
if m.log != nil {
m.log.Warn("migration failed",
zap.String("name", name), zap.Error(wrapped))
}
if m.strict {
return wrapped
}
return nil
}
// Context returns the context used by this migration logger. It is used by
// legacy migration helpers that need to switch a long-running query to
// ExecContext without changing their public constructor signatures.
func (m *MigrateLogger) Context() context.Context {
if m.ctx == nil {
return context.Background()
}
return m.ctx
}
// Err returns the first unexpected migration failure observed by this
// logger. Idempotent duplicate errors are not reported.
func (m *MigrateLogger) Err() error {
return m.firstErr
}