API local
Las siete operaciones de una colección, tal como quedan tipadas en Astro.locals.cms.collections.<slug>, y
las dos de un global en Astro.locals.cms.globals.<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>, options?: { ifMatch?: string }): 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.
update acepta un tercer argumento opcional con ifMatch: el updatedAt en ISO 8601 que leíste por
última vez —doc.updatedAt.toISOString()—. Si ya no coincide con el de la fila, no escribe nada y lanza
PreconditionFailedError. Es el mismo mecanismo que la cabecera
If-Match de la API REST:
const post = await cms.collections.posts.findByID(id)
await cms.collections.posts.update( id, { title: 'Hola de nuevo' }, { ifMatch: post.updatedAt.toISOString() },)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.
GlobalAPI<T>
Sección titulada «GlobalAPI<T>»Un global es un documento único sin listado, así que su API son dos
operaciones y no siete. Astro.locals.cms.globals.<slug> las declara:
interface GlobalAPI<T> { get<K extends keyof T>(args: GlobalGetArgs<T> & { select: readonly K[] }): Promise<Pick<T, K | 'id'>> get(args?: GlobalGetArgs<T>): Promise<T> update(data: WriteData<T>): Promise<T>}
interface GlobalGetArgs<T> { depth?: number select?: readonly (keyof T)[]}depth y select se comportan exactamente igual que en una colección —mismo rango, mismo
QueryError fuera de él, id siempre dentro del select—. No hay where, sort, limit ni
page: no hay nada que filtrar ni que paginar.
Tres diferencias con CollectionAPI, y las tres salen de que un global vive en KV y no en D1:
get() nunca lanza NotFoundError. No hay fila que pueda faltar: antes del primer guardado
devuelve el defaultValue de cada campo —o null— con createdAt y updatedAt a null, y no
escribe nada.
update() no acepta options. Ni ifMatch —KV no tiene put condicional, así que no hay nada
contra lo que comparar— ni system, que es cosa de los ficheros. Y valida el documento completo:
funde lo guardado (o los defaultValue) con lo que le mandas y pasa el resultado entero por la
validación, así que un required sin default que falte en el primer guardado es REQUIRED aunque
tu llamada no lo mencione.
Sin namespace de KV no se cae, pero no se guarda. get() devuelve los valores por defecto y
avisa una vez por isolate; update() lanza ConfigError con código MISSING_KV.
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, y and/or como mucho 100 condiciones por lista, en
cualquier nivel de anidamiento; pasarse lanza QueryError. Una consulta que D1 rechace por tamaño aunque
ninguna lista supere el tope lanza también QueryError, no DatabaseError, con el error original de D1 en cause.
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 | Medio | 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.
Lo que una colección, un global o un campo pueden declarar en su hooks. La
narrativa —el orden, qué recibe cada fase, qué pasa cuando una falla— está en
Hooks.
interface CollectionHooks<T = Row> { beforeValidate?: BeforeValidateHook<T>[] beforeChange?: BeforeChangeHook<T>[] afterChange?: AfterChangeHook<T>[] afterRead?: AfterReadHook<T>[] beforeDelete?: BeforeDeleteHook<T>[] afterDelete?: AfterDeleteHook<T>[]}
interface GlobalHooks<T = Row> { beforeValidate?: BeforeValidateHook<T>[] beforeChange?: BeforeChangeHook<T>[] afterChange?: AfterChangeHook<T>[] afterRead?: AfterReadHook<T>[]}
interface FieldHooks<T = Row> { beforeValidate?: FieldHook<T>[] beforeChange?: FieldHook<T>[] afterChange?: FieldHook<T>[] afterRead?: FieldHook<T>[]}Las tres listas de claves se exportan como constantes —COLLECTION_HOOKS, GLOBAL_HOOKS y
FIELD_HOOKS—, y son exactamente lo que defineConfig acepta: cualquier otra clave es un
ConfigError con código INVALID_HOOK al arrancar.
Lo que recibe un hook
Sección titulada «Lo que recibe un hook»type HookOperation = 'create' | 'update' | 'delete' | 'read'
type MaybePromise<T> = T | Promise<T>
/** Un documento tal como lo ve un hook: los miembros de `T`, y nada más */type HookDoc<T> = { [K in keyof T]: T[K] }
interface HookCMS { collections: Record<string, CollectionAPI<Row>> globals: Record<string, GlobalAPI<Row>>}
interface HookArgs { operation: HookOperation /** El slug de la colección o del global dueño del hook */ collection: string /** Un objeto por operación de raíz, compartido con las anidadas */ context: Record<string, unknown> cms: HookCMS}
interface FieldHookArgs<T = Row> extends HookArgs { value: unknown /** El nombre del campo */ field: string /** En `create`/`update`, lo que se va a escribir; en `read` y en `afterChange`, el documento */ data: WriteData<T> | HookDoc<T> originalDoc?: HookDoc<T>}cms es una API local ligada a la operación que corre el hook: hereda su context y cuenta la
profundidad, que topa en MAX_HOOK_DEPTH —5 reentradas anidadas— y a la sexta lanza
HookRecursionError.
Los siete tipos
Sección titulada «Los siete tipos»type BeforeValidateHook<T = Row> = ( args: HookArgs & { data: WriteData<T>; originalDoc?: HookDoc<T> },) => MaybePromise<WriteData<T> | undefined | void>
type BeforeChangeHook<T = Row> = BeforeValidateHook<T>
type AfterChangeHook<T = Row> = ( args: HookArgs & { doc: HookDoc<T>; previousDoc?: HookDoc<T> },) => MaybePromise<void>
type AfterReadHook<T = Row> = ( args: HookArgs & { doc: HookDoc<T> },) => MaybePromise<(T & Row) | undefined | void>
type BeforeDeleteHook<T = Row> = (args: HookArgs & { id: string }) => MaybePromise<void>
type AfterDeleteHook<T = Row> = ( args: HookArgs & { id: string; doc: HookDoc<T> },) => MaybePromise<void>
type FieldHook<T = Row> = (args: FieldHookArgs<T>) => MaybePromise<unknown>Devolver undefined —o no devolver nada— deja las cosas como estaban, que es el caso corriente y por
eso el retorno admite void. Lo que devuelve un FieldHook es el nuevo valor de su campo, y ahí un
null sí se asigna.
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 |
PreconditionFailedError |
PRECONDITION_FAILED |
412 | El ifMatch de update ya no coincide con el updatedAt |
ConfigError |
— | — | Config o schema inválidos, al arrancar; y MISSING_KV en update() de un global sin namespace de KV |
HookRecursionError |
HOOK_RECURSION |
500 | Un hook reentró en la misma operación más de MAX_HOOK_DEPTH veces |
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 o en el global |
UNIQUE |
Ya hay un documento con ese valor |
INVALID_RELATION |
La relación apunta a un id que no existe. Solo en una colección: un global vive en KV, que no tiene claves foráneas, y guarda el id tal cual |
RESTRICTED |
No se puede borrar: otros documentos apuntan a este. Solo cuenta lo que apunte desde una colección: un global vive en KV y no retiene un documento; un fichero que use sí lo retiene, con REFERENCED |
REFERENCED |
No se puede borrar el fichero: algo lo usa. Solo sale de delete sobre una colección que guarda ficheros, con el path vacío, y el mensaje nombra a quien lo usa —Entradas «Mi artículo» (cover) para una colección, Sitio (logo) para un global—, hasta 5 y luego «y N más». Cuentan igual los campos upload y relationship, con el onDelete que sea; un global solo cuenta si hay namespace de KV. Ver Ficheros |
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 |
| Profundidad del árbol de expresión | and/or topan en 100 condiciones por lista; lo que D1 rechace igualmente sale como QueryError |
| 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, bucket?: R2Bucket, kv?: KVNamespace,): { collections: Record<string, CollectionAPI<never>> globals: Record<string, GlobalAPI<never>> bucket?: R2Bucket kv?: KVNamespace}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.
bucket y kv son opcionales: el primero solo hace falta donde alguna colección tuya declara
upload —la biblioteca ssd que inyecta el paquete arranca sin él—, y
el segundo es donde viven los globals. Sin kv el arranque no falla —un
global no tiene tabla que comprobar—: lo que falla es el primer update().