Entity Callbacks
Overview
Section titled “Overview”Callbacks let you hook into the entity lifecycle to:
- Sync data between collections — copy or move entities across tables on status changes
- Transform data before saving (computed fields, slugification)
- Validate business rules beyond schema validation
- Trigger side effects after writes (send emails, sync APIs, update caches)
- Filter/transform data after reading
- Cascade operations — clean up related records on delete
Defining Callbacks
Section titled “Defining 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, id }) => { // Send notification console.log(`Article ${id} saved: ${values.title}`); },
beforeDelete: async ({ id }) => { // Prevent deletion of published articles // Throw to block the deletion },
afterRead: async ({ row }) => { // Transform data after loading return row; } }, properties: { /* ... */ }});Callback Reference
Section titled “Callback Reference”beforeSave
Section titled “beforeSave”Called before a entity is written to the database. Return the modified values.
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, updated_at: new Date() };}Throw an error to block the save:
beforeSave: async ({ values }) => { if (values.price < 0) { throw new Error("Price cannot be negative"); } return values;}afterSave
Section titled “afterSave”Called after a successful save. Use for side effects.
afterSave: async ({ values, // Saved values id, // 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
Section titled “afterSaveError”Called when a save operation fails.
afterSaveError: async ({ values, id, error, context}) => { console.error("Save failed:", error);}afterRead
Section titled “afterRead”Called after reading entities from the database. Transform the data for display.
afterRead: async ({ row, // The row to transform context}) => { // Add computed fields return { ...row, displayName: `${row.first_name} ${row.last_name}` };}beforeDelete
Section titled “beforeDelete”Called before a entity is deleted. Throw to block deletion.
beforeDelete: async ({ id, row, context}) => { if (row.status === "published") { throw new Error("Cannot delete published articles. Unpublish first."); }}afterDelete
Section titled “afterDelete”Called after a successful deletion.
afterDelete: async ({ id, row, context}) => { // Cleanup related data console.log(`Article ${id} deleted`);}Property Callbacks
Section titled “Property Callbacks”You can also define callbacks at the property level for field-specific transformations:
properties: { email: { type: "string", name: "Email", callbacks: { beforeSave: ({ value }) => value?.toLowerCase().trim(), afterRead: ({ value }) => value // Could decrypt, etc. } }}The context.data API
Section titled “The context.data API”Every callback receives a context object that includes context.data — a unified data access layer for performing cross-collection operations from within lifecycle hooks.
Accessing Collections
Section titled “Accessing Collections”context.data uses a JavaScript Proxy, so you can access any collection by its slug as a property:
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);}Available Methods
Section titled “Available Methods”Each collection accessor (context.data.<slug>) provides these methods:
| Method | Signature | Description |
|---|---|---|
.find() |
find(params?: FindParams) → FindResponse |
Query entities with filters, sorting, and pagination |
.findById() |
findById(id: string | number) → Entity | undefined |
Fetch a single entity by ID |
.create() |
create(data: Partial<Values>, id?: string) → Entity |
Create a new entity |
.update() |
update(id: string | number, data: Partial<Values>) → Entity |
Update an existing entity |
.delete() |
delete(id: string | number) → void |
Delete a entity |
.count() |
count(params?: FindParams) → number |
Count matching entities |
.listen() |
listen(params, onUpdate, onError?) → unsubscribe |
Real-time subscription (where supported) |
.listenById() |
listenById(id, onUpdate, onError?) → unsubscribe |
Listen to a single entity |
Querying with .find()
Section titled “Querying with .find()”The find() method supports rich filtering:
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"]] } });}Creating Entities
Section titled “Creating Entities”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, }); }}Security: which privileges context.data runs with
Section titled “Security: which privileges context.data runs with”context.data inherits the privileges of whatever triggered the callback. It is not a fixed trust level.
- Triggered by a user request (REST, realtime, an admin-panel edit) → user-scoped. The callback runs inside the RLS-bound transaction opened for that request, so policies apply to reads and writes. A callback cannot see a row its caller could not.
- Triggered by
rebase.dataAsAdminor a cron job (the same singleton) → admin-scoped, not unscoped. That driver is scoped as{ uid: "service", roles: ["admin"] }, so the callback still runs on an RLS-bound transaction — your policies are evaluated, against that identity. - Triggered by the base driver (built-in auth flows, migrations) → unscoped. It runs on the owner connection and bypasses RLS.
This matters most in the direction that fails quietly. RLS filters, it does not raise — so a callback that reads a sibling row will find it when an admin task saves and may find nothing when an end user saves, with no error either way. Write callbacks that tolerate an empty result, or reach for the admin plane deliberately:
afterSave: async ({ context }) => { // User-scoped when a user triggered this save: RLS applies. await context.data.audit_logs.create({ action: "approved" });
// Deliberately admin-scoped — for work the caller genuinely may not see, // such as an audit trail they must not be able to read or edit. Note this // is an admin's reach, not a bypass: a collection whose only rule is // `policy.serverContext()` stays closed to it, since that compiles to // `auth.uid() IS NULL` and this accessor's uid is `service`. await context.client.dataAsAdmin.audit_logs.create({ action: "approved" });}Transaction Semantics
Section titled “Transaction Semantics”context.data operations are NOT automatically wrapped in the same transaction as the triggering save.
The original entity save completes its database transaction first. Then afterSave runs and any context.data calls open separate transactions. If a context.data operation fails in afterSave, the original save is not rolled back.
This means:
- ✅ The triggering save always succeeds independently
- ⚠️ Side-effect writes may fail without affecting the original operation
- ⚠️ There is no atomicity guarantee between the original save and subsequent
context.datacalls
For operations that must be atomic, wrap them in error handling:
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 ${id}:`, error); // Optionally: mark the submission as "promotion_failed" await context.data["job-submissions"].update(id, { promotion_status: "failed", promotion_error: String(error), }); }}Syncing Data Between Collections
Section titled “Syncing Data Between Collections”One of the most powerful uses of callbacks is syncing data across collections using 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.collection<Record<string, unknown>>("job_submissions").update(id, { promoted_job_id: newJob.id, }); } } }, properties: { /* ... */ }});Other cross-collection patterns:
- Cascade delete: Use
afterDeleteto remove related records in child collections - Denormalization: Use
afterSaveto update summary fields in a parent collection - Audit logging: Use
afterSave/afterDeleteto write to an audit log collection - Counters: Use
afterSave/afterDeleteto update count fields on related entities
Full Context Reference
Section titled “Full Context Reference”Every callback receives a context object of type 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;}Next Steps
Section titled “Next Steps”- Security Rules — Row Level Security
- Entity History — Audit trail
- Custom Functions — Add custom API endpoints
