Supabase avancé

Posted on

Supabase Studio, la même interface que dans le cloud, ouverte sur une base qui tourne sur ma machine.

En juin, j’ai monté un backend complet sur Supabase et j’en ai tiré un article : Le Cellier, une cave en ligne fictive mais crédible, 14 160 cuvées, des comptes clients, des commandes, un paiement Stripe. Je le terminais en écrivant que je n’avais fait qu’effleurer la surface.

J’ai continué depuis, trois mois durant, sur la même base : les migrations en ligne de commande, le temps réel, le travail différé, le chiffrement, et la sortie vers mon propre serveur.

Ce texte n’est pas un tutoriel et ne fait pas le tour du sujet. C’est le relevé de ce que j’ai parcouru, et de ce que ces briques permettent de faire. Ce parcours permet aussi de trancher une question qui traverse tout l’article et décide de sa conclusion : là-dedans, qu’est-ce qui appartient à Supabase, et qu’est-ce qui appartient à PostgreSQL ?

Supabase CLI : le chantier monté à côté

Sur un projet qui tourne, il arrive un moment où il faut réorganiser : changer des tables, déplacer des données, poser une nouvelle règle. Et en production, on ne casse pas pour réparer ensuite : une donnée perdue l’est parfois pour de bon. Le chantier se monte donc à côté, et on n’applique que ce qui est sûr.

C’est le métier de la CLI de Supabase, un outil local qui s’utilise en ligne de commande. Ce qu’elle ramène du projet distant, ce ne sont pas ses données vivantes : c’est sa recette. supabase db pull écrit tout l’existant dans un fichier SQL (tables, relations, index, règles de sécurité) et supabase db dump --data-only en sort les données. Deux fichiers texte, versionnables avec Git, rejouables en une commande sur sa propre machine. La base distante, elle, n’a pas bougé d’un octet.

Mon cas : sur la table des cuvées, le cépage était du texte libre, une colonne qui mélangeait les mono-cépages (« Gamay ») et les assemblages (« Grenache, Syrah, Mourvèdre »). Bon pour afficher, inexploitable pour chercher. Objectif : une vraie relation, un vin ayant un ou plusieurs cépages, chaque cépage n’existant qu’une fois.

La migration s’écrit et s’éprouve sur ma machine, jamais sur la production. supabase start y monte une vraie base PostgreSQL : la CLI y rejoue les migrations du projet, puis y charge la copie des 14 160 cuvées ramenées la veille. Même structure qu’en production, et toutes ses données, mais une copie : je peux tout casser ici sans rien risquer là-bas. J’applique la migration dessus, elle passe.

Mais « ça passe » ne veut pas dire « c’est juste ». Une migration qui plante se voit tout de suite ; une migration qui réussit en perdant un cépage au passage ne dit rien du tout. C’est ce silence qu’il faut traquer, et seules les vraies données le révèlent. Trois contrôles de réconciliation, sur la totalité du catalogue : autant de cépages créés que de libellés distincts à l’origine, aucune cuvée laissée sans lien, autant de liens que de couples présents dans le texte de départ. Les trois tombent juste, rien ne s’est perdu.

La preuve est faite en local, sur une copie de toutes les données de production : la migration peut donc s’appliquer à la vraie base. J’écris alors la seconde migration, celle qui supprime l’ancienne colonne texte devenue inutile, et j’envoie le tout. C’est une seule commande, supabase db push, et quelques secondes : en production, deux tables nouvelles, 58 cépages, 25 440 liens sur les 14 160 cuvées, et la colonne d’origine supprimée. Cette suppression, elle, est sans retour ; ce qui autorise à la faire, c’est la preuve d’avant.

Ce que ça coûte : tout passe par la ligne de commande, et Docker est un prérequis. À savoir aussi : la CLI ne fonctionne qu’avec le cloud de Supabase, pas avec une instance auto-hébergée.

Realtime : la base qui pousse

Une API REST ne parle que si on l’interroge. Realtime fait l’inverse : il pousse. C’est un serveur distinct, posé à côté de PostgreSQL, qui garde un tuyau ouvert avec chaque client connecté. Il lit le journal des écritures que Postgres tient de toute façon, et relaie les changements par WebSocket à mesure qu’ils arrivent.

Quatre blocs reliés par des flèches : PostgreSQL, où le stock passe de 25 à 24 ; le journal des écritures, soit le WAL de PostgreSQL ; Realtime, serveur distinct ; les clients, qui affichent tous 24. La flèche vers les clients porte la mention WebSocket. Le trajet d’un changement, de la base aux clients connectés.

Realtime est bien une brique Supabase, sous licence Apache 2.0, et capable de tourner seule contre n’importe quelle base PostgreSQL. Trois usages, que j’ai essayés l’un après l’autre.

Écouter une table. Une ligne de SQL suffit à l’ouvrir :

alter publication supabase_realtime add table public.cuvees;

Rien d’autre à toucher : ni le schéma, ni la sécurité. La RLS, les règles d’accès ligne par ligne, s’applique telle quelle : un client ne reçoit que ce qu’il a déjà le droit de lire. Pour l’essayer, une page web qui affiche une cuvée du Cellier : son nom, son millésime, sa couleur, son prix, son stock et son domaine. Deux écrans côte à côte : je modifie le stock dans la table, la valeur change sur la page au même instant, sans rechargement ni bouton.

Faire passer des messages. Les clients rejoignent un salon désigné par un simple nom, et Realtime fait suivre ce qui y passe. Rien n’est écrit, rien n’est lu, la base n’est pas dans la boucle. Il n’y a rien à créer non plus : le salon existe tant que quelqu’un s’y trouve. Le revers, c’est qu’un tel salon est public, la clé publique du projet et son nom suffisent à y entrer ; pour une conversation qui ne regarde pas tout le monde, il existe des salons privés, dont l’accès se décide par des règles.

Savoir qui est là. Le canal tient la liste de ceux qui s’y trouvent et la rafraîchit à chaque arrivée ou départ. Ce n’est pas de la surveillance : le serveur ne compte que ceux qui se déclarent, et chacun choisit ce qu’il dit de lui, un pseudo, une position de curseur, ou rien du tout. J’ai pris le rien du tout : une page qui affiche une cuvée et un compteur, le canal portant l’identifiant de la cuvée. Cinq onglets ouverts, le chiffre monte sur les cinq à la fois ; j’en ferme un, les autres retombent à 4 en moins d’une seconde. Rien n’est écrit en base, et quand la dernière page se ferme, le compteur n’existe plus.

De quoi monter un tableau de suivi qui se met à jour tout seul, un stock qui baisse sous les yeux du vendeur, une notification qui arrive sans qu’on recharge. La mise en route est courte au regard de l’effet.

pg_cron et pgmq : le travail différé

Deux extensions PostgreSQL, et non deux produits Supabase : elles s’activent en une ligne dans un projet Supabase, mais tournent aussi bien sur n’importe quelle autre base PostgreSQL.

pg_cron est un planificateur qui vit dans la base et exécute une requête SQL à intervalle régulier. J’en ai posé deux sur Le Cellier : un état des lieux des stocks inséré chaque nuit dans une table journal, et la purge des relevés de plus de trente jours. Programmer une tâche est un appel de fonction, où l’intervalle s’écrit en syntaxe cron classique (sur Supabase, l’heure est en UTC) et la requête se pose telle quelle :

select cron.schedule(
  'purge-etat-des-lieux',
  '30 3 * * *',
  $$
  delete from public.etat_des_lieux
  where releve_le < current_date - interval '30 days';
  $$
);

pgmq installe une file de messages dans la base : on y dépose du JSON d’un côté, on le retire de l’autre. Mon cas : les réceptions de bouteilles arrivent toute la journée, mais je ne veux pas toucher au catalogue que voient les clients pendant l’ouverture. Elles vont donc dans une file qui se remplit la journée, et qu’on vide la nuit.

Le mécanisme mérite qu’on s’y arrête, parce que c’est lui qui distingue une file d’une table avec une colonne « traité ». Lire un message ne le sort pas de la file : il faut l’archiver explicitement. S’il ne l’est pas, parce que le traitement a échoué, il redevient visible au bout de trente secondes et reste à faire. Et comme un message lu est mis de côté pour les autres lecteurs, plusieurs travailleurs peuvent vider la même file en parallèle sans jamais traiter deux fois la même chose. Rien de tout cela n’est une promesse d’outil : ce sont des lignes dans des tables PostgreSQL, avec les transactions et la durabilité qui vont avec.

Les deux ensemble donnent le travailleur : une fonction PL/pgSQL de quatre instructions (lire le message, appliquer le stock, archiver), que pg_cron appelle au rythme voulu. L’ordre compte, l’archivage vient en dernier : si la mise à jour échoue, le message n’est pas archivé et reviendra. Personne n’a besoin de surveiller ça.

Et comme tout, dans PostgreSQL, est rangé dans des tables, tout se relit avec un select : la file, son archive, le catalogue des files, les tâches planifiées et le journal de leurs exécutions passées. Une trace complète de ce qui a tourné, sans avoir rien posé pour la produire.

Trois tables du schéma pgmq dessinées par Supabase : meta (queue_name, is_partitioned, is_unlogged, created_at), la file q_receptions_cuvees (msg_id, read_ct, enqueued_at, vt, message, headers) et son archive a_receptions_cuvees, qui ajoute archived_at. Une file de messages, vue de l’intérieur : trois tables. read_ct compte les lectures, vt dit quand un message redevient visible, archived_at quand il a quitté la file.

pg_net : la base qui appelle dehors

Dans le prolongement des deux précédentes, une troisième extension, développée cette fois par Supabase : pg_net envoie des requêtes HTTP depuis PostgreSQL, écrites en SQL comme le reste.

Sa particularité est d’être asynchrone : l’appel rend un identifiant, pas une réponse, et la réponse atterrit un instant plus tard dans une table dédiée. Deux conséquences concrètes. On ne peut pas conditionner la suite au statut HTTP dans la transaction qui a lancé l’appel, il faut y repasser plus tard. Et un appel échoué n’est pas retenté : ce n’est pas son rôle.

Ma démonstration tient dans une fonction : le stock total est calculé par un select, puis envoyé en notification sur mon téléphone, vers le serveur ntfy que j’héberge. Avec un pg_cron devant, le relevé arrive chaque matin. En restant dans la base, on peut donc quand même en sortir.

La fonction notifier_stock_total ouverte dans un éditeur : elle fait select sum(stock) into total from public.cuvees, puis appelle net.http_post avec une url, des headers et un body JSON (topic demo, title Stock du jour, message total bouteilles en cave). L’adresse du serveur et le jeton y sont masqués. La fonction entière : un select pour compter, un appel HTTP pour prévenir. Adresse et jeton masqués.

pgcrypto : chiffrer là où vivent les données

pgcrypto est une extension PostgreSQL standard, disponible partout, qui apporte le hachage et le chiffrement directement en base. Trois usages, et une question qui les sépare : où vit la clé, et qui peut relire ?

Hacher : pas de clé, personne ne relit. Un hash est une empreinte, l’opération est à sens unique. Le mot de passe n’est pas haché seul : on lui ajoute un sel tiré au hasard à chaque enregistrement, si bien que deux comptes ayant le même mot de passe portent deux empreintes sans rien de commun. Vérifier consiste à relire ce sel dans l’empreinte stockée, refaire le calcul et comparer les deux empreintes, jamais deux mots de passe. crypt() et gen_salt() s’en chargent. J’ai ensuite ouvert auth.users, la table des comptes de Supabase : les mots de passe y sont stockés sous la même forme. Je venais de refaire à la main ce que la plateforme fait en arrière-plan.

Chiffrer avec une clé partagée : le serveur peut lire. La clé est rangée dans Vault, le coffre de Supabase, ni dans le code ni dans le SQL. Un trigger chiffre la colonne avant l’insertion, une vue la déchiffre à la lecture. Le client, lui, envoie un numéro de téléphone en clair et ne sait rien de tout ça. Le point qui compte : la donnée en clair n’est stockée nulle part, elle est calculée au moment de la lecture, et la table ne contient que du chiffré, au repos comme dans les sauvegardes. Le prix est réel : une colonne chiffrée ne se trie plus, ne se filtre plus, ne se cherche plus, et prend bien plus de place (dix chiffres deviennent un bloc d’environ 170 caractères).

Chiffrer avec une clé publique : le serveur écrit sans pouvoir relire. La clé publique est écrite en dur dans le trigger, à la place de cle_publique ci-dessous : être exposée est son rôle. La clé privée, elle, n’entre jamais dans la base :

new.message := armor(pgp_pub_encrypt(new.message, dearmor(cle_publique)));

Le montage est le même que précédemment, mais cette fois la base ne sait pas relire ce qu’elle a écrit. Ni Supabase, ni l’hébergeur, ni moi tant que je reste dans l’éditeur SQL : pour lire, je copie le bloc chiffré et je le déchiffre dans mon terminal avec GPG, seul endroit où vit la clé privée. Une boîte aux lettres dont la fente est publique et la clé chez moi.

Ce que ça ouvre dépasse la démonstration. Il suffit d’une colonne de plus dans la table des profils : la clé publique de chaque utilisateur, qu’il y dépose lui-même. Ce qu’on écrit pour lui est alors scellé avec sa clé au moment de l’insertion, et lui seul peut le rouvrir, avec une clé privée qui n’a jamais quitté sa machine. Ni les autres utilisateurs, ni l’administrateur de la base, ni l’hébergeur ne lisent quoi que ce soit.

Sortir : reprendre la base ailleurs

Dernier terrain, et celui qui donne son sens aux précédents : faire sortir Le Cellier de chez Supabase, pour de vrai, vers un PostgreSQL que j’administre sur mon propre serveur.

La ligne de partage est nette. D’un côté, ce qui vit dans la base : tables, données, comptes, fonctions, triggers, règles d’accès, tâches planifiées, file de messages, colonnes chiffrées. C’est du PostgreSQL, ça se transporte. De l’autre, ce qui vit autour : GoTrue pour l’authentification, PostgREST pour l’API, le stockage de fichiers, Realtime, les Edge Functions. Rien de tout cela ne se transporte, c’est à recréer.

Pour le premier lot, aucun outil Supabase n’est nécessaire, puisque Supabase n’a pas transformé PostgreSQL, il l’a entouré. Un pg_dump passé deux fois, une fois le schéma, une fois les données, et le patrimoine tient dans deux fichiers.

L’import chez moi a défilé avec beaucoup d’erreurs, et elles sont instructives : presque toutes disaient la même chose, un rôle absent. Les rôles d’administration de la plateforme, ceux qui la font tourner et qui n’ont rien à faire chez moi. Ces erreurs ne sont pas l’échec de la migration, elles en sont la trace : elles dessinent la frontière exacte entre ce qui était mon application et ce qui était la plateforme autour. Deux rôles, en revanche, étaient bien à moi, anon et authenticated, que mes policies nomment une par une ; pg_dump ne les emporte pas, un rôle appartenant au cluster et non à la base. Sans eux, chaque policy échoue à la restauration pendant que la sécurité, elle, reste activée sur les tables : la base se retrouve verrouillée et sans règles.

Le reste a été du réglage, pas de la reconstruction : supprimer les déclencheurs d’événement et les schémas de service venus avec le dump (treize schémas chez Supabase, cinq chez moi, tous utiles), installer pg_cron et pgmq que l’image Docker officielle de PostgreSQL n’embarque pas, réécrire les deux tâches planifiées et remettre la file au catalogue. Je ne réinstalle pas Supabase, je remets Le Cellier en marche, ni moins ni plus que ce que j’avais écrit.

Un mot sur la sauvegarde, au passage : sur mon propre cluster, je suis superutilisateur. Un pg_dumpall sort donc vraiment tout, bases, rôles, droits et données, dans un seul fichier. J’ai vidé le cluster et je l’ai rechargé depuis ce fichier : Le Cellier est revenu entier, ses 14 160 cuvées comprises, en moins de cinq minutes. Sur le cloud de Supabase, je n’ai pas ce niveau de droits, et une partie du dump me serait refusée.

Reste la question de la mise en production, et c’est là qu’on mesure ce qu’est un backend complet. La capture ci-dessous est le tableau de bord de mon projet Supabase : les six services qui le font tourner, tous en bonne santé, la base de données, l’API, l’authentification, le temps réel, le stockage de fichiers et les fonctions. Un seul est parti chez moi, le premier : la base. Les cinq autres, barrés sur la capture, sont restés : open source et réinstallables, mais les remonter un par un est un autre chantier, et reconstruire Supabase à l’identique n’aurait aucun intérêt.

Les six services du projet Supabase, chacun marqué Healthy : Database, PostgREST, Auth, Realtime, Storage, Edge Functions. Une croix rouge barre les cinq derniers. Les six services de mon projet Supabase, vus depuis son tableau de bord. Seule la base est partie sur mon serveur.

Ce que je retiens

Supabase est un outil sérieux, avec lequel on monte un backend complet : les services sont là, cohérents entre eux et prêts à l’emploi. Trois mois à creuser dessus ne m’ont pas fait rencontrer de mur, plutôt des portes que je n’avais pas encore ouvertes.

Et la porte de sortie est ouverte, elle aussi. Le cœur de ce que j’ai montré ici (les migrations, le planificateur, la file, le chiffrement, les règles d’accès) n’est pas du Supabase : c’est du PostgreSQL, une base libre et éprouvée depuis trente ans, qu’on emporte avec un pg_dump. Ce qui reste à recréer en partant, ce sont les services autour, et c’est là que d’autres choix s’ouvrent : une autre authentification, une autre API, et davantage si l’envie vient.

C’est ce que je voulais vérifier : la valise se prend et s’emporte. Connaître ses portes de sortie avant de s’engager, c’est ce qui rend le choix d’une plateforme tenable dans la durée. Il se peut qu’il n’y ait jamais besoin d’en sortir ; c’est bien de savoir qu’on n’est pas enfermé.

Table of Contents