Migraciones versionadas
Una base de datos cuyo esquema se edita a mano es estado oculto: nadie sabe con certeza qué versión corre, cómo llegó ahí ni cómo volver atrás. D1 trata el esquema como código con las migraciones de Wrangler: cada cambio es un archivo .sql numerado que se crea, se revisa en el pull request, se prueba en local y se aplica en remoto con orden estricto. La carpeta de migraciones como única fuente de verdad, el registro en d1_migrations que impide aplicar dos veces lo mismo, por qué el historial solo avanza y cómo llevar un cambio a la base que sirve tráfico real sin corromper los datos.
El esquema de una base de datos es código, aunque casi nadie lo trate así. Cada CREATE TABLE, cada índice, cada columna nueva es una decisión que debería vivir en el repositorio, revisarse en un pull request y desplegarse con la misma disciplina que una función. Cuando el esquema se edita a mano desde un panel, se vuelve estado oculto: nadie sabe con certeza qué versión corre en producción, cómo llegó ahí ni cómo deshacerlo. Las migraciones versionadas de D1 cierran esa grieta al convertir cada cambio en un archivo .sql numerado, ordenado e irrepetible.
- Entender el esquema como código, con la carpeta
migrationscomo única fuente de verdad. - Crear y aplicar cambios con
wrangler d1 migrations createyapply. - Separar
--localde--remotey razonar el orden hacia producción. - Comprender por qué el historial solo avanza y cómo
d1_migrationsevita aplicar dos veces.
El esquema como código
Una migración es una unidad de cambio de esquema con nombre, número e historia. En lugar de teclear un ALTER TABLE contra la base y olvidarlo, escribes ese ALTER TABLE en un archivo, lo numeras y lo guardas junto al código. La carpeta migrations —creada en la raíz del proyecto la primera vez que generas una— pasa a ser la definición autoritativa del esquema: la secuencia de archivos, leída en orden, reconstruye la estructura de la base desde cero.
El valor de esto no es burocrático, es epistemológico. Un esquema editado a mano no tiene respuesta a la pregunta de cómo se llegó a ese estado. Un esquema versionado sí: es la suma de sus migraciones, cada una revisable en su pull request, atribuible a un commit y reproducible en cualquier entorno. La base de desarrollo, la de staging y la de producción convergen a la misma forma porque aplican la misma lista de archivos.
Conviene subrayar qué no es una migración de D1: no es un ORM que infiere cambios comparando dos estados del esquema, ni una herramienta que revierte sola. Es más primitiva y, por eso, más honesta: un archivo de SQL que tú escribes y que se ejecuta tal cual. Esa primitividad es una virtud, porque no hay comportamiento oculto entre lo que lees en el pull request y lo que le ocurre a la base.
La contrapartida de esa transparencia es responsabilidad: como D1 no infiere ni revierte por ti, eres tú quien garantiza que el SQL de cada migración sea correcto y compatible con los datos existentes. La herramienta te da control total y, con él, la obligación de pensar cada cambio. Es el mismo trato que ofrece un buen sistema de tipos: no te ahorra decidir, te protege de olvidar lo que decidiste.
Los comandos aceptan tanto el nombre del binding como el database_name. El binding puede cambiar entre entornos; el nombre de la base, no. Para no aplicar por error una migración sobre la base equivocada, prefiere el database_name en los comandos que tocan producción.
Crear la migración
wrangler d1 migrations create genera un archivo vacío con el siguiente número de la secuencia y el mensaje que le des:
npx wrangler d1 migrations create mi-base "crear tabla usuarios"
# crea migrations/0001_crear_tabla_usuarios.sql
El archivo nace vacío; tú escribes el SQL del cambio. Aquí no hay introspección ni magia: una migración es exactamente el SQL que redactas, lo que la vuelve transparente y auditable.
-- migrations/0001_crear_tabla_usuarios.sql
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
creado_en INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_usuarios_email ON usuarios(email);
El número al frente del nombre no es decorativo: fija el orden total en que las migraciones se aplican. La 0002 va después de la 0001, siempre, en todos los entornos. Ese orden es lo que garantiza que la base llegue a la misma forma sin importar desde qué punto parta.
Una migración típica no crea tablas desde cero, sino que evoluciona lo existente. Añadir una columna es el caso más común, y la clave para que sea segura es darle un valor por defecto: así las filas que ya existen quedan bien definidas sin tocarlas una a una.
-- migrations/0002_anadir_estado_a_pedidos.sql
ALTER TABLE pedidos ADD COLUMN estado TEXT NOT NULL DEFAULT 'pendiente';
CREATE INDEX idx_pedidos_estado ON pedidos(estado);
Cada archivo hace una cosa nombrable —añadir el estado de un pedido— y su nombre lo dice. Cuando dentro de seis meses alguien lea el historial, la secuencia de nombres contará la evolución del producto tan bien como el propio código; una migración sin mensaje claro es una entrada de diario sin fecha.
Aplicar una migración sobre una base vacía casi siempre funciona; el riesgo aparece con datos reales. Antes de tocar --remote, siembra tu base local con un volumen parecido al de producción y aplica ahí: descubrirás a tiempo una columna sin DEFAULT que rompe filas viejas, o un índice único que choca con duplicados existentes. Ese hábito es lo que separa un despliegue de esquema aburrido, que es como deben ser, de uno que se vuelve incidente.
Por defecto los archivos van en migrations/ y lo aplicado se anota en la tabla d1_migrations. Ambos son configurables en el binding de D1 con migrations_dir y migrations_table; y si usas un ORM como Drizzle que escribe cada migración en su propio subdirectorio, migrations_pattern acepta un glob para descubrirlas. El valor por defecto sirve para casi todo: cámbialo solo cuando compartas migraciones entre varios paquetes de un monorepo.
Aplicar: local y remoto
Aplicar es ejecutar contra la base las migraciones que aún no se han aplicado. El comando distingue dos mundos:
# base local: un SQLite en .wrangler/state, para desarrollar
npx wrangler d1 migrations apply mi-base --local
# base real, la que usan tus Workers desplegados
npx wrangler d1 migrations apply mi-base --remote
--local toca un archivo SQLite en .wrangler/state: rápido, desechable, ideal para iterar. --remote toca la base de producción. Antes de aplicar, wrangler d1 migrations list te dice qué migraciones quedan pendientes, para que nunca despliegues a ciegas.
npx wrangler d1 migrations list mi-base --remote
El flujo maduro es una tubería: editas el .sql, aplicas en local y corres tus pruebas, abres el pull request donde el diff del esquema se revisa como cualquier código, y solo tras el merge aplicas en --remote como un paso más del despliegue.
En un proyecto serio, wrangler d1 migrations apply --remote no se teclea a mano en producción: corre en el pipeline de despliegue, tras pasar las pruebas y antes de publicar el Worker que depende del esquema nuevo. Así el orden queda garantizado —primero el esquema, luego el código que lo usa— y nadie olvida aplicar una migración pendiente.
Solo hacia adelante
D1 lleva la cuenta de lo aplicado en una tabla real dentro de tu base, d1_migrations. Cada vez que apply ejecuta un archivo, escribe su nombre ahí; en la siguiente corrida, salta lo ya anotado. De esa idempotencia sale una consecuencia dura: las migraciones no tienen down, solo up. El historial avanza, nunca retrocede.
Esto obliga a pensar cada cambio como irreversible en el sentido operativo. Un cambio destructivo —soltar una columna, renombrar una tabla— no se deshace con un rollback; se corrige con una migración nueva que lo compense, o se restaura la base entera con Time Travel. Por eso el patrón profesional para cambios peligrosos es expandir y contraer: primero añades lo nuevo en una migración, migras los datos y el código en otra, y solo cuando nada usa lo viejo lo retiras en una tercera.
# no se edita la 0007 ya aplicada; se compensa con la 0008
npx wrangler d1 migrations create mi-base "revertir columna sobrante"
El libro solo crece, y esa es precisamente la garantía: cualquiera puede reconstruir el estado actual releyéndolo entero, sin depender de qué se tecleó a mano en qué consola.
Hay un corolario operativo que vale oro en equipos: como el estado de la base es la lista de migraciones aplicadas, poner al día un entorno nuevo —la máquina de un compañero, un staging recién creado— es un solo comando, migrations apply, que reproduce la historia entera. No se copian bases; se replican historias.
Un compañero que clona el repositorio y aplica las migraciones obtiene, por construcción, exactamente tu esquema. No hay que exportar ni importar la estructura: está en los archivos, versionada junto al código que la usa. Esa es la promesa completa del esquema como código.
Y cuando un entorno se corrompe, no se restaura desde una copia frágil: se recrea desde cero aplicando la historia, o se rebobina con Time Travel. Dos redes de seguridad que nacen de la misma idea —tratar los cambios como hechos registrados—.
Un cambio que reescribe tablas puede violar temporalmente las restricciones de clave foránea a mitad de camino. Antes de esas operaciones, ejecuta PRAGMA defer_foreign_keys = true dentro de la migración: difiere la verificación hasta el final de la transacción, cuando el esquema ya vuelve a ser coherente.
Esquema como código
Cada cambio es un archivo .sql numerado, revisable en el pull request y reproducible en cualquier entorno.
Orden total
La numeración fija la secuencia de aplicación; la misma lista, leída en orden, da la misma base en todas partes.
Solo hacia adelante
No hay rollback: se compensa con una migración nueva o se restaura con Time Travel. El historial nunca retrocede.
La tentación de editar el esquema a mano nace de una confusión sobre qué es una base de datos: creemos que su estado presente es la verdad, cuando la verdad es la historia que lo produjo. Un ALTER TABLE tecleado en una consola cambia el presente y borra el pasado en el mismo gesto; nadie podrá reconstruir después por qué existe esa columna, quién la pidió ni qué había antes. La migración versionada invierte esa relación: el archivo .sql numerado no describe el estado, describe el acontecimiento que llevó a él, y por eso es inmutable. No editas la 0003 una vez aplicada, igual que no editas un commit ya publicado: escribes la 0004. La carpeta migrations deja de ser un montón de scripts para volverse un libro contable de doble entrada, donde cada asiento es irreversible y la base no es más que la suma de todos ellos leídos en orden. De ahí que el registro viva dentro de la propia base, en d1_migrations, y no en tu máquina: la base carga consigo la memoria de cómo llegó a ser lo que es, y esa memoria es lo que permite que desarrollo, staging y producción —tres bases físicamente distintas— sean la misma base lógica. El ingeniero que edita el esquema a mano gana diez minutos y pierde la propiedad más valiosa de un sistema serio: poder responder, con certeza y sin arqueología, a cómo se llegó hasta aquí. Versionar el esquema no es una ceremonia de proceso; es negarse a que la base tenga un pasado que nadie pueda leer.
- Crea una migración
0001conwrangler d1 migrations createque defina una tablausuarioscon un índice, y aplícala en--local. - Escribe una
0002que añada una columna con valor por defecto; aplica en local y confirma conmigrations listque ninguna queda pendiente. - Inspecciona la tabla
d1_migrationsen tu base local y observa cómo cada archivo aplicado queda anotado por nombre. - Planifica en papel un renombrado de columna con el patrón expandir y contraer: enumera las tres migraciones y qué hace cada una.
- Aplica todo en
--remotesobre una base de prueba y razona por qué el orden numérico da el mismo resultado que en local.