Ir al contenido

Campos

Un campo declara tres cosas a la vez: la columna de D1, la validación que se aplica al escribir, y el componente que lo edita en el administrador. Los declaras una vez y las tres salen solas.

type Columna en D1 Tipo en TypeScript
text text('name') string
textarea text('name') string
number real('name'), o integer con integer: true number
checkbox integer('name', { mode: 'boolean' }) boolean
date integer('name', { mode: 'timestamp_ms' }) Date
select text('name') + CHECK unión de sus value
json text('name', { mode: 'json' }) unknown
relationship text('name') + clave foránea + índice string | Doc
upload text('name') + clave foránea + índice string | Doc

textarea genera exactamente la misma columna que text; lo único que cambia es el componente del administrador.

Todos los tipos aceptan esto:

{
name: 'publishedAt', // nombre de la columna y clave en la API
label: 'Fecha', // por defecto se deriva del name: 'publishedAt' → 'Published At'
required: true, // NOT NULL, y validación al escribir
unique: true, // índice único
index: true, // índice
defaultValue: 'draft', // se aplica en la aplicación, no como DEFAULT de SQL
admin: {
description: 'Texto de ayuda bajo el campo',
readOnly: false,
hidden: false, // fuera del administrador, presente en la API
position: 'sidebar', // 'main' | 'sidebar'
width: 'half', // 'full' | 'half'
},
}

Un campo con unique: true e index: true genera solo el índice único. Un índice único ya sirve para toda lectura que serviría el normal, y tener los dos duplica el coste de escritura por fila a cambio de nada.

Las cinco claves de admin las lee el editor del panel: description se pinta como pista bajo el campo, readOnly lo deshabilita, hidden lo saca del formulario sin sacarlo de la API, position decide en qué columna cae y width si ocupa media anchura.

Toda colección lleva estos tres, sin declararlos:

id: text('id').primaryKey(),
createdAt: integer('createdAt', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp_ms' }).notNull(),

El id es un UUID v7: ordenado por tiempo, así que el índice de la clave primaria sirve además como índice de orden de creación. Redefinir cualquiera de los tres en tu config es un error de configuración.

{ name: 'slug', type: 'text', required: true, unique: true, minLength: 1, maxLength: 200, pattern: '^[a-z0-9-]+$' }

pattern es una expresión regular en forma de cadena. Se compila al arrancar, así que una mal escrita falla al inicio y no a mitad de una escritura.

Mientras no haya rich text, textarea con markdown cubre el texto largo.

{ name: 'readingTime', type: 'number', integer: true, min: 0, max: 120 }

integer: true cambia las dos mitades a la vez: la columna pasa de REAL a INTEGER y la validación deja de aceptar decimales.

{ name: 'featured', type: 'checkbox' }

SQLite no tiene booleanos: se guarda como 0 o 1 y vuelve como boolean.

{ name: 'publishedAt', type: 'date', mode: 'datetime' }

Se guarda como epoch en milisegundos y vuelve como Date. mode ('date' o 'datetime') solo elige el componente del administrador — los dos guardan lo mismo.

Al escribir acepta un Date o una cadena ISO, porque el mismo campo llega de las dos formas según vengas de la API local o de HTTP.

En el administrador, 'datetime' te da calendario y hora y 'date' solo calendario. Eliges en el huso de tu navegador, que el campo rotula, pero lo que se guarda es UTC en los dos casos. Un 'date' es un día del calendario, no un instante: se guarda tal cual, sin convertir, o cambiaría de día para cualquiera al oeste de Greenwich cada vez que abriera y guardara.

{ name: 'status', type: 'select', required: true, options: ['draft', 'published'] }

Las opciones pueden llevar etiqueta:

options: [
{ label: 'Borrador', value: 'draft' },
{ label: 'Publicado', value: 'published' },
]

Genera un CHECK en la tabla, así que la base de datos rechaza un valor fuera de la lista aunque algo se salte la validación:

CONSTRAINT "chk_posts_status" CHECK(status IN ('draft', 'published'))

Y en TypeScript sale la unión de los valores, no string:

status: 'draft' | 'published'

Que es lo que hace que el editor te corrija al escribir una plantilla.

{ name: 'meta', type: 'json' }

Para estructuras a medida mientras no existan array ni blocks. Entra cualquier cosa serializable y vuelve tal cual, anidamiento incluido. En TypeScript es unknown, así que te toca estrecharlo.

{ name: 'author', type: 'relationship', to: 'users', onDelete: 'cascade' }

to es el slug de otra colección. Se comprueba al arrancar: si apuntas a una que no existe, el error te sugiere la más parecida.

onDelete acepta 'setNull' (por defecto), 'cascade' y 'restrict'. El defecto es deliberado — borrar un autor no debería borrar sus posts en silencio; si es lo que quieres, lo pides.

Siempre lleva índice, lo declares o no.

{ name: 'cover', type: 'upload', to: 'media', accept: ['image/*'] }

Como relationship, pero apuntando a una colección que declare upload. Si apunta a una que no lo declara, falla al arrancar: sería una clave foránea a una tabla sin ficheros.

accept restringe qué fichero puede referenciar este campo: escribir en cover el id de un PDF es un 400 con código INVALID_FILE_TYPE, aunque el PDF esté bien guardado. Se comprueba al escribir el documento, no al subir el fichero — la subida va contra la colección de media y no sabe qué campo acabará usándolo. No cuesta ninguna consulta si el campo no declara accept.

Es una regla distinta del mimeTypes de la colección, y esa va primero. Ver Ficheros.

La regla es una: required: true da un tipo llano, cualquier otra cosa añade | null.

.cms/types.d.ts
export interface Post {
id: string
createdAt: Date
updatedAt: Date
title: string // required: true
slug: string // required: true
excerpt: string | null // sin required
readingTime: number | null
featured: boolean | null
publishedAt: Date | null
status: 'draft' | 'published' // select con required
meta: unknown | null
cover: string | Media | null // upload → el id o el documento
author: string | User | null // relationship → el id o el documento
}

Una relationship y un upload se tipan como la unión, no solo como el id: depth se resuelve en tiempo de ejecución y por defecto vale 1, así que TypeScript no puede saber cuál de las dos ramas toca y un tipo que dijera solo string mentiría justo en el caso por defecto. En la plantilla lo estrechas antes de pintarlo — ver Relaciones: depth.

Cada tipo trae su propio control en el panel, y cinco hacen bastante más que un input: relationship busca en la colección destino, date abre un calendario, select se vuelve un buscador a partir de diez opciones, json valida al salir del campo y upload abre tu biblioteca de medios. Los mismos controles pintan el valor de un filtro en el listado.

Puedes sustituir el de cualquier tipo por uno tuyo con admin.fieldComponents. Ver El administrador.

richText, array y blocks no están todavía. Mientras tanto, textarea con markdown cubre el texto largo y json cubre las estructuras a medida.

Ver también Schema y migraciones para lo que ocurre cuando cambias un campo que ya está en producción.