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, id, 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);}A property is the collection’s accessor, the name the generated SDK gives it:
context.data.myNotes is the my-notes collection, whatever table it is
stored in. A name that is no collection’s accessor is read as its snake_case,
so context.data.auditLog is audit_log.
Rows are untyped (Record<string, unknown>) until the project registers its
generated Database; then every accessor is typed from it. See
Typed server-side data.
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 record |
.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 |
.trash |
trash.find(), .count(), .restore(id), .restoreMany(ids), .purge(id), .purgeMany(ids) |
The rows a soft delete stamped |
Querying with .find()
Section titled “Querying with .find()”The find() method filters with [operator, value] tuples — the typed form of
the ?status=eq.published query string the REST API reads:
afterSave: async ({ values, context }) => { // Equality const { data: activeJobs } = await context.data.jobs.find({ where: { status: ["==", "published"] }, limit: 10, orderBy: ["createdAt", "desc"] });
// Several conditions, AND-ed const { data: expensiveJobs } = await context.data.jobs.find({ where: { salary: [">=", 100000], role: ["in", ["admin", "manager"]] } });}Creating Entities
Section titled “Creating Entities”.create() and .update() take the values to write, with the signatures above.
Syncing Data Between Collections uses both:
an approved submission creates a published job and is linked back to it.
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 // `rebase.uid() IS NULL` and this accessor's uid is `service`. // `dataAsAdmin` is always there server-side; its type allows for the // browser SDK, which has none — hence the `!`. await context.client.dataAsAdmin!.audit_logs.create({ action: "approved" });}Transaction Semantics
Section titled “Transaction Semantics”A callback’s context.data writes are part of the write that triggered it. On Postgres, beforeSave, the save and afterSave — or beforeDelete, the delete and afterDelete — run inside one transaction, each callback awaited before the commit, and context.data writes through that same transaction.
So the triggering write and everything its callbacks wrote commit together or not at all:
- A throw from
afterSaveorafterDeleterolls the triggering write back, along with everycontext.datawrite the callbacks made. The caller is answered 400CALLBACK_REJECTEDwithdetails.stagenaming the hook — or with the error’s own status when it carries one: aRebaseApiErroryou threw, a unique violation’s 409. - Realtime subscribers hear about the row only after the commit, so a write that rolled back is never announced.
- A callback holds the transaction open while it runs, so a slow one is a lock held and a pooled connection tied up —
beforeSaveandbeforeDeleteas much as theafterhooks. See A callback holds a connection. - A
context.datawrite runs the target collection’s callbacks too, so anafterSavethat updates its own row runs itself again. Writes nested more than 16 deep are refused with 500CALLBACK_RECURSION, naming the hook and the collection, and the whole write rolls back. Make such a write conditional, as the example below does.
Let a failure throw when the triggering write should not survive it. Catch it when it should, but only around a context.data write: a create, update or delete the database refuses (a unique or foreign key violation, a trigger) is undone on its own, and the rest commits.
Any other statement that fails on the write’s transaction — a lookup, the read an update or delete makes to find its row (an id the key column cannot hold), a job enqueue the database refused — aborts that transaction in Postgres, and catching the error in JavaScript does not undo that. The write is refused with 500 TRANSACTION_ABORTED and nothing is stored, rather than answering success for a write that was rolled back. Let such a failure throw, or check for the condition before running the statement.
afterSave: async ({ values, id, status, context }) => { // The update below saves this collection again, which runs this callback // again: act on creates only, or it never stops. if (status !== "new") return; try { await context.data.jobs.create({ title: values.title, status: "published" }); } catch (error) { // Only the failed create is undone. The submission and this marker commit. await context.data.job_submissions.update(id, { promotion_status: "failed", promotion_error: String(error) }); }}Work that has to leave the database — an email, a webhook, a call to a third-party API — does not belong in the callback body. It would hold the transaction open for a network round trip, and nothing can take it back when the write rolls back. Enqueue a job for it, or do it after the write returns: publish on a realtime channel, or use waitUntil in a custom function. Hooks says which fits.
On MongoDB none of this holds. That driver runs the same callbacks without a transaction, so the write is already stored when afterSave runs, and a throw there reports the failure without undoing it.
A callback holds a connection
Section titled “A callback holds a connection”On Postgres a write runs on one connection checked out of the database pool,
and every callback runs on it: beforeSave and beforeDelete after the
transaction has begun, afterSave and afterDelete before it commits. For as
long as a callback runs — awaiting a lookup, a computation, anything — that
connection serves nothing else, and the locks the write has taken stay taken:
in afterSave, the row just written; in a bulk or _batch
write, every row the transaction wrote before it.
The cost is the callback’s own duration, and concurrent writes add it up:
- The pool holds 20 connections by default (
DB_POOL_MAX). 25 saves at once whosebeforeSaveawaited 300 ms made an unrelated one-row read take 290 ms, nearly all of it spent waiting for a connection. - A bulk create of 100 rows whose
beforeSaveawaits 50 ms is one transaction open for 5.3 s, holding its connection and the locks of every row written so far for all of it.
So:
- Keep anything slow out of callbacks. A call to another service belongs in a job, or after the write returns.
- Look up what a hook needs before the write when you can — in the client, or in a custom function that then writes.
- Send rows whose hooks are slow in smaller bulks.
- If many slow hooks run at once by design, raise
DB_POOL_MAX, within what the database’smax_connectionsallows for every instance. - Since 0.25 With
REBASE_METRICS=true,/metricsreportsrebase_db_pool_wait_ms: how long each query waited for a connection. A healthy pool keeps it near zero; a tail that grows with traffic is requests queueing for connections that callbacks hold. See Metrics.