@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.
- package/LICENSE +544 -0
- package/README.md +131 -0
- package/dist/index.js +22 -0
- package/dist/nodefony/interfaces/IEntity.js +1 -0
- package/dist/nodefony/interfaces/IOrm.js +1 -0
- package/dist/nodefony/interfaces/IOrmFlow.js +1 -0
- package/dist/nodefony/interfaces/IOrmGraph.js +1 -0
- package/dist/nodefony/interfaces/IOrmMigrations.js +12 -0
- package/dist/nodefony/interfaces/IOrmProbe.js +1 -0
- package/dist/nodefony/interfaces/IPage.js +1 -0
- package/dist/nodefony/interfaces/IRepository.js +1 -0
- package/dist/nodefony/interfaces/ITransaction.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/AbstractCrudService.js +199 -0
- package/dist/nodefony/src/ConnectionMonitor.js +181 -0
- package/dist/nodefony/src/Entity.js +42 -0
- package/dist/nodefony/src/EntityRegistry.js +109 -0
- package/dist/nodefony/src/Orm.js +297 -0
- package/dist/nodefony/src/OrmAdminApi.js +491 -0
- package/dist/nodefony/src/OrmRegistry.js +75 -0
- package/dist/nodefony/src/QueryFlowMonitor.js +128 -0
- package/dist/nodefony/src/buildOrmLeanHealth.js +54 -0
- package/dist/nodefony/src/criteria.js +176 -0
- package/dist/nodefony/src/decorators/entitiesDecorator.js +67 -0
- package/dist/nodefony/src/decorators/entityDecorator.js +51 -0
- package/dist/nodefony/src/decorators/index.js +5 -0
- package/dist/nodefony/src/decorators/metadataStore.js +38 -0
- package/dist/nodefony/src/decorators/repositoryDecorator.js +35 -0
- package/dist/nodefony/src/defineEntity.js +27 -0
- package/dist/nodefony/src/errors.js +76 -0
- package/dist/nodefony/src/ormWiring.js +78 -0
- package/dist/nodefony/src/paginate.js +55 -0
- package/dist/nodefony/src/readOptions.js +52 -0
- package/dist/nodefony/src/serviceWiring.js +1 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IEntity.d.ts +65 -0
- package/dist/types/nodefony/interfaces/IOrm.d.ts +136 -0
- package/dist/types/nodefony/interfaces/IOrmFlow.d.ts +71 -0
- package/dist/types/nodefony/interfaces/IOrmGraph.d.ts +165 -0
- package/dist/types/nodefony/interfaces/IOrmMigrations.d.ts +145 -0
- package/dist/types/nodefony/interfaces/IOrmProbe.d.ts +70 -0
- package/dist/types/nodefony/interfaces/IPage.d.ts +24 -0
- package/dist/types/nodefony/interfaces/IRepository.d.ts +369 -0
- package/dist/types/nodefony/interfaces/ITransaction.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/src/AbstractCrudService.d.ts +158 -0
- package/dist/types/nodefony/src/ConnectionMonitor.d.ts +86 -0
- package/dist/types/nodefony/src/Entity.d.ts +44 -0
- package/dist/types/nodefony/src/EntityRegistry.d.ts +60 -0
- package/dist/types/nodefony/src/Orm.d.ts +197 -0
- package/dist/types/nodefony/src/OrmAdminApi.d.ts +84 -0
- package/dist/types/nodefony/src/OrmRegistry.d.ts +58 -0
- package/dist/types/nodefony/src/QueryFlowMonitor.d.ts +53 -0
- package/dist/types/nodefony/src/buildOrmLeanHealth.d.ts +15 -0
- package/dist/types/nodefony/src/criteria.d.ts +131 -0
- package/dist/types/nodefony/src/decorators/entitiesDecorator.d.ts +48 -0
- package/dist/types/nodefony/src/decorators/entityDecorator.d.ts +49 -0
- package/dist/types/nodefony/src/decorators/index.d.ts +10 -0
- package/dist/types/nodefony/src/decorators/metadataStore.d.ts +54 -0
- package/dist/types/nodefony/src/decorators/repositoryDecorator.d.ts +30 -0
- package/dist/types/nodefony/src/defineEntity.d.ts +43 -0
- package/dist/types/nodefony/src/errors.d.ts +62 -0
- package/dist/types/nodefony/src/ormWiring.d.ts +48 -0
- package/dist/types/nodefony/src/paginate.d.ts +43 -0
- package/dist/types/nodefony/src/readOptions.d.ts +21 -0
- package/dist/types/nodefony/src/serviceWiring.d.ts +24 -0
- package/docs/index.md +791 -0
- package/docs/tutorial-entity.md +577 -0
- 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<T><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.
|