Hooks globales del backend
Visión general
Sección titulada «Visión general»Rebase proporciona dos niveles de callbacks del ciclo de vida de entidades; ambos utilizan el mismo tipo CollectionCallbacks de @rebasepro/types:
- Callbacks por colección: Se definen en configuraciones de colección individuales. Se ejecutan únicamente para esa colección.
- Callbacks globales: Se definen en
initializeRebaseBackend({ callbacks }). Se disparan en cada colección, en cada ruta de datos (API REST, WebSocket / tiempo real,rebase.dataAsAdminen el servidor). El único escritor que no ven es 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 usuario directamente y ejecutan los hooks de autenticación (afterUserCreate, …) en su lugar.
Utilice callbacks globales para:
- Alcance de filas (Row scoping) —
beforeQueryen cada colección, de modo que las lecturas de un inquilino se delimiten en un solo lugar en vez de por colección. Solo Postgres: junto a una fuente de datos de MongoDB o Firestore, unbeforeQueryglobal rechaza iniciarse en lugar de dejar las lecturas de esa fuente sin delimitar. ConsultebeforeQuery. - Enmascaramiento de PII — ofusca campos sensibles para quienes realizan llamadas sin permisos de administrador en todas las colecciones.
- Registro de auditoría unificado — registra cada creación, actualización o eliminación en un solo lugar. Las cuentas nuevas del registro y de OAuth no están entre ellas; registre esas desde
afterUserCreate. - Validación transversal — aplica invariantes que abarcan múltiples colecciones.
Configuración
Sección titulada «Configuración»Pase la clave callbacks a initializeRebaseBackend:
import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterRead({ row, context }) { // Runs after every entity read, across all collections return row; }, beforeSave({ values, context }) { // Runs before every entity save return values; } }});Desde 0.25 callbacks recibe
un solo conjunto. Para mantener separadas varias responsabilidades, escriba cada una como su propio conjunto y pase
composeCallbacks(tenantScope, auditTrail): se combinan exactamente como los niveles
anteriores. Consulte
Combinar conjuntos de callbacks.
Lo mismo ocurre con backend.setCollectionCallbacks(slug, callbacks), que
reemplaza los callbacks que declaró una colección: pase
composeCallbacks(declared, yours) para conservarlos.
Tipo CollectionCallbacks
Sección titulada «Tipo CollectionCallbacks»type CollectionCallbacks = { beforeQuery?(props): QueryNarrowing | void; // Conditions to AND into a read before it is compiled afterRead?(props): Record<string, unknown>; // Transform row before returning to caller beforeSave?(props): Partial<Values>; // Modify values before writing to DB afterSave?(props): void; // After the write, still in the transaction afterSaveError?(props): void; // Side-effects after a failed save beforeDelete?(props): boolean | void; // Return false (403) or throw to block deletion afterDelete?(props): void; // After the delete, still in the transaction};beforeQuery delimita una lectura antes de que se compile; consulte
beforeQuery.
Todos los callbacks pueden devolver una Promise (asíncrono) o un valor simple (síncrono).
Props de callbacks
Sección titulada «Props de callbacks»Cada callback recibe un único objeto de props. Campos comunes:
| Campo | Tipo | Presente en |
|---|---|---|
collection |
CollectionConfig |
Todos los callbacks |
path |
string |
Todos los callbacks |
row |
Record<string, unknown> |
afterRead, beforeDelete, afterDelete |
id |
string |
beforeSave (opcional), afterSave, afterSaveError, beforeDelete, afterDelete |
values |
EntityValues |
beforeSave, afterSave, afterSaveError |
previousValues |
EntityValues (opcional) |
beforeSave, afterSave, afterSaveError |
status |
"new" | "existing" |
beforeSave, afterSave, afterSaveError |
context |
RebaseCallContext |
Todos los callbacks |
context.user contiene el usuario autenticado (uid, roles, etc.), o es undefined para solicitudes públicas.
collection siempre está presente. Un callback global se dispara para cada colección, por lo que
es el único nivel que se registra independientemente de cualquiera de ellas, pero aun así
nunca recibe una colección faltante. Una solicitud que nombra una ruta que el registro de
colecciones no puede resolver se rechaza con 404 NOT_FOUND antes de que se ejecute ningún nivel,
que es la misma respuesta que las rutas de lectura y escritura dan a dicha ruta de todos modos. La
alternativa —omitir el nivel para esas rutas— convertiría a afterRead en un paso de
ofuscación con una excepción silenciosa, por lo que no se ofrece.
Flujo de ejecución
Sección titulada «Flujo de ejecución»[Client Request] │ ▼ [Hono Router] │ [Database Driver] ┌─────┴───────────────────────────────────────────────────────┐ │ 1. Start PostgreSQL Transaction │ │ 2. Set Config: app.user_id = '<uid>', app.user_roles = ... │ │ │ │ 3. Global Callback: beforeSave ─┐ │ │ 4. Collection Callback: beforeSave ─┘ awaited │ │ 5. Drizzle SQL execution & Postgres RLS evaluation │ │ 6. Global Callback: afterSave ─┐ │ │ 7. Collection Callback: afterSave ─┘ awaited │ │ │ │ 8. Commit ← a throw anywhere in 3–7 rolls the write back │ └─────┬───────────────────────────────────────────────────────┘ │ [Realtime notifications flushed — after the commit, never before] │ ▼[Client Response]Semántica bloqueante vs. asíncrona
Sección titulada «Semántica bloqueante vs. asíncrona»Cada callback en la lista a continuación se espera con await, y todos ellos salvo
afterSaveError se ejecutan dentro de la transacción que procesa la escritura. No existe un nivel de “disparar y olvidar”: la
fila y todo lo que hicieron sus callbacks se confirman (commit) juntos o no se confirma nada.
beforeSave,beforeDelete— si el callback lanza un error (throw), la operación se rechaza con un HTTP 400 que incluye su mensaje y el códigoCALLBACK_REJECTED, y la escritura en la base de datos nunca ocurre. Lance unRebaseApiErrorde@rebasepro/typespara elegir el estado usted mismo — consulte Callbacks de entidad. UnbeforeDeleteque devuelvefalsees el mismo rechazo sin mensaje, y responde 403 con ese código.afterRead— la fila devuelta (o la fila transformada) es lo que recibe el emisor de la llamada, y solo él: en una escritura da forma a la respuesta, mientras queafterSave,beforeDelete,afterDeletey el historial obtienen la fila tal como se almacenó — un valor enmascarado nunca se registra ni se revierte en la columna. Su transacción esREAD ONLY— consulte más abajo.afterSave,afterDelete— se ejecutan antes del commit, conawait. Si lanzan un error aquí, se revierte (rollback) la fila y se responde con el mismo 400CALLBACK_REJECTED, condetails.stageindicando el hook. Mantienen la transacción abierta mientras se ejecutan, por lo que uno lento mantiene un bloqueo activo.afterSaveError— se ejecuta cuando el guardado falló, en la salida. En una solicitud se ejecuta después de que la transacción de la escritura fallida se haya revertido, con uncontexten el que cada llamada es una transacción propia, de modo que un trabajo que encole para informar del fallo se conserva. Un error que lance se registra en el log; el emisor de la llamada recibe el error del propio guardado.
Cuándo se ejecuta afterSaveError
Sección titulada «Cuándo se ejecuta afterSaveError»Informa de un guardado que falló en la base de datos o después de ella — desde la sentencia INSERT o UPDATE en adelante. Un guardado rechazado antes de ese punto nunca llegó a la base de datos, así que el hook no se entera.
| Se ejecuta | No se ejecuta |
|---|---|
La base de datos rechaza la sentencia: una violación de clave única o foránea, una restricción de comprobación (check) o un trigger, una política de seguridad a nivel de fila (WRITE_DENIED) |
Un rechazo de beforeSave — el hook que rechazó es el que lo sabe |
afterRead o afterSave lanza un error (el emisor recibe CALLBACK_REJECTED) |
Una solicitud rechazada antes del driver: validación (VALIDATION_*), un campo que el emisor no puede escribir, un permiso faltante |
| La entrada del historial no se puede registrar | Una fila que el emisor no puede direccionar (404), una marca de inquilino que no puede establecer |
El commit se rechaza después de que el guardado retornara: TRANSACTION_ABORTED (una sentencia fallida que un callback capturó), o una restricción diferida comprobada en el COMMIT |
|
| Una eliminación — no hay contraparte de eliminación |
En una escritura masiva o _batch se ejecuta para la fila que falló; las
filas anteriores a ella se revierten junto con el lote y no se informan.
Efectos secundarios que no deben retener la transacción
Sección titulada «Efectos secundarios que no deben retener la transacción»Cualquier cosa lenta, o cualquier cosa que no se pueda deshacer si la transacción se revierte, no pertenece al cuerpo del callback:
| Desea | Haga esto en su lugar |
|---|---|
| Llamar a un tercero, enviar correo, generar un archivo | Encolar un trabajo. Un trabajo encolado en una transacción que se revierte nunca fue encolado — lo cual es el comportamiento deseado. |
| Notificar a otros procesos que algo sucedió | Publicar en un canal en tiempo real después de que la escritura retorne, no desde dentro del hook. |
| Trabajo en una función personalizada que el emisor de la llamada no necesita esperar | waitUntil(c, promise) de @rebasepro/server/functions — se ejecuta después de la respuesta, y el host espera a que termine antes de apagarse. |
La regla general: si el trabajo aún debe ocurrir cuando se deshace la escritura, no es parte de la escritura, por lo que no debe ir dentro del hook.
afterRead no puede escribir
Sección titulada «afterRead no puede escribir»Una lectura con alcance de solicitud abre su transacción en modo READ ONLY, y también lo hace la
relectura de una suscripción en tiempo real. afterRead se ejecuta dentro
de ella, por lo que ninguna escritura desde ese callback puede tener éxito — ni una creación con context.data,
ni una actualización, ni una oculta dentro de una función auxiliar a la que invoque. Postgres rechaza la
instrucción con SQLSTATE 25006, y al emisor de la llamada se le responde:
{ "error": { "message": "An `afterRead` callback tried to write. …", "code": "READ_ONLY_TRANSACTION", "details": { "dbCode": "25006" } } }Eso es un 409, no un 500: es su código el que está siendo rechazado, no el servidor fallando. El modo de solo lectura es deliberado — una lectura que escribe de forma silenciosa es una lectura cuyo costo, bloqueos y superficie de RLS nadie tenía presupuestados.
Por lo tanto, la auditoría de lectura no pertenece a afterRead. Registre la lectura fuera de la
solicitud en su lugar — desde un trabajo en segundo plano alimentado por lo que ya emita, o
desde una función personalizada que realice la lectura y la escritura mediante dos llamadas
independientes:
// ✗ Fails with READ_ONLY_TRANSACTION on every read.callbacks: { afterRead: async ({ path, row, context }) => { await context.data.read_log.create({ path, uid: context.user?.uid }); return row; }}// backend/functions/read-article.ts — served at /api/functions/read-article/:id// ✓ The read and the audit row are two operations, and only the second writes.import { defineFunction, getUserId, requireAuth, requireDriver } from "@rebasepro/server/functions";
export default defineFunction((app, { rebase }) => { app.get("/:id", requireAuth, async (c) => { const article = await requireDriver(c).fetchOne({ path: "articles", id: c.req.param("id") }); await rebase.dataAsAdmin.read_log.create({ path: "articles", uid: getUserId(c) }); return c.json(article); });});La auditoría del lado de la escritura no tiene tal problema: afterSave y afterDelete se ejecutan en una
transacción de lectura-escritura, y la fila de auditoría se confirma junto con el cambio que registra.
Ejemplos
Sección titulada «Ejemplos»Enmascaramiento de PII
Sección titulada «Enmascaramiento de PII»Ofusque las direcciones de correo electrónico para los emisores sin privilegios de administrador en todas las colecciones:
import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterRead({ row, context }) { const isAdmin = context.user?.roles?.includes("admin"); if (!isAdmin && row.email) { return { ...row, email: "********" }; } return row; } }});Registro de auditoría global
Sección titulada «Registro de auditoría global»Registre cada eliminación, en todas las colecciones, en una tabla audit_log. Debido a que
afterDelete se ejecuta en la propia transacción de la eliminación, la fila de auditoría y la
eliminación se confirman juntas — no hay un intervalo en el que una exista sin la
otra:
import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({ // ... other config callbacks: { async afterDelete({ collection, id, row, context }) { if (collection.slug === "audit_log") return; // don't audit the audit await context.data.audit_log.create({ action: "delete", collection: collection.slug, entity_id: String(id), actor: context.user?.uid ?? "anonymous", snapshot: row }); } }});Tenga en cuenta lo que esto aporta y lo que cuesta: si la fila de auditoría no se puede escribir, la eliminación tampoco se produce. Para un registro de auditoría, esto suele ser lo que se desea. Si no es así, capture el error en el callback e indíquelo en un comentario.
Lógica específica por colección
Sección titulada «Lógica específica por colección»Los callbacks globales se disparan para todas las colecciones. Para delimitar la lógica a una sola colección, verifique collection.slug o path:
callbacks: { beforeSave({ collection, values, context }) { if (collection.slug === "orders") { if (!values.total || values.total <= 0) { throw new Error("Order total must be positive"); } } return values; }}Para callbacks que solo se aplican a una única colección, prefiera en su lugar callbacks por colección.