Aller au contenu

Hooks Backend Globaux

Rebase propose deux niveaux de callbacks de cycle de vie des entités — tous deux utilisent le même type CollectionCallbacks issu de @rebasepro/types :

  • Callbacks par collection : Définis sur les configurations de collections individuelles. Ils s’exécutent uniquement pour cette collection.
  • Callbacks globaux : Définis sur initializeRebaseBackend({ callbacks }). Ils se déclenchent sur chaque collection, sur chaque chemin de données (API REST, WebSocket / temps réel, rebase.dataAsAdmin côté serveur). Le seul point d’écriture qu’ils ne voient pas est le système d’authentification : l’inscription, la connexion OAuth et la gestion des utilisateurs par un administrateur écrivent directement les lignes utilisateur et déclenchent à la place les hooks d’authentification (afterUserCreate, …).

Utilisez les callbacks globaux pour :

  • Le ciblage des lignes (Row scoping) — beforeQuery sur chaque collection, afin que les lectures d’un tenant soient restreintes à un seul endroit plutôt que par collection. Postgres uniquement : à côté d’une source de données MongoDB ou Firestore, un beforeQuery global refusera de démarrer plutôt que de laisser les lectures de cette source sans restriction. Voir beforeQuery.
  • Le masquage des données personnelles (PII) — masquer les champs sensibles pour les appelants non-administrateurs sur l’ensemble des collections.
  • La journalisation d’audit unifiée — consigner chaque création, mise à jour ou suppression à un emplacement unique. Les nouveaux comptes créés par inscription ou OAuth n’en font pas partie ; journalisez-les depuis afterUserCreate.
  • La validation transversale — appliquer des invariants qui s’étendent sur plusieurs collections.

Passez la clé callbacks à 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;
}
}
});

Depuis 0.25 callbacks prend un seul ensemble. Pour garder plusieurs préoccupations séparées, écrivez chacune comme son propre ensemble et passez composeCallbacks(tenantScope, auditTrail) : ils se combinent exactement comme les niveaux ci-dessus. Voir Combiner des ensembles de callbacks. Il en va de même pour backend.setCollectionCallbacks(slug, callbacks), qui remplace les callbacks qu’une collection a déclarés : passez composeCallbacks(declared, yours) pour les conserver.


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 restreint une lecture avant qu’elle ne soit compilée ; voir beforeQuery.

Tous les callbacks peuvent renvoyer une Promise (asynchrone) ou une valeur simple (synchrone).


Chaque callback reçoit un objet props unique. Champs communs :

Champ Type Présent dans
collection CollectionConfig Tous les callbacks
path string Tous les callbacks
row Record<string, unknown> afterRead, beforeDelete, afterDelete
id string beforeSave (facultatif), afterSave, afterSaveError, beforeDelete, afterDelete
values EntityValues beforeSave, afterSave, afterSaveError
previousValues EntityValues (facultatif) beforeSave, afterSave, afterSaveError
status "new" | "existing" beforeSave, afterSave, afterSaveError
context RebaseCallContext Tous les callbacks

context.user contient l’utilisateur authentifié (uid, roles, etc.), ou vaut undefined pour les requêtes publiques.

collection est toujours présent. Un callback global se déclenche pour chaque collection, c’est donc le seul niveau enregistré indépendamment de chacune d’elles — mais il n’est jamais confronté à une collection manquante. Une requête mentionnant un chemin que le registre de collections ne peut pas résoudre est rejetée avec un code 404 NOT_FOUND avant même qu’un niveau ne s’exécute, ce qui correspond à la même réponse que les chemins de lecture et d’écriture renvoient de toute façon. L’alternative — ignorer le niveau pour ces chemins — ferait d’afterRead une étape de masquage avec une exception silencieuse, ce qui n’est donc pas proposé.


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

Chaque callback de la liste ci-dessous est attendu (await), et tous sauf afterSaveError s’exécutent au sein de la transaction qui porte l’écriture. Il n’existe pas de niveau « fire and forget » : la ligne et tout ce que ses callbacks ont effectué sont validés ensemble ou pas du tout.

  • beforeSave, beforeDelete — si le callback lève une exception, l’opération est rejetée avec une erreur HTTP 400 contenant votre message et le code CALLBACK_REJECTED, et l’écriture en base de données n’a jamais lieu. Lancez une RebaseApiError issue de @rebasepro/types pour choisir vous-même le statut — voir Callbacks d’entité. Un beforeDelete qui renvoie false équivaut au même refus sans message, et répond 403 avec ce code.
  • afterRead — la ligne renvoyée (ou la ligne transformée) est ce que l’appelant reçoit, et uniquement lui : lors d’une écriture, elle façonne la réponse, tandis qu’afterSave, beforeDelete, afterDelete et l’historique reçoivent la ligne telle que stockée — une valeur masquée n’est jamais enregistrée ni réinjectée dans la colonne. Sa transaction est en lecture seule (READ ONLY) — voir ci-dessous.
  • afterSave, afterDelete — s’exécutent avant le commit, avec attente (await). Une exception levée ici annule la ligne (rollback) et répond avec la même erreur 400 CALLBACK_REJECTED, avec details.stage indiquant le nom du hook. Ils maintiennent la transaction ouverte pendant leur exécution ; un hook lent correspond donc à un verrou conservé.
  • afterSaveError — s’exécute lorsque l’enregistrement a échoué, sur le chemin de sortie. Lors d’une requête, il s’exécute après l’annulation (rollback) de la transaction de l’écriture en échec, avec un context dont chaque appel est une transaction à part entière ; un job qu’il met en file d’attente pour signaler l’échec est donc conservé. Une exception levée par ce callback est journalisée ; l’appelant reçoit l’erreur propre à l’enregistrement.

Il signale un enregistrement qui a échoué au niveau de la base de données ou après celle-ci — à partir de l’instruction INSERT ou UPDATE. Un enregistrement refusé avant ce point n’a jamais atteint la base de données, et le hook n’en est donc pas informé.

S’exécute Ne s’exécute pas
La base de données refuse l’instruction : violation d’unicité ou de clé étrangère, contrainte de vérification ou déclencheur, politique de sécurité au niveau des lignes (WRITE_DENIED) Un refus par beforeSave — le hook qui a refusé est celui qui le sait
afterRead ou afterSave lève une exception (l’appelant reçoit CALLBACK_REJECTED) Une requête refusée avant le pilote : validation (VALIDATION_*), un champ que l’appelant n’a pas le droit d’écrire, une permission manquante
L’entrée d’historique ne peut pas être enregistrée Une ligne que l’appelant ne peut pas adresser (404), un marquage de tenant qu’il n’a pas le droit de définir
Le commit est refusé après le retour de l’enregistrement : TRANSACTION_ABORTED (une instruction en échec qu’un callback a interceptée), ou une contrainte différée vérifiée au COMMIT
Une suppression — il n’existe pas d’équivalent pour la suppression

Dans une écriture en masse ou _batch, il s’exécute pour la ligne qui a échoué ; les lignes qui la précèdent sont annulées (rollback) avec le lot et ne sont pas signalées.

Effets de bord qui ne doivent pas maintenir la transaction ouverte

Section intitulée « Effets de bord qui ne doivent pas maintenir la transaction ouverte »

Tout ce qui est lent, ou tout ce qui ne peut pas être annulé si la transaction subit un rollback, n’a pas sa place dans le corps du callback :

Objectif Ce qu’il faut faire à la place
Appeler un service tiers, envoyer un email, générer un fichier Mettre un job en file d’attente. Un job mis en file d’attente dans une transaction qui subit un rollback n’a en fait jamais été mis en file d’attente — ce qui est le comportement souhaité.
Informer d’autres processus qu’un événement s’est produit Publier sur un canal en temps réel après le retour de l’écriture, et non depuis l’intérieur du hook.
Effectuer un traitement dans une fonction personnalisée que l’appelant n’a pas besoin d’attendre Utiliser waitUntil(c, promise) de @rebasepro/server/functions — elle s’exécute après la réponse, et l’hôte attend sa fin avant de s’arrêter.

La règle générale : si le traitement doit quand même avoir lieu lorsque l’écriture est annulée, il ne fait pas partie de l’écriture, et n’a donc pas sa place dans le hook.

Une lecture avec portée de requête (request-scoped) ouvre sa transaction en READ ONLY, tout comme le rechargement (refetch) d’un abonnement en temps réel. afterRead s’exécute à l’intérieur de celle-ci, de sorte que toute écriture issue de ce callback ne peut aboutir — ni une création via context.data, ni une mise à jour, ni même une écriture enfouie dans un helper qu’il appelle. Postgres refuse l’instruction avec le SQLSTATE 25006, et la réponse suivante est envoyée à l’appelant :

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

Il s’agit d’une erreur 409, pas 500 : c’est votre code qui est refusé, pas le serveur qui a échoué. Le mode lecture seule est délibéré — une lecture qui écrit silencieusement est une lecture dont le coût, les verrous et la surface RLS n’ont été prévus par personne.

Par conséquent, l’audit de lecture n’a pas sa place dans afterRead. Consignez la lecture en dehors de la requête à la place — à partir d’un job en arrière-plan alimenté par ce que vous émettez déjà, ou depuis une fonction personnalisée qui effectue la lecture et l’écriture avec deux appels distincts :

// ✗ 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’audit côté écriture ne pose pas ce problème : afterSave et afterDelete s’exécutent dans une transaction en lecture-écriture, et la ligne d’audit est validée en même temps que la modification qu’elle enregistre.


Masquer les adresses e-mail pour les appelants non-administrateurs sur l’ensemble des collections :

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

Enregistrer chaque suppression, sur l’ensemble des collections, dans une table audit_log. Comme afterDelete s’exécute dans la propre transaction de la suppression, la ligne d’audit et la suppression sont validées ensemble — il n’y a aucun laps de temps où l’une existe sans l’autre :

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

Notez bien ce que cela apporte et ce que cela implique : si la ligne d’audit ne peut pas être écrite, la suppression n’a pas lieu non plus. Pour une piste d’audit, c’est généralement ce que l’on recherche. Si ce n’est pas le cas, interceptez l’erreur dans le callback et explicitez-le dans un commentaire.

Les callbacks globaux se déclenchent pour toutes les collections. Pour limiter la portée d’une logique à une seule collection, vérifiez collection.slug ou 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;
}
}

Pour les callbacks qui ne s’appliquent qu’à une seule collection, privilégiez plutôt les callbacks par collection.