Base de données

Pool PostgreSQL, requêtes paramétrées, transactions et helpers CRUD de core/database.

core/database fournit la couche d'accès PostgreSQL : connexion en pool, requêtes paramétrées, transactions et helpers CRUD. Il ne connaît aucune feature.

Règle absolue : tout accès à la base passe par ce module. Jamais de client pg instancié ailleurs.

Import

import {
  query, queryOne, queryAll, transaction, testConnection, DatabaseError,
  tableExists, columnExists, indexExists, withAdvisoryLock,
  ensureMigrationsTable, getAppliedMigrations, runMigrations, baselineIfEmpty, migrateAll,
  create, findById, find, findOne, updateById, update, deleteById, deleteWhere, count, exists,
  safeIdentifier, safeOrderBy, filterAllowedColumns,
  getPool, closePool,
} from '@lrvo/core/database';

Module serveur strict (db.server.js importe pg). Ne jamais l'importer depuis un fichier 'use client', ni depuis un barrel atteignable par un bundle client.

Variables d'environnement

VariableRôle
LRVO_DATABASE_URLURL de connexion PostgreSQL.
LRVO_DATABASE_URL_DEVURL utilisée à la place quand NODE_ENV=development.
LRVO_DB_SSL_DISABLEDtrue désactive TLS entièrement.
LRVO_DB_SSL_NO_VERIFYtrue garde TLS mais désactive la vérification du certificat.
LRVO_DB_SSL_CAAutorité de certification à épingler. PEM inline, ou chemin vers un .crt / .pem.

Politique TLS

La résolution suit cet ordre, premier cas gagnant. Elle est évaluée dans tous les environnements, ce qui permet de déployer en production une base joignable seulement par un réseau privé sans affaiblir le comportement par défaut.

OrdreConditionComportement
1LRVO_DB_SSL_DISABLED=truePas de TLS. Réservé aux liens locaux de confiance.
2LRVO_DB_SSL_NO_VERIFY=trueTLS actif, certificat non vérifié. Chiffre le trafic et tolère un certificat auto-signé sur un réseau privé.
3LRVO_DB_SSL_CA définiTLS strict, épinglé sur l'autorité fournie.
4sslmode porté par l'URL de connexionTraduit, voir ci-dessous.
5production, sans surchargeTLS strict contre le magasin d'autorités du système.
6Autres environnements, sans surchargeTLS actif, vérification désactivée.

Préférer l'épinglage par LRVO_DB_SSL_CA à LRVO_DB_SSL_NO_VERIFY en production : le second chiffre le trafic mais n'authentifie pas le serveur.

Paramètres TLS dans l'URL

Une URL copiée telle quelle chez un fournisseur PostgreSQL se termine souvent par ?sslmode=allow. Le core retire ces paramètres de la chaîne de connexion avant de la passer à pg : sslmode, ssl, sslcert, sslkey, sslrootcert et uselibpqcompat. Chaque retrait sort un avertissement au démarrage du pool. Tous les autres paramètres sont conservés, application_name et connect_timeout compris.

Ce retrait n'est pas cosmétique. pg construit sa configuration en fusionnant la chaîne de connexion par-dessus les options explicites du pool, et son parseur active TLS avec vérification stricte du certificat dès que sslmode est présent, quelle que soit sa valeur. Laissé en place, un ?sslmode=allow annule silencieusement toute la politique ci-dessus, LRVO_DB_SSL_DISABLED=true compris, et une base au certificat auto-signé devient injoignable.

Le sslmode n'est pas perdu pour autant : il sert de valeur par défaut quand aucune variable LRVO_DB_SSL_* n'est posée.

sslmodePolitique appliquée
disablePas de TLS.
allow, prefer, requireTLS actif, certificat non vérifié.
verify-ca, verify-fullTLS strict.
Absent ou inconnuPolitique par défaut, cas 5 et 6 du tableau.

allow et prefer signifient chez libpq « essayer un transport, basculer sur l'autre », un repli que pg ne sait pas faire. Les trois modes non vérifiants sont donc rendus par la seule configuration qui se connecte réellement à une base au certificat auto-signé, ce qui correspond à leur garantie chez libpq : la vérification du certificat ne commence qu'à verify-ca.

Pour épingler une autorité, passer par LRVO_DB_SSL_CA : un sslrootcert dans l'URL est retiré, jamais lu.

Pool

Le pool est créé une fois, au premier appel : 20 connexions maximum, connexions inactives fermées après 30 secondes, erreur de connexion après 2 secondes.

getPool() retourne cette instance, et lève si aucune URL de connexion n'est configurée. Une application n'a pas à l'appeler : query, transaction et les helpers CRUD s'en chargent. Il sert au code qui a besoin du client pg brut, par exemple pour un LISTEN.

closePool() ferme le pool et le remet à null, ce qui permet une réouverture ultérieure. À appeler à la fin d'un script ou d'une commande, sans quoi le processus reste en vie sur des connexions ouvertes. C'est ce que fait lrvo-db en sortie, y compris sur erreur. Un serveur Next.js, lui, garde son pool jusqu'à l'arrêt du processus.

Requêtes bas niveau

query(sql, params?)

Exécute une requête paramétrée et retourne l'objet résultat pg complet.

const result = await query('SELECT * FROM users WHERE id = $1', [userId]);
// result.rows, result.rowCount

queryOne(sql, params?)

Retourne la première ligne, ou null.

const user = await queryOne('SELECT * FROM users WHERE email = $1', [email]);

queryAll(sql, params?)

Retourne toutes les lignes sous forme de tableau.

transaction(callback)

Exécute plusieurs requêtes dans une transaction atomique. Rollback automatique si le callback lève.

const post = await transaction(async (client) => {
  const inserted = await client.query(
    'INSERT INTO posts (title) VALUES ($1) RETURNING *',
    ['Mon article']
  );
  await client.query(
    'INSERT INTO post_tags (post_id, tag) VALUES ($1, $2)',
    [inserted.rows[0].id, 'actu']
  );
  return inserted.rows[0];
});

testConnection()

Retourne true si la connexion est établie. Utilisée par lrvo-db test.

tableExists(tableName)

Retourne true si la table existe dans le schéma public. Sert à rendre createTables() idempotent.

if (!(await tableExists('lrvo_blog_posts'))) {
  await query(CREATE_SQL);
}

À ne jamais utiliser dans une migration. Cette fonction interroge le pool, donc une connexion différente de celle qui porte la transaction en cours : elle ne verra pas le schéma créé quelques lignes plus haut. Une migration utilise ctx.tableExists. Voir Écrire une migration.

columnExists(tableName, columnName) et indexExists(indexName)

Même contrat, même réserve : ces fonctions passent par le pool.

withAdvisoryLock(lockId, callback)

Exécute un traitement sous verrou consultatif PostgreSQL, en gardant un seul client pour toute sa durée.

Ce n'est pas un détail d'implémentation : un verrou consultatif de session est lié au client, et transaction() emprunte un client quelconque au pool puis le relâche. Le verrou serait posé sur une session et relâché sur une autre.

Le verrou est pris avec pg_try_advisory_lock et quelques tentatives bornées, pour que deux répliques de déploiement obtiennent un message clair plutôt qu'un blocage indéfini. Il est relâché même en cas d'échec.

Helpers CRUD

Toutes ces fonctions construisent du SQL entièrement paramétré. Les noms de tables et de colonnes passent par safeIdentifier() : l'injection SQL est impossible, y compris via un nom de colonne.

create(tableName, data, options?)

Insère une ligne et retourne l'enregistrement créé (RETURNING *).

const user = await create('users', { email, name, role: 'user' });

Option allowedColumns, recommandée dès que data vient d'une requête HTTP : liste blanche explicite. Toute clé absente lève immédiatement, ce qui bloque le mass-assignment.

const user = await create('users', body, { allowedColumns: ['email', 'name'] });
// une tentative de passer `role` lève au lieu d'écrire

findById(tableName, id, idColumn?)

Retourne l'enregistrement, ou null.

const post = await findById('posts', 42);
const page = await findById('pages', slug, 'slug');

find(tableName, conditions?, options?)

Retourne un tableau.

const published = await find('posts', { published: true }, {
  orderBy: 'created_at DESC',
  limit: 10,
  offset: 0,
});
OptionTypeContrainte
orderBystring"colonne" ou "colonne ASC|DESC"
limitnumberEntier entre 1 et 10 000
offsetnumberEntier ≥ 0

findOne(tableName, conditions)

Retourne le premier enregistrement correspondant, ou null.

updateById(tableName, id, data, idColumn?, options?)

Met à jour par identifiant et retourne la ligne, ou null si introuvable.

const updated = await updateById('users', userId, body, 'id', {
  allowedColumns: ['name', 'avatar_url'],
});

update(tableName, conditions, data, options?)

Met à jour toutes les lignes correspondantes. Au moins une condition est obligatoire : un UPDATE sans WHERE lève au lieu de réécrire la table.

deleteById(tableName, id, idColumn?)

Retourne true si une ligne a été supprimée.

deleteWhere(tableName, conditions)

Supprime les lignes correspondantes et retourne le compte. Au moins une condition est obligatoire, même raison que update.

count(tableName, conditions?) et exists(tableName, conditions)

const total  = await count('users');
const admins = await count('users', { role: 'admin' });
const taken  = await exists('users', { email });

Erreurs

Toutes les opérations lèvent une DatabaseError en cas d'échec. Elle expose un message générique et le code SQLSTATE, jamais un nom de table, une contrainte ou un fragment de SQL : ces informations restent dans les logs serveur.

import { DatabaseError } from '@lrvo/core/database';

try {
  await create('users', { email });
} catch (error) {
  if (error instanceof DatabaseError && error.code === '23505') {
    return apiError('Bad Request', 'Cette adresse est déjà utilisée.');
  }
  throw error;
}
CodeSignification
23505Violation de contrainte UNIQUE
23503Violation de contrainte FOREIGN KEY
23502Violation de contrainte NOT NULL

Utilitaires de sécurité

safeIdentifier(name)

Valide et encadre un identifiant SQL de guillemets doubles. Autorise [A-Za-z_][A-Za-z0-9_]*, 63 caractères maximum.

safeIdentifier('users')    // '"users"'
safeIdentifier('my-table') // lève

safeOrderBy(orderBy)

Valide "colonne" ou "colonne ASC|DESC".

safeOrderBy('created_at DESC') // '"created_at" DESC'
safeOrderBy('1=1; DROP TABLE') // lève

filterAllowedColumns(data, allowedColumns?)

Filtre un objet selon une liste blanche. Sans liste, retourne l'objet tel quel. Avec liste, toute clé absente lève.

Usage depuis un module

// @lrvo/module-blog — src/db.server.js
import { query, tableExists, create, find } from '@lrvo/core/database';

export async function createPost(data) {
  return create('lrvo_blog_posts', data, { allowedColumns: ['title', 'slug', 'content'] });
}

export async function listPublished() {
  return find('lrvo_blog_posts', { published: true }, { orderBy: 'created_at DESC', limit: 50 });
}

Convention : préfixer toutes les tables d'un module par lrvo_<module>_.

Notes

createTables() crée le schéma initial, les migrations le font évoluer. Un ALTER TABLE glissé dans createTables() doit être rejoué à chaque démarrage pour l'éternité, puisque la fonction court-circuite sur une table existante. Voir Écrire une migration et lrvo-db.

DatabaseError porte désormais l'erreur d'origine dans cause. Le message exposé reste générique, sans nom de table ni fragment SQL : le contrat d'opacité est intact. cause n'existe que pour les chemins d'administration, où un « une erreur est survenue » sans nom de contrainte rend un échec de migration indiagnosticable.

Sur cette page