Ir al contenido

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.

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() },
)

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.

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.

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

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

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_DEPTH5 reentradas anidadas— y a la sexta lanza HookRecursionError.

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.

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

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().