Zum Inhalt springen

Globale Backend-Hooks

Rebase bietet zwei Ebenen für Entity-Lifecycle-Callbacks – beide verwenden denselben CollectionCallbacks-Typ aus @rebasepro/types:

  • Callbacks pro Collection: Werden in einzelnen Collection-Konfigurationen definiert. Sie werden nur für diese Collection ausgeführt.
  • Globale Callbacks: Werden bei initializeRebaseBackend({ callbacks }) definiert. Sie werden bei jeder Collection und auf jedem Datenpfad ausgelöst (REST-API, WebSocket / Realtime, serverseitiges rebase.dataAsAdmin). Der einzige Writer, den sie nicht sehen, ist das Auth-System: Registrierung, OAuth-Anmeldung und die Admin-Benutzerverwaltung schreiben Benutzerzeilen direkt und lösen stattdessen die Auth-Hooks (afterUserCreate, …) aus.

Verwenden Sie globale Callbacks für:

  • Row-Scoping — beforeQuery auf jeder Collection, sodass Lesezugriffe eines Mandanten an einer zentralen Stelle statt pro Collection eingegrenzt werden. Nur Postgres: Neben einer MongoDB- oder Firestore-Datenquelle verweigert ein globales beforeQuery den Start, anstatt die Lesezugriffe dieser Quelle uneingeschränkt zu lassen. Siehe beforeQuery.
  • PII-Maskierung — Ausblenden sensibler Felder für Nicht-Admin-Aufrufer über alle Collections hinweg.
  • Einheitliches Audit-Logging — Protokollieren jeder Erstellung, Aktualisierung oder Löschung an einem zentralen Ort. Neue Konten aus Registrierung und OAuth gehören nicht dazu; protokollieren Sie diese über afterUserCreate.
  • Querschnittliche Validierung — Durchsetzen von Invarianten, die mehrere Collections umfassen.

Übergeben Sie den Schlüssel callbacks an 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;
}
}
});

Seit 0.25 callbacks nimmt ein Set entgegen. Um mehrere Belange getrennt zu halten, schreiben Sie jeden als eigenes Set und übergeben composeCallbacks(tenantScope, auditTrail): Sie werden genau so kombiniert wie die Ebenen oben. Siehe Callback-Sets kombinieren. Dasselbe gilt für backend.setCollectionCallbacks(slug, callbacks), das die Callbacks ersetzt, die eine Collection deklariert hat: Übergeben Sie composeCallbacks(declared, yours), um sie zu behalten.


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 grenzt einen Lesezugriff ein, bevor er kompiliert wird; siehe beforeQuery.

Alle Callbacks können ein Promise (asynchron) oder einen einfachen Wert (synchron) zurückgeben.


Jeder Callback erhält ein einzelnes Props-Objekt. Gemeinsame Felder:

Feld Typ Vorhanden in
collection CollectionConfig Allen Callbacks
path string Allen 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 Allen Callbacks

context.user enthält den authentifizierten Benutzer (uid, roles usw.) oder ist bei öffentlichen Anfragen undefined.

collection ist immer vorhanden. Ein globaler Callback wird für jede Collection ausgelöst, ist also die einzige Ebene, die unabhängig von einer bestimmten Collection registriert wird – dennoch wird ihm niemals eine fehlende Collection übergeben. Eine Anfrage, die einen Pfad angibt, den die Collection-Registry nicht auflösen kann, wird mit 404 NOT_FOUND abgewiesen, bevor irgendeine Ebene ausgeführt wird. Dies ist dieselbe Antwort, die Lese- und Schreibpfade für einen solchen Pfad ohnehin liefern. Die Alternative – das Überspringen der Ebene für diese Pfade – würde afterRead zu einem Redaktionsschritt mit einer stillschweigenden Ausnahme machen, weshalb dies nicht vorgesehen ist.


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

Jeder Callback in der folgenden Liste wird per await abgewartet, und alle außer afterSaveError laufen innerhalb der Transaktion, die den Schreibvorgang ausführt. Es gibt keine „Fire-and-Forget“-Ebene: Die Zeile und alles, was ihre Callbacks getan haben, werden zusammen committet oder gar nicht.

  • beforeSave, beforeDelete — Wenn der Callback einen Fehler auslöst (throw), wird die Operation mit einem HTTP 400 abgelehnt, der Ihre Nachricht und den Code CALLBACK_REJECTED enthält, und der Schreibvorgang in die Datenbank findet niemals statt. Werfen Sie einen RebaseApiError aus @rebasepro/types, um den Status selbst zu wählen – siehe Entity-Callbacks. Ein beforeDelete, das false zurückgibt, stellt dieselbe Verweigerung ohne Nachricht dar und antwortet mit 403 und diesem Code.
  • afterRead — Die zurückgegebene Zeile (oder transformierte Zeile) ist das, was der Aufrufer erhält, und zwar nur der Aufrufer: Bei einem Schreibvorgang formt sie die Antwort, während afterSave, beforeDelete, afterDelete und die Historie die Zeile so erhalten, wie sie gespeichert ist — ein maskierter Wert wird nie aufgezeichnet oder in die Spalte zurück revertiert. Ihre Transaktion ist READ ONLY – siehe unten.
  • afterSave, afterDelete — Werden vor dem Commit ausgeführt und abgewartet. Ein Fehler (throw) an dieser Stelle rollt die Zeile zurück und antwortet ebenfalls mit 400 CALLBACK_REJECTED, wobei details.stage den Hook benennt. Sie halten die Transaktion während ihrer Ausführung offen; ein langsamer Hook blockiert somit Sperren (Locks).
  • afterSaveError — Wird ausgeführt, wenn das Speichern fehlgeschlagen ist, auf dem Rückweg. Bei einem Request läuft er, nachdem die Transaktion des fehlgeschlagenen Schreibvorgangs zurückgerollt wurde, mit einem context, dessen Aufrufe jeweils eine eigene Transaktion sind, sodass ein Job, den er einreiht, um den Fehler zu melden, erhalten bleibt. Ein Fehler (throw) aus ihm wird protokolliert; der Aufrufer erhält den eigenen Fehler des Speichervorgangs.

Er meldet einen Speichervorgang, der auf Datenbankebene oder danach fehlgeschlagen ist — ab dem INSERT- oder UPDATE-Statement. Ein Speichervorgang, der vor diesem Punkt abgelehnt wurde, hat die Datenbank nie erreicht, daher wird der Hook darüber nicht informiert.

Wird ausgeführt Wird nicht ausgeführt
Die Datenbank weist das Statement zurück: eine Unique- oder Foreign-Key-Verletzung, ein Check-Constraint oder Trigger, eine Row-Level-Security-Policy (WRITE_DENIED) Eine beforeSave-Ablehnung — der Hook, der abgelehnt hat, ist derjenige, der es weiß
afterRead oder afterSave wirft einen Fehler (der Aufrufer erhält CALLBACK_REJECTED) Eine Anfrage, die vor dem Treiber abgelehnt wurde: Validierung (VALIDATION_*), ein Feld, das der Aufrufer nicht schreiben darf, eine fehlende Berechtigung
Der Historie-Eintrag kann nicht aufgezeichnet werden Eine Zeile, die der Aufrufer nicht adressieren kann (404), ein Mandanten-Stempel, den er nicht setzen darf
Der Commit wird abgelehnt, nachdem der Speichervorgang zurückgekehrt ist: TRANSACTION_ABORTED (ein fehlgeschlagenes Statement, das ein Callback abgefangen hat), oder ein aufgeschobener Constraint, der bei COMMIT geprüft wird
Ein Löschvorgang — dafür gibt es kein Gegenstück

Bei einem Bulk- oder _batch-Schreibvorgang wird er für die Zeile ausgeführt, die fehlgeschlagen ist; die Zeilen davor werden mit dem Batch zurückgerollt und nicht gemeldet.

Seiteneffekte, die die Transaktion nicht blockieren dürfen

Abschnitt betitelt „Seiteneffekte, die die Transaktion nicht blockieren dürfen“

Alles, was langsam ist oder nicht rückgängig gemacht werden kann, wenn die Transaktion zurückgerollt wird, gehört nicht in den Callback-Body:

Anwendungsfall Stattdessen dies tun
Drittanbieter aufrufen, E-Mail senden, Datei generieren Einen Job einreihen. Ein Job, der in einer Transaktion eingereiht wurde, die zurückgerollt wird, wurde nie eingereiht – was genau das gewünschte Verhalten ist.
Anderen Prozessen mitteilen, dass etwas passiert ist Auf einem Realtime-Kanal veröffentlichen, nachdem der Schreibvorgang zurückgekehrt ist, nicht aus dem Hook heraus.
Arbeiten in einer Custom Function, auf die der Aufrufer nicht warten muss waitUntil(c, promise) aus @rebasepro/server/functions — dies läuft nach der Antwort, und der Host wartet darauf, bevor er herunterfährt.

Die Faustregel: Wenn die Arbeit auch dann stattfinden soll, wenn der Schreibvorgang rückgängig gemacht wird, ist sie nicht Teil des Schreibvorgangs und gehört daher nicht in den Hook.

Ein Lesezugriff im Request-Scope öffnet seine Transaktion als READ ONLY, ebenso das erneute Abrufen (Refetch) einer Realtime-Subscription. afterRead läuft darin, sodass kein Schreibvorgang aus diesem Callback erfolgreich sein kann — weder ein Erstellen über context.data, noch ein Update, noch ein Aufruf, der in einer aufgerufenen Hilfsfunktion verborgen ist. Postgres weist das Statement mit SQLSTATE 25006 ab, und der Aufrufer erhält als Antwort:

{ "error": { "message": "An `afterRead` callback tried to write. …",
"code": "READ_ONLY_TRANSACTION",
"details": { "dbCode": "25006" } } }

Das ist ein 409, kein 500: Es bedeutet, dass Ihr Code abgelehnt wird, nicht dass der Server ausfällt. Der Read-Only-Modus ist beabsichtigt — ein Lesevorgang, der heimlich schreibt, ist ein Lesevorgang, dessen Kosten, Sperren und RLS-Angriffsfläche von niemandem einkalkuliert wurden.

Daher gehört Lese-Auditing nicht in afterRead. Protokollieren Sie den Lesevorgang stattdessen außerhalb der Anfrage — über einen Hintergrundjob, der mit dem gespeist wird, was Sie bereits ausgeben, oder über eine Custom Function, die das Lesen und das Schreiben über zwei separate Aufrufe durchführt:

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

Schreibseitiges Auditing hat dieses Problem nicht: afterSave und afterDelete laufen in einer Read-Write-Transaktion, und die Audit-Zeile wird zusammen mit der Änderung committet, die sie aufzeichnet.


E-Mail-Adressen für Nicht-Admin-Aufrufer über alle Collections hinweg unkenntlich machen:

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

Jede Löschung über jede Collection hinweg in einer audit_log-Tabelle aufzeichnen. Da afterDelete in der eigenen Transaktion des Löschvorgangs läuft, werden die Audit-Zeile und die Löschung zusammen committet — es gibt kein Zeitfenster, in dem das eine ohne das andere existiert:

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

Beachten Sie, was dies bringt und was es kostet: Wenn die Audit-Zeile nicht geschrieben werden kann, findet auch die Löschung nicht statt. Für ein Audit-Trail ist das meist genau das, was Sie wollen. Wenn dies nicht gewünscht ist, fangen Sie den Fehler im Callback ab und vermerken Sie dies in einem Kommentar.

Globale Callbacks werden für alle Collections ausgelöst. Um Logik auf eine einzelne Collection zu beschränken, prüfen Sie collection.slug oder 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;
}
}

Für Callbacks, die nur für eine einzelne Collection gelten, sollten Sie stattdessen Callbacks pro Collection bevorzugen.