416b15786d
Promueve patron versionado (schema_migrations + tx por archivo) al registry
como sqlite_apply_versioned_migrations_go_infra. Migra fn_operations/migrate.go
y registry/migrate.go al consumirla. ~200 LOC duplicadas eliminadas.
- functions/infra/sqlite_apply_versioned_migrations.{go,md,_test.go}: nueva,
5/5 tests pass. Generaliza fs.FS + dir param (fn_operations usaba embed.FS
hardcoded). Distinta de sqlite_apply_migrations_go_infra (naive split-by-`;`,
idempotent-by-error) — esta hace tracking explicito + transactions.
- fn_operations/migrate.go: 111 LOC -> 17. Wrapper sobre infra.ApplyVersionedMigrations.
- registry/migrate.go: idem. Mismo patron copy-paste, ahora unificado.
Smoke: ./fn ops init crea operations.db con schema_migrations poblada.
fn_operations + registry tests: PASS. fn index registra nueva fn (1091 total).
128 lines
3.5 KiB
Go
128 lines
3.5 KiB
Go
package infra
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const createSchemaMigrationsTable = `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL
|
|
);`
|
|
|
|
// ApplyVersionedMigrations applies pending SQLite migrations from fsys, tracking
|
|
// applied versions in a schema_migrations table. Each migration runs in its
|
|
// own transaction; on error the tx is rolled back and the function returns.
|
|
//
|
|
// Migration filenames must be NNN_name.sql (e.g. 001_init.sql,
|
|
// 002_add_users.sql). The numeric prefix is the version. Files without a
|
|
// numeric prefix or with non-.sql extensions are skipped.
|
|
//
|
|
// dir is the directory inside fsys containing the migrations (e.g.
|
|
// "migrations"). Idempotent: migrations whose version <= current are skipped.
|
|
func ApplyVersionedMigrations(conn *sql.DB, fsys fs.FS, dir string) error {
|
|
if _, err := conn.Exec(createSchemaMigrationsTable); err != nil {
|
|
return fmt.Errorf("apply_versioned_migrations: create schema_migrations: %w", err)
|
|
}
|
|
|
|
current, err := versionedMigrationsCurrentVersion(conn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
files, err := versionedMigrationsList(fsys, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, mf := range files {
|
|
if mf.version <= current {
|
|
continue
|
|
}
|
|
|
|
content, err := fs.ReadFile(fsys, path.Join(dir, mf.filename))
|
|
if err != nil {
|
|
return fmt.Errorf("apply_versioned_migrations: read %s: %w", mf.filename, err)
|
|
}
|
|
|
|
tx, err := conn.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("apply_versioned_migrations: begin tx for %s: %w", mf.filename, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(string(content)); err != nil {
|
|
tx.Rollback() //nolint:errcheck
|
|
return fmt.Errorf("apply_versioned_migrations: exec %s: %w", mf.filename, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(
|
|
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
|
mf.version, mf.filename, time.Now().UTC().Format(time.RFC3339),
|
|
); err != nil {
|
|
tx.Rollback() //nolint:errcheck
|
|
return fmt.Errorf("apply_versioned_migrations: record %s: %w", mf.filename, err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("apply_versioned_migrations: commit %s: %w", mf.filename, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// versionedMigrationsCurrentVersion returns MAX(version) from schema_migrations,
|
|
// or 0 if the table is empty.
|
|
func versionedMigrationsCurrentVersion(conn *sql.DB) (int, error) {
|
|
var v int
|
|
err := conn.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("apply_versioned_migrations: read current version: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
type versionedMigrationFile struct {
|
|
version int
|
|
filename string
|
|
}
|
|
|
|
// versionedMigrationsList reads dir from fsys and returns .sql files with a
|
|
// numeric NNN_ prefix, sorted by version ascending.
|
|
func versionedMigrationsList(fsys fs.FS, dir string) ([]versionedMigrationFile, error) {
|
|
entries, err := fs.ReadDir(fsys, dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("apply_versioned_migrations: read dir %q: %w", dir, err)
|
|
}
|
|
|
|
var files []versionedMigrationFile
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(e.Name(), "_", 2)
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
v, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, versionedMigrationFile{version: v, filename: e.Name()})
|
|
}
|
|
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].version < files[j].version
|
|
})
|
|
|
|
return files, nil
|
|
}
|