Global Backend Hooks
Overview
Section titled “Overview”Rebase provides two levels of entity lifecycle callbacks — both use the same CollectionCallbacks type from @rebasepro/types:
- Per-collection callbacks: Defined on individual collection configurations. They run only for that collection.
- Global callbacks: Defined on
initializeRebaseBackend({ callbacks }). They fire on every collection, on every data path (REST API, WebSocket / realtime, server-siderebase.dataAsAdmin). The one writer they do not see is the auth system: sign-up, OAuth sign-in and the admin’s user management write user rows directly and run the auth hooks (afterUserCreate, …) instead.
Use global callbacks for:
- Row scoping —
beforeQueryon every collection, so a tenant’s reads are narrowed in one place instead of per collection. Postgres only: beside a MongoDB or Firestore data source, a globalbeforeQueryrefuses to boot rather than leave that source’s reads unnarrowed. SeebeforeQuery. - PII masking — redact sensitive fields for non-admin callers across all collections.
- Unified audit logging — log every create, update, or delete in one place. New accounts from sign-up and OAuth are not among them; log those from
afterUserCreate. - Cross-cutting validation — enforce invariants that span multiple collections.
Configuration
Section titled “Configuration”Pass the callbacks key to initializeRebaseBackend:
import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterRead({ row, context }) { // Runs after every entity read, across all collections return row; }, beforeSave({ values, context }) { // Runs before every entity save return values; } }});Since 0.25 callbacks takes
one set. To keep several concerns apart, write each as its own set and pass
composeCallbacks(tenantScope, auditTrail): they combine exactly as the tiers
above do. See
Combining callback sets.
The same goes for backend.setCollectionCallbacks(slug, callbacks), which
replaces the callbacks a collection declared: pass
composeCallbacks(declared, yours) to keep them.
CollectionCallbacks Type
Section titled “CollectionCallbacks Type”type CollectionCallbacks = { beforeQuery?(props): QueryNarrowing | void; // Conditions to AND into a read before it is compiled afterRead?(props): Record<string, unknown>; // Transform row before returning to caller beforeSave?(props): Partial<Values>; // Modify values before writing to DB afterSave?(props): void; // After the write, still in the transaction afterSaveError?(props): void; // Side-effects after a failed save beforeDelete?(props): boolean | void; // Return false (403) or throw to block deletion afterDelete?(props): void; // After the delete, still in the transaction};beforeQuery narrows a read before it is compiled; see
beforeQuery.
All callbacks may return a Promise (async) or a plain value (sync).
Callback Props
Section titled “Callback Props”Each callback receives a single props object. Common fields:
| Field | Type | Present in |
|---|---|---|
collection |
CollectionConfig |
All callbacks |
path |
string |
All callbacks |
row |
Record<string, unknown> |
afterRead, beforeDelete, afterDelete |
id |
string |
beforeSave (optional), afterSave, afterSaveError, beforeDelete, afterDelete |
values |
EntityValues |
beforeSave, afterSave, afterSaveError |
previousValues |
EntityValues (optional) |
beforeSave, afterSave, afterSaveError |
status |
"new" | "existing" |
beforeSave, afterSave, afterSaveError |
context |
RebaseCallContext |
All callbacks |
context.user contains the authenticated user (uid, roles, etc.), or is undefined for public requests.
collection is always there. A global callback fires for every collection, so
it is the one tier that is registered independently of any of them — but it is
still never handed a missing collection. A request naming a path the collection
registry cannot resolve is refused with 404 NOT_FOUND before any tier runs,
which is the same answer the read and write paths give such a path anyway. The
alternative — skipping the tier for those paths — would make afterRead a
redaction step with a silent exception, so it is not on offer.
Execution Pipeline
Section titled “Execution Pipeline”[Client Request] │ ▼ [Hono Router] │ [Database Driver] ┌─────┴───────────────────────────────────────────────────────┐ │ 1. Start PostgreSQL Transaction │ │ 2. Set Config: app.user_id = '<uid>', app.user_roles = ... │ │ │ │ 3. Global Callback: beforeSave ─┐ │ │ 4. Collection Callback: beforeSave ─┘ awaited │ │ 5. Drizzle SQL execution & Postgres RLS evaluation │ │ 6. Global Callback: afterSave ─┐ │ │ 7. Collection Callback: afterSave ─┘ awaited │ │ │ │ 8. Commit ← a throw anywhere in 3–7 rolls the write back │ └─────┬───────────────────────────────────────────────────────┘ │ [Realtime notifications flushed — after the commit, never before] │ ▼[Client Response]Blocking vs. Async Semantics
Section titled “Blocking vs. Async Semantics”Every callback in the list below is awaited, and all of them but
afterSaveError run inside the transaction that carries the write. There is
no “fire and forget” tier: the row and everything its callbacks did commit
together or not at all.
beforeSave,beforeDelete— if the callback throws, the operation is rejected with an HTTP 400 carrying your message and the codeCALLBACK_REJECTED, and the database write never happens. Throw aRebaseApiErrorfrom@rebasepro/typesto pick the status yourself — see Entity Callbacks. AbeforeDeletethat returnsfalseis the same refusal with no message, and answers 403 with that code.afterRead— the returned row (or transformed row) is what the caller receives, and only the caller: on a write it shapes the response, whileafterSave,beforeDelete,afterDeleteand history get the row as stored — a masked value is never recorded or reverted into the column. Its transaction isREAD ONLY— see below.afterSave,afterDelete— run before the commit, awaited. A throw here rolls the row back and answers the same 400CALLBACK_REJECTED, withdetails.stagenaming the hook. They hold the transaction open while they run, so a slow one is a lock held.afterSaveError— runs when the save failed, on the way out. On a request it runs after the failed write’s transaction has rolled back, with acontextwhose every call is a transaction of its own, so a job it enqueues to report the failure is kept. A throw from it is logged; the caller gets the save’s own error.
When afterSaveError runs
Section titled “When afterSaveError runs”It reports a save that failed at the database or after it — from the INSERT or UPDATE statement on. A save refused before that point never reached the database, so the hook is not told about it.
| Runs | Does not run |
|---|---|
The database refuses the statement: a unique or foreign key violation, a check constraint or trigger, a row-level security policy (WRITE_DENIED) |
A beforeSave refusal — the hook that refused is the one that knows |
afterRead or afterSave throws (the caller gets CALLBACK_REJECTED) |
A request refused before the driver: validation (VALIDATION_*), a field the caller may not write, a missing permission |
| The history entry cannot be recorded | A row the caller cannot address (404), a tenant stamp it may not set |
The commit is refused after the save returned: TRANSACTION_ABORTED (a failed statement a callback caught), or a deferred constraint checked at COMMIT |
|
| A delete — there is no delete counterpart |
In a bulk or _batch write it runs for the row that failed; the rows before it
are rolled back with the batch and are not reported.
Side effects that must not hold the transaction
Section titled “Side effects that must not hold the transaction”Anything slow, or anything that cannot be undone if the transaction rolls back, does not belong in the callback body:
| Want | Do this instead |
|---|---|
| Call a third party, send mail, generate a file | Enqueue a job. A job enqueued in a transaction that rolls back was never enqueued — which is the behaviour you want. |
| Tell other processes something happened | Publish on a realtime channel after the write returns, not from inside the hook. |
| Work in a custom function that the caller need not wait for | waitUntil(c, promise) from @rebasepro/server/functions — it runs after the response, and the host waits for it before shutting down. |
The rule of thumb: if the work should still happen when the write is undone, it is not part of the write, so it does not go in the hook.
afterRead cannot write
Section titled “afterRead cannot write”A request-scoped read opens its transaction READ ONLY, and so does a realtime
subscription’s refetch. afterRead runs inside
it, so no write from that callback can succeed — not a context.data
create, not an update, not one buried in a helper it calls. Postgres refuses the
statement with SQLSTATE 25006, and the caller is answered:
{ "error": { "message": "An `afterRead` callback tried to write. …", "code": "READ_ONLY_TRANSACTION", "details": { "dbCode": "25006" } } }That is a 409, not a 500: it is your code being refused, not the server failing. The read-only mode is deliberate — a read that quietly writes is a read whose cost, locks and RLS surface nobody budgeted for.
So read auditing does not belong in afterRead. Log the read outside the
request instead — from a background job fed by whatever you already emit, or
from a custom function that does the read and the write with two separate
calls:
// ✗ Fails with READ_ONLY_TRANSACTION on every read.callbacks: { afterRead: async ({ path, row, context }) => { await context.data.read_log.create({ path, uid: context.user?.uid }); return row; }}// backend/functions/read-article.ts — served at /api/functions/read-article/:id// ✓ The read and the audit row are two operations, and only the second writes.import { defineFunction, getUserId, requireAuth, requireDriver } from "@rebasepro/server/functions";
export default defineFunction((app, { rebase }) => { app.get("/:id", requireAuth, async (c) => { const article = await requireDriver(c).fetchOne({ path: "articles", id: c.req.param("id") }); await rebase.dataAsAdmin.read_log.create({ path: "articles", uid: getUserId(c) }); return c.json(article); });});Write-side auditing has no such problem: afterSave and afterDelete run in a
read-write transaction, and the audit row commits with the change it records.
Examples
Section titled “Examples”PII Masking
Section titled “PII Masking”Redact email addresses for non-admin callers across every collection:
import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({ // ... other config callbacks: { afterRead({ row, context }) { const isAdmin = context.user?.roles?.includes("admin"); if (!isAdmin && row.email) { return { ...row, email: "********" }; } return row; } }});Global Audit Logging
Section titled “Global Audit Logging”Record every deletion, across every collection, in an audit_log table. Because
afterDelete runs in the delete’s own transaction, the audit row and the
deletion commit together — there is no window in which one exists without the
other:
import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({ // ... other config callbacks: { async afterDelete({ collection, id, row, context }) { if (collection.slug === "audit_log") return; // don't audit the audit await context.data.audit_log.create({ action: "delete", collection: collection.slug, entity_id: String(id), actor: context.user?.uid ?? "anonymous", snapshot: row }); } }});Note what this buys and what it costs: if the audit row cannot be written, the delete does not happen either. For an audit trail that is usually what you want. If it is not, catch the error in the callback and say so in a comment.
Collection-Specific Logic
Section titled “Collection-Specific Logic”Global callbacks fire for all collections. To scope logic to a single collection, check collection.slug or path:
callbacks: { beforeSave({ collection, values, context }) { if (collection.slug === "orders") { if (!values.total || values.total <= 0) { throw new Error("Order total must be positive"); } } return values; }}For callbacks that only apply to a single collection, prefer per-collection callbacks instead.