@nodefony/orm-core 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 (69) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +131 -0
  3. package/dist/index.js +22 -0
  4. package/dist/nodefony/interfaces/IEntity.js +1 -0
  5. package/dist/nodefony/interfaces/IOrm.js +1 -0
  6. package/dist/nodefony/interfaces/IOrmFlow.js +1 -0
  7. package/dist/nodefony/interfaces/IOrmGraph.js +1 -0
  8. package/dist/nodefony/interfaces/IOrmMigrations.js +12 -0
  9. package/dist/nodefony/interfaces/IOrmProbe.js +1 -0
  10. package/dist/nodefony/interfaces/IPage.js +1 -0
  11. package/dist/nodefony/interfaces/IRepository.js +1 -0
  12. package/dist/nodefony/interfaces/ITransaction.js +1 -0
  13. package/dist/nodefony/interfaces/index.js +1 -0
  14. package/dist/nodefony/src/AbstractCrudService.js +199 -0
  15. package/dist/nodefony/src/ConnectionMonitor.js +181 -0
  16. package/dist/nodefony/src/Entity.js +42 -0
  17. package/dist/nodefony/src/EntityRegistry.js +109 -0
  18. package/dist/nodefony/src/Orm.js +297 -0
  19. package/dist/nodefony/src/OrmAdminApi.js +491 -0
  20. package/dist/nodefony/src/OrmRegistry.js +75 -0
  21. package/dist/nodefony/src/QueryFlowMonitor.js +128 -0
  22. package/dist/nodefony/src/buildOrmLeanHealth.js +54 -0
  23. package/dist/nodefony/src/criteria.js +176 -0
  24. package/dist/nodefony/src/decorators/entitiesDecorator.js +67 -0
  25. package/dist/nodefony/src/decorators/entityDecorator.js +51 -0
  26. package/dist/nodefony/src/decorators/index.js +5 -0
  27. package/dist/nodefony/src/decorators/metadataStore.js +38 -0
  28. package/dist/nodefony/src/decorators/repositoryDecorator.js +35 -0
  29. package/dist/nodefony/src/defineEntity.js +27 -0
  30. package/dist/nodefony/src/errors.js +76 -0
  31. package/dist/nodefony/src/ormWiring.js +78 -0
  32. package/dist/nodefony/src/paginate.js +55 -0
  33. package/dist/nodefony/src/readOptions.js +52 -0
  34. package/dist/nodefony/src/serviceWiring.js +1 -0
  35. package/dist/types/index.d.ts +39 -0
  36. package/dist/types/nodefony/interfaces/IEntity.d.ts +65 -0
  37. package/dist/types/nodefony/interfaces/IOrm.d.ts +136 -0
  38. package/dist/types/nodefony/interfaces/IOrmFlow.d.ts +71 -0
  39. package/dist/types/nodefony/interfaces/IOrmGraph.d.ts +165 -0
  40. package/dist/types/nodefony/interfaces/IOrmMigrations.d.ts +145 -0
  41. package/dist/types/nodefony/interfaces/IOrmProbe.d.ts +70 -0
  42. package/dist/types/nodefony/interfaces/IPage.d.ts +24 -0
  43. package/dist/types/nodefony/interfaces/IRepository.d.ts +369 -0
  44. package/dist/types/nodefony/interfaces/ITransaction.d.ts +31 -0
  45. package/dist/types/nodefony/interfaces/index.d.ts +7 -0
  46. package/dist/types/nodefony/src/AbstractCrudService.d.ts +158 -0
  47. package/dist/types/nodefony/src/ConnectionMonitor.d.ts +86 -0
  48. package/dist/types/nodefony/src/Entity.d.ts +44 -0
  49. package/dist/types/nodefony/src/EntityRegistry.d.ts +60 -0
  50. package/dist/types/nodefony/src/Orm.d.ts +197 -0
  51. package/dist/types/nodefony/src/OrmAdminApi.d.ts +84 -0
  52. package/dist/types/nodefony/src/OrmRegistry.d.ts +58 -0
  53. package/dist/types/nodefony/src/QueryFlowMonitor.d.ts +53 -0
  54. package/dist/types/nodefony/src/buildOrmLeanHealth.d.ts +15 -0
  55. package/dist/types/nodefony/src/criteria.d.ts +131 -0
  56. package/dist/types/nodefony/src/decorators/entitiesDecorator.d.ts +48 -0
  57. package/dist/types/nodefony/src/decorators/entityDecorator.d.ts +49 -0
  58. package/dist/types/nodefony/src/decorators/index.d.ts +10 -0
  59. package/dist/types/nodefony/src/decorators/metadataStore.d.ts +54 -0
  60. package/dist/types/nodefony/src/decorators/repositoryDecorator.d.ts +30 -0
  61. package/dist/types/nodefony/src/defineEntity.d.ts +43 -0
  62. package/dist/types/nodefony/src/errors.d.ts +62 -0
  63. package/dist/types/nodefony/src/ormWiring.d.ts +48 -0
  64. package/dist/types/nodefony/src/paginate.d.ts +43 -0
  65. package/dist/types/nodefony/src/readOptions.d.ts +21 -0
  66. package/dist/types/nodefony/src/serviceWiring.d.ts +24 -0
  67. package/docs/index.md +791 -0
  68. package/docs/tutorial-entity.md +577 -0
  69. package/package.json +73 -0
package/docs/index.md ADDED
@@ -0,0 +1,791 @@
1
+ ---
2
+ title: "@nodefony/orm-core — le contrat de persistance"
3
+ navTitle: "@nodefony/orm-core"
4
+ lang: fr
5
+ module: "@nodefony/orm-core"
6
+ topic: orm-core
7
+ section: "Données"
8
+ audience: [developer]
9
+ tags:
10
+ [
11
+ orm,
12
+ repository,
13
+ entite,
14
+ criteria,
15
+ operateurs,
16
+ pagination,
17
+ transaction,
18
+ crud,
19
+ registre,
20
+ ]
21
+ version: "doc"
22
+ status: stable
23
+ updated: 2026-07-19
24
+ source: "src/packages/@nodefony/orm-core/docs/index.md"
25
+ coverageModule: orm-core
26
+ coverageFiles: IRepository.ts,IOrm.ts,IEntity.ts,criteria.ts,paginate.ts,AbstractCrudService.ts,EntityRegistry.ts,OrmRegistry.ts,ConnectionMonitor.ts,QueryFlowMonitor.ts
27
+ ---
28
+
29
+ # @nodefony/orm-core — le contrat de persistance
30
+
31
+ > La **prise de courant** de la couche données : ton code métier branche `IRepository`, et
32
+ > derrière la prise il y a Drizzle (SQL) ou Mongoose (MongoDB) — sans que le métier le sache.
33
+ > `orm-core` ne contient **aucun** driver : il définit les contrats (`IOrm`, `IRepository`,
34
+ > `IEntity`, `ITransaction`), les registres qui les relient, les critères de recherche portables,
35
+ > la pagination et le socle CRUD. Promesse tenue : **changer d'ORM sans réécrire le métier**.
36
+
37
+ 📍 [Documentation](../../../../../docs/index.md) › **ORM — le socle**
38
+
39
+ ## 🧠 Schéma général
40
+
41
+ ```mermaid
42
+ flowchart TB
43
+ subgraph app["TON APPLICATION"]
44
+ CTRL["Controller / Resolver / Commande CLI"]
45
+ SVC["Service métier<br/>(AbstractCrudService)"]
46
+ CTRL --> SVC
47
+ end
48
+
49
+ subgraph core["@nodefony/orm-core — les CONTRATS (aucun driver)"]
50
+ IREPO["IRepository&lt;T&gt;<br/>find · create · upsert · increment · …"]
51
+ IORM["IOrm<br/>connect · getRepository · transaction"]
52
+ REG["entityRegistry + ormRegistry<br/>(singletons process-wide)"]
53
+ IREPO --- IORM
54
+ IORM --- REG
55
+ end
56
+
57
+ subgraph drv["Les DRIVERS — modules bootables"]
58
+ DZ["@nodefony/drizzle<br/>sqlite · postgres · mysql"]
59
+ MG["@nodefony/mongoose<br/>MongoDB"]
60
+ end
61
+
62
+ SVC --> IREPO
63
+ IORM --> DZ
64
+ IORM --> MG
65
+ DZ --> DB[("Base SQL")]
66
+ MG --> MDB[("MongoDB")]
67
+ ```
68
+
69
+ Une lecture en une phrase : **le métier ne parle qu'aux contrats du milieu** ; les drivers du bas
70
+ sont interchangeables, et les registres savent quel driver sert quelle entité.
71
+
72
+ ## 🧭 Par où commencer
73
+
74
+ Trois parcours selon ce que tu viens faire. L'ordre compte — chaque étape suppose la précédente.
75
+
76
+ **Je persiste ma première table** — je n'ai encore rien en base.
77
+
78
+ 1. [Créer une entité, de zéro à `find()`](tutorial-entity.md) — le pas-à-pas complet, sans rien
79
+ supposer connu. **Commence là si tu débutes.**
80
+ 2. La section [🚀 Démarrage rapide](#-démarrage-rapide) de cette page — la même chose en condensé,
81
+ copiable telle quelle.
82
+ 3. [`@nodefony/drizzle`](../../drizzle/docs/index.md) — le driver SQL par défaut : où se déclare le
83
+ connecteur, quels dialectes, comment se crée la table.
84
+
85
+ **J'écris des requêtes qui tiennent** — je sais persister, je veux interroger correctement.
86
+
87
+ 1. [🔎 Les critères de recherche](#-les-critères-de-recherche) — l'égalité, les opérateurs `$`, et
88
+ le piège du `null` en SQL.
89
+ 2. [📄 Pagination portable](#-pagination-portable) — pourquoi `paginate()` évite le `COUNT(*)`.
90
+ 3. [⚙️ Le socle CRUD](#-le-socle-crud--abstractcrudservice) — mettre la logique dans un service,
91
+ pas dans un controller.
92
+ 4. [🧰 Les contrats](#-les-contrats--la-surface-publique) — la liste complète des verbes, et lequel
93
+ choisir (`updateOne` vs `updateMany` vs `upsert`).
94
+
95
+ **Je choisis mon backend / j'en branche un nouveau** — décision d'architecture.
96
+
97
+ 1. [🗄️ Backends pris en charge](#-backends-pris-en-charge) — ce que couvre chaque driver, et ce
98
+ qu'il ne couvre **pas** (choix assumé, pas un manque).
99
+ 2. [🧩 Extension](#-extension--brancher-son-propre-driver) — le contrat minimal d'un adapter.
100
+ 3. [ADR-0003](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md) — les
101
+ risques de l'abstraction, écrits **avant** qu'elle ne soit validée.
102
+ 4. [Guide persistance](../../../../../docs/guides/persistence.md) — déclarer l'infra d'une app
103
+ complète (base, stores de session, migrations).
104
+
105
+ ## 🗂️ Le catalogue
106
+
107
+ Choisir en cinq secondes avec le tableau, puis lire la card correspondante.
108
+
109
+ <!-- prettier-ignore -->
110
+ | Page | À quoi ça sert | Tu en as besoin quand… |
111
+ | --- | --- | --- |
112
+ | [Créer une entité](tutorial-entity.md) | déclarer une table et lire/écrire dedans | tu pars de zéro |
113
+ | [`@nodefony/drizzle`](../../drizzle/docs/index.md) | le driver SQL (sqlite, postgres, mysql) | ton application stocke en SQL — le cas par défaut |
114
+ | [`@nodefony/mongoose`](../../mongoose/docs/index.md) | le driver MongoDB | ton modèle est documentaire |
115
+ | [Configuration Mongoose](../../mongoose/docs/configuration.md) | connecteurs, options du driver Mongo | tu branches un cluster Mongo réel |
116
+ | [Guide persistance](../../../../../docs/guides/persistence.md) | déclarer l'infra d'une app (base, stores, secrets) | tu prépares un déploiement |
117
+ | [Stockage de session](../../../../../docs/guides/session-storage.md) | où vivent les sessions HTTP | tu passes de la mémoire à une base partagée |
118
+ | [ADR-0003](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md) | pourquoi Repository, et à quel prix | tu remets l'abstraction en question (légitime) |
119
+
120
+ ```nodefony-cards
121
+ [
122
+ { "icon": "🚀", "title": "tutorial-entity", "href": "tutorial-entity.md", "featured": true,
123
+ "desc": "Ta première table, pas à pas : les trois mots à connaître (connecteur, entité, repository), le schéma Drizzle, l'enregistrement, puis le CRUD complet. Il ne suppose rien de connu.",
124
+ "meta": "dix minutes — prends-le avant cette page si « repository » ne t'évoque rien" },
125
+ { "icon": "🐘", "title": "@nodefony/drizzle", "href": "../../drizzle/docs/index.md",
126
+ "desc": "Le driver SQL par défaut : l'implémentation de référence des contrats décrits ici, en SQL type-safe — sqlite (zéro installation, le défaut de développement), postgres et mysql. C'est lui qui crée les tables au boot, alimente la sonde de flux et fournit les colonnes de l'ERD Studio.",
127
+ "meta": "le driver que tu auras par défaut en générant une application" },
128
+ { "icon": "🍃", "title": "@nodefony/mongoose", "href": "../../mongoose/docs/index.md",
129
+ "desc": "Le driver MongoDB : la même surface IRepository, sur un modèle documentaire — schémas Mongoose, populate pour les relations déclarées, $max/$min natifs pour l'upsert.",
130
+ "meta": "il porte les stores dont un déploiement Mongo a besoin — liste exacte en « Backends pris en charge »" },
131
+ { "icon": "🗄️", "title": "persistence", "href": "../../../../../docs/guides/persistence.md",
132
+ "desc": "L'infra vue de l'application : comment une application déclare sa base (variables d'environnement, secrets), quels modules se câblent automatiquement dessus, et ce qui change entre développement et production.",
133
+ "meta": "transverse — à lire quand tu quittes le SQLite de développement" }
134
+ ]
135
+ ```
136
+
137
+ ## 📖 Lexique
138
+
139
+ | Terme | Sens |
140
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------- |
141
+ | ORM | _Object-Relational Mapping_ : la bibliothèque qui traduit tes objets en lignes de base (Drizzle, Mongoose). |
142
+ | Repository | Objet qui lit/écrit une collection d'entités. Ici : `IRepository<T>`, la seule surface que voit le métier. |
143
+ | Entité | La description d'une table/collection : un nom logique, un schéma natif, un connecteur cible. |
144
+ | Connecteur | Le **nom** d'une connexion déclarée en config (`"default"`, `"analytics"`) — jamais le nom d'un moteur. |
145
+ | Driver / adapter | Le module qui implémente les contrats pour un moteur donné (`@nodefony/drizzle`, `@nodefony/mongoose`). |
146
+ | Dialecte | La variante SQL d'un même driver : `sqlite`, `postgres`, `mysql`. |
147
+ | Critère | Le filtre d'une requête : `{ email: "a@b.c" }` (égalité) ou `{ age: { $gte: 18 } }` (opérateurs). |
148
+ | Upsert | « insère **ou** met à jour » en une seule instruction atomique, sur conflit de clé unique. |
149
+ | Eager-load | Charger les entités liées **dans la même requête** que l'entité principale (`{ relations: [...] }`). |
150
+ | Transaction | Groupe d'écritures tout-ou-rien : `commit` valide l'ensemble, `rollback` annule l'ensemble. |
151
+ | Savepoint | Point de reprise **à l'intérieur** d'une transaction : on annule jusque-là sans tout perdre. |
152
+ | DDL | _Data Definition Language_ : le SQL qui crée/modifie les tables (`CREATE TABLE`, `ALTER`). |
153
+ | DBML | _Database Markup Language_ : format texte de schéma, lisible par les outils d'ERD. |
154
+ | ERD | _Entity-Relationship Diagram_ : le schéma visuel des tables et de leurs liens (écran Studio). |
155
+ | EWMA | _Exponentially Weighted Moving Average_ : moyenne qui privilégie le récent — utilisée pour la latence des requêtes. |
156
+ | 2PC | _Two-Phase Commit_ : protocole de transaction répartie sur plusieurs bases. **Non garanti** ici. |
157
+ | Ports & adapters | Architecture dite hexagonale : le cœur définit des prises (ports), l'extérieur fournit les fiches (adapters). |
158
+
159
+ ## Qu'est-ce que c'est ?
160
+
161
+ Une **prise normalisée** entre ton code métier et la base de données.
162
+
163
+ Sans elle, ton service appelle directement Drizzle : `db.select().from(users).where(eq(users.id, x))`.
164
+ Ça marche — jusqu'au jour où l'application doit passer sur MongoDB, ou simplement changer de version
165
+ majeure d'ORM. Alors chaque service, chaque controller, chaque commande CLI est à réécrire, parce que
166
+ la syntaxe du moteur a fui partout dans le métier.
167
+
168
+ `orm-core` interpose un contrat : le métier écrit `articles.find({ views: { $gte: 100 } })`, et
169
+ c'est le **driver** qui traduit — en `gte()` Drizzle, en `$gte` Mongo. Le vocabulaire du moteur ne
170
+ franchit jamais la frontière.
171
+
172
+ C'est le patron **Repository** (une collection d'entités qu'on interroge), monté en **ports &
173
+ adapters** : `orm-core` publie les ports, les drivers fournissent les adapters. Conséquence
174
+ structurante — `orm-core` **n'importe aucun driver**, et ne peut pas en importer : c'est ce qui
175
+ garantit que la dépendance va bien du concret vers l'abstrait, et jamais l'inverse.
176
+
177
+ > [!NOTE]
178
+ > `orm-core` n'est **pas un module bootable** : pas de classe `Module`, rien à mettre dans
179
+ > `modules: [...]`. C'est une bibliothèque pure. Les modules, ce sont les **drivers** — ce sont eux
180
+ > qui s'enregistrent dans `ormRegistry` (`OrmRegistry.ts:88`) à leur démarrage.
181
+
182
+ ## La vision Nodefony
183
+
184
+ Trois partis pris expliquent la forme exacte de l'API, et ils méritent d'être connus avant de
185
+ l'utiliser.
186
+
187
+ **1. La portabilité visée est celle du TEMPS, pas de l'espace.** L'objectif officiel est de pouvoir
188
+ changer d'ORM au fil des années sans réécrire le métier — pas de faire tourner quatre ORM
189
+ simultanément. Le multi-connecteur reste possible (`analytics` à côté de `default`), mais ce n'est
190
+ pas ce qui justifie l'API. C'est écrit noir sur blanc dans
191
+ [l'ADR-0003](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md), risque n°2 —
192
+ sur-dimensionner pour ce cas serait une erreur.
193
+
194
+ **2. L'abstraction assume de ne pas tout couvrir — et fournit la trappe.** Une jointure arbitraire,
195
+ une CTE, une fonction fenêtre : ça ne se porte pas d'un moteur SQL à MongoDB. Plutôt que d'inventer
196
+ un langage de requête maison, `IOrm.getNativeConnection()` (`IOrm.ts:51`) rend la connexion brute du
197
+ driver. Le contrat est honnête : **plus tu recours à la trappe, moins ton code est portable** — et
198
+ tu le sais en l'écrivant, parce que l'appel est visible.
199
+
200
+ **3. Une erreur doit se produire à l'identique sur tous les drivers.** C'est le point le plus subtil.
201
+ Un critère qui référence un champ inexistant (`{ emial: "…" }`) serait **ignoré** par Drizzle — la
202
+ condition disparaît, la requête rend **toute** la table — et **conservé** par Mongoose, qui rend
203
+ **zéro** résultat. Le même code, deux comportements opposés, aucune erreur : la promesse de
204
+ portabilité s'effondre en silence. D'où `UnknownCriteriaField` (`errors.ts:23`), levée **par les deux
205
+ drivers** : on échoue tôt, et pareil.
206
+
207
+ La même règle vaut pour les **options** : une forme de tri voisine mais fausse — `{ age: "asc" }` au
208
+ lieu de `[["age", "ASC"]]` — faisait partir la requête **sans `ORDER BY`**, et l'appelant recevait des
209
+ lignes non triées qu'il croyait triées. `assertOrderOption()` (`readOptions.ts:39`) est appelée par
210
+ chaque adapter en amont de sa requête et lève `InvalidOrderOption` (`errors.ts:65`) ; le sens est
211
+ vérifié en casse exacte, parce qu'un `"desc"` minuscule aurait trié à l'**envers**. Normaliser une
212
+ entrée utilisateur (casse, `champ:sens`) appartient à la frontière qui la reçoit — `parsePageQuery`
213
+ le fait —, jamais au repository.
214
+
215
+ **4. Le contrat va plus loin que le CRUD scolaire.** `IRepository` (`IRepository.ts:197`) porte
216
+ quinze verbes, pas cinq : les opérations **atomiques** (`upsert`, `increment`, `updateOne`,
217
+ `findOneAndDelete`) sont dans le contrat parce qu'un `SELECT` suivi d'un `UPDATE` est une **course**,
218
+ et qu'une course en base ne se rattrape pas côté application.
219
+
220
+ ## 🚀 Démarrage rapide
221
+
222
+ Vu d'une application générée par `nodefony create app`. Trois fichiers, et une base qui répond.
223
+
224
+ ### 1. Déclarer la connexion
225
+
226
+ Le driver et la cible physique vivent dans la config, **jamais** dans l'entité — c'est ce qui rend le
227
+ changement de moteur indolore.
228
+
229
+ ```ts
230
+ // nodefony.config.ts — un seul connecteur, en SQLite : zéro installation.
231
+ export default defineConfig(() => ({
232
+ modules: [
233
+ "@nodefony/framework",
234
+ use("@nodefony/drizzle", {
235
+ connectors: {
236
+ // "default" est le NOM de la connexion, pas celui du moteur.
237
+ default: { dialect: "sqlite", filename: "nodefony/databases/app.db" },
238
+ },
239
+ }),
240
+ ],
241
+ }));
242
+ ```
243
+
244
+ ### 2. Décrire l'entité, et la déclarer au module
245
+
246
+ `defineEntity()` (`defineEntity.ts:48`) n'a **aucun effet de bord** : importer ce fichier n'inscrit
247
+ rien. C'est le décorateur `entities()` (`entitiesDecorator.ts:56`) posé sur le module qui inscrit la
248
+ liste, à la phase `onRegister` — avant que le connecteur n'ouvre et ne crée les tables.
249
+
250
+ ```ts
251
+ // src/modules/blog/index.ts
252
+ import { randomUUID } from "node:crypto";
253
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
254
+ import { Module } from "nodefony";
255
+ import { defineEntity, entities } from "@nodefony/orm-core";
256
+
257
+ // Le schéma est du Drizzle NATIF : tous les types du moteur restent disponibles.
258
+ export const articleTable = sqliteTable("Article", {
259
+ id: text("id")
260
+ .primaryKey()
261
+ .$defaultFn(() => randomUUID()),
262
+ title: text("title").notNull(),
263
+ // Défaut posé côté JS : le DDL dérivé n'émet pas les DEFAULT SQL.
264
+ views: integer("views")
265
+ .notNull()
266
+ .$defaultFn(() => 0),
267
+ publishedAt: integer("publishedAt"), // null = brouillon
268
+ });
269
+
270
+ /** Une ligne d'`Article`, telle que la rend le repository. */
271
+ export interface ArticleRow {
272
+ id: string;
273
+ title: string;
274
+ views: number;
275
+ publishedAt: number | null;
276
+ }
277
+
278
+ // Pas de `connector` ici : il est résolu au boot (défaut `"default"`).
279
+ export const ArticleEntity = defineEntity({
280
+ name: "Article",
281
+ module: "blog",
282
+ schema: articleTable,
283
+ });
284
+
285
+ @entities([ArticleEntity])
286
+ class Blog extends Module {}
287
+
288
+ export default Blog;
289
+ ```
290
+
291
+ ### 3. Lire et écrire
292
+
293
+ Le repository s'obtient par le registre (ou par injection dans un controller). À partir de là, plus
294
+ une ligne de code ne nomme Drizzle.
295
+
296
+ ```ts
297
+ // nodefony/service/ArticleService.ts
298
+ import { AbstractCrudService, ormRegistry, paginate } from "@nodefony/orm-core";
299
+ import type { IRepository } from "@nodefony/orm-core";
300
+
301
+ /** Le type de ligne exporté à côté de l'entité (rappelé ici pour l'extrait). */
302
+ interface ArticleRow {
303
+ id: string;
304
+ title: string;
305
+ views: number;
306
+ publishedAt: number | null;
307
+ }
308
+
309
+ /** Le métier vit dans le service — REST, WebSocket et CLI l'appellent tous. */
310
+ export class ArticleService extends AbstractCrudService<ArticleRow> {
311
+ constructor(repository: IRepository<ArticleRow>) {
312
+ super("articleService", repository);
313
+ }
314
+ }
315
+
316
+ export async function demo(): Promise<void> {
317
+ const articles = ormRegistry
318
+ .get("default")
319
+ .getRepository<ArticleRow>("Article");
320
+
321
+ await articles.create({ title: "Bonjour" });
322
+
323
+ // `$null: true` → IS NULL. Une égalité `= NULL` serait toujours fausse en SQL.
324
+ const brouillons = await articles.find({ publishedAt: { $null: true } });
325
+
326
+ // Compteur atomique : jamais de lecture-modification-écriture, donc jamais de course.
327
+ await articles.increment({ title: "Bonjour" }, { views: 1 });
328
+
329
+ // Une page, sans jamais matérialiser toute la table.
330
+ const page = await paginate(articles, {
331
+ limit: 20,
332
+ order: [["views", "DESC"]],
333
+ });
334
+
335
+ console.log(brouillons.length, page.total, page.hasNext);
336
+ }
337
+ ```
338
+
339
+ ### Ce qu'on observe
340
+
341
+ Au démarrage, le driver crée la table et le récap de boot annonce la connexion
342
+ (`reportOrmBootLines()`, `ormWiring.ts:60`). Le data plane confirme depuis l'extérieur :
343
+
344
+ ```bash
345
+ # Les connecteurs enregistrés et leur état
346
+ curl -s http://localhost:5151/nodefony/orm/api/orms
347
+ # [{"name":"default","default":true,"connected":true,"entityCount":1}]
348
+
349
+ # Le modèle canonique : colonnes + relations, tel que l'ERD Studio le consomme
350
+ curl -s http://localhost:5151/nodefony/orm/api/entity/Article
351
+
352
+ # Le nombre de lignes par entité (un COUNT(*) par table, à la demande)
353
+ curl -s http://localhost:5151/nodefony/orm/api/counts
354
+ # {"Article":1}
355
+ ```
356
+
357
+ ## 🏗️ Architecture interne
358
+
359
+ Deux registres, un cycle de vie. Tout le reste en découle.
360
+
361
+ ```mermaid
362
+ sequenceDiagram
363
+ participant K as Kernel
364
+ participant M as Module (blog)
365
+ participant ER as entityRegistry
366
+ participant D as Driver (DrizzleService)
367
+ participant OR as ormRegistry
368
+
369
+ K->>M: onRegister
370
+ M->>ER: register(Article → connecteur "default")
371
+ Note over ER: PHASE CRITIQUE — avant toute connexion
372
+ K->>D: onBoot
373
+ D->>OR: register("default", orm)
374
+ D->>ER: list() → les entités de ce connecteur
375
+ D->>D: connect() → CREATE TABLE IF NOT EXISTS
376
+ D-->>K: fire("onOrmReady")
377
+ Note over K: onServersReady → récap de boot
378
+ ```
379
+
380
+ **`entityRegistry`** (`EntityRegistry.ts:147`) indexe les entités à **deux** niveaux — nom puis
381
+ connecteur — parce qu'une même entité logique (`User`) peut vivre sur plusieurs connexions. Demander
382
+ `get("User")` sans préciser le connecteur alors que deux le portent **lève** plutôt que de deviner
383
+ (`EntityRegistry.get()`, `EntityRegistry.ts:54`). Le stockage est un `Object.create(null)` alloué
384
+ **au premier enregistrement** : une application sans base ne paie rien.
385
+
386
+ **`ormRegistry`** (`OrmRegistry.ts:88`) associe un nom de connexion à son instance `IOrm`. Un doublon
387
+ de nom **lève** (`OrmRegistry.register()`, `OrmRegistry.ts:26`) : deux connexions homonymes seraient
388
+ un bug silencieux, jamais une intention.
389
+
390
+ **La phase d'inscription est le piège n°1.** Les connecteurs se branchent à `onBoot` et créent les
391
+ tables à ce moment. Inscrire une entité à `onBoot` la met donc en **course** avec `connect()` : selon
392
+ l'ordre des écouteurs, la table existe ou non. `entities()` s'accroche à `onRegister`
393
+ (`entitiesDecorator.ts:66`), strictement antérieur — sûr par construction. C'est la différence de
394
+ comportement avec `@controllers`, qui lui reste à `onBoot`.
395
+
396
+ **`Orm.connect()`** (`Orm.ts:54`) est une **template method** : elle mesure la latence, alimente le
397
+ moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts:74`), et
398
+ **jamais** `connect()` — sinon l'événement et l'instrumentation disparaissent.
399
+
400
+ ## 🧰 Les contrats — la surface publique
401
+
402
+ Quatre interfaces. Les signatures exactes vivent dans le graphe généré
403
+ (`jq '.symbols.IRepository' .ai/symbols.json`) — elles ne sont pas recopiées ici, elles
404
+ divergeraient.
405
+
406
+ ### [`IOrm`](../../drizzle/docs/index.md) — une connexion logique
407
+
408
+ `IOrm` (`IOrm.ts:12`) représente **une** connexion nommée. Il ouvre et ferme
409
+ (`connect`/`disconnect`), rend les repositories (`IOrm.getRepository()`, `IOrm.ts:33`), ouvre une
410
+ transaction (`IOrm.transaction()`, `IOrm.ts:41`) et expose la trappe native
411
+ (`IOrm.getNativeConnection()`, `IOrm.ts:51`).
412
+
413
+ Quatre méthodes sont **optionnelles** — un adapter qui ne les implémente pas dégrade proprement au
414
+ lieu d'échouer : `describeEntity()` (`IOrm.ts:78`, colonnes pour l'ERD), `describeConnection()`
415
+ (`IOrm.ts:71`, driver et cible **sans credential**), `ping()` (`IOrm.ts:82`, aller-retour réel) et
416
+ `probe()` (`IOrm.ts:92`, métriques driver, qui ne doit **jamais** lever).
417
+
418
+ ### [`IRepository`](tutorial-entity.md) — les quinze verbes
419
+
420
+ `IRepository<T>` (`IRepository.ts:197`) est la seule surface que ton métier devrait connaître. Les
421
+ verbes se choisissent sur **la garantie** qu'ils apportent, pas sur leur nom.
422
+
423
+ | Verbe | Ce qu'il garantit | Ancre |
424
+ | --------------------- | -------------------------------------------------------------------- | ---------------------------- |
425
+ | `find` / `findOne` | lecture filtrée + eager-load + tri + bornes | `IRepository.ts:213` |
426
+ | `count` / `exists` | compter, ou juste savoir s'il y en a un (sans charger de colonne) | `IRepository.ts:378`, `:335` |
427
+ | `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts:240` |
428
+ | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
429
+ | `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
430
+ | `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
431
+ | `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts:296` |
432
+ | `increment` | `SET f = f + ?` atomique — compteurs, quotas, rate-limit | `IRepository.ts:287` |
433
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:298` |
434
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:328` |
435
+ | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts:355` |
436
+ | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
437
+
438
+ > [!IMPORTANT]
439
+ > `updateOne` est atomique **par construction** : une seule requête (`UPDATE … RETURNING` en SQL,
440
+ > `findOneAndUpdate` en Mongo), jamais un `UPDATE` suivi d'une relecture. La différence n'est pas
441
+ > cosmétique : la relecture rendrait `null` **à tort** dès que le critère porte sur un champ qu'on
442
+ > vient de modifier — `updateOne({ status: "pending" }, { status: "done" })` ne retrouve plus rien
443
+ > après coup (`IRepository.ts:221`).
444
+
445
+ Quelques usages, un par garantie :
446
+
447
+ ```ts ignore
448
+ // Insertion par lots : une seule requête, l'ordre est conservé.
449
+ await articles.createMany([{ title: "A" }, { title: "B" }]);
450
+
451
+ // Existence sans charger la ligne (ni compter la table).
452
+ if (await articles.exists({ title: "A" })) {
453
+ /* … */
454
+ }
455
+
456
+ // Claim-and-remove : on prend le job ET on le retire, sans que deux workers l'obtiennent.
457
+ const job = await jobs.findOneAndDelete({ status: "queued" });
458
+
459
+ // Upsert : le seuil ne recule jamais, même sur deux appels simultanés.
460
+ await quotas.upsert(
461
+ { userId },
462
+ { seuil: { $max: Date.now() } },
463
+ { createdAt: Date.now() },
464
+ );
465
+ ```
466
+
467
+ L'`upsert` mérite une note. Son `DO UPDATE` est **inconditionnel** — MySQL n'accepte pas de `WHERE`
468
+ sur `ON DUPLICATE KEY UPDATE`. Une valeur qui ne doit jamais régresser porte donc sa condition
469
+ **dans la valeur écrite**, via `UpdateOperators` (`IRepository.ts:94`) : `$max`/`$min` se traduisent
470
+ en `MAX()` (sqlite), `GREATEST()` (postgres, mysql) ou `$max` natif (Mongo) — **une** instruction
471
+ atomique sur les quatre backends.
472
+
473
+ ### [`IEntity`](tutorial-entity.md) — la description d'une table
474
+
475
+ `IEntity` (`IEntity.ts:37`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
476
+ d'un moteur), un `schema` natif du driver, et des `relations` déclaratives (`IEntityRelation`,
477
+ `IEntity.ts:4`). Deux champs facultatifs servent la lisibilité d'un gros modèle : `module` (qui
478
+ apporte l'entité) et `domain` (`IEntity.ts:58`, la classification métier — l'axe qui rend navigable
479
+ une base de plusieurs centaines de tables).
480
+
481
+ Dans une application, on ne construit pas un `IEntity` à la main : on écrit un `IEntityDefinition`
482
+ (`defineEntity.ts:15`) — le même objet **sans** `connector`, justement parce que le connecteur est
483
+ une donnée de configuration, résolue au boot.
484
+
485
+ ### [`ITransaction`](../../drizzle/docs/index.md) — tout ou rien
486
+
487
+ `ITransaction` (`ITransaction.ts:8`) expose `commit`, `rollback`, `savepoint` (`ITransaction.ts:20`),
488
+ `rollbackTo` et la trappe `getNative`. En pratique on ne l'appelle presque jamais directement : on
489
+ passe par `IOrm.transaction()`, qui valide si le travail se résout et annule s'il lève.
490
+
491
+ ```ts ignore
492
+ await orm.transaction(async (tx) => {
493
+ const auteur = await users.withTransaction(tx).create({ email: "x@y.z" });
494
+ await articles
495
+ .withTransaction(tx)
496
+ .create({ title: "Hello", userId: auteur.id });
497
+ // une exception ici ⇒ rollback des DEUX écritures ; sinon commit automatique
498
+ });
499
+ ```
500
+
501
+ `withTransaction(tx)` rend une **vue** du repository, pas un état global : rien n'est stocké dans un
502
+ contexte implicite, donc deux transactions concurrentes ne peuvent pas se mélanger.
503
+
504
+ > [!WARNING]
505
+ > Une transaction porte sur **un seul** connecteur. Les transactions réparties (2PC) ne sont pas
506
+ > garanties : écrire dans `default` et `analytics` dans un même `transaction()` ne donne aucune
507
+ > atomicité entre les deux (`ITransaction.ts:5`).
508
+
509
+ ## 🔎 Les critères de recherche
510
+
511
+ Un critère est un objet. Chaque clé est un champ ; chaque valeur est soit une **égalité**, soit un
512
+ objet d'**opérateurs**.
513
+
514
+ ```ts ignore
515
+ await articles.find({ title: "Hello" }); // égalité
516
+ await articles.find({ views: { $gte: 100, $lt: 1000 } }); // deux opérateurs = ET
517
+ await articles.find({ id: { $in: ids } }); // appartenance
518
+ await articles.find({ title: { $like: "Hel%" } }); // motif SQL
519
+ await articles.find({ publishedAt: { $null: false } }); // IS NOT NULL
520
+ ```
521
+
522
+ Les dix opérateurs reconnus sont figés dans `OPERATOR_KEYS` (`criteria.ts:13`) — source unique
523
+ partagée par tous les adapters : `$eq $ne $gt $gte $lt $lte $in $nin $like $null`. Plusieurs
524
+ opérateurs sur un même champ se combinent en **ET**.
525
+
526
+ **Le `null` est le piège que le contrat désamorce.** En SQL, `colonne = NULL` est **toujours faux** :
527
+ un filtre « la colonne est vide » écrit naïvement ne matcherait jamais rien, sans erreur. Deux formes
528
+ équivalentes le résolvent — la valeur nue `{ publishedAt: null }` et l'opérateur
529
+ `{ publishedAt: { $null: true } }` (`IRepository.ts:65`), qui produisent tous deux un `IS NULL`. La
530
+ valeur nue n'est d'ailleurs **ouverte par le typage que si le champ est nullable** (`FieldCriteria`,
531
+ `IRepository.ts:128`) : chercher `IS NULL` sur une colonne non-nullable est une erreur de
532
+ raisonnement, et le compilateur la refuse.
533
+
534
+ **Comment un objet est reconnu comme filtre plutôt que comme valeur** : `isFieldOperators()`
535
+ (`criteria.ts:42`) ne l'interprète que si **toutes** ses clés sont des opérateurs connus. Une colonne
536
+ JSON ou un sous-document (`{ meta: { auteur: "…" } }`) reste donc une égalité — c'est ce qui évite
537
+ qu'une donnée métier soit prise pour une requête.
538
+
539
+ **Ce que le critère ne couvre pas** : les `OR` logiques, les sous-requêtes, les agrégats, les
540
+ jointures arbitraires. Ce n'est pas un oubli — c'est la limite du portable, et la sortie est
541
+ `getNativeConnection()`. L'erreur `UnknownCriteriaField` (`errors.ts:23`) le dit d'ailleurs
542
+ explicitement dans son message, avec la liste des champs connus de l'entité (diagnostic d'une faute
543
+ de frappe).
544
+
545
+ ## 📄 Pagination portable
546
+
547
+ `paginate()` (`paginate.ts:47`) construit une page **au-dessus** des primitives que tout adapter
548
+ implémente déjà (`find` avec `limit`/`offset`/`order`, et `count`). Aucun driver n'a eu à changer.
549
+
550
+ Deux décisions le rendent utilisable sur une grosse table :
551
+
552
+ 1. **`hasNext` sans `COUNT`** — on demande `limit + 1` lignes ; si la ligne supplémentaire arrive,
553
+ il y a une suite, et on la retire du résultat.
554
+ 2. **`total` optionnel** — le `COUNT(*)` est coûteux, il n'est payé que si `withTotal` n'est pas
555
+ `false`. C'est la distinction « Page » (avec total) et « Slice » (sans), reprise de Spring Data.
556
+
557
+ Les bornes sont normalisées plutôt que propagées : un `limit` de `0` ou un `offset` négatif est
558
+ ramené dans le domaine valide (`paginate.ts:28`) — un `find({ limit: 0 })` a un comportement qui
559
+ dépend du dialecte, donc on ne le laisse pas sortir.
560
+
561
+ Le contrat de page lui-même (`IPage`, `IPageQuery`) vit dans le **cœur**
562
+ (`src/nodefony/src/types/IPage.ts:22`), pas ici : il est partagé par tous les stores paginés du
563
+ framework (sessions HTTP, jetons, audit…). `orm-core` ne fait que l'enrichir du `criteria` typé, sous
564
+ le nom `PageQuery` (`IPage.ts:18`).
565
+
566
+ ## ⚙️ Le socle CRUD — `AbstractCrudService`
567
+
568
+ Une classe à étendre pour que chaque entité expose son CRUD **de la même manière**, et qu'il n'y ait
569
+ qu'un seul endroit à modifier quand la règle métier change.
570
+
571
+ ```ts ignore
572
+ export class ArticleService extends AbstractCrudService<ArticleRow> {
573
+ constructor(repository: IRepository<ArticleRow>) {
574
+ super("articleService", repository);
575
+ }
576
+
577
+ /** Valide avant insertion : un rejet devient un 422, quel que soit le transport. */
578
+ protected override beforeCreate(
579
+ data: Partial<ArticleRow>,
580
+ ): Partial<ArticleRow> {
581
+ return createArticleSchema.parse(data) as Partial<ArticleRow>;
582
+ }
583
+ }
584
+ ```
585
+
586
+ `AbstractCrudService` (`AbstractCrudService.ts:37`) sépare volontairement deux régimes :
587
+
588
+ - **Lectures** (`find`, `findOne`, `findById`, `count`, `findPage`) — **délégation pure**. Aucun
589
+ hook, aucun événement : c'est le chemin chaud, il ne doit rien payer.
590
+ - **Mutations** (`create`, `updateOne`, `delete`) — encadrées par des hooks _template method_
591
+ (`beforeCreate`, `AbstractCrudService.ts:185`, et ses six frères) puis un événement de cycle de vie
592
+ `onCreated` / `onUpdated` / `onDeleted`, **émis seulement si la mutation a eu lieu**
593
+ (`AbstractCrudService.ts:167`). L'audit, l'invalidation de cache ou une notification Studio s'y
594
+ abonnent sans toucher au service.
595
+
596
+ `findPage()` (`AbstractCrudService.ts:110`) est la primitive à utiliser pour **toute** liste
597
+ d'administration : elle ne charge qu'une page, quelle que soit la taille de la table.
598
+
599
+ > [!WARNING]
600
+ > Ce service est un **singleton** partagé (il étend `Service`, donc l'injection l'instancie une
601
+ > fois). C'est légitime **parce qu'il est sans état** : ne jamais écrire `this.currentUser = …`
602
+ > pendant une requête. L'utilisateur courant, le tenant, la transaction voyagent dans le contexte —
603
+ > jamais sur l'instance.
604
+
605
+ ## 🗄️ Backends pris en charge
606
+
607
+ Deux drivers implémentent les contrats. Le contrat `IRepository` est tenu **en entier** par les
608
+ deux : les quinze verbes existent des deux côtés — par exemple l'upsert, avec
609
+ `DrizzleRepository.upsert()` (`DrizzleRepository.ts:868`) et `MongooseRepository.upsert()`
610
+ (`MongooseRepository.ts:343`).
611
+
612
+ | Capacité | `@nodefony/drizzle` | `@nodefony/mongoose` |
613
+ | -------------------------------------- | ----------------------------------- | ---------------------------------- |
614
+ | Moteurs | SQLite, PostgreSQL, MySQL / MariaDB | MongoDB |
615
+ | Contrat `IRepository` (15 verbes) | complet | complet |
616
+ | Eager-load `{ relations }` | oui | oui (`populate`) |
617
+ | Transactions + savepoints | oui | oui (replica set requis par Mongo) |
618
+ | Colonnes pour l'ERD (`describeEntity`) | oui (`DrizzleOrm.ts:1593`) | oui (`MongooseOrm.ts:558`) |
619
+ | Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor` | non câblée |
620
+ | Sonde profonde (`probe`) | oui (`DrizzleOrm.ts:1495`) | oui (`MongooseOrm.ts:529`) |
621
+
622
+ **Les « stores » du framework, eux, ne sont pas alignés — et c'est un choix.** Un adapter déclare ce
623
+ qu'il porte dans son `package.json`, clé `nodefony.stores` :
624
+
625
+ | Store | drizzle | mongoose |
626
+ | ------------- | ------- | -------- |
627
+ | `session` | ✅ | ✅ |
628
+ | `user` | ✅ | ✅ |
629
+ | `tokens` | ✅ | ✅ |
630
+ | `passkeys` | ✅ | ✅ |
631
+ | `webhooks` | ✅ | ✅ |
632
+ | `totp` | ✅ | — |
633
+ | `audit` | ✅ | — |
634
+ | `idempotency` | ✅ | — |
635
+
636
+ > [!NOTE]
637
+ > La couverture est **adaptée à la nature de chaque backend**, ce n'est pas une parité SQL × NoSQL à
638
+ > atteindre. Un store d'idempotence veut une contrainte d'unicité et un `ON CONFLICT` — le terrain du
639
+ > SQL. Lire ce tableau comme « mongoose est incomplet » serait un contresens : il porte exactement ce
640
+ > qu'un déploiement Mongo attend de lui. La liste fait autorité côté code
641
+ > (`@nodefony/drizzle/package.json`, clé `nodefony.stores`) — elle n'est pas un commentaire.
642
+
643
+ ## 🧩 Extension — brancher son propre driver
644
+
645
+ Le contrat minimal tient en peu de choses, parce que `orm-core` fournit déjà la plomberie.
646
+
647
+ 1. **Étendre `Orm`** (`Orm.ts:29`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
648
+ `getRepository()`, `transaction()`, `getNativeConnection()`. L'enregistrement dans `ormRegistry`
649
+ est fait par le constructeur de base — il n'y a rien à écrire. **Ne jamais surcharger
650
+ `connect()`** : c'est la template method qui émet `onOrmReady` et instrumente la latence.
651
+ 2. **Implémenter `IRepository<T>`** en traduisant les critères. `isFieldOperators()` (`criteria.ts:42`)
652
+ et `isUpdateOperators()` (`criteria.ts:87`) sont fournis pour que la détection soit **identique**
653
+ partout — les réécrire, c'est fabriquer une divergence.
654
+ 3. **Lever `UnknownCriteriaField`** (`errors.ts:23`) sur un champ inconnu, et **appeler
655
+ `assertOrderOption()`** (`readOptions.ts:39`) une fois, en amont, sur `options.order`. C'est le
656
+ prix de la promesse de portabilité : un adapter qui retesterait la forme lui-même finirait par
657
+ diverger de l'autre.
658
+ 4. **Câbler le data plane** en une ligne à `onKernelBoot` : `wireOrmAdminPlane(this.kernel)`
659
+ (`ormWiring.ts:31`) monte les routes admin, la santé et le diagnostic riche. Et
660
+ `resolveOrmFlowEnabled(kernel)` (`ormWiring.ts:96`) décide si la sonde de flux s'allume — hors
661
+ production par défaut, `NF_ORM_FLOW=1` force.
662
+ 5. **Déclarer les capacités** dans `package.json` (`nodefony.storeKind`, `nodefony.stores`) : c'est
663
+ ainsi que le framework sait ce que ton adapter sait faire.
664
+
665
+ Les méthodes optionnelles d'`IOrm` (`describeEntity`, `describeConnection`, `ping`, `probe`)
666
+ s'ajoutent ensuite : sans elles l'adapter fonctionne, il est simplement moins observable.
667
+
668
+ ## 📡 Observabilité — Studio
669
+
670
+ Deux sondes **indépendantes**, et un data plane qui les expose. La séparation est délibérée : mesurer
671
+ la santé ne doit pas coûter le débit, et observer le débit ne doit pas réveiller la base.
672
+
673
+ **`connectionMonitor`** (`ConnectionMonitor.ts:197`) suit le **cycle de vie** d'une connexion :
674
+ première connexion, reconnexions, erreurs récentes, et une fenêtre de latence de ping
675
+ (`ConnectionMonitor.recordPing()`, `ConnectionMonitor.ts:106`). Il est alimenté par `Orm.connect()`
676
+ sans que l'adapter ait à y penser.
677
+
678
+ **`queryFlowMonitor`** (`QueryFlowMonitor.ts:146`) suit le **débit** : total de requêtes, latence
679
+ moyenne et EWMA, pire latence, et un anneau borné à vingt requêtes lentes. Trois propriétés le
680
+ rendent sûr en production :
681
+
682
+ - **éteint par défaut** — `enabled = false` : coût nul tant qu'on ne l'allume pas ;
683
+ - **lazy** — la table de statistiques n'est allouée qu'au premier enregistrement ;
684
+ - **le SQL n'est capté que sur le chemin lent** (`QueryFlowMonitor.record()`, `QueryFlowMonitor.ts:101`) :
685
+ jamais de sérialisation de requête au cas nominal ;
686
+ - **aucune persistance** — tout est en mémoire vive. Une sonde n'écrit jamais dans la base qu'elle
687
+ observe, et le débit par seconde est **dérivé côté lecteur** (delta entre deux relevés), donc rien
688
+ n'est muté à la lecture.
689
+
690
+ Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:421`) expose huit points
691
+ d'entrée, tous filtrables par `?connector=` :
692
+
693
+ | Point d'entrée | Ce qu'il rend |
694
+ | ------------------- | ----------------------------------------------------------------- |
695
+ | `orms` | les connecteurs, leur état, leur nombre d'entités |
696
+ | `entities` | le modèle complet : colonnes + relations |
697
+ | `entity/{name}` | une entité (404 si inconnue) |
698
+ | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:184`) |
699
+ | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
700
+ | `connection/health` | état, latence, erreurs, reconnexions, sondes |
701
+ | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts:310`) |
702
+ | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts:345`) ou `jsonschema` |
703
+
704
+ Ce graphe canonique est **la pièce maîtresse**, pas le diagramme : c'est une donnée sérialisable qui
705
+ sert à la fois l'ERD de Studio, un export vers un outil tiers, et le contexte d'un agent IA
706
+ (text-to-SQL, RAG). Le dessin n'en est qu'une projection.
707
+
708
+ Côté Studio, trois écrans le consomment : `/nodefony/orm` (vue d'ensemble), `/nodefony/orm/:pid`
709
+ (le détail d'un worker, en cluster) et `/nodefony/orm-entity` (le détail d'une entité).
710
+
711
+ ## ⚡ Performance & mémoire
712
+
713
+ Le socle est conçu pour **ne rien coûter quand il ne sert pas**.
714
+
715
+ | Ce qui pourrait coûter | Ce que fait `orm-core` |
716
+ | -------------------------- | ----------------------------------------------------------------------------------- |
717
+ | Registres au démarrage | alloués au **premier** enregistrement (`EntityRegistry.ts:25`, `OrmRegistry.ts:27`) |
718
+ | Contrats | interfaces TypeScript — effacées à la compilation, zéro coût runtime |
719
+ | Lectures d'un service CRUD | délégation directe : ni hook, ni événement sur le chemin chaud |
720
+ | Sonde de flux | éteinte par défaut ; table allouée au premier relevé ; anneau des lentes borné à 20 |
721
+ | Sérialisation du SQL | seulement sur le chemin **lent**, jamais au cas nominal |
722
+ | Comptage d'une page | `COUNT(*)` évitable (`withTotal: false`), `hasNext` obtenu par `limit + 1` |
723
+
724
+ Deux conséquences pratiques pour ton code : préférer `exists()` à `findOne() !== null` (aucune
725
+ colonne n'est chargée), et `increment()` à une lecture suivie d'une écriture (une requête au lieu de
726
+ deux, et pas de course).
727
+
728
+ ## ⚠️ Pièges
729
+
730
+ | Symptôme | Cause | Correction |
731
+ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
732
+ | « no entity registered under "X" » au premier appel | l'entité n'a jamais été inscrite (fichier importé mais `defineEntity` est sans effet de bord) | l'ajouter à `@entities([...])` sur le module (`entitiesDecorator.ts:56`) |
733
+ | La table n'existe pas alors que l'entité est déclarée | inscription faite à `onBoot` → course avec `connect()` | inscrire à `onRegister` — c'est ce que fait `entities()` (`entitiesDecorator.ts:66`) |
734
+ | « entity "User" exists on multiple connectors … specify one » | la même entité vit sur plusieurs connecteurs | préciser le connecteur : `entityRegistry.get("User", "analytics")` (`EntityRegistry.ts:54`) |
735
+ | Un filtre « champ vide » ne remonte jamais rien | `colonne = NULL` est toujours faux en SQL | `{ champ: { $null: true } }` ou la valeur nue `{ champ: null }` (`IRepository.ts:65`) |
736
+ | `UnknownCriteriaField` sur un champ qui existe « pourtant » | faute de frappe, ou champ calculé absent du schéma | lire les champs connus dans le message ; pour du natif, passer par `getNativeConnection()` |
737
+ | `updateOne` rend `null` alors que la ligne a bien changé | ancien réflexe `UPDATE` + relecture (le critère porte sur le champ modifié) | utiliser `updateOne`, atomique par construction (`IRepository.ts:252`) |
738
+ | Un `upsert` écrase une valeur qui devait progresser | le `DO UPDATE` est inconditionnel (contrainte MySQL) | poser la condition **dans** la valeur : `{ seuil: { $max: v } }` (`IRepository.ts:94`) |
739
+ | Un objet de critère est pris pour une égalité (colonne JSON) | comportement **voulu** : une valeur n'est un filtre que si **toutes** ses clés sont des opérateurs | c'est la protection ; pour filtrer dedans, passer au natif (`criteria.ts:42`) |
740
+ | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts:74`), jamais `connect()` |
741
+ | Une entité déclarée dans un module reste invisible | le module embarque sa **propre copie** du registre (singleton dédoublé) | externaliser `@nodefony/orm-core` dans le `rolldown.config.ts` du module |
742
+ | Rien dans `flow` alors que la base travaille | la sonde est éteinte hors développement, ou le driver n'a pas de tap | `NF_ORM_FLOW=1` (`ormWiring.ts:96`) ; le tap n'est câblé que côté Drizzle |
743
+
744
+ ## 🧪 Tests & couverture
745
+
746
+ Les compteurs de cette page sont **recomptés à chaque génération** — jamais figés dans le texte. Ils
747
+ portent sur les tests **unitaires** du socle : `tests/unit/**` couvre les deux registres, les
748
+ critères, la pagination, le service CRUD, les décorateurs, les deux moniteurs, le câblage et le data
749
+ plane.
750
+
751
+ **Ce que ces tests prouvent** : la logique pure du socle, sans base de données. C'est cohérent —
752
+ `orm-core` ne contient aucun driver, donc rien à connecter.
753
+
754
+ **Ce qu'ils ne prouvent pas, et où c'est prouvé.** Le contrat `IRepository` n'a de sens qu'exécuté
755
+ sur une vraie base. Cette preuve vit chez les drivers, sous forme de **bancs de contrat** — une même
756
+ suite rejouée par dialecte :
757
+
758
+ - `@nodefony/drizzle` — `tests/integration/repository-contract.ts` est le banc commun, rejoué par
759
+ `repository-contract-sqlite.test.ts` (toujours exécuté, base en mémoire),
760
+ `repository-contract-postgres.e2e.test.ts` et `repository-contract-mysql.e2e.test.ts` ;
761
+ - `@nodefony/mongoose` — `tests/integration/orm-core-mongoose.test.ts` exerce le même contrat côté
762
+ documentaire.
763
+
764
+ > [!WARNING]
765
+ > **Un compteur vert ne prouve pas qu'une base a été touchée.** Les bancs sur serveur réel se
766
+ > **skippent** quand leur variable d'infra est absente — et un test skippé compte comme vert.
767
+ > PostgreSQL exige `NF_PG_URL`, MySQL/MariaDB `NF_MYSQL_URL`, MongoDB `NF_MONGO_TEST_URI`. La source
768
+ > unique de ces variables et des commandes Docker correspondantes est `vitest.gates.ts` à la racine
769
+ > du dépôt ; les suites concernées affichent un récapitulatif de couverture en fin d'exécution
770
+ > (`gateReporter`). **Lire ce bloc avant de conclure « vert ».**
771
+
772
+ Ce qui **manque** aujourd'hui, dit franchement : pas de banc de **charge** ni de test **mémoire**
773
+ dédié au socle (le coût réel se mesure chez les drivers, sur une vraie base) — voir le skill
774
+ `nodefony-load-test` pour monter un banc, et `nodefony-check-memory-health` pour la gate mémoire du
775
+ pipeline. Couverture de lignes : `npm run coverage` dans le module (le pourcentage vit dans le
776
+ rapport vitest, jamais dans cette page — il vieillirait).
777
+
778
+ ## 🔗 Pour aller plus loin
779
+
780
+ - ⬆️ **Retour** : [Toute la documentation](../../../../../docs/index.md) ·
781
+ [Par où démarrer](../../../../../docs/demarrer.md)
782
+ - 🧭 **Les drivers** : [`@nodefony/drizzle`](../../drizzle/docs/index.md) (SQL, par défaut) ·
783
+ [`@nodefony/mongoose`](../../mongoose/docs/index.md) (MongoDB) et sa
784
+ [configuration](../../mongoose/docs/configuration.md)
785
+ - 🚀 **Débuter** : [Créer une entité, de zéro à `find()`](tutorial-entity.md)
786
+ - 🏛️ **Transverse** : [guide persistance](../../../../../docs/guides/persistence.md) ·
787
+ [stockage de session](../../../../../docs/guides/session-storage.md) ·
788
+ [configuration d'une application](../../../../../docs/guides/configuration.md)
789
+ - 📐 **Décisions** :
790
+ [ADR-0003 — abstraction Repository multi-ORM](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md)
791
+ - 📖 [Lexique général](../../../../../docs/lexique.md) du framework.