@nodefony/drizzle 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +162 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +105 -0
  6. package/dist/nodefony/command/migrateShared.js +247 -0
  7. package/dist/nodefony/command/orm-generate.js +356 -0
  8. package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
  9. package/dist/nodefony/command/orm-migrate-repair.js +114 -0
  10. package/dist/nodefony/command/orm-migrate-status.js +67 -0
  11. package/dist/nodefony/command/orm-migrate.js +141 -0
  12. package/dist/nodefony/command/orm-reset.js +166 -0
  13. package/dist/nodefony/config/config.js +107 -0
  14. package/dist/nodefony/config/defineModuleConfig.js +63 -0
  15. package/dist/nodefony/entity/auditEventEntity.js +93 -0
  16. package/dist/nodefony/entity/colKit.js +260 -0
  17. package/dist/nodefony/entity/idempotencyEntity.js +74 -0
  18. package/dist/nodefony/entity/sessionEntity.js +75 -0
  19. package/dist/nodefony/entity/tokenEntity.js +198 -0
  20. package/dist/nodefony/entity/totpSecretEntity.js +98 -0
  21. package/dist/nodefony/entity/userTable.js +141 -0
  22. package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
  23. package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
  24. package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/migrations-schema/mysql.js +48 -0
  27. package/dist/nodefony/migrations-schema/postgres.js +48 -0
  28. package/dist/nodefony/migrations-schema/sqlite.js +48 -0
  29. package/dist/nodefony/registerStores.js +218 -0
  30. package/dist/nodefony/service/DrizzleService.js +282 -0
  31. package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
  32. package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
  33. package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
  34. package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
  35. package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
  36. package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
  37. package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
  38. package/dist/nodefony/src/SessionStorage.js +259 -0
  39. package/dist/nodefony/src/connectorTarget.js +59 -0
  40. package/dist/nodefony/src/likeSql.js +50 -0
  41. package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
  42. package/dist/nodefony/src/migrator/adopt.js +553 -0
  43. package/dist/nodefony/src/migrator/appSchema.js +414 -0
  44. package/dist/nodefony/src/migrator/catalog.js +76 -0
  45. package/dist/nodefony/src/migrator/destructive.js +213 -0
  46. package/dist/nodefony/src/migrator/divergence.js +84 -0
  47. package/dist/nodefony/src/migrator/drivers/index.js +39 -0
  48. package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
  49. package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
  50. package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
  51. package/dist/nodefony/src/migrator/explain.js +565 -0
  52. package/dist/nodefony/src/migrator/hash.js +47 -0
  53. package/dist/nodefony/src/migrator/history.js +219 -0
  54. package/dist/nodefony/src/migrator/index.js +16 -0
  55. package/dist/nodefony/src/migrator/kit.js +296 -0
  56. package/dist/nodefony/src/migrator/name.js +68 -0
  57. package/dist/nodefony/src/migrator/paths.js +88 -0
  58. package/dist/nodefony/src/migrator/refusals.js +143 -0
  59. package/dist/nodefony/src/migrator/resolve.js +281 -0
  60. package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
  61. package/dist/nodefony/src/migrator/sources.js +419 -0
  62. package/dist/nodefony/src/migrator/status.js +231 -0
  63. package/dist/nodefony/src/migrator/types.js +91 -0
  64. package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
  65. package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
  66. package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
  67. package/dist/nodefony/src/orm-core/index.js +4 -0
  68. package/dist/nodefony/src/queryKit.js +318 -0
  69. package/dist/nodefony/src/safeTarget.js +55 -0
  70. package/dist/types/index.d.ts +76 -0
  71. package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
  72. package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
  73. package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
  74. package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
  75. package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
  76. package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
  77. package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
  78. package/dist/types/nodefony/config/config.d.ts +110 -0
  79. package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
  80. package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
  81. package/dist/types/nodefony/entity/colKit.d.ts +130 -0
  82. package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
  83. package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
  84. package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
  85. package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
  86. package/dist/types/nodefony/entity/userTable.d.ts +65 -0
  87. package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
  88. package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
  89. package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  91. package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
  92. package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
  93. package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
  94. package/dist/types/nodefony/registerStores.d.ts +52 -0
  95. package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
  96. package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
  97. package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
  98. package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
  99. package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
  100. package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
  101. package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
  102. package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
  103. package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
  104. package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
  105. package/dist/types/nodefony/src/likeSql.d.ts +29 -0
  106. package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
  107. package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
  108. package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
  109. package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
  110. package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
  111. package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
  112. package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
  113. package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
  114. package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
  115. package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
  116. package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
  117. package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
  118. package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
  119. package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
  120. package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
  121. package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
  122. package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
  123. package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
  124. package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
  125. package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
  126. package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
  127. package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
  128. package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
  129. package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
  130. package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
  131. package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
  132. package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
  133. package/dist/types/nodefony/src/queryKit.d.ts +136 -0
  134. package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
  135. package/docs/index.md +954 -0
  136. package/docs/migrations.md +691 -0
  137. package/migrations/mysql/0000_framework_init.sql +137 -0
  138. package/migrations/mysql/meta/_journal.json +13 -0
  139. package/migrations/postgres/0000_framework_init.sql +128 -0
  140. package/migrations/postgres/meta/_journal.json +13 -0
  141. package/migrations/sqlite/0000_framework_init.sql +127 -0
  142. package/migrations/sqlite/meta/_journal.json +13 -0
  143. package/package.json +126 -0
package/docs/index.md ADDED
@@ -0,0 +1,954 @@
1
+ ---
2
+ title: "@nodefony/drizzle — l'ORM SQL par défaut"
3
+ navTitle: "@nodefony/drizzle"
4
+ lang: fr
5
+ module: "@nodefony/drizzle"
6
+ topic: drizzle
7
+ section: "Persistance"
8
+ audience: [developer]
9
+ tags:
10
+ [
11
+ orm,
12
+ drizzle,
13
+ sql,
14
+ sqlite,
15
+ postgresql,
16
+ mysql,
17
+ mariadb,
18
+ repository,
19
+ entite,
20
+ transaction,
21
+ store,
22
+ migration,
23
+ ]
24
+ version: "doc"
25
+ status: stable
26
+ updated: 2026-07-19
27
+ source: "src/packages/@nodefony/drizzle/docs/index.md"
28
+ coverageModule: drizzle
29
+ ---
30
+
31
+ # @nodefony/drizzle — l'ORM SQL par défaut
32
+
33
+ > Le module que rencontre **toute application Nodefony** : il ouvre les connexions SQL, sert les
34
+ > repositories de tes entités, et fournit au framework les **huit briques durables** (session,
35
+ > utilisateurs, jetons, passkeys, 2FA, audit, webhooks, idempotence) sans que tu écrives une ligne de
36
+ > câblage. Un seul code applicatif, **trois dialectes** — SQLite en développement, PostgreSQL ou
37
+ > MySQL/MariaDB en production. C'est le **driver SQL** des contrats de
38
+ > [`@nodefony/orm-core`](../../orm-core/docs/index.md).
39
+
40
+ 📍 [Documentation](../../../../../docs/index.md) › **Drizzle — ORM SQL**
41
+
42
+ ## 🧠 Le modèle mental — trois étages, une seule vérité
43
+
44
+ Drizzle occupe l'étage du **driver**. Au-dessus, `@nodefony/orm-core` définit les contrats portables
45
+ (`IOrm`, `IRepository`, `ITransaction`) que ton code métier consomme ; en dessous, un driver natif par
46
+ dialecte. Changer de base = changer une URL, pas ton code.
47
+
48
+ ```mermaid
49
+ flowchart TD
50
+ APP["Ton code<br/>controllers · services"] --> CORE["@nodefony/orm-core<br/>IRepository · Criteria · ITransaction"]
51
+ CORE --> DRZ["@nodefony/drizzle<br/>DrizzleOrm · DrizzleRepository"]
52
+ DRZ -->|dialect: sqlite| SQLITE["better-sqlite3<br/>fichier local"]
53
+ DRZ -->|dialect: postgres| PG["pg<br/>pool réseau"]
54
+ DRZ -->|dialect: mysql| MY["mysql2<br/>MySQL / MariaDB"]
55
+ DRZ -.->|"stores auto-enregistrés"| FW["http · security · framework<br/>session, users, jetons, audit…"]
56
+ ```
57
+
58
+ Deux flux à distinguer, et c'est toute la lecture de cette page :
59
+
60
+ 1. **Le flux applicatif** (trait plein) — tes entités, tes repositories, tes transactions.
61
+ 2. **Le flux framework** (pointillés) — les briques durables que le module **déclare tout seul** au
62
+ démarrage, décrites dans [les huit stores](#les-huit-stores-du-framework--la-persistance-clé-en-main).
63
+
64
+ ## 📖 Lexique
65
+
66
+ | Terme | Sens |
67
+ | --------------- | -------------------------------------------------------------------------------------------------- |
68
+ | ORM | _Object-Relational Mapping_ : traduit des lignes de table en objets, et l'inverse. |
69
+ | Dialecte | La variante de SQL d'un moteur (`sqlite`, `postgres`, `mysql`) — types et syntaxe diffèrent. |
70
+ | Connecteur | Une connexion nommée (`default`, `analytics`…). Clé de lookup dans le registre des ORM. |
71
+ | Repository | L'objet qui lit/écrit une entité (`find`, `create`, `updateOne`…), sans SQL visible. |
72
+ | Entité | Le nom logique d'une table + son schéma, déclaré à l'application (`defineEntity`). |
73
+ | Schema-as-code | Le schéma **est** du code TypeScript typé, pas un fichier de description séparé. |
74
+ | DDL | _Data Definition Language_ : le SQL qui crée/modifie les tables (`CREATE TABLE`, `ALTER`). |
75
+ | Store | **Où vivent des données** (session, jetons, audit…). À ne pas confondre avec `driver` = transport. |
76
+ | Dialecte porté | Un dialecte sur lequel une entité framework sait se construire — sinon le store est indisponible. |
77
+ | Criteria | Le filtre portable d'orm-core (`{ age: { $gte: 18 } }`), traduit en SQL par le driver. |
78
+ | Trappe SQL brut | L'accès direct au moteur pour ce que l'abstraction ne couvre pas (CTE, fenêtres, `JOIN` libres). |
79
+ | E2E | _End-to-end_ : un test qui parle à une **vraie** base, pas à un double. |
80
+ | Idempotence | Rejouer une mutation sans la ré-exécuter (double-clic, reconnexion) — pas de double effet. |
81
+ | PAT | _Personal Access Token_ : une clé d'API opaque, révocable côté serveur. |
82
+ | GC | _Garbage collection_ : la purge périodique des lignes expirées (pas de TTL natif en SQL). |
83
+
84
+ ## Qu'est-ce que c'est, et pourquoi c'est le défaut
85
+
86
+ Une application a besoin d'une base **dès la première minute** : une session à retenir, un compte à
87
+ créer. Le réflexe habituel — « on branchera une vraie base plus tard » — coûte cher : le code s'écrit
88
+ contre un stockage en mémoire, puis tout est à reprendre quand la persistance arrive.
89
+
90
+ Nodefony prend le problème dans l'autre sens. **Charger `@nodefony/drizzle` suffit** : une base SQLite
91
+ locale apparaît sous `var/databases/`, et toutes les briques durables du framework s'y installent
92
+ d'elles-mêmes. Le jour où tu déclares `NF_DATABASE_URL=postgres://…`, **rien ne change dans ton code**
93
+ — le même module ouvre un pool PostgreSQL et recrée les mêmes tables dans les types du dialecte.
94
+
95
+ Le nom du paquet dit le moteur choisi : [Drizzle ORM](https://orm.drizzle.team). Trois raisons l'ont
96
+ fait retenir comme référence SQL du framework :
97
+
98
+ - **Type-safe-first** — un schéma est du TypeScript ; une colonne renommée casse à la compilation, pas
99
+ en production ;
100
+ - **léger** — pas de couche de métadonnées à l'exécution, le SQL émis reste lisible ;
101
+ - **jamais bloquant** — la trappe `sql` du moteur reste accessible pour tout ce que l'abstraction ne
102
+ couvre pas.
103
+
104
+ > [!NOTE]
105
+ > **Le défaut n'est pas une obligation.** `@nodefony/mongoose` sert le même contrat pour MongoDB, et
106
+ > ton code métier ne fait la différence nulle part : c'est précisément la valeur d'orm-core. Voir
107
+ > [le guide de persistance](../../../../../docs/guides/persistence.md) pour arbitrer.
108
+
109
+ ### La vision Nodefony — ce que le module fait différemment
110
+
111
+ Un adapter ORM classique s'arrête à « je traduis un repository en SQL ». Celui-ci va deux pas plus
112
+ loin, et ces deux pas expliquent la taille de cette page.
113
+
114
+ **1. Il porte le framework, pas seulement ton métier.** Les huit briques durables (session, users,
115
+ jetons, passkeys, TOTP, audit, webhooks, idempotence) ont leurs tables **dans le module**, déclarées
116
+ au démarrage par `registerDrizzleFrameworkStores()` (`registerStores.ts:149`). Aucune application
117
+ n'écrit de `registerXStore(...)`.
118
+
119
+ **2. Il reconstruit la portabilité que Drizzle n'offre pas.** Drizzle est schema-as-code
120
+ **dialect-spécifique** : `sqliteTable` n'est pas `pgTable`, les types de colonnes diffèrent. On ne peut
121
+ donc pas « juste changer le dialecte ». Le module rétablit cette abstraction au niveau du framework —
122
+ une spécification logique par entité, traduite dans le bon dialecte par le `colKit`
123
+ (`buildFrameworkTable()`, `colKit.ts:543`). Le coût est assumé (une fabrique par entité framework),
124
+ la contrepartie est la type-safety intégrale.
125
+
126
+ > [!IMPORTANT]
127
+ > Le `colKit` est **interne** : il n'est pas exporté. Tes entités d'application écrivent du **Drizzle
128
+ > natif** du dialecte que tu vises — c'est ce que produit `nodefony create entity`. Exposer le kit
129
+ > serait promettre une API à maintenir pour un besoin que le scaffold couvre déjà.
130
+
131
+ ## 🧭 Par où commencer
132
+
133
+ Le module n'a qu'une page — celle-ci — mais plusieurs façons de la lire. Choisis ton entrée ; chaque
134
+ parcours suit un ordre qui a une raison.
135
+
136
+ **Je démarre une application** — le chemin le plus court vers des données qui survivent au redémarrage.
137
+
138
+ 1. [Démarrage rapide](#-démarrage-rapide) — la config, une entité, une requête. Copie-colle, ça marche.
139
+ 2. [Configuration](#-configuration) — ce que tu peux régler, et ce qui se résout tout seul.
140
+ 3. [Les huit stores](#les-huit-stores-du-framework--la-persistance-clé-en-main) — pourquoi tu n'as
141
+ rien câblé et que la session persiste quand même.
142
+ 4. [Le tutoriel d'entité d'orm-core](../../orm-core/docs/tutorial-entity.md) — pour aller au-delà d'une table.
143
+
144
+ **Je passe en production** — l'ordre compte : le dialecte d'abord, le schéma ensuite.
145
+
146
+ 1. [Dialectes](#dialectes--une-base-par-déploiement-un-seul-code) — ce qui change vraiment entre
147
+ SQLite, PostgreSQL et MySQL.
148
+ 2. [Migrations et création des tables](#migrations--deux-chemins-un-seul-schéma) —
149
+ **à lire avant le premier déploiement**, le DDL dérivé ne fait pas d'`ALTER`.
150
+ 3. [Pièges](#-pièges) — les symptômes qu'on rencontre en changeant de base.
151
+ 4. [Tests](#-tests--couverture) — comment prouver ton dialecte, au lieu de le supposer.
152
+
153
+ **J'écris des requêtes** — de la plus portable à la plus spécifique.
154
+
155
+ 1. [Repository](#-api-publique--du-repository-au-sql-brut) — le CRUD portable et les opérateurs riches.
156
+ 2. [Transactions](#transactions--une-connexion-dédiée-jamais-le-pool) — et pourquoi elles s'écrivent
157
+ ainsi et pas autrement.
158
+ 3. [Trappe SQL brut](#trappe-sql-brut--quand-labstraction-ne-suffit-plus) — CTE, fenêtres, jointures libres.
159
+ 4. [Les contrats d'orm-core](../../orm-core/docs/index.md) — la référence de `Criteria` et des opérateurs.
160
+
161
+ **Je supervise ce qui tourne.**
162
+
163
+ 1. [Observabilité Studio](#-observabilité--studio) — les écrans, le graphe d'entités, le data plane.
164
+ 2. [Performance & mémoire](#-performance--mémoire) — le plafond connu de SQLite, et pourquoi.
165
+
166
+ ## 🗂️ Ce que le module apporte
167
+
168
+ Le tableau pour situer en cinq secondes ; les cards en dessous pour savoir où lire.
169
+
170
+ | Brique | Ce qu'elle résout | Tu en as besoin quand… |
171
+ | ------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------- |
172
+ | [Connecteurs](#-configuration) | ouvrir une ou plusieurs bases, par dialecte | toujours — c'est le point d'entrée |
173
+ | [Dialectes](#dialectes--une-base-par-déploiement-un-seul-code) | SQLite, PostgreSQL, MySQL/MariaDB au même contrat | tu quittes le poste de développement |
174
+ | [Entités](#-démarrage-rapide) | déclarer une table et son nom logique | tu as des données à toi |
175
+ | [Repository](#-api-publique--du-repository-au-sql-brut) | CRUD portable, opérateurs riches, pagination | à chaque requête |
176
+ | [Transactions](#transactions--une-connexion-dédiée-jamais-le-pool) | tout-ou-rien sur plusieurs écritures | une opération ne doit jamais rester à moitié |
177
+ | [Trappe SQL](#trappe-sql-brut--quand-labstraction-ne-suffit-plus) | CTE, fenêtres, jointures arbitraires | l'abstraction ne couvre pas ton besoin |
178
+ | [Les 8 stores](#les-huit-stores-du-framework--la-persistance-clé-en-main) | session, users, jetons, audit… durables | jamais : c'est déjà branché |
179
+ | [Migrations](#migrations--deux-chemins-un-seul-schéma) | créer et faire évoluer les tables | avant le premier déploiement |
180
+ | [Studio](#-observabilité--studio) | voir les connexions, les entités, le graphe | tu veux comprendre ce qui tourne |
181
+
182
+ ```nodefony-cards
183
+ [
184
+ { "icon": "⚙️", "title": "connecteurs", "href": "#-configuration",
185
+ "desc": "Un connecteur = une base. `default` est celui que tout le framework utilise ; tu peux en déclarer d'autres — `analytics`, une fixture — sur leur propre fichier ou serveur. Le dialecte et la cible se déduisent de l'infra déclarée (`NF_DATABASE_URL`), sinon de ta config.",
186
+ "meta": "commence ici — tout le reste suppose un connecteur ouvert" },
187
+ { "icon": "🧱", "title": "entités", "href": "#-démarrage-rapide",
188
+ "desc": "Une table Drizzle ordinaire plus un descripteur `defineEntity` qui lui donne un nom logique : aucune couche à contourner, tous les types du moteur restent accessibles. `nodefony create entity` écrit le reste — ligne typée, schémas de validation, service CRUD, controller REST/WebSocket et tests.",
189
+ "meta": "tu as des données à toi" },
190
+ { "icon": "🧰", "title": "repository", "href": "#-api-publique--du-repository-au-sql-brut",
191
+ "desc": "`find`, `create`, `updateOne`, `upsert`, `increment`, `count`, la pagination — et des filtres portables (`views: { $gte: 10 }`) traduits en SQL par le driver. Le même code tourne sur les trois dialectes, prouvé par un banc de parité qui rejoue la même suite sur chacun.",
192
+ "meta": "à chaque requête" },
193
+ { "icon": "🔒", "title": "transactions", "href": "#transactions--une-connexion-dédiée-jamais-le-pool",
194
+ "desc": "Ce qui réussit ensemble est commité ensemble, une exception annule tout. La section explique pourquoi une transaction emprunte une connexion dédiée au pool, et pourquoi seul `repo.withTransaction(tx)` y entre réellement — l'erreur classique coûte l'atomicité sans prévenir.",
195
+ "meta": "une opération ne doit jamais rester à moitié" },
196
+ { "icon": "📦", "title": "stores", "href": "#les-huit-stores-du-framework--la-persistance-clé-en-main",
197
+ "desc": "Session, utilisateurs, jetons, passkeys, TOTP, audit, webhooks, idempotence : leurs tables vivent dans le module, leurs fabriques s'inscrivent seules dans les registres de `http`, `security` et `framework`. Rien à écrire.",
198
+ "meta": "à lire pour savoir où sont tes données, ou pour couper la déclaration" },
199
+ { "icon": "🗃️", "title": "dialectes", "href": "#dialectes--une-base-par-déploiement-un-seul-code",
200
+ "desc": "Ce qui reste identique (les noms de colonnes, le contrat du repository) et ce qui diverge, avec la raison : types epoch/date/JSON, absence de `RETURNING` en MySQL, `OFFSET` sans `LIMIT`.",
201
+ "meta": "à lire avant de changer de base, pas après le premier incident" },
202
+ { "icon": "🚧", "title": "migrations", "href": "#migrations--deux-chemins-un-seul-schéma",
203
+ "desc": "En développement, les tables sont créées au démarrage par un DDL dérivé de tes schémas. Ce DDL ne fait aucun `ALTER`, n'émet ni `DEFAULT` SQL ni index : la section dit ce que ça implique en production, et ce que le framework ne fournit pas encore.",
204
+ "meta": "le point à ne pas rater avant le premier déploiement" }
205
+ ]
206
+ ```
207
+
208
+ ## 🚀 Démarrage rapide
209
+
210
+ Vu depuis une application créée par `nodefony create app`. Trois fichiers, et des données qui
211
+ survivent au redémarrage.
212
+
213
+ ### 1. Déclarer le module
214
+
215
+ Le manifeste `modules` de `nodefony.config.ts` est **ordonné** : Drizzle vient avant les modules qui
216
+ consomment ses stores, parce qu'il déclare leur schéma au moment où le kernel enregistre les modules.
217
+
218
+ ```ts
219
+ // nodefony.config.ts — l'orchestrateur de l'application
220
+ export default defineConfig(() => ({
221
+ modules: [
222
+ // Drizzle EN PREMIER : il déclare le schéma des briques durables (session,
223
+ // users, jetons…) AVANT que http/security/framework ne résolvent leurs stores.
224
+ use("@nodefony/drizzle", {
225
+ connectors: {
226
+ // `filename` est facultatif : omis, il est résolu au boot vers
227
+ // <app>/var/databases/nodefony-drizzle.db — un seul dossier à sauvegarder.
228
+ default: { dialect: "sqlite", filename: "var/databases/app.db" },
229
+ },
230
+ }),
231
+ "@nodefony/http",
232
+ "@nodefony/framework",
233
+ ],
234
+ }));
235
+ ```
236
+
237
+ ### 2. Déclarer une entité
238
+
239
+ La table est du **Drizzle natif** — pas une couche Nodefony. `defineEntity` ne fait que lui attacher un
240
+ nom logique ; c'est le décorateur `@entities([...])` posé sur ton module qui l'inscrit au démarrage,
241
+ avant toute connexion.
242
+
243
+ ```ts
244
+ // nodefony/entity/Post.ts + index.ts du module, réunis ici pour l'exemple
245
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
246
+ import { defineEntity, entities } from "@nodefony/orm-core";
247
+ import { Kernel, Module } from "nodefony";
248
+
249
+ export const postTable = sqliteTable("Post", {
250
+ id: text("id")
251
+ .primaryKey()
252
+ .$defaultFn(() => crypto.randomUUID()),
253
+ title: text("title").notNull(),
254
+ // ⚠️ TOUJOURS `$defaultFn` (défaut posé côté JS), JAMAIS `.default()` (SQL) :
255
+ // le DDL dérivé n'émet pas de DEFAULT — une colonne NOT NULL casserait l'INSERT.
256
+ views: integer("views")
257
+ .notNull()
258
+ .$defaultFn(() => 0),
259
+ });
260
+
261
+ /** Une ligne de `Post`, telle que la rend le repository. */
262
+ export interface PostRow {
263
+ id: string;
264
+ title: string;
265
+ views: number;
266
+ }
267
+
268
+ export const PostEntity = defineEntity({
269
+ name: "Post", // le nom logique passé à getRepository()
270
+ module: "blog",
271
+ schema: postTable,
272
+ });
273
+
274
+ // Le connecteur n'est PAS figé dans l'entité : c'est une donnée de configuration,
275
+ // résolue au démarrage (défaut `default`).
276
+ @entities([PostEntity])
277
+ class Blog extends Module {
278
+ constructor(kernel: Kernel) {
279
+ super("blog", kernel, import.meta.url, {});
280
+ }
281
+ }
282
+
283
+ export default Blog;
284
+ ```
285
+
286
+ ### 3. Lire et écrire
287
+
288
+ Le repository se récupère par le **nom logique** de l'entité, sur le connecteur voulu. Les filtres sont
289
+ portables : le même code tournera tel quel sur PostgreSQL.
290
+
291
+ ```ts
292
+ // nodefony/service/PostService.ts (extrait)
293
+ import { ormRegistry } from "@nodefony/orm-core";
294
+
295
+ // Dans l'application : `import type { PostRow } from "../entity/Post";`
296
+ interface PostRow {
297
+ id: string;
298
+ title: string;
299
+ views: number;
300
+ }
301
+
302
+ export async function popularPosts(): Promise<PostRow[]> {
303
+ const posts = ormRegistry.get("default").getRepository<PostRow>("Post");
304
+
305
+ await posts.create({ title: "Bonjour" }); // id et views posés par $defaultFn
306
+ await posts.increment({ title: "Bonjour" }, { views: 1 });
307
+
308
+ return posts.find(
309
+ { views: { $gte: 10 } }, // opérateur riche portable
310
+ { order: [["views", "DESC"]], limit: 5 },
311
+ );
312
+ }
313
+ ```
314
+
315
+ ### Ce qu'on observe
316
+
317
+ ```bash
318
+ # Au démarrage : le connecteur s'ouvre et annonce sa cible
319
+ # INFO Drizzle ORM "default" connected (sqlite: var/databases/app.db)
320
+
321
+ # La base est là, la table aussi (créée au connect depuis ton schéma)
322
+ sqlite3 var/databases/app.db '.tables'
323
+ # Post audit_event idempotency_key session User access_token …
324
+
325
+ # Et le data plane d'administration voit le modèle
326
+ curl -s http://localhost:5151/nodefony/orm/api/entities | head -20
327
+ ```
328
+
329
+ > [!TIP]
330
+ > Tu n'as déclaré **aucun** store. Les tables `session`, `User`, `access_token`… apparaissent quand
331
+ > même : c'est l'[auto-enregistrement](#les-huit-stores-du-framework--la-persistance-clé-en-main).
332
+
333
+ ## ⚙️ Configuration
334
+
335
+ ### La convention à deux fichiers — drizzle est la référence du dépôt
336
+
337
+ Tout module Nodefony qui expose une configuration suit **exactement** cette structure, et c'est celle
338
+ de drizzle qui sert de modèle. Deux fichiers, mêmes noms partout, aucune question à se poser :
339
+
340
+ | Fichier | Rôle | Contenu |
341
+ | --------------------------------------- | -------------- | ----------------------------------------------------------------------------- |
342
+ | `nodefony/config/config.ts` | **le QUOI** | schéma Zod commenté = source **unique** des défauts, matérialisés `parse({})` |
343
+ | `nodefony/config/defineModuleConfig.ts` | **le COMMENT** | builder **pur** : parse → surcharge d'environnement → gel |
344
+
345
+ Concrètement : `drizzleConfigSchema` (`config.ts:79`) porte chaque `.default()` et chaque
346
+ `.describe()` — changer un défaut du module, c'est éditer **là et nulle part ailleurs**. Le builder
347
+ `defineDrizzleConfig()` (`defineModuleConfig.ts:58`) ne retape jamais une valeur : il valide, applique
348
+ l'environnement, gèle. Et `drizzleConfigJsonSchema()` (`defineModuleConfig.ts:69`) expose le tout en
349
+ JSON Schema pour l'écran de configuration de Studio.
350
+
351
+ Le schéma reste **pur** : il ne lit ni `process.env` ni le kernel. C'est ce qui rend le module
352
+ importable et testable sans serveur.
353
+
354
+ ### Les options
355
+
356
+ | Option | Type | Défaut | Effet |
357
+ | ------------------------- | ----------------------------------- | ------------------ | ---------------------------------------------------------------------- |
358
+ | `connectors` | `Record<string, Connector>` | `{ default: {…} }` | Les connexions, indexées par nom (= clé du registre des ORM). |
359
+ | `connectors.<n>.dialect` | `"sqlite" \| "postgres" \| "mysql"` | `"sqlite"` | Choisit le driver (`better-sqlite3` / `pg` / `mysql2`). |
360
+ | `connectors.<n>.filename` | `string` | _résolu au boot_ | Fichier SQLite. `:memory:` = base éphémère. Ignoré hors `sqlite`. |
361
+ | `connectors.<n>.url` | `string` | — | Chaîne de connexion `postgres://…` / `mysql://…`. **Porte un secret.** |
362
+ | `frameworkEntities` | `boolean` | `true` | Déclare (ou non) le schéma des huit briques durables sur `default`. |
363
+
364
+ Table dérivée de `drizzleConfigSchema` (`config.ts:79`) et de `SQL_DIALECTS` (`config.ts:37`).
365
+
366
+ **`filename` est volontairement sans défaut.** Le chemin dépend du kernel, qui n'existe pas quand le
367
+ schéma est évalué. Il est résolu **au démarrage** par `DrizzleService.#defaultFilename()`
368
+ (`DrizzleService.ts:95`) vers `<app>/var/databases/nodefony-<connecteur>.db` — sous `var/`, le dossier
369
+ commun des données runtime : « où sont mes données ? » a une réponse unique, un seul chemin à
370
+ sauvegarder et à ignorer dans git.
371
+
372
+ ### Trois façons de désigner sa base
373
+
374
+ L'ordre de précédence est croissant : le défaut, puis ta config, puis l'environnement.
375
+
376
+ **Situation 1 — je développe.** Rien à écrire. Charger le module suffit : dialecte `sqlite`, fichier
377
+ sous `var/databases/`, tables créées au démarrage.
378
+
379
+ **Situation 2 — je déploie, l'infra est déclarée par l'orchestrateur.** C'est le chemin normal en
380
+ conteneur : une seule variable, et le module en déduit **tout**.
381
+
382
+ ```bash
383
+ NF_DATABASE_URL=postgres://app:secret@db:5432/prod # dialecte + cible déduits
384
+ NF_DATABASE_URL=mysql://app:secret@db:3306/prod
385
+ NF_DATABASE_URL=sqlite:/var/lib/app/prod.db
386
+ ```
387
+
388
+ `applyEnvOverrides()` (`defineModuleConfig.ts:20`) lit l'infra, en déduit le dialecte depuis le
389
+ _scheme_, et pose `filename` ou `url` sur le connecteur primaire. Une URL `mongodb://` est **ignorée
390
+ ici** : elle appartient alors à `@nodefony/mongoose`.
391
+
392
+ **Situation 3 — plusieurs bases.** Un connecteur par base. Seul `default` porte le schéma du framework ;
393
+ les autres sont à toi.
394
+
395
+ ```ts ignore
396
+ use("@nodefony/drizzle", {
397
+ connectors: {
398
+ default: { dialect: "postgres", url: process.env.NF_DATABASE_URL },
399
+ analytics: { dialect: "sqlite", filename: "var/databases/analytics.db" },
400
+ },
401
+ });
402
+ ```
403
+
404
+ > [!WARNING]
405
+ > Une URL de connexion **porte un mot de passe**. Le module ne la journalise jamais telle quelle :
406
+ > `redactUrl()` (`DrizzleService.ts:68`) remplace le mot de passe par `***` avant tout log de
407
+ > démarrage, et la sonde d'administration applique la même règle.
408
+
409
+ ### Quand la connexion échoue, le démarrage échoue
410
+
411
+ Un connecteur **déclaré** qui ne se connecte pas lève une `BootConfigurationError`
412
+ (`DrizzleService.ts:244`) — en développement **comme** en production. Ce n'est pas une sévérité
413
+ gratuite : une infrastructure déclarée mais injoignable ne se répare pas en continuant. Un serveur qui
414
+ démarrerait « vivant » avec ses stores morts accepterait des requêtes pour échouer plus tard, la cause
415
+ noyée dans un avertissement. Le message d'erreur nomme le connecteur, le dialecte, la cible rédigée et
416
+ la piste à vérifier.
417
+
418
+ Pour les dialectes réseau, la connexion fait un **ping réel** au démarrage : les pools `pg` et `mysql2`
419
+ sont paresseux, sans ce `SELECT 1` une base morte « se connecterait » et n'échouerait qu'à la première
420
+ requête métier (`#connectPostgres()`, `DrizzleOrm.ts:597` · `#connectMysql()`, `DrizzleOrm.ts:1200`).
421
+
422
+ ## Dialectes — une base par déploiement, un seul code
423
+
424
+ Trois dialectes au même contrat. Le tableau dit ce qu'il faut installer et ce qu'on obtient.
425
+
426
+ | Dialecte | Driver | Statut du paquet | Cible | Configuration |
427
+ | ---------- | ---------------- | -------------------------- | ------------------------------------------ | ------------- |
428
+ | `sqlite` | `better-sqlite3` | **dépendance** (fournie) | développement, tests, production mono-nœud | `filename` |
429
+ | `postgres` | `pg` | dépendance **optionnelle** | production multi-pod (recommandé) | `url` |
430
+ | `mysql` | `mysql2` | dépendance **optionnelle** | production — **MySQL 8.4 et MariaDB 11.4** | `url` |
431
+
432
+ Les drivers réseau sont chargés **paresseusement** au moment de la connexion : une application SQLite
433
+ ne paie ni l'installation ni le chargement de `pg`/`mysql2`.
434
+
435
+ ### Ce qui ne change pas
436
+
437
+ - **Les noms de colonnes**, identiques sur les trois dialectes — c'est ce qui rend les stores et les
438
+ repositories agnostiques ;
439
+ - **le contrat `IRepository`** en entier, prouvé par un banc de parité qui rejoue la **même** suite sur
440
+ les trois moteurs — un écart de comportement y est un bug du framework, par construction ;
441
+ - **ton code applicatif**. C'est tout l'objet de l'exercice.
442
+
443
+ ### Ce qui diverge, et pourquoi
444
+
445
+ Ces divergences sont **encapsulées** dans le module. Elles sont listées ici pour que tu saches quoi
446
+ regarder si un comportement te surprend.
447
+
448
+ <!-- prettier-ignore -->
449
+ | Sujet | SQLite | PostgreSQL | MySQL / MariaDB | Raison |
450
+ | --- | --- | --- | --- | --- |
451
+ | Horodatage epoch (ms) | `integer` | `bigint` | `bigint` | `integer` est 32 bits en PG/MySQL : un epoch ms déborde. |
452
+ | Date JS | `integer` (timestamp ms) | `timestamptz(3)` | `datetime(3)` | `timestamp` MySQL est borné à 2038 et dépend de la timezone. |
453
+ | JSON | `text` (mode json) | `jsonb` | type JSON compatible MariaDB | MariaDB rend une chaîne (LONGTEXT), MySQL un objet. |
454
+ | Booléen | `integer` | `boolean` | `boolean` (alias `tinyint(1)`) | SQLite n'a pas de type booléen. |
455
+ | Texte indexé / PK | `text` | `text` | `varchar(512)` | InnoDB n'indexe pas `TEXT` sans préfixe. |
456
+ | Retour d'une ligne écrite | `RETURNING` | `RETURNING` | **absent** → relecture par PK | MySQL n'a pas de `RETURNING`. |
457
+ | `OFFSET` sans `LIMIT` | fragment `-1` | rien (valide seul) | `LIMIT` sentinelle | Chaque moteur refuse une forme différente. |
458
+
459
+ Toute cette traduction vit à **un seul endroit**, le `colKit` (`buildFrameworkTable()`,
460
+ `colKit.ts:543` ; variante MySQL : `mysqlColumn()`, `colKit.ts:444`). Ajouter un dialecte, c'est
461
+ étendre le kit — jamais retoucher les entités.
462
+
463
+ En MySQL, les verbes « qui rendent la ligne écrite » (`create`, `updateOne`, `upsert`,
464
+ `findOneAndDelete`) se décomposent en sélection de la cible → mutation bornée par la clé primaire **avec
465
+ le critère revérifié dans le `WHERE`** → relecture. Deux à trois allers-retours au lieu d'un : c'est le
466
+ prix du dialecte, payé **uniquement** en MySQL. Une course perdue rend `null`, jamais une mutation hors
467
+ critère (`#mysqlInsertReturning()`, `DrizzleRepository.ts:1138`).
468
+
469
+ Le SQL brut nécessaire aux entités du framework est lui aussi routé par dialecte, dans un seul fichier
470
+ (`queryKit.ts`) : recherche dans une colonne JSON (`findUserIdBySocialProvider()`, `queryKit.ts:76`),
471
+ réservation atomique d'idempotence en MySQL (`reserveIdempotencyKeyMysql()`, `queryKit.ts:152`),
472
+ pagination des utilisateurs (`listUserIdsPage()`, `queryKit.ts:352`). Toutes ces requêtes sont
473
+ **paramétrées** — jamais de concaténation.
474
+
475
+ ## 🏗️ Architecture interne
476
+
477
+ ### Le trajet du démarrage
478
+
479
+ ```mermaid
480
+ sequenceDiagram
481
+ participant K as Kernel
482
+ participant M as Module Drizzle
483
+ participant S as DrizzleService
484
+ participant O as DrizzleOrm
485
+ K->>M: onKernelRegister
486
+ M->>M: defineDrizzleConfig() — valide, applique l'env, gèle
487
+ M->>M: registerDrizzleFrameworkStores(dialecte)
488
+ Note over M: entités des 8 briques + fabriques<br/>dans les registres http/security/framework
489
+ K->>S: onBoot
490
+ S->>O: new DrizzleOrm(nom, {dialect, filename, url})
491
+ O->>O: connexion + CREATE TABLE IF NOT EXISTS (dérivé)
492
+ Note over O: échec ⇒ BootConfigurationError (boot fatal)
493
+ K->>M: onKernelBoot
494
+ M->>M: montage du data plane /nodefony/orm/api/*
495
+ K->>S: onTerminate
496
+ S->>O: disconnect (toutes les connexions)
497
+ ```
498
+
499
+ L'ordre est ce qui rend l'auto-enregistrement possible : **les entités sont déclarées avant la
500
+ connexion**, donc leurs tables sont créées au moment où l'ORM s'ouvre.
501
+
502
+ ### Les pièces
503
+
504
+ | Pièce | Rôle | Ancre |
505
+ | ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
506
+ | `Drizzle` (le module) | valide la config, déclare le schéma framework, monte le data plane | `index.ts` du module |
507
+ | `DrizzleService` | ouvre un ORM par connecteur au boot, ferme tout à l'arrêt | `connectAll()`, `DrizzleService.ts:148` |
508
+ | `DrizzleOrm` | la connexion : DDL dérivé, repositories, transactions, sonde | `DrizzleOrm.ts:214` |
509
+ | `DrizzleRepository<T>` | le CRUD portable, les opérateurs riches, l'eager-load | `DrizzleRepository.ts:146` |
510
+ | `DrizzleTransaction` | `BEGIN`/`COMMIT`/`ROLLBACK` pilotés à la main, sur les trois dialectes | `DrizzleTransaction.ts:70` |
511
+ | `buildFrameworkTable` | une spécification logique → la table du dialecte demandé | `colKit.ts:543` |
512
+ | `queryKit` (interne) | le SQL brut des entités framework, émis **et exécuté** par dialecte | `findUserIdBySocialProvider()` (`queryKit.ts:76`) |
513
+ | `registerStores` | l'auto-enregistrement des huit briques | `registerDrizzleFrameworkStores()` (`registerStores.ts:149`) |
514
+
515
+ ### Le DDL dérivé — comment les tables apparaissent
516
+
517
+ Drizzle ne « synchronise » pas un schéma. L'adapter dérive lui-même un `CREATE TABLE IF NOT EXISTS`
518
+ depuis chaque table déclarée (`#buildCreateTable()`, `DrizzleOrm.ts:400`) et l'exécute à la connexion.
519
+ Trois conséquences à connaître **avant** de dépendre de ce mécanisme :
520
+
521
+ 1. il **crée**, il ne **modifie** pas — aucun `ALTER` n'est émis ;
522
+ 2. il n'émet **ni `DEFAULT` SQL ni index** — d'où la règle des défauts en `$defaultFn` ;
523
+ 3. il ne connaît que les colonnes, les clés primaires, `NOT NULL` et `UNIQUE`.
524
+
525
+ C'est un confort de développement, pas un outil de migration. La suite est dans
526
+ [Migrations](#migrations--deux-chemins-un-seul-schéma).
527
+
528
+ ## 🧰 API publique — du repository au SQL brut
529
+
530
+ Les signatures exactes vivent dans le graphe généré (`jq '.symbols.DrizzleRepository' .ai/symbols.json`)
531
+ — jamais recopiées ici, elles divergeraient. Ce qui suit montre **l'usage**.
532
+
533
+ ### Le repository
534
+
535
+ ```ts ignore
536
+ const posts = ormRegistry.get("default").getRepository<PostRow>("Post");
537
+
538
+ // Lecture — égalité, opérateurs riches, tri, pagination, eager-load
539
+ await posts.find({ title: "Bonjour" });
540
+ await posts.find({ views: { $gte: 10, $lt: 1000 } }); // plusieurs opérateurs = AND
541
+ await posts.find({ id: { $in: ids } });
542
+ await posts.find({ title: { $like: "Bon%" } }); // sémantique SQL (`%`, `_`)
543
+ await posts.find({ title: { $like: escapeLikeTerm("100%") } }); // `%` littéral
544
+ await posts.find({}, { order: [["views", "DESC"]], limit: 20, offset: 40 });
545
+ await posts.find({}, { relations: ["comments"] }); // associations déclarées
546
+
547
+ // Écriture
548
+ await posts.create({ title: "Neuf" });
549
+ await posts.createMany([{ title: "a" }, { title: "b" }]);
550
+ await posts.updateOne({ id }, { title: "Corrigé" }); // rend la ligne, ou null
551
+ await posts.upsert({ id }, { title: "Neuf" });
552
+ await posts.increment({ id }, { views: 1 }); // compteur atomique
553
+ await posts.deleteOne({ id });
554
+ await posts.count({ views: { $gte: 10 } });
555
+ ```
556
+
557
+ Les opérateurs (`$eq $ne $gt $gte $lt $lte $in $nin $like`) sont **ceux d'orm-core**, identiques sur
558
+ tous les drivers ; la traduction en `eq()`/`inArray()` se fait dans `#where()`
559
+ (`DrizzleRepository.ts:420`). Leur référence complète est dans
560
+ [la page d'orm-core](../../orm-core/docs/index.md).
561
+
562
+ `$like` est émis avec sa clause `ESCAPE '\'` (`likeSql.ts`), ce qui rend un `%` ou un `_` **littéral**
563
+ exprimable : passez le fragment par `escapeLikeTerm` (orm-core) plutôt que de composer le motif à la
564
+ main. Sans cette clause — c'était le cas — un antislash valait échappement en PostgreSQL et MySQL, et
565
+ lui-même en SQLite : le même critère ne rendait pas les mêmes lignes selon la base.
566
+
567
+ Deux points de comportement qui évitent des surprises :
568
+
569
+ - **« au plus une ligne »** est garanti par construction pour `updateOne`/`deleteOne`/`increment` :
570
+ la mutation est bornée par la clé primaire découverte de la table, jamais par un `LIMIT` sur un
571
+ `UPDATE` (`#pickOne()`, `DrizzleRepository.ts:259`). C'est ce qui rend ces verbes portables — MySQL
572
+ interdit la forme naïve.
573
+ - **l'eager-load est manuel** : une requête `IN (…)` par relation déclarée, puis regroupement en
574
+ mémoire (`#populate()`, `DrizzleRepository.ts:683`). Choix assumé — pas de couche de relations à
575
+ déclarer une seconde fois, et le comportement est le même sur les trois dialectes.
576
+
577
+ ### Transactions — une connexion dédiée, jamais le pool
578
+
579
+ ```ts ignore
580
+ await orm.transaction(async (tx) => {
581
+ // withTransaction(tx) est le SEUL moyen d'entrer dans la transaction
582
+ const author = await users.withTransaction(tx).create({ email: "x@y.z" });
583
+ await posts.withTransaction(tx).create({ title: "…", authorId: author.id });
584
+ // une exception ⇒ ROLLBACK de tout ; sinon COMMIT automatique
585
+ });
586
+ ```
587
+
588
+ Le module pilote `BEGIN`/`COMMIT`/`ROLLBACK` **à la main** plutôt que d'utiliser l'aide du moteur, pour
589
+ une raison précise : `better-sqlite3` est **synchrone**, son helper commite au `return` — donc _avant_
590
+ les `await` d'un contrat asynchrone. Le pilotage manuel rétablit la sémantique attendue
591
+ (`DrizzleTransaction`, `DrizzleTransaction.ts:70`).
592
+
593
+ > [!WARNING]
594
+ > **`getRepository()` écrit HORS transaction.** Un repository obtenu normalement passe par le pool ;
595
+ > seul `repo.withTransaction(tx)` emprunte la connexion de la transaction. C'est l'erreur la plus
596
+ > coûteuse du domaine : sur PostgreSQL/MySQL, le `BEGIN` et les écritures partiraient sur des
597
+ > connexions différentes — **aucune atomicité**, et un `BEGIN` orphelin recyclé dans le pool.
598
+
599
+ Le mécanisme diffère par dialecte, sans que ton code le voie : en PostgreSQL/MySQL la transaction
600
+ emprunte une connexion **dédiée** au pool, rendue au commit — et **détruite** si celui-ci échoue,
601
+ jamais recyclée dans un état inconnu. En SQLite la connexion est unique, donc c'est un pool de taille 1 :
602
+ les transactions concurrentes sont **sérialisées par une file d'attente** (`#sqliteTxGate`,
603
+ `DrizzleOrm.ts:267`), sinon deux requêtes HTTP simultanées émettraient deux `BEGIN` sur la même
604
+ connexion et la seconde échouerait.
605
+
606
+ Les points de sauvegarde sont disponibles (`savepoint()`, `DrizzleTransaction.ts:123`) ; le nom est
607
+ validé et cité selon le dialecte — en MySQL/MariaDB, `"x"` est une **chaîne**, pas un identifiant.
608
+
609
+ ### Trappe SQL brut — quand l'abstraction ne suffit plus
610
+
611
+ Toute requête que le repository ne couvre pas s'écrit directement, avec le moteur :
612
+
613
+ ```ts ignore
614
+ import { sql } from "drizzle-orm";
615
+
616
+ const db = orm.getNativeConnection<DrizzleDb>();
617
+ const rows = await db.all(sql`
618
+ WITH ranked AS (
619
+ SELECT id, title, views,
620
+ ROW_NUMBER() OVER (PARTITION BY authorId ORDER BY views DESC) AS rk
621
+ FROM Post
622
+ )
623
+ SELECT * FROM ranked WHERE rk = 1
624
+ `);
625
+ ```
626
+
627
+ C'est l'**anti-blocage** du modèle Repository (`getNativeConnection()`, `DrizzleOrm.ts:1405`) : CTE,
628
+ fonctions de fenêtre, sous-requêtes corrélées, jointures arbitraires. Deux contreparties assumées :
629
+ ce SQL n'est plus portable entre dialectes, et il **ne passe pas** par la sonde de profilage des
630
+ requêtes.
631
+
632
+ ## Les huit stores du framework — la persistance clé en main
633
+
634
+ C'est la partie qui distingue ce module d'un simple adapter : **charger `@nodefony/drizzle` rend huit
635
+ briques durables disponibles**, sans aucun câblage.
636
+
637
+ <!-- prettier-ignore -->
638
+ | Brique | Table(s) | Contrat servi | Ce que ça rend durable |
639
+ | --- | --- | --- | --- |
640
+ | Session | `session` | `ISessionStorage` (http) | les sessions survivent au redémarrage |
641
+ | Utilisateurs | `User` | `IUserRepository` (user) | l'annuaire des comptes |
642
+ | Jetons | `access_token`, `denied_jti`, `subject_revocation` | `ITokenStore` (security) | PAT, denylist JWT, révocation par sujet |
643
+ | Passkeys | `webauthn_credential` | `IWebAuthnCredentialStore` | les clés WebAuthn enrôlées |
644
+ | 2FA (TOTP) | `totp_secret` | `ITotpSecretStore` | les secrets 2FA (chiffrés en amont) |
645
+ | Audit | `audit_event` | `IAuditStore` | le journal de sécurité, append-only |
646
+ | Webhooks | `webhook_endpoint` | `IWebhookStore` | le registre des destinataires |
647
+ | Idempotence | `idempotency_key` | `IIdempotencyStore` (core) | la dédup des mutations, **partagée** |
648
+
649
+ Les huit sont portées sur les **trois** dialectes. Chaque table est déclarée par une spécification
650
+ logique traduite par le colKit — par exemple la session (`SESSION_TABLE_SPEC`, `sessionEntity.ts:25`)
651
+ ou l'idempotence (`createIdempotencyTable`, `idempotencyEntity.ts:85`).
652
+
653
+ ### Comment ça s'active — la réponse est : tout seul
654
+
655
+ Chaque brique choisit son backend par une option `store`, dont le **défaut est `"auto"`**. La
656
+ résolution automatique (`resolveAutoStore()`, `infra.ts:241`) applique cette préférence :
657
+
658
+ 1. une infra `database` déclarée (`NF_DATABASE_URL`) → **`drizzle`** (ou `mongoose` si l'URL est Mongo) ;
659
+ 2. sinon, un backend local persistant réellement chargé → **`drizzle`**, c'est-à-dire SQLite ;
660
+ 3. sinon seulement, repli en mémoire (volatil), **annoncé** dans les journaux.
661
+
662
+ Autrement dit : le simple fait de charger ce module fait basculer session, jetons, audit, passkeys,
663
+ 2FA, webhooks et idempotence sur une base **persistante**. Le journal de démarrage dit toujours quel
664
+ backend a été retenu **et pourquoi** — `audit.store "auto" → "drizzle" (aucune infra déclarée — backend
665
+ local persistant "drizzle" (mono-nœud))`.
666
+
667
+ Tu peux évidemment forcer :
668
+
669
+ ```ts ignore
670
+ use("@nodefony/http", { session: { store: "drizzle" } });
671
+ use("@nodefony/security", {
672
+ audit: { store: "drizzle" },
673
+ tokenStore: { store: "drizzle" },
674
+ });
675
+ ```
676
+
677
+ > [!IMPORTANT]
678
+ > **Les TSDoc de deux fichiers du module décrivent une « approche B » où l'application câblerait
679
+ > elle-même la fabrique et l'entité d'idempotence** (`DrizzleIdempotencyStore.ts:97` ·
680
+ > `idempotencyEntity.ts:32`). Ce n'est plus le comportement : le module inscrit lui-même la fabrique
681
+ > via `registerIdempotencyStore()` (`registerStores.ts:316`). **Le code exécuté fait autorité** —
682
+ > ces commentaires sont périmés.
683
+
684
+ ### Le mécanisme, et comment garder la main
685
+
686
+ `registerDrizzleFrameworkStores()` (`registerStores.ts:149`) est appelé à l'enregistrement du module,
687
+ avec le dialecte du connecteur `default`. Pour chaque brique, il déclare l'entité puis inscrit la
688
+ fabrique du store dans le registre de son propriétaire (`http`, `security` ou `framework`). Deux
689
+ garde-fous préservent ta liberté :
690
+
691
+ - **entité déjà enregistrée par l'application** → elle est respectée, jamais écrasée ;
692
+ - **fabrique déjà posée** → elle garde la main (premier arrivé, premier servi).
693
+
694
+ Et deux garde-fous protègent de l'incohérence :
695
+
696
+ - une brique **non portée** sur le dialecte configuré n'est ni déclarée ni fabricable — la
697
+ sélectionner échoue franchement au démarrage plutôt que de produire une table fantôme ;
698
+ - la fabrique **capture le dialecte** de son enregistrement : elle refuse un ORM d'un autre dialecte
699
+ (`resolveConnectedOrm()`, `registerStores.ts:112`).
700
+
701
+ Pour tout couper — module « données seulement », aucune entité ni fabrique framework :
702
+
703
+ ```ts ignore
704
+ use("@nodefony/drizzle", { frameworkEntities: false });
705
+ ```
706
+
707
+ Le bilan de l'opération (déclarées / laissées à l'application / non portées) est **journalisé**, jamais
708
+ silencieux.
709
+
710
+ ### Deux comportements à connaître
711
+
712
+ **La résolution du handle est paresseuse.** Les stores ne capturent pas la connexion à leur
713
+ construction : ils la résolvent à chaque appel. C'est nécessaire parce que l'ordre n'est pas garanti —
714
+ le framework résout ses stores avant que l'ORM ne soit connecté — et parce que l'ORM se **déconnecte à
715
+ l'arrêt** avant que les serveurs HTTP n'aient fini de vider leurs requêtes en vol. Handle absent =
716
+ dégradation annoncée, pas un plantage : le `SessionStorage` rend une session vide et ignore les
717
+ écritures (`#repo()`, `SessionStorage.ts:65`), le store d'idempotence laisse passer la mutation sans
718
+ dédup (`begin()`, `DrizzleIdempotencyStore.ts:200`).
719
+
720
+ **SQL n'a pas de TTL.** Contrairement à Redis, rien n'expire tout seul : chaque store expose un `gc()`
721
+ applicatif qui supprime les lignes échues, déclenché par un minuteur hors du chemin chaud —
722
+ sessions (`SessionStorage.ts:166`), jetons (`DrizzleTokenStore.ts:297`), idempotence
723
+ (`DrizzleIdempotencyStore.ts:318`), audit selon la rétention configurée (`DrizzleAuditStore.ts:231`).
724
+
725
+ ### Deux détails de conception qui valent la lecture
726
+
727
+ **L'idempotence** est une **réservation atomique** : un `INSERT … ON CONFLICT(key) DO UPDATE … WHERE
728
+ expiré` dont le `RETURNING` ne rend une ligne que si l'insertion (clé neuve) ou le vol d'une entrée
729
+ morte a réellement eu lieu. C'est l'équivalent SQL du `SET NX PX` de Redis, en une instruction — et
730
+ c'est ce qui interdit de conclure « nouvelle mutation » hors d'une réservation gagnée. En MySQL, où ni
731
+ `RETURNING` ni `WHERE` ne sont disponibles sur un `ON DUPLICATE KEY UPDATE`, la même garantie s'obtient
732
+ en deux instructions au verdict non ambigu (`reserveIdempotencyKeyMysql()`, `queryKit.ts:152`).
733
+
734
+ **Le journal d'audit** pagine par un curseur **composite auto-portant** `<horodatage>:<id>` sur un ordre
735
+ total, plutôt que par un identifiant seul (`listPage()`, `DrizzleAuditStore.ts:154`). Deux gains : plus
736
+ d'aller-retour pour résoudre le curseur, et une pagination qui ne rembobine pas à la première page si
737
+ l'événement de référence a été purgé entre deux appels.
738
+
739
+ Le détail fonctionnel de ces briques vit chez leur propriétaire :
740
+ [audit](../../security/docs/audit.md) · [jetons](../../security/docs/tokens.md) ·
741
+ [passkeys](../../security/docs/webauthn.md) · [TOTP](../../security/docs/totp.md) ·
742
+ [webhooks](../../security/docs/webhooks.md) · [idempotence](../../framework/docs/idempotence.md) ·
743
+ [stockage de session](../../../../../docs/guides/session-storage.md).
744
+
745
+ ## Migrations — deux chemins, un seul schéma
746
+
747
+ Deux mécanismes créent les tables, et il faut savoir lequel travaille pour toi.
748
+
749
+ **En développement et en test**, chaque table déclarée est créée à la connexion par un
750
+ `CREATE TABLE IF NOT EXISTS` dérivé de ton schéma — index compris. Tu n'as rien à lancer : la base
751
+ part de zéro et fonctionne.
752
+
753
+ **Sa limite tient en un mot : il n'ajoute jamais ce qu'il faudrait inventer.**
754
+
755
+ | Attente | Réalité |
756
+ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
757
+ | « j'ajoute un champ nullable, ça suit » | **Oui** — la colonne est posée au démarrage et journalisée. Voir [Migrations de schéma](migrations.md#en-développement--le-schéma-se-répare-tout-seul). |
758
+ | « j'ajoute un champ obligatoire » | **Non** — il faudrait inventer une valeur pour les lignes déjà là. L'écart est publié avec son geste : `nodefony orm:reset`, ou une migration. |
759
+ | « mes `.default()` SQL s'appliquent » | **Non** — le DDL dérivé ne les émet pas. Utilise `$defaultFn` (côté JS). |
760
+ | « mes index sont créés » | **Oui** — des deux côtés. Un banc de parité le vérifie table par table. |
761
+
762
+ > [!CAUTION]
763
+ > Ne déploie jamais sur le DDL dérivé. Il est conçu pour qu'une base **neuve** fonctionne, et pour
764
+ > qu'une base de développement suive un ajout de champ — pas pour faire évoluer une base de
765
+ > production. Il ne pose aucune trace dans l'historique : les tables qu'il crée n'ont, pour qui
766
+ > arrive après, aucune origine connue.
767
+
768
+ **En production**, le schéma vient de fichiers de migration versionnés. Ceux du framework sont
769
+ **livrés dans le paquet** : `migrations/{sqlite,postgres,mysql}/`, un fichier par changement, plus le
770
+ journal qui dit lesquels ont été appliqués. Ils sont produits par `drizzle-kit` à partir des mêmes
771
+ entités que le DDL dérivé, et un banc vérifie sur les trois dialectes qu'une base **migrée** est
772
+ identique à une base **dérivée** — colonne par colonne, index compris. Sans cette preuve, les deux
773
+ chemins divergeraient en silence.
774
+
775
+ Les appliquer, les adopter, les réparer et surveiller leur état est le travail de cinq commandes
776
+ `nodefony orm:migrate*` — avec leur verrou, leur historique et leur branchement sur la sonde de
777
+ disponibilité. Tout est dans **[Migrations de schéma](migrations.md)**, qui porte aussi le patron de
778
+ déploiement sans interruption et les droits du compte qui migre.
779
+
780
+ ### Regénérer les migrations du framework
781
+
782
+ Concerne les contributeurs du framework, pas les applications.
783
+
784
+ ```bash
785
+ # Les TROIS dialectes ensemble, sous le même nom — jamais un seul.
786
+ npm run generate:migrations -w @nodefony/drizzle -- --name ajout_du_champ_x
787
+
788
+ # Le schéma des entités a-t-il bougé sans regénération ?
789
+ npm run check:migrations -w @nodefony/drizzle
790
+ ```
791
+
792
+ Les trois dialectes se génèrent **ensemble** parce qu'un identifiant de migration publié sur npm est
793
+ immuable à vie : trois journaux désalignés ne se renumérotent pas. Le contrôle de dérive tourne aussi
794
+ en intégration continue — une entité modifiée sans regénération y devient rouge.
795
+
796
+ ### Les trois refus, et ce qu'ils protègent
797
+
798
+ Un générateur de migrations compare deux schémas. Il ne voit pas une **intention** — et c'est
799
+ précisément là que les données se perdent.
800
+
801
+ | Ce qui arrive | Ce que tu obtiens |
802
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
803
+ | Tu **renommes** une colonne | La génération s'arrête et demande un terminal interactif. Une colonne qui disparaît et une autre qui apparaît, c'est un renommage (les données suivent) ou une suppression puis un ajout (elles sont perdues) : seul toi peux trancher. |
804
+ | La migration **détruit** des données | Refus. `DROP TABLE`, `DROP COLUMN`, changement de type, `TRUNCATE` — les fichiers sont écrits pour que tu les relises, et c'est leur MISE EN SERVICE qui est retenue : `orm:migrate` la refuse hors développement sans `--allow-destructive`. Les annuler se fait avec l'outil de gestion de versions, pas en regénérant. |
805
+ | La migration **verrouille** en production | Avertissement, sans blocage — un `CREATE INDEX` PostgreSQL non concurrent bloque les écritures de la table, un `SET NOT NULL` la scanne entière sous verrou exclusif. La manœuvre sûre est indiquée. |
806
+
807
+ > [!WARNING]
808
+ > Après avoir répondu « renamed », **relis le fichier produit**. Quand une colonne est renommée _et_
809
+ > que son type change, l'outil n'écrit que le renommage et oublie le changement de type
810
+ > ([drizzle-orm#3826](https://github.com/drizzle-team/drizzle-orm/issues/3826)). Le contrôle de
811
+ > dérive le rattrape — c'est aussi pour cela qu'il existe.
812
+
813
+ Chaque fichier généré porte en tête `-- nodefony:migration format=1`. Ce n'est pas décoratif : un
814
+ applicateur ne doit pas deviner le format de ce qu'il exécute, et c'est la porte de sortie prévue
815
+ pour changer d'outil sans réécrire les bases existantes.
816
+
817
+ ## 📡 Observabilité — Studio
818
+
819
+ Le module monte le **data plane d'administration ORM** au démarrage — un branchement global, idempotent,
820
+ que chaque driver déclenche à l'identique (orm-core étant une bibliothèque pure, il ne peut pas le
821
+ faire lui-même).
822
+
823
+ | Route | Contenu |
824
+ | --------------------------------------- | ---------------------------------------- |
825
+ | `GET /nodefony/orm/api/orms` | les ORM et connecteurs, avec leur santé |
826
+ | `GET /nodefony/orm/api/entities` | les entités (`?connector=` pour filtrer) |
827
+ | `GET /nodefony/orm/api/entity/{name}` | une entité et ses colonnes normalisées |
828
+ | `GET /nodefony/orm/api/graph` | le graphe complet du modèle de données |
829
+ | `GET /nodefony/orm/api/export/{format}` | export du schéma (`dbml`) |
830
+
831
+ Côté écrans : **Database**, **ORM (vue d'ensemble et par entité)** et **Stores** — ce dernier répond à
832
+ la question « où sont écrites mes données ? » pour chaque brique.
833
+
834
+ La sonde d'un connecteur s'adapte au dialecte (`probe()`, `DrizzleOrm.ts:1495`) :
835
+
836
+ - **SQLite** → `storage` : taille du fichier, mode de journal, pages libres (lus par `PRAGMA`) ;
837
+ - **PostgreSQL / MySQL** → `pool` : taille, connexions libres, empruntées, en attente — **compteurs en
838
+ mémoire, aucune requête émise**.
839
+
840
+ La taille d'une base **serveur** n'est délibérément **pas** sondée : la mesurer coûterait une requête à
841
+ chaque appel, pour une donnée que l'administration du SGBD expose déjà. Ne rien promettre vaut mieux
842
+ que promettre en silence — c'est le principe « superviser sans peser sur la production ».
843
+
844
+ Chaque store expose aussi son **emplacement physique** pour l'écran Stores : le chemin du fichier
845
+ SQLite, relativisé (anti-fuite d'information), et `undefined` pour un backend réseau — dont
846
+ l'emplacement **est** l'infra déclarée, déjà affichée ailleurs (`location`, `DrizzleOrm.ts:372`).
847
+
848
+ ## ⚡ Performance & mémoire
849
+
850
+ **La sonde de requêtes ne coûte rien quand elle est éteinte.** Chaque exécution passe par un point de
851
+ mesure unique (`#prof()`, `DrizzleRepository.ts:330`) qui alimente deux consommateurs — le profileur
852
+ par requête (barre de debug) et l'agrégat de flux. Les deux sont gardés par un drapeau : si aucun n'est
853
+ actif, la fonction rend le constructeur de requête tel quel, sans allocation. Le flux est **désactivé
854
+ en production** par défaut.
855
+
856
+ La sérialisation du SQL n'est faite que sur le **chemin lent** (au-delà du seuil de requête lente),
857
+ jamais au cas nominal — et le SQL journalisé est la forme **paramétrée** (`?`), jamais les valeurs.
858
+
859
+ **Le plafond de SQLite est structurel, pas un défaut.** `better-sqlite3` est synchrone et
860
+ mono-connexion : les écritures sont sérialisées. Sur un banc de session en HTTP/2, cela donne un
861
+ plafond stable autour de **400 requêtes/s** avec 100 % de réponses correctes et zéro session perdue ;
862
+ augmenter la concurrence augmente la latence, pas le débit. PostgreSQL ou MySQL paralléliseraient. Si
863
+ tu vises plus haut, ce n'est pas le module qu'il faut changer, c'est le dialecte.
864
+
865
+ Les bancs de charge du module (`npm run test:load`) mesurent l'insertion, le balayage, les gros `$in`,
866
+ et vérifient l'absence de fuite mémoire sur des dizaines de milliers de cycles et des centaines de
867
+ connexions. Les chiffres vivent dans la sortie du banc, pas ici — ils dépendent de la machine.
868
+
869
+ ## ⚠️ Pièges
870
+
871
+ | Symptôme | Cause | Correction |
872
+ | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
873
+ | Une écriture dans `transaction()` n'est pas annulée | repository obtenu par `getRepository()` → passe par le **pool**, hors tx | `repo.withTransaction(tx)` — le seul moyen d'entrer dans la transaction |
874
+ | `NOT NULL constraint failed` sur une colonne pourtant « par défaut » | `.default()` SQL : le DDL dérivé ne l'émet pas | poser le défaut côté JS : `$defaultFn(() => …)` |
875
+ | Colonne ajoutée, table inchangée | le DDL dérivé **ne fait aucun `ALTER`** | supprimer la base de dev, ou passer par `drizzle-kit` |
876
+ | Index déclaré, absent en base | les index ne sortent que via `drizzle-kit` | migration `drizzle-kit` en production |
877
+ | Le démarrage échoue sur le connecteur | infra déclarée injoignable → `BootConfigurationError` (voulu) | corriger l'URL / démarrer la base / retirer le connecteur |
878
+ | `cannot start a transaction within a transaction` (SQLite) | deux `BEGIN` concurrents sur la connexion unique | rien à faire : la file d'attente interne les sérialise |
879
+ | Une entité fonctionne en SQLite, échoue en PostgreSQL | table Drizzle **figée** sur un dialecte, posée sur le connecteur `default` | fixer l'entité sur son propre connecteur SQLite, ou la porter au dialecte |
880
+ | `store inconnu` au démarrage | brique non portée sur le dialecte, ou `frameworkEntities: false` | vérifier le dialecte configuré et le bilan journalisé de l'auto-register |
881
+ | La session repart vide à chaque redémarrage | store résolu en `memory` (aucun backend persistant chargé) | charger `@nodefony/drizzle` ; le journal dit toujours le store retenu |
882
+ | Débit bloqué autour de 400 req/s en écriture | SQLite est synchrone et mono-connexion — pas un bug | passer en `postgres`/`mysql` via `NF_DATABASE_URL` |
883
+ | Le SQL brut n'apparaît pas dans le profileur | les requêtes de la trappe native ne passent pas par le point de mesure | attendu ; utiliser le repository si la mesure compte |
884
+
885
+ ## 🧪 Tests & couverture
886
+
887
+ Le module est couvert par plusieurs familles complémentaires — les compteurs exacts sont **régénérés**
888
+ depuis vitest, jamais figés dans ce texte :
889
+
890
+ - **intégration** — la config Zod, le banc orm-core, la jointure très complexe (CTE, fenêtres,
891
+ sous-requêtes corrélées via la trappe native), le stockage de session, l'entité `User`, et chacun
892
+ des huit stores ;
893
+ - **bancs de contrat partagés** — les invariants que **tous** les backends doivent tenir, importés de
894
+ leur module propriétaire (session et pagination depuis `http`, pagination d'audit depuis `security`)
895
+ et rejoués ici ;
896
+ - **banc de parité `IRepository`** — la **même** suite exécutée sur les trois dialectes ; c'est lui qui
897
+ a attrapé un `LIMIT` négatif silencieusement ignoré par le moteur ;
898
+ - **E2E sur base réelle** — une suite par brique pour PostgreSQL et pour MySQL/MariaDB, plus la preuve
899
+ d'atomicité **inter-pods** de l'idempotence (deux pools concurrents, un seul gagnant par tour) ;
900
+ - **charge et mémoire** — `npm run test:load` : débit, grands `$in`, absence de fuite.
901
+
902
+ ### ⚠️ Un `npm test` vert ne prouve que SQLite
903
+
904
+ C'est le piège le plus important de cette page, et il a été vécu sur **ce module** : les suites E2E se
905
+ **skippent** silencieusement sans leur base, et un test skippé compte comme vert. Une exécution sans
906
+ variables laisse **des centaines de tests non exécutés — soit les deux dialectes de production** — et
907
+ annonce quand même un succès.
908
+
909
+ Le module rend donc ce silence audible : ses gates d'infrastructure sont déclarées dans
910
+ `vitest.gates.ts` à la racine (source **unique** du dépôt), et un rapporteur nomme en fin d'exécution
911
+ les cibles **non exercées**, avec la commande exacte pour les activer.
912
+
913
+ ```bash
914
+ # Ne prouve QUE sqlite — lire le bloc de fin de run avant de conclure
915
+ npm test
916
+
917
+ # PostgreSQL réellement exercé
918
+ docker compose -f docker/docker-compose.yml --profile postgres up -d postgres
919
+ NF_PG_URL=postgres://… npm test
920
+
921
+ # MySQL / MariaDB réellement exercés
922
+ docker compose -f docker/docker-compose.yml --profile mariadb up -d mariadb
923
+ NF_MYSQL_URL=mysql://… npm test
924
+
925
+ npm run coverage # couverture (vitest)
926
+ npm run test:load # charge, limites, mémoire
927
+ ```
928
+
929
+ > [!CAUTION]
930
+ > N'affirme jamais qu'un dialecte est « prouvé » sur la foi d'un compteur vert. **PostgreSQL exige
931
+ > `NF_PG_URL`, MySQL/MariaDB exigent `NF_MYSQL_URL`** — sans elles, ces dialectes ne sont pas testés,
932
+ > ils sont sautés. Les commandes exactes sont affichées par le rapporteur, jamais recopiées à la main.
933
+
934
+ ## 🔗 Pour aller plus loin
935
+
936
+ - ⬆️ **Retour** : [Toute la documentation](../../../../../docs/index.md) ·
937
+ [Démarrer avec Nodefony](../../../../../docs/demarrer.md)
938
+ - 🧭 **L'abstraction au-dessus** : [`@nodefony/orm-core`](../../orm-core/docs/index.md) — contrats
939
+ `IOrm`/`IRepository`/`ITransaction`, `Criteria` et opérateurs riches, registres. À lire pour tout ce
940
+ qui est **portable** ; cette page-ci ne documente que le driver SQL.
941
+ - 🗄️ **Déployer un schéma** : [Migrations de schéma](migrations.md) — les cinq commandes, le
942
+ rattrapage de développement, le travail d'orchestrateur et l'expansion/contraction.
943
+ - 📗 **Tutoriel** : [créer une entité pas à pas](../../orm-core/docs/tutorial-entity.md)
944
+ - 🧩 **L'autre driver** : [`@nodefony/mongoose`](../../mongoose/docs/index.md) — même contrat, MongoDB.
945
+ - 🗄️ **Guides transverses** : [choisir sa persistance](../../../../../docs/guides/persistence.md) ·
946
+ [stockage de session](../../../../../docs/guides/session-storage.md) ·
947
+ [configuration d'une application](../../../../../docs/guides/configuration.md)
948
+ - 🔐 **Les briques servies** : [`@nodefony/security`](../../security/docs/index.md) (jetons, audit,
949
+ passkeys, TOTP, webhooks) · [`@nodefony/user`](../../user/docs/index.md) (l'annuaire) ·
950
+ [`@nodefony/http`](../../http/docs/index.md) (sessions) ·
951
+ [`@nodefony/framework`](../../framework/docs/index.md) (idempotence)
952
+ - 🏛️ **Décision d'architecture** :
953
+ [ADR-0003 — abstraction Repository multi-ORM](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md)
954
+ - 📖 [Lexique général](../../../../../docs/lexique.md) du framework.