Accès aux données
Le backend n’utilise pas d’ORM. Il utilise Kysely avec un type Database généré qui reflète le catalogue PostgreSQL. La frontière base de données vit dans src/db/ et est garantie par DbAccess, AccessScope et TxAccess.
Pool et parsing des types
Section intitulée « Pool et parsing des types »src/db/pool.ts crée un pg.Pool unique et enregistre des parseurs de types pour que date, time et timestamp without time zone restent des chaînes :
import pg from 'pg';
pg.types.setTypeParser(1082, (v) => v); // datepg.types.setTypeParser(1083, (v) => v); // timepg.types.setTypeParser(1114, (v) => v); // timestamp without time zoneCes chaînes sont marquées DateStr, TimeStr et TimestampStr dans src/db/generated.ts.
DatabaseModule
Section intitulée « DatabaseModule »DatabaseModule est @Global() et exporte quatre primitives :
@Global()@Module({ providers: [ { provide: Kysely, useFactory: () => new Kysely<Database>({ dialect: new PostgresDialect({ pool }), }), }, DbAccess, { provide: DB_FUNCTIONS, useFactory: (db: Kysely<Database>) => makeFunctions(db), inject: [Kysely], }, AccessScope, ], exports: [Kysely, DbAccess, DB_FUNCTIONS, AccessScope],})export class DatabaseModule {}DbAccess (CRUD figé)
Section intitulée « DbAccess (CRUD figé) »DbAccess restreint les mutations aux vraies tables uniquement :
@Injectable()export class DbAccess { selectFrom<K extends keyof Database>(table: K) { /* tables + vues */ } insertInto<K extends TableKeys>(table: K) { /* tables uniquement */ } updateTable<K extends TableKeys>(table: K) { /* tables uniquement */ } deleteFrom<K extends TableKeys>(table: K) { /* tables uniquement */ }}TableKeys = Exclude<keyof Database, ViewKeys>, donc une vue ne peut pas être insérée ou supprimée accidentellement via le système de types.
TxAccess
Section intitulée « TxAccess »TxAccess lie un DbAccess et des fonctions SQL typées à un exécuteur de requêtes (racine ou transaction). Cela empêche les écritures dans une transaction d’utiliser accidentellement une autre connexion du pool.
export interface TxAccess { acc: DbAccess; fn: DbFunctions;}AccessScope
Section intitulée « AccessScope »AccessScope est l’API injectable des repositories :
@Injectable()export class AccessScope { readonly root: TxAccess;
audit<T>( idUtilisateur: bigint | string | null, work: (a: TxAccess, tx: Kysely<Database>) => Promise<T>, ): Promise<T> { /* ... */ }}- Utiliser
this.scope.rootpour les lectures. - Utiliser
this.scope.audit(user.id, async (a) => …)pour les écritures.
Casts vers les types brandés
Section intitulée « Casts vers les types brandés »Les DTO réseau restent en chaînes. La couche service caste vers les types DB brandés à la frontière du repository :
date_naissance: (dto.date_naissance ?? null) as DateStr | null;montant_loyer: dto.montant_loyer as Money;id_logement: dto.id_logement as BigIntStr;date_enregistrement: new Date().toISOString() as TimestampStr;Cela garde la couche de transport exempte de marques pendant que le repository reçoit des types d’insert/update sûrs à la compilation.
Helpers de requêtes de liste
Section intitulée « Helpers de requêtes de liste »src/db/list-query.ts fournit des helpers explicites que les repositories appellent directement :
| Helper | Rôle |
|---|---|
applyPagination |
limit (max 200) et offset |
applySearch |
ilike sur une liste blanche de colonnes |
applySort |
sort + order en liste blanche avec colonne par défaut |
applyDateRange |
du/au sur une colonne date/timestamp |
applyEquality |
filtres d’égalité exacte sur des champs en liste blanche |
Une méthode de repository de liste typique ressemble à :
async listerClients(a: TxAccess, filtres: ListerClientsFiltres) { let q = a.acc.selectFrom('client.client').selectAll(); q = applyEquality(q, filtres, { type_client: 'type_client', code: 'code', nom: 'nom', // ... }); q = applySearch(q, filtres.recherche, SEARCH_COLUMNS); q = applySort(q, filtres.sort, filtres.order, SORT_COLUMNS, 'nom'); q = applyPagination(q, filtres); return q.execute();}DbFunctions
Section intitulée « DbFunctions »Les fonctions PostgreSQL appelables sont typées et exposées via TxAccess.fn :
await a.fn.residence.encaisserLoyer({ p_id_echeance: id, p_montant: montant, p_id_moyen: idMoyen, p_id_utilisateur: user.id,});Fonctions encapsulées :
audit.masquer_donneesmarket.reesolde_stockresidence.encaisser_loyer,residence.encaisser_loyer_lot,residence.generer_echeances,residence.reviser_loyerrh.recalculer_paie
Garde PATCH
Section intitulée « Garde PATCH »assertNonEmptyPatch transforme un UPDATE ... SET vide en 400 BadRequestException claire :
export function assertNonEmptyPatch(patch: Record<string, unknown>): void { if (Object.keys(patch).length === 0) { throw new BadRequestException('Aucun champ à mettre à jour (corps vide ou content-type incorrect).'); }}Règles de la frontière
Section intitulée « Règles de la frontière »- Toujours lire via
this.scope.root. - Toujours écrire via
this.scope.audit(user.id, async (a) => …). - Passer
a: TxAccesscomme premier argument des méthodes d’écriture du repository. - Caster les chaînes du DTO vers les types brandés dans le service, pas dans le contrôleur.
- Utiliser
sql.refpour les identifiants ; lier les valeurs avecsql${value}`. - Ne pas écrire dans les
ViewKeys;DbAccessl’empêche à la compilation. - Régénérer
generated.tsaprès chaque migration et exécuterpnpm db:verify.
