Ir al contenido

Callbacks de Entidad

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
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: { /* ... */ }
});

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;
}

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}` })
});
}

Se invoca cuando una operación de guardar falla.

afterSaveError: async ({
values,
entityId,
error,
context
}) => {
console.error("Save failed:", error);
}

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}`
}
};
}

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.");
}
}

Se invoca después de una eliminación exitosa.

afterDelete: async ({
entityId,
entity,
context
}) => {
// Cleanup related data
console.log(`Article ${entityId} deleted`);
}

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

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.

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);
}

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

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"]]
}
});
}
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.dataAsAdmin o 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" });
}

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.data subsiguientes

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),
});
}
}

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 afterDelete para eliminar registros relacionados en colecciones secundarias
  • Desnormalización: Utilice afterSave para actualizar campos de resumen en una colección padre
  • Registro de auditoría: Utilice afterSave / afterDelete para escribir en una colección de registro de auditoría
  • Contadores: Utilice afterSave / afterDelete para actualizar campos de recuento en entidades relacionadas

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;
}