@nodefony/orm-core 10.0.0-alpha.7 → 10.0.0-alpha.9

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/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { LIKE_ESCAPE_CHAR, OPERATOR_KEYS, UPDATE_OPERATOR_KEYS, escapeLikeTerm, isFieldOperators, isUpdateOperators, likePatternToRegExp, searchCriteria } from "./nodefony/src/criteria.js";
2
- import { InvalidOrderOption, UnknownCriteriaField } from "./nodefony/src/errors.js";
2
+ import { InvalidOrderOption, ReferencedEntityError, SavepointNotSupportedError, UnknownCriteriaField } from "./nodefony/src/errors.js";
3
3
  import { assertOrderOption } from "./nodefony/src/readOptions.js";
4
4
  import { OrmRegistry, ormRegistry } from "./nodefony/src/OrmRegistry.js";
5
5
  import { EntityRegistry, entityRegistry } from "./nodefony/src/EntityRegistry.js";
6
+ import { reportOrphanEntities } from "./nodefony/src/orphanEntities.js";
6
7
  import { connectionMonitor } from "./nodefony/src/ConnectionMonitor.js";
7
8
  import { Orm } from "./nodefony/src/Orm.js";
8
9
  import { Entity } from "./nodefony/src/Entity.js";
@@ -11,7 +12,7 @@ import { AbstractCrudService } from "./nodefony/src/AbstractCrudService.js";
11
12
  import { queryFlowMonitor } from "./nodefony/src/QueryFlowMonitor.js";
12
13
  import { buildConnectionHealth, buildOrmFlow, buildOrmGraph, createOrmAdminApi, registerOrmAdminApi, toDbml, toJsonSchema } from "./nodefony/src/OrmAdminApi.js";
13
14
  import { buildOrmLeanHealth } from "./nodefony/src/buildOrmLeanHealth.js";
14
- import { diagnoseConnectionFailure, parseConnectionTarget } from "./nodefony/src/connectionDiagnosis.js";
15
+ import { REJECTED_MARKER, describeConnectFailure, diagnoseConnectionFailure, parseConnectionTarget } from "./nodefony/src/connectionDiagnosis.js";
15
16
  import { CONNECT_TIMEOUT_MS, withConnectDeadline } from "./nodefony/src/connectDeadline.js";
16
17
  import { reportOrmBootLines, resolveOrmFlowEnabled, wireOrmAdminPlane } from "./nodefony/src/ormWiring.js";
17
18
  import { isMigrationFailure } from "./nodefony/interfaces/IOrmMigrations.js";
@@ -21,4 +22,4 @@ import { DEFAULT_CONNECTOR, entities } from "./nodefony/src/decorators/entitiesD
21
22
  import { repository } from "./nodefony/src/decorators/repositoryDecorator.js";
22
23
  import "./nodefony/src/decorators/index.js";
23
24
  import { defineEntity } from "./nodefony/src/defineEntity.js";
24
- export { AbstractCrudService, CONNECT_TIMEOUT_MS, DEFAULT_CONNECTOR, Entity, EntityRegistry, InvalidOrderOption, LIKE_ESCAPE_CHAR, OPERATOR_KEYS, Orm, OrmRegistry, UPDATE_OPERATOR_KEYS, UnknownCriteriaField, assertOrderOption, buildConnectionHealth, buildOrmFlow, buildOrmGraph, buildOrmLeanHealth, connectionMonitor, createOrmAdminApi, defineEntity, diagnoseConnectionFailure, entities, entity, entityRegistry, escapeLikeTerm, getEntityMeta, getRepositoryMeta, hasEntityMeta, hasRepositoryMeta, isFieldOperators, isMigrationFailure, isUpdateOperators, likePatternToRegExp, ormRegistry, paginate, parseConnectionTarget, queryFlowMonitor, registerOrmAdminApi, reportOrmBootLines, repository, resolveOrmFlowEnabled, searchCriteria, toDbml, toJsonSchema, wireOrmAdminPlane, withConnectDeadline };
25
+ export { AbstractCrudService, CONNECT_TIMEOUT_MS, DEFAULT_CONNECTOR, Entity, EntityRegistry, InvalidOrderOption, LIKE_ESCAPE_CHAR, OPERATOR_KEYS, Orm, OrmRegistry, REJECTED_MARKER, ReferencedEntityError, SavepointNotSupportedError, UPDATE_OPERATOR_KEYS, UnknownCriteriaField, assertOrderOption, buildConnectionHealth, buildOrmFlow, buildOrmGraph, buildOrmLeanHealth, connectionMonitor, createOrmAdminApi, defineEntity, describeConnectFailure, diagnoseConnectionFailure, entities, entity, entityRegistry, escapeLikeTerm, getEntityMeta, getRepositoryMeta, hasEntityMeta, hasRepositoryMeta, isFieldOperators, isMigrationFailure, isUpdateOperators, likePatternToRegExp, ormRegistry, paginate, parseConnectionTarget, queryFlowMonitor, registerOrmAdminApi, reportOrmBootLines, reportOrphanEntities, repository, resolveOrmFlowEnabled, searchCriteria, toDbml, toJsonSchema, wireOrmAdminPlane, withConnectDeadline };
@@ -1,6 +1,8 @@
1
1
  //#region nodefony/src/ConnectionMonitor.ts
2
2
  /** Taille max du ring d'erreurs récentes (borne mémoire). */
3
3
  const MAX_RECENT_ERRORS = 12;
4
+ /** Taille max de la chronologie pertes/reprises (borne mémoire). */
5
+ const MAX_EVENTS = 20;
4
6
  /** Taille de la fenêtre glissante de latence (min/moy/max). */
5
7
  const MAX_LATENCY_SAMPLES = 30;
6
8
  /** Fenêtre de latence vide. */
@@ -18,6 +20,9 @@ const EMPTY_CORE = {
18
20
  connectCount: 0,
19
21
  reconnectCount: 0,
20
22
  lostCount: 0,
23
+ lastLostAt: null,
24
+ lastRestoredAt: null,
25
+ events: [],
21
26
  errorCount: 0,
22
27
  lastConnectMs: null,
23
28
  lastError: null,
@@ -54,6 +59,9 @@ var ConnectionMonitor = class {
54
59
  connectCount: 0,
55
60
  reconnectCount: 0,
56
61
  lostCount: 0,
62
+ lastLostAt: null,
63
+ lastRestoredAt: null,
64
+ events: null,
57
65
  errorCount: 0,
58
66
  lastConnectMs: null,
59
67
  lastError: null,
@@ -99,11 +107,19 @@ var ConnectionMonitor = class {
99
107
  * preuve de bonne santé.
100
108
  *
101
109
  * @param name - clé du connecteur.
110
+ * @param reason - cause lisible (credential déjà retiré), si connue.
102
111
  */
103
- recordLost(name) {
112
+ recordLost(name, reason) {
104
113
  const s = this.#ensure(name);
114
+ const ts = Date.now();
105
115
  s.lostCount += 1;
106
116
  s.connectedSince = null;
117
+ s.lastLostAt = ts;
118
+ this.#pushEvent(s, {
119
+ kind: "lost",
120
+ ts,
121
+ reason
122
+ });
107
123
  }
108
124
  /**
109
125
  * Enregistre une **reprise** de connexion constatée par l'adapter.
@@ -112,8 +128,20 @@ var ConnectionMonitor = class {
112
128
  */
113
129
  recordReconnect(name) {
114
130
  const s = this.#ensure(name);
131
+ const ts = Date.now();
115
132
  s.reconnectCount += 1;
116
- s.connectedSince = Date.now();
133
+ s.connectedSince = ts;
134
+ s.lastRestoredAt = ts;
135
+ this.#pushEvent(s, {
136
+ kind: "restored",
137
+ ts
138
+ });
139
+ }
140
+ /** Ajoute un événement en tête de la chronologie (allouée au 1ᵉʳ, bornée). */
141
+ #pushEvent(s, e) {
142
+ if (s.events === null) s.events = [];
143
+ s.events.unshift(e);
144
+ if (s.events.length > MAX_EVENTS) s.events.length = MAX_EVENTS;
117
145
  }
118
146
  /**
119
147
  * Enregistre une erreur (connexion échouée ou ping en échec).
@@ -167,6 +195,9 @@ var ConnectionMonitor = class {
167
195
  connectCount: s.connectCount,
168
196
  reconnectCount: s.reconnectCount,
169
197
  lostCount: s.lostCount,
198
+ lastLostAt: s.lastLostAt,
199
+ lastRestoredAt: s.lastRestoredAt,
200
+ events: s.events ?? [],
170
201
  errorCount: s.errorCount,
171
202
  lastConnectMs: s.lastConnectMs,
172
203
  lastError: s.lastError,
@@ -126,7 +126,7 @@ var Orm = class extends Service {
126
126
  if (!this.alive) return;
127
127
  this.alive = false;
128
128
  this.#lostPending = true;
129
- connectionMonitor.recordLost(this.name);
129
+ connectionMonitor.recordLost(this.name, reason);
130
130
  this.log(`connexion perdue : ${reason}`, "WARNING");
131
131
  this.fire("onOrmLost", this, reason);
132
132
  }
@@ -197,6 +197,22 @@ var Orm = class extends Service {
197
197
  timer.unref?.();
198
198
  this.#heartbeat = timer;
199
199
  }
200
+ /**
201
+ * Décrit le mécanisme de résilience TEL QU'IL TOURNE — lecture pure, pour
202
+ * le data plane (`connection/health`) : période et montre du battement,
203
+ * battement actif ou non, perte en souffrance.
204
+ *
205
+ * @returns l'état du mécanisme dans ce process.
206
+ */
207
+ describeResilience() {
208
+ return {
209
+ heartbeatMs: this.heartbeatMs,
210
+ heartbeatTimeoutMs: this.heartbeatTimeoutMs,
211
+ heartbeatActive: this.#heartbeat !== null,
212
+ pingable: typeof this.ping === "function",
213
+ lostPending: this.#lostPending
214
+ };
215
+ }
200
216
  /** Arrête le battement et libère la minuterie. Idempotent. */
201
217
  stopHeartbeat() {
202
218
  if (this.#heartbeat !== null) {
@@ -2,6 +2,7 @@ import { ormRegistry } from "./OrmRegistry.js";
2
2
  import { entityRegistry } from "./EntityRegistry.js";
3
3
  import { connectionMonitor } from "./ConnectionMonitor.js";
4
4
  import { queryFlowMonitor } from "./QueryFlowMonitor.js";
5
+ import { Nodefony } from "nodefony";
5
6
  import { performance } from "node:perf_hooks";
6
7
  //#region nodefony/src/OrmAdminApi.ts
7
8
  /**
@@ -76,9 +77,38 @@ async function migrationCapability(request, capability, missingMessage) {
76
77
  return fn.call(orm);
77
78
  }
78
79
  /** Résumé des ORM enregistrés (statut connexion + nombre d'entités). */
80
+ /**
81
+ * Connecteur qui porte les stores de l'application — le connecteur PAR DÉFAUT.
82
+ *
83
+ * « Défaut » est un RÔLE, pas un nom : sur une infra MongoDB les briques
84
+ * durables vivent sur `nodefony` (Mongoose), et marquer `default` (Drizzle)
85
+ * parce qu'il s'appelle ainsi désignait une base que personne n'utilise. Le
86
+ * rôle se CONSTATE dans le registre des stores (#474) : le connecteur qui porte
87
+ * le plus de briques durables, puis de briques tout court.
88
+ *
89
+ * @param resolutions - registre des stores du kernel (`storeResolutions`).
90
+ * @param names - connecteurs enregistrés ; une brique sur un autre nom est ignorée.
91
+ * @returns le nom du connecteur, ou `null` si aucune brique n'est portée par
92
+ * un ORM (le repli sur le nom `default` s'applique alors).
93
+ */
94
+ function storeCarrier(resolutions, names) {
95
+ const score = /* @__PURE__ */ new Map();
96
+ for (const r of resolutions) {
97
+ if (!r.connector || !names.includes(r.connector)) continue;
98
+ score.set(r.connector, (score.get(r.connector) ?? 0) + (r.nature === "durable" ? 1e3 : 1));
99
+ }
100
+ let best = null;
101
+ for (const name of names) {
102
+ const s = score.get(name) ?? 0;
103
+ if (s > 0 && (best === null || s > (score.get(best) ?? 0))) best = name;
104
+ }
105
+ return best;
106
+ }
79
107
  function buildOrmSummaries() {
80
108
  const entities = entityRegistry.list();
81
- return ormRegistry.list().map((name) => {
109
+ const names = ormRegistry.list();
110
+ const carrier = storeCarrier(Nodefony.getKernel()?.storeResolutions ?? [], names);
111
+ return names.map((name) => {
82
112
  let connected = false;
83
113
  let vendor = "";
84
114
  let connection;
@@ -93,7 +123,7 @@ function buildOrmSummaries() {
93
123
  return {
94
124
  name,
95
125
  vendor,
96
- default: name === "default",
126
+ default: carrier === null ? name === "default" : name === carrier,
97
127
  connected,
98
128
  entityCount: entities.filter((e) => e.connector === name).length,
99
129
  connection
@@ -162,8 +192,10 @@ async function buildConnectionHealth(filter) {
162
192
  let storage;
163
193
  let pool;
164
194
  let extra;
195
+ let resilience;
165
196
  try {
166
197
  const inst = ormRegistry.get(name);
198
+ resilience = inst.describeResilience?.();
167
199
  connected = inst.isConnected();
168
200
  vendor = vendorOf(inst);
169
201
  const c = inst.describeConnection?.();
@@ -211,6 +243,11 @@ async function buildConnectionHealth(filter) {
211
243
  uptimeMs: core.uptimeMs,
212
244
  connectCount: core.connectCount,
213
245
  reconnectCount: core.reconnectCount,
246
+ lostCount: core.lostCount,
247
+ lastLostAt: core.lastLostAt,
248
+ lastRestoredAt: core.lastRestoredAt,
249
+ events: core.events,
250
+ resilience,
214
251
  errorCount: core.errorCount,
215
252
  lastError: core.lastError,
216
253
  recentErrors: core.recentErrors,
@@ -409,16 +446,21 @@ function createOrmAdminApi() {
409
446
  },
410
447
  {
411
448
  path: "counts",
412
- summary: "Nombre de lignes par entité (COUNT(*)) — ?connector= pour filtrer. Lazy : 1 COUNT par table.",
449
+ summary: "Nombre de lignes par entité (COUNT(*)) — ?connector= pour filtrer. Lazy : 1 COUNT par table. Clé = nom de l'entité, qualifiée `connecteur:nom` quand plusieurs connecteurs portent ce nom.",
413
450
  handler: async (request) => {
414
451
  const connector = oneParam(request, "connector");
415
452
  const counts = {};
416
453
  const entities = entityRegistry.list().filter((e) => !connector || e.connector === connector);
417
- for (const e of entities) try {
418
- const inst = ormRegistry.get(e.connector);
419
- counts[e.name] = inst.isConnected() ? await inst.getRepository(e.name).count() : -1;
420
- } catch {
421
- counts[e.name] = -1;
454
+ const homonyms = /* @__PURE__ */ new Map();
455
+ for (const e of entities) homonyms.set(e.name, (homonyms.get(e.name) ?? 0) + 1);
456
+ for (const e of entities) {
457
+ const key = (homonyms.get(e.name) ?? 0) > 1 ? `${e.connector}:${e.name}` : e.name;
458
+ try {
459
+ const inst = ormRegistry.get(e.connector);
460
+ counts[key] = inst.isConnected() ? await inst.getRepository(e.name).count() : -1;
461
+ } catch {
462
+ counts[key] = -1;
463
+ }
422
464
  }
423
465
  return counts;
424
466
  }
@@ -488,4 +530,4 @@ function registerOrmAdminApi(registry) {
488
530
  registry.register(createOrmAdminApi());
489
531
  }
490
532
  //#endregion
491
- export { buildConnectionHealth, buildOrmFlow, buildOrmGraph, createOrmAdminApi, registerOrmAdminApi, toDbml, toJsonSchema };
533
+ export { buildConnectionHealth, buildOrmFlow, buildOrmGraph, createOrmAdminApi, registerOrmAdminApi, storeCarrier, toDbml, toJsonSchema };
@@ -31,15 +31,41 @@ const ANSWERED_CODES = /* @__PURE__ */ new Set([
31
31
  "AuthenticationFailed",
32
32
  "Unauthorized"
33
33
  ]);
34
+ /**
35
+ * Classes SQLSTATE (PostgreSQL) qui décrivent la CONNEXION elle-même, pas une
36
+ * instruction : `08` exception de connexion, `57` intervention de l'opérateur
37
+ * (arrêt, base qui démarre). Tout autre SQLSTATE est rendu par un serveur à qui
38
+ * l'on parlait déjà.
39
+ */
40
+ const CONNECTION_SQLSTATE_CLASSES = /* @__PURE__ */ new Set(["08", "57"]);
41
+ /**
42
+ * Refus MySQL/MariaDB émis PENDANT l'établissement : le serveur n'a encore
43
+ * exécuté aucune instruction. Tout autre `ER_*` vient d'un serveur connecté.
44
+ */
45
+ const MYSQL_CONNECTION_PHASE = /* @__PURE__ */ new Set([
46
+ "ER_CON_COUNT_ERROR",
47
+ "ER_TOO_MANY_USER_CONNECTIONS",
48
+ "ER_HOST_IS_BLOCKED",
49
+ "ER_SERVER_SHUTDOWN"
50
+ ]);
51
+ /**
52
+ * Le code prouve-t-il que la connexion avait ABOUTI quand le refus est tombé ?
53
+ *
54
+ * Ce n'est vrai que d'un code produit par le SERVEUR en réponse à une
55
+ * instruction : un SQLSTATE PostgreSQL hors des classes de connexion, ou un
56
+ * `ER_*` MySQL hors de la phase d'établissement. Les codes de refus connus
57
+ * ({@link ANSWERED_CODES}) sont tranchés AVANT.
58
+ */
59
+ function provesEstablished(code) {
60
+ if (/^[0-9A-Z]{5}$/.test(code) && /[0-9]/.test(code)) return !CONNECTION_SQLSTATE_CLASSES.has(code.slice(0, 2));
61
+ return code.startsWith("ER_") && !MYSQL_CONNECTION_PHASE.has(code);
62
+ }
34
63
  /** Lit le code que le driver a posé sur l'erreur, sans rien supposer de sa forme. */
35
64
  function readCode(error) {
36
65
  if (typeof error !== "object" || error === null) return null;
37
66
  const carrier = error;
38
- for (const raw of [carrier.code, carrier.codeName]) {
39
- if (typeof raw === "string" && raw.length > 0) return raw;
40
- if (typeof raw === "number") return String(raw);
41
- }
42
- return null;
67
+ for (const raw of [carrier.code, carrier.codeName]) if (typeof raw === "string" && raw.length > 0) return raw;
68
+ return typeof carrier.code === "number" ? String(carrier.code) : null;
43
69
  }
44
70
  /**
45
71
  * Extrait l'hôte et le port d'une URL de connexion, sans son secret.
@@ -102,11 +128,45 @@ function diagnoseConnectionFailure(error, target = {
102
128
  code,
103
129
  explanation: `un serveur a RÉPONDU sur ${where} puis a refusé (${code}) — donc quelque chose tient bien ce port. Ce peut être la base attendue avec de mauvais identifiants, mais aussi UN AUTRE SERVEUR (le conteneur d'un autre projet, par exemple) : le refus vient alors d'une base qui n'est pas la vôtre, et vérifier les identifiants ne mène nulle part.${target.port !== null ? ` Pour savoir qui tient ce port : « docker ps --filter publish=${target.port} », ou « ${whoHoldsPort(target.port, platform)} ».` : ""}`
104
130
  };
131
+ if (code !== null && provesEstablished(code)) return {
132
+ verdict: "rejected",
133
+ code,
134
+ explanation: `la connexion à ${where} a ABOUTI, puis le serveur a refusé une instruction (${code}) — ce n'est ni l'adresse ni l'état de la base : c'est le schéma envoyé (une table, une contrainte, un type) qu'il faut corriger.`
135
+ };
105
136
  return {
106
137
  verdict: "unknown",
107
138
  code,
108
139
  explanation: `échec de connexion à ${where}${code ? ` (${code})` : ""} — vérifier que l'infrastructure déclarée (NF_DATABASE_URL / connectors) désigne bien la base attendue, et qu'elle est démarrée.`
109
140
  };
110
141
  }
142
+ /**
143
+ * Marqueur de la phrase « connecté, puis refusé » — lu par `create app`
144
+ * (`migrationFailureCause`, cœur) pour NE PAS conclure à une base injoignable.
145
+ * Le cœur ne peut pas importer ce paquet : sa copie est éprouvée contre
146
+ * celle-ci par un test de parité.
147
+ */
148
+ const REJECTED_MARKER = "s'est connecté, mais le serveur a refusé une instruction — ";
149
+ /**
150
+ * Compose le message d'échec au démarrage d'un connecteur — UNE rédaction pour
151
+ * tous les adapters.
152
+ *
153
+ * 🔴 Un refus d'instruction (verdict `rejected`) ne commence JAMAIS par « n'a
154
+ * pas pu se connecter » : c'était faux, et la phrase envoyait vérifier
155
+ * l'adresse et l'état d'une base qui répondait très bien. La cause du pilote
156
+ * passe alors EN TÊTE — c'est elle qui nomme la table ou la contrainte.
157
+ *
158
+ * @param subject - sujet de la phrase (`Drizzle : le connecteur "x" (…)`).
159
+ * @param diagnosis - verdict de {@link diagnoseConnectionFailure}.
160
+ * @param cause - message brut du pilote.
161
+ * @param advice - conseil propre à l'adapter, pour un échec de CONNEXION.
162
+ * @returns le message complet.
163
+ */
164
+ function describeConnectFailure(subject, diagnosis, cause, advice = "") {
165
+ if (diagnosis.verdict === "rejected") {
166
+ const explanation = diagnosis.explanation.charAt(0).toUpperCase() + diagnosis.explanation.slice(1);
167
+ return `${subject} ${REJECTED_MARKER}${cause}${diagnosis.code ? ` (${diagnosis.code})` : ""}. ${explanation}`;
168
+ }
169
+ return `${subject} n'a pas pu se connecter — ${diagnosis.explanation}${advice} Cause : ${cause}`;
170
+ }
111
171
  //#endregion
112
- export { diagnoseConnectionFailure, parseConnectionTarget };
172
+ export { REJECTED_MARKER, describeConnectFailure, diagnoseConnectionFailure, parseConnectionTarget };
@@ -72,5 +72,63 @@ var InvalidOrderOption = class extends Error {
72
72
  this.received = received;
73
73
  }
74
74
  };
75
+ /**
76
+ * Levée quand une suppression viserait une entité encore RÉFÉRENCÉE par une
77
+ * relation obligatoire d'une autre entité.
78
+ *
79
+ * C'est le pendant, pour un moteur sans clé étrangère (MongoDB), du `restrict`
80
+ * que le SQL généré pose sur une colonne de référence obligatoire : le parent
81
+ * ne part pas tant qu'un enfant le désigne. Sans cette garde, la suppression
82
+ * passait et `?include=` rendait ensuite `null` à la place du parent — une
83
+ * application passée du SQL à MongoDB perdait en silence une garantie qu'elle
84
+ * croyait avoir.
85
+ *
86
+ * Erreur de DONNÉES : c'est la surface API qui la projette (le rendu d'erreur
87
+ * HTTP la reconnaît par son `name` et répond 409).
88
+ */
89
+ var ReferencedEntityError = class extends Error {
90
+ /** Entité dont la suppression est refusée (nom logique). */
91
+ entity;
92
+ /** Entité qui la référence encore (nom logique). */
93
+ referencedBy;
94
+ /** Champ de référence de {@link ReferencedEntityError.referencedBy}. */
95
+ field;
96
+ /**
97
+ * @param entity - entité dont la suppression est refusée.
98
+ * @param referencedBy - entité qui la référence encore.
99
+ * @param field - champ de référence obligatoire qui la désigne.
100
+ */
101
+ constructor(entity, referencedBy, field) {
102
+ super(`Cannot delete "${entity}": still referenced by "${referencedBy}.${field}" (required reference). Delete or reassign the "${referencedBy}" entries first.`);
103
+ this.name = "ReferencedEntityError";
104
+ this.entity = entity;
105
+ this.referencedBy = referencedBy;
106
+ this.field = field;
107
+ }
108
+ };
109
+ /**
110
+ * Levée par `ITransaction.savepoint()` / `rollbackTo()` sur un moteur qui ne porte
111
+ * PAS de savepoints (MongoDB).
112
+ *
113
+ * Refuser vaut mieux que se taire : un `rollbackTo()` rendu en no-op laissait
114
+ * l'appelant croire ses écritures annulées alors qu'elles restaient en base. Le
115
+ * refus tombe dès `savepoint()`, pas au `rollbackTo()` : le chemin d'erreur, où
116
+ * l'on annule, est celui qu'on teste le moins — la faute doit se voir au premier
117
+ * essai. Pour annuler, terminer la transaction entière (`rollback()`, ou laisser
118
+ * le callback de `IOrm.transaction()` lever).
119
+ */
120
+ var SavepointNotSupportedError = class extends Error {
121
+ /** Moteur qui refuse (valeur de `describeConnection().driver`). */
122
+ driver;
123
+ /**
124
+ * @param driver - moteur qui ne porte pas de savepoints.
125
+ * @param operation - opération refusée (`savepoint` ou `rollbackTo`).
126
+ */
127
+ constructor(driver, operation) {
128
+ super(`${operation}() is not supported by the "${driver}" driver: it has no savepoints. Roll back the whole transaction instead (rollback(), or throw from the transaction callback).`);
129
+ this.name = "SavepointNotSupportedError";
130
+ this.driver = driver;
131
+ }
132
+ };
75
133
  //#endregion
76
- export { InvalidOrderOption, UnknownCriteriaField };
134
+ export { InvalidOrderOption, ReferencedEntityError, SavepointNotSupportedError, UnknownCriteriaField };
@@ -0,0 +1,63 @@
1
+ import { ormRegistry } from "./OrmRegistry.js";
2
+ import { entityRegistry } from "./EntityRegistry.js";
3
+ //#region nodefony/src/orphanEntities.ts
4
+ /**
5
+ * Entités inscrites sur un connecteur qu'aucun ORM n'a ouvert.
6
+ *
7
+ * Une telle entité ne sera servie par rien : son dépôt lèvera au premier
8
+ * appel, loin de sa cause. Vécu : sur une infra MongoDB, le `User` d'une
9
+ * application restait écrit en table SQL sur `default` — un connecteur que
10
+ * Drizzle n'ouvre pas sur cette infra — et le boot n'en disait RIEN.
11
+ *
12
+ * @param entities - entités inscrites (registre des entités).
13
+ * @param connectors - connecteurs ouverts (registre des ORM).
14
+ * @returns les orphelines, dans l'ordre d'inscription.
15
+ */
16
+ function findOrphanEntities(entities, connectors) {
17
+ return entities.filter((e) => !connectors.includes(e.connector));
18
+ }
19
+ /**
20
+ * La phrase qui NOMME les orphelines et le remède — `null` s'il n'y en a pas.
21
+ *
22
+ * @param orphans - entités sans connecteur ouvert.
23
+ * @param connectors - connecteurs ouverts, cités pour situer la faute.
24
+ * @param declared - connecteurs DÉCLARÉS par les modules ORM (leur configuration).
25
+ */
26
+ function describeOrphanEntities(orphans, connectors, declared = []) {
27
+ if (orphans.length === 0) return null;
28
+ const name = (e) => `${e.name}${e.module ? `@${e.module}` : ""} → « ${e.connector} »`;
29
+ const open = `Connecteurs ouverts : ${connectors.length > 0 ? connectors.join(", ") : "aucun"}.`;
30
+ const skipped = orphans.filter((e) => declared.includes(e.connector));
31
+ const unknown = orphans.filter((e) => !declared.includes(e.connector));
32
+ const parts = [];
33
+ if (unknown.length > 0) parts.push(`${unknown.length} entité(s) inscrite(s) sur un connecteur qu'aucun ORM n'a ouvert — rien ne les servira, leur dépôt lèvera au premier appel : ${unknown.map(name).join(", ")}. ${open} Une entité doit suivre la base déclarée (NF_DATABASE_URL) : table SQL sur une infra SQL, document sur MongoDB. État réel : npx nodefony inspect entities.`);
34
+ if (skipped.length > 0) parts.push(`${skipped.length} entité(s) sur un connecteur DÉCLARÉ mais jamais ouvert : ${skipped.map(name).join(", ")}. Ce n'est pas leur déclaration qui est en cause : un connecteur a échoué au démarrage (voir l'erreur de connexion plus haut), et ceux qui le suivent n'ont pas été ouverts. ${open}`);
35
+ return parts.join(" ");
36
+ }
37
+ /** Les démarrages déjà rapportés — un Kernel ne le dit qu'une fois. */
38
+ const reported = /* @__PURE__ */ new WeakSet();
39
+ /**
40
+ * Signale UNE fois par démarrage les entités orphelines, au moment où tous les
41
+ * ORM sont ouverts.
42
+ *
43
+ * Appelée par chaque module ORM (Drizzle, Mongoose) à `onReady` : la règle vit
44
+ * ici, une fois, et le premier appel la rend — le second se tait, sans quoi une
45
+ * application qui charge les deux ORM lirait deux fois le même avertissement.
46
+ * La garde porte sur le KERNEL et non sur le process : un process qui démarre
47
+ * plusieurs noyaux (une suite de tests) doit être prévenu à chacun.
48
+ *
49
+ * @param log - journal du module appelant.
50
+ * @param owner - le démarrage concerné (le Kernel), clé de la garde.
51
+ * @param declared - connecteurs que l'appelant a DÉCLARÉS (sa configuration).
52
+ * @returns le message rendu, ou `null` (rien à dire, ou déjà dit).
53
+ */
54
+ function reportOrphanEntities(log, owner, declared = []) {
55
+ if (reported.has(owner)) return null;
56
+ reported.add(owner);
57
+ const connectors = ormRegistry.list();
58
+ const message = describeOrphanEntities(findOrphanEntities(entityRegistry.list(), connectors), connectors, declared);
59
+ if (message !== null) log(message, "WARNING");
60
+ return message;
61
+ }
62
+ //#endregion
63
+ export { describeOrphanEntities, findOrphanEntities, reportOrphanEntities };
@@ -7,15 +7,16 @@
7
7
  * Module, pas d'enregistrement dans `@modules()`. Les drivers concrets sont les
8
8
  * Modules ; ils s'enregistrent eux-mêmes dans le `OrmRegistry` à leur boot.
9
9
  */
10
- export type { IOrm, IEntity, IEntityRelation, IRepository, OrmCriteria, Criteria, FieldCriteria, FieldOperators, UpdateData, UpdateOperators, RepositoryReadOptions, ITransaction, IPage, IPageQuery, PageQuery, } from "./nodefony/interfaces/index.js";
10
+ export type { IOrm, IEntity, IEntityIndex, IEntityRelation, IRepository, OrmCriteria, Criteria, FieldCriteria, FieldOperators, UpdateData, UpdateOperators, RepositoryReadOptions, ITransaction, IPage, IPageQuery, PageQuery, } from "./nodefony/interfaces/index.js";
11
11
  export { OPERATOR_KEYS, isFieldOperators, searchCriteria, LIKE_ESCAPE_CHAR, escapeLikeTerm, likePatternToRegExp, } from "./nodefony/src/criteria.js";
12
12
  export type { OperatorKey } from "./nodefony/src/criteria.js";
13
13
  export { UPDATE_OPERATOR_KEYS, isUpdateOperators, } from "./nodefony/src/criteria.js";
14
14
  export type { UpdateOperatorKey } from "./nodefony/src/criteria.js";
15
- export { UnknownCriteriaField, InvalidOrderOption, } from "./nodefony/src/errors.js";
15
+ export { UnknownCriteriaField, InvalidOrderOption, ReferencedEntityError, SavepointNotSupportedError, } from "./nodefony/src/errors.js";
16
16
  export { assertOrderOption } from "./nodefony/src/readOptions.js";
17
17
  export { OrmRegistry, ormRegistry } from "./nodefony/src/OrmRegistry.js";
18
18
  export { EntityRegistry, entityRegistry } from "./nodefony/src/EntityRegistry.js";
19
+ export { reportOrphanEntities } from "./nodefony/src/orphanEntities.js";
19
20
  export { Orm } from "./nodefony/src/Orm.js";
20
21
  export { Entity } from "./nodefony/src/Entity.js";
21
22
  export { AbstractCrudService } from "./nodefony/src/AbstractCrudService.js";
@@ -25,12 +26,12 @@ export { buildOrmGraph, buildConnectionHealth, buildOrmFlow, toDbml, toJsonSchem
25
26
  export { queryFlowMonitor } from "./nodefony/src/QueryFlowMonitor.js";
26
27
  export { connectionMonitor } from "./nodefony/src/ConnectionMonitor.js";
27
28
  export { buildOrmLeanHealth } from "./nodefony/src/buildOrmLeanHealth.js";
28
- export { diagnoseConnectionFailure, parseConnectionTarget, } from "./nodefony/src/connectionDiagnosis.js";
29
+ export { diagnoseConnectionFailure, describeConnectFailure, parseConnectionTarget, REJECTED_MARKER, } from "./nodefony/src/connectionDiagnosis.js";
29
30
  export type { ConnectionVerdict, IConnectionDiagnosis, IConnectionTarget, } from "./nodefony/src/connectionDiagnosis.js";
30
31
  export { CONNECT_TIMEOUT_MS, withConnectDeadline, } from "./nodefony/src/connectDeadline.js";
31
32
  export { wireOrmAdminPlane, resolveOrmFlowEnabled, reportOrmBootLines, } from "./nodefony/src/ormWiring.js";
32
33
  export type { ISlowQuery, IQueryFlow, IOrmFlowReport, } from "./nodefony/interfaces/IOrmFlow.js";
33
- export type { IColumnInfo, IConnectionInfo, IRelationInfo, IEntityGraphNode, IOrmSummary, IOrmGraph, IConnectionError, IConnectionHealth, } from "./nodefony/interfaces/IOrmGraph.js";
34
+ export type { IColumnInfo, IConnectionInfo, IRelationInfo, IEntityGraphNode, IOrmSummary, IOrmGraph, IConnectionError, IConnectionEvent, IOrmResilience, IConnectionHealth, } from "./nodefony/interfaces/IOrmGraph.js";
34
35
  export type { ILatencyWindow, IOrmStorageProbe, IOrmPoolProbe, IOrmProbe, } from "./nodefony/interfaces/IOrmProbe.js";
35
36
  export type { IOrmMigrationAction, IOrmMigrationEntry, IOrmMigrationSource, IOrmMigrationStatus, IOrmMigrationFailure, IOrmMigrationReply, IOrmPendingMigration, IOrmMigrationPlan, IOrmMigrationPlanReply, IOrmMigrationApplied, IOrmMigrationApplyReply, } from "./nodefony/interfaces/IOrmMigrations.js";
36
37
  export { isMigrationFailure } from "./nodefony/interfaces/IOrmMigrations.js";
@@ -30,6 +30,19 @@ export interface IEntityRelation {
30
30
  * @typeParam S - type du schéma natif du driver.
31
31
  * @typeParam M - type du modèle compilé natif du driver.
32
32
  */
33
+ /**
34
+ * Index COMPOSITE déclaré par une entité, pour l'adapter qui ne sait pas
35
+ * l'exprimer dans son schéma (Mongoose : un `SchemaDefinition` plat n'a d'index
36
+ * que par champ).
37
+ */
38
+ export interface IEntityIndex {
39
+ /** Champs et sens, dans l'ORDRE de l'index (`1` croissant, `-1` décroissant). */
40
+ readonly fields: Readonly<Record<string, 1 | -1>>;
41
+ /** Contrainte d'unicité sur la combinaison. */
42
+ readonly unique?: boolean;
43
+ /** Nom de l'index en base (sinon celui que le driver dérive des champs). */
44
+ readonly name?: string;
45
+ }
33
46
  export interface IEntity<S = unknown, M = unknown> {
34
47
  /** Nom logique de l'entité (clé de lookup, ex. `"User"`). */
35
48
  readonly name: string;
@@ -62,4 +75,10 @@ export interface IEntity<S = unknown, M = unknown> {
62
75
  readonly timestamps?: boolean;
63
76
  /** Relations déclarées vers d'autres entités (par nom logique). */
64
77
  readonly relations?: ReadonlyArray<IEntityRelation>;
78
+ /**
79
+ * Index composites, appliqués par l'adapter qui les exprime hors schéma
80
+ * (Mongoose `schema.index()`). Les ORM schema-as-code (Drizzle) les déclarent
81
+ * dans leur table → ce champ y est sans effet. **Optionnel**.
82
+ */
83
+ readonly indexes?: ReadonlyArray<IEntityIndex>;
65
84
  }
@@ -1,6 +1,6 @@
1
1
  import type { IRepository } from "./IRepository.js";
2
2
  import type { ITransaction } from "./ITransaction.js";
3
- import type { IColumnInfo, IConnectionInfo } from "./IOrmGraph.js";
3
+ import type { IOrmResilience, IColumnInfo, IConnectionInfo } from "./IOrmGraph.js";
4
4
  import type { IOrmProbe } from "./IOrmProbe.js";
5
5
  import type { IOrmMigrationApplyReply, IOrmMigrationPlanReply, IOrmMigrationReply } from "./IOrmMigrations.js";
6
6
  /**
@@ -73,6 +73,15 @@ export interface IOrm {
73
73
  * @returns infos de connexion, ou `undefined` si non implémenté.
74
74
  */
75
75
  describeConnection?(): IConnectionInfo;
76
+ /**
77
+ * Décrit le mécanisme de résilience tel qu'il tourne (battement, perte en
78
+ * souffrance) pour le data plane. **Optionnel** : la classe `Orm` l'implémente
79
+ * pour tous ses adapters ; un ORM qui ne dérive pas d'elle laisse le
80
+ * diagnostic sans cette section.
81
+ *
82
+ * @returns l'état du mécanisme.
83
+ */
84
+ describeResilience?(): IOrmResilience;
76
85
  /**
77
86
  * Ping bas-coût de la connexion (round-trip réel vers la base) pour le
78
87
  * diagnostic du data plane. **Optionnel** : un adapter qui ne l'implémente pas
@@ -82,7 +82,11 @@ export interface IOrmSummary {
82
82
  * `IOrm.vendor` déclaré par chaque adapter (P7.1 industrialisation ORM).
83
83
  */
84
84
  vendor: string;
85
- /** `true` si c'est le connecteur par défaut (`"default"`). */
85
+ /**
86
+ * `true` pour le connecteur par défaut : celui qui porte les stores de
87
+ * l'application (un RÔLE constaté, pas le nom `"default"` — repli sur ce nom
88
+ * seulement quand aucun store n'est porté par un ORM).
89
+ */
86
90
  default: boolean;
87
91
  /** État de la connexion. */
88
92
  connected: boolean;
@@ -98,6 +102,37 @@ export interface IConnectionError {
98
102
  /** Horodatage epoch ms. */
99
103
  ts: number;
100
104
  }
105
+ /**
106
+ * Un événement de vie de la connexion : une PERTE ou une REPRISE, constatée
107
+ * par l'adapter (événement du driver) ou par le battement de cœur.
108
+ */
109
+ export interface IConnectionEvent {
110
+ /** `lost` : la base a cessé de répondre ; `restored` : elle répond à nouveau. */
111
+ kind: "lost" | "restored";
112
+ /** Horodatage epoch ms. */
113
+ ts: number;
114
+ /** Cause de la perte (credential déjà retiré) — absente pour une reprise. */
115
+ reason?: string;
116
+ }
117
+ /**
118
+ * Le mécanisme de résilience d'un connecteur, TEL QU'IL TOURNE dans ce process.
119
+ *
120
+ * Rend lisible ce qui décide de « connecté / perdu / rétabli » : un pilote qui
121
+ * surveille ses serveurs (MongoDB) le sait seul ; les autres ne l'apprennent
122
+ * que par une requête, d'où le battement de cœur que porte la classe `Orm`.
123
+ */
124
+ export interface IOrmResilience {
125
+ /** Période du battement (ms) ; `0` = désactivé (`NF_ORM_HEARTBEAT_MS=0`). */
126
+ heartbeatMs: number;
127
+ /** Délai au-delà duquel un battement sans réponse vaut une perte (ms). */
128
+ heartbeatTimeoutMs: number;
129
+ /** Le battement tourne-t-il en ce moment ? */
130
+ heartbeatActive: boolean;
131
+ /** L'adapter sait-il répondre à `ping()` (condition du battement) ? */
132
+ pingable: boolean;
133
+ /** Une perte est-elle constatée et PAS ENCORE réparée ? */
134
+ lostPending: boolean;
135
+ }
101
136
  /**
102
137
  * Diagnostic d'un connecteur — réponse de `/nodefony/orm/api/connection/health`.
103
138
  * Combine l'état figé ({@link IConnectionInfo}), les compteurs de cycle de vie
@@ -131,8 +166,18 @@ export interface IConnectionHealth {
131
166
  uptimeMs: number | null;
132
167
  /** Nombre total de connexions réussies. */
133
168
  connectCount: number;
134
- /** Reconnexions (connexions au-delà de la première). */
169
+ /** Reprises CONSTATÉES après une perte (jamais déduites des connexions). */
135
170
  reconnectCount: number;
171
+ /** Pertes constatées depuis le démarrage du process. */
172
+ lostCount: number;
173
+ /** Dernière perte (epoch ms), `null` si aucune. */
174
+ lastLostAt: number | null;
175
+ /** Dernière reprise (epoch ms), `null` si aucune. */
176
+ lastRestoredAt: number | null;
177
+ /** Pertes et reprises récentes (ring borné, plus récentes d'abord). */
178
+ events: IConnectionEvent[];
179
+ /** Mécanisme de détection et de reprise, si l'ORM le décrit. */
180
+ resilience?: IOrmResilience;
136
181
  /** Nombre total d'erreurs enregistrées (connexion + ping). */
137
182
  errorCount: number;
138
183
  /** Dernière erreur, `null` si aucune. */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Obtenue dans le callback de `IOrm.transaction()`. Les transactions cross-ORM
5
5
  * (2PC) ne sont PAS garanties : une transaction porte sur un seul ORM/connexion.
6
- * La sémantique des savepoints dépend du driver (peut être un no-op).
6
+ * Un moteur sans savepoints (MongoDB) les REFUSE : `SavepointNotSupportedError`.
7
7
  */
8
8
  export interface ITransaction {
9
9
  /** Valide définitivement les opérations de la transaction. */
@@ -14,12 +14,14 @@ export interface ITransaction {
14
14
  * Crée un point de sauvegarde nommé pour un rollback partiel ultérieur.
15
15
  *
16
16
  * @param name - identifiant du savepoint.
17
+ * @throws SavepointNotSupportedError si le moteur n'en porte pas (MongoDB).
17
18
  */
18
19
  savepoint(name: string): Promise<void>;
19
20
  /**
20
21
  * Annule jusqu'à un savepoint sans terminer la transaction.
21
22
  *
22
23
  * @param name - savepoint cible.
24
+ * @throws SavepointNotSupportedError si le moteur n'en porte pas (MongoDB).
23
25
  */
24
26
  rollbackTo(name: string): Promise<void>;
25
27
  /**
@@ -1,7 +1,7 @@
1
1
  export type { IOrm } from "./IOrm.js";
2
- export type { IEntity, IEntityRelation } from "./IEntity.js";
2
+ export type { IEntity, IEntityIndex, IEntityRelation } from "./IEntity.js";
3
3
  export type { IRepository, OrmCriteria, Criteria, FieldCriteria, FieldOperators, UpdateData, UpdateOperators, RepositoryReadOptions, } from "./IRepository.js";
4
4
  export type { ITransaction } from "./ITransaction.js";
5
5
  export type { IPage, IPageQuery, PageQuery } from "./IPage.js";
6
- export type { IColumnInfo, IConnectionInfo, IRelationInfo, IEntityGraphNode, IOrmSummary, IOrmGraph, IConnectionError, IConnectionHealth, } from "./IOrmGraph.js";
6
+ export type { IColumnInfo, IConnectionInfo, IRelationInfo, IEntityGraphNode, IOrmSummary, IOrmGraph, IConnectionError, IConnectionEvent, IOrmResilience, IConnectionHealth, } from "./IOrmGraph.js";
7
7
  export type { ILatencyWindow, IOrmStorageProbe, IOrmPoolProbe, IOrmProbe, } from "./IOrmProbe.js";
@@ -1,4 +1,4 @@
1
- import type { IConnectionError } from "../interfaces/IOrmGraph.js";
1
+ import type { IConnectionError, IConnectionEvent } from "../interfaces/IOrmGraph.js";
2
2
  import type { ILatencyWindow } from "../interfaces/IOrmProbe.js";
3
3
  /** Vue figée des compteurs du moniteur pour un connecteur. */
4
4
  export interface IConnectionMonitorCore {
@@ -8,6 +8,12 @@ export interface IConnectionMonitorCore {
8
8
  reconnectCount: number;
9
9
  /** Pertes de connexion constatées depuis le démarrage du process. */
10
10
  lostCount: number;
11
+ /** Dernière perte (epoch ms), `null` si aucune. */
12
+ lastLostAt: number | null;
13
+ /** Dernière reprise (epoch ms), `null` si aucune. */
14
+ lastRestoredAt: number | null;
15
+ /** Pertes et reprises récentes, plus récentes d'abord. */
16
+ events: IConnectionEvent[];
11
17
  errorCount: number;
12
18
  lastConnectMs: number | null;
13
19
  lastError: IConnectionError | null;
@@ -58,8 +64,9 @@ declare class ConnectionMonitor {
58
64
  * preuve de bonne santé.
59
65
  *
60
66
  * @param name - clé du connecteur.
67
+ * @param reason - cause lisible (credential déjà retiré), si connue.
61
68
  */
62
- recordLost(name: string): void;
69
+ recordLost(name: string, reason?: string): void;
63
70
  /**
64
71
  * Enregistre une **reprise** de connexion constatée par l'adapter.
65
72
  *
@@ -1,6 +1,6 @@
1
1
  import { Service } from "nodefony";
2
2
  import type { Container, Event, DefaultOptionsService } from "nodefony";
3
- import type { IColumnInfo, IConnectionInfo, IOrm, IRepository, ITransaction } from "../interfaces/index.js";
3
+ import type { IColumnInfo, IConnectionInfo, IOrm, IOrmResilience, IRepository, ITransaction } from "../interfaces/index.js";
4
4
  /**
5
5
  * Classe de base abstraite de tout ORM Nodefony — câble {@link Service} (DI,
6
6
  * Syslog, bus d'événements) et le contrat {@link IOrm}, et s'auto-enregistre
@@ -121,6 +121,14 @@ export declare abstract class Orm extends Service implements IOrm {
121
121
  * prochain battement pour rendre la main.
122
122
  */
123
123
  protected startHeartbeat(): void;
124
+ /**
125
+ * Décrit le mécanisme de résilience TEL QU'IL TOURNE — lecture pure, pour
126
+ * le data plane (`connection/health`) : période et montre du battement,
127
+ * battement actif ou non, perte en souffrance.
128
+ *
129
+ * @returns l'état du mécanisme dans ce process.
130
+ */
131
+ describeResilience(): IOrmResilience;
124
132
  /** Arrête le battement et libère la minuterie. Idempotent. */
125
133
  protected stopHeartbeat(): void;
126
134
  /**
@@ -1,6 +1,22 @@
1
- import type { IAdminApi, IAdminRegistry } from "nodefony";
1
+ import type { IAdminApi, IAdminRegistry, IStoreResolution } from "nodefony";
2
2
  import type { IConnectionHealth, IOrmGraph } from "../interfaces/IOrmGraph.js";
3
3
  import type { IOrmFlowReport } from "../interfaces/IOrmFlow.js";
4
+ /** Résumé des ORM enregistrés (statut connexion + nombre d'entités). */
5
+ /**
6
+ * Connecteur qui porte les stores de l'application — le connecteur PAR DÉFAUT.
7
+ *
8
+ * « Défaut » est un RÔLE, pas un nom : sur une infra MongoDB les briques
9
+ * durables vivent sur `nodefony` (Mongoose), et marquer `default` (Drizzle)
10
+ * parce qu'il s'appelle ainsi désignait une base que personne n'utilise. Le
11
+ * rôle se CONSTATE dans le registre des stores (#474) : le connecteur qui porte
12
+ * le plus de briques durables, puis de briques tout court.
13
+ *
14
+ * @param resolutions - registre des stores du kernel (`storeResolutions`).
15
+ * @param names - connecteurs enregistrés ; une brique sur un autre nom est ignorée.
16
+ * @returns le nom du connecteur, ou `null` si aucune brique n'est portée par
17
+ * un ORM (le repli sur le nom `default` s'applique alors).
18
+ */
19
+ export declare function storeCarrier(resolutions: readonly IStoreResolution[], names: readonly string[]): string | null;
4
20
  /** Construit le graphe canonique complet (optionnellement filtré par connecteur). */
5
21
  export declare function buildOrmGraph(connectorFilter?: string): IOrmGraph;
6
22
  /**
@@ -24,6 +24,12 @@ export type ConnectionVerdict =
24
24
  "unreachable"
25
25
  /** Un serveur a répondu, et il refuse — le port est bien tenu par quelqu'un. */
26
26
  | "answered"
27
+ /**
28
+ * La connexion a ABOUTI, puis le serveur a refusé une INSTRUCTION — une
29
+ * table, une contrainte, un type. Ni l'adresse ni l'état de la base ne sont
30
+ * en cause : c'est le schéma envoyé qu'il faut corriger.
31
+ */
32
+ | "rejected"
27
33
  /** L'erreur ne permet de trancher ni dans un sens ni dans l'autre. */
28
34
  | "unknown";
29
35
  /** Diagnostic rendu à l'appelant, qui compose le message final. */
@@ -56,3 +62,26 @@ export declare function parseConnectionTarget(url?: string | null): IConnectionT
56
62
  * @returns le verdict, le code constaté et une explication déjà rédigée.
57
63
  */
58
64
  export declare function diagnoseConnectionFailure(error: unknown, target?: IConnectionTarget, platform?: string): IConnectionDiagnosis;
65
+ /**
66
+ * Marqueur de la phrase « connecté, puis refusé » — lu par `create app`
67
+ * (`migrationFailureCause`, cœur) pour NE PAS conclure à une base injoignable.
68
+ * Le cœur ne peut pas importer ce paquet : sa copie est éprouvée contre
69
+ * celle-ci par un test de parité.
70
+ */
71
+ export declare const REJECTED_MARKER = "s'est connect\u00E9, mais le serveur a refus\u00E9 une instruction \u2014 ";
72
+ /**
73
+ * Compose le message d'échec au démarrage d'un connecteur — UNE rédaction pour
74
+ * tous les adapters.
75
+ *
76
+ * 🔴 Un refus d'instruction (verdict `rejected`) ne commence JAMAIS par « n'a
77
+ * pas pu se connecter » : c'était faux, et la phrase envoyait vérifier
78
+ * l'adresse et l'état d'une base qui répondait très bien. La cause du pilote
79
+ * passe alors EN TÊTE — c'est elle qui nomme la table ou la contrainte.
80
+ *
81
+ * @param subject - sujet de la phrase (`Drizzle : le connecteur "x" (…)`).
82
+ * @param diagnosis - verdict de {@link diagnoseConnectionFailure}.
83
+ * @param cause - message brut du pilote.
84
+ * @param advice - conseil propre à l'adapter, pour un échec de CONNEXION.
85
+ * @returns le message complet.
86
+ */
87
+ export declare function describeConnectFailure(subject: string, diagnosis: IConnectionDiagnosis, cause: string, advice?: string): string;
@@ -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,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
@@ -393,7 +393,7 @@ 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
396
+ **`Orm.connect()`** (`Orm.ts:118`) est une **template method** : elle mesure la latence, alimente le
397
397
  moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts:74`), et
398
398
  **jamais** `connect()` — sinon l'événement et l'instrumentation disparaissent.
399
399
 
@@ -412,8 +412,8 @@ 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
 
@@ -422,16 +422,16 @@ verbes se choisissent sur **la garantie** qu'ils apportent, pas sur leur nom.
422
422
 
423
423
  | Verbe | Ce qu'il garantit | Ancre |
424
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` |
425
+ | `find` / `findOne` | lecture filtrée + eager-load + tri + bornes | `IRepository.ts:230` |
426
+ | `count` / `exists` | compter, ou juste savoir s'il y en a un (sans charger de colonne) | `IRepository.ts:395`, `:335` |
427
427
  | `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts:240` |
428
428
  | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
429
429
  | `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
430
430
  | `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
431
431
  | `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts:296` |
432
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` |
433
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
434
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
435
435
  | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts:355` |
436
436
  | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
437
437
 
@@ -440,7 +440,7 @@ verbes se choisissent sur **la garantie** qu'ils apportent, pas sur leur nom.
440
440
  > `findOneAndUpdate` en Mongo), jamais un `UPDATE` suivi d'une relecture. La différence n'est pas
441
441
  > cosmétique : la relecture rendrait `null` **à tort** dès que le critère porte sur un champ qu'on
442
442
  > vient de modifier — `updateOne({ status: "pending" }, { status: "done" })` ne retrouve plus rien
443
- > après coup (`IRepository.ts:221`).
443
+ > après coup (`IRepository.ts:269`).
444
444
 
445
445
  Quelques usages, un par garantie :
446
446
 
@@ -472,11 +472,13 @@ atomique sur les quatre backends.
472
472
 
473
473
  ### [`IEntity`](tutorial-entity.md) — la description d'une table
474
474
 
475
- `IEntity` (`IEntity.ts:37`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
475
+ `IEntity` (`IEntity.ts:51`) porte un nom logique, un `connector` (le nom d'une **connexion**, jamais
476
476
  d'un moteur), un `schema` natif du driver, et des `relations` déclaratives (`IEntityRelation`,
477
477
  `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).
478
+ apporte l'entité) et `domain` (`IEntity.ts:72`, la classification métier — l'axe qui rend navigable
479
+ une base de plusieurs centaines de tables). Un troisième, `indexes` (`IEntityIndex`), déclare les
480
+ index COMPOSITES qu'un adaptateur ne sait pas exprimer dans son schéma : Mongoose les applique
481
+ (`schema.index()`), Drizzle les ignore, puisqu'il les déclare dans sa table.
480
482
 
481
483
  Dans une application, on ne construit pas un `IEntity` à la main : on écrit un `IEntityDefinition`
482
484
  (`defineEntity.ts:15`) — le même objet **sans** `connector`, justement parce que le connecteur est
@@ -555,7 +557,7 @@ Deux décisions le rendent utilisable sur une grosse table :
555
557
  `false`. C'est la distinction « Page » (avec total) et « Slice » (sans), reprise de Spring Data.
556
558
 
557
559
  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
560
+ ramené dans le domaine valide (`paginate.ts:56`) — un `find({ limit: 0 })` a un comportement qui
559
561
  dépend du dialecte, donc on ne le laisse pas sortir.
560
562
 
561
563
  Le contrat de page lui-même (`IPage`, `IPageQuery`) vit dans le **cœur**
@@ -588,7 +590,7 @@ export class ArticleService extends AbstractCrudService<ArticleRow> {
588
590
  - **Lectures** (`find`, `findOne`, `findById`, `count`, `findPage`) — **délégation pure**. Aucun
589
591
  hook, aucun événement : c'est le chemin chaud, il ne doit rien payer.
590
592
  - **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
593
+ (`beforeCreate`, `AbstractCrudService.ts:212`, et ses six frères) puis un événement de cycle de vie
592
594
  `onCreated` / `onUpdated` / `onDeleted`, **émis seulement si la mutation a eu lieu**
593
595
  (`AbstractCrudService.ts:167`). L'audit, l'invalidation de cache ou une notification Studio s'y
594
596
  abonnent sans toucher au service.
@@ -606,18 +608,19 @@ d'administration : elle ne charge qu'une page, quelle que soit la taille de la t
606
608
 
607
609
  Deux drivers implémentent les contrats. Le contrat `IRepository` est tenu **en entier** par les
608
610
  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`) |
611
+ `DrizzleRepository.upsert()` (`DrizzleRepository.ts:879`) et `MongooseRepository.upsert()`
612
+ (`MongooseRepository.ts:405`).
613
+
614
+ | Capacité | `@nodefony/drizzle` | `@nodefony/mongoose` |
615
+ | -------------------------------------- | ----------------------------------- | -------------------------------------------- |
616
+ | Moteurs | SQLite, PostgreSQL, MySQL / MariaDB | MongoDB |
617
+ | Contrat `IRepository` (15 verbes) | complet | complet |
618
+ | Eager-load `{ relations }` | oui | oui (`populate`) |
619
+ | Transactions | oui | oui (replica set requis par Mongo) |
620
+ | Savepoints (rollback partiel) | oui | non — refusés (`SavepointNotSupportedError`) |
621
+ | Colonnes pour l'ERD (`describeEntity`) | oui (`DrizzleOrm.ts:1933`) | oui (`MongooseOrm.ts:649`) |
622
+ | Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor` | non câblée |
623
+ | Sonde profonde (`probe`) | oui (`DrizzleOrm.ts:1835`) | oui (`MongooseOrm.ts:617`) |
621
624
 
622
625
  **Les « stores » du framework, eux, ne sont pas alignés — et c'est un choix.** Un adapter déclare ce
623
626
  qu'il porte dans son `package.json`, clé `nodefony.stores` :
@@ -644,7 +647,7 @@ qu'il porte dans son `package.json`, clé `nodefony.stores` :
644
647
 
645
648
  Le contrat minimal tient en peu de choses, parce que `orm-core` fournit déjà la plomberie.
646
649
 
647
- 1. **Étendre `Orm`** (`Orm.ts:29`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
650
+ 1. **Étendre `Orm`** (`Orm.ts:46`) : implémenter `onConnect()`, `disconnect()`, `isConnected()`,
648
651
  `getRepository()`, `transaction()`, `getNativeConnection()`. L'enregistrement dans `ormRegistry`
649
652
  est fait par le constructeur de base — il n'y a rien à écrire. **Ne jamais surcharger
650
653
  `connect()`** : c'est la template method qui émet `onOrmReady` et instrumente la latence.
@@ -672,7 +675,7 @@ la santé ne doit pas coûter le débit, et observer le débit ne doit pas réve
672
675
 
673
676
  **`connectionMonitor`** (`ConnectionMonitor.ts:197`) suit le **cycle de vie** d'une connexion :
674
677
  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()`
678
+ (`ConnectionMonitor.recordPing()`, `ConnectionMonitor.ts:136`). Il est alimenté par `Orm.connect()`
676
679
  sans que l'adapter ait à y penser.
677
680
 
678
681
  **`queryFlowMonitor`** (`QueryFlowMonitor.ts:146`) suit le **débit** : total de requêtes, latence
@@ -687,7 +690,7 @@ rendent sûr en production :
687
690
  observe, et le débit par seconde est **dérivé côté lecteur** (delta entre deux relevés), donc rien
688
691
  n'est muté à la lecture.
689
692
 
690
- Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:421`) expose huit points
693
+ Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:541`) expose huit points
691
694
  d'entrée, tous filtrables par `?connector=` :
692
695
 
693
696
  | Point d'entrée | Ce qu'il rend |
@@ -695,11 +698,11 @@ d'entrée, tous filtrables par `?connector=` :
695
698
  | `orms` | les connecteurs, leur état, leur nombre d'entités |
696
699
  | `entities` | le modèle complet : colonnes + relations |
697
700
  | `entity/{name}` | une entité (404 si inconnue) |
698
- | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:184`) |
701
+ | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:227`) |
699
702
  | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
700
703
  | `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` |
704
+ | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts:360`) |
705
+ | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts:395`) ou `jsonschema` |
703
706
 
704
707
  Ce graphe canonique est **la pièce maîtresse**, pas le diagramme : c'est une donnée sérialisable qui
705
708
  sert à la fois l'ERD de Studio, un export vers un outil tiers, et le contexte d'un agent IA
@@ -730,11 +733,11 @@ deux, et pas de course).
730
733
  | Symptôme | Cause | Correction |
731
734
  | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
732
735
  | « 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`) |
736
+ | 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
737
  | « 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
738
  | 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
739
  | `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`) |
740
+ | `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
741
  | 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
742
  | 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
743
  | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts:74`), jamais `connect()` |
@@ -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,7 +339,8 @@ 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
 
@@ -348,23 +349,23 @@ apportent, jamais sur leur nom.
348
349
 
349
350
  | Verbe | Ce qu'il garantit | Ancre |
350
351
  | --------------------- | ------------------------------------------------------------------ | ---------------------------- |
351
- | `find` / `findOne` | lecture filtrée, avec tri, bornes et eager-load | `IRepository.ts:213`, `:192` |
352
+ | `find` / `findOne` | lecture filtrée, avec tri, bornes et eager-load | `IRepository.ts:230`, `:192` |
352
353
  | `create` | insère une ligne et rend sa version persistée (id, défauts) | `IRepository.ts:240` |
353
354
  | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
354
355
  | `updateOne` | modifie **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
355
356
  | `updateMany` | modifie toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
356
357
  | `upsert` | insère **ou** met à jour sur conflit de clé, en une instruction | `IRepository.ts:296` |
357
358
  | `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` |
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` |
360
361
  | `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
+ | `count` / `exists` | compter, ou juste savoir s'il y en a une (sans charger de colonne) | `IRepository.ts:395`, `:335` |
362
363
  | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
363
364
 
364
365
  > [!IMPORTANT]
365
366
  > **Il n'existe pas de méthode `update()`.** Le choix est explicite et il est intentionnel :
366
367
  > `IRepository.updateOne()` (`IRepository.ts:269`) pour une ligne — atomique, et elle **rend** la
367
- > ligne modifiée —, `IRepository.updateMany()` (`IRepository.ts:274`) pour un lot — qui rend le
368
+ > ligne modifiée —, `IRepository.updateMany()` (`IRepository.ts:312`) pour un lot — qui rend le
368
369
  > **nombre** de lignes touchées. Un verbe unique masquerait cette différence de garantie, qui est
369
370
  > précisément ce qu'on veut choisir en connaissance de cause.
370
371
 
@@ -393,7 +394,7 @@ await orm.transaction(async (tx) => {
393
394
  ```
394
395
 
395
396
  Deux réflexes de performance, dès le premier jour : préférer `IRepository.exists()`
396
- (`IRepository.ts:335`) à `findOne(...) !== null` — aucune colonne n'est chargée —, et
397
+ (`IRepository.ts:395`) à `findOne(...) !== null` — aucune colonne n'est chargée —, et
397
398
  `IRepository.increment()` (`IRepository.ts:325`) à une lecture suivie d'une écriture : une requête
398
399
  au lieu de deux, et pas de course.
399
400
 
@@ -471,7 +472,7 @@ champs connus — le diagnostic d'une faute de frappe est immédiat.
471
472
 
472
473
  ## 📄 Pager les résultats
473
474
 
474
- `find()` avec `limit`/`offset`/`order` (`RepositoryReadOptions`, `IRepository.ts:153`) suffit pour
475
+ `find()` avec `limit`/`offset`/`order` (`RepositoryReadOptions`, `IRepository.ts:174`) suffit pour
475
476
  une tranche. Pour une vraie page — celle qui sait s'il y a une suite — utilise `paginate()`
476
477
  (`paginate.ts:47`) :
477
478
 
@@ -524,9 +525,9 @@ d'administration, la primitive est `AbstractCrudService.findPage()` (`AbstractCr
524
525
 
525
526
  | Symptôme | Cause | Correction |
526
527
  | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
527
- | `posts.update is not a function` | la méthode `update()` n'existe pas dans le contrat | `updateOne` pour une ligne (`IRepository.ts:269`), `updateMany` pour un lot (`IRepository.ts:274`) |
528
+ | `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
529
  | « no entity registered under "Post" » au premier appel | le fichier d'entité est importé, mais `defineEntity()` est **sans effet de bord** | ajouter l'entité à `@entities([...])` sur le module (`entitiesDecorator.ts:56`) |
529
- | La table n'existe pas alors que l'entité est déclarée | inscription faite à `onBoot` → course avec l'ouverture du connecteur | inscrire à `onRegister` — c'est ce que fait `entities()` (`entitiesDecorator.ts:66`) |
530
+ | 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
531
  | 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
532
  | 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
533
  | 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.7",
3
+ "version": "10.0.0-alpha.9",
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,12 +37,11 @@
36
37
  "esm"
37
38
  ],
38
39
  "peerDependencies": {
39
- "nodefony": "^10.0.0-alpha.7"
40
+ "nodefony": "^10.0.0-alpha.9"
40
41
  },
41
42
  "devDependencies": {
42
- "@types/node": "26.6.1",
43
- "nodefony": "^10.0.0-alpha.7",
44
- "rimraf": "6.1.3",
43
+ "@types/node": "26.6.2",
44
+ "nodefony": "^10.0.0-alpha.9",
45
45
  "vitest": "5.0.1"
46
46
  },
47
47
  "repository": {