Leer y escribir contenido
Dentro del servidor no hay HTTP de por medio. Astro.locals.cms habla con D1 directamente, tipado con tus
propias colecciones:
---export const prerender = false
const { docs, pagination } = await Astro.locals.cms.collections.posts.find({ where: { status: { equals: 'published' } }, sort: '-publishedAt', limit: 10,})---
<h1>{pagination.totalDocs} artículos</h1>{docs.map((post) => <a href={`/blog/${post.slug}`}>{post.title}</a>)}prerender = false en toda página que lea contenido: en build no hay bindings de Cloudflare y no hay
nada que leer. Quien pone ahí locals.cms es la integración de Astro.
Es la misma API que usan por dentro la API REST y el administrador. Siete operaciones:
| Operación | Devuelve | Si no existe |
|---|---|---|
find(args?) |
Una página con sus totales | Una página vacía |
findByID(id, args?) |
El documento | Lanza NotFoundError |
findOne(args?) |
El primero del orden | null |
count(args?) |
Un número | 0 |
create(data) |
El documento creado | — |
update(id, data) |
El documento actualizado | Lanza NotFoundError |
delete(id) |
El documento borrado | Lanza NotFoundError |
findByID lanza y findOne devuelve null a propósito: son dos intenciones distintas, buscar algo que
sabes que está o mirar a ver si está.
Filtrar
Sección titulada «Filtrar»where es un objeto de campos y operadores, y se anida con and y or:
await cms.collections.posts.find({ where: { and: [ { status: { equals: 'published' } }, { publishedAt: { less_than: new Date() } }, { or: [{ title: { contains: 'astro' } }, { author: { in: ['user-1', 'user-2'] } }] }, ], },})La lista completa de operadores y de qué tipos admite cada uno está en la referencia.
Un campo que no existe en la colección lanza QueryError, no se ignora. Un filtro que desaparece en
silencio devuelve documentos de más, y eso se descubre tarde y en producción. Lo mismo vale para un operador
que no aplica al tipo del campo: contains sobre un checkbox no filtra nada útil, y like sobre un json
busca dentro de un blob serializado y parece que funciona hasta que no.
contains y like no son lo mismo
Sección titulada «contains y like no son lo mismo»like respeta los comodines de SQL. contains los escapa y envuelve el valor en %…%:
{ excerpt: { contains: '50%' } } // encuentra el texto literal «50%»{ excerpt: { like: '50%' } } // encuentra todo lo que empieza por «50»Un buscador de la web va con contains siempre: si no, cualquiera puede escribir % en tu caja de búsqueda
y traerse la tabla entera.
Valores en crudo
Sección titulada «Valores en crudo»Un where escrito en una plantilla trae valores de verdad — un Date, un number, un boolean. El mismo
where armado desde una query string trae cadenas. Las dos formas filtran igual:
{ publishedAt: { greater_than: new Date('2026-01-01') } }{ publishedAt: { greater_than: '2026-01-01' } } // idéntico{ featured: { equals: 'true' } } // idéntico a `true`La conversión es solo del where. Lo que se guarda y lo que se lee lo convierte el schema generado, en una
única capa: ver Campos.
Ordenar y paginar
Sección titulada «Ordenar y paginar»find() // ORDER BY createdAt DESC, id DESCfind({ sort: 'title' }) // ORDER BY title ASC, id ASCfind({ sort: '-a,b' }) // ORDER BY a DESC, b ASC, id ASCAl orden que pidas siempre se le añade id como último criterio. No es cosmético: createdAt tiene
resolución de milisegundos, y dos documentos creados en el mismo milisegundo se ordenarían de forma
arbitraria entre una página y la siguiente, repitiendo unos y saltándose otros. Como el id es un UUID v7,
ordenar por él es ordenar por instante de creación, así que el desempate además significa algo.
find devuelve las filas en docs y todo lo demás en pagination:
const { docs, pagination } = await cms.collections.posts.find({ limit: 20, page: 2 })
pagination.totalDocs // 42pagination.totalPages // 3pagination.hasNextPage // trueVan juntos en un solo objeto porque casi siempre se pasan juntos: <Paginador {...pagination} /> es todo lo
que necesita un componente para pintarse.
limit es un entero entre 1 y 1000, page un entero mayor o igual que 1, y cualquier otra cosa es
QueryError — también el limit: 0 que en otros CMS significa «todos». En D1 una lectura sin techo es la
forma más fácil de agotar el Worker. Una page más allá del final no es un error: devuelve docs: []
con los totales correctos.
El conteo no cuesta un segundo viaje: las filas y el COUNT(*) van en el mismo batch(), que además es
atómico, así que el total corresponde exactamente a la página devuelta.
Traer solo unas columnas
Sección titulada «Traer solo unas columnas»const { docs } = await cms.collections.posts.find({ select: ['title', 'slug'] })// ^ docs: { id: string, title: string, slug: string }[]El tipo de retorno se estrecha con lo que pidas. id entra siempre, lo pidas o no.
Relaciones: depth
Sección titulada «Relaciones: depth»depth |
Qué devuelve un campo relationship o upload |
|---|---|
0 |
El id: '01930d4c-…' |
1 |
El documento entero, con sus propias relaciones en ids |
n |
Se repite hasta n niveles (máximo 10) |
Por defecto es 1, que es lo que casi siempre quieres al pintar una plantilla. Un campo upload se resuelve
igual que un relationship.
Por eso el tipo generado es una unión y no solo el id:
interface Post { cover: string | Media | null}Con depth decidido en tiempo de ejecución, TypeScript no puede saber qué rama toca, así que lo dice.
Estrechas al usarlo:
---const media = Astro.locals.cms.collections.media---
{typeof post.cover === 'object' && post.cover !== null && ( <img src={media.url(post.cover)} alt={post.cover.alt} />)}El documento resuelto trae sus campos —filename, mimeType, alt…— pero no una url: la ruta la
construye url(), que vive en la colección upload. Ver Ficheros.
Un id que no resuelve se queda como la cadena que era. La fila puede haberse borrado entre dos consultas, o alguien puede haber escrito en D1 por fuera. Ni se pierde el id —que es la única pista para saber de dónde venía— ni se cae un listado de cincuenta artículos por una referencia rota.
Cuesta consultas fijas, no una por documento
Sección titulada «Cuesta consultas fijas, no una por documento»Por cada nivel se agrupan todos los ids que apuntan a la misma colección, se deduplican y se lanza una sola consulta por colección de destino. Cincuenta artículos con autor y portada son tres consultas, no cincuenta y una.
Escribir
Sección titulada «Escribir»const post = await cms.collections.posts.create({ title: 'Hola', slug: 'hola', status: 'published', cover: 'a1b2c3d4-…', // en escritura, una relación es siempre el id})create valida contra el esquema de la colección, aplica los defaultValue a lo que falte, genera id,
createdAt y updatedAt, inserta y devuelve el documento con sus relaciones ya resueltas.
El id lo genera siempre el CMS, un UUID v7. Pasarlo a mano se rechaza, igual que createdAt y
updatedAt. Es lo que hace que todos los ids sean ordenables por tiempo por construcción: los 48 bits altos
de un v7 son el timestamp en milisegundos, así que el índice de la clave primaria sirve además de índice de
orden de creación.
update es parcial. Lo que no mandes se queda como estaba, y updatedAt se refresca siempre:
await cms.collections.posts.update(post.id, { title: 'Hola de nuevo' })delete devuelve el documento borrado, para que puedas registrar qué desapareció —o deshacerlo— sin una
lectura previa.
Errores
Sección titulada «Errores»Todos heredan de CMSError y llevan un code estable y un status. El mensaje es para humanos y puede
cambiar; el code no.
| Clase | code |
status |
Cuándo |
|---|---|---|---|
ValidationError |
VALIDATION_ERROR |
400 | El documento no pasa el esquema, o choca un constraint |
QueryError |
QUERY_ERROR |
400 | where, sort, select, limit, page o depth mal formados |
NotFoundError |
NOT_FOUND |
404 | El documento no existe |
DatabaseError |
DATABASE_ERROR |
500 | D1 ha fallado; el error original va en cause |
ValidationError trae un issues con un problema por campo, que es lo que el administrador pinta debajo de
cada uno:
try { await cms.collections.posts.create({ title: 'Repetido', slug: 'hola', status: 'draft' })} catch (error) { if (error instanceof ValidationError) { console.log(error.issues) // [{ path: 'slug', code: 'UNIQUE', message: 'Ya existe un documento con ese slug' }] }}Un slug duplicado o una relación que apunta a un documento inexistente son errores de quien escribe, no del
servidor, así que salen como 400 con el campo culpable señalado y no como un fallo interno:
| Lo que falla | code del issue |
|---|---|
Un unique que ya existe |
UNIQUE |
| Una relación a un id que no existe | INVALID_RELATION |
Borrar algo a lo que otros apuntan con onDelete: 'restrict' |
RESTRICTED |
Lo que no encaje con ninguno de esos patrones cae a DatabaseError 500 sin inventar nada: un error mal
traducido es peor que uno genérico.