@nodefony/orm-core 10.0.0-beta.3 → 10.0.0-beta.5
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/dist/nodefony/interfaces/IOrmMigrations.js +1 -2
- package/dist/nodefony/src/AbstractCrudService.js +1 -2
- package/dist/nodefony/src/ConnectionMonitor.js +1 -2
- package/dist/nodefony/src/Entity.js +1 -2
- package/dist/nodefony/src/EntityRegistry.js +1 -2
- package/dist/nodefony/src/Orm.js +1 -2
- package/dist/nodefony/src/OrmAdminApi.js +4 -5
- package/dist/nodefony/src/OrmRegistry.js +1 -2
- package/dist/nodefony/src/QueryFlowMonitor.js +1 -2
- package/dist/nodefony/src/buildOrmLeanHealth.js +1 -2
- package/dist/nodefony/src/connectDeadline.js +1 -2
- package/dist/nodefony/src/connectionDiagnosis.js +72 -15
- package/dist/nodefony/src/criteria.js +1 -2
- package/dist/nodefony/src/decorators/entitiesDecorator.js +1 -2
- package/dist/nodefony/src/decorators/entityDecorator.js +1 -2
- package/dist/nodefony/src/decorators/metadataStore.js +3 -4
- package/dist/nodefony/src/decorators/repositoryDecorator.js +1 -2
- package/dist/nodefony/src/defineEntity.js +1 -2
- package/dist/nodefony/src/errors.js +1 -2
- package/dist/nodefony/src/omitUndefined.js +1 -2
- package/dist/nodefony/src/ormWiring.js +1 -2
- package/dist/nodefony/src/orphanEntities.js +1 -2
- package/dist/nodefony/src/paginate.js +1 -2
- package/dist/nodefony/src/readOptions.js +1 -2
- package/dist/types/nodefony/src/connectionDiagnosis.d.ts +9 -1
- package/docs/index.md +133 -132
- package/docs/tutorial-entity.md +116 -80
- package/package.json +5 -5
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
|
|
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
|
|
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
|
|
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
|
|
210
|
-
chaque adapter en amont de sa requête et lève `InvalidOrderOption` (`errors.ts
|
|
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
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
247
|
-
rien. C'est le décorateur `entities()` (`entitiesDecorator.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
387
|
-
de nom **lève** (`OrmRegistry.register()`, `OrmRegistry.ts
|
|
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
|
|
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
|
|
397
|
-
moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts
|
|
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
|
|
409
|
-
(`connect`/`disconnect`), rend les repositories (`IOrm.getRepository()`, `IOrm.ts
|
|
410
|
-
transaction (`IOrm.transaction()`, `IOrm.ts
|
|
411
|
-
(`IOrm.getNativeConnection()`, `IOrm.ts
|
|
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
|
|
415
|
-
(`IOrm.ts
|
|
416
|
-
`probe()` (`IOrm.ts
|
|
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
|
|
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
|
|
426
|
-
| `count` | compter les entités qui correspondent au critère | `IRepository.ts
|
|
427
|
-
| `exists` | savoir s'il y en a au moins un, sans charger de colonne | `IRepository.ts
|
|
428
|
-
| `countDistinct` | compter les valeurs DISTINCTES et non nulles d'un champ, sans les charger | `IRepository.ts
|
|
429
|
-
| `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts
|
|
430
|
-
| `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts
|
|
431
|
-
| `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts
|
|
432
|
-
| `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts
|
|
433
|
-
| `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts
|
|
434
|
-
| `increment` | `SET f = f + ?` atomique — compteurs, quotas, rate-limit | `IRepository.ts
|
|
435
|
-
| `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts
|
|
436
|
-
| `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts
|
|
437
|
-
| `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts
|
|
438
|
-
| `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
480
|
-
apporte l'entité) et `domain` (`IEntity.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
614
|
-
(`MongooseRepository.ts
|
|
615
|
-
|
|
616
|
-
| Capacité | `@nodefony/drizzle`
|
|
617
|
-
| -------------------------------------- |
|
|
618
|
-
| Moteurs | SQLite, PostgreSQL, MySQL / MariaDB
|
|
619
|
-
| Contrat `IRepository` (15 verbes) | complet
|
|
620
|
-
| Eager-load `{ relations }` | oui
|
|
621
|
-
| Transactions | oui
|
|
622
|
-
| Savepoints (rollback partiel) | oui
|
|
623
|
-
| Colonnes pour l'ERD (`describeEntity`) | oui (`DrizzleOrm.ts
|
|
624
|
-
| Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor`
|
|
625
|
-
| Sonde profonde (`probe`) | oui (`DrizzleOrm.ts
|
|
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
|
|
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
|
|
656
|
-
et `isUpdateOperators()` (`criteria.ts
|
|
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
|
|
659
|
-
`assertOrderOption()`** (`readOptions.ts
|
|
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
|
|
664
|
-
`resolveOrmFlowEnabled(kernel)` (`ormWiring.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
706
|
-
| `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts
|
|
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
|
|
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,20 @@ deux, et pas de course).
|
|
|
734
734
|
|
|
735
735
|
## ⚠️ Pièges
|
|
736
736
|
|
|
737
|
-
| Symptôme
|
|
738
|
-
|
|
|
739
|
-
| « no entity registered under "X" » au premier appel
|
|
740
|
-
| La table n'existe pas alors que l'entité est déclarée
|
|
741
|
-
| « entity "User" exists on multiple connectors … specify one »
|
|
742
|
-
| Un filtre « champ vide » ne remonte jamais rien
|
|
743
|
-
| `UnknownCriteriaField` sur un champ qui existe « pourtant »
|
|
744
|
-
| `updateOne` rend `null` alors que la ligne a bien changé
|
|
745
|
-
| Un `upsert` écrase une valeur qui devait progresser
|
|
746
|
-
| Un objet de critère est pris pour une égalité (colonne JSON)
|
|
747
|
-
| `onOrmReady` ne part plus après un ajout dans l'adapter
|
|
748
|
-
| Une entité déclarée dans un module reste invisible
|
|
749
|
-
| Rien dans `flow` alors que la base travaille
|
|
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
|
+
| Un champ vaut `null` dans la réponse alors que la colonne est remplie | repository maison : la fonction qui transforme une ligne en entité ne recopie pas cette colonne — rien ne lève, le champ optionnel reste vide | faire porter au mapping **chaque** champ exposé, et un test qui écrit une ligne complète puis compare tous les champs relus (`IRepository.ts#IRepository`) |
|
|
750
751
|
|
|
751
752
|
## 🧪 Tests & couverture
|
|
752
753
|
|