Ir al contenido

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

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.

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.

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.

find() // ORDER BY createdAt DESC, id DESC
find({ sort: 'title' }) // ORDER BY title ASC, id ASC
find({ sort: '-a,b' }) // ORDER BY a DESC, b ASC, id ASC

Al 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 // 42
pagination.totalPages // 3
pagination.hasNextPage // true

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

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.

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.

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.

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.