Ir al contenido

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.

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
D1

Commitea migrations/. Añade .cms/ al .gitignore: se regenera siempre, y commitear artefactos generados solo produce conflictos de merge.

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.

  1. Cambia tu cms.config.ts — añade un campo, marca uno como unique, lo que sea.

  2. Genera la migración:

    Ventana de terminal
    pnpm cms db:migrate

    Esto regenera .cms/ y después llama a drizzle-kit generate, que escribe el SQL.

  3. Lee el SQL que ha salido. Es el paso que no conviene saltarse:

    Ventana de terminal
    cat migrations/*/migration.sql

    Si no te convence, tíralo y vuelve al paso 1:

    Ventana de terminal
    pnpm cms db:pop
  4. Aplícalo en local y compruébalo:

    Ventana de terminal
    pnpm cms db:apply
  5. 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.

Hacen falta dos ficheros, una vez.

drizzle.config.ts
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:

wrangler.jsonc
"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:

Ventana de terminal
pnpm add -D drizzle-kit

Un ejemplo real. Este config:

cms.config.ts
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:

.cms/schema.ts
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, createdAt y updatedAt en 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 CHECK por cada select, 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.

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.

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.body
Ejecuta `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:

Ventana de terminal
pnpm cms db:generate --check

La 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.

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.

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 decisions
1. Rename or create — column public.posts.heading

db: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.

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ú.