This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

146
internal/db/migrate.go Normal file
View file

@ -0,0 +1,146 @@
package db
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
const migrationsTableDDL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
checksum_sha256 TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
`
func Migrate(ctx context.Context, database *sql.DB, migrationsDir string) error {
if migrationsDir == "" {
return fmt.Errorf("migrations dir is required")
}
if _, err := database.ExecContext(ctx, migrationsTableDDL); err != nil {
return fmt.Errorf("ensure schema_migrations table: %w", err)
}
applied, err := appliedMigrations(ctx, database)
if err != nil {
return err
}
files, err := listMigrationFiles(migrationsDir)
if err != nil {
return err
}
for _, filename := range files {
fullPath := filepath.Join(migrationsDir, filename)
contents, err := os.ReadFile(fullPath)
if err != nil {
return fmt.Errorf("read migration %s: %w", filename, err)
}
checksum := checksum(contents)
if appliedChecksum, ok := applied[filename]; ok {
if appliedChecksum != checksum {
return fmt.Errorf("migration %s checksum mismatch: applied=%s current=%s", filename, appliedChecksum, checksum)
}
continue
}
if err := applyMigration(ctx, database, filename, checksum, string(contents)); err != nil {
return err
}
}
return nil
}
func appliedMigrations(ctx context.Context, database *sql.DB) (map[string]string, error) {
rows, err := database.QueryContext(ctx, `SELECT name, checksum_sha256 FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("load applied migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]string)
for rows.Next() {
var name string
var checksum string
if err := rows.Scan(&name, &checksum); err != nil {
return nil, fmt.Errorf("scan applied migration: %w", err)
}
applied[name] = checksum
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate applied migrations: %w", err)
}
return applied, nil
}
func listMigrationFiles(migrationsDir string) ([]string, error) {
entries, err := os.ReadDir(migrationsDir)
if err != nil {
return nil, fmt.Errorf("read migrations dir: %w", err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
continue
}
files = append(files, entry.Name())
}
slices.Sort(files)
return files, nil
}
func applyMigration(ctx context.Context, database *sql.DB, filename, checksum, sqlText string) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", filename, err)
}
if strings.TrimSpace(sqlText) != "" {
if _, err := tx.ExecContext(ctx, sqlText); err != nil {
tx.Rollback()
return fmt.Errorf("execute migration %s: %w", filename, err)
}
}
if _, err := tx.ExecContext(
ctx,
`INSERT INTO schema_migrations (name, checksum_sha256) VALUES (?, ?)`,
filename,
checksum,
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", filename, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", filename, err)
}
return nil
}
func checksum(contents []byte) string {
sum := sha256.Sum256(contents)
return hex.EncodeToString(sum[:])
}