Hooks
Un hook es una función tuya que el CMS llama dentro de una operación, no después de ella. Corre en la misma petición, con el documento entre manos, y lo que devuelve cambia lo que se guarda o lo que se sirve.
Tres ejemplos de una frase, que son los tres motivos por los que existen:
- Derivar un
slugdel título antes de validar, para que quien escribe no tenga que teclearlo. - Purgar una caché —o disparar un webhook— en cuanto un artículo se guarda.
- Negarse a borrar un documento que tu negocio todavía necesita.
Dónde se declaran
Sección titulada «Dónde se declaran»En el cms.config.ts, en tres sitios, y cada uno admite las claves que tienen sentido para él:
| Dónde | Clave | Hooks que admite |
|---|---|---|
| Una colección | hooks de defineCollection |
beforeValidate, beforeChange, afterChange, afterRead, beforeDelete, afterDelete |
| Un global | hooks de defineGlobal |
beforeValidate, beforeChange, afterChange, afterRead — un global no se borra |
| Un campo | hooks del campo |
beforeValidate, beforeChange, afterChange, afterRead — un campo no tiene borrado propio |
Cada clave es un array de funciones, síncronas o async, y corren en el orden del array:
import { type AfterChangeHook, type BeforeValidateHook, defineCollection, type FieldHook,} from '@kevolution-co/cms'import type { Post } from './.cms/types'
const slugify: BeforeValidateHook<Post> = ({ data }) => ({ ...data, slug: data.title?.toLowerCase(),})const logChange: AfterChangeHook<Post> = ({ operation, doc, previousDoc }) => { const cambióStatus = previousDoc?.status !== doc.status
console.log('demo: post', operation, doc.slug, cambióStatus)}const trim: FieldHook<Post> = ({ value }) => (typeof value === 'string' ? value.trim() : value)
const posts = defineCollection({ slug: 'posts', fields: [ { name: 'title', type: 'text', required: true, hooks: { beforeValidate: [trim] } }, { name: 'slug', type: 'text', required: true, unique: true }, ], hooks: { beforeValidate: [slugify], afterChange: [logChange], },})defineConfig los comprueba al arrancar, no en la primera escritura: un hooks que no es un
objeto, una clave que no existe, un valor que no es un array o un elemento que no es una función son
un ConfigError con código INVALID_HOOK, y el mensaje dice qué escribir en su lugar. Una clave que
Payload tiene y aquí no se reconoce por su nombre:
la colección "posts" declara el hook "beforeRead", que no existe. Los válidos son: beforeValidate,beforeChange, afterChange, afterRead, beforeDelete, afterDelete. ¿Querías decir "afterRead"?En qué orden corren
Sección titulada «En qué orden corren»Tres tuberías, una por familia de operación. Dentro de cada fase corren primero todos los hooks de
esa fase de un lado y después los del otro, y el lado que abre cambia:
colección → campos en beforeValidate, y campos → colección en todas las demás.
create y update:
beforeValidate (colección) → beforeValidate (campos) ↓ validación ↓beforeChange (campos) → beforeChange (colección) ↓ se vuelve a validar, si alguno devolvió algo ↓ escritura en D1 ↓afterChange (campos) → afterChange (colección)find, findByID y findOne, una vuelta por documento:
consulta a D1 ↓ depth (las relaciones, ya resueltas) ↓afterRead (campos) → afterRead (colección)delete, sin hooks de campo —lo que se borra es el documento entero—:
beforeDelete (colección) ↓ borrado en D1 (y el fichero de R2, si lo hay) ↓afterDelete (colección)Lo que se escribe siempre está validado: si algún beforeChange sustituyó algo, el documento
vuelve a pasar por la validación antes de tocar la base. Si nadie devolvió nada, se valida una sola
vez.
Y por el otro extremo: un beforeValidate corre antes de que nadie mire nada, así que su data
es lo que llegó sin validar —el JSON crudo de la petición, donde un title que el tipo declara
string puede ser un número—. Comprueba con un typeof lo que vayas a usar: un TypeError dentro
del hook convierte en un 500 lo que la validación habría contestado como un 400.
Qué recibe cada uno
Sección titulada «Qué recibe cada uno»Todos reciben operation, collection —el slug de quien tiene el hook—, context y cms, y
además lo suyo:
| Hook | Recibe además | Qué devuelve |
|---|---|---|
beforeValidate |
data, y originalDoc en update |
El data que quieres escribir, o nada |
beforeChange |
data, y originalDoc en update |
El data que quieres escribir, o nada |
afterChange |
doc, y previousDoc en update |
Nada: lo que devuelva se ignora |
afterRead |
doc |
El documento que quieres servir, o nada |
beforeDelete |
id |
Nada |
afterDelete |
id y doc, la fila que desapareció |
Nada |
Y los de campo: las mismas cuatro fases que un global, pero con otros argumentos. No reciben doc
ni previousDoc, sino data —lo que se va a escribir en beforeValidate y beforeChange, y el
documento ya escrito o leído en afterChange y afterRead—, más originalDoc en update, value
—el valor de su campo— y field, su nombre. Lo que devuelva un hook de campo es el nuevo valor
del campo, y un null devuelto también se asigna: solo undefined lo deja como estaba.
Tres cosas que conviene tener claras sobre data:
- En
updatees el parche, no el documento entero: lo que llegó en la llamada y nada más. El documento de antes viaja aparte, enoriginalDoc, crudo y con los ids sin hidratar. originalDocypreviousDocsolo existen enupdate. Encreatelos dos sonundefined: no hubo nada antes.- Devolver
dataampliado es la forma de derivar un campo que no llegó. En unupdate, los hooks de campo debeforeValidatey debeforeChangecorren solo para los campos presentes en el parche, porque una escritura parcial no empieza a escribir campos que nadie mandó; encreatecorren para todos los declarados, así que ahí sí puede un hook de campo rellenar el suyo. ElafterChangede campo es la excepción: corre para todos los campos declarados en las dos operaciones, porque a esas alturas el documento está completo.
Las firmas exactas están en local-api.
Cuando un hook falla
Sección titulada «Cuando un hook falla»No todos fallan igual, y la diferencia es si hay algo escrito que proteger:
| Fase | Qué pasa si lanza |
|---|---|
beforeValidate, beforeChange, beforeDelete |
Aborta la operación. El error viaja tal cual: nada se escribe |
afterChange, afterDelete |
Se registra con console.error y no deshace nada; los siguientes del array siguen corriendo |
afterRead |
Tumba la lectura entera: aquí no hay nada escrito que proteger |
Un before* que lanza un CMSError conserva su código y su status, así que la
API REST responde lo que el hook decidió:
import { CMSError } from '@kevolution-co/cms'
const hayStock: BeforeValidateHook<Post> = () => { throw new CMSError('SIN_STOCK', 'No hay', 409)}POST /api/cms/posts contesta 409 con "error": "SIN_STOCK". Cualquier otro error —uno tuyo, un
TypeError, o un CMSError de 5xx— sale como 500 genérico: la capa REST solo deja pasar un
CMSError con status menor que 500, y lo demás lo registra y lo contesta como error interno.
De ahí sale la regla: lo que no puede fallar en silencio va en un before*. Un afterChange
que no se entera de que su webhook devolvió 500 deja el documento guardado y a ti sin aviso; un
beforeChange que comprueba lo mismo no deja escribir.
En serie, no en paralelo
Sección titulada «En serie, no en paralelo»Los hooks de una fase corren uno detrás de otro, esperando a cada uno. No es una limitación: dos
hooks de la misma fase escribiendo a la vez sobre el mismo data lo dejarían en un estado que
depende de quién acabe antes, y el orden del array es lo único que tú controlas.
La consecuencia es de rendimiento, y es la que importa:
- Nada lento en
afterRead. Corre una vez por documento: un listado de cien filas son cien vueltas. Ni peticiones de red ni consultas por documento. - Lo derivado que cuesta se calcula en
beforeChangey se guarda. Una escritura es una; las lecturas son todas. UnreadingTimecalculado al escribir y guardado en su columna se lee gratis para siempre; calculado enafterReadse paga en cada visita.
No entrar en bucle
Sección titulada «No entrar en bucle»Un hook puede volver a llamar al CMS, y ahí es donde se hacen los bucles infinitos. Dos cosas lo evitan.
context es un objeto por operación de raíz, compartido con todo lo que anide bajo ella. Es
donde se pone la bandera que corta la segunda vuelta:
const afterChange: AfterChangeHook<Post> = async ({ doc, context, cms }) => { if (context.skip === true) return
context.skip = true
await cms.collections.posts.update(String(doc.id), { excerpt: 'desde el hook' })}cms es una API local ligada a esa misma operación: cms.collections.<slug> y
cms.globals.<slug>, con las mismas firmas de siempre, pero heredando el context y contando la
profundidad.
Y si aun así se cierra el círculo, hay un tope: una operación de raíz admite 5 reentradas
anidadas, y la sexta se rechaza con HookRecursionError —código HOOK_RECURSION, status 500— y la
cadena que lo provocó en el mensaje. La cadena lleva un tramo por salto: empieza por el hook que
abrió el bucle, repetido una vez por vuelta, y acaba por el método por el que se reentró. Un
afterChange de posts que vuelve a llamar a update sobre posts deja esto:
Un hook ha vuelto a entrar en la misma operación más de 5 veces: posts.afterChange →posts.afterChange → posts.afterChange → posts.afterChange → posts.afterChange →posts.afterChange → posts.updateTiparlos con el tipo generado
Sección titulada «Tiparlos con el tipo generado»Los siete tipos aceptan el genérico de tu colección, así que un hook se escribe contra el tipo que
cms db:generate emite en .cms/types.d.ts y cabe en el config sin cast:
import type { AfterChangeHook, BeforeValidateHook } from '@kevolution-co/cms'import type { Post } from '../../.cms/types'
export const deriveSlugAndReadingTime: BeforeValidateHook<Post> = ({ data, originalDoc }) => { const { title, slug, body, readingTime } = data const derivado: { slug?: string; readingTime?: number } = {}
if ((slug ?? originalDoc?.slug ?? '') === '' && typeof title === 'string') { const slugDerivado = slugify(title)
if (slugDerivado !== '') derivado.slug = slugDerivado }
if ( readingTime == null && originalDoc?.readingTime == null && typeof body === 'string' && body !== '' ) { derivado.readingTime = minutosDeLectura(body) }
if (derivado.slug === undefined && derivado.readingTime === undefined) return data
return { ...data, ...derivado }}import type y no import: .cms/types.d.ts son solo declaraciones y no existe en tiempo de
ejecución. Ese es uno de los dos hooks de la demo del repositorio —apps/demo/src/cms/hooks.ts—,
que es donde se los ve corriendo.
Los globals
Sección titulada «Los globals»Un global admite cuatro: beforeValidate, beforeChange, afterChange y
afterRead. No tiene los dos del borrado porque no se borra. Corren igual que los de una colección
—en serie, con el mismo context y el mismo cms— con cuatro diferencias que salen de que vive en
KV:
beforeValidateve el parche;beforeChangeve el documento completo. El de un global se valida entero en cada guardado, así que a esas alturas se conoce entero, con todos los campos declarados.- Lo que un
beforeChangederiva se guarda. Además de los campos que llegaron en el parche se escribe la clave de cada campo que el hook haya dejado distinto de lo que había. afterReadno se cachea. La caché de 60 s sigue guardando lo que KV dio, así que el hook corre en cada lectura y una lectura en caliente sigue sin ir a KV.MISSING_KVgana a cualquier hook. Sin namespace donde guardar,update()se niega antes de correr ninguno.get()sin KV sí corre suafterRead, sobre los valores por defecto.
Lo que un hook no tiene
Sección titulada «Lo que un hook no tiene»| Cosa | Estado |
|---|---|
beforeOperation y afterOperation |
❌ No hay envoltorio alrededor de la operación entera. #23 |
beforeRead |
❌ Una lectura corre afterRead y nada antes: no hay dónde intervenir sin haber consultado. #23 |
afterError |
❌ Un fallo se registra y se propaga; no se puede enganchar nada a él. #23 |
beforeDuplicate |
❌ No hay duplicar documento que enganchar. #23 |
Los hooks de auth (beforeLogin, afterLogin, afterLogout…) |
❌ La sesión la lleva better-auth y no pasa por aquí. #22 |
background |
❌ Un hook corre dentro de la petición; no hay forma de mandarlo a segundo plano. #23 |
user y req |
❌ Un hook no sabe quién escribe ni desde qué petición: no hay roles que consultar todavía. #22 |
siblingData |
❌ Un hook de campo ve el documento entero en data, que con campos planos es lo mismo; hará falta con array y blocks. #27 |
Las cinco primeras son claves, y declararlas no falla en silencio: son un ConfigError con código
INVALID_HOOK al arrancar. Las tres últimas no son claves sino argumentos que no llegan, así que lo
que falta ahí es la clave en el objeto que el hook recibe.
Ver también Limitaciones de la versión 1, que enumera lo que le falta al CMS entero.