Ir al contenido

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.

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.

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.

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.

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.

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.

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

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.

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í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

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.