Skip to content

Entity Callbacks

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

Called before an entity is written to the database. Return the modified values.

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

Throw an error to block the save:

beforeSave: async ({ values }) => {
if (values.price < 0) {
throw new Error("Price cannot be negative");
}
return values;
}

Called after a successful save. Use for side effects.

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

Called when a save operation fails.

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

Called after reading entities from the database. Transform the data for display.

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

Called before an entity is deleted. Throw to block deletion.

beforeDelete: async ({
entityId,
entity,
context
}) => {
if (entity.values.status === "published") {
throw new Error("Cannot delete published articles. Unpublish first.");
}
}

Called after a successful deletion.

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

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

Every callback receives a context object that includes context.data — a unified data access layer for performing cross-collection operations from within lifecycle hooks.

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

Each collection accessor (context.data.<slug>) provides these methods:

MethodSignatureDescription
.find()find(params?: FindParams) → FindResponseQuery entities with filters, sorting, and pagination
.findById()findById(id: string | number) → Entity | undefinedFetch a single entity by ID
.create()create(data: Partial<Values>, id?: string) → EntityCreate a new entity
.update()update(id: string | number, data: Partial<Values>) → EntityUpdate an existing entity
.delete()delete(id: string | number) → voidDelete an entity
.count()count(params?: FindParams) → numberCount matching entities
.listen()listen(params, onUpdate, onError?) → unsubscribeReal-time subscription (where supported)
.listenById()listenById(id, onUpdate, onError?) → unsubscribeListen to a single entity

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

context.data operations in callbacks bypass Row Level Security (RLS).

When callbacks execute on the backend, they run through the base PostgresBackendDriver — not the authenticated wrapper. This means context.data has full database access regardless of the triggering user’s permissions.

This is intentional: server-side lifecycle hooks are trusted code that often needs to write to collections the end-user doesn’t have direct access to (e.g., creating an audit log entry, updating a counter on a parent record).

If you need RLS-scoped operations within a callback, use the authenticated driver directly:

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
}

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

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 ${entityId}:`, error);
// Optionally: mark the submission as "promotion_failed"
await context.data["job-submissions"].update(entityId, {
promotion_status: "failed",
promotion_error: String(error),
});
}
}

One of the most powerful uses of callbacks is syncing data across collections using 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: { /* ... */ }
};

Other cross-collection patterns:

  • Cascade delete: Use afterDelete to remove related records in child collections
  • Denormalization: Use afterSave to update summary fields in a parent collection
  • Audit logging: Use afterSave / afterDelete to write to an audit log collection
  • Counters: Use afterSave / afterDelete to update count fields on related entities

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