Aller au contenu

Accès aux données — Kysely

La décision

Tout accès DB du nouveau code passe par Kysely, avec des types dérivés de zod (pas de codegen), encapsulé dans les repositories. TypeORM et le SQL brut sont en extinction.

Le contexte

Le legacy accède à la base de deux façons, toutes deux à des défauts rédhibitoires :

  • TypeORM (figé en 0.3.5, 18 entités, synchronize: false) : mal typé — les types promettent plus que ce que le runtime garantit — et le pattern décorateur ajoute un overhead difficilement compréhensible, une black box entre le code et la requête réelle.
  • Le SQL brut (queryPgAndValidate, dataSource.query) : pas typé du tout. La validation idtlt rattrape les types en sortie, mais rien ne vérifie la requête elle-même (colonnes, jointures, paramètres).

Pourquoi Kysely

  • Un query builder extrêmement bien typé : il dérive les queries des types qu'on lui injecte. Colonne inexistante, jointure invalide, paramètre du mauvais type → erreur de compilation.
  • Il supporte les branded types — facteur décisif pour l'équipe : nous évitons au maximum les primitives JS, trop souples. Un ProjectFinancingRequestId n'est pas un string, et Kysely propage cette exigence jusque dans la requête.
  • C'est un query builder, pas un ORM : pas d'état caché, pas de lazy loading — la requête écrite est celle qui part.

Alternatives écartées

Option Pourquoi non
Moderniser TypeORM Le problème est structurel : décorateurs + black box, quelle que soit la version
Drizzle ORM complet, mais ne supporte que les types primitifs PostgreSQL — incompatible avec notre pattern JSONB riche et nos branded types. Pertinent en migration totale, ce qui n'est pas notre trajectoire d'attrition
Prolonger queryPgAndValidate Les requêtes resteraient non typées

Les types viennent de zod, pas d'un codegen

Le flux de types est zod → TypeScript → Kysely : les schemas manuscrits de __new/lib/kysely/schemas/ sont la source de vérité, le type Database est assemblé à la main.

Un codegen (type kysely-codegen) inverserait le flux — DB → types — et perdrait les deux choses qui nous importent : les brands (un codegen sort des string nus) et la validation runtime au même endroit. Notre but est de dériver tous nos types depuis zod : le schema qui type la query est exactement celui qui valide les rows à la lecture (ky_parseRows, ky_parseOneRowky-parse.ts). Le drift DB ↔ types est attrapé au runtime, à la première lecture.

Les schemas sont centralisés (pas rangés par module) : ils reflètent PostgreSQL directement, et certains modules partagent les mêmes tables. C'est le pragmatisme DX retenu — voir les limites en bas de page.

Le pattern JSONB + colonnes *_view

Les nouvelles tables suivent un modèle hybride : une colonne json (JSONB) qui porte l'entité entière, et des colonnes générées *_view pour le relationnel :

CREATE TABLE project_financing_request_question (
  "id_view" uuid NOT NULL PRIMARY KEY GENERATED ALWAYS AS ((json->>'id')::uuid) STORED,
  "projectId_view" uuid NOT NULL GENERATED ALWAYS AS ((json->>'projectId')::uuid) STORED,
  ...
  json jsonb NOT NULL
)

Le meilleur des deux mondes — DB orientée document et relationnelle :

  • Côté document : le JSONB épouse les unions discriminées qui représentent les états d'une entité (draft / completed n'ont pas les mêmes champs requis — voir Validation). Le schéma applicatif évolue sans ALTER TABLE.
  • Côté relationnel : les *_view donnent les foreign keys, les index optimisés, les jointures et les filtres SQL.
  • Un seul point d'entrée en écriture : on écrit json, PostgreSQL dérive les *_view — aucune double écriture à désynchroniser.

Les transitions d'état

Deux patterns coexistent, selon la nature du module :

  • Parse du schema cible — adapté aux CRUD en auto-save : complete() de PFR valide le passage draft → completed en parsant l'entité contre le schema completed ; un champ manquant = presentation-incomplete. L'union discriminée est la règle de complétude.
  • Machine à états explicite — pour les workflows transactionnels : transitions nommées et state watchers, cf. primary-purchase et lemonway-p2p.

Transactions et verrous

ky_safePgTransaction avec Err = rollback (Erreurs & Result). Les mutations des repositories prennent trx: Transaction<Database> obligatoire. FOR UPDATE (.forUpdate()) avant tout update non atomique ; SKIP LOCKED réservé aux workers.

Le pool partagé

getKysely() ne crée pas son pool : il consomme le pool pg partagé avec better-auth (get-app-shared-pg-pool.ts) — la moitié de DATABASE_POOL_MAXSIZE, le reste à TypeORM. Conséquence de l'incident Railway du 2026-05-17 : des pools indépendants s'affament mutuellement quand les connexions se raréfient. Statement timeout appliqué à chaque connexion.

Limites assumées & trajectoire

  • Pas de frontière domaine ↔ persistance : services et business consomment directement les types PgSchema des schemas centralisés. Trajectoire envisagée : chaque module redéclare les modèles utiles à son service/business, le repository faisant l'intermédiaire applicatif ↔ relationnel. Pas fait dans PFR — à reconsidérer quand un module en aura le besoin réel.
  • Aucun fragment sql`…` brut à ce jour : la fluent API suffit et garde le typage. Si un raw s'impose, il restera confiné au repository.
  • TypeORM reste massif dans le legacy ; il part par attrition, pas par chantier dédié.