API local
Las siete operaciones de una colección, tal como quedan tipadas en Astro.locals.cms.collections.<slug>. La
narrativa está en Leer y escribir contenido; quién pone ese
locals.cms ahí, en Integración de Astro.
CollectionAPI<T>
Sección titulada «CollectionAPI<T>»interface CollectionAPI<T> { find<K extends keyof T>(args: FindArgs<T> & { select: readonly K[] }): Promise<PaginatedDocs<Pick<T, K | 'id'>>> find(args?: FindArgs<T>): Promise<PaginatedDocs<T>>
findByID<K extends keyof T>(id: string, args: FindByIDArgs & { select: readonly K[] }): Promise<Pick<T, K | 'id'>> findByID(id: string, args?: FindByIDArgs): Promise<T>
findOne<K extends keyof T>(args: FindArgs<T> & { select: readonly K[] }): Promise<Pick<T, K | 'id'> | null> findOne(args?: FindArgs<T>): Promise<T | null>
create(data: WriteData<T>): Promise<T> update(id: string, data: WriteData<T>): Promise<T> delete(id: string): Promise<T> count(args?: { where?: Where }): Promise<number>}T es la interfaz que db:generate escribe para esa colección en .cms/types.d.ts.
UploadAPI<T>
Sección titulada «UploadAPI<T>»Una colección que declara upload recibe dos operaciones más. Los tipos generados la declaran como
UploadAPI, así que upload() y url() solo existen donde tienen sentido:
interface UploadAPI<T> extends CollectionAPI<T> { upload(file: File, data?: WriteData<T>): Promise<T> url(doc: T | string): string}upload() guarda los bytes en R2 y la fila en D1, deduciendo filename, mimeType, filesize,
width y height del propio fichero. Mandar cualquiera de los cinco en data es un
ValidationError con código READ_ONLY_FIELD.
url() es síncrona y no hace ninguna E/S: puedes llamarla una vez por imagen en un listado sin
convertir el render en una cascada de await. Con el documento devuelve la ruta canónica; con un id
suelto, la corta que redirige.
Todo el flujo —el orden de las operaciones, la detección de tipo, el saneado del nombre y las cabeceras del servido— está en Ficheros.
Argumentos
Sección titulada «Argumentos»interface FindArgs<T> { where?: Where sort?: string limit?: number page?: number depth?: number select?: readonly (keyof T)[]}
interface FindByIDArgs { depth?: number}| Argumento | Por defecto | Rango | Fuera de rango |
|---|---|---|---|
limit |
10 |
entero de 1 a 1000 | QueryError |
page |
1 |
entero ≥ 1 | QueryError |
depth |
1 |
entero de 0 a 10 | QueryError |
sort |
'-createdAt' |
campos de la colección | QueryError |
select |
todas las columnas | campos de la colección | QueryError |
limit: 0 no significa «todos»: es QueryError como cualquier otro valor fuera de rango.
Retorno de find
Sección titulada «Retorno de find»interface PaginatedDocs<T> { docs: T[] pagination: Pagination}
interface Pagination { totalDocs: number limit: number page: number totalPages: number hasNextPage: boolean hasPrevPage: boolean}Las filas van en docs y todo lo demás en pagination, en un solo objeto que se pasa entero a un
componente de paginado.
Una page más allá de pagination.totalPages devuelve docs: [] con el resto de totales correctos.
type Where = | { and: Where[] } | { or: Where[] } | { [field: string]: Partial<Record<Operator, unknown>> }Los campos de sistema (id, createdAt, updatedAt) se filtran igual que los tuyos. Un campo desconocido
lanza QueryError.
Operadores
Sección titulada «Operadores»| Operador | SQL | Tipos que lo admiten |
|---|---|---|
equals |
= |
todos |
not_equals |
<> |
todos |
in |
IN (…) |
todos |
not_in |
NOT IN (…) |
todos |
exists |
IS NOT NULL / IS NULL |
todos |
greater_than |
> |
number, date, text, textarea |
greater_than_equal |
>= |
number, date, text, textarea |
less_than |
< |
number, date, text, textarea |
less_than_equal |
<= |
number, date, text, textarea |
like |
LIKE, con comodines |
text, textarea, select |
contains |
LIKE '%…%', comodines escapados |
text, textarea, select |
Un operador sobre un tipo que no lo admite lanza QueryError.
exists: true es IS NOT NULL y exists: false es IS NULL. Sobre una columna NOT NULL no falla:
devuelve todo o nada, que es la respuesta honesta.
in y not_in aceptan como mucho 100 valores.
Conversión de valores
Sección titulada «Conversión de valores»Cada valor del where —y cada elemento de la lista en in y not_in— se convierte al dominio antes de
comparar, para que un filtro armado desde una query string se comporte igual que uno escrito a mano:
| Tipo de campo | Acepta | Se convierte en |
|---|---|---|
date |
'2026-01-01', epoch en ms, Date |
Date |
number |
'5', 5 |
5 |
checkbox |
'true', '1', 'false', '0', boolean |
boolean |
| resto | — | tal cual |
null y undefined pasan sin tocar, porque exists los usa como bandera. Si tras convertir el valor sigue
sin encajar con el tipo del campo, es QueryError.
Campos separados por comas, cada uno con - opcional para descendente.
sort |
ORDER BY |
|---|---|
| — | createdAt DESC, id DESC |
'title' |
title ASC, id ASC |
'-title' |
title DESC, id DESC |
'-a,b' |
a DESC, b ASC, id ASC |
id se añade siempre como último criterio, con la misma dirección que el último campo pedido, salvo que ya
lo hayas puesto tú.
Datos de escritura
Sección titulada «Datos de escritura»type Ids<V> = V extends { id: string } ? string : V
type WriteData<T> = { [K in Exclude<keyof T, 'id' | 'createdAt' | 'updatedAt'>]?: Ids<T[K]>}Los tipos generados describen la lectura, donde una relación puede venir resuelta
(cover: string | Media | null). En escritura solo se acepta el id, así que el tipo de entrada se deriva del
de lectura en vez de generarse aparte.
Todas las claves son opcionales en el tipo, también en create: la obligatoriedad la comprueba la
validación en tiempo de ejecución, que reporta todos los campos que faltan de una vez con sus code. Un
campo required con defaultValue es legítimamente omitible, y eso el tipo no lo sabe.
id, createdAt y updatedAt no se pueden escribir: llegan como READ_ONLY_FIELD.
Errores
Sección titulada «Errores»| 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 |
ConfigError |
— | — | Config o schema inválidos, al arrancar |
DatabaseError |
DATABASE_ERROR |
500 | D1 ha fallado; el original va en cause |
ValidationError.issues es una lista de { path, code, message }. Códigos que salen de una escritura:
code |
Significa |
|---|---|
REQUIRED |
Falta un campo obligatorio |
READ_ONLY_FIELD |
Has mandado id, createdAt o updatedAt |
UNKNOWN_FIELD |
El campo no existe en la colección |
UNIQUE |
Ya hay un documento con ese valor |
INVALID_RELATION |
La relación apunta a un id que no existe |
RESTRICTED |
No se puede borrar: otros documentos apuntan a este |
Límites de D1
Sección titulada «Límites de D1»| Límite | Cómo se maneja |
|---|---|
| 100 parámetros ligados por consulta | in/not_in topan en 100 valores; las listas de ids de depth se trocean de 100 en 100 |
| Latencia por viaje | Las filas y el conteo de find van en un solo batch(); depth resuelve por lotes |
buildAPI
Sección titulada «buildAPI»Lo que construye el objeto de colecciones. La integración de Astro lo llama por ti y deja el resultado en
Astro.locals.cms; esta firma solo importa si montas el CMS a mano.
function buildAPI( config: ResolvedConfig, schema: Record<string, SQLiteTable>, d1: D1Database,): { collections: Record<string, CollectionAPI<never>> }Las tablas de Drizzle se le pasan: son las de tu .cms/schema.ts, exactamente el mismo fichero del que
drizzle-kit saca las migraciones. Una sola definición, así que el runtime y las migraciones no pueden
divergir. Una colección sin tabla en el schema falla al arrancar, no a mitad de un request.