Ir al contenido

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

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:

cms.config.ts
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"?

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.

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 update es el parche, no el documento entero: lo que llegó en la llamada y nada más. El documento de antes viaja aparte, en originalDoc, crudo y con los ids sin hidratar.
  • originalDoc y previousDoc solo existen en update. En create los dos son undefined: no hubo nada antes.
  • Devolver data ampliado es la forma de derivar un campo que no llegó. En un update, los hooks de campo de beforeValidate y de beforeChange corren solo para los campos presentes en el parche, porque una escritura parcial no empieza a escribir campos que nadie mandó; en create corren para todos los declarados, así que ahí sí puede un hook de campo rellenar el suyo. El afterChange de 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.

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.

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 beforeChange y se guarda. Una escritura es una; las lecturas son todas. Un readingTime calculado al escribir y guardado en su columna se lee gratis para siempre; calculado en afterRead se paga en cada visita.

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

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:

src/cms/hooks.ts
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.

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:

  • beforeValidate ve el parche; beforeChange ve 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 beforeChange deriva 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.
  • afterRead no 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_KV gana a cualquier hook. Sin namespace donde guardar, update() se niega antes de correr ninguno. get() sin KV sí corre su afterRead, sobre los valores por defecto.
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.