Hook di backend globali
Panoramica
Sezione intitolata “Panoramica”Rebase fornisce due livelli di callback per il ciclo di vita delle entità — entrambi utilizzano lo stesso tipo CollectionCallbacks di @rebasepro/types:
- Callback per collection: definiti nelle configurazioni delle singole collection. Vengono eseguiti solo per quella specifica collection.
- Callback globali: definiti su
initializeRebaseBackend({ callbacks }). Vengono eseguiti su ogni collection, su qualsiasi percorso dati (API REST, WebSocket / realtime,rebase.dataAsAdminlato server). L’unico writer che non vedono è il sistema di autenticazione: la registrazione, l’accesso OAuth e la gestione utenti dell’admin scrivono direttamente le righe utente ed eseguono invece gli hook di autenticazione (afterUserCreate, …).
Usa i callback globali per:
- Row scoping —
beforeQuerysu ogni collection, in modo che le letture di un tenant vengano limitate in un unico punto anziché per ciascuna collection. Solo per Postgres: affiancato a una sorgente dati MongoDB o Firestore, unbeforeQueryglobale rifiuta l’avvio anziché lasciare le letture di quella sorgente non limitate. VedibeforeQuery. - Mascheramento PII — oscura i campi sensibili per i chiamanti non amministratori in tutte le collection.
- Audit logging unificato — registra ogni creazione, aggiornamento o eliminazione in un unico punto. I nuovi account da registrazione e OAuth non ne fanno parte: registra quelli da
afterUserCreate. - Validazione trasversale — applica invarianti che interessano più collection.
Configurazione
Sezione intitolata “Configurazione”Passa la chiave callbacks a 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; } }});Da 0.25 callbacks accetta un solo
insieme. Per tenere separate più responsabilità, scrivi ciascuna come insieme a sé e passa
composeCallbacks(tenantScope, auditTrail): si combinano esattamente come i livelli
descritti sopra. Vedi
Combinare Insiemi di Callback.
Lo stesso vale per backend.setCollectionCallbacks(slug, callbacks), che
sostituisce i callback dichiarati da una collection: passa
composeCallbacks(declared, yours) per mantenerli.
Tipo CollectionCallbacks
Sezione intitolata “Tipo CollectionCallbacks”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 restringe una lettura prima che venga compilata; vedi
beforeQuery.
Tutti i callback possono restituire una Promise (asincroni) o un valore diretto (sincroni).
Proprietà dei callback
Sezione intitolata “Proprietà dei callback”Ogni callback riceve un singolo oggetto props. Campi comuni:
| Campo | Tipo | Presente in |
|---|---|---|
collection |
CollectionConfig |
Tutti i callback |
path |
string |
Tutti i callback |
row |
Record<string, unknown> |
afterRead, beforeDelete, afterDelete |
id |
string |
beforeSave (opzionale), afterSave, afterSaveError, beforeDelete, afterDelete |
values |
EntityValues |
beforeSave, afterSave, afterSaveError |
previousValues |
EntityValues (opzionale) |
beforeSave, afterSave, afterSaveError |
status |
"new" | "existing" |
beforeSave, afterSave, afterSaveError |
context |
RebaseCallContext |
Tutti i callback |
context.user contiene l’utente autenticato (uid, roles, ecc.), oppure è undefined per le richieste pubbliche.
collection è sempre presente. Un callback globale scatta per ogni collection, quindi
è l’unico livello registrato indipendentemente da ciascuna di esse — ma non
riceve mai una collection mancante. Una richiesta che specifica un percorso che il
registro delle collection non può risolvere viene rifiutata con 404 NOT_FOUND prima dell’esecuzione di qualsiasi livello,
che è la stessa risposta che i percorsi di lettura e scrittura darebbero comunque a tale percorso.
L’alternativa — saltare il livello per questi percorsi — renderebbe afterRead un
passaggio di redazione con un’eccezione silenziosa, pertanto non è prevista.
Pipeline di esecuzione
Sezione intitolata “Pipeline di esecuzione”[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]Semantica bloccante vs. asincrona
Sezione intitolata “Semantica bloccante vs. asincrona”Ogni callback nell’elenco seguente viene atteso con await, e tutti tranne
afterSaveError vengono eseguiti all’interno della transazione che esegue la scrittura. Non esiste un livello “fire and forget”: la
riga e tutto ciò che i suoi callback hanno fatto eseguono il commit insieme oppure non lo eseguono affatto.
beforeSave,beforeDelete— se il callback lancia un errore, l’operazione viene rifiutata con un HTTP 400 contenente il tuo messaggio e il codiceCALLBACK_REJECTED, e la scrittura sul database non viene mai eseguita. Lancia unRebaseApiErrorda@rebasepro/typesper scegliere autonomamente lo stato — vedi Callback di entità. UnbeforeDeleteche restituiscefalseequivale allo stesso rifiuto senza messaggio, e risponde con 403 e tale codice.afterRead— la riga restituita (o la riga trasformata) è ciò che riceve il chiamante, e solo il chiamante: su una scrittura modella la risposta, mentreafterSave,beforeDelete,afterDeletee la cronologia ricevono la riga come memorizzata — un valore mascherato non viene mai registrato né riportato nella colonna. La sua transazione èREAD ONLY— vedi sotto.afterSave,afterDelete— vengono eseguiti prima del commit, attesi con await. Un errore lanciato qui annulla la riga con un rollback e risponde con lo stesso 400CALLBACK_REJECTED, condetails.stageche indica l’hook. Mantengono aperta la transazione durante la loro esecuzione, quindi un callback lento equivale a un lock trattenuto.afterSaveError— viene eseguito quando il salvataggio è fallito, durante la fase di uscita. In una richiesta viene eseguito dopo il rollback della transazione della scrittura fallita, con uncontextin cui ogni chiamata è una transazione a sé, quindi un job che accoda per segnalare l’errore viene conservato. Un errore lanciato da esso viene registrato nei log; il chiamante riceve l’errore del salvataggio stesso.
Quando viene eseguito afterSaveError
Sezione intitolata “Quando viene eseguito afterSaveError”Segnala un salvataggio fallito a livello di database o successivamente — dall’istruzione INSERT o UPDATE in poi. Un salvataggio rifiutato prima di quel punto non ha mai raggiunto il database, quindi l’hook non ne viene informato.
| Viene eseguito | Non viene eseguito |
|---|---|
Il database rifiuta l’istruzione: una violazione di unicità o foreign key, un check constraint o un trigger, una policy di row-level security (WRITE_DENIED) |
Un rifiuto di beforeSave — l’hook che ha rifiutato è quello che lo sa |
afterRead o afterSave lancia un errore (il chiamante riceve CALLBACK_REJECTED) |
Una richiesta rifiutata prima del driver: validazione (VALIDATION_*), un campo che il chiamante non può scrivere, un permesso mancante |
| La voce di cronologia non può essere registrata | Una riga che il chiamante non può indirizzare (404), uno stamp di tenant che non può impostare |
Il commit viene rifiutato dopo che il salvataggio è stato restituito: TRANSACTION_ABORTED (un’istruzione fallita che un callback ha intercettato), oppure un constraint differito verificato al COMMIT |
|
| Un’eliminazione — non esiste una controparte per il delete |
In una scrittura bulk o _batch viene eseguito per la riga che ha fallito; le
righe precedenti vengono annullate con il batch e non vengono segnalate.
Effetti collaterali che non devono trattenere la transazione
Sezione intitolata “Effetti collaterali che non devono trattenere la transazione”Qualsiasi operazione lenta, o qualsiasi operazione che non possa essere annullata in caso di rollback della transazione, non deve risiedere nel corpo del callback:
| Obiettivo | Fai invece questo |
|---|---|
| Chiamare una terza parte, inviare email, generare un file | Accoda un job. Un job accodato in una transazione su cui viene eseguito il rollback non è mai stato accodato — che è esattamente il comportamento desiderato. |
| Comunicare ad altri processi che è successo qualcosa | Pubblica su un canale realtime dopo che la scrittura ha restituito un valore, non dall’interno dell’hook. |
| Operare in una funzione personalizzata senza che il chiamante debba attendere | waitUntil(c, promise) da @rebasepro/server/functions — viene eseguito dopo la risposta, e l’host ne attende il completamento prima di arrestarsi. |
La regola empirica: se il lavoro deve comunque essere eseguito anche quando la scrittura viene annullata, non fa parte della scrittura, e quindi non va inserito nell’hook.
afterRead non può scrivere
Sezione intitolata “afterRead non può scrivere”Una lettura nell’ambito di una richiesta apre la propria transazione in modalità READ ONLY, e lo stesso fa il
refetch di una sottoscrizione realtime. afterRead viene eseguito al suo
interno, pertanto nessuna scrittura proveniente da tale callback può andare a buon fine — né una create
con context.data, né un aggiornamento, né una scrittura inclusa in una funzione helper richiamata. Postgres rifiuta
l’istruzione con SQLSTATE 25006, e al chiamante viene risposto:
{ "error": { "message": "An `afterRead` callback tried to write. …", "code": "READ_ONLY_TRANSACTION", "details": { "dbCode": "25006" } } }Si tratta di un 409, non di un 500: è il tuo codice a venire rifiutato, non il server a fallire. La modalità di sola lettura è intenzionale — una lettura che scrive silenziosamente è una lettura i cui costi, lock e superficie RLS non sono stati preventivati da nessuno.
Di conseguenza, l’auditing delle letture non appartiene ad afterRead. Registra la lettura all’esterno della
richiesta — da un job in background alimentato da ciò che già emetti, oppure
da una funzione personalizzata che esegue la lettura e la scrittura con due
chiamate distinte:
// ✗ 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); });});L’auditing lato scrittura non presenta questo problema: afterSave e afterDelete vengono eseguiti in una
transazione di lettura-scrittura, e la riga di audit esegue il commit insieme alla modifica che registra.
Mascheramento PII
Sezione intitolata “Mascheramento PII”Oscura gli indirizzi email per i chiamanti non amministratori in tutte le 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; } }});Audit logging globale
Sezione intitolata “Audit logging globale”Registra ogni eliminazione, in ogni collection, in una tabella audit_log. Poiché
afterDelete viene eseguito nella transazione stessa dell’eliminazione, la riga di audit e
l’eliminazione eseguono il commit insieme — non esiste una finestra temporale in cui una esista senza
l’altra:
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 }); } }});Nota i pro e i contro di questo approccio: se la riga di audit non può essere scritta, non avviene nemmeno l’eliminazione. Per un audit trail questo è solitamente ciò che si desidera. In caso contrario, intercetta l’errore nel callback e indicalo in un commento.
Logica specifica per collection
Sezione intitolata “Logica specifica per collection”I callback globali si attivano per tutte le collection. Per limitare la logica a una singola collection, controlla collection.slug o 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; }}Per i callback che devono essere applicati a una sola collection, preferisci invece i callback per collection.