fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
332 lines
15 KiB
Markdown
332 lines
15 KiB
Markdown
---
|
|
id: "0015"
|
|
title: "Database Migrations"
|
|
status: completado
|
|
type: feature
|
|
domain: []
|
|
scope: multi-app
|
|
priority: media
|
|
depends: []
|
|
blocks: []
|
|
related: []
|
|
created: 2026-05-17
|
|
updated: 2026-05-17
|
|
tags: []
|
|
---
|
|
# 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:
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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.
|