Files
fn_registry/dev/issues/completed/0015-db-migrations.md
T

15 KiB

id, title, status, type, domain, scope, priority, depends, blocks, related, created, updated, tags
id title status type domain scope priority depends blocks related created updated tags
0015 Database Migrations completado feature
multi-app media
2026-05-17 2026-05-17

0015 — Database Migrations

Metadata

Campo Valor
ID 0015
Estado pendiente
Prioridad media
Tipo feature

Dependencias

Ninguna.


Objetivo

Sistema de migraciones para evolucionar schemas SQLite de forma controlada y reproducible. Reemplaza los ALTER TABLE manuales y la creacion ad-hoc de tablas con db_create_table_go_infra por un flujo formal: crear migracion, aplicar, revertir, verificar estado.

Contexto

  • Cada app en apps/*/ tiene su operations.db con tablas creadas al inicio usando db_create_table_go_infra o SQL inline.
  • Cuando el schema necesita cambiar (nueva columna, nuevo indice, renombrar tabla), se ejecutan ALTER TABLE manuales sin control de version ni trazabilidad.
  • No existe forma de saber que version de schema tiene una BD concreta, ni de reproducir el estado actual desde cero de forma incremental.
  • deploy_server, pipeline_launcher, sqlite_api y cualquier app futura con SQLite necesitan lo mismo: migraciones secuenciales, reversibles, con estado.
  • registry.db se regenera con fn index asi que no necesita migraciones. El sistema es exclusivamente para operations.db y otras BDs de apps.

Arquitectura

functions/infra/
├── migration_create.go          — NEW: crear archivo de migracion
├── migration_create.md          — NEW
├── migration_up.go              — NEW: aplicar migraciones pendientes
├── migration_up.md              — NEW
├── migration_down.go            — NEW: revertir ultimas N migraciones
├── migration_down.md            — NEW
├── migration_status.go          — NEW: listar estado de migraciones
├── migration_status.md          — NEW
├── migration_validate.go        — NEW: validar secuencia de archivos
├── migration_validate.md        — NEW
├── migration_parse.go           — NEW: parsear archivo de migracion
├── migration_parse.md           — NEW

types/infra/
├── migration.md                 — NEW: metadata del tipo Migration
├── migration_status.md          — NEW: metadata del tipo MigrationStatus

Patron pure core / impure shell

  • Pure: migration_validate (verifica secuencia de archivos sin tocar BD ni disco), migration_parse (extrae SQL de un string, sin I/O)
  • Impure: migration_create (escribe archivo en disco), migration_up (ejecuta SQL contra BD), migration_down (ejecuta SQL contra BD), migration_status (lee BD y disco)

Diseno

Formato de archivo de migracion

Cada migracion es un unico archivo .sql con marcadores -- +up y -- +down que separan los bloques de aplicacion y reversion:

-- 001_create_entities.sql

-- +up
CREATE TABLE entities (
    id TEXT PRIMARY KEY,
    type_ref TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'active',
    metadata TEXT NOT NULL DEFAULT '{}',
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_entities_type ON entities(type_ref);
CREATE INDEX idx_entities_status ON entities(status);

-- +down
DROP INDEX IF EXISTS idx_entities_status;
DROP INDEX IF EXISTS idx_entities_type;
DROP TABLE IF EXISTS entities;

Nomenclatura: {NNN}_{nombre_descriptivo}.sql donde NNN es un numero secuencial con padding de 3 digitos (001, 002, ..., 999).

Tabla _migrations

Creada automaticamente por migration_up en la primera ejecucion. Almacena las migraciones aplicadas:

CREATE TABLE IF NOT EXISTS _migrations (
    version  INTEGER PRIMARY KEY,
    name     TEXT    NOT NULL,
    up_sql   TEXT    NOT NULL,
    down_sql TEXT    NOT NULL,
    applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);

Se guarda el SQL completo (up_sql, down_sql) en la tabla para que el rollback funcione incluso si el archivo de migracion se modifica o elimina despues de aplicarse. Esto hace la BD autocontenida.

Tipos

// Migration representa una migracion parseada desde un archivo .sql
type Migration struct {
    Version   int       // Numero secuencial (1, 2, 3...)
    Name      string    // Nombre descriptivo (create_entities, add_status_column)
    UpSQL     string    // Bloque SQL de aplicacion
    DownSQL   string    // Bloque SQL de reversion
    AppliedAt time.Time // Zero value si no aplicada
}

// MigrationStatus representa el estado de una migracion respecto a una BD
type MigrationStatus struct {
    Version   int       // Numero secuencial
    Name      string    // Nombre descriptivo
    Applied   bool      // true si ya aplicada en la BD
    AppliedAt time.Time // Cuando se aplico (zero value si pending)
}

Funciones

Funcion Purity Firma (simplificada) Descripcion
migration_create impure (dir string, name string) (string, error) Crea archivo .sql con template -- +up / -- +down. Calcula siguiente version. Retorna path creado.
migration_up impure (db *sql.DB, dir string) ([]Migration, error) Lee archivos del directorio, compara con _migrations, ejecuta pendientes en orden dentro de transacciones individuales. Retorna las aplicadas.
migration_down impure (db *sql.DB, n int) ([]Migration, error) Revierte las ultimas n migraciones usando el down_sql guardado en _migrations. Cada reversion en su propia transaccion. Retorna las revertidas.
migration_status impure (db *sql.DB, dir string) ([]MigrationStatus, error) Cruza archivos en disco con registros en _migrations. Retorna lista ordenada con estado de cada migracion.
migration_validate pure (migrations []Migration) []string Verifica que las versiones sean secuenciales sin huecos (1,2,3...), sin duplicados, y que cada migracion tenga up y down no vacios. Retorna lista de errores (vacia si todo OK).
migration_parse pure (filename string, content string) (Migration, error) Extrae version y nombre del filename, separa bloques up/down por marcadores. Error si el formato es invalido.

Directorio de migraciones por app

Cada app que use migraciones tendra un directorio migrations/ dentro de su carpeta:

apps/deploy_server/
├── main.go
├── app.md
├── operations.db
└── migrations/
    ├── 001_create_deploy_targets.sql
    ├── 002_create_deploy_logs.sql
    └── 003_add_env_to_targets.sql

Tareas

Fase 1: Tipos y funciones puras

  • 1.1 Crear tipo Migration en functions/infra/migration.go con .md en types/infra/migration.md
  • 1.2 Crear tipo MigrationStatus en functions/infra/migration_status.go con .md en types/infra/migration_status.md
  • 1.3 migration_parse — parsear filename + contenido, extraer version/nombre/up/down. Tests con archivos validos e invalidos (sin marcador, version no numerica, down vacio).
  • 1.4 migration_validate — verificar secuencia, huecos, duplicados, bloques vacios. Tests con secuencias validas, con huecos, con duplicados.

Fase 2: Funciones impuras

  • 2.1 migration_create — calcular siguiente version leyendo archivos existentes, escribir template. Test con directorio vacio y con migraciones existentes.
  • 2.2 migration_up — crear tabla _migrations si no existe, leer archivos, filtrar pendientes, ejecutar en transacciones. Tests con BD vacia, con migraciones parcialmente aplicadas, con migracion que falla a mitad.
  • 2.3 migration_down — leer ultimas N de _migrations, ejecutar down_sql en orden inverso, borrar registros. Tests con rollback de 1 y de N, con BD sin migraciones.
  • 2.4 migration_status — cruzar disco con BD, marcar applied/pending. Test con migraciones en disco pero no en BD, en BD pero no en disco (huerfanas).

Fase 3: Integracion y cleanup

  • 3.1 Tests de integracion: ciclo completo create -> up -> status -> down -> status
  • 3.2 fn index y verificar que las 6 funciones y 2 tipos aparecen en registry.db
  • 3.3 Verificar go vet -tags fts5 y go test -tags fts5 ./functions/infra/
  • 3.4 Migrar al menos una app existente (deploy_server) para validar el flujo con un caso real: extraer los CREATE TABLE actuales como migracion 001

Ejemplo de uso

Crear una migracion

// Crear primera migracion para una app
path, err := infra.MigrationCreate("apps/my_app/migrations", "create_users")
// path = "apps/my_app/migrations/001_create_users.sql"
// El archivo contiene el template:
//   -- +up
//   
//   -- +down

Editar el archivo generado con el SQL concreto:

-- 001_create_users.sql

-- +up
CREATE TABLE users (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- +down
DROP TABLE IF EXISTS users;

Aplicar migraciones pendientes

db, _ := infra.SqliteOpen("", "apps/my_app/operations.db")
defer db.Close()

applied, err := infra.MigrationUp(db, "apps/my_app/migrations")
if err != nil {
    log.Fatal(err)
}
for _, m := range applied {
    fmt.Printf("Applied: %03d_%s\n", m.Version, m.Name)
}
// Applied: 001_create_users
// Applied: 002_add_roles

Verificar estado

statuses, err := infra.MigrationStatus(db, "apps/my_app/migrations")
for _, s := range statuses {
    status := "pending"
    if s.Applied {
        status = fmt.Sprintf("applied at %s", s.AppliedAt.Format(time.RFC3339))
    }
    fmt.Printf("%03d %-30s %s\n", s.Version, s.Name, status)
}
// 001 create_users                  applied at 2026-04-13T10:30:00Z
// 002 add_roles                     applied at 2026-04-13T10:30:00Z
// 003 add_audit_log                 pending

Revertir la ultima migracion

reverted, err := infra.MigrationDown(db, 1)
if err != nil {
    log.Fatal(err)
}
for _, m := range reverted {
    fmt.Printf("Reverted: %03d_%s\n", m.Version, m.Name)
}
// Reverted: 002_add_roles

Validar archivos antes de aplicar

// Parsear todos los archivos del directorio
files, _ := os.ReadDir("apps/my_app/migrations")
var migrations []infra.Migration
for _, f := range files {
    content, _ := os.ReadFile(filepath.Join("apps/my_app/migrations", f.Name()))
    m, err := infra.MigrationParse(f.Name(), string(content))
    if err != nil {
        log.Fatalf("Parse error in %s: %v", f.Name(), err)
    }
    migrations = append(migrations, m)
}

// Validar secuencia
errors := infra.MigrationValidate(migrations)
if len(errors) > 0 {
    for _, e := range errors {
        fmt.Println("ERROR:", e)
    }
    os.Exit(1)
}
fmt.Println("All migrations valid")

Uso tipico en el main.go de una app

func main() {
    db, err := infra.SqliteOpen("", "operations.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Aplicar migraciones pendientes al arrancar
    applied, err := infra.MigrationUp(db, "migrations")
    if err != nil {
        log.Fatalf("Migration failed: %v", err)
    }
    if len(applied) > 0 {
        log.Printf("Applied %d migration(s)", len(applied))
    }

    // ... resto de la app
}

Decisiones de diseno

  • Archivo unico con marcadores -- +up / -- +down: Mantener up y down juntos en el mismo archivo facilita la revision y evita desincronizacion entre archivos separados. El formato es simple de parsear (split por marcadores) y legible como SQL plano. Alternativa descartada: archivos separados 001_create_users.up.sql / 001_create_users.down.sql — mas archivos, mas facil olvidar el down.
  • Versiones numericas secuenciales (no timestamps): Para un proyecto personal con un solo desarrollador, los numeros secuenciales son mas legibles y evitan colisiones de merge que no aplican aqui. Un archivo 003_add_index.sql es mas claro que 20260413103045_add_index.sql. Si el proyecto creciera a multiples desarrolladores, se podria migrar a timestamps.
  • SQL almacenado en _migrations: Guardar up_sql y down_sql en la tabla permite revertir migraciones incluso si el archivo original fue modificado o eliminado. La BD es autocontenida para rollbacks. Tradeoff: ocupa mas espacio en la tabla, pero las migraciones son texto pequeno y SQLite lo maneja sin problema.
  • Transaccion por migracion, no global: Cada migracion se ejecuta en su propia transaccion. Si la migracion 003 falla, las 001 y 002 ya aplicadas quedan. Esto permite diagnosticar y corregir la migracion fallida sin tener que reaplicar todo desde cero. SQLite no soporta DDL transaccional completo (CREATE TABLE si, pero ALTER TABLE tiene limitaciones), asi que una transaccion global daria falsa seguridad.
  • Funciones del registry, no CLI separado: Las migraciones son funciones reutilizables en functions/infra/, no una herramienta CLI standalone. Cualquier app las importa y las llama desde su main.go. Si en el futuro se necesita un subcomando fn migrate, se compone sobre estas funciones.
  • Sin migracion automatica de schema (auto-diff): El sistema no compara el schema actual con un schema deseado para generar migraciones. Las migraciones se escriben a mano. Esto es deliberado: el SQL explicito es mas predecible y debuggeable que un diff automatico, especialmente con SQLite que tiene limitaciones en ALTER TABLE.
  • Directorio migrations/ por app: Cada app tiene su propio directorio de migraciones. No hay un directorio global. Esto es coherente con la regla de que cada operations.db es independiente.

Riesgos

  • ALTER TABLE limitado en SQLite: SQLite no soporta DROP COLUMN (antes de 3.35.0), RENAME COLUMN (antes de 3.25.0), ni ALTER COLUMN. Migraciones que necesiten estos cambios requieren el patron de recrear tabla (CREATE new, INSERT INTO new SELECT FROM old, DROP old, ALTER TABLE new RENAME TO old). Documentar este patron en el template generado por migration_create.
  • Migraciones con datos en produccion: Un DROP TABLE en el down destruye datos. El down es para desarrollo, no para revertir en produccion con datos vivos. Documentar que migration_down es destructivo y no debe usarse a la ligera en BDs con datos importantes.
  • Archivos editados despues de aplicarse: Si alguien modifica un archivo .sql despues de que la migracion fue aplicada, el estado queda inconsistente (el archivo dice una cosa, la BD tiene otra). migration_status podria detectar esto comparando hashes, pero la v1 no lo incluye para mantener simplicidad. Mitigado por el hecho de que el up_sql guardado en _migrations es el que realmente se ejecuto.
  • Scope creep hacia ORM o schema manager: El sistema es intencionalmente minimo: archivos SQL, tabla de tracking, funciones para aplicar y revertir. No se agregan features como generacion automatica de SQL, migracion de datos programatica, ni integracion con modelos Go. Si se necesita algo asi, se construye como capa superior.