Skip to main content

Stack

Conventions

  • Modules : ES modules uniquement ("type": "module")
  • Formatage : tabs, double quotes — via Biome
  • Imports packages : protocole workspace:*
  • API JSX : hono/jsx comme jsxImportSource

Commandes qualité

Structure d’un package

API (Hono.js)

Architecture en couches :

Base de données (Drizzle + Neon)

Le schéma TypeScript (packages/database/src/db/schema/) est la seule source de vérité. Une base n’évolue que par les migrations commitées dans packages/database/src/drizzle/ : db:generate les écrit depuis le schéma, db:migrate applique ce que déclare meta/_journal.json, rien d’autre.
db:push ne touche jamais une base partagée (prod, branche Neon de preview). Il applique le schéma sans laisser de trace : c’est ce qui a fait diverger la prod de son historique jusqu’en octobre 2026. La prod évolue par db:generate (migration relue en PR) puis db:migrate.
Toutes ces commandes lisent DATABASE_URL et rien d’autre, db:generate et db:check compris (la config l’exige même si la génération ne se connecte pas). drizzle-kit passe par le driver pg (TCP) : il marche aussi bien contre un Postgres local que contre Neon.

Écrire une migration

  1. Modifier le schéma dans packages/database/src/db/schema/.
  2. cd packages/database && bun run db:generate --name <ce_qui_change>, puis relire le SQL produit.
  3. Un rattrapage de données (backfill) se fait dans une migration vide créée par bunx drizzle-kit generate --config ./src/drizzle.config.ts --custom --name <nom>, jamais à la main hors journal : un SQL absent du journal est sauté sans un mot sur une base neuve.
  4. Commiter le SQL et meta/ ensemble. db:check et le test du journal le vérifient.
  5. L’appliquer sur ta base de dev (branche Neon dev, celle de ton .env) : bun run db:migrate.
En PR, le job quality de ci.yml rejoue toute la chaîne sur un Postgres vide puis lance db:check. Au merge sur main, le job migrate de build-images.yml migre la prod (secret DATABASE_URL_MIGRATE) avant le déploiement, qui n’a pas lieu si la migration échoue. Un changement qui casse le code déployé (colonne renommée, NOT NULL ajouté) se fait en deux temps : d’abord ajouter, déployer, migrer les données ; ensuite retirer l’ancien dans une migration suivante. Drizzle n’a pas de migration descendante : on corrige par une nouvelle migration, ou par une restauration Neon à un instant donné.

Historique

Avant octobre 2026, la prod a évolué par db:push et par des SQL écrits à la main : le dossier comptait 37 fichiers mais le journal n’en déclarait que 20, et le premier (0001) ne passait pas sur une base vide. Ces fichiers sont gardés dans src/drizzle/_archive/, que drizzle-kit ne lit pas. Ils sont remplacés par une migration unique, 0000_baseline, générée depuis le schéma TS.

Runbook : basculer la prod sur la baseline

Exécuté le 2026-10-08 sur la branche Neon main, répété d’abord sur une copie. Point de retour : la branche backup/pre-baseline. Le runbook reste pour toute base créée avant cette date.
À faire une fois, avant que db:migrate ne tourne en release. Sans ce marquage, db:migrate tenterait de recréer toutes les tables : il échoue alors sur la première (already exists) et annule tout, la migration tournant dans une transaction — sans dégât, mais sans déploiement.
Dérouler tout le runbook d’abord sur une branche Neon de la prod (copie instantanée, jetable), puis le rejouer sur la prod. Les commandes partent de packages/database/.
1

a. Lire la prod, en lecture seule

Créer dans Neon un rôle en lecture seule (ou prendre l’URL de la branche de répétition) dans PROD_URL_RO. drizzle-kit pull ne fait qu’introspecter :
--out doit rester relatif (drizzle-kit préfixe ./ aux chemins absolus) ; prod-pull/ est ignoré par git. Lire prod-pull/schema.ts pour repérer ce que l’étape suivante ne compare pas : autre schéma que public, extensions, vues, types.
2

b. Comparer la prod à la baseline

L’empreinte src/scripts/schema-fingerprint.sql liste, triées, chaque colonne (type, nullabilité, défaut), contrainte et index du schéma public. On la tire de la prod et d’un Postgres local vide migré sur la baseline, puis on compare :
Ne pas comparer via drizzle-kit generate sur le snapshot tiré : l’introspection décrit index et CHECK autrement que le schéma TS, et le diff les supprime et recrée tous.On ne bascule qu’avec un diff vide. Chaque écart se corrige avant :
  • ce que la prod a en trop ou de trave Écarts constatés en prod le 2026-10-08, corrigés en une transaction :
  • deux clés primaires au nom des anciennes tables (gifts_pkey, invitation_pkey) ;
  • les 7 index que supprimait l’archive 0036, jamais appliquée en prod ;
  • les 3 CHECK d’encaissement de l’archive 0010, retirés par un db:push. Reposés en NOT VALID puis VALIDATE, ce qui ne bloque pas les écritures.
Les noms de contraintes que le rejeu des 37 SQL archivés laissait prévoir (entitlements_*_key, org_invitation_*) n’existaient pas : la prod a évolué par db:push, pas par ces fichiers. Une autre base se compare toujours avant d’appliquer quoi que ce soit.TABLE “orders” VALIDATE CONSTRAINT “orders_amount_positive”; ALTER TABLE “orders” VALIDATE CONSTRAINT “orders_status_valid”; ALTER TABLE “payments” VALIDATE CONSTRAINT “payments_kind_valid”;
Vérifier : DATABASE_URL="$PROD_URL" bunx turbo run db:migrate réussit sans rien appliquer (une seule ligne de plus dans la table, l’empreinte de l’étape b inchangée).
3

d. Revenir en arrière

  • Avant le marquage, seul le correctif de l’étape b a touché la prod : inverser les RENAME CONSTRAINT (noms permutés), recréer les index supprimés, retirer les CHECK par DROP CONSTRAINT. Ou restaurer depuis la branche Neon de sauvegarde créée juste avant.
  • Après le marquage : DELETE FROM drizzle.__drizzle_migrations WHERE created_at = 1791444919068; rend la table à son état d’avant. Aucune donnée n’a bougé.
  • Une migration ultérieure qui casse la prod : nouvelle migration corrective, ou restauration Neon à un instant donné (branche créée depuis l’heure d’avant le migrate).

Créer un nouveau package