Skip to content

Global Backend Hooks

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-side rebase.dataAsAdmin).

Use global callbacks for:

  • PII masking — redact sensitive fields for non-admin callers across all collections.
  • Unified audit logging — log every create, update, or delete in one place.
  • Cross-cutting validation — enforce invariants that span multiple collections.

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

type CollectionCallbacks = {
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
};

All callbacks may return a Promise (async) or a plain value (sync).


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.


[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]

Every callback in the list below is awaited, and all of them 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 code CALLBACK_REJECTED, and the database write never happens. Throw a RebaseApiError from @rebasepro/types to pick the status yourself — see Entity Callbacks. A beforeDelete that returns false is the same refusal with no message, and answers 403 with that code.
  • afterRead — the returned row (or transformed row) is what the caller receives. Its transaction is READ ONLY — see below.
  • afterSave, afterDelete — run before the commit, awaited. A throw here rolls the row back and answers the same 400 CALLBACK_REJECTED, with details.stage naming 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.

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.

A request-scoped read opens its transaction READ ONLY. 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;
}
}
// ✓ The read and the audit row are two operations, and only the second writes.
import { rebase } from "@rebasepro/server";
export default defineFunction("read-article", (app) => {
app.get("/:id", async (c) => {
const article = await c.var.driver.fetchOne({ path: "articles", id: c.req.param("id") });
await rebase.dataAsAdmin.read_log.create({ path: "articles", uid: c.var.user?.uid });
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.


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

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.

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.