@nodefony/orm-core 10.0.0-alpha.9 → 10.0.0-beta.2

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/README.md CHANGED
@@ -5,8 +5,8 @@ plusieurs ORM dans le même processus — par exemple Drizzle pour SQL et Mongoo
5
5
  derrière une API portable, avec une trappe vers le driver natif pour les cas que l'abstraction ne
6
6
  couvre pas.
7
7
 
8
- > Bibliothèque pure : ce module n'expose pas de classe `Module` et n'est pas listé dans
9
- > `@modules()`. Ce sont les **drivers** (`@nodefony/drizzle`, `@nodefony/mongoose`…) qui sont des
8
+ > Bibliothèque pure : ce module n'expose pas de classe `Module` et n'est pas listé dans le
9
+ > manifeste `modules` de `nodefony.config.ts`. Ce sont les **drivers** (`@nodefony/drizzle`, `@nodefony/mongoose`…) qui sont des
10
10
  > modules Nodefony et qui s'enregistrent eux-mêmes dans l'`OrmRegistry`.
11
11
 
12
12
  ## Quel ORM par défaut ?
@@ -36,13 +36,13 @@ d'imposer plusieurs ORM simultanés.
36
36
 
37
37
  ## Contrats exposés
38
38
 
39
- | Interface | Rôle |
40
- | ---------------- | ------------------------------------------------------------------------- |
41
- | `IOrm` | Instance ORM (connexion logique) : `connect`, `repository`, `transaction` |
42
- | `IEntity<S,M>` | Entité enregistrée : nom logique, connecteur cible, schéma, modèle natif |
43
- | `IRepository<T>` | CRUD portable : `find`/`findOne`/`create`/`update`/`delete`/`count` |
44
- | `ITransaction` | Unité de travail : `commit`/`rollback`/`savepoint`/`rollbackTo` |
45
- | `Criteria<T>` | Filtre typé par champ et opérateurs riches ; `OrmCriteria` = échappatoire |
39
+ | Interface | Rôle |
40
+ | ---------------- | --------------------------------------------------------------------------------------------------------- |
41
+ | `IOrm` | Instance ORM (connexion logique) : `connect`, `getRepository`, `transaction` |
42
+ | `IEntity<S,M>` | Entité enregistrée : nom logique, connecteur cible, schéma, modèle natif |
43
+ | `IRepository<T>` | CRUD portable (15 verbes) : `find`/`findOne`/`create`/`updateOne`/`updateMany`/`upsert`/`delete`/`count`… |
44
+ | `ITransaction` | Unité de travail : `commit`/`rollback`/`savepoint`/`rollbackTo` |
45
+ | `Criteria<T>` | Filtre typé par champ et opérateurs riches ; `OrmCriteria` = échappatoire |
46
46
 
47
47
  ### Critères riches (opérateurs typés)
48
48
 
@@ -70,7 +70,7 @@ fenêtrage. C'est le garde-fou qui empêche l'abstraction de devenir un plafond.
70
70
  ## Exemple
71
71
 
72
72
  ```typescript
73
- import { OrmRegistry } from "@nodefony/orm-core";
73
+ import { ormRegistry } from "@nodefony/orm-core";
74
74
  import type { IRepository } from "@nodefony/orm-core";
75
75
 
76
76
  interface User {
@@ -78,14 +78,16 @@ interface User {
78
78
  email: string;
79
79
  }
80
80
 
81
- const orm = OrmRegistry.get("db_principale");
81
+ const orm = ormRegistry.get("db_principale");
82
82
  const users: IRepository<User> = orm.getRepository<User>("User");
83
83
 
84
84
  const created = await users.create({ email: "a@b.c" });
85
85
  const found = await users.findOne({ email: "a@b.c" });
86
86
 
87
87
  await orm.transaction(async (tx) => {
88
- await users.update({ id: created.id }, { email: "x@y.z" });
88
+ await users
89
+ .withTransaction(tx)
90
+ .updateOne({ id: created.id }, { email: "x@y.z" });
89
91
  // validation automatique si la fonction résout, annulation si elle rejette
90
92
  });
91
93
  ```
@@ -1,3 +1,4 @@
1
+ import { omitUndefined } from "./omitUndefined.js";
1
2
  import { paginate } from "./paginate.js";
2
3
  import { Service } from "nodefony";
3
4
  //#region nodefony/src/AbstractCrudService.ts
@@ -115,12 +116,15 @@ var AbstractCrudService = class extends Service {
115
116
  /**
116
117
  * Crée une entité : `beforeCreate` → persistance → `afterCreate` → `onCreated`.
117
118
  *
119
+ * Une clé qui vaut `undefined` vaut clé absente : elle est retirée avant la
120
+ * persistance (cf `omitUndefined`), quel que soit l'adaptateur.
121
+ *
118
122
  * @param data - champs de l'entité à créer.
119
123
  * @returns l'entité persistée. Émet `onCreated`.
120
124
  */
121
125
  async create(data) {
122
126
  const prepared = await this.beforeCreate(data);
123
- const entity = await this.repository.create(prepared);
127
+ const entity = await this.repository.create(omitUndefined(prepared));
124
128
  await this.afterCreate(entity);
125
129
  this.fire("onCreated", entity);
126
130
  return entity;
@@ -139,12 +143,12 @@ var AbstractCrudService = class extends Service {
139
143
  * on la remontera dans un service le jour où un usage métier la réclame.
140
144
  *
141
145
  * @param criteria - filtre de sélection (champ inconnu → `UnknownCriteriaField`).
142
- * @param data - champs à modifier.
146
+ * @param data - champs à modifier ; une clé `undefined` n'est pas touchée.
143
147
  * @returns l'entité mise à jour, ou `null` si aucune ne correspond (pas d'event).
144
148
  */
145
149
  async updateOne(criteria, data) {
146
150
  const prepared = await this.beforeUpdate(criteria, data);
147
- const updated = await this.repository.updateOne(criteria, prepared);
151
+ const updated = await this.repository.updateOne(criteria, omitUndefined(prepared));
148
152
  if (updated !== null) {
149
153
  await this.afterUpdate(updated);
150
154
  this.fire("onUpdated", updated);
@@ -51,7 +51,7 @@ var ConnectionMonitor = class {
51
51
  #stats = null;
52
52
  /** Crée/retourne les stats mutables d'un connecteur (alloue à la demande). */
53
53
  #ensure(name) {
54
- if (this.#stats === null) this.#stats = /* @__PURE__ */ new Map();
54
+ this.#stats ??= /* @__PURE__ */ new Map();
55
55
  let s = this.#stats.get(name);
56
56
  if (s === void 0) {
57
57
  s = {
@@ -80,7 +80,7 @@ var ConnectionMonitor = class {
80
80
  */
81
81
  recordPing(name, ms) {
82
82
  const s = this.#ensure(name);
83
- if (s.latencies === null) s.latencies = [];
83
+ s.latencies ??= [];
84
84
  s.latencies.push(ms);
85
85
  if (s.latencies.length > MAX_LATENCY_SAMPLES) s.latencies.shift();
86
86
  }
@@ -139,7 +139,7 @@ var ConnectionMonitor = class {
139
139
  }
140
140
  /** Ajoute un événement en tête de la chronologie (allouée au 1ᵉʳ, bornée). */
141
141
  #pushEvent(s, e) {
142
- if (s.events === null) s.events = [];
142
+ s.events ??= [];
143
143
  s.events.unshift(e);
144
144
  if (s.events.length > MAX_EVENTS) s.events.length = MAX_EVENTS;
145
145
  }
@@ -157,7 +157,7 @@ var ConnectionMonitor = class {
157
157
  ts: Date.now()
158
158
  };
159
159
  s.lastError = err;
160
- if (s.recentErrors === null) s.recentErrors = [];
160
+ s.recentErrors ??= [];
161
161
  s.recentErrors.unshift(err);
162
162
  if (s.recentErrors.length > MAX_RECENT_ERRORS) s.recentErrors.length = MAX_RECENT_ERRORS;
163
163
  }
@@ -172,9 +172,10 @@ var ConnectionMonitor = class {
172
172
  if (s === void 0) return EMPTY_CORE;
173
173
  const lat = s.latencies;
174
174
  let latency = EMPTY_LATENCY;
175
- if (lat && lat.length) {
176
- let min = lat[0];
177
- let max = lat[0];
175
+ const last = lat?.at(-1);
176
+ if (lat && last !== void 0) {
177
+ let min = last;
178
+ let max = last;
178
179
  let sum = 0;
179
180
  for (const v of lat) {
180
181
  if (v < min) min = v;
@@ -182,7 +183,7 @@ var ConnectionMonitor = class {
182
183
  sum += v;
183
184
  }
184
185
  latency = {
185
- last: lat[lat.length - 1],
186
+ last,
186
187
  min: Math.round(min * 100) / 100,
187
188
  avg: Math.round(sum / lat.length * 100) / 100,
188
189
  max: Math.round(max * 100) / 100,
@@ -20,7 +20,7 @@ var EntityRegistry = class {
20
20
  * @throws si cette entité est déjà enregistrée sur le même connecteur.
21
21
  */
22
22
  register(entity) {
23
- if (this.#entities === null) this.#entities = Object.create(null);
23
+ this.#entities ??= Object.create(null);
24
24
  const store = this.#entities;
25
25
  let bucket = store[entity.name];
26
26
  if (bucket === void 0) {
@@ -49,7 +49,10 @@ var EntityRegistry = class {
49
49
  }
50
50
  const connectors = Object.keys(bucket);
51
51
  if (connectors.length > 1) throw new Error(`EntityRegistry: entity "${name}" exists on multiple connectors (${connectors.join(", ")}); specify one.`);
52
- return bucket[connectors[0]];
52
+ const only = connectors[0];
53
+ const entity = only === void 0 ? void 0 : bucket[only];
54
+ if (entity === void 0) throw new Error(`EntityRegistry: no entity registered under "${name}".`);
55
+ return entity;
53
56
  }
54
57
  /**
55
58
  * Indique si une entité (optionnellement sur un connecteur précis) est enregistrée.
@@ -72,7 +75,10 @@ var EntityRegistry = class {
72
75
  const out = [];
73
76
  for (const name in this.#entities) {
74
77
  const bucket = this.#entities[name];
75
- for (const connector in bucket) out.push(bucket[connector]);
78
+ for (const connector in bucket) {
79
+ const entity = bucket[connector];
80
+ if (entity !== void 0) out.push(entity);
81
+ }
76
82
  }
77
83
  return out;
78
84
  }
@@ -194,7 +194,7 @@ var Orm = class extends Service {
194
194
  const timer = setInterval(() => {
195
195
  this.#beat();
196
196
  }, this.heartbeatMs);
197
- timer.unref?.();
197
+ timer.unref();
198
198
  this.#heartbeat = timer;
199
199
  }
200
200
  /**
@@ -264,7 +264,7 @@ var Orm = class extends Service {
264
264
  watchdog = setTimeout(() => {
265
265
  rejeter(/* @__PURE__ */ new Error(`aucune réponse en ${this.heartbeatTimeoutMs} ms`));
266
266
  }, this.heartbeatTimeoutMs);
267
- watchdog.unref?.();
267
+ watchdog.unref();
268
268
  })]);
269
269
  this.connectionRestored();
270
270
  } catch (e) {
@@ -349,8 +349,8 @@ function jsonSchemaType(nativeType) {
349
349
  const t = nativeType.toLowerCase();
350
350
  if (/\b(serial|bigint|smallint|tinyint|mediumint|int|integer)\b/.test(t)) return { type: "integer" };
351
351
  if (/(real|float|double|decimal|numeric|number)/.test(t)) return { type: "number" };
352
- if (/bool/.test(t)) return { type: "boolean" };
353
- if (/json/.test(t)) return { type: "object" };
352
+ if (t.includes("bool")) return { type: "boolean" };
353
+ if (t.includes("json")) return { type: "object" };
354
354
  if (/(date|time|timestamp)/.test(t)) return {
355
355
  type: "string",
356
356
  format: "date-time"
@@ -422,7 +422,7 @@ function createOrmAdminApi() {
422
422
  path: "entity/{name}",
423
423
  summary: "Une entité (colonnes + relations) — ?connector= si homonymes",
424
424
  handler: (request) => {
425
- const name = request.params.name ?? "";
425
+ const name = request.params.name;
426
426
  const connector = oneParam(request, "connector");
427
427
  try {
428
428
  const found = entityRegistry.list().find((e) => e.name === name && (!connector || e.connector === connector));
@@ -22,7 +22,7 @@ var OrmRegistry = class {
22
22
  * @throws si un ORM du même nom est déjà enregistré (erreur de configuration).
23
23
  */
24
24
  register(name, orm) {
25
- if (this.#orms === null) this.#orms = /* @__PURE__ */ new Map();
25
+ this.#orms ??= /* @__PURE__ */ new Map();
26
26
  if (this.#orms.has(name)) throw new Error(`OrmRegistry: an ORM named "${name}" is already registered.`);
27
27
  this.#orms.set(name, orm);
28
28
  }
@@ -51,7 +51,7 @@ var QueryFlowMonitor = class {
51
51
  }
52
52
  /** Crée/retourne les stats mutables d'un connecteur (alloue à la demande). */
53
53
  #ensure(connector) {
54
- if (this.#stats === null) this.#stats = /* @__PURE__ */ new Map();
54
+ this.#stats ??= /* @__PURE__ */ new Map();
55
55
  let s = this.#stats.get(connector);
56
56
  if (s === void 0) {
57
57
  s = {
@@ -85,7 +85,7 @@ var QueryFlowMonitor = class {
85
85
  s.ewmaMs = s.ewmaMs === null ? durationMs : EWMA_ALPHA * durationMs + .8 * s.ewmaMs;
86
86
  if (durationMs >= this.slowMs) {
87
87
  s.slowTotal += 1;
88
- if (s.slow === null) s.slow = [];
88
+ s.slow ??= [];
89
89
  s.slow.unshift({
90
90
  ts: Date.now(),
91
91
  durationMs,
@@ -57,7 +57,7 @@ async function withConnectDeadline(work, what, ms = CONNECT_TIMEOUT_MS) {
57
57
  timer = setTimeout(() => {
58
58
  reject(/* @__PURE__ */ new Error(`délai dépassé (${ms} ms) en attendant ${what} — le serveur a accepté la connexion sans répondre, ou n'a jamais répondu. Vérifier que le service visé est bien celui qui écoute, et qu'il est en état de servir.`));
59
59
  }, ms);
60
- timer.unref?.();
60
+ timer.unref();
61
61
  })]);
62
62
  } finally {
63
63
  if (timer !== void 0) clearTimeout(timer);
@@ -124,8 +124,8 @@ function escapeLikeTerm(text) {
124
124
  function likePatternToRegExp(pattern) {
125
125
  let source = "";
126
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]);
127
+ const c = pattern.charAt(i);
128
+ if (c === "\\" && i + 1 < pattern.length) source += escapeRegExpChar(pattern.charAt(++i));
129
129
  else if (c === "%") source += ".*";
130
130
  else if (c === "_") source += ".";
131
131
  else source += escapeRegExpChar(c);
@@ -169,7 +169,8 @@ function searchCriteria(q, fields) {
169
169
  const term = q?.trim();
170
170
  if (!term || fields.length === 0) return null;
171
171
  const likePattern = `${escapeLikeTerm(term)}%`;
172
- if (fields.length === 1) return { [fields[0]]: { $like: likePattern } };
172
+ const only = fields.length === 1 ? fields[0] : void 0;
173
+ if (only !== void 0) return { [only]: { $like: likePattern } };
173
174
  return { $or: fields.map((f) => ({ [f]: { $like: likePattern } })) };
174
175
  }
175
176
  //#endregion
@@ -0,0 +1,30 @@
1
+ //#region nodefony/src/omitUndefined.ts
2
+ /**
3
+ * Retire les clés qui valent `undefined` d'une donnée d'écriture partielle.
4
+ *
5
+ * `undefined` et clé absente veulent dire « ne pas toucher » : Drizzle ignore
6
+ * déjà une valeur `undefined`, Mongoose (≥ 6) la retire d'une mise à jour. La
7
+ * règle est posée ICI, une fois, au seuil de la persistance, au lieu d'être
8
+ * un comportement propre à chaque adaptateur.
9
+ *
10
+ * Rend l'objet reçu TEL QUEL quand aucune clé ne vaut `undefined` — le cas
11
+ * courant d'un corps JSON, qui ne peut pas en porter : aucune allocation.
12
+ *
13
+ * @param data - données d'écriture, clés `undefined` admises
14
+ * @returns les mêmes données, sans aucune clé `undefined`
15
+ */
16
+ function omitUndefined(data) {
17
+ const source = data;
18
+ for (const key of Object.keys(source)) {
19
+ if (source[key] !== void 0) continue;
20
+ const out = {};
21
+ for (const k of Object.keys(source)) {
22
+ const value = source[k];
23
+ if (value !== void 0) out[k] = value;
24
+ }
25
+ return out;
26
+ }
27
+ return data;
28
+ }
29
+ //#endregion
30
+ export { omitUndefined };
@@ -53,7 +53,7 @@ export interface IEntity<S = unknown, M = unknown> {
53
53
  * **Optionnel** : sert au regroupement dans le graphe canonique / ERD Studio.
54
54
  * Non renseigné → entité non rattachée (groupe « — » côté UI).
55
55
  */
56
- readonly module?: string;
56
+ readonly module?: string | undefined;
57
57
  /**
58
58
  * **Classification** de l'entité (ex. domaine fonctionnel `"facturation"`,
59
59
  * `"comptabilité"`) — axe de regroupement **distinct** du `module`
@@ -61,7 +61,7 @@ export interface IEntity<S = unknown, M = unknown> {
61
61
  * base** (centaines de tables d'un même module) : l'ERD groupe/filtre par
62
62
  * `domain`. **Optionnel** ; non renseigné → retombe sur `module`.
63
63
  */
64
- readonly domain?: string;
64
+ readonly domain?: string | undefined;
65
65
  /** Définition de schéma propre au driver (forme libre). */
66
66
  readonly schema: S;
67
67
  /** Modèle compilé natif, renseigné après connexion de l'ORM. */
@@ -72,13 +72,13 @@ export interface IEntity<S = unknown, M = unknown> {
72
72
  * schema-as-code (Drizzle) les expriment en colonnes → ce flag y est sans effet.
73
73
  * **Optionnel**, défaut `false`.
74
74
  */
75
- readonly timestamps?: boolean;
75
+ readonly timestamps?: boolean | undefined;
76
76
  /** Relations déclarées vers d'autres entités (par nom logique). */
77
- readonly relations?: ReadonlyArray<IEntityRelation>;
77
+ readonly relations?: ReadonlyArray<IEntityRelation> | undefined;
78
78
  /**
79
79
  * Index composites, appliqués par l'adapter qui les exprime hors schéma
80
80
  * (Mongoose `schema.index()`). Les ORM schema-as-code (Drizzle) les déclarent
81
81
  * dans leur table → ce champ y est sans effet. **Optionnel**.
82
82
  */
83
- readonly indexes?: ReadonlyArray<IEntityIndex>;
83
+ readonly indexes?: ReadonlyArray<IEntityIndex> | undefined;
84
84
  }
@@ -26,7 +26,7 @@ export interface ISlowQuery {
26
26
  /** Connecteur ORM (clé du registre). */
27
27
  connector: string;
28
28
  /** SQL paramétré et redacté (absent si l'adapter ne sait pas l'extraire). */
29
- sql?: string;
29
+ sql?: string | undefined;
30
30
  }
31
31
  /**
32
32
  * Compteurs de flux d'UN connecteur ORM. Cumuls depuis le boot (le débit/s se
@@ -32,7 +32,7 @@ export interface IRelationInfo {
32
32
  /** Champ portant la relation côté entité courante. */
33
33
  field: string;
34
34
  /** Clé étrangère (explicite ou dérivée déterministe). */
35
- foreignKey?: string;
35
+ foreignKey?: string | undefined;
36
36
  }
37
37
  /** Nœud du graphe : une entité avec ses colonnes et ses relations. */
38
38
  export interface IEntityGraphNode {
@@ -66,11 +66,11 @@ export interface IConnectionInfo {
66
66
  * `host:port/base` (serveur) ou URI redactée. **Jamais de credential** (mot de
67
67
  * passe retiré côté adapter).
68
68
  */
69
- target?: string;
69
+ target?: string | undefined;
70
70
  /** Version du moteur/base (ex. SQLite `3.45.1`), si l'adapter peut l'obtenir. */
71
- version?: string;
71
+ version?: string | undefined;
72
72
  /** Version de la lib ORM elle-même (ex. drizzle-orm `0.44.x`), si connue. */
73
- ormVersion?: string;
73
+ ormVersion?: string | undefined;
74
74
  }
75
75
  /** Résumé d'un ORM/connecteur enregistré. */
76
76
  export interface IOrmSummary {
@@ -93,7 +93,7 @@ export interface IOrmSummary {
93
93
  /** Nombre d'entités rattachées à cet ORM. */
94
94
  entityCount: number;
95
95
  /** Connexion sous-jacente (driver + cible), si l'adapter l'expose. */
96
- connection?: IConnectionInfo;
96
+ connection?: IConnectionInfo | undefined;
97
97
  }
98
98
  /** Erreur de connexion horodatée (message **redacté** — jamais de credential). */
99
99
  export interface IConnectionError {
@@ -112,7 +112,7 @@ export interface IConnectionEvent {
112
112
  /** Horodatage epoch ms. */
113
113
  ts: number;
114
114
  /** Cause de la perte (credential déjà retiré) — absente pour une reprise. */
115
- reason?: string;
115
+ reason?: string | undefined;
116
116
  }
117
117
  /**
118
118
  * Le mécanisme de résilience d'un connecteur, TEL QU'IL TOURNE dans ce process.
@@ -153,11 +153,11 @@ export interface IConnectionHealth {
153
153
  /** Base/driver (`sqlite`, `mongodb`…). */
154
154
  driver: string;
155
155
  /** Cible lisible (chemin relatif / host:port), jamais de credential. */
156
- target?: string;
156
+ target?: string | undefined;
157
157
  /** Version moteur. */
158
- version?: string;
158
+ version?: string | undefined;
159
159
  /** Version lib ORM. */
160
- ormVersion?: string;
160
+ ormVersion?: string | undefined;
161
161
  /** État courant (`isConnected`). */
162
162
  connected: boolean;
163
163
  /** Connecté depuis (epoch ms), `null` si jamais connecté. */
@@ -177,7 +177,7 @@ export interface IConnectionHealth {
177
177
  /** Pertes et reprises récentes (ring borné, plus récentes d'abord). */
178
178
  events: IConnectionEvent[];
179
179
  /** Mécanisme de détection et de reprise, si l'ORM le décrit. */
180
- resilience?: IOrmResilience;
180
+ resilience?: IOrmResilience | undefined;
181
181
  /** Nombre total d'erreurs enregistrées (connexion + ping). */
182
182
  errorCount: number;
183
183
  /** Dernière erreur, `null` si aucune. */
@@ -195,11 +195,11 @@ export interface IConnectionHealth {
195
195
  /** Fenêtre glissante de latence (min/moy/max sur les N derniers pings). */
196
196
  latency: ILatencyWindow;
197
197
  /** Sonde de stockage (driver), si l'adapter l'expose. */
198
- storage?: IOrmStorageProbe;
198
+ storage?: IOrmStorageProbe | undefined;
199
199
  /** Sonde de pool de connexions (driver), si l'adapter l'expose. */
200
- pool?: IOrmPoolProbe;
200
+ pool?: IOrmPoolProbe | undefined;
201
201
  /** Métriques driver libres (clé→valeur). */
202
- extra?: Record<string, string | number | boolean>;
202
+ extra?: Record<string, string | number | boolean> | undefined;
203
203
  }
204
204
  /** Graphe complet du modèle de données — réponse de `/nodefony/orm/api/graph`. */
205
205
  export interface IOrmGraph {
@@ -32,15 +32,15 @@ export interface ILatencyWindow {
32
32
  */
33
33
  export interface IOrmStorageProbe {
34
34
  /** Taille de la base (octets) — `pages × pageSize` (SQLite). */
35
- sizeBytes?: number;
35
+ sizeBytes?: number | undefined;
36
36
  /** Nombre de pages allouées. */
37
- pages?: number;
37
+ pages?: number | undefined;
38
38
  /** Taille d'une page (octets). */
39
- pageSize?: number;
39
+ pageSize?: number | undefined;
40
40
  /** Mode de journalisation (SQLite : `wal`, `delete`, `memory`…). */
41
- journalMode?: string;
41
+ journalMode?: string | undefined;
42
42
  /** Pages libres (fragmentation / espace récupérable au VACUUM). */
43
- freePages?: number;
43
+ freePages?: number | undefined;
44
44
  }
45
45
  /**
46
46
  * Sonde de pool de connexions — pertinente pour les bases serveur (Mongo,
@@ -48,13 +48,13 @@ export interface IOrmStorageProbe {
48
48
  */
49
49
  export interface IOrmPoolProbe {
50
50
  /** Taille max configurée du pool. */
51
- size?: number;
51
+ size?: number | undefined;
52
52
  /** Connexions disponibles (idle). */
53
- available?: number;
53
+ available?: number | undefined;
54
54
  /** Connexions en cours d'utilisation. */
55
- borrowed?: number;
55
+ borrowed?: number | undefined;
56
56
  /** Demandes en attente d'une connexion. */
57
- pending?: number;
57
+ pending?: number | undefined;
58
58
  }
59
59
  /**
60
60
  * Sonde profonde driver-spécifique retournée par {@link IOrm.probe}. Tous les
@@ -20,5 +20,5 @@ export type { IPage, IPageQuery } from "nodefony";
20
20
  */
21
21
  export interface PageQuery<T = unknown> extends IPageQuery {
22
22
  /** Filtre typé optionnel appliqué avant la pagination (toutes les lignes si omis). */
23
- criteria?: Criteria<T>;
23
+ criteria?: Criteria<T> | undefined;
24
24
  }
@@ -23,23 +23,23 @@ export type OrmCriteria = Record<string, unknown>;
23
23
  */
24
24
  export interface FieldOperators<V> {
25
25
  /** Égalité stricte (équivalent à passer la valeur nue). */
26
- $eq?: V;
26
+ $eq?: V | undefined;
27
27
  /** Différent de. */
28
- $ne?: V;
28
+ $ne?: V | undefined;
29
29
  /** Strictement supérieur à. */
30
- $gt?: V;
30
+ $gt?: V | undefined;
31
31
  /** Supérieur ou égal à. */
32
- $gte?: V;
32
+ $gte?: V | undefined;
33
33
  /** Strictement inférieur à. */
34
- $lt?: V;
34
+ $lt?: V | undefined;
35
35
  /** Inférieur ou égal à. */
36
- $lte?: V;
36
+ $lte?: V | undefined;
37
37
  /** Appartient à l'ensemble. */
38
- $in?: readonly V[];
38
+ $in?: readonly V[] | undefined;
39
39
  /** N'appartient pas à l'ensemble. */
40
- $nin?: readonly V[];
40
+ $nin?: readonly V[] | undefined;
41
41
  /** Motif SQL `LIKE` (`%`/`_`) — pertinent pour les champs texte uniquement. */
42
- $like?: string;
42
+ $like?: string | undefined;
43
43
  /**
44
44
  * Teste l'**absence de valeur** : `true` → `IS NULL`, `false` → `IS NOT NULL`
45
45
  * (Mongo : `$eq`/`$ne null`, qui couvre aussi le champ absent).
@@ -60,7 +60,7 @@ export interface FieldOperators<V> {
60
60
  * // les PAT qui ont une expiration
61
61
  * repo.find({ expiresAt: { $null: false } });
62
62
  */
63
- $null?: boolean;
63
+ $null?: boolean | undefined;
64
64
  }
65
65
  /**
66
66
  * Opérateurs d'**écriture** applicables à un champ dans le `update` d'un
@@ -154,7 +154,7 @@ export type Criteria<T> = {
154
154
  * // les clés encore utilisables
155
155
  * repo.count({ revokedAt: null, $or: [{ expiresAt: null }, { expiresAt: { $gt: now } }] });
156
156
  */
157
- $or?: ReadonlyArray<Criteria<T>>;
157
+ $or?: ReadonlyArray<Criteria<T>> | undefined;
158
158
  } & OrmCriteria;
159
159
  /**
160
160
  * Options de lecture (`find`/`findOne`) portables cross-ORM.
@@ -181,7 +181,7 @@ export interface RepositoryReadOptions {
181
181
  * la frontière qui la reçoit (`parsePageQuery` accepte `champ:sens` en casse
182
182
  * libre), jamais au repository.
183
183
  */
184
- order?: Array<[string, "ASC" | "DESC"]>;
184
+ order?: Array<[string, "ASC" | "DESC"]> | undefined;
185
185
  }
186
186
  /**
187
187
  * Contrat CRUD minimal exposé par un repository, indépendant de l'ORM sous-jacent.
@@ -1,4 +1,5 @@
1
1
  import { Service } from "nodefony";
2
+ import type { PartialInput } from "nodefony";
2
3
  import type { IRepository, Criteria, RepositoryReadOptions, IPage, PageQuery } from "../interfaces/index.js";
3
4
  import type { ServiceWiring } from "./serviceWiring.js";
4
5
  /**
@@ -102,10 +103,13 @@ export declare abstract class AbstractCrudService<T, R extends IRepository<T> =
102
103
  /**
103
104
  * Crée une entité : `beforeCreate` → persistance → `afterCreate` → `onCreated`.
104
105
  *
106
+ * Une clé qui vaut `undefined` vaut clé absente : elle est retirée avant la
107
+ * persistance (cf `omitUndefined`), quel que soit l'adaptateur.
108
+ *
105
109
  * @param data - champs de l'entité à créer.
106
110
  * @returns l'entité persistée. Émet `onCreated`.
107
111
  */
108
- create(data: Partial<T>): Promise<T>;
112
+ create(data: PartialInput<T>): Promise<T>;
109
113
  /**
110
114
  * Met à jour **au plus une** entité : `beforeUpdate` → persistance atomique
111
115
  * → `afterUpdate` → `onUpdated`.
@@ -120,10 +124,10 @@ export declare abstract class AbstractCrudService<T, R extends IRepository<T> =
120
124
  * on la remontera dans un service le jour où un usage métier la réclame.
121
125
  *
122
126
  * @param criteria - filtre de sélection (champ inconnu → `UnknownCriteriaField`).
123
- * @param data - champs à modifier.
127
+ * @param data - champs à modifier ; une clé `undefined` n'est pas touchée.
124
128
  * @returns l'entité mise à jour, ou `null` si aucune ne correspond (pas d'event).
125
129
  */
126
- updateOne(criteria: Criteria<T>, data: Partial<T>): Promise<T | null>;
130
+ updateOne(criteria: Criteria<T>, data: PartialInput<T>): Promise<T | null>;
127
131
  /**
128
132
  * Supprime : `beforeDelete` → persistance → `afterDelete` → `onDeleted`.
129
133
  *
@@ -138,7 +142,7 @@ export declare abstract class AbstractCrudService<T, R extends IRepository<T> =
138
142
  * @param data - données entrantes.
139
143
  * @returns les données préparées à persister.
140
144
  */
141
- protected beforeCreate(data: Partial<T>): Partial<T> | Promise<Partial<T>>;
145
+ protected beforeCreate(data: PartialInput<T>): PartialInput<T> | Promise<PartialInput<T>>;
142
146
  /** Effet de bord après création (ex. provisionner une ressource liée). */
143
147
  protected afterCreate(_entity: T): void | Promise<void>;
144
148
  /**
@@ -148,7 +152,7 @@ export declare abstract class AbstractCrudService<T, R extends IRepository<T> =
148
152
  * @param data - données entrantes.
149
153
  * @returns les données préparées à persister.
150
154
  */
151
- protected beforeUpdate(_criteria: Criteria<T>, data: Partial<T>): Partial<T> | Promise<Partial<T>>;
155
+ protected beforeUpdate(_criteria: Criteria<T>, data: PartialInput<T>): PartialInput<T> | Promise<PartialInput<T>>;
152
156
  /** Effet de bord après mise à jour. */
153
157
  protected afterUpdate(_entity: T): void | Promise<void>;
154
158
  /** Garde/validation avant suppression (ex. interdire la suppression du dernier admin). */
@@ -25,7 +25,7 @@ export declare abstract class Entity<S = unknown, M = unknown> implements IEntit
25
25
  /** Modèle compilé natif, renseigné après connexion de l'ORM. */
26
26
  model?: M;
27
27
  /** Relations déclarées vers d'autres entités (par nom logique). */
28
- readonly relations?: ReadonlyArray<IEntityRelation>;
28
+ readonly relations?: ReadonlyArray<IEntityRelation> | undefined;
29
29
  /**
30
30
  * Construit la définition de schéma propre au driver.
31
31
  *
@@ -21,7 +21,7 @@ export interface EntityOptions<S = unknown> {
21
21
  */
22
22
  domain?: string;
23
23
  /** Schéma natif du driver (schéma Mongoose, schéma Drizzle...). */
24
- schema?: S;
24
+ schema?: S | undefined;
25
25
  /** Relations déclaratives vers d'autres entités. */
26
26
  relations?: ReadonlyArray<IEntityRelation>;
27
27
  }
@@ -17,13 +17,13 @@ export interface EntityMetadata<S = unknown> {
17
17
  /** Connexion nommée cible (clé du `ormRegistry`). */
18
18
  readonly connector: string;
19
19
  /** Module Nodefony propriétaire (regroupement graphe/ERD), si fourni. */
20
- readonly module?: string;
20
+ readonly module?: string | undefined;
21
21
  /** Classification (domaine fonctionnel), axe de regroupement distinct du module. */
22
- readonly domain?: string;
22
+ readonly domain?: string | undefined;
23
23
  /** Schéma natif du driver (forme libre), si fourni au décorateur. */
24
- readonly schema?: S;
24
+ readonly schema?: S | undefined;
25
25
  /** Relations déclaratives vers d'autres entités. */
26
- readonly relations?: ReadonlyArray<IEntityRelation>;
26
+ readonly relations?: ReadonlyArray<IEntityRelation> | undefined;
27
27
  /** Constructeur décoré (pour introspection / résolution lazy par le driver). */
28
28
  readonly target: DecoratedClass;
29
29
  }
@@ -36,7 +36,7 @@ export interface RepositoryMetadata {
36
36
  /** Nom logique de l'entité gérée par ce repository. */
37
37
  readonly entity: string;
38
38
  /** Connecteur cible (lève l'ambiguïté si l'entité existe sur plusieurs connexions). */
39
- readonly connector?: string;
39
+ readonly connector?: string | undefined;
40
40
  /** Constructeur décoré. */
41
41
  readonly target: DecoratedClass;
42
42
  }
@@ -0,0 +1,16 @@
1
+ import type { PartialInput } from "nodefony";
2
+ /**
3
+ * Retire les clés qui valent `undefined` d'une donnée d'écriture partielle.
4
+ *
5
+ * `undefined` et clé absente veulent dire « ne pas toucher » : Drizzle ignore
6
+ * déjà une valeur `undefined`, Mongoose (≥ 6) la retire d'une mise à jour. La
7
+ * règle est posée ICI, une fois, au seuil de la persistance, au lieu d'être
8
+ * un comportement propre à chaque adaptateur.
9
+ *
10
+ * Rend l'objet reçu TEL QUEL quand aucune clé ne vaut `undefined` — le cas
11
+ * courant d'un corps JSON, qui ne peut pas en porter : aucune allocation.
12
+ *
13
+ * @param data - données d'écriture, clés `undefined` admises
14
+ * @returns les mêmes données, sans aucune clé `undefined`
15
+ */
16
+ export declare function omitUndefined<T>(data: PartialInput<T>): Partial<T>;
package/docs/index.md CHANGED
@@ -177,7 +177,7 @@ garantit que la dépendance va bien du concret vers l'abstrait, et jamais l'inve
177
177
  > [!NOTE]
178
178
  > `orm-core` n'est **pas un module bootable** : pas de classe `Module`, rien à mettre dans
179
179
  > `modules: [...]`. C'est une bibliothèque pure. Les modules, ce sont les **drivers** — ce sont eux
180
- > qui s'enregistrent dans `ormRegistry` (`OrmRegistry.ts:88`) à leur démarrage.
180
+ > qui s'enregistrent dans `ormRegistry` (`OrmRegistry.ts:86`) à leur démarrage.
181
181
 
182
182
  ## La vision Nodefony
183
183
 
@@ -377,13 +377,13 @@ sequenceDiagram
377
377
  Note over K: onServersReady → récap de boot
378
378
  ```
379
379
 
380
- **`entityRegistry`** (`EntityRegistry.ts:147`) indexe les entités à **deux** niveaux — nom puis
380
+ **`entityRegistry`** (`EntityRegistry.ts:145`) indexe les entités à **deux** niveaux — nom puis
381
381
  connecteur — parce qu'une même entité logique (`User`) peut vivre sur plusieurs connexions. Demander
382
382
  `get("User")` sans préciser le connecteur alors que deux le portent **lève** plutôt que de deviner
383
383
  (`EntityRegistry.get()`, `EntityRegistry.ts:54`). Le stockage est un `Object.create(null)` alloué
384
384
  **au premier enregistrement** : une application sans base ne paie rien.
385
385
 
386
- **`ormRegistry`** (`OrmRegistry.ts:88`) associe un nom de connexion à son instance `IOrm`. Un doublon
386
+ **`ormRegistry`** (`OrmRegistry.ts:86`) associe un nom de connexion à son instance `IOrm`. Un doublon
387
387
  de nom **lève** (`OrmRegistry.register()`, `OrmRegistry.ts:26`) : deux connexions homonymes seraient
388
388
  un bug silencieux, jamais une intention.
389
389
 
@@ -394,7 +394,7 @@ l'ordre des écouteurs, la table existe ou non. `entities()` s'accroche à `onRe
394
394
  comportement avec `@controllers`, qui lui reste à `onBoot`.
395
395
 
396
396
  **`Orm.connect()`** (`Orm.ts:118`) est une **template method** : elle mesure la latence, alimente le
397
- moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts:74`), et
397
+ moniteur de connexion, puis émet `onOrmReady`. Un adapter surcharge `onConnect()` (`Orm.ts:363`), et
398
398
  **jamais** `connect()` — sinon l'événement et l'instrumentation disparaissent.
399
399
 
400
400
  ## 🧰 Les contrats — la surface publique
@@ -420,20 +420,22 @@ lieu d'échouer : `describeEntity()` (`IOrm.ts:78`, colonnes pour l'ERD), `descr
420
420
  `IRepository<T>` (`IRepository.ts:197`) est la seule surface que ton métier devrait connaître. Les
421
421
  verbes se choisissent sur **la garantie** qu'ils apportent, pas sur leur nom.
422
422
 
423
- | Verbe | Ce qu'il garantit | Ancre |
424
- | --------------------- | -------------------------------------------------------------------- | ---------------------------- |
425
- | `find` / `findOne` | lecture filtrée + eager-load + tri + bornes | `IRepository.ts:230` |
426
- | `count` / `exists` | compter, ou juste savoir s'il y en a un (sans charger de colonne) | `IRepository.ts:395`, `:335` |
427
- | `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts:240` |
428
- | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
429
- | `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
430
- | `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
431
- | `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts:296` |
432
- | `increment` | `SET f = f + ?` atomique — compteurs, quotas, rate-limit | `IRepository.ts:287` |
433
- | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
434
- | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
435
- | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts:355` |
436
- | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
423
+ | Verbe | Ce qu'il garantit | Ancre |
424
+ | --------------------- | ------------------------------------------------------------------------- | -------------------- |
425
+ | `find` / `findOne` | lecture filtrée + eager-load + tri + bornes | `IRepository.ts:230` |
426
+ | `count` | compter les entités qui correspondent au critère | `IRepository.ts:362` |
427
+ | `exists` | savoir s'il y en a au moins un, sans charger de colonne | `IRepository.ts:395` |
428
+ | `countDistinct` | compter les valeurs DISTINCTES et non nulles d'un champ, sans les charger | `IRepository.ts:381` |
429
+ | `create` | insertion d'une ligne, rend la version persistée (id, défauts) | `IRepository.ts:240` |
430
+ | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
431
+ | `updateOne` | met à jour **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
432
+ | `updateMany` | met à jour toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
433
+ | `upsert` | insère **ou** met à jour sur conflit de clé, en **une** instruction | `IRepository.ts:296` |
434
+ | `increment` | `SET f = f + ?` atomique — compteurs, quotas, rate-limit | `IRepository.ts:287` |
435
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
436
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
437
+ | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, outbox, `pop` atomique | `IRepository.ts:355` |
438
+ | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
437
439
 
438
440
  > [!IMPORTANT]
439
441
  > `updateOne` est atomique **par construction** : une seule requête (`UPDATE … RETURNING` en SQL,
@@ -538,8 +540,8 @@ raisonnement, et le compilateur la refuse.
538
540
  JSON ou un sous-document (`{ meta: { auteur: "…" } }`) reste donc une égalité — c'est ce qui évite
539
541
  qu'une donnée métier soit prise pour une requête.
540
542
 
541
- **Ce que le critère ne couvre pas** : les `OR` logiques, les sous-requêtes, les agrégats, les
542
- jointures arbitraires. Ce n'est pas un oubli — c'est la limite du portable, et la sortie est
543
+ **Ce que le critère ne couvre pas** : les sous-requêtes, les agrégats, les jointures arbitraires
544
+ (la disjonction, elle, s'exprime par `$or: [...]`, portable). Ce n'est pas un oubli — c'est la limite du portable, et la sortie est
543
545
  `getNativeConnection()`. L'erreur `UnknownCriteriaField` (`errors.ts:23`) le dit d'ailleurs
544
546
  explicitement dans son message, avec la liste des champs connus de l'entité (diagnostic d'une faute
545
547
  de frappe).
@@ -608,8 +610,8 @@ d'administration : elle ne charge qu'une page, quelle que soit la taille de la t
608
610
 
609
611
  Deux drivers implémentent les contrats. Le contrat `IRepository` est tenu **en entier** par les
610
612
  deux : les quinze verbes existent des deux côtés — par exemple l'upsert, avec
611
- `DrizzleRepository.upsert()` (`DrizzleRepository.ts:879`) et `MongooseRepository.upsert()`
612
- (`MongooseRepository.ts:405`).
613
+ `DrizzleRepository.upsert()` (`DrizzleRepository.ts:986`) et `MongooseRepository.upsert()`
614
+ (`MongooseRepository.ts:422`).
613
615
 
614
616
  | Capacité | `@nodefony/drizzle` | `@nodefony/mongoose` |
615
617
  | -------------------------------------- | ----------------------------------- | -------------------------------------------- |
@@ -618,12 +620,12 @@ deux : les quinze verbes existent des deux côtés — par exemple l'upsert, ave
618
620
  | Eager-load `{ relations }` | oui | oui (`populate`) |
619
621
  | Transactions | oui | oui (replica set requis par Mongo) |
620
622
  | 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`) |
623
+ | Colonnes pour l'ERD (`describeEntity`) | oui (`DrizzleOrm.ts:1982`) | oui (`MongooseOrm.ts:649`) |
624
+ | Sonde de flux (requêtes/s, lentes) | oui — alimente `queryFlowMonitor` | oui — alimente `queryFlowMonitor` |
625
+ | Sonde profonde (`probe`) | oui (`DrizzleOrm.ts:1873`) | oui (`MongooseOrm.ts:617`) |
624
626
 
625
- **Les « stores » du framework, eux, ne sont pas alignés — et c'est un choix.** Un adapter déclare ce
626
- qu'il porte dans son `package.json`, clé `nodefony.stores` :
627
+ **Les « stores » du framework** : chaque adapter déclare ce qu'il porte dans son `package.json`, clé
628
+ `nodefony.stores`. Les deux backends durables portent les huit :
627
629
 
628
630
  | Store | drizzle | mongoose |
629
631
  | ------------- | ------- | -------- |
@@ -632,16 +634,15 @@ qu'il porte dans son `package.json`, clé `nodefony.stores` :
632
634
  | `tokens` | ✅ | ✅ |
633
635
  | `passkeys` | ✅ | ✅ |
634
636
  | `webhooks` | ✅ | ✅ |
635
- | `totp` | ✅ | — |
636
- | `audit` | ✅ | — |
637
- | `idempotency` | ✅ | — |
637
+ | `totp` | ✅ | ✅ |
638
+ | `audit` | ✅ | ✅ |
639
+ | `idempotency` | ✅ | ✅ |
638
640
 
639
641
  > [!NOTE]
640
- > La couverture est **adaptée à la nature de chaque backend**, ce n'est pas une parité SQL × NoSQL à
641
- > atteindre. Un store d'idempotence veut une contrainte d'unicité et un `ON CONFLICT` — le terrain du
642
- > SQL. Lire ce tableau comme « mongoose est incomplet » serait un contresens : il porte exactement ce
643
- > qu'un déploiement Mongo attend de lui. La liste fait autorité côté code
644
- > (`@nodefony/drizzle/package.json`, clé `nodefony.stores`) — elle n'est pas un commentaire.
642
+ > La couverture est **adaptée à la nature de chaque backend** : c'est `@nodefony/redis`, un cache,
643
+ > qui s'arrête à quatre stores (`session`, `tokens`, `passkeys`, `idempotency`) — il ne garde ni
644
+ > comptes ni journal. La liste fait autorité côté code (`nodefony.stores` du `package.json` de chaque
645
+ > adapter) — elle n'est pas un commentaire.
645
646
 
646
647
  ## 🧩 Extension — brancher son propre driver
647
648
 
@@ -678,7 +679,7 @@ première connexion, reconnexions, erreurs récentes, et une fenêtre de latence
678
679
  (`ConnectionMonitor.recordPing()`, `ConnectionMonitor.ts:136`). Il est alimenté par `Orm.connect()`
679
680
  sans que l'adapter ait à y penser.
680
681
 
681
- **`queryFlowMonitor`** (`QueryFlowMonitor.ts:146`) suit le **débit** : total de requêtes, latence
682
+ **`queryFlowMonitor`** (`QueryFlowMonitor.ts:144`) suit le **débit** : total de requêtes, latence
682
683
  moyenne et EWMA, pire latence, et un anneau borné à vingt requêtes lentes. Trois propriétés le
683
684
  rendent sûr en production :
684
685
 
@@ -690,19 +691,22 @@ rendent sûr en production :
690
691
  observe, et le débit par seconde est **dérivé côté lecteur** (delta entre deux relevés), donc rien
691
692
  n'est muté à la lecture.
692
693
 
693
- Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:541`) expose huit points
694
+ Le data plane `/nodefony/orm/api/*` (`createOrmAdminApi()`, `OrmAdminApi.ts:541`) expose onze points
694
695
  d'entrée, tous filtrables par `?connector=` :
695
696
 
696
- | Point d'entrée | Ce qu'il rend |
697
- | ------------------- | ----------------------------------------------------------------- |
698
- | `orms` | les connecteurs, leur état, leur nombre d'entités |
699
- | `entities` | le modèle complet : colonnes + relations |
700
- | `entity/{name}` | une entité (404 si inconnue) |
701
- | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:227`) |
702
- | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
703
- | `connection/health` | état, latence, erreurs, reconnexions, sondes |
704
- | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts:360`) |
705
- | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts:395`) ou `jsonschema` |
697
+ | Point d'entrée | Ce qu'il rend |
698
+ | ------------------- | ------------------------------------------------------------------ |
699
+ | `orms` | les connecteurs, leur état, leur nombre d'entités |
700
+ | `entities` | le modèle complet : colonnes + relations |
701
+ | `entity/{name}` | une entité (404 si inconnue) |
702
+ | `graph` | le graphe canonique (`buildOrmGraph()`, `OrmAdminApi.ts:227`) |
703
+ | `counts` | le nombre de lignes par entité — un `COUNT(*)` par table |
704
+ | `connection/health` | état, latence, erreurs, reconnexions, sondes |
705
+ | `flow` | débit et requêtes lentes (`buildOrmFlow()`, `OrmAdminApi.ts:360`) |
706
+ | `export/{format}` | `dbml` (`toDbml()`, `OrmAdminApi.ts:395`) ou `jsonschema` |
707
+ | `migrations` | l'état des migrations (même objet que `orm:migrate:status --json`) |
708
+ | `migrations/plan` | le SQL en attente |
709
+ | `migrations/apply` | applique les migrations (développement seulement) |
706
710
 
707
711
  Ce graphe canonique est **la pièce maîtresse**, pas le diagramme : c'est une donnée sérialisable qui
708
712
  sert à la fois l'ERD de Studio, un export vers un outil tiers, et le contexte d'un agent IA
@@ -740,9 +744,9 @@ deux, et pas de course).
740
744
  | `updateOne` rend `null` alors que la ligne a bien changé | ancien réflexe `UPDATE` + relecture (le critère porte sur le champ modifié) | utiliser `updateOne`, atomique par construction (`IRepository.ts:269`) |
741
745
  | Un `upsert` écrase une valeur qui devait progresser | le `DO UPDATE` est inconditionnel (contrainte MySQL) | poser la condition **dans** la valeur : `{ seuil: { $max: v } }` (`IRepository.ts:94`) |
742
746
  | Un objet de critère est pris pour une égalité (colonne JSON) | comportement **voulu** : une valeur n'est un filtre que si **toutes** ses clés sont des opérateurs | c'est la protection ; pour filtrer dedans, passer au natif (`criteria.ts:42`) |
743
- | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts:74`), jamais `connect()` |
747
+ | `onOrmReady` ne part plus après un ajout dans l'adapter | `connect()` a été surchargé | surcharger `onConnect()` (`Orm.ts:363`), jamais `connect()` |
744
748
  | Une entité déclarée dans un module reste invisible | le module embarque sa **propre copie** du registre (singleton dédoublé) | externaliser `@nodefony/orm-core` dans le `rolldown.config.ts` du module |
745
- | Rien dans `flow` alors que la base travaille | la sonde est éteinte hors développement, ou le driver n'a pas de tap | `NF_ORM_FLOW=1` (`ormWiring.ts:96`) ; le tap n'est câblé que côté Drizzle |
749
+ | Rien dans `flow` alors que la base travaille | la sonde est éteinte hors développement, ou le driver n'a pas de tap | `NF_ORM_FLOW=1` (`ormWiring.ts:96`) ; les deux drivers officiels ont un tap |
746
750
 
747
751
  ## 🧪 Tests & couverture
748
752
 
@@ -285,14 +285,15 @@ fichier, sans rien contourner.
285
285
 
286
286
  Deux conséquences du DDL dérivé au boot, à connaître dès maintenant :
287
287
 
288
- - la table est créée par un `CREATE TABLE IF NOT EXISTS` — **modifier le schéma n'altère pas une
289
- table déjà créée** (aucun `ALTER` n'est émis) ;
290
- - les **index** déclarés et les `DEFAULT` **SQL** ne sont pas émis. D'où les défauts posés côté
291
- JavaScript (`$defaultFn`), qui s'appliquent quoi qu'il arrive.
288
+ - la table est créée par un `CREATE TABLE IF NOT EXISTS`, avec ses index et ses clés étrangères ;
289
+ au démarrage suivant, seule une colonne ajoutée qui **accepte le vide** est rattrapée — une colonne
290
+ obligatoire ne l'est jamais ;
291
+ - les `DEFAULT` **SQL** ne sont pas émis. D'où les défauts posés côté JavaScript (`$defaultFn`), qui
292
+ s'appliquent quoi qu'il arrive.
292
293
 
293
294
  > [!TIP]
294
- > En développement, la façon la plus rapide de prendre en compte une colonne ajoutée est de
295
- > supprimer le fichier SQLite et de redémarrer. En production, cela relève d'une migration.
295
+ > En développement, une colonne obligatoire ajoutée se prend en compte par `nodefony orm:reset`. En
296
+ > production, cela relève d'une migration (`orm:generate`, puis `orm:migrate`).
296
297
 
297
298
  ### Étape 3 — déclarer l'entité
298
299
 
@@ -347,20 +348,22 @@ que l'import plus la ligne du décorateur.
347
348
  Le repository porte **quinze verbes**, pas cinq. Ils se choisissent sur la **garantie** qu'ils
348
349
  apportent, jamais sur leur nom.
349
350
 
350
- | Verbe | Ce qu'il garantit | Ancre |
351
- | --------------------- | ------------------------------------------------------------------ | ---------------------------- |
352
- | `find` / `findOne` | lecture filtrée, avec tri, bornes et eager-load | `IRepository.ts:230`, `:192` |
353
- | `create` | insère une ligne et rend sa version persistée (id, défauts) | `IRepository.ts:240` |
354
- | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
355
- | `updateOne` | modifie **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
356
- | `updateMany` | modifie toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
357
- | `upsert` | insère **ou** met à jour sur conflit de clé, en une instruction | `IRepository.ts:296` |
358
- | `increment` | `SET f = f + ?` atomique — compteurs, quotas, limitation de débit | `IRepository.ts:287` |
359
- | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
360
- | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
361
- | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, `pop` atomique | `IRepository.ts:355` |
362
- | `count` / `exists` | compter, ou juste savoir s'il y en a une (sans charger de colonne) | `IRepository.ts:395`, `:335` |
363
- | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
351
+ | Verbe | Ce qu'il garantit | Ancre |
352
+ | --------------------- | ------------------------------------------------------------------------- | ---------------------------- |
353
+ | `find` / `findOne` | lecture filtrée, avec tri, bornes et eager-load | `IRepository.ts:230`, `:192` |
354
+ | `create` | insère une ligne et rend sa version persistée (id, défauts) | `IRepository.ts:240` |
355
+ | `createMany` | N lignes en **une** requête — seed, import, ingestion par lots | `IRepository.ts:252` |
356
+ | `updateOne` | modifie **au plus une** ligne, **atomiquement**, et la rend | `IRepository.ts:269` |
357
+ | `updateMany` | modifie toutes les lignes du critère, rend le **nombre** | `IRepository.ts:312` |
358
+ | `upsert` | insère **ou** met à jour sur conflit de clé, en une instruction | `IRepository.ts:296` |
359
+ | `increment` | `SET f = f + ?` atomique — compteurs, quotas, limitation de débit | `IRepository.ts:287` |
360
+ | `delete` | supprime tout ce qui matche, rend le nombre | `IRepository.ts:336` |
361
+ | `deleteOne` | supprime **au plus une** ligne, rend un booléen | `IRepository.ts:345` |
362
+ | `findOneAndDelete` | supprime **et rend** la ligne — file de jobs, `pop` atomique | `IRepository.ts:355` |
363
+ | `count` | compter les entités qui correspondent au critère | `IRepository.ts:362` |
364
+ | `exists` | savoir s'il y en a au moins une, sans charger de colonne | `IRepository.ts:395` |
365
+ | `countDistinct` | compter les valeurs DISTINCTES et non nulles d'un champ, sans les charger | `IRepository.ts:381` |
366
+ | `withTransaction(tx)` | une **vue** du repository liée à une transaction | `IRepository.ts:406` |
364
367
 
365
368
  > [!IMPORTANT]
366
369
  > **Il n'existe pas de méthode `update()`.** Le choix est explicite et il est intentionnel :
@@ -463,8 +466,8 @@ Sur une ligne dont on sait qu'elle **existe**, ils sont inutiles :
463
466
 
464
467
  ### Ce que le critère ne couvre pas
465
468
 
466
- Les `OR` logiques, les sous-requêtes, les agrégats et les jointures arbitraires n'en font pas
467
- partie. Ce n'est pas un oubli : c'est la limite de ce qui se porte d'un moteur SQL à MongoDB. La
469
+ Les sous-requêtes, les agrégats et les jointures arbitraires n'en font pas partie (la disjonction,
470
+ elle, s'exprime par `$or: [...]`). Ce n'est pas un oubli : c'est la limite de ce qui se porte d'un moteur SQL à MongoDB. La
468
471
  sortie est `IOrm.getNativeConnection()` (`IOrm.ts:51`), qui rend la connexion brute du driver.
469
472
 
470
473
  Un champ absent de l'entité lève `UnknownCriteriaField` (`errors.ts:23`), et le message liste les
@@ -528,8 +531,8 @@ d'administration, la primitive est `AbstractCrudService.findPage()` (`AbstractCr
528
531
  | `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`) |
529
532
  | « 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`) |
530
533
  | 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`) |
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 |
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 |
534
+ | Une colonne ajoutée au schéma reste absente de la table | colonne **obligatoire** : le rattrapage de développement n'ajoute que les colonnes facultatives | `nodefony orm:reset` en développement, ou `orm:generate` puis `orm:migrate` |
535
+ | Un `DEFAULT` SQL déclaré n'apparaît pas | le DDL dérivé ne l'émet pas | poser le défaut côté JavaScript (`$defaultFn`) |
533
536
  | 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`) |
534
537
  | `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()` |
535
538
  | `connector: "sqlite"` ne trouve aucune connexion | `connector` nomme une **connexion**, pas un moteur | mettre la clé de `connectors` (`"default"`) ; le moteur est le `dialect` de la config |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/orm-core",
3
- "version": "10.0.0-alpha.9",
3
+ "version": "10.0.0-beta.2",
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",
@@ -37,12 +37,12 @@
37
37
  "esm"
38
38
  ],
39
39
  "peerDependencies": {
40
- "nodefony": "^10.0.0-alpha.9"
40
+ "nodefony": "^10.0.0-beta.2"
41
41
  },
42
42
  "devDependencies": {
43
- "@types/node": "26.6.2",
44
- "nodefony": "^10.0.0-alpha.9",
45
- "vitest": "5.0.1"
43
+ "@types/node": "26.6.3",
44
+ "nodefony": "^10.0.0-beta.2",
45
+ "vitest": "5.0.3"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",