@nodefony/orm-core 10.0.0-beta.3 → 10.0.0-beta.4

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/docs/index.md CHANGED
@@ -177,7 +177,7 @@ garantit que la dépendance va bien du concret vers l'abstrait, et jamais l'inve
177
177
  > [!NOTE]
178
178
  > `orm-core` n'est **pas un module bootable** : pas de classe `Module`, rien à mettre dans
179
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:86`) à leur démarrage.
180
+ > qui s'enregistrent dans `ormRegistry` (`OrmRegistry.ts#ormRegistry`) à leur démarrage.
181
181
 
182
182
  ## La vision Nodefony
183
183
 
@@ -193,7 +193,7 @@ sur-dimensionner pour ce cas serait une erreur.
193
193
 
194
194
  **2. L'abstraction assume de ne pas tout couvrir — et fournit la trappe.** Une jointure arbitraire,
195
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
196
+ un langage de requête maison, `IOrm.getNativeConnection()` (`IOrm.ts#IOrm.getNativeConnection`) rend la connexion brute du
197
197
  driver. Le contrat est honnête : **plus tu recours à la trappe, moins ton code est portable** — et
198
198
  tu le sais en l'écrivant, parce que l'appel est visible.
199
199
 
@@ -201,18 +201,18 @@ tu le sais en l'écrivant, parce que l'appel est visible.
201
201
  Un critère qui référence un champ inexistant (`{ emial: "…" }`) serait **ignoré** par Drizzle — la
202
202
  condition disparaît, la requête rend **toute** la table — et **conservé** par Mongoose, qui rend
203
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
204
+ portabilité s'effondre en silence. D'où `UnknownCriteriaField` (`errors.ts#UnknownCriteriaField`), levée **par les deux
205
205
  drivers** : on échoue tôt, et pareil.
206
206
 
207
207
  La même règle vaut pour les **options** : une forme de tri voisine mais fausse — `{ age: "asc" }` au
208
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
209
+ lignes non triées qu'il croyait triées. `assertOrderOption()` (`readOptions.ts#assertOrderOption`) est appelée par
210
+ chaque adapter en amont de sa requête et lève `InvalidOrderOption` (`errors.ts#InvalidOrderOption`) ; le sens est
211
211
  vérifié en casse exacte, parce qu'un `"desc"` minuscule aurait trié à l'**envers**. Normaliser une
212
212
  entrée utilisateur (casse, `champ:sens`) appartient à la frontière qui la reçoit — `parsePageQuery`
213
213
  le fait —, jamais au repository.
214
214
 
215
- **4. Le contrat va plus loin que le CRUD scolaire.** `IRepository` (`IRepository.ts:197`) porte
215
+ **4. Le contrat va plus loin que le CRUD scolaire.** `IRepository` (`IRepository.ts#IRepository`) porte
216
216
  quinze verbes, pas cinq : les opérations **atomiques** (`upsert`, `increment`, `updateOne`,
217
217
  `findOneAndDelete`) sont dans le contrat parce qu'un `SELECT` suivi d'un `UPDATE` est une **course**,
218
218
  et qu'une course en base ne se rattrape pas côté application.
@@ -227,24 +227,24 @@ Le driver et la cible physique vivent dans la config, **jamais** dans l'entité
227
227
  changement de moteur indolore.
228
228
 
229
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
- }));
230
+ // nodefony/config/drizzle.ts — le fragment que le générateur a posé, que
231
+ // `nodefony.config.ts` passe à `use("@nodefony/drizzle", drizzleConfig())`.
232
+ // Un seul connecteur, en SQLite : zéro installation.
233
+ import type { IDrizzleConfigInput } from "@nodefony/drizzle";
234
+
235
+ export const drizzleConfig = () =>
236
+ ({
237
+ connectors: {
238
+ // "default" est le NOM de la connexion, pas celui du moteur.
239
+ default: { dialect: "sqlite", filename: "var/databases/app.db" },
240
+ },
241
+ }) satisfies IDrizzleConfigInput;
242
242
  ```
243
243
 
244
244
  ### 2. Décrire l'entité, et la déclarer au module
245
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
246
+ `defineEntity()` (`defineEntity.ts#defineEntity`) n'a **aucun effet de bord** : importer ce fichier n'inscrit
247
+ rien. C'est le décorateur `entities()` (`entitiesDecorator.ts#entities`) posé sur le module qui inscrit la
248
248
  liste, à la phase `onRegister` — avant que le connecteur n'ouvre et ne crée les tables.
249
249
 
250
250
  ```ts
@@ -339,7 +339,7 @@ export async function demo(): Promise<void> {
339
339
  ### Ce qu'on observe
340
340
 
341
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 :
342
+ (`reportOrmBootLines()`, `ormWiring.ts#reportOrmBootLines`). Le data plane confirme depuis l'extérieur :
343
343
 
344
344
  ```bash
345
345
  # Les connecteurs enregistrés et leur état
@@ -377,24 +377,24 @@ sequenceDiagram
377
377
  Note over K: onServersReady → récap de boot
378
378
  ```
379
379
 
380
- **`entityRegistry`** (`EntityRegistry.ts:145`) indexe les entités à **deux** niveaux — nom puis
380
+ **`entityRegistry`** (`EntityRegistry.ts#entityRegistry`) indexe les entités à **deux** niveaux — nom puis
381
381
  connecteur — parce qu'une même entité logique (`User`) peut vivre sur plusieurs connexions. Demander
382
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é
383
+ (`EntityRegistry.get()`, `EntityRegistry.ts#EntityRegistry.get`). Le stockage est un `Object.create(null)` alloué
384
384
  **au premier enregistrement** : une application sans base ne paie rien.
385
385
 
386
- **`ormRegistry`** (`OrmRegistry.ts:86`) associe un nom de connexion à son instance `IOrm`. Un doublon
387
- de nom **lève** (`OrmRegistry.register()`, `OrmRegistry.ts:26`) : deux connexions homonymes seraient
386
+ **`ormRegistry`** (`OrmRegistry.ts#ormRegistry`) associe un nom de connexion à son instance `IOrm`. Un doublon
387
+ de nom **lève** (`OrmRegistry.register()`, `OrmRegistry.ts#OrmRegistry.register`) : deux connexions homonymes seraient
388
388
  un bug silencieux, jamais une intention.
389
389
 
390
390
  **La phase d'inscription est le piège n°1.** Les connecteurs se branchent à `onBoot` et créent les
391
391
  tables à ce moment. Inscrire une entité à `onBoot` la met donc en **course** avec `connect()` : selon
392
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
393
+ (`entitiesDecorator.ts#entities`), strictement antérieur — sûr par construction. C'est la différence de
394
394
  comportement avec `@controllers`, qui lui reste à `onBoot`.
395
395
 
396
- **`Orm.connect()`** (`Orm.ts:118`) est une **template method** : elle mesure la latence, alimente le
397
- moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts:363`), et
396
+ **`Orm.connect()`** (`Orm.ts#Orm.connect`) est une **template method** : elle mesure la latence, alimente le
397
+ moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts#Orm.onConnect`), et
398
398
  **jamais** `connect()` — sinon l'événement et l'instrumentation disparaissent.
399
399
 
400
400
  ## 🧰 Les contrats — la surface publique
@@ -405,44 +405,44 @@ divergeraient.
405
405
 
406
406
  ### [`IOrm`](../../drizzle/docs/index.md) — une connexion logique
407
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`).
408
+ `IOrm` (`IOrm.ts#IOrm`) représente **une** connexion nommée. Il ouvre et ferme
409
+ (`connect`/`disconnect`), rend les repositories (`IOrm.getRepository()`, `IOrm.ts#IOrm.getRepository`), ouvre une
410
+ transaction (`IOrm.transaction()`, `IOrm.ts#IOrm.transaction`) et expose la trappe native
411
+ (`IOrm.getNativeConnection()`, `IOrm.ts#IOrm.getNativeConnection`).
412
412
 
413
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:88`, driver et cible **sans credential**), `ping()` (`IOrm.ts:109`, aller-retour réel) et
416
- `probe()` (`IOrm.ts:119`, métriques driver, qui ne doit **jamais** lever).
414
+ lieu d'échouer : `describeEntity()` (`IOrm.ts#IOrm.describeEntity`, colonnes pour l'ERD), `describeConnection()`
415
+ (`IOrm.ts#IOrm.describeConnection`, driver et cible **sans credential**), `ping()` (`IOrm.ts#IOrm.ping`, aller-retour réel) et
416
+ `probe()` (`IOrm.ts#IOrm.probe`, métriques driver, qui ne doit **jamais** lever).
417
417
 
418
418
  ### [`IRepository`](tutorial-entity.md) — les quinze verbes
419
419
 
420
- `IRepository<T>` (`IRepository.ts:197`) est la seule surface que ton métier devrait connaître. Les
420
+ `IRepository<T>` (`IRepository.ts#IRepository`) est la seule surface que ton métier devrait connaître. Les
421
421
  verbes se choisissent sur **la garantie** qu'ils apportent, pas sur leur nom.
422
422
 
423
- | Verbe | Ce qu'il garantit | Ancre |
424
- | --------------------- | ------------------------------------------------------------------------- | -------------------- |
425
- | `find` / `findOne` | lecture filtrée + eager-load + tri + bornes | `IRepository.ts:230` |
426
- | `count` | compter les entités qui correspondent au critère | `IRepository.ts:362` |
427
- | `exists` | savoir s'il y en a au moins un, sans charger de colonne | `IRepository.ts:395` |
428
- | `countDistinct` | compter les valeurs DISTINCTES et non nulles d'un champ, sans les charger | `IRepository.ts:381` |
429
- | `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts:240` |
430
- | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
431
- | `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
432
- | `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
433
- | `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts:296` |
434
- | `increment` | `SET f = f + ?` atomique — compteurs, quotas, rate-limit | `IRepository.ts:287` |
435
- | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
436
- | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
437
- | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts:355` |
438
- | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
423
+ | Verbe | Ce qu'il garantit | Ancre |
424
+ | --------------------- | ------------------------------------------------------------------------- | --------------------------------------------- |
425
+ | `find` / `findOne` | lecture filtrée + eager-load + tri + bornes | `IRepository.ts#IRepository.findOne` |
426
+ | `count` | compter les entités qui correspondent au critère | `IRepository.ts#IRepository.count` |
427
+ | `exists` | savoir s'il y en a au moins un, sans charger de colonne | `IRepository.ts#IRepository.exists` |
428
+ | `countDistinct` | compter les valeurs DISTINCTES et non nulles d'un champ, sans les charger | `IRepository.ts#IRepository.countDistinct` |
429
+ | `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts#IRepository.create` |
430
+ | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts#IRepository.createMany` |
431
+ | `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts#IRepository.updateOne` |
432
+ | `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts#IRepository.updateMany` |
433
+ | `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts#IRepository.upsert` |
434
+ | `increment` | `SET f = f + ?` atomique — compteurs, quotas, rate-limit | `IRepository.ts#IRepository.upsert` |
435
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts#IRepository.delete` |
436
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts#IRepository.deleteOne` |
437
+ | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts#IRepository.findOneAndDelete` |
438
+ | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts#IRepository.withTransaction` |
439
439
 
440
440
  > [!IMPORTANT]
441
441
  > `updateOne` est atomique **par construction** : une seule requête (`UPDATE … RETURNING` en SQL,
442
442
  > `findOneAndUpdate` en Mongo), jamais un `UPDATE` suivi d'une relecture. La différence n'est pas
443
443
  > cosmétique : la relecture rendrait `null` **à tort** dès que le critère porte sur un champ qu'on
444
444
  > vient de modifier — `updateOne({ status: "pending" }, { status: "done" })` ne retrouve plus rien
445
- > après coup (`IRepository.ts:269`).
445
+ > après coup (`IRepository.ts#IRepository.updateOne`).
446
446
 
447
447
  Quelques usages, un par garantie :
448
448
 
@@ -468,27 +468,27 @@ await quotas.upsert(
468
468
 
469
469
  L'`upsert` mérite une note. Son `DO UPDATE` est **inconditionnel** — MySQL n'accepte pas de `WHERE`
470
470
  sur `ON DUPLICATE KEY UPDATE`. Une valeur qui ne doit jamais régresser porte donc sa condition
471
- **dans la valeur écrite**, via `UpdateOperators` (`IRepository.ts:94`) : `$max`/`$min` se traduisent
471
+ **dans la valeur écrite**, via `UpdateOperators` (`IRepository.ts#UpdateOperators`) : `$max`/`$min` se traduisent
472
472
  en `MAX()` (sqlite), `GREATEST()` (postgres, mysql) ou `$max` natif (Mongo) — **une** instruction
473
473
  atomique sur les quatre backends.
474
474
 
475
475
  ### [`IEntity`](tutorial-entity.md) — la description d'une table
476
476
 
477
- `IEntity` (`IEntity.ts:51`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
477
+ `IEntity` (`IEntity.ts#IEntity`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
478
478
  d'un moteur), un `schema` natif du driver, et des `relations` déclaratives (`IEntityRelation`,
479
- `IEntity.ts:4`). Deux champs facultatifs servent la lisibilité d'un gros modèle : `module` (qui
480
- apporte l'entité) et `domain` (`IEntity.ts:72`, la classification métier — l'axe qui rend navigable
479
+ `IEntity.ts#IEntityRelation`). Deux champs facultatifs servent la lisibilité d'un gros modèle : `module` (qui
480
+ apporte l'entité) et `domain` (`IEntity.ts#IEntity.domain`, la classification métier — l'axe qui rend navigable
481
481
  une base de plusieurs centaines de tables). Un troisième, `indexes` (`IEntityIndex`), déclare les
482
482
  index COMPOSITES qu'un adaptateur ne sait pas exprimer dans son schéma : Mongoose les applique
483
483
  (`schema.index()`), Drizzle les ignore, puisqu'il les déclare dans sa table.
484
484
 
485
485
  Dans une application, on ne construit pas un `IEntity` à la main : on écrit un `IEntityDefinition`
486
- (`defineEntity.ts:15`) — le même objet **sans** `connector`, justement parce que le connecteur est
486
+ (`defineEntity.ts#IEntityDefinition`) — le même objet **sans** `connector`, justement parce que le connecteur est
487
487
  une donnée de configuration, résolue au boot.
488
488
 
489
489
  ### [`ITransaction`](../../drizzle/docs/index.md) — tout ou rien
490
490
 
491
- `ITransaction` (`ITransaction.ts:8`) expose `commit`, `rollback`, `savepoint` (`ITransaction.ts:20`),
491
+ `ITransaction` (`ITransaction.ts#ITransaction`) expose `commit`, `rollback`, `savepoint` (`ITransaction.ts#ITransaction.savepoint`),
492
492
  `rollbackTo` et la trappe `getNative`. En pratique on ne l'appelle presque jamais directement : on
493
493
  passe par `IOrm.transaction()`, qui valide si le travail se résout et annule s'il lève.
494
494
 
@@ -508,7 +508,7 @@ contexte implicite, donc deux transactions concurrentes ne peuvent pas se mélan
508
508
  > [!WARNING]
509
509
  > Une transaction porte sur **un seul** connecteur. Les transactions réparties (2PC) ne sont pas
510
510
  > garanties : écrire dans `default` et `analytics` dans un même `transaction()` ne donne aucune
511
- > atomicité entre les deux (`ITransaction.ts:5`).
511
+ > atomicité entre les deux (`ITransaction.ts#ITransaction`).
512
512
 
513
513
  ## 🔎 Les critères de recherche
514
514
 
@@ -523,32 +523,32 @@ await articles.find({ title: { $like: "Hel%" } }); // motif SQL
523
523
  await articles.find({ publishedAt: { $null: false } }); // IS NOT NULL
524
524
  ```
525
525
 
526
- Les dix opérateurs reconnus sont figés dans `OPERATOR_KEYS` (`criteria.ts:13`) — source unique
526
+ Les dix opérateurs reconnus sont figés dans `OPERATOR_KEYS` (`criteria.ts#OPERATOR_KEYS`) — source unique
527
527
  partagée par tous les adapters : `$eq $ne $gt $gte $lt $lte $in $nin $like $null`. Plusieurs
528
528
  opérateurs sur un même champ se combinent en **ET**.
529
529
 
530
530
  **Le `null` est le piège que le contrat désamorce.** En SQL, `colonne = NULL` est **toujours faux** :
531
531
  un filtre « la colonne est vide » écrit naïvement ne matcherait jamais rien, sans erreur. Deux formes
532
532
  équivalentes le résolvent — la valeur nue `{ publishedAt: null }` et l'opérateur
533
- `{ publishedAt: { $null: true } }` (`IRepository.ts:65`), qui produisent tous deux un `IS NULL`. La
533
+ `{ publishedAt: { $null: true } }` (`IRepository.ts#FieldOperators.$null`), qui produisent tous deux un `IS NULL`. La
534
534
  valeur nue n'est d'ailleurs **ouverte par le typage que si le champ est nullable** (`FieldCriteria`,
535
- `IRepository.ts:128`) : chercher `IS NULL` sur une colonne non-nullable est une erreur de
535
+ `IRepository.ts#FieldCriteria`) : chercher `IS NULL` sur une colonne non-nullable est une erreur de
536
536
  raisonnement, et le compilateur la refuse.
537
537
 
538
538
  **Comment un objet est reconnu comme filtre plutôt que comme valeur** : `isFieldOperators()`
539
- (`criteria.ts:42`) ne l'interprète que si **toutes** ses clés sont des opérateurs connus. Une colonne
539
+ (`criteria.ts#isFieldOperators`) ne l'interprète que si **toutes** ses clés sont des opérateurs connus. Une colonne
540
540
  JSON ou un sous-document (`{ meta: { auteur: "…" } }`) reste donc une égalité — c'est ce qui évite
541
541
  qu'une donnée métier soit prise pour une requête.
542
542
 
543
543
  **Ce que le critère ne couvre pas** : les sous-requêtes, les agrégats, les jointures arbitraires
544
544
  (la disjonction, elle, s'exprime par `$or: [...]`, portable). Ce n'est pas un oubli — c'est la limite du portable, et la sortie est
545
- `getNativeConnection()`. L'erreur `UnknownCriteriaField` (`errors.ts:23`) le dit d'ailleurs
545
+ `getNativeConnection()`. L'erreur `UnknownCriteriaField` (`errors.ts#UnknownCriteriaField`) le dit d'ailleurs
546
546
  explicitement dans son message, avec la liste des champs connus de l'entité (diagnostic d'une faute
547
547
  de frappe).
548
548
 
549
549
  ## 📄 Pagination portable
550
550
 
551
- `paginate()` (`paginate.ts:47`) construit une page **au-dessus** des primitives que tout adapter
551
+ `paginate()` (`paginate.ts#paginate`) construit une page **au-dessus** des primitives que tout adapter
552
552
  implémente déjà (`find` avec `limit`/`offset`/`order`, et `count`). Aucun driver n'a eu à changer.
553
553
 
554
554
  Deux décisions le rendent utilisable sur une grosse table :
@@ -559,13 +559,13 @@ Deux décisions le rendent utilisable sur une grosse table :
559
559
  `false`. C'est la distinction « Page » (avec total) et « Slice » (sans), reprise de Spring Data.
560
560
 
561
561
  Les bornes sont normalisées plutôt que propagées : un `limit` de `0` ou un `offset` négatif est
562
- ramené dans le domaine valide (`paginate.ts:56`) — un `find({ limit: 0 })` a un comportement qui
562
+ ramené dans le domaine valide (`paginate.ts#paginate`) — un `find({ limit: 0 })` a un comportement qui
563
563
  dépend du dialecte, donc on ne le laisse pas sortir.
564
564
 
565
565
  Le contrat de page lui-même (`IPage`, `IPageQuery`) vit dans le **cœur**
566
- (`src/nodefony/src/types/IPage.ts:22`), pas ici : il est partagé par tous les stores paginés du
566
+ (`src/nodefony/src/types/IPage.ts#IPageQuery`), pas ici : il est partagé par tous les stores paginés du
567
567
  framework (sessions HTTP, jetons, audit…). `orm-core` ne fait que l'enrichir du `criteria` typé, sous
568
- le nom `PageQuery` (`IPage.ts:18`).
568
+ le nom `PageQuery` (`IPage.ts#PageQuery`).
569
569
 
570
570
  ## ⚙️ Le socle CRUD — `AbstractCrudService`
571
571
 
@@ -587,17 +587,17 @@ export class ArticleService extends AbstractCrudService<ArticleRow> {
587
587
  }
588
588
  ```
589
589
 
590
- `AbstractCrudService` (`AbstractCrudService.ts:37`) sépare volontairement deux régimes :
590
+ `AbstractCrudService` (`AbstractCrudService.ts#AbstractCrudService`) sépare volontairement deux régimes :
591
591
 
592
592
  - **Lectures** (`find`, `findOne`, `findById`, `count`, `findPage`) — **délégation pure**. Aucun
593
593
  hook, aucun événement : c'est le chemin chaud, il ne doit rien payer.
594
594
  - **Mutations** (`create`, `updateOne`, `delete`) — encadrées par des hooks _template method_
595
- (`beforeCreate`, `AbstractCrudService.ts:212`, et ses six frères) puis un événement de cycle de vie
595
+ (`beforeCreate`, `AbstractCrudService.ts#AbstractCrudService.beforeCreate`, et ses six frères) puis un événement de cycle de vie
596
596
  `onCreated` / `onUpdated` / `onDeleted`, **émis seulement si la mutation a eu lieu**
597
- (`AbstractCrudService.ts:167`). L'audit, l'invalidation de cache ou une notification Studio s'y
597
+ (`AbstractCrudService.ts#AbstractCrudService.updateOne`). L'audit, l'invalidation de cache ou une notification Studio s'y
598
598
  abonnent sans toucher au service.
599
599
 
600
- `findPage()` (`AbstractCrudService.ts:110`) est la primitive à utiliser pour **toute** liste
600
+ `findPage()` (`AbstractCrudService.ts#AbstractCrudService.findPage`) est la primitive à utiliser pour **toute** liste
601
601
  d'administration : elle ne charge qu'une page, quelle que soit la taille de la table.
602
602
 
603
603
  > [!WARNING]
@@ -610,19 +610,19 @@ d'administration : elle ne charge qu'une page, quelle que soit la taille de la t
610
610
 
611
611
  Deux drivers implémentent les contrats. Le contrat `IRepository` est tenu **en entier** par les
612
612
  deux : les quinze verbes existent des deux côtés — par exemple l'upsert, avec
613
- `DrizzleRepository.upsert()` (`DrizzleRepository.ts:1091`) et `MongooseRepository.upsert()`
614
- (`MongooseRepository.ts:439`).
615
-
616
- | Capacité | `@nodefony/drizzle` | `@nodefony/mongoose` |
617
- | -------------------------------------- | ----------------------------------- | -------------------------------------------- |
618
- | Moteurs | SQLite, PostgreSQL, MySQL / MariaDB | MongoDB |
619
- | Contrat `IRepository` (15 verbes) | complet | complet |
620
- | Eager-load `{ relations }` | oui | oui (`populate`) |
621
- | Transactions | oui | oui (replica set requis par Mongo) |
622
- | Savepoints (rollback partiel) | oui | non — refusés (`SavepointNotSupportedError`) |
623
- | Colonnes pour l'ERD (`describeEntity`) | oui (`DrizzleOrm.ts:1982`) | oui (`MongooseOrm.ts:649`) |
624
- | Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor` | oui — alimente `queryFlowMonitor` |
625
- | Sonde profonde (`probe`) | oui (`DrizzleOrm.ts:1873`) | oui (`MongooseOrm.ts:617`) |
613
+ `DrizzleRepository.upsert()` (`DrizzleRepository.ts#DrizzleRepository.upsert`) et `MongooseRepository.upsert()`
614
+ (`MongooseRepository.ts#MongooseRepository.upsert`).
615
+
616
+ | Capacité | `@nodefony/drizzle` | `@nodefony/mongoose` |
617
+ | -------------------------------------- | ----------------------------------------------- | ------------------------------------------------------ |
618
+ | Moteurs | SQLite, PostgreSQL, MySQL / MariaDB | MongoDB |
619
+ | Contrat `IRepository` (15 verbes) | complet | complet |
620
+ | Eager-load `{ relations }` | oui | oui (`populate`) |
621
+ | Transactions | oui | oui (replica set requis par Mongo) |
622
+ | Savepoints (rollback partiel) | oui | non — refusés (`SavepointNotSupportedError`) |
623
+ | Colonnes pour l'ERD (`describeEntity`) | oui (`DrizzleOrm.ts#DrizzleOrm.describeEntity`) | oui (`MongooseOrm.ts#MongooseOrm.probe`) |
624
+ | Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor` | oui — alimente `queryFlowMonitor` |
625
+ | Sonde profonde (`probe`) | oui (`DrizzleOrm.ts#DrizzleOrm.probe`) | oui (`MongooseOrm.ts#MongooseOrm.getNativeConnection`) |
626
626
 
627
627
  **Les « stores » du framework** : chaque adapter déclare ce qu'il porte dans son `package.json`, clé
628
628
  `nodefony.stores`. Les deux backends durables portent les huit :
@@ -648,20 +648,20 @@ deux : les quinze verbes existent des deux côtés — par exemple l'upsert, ave
648
648
 
649
649
  Le contrat minimal tient en peu de choses, parce que `orm-core` fournit déjà la plomberie.
650
650
 
651
- 1. **Étendre `Orm`** (`Orm.ts:46`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
651
+ 1. **Étendre `Orm`** (`Orm.ts#Orm`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
652
652
  `getRepository()`, `transaction()`, `getNativeConnection()`. L'enregistrement dans `ormRegistry`
653
653
  est fait par le constructeur de base — il n'y a rien à écrire. **Ne jamais surcharger
654
654
  `connect()`** : c'est la template method qui émet `onOrmReady` et instrumente la latence.
655
- 2. **Implémenter `IRepository<T>`** en traduisant les critères. `isFieldOperators()` (`criteria.ts:42`)
656
- et `isUpdateOperators()` (`criteria.ts:87`) sont fournis pour que la détection soit **identique**
655
+ 2. **Implémenter `IRepository<T>`** en traduisant les critères. `isFieldOperators()` (`criteria.ts#isFieldOperators`)
656
+ et `isUpdateOperators()` (`criteria.ts#isUpdateOperators`) sont fournis pour que la détection soit **identique**
657
657
  partout — les réécrire, c'est fabriquer une divergence.
658
- 3. **Lever `UnknownCriteriaField`** (`errors.ts:23`) sur un champ inconnu, et **appeler
659
- `assertOrderOption()`** (`readOptions.ts:39`) une fois, en amont, sur `options.order`. C'est le
658
+ 3. **Lever `UnknownCriteriaField`** (`errors.ts#UnknownCriteriaField`) sur un champ inconnu, et **appeler
659
+ `assertOrderOption()`** (`readOptions.ts#assertOrderOption`) une fois, en amont, sur `options.order`. C'est le
660
660
  prix de la promesse de portabilité : un adapter qui retesterait la forme lui-même finirait par
661
661
  diverger de l'autre.
662
662
  4. **Câbler le data plane** en une ligne à `onKernelBoot` : `wireOrmAdminPlane(this.kernel)`
663
- (`ormWiring.ts:31`) monte les routes admin, la santé et le diagnostic riche. Et
664
- `resolveOrmFlowEnabled(kernel)` (`ormWiring.ts:96`) décide si la sonde de flux s'allume — hors
663
+ (`ormWiring.ts#wireOrmAdminPlane`) monte les routes admin, la santé et le diagnostic riche. Et
664
+ `resolveOrmFlowEnabled(kernel)` (`ormWiring.ts#resolveOrmFlowEnabled`) décide si la sonde de flux s'allume — hors
665
665
  production par défaut, `NF_ORM_FLOW=1` force.
666
666
  5. **Déclarer les capacités** dans `package.json` (`nodefony.storeKind`, `nodefony.stores`) : c'est
667
667
  ainsi que le framework sait ce que ton adapter sait faire.
@@ -674,39 +674,39 @@ s'ajoutent ensuite : sans elles l'adapter fonctionne, il est simplement moins ob
674
674
  Deux sondes **indépendantes**, et un data plane qui les expose. La séparation est délibérée : mesurer
675
675
  la santé ne doit pas coûter le débit, et observer le débit ne doit pas réveiller la base.
676
676
 
677
- **`connectionMonitor`** (`ConnectionMonitor.ts:197`) suit le **cycle de vie** d'une connexion :
677
+ **`connectionMonitor`** (`ConnectionMonitor.ts#ConnectionMonitor.#pushEvent`) suit le **cycle de vie** d'une connexion :
678
678
  première connexion, reconnexions, erreurs récentes, et une fenêtre de latence de ping
679
- (`ConnectionMonitor.recordPing()`, `ConnectionMonitor.ts:136`). Il est alimenté par `Orm.connect()`
679
+ (`ConnectionMonitor.recordPing()`, `ConnectionMonitor.ts#ConnectionMonitor.recordPing`). Il est alimenté par `Orm.connect()`
680
680
  sans que l'adapter ait à y penser.
681
681
 
682
- **`queryFlowMonitor`** (`QueryFlowMonitor.ts:144`) suit le **débit** : total de requêtes, latence
682
+ **`queryFlowMonitor`** (`QueryFlowMonitor.ts#queryFlowMonitor`) suit le **débit** : total de requêtes, latence
683
683
  moyenne et EWMA, pire latence, et un anneau borné à vingt requêtes lentes. Trois propriétés le
684
684
  rendent sûr en production :
685
685
 
686
686
  - **éteint par défaut** — `enabled = false` : coût nul tant qu'on ne l'allume pas ;
687
687
  - **lazy** — la table de statistiques n'est allouée qu'au premier enregistrement ;
688
- - **le SQL n'est capté que sur le chemin lent** (`QueryFlowMonitor.record()`, `QueryFlowMonitor.ts:101`) :
688
+ - **le SQL n'est capté que sur le chemin lent** (`QueryFlowMonitor.record()`, `QueryFlowMonitor.ts#QueryFlowMonitor.record`) :
689
689
  jamais de sérialisation de requête au cas nominal ;
690
690
  - **aucune persistance** — tout est en mémoire vive. Une sonde n'écrit jamais dans la base qu'elle
691
691
  observe, et le débit par seconde est **dérivé côté lecteur** (delta entre deux relevés), donc rien
692
692
  n'est muté à la lecture.
693
693
 
694
- Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:541`) expose onze points
694
+ Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts#createOrmAdminApi`) expose onze points
695
695
  d'entrée, tous filtrables par `?connector=` :
696
696
 
697
- | Point d'entrée | Ce qu'il rend |
698
- | ------------------- | ------------------------------------------------------------------ |
699
- | `orms` | les connecteurs, leur état, leur nombre d'entités |
700
- | `entities` | le modèle complet : colonnes + relations |
701
- | `entity/{name}` | une entité (404 si inconnue) |
702
- | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:227`) |
703
- | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
704
- | `connection/health` | état, latence, erreurs, reconnexions, sondes |
705
- | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts:360`) |
706
- | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts:395`) ou `jsonschema` |
707
- | `migrations` | l'état des migrations (même objet que `orm:migrate:status --json`) |
708
- | `migrations/plan` | le SQL en attente |
709
- | `migrations/apply` | applique les migrations (développement seulement) |
697
+ | Point d'entrée | Ce qu'il rend |
698
+ | ------------------- | -------------------------------------------------------------------------- |
699
+ | `orms` | les connecteurs, leur état, leur nombre d'entités |
700
+ | `entities` | le modèle complet : colonnes + relations |
701
+ | `entity/{name}` | une entité (404 si inconnue) |
702
+ | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts#buildOrmGraph`) |
703
+ | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
704
+ | `connection/health` | état, latence, erreurs, reconnexions, sondes |
705
+ | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts#buildOrmFlow`) |
706
+ | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts#toDbml`) ou `jsonschema` |
707
+ | `migrations` | l'état des migrations (même objet que `orm:migrate:status --json`) |
708
+ | `migrations/plan` | le SQL en attente |
709
+ | `migrations/apply` | applique les migrations (développement seulement) |
710
710
 
711
711
  Ce graphe canonique est **la pièce maîtresse**, pas le diagramme : c'est une donnée sérialisable qui
712
712
  sert à la fois l'ERD de Studio, un export vers un outil tiers, et le contexte d'un agent IA
@@ -719,14 +719,14 @@ Côté Studio, trois écrans le consomment : `/nodefony/orm` (vue d'ensemble), `
719
719
 
720
720
  Le socle est conçu pour **ne rien coûter quand il ne sert pas**.
721
721
 
722
- | Ce qui pourrait coûter | Ce que fait `orm-core` |
723
- | -------------------------- | ----------------------------------------------------------------------------------- |
724
- | Registres au démarrage | alloués au **premier** enregistrement (`EntityRegistry.ts:25`, `OrmRegistry.ts:27`) |
725
- | Contrats | interfaces TypeScript — effacées à la compilation, zéro coût runtime |
726
- | Lectures d'un service CRUD | délégation directe : ni hook, ni événement sur le chemin chaud |
727
- | Sonde de flux | éteinte par défaut ; table allouée au premier relevé ; anneau des lentes borné à 20 |
728
- | Sérialisation du SQL | seulement sur le chemin **lent**, jamais au cas nominal |
729
- | Comptage d'une page | `COUNT(*)` évitable (`withTotal: false`), `hasNext` obtenu par `limit + 1` |
722
+ | Ce qui pourrait coûter | Ce que fait `orm-core` |
723
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
724
+ | Registres au démarrage | alloués au **premier** enregistrement (`EntityRegistry.ts#EntityRegistry.register`, `OrmRegistry.ts#OrmRegistry.register`) |
725
+ | Contrats | interfaces TypeScript — effacées à la compilation, zéro coût runtime |
726
+ | Lectures d'un service CRUD | délégation directe : ni hook, ni événement sur le chemin chaud |
727
+ | Sonde de flux | éteinte par défaut ; table allouée au premier relevé ; anneau des lentes borné à 20 |
728
+ | Sérialisation du SQL | seulement sur le chemin **lent**, jamais au cas nominal |
729
+ | Comptage d'une page | `COUNT(*)` évitable (`withTotal: false`), `hasNext` obtenu par `limit + 1` |
730
730
 
731
731
  Deux conséquences pratiques pour ton code : préférer `exists()` à `findOne() !== null` (aucune
732
732
  colonne n'est chargée), et `increment()` à une lecture suivie d'une écriture (une requête au lieu de
@@ -734,19 +734,19 @@ deux, et pas de course).
734
734
 
735
735
  ## ⚠️ Pièges
736
736
 
737
- | Symptôme | Cause | Correction |
738
- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
739
- | « 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`) |
740
- | 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:56`) |
741
- | « 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`) |
742
- | 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`) |
743
- | `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()` |
744
- | `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:269`) |
745
- | 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`) |
746
- | 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`) |
747
- | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts:363`), jamais `connect()` |
748
- | 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 |
749
- | 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`) ; les deux drivers officiels ont un tap |
737
+ | Symptôme | Cause | Correction |
738
+ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
739
+ | « 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#entities`) |
740
+ | 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#entities`) |
741
+ | « 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#EntityRegistry.get`) |
742
+ | 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#FieldOperators.$null`) |
743
+ | `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()` |
744
+ | `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#IRepository.updateOne`) |
745
+ | 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#UpdateOperators`) |
746
+ | 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#isFieldOperators`) |
747
+ | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts#Orm.onConnect`), jamais `connect()` |
748
+ | 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 |
749
+ | 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#resolveOrmFlowEnabled`) ; les deux drivers officiels ont un tap |
750
750
 
751
751
  ## 🧪 Tests & couverture
752
752