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
| Variable | Rôle |
|---|---|
LRVO_DATABASE_URL | URL de connexion PostgreSQL. |
LRVO_DATABASE_URL_DEV | URL utilisée à la place quand NODE_ENV=development. |
LRVO_DB_SSL_DISABLED | true désactive TLS entièrement. |
LRVO_DB_SSL_NO_VERIFY | true garde TLS mais désactive la vérification du certificat. |
LRVO_DB_SSL_CA | Autorité 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.
| Ordre | Condition | Comportement |
|---|---|---|
| 1 | LRVO_DB_SSL_DISABLED=true | Pas de TLS. Réservé aux liens locaux de confiance. |
| 2 | LRVO_DB_SSL_NO_VERIFY=true | TLS actif, certificat non vérifié. Chiffre le trafic et tolère un certificat auto-signé sur un réseau privé. |
| 3 | LRVO_DB_SSL_CA défini | TLS strict, épinglé sur l'autorité fournie. |
| 4 | sslmode porté par l'URL de connexion | Traduit, voir ci-dessous. |
| 5 | production, sans surcharge | TLS strict contre le magasin d'autorités du système. |
| 6 | Autres environnements, sans surcharge | TLS 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.
sslmode | Politique appliquée |
|---|---|
disable | Pas de TLS. |
allow, prefer, require | TLS actif, certificat non vérifié. |
verify-ca, verify-full | TLS strict. |
| Absent ou inconnu | Politique 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.rowCountqueryOne(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'écrirefindById(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,
});| Option | Type | Contrainte |
|---|---|---|
orderBy | string | "colonne" ou "colonne ASC|DESC" |
limit | number | Entier entre 1 et 10 000 |
offset | number | Entier ≥ 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;
}| Code | Signification |
|---|---|
23505 | Violation de contrainte UNIQUE |
23503 | Violation de contrainte FOREIGN KEY |
23502 | Violation 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èvesafeOrderBy(orderBy)
Valide "colonne" ou "colonne ASC|DESC".
safeOrderBy('created_at DESC') // '"created_at" DESC'
safeOrderBy('1=1; DROP TABLE') // lèvefilterAllowedColumns(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.