@nodefony/orm-core 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +131 -0
  3. package/dist/index.js +22 -0
  4. package/dist/nodefony/interfaces/IEntity.js +1 -0
  5. package/dist/nodefony/interfaces/IOrm.js +1 -0
  6. package/dist/nodefony/interfaces/IOrmFlow.js +1 -0
  7. package/dist/nodefony/interfaces/IOrmGraph.js +1 -0
  8. package/dist/nodefony/interfaces/IOrmMigrations.js +12 -0
  9. package/dist/nodefony/interfaces/IOrmProbe.js +1 -0
  10. package/dist/nodefony/interfaces/IPage.js +1 -0
  11. package/dist/nodefony/interfaces/IRepository.js +1 -0
  12. package/dist/nodefony/interfaces/ITransaction.js +1 -0
  13. package/dist/nodefony/interfaces/index.js +1 -0
  14. package/dist/nodefony/src/AbstractCrudService.js +199 -0
  15. package/dist/nodefony/src/ConnectionMonitor.js +181 -0
  16. package/dist/nodefony/src/Entity.js +42 -0
  17. package/dist/nodefony/src/EntityRegistry.js +109 -0
  18. package/dist/nodefony/src/Orm.js +297 -0
  19. package/dist/nodefony/src/OrmAdminApi.js +491 -0
  20. package/dist/nodefony/src/OrmRegistry.js +75 -0
  21. package/dist/nodefony/src/QueryFlowMonitor.js +128 -0
  22. package/dist/nodefony/src/buildOrmLeanHealth.js +54 -0
  23. package/dist/nodefony/src/criteria.js +176 -0
  24. package/dist/nodefony/src/decorators/entitiesDecorator.js +67 -0
  25. package/dist/nodefony/src/decorators/entityDecorator.js +51 -0
  26. package/dist/nodefony/src/decorators/index.js +5 -0
  27. package/dist/nodefony/src/decorators/metadataStore.js +38 -0
  28. package/dist/nodefony/src/decorators/repositoryDecorator.js +35 -0
  29. package/dist/nodefony/src/defineEntity.js +27 -0
  30. package/dist/nodefony/src/errors.js +76 -0
  31. package/dist/nodefony/src/ormWiring.js +78 -0
  32. package/dist/nodefony/src/paginate.js +55 -0
  33. package/dist/nodefony/src/readOptions.js +52 -0
  34. package/dist/nodefony/src/serviceWiring.js +1 -0
  35. package/dist/types/index.d.ts +39 -0
  36. package/dist/types/nodefony/interfaces/IEntity.d.ts +65 -0
  37. package/dist/types/nodefony/interfaces/IOrm.d.ts +136 -0
  38. package/dist/types/nodefony/interfaces/IOrmFlow.d.ts +71 -0
  39. package/dist/types/nodefony/interfaces/IOrmGraph.d.ts +165 -0
  40. package/dist/types/nodefony/interfaces/IOrmMigrations.d.ts +145 -0
  41. package/dist/types/nodefony/interfaces/IOrmProbe.d.ts +70 -0
  42. package/dist/types/nodefony/interfaces/IPage.d.ts +24 -0
  43. package/dist/types/nodefony/interfaces/IRepository.d.ts +369 -0
  44. package/dist/types/nodefony/interfaces/ITransaction.d.ts +31 -0
  45. package/dist/types/nodefony/interfaces/index.d.ts +7 -0
  46. package/dist/types/nodefony/src/AbstractCrudService.d.ts +158 -0
  47. package/dist/types/nodefony/src/ConnectionMonitor.d.ts +86 -0
  48. package/dist/types/nodefony/src/Entity.d.ts +44 -0
  49. package/dist/types/nodefony/src/EntityRegistry.d.ts +60 -0
  50. package/dist/types/nodefony/src/Orm.d.ts +197 -0
  51. package/dist/types/nodefony/src/OrmAdminApi.d.ts +84 -0
  52. package/dist/types/nodefony/src/OrmRegistry.d.ts +58 -0
  53. package/dist/types/nodefony/src/QueryFlowMonitor.d.ts +53 -0
  54. package/dist/types/nodefony/src/buildOrmLeanHealth.d.ts +15 -0
  55. package/dist/types/nodefony/src/criteria.d.ts +131 -0
  56. package/dist/types/nodefony/src/decorators/entitiesDecorator.d.ts +48 -0
  57. package/dist/types/nodefony/src/decorators/entityDecorator.d.ts +49 -0
  58. package/dist/types/nodefony/src/decorators/index.d.ts +10 -0
  59. package/dist/types/nodefony/src/decorators/metadataStore.d.ts +54 -0
  60. package/dist/types/nodefony/src/decorators/repositoryDecorator.d.ts +30 -0
  61. package/dist/types/nodefony/src/defineEntity.d.ts +43 -0
  62. package/dist/types/nodefony/src/errors.d.ts +62 -0
  63. package/dist/types/nodefony/src/ormWiring.d.ts +48 -0
  64. package/dist/types/nodefony/src/paginate.d.ts +43 -0
  65. package/dist/types/nodefony/src/readOptions.d.ts +21 -0
  66. package/dist/types/nodefony/src/serviceWiring.d.ts +24 -0
  67. package/docs/index.md +791 -0
  68. package/docs/tutorial-entity.md +577 -0
  69. package/package.json +73 -0
@@ -0,0 +1,54 @@
1
+ import { ormRegistry } from "./OrmRegistry.js";
2
+ import { connectionMonitor } from "./ConnectionMonitor.js";
3
+ import { queryFlowMonitor } from "./QueryFlowMonitor.js";
4
+ //#region nodefony/src/buildOrmLeanHealth.ts
5
+ /**
6
+ * **Santé ORM lean per-instance** — somme process-wide de tous les connecteurs enregistrés,
7
+ * destinée à voyager dans le report de sonde cluster (« ORM par worker »). C'est la fonction
8
+ * branchée par le driver via `setOrmHealthProvider` (core) ; le framework la lit dans le report.
9
+ *
10
+ * Perf (règle ABSOLUE) : lecture PURE des singletons déjà alimentés (`queryFlowMonitor` +
11
+ * `connectionMonitor`) → **0 ping**, **0 `toSQL()`**, O(N connecteurs, N petit). `isConnected()`
12
+ * est un simple test d'état (pas une requête). `queryTotal` reste à 0 si le flux est OFF
13
+ * (prod) — c'est voulu : la sonde ne crée aucun coût, elle agrège ce qui existe déjà.
14
+ *
15
+ * @returns la santé ORM agrégée du process ({@link IOrmLeanHealth}).
16
+ */
17
+ function buildOrmLeanHealth() {
18
+ const names = ormRegistry.list();
19
+ let connected = 0;
20
+ let assumed = 0;
21
+ let queryTotal = 0;
22
+ let slowTotal = 0;
23
+ let errorTotal = 0;
24
+ let reconnectTotal = 0;
25
+ let maxEwmaMs = null;
26
+ for (const name of names) {
27
+ try {
28
+ const orm = ormRegistry.get(name);
29
+ if (orm.isConnected()) {
30
+ connected += 1;
31
+ if (orm.liveness !== "events") assumed += 1;
32
+ }
33
+ } catch {}
34
+ const conn = connectionMonitor.snapshot(name);
35
+ errorTotal += conn.errorCount;
36
+ reconnectTotal += conn.reconnectCount;
37
+ const flow = queryFlowMonitor.snapshot(name, "");
38
+ queryTotal += flow.total;
39
+ slowTotal += flow.slowTotal;
40
+ if (flow.ewmaMs !== null && (maxEwmaMs === null || flow.ewmaMs > maxEwmaMs)) maxEwmaMs = flow.ewmaMs;
41
+ }
42
+ return {
43
+ connectors: names.length,
44
+ connected,
45
+ assumed,
46
+ queryTotal,
47
+ slowTotal,
48
+ errorTotal,
49
+ reconnectTotal,
50
+ maxEwmaMs
51
+ };
52
+ }
53
+ //#endregion
54
+ export { buildOrmLeanHealth, buildOrmLeanHealth as default };
@@ -0,0 +1,176 @@
1
+ //#region nodefony/src/criteria.ts
2
+ /**
3
+ * Liste figée des opérateurs riches reconnus dans un critère portable.
4
+ *
5
+ * Source de vérité unique partagée par tous les adapters : un objet de valeur de
6
+ * champ n'est traité comme {@link FieldOperators} que si **toutes** ses clés
7
+ * appartiennent à cette liste (cf {@link isFieldOperators}).
8
+ */
9
+ const OPERATOR_KEYS = [
10
+ "$eq",
11
+ "$ne",
12
+ "$gt",
13
+ "$gte",
14
+ "$lt",
15
+ "$lte",
16
+ "$in",
17
+ "$nin",
18
+ "$like",
19
+ "$null"
20
+ ];
21
+ const OPERATOR_SET = new Set(OPERATOR_KEYS);
22
+ /**
23
+ * Indique si une valeur de champ est un objet d'{@link FieldOperators}.
24
+ *
25
+ * Heuristique : objet simple, non-`null`, non-tableau, dont **toutes** les clés
26
+ * propres sont des opérateurs reconnus (et au moins une). Une valeur objet
27
+ * « ordinaire » (colonne JSON, sous-document) n'a pas que des clés `$`-préfixées
28
+ * reconnues → elle est traitée comme une égalité, jamais comme un filtre riche.
29
+ *
30
+ * @param value - valeur de critère associée à un champ.
31
+ * @returns `true` si `value` doit être interprétée comme des opérateurs.
32
+ */
33
+ function isFieldOperators(value) {
34
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
35
+ const keys = Object.keys(value);
36
+ if (keys.length === 0) return false;
37
+ for (const key of keys) if (!OPERATOR_SET.has(key)) return false;
38
+ return true;
39
+ }
40
+ /**
41
+ * Liste figée des opérateurs d'**écriture** reconnus dans le `update` d'un
42
+ * `upsert` (cf {@link UpdateOperators}).
43
+ *
44
+ * Source de vérité unique partagée par tous les adapters — pendant, côté
45
+ * écriture, de {@link OPERATOR_KEYS}.
46
+ */
47
+ const UPDATE_OPERATOR_KEYS = ["$max", "$min"];
48
+ const UPDATE_OPERATOR_SET = new Set(UPDATE_OPERATOR_KEYS);
49
+ /**
50
+ * Indique si une valeur d'écriture est un objet d'{@link UpdateOperators}.
51
+ *
52
+ * Même heuristique que {@link isFieldOperators} : objet simple, non-`null`,
53
+ * non-tableau, dont **toutes** les clés propres sont des opérateurs d'écriture
54
+ * reconnus (et au moins une). Une valeur objet « ordinaire » (colonne JSON,
55
+ * sous-document) est donc écrite telle quelle, jamais interprétée.
56
+ *
57
+ * @param value - valeur d'écriture associée à un champ.
58
+ * @returns `true` si `value` doit être interprétée comme des opérateurs.
59
+ */
60
+ function isUpdateOperators(value) {
61
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
62
+ const keys = Object.keys(value);
63
+ if (keys.length === 0) return false;
64
+ for (const key of keys) if (!UPDATE_OPERATOR_SET.has(key)) return false;
65
+ return true;
66
+ }
67
+ /**
68
+ * Le caractère d'échappement des motifs `$like` du contrat portable.
69
+ *
70
+ * Il ne se choisit pas librement : **PostgreSQL et MySQL appliquent déjà `\`**
71
+ * quand aucune clause `ESCAPE` n'est écrite, si bien qu'un motif portant un
72
+ * antislash se comportait DÉJÀ différemment selon le moteur (SQLite, lui, n'a
73
+ * aucun échappement par défaut : il cherchait l'antislash littéral). Fixer `\`
74
+ * et l'émettre explicitement ne change donc pas la sémantique du contrat — cela
75
+ * la fait exister, en alignant les trois moteurs sur celui des deux
76
+ * comportements qui était déjà majoritaire.
77
+ *
78
+ * @see {@link escapeLikeTerm} pour construire un motif, {@link likePatternToRegExp}
79
+ * pour l'interpréter là où il n'y a pas de SQL (Mongo, mémoire).
80
+ */
81
+ const LIKE_ESCAPE_CHAR = "\\";
82
+ /**
83
+ * Neutralise les métacaractères d'un **texte** pour l'insérer dans un motif
84
+ * `$like` — `%`, `_` et l'antislash lui-même.
85
+ *
86
+ * À utiliser dès qu'un fragment de motif vient d'un humain ou d'une donnée :
87
+ * sans elle, chercher `50%` demande « 50 suivi de n'importe quoi », et chercher
88
+ * `a_b` ramène `axb`. L'utilisateur ne lit pas ça comme une imprécision, il le
89
+ * lit comme un résultat.
90
+ *
91
+ * L'échappement n'a de valeur que si la clause `ESCAPE` correspondante est
92
+ * ÉMISE : un motif échappé sans elle est cherché littéralement, antislash
93
+ * compris, et ne rend plus rien — en silence. C'est pourquoi les deux vont
94
+ * ensemble et vivent ici, et non chez chaque appelant.
95
+ *
96
+ * @param text - le fragment littéral à insérer dans un motif.
97
+ * @returns le même texte, ses métacaractères neutralisés.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * { discount: { $like: `${escapeLikeTerm("50%")}%` } } // → "50\%%"
102
+ * ```
103
+ */
104
+ function escapeLikeTerm(text) {
105
+ return text.replace(/[\\%_]/g, (c) => "\\" + c);
106
+ }
107
+ /**
108
+ * Traduit un motif `$like` en expression régulière **ancrée** — pour les stores
109
+ * qui n'ont pas de `LIKE` (MongoDB, implémentations en mémoire).
110
+ *
111
+ * C'est la contrepartie exacte de ce qu'un moteur SQL fait avec
112
+ * `LIKE … ESCAPE '\'` : `%` vaut « n'importe quelle suite », `_` « un
113
+ * caractère », et un caractère précédé de {@link LIKE_ESCAPE_CHAR} vaut
114
+ * lui-même. Sans cette lecture, un adapter documentaire rendrait des résultats
115
+ * différents d'un adapter SQL pour le même critère portable — la divergence la
116
+ * plus coûteuse qui soit, puisqu'elle ne se voit qu'en changeant de backend.
117
+ *
118
+ * Un antislash final sans caractère à échapper est traité comme un antislash
119
+ * littéral, comme le font les moteurs SQL.
120
+ *
121
+ * @param pattern - le motif du contrat (`préfixe%`, `a\_b`…).
122
+ * @returns une `RegExp` ancrée aux deux bouts.
123
+ */
124
+ function likePatternToRegExp(pattern) {
125
+ let source = "";
126
+ for (let i = 0; i < pattern.length; i++) {
127
+ const c = pattern[i];
128
+ if (c === "\\" && i + 1 < pattern.length) source += escapeRegExpChar(pattern[++i]);
129
+ else if (c === "%") source += ".*";
130
+ else if (c === "_") source += ".";
131
+ else source += escapeRegExpChar(c);
132
+ }
133
+ return new RegExp(`^${source}$`);
134
+ }
135
+ /** Neutralise UN caractère pour l'insérer dans une expression régulière. */
136
+ function escapeRegExpChar(c) {
137
+ return /[.*+?^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
138
+ }
139
+ /**
140
+ * Traduit un terme de recherche `?q=` en critère portable — **la** règle de
141
+ * recherche d'orm-core, en un seul exemplaire.
142
+ *
143
+ * Elle existe parce qu'elle était écrite plusieurs fois, presque à l'identique,
144
+ * dans des stores qui n'avaient aucun test dessus.
145
+ *
146
+ * **Le motif est ANCRÉ À GAUCHE** (`préfixe%`), donc **indexable**. Une
147
+ * recherche `%terme%` interdit tout usage d'index et impose un balayage
148
+ * complet : plus « pratique » sur dix lignes, intenable sur un million. C'est un
149
+ * choix de conception — un besoin de sous-chaîne relève d'un index plein-texte,
150
+ * pas de `LIKE`.
151
+ *
152
+ * Plusieurs champs deviennent un `$or` ; un seul reste un critère plat (les
153
+ * deux formes sont équivalentes pour les adapters, la seconde est plus lisible
154
+ * dans les journaux de requêtes).
155
+ *
156
+ * Le terme est **échappé** ({@link escapeLikeTerm}) : chercher `50%` cherche
157
+ * « 50% », et `a_b` ne ramène pas `axb`. Ça n'a été possible qu'une fois la
158
+ * clause `ESCAPE` émise par la traduction de `$like` — auparavant un terme
159
+ * échappé était cherché littéralement, antislash compris, et ne rendait plus
160
+ * rien du tout, en silence. Les deux gestes sont indissociables : c'est pourquoi
161
+ * ils vivent dans le même fichier.
162
+ *
163
+ * @param q - le terme saisi, déjà trimé par `parsePageQuery`.
164
+ * @param fields - les champs sur lesquels chercher, en noms de propriétés.
165
+ * @returns le critère à fusionner, ou `null` si le terme est vide ou qu'aucun
166
+ * champ n'est déclaré — l'appelant décide alors quoi faire de `q`.
167
+ */
168
+ function searchCriteria(q, fields) {
169
+ const term = q?.trim();
170
+ if (!term || fields.length === 0) return null;
171
+ const likePattern = `${escapeLikeTerm(term)}%`;
172
+ if (fields.length === 1) return { [fields[0]]: { $like: likePattern } };
173
+ return { $or: fields.map((f) => ({ [f]: { $like: likePattern } })) };
174
+ }
175
+ //#endregion
176
+ export { LIKE_ESCAPE_CHAR, OPERATOR_KEYS, UPDATE_OPERATOR_KEYS, escapeLikeTerm, isFieldOperators, isUpdateOperators, likePatternToRegExp, searchCriteria };
@@ -0,0 +1,67 @@
1
+ import { entityRegistry } from "../EntityRegistry.js";
2
+ //#region nodefony/src/decorators/entitiesDecorator.ts
3
+ /** Connecteur retenu quand ni l'entité ni le décorateur n'en nomment un. */
4
+ const DEFAULT_CONNECTOR = "default";
5
+ /**
6
+ * Déclare les entités qu'un module apporte — l'équivalent de `@controllers([...])`
7
+ * pour la couche données.
8
+ *
9
+ * ```ts
10
+ * @entities([PostEntity, CommentEntity])
11
+ * @controllers([PostController])
12
+ * class Blog extends Module { … }
13
+ * ```
14
+ *
15
+ * **Pourquoi ce décorateur existe** : l'inscription d'une entité est impérative
16
+ * (`entityRegistry.register()`), et il n'y a aucune découverte automatique. Sans lui,
17
+ * une application n'a **aucun endroit** où déclarer ses entités : elle dépend d'un
18
+ * import à effet de bord, où un fichier simplement oublié donne une entité
19
+ * silencieusement absente — l'erreur n'apparaît qu'au premier `getRepository()`, loin
20
+ * de sa cause. Une liste, elle, se lit.
21
+ *
22
+ * **Phase `onRegister`, jamais `onBoot`** (piège) : les connecteurs se branchent à
23
+ * `onBoot` et créent les tables à ce moment-là. Enregistrer les entités à `onBoot`,
24
+ * comme le fait `@controllers`, en ferait une **course** avec le `connect()` : selon
25
+ * l'ordre des écouteurs, la table n'existerait pas. `onRegister` est strictement
26
+ * antérieur — sûr par construction.
27
+ *
28
+ * **Idempotent** : une entité déjà inscrite pour le même connecteur est ignorée (un
29
+ * module peut être instancié deux fois dans un même processus — tests, rechargement).
30
+ * Une **collision réelle** (deux entités différentes, même nom, même connecteur) reste
31
+ * une erreur levée par le registre : c'est un conflit de modèle, pas un doublon bénin.
32
+ *
33
+ * @param list - descripteurs produits par `defineEntity()` (un seul ou un tableau).
34
+ * @param options - connecteur cible commun.
35
+ * @returns le décorateur de classe `Module`.
36
+ */
37
+ function entities(list, options = {}) {
38
+ const definitions = Array.isArray(list) ? list : [list];
39
+ return function(constructor) {
40
+ class NewConstructorEntities extends constructor {
41
+ constructor(...args) {
42
+ super(...args);
43
+ this.kernel?.once("onRegister", () => {
44
+ this.initDecoratorEntities();
45
+ });
46
+ }
47
+ /** Inscrit les entités déclarées dans le registre, avant toute connexion ORM. */
48
+ initDecoratorEntities() {
49
+ for (const definition of definitions) {
50
+ const connector = definition.connector ?? options.connector ?? "default";
51
+ if (entityRegistry.has(definition.name, connector)) {
52
+ this.log(`ENTITY ${definition.name} (${connector}) déjà enregistrée — ignorée`, "DEBUG");
53
+ continue;
54
+ }
55
+ entityRegistry.register({
56
+ ...definition,
57
+ connector
58
+ });
59
+ this.log(`ADD ENTITY : ${definition.name} (connector ${connector})`, "DEBUG");
60
+ }
61
+ }
62
+ }
63
+ return NewConstructorEntities;
64
+ };
65
+ }
66
+ //#endregion
67
+ export { DEFAULT_CONNECTOR, entities };
@@ -0,0 +1,51 @@
1
+ import { entityRegistry } from "../EntityRegistry.js";
2
+ import { setEntityMeta } from "./metadataStore.js";
3
+ //#region nodefony/src/decorators/entityDecorator.ts
4
+ /**
5
+ * Décore une classe comme entité multi-ORM et l'enregistre au chargement.
6
+ *
7
+ * Résout le piège d'ordre d'initialisation TS (le constructeur de base
8
+ * s'exécute avant les initialiseurs de champs de la sous-classe) : le décorateur
9
+ * s'exécute sur la **classe** au chargement du module, donc `name`/`connector`
10
+ * sont connus sans instance. Il construit un **descripteur {@link IEntity} depuis
11
+ * les options** (aucune instanciation au boot), l'enregistre dans le
12
+ * `entityRegistry` process-wide, et stocke la métadonnée via un `WeakMap`
13
+ * (cf. `metadataStore`, sans `reflect-metadata`).
14
+ *
15
+ * @param options - connecteur cible + nom/schéma/relations optionnels.
16
+ * @returns le décorateur de classe (renvoie la classe inchangée).
17
+ * @throws si une entité de même `name` est déjà enregistrée sur le même `connector`.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * \@entity({ connector: "db_principale", schema: { id: { type: "uuid" } } })
22
+ * class User extends Entity { ... }
23
+ * ```
24
+ */
25
+ function entity(options) {
26
+ return (target) => {
27
+ const name = options.name ?? target.name;
28
+ const meta = {
29
+ name,
30
+ connector: options.connector,
31
+ module: options.module,
32
+ domain: options.domain,
33
+ schema: options.schema,
34
+ relations: options.relations,
35
+ target
36
+ };
37
+ setEntityMeta(target, meta);
38
+ const descriptor = {
39
+ name,
40
+ connector: options.connector,
41
+ module: options.module,
42
+ domain: options.domain,
43
+ schema: options.schema,
44
+ relations: options.relations
45
+ };
46
+ entityRegistry.register(descriptor);
47
+ return target;
48
+ };
49
+ }
50
+ //#endregion
51
+ export { entity };
@@ -0,0 +1,5 @@
1
+ import { getEntityMeta, getRepositoryMeta, hasEntityMeta, hasRepositoryMeta } from "./metadataStore.js";
2
+ import { entity } from "./entityDecorator.js";
3
+ import { DEFAULT_CONNECTOR, entities } from "./entitiesDecorator.js";
4
+ import { repository } from "./repositoryDecorator.js";
5
+ export { DEFAULT_CONNECTOR, entities, entity, getEntityMeta, getRepositoryMeta, hasEntityMeta, hasRepositoryMeta, repository };
@@ -0,0 +1,38 @@
1
+ //#region nodefony/src/decorators/metadataStore.ts
2
+ /**
3
+ * Stockage des métadonnées de décorateurs SANS `reflect-metadata`.
4
+ *
5
+ * orm-core est une lib pure : aucune dépendance runtime hors `nodefony`. Les
6
+ * décorateurs `@entity`/`@repository` ne lisent jamais les types émis par TS
7
+ * (`design:paramtypes`), donc un `WeakMap<classe, métadonnée>` process-wide
8
+ * suffit — clé = constructeur, GC-friendly (pas de fuite si la classe est
9
+ * déréférencée). Aucun polyfill Reflect requis.
10
+ */
11
+ const ENTITY_META = /* @__PURE__ */ new WeakMap();
12
+ const REPOSITORY_META = /* @__PURE__ */ new WeakMap();
13
+ /** Enregistre la métadonnée `@entity` d'une classe. */
14
+ function setEntityMeta(target, meta) {
15
+ ENTITY_META.set(target, meta);
16
+ }
17
+ /** Récupère la métadonnée `@entity` d'une classe, ou `undefined`. */
18
+ function getEntityMeta(target) {
19
+ return ENTITY_META.get(target);
20
+ }
21
+ /** Indique si une classe porte une métadonnée `@entity`. */
22
+ function hasEntityMeta(target) {
23
+ return ENTITY_META.has(target);
24
+ }
25
+ /** Enregistre la métadonnée `@repository` d'une classe. */
26
+ function setRepositoryMeta(target, meta) {
27
+ REPOSITORY_META.set(target, meta);
28
+ }
29
+ /** Récupère la métadonnée `@repository` d'une classe, ou `undefined`. */
30
+ function getRepositoryMeta(target) {
31
+ return REPOSITORY_META.get(target);
32
+ }
33
+ /** Indique si une classe porte une métadonnée `@repository`. */
34
+ function hasRepositoryMeta(target) {
35
+ return REPOSITORY_META.has(target);
36
+ }
37
+ //#endregion
38
+ export { getEntityMeta, getRepositoryMeta, hasEntityMeta, hasRepositoryMeta, setEntityMeta, setRepositoryMeta };
@@ -0,0 +1,35 @@
1
+ import { setRepositoryMeta } from "./metadataStore.js";
2
+ //#region nodefony/src/decorators/repositoryDecorator.ts
3
+ /**
4
+ * Décore une classe comme repository d'une entité (tag métadonnée pur).
5
+ *
6
+ * Stocke le lien repo↔entity dans un `WeakMap` (cf. `metadataStore`, sans
7
+ * `reflect-metadata`). N'enregistre RIEN dans un registre en P5.3 : le binding
8
+ * DI (`@Inject('repository.user.db_principale')`) est câblé par l'adapter ORM
9
+ * concret (P5.4+), qui scanne ces métadonnées. Coût uniquement au chargement
10
+ * du module — jamais par requête.
11
+ *
12
+ * @param name - nom logique du repository (clé DI, ex. `"repository.user"`).
13
+ * @param options - entité gérée + connecteur cible optionnel.
14
+ * @returns le décorateur de classe (renvoie la classe inchangée).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * \@repository("repository.user", { entity: "User", connector: "db_principale" })
19
+ * class UserRepository implements IRepository<User> { ... }
20
+ * ```
21
+ */
22
+ function repository(name, options) {
23
+ return (target) => {
24
+ const meta = {
25
+ name,
26
+ entity: options.entity,
27
+ connector: options.connector,
28
+ target
29
+ };
30
+ setRepositoryMeta(target, meta);
31
+ return target;
32
+ };
33
+ }
34
+ //#endregion
35
+ export { repository };
@@ -0,0 +1,27 @@
1
+ //#region nodefony/src/defineEntity.ts
2
+ /**
3
+ * Déclare une entité applicative. Fonction d'**identité typée** : elle ne fait
4
+ * qu'attacher le type — aucun effet de bord, aucun enregistrement.
5
+ *
6
+ * L'enregistrement est le rôle du décorateur `entities([...])` posé sur le Module,
7
+ * qui s'exécute à la phase `onRegister` (avant que l'ORM ne se connecte). Séparer
8
+ * les deux permet d'importer une entité (dans un test, un script, un autre module)
9
+ * sans déclencher son inscription dans un registre global.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * export const PostEntity = defineEntity({
14
+ * name: "Post",
15
+ * module: "blog",
16
+ * schema: postTable, // table Drizzle native (ou SchemaDefinition Mongoose)
17
+ * });
18
+ * ```
19
+ *
20
+ * @param definition - descripteur (nom logique, schéma natif, module, relations…).
21
+ * @returns le descripteur, tel quel.
22
+ */
23
+ function defineEntity(definition) {
24
+ return definition;
25
+ }
26
+ //#endregion
27
+ export { defineEntity };
@@ -0,0 +1,76 @@
1
+ //#region nodefony/src/errors.ts
2
+ /**
3
+ * Erreurs typées du socle ORM — **data-level uniquement** (aucun couplage à une
4
+ * couche API/transport : ces erreurs décrivent des fautes sur les données, pas
5
+ * sur une requête HTTP/GraphQL). Une surface API qui les rencontre décide
6
+ * elle-même comment les projeter (400, erreur GraphQL…), l'ORM n'en sait rien.
7
+ */
8
+ /**
9
+ * Levée quand un critère de repository référence un **champ inconnu** de l'entité
10
+ * ciblée.
11
+ *
12
+ * Garde-fou « ultra solide » + **portabilité** : sans elle, un champ inconnu est
13
+ * traité différemment selon l'adapter — Drizzle l'**ignore** (la condition
14
+ * disparaît → la requête peut renvoyer **toute** la table), Mongoose le **garde**
15
+ * (→ **0 résultat**). Le même critère mal typé donnerait donc des résultats
16
+ * opposés selon l'ORM (rupture de la promesse « swap d'ORM ») et une faute de
17
+ * frappe (`emial`) passerait silencieusement. On échoue **tôt et pareil** sur les
18
+ * deux drivers.
19
+ *
20
+ * Les requêtes natives/calculées (opérateurs logiques `$or`, sous-requêtes…) ne
21
+ * relèvent **pas** du critère portable : passer par `IOrm.getNativeConnection()`.
22
+ */
23
+ var UnknownCriteriaField = class extends Error {
24
+ /** Champ fautif (clé du critère non résolue sur l'entité). */
25
+ field;
26
+ /** Nom logique de l'entité ciblée. */
27
+ entity;
28
+ /** Champs connus de l'entité (aide au diagnostic / faute de frappe). */
29
+ known;
30
+ /**
31
+ * @param field - champ inconnu rencontré dans le critère.
32
+ * @param entity - entité ciblée (nom logique).
33
+ * @param known - champs connus de l'entité.
34
+ */
35
+ constructor(field, entity, known) {
36
+ super(`Unknown criteria field "${field}" on entity "${entity}". Known fields: ${known.join(", ")}. For native/computed queries (logical $or, sub-queries…), use getNativeConnection().`);
37
+ this.name = "UnknownCriteriaField";
38
+ this.field = field;
39
+ this.entity = entity;
40
+ this.known = known;
41
+ }
42
+ };
43
+ /**
44
+ * Levée quand l'option de lecture `order` n'est pas un tableau de couples
45
+ * `[champ, "ASC" | "DESC"]`.
46
+ *
47
+ * Symétrique de {@link UnknownCriteriaField}, sous la même règle « jamais un skip
48
+ * silencieux » : avant cette garde, une forme voisine mais fausse — `{ age: "asc" }`
49
+ * au lieu de `[["age", "ASC"]]` — faisait partir la requête **sans `ORDER BY`**, et
50
+ * l'appelant recevait des lignes non triées qu'il croyait triées. Le sens est vérifié
51
+ * en casse exacte : un `"desc"` minuscule aurait produit un tri **ASC**, soit
52
+ * l'inverse de l'intention.
53
+ *
54
+ * TypeScript attrape déjà le cas au typage ; la surface réelle est l'appel non typé
55
+ * ou construit dynamiquement — exactement là où un tri faux ne se voit pas. La
56
+ * normalisation d'une entrée utilisateur (casse, `champ:sens`) appartient à la
57
+ * frontière qui la reçoit (`parsePageQuery`), jamais au repository.
58
+ */
59
+ var InvalidOrderOption = class extends Error {
60
+ /** Nom logique de l'entité ciblée. */
61
+ entity;
62
+ /** Ce qui a été reçu, décrit par sa FORME (jamais la valeur : pas de fuite de données). */
63
+ received;
64
+ /**
65
+ * @param entity - entité ciblée (nom logique).
66
+ * @param received - description de la forme reçue (ex. `an object`, `pair #0 is not an array`).
67
+ */
68
+ constructor(entity, received) {
69
+ super(`Invalid "order" option on entity "${entity}": expected an array of [field, "ASC" | "DESC"] pairs, received ${received}. Example: { order: [["createdAt", "DESC"]] }.`);
70
+ this.name = "InvalidOrderOption";
71
+ this.entity = entity;
72
+ this.received = received;
73
+ }
74
+ };
75
+ //#endregion
76
+ export { InvalidOrderOption, UnknownCriteriaField };
@@ -0,0 +1,78 @@
1
+ import { ormRegistry } from "./OrmRegistry.js";
2
+ import { buildConnectionHealth, buildOrmFlow, registerOrmAdminApi } from "./OrmAdminApi.js";
3
+ import { buildOrmLeanHealth } from "./buildOrmLeanHealth.js";
4
+ import { setOrmHealthProvider, setOrmRichProvider } from "nodefony";
5
+ //#region nodefony/src/ormWiring.ts
6
+ /**
7
+ * Branche le « plan d'administration » ORM dans le kernel — montage **idempotent**
8
+ * appelé par CHAQUE driver à son `onKernelBoot`. Factorise la dette C5 : le bloc
9
+ * était jusqu'ici recopié à l'identique dans chaque module driver (Drizzle,
10
+ * Mongoose), donc voué à diverger ; il vit désormais à un seul endroit.
11
+ *
12
+ * Trois branchements, tous **GLOBAUX** (ils itèrent `ormRegistry`) → couvrent
13
+ * TOUS les ORM enregistrés, peu importe le driver appelant ; idempotents
14
+ * (« dernier gagne ») → sûr d'être invoqué par N drivers :
15
+ * 1. `registerOrmAdminApi` (si un broker admin est présent) — monte les routes
16
+ * data plane `/nodefony/orm/api/*` ;
17
+ * 2. `setOrmHealthProvider` — santé ORM lean dans la sonde cluster (par worker) ;
18
+ * 3. `setOrmRichProvider` — diagnostic riche (connexion + flux) pour le drill Studio.
19
+ *
20
+ * Les seams `setOrm*Provider` matérialisent l'inversion de dépendance : le core
21
+ * expose la prise, orm-core fournit l'implémentation agnostique
22
+ * (0 dépendance `framework` → `orm-core`).
23
+ *
24
+ * @param kernel - kernel courant (`this.kernel` du module driver), ou nullish.
25
+ */
26
+ function wireOrmAdminPlane(kernel) {
27
+ const broker = kernel?.container?.get("adminBroker");
28
+ if (broker) registerOrmAdminApi(broker);
29
+ setOrmHealthProvider(buildOrmLeanHealth);
30
+ setOrmRichProvider(async () => ({
31
+ health: await buildConnectionHealth(),
32
+ flow: buildOrmFlow()
33
+ }));
34
+ kernel?.once?.("onServersReady", () => reportOrmBootLines(kernel));
35
+ }
36
+ /**
37
+ * Pousse dans le BootReporter une ligne par ORM enregistré (« nom → driver
38
+ * (cible) ») pour que la phase de boot « Services & ORM » RACONTE les connexions
39
+ * mises en place, au lieu d'une phase muette. Idempotent : reconstruit la liste
40
+ * complète depuis le registre et REMPLACE (sûr d'être appelé par N drivers).
41
+ *
42
+ * `describeConnection()` ne révèle JAMAIS de credential (redaction côté adapter).
43
+ * Le libellé « Services & ORM » doit matcher une phase du `BootReporter` (core).
44
+ *
45
+ * @param kernel - kernel courant (`this.kernel` du module driver), ou nullish.
46
+ */
47
+ function reportOrmBootLines(kernel) {
48
+ if (!kernel) return;
49
+ const lines = [];
50
+ for (const name of ormRegistry.list()) try {
51
+ const orm = ormRegistry.get(name);
52
+ const info = orm.describeConnection?.();
53
+ if (!info) continue;
54
+ const target = info.target ? ` ${info.target}` : "";
55
+ const state = orm.isConnected() ? "" : " (non connecté)";
56
+ lines.push(`${name} → ${info.driver}${target}${state}`);
57
+ } catch {}
58
+ kernel.setBootLines("Services & ORM", lines);
59
+ }
60
+ /**
61
+ * Détermine si la sonde de flux ORM (`queryFlowMonitor`) doit être active :
62
+ * **OFF en production** (coût nul sur le hot path des requêtes), **ON sinon**
63
+ * (observabilité dev / Supervision). Override explicite par la variable
64
+ * d'environnement `NF_ORM_FLOW` (`1`/`true` = forcer ON).
65
+ *
66
+ * Factorise le calcul recopié à l'identique dans chaque `*Service.onBoot` (le
67
+ * pendant « flux » de {@link wireOrmAdminPlane}, gardé distinct car il s'exécute
68
+ * dans le Service, pas le Module, et porte une responsabilité différente).
69
+ *
70
+ * @param kernel - kernel courant (pour lire l'environnement d'exécution).
71
+ * @returns `true` si la sonde de flux doit être activée.
72
+ */
73
+ function resolveOrmFlowEnabled(kernel) {
74
+ const flag = process.env.NF_ORM_FLOW;
75
+ return flag !== void 0 ? flag === "1" || flag === "true" : kernel?.environment !== "production";
76
+ }
77
+ //#endregion
78
+ export { reportOrmBootLines, resolveOrmFlowEnabled, wireOrmAdminPlane };
@@ -0,0 +1,55 @@
1
+ import { searchCriteria } from "./criteria.js";
2
+ import { PageQueryError } from "nodefony";
3
+ //#region nodefony/src/paginate.ts
4
+ /**
5
+ * Pagine un {@link IRepository} de façon **portable** — au-dessus des primitives
6
+ * natives `find(criteria, { limit, offset, order })` et `count(criteria)` que tout
7
+ * adapter implémente déjà (SQL `LIMIT/OFFSET`+`COUNT`, Mongo `skip/limit`+
8
+ * `countDocuments`…). Aucun adapter n'a à changer : un seul aller vers la page,
9
+ * jamais de matérialisation de toute la collection.
10
+ *
11
+ * `hasNext` est obtenu **sans `COUNT`** par l'astuce `limit + 1` : on demande une
12
+ * ligne de plus que la page ; si elle arrive, il y a une suite (on la retire du
13
+ * résultat). Le `COUNT(*)` — coûteux sur les grosses tables — n'est payé que si
14
+ * `withTotal` n'est pas `false` (mode « Page » vs « Slice », cf Spring Data).
15
+ *
16
+ * La **recherche** `?q=` n'est honorée que si l'appelant déclare où chercher
17
+ * ({@link IPaginateOptions.searchable}) ; sans cela elle est refusée en `400`.
18
+ *
19
+ * @typeParam T - type de l'entité paginée.
20
+ * @param repo - le repository à paginer.
21
+ * @param page - la requête de page ({@link PageQuery}).
22
+ * @param options - capacités de ce point d'entrée (recherche).
23
+ * @returns une {@link Page} : au plus `limit` items, `hasNext`, et `total` si demandé.
24
+ * @throws {@link PageQueryError} (`400`) si un `q` est reçu sans champ
25
+ * cherchable déclaré, ou si le critère porte déjà un `$or`.
26
+ */
27
+ async function paginate(repo, page, options = {}) {
28
+ const limit = Math.max(1, Math.floor(page.limit));
29
+ const offset = Math.max(0, Math.floor(page.offset ?? 0));
30
+ const withTotal = page.withTotal ?? true;
31
+ let criteria = page.criteria;
32
+ const search = searchCriteria(page.q, options.searchable ?? []);
33
+ if (search) {
34
+ if (criteria && "$or" in criteria) throw new PageQueryError("Cannot combine full-text search with a criteria that already uses \"$or\" (the grammar has no \"$and\" to nest them). Search on a single field, or build the criteria in the caller.");
35
+ criteria = {
36
+ ...criteria,
37
+ ...search
38
+ };
39
+ } else if (page.q?.trim()) throw new PageQueryError("This endpoint does not support full-text search (\"q\"): no searchable field is declared. Pass { searchable: [...] } to paginate(), or drop \"q\".");
40
+ const rows = await repo.find(criteria, {
41
+ limit: limit + 1,
42
+ offset,
43
+ order: page.order
44
+ });
45
+ const hasNext = rows.length > limit;
46
+ return {
47
+ items: hasNext ? rows.slice(0, limit) : rows,
48
+ limit,
49
+ offset,
50
+ hasNext,
51
+ total: withTotal ? await repo.count(criteria) : void 0
52
+ };
53
+ }
54
+ //#endregion
55
+ export { paginate };