Uno de los commits con más líneas borradas que recuerdo no tocaba el esquema de la base de datos. Era una herramienta de documentación con IA y yo había llamado documents a lo que subía el usuario. Al mes me di cuenta de que el concepto era otro: trozos de texto que el agente podía leer, citar y reescribir, y que no tenían por qué venir de un fichero, también de una URL. Pasaron a llamarse contexts. En base de datos, un rename. En el código, el commit toca la página de chat, el input, y borra enteros context-url-dialog, context-view y context-viewer: 1.127 líneas fuera de golpe.
La migración tarda dos segundos. El resto me llevó un día.
Llevo tiempo aceptando que el esquema que escribo el primer día es una hipótesis sobre un dominio que todavía no entiendo, y que va a cambiar. Así que he dejado de intentar acertar a la primera y me preocupa más otra cosa: cuánto me va a doler cuando cambie. Y el dolor, casi siempre, no está en el SQL.
Una tabla que duró cinco días
En una app que genera cuadrantes de turnos con un modelo de IA, en dos semanas cambié el esquema tres veces de forma incompatible. La que mejor lo explica es worker_verifications. Cada trabajador pasaba por unas comprobaciones que hacía el modelo, y yo quería guardar el resultado y no repetir la llamada si nada había cambiado. La tabla tenía ocho columnas, entre ellas ai_result, worker_hash, context_hash, verified_at, checked_rules y las claves. Si el hash del trabajador y el del contexto coincidían, cache hit. Me pareció razonable y lo sigue pareciendo en abstracto.
El problema era que la unidad real del dominio no era "una verificación", era "las reglas de un trabajador en una semana", y esa fila ya existía en worker_week_rules. La verificación era un atributo de esa fila y yo le había dado entidad propia. Cinco días después la tabla desapareció y su única columna útil se fue a donde debía estar:
ALTER TABLE "worker_week_rules" ADD COLUMN "checked_rules" text DEFAULT '{}' NOT NULL;
UPDATE "worker_week_rules" wwr
SET "checked_rules" = wv."checked_rules"
FROM "worker_verifications" wv
WHERE wwr."week_id" = wv."week_id"
AND wwr."worker_id" = wv."worker_id"
AND wv."checked_rules" != '{}';
DROP TABLE "worker_verifications";
ALTER TABLE "worker_week_rules" DROP COLUMN "created_at";
Dos días después quité extra_closed_days de weeks, que había añadido en el rediseño anterior. Ninguno de esos cambios vino de una decisión de producto nueva. Vinieron de entender mejor qué estaba modelando.
De ahí saqué una prueba que ahora hago antes de crear cualquier tabla: si la tabla nueva siempre va a tener exactamente una fila por cada fila de otra tabla, lo más probable es que sean columnas de esa otra tabla con pretensiones. No siempre, hay razones legítimas para partir una tabla (columnas enormes que no quieres leer siempre, permisos distintos), pero la carga de la prueba la pongo en el lado de crearla.
No todos los cambios de esquema significan lo mismo
Cuando miro el historial de migraciones de un proyecto ya distingo dos familias, y me dicen cosas opuestas.
Una es añadir: checked_week_rules en weeks, calendar_rules en calendars. Un ALTER TABLE ... ADD COLUMN con su default, se despliega sin parar nada y el código viejo sigue funcionando. Eso es el sistema creciendo. Dentro de esta familia meto también los cambios que traen información nueva sobre el mundo, como cuando pasé preferred_day_off integer a preferred_days_off text porque resultó que un trabajador podía preferir dos días. Nadie se equivocó; el dominio era más ancho de lo que sabíamos, la migración cabe en tres líneas y conserva los datos con un CAST.
La otra familia es mover cosas de sitio: borrar una tabla, renombrar un concepto, cambiar de quién cuelga un dato. Eso casi siempre significa que la primera vez lo modelé mal. Y tiene una señal muy clara: el diff gordo no está en la carpeta de migraciones, está en los componentes.
Si en un mes veo muchas de la primera, tranquilo. Si veo varias de la segunda, paro y me pregunto qué parte del dominio no he entendido todavía, porque suele haber más.
El coste es proporcional a cuántos sitios escriben el nombre
En el mismo proyecto de documentación renombré starCount a favoriteCount. El SQL son cinco líneas repartidas en dos migraciones. El diff toca el esquema, dos ficheros de acciones de base de datos y la UI que lo pintaba. Un cambio de nombre minúsculo que atraviesa cuatro capas.
La conclusión que saqué es que lo que cuesta cambiar un nombre de columna depende de cuántos ficheros lo escriben literalmente, no de lo que tarda la migración. Y eso es algo que decides al escribir la primera versión, cuando todavía te da igual.
Lo que hago ahora con Drizzle es que la UI no toque nombres de columna sueltos. Tipos derivados del esquema y unas pocas funciones de acceso. Algo así (simplificado, el nombre de la tabla es ilustrativo):
import { items } from "@/db/schema";
export type Item = typeof items.$inferSelect;
export function favoriteLabel(item: Item) {
return `${item.favoriteCount} favoritos`;
}
Si mañana renombro la columna en el esquema, el compilador me marca cada sitio que la usa, en vez de enterarme por un grep o, peor, en producción con un undefined en pantalla. No hace el cambio gratis, pero lo convierte en una lista de errores que vas cerrando.
Alguien dirá que también puedes no renombrar en base de datos y solo en el código, porque Drizzle te deja poner favoriteCount: integer("star_count"). Se puede, y para un cambio urgente me parece bien. A la larga no me gusta: el día que alguien abre una consola SQL o escribe una consulta a mano, los nombres no coinciden con lo que ve en el código, y esa confusión la pagas muchas veces en pequeño.
Nombrar por el papel, no por el origen
El caso de documents me dejó otra regla. Ese nombre describía de dónde venía el dato (un fichero subido). contexts describe qué papel juega en el sistema. El primero se rompió en cuanto apareció otro origen; el segundo lo aguanta. Cuando dudo con un nombre, me pregunto si sobreviviría a que el dato llegara por un camino distinto.
Y ya que estoy confesando: en el rediseño grande de la app de turnos convertí a la vez created_at de text a timestamptz, week_start de text a date, y el JSON que guardaba como texto a jsonb. Todo eso lo podía haber hecho bien el primer día sin coste extra. Hacerlo después fue un ALTER COLUMN ... USING por tabla y repasar todo el código que comparaba fechas como cadenas, que había más del que me habría gustado. Esto sí se podía acertar a la primera, sin conocer el dominio ni nada, y aun así no lo hice.
El caso en el que sí debí pararme
En un editor de planos de oficina tenía dos componentes, DualDesk y QuadDesk, que acabaron fusionados en un MultiDesk con un número de plazas, y luego refactorizados otra vez para permitir plazas deshabilitadas. Una mesa de dos y una de cuatro parecen dos casos distintos y son el mismo con un parámetro. Eso se veía el primer día si me hubiera parado diez minutos a preguntarme si el 2 y el 4 eran constantes o datos.
Así que tengo un criterio para decidir cuándo pensar más antes de escribir: si la diferencia entre dos cosas que estoy modelando es un número, probablemente es un dato y no dos tipos. Para casi todo lo demás escribo el esquema asumiendo que me equivoco, y me aseguro de que la semana que viene, cuando lo descubra, el arreglo quepa en una migración y en una lista de errores del compilador.
