Schema y migraciones
Tu configuración no crea tablas por sí sola. Kevin CMS la convierte en un schema de Drizzle y en ficheros SQL que revisas antes de aplicar. Nada toca la base de datos sin que tú lo pidas.
La cadena completa
Sección titulada «La cadena completa»cms.config.ts │ cms db:generate ├──▶ .cms/schema.ts schema de Drizzle (generado, no se commitea) └──▶ .cms/types.d.ts tipos + App.Locals (generado, no se commitea) │ │ cms db:migrate ▼ migrations/<fecha>_<nombre>/migration.sql (se commitea) ▲ migrations/<fecha>_<nombre>/snapshot.json (se commitea) │ db:pop │ │ lo deshace │ cms db:apply ▼ D1Commitea migrations/. Añade .cms/ al .gitignore: se regenera siempre, y commitear artefactos
generados solo produce conflictos de merge.
Esa cadena la recorren las colecciones. Un global se queda a mitad: vive
en Cloudflare KV, así que no genera tabla ni migración —añadir uno, cambiarle un campo o
quitarlo no produce una línea de SQL, y db:generate --check ni lo mira— pero sí entra en
.cms/types.d.ts, con su interfaz y su entrada en Astro.locals.cms.globals.
Por qué en build y no en caliente
Sección titulada «Por qué en build y no en caliente»Un CMS que altera su propio schema durante un request es imposible de revisar y de revertir. Generando antes de desplegar consigues tres cosas: las migraciones son ficheros SQL que se leen en una pull request, se pueden revertir, y el runtime nunca toca el schema, así que ningún request puede romper la base de datos.
El ciclo
Sección titulada «El ciclo»-
Cambia tu
cms.config.ts— añade un campo, marca uno comounique, lo que sea. -
Genera la migración:
Ventana de terminal pnpm cms db:migrateEsto regenera
.cms/y después llama adrizzle-kit generate, que escribe el SQL. -
Lee el SQL que ha salido. Es el paso que no conviene saltarse:
Ventana de terminal cat migrations/*/migration.sqlSi no te convence, tíralo y vuelve al paso 1:
Ventana de terminal pnpm cms db:pop -
Aplícalo en local y compruébalo:
Ventana de terminal pnpm cms db:apply -
Cuando estés conforme, en producción:
Ventana de terminal pnpm cms db:apply --remote
Ninguno de esos comandos hace el trabajo del siguiente. db:migrate no aplica, db:apply no genera. Es a
propósito: el paso 3, leer el SQL antes de que toque una base de datos, sólo existe si nada lo salta por ti.
Configuración
Sección titulada «Configuración»Hacen falta dos ficheros, una vez.
import { defineConfig } from 'drizzle-kit'
export default defineConfig({ dialect: 'sqlite', schema: './.cms/schema.ts', out: './migrations',})Y en wrangler.jsonc, junto al binding de D1:
"d1_databases": [{ "binding": "DB", "database_name": "mi-sitio", "database_id": "<id>", "migrations_dir": "migrations", "migrations_pattern": "migrations/*/migration.sql"}]drizzle-kit lo instalas tú, en tu proyecto, y con la versión que el CMS declara como peer —sin
fijarla te llevas la línea 0.31, que no la satisface:
pnpm add -D drizzle-kit@1.0.0-rc.4Qué genera cada campo
Sección titulada «Qué genera cada campo»Un ejemplo real. Este config:
const posts = defineCollection({ slug: 'posts', fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', required: true, unique: true, index: true }, { name: 'status', type: 'select', options: ['draft', 'published'], required: true }, { name: 'author', type: 'relationship', to: 'users', onDelete: 'cascade' }, ],})produce este schema:
export const posts = sqliteTable('posts', { id: text('id').primaryKey(), createdAt: integer('createdAt', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updatedAt', { mode: 'timestamp_ms' }).notNull(), title: text('title').notNull(), slug: text('slug').notNull(), status: text('status').notNull(), author: text('author').references(() => users.id, { onDelete: 'cascade' }),}, (t) => [ uniqueIndex('uniq_posts_slug').on(t.slug), check('chk_posts_status', sql`status IN ('draft', 'published')`), index('idx_posts_author').on(t.author),])Tres cosas que aparecen sin que las pidas:
id,createdAtyupdatedAten toda colección.- Un índice en cada relación, la pidas o no. Filtrar por una relación es el patrón de acceso más común de un CMS, y sin índice cada una de esas consultas recorre la tabla entera.
- Un
CHECKpor cadaselect, con sus opciones. La base de datos rechaza un valor fuera de la lista aunque algo se salte la validación.
La tabla completa de tipo de campo a columna está en Campos.
Nombres
Sección titulada «Nombres»| Elemento | Regla | Ejemplo |
|---|---|---|
| Tabla | El slug de la colección, tal cual | posts |
| Columna | El nombre del campo, tal cual | publishedAt |
| Índice | idx_<tabla>_<columna> |
idx_posts_author |
| Único | uniq_<tabla>_<columna> |
uniq_posts_slug |
| CHECK | chk_<tabla>_<columna> |
chk_posts_status |
Sin conversión a snake_case: el nombre que escribes en el config es el que aparece en la base de datos.
Una traducción implícita solo obliga a recordar dos nombres para lo mismo.
Aviso de deriva
Sección titulada «Aviso de deriva»Cuando cambias el config sin generar la migración, db:generate te lo dice:
El schema ha cambiado desde la última migración: ~ posts.readingTime (integer → real) + posts.views (integer) - posts.bodyEjecuta `cms db:migrate` para generar la migración.+ es una columna nueva, - una que has quitado, ~ un cambio de tipo.
Es un aviso, no un error: en desarrollo cambias el config todo el rato y bloquear el dev sería
insoportable. En integración continua usa --check, que convierte ese aviso en un fallo:
pnpm cms db:generate --checkLa comparación se hace contra los snapshot.json que hay en migrations/, que están commiteados, así que
funciona igual en tu máquina que en un clon recién hecho por CI.
Qué genera cada cambio
Sección titulada «Qué genera cada cambio»No todos los cambios cuestan lo mismo. Estos son los cuatro casos, con el SQL que sale de verdad:
| Cambio en el config | SQL generado | Tus datos |
|---|---|---|
| Añadir un campo | ALTER TABLE posts ADD subtitle text |
Intactos |
| Quitar un campo | ALTER TABLE posts DROP COLUMN subtitle |
Se pierde esa columna |
| Cambiar el tipo de un campo | Recrea la tabla con INSERT … SELECT |
Se conservan las filas |
| Renombrar un campo | Ninguno: falla pidiendo --hints |
Intactos |
db:migrate te pide confirmación en los dos casos destructivos —quitar y cambiar tipo— antes de generar
nada.
Recrear una tabla a mano
Sección titulada «Recrear una tabla a mano»Recrear es CREATE TABLE __new_x, INSERT … SELECT, DROP TABLE x y RENAME. Ese DROP destruye
datos en D1 cuando alguna clave foránea apunta a x: D1 acepta el PRAGMA foreign_keys=OFF que
drizzle-kit pone delante y lo ignora, así que el borrado implícito del DROP dispara los
ON DELETE de las hijas y se lleva sus filas, en verde y sin que wrangler avise. Está en
Limitaciones.
Por eso db:migrate se planta ahí, borra la migración que acababa de generar y sale con error:
Migración bloqueada y borrada: recrea tablas a las que apuntan claves foráneas, y en D1 eso destruyefilas de otras tablas. users, apuntada desde: - account (ON DELETE CASCADE) - session (ON DELETE CASCADE) - posts (ON DELETE SET NULL)El camino es escribir esa migración tú, respaldando las filas de las hijas en tablas sin claves
foráneas —CREATE TABLE … AS SELECT no copia las restricciones— y restaurándolas después del
RENAME. Para el caso de arriba, con account en cascada y posts.author a NULL:
-- 1. Respaldo, en tablas que ninguna clave foránea vigilaCREATE TABLE `backup_account` AS SELECT * FROM `account`;CREATE TABLE `backup_posts_author` AS SELECT `id`, `author` FROM `posts`;
-- 2. La recreación. El CREATE es tu tabla ya con el tipo nuevo: sale de `.cms/schema.ts`,-- que `db:generate` acaba de regenerarCREATE TABLE `__new_users` ( `id` text PRIMARY KEY NOT NULL, `age` text);INSERT INTO `__new_users`(`id`, `age`) SELECT `id`, `age` FROM `users`;DROP TABLE `users`;ALTER TABLE `__new_users` RENAME TO `users`;
-- 3. Restauración: el DROP vació `account` y anuló `posts`.`author`INSERT INTO `account` SELECT * FROM `backup_account`;UPDATE `posts` SET `author` = ( SELECT `author` FROM `backup_posts_author` WHERE `backup_posts_author`.`id` = `posts`.`id`);
-- 4. LimpiezaDROP TABLE `backup_account`;DROP TABLE `backup_posts_author`;Una hija por cada clave foránea que apunte a la tabla, y una restauración por cada onDelete:
CASCADE se repone con el INSERT entero, SET NULL con un UPDATE de la columna. Aplícalo como
cualquier otra migración, primero en local y después con --remote.
Renombrar un campo no está soportado
Sección titulada «Renombrar un campo no está soportado»Kevin CMS no intenta adivinar renombrados, y drizzle-kit tampoco: si quitas title y añades heading,
no puede saber si querías renombrar o si querías borrar uno y crear otro. En vez de elegir por ti, se planta:
missing_hints: 1 unresolved decisions1. Rename or create — column public.posts.headingdb:migrate sale con código 2 y no genera ninguna migración. cms todavía no sabe pasarle esas
pistas, así que hoy el camino es escribir la migración a mano en migrations/:
ALTER TABLE posts RENAME COLUMN title TO heading;Adivinar la intención sería peor que plantarse: una herramienta que confunde un renombrado con un borrado te vacía una columna sin avisar.
Durante el desarrollo
Sección titulada «Durante el desarrollo»La integración de Astro llama a generate al arrancar y vigila tu cms.config.ts, así que editarlo
regenera los tipos sin salir del dev.
Lo que no hace nunca es generar ni aplicar migraciones. Eso siempre lo pides tú.