Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Persistance & stores — déclarer son infrastructure, pas onze backends

stablemis à jour 2026-09-01

Où Nodefony range ses données durables — utilisateurs, jetons, passkeys, audit, webhooks, idempotence, sessions — sans configurer huit briques une par une. Vous déclarez votre infrastructure (une ou deux URL), le framework dérive où va chaque brique.

📍 Documentation › Guides › Persistance & stores

Le modèle — vous déclarez, le framework dérive#

Vous déclarez votre infrastructure, le framework choisit les stores. Une NF_DATABASE_URL suffit à câbler tout le durable ; ajoutez une NF_REDIS_URL et le partagé entre process suit. Aucun réglage brique par brique tant que les défauts conviennent.

Déclarer l’infrastructure#

Trois familles, déclarées par URL — le schéma décide du backend :

Variable Infrastructure Exemples de valeur
NF_DATABASE_URL (alias DATABASE_URL) durable (base) sqlite:./var/app.db · postgres://… · mysql://… · mongodb://…
NF_REDIS_URL (alias REDIS_URL) cache (éphémère partagé) redis://localhost:6379 — sa présence charge @nodefony/redis
NF_LOKI_URL / NF_OPENSEARCH_URL journaux (relecture) http://loki:3100 · https://opensearch:9200

Les alias de plateforme (DATABASE_URL, REDIS_URL) sont acceptés tels quels — ce sont les noms qu’un hébergeur pose lui-même. La lecture est faite une seule fois, par resolveInfra() (infra.ts#resolveInfra).

Le schéma de NF_DATABASE_URL déduit le dialecte : sqlite: → SQLite, postgres:// → PostgreSQL, mysql:// → MySQL, mongodb:// → MongoDB. Un schéma inconnu fait échouer le boot (infra.ts#parseDatabaseUrl) : jamais de choix silencieux.

Les trois profils#

Profil Infrastructure déclarée Ce que ça donne
solo aucune URL SQLite local via drizzle, s’il est chargé ; sinon memory (volatil)
serveur NF_DATABASE_URL tout le durable sur la base déclarée
cluster NF_DATABASE_URL + NF_REDIS_URL durable sur la base, éphémère et sessions sur Redis (partagé entre pods)

store: "auto" — comment le framework choisit#

Chaque brique a store: "auto" par défaut. La résolution est portée par une fonction unique, resolveAutoStore() (infra.ts#resolveAutoStore), et suit la nature de la donnée :

Sans aucune infrastructure réseau déclarée, auto ne se rabat pas tout de suite sur le volatil : il prend le premier backend local persistant réellement chargé — drizzle (SQLite), puis mongoose — et seulement à défaut memory. C’est ce qui fait qu’une application neuve persiste ses données sans une ligne de configuration.

À chaque étape, le choix est borné aux backends réellement enregistrés pour cette brique, et la raison est écrite dans les journaux par le consommateur — jamais de résolution opaque :

session.store "auto" → "drizzle" (aucune infra déclarée — backend local persistant "drizzle" (mono-nœud))

Un interrupteur global existe : NF_STORE force toute brique auto sur un backend donné, quand il est enregistré pour elle (infra.ts#resolveAutoStore). Il sert aux bancs de charge — pas à la production.

Matrice brique × backend#

✅ = sélectionnable par son nom · — = pas d’implémentation enregistrée. Chaque case ci-dessous correspond à un register…Store("<nom>", …) présent dans le code.

Brique memory drizzle mongoose redis
Session ✅ ✅ ✅ ✅
Utilisateurs ✅ ✅ ✅ —
Jetons (rafraîchissement) ✅ ✅ ✅ ✅
Passkeys (WebAuthn) ✅ ✅ ✅ ✅
TOTP (double facteur) ✅ ✅ ✅ —
Audit ✅ ✅ ✅ —
Webhooks ✅ ✅ ✅ —
Idempotence ✅ ✅ ✅ ✅

Les deux backends DURABLES portent toutes les briques durables. C’est la propriété à retenir avant de choisir : une application peut tourner entièrement sur MongoDB, ou entièrement sur SQL, sans qu’aucune brique ne retombe ailleurs. Un backend durable est un chemin complet ou n’en est pas un.

Une application naît sur MongoDB par le générateur : nodefony create app --database mongodb. Elle monte @nodefony/mongoose en tête du manifeste, SANS @nodefony/drizzle, déclare son entité User sur Mongoose, et son compose.yaml lève un MongoDB en jeu de réplicas — les transactions l’exigent. Pas de migrations : une collection naît à la première écriture. En contrepartie, MongoDB ne tient aucune clé étrangère : create entity y écrit des entités DOCUMENT (schéma Mongoose, même grammaire de champs qu’en SQL), dont les relations sont des ObjectId chargés par ?include=. C’est l’ORM qui y tient la politique d’effacement du SQL : supprimer un parent encore désigné par une référence obligatoire est refusé (409), une référence facultative est remise à null.

Redis n’est pas un trou, c’est un domaine. Il sert les briques à forte rotation (session, jetons, passkeys, idempotence) ; les durables (audit, utilisateurs, TOTP, webhooks) ne sont pas de sa vocation — volume et motif d’accès, pas « c’est un cache ». Les lire comme des absences ferait chercher un manque là où il y a une décision.

Quand auto tombe sur une brique que l’infrastructure déclarée ne porte pas, le repli est annoncé dans la raison écrite au journal, jamais silencieux.

🔴 L’ORDRE des modules décide de tout — un ORM se charge AVANT @nodefony/security#

C’est la règle qu’on ne découvre qu’en démarrant vraiment, et elle coûte cher parce que l’application démarre quand même.

@nodefony/security fabrique ses magasins à son propre démarrage. Un ORM déclaré après lui dans modules n’est pas encore enregistré à ce moment-là : chaque brique durable échoue en fail-soft, et le journal le dit une fois, en WARNING :

tokenStore "mongoose" : ORM "nodefony" introuvable —
charger @nodefony/mongoose AVANT @nodefony/security dans le manifeste "modules".

Ce qui se passe ensuite est le vrai piège : le serveur démarre, écoute, répond 200 — et il a perdu ses jetons, ses passkeys, son journal d’audit et son second facteur, retombés en mémoire. Le superviseur de développement l’annonce (⚠ boot DÉGRADÉ), mais rien n’arrête le démarrage : c’est la doctrine de résilience, et elle est juste — un ORM injoignable ne doit pas tuer un pod.

Donc un ORM se déclare en TÊTE du manifeste, avant le socle. C’est la raison — pas une convention de style — pour laquelle @nodefony/drizzle y est premier :

modules: [
  "@nodefony/drizzle",                      // ← ORM SQL, en tête
  use("@nodefony/mongoose", undefined, {    // ← ORM NoSQL, en tête aussi
    when: () => ctx.infra.database?.family === "mongo",
  }),
  use("@nodefony/http", …, { policy: "mandatory" }),
  use("@nodefony/framework", …, { policy: "mandatory" }),
  use("@nodefony/security", …, { policy: "mandatory" }),   // ← consomme les ORM ci-dessus
  …
]

Charger un module sur la DÉCLARATION de l’infra, pas sur une variable dédiée. Le when ci-dessus suit le même principe que @nodefony/redis (when: () => !!ctx.infra.cache) : un seul signal — l’URL que vous avez écrite —, et jamais de connexion implicite vers un localhost que personne n’a demandé.

Comment constater que c’est bien parti : le journal de démarrage nomme chaque résolution. Les voir toutes sur le même backend, sans WARNING entre elles, est la seule preuve qui vaut.

session.store    "auto" → "mongoose" (infra database (mongodb))
idempotency.store "auto" → "mongoose" (infra database (mongodb))
tokenStore       "auto" → "mongoose" (infra database (mongodb))
audit.store      "auto" → "mongoose" (infra database (mongodb))
…

Audit ≠ journaux#

Deux chemins distincts, à ne pas confondre :

Un événement d’audit ne doit jamais finir uniquement dans les journaux, et une trace de mise au point n’a rien à faire dans le store d’audit.

Doctrine d’échec — jamais de dégradation silencieuse#

Nodefony échoue bruyamment sur la dégradation, et doucement sur la disponibilité :

Le principe : toute dégradation est visible, jamais subie en silence.

Exploiter — ce qu’on sauvegarde, et ce qu’on peut perdre#

Le framework ne sauvegarde rien lui-même : il déclare où chaque chose vit, et c’est cette carte qui dit quoi protéger.

Ce qui vit là Perdre ce backend, ça veut dire À sauvegarder
La base (NF_DATABASE_URL) — entités, jetons, passkeys, audit, webhooks, idempotence tout ce qui n’est pas rejouable oui
Redis (NF_REDIS_URL) — sessions, cache, bus temps réel les utilisateurs sont déconnectés, le cache se reconstruit, le bus reprend non

Deux conséquences pratiques :

La sauvegarde elle-même est celle de votre serveur — pg_dump, mysqldump, mongodump — et n’a rien de spécifique au framework. Ce qui est spécifique, c’est de savoir ce qui compte, et c’est la table ci-dessus.

Forcer un store (exception d’expert)#

L’auto couvre l’immense majorité des cas. Pour épingler une brique à un backend précis, donnez un nom explicite dans la configuration du module — il gagne sur l’auto, et sa provenance reste visible :

// nodefony/config/security.ts — deux clés de plus dans `securityConfig`
import type { ISecurityConfigInput } from "@nodefony/security";

export const securityConfig = () =>
  ({
    tokenStore: { store: "redis" }, // jetons sur Redis même si la base est PostgreSQL
    audit: { store: "drizzle" }, // audit sur la base SQL
  }) satisfies ISecurityConfigInput;

📖 Lexique#

Terme Ce que c’est
Infrastructure Ce que vous déclarez : une base, un cache, un collecteur de journaux — par URL. Trois familles, jamais plus.
Store Où une brique donnée range ses données. Se choisit par un nom (drizzle, redis…) ou se laisse à auto.
auto La sentinelle par défaut : ne nomme pas un backend, laisse resolveAutoStore() le dériver de l’infrastructure et de ce qui est chargé.
Durable / éphémère La nature de la donnée. Le durable survit au redémarrage et va en base ; l’éphémère peut vivre en cache.
Repli (fallback) Le backend pris quand le préféré n’est pas enregistré pour cette brique. Toujours annoncé dans la raison écrite au journal.
Provenance D’où vient la valeur retenue : infra (dérivée) ou explicit (nommée dans la configuration). Elle reste lisible après le boot.

⚠️ Pièges#

🧪 Tests & couverture#

Les chiffres exacts vivent dans la carte de l’aperçu, régénérée depuis vitest — jamais figés ici.

Type Où Ce qui est prouvé
Unitaires (cœur) nodefony tests/infra.test.ts lecture des URL et de leurs alias, schéma inconnu fatal, résolution auto par nature de donnée, priorité de NF_STORE
Unitaires (registres) @nodefony/security unit/auditStoreRegistry.test.ts, unit/tokenStore.test.ts · @nodefony/framework unit/idempotencyStoreRegistry.test.ts, unit/resolverIdempotency.test.ts ce que chaque registre accepte, et ce qu’il refuse
Intégration @nodefony/drizzle auto-register.test.ts l’inscription automatique des stores au chargement du module
E2E (base réelle) @nodefony/drizzle auto-register-postgres.test.ts, auto-register-mysql.test.ts la même inscription sur des dialectes réels

🛑 Prudence

Les suites E2E se skippent sans leurs variables d’infrastructure, et un skip compte comme vert. Source unique des variables : scripts/test/vitest/gates.ts à la racine.

🔗 Pour aller plus loin#