@nodefony/orm-core 10.0.0-alpha.8 → 10.0.0-beta.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 (38) hide show
  1. package/dist/index.js +4 -3
  2. package/dist/nodefony/src/AbstractCrudService.js +7 -3
  3. package/dist/nodefony/src/ConnectionMonitor.js +41 -9
  4. package/dist/nodefony/src/EntityRegistry.js +9 -3
  5. package/dist/nodefony/src/Orm.js +19 -3
  6. package/dist/nodefony/src/OrmAdminApi.js +54 -12
  7. package/dist/nodefony/src/OrmRegistry.js +1 -1
  8. package/dist/nodefony/src/QueryFlowMonitor.js +2 -2
  9. package/dist/nodefony/src/connectDeadline.js +1 -1
  10. package/dist/nodefony/src/connectionDiagnosis.js +66 -6
  11. package/dist/nodefony/src/criteria.js +4 -3
  12. package/dist/nodefony/src/errors.js +59 -1
  13. package/dist/nodefony/src/omitUndefined.js +30 -0
  14. package/dist/nodefony/src/orphanEntities.js +63 -0
  15. package/dist/types/index.d.ts +5 -4
  16. package/dist/types/nodefony/interfaces/IEntity.d.ts +23 -4
  17. package/dist/types/nodefony/interfaces/IOrm.d.ts +10 -1
  18. package/dist/types/nodefony/interfaces/IOrmFlow.d.ts +1 -1
  19. package/dist/types/nodefony/interfaces/IOrmGraph.d.ts +58 -13
  20. package/dist/types/nodefony/interfaces/IOrmProbe.d.ts +9 -9
  21. package/dist/types/nodefony/interfaces/IPage.d.ts +1 -1
  22. package/dist/types/nodefony/interfaces/IRepository.d.ts +12 -12
  23. package/dist/types/nodefony/interfaces/ITransaction.d.ts +3 -1
  24. package/dist/types/nodefony/interfaces/index.d.ts +2 -2
  25. package/dist/types/nodefony/src/AbstractCrudService.d.ts +9 -5
  26. package/dist/types/nodefony/src/ConnectionMonitor.d.ts +9 -2
  27. package/dist/types/nodefony/src/Entity.d.ts +1 -1
  28. package/dist/types/nodefony/src/Orm.d.ts +9 -1
  29. package/dist/types/nodefony/src/OrmAdminApi.d.ts +17 -1
  30. package/dist/types/nodefony/src/connectionDiagnosis.d.ts +29 -0
  31. package/dist/types/nodefony/src/decorators/entityDecorator.d.ts +1 -1
  32. package/dist/types/nodefony/src/decorators/metadataStore.d.ts +5 -5
  33. package/dist/types/nodefony/src/errors.d.ts +48 -0
  34. package/dist/types/nodefony/src/omitUndefined.d.ts +16 -0
  35. package/dist/types/nodefony/src/orphanEntities.d.ts +38 -0
  36. package/docs/index.md +64 -60
  37. package/docs/tutorial-entity.md +26 -23
  38. package/package.json +8 -8
@@ -21,7 +21,7 @@ export interface EntityOptions<S = unknown> {
21
21
  */
22
22
  domain?: string;
23
23
  /** Schéma natif du driver (schéma Mongoose, schéma Drizzle...). */
24
- schema?: S;
24
+ schema?: S | undefined;
25
25
  /** Relations déclaratives vers d'autres entités. */
26
26
  relations?: ReadonlyArray<IEntityRelation>;
27
27
  }
@@ -17,13 +17,13 @@ export interface EntityMetadata<S = unknown> {
17
17
  /** Connexion nommée cible (clé du `ormRegistry`). */
18
18
  readonly connector: string;
19
19
  /** Module Nodefony propriétaire (regroupement graphe/ERD), si fourni. */
20
- readonly module?: string;
20
+ readonly module?: string | undefined;
21
21
  /** Classification (domaine fonctionnel), axe de regroupement distinct du module. */
22
- readonly domain?: string;
22
+ readonly domain?: string | undefined;
23
23
  /** Schéma natif du driver (forme libre), si fourni au décorateur. */
24
- readonly schema?: S;
24
+ readonly schema?: S | undefined;
25
25
  /** Relations déclaratives vers d'autres entités. */
26
- readonly relations?: ReadonlyArray<IEntityRelation>;
26
+ readonly relations?: ReadonlyArray<IEntityRelation> | undefined;
27
27
  /** Constructeur décoré (pour introspection / résolution lazy par le driver). */
28
28
  readonly target: DecoratedClass;
29
29
  }
@@ -36,7 +36,7 @@ export interface RepositoryMetadata {
36
36
  /** Nom logique de l'entité gérée par ce repository. */
37
37
  readonly entity: string;
38
38
  /** Connecteur cible (lève l'ambiguïté si l'entité existe sur plusieurs connexions). */
39
- readonly connector?: string;
39
+ readonly connector?: string | undefined;
40
40
  /** Constructeur décoré. */
41
41
  readonly target: DecoratedClass;
42
42
  }
@@ -60,3 +60,51 @@ export declare class InvalidOrderOption extends Error {
60
60
  */
61
61
  constructor(entity: string, received: string);
62
62
  }
63
+ /**
64
+ * Levée quand une suppression viserait une entité encore RÉFÉRENCÉE par une
65
+ * relation obligatoire d'une autre entité.
66
+ *
67
+ * C'est le pendant, pour un moteur sans clé étrangère (MongoDB), du `restrict`
68
+ * que le SQL généré pose sur une colonne de référence obligatoire : le parent
69
+ * ne part pas tant qu'un enfant le désigne. Sans cette garde, la suppression
70
+ * passait et `?include=` rendait ensuite `null` à la place du parent — une
71
+ * application passée du SQL à MongoDB perdait en silence une garantie qu'elle
72
+ * croyait avoir.
73
+ *
74
+ * Erreur de DONNÉES : c'est la surface API qui la projette (le rendu d'erreur
75
+ * HTTP la reconnaît par son `name` et répond 409).
76
+ */
77
+ export declare class ReferencedEntityError extends Error {
78
+ /** Entité dont la suppression est refusée (nom logique). */
79
+ readonly entity: string;
80
+ /** Entité qui la référence encore (nom logique). */
81
+ readonly referencedBy: string;
82
+ /** Champ de référence de {@link ReferencedEntityError.referencedBy}. */
83
+ readonly field: string;
84
+ /**
85
+ * @param entity - entité dont la suppression est refusée.
86
+ * @param referencedBy - entité qui la référence encore.
87
+ * @param field - champ de référence obligatoire qui la désigne.
88
+ */
89
+ constructor(entity: string, referencedBy: string, field: string);
90
+ }
91
+ /**
92
+ * Levée par `ITransaction.savepoint()` / `rollbackTo()` sur un moteur qui ne porte
93
+ * PAS de savepoints (MongoDB).
94
+ *
95
+ * Refuser vaut mieux que se taire : un `rollbackTo()` rendu en no-op laissait
96
+ * l'appelant croire ses écritures annulées alors qu'elles restaient en base. Le
97
+ * refus tombe dès `savepoint()`, pas au `rollbackTo()` : le chemin d'erreur, où
98
+ * l'on annule, est celui qu'on teste le moins — la faute doit se voir au premier
99
+ * essai. Pour annuler, terminer la transaction entière (`rollback()`, ou laisser
100
+ * le callback de `IOrm.transaction()` lever).
101
+ */
102
+ export declare class SavepointNotSupportedError extends Error {
103
+ /** Moteur qui refuse (valeur de `describeConnection().driver`). */
104
+ readonly driver: string;
105
+ /**
106
+ * @param driver - moteur qui ne porte pas de savepoints.
107
+ * @param operation - opération refusée (`savepoint` ou `rollbackTo`).
108
+ */
109
+ constructor(driver: string, operation: "savepoint" | "rollbackTo");
110
+ }
@@ -0,0 +1,16 @@
1
+ import type { PartialInput } from "nodefony";
2
+ /**
3
+ * Retire les clés qui valent `undefined` d'une donnée d'écriture partielle.
4
+ *
5
+ * `undefined` et clé absente veulent dire « ne pas toucher » : Drizzle ignore
6
+ * déjà une valeur `undefined`, Mongoose (≥ 6) la retire d'une mise à jour. La
7
+ * règle est posée ICI, une fois, au seuil de la persistance, au lieu d'être
8
+ * un comportement propre à chaque adaptateur.
9
+ *
10
+ * Rend l'objet reçu TEL QUEL quand aucune clé ne vaut `undefined` — le cas
11
+ * courant d'un corps JSON, qui ne peut pas en porter : aucune allocation.
12
+ *
13
+ * @param data - données d'écriture, clés `undefined` admises
14
+ * @returns les mêmes données, sans aucune clé `undefined`
15
+ */
16
+ export declare function omitUndefined<T>(data: PartialInput<T>): Partial<T>;
@@ -0,0 +1,38 @@
1
+ import type { IEntity } from "../interfaces/IEntity.js";
2
+ /**
3
+ * Entités inscrites sur un connecteur qu'aucun ORM n'a ouvert.
4
+ *
5
+ * Une telle entité ne sera servie par rien : son dépôt lèvera au premier
6
+ * appel, loin de sa cause. Vécu : sur une infra MongoDB, le `User` d'une
7
+ * application restait écrit en table SQL sur `default` — un connecteur que
8
+ * Drizzle n'ouvre pas sur cette infra — et le boot n'en disait RIEN.
9
+ *
10
+ * @param entities - entités inscrites (registre des entités).
11
+ * @param connectors - connecteurs ouverts (registre des ORM).
12
+ * @returns les orphelines, dans l'ordre d'inscription.
13
+ */
14
+ export declare function findOrphanEntities(entities: readonly IEntity[], connectors: readonly string[]): IEntity[];
15
+ /**
16
+ * La phrase qui NOMME les orphelines et le remède — `null` s'il n'y en a pas.
17
+ *
18
+ * @param orphans - entités sans connecteur ouvert.
19
+ * @param connectors - connecteurs ouverts, cités pour situer la faute.
20
+ * @param declared - connecteurs DÉCLARÉS par les modules ORM (leur configuration).
21
+ */
22
+ export declare function describeOrphanEntities(orphans: readonly IEntity[], connectors: readonly string[], declared?: readonly string[]): string | null;
23
+ /**
24
+ * Signale UNE fois par démarrage les entités orphelines, au moment où tous les
25
+ * ORM sont ouverts.
26
+ *
27
+ * Appelée par chaque module ORM (Drizzle, Mongoose) à `onReady` : la règle vit
28
+ * ici, une fois, et le premier appel la rend — le second se tait, sans quoi une
29
+ * application qui charge les deux ORM lirait deux fois le même avertissement.
30
+ * La garde porte sur le KERNEL et non sur le process : un process qui démarre
31
+ * plusieurs noyaux (une suite de tests) doit être prévenu à chacun.
32
+ *
33
+ * @param log - journal du module appelant.
34
+ * @param owner - le démarrage concerné (le Kernel), clé de la garde.
35
+ * @param declared - connecteurs que l'appelant a DÉCLARÉS (sa configuration).
36
+ * @returns le message rendu, ou `null` (rien à dire, ou déjà dit).
37
+ */
38
+ export declare function reportOrphanEntities(log: (message: string, severity: "WARNING") => void, owner: object, declared?: readonly string[]): string | null;
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:88`) à leur démarrage.
180
+ > qui s'enregistrent dans `ormRegistry` (`OrmRegistry.ts:86`) à leur démarrage.
181
181
 
182
182
  ## La vision Nodefony
183
183
 
@@ -377,13 +377,13 @@ sequenceDiagram
377
377
  Note over K: onServersReady → récap de boot
378
378
  ```
379
379
 
380
- **`entityRegistry`** (`EntityRegistry.ts:147`) indexe les entités à **deux** niveaux — nom puis
380
+ **`entityRegistry`** (`EntityRegistry.ts:145`) 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
383
  (`EntityRegistry.get()`, `EntityRegistry.ts:54`). 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:88`) associe un nom de connexion à son instance `IOrm`. Un doublon
386
+ **`ormRegistry`** (`OrmRegistry.ts:86`) associe un nom de connexion à son instance `IOrm`. Un doublon
387
387
  de nom **lève** (`OrmRegistry.register()`, `OrmRegistry.ts:26`) : deux connexions homonymes seraient
388
388
  un bug silencieux, jamais une intention.
389
389
 
@@ -393,8 +393,8 @@ l'ordre des écouteurs, la table existe ou non. `entities()` s'accroche à `onRe
393
393
  (`entitiesDecorator.ts:66`), 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: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
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
398
398
  **jamais** `connect()` — sinon l'événement et l'instrumentation disparaissent.
399
399
 
400
400
  ## 🧰 Les contrats — la surface publique
@@ -412,35 +412,37 @@ transaction (`IOrm.transaction()`, `IOrm.ts:41`) et expose la trappe native
412
412
 
413
413
  Quatre méthodes sont **optionnelles** — un adapter qui ne les implémente pas dégrade proprement au
414
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).
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).
417
417
 
418
418
  ### [`IRepository`](tutorial-entity.md) — les quinze verbes
419
419
 
420
420
  `IRepository<T>` (`IRepository.ts:197`) 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: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` |
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` |
437
439
 
438
440
  > [!IMPORTANT]
439
441
  > `updateOne` est atomique **par construction** : une seule requête (`UPDATE … RETURNING` en SQL,
440
442
  > `findOneAndUpdate` en Mongo), jamais un `UPDATE` suivi d'une relecture. La différence n'est pas
441
443
  > cosmétique : la relecture rendrait `null` **à tort** dès que le critère porte sur un champ qu'on
442
444
  > vient de modifier — `updateOne({ status: "pending" }, { status: "done" })` ne retrouve plus rien
443
- > après coup (`IRepository.ts:221`).
445
+ > après coup (`IRepository.ts:269`).
444
446
 
445
447
  Quelques usages, un par garantie :
446
448
 
@@ -472,11 +474,13 @@ atomique sur les quatre backends.
472
474
 
473
475
  ### [`IEntity`](tutorial-entity.md) — la description d'une table
474
476
 
475
- `IEntity` (`IEntity.ts:37`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
477
+ `IEntity` (`IEntity.ts:51`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
476
478
  d'un moteur), un `schema` natif du driver, et des `relations` déclaratives (`IEntityRelation`,
477
479
  `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
+ apporte l'entité) et `domain` (`IEntity.ts:72`, la classification métier — l'axe qui rend navigable
481
+ une base de plusieurs centaines de tables). Un troisième, `indexes` (`IEntityIndex`), déclare les
482
+ index COMPOSITES qu'un adaptateur ne sait pas exprimer dans son schéma : Mongoose les applique
483
+ (`schema.index()`), Drizzle les ignore, puisqu'il les déclare dans sa table.
480
484
 
481
485
  Dans une application, on ne construit pas un `IEntity` à la main : on écrit un `IEntityDefinition`
482
486
  (`defineEntity.ts:15`) — le même objet **sans** `connector`, justement parce que le connecteur est
@@ -555,7 +559,7 @@ Deux décisions le rendent utilisable sur une grosse table :
555
559
  `false`. C'est la distinction « Page » (avec total) et « Slice » (sans), reprise de Spring Data.
556
560
 
557
561
  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
562
+ ramené dans le domaine valide (`paginate.ts:56`) — un `find({ limit: 0 })` a un comportement qui
559
563
  dépend du dialecte, donc on ne le laisse pas sortir.
560
564
 
561
565
  Le contrat de page lui-même (`IPage`, `IPageQuery`) vit dans le **cœur**
@@ -588,7 +592,7 @@ export class ArticleService extends AbstractCrudService<ArticleRow> {
588
592
  - **Lectures** (`find`, `findOne`, `findById`, `count`, `findPage`) — **délégation pure**. Aucun
589
593
  hook, aucun événement : c'est le chemin chaud, il ne doit rien payer.
590
594
  - **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
595
+ (`beforeCreate`, `AbstractCrudService.ts:212`, et ses six frères) puis un événement de cycle de vie
592
596
  `onCreated` / `onUpdated` / `onDeleted`, **émis seulement si la mutation a eu lieu**
593
597
  (`AbstractCrudService.ts:167`). L'audit, l'invalidation de cache ou une notification Studio s'y
594
598
  abonnent sans toucher au service.
@@ -606,21 +610,22 @@ d'administration : elle ne charge qu'une page, quelle que soit la taille de la t
606
610
 
607
611
  Deux drivers implémentent les contrats. Le contrat `IRepository` est tenu **en entier** par les
608
612
  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:1614`) | 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` :
613
+ `DrizzleRepository.upsert()` (`DrizzleRepository.ts:866`) et `MongooseRepository.upsert()`
614
+ (`MongooseRepository.ts:422`).
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:1971`) | oui (`MongooseOrm.ts:649`) |
624
+ | Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor` | non câblée |
625
+ | Sonde profonde (`probe`) | oui (`DrizzleOrm.ts:1873`) | oui (`MongooseOrm.ts:617`) |
626
+
627
+ **Les « stores » du framework** : chaque adapter déclare ce qu'il porte dans son `package.json`, clé
628
+ `nodefony.stores`. Les deux backends durables portent les huit :
624
629
 
625
630
  | Store | drizzle | mongoose |
626
631
  | ------------- | ------- | -------- |
@@ -629,22 +634,21 @@ qu'il porte dans son `package.json`, clé `nodefony.stores` :
629
634
  | `tokens` | ✅ | ✅ |
630
635
  | `passkeys` | ✅ | ✅ |
631
636
  | `webhooks` | ✅ | ✅ |
632
- | `totp` | ✅ | — |
633
- | `audit` | ✅ | — |
634
- | `idempotency` | ✅ | — |
637
+ | `totp` | ✅ | ✅ |
638
+ | `audit` | ✅ | ✅ |
639
+ | `idempotency` | ✅ | ✅ |
635
640
 
636
641
  > [!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
+ > La couverture est **adaptée à la nature de chaque backend** : c'est `@nodefony/redis`, un cache,
643
+ > qui s'arrête à quatre stores (`session`, `tokens`, `passkeys`, `idempotency`) — il ne garde ni
644
+ > comptes ni journal. La liste fait autorité côté code (`nodefony.stores` du `package.json` de chaque
645
+ > adapter) — elle n'est pas un commentaire.
642
646
 
643
647
  ## 🧩 Extension — brancher son propre driver
644
648
 
645
649
  Le contrat minimal tient en peu de choses, parce que `orm-core` fournit déjà la plomberie.
646
650
 
647
- 1. **Étendre `Orm`** (`Orm.ts:29`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
651
+ 1. **Étendre `Orm`** (`Orm.ts:46`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
648
652
  `getRepository()`, `transaction()`, `getNativeConnection()`. L'enregistrement dans `ormRegistry`
649
653
  est fait par le constructeur de base — il n'y a rien à écrire. **Ne jamais surcharger
650
654
  `connect()`** : c'est la template method qui émet `onOrmReady` et instrumente la latence.
@@ -672,10 +676,10 @@ la santé ne doit pas coûter le débit, et observer le débit ne doit pas réve
672
676
 
673
677
  **`connectionMonitor`** (`ConnectionMonitor.ts:197`) suit le **cycle de vie** d'une connexion :
674
678
  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()`
679
+ (`ConnectionMonitor.recordPing()`, `ConnectionMonitor.ts:136`). Il est alimenté par `Orm.connect()`
676
680
  sans que l'adapter ait à y penser.
677
681
 
678
- **`queryFlowMonitor`** (`QueryFlowMonitor.ts:146`) suit le **débit** : total de requêtes, latence
682
+ **`queryFlowMonitor`** (`QueryFlowMonitor.ts:144`) suit le **débit** : total de requêtes, latence
679
683
  moyenne et EWMA, pire latence, et un anneau borné à vingt requêtes lentes. Trois propriétés le
680
684
  rendent sûr en production :
681
685
 
@@ -687,7 +691,7 @@ rendent sûr en production :
687
691
  observe, et le débit par seconde est **dérivé côté lecteur** (delta entre deux relevés), donc rien
688
692
  n'est muté à la lecture.
689
693
 
690
- Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:421`) expose huit points
694
+ Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:541`) expose huit points
691
695
  d'entrée, tous filtrables par `?connector=` :
692
696
 
693
697
  | Point d'entrée | Ce qu'il rend |
@@ -695,11 +699,11 @@ d'entrée, tous filtrables par `?connector=` :
695
699
  | `orms` | les connecteurs, leur état, leur nombre d'entités |
696
700
  | `entities` | le modèle complet : colonnes + relations |
697
701
  | `entity/{name}` | une entité (404 si inconnue) |
698
- | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:184`) |
702
+ | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:227`) |
699
703
  | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
700
704
  | `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` |
705
+ | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts:360`) |
706
+ | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts:395`) ou `jsonschema` |
703
707
 
704
708
  Ce graphe canonique est **la pièce maîtresse**, pas le diagramme : c'est une donnée sérialisable qui
705
709
  sert à la fois l'ERD de Studio, un export vers un outil tiers, et le contexte d'un agent IA
@@ -730,14 +734,14 @@ deux, et pas de course).
730
734
  | Symptôme | Cause | Correction |
731
735
  | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
732
736
  | « 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`) |
737
+ | 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`) |
734
738
  | « 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
739
  | 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
740
  | `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`) |
741
+ | `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`) |
738
742
  | 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
743
  | 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()` |
744
+ | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts:363`), jamais `connect()` |
741
745
  | 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
746
  | 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
747
 
@@ -88,7 +88,7 @@ battre contre le cadre.
88
88
 
89
89
  **1. Le connecteur est une donnée de configuration, jamais de code.** C'est pourquoi le
90
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"`
91
+ (`IEntity.ts:51`) **privé de son `connector`** : celui-ci est résolu au démarrage, avec `"default"`
92
92
  pour valeur de repli (`DEFAULT_CONNECTOR`, `entitiesDecorator.ts:12`). Figer la connexion dans le
93
93
  fichier d'entité interdirait de servir la même table depuis une base différente selon
94
94
  l'environnement.
@@ -303,7 +303,7 @@ Cette absence d'effet de bord est délibérée : elle permet d'importer une enti
303
303
  un script ou un autre module **sans** déclencher son inscription dans un singleton global.
304
304
 
305
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
306
+ (`IEntity.ts:72`), l'axe de classification qui rend navigable une base de plusieurs centaines de
307
307
  tables dans l'ERD Studio.
308
308
 
309
309
  ### Étape 4 — brancher l'entité au module
@@ -318,7 +318,7 @@ d'être connues :
318
318
  être instancié deux fois : tests, rechargement). Une vraie collision, elle, lève
319
319
  (`EntityRegistry.register()`, `EntityRegistry.ts:24`).
320
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.
321
+ une autre base ; une entité qui porte son propre `connector` (`IEntity.ts:56`) garde le sien.
322
322
 
323
323
  ### Le raccourci — `nodefony create entity`
324
324
 
@@ -339,32 +339,35 @@ que l'import plus la ligne du décorateur.
339
339
  > [!WARNING]
340
340
  > La commande refuse de générer si aucun module ORM n'est déclaré dans l'application. C'est
341
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.
342
+ > `"@nodefony/drizzle"` (SQL) ou `"@nodefony/mongoose"` (MongoDB) au manifeste `modules`, puis
343
+ > relance. Sur MongoDB, la même commande écrit un schéma Mongoose au lieu d'une table.
343
344
 
344
345
  ## 🧰 Lire et écrire — le contrat complet
345
346
 
346
347
  Le repository porte **quinze verbes**, pas cinq. Ils se choisissent sur la **garantie** qu'ils
347
348
  apportent, jamais sur leur nom.
348
349
 
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` |
350
+ | Verbe | Ce qu'il garantit | Ancre |
351
+ | --------------------- | ------------------------------------------------------------------------- | ---------------------------- |
352
+ | `find` / `findOne` | lecture filtrée, avec tri, bornes et eager-load | `IRepository.ts:230`, `:192` |
353
+ | `create` | insère une ligne et rend sa version persistée (id, défauts) | `IRepository.ts:240` |
354
+ | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
355
+ | `updateOne` | modifie **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
356
+ | `updateMany` | modifie toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
357
+ | `upsert` | insère **ou** met à jour sur conflit de clé, en une instruction | `IRepository.ts:296` |
358
+ | `increment` | `SET f = f + ?` atomique — compteurs, quotas, limitation de débit | `IRepository.ts:287` |
359
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
360
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
361
+ | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, `pop` atomique | `IRepository.ts:355` |
362
+ | `count` | compter les entités qui correspondent au critère | `IRepository.ts:362` |
363
+ | `exists` | savoir s'il y en a au moins une, sans charger de colonne | `IRepository.ts:395` |
364
+ | `countDistinct` | compter les valeurs DISTINCTES et non nulles d'un champ, sans les charger | `IRepository.ts:381` |
365
+ | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
363
366
 
364
367
  > [!IMPORTANT]
365
368
  > **Il n'existe pas de méthode `update()`.** Le choix est explicite et il est intentionnel :
366
369
  > `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
370
+ > ligne modifiée —, `IRepository.updateMany()` (`IRepository.ts:312`) pour un lot — qui rend le
368
371
  > **nombre** de lignes touchées. Un verbe unique masquerait cette différence de garantie, qui est
369
372
  > précisément ce qu'on veut choisir en connaissance de cause.
370
373
 
@@ -393,7 +396,7 @@ await orm.transaction(async (tx) => {
393
396
  ```
394
397
 
395
398
  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
399
+ (`IRepository.ts:395`) à `findOne(...) !== null` — aucune colonne n'est chargée —, et
397
400
  `IRepository.increment()` (`IRepository.ts:325`) à une lecture suivie d'une écriture : une requête
398
401
  au lieu de deux, et pas de course.
399
402
 
@@ -471,7 +474,7 @@ champs connus — le diagnostic d'une faute de frappe est immédiat.
471
474
 
472
475
  ## 📄 Pager les résultats
473
476
 
474
- `find()` avec `limit`/`offset`/`order` (`RepositoryReadOptions`, `IRepository.ts:153`) suffit pour
477
+ `find()` avec `limit`/`offset`/`order` (`RepositoryReadOptions`, `IRepository.ts:174`) suffit pour
475
478
  une tranche. Pour une vraie page — celle qui sait s'il y a une suite — utilise `paginate()`
476
479
  (`paginate.ts:47`) :
477
480
 
@@ -524,9 +527,9 @@ d'administration, la primitive est `AbstractCrudService.findPage()` (`AbstractCr
524
527
 
525
528
  | Symptôme | Cause | Correction |
526
529
  | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
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`) |
530
+ | `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:312`) |
528
531
  | « 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`) |
532
+ | 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:56`) |
530
533
  | 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
534
  | 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
535
  | 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`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/orm-core",
3
- "version": "10.0.0-alpha.8",
3
+ "version": "10.0.0-beta.1",
4
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
5
  "contributors": [],
6
6
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "types": "./dist/types/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
+ "nodefony-source": "./index.ts",
11
12
  "types": "./dist/types/index.d.ts",
12
13
  "import": "./dist/index.js",
13
14
  "default": "./dist/index.js"
@@ -15,9 +16,9 @@
15
16
  },
16
17
  "scripts": {
17
18
  "start": "node dist/index.js",
18
- "build": "rimraf dist && rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
19
+ "build": "rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
19
20
  "dev": "rolldown -c rolldown.config.ts --watch",
20
- "clean": "rimraf dist",
21
+ "clean": "node --eval \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
21
22
  "test": "vitest run",
22
23
  "coverage": "vitest run --coverage",
23
24
  "typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p tsconfig.tests.json"
@@ -36,13 +37,12 @@
36
37
  "esm"
37
38
  ],
38
39
  "peerDependencies": {
39
- "nodefony": "^10.0.0-alpha.8"
40
+ "nodefony": "^10.0.0-beta.1"
40
41
  },
41
42
  "devDependencies": {
42
- "@types/node": "26.6.2",
43
- "nodefony": "^10.0.0-alpha.8",
44
- "rimraf": "6.1.3",
45
- "vitest": "5.0.1"
43
+ "@types/node": "26.6.3",
44
+ "nodefony": "^10.0.0-beta.1",
45
+ "vitest": "5.0.2"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",