@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
@@ -0,0 +1,577 @@
1
+ ---
2
+ title: "Créer une entité — du premier fichier à la première requête"
3
+ navTitle: Créer une entité
4
+ lang: fr
5
+ module: "@nodefony/orm-core"
6
+ topic: orm-core
7
+ section: "Données"
8
+ audience: [developer]
9
+ tags:
10
+ [orm, entite, repository, tutoriel, crud, criteres, operateurs, drizzle, page]
11
+ version: "doc"
12
+ status: stable
13
+ updated: 2026-07-19
14
+ source: "src/packages/@nodefony/orm-core/docs/tutorial-entity.md"
15
+ ---
16
+
17
+ # Créer une entité — du premier fichier à la première requête
18
+
19
+ > Le pas-à-pas d'une première table : déclarer la connexion, décrire le schéma, inscrire
20
+ > l'entité, obtenir le repository, lire et écrire. Rien n'est supposé connu, et le code de
21
+ > chaque étape est complet. À la fin, une table existe en base et ton code la lit sans jamais
22
+ > nommer Drizzle.
23
+
24
+ 📍 [Documentation](../../../../../docs/index.md) › [ORM — le socle](index.md) › **Créer une entité**
25
+
26
+ ## 🧠 Schéma général
27
+
28
+ Trois objets s'emboîtent, toujours dans le même ordre. Le tutoriel les parcourt de gauche à
29
+ droite.
30
+
31
+ ```mermaid
32
+ flowchart LR
33
+ CFG["1 · nodefony.config.ts<br/><b>le connecteur</b><br/>« default » = sqlite/postgres/mysql"]
34
+ ENT["2 · nodefony/entity/Post.ts<br/><b>l'entité</b><br/>nom logique + schéma natif"]
35
+ MOD["3 · index.ts<br/><b>@entities([PostEntity])</b><br/>inscription au boot"]
36
+ REPO["4 · le repository<br/><b>IRepository&lt;PostRow&gt;</b><br/>find · create · updateOne…"]
37
+ DB[("Base")]
38
+
39
+ CFG -->|"nomme le moteur"| REPO
40
+ ENT -->|"vise le NOM du connecteur"| MOD
41
+ MOD -->|"crée la table au boot"| REPO
42
+ REPO --> DB
43
+ ```
44
+
45
+ Le point à retenir dès maintenant : **l'entité ne connaît que le nom de la connexion**, jamais le
46
+ moteur. Passer de SQLite à PostgreSQL ne touche que le fichier de configuration.
47
+
48
+ ## 📖 Lexique
49
+
50
+ | Terme | Sens |
51
+ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
52
+ | ORM | _Object-Relational Mapping_ : la bibliothèque qui traduit tes objets en lignes de base (Drizzle pour le SQL, Mongoose pour Mongo). |
53
+ | Connecteur | Le **nom** d'une connexion déclarée en configuration (`"default"`, `"analytics"`) — jamais le nom d'un moteur. |
54
+ | Dialecte | La variante SQL servie par un même driver : `sqlite`, `postgres`, `mysql`. |
55
+ | Entité | La description d'une table : un nom logique, un schéma natif, un connecteur cible. |
56
+ | Schéma natif | La table écrite dans la syntaxe du driver (`sqliteTable(...)` en Drizzle). Aucune couche à contourner. |
57
+ | Repository | L'objet avec lequel on lit et écrit : `IRepository<T>`, la seule surface que voit ton métier. |
58
+ | Registre | Le singleton qui relie les noms aux objets : `entityRegistry` pour les entités, `ormRegistry` pour les connexions. |
59
+ | Critère | Le filtre d'une requête : `{ title: "Bonjour" }` (égalité) ou `{ views: { $gte: 100 } }` (opérateurs). |
60
+ | DDL | _Data Definition Language_ : le SQL qui crée ou modifie les tables (`CREATE TABLE`, `ALTER`). |
61
+ | Atomique | Qui tient en **une** requête, donc que rien ne peut interrompre à mi-chemin (pas de course entre deux appels). |
62
+ | Upsert | « insère **ou** met à jour » en une seule instruction, sur conflit de clé unique. |
63
+ | Page / Slice | Une tranche de résultats : avec le total compté (Page) ou sans (Slice, meilleur marché). |
64
+
65
+ ## Qu'est-ce que c'est ?
66
+
67
+ Une **entité**, c'est la fiche d'identité d'une table : un nom que ton code utilisera
68
+ (`"Post"`), un schéma qui décrit les colonnes, et le nom de la connexion où elle vit.
69
+
70
+ Compare avec une prise électrique. Le **schéma** dit la forme de la fiche (les colonnes). Le
71
+ **connecteur** dit dans quelle prise on la branche — mais pas si le courant vient d'un barrage ou
72
+ d'un panneau solaire. Ça, c'est le rôle de la configuration, et c'est pour cette raison que
73
+ changer de moteur ne touche pas ton code.
74
+
75
+ Une fois l'entité inscrite, tu ne la manipules plus jamais directement : tu demandes son
76
+ **repository**, et c'est lui qui porte les verbes (`find`, `create`, `updateOne`…). Un repository
77
+ est le contrat `IRepository<T>` (`IRepository.ts:176`), identique quel que soit le moteur.
78
+
79
+ > [!NOTE]
80
+ > `@nodefony/orm-core` n'est **pas un module à démarrer** : rien à ajouter dans `modules: [...]`.
81
+ > C'est une bibliothèque de contrats. Le module, c'est le **driver** — `@nodefony/drizzle` pour
82
+ > le SQL, `@nodefony/mongoose` pour MongoDB.
83
+
84
+ ## La vision Nodefony
85
+
86
+ Deux partis pris expliquent la forme exacte des étapes qui suivent. Les connaître évite de se
87
+ battre contre le cadre.
88
+
89
+ **1. Le connecteur est une donnée de configuration, jamais de code.** C'est pourquoi le
90
+ descripteur qu'on écrit (`IEntityDefinition`, `defineEntity.ts:15`) est un `IEntity`
91
+ (`IEntity.ts:37`) **privé de son `connector`** : celui-ci est résolu au démarrage, avec `"default"`
92
+ pour valeur de repli (`DEFAULT_CONNECTOR`, `entitiesDecorator.ts:12`). Figer la connexion dans le
93
+ fichier d'entité interdirait de servir la même table depuis une base différente selon
94
+ l'environnement.
95
+
96
+ **2. L'inscription est déclarative, et elle a lieu tôt.** `defineEntity()`
97
+ (`defineEntity.ts:48`) n'a **aucun effet de bord** : importer un fichier d'entité n'inscrit rien.
98
+ C'est le décorateur `entities()` (`entitiesDecorator.ts:56`), posé sur la classe du module, qui
99
+ inscrit la liste — à la phase `onRegister` (`entitiesDecorator.ts:66`), strictement **avant** que
100
+ le connecteur ne s'ouvre et ne crée les tables.
101
+
102
+ Le bénéfice est concret : une entité oubliée se voit **dans une liste**, pas dans un import à
103
+ effet de bord qu'on a omis. Et l'ordre des phases écarte par construction la course « la table
104
+ n'existait pas encore », qui ne se reproduit jamais en test.
105
+
106
+ ## 🚀 Démarrage rapide
107
+
108
+ Vu d'une application générée par `nodefony create app`. Trois fichiers, puis une table qui
109
+ répond.
110
+
111
+ ### 1. Déclarer la connexion
112
+
113
+ Le moteur et sa cible physique vivent ici, et nulle part ailleurs. SQLite ne demande aucune
114
+ installation : c'est le bon choix pour ce premier tour.
115
+
116
+ ```ts
117
+ // nodefony.config.ts — le connecteur "default" est le NOM de la connexion,
118
+ // pas celui du moteur : c'est `dialect` qui nomme le moteur.
119
+ export default defineConfig(() => ({
120
+ modules: [
121
+ "@nodefony/framework",
122
+ use("@nodefony/drizzle", {
123
+ connectors: {
124
+ default: { dialect: "sqlite", filename: "nodefony/databases/app.db" },
125
+ },
126
+ }),
127
+ ],
128
+ }));
129
+ ```
130
+
131
+ ### 2. Décrire la table et l'inscrire
132
+
133
+ Le schéma est du **Drizzle natif** : tous les types et options du moteur restent disponibles.
134
+ Le décorateur `@entities([...])` est ce qui rend l'entité réelle.
135
+
136
+ ```ts
137
+ // index.ts — pour une première table, tout tient dans le fichier d'entrée de l'app.
138
+ import { randomUUID } from "node:crypto";
139
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
140
+ import { Kernel, Module } from "nodefony";
141
+ import type { DefaultOptionsService } from "nodefony";
142
+ import { defineEntity, entities } from "@nodefony/orm-core";
143
+
144
+ export const postTable = sqliteTable("Post", {
145
+ id: text("id")
146
+ .primaryKey()
147
+ .$defaultFn(() => randomUUID()),
148
+ title: text("title").notNull(),
149
+ // Défaut posé côté JS : le DDL dérivé au boot n'émet pas les DEFAULT SQL.
150
+ views: integer("views")
151
+ .notNull()
152
+ .$defaultFn(() => 0),
153
+ publishedAt: integer("publishedAt"), // null = brouillon
154
+ });
155
+
156
+ /** Une ligne de `Post`, telle que la rend le repository. */
157
+ export interface PostRow {
158
+ id: string;
159
+ title: string;
160
+ views: number;
161
+ publishedAt: number | null;
162
+ }
163
+
164
+ // Pas de `connector` ici : il est résolu au démarrage (défaut `"default"`).
165
+ export const PostEntity = defineEntity({
166
+ name: "Post",
167
+ module: "app",
168
+ schema: postTable,
169
+ });
170
+
171
+ // `config` est déjà importé par ton index.ts (`./nodefony.config.js`) — inchangé.
172
+ declare const config: DefaultOptionsService;
173
+
174
+ @entities([PostEntity])
175
+ class App extends Module {
176
+ constructor(kernel: Kernel) {
177
+ super("app", kernel, import.meta.url, config);
178
+ }
179
+ }
180
+
181
+ export default App;
182
+ ```
183
+
184
+ ### 3. Lire et écrire
185
+
186
+ Le repository s'obtient par le registre des connexions. À partir de cette ligne, plus rien dans
187
+ ton code ne nomme Drizzle.
188
+
189
+ ```ts
190
+ // nodefony/service/postDemo.ts
191
+ import { ormRegistry, paginate } from "@nodefony/orm-core";
192
+
193
+ /** Le type de ligne exporté à côté de l'entité (rappelé ici pour l'extrait). */
194
+ interface PostRow {
195
+ id: string;
196
+ title: string;
197
+ views: number;
198
+ publishedAt: number | null;
199
+ }
200
+
201
+ export async function demo(): Promise<void> {
202
+ const posts = ormRegistry.get("default").getRepository<PostRow>("Post");
203
+
204
+ // CRÉER — rend la ligne persistée (id généré, défauts appliqués).
205
+ const post = await posts.create({ title: "Bonjour" });
206
+
207
+ // LIRE — sans critère : toute la table.
208
+ const tous = await posts.find();
209
+
210
+ // LIRE filtré — `$null: true` produit un IS NULL ; `= NULL` serait toujours faux en SQL.
211
+ const brouillons = await posts.find({ publishedAt: { $null: true } });
212
+
213
+ // MODIFIER — au plus une ligne, atomiquement, et la rend.
214
+ const renomme = await posts.updateOne({ id: post.id }, { title: "Salut" });
215
+
216
+ // COMPTER, puis SUPPRIMER (rend le nombre de lignes supprimées).
217
+ const total = await posts.count();
218
+ const supprimees = await posts.delete({ id: post.id });
219
+
220
+ // PAGINER — une page, sans jamais matérialiser toute la table.
221
+ const page = await paginate(posts, { limit: 20, order: [["views", "DESC"]] });
222
+
223
+ console.log(
224
+ tous.length,
225
+ brouillons.length,
226
+ renomme,
227
+ total,
228
+ supprimees,
229
+ page.hasNext,
230
+ );
231
+ }
232
+ ```
233
+
234
+ ### Ce qu'on observe
235
+
236
+ Au démarrage, le driver crée la table et le récap de boot affiche la connexion sous
237
+ « Services & ORM » (`reportOrmBootLines()`, `ormWiring.ts:60`). En journal `DEBUG`, chaque entité
238
+ inscrite laisse une ligne `ADD ENTITY` (`entitiesDecorator.ts:84`) — c'est la preuve la plus
239
+ directe que le décorateur a bien travaillé.
240
+
241
+ Le data plane confirme depuis l'extérieur, sans ouvrir la base :
242
+
243
+ ```bash
244
+ # Les connecteurs enregistrés, leur état, leur nombre d'entités
245
+ curl -s http://localhost:5151/nodefony/orm/api/orms
246
+ # [{"name":"default","default":true,"connected":true,"entityCount":1}]
247
+
248
+ # Le modèle canonique de l'entité : colonnes + relations
249
+ curl -s http://localhost:5151/nodefony/orm/api/entity/Post
250
+
251
+ # Le nombre de lignes par entité
252
+ curl -s http://localhost:5151/nodefony/orm/api/counts
253
+ # {"Post":1}
254
+ ```
255
+
256
+ ## 🧩 Le pas-à-pas commenté
257
+
258
+ Les quatre gestes du démarrage rapide, cette fois expliqués — c'est ici qu'on comprend
259
+ **pourquoi** chacun est nécessaire.
260
+
261
+ ### Étape 1 — nommer la connexion
262
+
263
+ `connectors` associe un **nom** à un moteur et à sa cible. Le nom `"default"` n'a rien de magique :
264
+ c'est simplement celui que les entités visent quand elles n'en nomment pas d'autre.
265
+
266
+ Une deuxième base se déclare exactement pareil, sous un autre nom :
267
+
268
+ ```ts ignore
269
+ use("@nodefony/drizzle", {
270
+ connectors: {
271
+ default: { dialect: "sqlite", filename: "nodefony/databases/app.db" },
272
+ analytics: { dialect: "postgres", url: process.env.PG_URL },
273
+ },
274
+ });
275
+ ```
276
+
277
+ Les dialectes disponibles, leurs options et le choix de la cible sont détaillés dans la page du
278
+ driver : [`@nodefony/drizzle`](../../drizzle/docs/index.md).
279
+
280
+ ### Étape 2 — écrire le schéma
281
+
282
+ Le schéma est du Drizzle ordinaire. Un besoin non couvert par les exemples — colonne
283
+ `numeric(12,4)`, index composite, contrainte de clé étrangère — s'écrit directement dans ce
284
+ fichier, sans rien contourner.
285
+
286
+ Deux conséquences du DDL dérivé au boot, à connaître dès maintenant :
287
+
288
+ - la table est créée par un `CREATE TABLE IF NOT EXISTS` — **modifier le schéma n'altère pas une
289
+ table déjà créée** (aucun `ALTER` n'est émis) ;
290
+ - les **index** déclarés et les `DEFAULT` **SQL** ne sont pas émis. D'où les défauts posés côté
291
+ JavaScript (`$defaultFn`), qui s'appliquent quoi qu'il arrive.
292
+
293
+ > [!TIP]
294
+ > En développement, la façon la plus rapide de prendre en compte une colonne ajoutée est de
295
+ > supprimer le fichier SQLite et de redémarrer. En production, cela relève d'une migration.
296
+
297
+ ### Étape 3 — déclarer l'entité
298
+
299
+ `defineEntity()` (`defineEntity.ts:48`) est une fonction d'**identité typée** : elle attache le
300
+ type et rend son argument, rien de plus. Aucun registre n'est touché.
301
+
302
+ Cette absence d'effet de bord est délibérée : elle permet d'importer une entité depuis un test,
303
+ un script ou un autre module **sans** déclencher son inscription dans un singleton global.
304
+
305
+ Les champs facultatifs qui servent tôt : `module` (qui apporte l'entité) et `domain`
306
+ (`IEntity.ts:58`), l'axe de classification qui rend navigable une base de plusieurs centaines de
307
+ tables dans l'ERD Studio.
308
+
309
+ ### Étape 4 — brancher l'entité au module
310
+
311
+ C'est `entities()` (`entitiesDecorator.ts:56`) qui inscrit, et lui seul. Trois propriétés valent
312
+ d'être connues :
313
+
314
+ 1. **Phase `onRegister`** (`entitiesDecorator.ts:66`) — strictement avant l'ouverture des
315
+ connexions. C'est ce qui rend l'ordre sûr par construction, contrairement à `@controllers`, qui
316
+ travaille à `onBoot`.
317
+ 2. **Idempotent** — une entité déjà inscrite sur le même connecteur est ignorée (un module peut
318
+ être instancié deux fois : tests, rechargement). Une vraie collision, elle, lève
319
+ (`EntityRegistry.register()`, `EntityRegistry.ts:24`).
320
+ 3. **Connecteur commun** — `entities([...], { connector: "analytics" })` pose toute la liste sur
321
+ une autre base ; une entité qui porte son propre `connector` (`IEntity.ts:42`) garde le sien.
322
+
323
+ ### Le raccourci — `nodefony create entity`
324
+
325
+ Tout ce qui précède est scaffoldé par une commande, dans l'application ou dans un module. Les
326
+ champs se déclarent en positionnels, façon Rails :
327
+
328
+ ```bash
329
+ nodefony create entity Post title:string content:text views:int
330
+ # --id uuid7|uuid4|serial --soft-delete --no-timestamps
331
+ # --module <nom> --no-controller --connector <nom>
332
+ ```
333
+
334
+ Elle écrit le fichier d'entité (`nodefony/entity/Post.ts`), les schémas de validation, un service
335
+ CRUD, un controller REST + WebSocket et les tests — puis **câble elle-même** `@entities([...])` et
336
+ `@controllers([...])`. Le schéma vit alors dans son propre fichier, et le point d'entrée ne garde
337
+ que l'import plus la ligne du décorateur.
338
+
339
+ > [!WARNING]
340
+ > La commande refuse de générer si aucun module ORM n'est déclaré dans l'application. C'est
341
+ > volontaire : une entité sans driver produit du code mort qui ne compile même pas. Ajoute
342
+ > `use("@nodefony/drizzle", …)` au manifeste `modules`, puis relance.
343
+
344
+ ## 🧰 Lire et écrire — le contrat complet
345
+
346
+ Le repository porte **quinze verbes**, pas cinq. Ils se choisissent sur la **garantie** qu'ils
347
+ apportent, jamais sur leur nom.
348
+
349
+ | Verbe | Ce qu'il garantit | Ancre |
350
+ | --------------------- | ------------------------------------------------------------------ | ---------------------------- |
351
+ | `find` / `findOne` | lecture filtrée, avec tri, bornes et eager-load | `IRepository.ts:213`, `:192` |
352
+ | `create` | insère une ligne et rend sa version persistée (id, défauts) | `IRepository.ts:240` |
353
+ | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
354
+ | `updateOne` | modifie **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
355
+ | `updateMany` | modifie toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
356
+ | `upsert` | insère **ou** met à jour sur conflit de clé, en une instruction | `IRepository.ts:296` |
357
+ | `increment` | `SET f = f + ?` atomique — compteurs, quotas, limitation de débit | `IRepository.ts:287` |
358
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:298` |
359
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:328` |
360
+ | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, `pop` atomique | `IRepository.ts:355` |
361
+ | `count` / `exists` | compter, ou juste savoir s'il y en a une (sans charger de colonne) | `IRepository.ts:378`, `:335` |
362
+ | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
363
+
364
+ > [!IMPORTANT]
365
+ > **Il n'existe pas de méthode `update()`.** Le choix est explicite et il est intentionnel :
366
+ > `IRepository.updateOne()` (`IRepository.ts:269`) pour une ligne — atomique, et elle **rend** la
367
+ > ligne modifiée —, `IRepository.updateMany()` (`IRepository.ts:274`) pour un lot — qui rend le
368
+ > **nombre** de lignes touchées. Un verbe unique masquerait cette différence de garantie, qui est
369
+ > précisément ce qu'on veut choisir en connaissance de cause.
370
+
371
+ Quelques usages, un par garantie :
372
+
373
+ ```ts ignore
374
+ // Insertion par lots : une seule requête, l'ordre est conservé.
375
+ await posts.createMany([{ title: "A" }, { title: "B" }]);
376
+
377
+ // Existence sans charger la ligne (ni compter la table).
378
+ if (await posts.exists({ title: "A" })) {
379
+ /* … */
380
+ }
381
+
382
+ // Compteur atomique : jamais de lecture-modification-écriture, donc jamais de course.
383
+ await posts.increment({ id }, { views: 1 });
384
+
385
+ // Claim-and-remove : on prend le job ET on le retire, sans que deux workers l'obtiennent.
386
+ const job = await jobs.findOneAndDelete({ status: "queued" });
387
+
388
+ // Tout ou rien : une exception dans le bloc annule les DEUX écritures.
389
+ await orm.transaction(async (tx) => {
390
+ const auteur = await users.withTransaction(tx).create({ email: "x@y.z" });
391
+ await posts.withTransaction(tx).create({ title: "Hello", userId: auteur.id });
392
+ });
393
+ ```
394
+
395
+ Deux réflexes de performance, dès le premier jour : préférer `IRepository.exists()`
396
+ (`IRepository.ts:335`) à `findOne(...) !== null` — aucune colonne n'est chargée —, et
397
+ `IRepository.increment()` (`IRepository.ts:325`) à une lecture suivie d'une écriture : une requête
398
+ au lieu de deux, et pas de course.
399
+
400
+ ## 🔎 Filtrer — les opérateurs de critère
401
+
402
+ Un critère est un objet. Chaque clé est un champ ; chaque valeur est soit une **égalité**, soit
403
+ un objet d'**opérateurs**.
404
+
405
+ ```ts ignore
406
+ await posts.find({ title: "Bonjour" }); // égalité
407
+ await posts.find({ views: { $gte: 100, $lt: 1000 } }); // deux opérateurs = ET
408
+ await posts.find({ id: { $in: ids } }); // appartenance
409
+ await posts.find({ title: { $like: "Bon%" } }); // motif SQL
410
+ await posts.find({ publishedAt: { $null: false } }); // IS NOT NULL
411
+ ```
412
+
413
+ **Dix** opérateurs sont reconnus, figés dans `OPERATOR_KEYS` (`criteria.ts:13`) — source unique
414
+ partagée par tous les drivers :
415
+
416
+ | Opérateur | Effet | Opérateur | Effet |
417
+ | --------- | -------------------------------- | --------- | ---------------------------------- |
418
+ | `$eq` | égal (identique à la valeur nue) | `$lte` | inférieur ou égal |
419
+ | `$ne` | différent | `$in` | appartient à la liste |
420
+ | `$gt` | strictement supérieur | `$nin` | n'appartient pas à la liste |
421
+ | `$gte` | supérieur ou égal | `$like` | motif SQL (`%`, `_`), champs texte |
422
+ | `$lt` | strictement inférieur | `$null` | `IS NULL` (`true`) / `IS NOT NULL` |
423
+
424
+ Plusieurs opérateurs sur un même champ se combinent en **ET**.
425
+
426
+ > [!WARNING]
427
+ > **`$null` est celui qu'on oublie, et il coûte cher.** En SQL, `colonne = NULL` est **toujours
428
+ > faux** : un filtre « la colonne est vide » écrit naïvement ne remonte jamais rien — sans la
429
+ > moindre erreur. Deux formes équivalentes le résolvent : la valeur nue `{ publishedAt: null }`
430
+ > et l'opérateur `{ publishedAt: { $null: true } }` (`FieldOperators.$null`, `IRepository.ts:65`).
431
+ > La valeur nue n'est ouverte par le typage que si le champ est **nullable** (`FieldCriteria`,
432
+ > `IRepository.ts:128`) : chercher `IS NULL` sur une colonne non-nullable est une erreur de
433
+ > raisonnement, et le compilateur la refuse.
434
+
435
+ **Comment un objet est reconnu comme filtre plutôt que comme valeur** : `isFieldOperators()`
436
+ (`criteria.ts:42`) ne l'interprète que si **toutes** ses clés sont des opérateurs connus. Une
437
+ colonne JSON ou un sous-document (`{ meta: { auteur: "…" } }`) reste donc une égalité — c'est ce
438
+ qui évite qu'une donnée métier soit prise pour une requête.
439
+
440
+ ### Les opérateurs d'écriture — `$max` et `$min`
441
+
442
+ Une seconde famille, distincte, s'applique **en écriture** dans un `upsert` :
443
+ `UpdateOperators` (`IRepository.ts:94`), dont les clés reconnues sont listées par
444
+ `UPDATE_OPERATOR_KEYS` (`criteria.ts:67`).
445
+
446
+ Ils existent parce qu'un upsert **ne peut pas porter de condition** : son `DO UPDATE` s'applique
447
+ dès qu'il y a conflit de clé (MySQL n'accepte pas de `WHERE` sur `ON DUPLICATE KEY UPDATE`). Pour
448
+ une valeur qui ne doit **jamais reculer**, la condition vit donc dans la valeur écrite.
449
+
450
+ ```ts ignore
451
+ // Le seuil ne recule jamais, même sur deux appels simultanés — une seule instruction.
452
+ await quotas.upsert(
453
+ { userId },
454
+ { seuil: { $max: Date.now() } },
455
+ { createdAt: Date.now() },
456
+ );
457
+ ```
458
+
459
+ Le driver traduit en `MAX()` (sqlite), `GREATEST()` (postgres, mysql) ou `$max` natif (Mongo).
460
+ Sur une ligne dont on sait qu'elle **existe**, ils sont inutiles :
461
+ `updateMany({ id, seuil: { $lt: v } }, { seuil: v })` l'exprime déjà, tout aussi atomiquement.
462
+
463
+ ### Ce que le critère ne couvre pas
464
+
465
+ Les `OR` logiques, les sous-requêtes, les agrégats et les jointures arbitraires n'en font pas
466
+ partie. Ce n'est pas un oubli : c'est la limite de ce qui se porte d'un moteur SQL à MongoDB. La
467
+ sortie est `IOrm.getNativeConnection()` (`IOrm.ts:51`), qui rend la connexion brute du driver.
468
+
469
+ Un champ absent de l'entité lève `UnknownCriteriaField` (`errors.ts:23`), et le message liste les
470
+ champs connus — le diagnostic d'une faute de frappe est immédiat.
471
+
472
+ ## 📄 Pager les résultats
473
+
474
+ `find()` avec `limit`/`offset`/`order` (`RepositoryReadOptions`, `IRepository.ts:153`) suffit pour
475
+ une tranche. Pour une vraie page — celle qui sait s'il y a une suite — utilise `paginate()`
476
+ (`paginate.ts:47`) :
477
+
478
+ ```ts ignore
479
+ const page = await paginate(posts, {
480
+ limit: 20,
481
+ offset: 0,
482
+ criteria: { publishedAt: { $null: false } },
483
+ order: [["publishedAt", "DESC"]],
484
+ withTotal: false, // le COUNT(*) est coûteux : on ne le paie que si on l'affiche
485
+ });
486
+ // page.items · page.hasNext · page.total (undefined si withTotal: false)
487
+ ```
488
+
489
+ `hasNext` est obtenu **sans `COUNT`** : la fonction demande `limit + 1` lignes et retire la ligne
490
+ excédentaire. C'est la distinction « Page » (avec total) / « Slice » (sans), et elle change tout
491
+ sur une grosse table.
492
+
493
+ ## ⚙️ Ranger le métier dans un service
494
+
495
+ Dès que la logique dépasse l'appel direct, elle quitte le controller. `AbstractCrudService`
496
+ (`AbstractCrudService.ts:37`) donne le socle : les lectures sont une **délégation pure** (aucun
497
+ surcoût sur le chemin chaud), les mutations sont encadrées par des points d'extension puis un
498
+ événement de cycle de vie.
499
+
500
+ ```ts ignore
501
+ export class PostService extends AbstractCrudService<PostRow> {
502
+ constructor(repository: IRepository<PostRow>) {
503
+ super("postService", repository);
504
+ }
505
+
506
+ /** Valide avant insertion : un rejet devient un 422, quel que soit le transport. */
507
+ protected override beforeCreate(data: Partial<PostRow>): Partial<PostRow> {
508
+ return createPostSchema.parse(data) as Partial<PostRow>;
509
+ }
510
+ }
511
+ ```
512
+
513
+ L'intérêt est de n'avoir **qu'un seul endroit** à modifier quand la règle change : la même méthode
514
+ sert la route REST, l'appel WebSocket, un résolveur GraphQL et une commande CLI. Pour toute liste
515
+ d'administration, la primitive est `AbstractCrudService.findPage()` (`AbstractCrudService.ts:110`)
516
+ — elle ne charge qu'une page, quelle que soit la taille de la table.
517
+
518
+ > [!WARNING]
519
+ > Ce service est un **singleton** partagé. C'est légitime **parce qu'il est sans état** : ne jamais
520
+ > écrire `this.utilisateurCourant = …` pendant une requête. L'utilisateur, le tenant et la
521
+ > transaction voyagent dans le contexte, jamais sur l'instance.
522
+
523
+ ## ⚠️ Pièges
524
+
525
+ | Symptôme | Cause | Correction |
526
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
527
+ | `posts.update is not a function` | la méthode `update()` n'existe pas dans le contrat | `updateOne` pour une ligne (`IRepository.ts:269`), `updateMany` pour un lot (`IRepository.ts:274`) |
528
+ | « no entity registered under "Post" » au premier appel | le fichier d'entité est importé, mais `defineEntity()` est **sans effet de bord** | ajouter l'entité à `@entities([...])` sur le module (`entitiesDecorator.ts:56`) |
529
+ | La table n'existe pas alors que l'entité est déclarée | inscription faite à `onBoot` → course avec l'ouverture du connecteur | inscrire à `onRegister` — c'est ce que fait `entities()` (`entitiesDecorator.ts:66`) |
530
+ | Une colonne ajoutée au schéma reste absente de la table | le DDL du boot est un `CREATE TABLE IF NOT EXISTS` : aucun `ALTER` n'est émis | supprimer la base de développement et redémarrer, ou passer par une migration |
531
+ | Un `DEFAULT` SQL ou un index déclaré n'apparaît pas | le DDL dérivé ne les émet pas | poser le défaut côté JavaScript (`$defaultFn`) ; créer l'index par migration |
532
+ | 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`) |
533
+ | `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()` |
534
+ | `connector: "sqlite"` ne trouve aucune connexion | `connector` nomme une **connexion**, pas un moteur | mettre la clé de `connectors` (`"default"`) ; le moteur est le `dialect` de la config |
535
+ | « entity "Post" exists on multiple connectors … specify one » | la même entité est inscrite sur deux connexions | préciser laquelle : `entityRegistry.get("Post", "analytics")` (`EntityRegistry.ts:54`) |
536
+ | « no ORM registered under "default" » au premier appel | le repository est demandé avant l'ouverture du connecteur, ou le driver n'est pas dans `modules` | construire le service **au premier usage**, pas au chargement (`ormRegistry.get()`, `OrmRegistry.ts:45`) |
537
+ | 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 |
538
+ | Une transaction ne couvre pas les deux bases | une transaction porte sur **un seul** connecteur (2PC non garanti) | rassembler les écritures atomiques sur une seule connexion |
539
+
540
+ ## 🧪 Tests & couverture
541
+
542
+ Les compteurs de cette page sont recomptés à chaque génération — jamais figés dans le texte. Ils
543
+ portent sur les tests **unitaires** de `@nodefony/orm-core` : les deux registres, le décorateur
544
+ `entities`, les critères, la pagination, le service CRUD, les moniteurs et le data plane.
545
+
546
+ Les mécanismes enseignés ici sont couverts directement :
547
+ `tests/unit/entitiesDecorator.test.ts` (inscription, idempotence, phase, connecteur commun),
548
+ `tests/unit/EntityRegistry.test.ts` (résolution, ambiguïté), `tests/unit/criteria.test.ts`
549
+ (reconnaissance des opérateurs) et `tests/unit/paginate.test.ts` (bornes, `hasNext`, total).
550
+
551
+ **Ce que ces tests ne prouvent pas.** Le contrat `IRepository` n'a de sens qu'exécuté sur une
552
+ vraie base : cette preuve vit chez les **drivers**, sous forme de bancs de contrat rejoués par
553
+ dialecte — `@nodefony/drizzle` (sqlite en mémoire, PostgreSQL et MySQL en end-to-end) et
554
+ `@nodefony/mongoose` pour le documentaire.
555
+
556
+ > [!WARNING]
557
+ > Un compteur vert ne prouve pas qu'une base a été touchée : les bancs sur serveur réel se
558
+ > **skippent** faute de leur variable d'infra, et un test skippé compte comme vert. La source
559
+ > unique de ces variables et des commandes Docker correspondantes est `vitest.gates.ts` à la
560
+ > racine du dépôt ; les suites concernées affichent leur récapitulatif en fin d'exécution.
561
+
562
+ ## 🔗 Pour aller plus loin
563
+
564
+ - ⬆️ **Retour au hub** : [ORM — le contrat de persistance](index.md) ·
565
+ [Toute la documentation](../../../../../docs/index.md)
566
+ - 🧰 **La suite logique** : la section [Les contrats](index.md#-les-contrats--la-surface-publique)
567
+ du hub (les quinze verbes en détail, transactions, eager-load) et
568
+ [Les critères de recherche](index.md#-les-critères-de-recherche)
569
+ - 🗄️ **Les drivers** : [`@nodefony/drizzle`](../../drizzle/docs/index.md) (dialectes, création des
570
+ tables, ERD) · [`@nodefony/mongoose`](../../mongoose/docs/index.md) et sa
571
+ [configuration](../../mongoose/docs/configuration.md)
572
+ - 🏛️ **Passer en production** : [guide persistance](../../../../../docs/guides/persistence.md) ·
573
+ [configuration d'une application](../../../../../docs/guides/configuration.md) ·
574
+ [stockage de session](../../../../../docs/guides/session-storage.md)
575
+ - 📐 **Pourquoi cette architecture** :
576
+ [ADR-0003 — abstraction Repository multi-ORM](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md)
577
+ - 📖 [Lexique général](../../../../../docs/lexique.md) du framework
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@nodefony/orm-core",
3
+ "version": "10.0.0-alpha.1",
4
+ "description": "Un contrat de dépôt de données pour Nodefony, plusieurs moteurs : interfaces, registre et classes de base pour le support multi-ORM",
5
+ "contributors": [],
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "start": "node dist/index.js",
18
+ "build": "rimraf dist && rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
19
+ "dev": "rolldown -c rolldown.config.ts --watch",
20
+ "clean": "rimraf dist",
21
+ "test": "vitest run",
22
+ "coverage": "vitest run --coverage",
23
+ "typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p tsconfig.tests.json"
24
+ },
25
+ "private": false,
26
+ "engines": {
27
+ "node": ">=24.0.0"
28
+ },
29
+ "keywords": [
30
+ "nodefony",
31
+ "orm",
32
+ "repository",
33
+ "database",
34
+ "abstraction",
35
+ "typescript",
36
+ "esm"
37
+ ],
38
+ "peerDependencies": {
39
+ "nodefony": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "26.4.1",
43
+ "nodefony": "*",
44
+ "rimraf": "6.1.3",
45
+ "vitest": "5.0.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/nodefony/nodefony-core.git",
50
+ "directory": "src/packages/@nodefony/orm-core"
51
+ },
52
+ "license": "CECILL-B",
53
+ "licenses": [
54
+ {
55
+ "type": "CECILL-B",
56
+ "url": "http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html"
57
+ }
58
+ ],
59
+ "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
60
+ "readmeFilename": "README.md",
61
+ "files": [
62
+ "dist",
63
+ "docs"
64
+ ],
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "dependencies": {},
69
+ "homepage": "https://nodefony.github.io/nodefony-core/",
70
+ "bugs": {
71
+ "url": "https://github.com/nodefony/nodefony-core/issues"
72
+ }
73
+ }