Aller au contenu

Rappels d'entité

Les rappels vous permettent d’intervenir dans le cycle de vie de l’entité pour :

  • Synchroniser les données entre les collections — copier ou déplacer des entités entre les tables lors de changements de statut
  • Transformer les données avant de les enregistrer (champs calculés, slugification)
  • Valider les règles métier au-delà de la validation de schéma
  • Déclencher des effets secondaires après les écritures (envoyer des e-mails, synchroniser des APIs, mettre à jour des caches)
  • Filtrer/transformer les données après lecture
  • Opérations en cascade — nettoyer les enregistrements liés lors de la suppression
import type { PostgresCollectionConfig } from "@rebasepro/types";
// The row shape. Without it every `values.x` below is `unknown`.
type Article = {
title: string;
slug: string;
created_at: string;
updated_at: string;
};
const articlesCollection: PostgresCollectionConfig<Article> = {
slug: "articles",
name: "Articles",
table: "articles",
properties: {
title: { name: "Title", type: "string" },
slug: { name: "Slug", type: "string" },
created_at: { name: "Created at", type: "string" },
updated_at: { name: "Updated at", type: "string" }
},
callbacks: {
beforeSave: async ({ values, id, status }) => {
// Auto-generate slug from title
if (values.title) {
values.slug = values.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
// Set timestamps
if (status === "new") {
values.created_at = new Date().toISOString();
}
values.updated_at = new Date().toISOString();
return values;
},
afterSave: async ({ values, entityId }) => {
// Send notification
console.log(`Article ${entityId} saved: ${values.title}`);
},
beforeDelete: async ({ entityId }) => {
// Prevent deletion of published articles
// Throw to block the deletion
},
afterRead: async ({ entity }) => {
// Transform data after loading
return entity;
}
},
properties: { /* ... */ }
});

Appelé avant qu’une entité ne soit écrite dans la base de données. Retournez les valeurs modifiées.

beforeSave: async ({
values, // Entity values
entityId, // Entity ID (null for new entities)
status, // "new" | "existing" | "copy"
previousValues, // Previous values (for updates)
context // Full Rebase context
}) => {
// Return modified values
return { ...values, updated_at: new Date() };
}

Lancez une erreur pour bloquer l’enregistrement :

beforeSave: async ({ values }) => {
if (values.price < 0) {
throw new Error("Price cannot be negative");
}
return values;
}

Appelé après un enregistrement réussi. À utiliser pour les effets secondaires.

afterSave: async ({
values, // Saved values
entityId, // Entity ID
previousValues, // Previous values (null for new entities)
status, // "new" | "existing" | "copy"
context
}) => {
// Send webhook
await fetch("https://api.slack.com/webhook", {
method: "POST",
body: JSON.stringify({ text: `New article: ${values.title}` })
});
}

Appelé lorsqu’une opération d’enregistrement échoue.

afterSaveError: async ({
values,
entityId,
error,
context
}) => {
console.error("Save failed:", error);
}

Appelé après la lecture d’entités depuis la base de données. Transformez les données pour l’affichage.

afterRead: async ({
entity, // The entity to transform
context
}) => {
// Add computed fields
return {
...entity,
values: {
...entity.values,
displayName: `${entity.values.first_name} ${entity.values.last_name}`
}
};
}

Appelé avant la suppression d’une entité. Lancez une erreur pour bloquer la suppression.

beforeDelete: async ({
entityId,
entity,
context
}) => {
if (entity.values.status === "published") {
throw new Error("Cannot delete published articles. Unpublish first.");
}
}

Appelé après une suppression réussie.

afterDelete: async ({
entityId,
entity,
context
}) => {
// Cleanup related data
console.log(`Article ${entityId} deleted`);
}

Vous pouvez également définir des rappels au niveau de la propriété pour des transformations spécifiques au champ :

properties: {
email: {
type: "string",
name: "Email",
callbacks: {
beforeSave: ({ value }) => value?.toLowerCase().trim(),
afterRead: ({ value }) => value // Could decrypt, etc.
}
}
}

Chaque rappel reçoit un objet context qui inclut context.data — une couche d’accès aux données unifiée pour effectuer des opérations inter-collections à partir des hooks de cycle de vie.

context.data utilise un Proxy JavaScript, vous pouvez donc accéder à n’importe quelle collection par son slug en tant que propriété :

afterSave: async ({ values, entityId, context }) => {
// Dynamic property access — works for any collection slug
const jobs = context.data.jobs;
const users = context.data.users;
// Alternatively, use the .collection() method for dynamic slugs
const collectionName = "jobs";
const accessor = context.data.collection(collectionName);
}

Chaque accesseur de collection (context.data.<slug>) fournit ces méthodes :

Méthode Signature Description
.find() find(params?: FindParams) → FindResponse Interroger les entités avec des filtres, tri et pagination
.findById() findById(id: string | number) → Entity | undefined Récupérer une seule entité par ID
.create() create(data: Partial<Values>, id?: string) → Entity Créer une nouvelle entité
.update() update(id: string | number, data: Partial<Values>) → Entity Mettre à jour une entité existante
.delete() delete(id: string | number) → void Supprimer une entité
.count() count(params?: FindParams) → number Compter les entités correspondantes
.listen() listen(params, onUpdate, onError?) → unsubscribe Abonnement en temps réel (si supporté)
.listenById() listenById(id, onUpdate, onError?) → unsubscribe Écouter une seule entité

La méthode find() prend en charge un filtrage riche :

afterSave: async ({ values, context }) => {
// Simple equality
const { data: activeJobs } = await context.data.jobs.find({
where: { status: "published" },
limit: 10,
orderBy: ["created_at", "desc"]
});
// PostgREST-style operators
const { data: recentJobs } = await context.data.jobs.find({
where: {
status: "eq.published",
salary: "gte.50000"
}
});
// Tuple syntax
const { data: expensiveJobs } = await context.data.jobs.find({
where: {
salary: [">=", 100000],
role: ["in", ["admin", "manager"]]
}
});
}
afterSave: async ({ values, entityId, previousValues, context }) => {
// Promote an approved submission to a published job
if (values.status === "approved" && previousValues?.status !== "approved") {
const newJob = await context.data.jobs.create({
title: values.title,
description: values.description,
company_id: values.company_id,
status: "published",
source_submission_id: entityId,
});
// Link back to the original submission
await context.data["job-submissions"].update(entityId, {
promoted_job_id: newJob.id,
});
}
}

Sécurité : avec quels privilèges context.data s’exécute

Section intitulée « Sécurité : avec quels privilèges context.data s’exécute »

context.data hérite des privilèges de ce qui a déclenché le rappel. Ce n’est pas un niveau de confiance fixe.

  • Déclenché par une requête utilisateur (REST, temps réel, une modification dans le panneau d’administration) → limité à l’utilisateur. Le rappel s’exécute dans la transaction soumise au RLS ouverte pour cette requête ; les politiques s’appliquent donc aux lectures et aux écritures. Un rappel ne peut pas voir une ligne que son appelant ne pouvait pas voir.
  • Déclenché par rebase.dataAsAdmin ou une tâche cron (le même singleton) → limité à l’administrateur, et non pas non limité. Ce pilote est restreint à { uid: "service", roles: ["admin"] } ; le rappel s’exécute donc toujours dans une transaction soumise au RLS : vos politiques sont évaluées, face à cette identité.
  • Déclenché par le pilote de base (les flux d’authentification intégrés, les migrations) → non limité. Il s’exécute sur la connexion propriétaire et contourne le RLS.

C’est surtout important dans la direction qui échoue en silence. Le RLS filtre, il ne lève pas d’erreur — un rappel qui lit une ligne voisine la trouvera lorsqu’une tâche d’administration enregistre, et peut ne rien trouver lorsqu’un utilisateur final enregistre, sans erreur dans les deux cas. Écrivez des rappels qui tolèrent un résultat vide, ou passez délibérément par le plan d’administration :

afterSave: async ({ context }) => {
// Limité à l'utilisateur quand c'est un utilisateur qui a déclenché cet
// enregistrement : le RLS s'applique.
await context.data.audit_logs.create({ action: "approved" });
// Délibérément limité à l'administrateur — pour un travail que l'appelant ne
// doit réellement pas voir, comme un journal d'audit qu'il ne peut ni lire ni
// modifier. Attention : c'est la portée d'un administrateur, pas un
// contournement du RLS — une collection dont la seule règle est
// `policy.serverContext()` lui reste fermée, car cela compile en
// `auth.uid() IS NULL` et l'uid de cet accesseur est `service`.
await context.client.dataAsAdmin.audit_logs.create({ action: "approved" });
}

Les opérations context.data ne sont PAS automatiquement enveloppées dans la même transaction que l’enregistrement déclencheur.

L’enregistrement original de l’entité termine sa transaction de base de données en premier. Ensuite, afterSave s’exécute et tout appel context.data ouvre des transactions distinctes. Si une opération context.data échoue dans afterSave, l’enregistrement original n’est pas annulé.

Cela signifie :

  • ✅ L’enregistrement déclencheur réussit toujours indépendamment
  • ⚠️ Les écritures d’effets secondaires peuvent échouer sans affecter l’opération originale
  • ⚠️ Il n’y a aucune garantie d’atomicité entre l’enregistrement original et les appels context.data ultérieurs

Pour les opérations qui doivent être atomiques, enveloppez-les dans une gestion des erreurs :

afterSave: async ({ values, entityId, context }) => {
try {
await context.data.jobs.create({
title: values.title,
status: "published",
});
} catch (error) {
// Log the failure — the original save already succeeded
console.error(`Failed to promote job from submission ${entityId}:`, error);
// Optionally: mark the submission as "promotion_failed"
await context.data["job-submissions"].update(entityId, {
promotion_status: "failed",
promotion_error: String(error),
});
}
}

L’une des utilisations les plus puissantes des rappels est la synchronisation des données entre les collections à l’aide de context.data :

import type { PostgresCollectionConfig } from "@rebasepro/types";
type Submission = {
title: string;
description: string;
company_id: string;
status: string;
promoted_job_id: string;
};
const submissionsCollection: PostgresCollectionConfig<Submission> = {
slug: "job_submissions",
name: "Job Submissions",
table: "job_submissions",
properties: {
title: { name: "Title", type: "string" },
description: { name: "Description", type: "string" },
company_id: { name: "Company", type: "string" },
status: { name: "Status", type: "string" },
promoted_job_id: { name: "Promoted job", type: "string" }
},
callbacks: {
afterSave: async ({ values, id, previousValues, context }) => {
// When a submission is approved, create a published job
if (values.status === "approved" && previousValues?.status !== "approved") {
const newJob = await context.data.collection<Record<string, unknown>>("jobs").create({
title: values.title,
description: values.description,
company_id: values.company_id,
status: "published",
source_submission_id: id,
});
// Update the submission with the promoted job reference
await context.data["job-submissions"].update(entityId, {
promoted_job_id: newJob.id,
});
}
}
},
properties: { /* ... */ }
});

Autres modèles inter-collections :

  • Suppression en cascade : Utilisez afterDelete pour supprimer les enregistrements liés dans les collections enfants
  • Dénormalisation : Utilisez afterSave pour mettre à jour les champs récapitulatifs dans une collection parente
  • Journalisation d’audit : Utilisez afterSave / afterDelete pour écrire dans une collection de journaux d’audit
  • Compteurs : Utilisez afterSave / afterDelete pour mettre à jour les champs de compte sur les entités liées

Chaque rappel reçoit un objet context de type RebaseCallContext :

interface RebaseCallContext {
/** L'utilisateur authentifié, le cas échéant */
user?: User;
/** Le pilote de données sous-jacent (PostgresBackendDriver) */
driver: DataDriver;
/** Accès unifié aux données — context.data.<slug>.create/update/find/delete */
data: RebaseData;
}