Callbacks de entidades
Descripción general
Sección titulada «Descripción general»Los callbacks te permiten engancharte al ciclo de vida de la entidad para:
- Sincronizar datos entre colecciones: copiar o mover entidades entre tablas ante cambios de estado
- Transformar datos antes de guardar (campos calculados, generación de slugs)
- Validar reglas de negocio más allá de la validación de esquemas
- Desencadenar efectos secundarios después de escribir (enviar correos electrónicos, sincronizar APIs, actualizar cachés)
- Restringir una lectura antes de que se compile, para que quien llama solo vea sus propias filas
- Filtrar/transformar datos después de leer
- Operaciones en cascada: limpiar registros relacionados al eliminar
Dónde se ejecutan los callbacks
Sección titulada «Dónde se ejecutan los callbacks»Una colección tiene dos bloques de callbacks, y la única diferencia es qué entorno de ejecución los ejecuta.
callbacks |
admin.browserCallbacks |
|
|---|---|---|
| Se ejecuta en | el servidor | el panel de administración, en el navegador |
| Se activa para | REST, el SDK, tiempo real, dataAsAdmin |
lecturas y escrituras que realiza el panel |
| Llega al navegador | no — los cuerpos se eliminan del bundle | sí, en su totalidad |
| Usar para | todo lo siguiente | colecciones con las que el panel se comunica directamente |
callbacks es el que buscas. Se ejecuta en cada ruta de datos que llega al
servidor — REST, el SDK, tiempo real, MCP y rebase.data — y su cuerpo nunca
sale de la máquina, por lo que una clave de API o una lectura de process.env
allí es segura. El resto de esta página trata sobre callbacks.
Hay un escritor que no es una ruta de datos: el sistema de autenticación.
El registro, el inicio de sesión por OAuth y la gestión de usuarios del
administrador escriben las filas de usuarios directamente y no ejecutan
ninguno de sus callbacks, así que un correo de bienvenida en afterSave sobre
users nunca se activa en el registro. Cuélgalo de los
hooks de autenticación que se pasan en
auth.hooks — beforeUserCreate, afterUserCreate, afterUserDelete —, que
requieren un backend con eject; el arranque avisa cuando la colección de
usuarios declara callbacks que el registro no ejecutará.
admin.browserCallbacks existe para un caso: una colección en un transporte direct o custom, que el panel lee y escribe por sí mismo sin un servidor Rebase en la ruta de la solicitud. Nada en el lado del servidor ve esas operaciones, por lo que callbacks nunca se activará para ellas, y este bloque es el único lugar donde puede residir su lógica de ciclo de vida.
import type { CollectionConfig } from "@rebasepro/types";
const eventsCollection: CollectionConfig = { slug: "events", name: "Events", dataSource: "analytics", // declared with transport: "direct" properties: { city: { name: "City", type: "string" }, code: { name: "Code", type: "string" } }, admin: { browserCallbacks: { afterRead: ({ row }) => ({ ...row, label: [row.city, row.code].join(" · ") }) } }};Dos reglas se derivan de «se envía a cada visitante», y ninguna es de estilo:
- Sin secretos. Nada de claves de API, ni
process.env, ni nada que te importaría que viera un lector del bundle. Eso pertenece acallbacks. - No es una barrera de seguridad. Un
browserCallbacks.afterReadque oculta un campo lo hace después de que el navegador ya tiene la fila — en un transporte directo, el documento sin procesar provino directamente del almacén de datos. Es presentación. La ocultación que deba mantenerse firme va encallbacks, o en las propias reglas del almacén de datos.
En una colección con transporte de servidor — el predeterminado, y casi con certeza el tuyo —, el servidor ya ha ejecutado callbacks antes de que la fila llegue al panel, por lo que un browserCallbacks.afterRead se ejecuta además de él. Escríbelo para que sea idempotente, o no lo escribas.
Un browserCallbacks.afterSave se ejecuta después de que la escritura se haya almacenado, y
nada en el navegador puede revertirla. Un error lanzado desde él se muestra como una advertencia;
el guardado en sí se da por hecho, de modo que el formulario no ofrece crear
el registro por segunda vez.
Definición de callbacks
Sección titulada «Definición de callbacks»import { defineCollection } from "@rebasepro/cms-types";
// The row shape is inferred from `properties`, so `values.title` below is a// `string` without anything being written twice.const articlesCollection = defineCollection({ slug: "articles", name: "Articles", table: "articles", properties: { title: { name: "Title", type: "string" }, slug: { name: "Slug", type: "string" }, createdAt: { name: "Created at", type: "string" }, updatedAt: { name: "Updated at", type: "string" } }, callbacks: { beforeSave: async ({ values, id, status }) => { // Auto-generate slug from title if (values.title) { values.slug = values.title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); }
// Set timestamps if (status === "new") { values.createdAt = new Date().toISOString(); } values.updatedAt = new Date().toISOString();
return values; },
afterSave: async ({ values, id }) => { // Send notification console.log(`Article ${id} saved: ${values.title}`); },
beforeDelete: async ({ id }) => { // Prevent deletion of published articles // Throw (or return false) to block the deletion },
afterRead: async ({ row }) => { // Transform data after loading return row; } }});Combinar conjuntos de callbacks
Sección titulada «Combinar conjuntos de callbacks»Desde 0.25 En 0.24, combina los conjuntos a mano.
Una colección tiene un solo objeto callbacks. Cuando varias responsabilidades quieren
cada una un hook, como las marcas de tiempo, un registro de auditoría y un alcance de
tenant, escribe cada una como su propio conjunto y combínalas con composeCallbacks:
import { composeCallbacks, defineCollection } from "@rebasepro/cms-types";import type { CollectionCallbacks } from "@rebasepro/types";
const timestamps: CollectionCallbacks = { beforeSave: ({ values, status }) => ({ ...values, ...(status === "new" ? { createdAt: new Date().toISOString() } : {}), updatedAt: new Date().toISOString() })};
const auditTrail: CollectionCallbacks = { afterSave: async ({ context, id, status }) => { await context.data.audit_log.create({ entity: "articles", entityId: String(id), action: status }); }};
export default defineCollection({ slug: "articles", name: "Articles", table: "articles", properties: { title: { name: "Title", type: "string" }, createdAt: { name: "Created at", type: "string" }, updatedAt: { name: "Updated at", type: "string" } }, callbacks: composeCallbacks(timestamps, auditTrail)});Los conjuntos se ejecutan en el orden dado, y cada hook se combina como el backend combina sus propios niveles global, de colección y de propiedad:
| Hook | Combinación |
|---|---|
beforeSave |
Encadenado: cada conjunto ve los valores que devolvió el anterior, fusionados de forma superficial sobre lo que recibió |
afterRead |
Encadenado: cada conjunto recibe la fila que devolvió el anterior |
beforeQuery |
Unido con AND: cada conjunto ve la consulta de quien llama, y una fila debe pasar todas las restricciones |
beforeDelete |
Cualquier false veta la eliminación; todos los conjuntos se ejecutan igualmente |
afterSave, afterDelete |
Se ejecutan en orden, cada uno con await; un throw detiene el resto |
afterSaveError |
Se ejecutan en orden; un throw no detiene al siguiente conjunto, y el primer error se vuelve a lanzar al final |
Un hook está en el resultado solo si algún conjunto lo tiene, y un conjunto
undefined se omite. composeCallbacks también se exporta desde @rebasepro/server y
@rebasepro/common.
Adjuntar callbacks después del arranque con backend.setCollectionCallbacks(slug, callbacks) reemplaza los que declaró la colección. Para añadir los tuyos a
ellos, pasa composeCallbacks(declared, yours).
Referencia de callbacks
Sección titulada «Referencia de callbacks»beforeQuery
Sección titulada «beforeQuery»Se llama antes de compilar una lectura, para limitar qué filas solicita. Devuelve condiciones para aplicar con AND a la consulta; no devuelvas nada para no añadir ninguna.
beforeQuery: ({ operation, // "list" | "get" | "count" | "aggregate" | "relation" query, // the parsed read, read-only context}) => { if (context.user?.roles?.includes("admin")) return; return { filter: { tenant_id: ["==", context.user?.tenant ?? null] } };}afterRead ve las filas que ya se han obtenido, por lo que puede ocultar un valor pero no puede evitar que la fila se lea. Esto se ejecuta antes, y vale la pena conocer tres cosas al respecto:
- Solo puede restringir. El valor de retorno es un filtro para combinar mediante AND, y ningún valor que pueda devolver amplía la lectura.
filteradmite los mismos filtros de campo que una consulta;logicaladmite un grupoor/and, para un alcance como “míos, o compartidos conmigo” — aún así combinado con AND en su totalidad, por lo que elorsolo elige entre las filas que el resto de la consulta ya admite. - Se activa en cada ruta de lectura. El listado, la obtención individual (get), el recuento (count), el agregado, la búsqueda, la lectura vectorial, un listado de ruta anidada, la reobtención en tiempo real detrás de un
.listen(), y las filas cargadas para una relación o un?include=— donde se aplica el hook de la colección de destino, porque esas son las filas de destino. - Un filtro que no pueda compilar rechaza la solicitud. Nombrar una columna que la tabla no tiene es un 400, nunca una condición descartada.
- Una escritura en una fila que excluye es un 404. Una actualización o una eliminación dirigida a una fila fuera del alcance se rechaza antes de la escritura, con la misma respuesta de “no hay fila…” que da una lectura — por lo que un alcance es un alcance tanto para escrituras como para lecturas. Una escritura de pertenencia uno-a-muchos (
comments: [ids]en un post) deja donde está a un hijo que el alcance del destino oculta, igual que deja un id que no nombra ninguna fila. Lo que no filtra son los valores que se están escribiendo: rechazar una escritura por su contenido corresponde abeforeSave.
Una lectura deliberadamente no se restringe: la comprobación de unicidad detrás de validation: { unique: true }. Pregunta si un valor existe en cualquier parte de la tabla, y si estuviera restringida respondería “único” para un valor que una fila oculta ya contiene.
→ Extender el servidor para ver dónde se sitúa esto entre las demás opciones.
beforeSave
Sección titulada «beforeSave»Se llama antes de que un registro se escriba en la base de datos. Devuelve los valores modificados. En Postgres se ejecuta dentro de la transacción de la escritura, que ya ha comenzado, así que retiene una conexión del pool mientras se ejecuta — consulta Un callback retiene una conexión.
beforeSave: async ({ values, // Entity values id, // Entity ID (null for new entities) status, // "new" | "existing" | "copy" previousValues, // Previous values (for updates) context // Full Rebase context}) => { // Return modified values return { ...values, updatedAt: new Date() };}Lanza un error para bloquear el guardado. La escritura nunca llega a la base de datos y quien llama recibe un 400 con tu mensaje y el código CALLBACK_REJECTED:
beforeSave: async ({ values }) => { if (values.price < 0) { throw new Error("Price cannot be negative"); } return values;}{ "error": { "message": "Price cannot be negative", "code": "CALLBACK_REJECTED", "details": { "stage": "beforeSave", "path": "products" } } }Para elegir el estado y el código tú mismo — un 409 para un conflicto, un 422 para algo bien formado pero inaceptable —, lanza un RebaseApiError:
import { RebaseApiError } from "@rebasepro/types";
beforeSave: async ({ values }) => { if (await isTaken(values.slug)) { throw new RebaseApiError("That slug is taken", { status: 409, code: "SLUG_TAKEN" }); } return values;}afterSave
Sección titulada «afterSave»Se llama después de que la fila se escribe y antes del commit, dentro de la misma transacción. Lanzar un error revierte el guardado — consulta Semántica de transacciones.
afterSave: async ({ values, // Saved values: the row as stored, not afterRead's view of it id, // Entity ID previousValues, // Previous values (undefined for new entities) status, // "new" | "existing" | "copy" context}) => { // Same transaction as the save: the log row commits with the article or not at all await context.data.audit_log.create({ action: status, article_id: id, title: values.title });}afterSaveError
Sección titulada «afterSaveError»Se llama cuando falla una operación de guardado.
afterSaveError: async ({ values, id, error, context}) => { console.error("Save failed:", error);}Se ejecuta para un guardado que falló en la base de datos o después de ella —
no para un rechazo de beforeSave, una solicitud rechazada antes de la
escritura (validación, un permiso faltante, un 404), ni un commit rechazado
después de que el guardado retornara (lista completa).
En una solicitud, se ejecuta una vez que la transacción de la escritura fallida se ha revertido, no dentro de ella. Su context.data es uno nuevo, para la misma identidad de quien llama, en el que cada llamada es una transacción propia, por lo que un job, mensaje de cola o webhook que encole se confirma y sobrevive al fallo del que informa. Un error lanzado desde afterSaveError se registra en el log, y quien llama sigue recibiendo el error del propio guardado.
afterRead
Sección titulada «afterRead»Se llama después de leer entidades de la base de datos. Transforma los datos para su visualización.
Da forma a lo que recibe quien llama — la respuesta de una lectura o de una
escritura, y su frame en tiempo real — y nada más: afterSave, beforeDelete,
afterDelete y el historial obtienen la fila tal
como se almacenó: un valor enmascarado aquí nunca es lo que registra una
auditoría ni lo que escribe de vuelta un revert, y un campo añadido aquí nunca
se escribe.
afterRead: async ({ row, // The row to transform context}) => { // Add computed fields return { ...row, displayName: `${row.first_name} ${row.last_name}` };}beforeDelete
Sección titulada «beforeDelete»Se llama antes de que se elimine un registro. Lanza un error para bloquear la eliminación, y quien llama recibe 400 CALLBACK_REJECTED con tu mensaje. Devolver false también la bloquea, como 403 CALLBACK_REJECTED sin ningún mensaje tuyo.
beforeDelete: async ({ id, row, context}) => { if (row.status === "published") { throw new Error("Cannot delete published articles. Unpublish first."); }}afterDelete
Sección titulada «afterDelete»Se llama después de que se elimina la fila y antes del commit, dentro de la misma transacción. Lanzar un error revierte la eliminación.
afterDelete: async ({ id, row, context}) => { // Cleanup related data console.log(`Article ${id} deleted`);}Callbacks de propiedades
Sección titulada «Callbacks de propiedades»También puedes definir callbacks a nivel de propiedad para transformaciones específicas de cada campo:
properties: { email: { type: "string", name: "Email", callbacks: { beforeSave: ({ value }) => value?.toLowerCase().trim(), afterRead: ({ value }) => value // Could decrypt, etc. } }}Cada callback recibe además context.data, una capa de datos para leer y escribir otras colecciones desde dentro del hook — consulta La API context.data.
Sincronización de datos entre colecciones
Sección titulada «Sincronización de datos entre colecciones»Uno de los usos más potentes de los callbacks es sincronizar datos entre colecciones utilizando context.data:
import { defineCollection } from "@rebasepro/cms-types";
const submissionsCollection = defineCollection({ slug: "job_submissions", name: "Job Submissions", table: "job_submissions", properties: { title: { name: "Title", type: "string" }, description: { name: "Description", type: "string" }, company_id: { name: "Company", type: "string" }, status: { name: "Status", type: "string" }, promoted_job_id: { name: "Promoted job", type: "string" } }, callbacks: { afterSave: async ({ values, id, previousValues, context }) => { // When a submission is approved, create a published job if (values.status === "approved" && previousValues?.status !== "approved") { const newJob = await context.data.collection<Record<string, unknown>>("jobs").create({ title: values.title, description: values.description, company_id: values.company_id, status: "published", source_submission_id: id, });
// Update the submission with the promoted job reference await context.data.collection<Record<string, unknown>>("job_submissions").update(id, { promoted_job_id: newJob.id, }); } } }});Otros patrones entre colecciones:
- Eliminación en cascada: Usa
afterDeletepara eliminar registros relacionados en colecciones secundarias - Desnormalización: Usa
afterSavepara actualizar campos de resumen en una colección principal - Registro de auditoría: Usa
afterSave/afterDeletepara escribir en una colección de registro de auditoría - Contadores: Usa
afterSave/afterDeletepara actualizar campos de recuento en entidades relacionadas
Probar un callback
Sección titulada «Probar un callback»Desde 0.25
Un callback es una función simple de un objeto de props, así que una prueba unitaria la
llama. createCallContext construye la prop context: los campos que pasas se usan
tal cual, y cualquiera que omitas es un sustituto que lanza un error en el primer uso,
nombrándose a sí mismo, de modo que una prueba aporta solo lo que el hook realmente toca.
import { test } from "node:test";import assert from "node:assert/strict";import { createCallContext } from "@rebasepro/server";import type { User } from "@rebasepro/types";import articlesCollection from "../config/collections/articles";
const editor: User = { uid: "u1", displayName: null, email: null, photoURL: null, providerId: "password", isAnonymous: false, roles: ["editor"]};
test("beforeSave derives the slug from the title", async () => { const values = await articlesCollection.callbacks!.beforeSave!({ collection: articlesCollection, path: "articles", status: "new", values: { title: "Hello World" }, context: createCallContext({ user: editor }) }); assert.equal(values.slug, "hello-world");});Un hook que lee o escribe a través de context.data necesita un driver: pasa
createCallContext({ data: buildSdkData(driver) }), con buildSdkData de
@rebasepro/common y cualquier DataDriver, incluido uno en memoria. user
queda ausente salvo que lo pases, como ocurre en una escritura sin quien llama, de modo que
una rama if (context.user) se prueba con honestidad. Para ejecutar los hooks contra una base
de datos real, consulta Scripts y la consola.
Referencia completa de Context
Sección titulada «Referencia completa de Context»Cada callback recibe un objeto context de tipo RebaseCallContext:
interface RebaseCallContext { /** The authenticated user, if any */ user?: User; /** The driver running this operation (server-side only) */ driver?: DataDriver; /** The query accessor — context.data.<slug>.create/update/find/delete */ data: RebaseSdkData; /** Functions, storage, email and dataAsAdmin — but no `data` */ client: RebaseCallbackClient; /** The default storage source */ storageSource: StorageSource;}Realiza consultas a través de context.data. context.client no tiene data: en el lado del servidor es el singleton rebase, cuyo único plano de datos es dataAsAdmin con alcance de administrador, por lo que context.client.data genera un error de compilación. Para construir uno de estos
para una prueba, consulta Probar un callback.
Próximos pasos
Sección titulada «Próximos pasos»- Reglas de seguridad — Seguridad a nivel de fila (Row Level Security)
- Historial de entidades — Registro de auditoría
- Funciones personalizadas — Añadir endpoints de API personalizados