fix(umind): libera las columnas huérfanas que dejó el refactor multi-agente

Error en producción al crear un tenant:

    null value in column "site_key" of relation "umind_tenants"
    violates not-null constraint (SQLSTATE 23502)

Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.

Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.

La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.

No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.

El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 07:45:17 -05:00
co-authored by Claude Sonnet 5
parent 2e3be1ca92
commit a25329c7b1
7 changed files with 313 additions and 131 deletions
+55
View File
@@ -1478,3 +1478,58 @@ func MigrarUmindAgentes() {
log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID)
}
}
// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron
// de mapearse cuando uMind pasó a multi-agente.
//
// GORM agrega columnas pero nunca las borra ni les cambia las restricciones.
// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis
// tablas, las columnas viejas quedaron en la base CON su NOT NULL original.
// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni
// guardar un mensaje: es decir, uMind queda inutilizable después de
// desplegar el refactor.
//
// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay
// que reconciliar algo. Solo se libera la restricción.
// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind.
// Está afuera de la función para poder verificar en un test que cada nombre de
// tabla coincide con el TableName() real del modelo: un typo acá haría que la
// migración no encuentre la columna y no haga nada, en silencio.
var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{
{"umind_tenants", "site_key"},
{"umind_documentos", "tenant_id"},
{"umind_chunks", "tenant_id"},
{"umind_mensajes", "tenant_id"},
{"umind_herramientas", "tenant_id"},
{"umind_canales", "tenant_id"},
{"umind_conexiones", "tenant_id"},
}
func LiberarColumnasHuerfanasUmind() {
db := app.Http.Database.DB
for _, h := range ColumnasHuerfanasUmind {
// Se consulta information_schema en vez de intentar el ALTER a ciegas:
// en una instalación nueva la columna no existe y el error sería ruido
// en cada arranque.
var nullable string
err := db.Raw(`
SELECT is_nullable FROM information_schema.columns
WHERE table_name = ? AND column_name = ?
`, h.Tabla, h.Columna).Scan(&nullable).Error
if err != nil || nullable == "" || nullable == "YES" {
continue
}
sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err)
continue
}
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
}
}
+53
View File
@@ -0,0 +1,53 @@
package migrations
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y
// GORM no las toca. El síntoma en producción es:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Si un nombre de tabla acá no coincide con el TableName() real, la migración
// consulta information_schema, no encuentra nada y sigue de largo sin avisar:
// el bug quedaría igual y el arranque se vería sano. Por eso se comparan
// contra los modelos en vez de confiar en las cadenas escritas a mano.
func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) {
reales := map[string]bool{
models.UmindTenant{}.TableName(): true,
models.UmindDocumento{}.TableName(): true,
models.UmindChunk{}.TableName(): true,
models.UmindMensaje{}.TableName(): true,
models.UmindHerramienta{}.TableName(): true,
models.UmindCanal{}.TableName(): true,
models.UmindConexion{}.TableName(): true,
}
for _, h := range ColumnasHuerfanasUmind {
if !reales[h.Tabla] {
t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla)
}
}
// Las siete tablas afectadas tienen que estar cubiertas: si falta una,
// insertar en ella sigue fallando.
if len(ColumnasHuerfanasUmind) != len(reales) {
t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales))
}
}
// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió
// a mapearse, liberar su NOT NULL sería incorrecto.
func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) {
// UmindTenant ya no debe tener SiteKey; vive en UmindAgente.
if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene {
t.Skip("modelo inesperado")
}
a := models.UmindAgente{SiteKey: "umk_x"}
if a.SiteKey != "umk_x" {
t.Error("UmindAgente debería ser el dueño de SiteKey")
}
}