J4 — Le modèle et le RLS
Vérifié contre le produit le .
L’objectif du jour : comprendre pourquoi une requête parfaitement écrite peut ne rien renvoyer — et ne plus jamais y perdre une journée.
| Durée réaliste | une demi-journée |
| Prérequis | J1 et J2 — une base peuplée à interroger |
Tout est dans packages/db, qui est la source de vérité de l’isolation. Son README.md est
court et précis : lisez-le, cette page ne le remplace pas, elle vous dit quoi en retenir.
Une table métier ne se déclare pas avec pgTable()
Section intitulée « Une table métier ne se déclare pas avec pgTable() »Trois fonctions, et le choix n’est jamais laissé au goût de chacun :
| Fonction | Pour quoi | Ce qu’elle ajoute |
|---|---|---|
tenantTable() |
toute table métier | les colonnes de convention — id, establishment_id, created_at, updated_at, created_by — et la politique d’isolation |
sharedTable() |
les rares données globales, comme un catalogue | lecture pour tous les établissements, écriture réservée à la connexion propriétaire |
pgTable() |
jamais, pour une table de domaine | rien — et c’est le problème |
La politique posée par tenantTable() compare establishment_id à
current_setting('app.tenant_id')::uuid. Ce réglage n’est pas une variable d’application : c’est
un paramètre de session PostgreSQL, et c’est la base elle-même qui filtre.
Le contexte de tenant se pose par transaction
Section intitulée « Le contexte de tenant se pose par transaction »withTenant() est le point d’entrée de tout accès aux données :
const rooms = await withTenant(db, establishmentId, (tx) => tx.select().from(room));Il ouvre une transaction, y pose le tenant avec set_config(..., is_local => true) — la
sémantique de SET LOCAL — puis exécute votre code. Le réglage meurt avec la transaction, ce
qui est obligatoire avec un pool de connexions partagé : sans cela, une requête pourrait hériter
du tenant de la précédente.
Deux chemins seulement y mènent :
- une requête HTTP — l’API résout la session, puis l’organisation active, puis
l’établissement, et passe par
TenantContextService; - un worker asynchrone — pas de requête HTTP : l’
establishment_idvoyage dans le message de la file, et le gestionnaire rappellewithTenant()avec lui.
Les migrations
Section intitulée « Les migrations »bun run --filter @beemyschool/db db:generate # dérive une migration du schémabun run --filter @beemyschool/db db:migrate # l'applique (connexion propriétaire)On modifie src/schema.ts, puis on génère : le SQL est dérivé du schéma, jamais écrit à la
main d’abord. Le fichier produit se relit avant d’être committé — c’est du SQL qui tournera en
production.
En développement, l’API applique les migrations à son démarrage. Dans les tests, chaque suite
crée sa propre base et appelle migrateDb().
Pourquoi la migration 0000 crée un rôle PostgreSQL. C’est elle qui crée
beemyschool_app, le rôle non-superutilisateur du runtime — celui à qui le RLS s’applique
toujours. Conséquence directe, visible dans le workflow de CI : le rôle n’y est pas
provisionné, volontairement. Chaque suite le fabrique en jouant la migration, et le provisionner
en double dans le workflow le ferait diverger de ce que la production exécutera.
Le piège du jour
Section intitulée « Le piège du jour »Ce qui doit exister à la fin de la journée
Section intitulée « Ce qui doit exister à la fin de la journée »- Vous savez déclarer une table métier avec
tenantTable()et dire ce qu’elle ajoute - Vous savez pourquoi le contexte de tenant est posé par transaction et non par connexion
- Vous avez lu
packages/db/README.mdet parcourusrc/rls.test.ts - Vous savez générer et appliquer une migration
- Vous savez expliquer pourquoi la migration
0000crée un rôle PostgreSQL - Devant une requête qui ne renvoie rien, votre première hypothèse est le contexte de tenant
J5 — Les contrats : la chaîne qui relie l’API au web, et que personne ne devine avant de s’y être cassé les dents une fois.