Callbacks de Entidad
Resumen
Sección titulada «Resumen»Los callbacks le permiten integrar su lógica en el ciclo de vida de la entidad para:
- Sincronizar datos entre colecciones — copiar o mover entidades entre tablas en cambios de estado
- Transformar datos antes de guardar (campos calculados, slugificación)
- Validar reglas de negocio más allá de la validación de esquemas
- Disparar efectos secundarios después de las escrituras (enviar correos electrónicos, sincronizar APIs, actualizar cachés)
- Filtrar/transformar datos después de la lectura
- Operaciones en cascada — limpiar registros relacionados al eliminar
Definición de Callbacks
Sección titulada «Definición de Callbacks»import type { PostgresCollectionConfig } from "@rebasepro/types";
// The row shape. Without it every `values.x` below is `unknown`.type Article = { title: string; slug: string; created_at: string; updated_at: string;};
const articlesCollection: PostgresCollectionConfig<Article> = { slug: "articles", name: "Articles", table: "articles", properties: { title: { name: "Title", type: "string" }, slug: { name: "Slug", type: "string" }, created_at: { name: "Created at", type: "string" }, updated_at: { 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.created_at = new Date().toISOString(); } values.updated_at = new Date().toISOString();
return values; },
afterSave: async ({ values, entityId }) => { // Send notification console.log(`Article ${entityId} saved: ${values.title}`); },
beforeDelete: async ({ entityId }) => { // Prevent deletion of published articles // Throw to block the deletion },
afterRead: async ({ entity }) => { // Transform data after loading return entity; } }, properties: { /* ... */ }});Referencia de Callbacks
Sección titulada «Referencia de Callbacks»beforeSave
Sección titulada «beforeSave»Se invoca antes de que una entidad sea escrita en la base de datos. Devuelve los valores modificados.
beforeSave: async ({ values, // Entity values entityId, // Entity ID (null for new entities) status, // "new" | "existing" | "copy" previousValues, // Previous values (for updates) context // Full Rebase context}) => { // Return modified values return { ...values, updated_at: new Date() };}Lanza un error para bloquear la acción de guardar:
beforeSave: async ({ values }) => { if (values.price < 0) { throw new Error("Price cannot be negative"); } return values;}afterSave
Sección titulada «afterSave»Se invoca después de una operación de guardar exitosa. Utilícelo para efectos secundarios.
afterSave: async ({ values, // Saved values entityId, // Entity ID previousValues, // Previous values (null for new entities) status, // "new" | "existing" | "copy" context}) => { // Send webhook await fetch("https://api.slack.com/webhook", { method: "POST", body: JSON.stringify({ text: `New article: ${values.title}` }) });}afterSaveError
Sección titulada «afterSaveError»Se invoca cuando una operación de guardar falla.
afterSaveError: async ({ values, entityId, error, context}) => { console.error("Save failed:", error);}afterRead
Sección titulada «afterRead»Se invoca después de leer entidades de la base de datos. Transforma los datos para su visualización.
afterRead: async ({ entity, // The entity to transform context}) => { // Add computed fields return { ...entity, values: { ...entity.values, displayName: `${entity.values.first_name} ${entity.values.last_name}` } };}beforeDelete
Sección titulada «beforeDelete»Se invoca antes de que una entidad sea eliminada. Lanza un error para bloquear la eliminación.
beforeDelete: async ({ entityId, entity, context}) => { if (entity.values.status === "published") { throw new Error("Cannot delete published articles. Unpublish first."); }}afterDelete
Sección titulada «afterDelete»Se invoca después de una eliminación exitosa.
afterDelete: async ({ entityId, entity, context}) => { // Cleanup related data console.log(`Article ${entityId} deleted`);}Callbacks de Propiedad
Sección titulada «Callbacks de Propiedad»También puede definir callbacks a nivel de propiedad para transformaciones específicas de campo:
properties: { email: { type: "string", name: "Email", callbacks: { beforeSave: ({ value }) => value?.toLowerCase().trim(), afterRead: ({ value }) => value // Could decrypt, etc. } }}La API context.data
Sección titulada «La API context.data»Cada callback recibe un objeto context que incluye context.data — una capa unificada de acceso a datos para realizar operaciones entre colecciones desde los hooks del ciclo de vida.
Acceso a Colecciones
Sección titulada «Acceso a Colecciones»context.data utiliza un Proxy de JavaScript, por lo que puede acceder a cualquier colección por su slug como una propiedad:
afterSave: async ({ values, entityId, context }) => { // Dynamic property access — works for any collection slug const jobs = context.data.jobs; const users = context.data.users;
// Alternatively, use the .collection() method for dynamic slugs const collectionName = "jobs"; const accessor = context.data.collection(collectionName);}Métodos Disponibles
Sección titulada «Métodos Disponibles»Cada accesor de colección (context.data.<slug>) proporciona estos métodos:
| Método | Firma | Descripción |
|---|---|---|
.find() |
find(params?: FindParams) → FindResponse |
Consulta entidades con filtros, ordenación y paginación |
.findById() |
findById(id: string | number) → Entity | undefined |
Obtiene una sola entidad por ID |
.create() |
create(data: Partial<Values>, id?: string) → Entity |
Crea una nueva entidad |
.update() |
update(id: string | number, data: Partial<Values>) → Entity |
Actualiza una entidad existente |
.delete() |
delete(id: string | number) → void |
Elimina una entidad |
.count() |
count(params?: FindParams) → number |
Cuenta las entidades coincidentes |
.listen() |
listen(params, onUpdate, onError?) → unsubscribe |
Suscripción en tiempo real (donde sea compatible) |
.listenById() |
listenById(id, onUpdate, onError?) → unsubscribe |
Escucha a una sola entidad |
Consultas con .find()
Sección titulada «Consultas con .find()»El método find() soporta filtrado avanzado:
afterSave: async ({ values, context }) => { // Simple equality const { data: activeJobs } = await context.data.jobs.find({ where: { status: "published" }, limit: 10, orderBy: ["created_at", "desc"] });
// PostgREST-style operators const { data: recentJobs } = await context.data.jobs.find({ where: { status: "eq.published", salary: "gte.50000" } });
// Tuple syntax const { data: expensiveJobs } = await context.data.jobs.find({ where: { salary: [">=", 100000], role: ["in", ["admin", "manager"]] } });}Creando Entidades
Sección titulada «Creando Entidades»afterSave: async ({ values, entityId, previousValues, context }) => { // Promote an approved submission to a published job if (values.status === "approved" && previousValues?.status !== "approved") { const newJob = await context.data.jobs.create({ title: values.title, description: values.description, company_id: values.company_id, status: "published", source_submission_id: entityId, });
// Link back to the original submission await context.data["job-submissions"].update(entityId, { promoted_job_id: newJob.id, }); }}Seguridad: con qué privilegios se ejecuta context.data
Sección titulada «Seguridad: con qué privilegios se ejecuta context.data»context.data hereda los privilegios de aquello que activó el callback. No es un nivel de confianza fijo.
- Activado por una petición de usuario (REST, tiempo real, una edición en el panel de administración) → con ámbito de usuario. El callback se ejecuta dentro de la transacción sujeta a RLS abierta para esa petición, por lo que las políticas se aplican tanto a lecturas como a escrituras. Un callback no puede ver una fila que su llamante no pudiera ver.
- Activado por
rebase.dataAsAdmino una tarea cron (el mismo singleton) → con ámbito de administrador, no sin ámbito. Ese driver está limitado a{ uid: "service", roles: ["admin"] }, así que el callback sigue ejecutándose en una transacción sujeta a RLS: tus políticas se evalúan, contra esa identidad. - Activado por el driver base (los flujos de autenticación integrados, las migraciones) → sin ámbito. Se ejecuta sobre la conexión propietaria y omite RLS.
Esto importa sobre todo en la dirección que falla en silencio. RLS filtra, no lanza errores — así que un callback que lee una fila hermana la encontrará cuando guarde una tarea de administración y puede no encontrar nada cuando guarde un usuario final, sin error en ninguno de los dos casos. Escribe callbacks que toleren un resultado vacío, o recurre al plano de administración de forma deliberada:
afterSave: async ({ context }) => { // Con ámbito de usuario cuando un usuario activó este guardado: se aplica RLS. await context.data.audit_logs.create({ action: "approved" });
// Ámbito de administrador deliberado — para trabajo que el llamante // realmente no debe ver, como un registro de auditoría que no puede leer ni // editar. Ojo: es el alcance de un administrador, no una omisión de RLS: una // colección cuya única regla sea `policy.serverContext()` le sigue estando // cerrada, porque eso compila a `auth.uid() IS NULL` y el uid de este // accesor es `service`. await context.client.dataAsAdmin.audit_logs.create({ action: "approved" });}Semántica de Transacciones
Sección titulada «Semántica de Transacciones»Las operaciones de context.data NO se envuelven automáticamente en la misma transacción que el guardado que las activa.
El guardado de la entidad original completa primero su transacción de base de datos. Luego se ejecuta afterSave y cualquier llamada a context.data abre transacciones separadas. Si una operación de context.data falla en afterSave, el guardado original no se revierte.
Esto significa:
- ✅ El guardado que activa la operación siempre se realiza con éxito de forma independiente
- ⚠️ Las escrituras de efectos secundarios pueden fallar sin afectar la operación original
- ⚠️ No hay garantía de atomicidad entre el guardado original y las llamadas
context.datasubsiguientes
Para operaciones que deben ser atómicas, envuélvalas en manejo de errores:
afterSave: async ({ values, entityId, context }) => { try { await context.data.jobs.create({ title: values.title, status: "published", }); } catch (error) { // Log the failure — the original save already succeeded console.error(`Failed to promote job from submission ${entityId}:`, error); // Optionally: mark the submission as "promotion_failed" await context.data["job-submissions"].update(entityId, { promotion_status: "failed", promotion_error: String(error), }); }}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 la sincronización de datos entre colecciones utilizando context.data:
import type { PostgresCollectionConfig } from "@rebasepro/types";
type Submission = { title: string; description: string; company_id: string; status: string; promoted_job_id: string;};
const submissionsCollection: PostgresCollectionConfig<Submission> = { 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["job-submissions"].update(entityId, { promoted_job_id: newJob.id, }); } } }, properties: { /* ... */ }});Otros patrones entre colecciones:
- Eliminación en cascada: Utilice
afterDeletepara eliminar registros relacionados en colecciones secundarias - Desnormalización: Utilice
afterSavepara actualizar campos de resumen en una colección padre - Registro de auditoría: Utilice
afterSave/afterDeletepara escribir en una colección de registro de auditoría - Contadores: Utilice
afterSave/afterDeletepara actualizar campos de recuento en entidades relacionadas
Referencia Completa del Contexto
Sección titulada «Referencia Completa del Contexto»Cada callback recibe un objeto context de tipo RebaseCallContext:
interface RebaseCallContext { /** The authenticated user, if any */ user?: User; /** The underlying data driver (PostgresBackendDriver) */ driver: DataDriver; /** Unified data access — context.data.<slug>.create/update/find/delete */ data: RebaseData;}Próximos Pasos
Sección titulada «Próximos Pasos»- Reglas de Seguridad — Seguridad a Nivel de Fila
- Historial de Entidades — Registro de auditoría
- Funciones Personalizadas — Añadir endpoints de API personalizados
