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
const articlesCollection: EntityCollection = {
slug: "articles",
callbacks: {
beforeSave: async ({ values, entityId, 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();
}
values.updated_at = new Date();
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étodoFirmaDescripción
.find()find(params?: FindParams) → FindResponseConsulta entidades con filtros, ordenación y paginación
.findById()findById(id: string | number) → Entity | undefinedObtiene una sola entidad por ID
.create()create(data: Partial<Values>, id?: string) → EntityCrea una nueva entidad
.update()update(id: string | number, data: Partial<Values>) → EntityActualiza una entidad existente
.delete()delete(id: string | number) → voidElimina una entidad
.count()count(params?: FindParams) → numberCuenta las entidades coincidentes
.listen()listen(params, onUpdate, onError?) → unsubscribeSuscripción en tiempo real (donde sea compatible)
.listenById()listenById(id, onUpdate, onError?) → unsubscribeEscucha 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: Comportamiento de Omisión de RLS

Sección titulada «Seguridad: Comportamiento de Omisión de RLS»

Las operaciones de context.data en los callbacks omiten la Seguridad a Nivel de Fila (RLS).

Cuando los callbacks se ejecutan en el backend, lo hacen a través del PostgresBackendDriver base — no el envoltorio autenticado. Esto significa que context.data tiene acceso completo a la base de datos independientemente de los permisos del usuario que lo activa.

Esto es intencional: los hooks del ciclo de vida del lado del servidor son código de confianza que a menudo necesita escribir en colecciones a las que el usuario final no tiene acceso directo (p. ej., crear una entrada de registro de auditoría, actualizar un contador en un registro padre).

Si necesita operaciones con ámbito RLS dentro de un callback, utilice el driver autenticado directamente:

afterSave: async ({ context }) => {
// This bypasses RLS (normal for callbacks):
await context.data.audit_logs.create({ action: "approved" });
// To enforce RLS, access the driver and call withAuth():
const authDriver = await context.driver.withAuth(context.user);
// authDriver.data operations respect RLS
}

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:

const submissionsCollection: EntityCollection = {
slug: "job_submissions",
callbacks: {
afterSave: async ({ values, entityId, previousValues, context }) => {
// When a submission is approved, create 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,
});
// 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;
}