@nodefony/orm-core 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +131 -0
- package/dist/index.js +22 -0
- package/dist/nodefony/interfaces/IEntity.js +1 -0
- package/dist/nodefony/interfaces/IOrm.js +1 -0
- package/dist/nodefony/interfaces/IOrmFlow.js +1 -0
- package/dist/nodefony/interfaces/IOrmGraph.js +1 -0
- package/dist/nodefony/interfaces/IOrmMigrations.js +12 -0
- package/dist/nodefony/interfaces/IOrmProbe.js +1 -0
- package/dist/nodefony/interfaces/IPage.js +1 -0
- package/dist/nodefony/interfaces/IRepository.js +1 -0
- package/dist/nodefony/interfaces/ITransaction.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/AbstractCrudService.js +199 -0
- package/dist/nodefony/src/ConnectionMonitor.js +181 -0
- package/dist/nodefony/src/Entity.js +42 -0
- package/dist/nodefony/src/EntityRegistry.js +109 -0
- package/dist/nodefony/src/Orm.js +297 -0
- package/dist/nodefony/src/OrmAdminApi.js +491 -0
- package/dist/nodefony/src/OrmRegistry.js +75 -0
- package/dist/nodefony/src/QueryFlowMonitor.js +128 -0
- package/dist/nodefony/src/buildOrmLeanHealth.js +54 -0
- package/dist/nodefony/src/criteria.js +176 -0
- package/dist/nodefony/src/decorators/entitiesDecorator.js +67 -0
- package/dist/nodefony/src/decorators/entityDecorator.js +51 -0
- package/dist/nodefony/src/decorators/index.js +5 -0
- package/dist/nodefony/src/decorators/metadataStore.js +38 -0
- package/dist/nodefony/src/decorators/repositoryDecorator.js +35 -0
- package/dist/nodefony/src/defineEntity.js +27 -0
- package/dist/nodefony/src/errors.js +76 -0
- package/dist/nodefony/src/ormWiring.js +78 -0
- package/dist/nodefony/src/paginate.js +55 -0
- package/dist/nodefony/src/readOptions.js +52 -0
- package/dist/nodefony/src/serviceWiring.js +1 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IEntity.d.ts +65 -0
- package/dist/types/nodefony/interfaces/IOrm.d.ts +136 -0
- package/dist/types/nodefony/interfaces/IOrmFlow.d.ts +71 -0
- package/dist/types/nodefony/interfaces/IOrmGraph.d.ts +165 -0
- package/dist/types/nodefony/interfaces/IOrmMigrations.d.ts +145 -0
- package/dist/types/nodefony/interfaces/IOrmProbe.d.ts +70 -0
- package/dist/types/nodefony/interfaces/IPage.d.ts +24 -0
- package/dist/types/nodefony/interfaces/IRepository.d.ts +369 -0
- package/dist/types/nodefony/interfaces/ITransaction.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/src/AbstractCrudService.d.ts +158 -0
- package/dist/types/nodefony/src/ConnectionMonitor.d.ts +86 -0
- package/dist/types/nodefony/src/Entity.d.ts +44 -0
- package/dist/types/nodefony/src/EntityRegistry.d.ts +60 -0
- package/dist/types/nodefony/src/Orm.d.ts +197 -0
- package/dist/types/nodefony/src/OrmAdminApi.d.ts +84 -0
- package/dist/types/nodefony/src/OrmRegistry.d.ts +58 -0
- package/dist/types/nodefony/src/QueryFlowMonitor.d.ts +53 -0
- package/dist/types/nodefony/src/buildOrmLeanHealth.d.ts +15 -0
- package/dist/types/nodefony/src/criteria.d.ts +131 -0
- package/dist/types/nodefony/src/decorators/entitiesDecorator.d.ts +48 -0
- package/dist/types/nodefony/src/decorators/entityDecorator.d.ts +49 -0
- package/dist/types/nodefony/src/decorators/index.d.ts +10 -0
- package/dist/types/nodefony/src/decorators/metadataStore.d.ts +54 -0
- package/dist/types/nodefony/src/decorators/repositoryDecorator.d.ts +30 -0
- package/dist/types/nodefony/src/defineEntity.d.ts +43 -0
- package/dist/types/nodefony/src/errors.d.ts +62 -0
- package/dist/types/nodefony/src/ormWiring.d.ts +48 -0
- package/dist/types/nodefony/src/paginate.d.ts +43 -0
- package/dist/types/nodefony/src/readOptions.d.ts +21 -0
- package/dist/types/nodefony/src/serviceWiring.d.ts +24 -0
- package/docs/index.md +791 -0
- package/docs/tutorial-entity.md +577 -0
- package/package.json +73 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { ormRegistry } from "./OrmRegistry.js";
|
|
2
|
+
import { entityRegistry } from "./EntityRegistry.js";
|
|
3
|
+
import { connectionMonitor } from "./ConnectionMonitor.js";
|
|
4
|
+
import { queryFlowMonitor } from "./QueryFlowMonitor.js";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
6
|
+
//#region nodefony/src/OrmAdminApi.ts
|
|
7
|
+
/**
|
|
8
|
+
* Producteur `IAdminApi` du **modèle de données ORM** — exposé sous
|
|
9
|
+
* `/nodefony/orm/api/*`. Fondation « IA-first » : construit le graphe canonique
|
|
10
|
+
* (ORMs, entités, colonnes, relations) depuis les registres process-wide
|
|
11
|
+
* ({@link ormRegistry} + {@link entityRegistry}), et l'exporte vers des formats
|
|
12
|
+
* que l'écosystème IA comprend (**DBML** d'abord).
|
|
13
|
+
*
|
|
14
|
+
* orm-core est une **lib pure** (pas un Module) → il ne peut pas s'auto-monter ;
|
|
15
|
+
* un module driver l'enregistre via {@link registerOrmAdminApi} à son boot
|
|
16
|
+
* (idempotent). Le graphe lit les registres GLOBAUX → couvre tous les ORM
|
|
17
|
+
* présents, peu importe quel adapter a enregistré l'API.
|
|
18
|
+
*
|
|
19
|
+
* Endpoints :
|
|
20
|
+
* - `GET /nodefony/orm/api/orms` → résumé des ORM/connecteurs
|
|
21
|
+
* - `GET /nodefony/orm/api/entities` → entités (`?connector=` pour filtrer)
|
|
22
|
+
* - `GET /nodefony/orm/api/entity/{name}` → une entité (`?connector=`)
|
|
23
|
+
* - `GET /nodefony/orm/api/graph` → graphe complet (`?connector=`)
|
|
24
|
+
* - `GET /nodefony/orm/api/export/{format}`→ export (`dbml`), `?connector=`
|
|
25
|
+
* - `GET /nodefony/orm/api/migrations` → état des migrations (`?connector=`)
|
|
26
|
+
*/
|
|
27
|
+
/** Première valeur d'un param de query (`?connector=default`). */
|
|
28
|
+
function oneParam(req, key) {
|
|
29
|
+
const raw = req.query[key];
|
|
30
|
+
return Array.isArray(raw) ? raw[0] : raw;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Vendor de l'adapter dérivé de son nom de classe (`DrizzleOrm` → `drizzle`,
|
|
34
|
+
* `DrizzleOrm`/`Drizzle` → `drizzle`…). Dette : remplacer par un
|
|
35
|
+
* `IOrm.vendor` déclaré par chaque adapter (P7.1). `""` si indéterminé.
|
|
36
|
+
*/
|
|
37
|
+
function vendorOf(orm) {
|
|
38
|
+
const cls = orm?.constructor?.name;
|
|
39
|
+
if (!cls) return "";
|
|
40
|
+
return cls.replace(/Orm$/, "").toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Résout un connecteur et lui demande UNE de ses capacités de migration.
|
|
44
|
+
*
|
|
45
|
+
* Les trois points de migration posent la même question dans le même ordre —
|
|
46
|
+
* le connecteur existe-t-il, porte-t-il la capacité, que répond-elle — et
|
|
47
|
+
* trois copies de cette suite auraient fini par répondre trois choses
|
|
48
|
+
* différentes au même cas.
|
|
49
|
+
*
|
|
50
|
+
* @param request - requête admin (`?connector=`, défaut « default »).
|
|
51
|
+
* @param capability - nom de la méthode optionnelle demandée à l'ORM.
|
|
52
|
+
* @param missingMessage - ce qu'on dit quand l'ORM ne la porte pas.
|
|
53
|
+
* @returns la réponse de l'ORM, ou une réponse d'administration explicite.
|
|
54
|
+
*/
|
|
55
|
+
async function migrationCapability(request, capability, missingMessage) {
|
|
56
|
+
const connector = oneParam(request, "connector") ?? "default";
|
|
57
|
+
if (!ormRegistry.has(connector)) return {
|
|
58
|
+
status: 404,
|
|
59
|
+
body: { error: `aucun connecteur « ${connector} » — ceux que cette application déclare : ${ormRegistry.list().join(", ") || "aucun"}` }
|
|
60
|
+
};
|
|
61
|
+
const orm = ormRegistry.get(connector);
|
|
62
|
+
const fn = orm[capability];
|
|
63
|
+
if (typeof fn !== "function") return {
|
|
64
|
+
status: 501,
|
|
65
|
+
body: {
|
|
66
|
+
formatVersion: 1,
|
|
67
|
+
connector,
|
|
68
|
+
error: {
|
|
69
|
+
code: "NF_MIGRATE_NO_MIGRATIONS",
|
|
70
|
+
summary: `Le connecteur « ${connector} » est porté par ${vendorOf(orm) || orm.name}, dont la base ne se met pas à jour par des migrations de schéma.`,
|
|
71
|
+
meaning: `${missingMessage} Les migrations par fichiers versionnés sont une mécanique SQL ; les autres bases résorbent l'écart entre le code et le schéma autrement.`,
|
|
72
|
+
nextActions: []
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
return fn.call(orm);
|
|
77
|
+
}
|
|
78
|
+
/** Résumé des ORM enregistrés (statut connexion + nombre d'entités). */
|
|
79
|
+
function buildOrmSummaries() {
|
|
80
|
+
const entities = entityRegistry.list();
|
|
81
|
+
return ormRegistry.list().map((name) => {
|
|
82
|
+
let connected = false;
|
|
83
|
+
let vendor = "";
|
|
84
|
+
let connection;
|
|
85
|
+
try {
|
|
86
|
+
const orm = ormRegistry.get(name);
|
|
87
|
+
connected = orm.isConnected();
|
|
88
|
+
vendor = vendorOf(orm);
|
|
89
|
+
connection = orm.describeConnection?.();
|
|
90
|
+
} catch {
|
|
91
|
+
connected = false;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
name,
|
|
95
|
+
vendor,
|
|
96
|
+
default: name === "default",
|
|
97
|
+
connected,
|
|
98
|
+
entityCount: entities.filter((e) => e.connector === name).length,
|
|
99
|
+
connection
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Construit un nœud de graphe pour une entité : relations (toujours) + colonnes
|
|
105
|
+
* (via `orm.describeEntity` si l'adapter l'implémente et si l'ORM est connecté).
|
|
106
|
+
*/
|
|
107
|
+
function buildEntityNode(connector, name) {
|
|
108
|
+
const entity = entityRegistry.get(name, connector);
|
|
109
|
+
let columns = [];
|
|
110
|
+
try {
|
|
111
|
+
columns = ormRegistry.get(connector).describeEntity?.(name) ?? [];
|
|
112
|
+
} catch {
|
|
113
|
+
columns = [];
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
name: entity.name,
|
|
117
|
+
connector: entity.connector,
|
|
118
|
+
module: entity.module ?? "",
|
|
119
|
+
domain: entity.domain ?? "",
|
|
120
|
+
columns,
|
|
121
|
+
relations: (entity.relations ?? []).map((r) => ({
|
|
122
|
+
type: r.type,
|
|
123
|
+
target: r.target,
|
|
124
|
+
field: r.field,
|
|
125
|
+
foreignKey: r.foreignKey
|
|
126
|
+
}))
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Construit le graphe canonique complet (optionnellement filtré par connecteur). */
|
|
130
|
+
function buildOrmGraph(connectorFilter) {
|
|
131
|
+
const orms = buildOrmSummaries();
|
|
132
|
+
const entities = entityRegistry.list().filter((e) => !connectorFilter || e.connector === connectorFilter).map((e) => buildEntityNode(e.connector, e.name));
|
|
133
|
+
return {
|
|
134
|
+
orms: connectorFilter ? orms.filter((o) => o.name === connectorFilter) : orms,
|
|
135
|
+
entities
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Construit le **diagnostic complet des connexions** (per-instance) : ping live
|
|
140
|
+
* (latence enregistrée dans la fenêtre glissante), sonde profonde driver
|
|
141
|
+
* ({@link IOrm.probe} — stockage/pool), et compteurs de cycle de vie du
|
|
142
|
+
* {@link connectionMonitor}. Réutilisé par l'endpoint `connection/health` ET par
|
|
143
|
+
* le ticker hub realtime de Studio (« contrôle total des ORM »).
|
|
144
|
+
*
|
|
145
|
+
* @param filter - nom de connecteur (optionnel) pour ne sonder que celui-ci.
|
|
146
|
+
* @returns un {@link IConnectionHealth} par connecteur.
|
|
147
|
+
*/
|
|
148
|
+
async function buildConnectionHealth(filter) {
|
|
149
|
+
const instanceId = String(process.pid);
|
|
150
|
+
const names = ormRegistry.list().filter((n) => !filter || n === filter);
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const name of names) {
|
|
153
|
+
let connected = false;
|
|
154
|
+
let vendor = "";
|
|
155
|
+
let driver = "";
|
|
156
|
+
let target;
|
|
157
|
+
let version;
|
|
158
|
+
let ormVersion;
|
|
159
|
+
let pingMs = null;
|
|
160
|
+
let pingOk = false;
|
|
161
|
+
let pingError = null;
|
|
162
|
+
let storage;
|
|
163
|
+
let pool;
|
|
164
|
+
let extra;
|
|
165
|
+
try {
|
|
166
|
+
const inst = ormRegistry.get(name);
|
|
167
|
+
connected = inst.isConnected();
|
|
168
|
+
vendor = vendorOf(inst);
|
|
169
|
+
const c = inst.describeConnection?.();
|
|
170
|
+
if (c) {
|
|
171
|
+
driver = c.driver;
|
|
172
|
+
target = c.target;
|
|
173
|
+
version = c.version;
|
|
174
|
+
ormVersion = c.ormVersion;
|
|
175
|
+
}
|
|
176
|
+
if (connected) {
|
|
177
|
+
const t0 = performance.now();
|
|
178
|
+
try {
|
|
179
|
+
if (typeof inst.ping === "function") await inst.ping();
|
|
180
|
+
else await inst.transaction(async () => void 0);
|
|
181
|
+
pingMs = Math.round((performance.now() - t0) * 100) / 100;
|
|
182
|
+
pingOk = true;
|
|
183
|
+
connectionMonitor.recordPing(name, pingMs);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
pingError = e instanceof Error ? e.message : "ping failed";
|
|
186
|
+
connectionMonitor.recordError(name, pingError);
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const probe = await inst.probe?.();
|
|
190
|
+
if (probe) {
|
|
191
|
+
storage = probe.storage;
|
|
192
|
+
pool = probe.pool;
|
|
193
|
+
extra = probe.extra;
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
pingError = e instanceof Error ? e.message : String(e);
|
|
199
|
+
}
|
|
200
|
+
const core = connectionMonitor.snapshot(name);
|
|
201
|
+
out.push({
|
|
202
|
+
instanceId,
|
|
203
|
+
name,
|
|
204
|
+
vendor,
|
|
205
|
+
driver,
|
|
206
|
+
target,
|
|
207
|
+
version,
|
|
208
|
+
ormVersion,
|
|
209
|
+
connected,
|
|
210
|
+
connectedSince: core.connectedSince,
|
|
211
|
+
uptimeMs: core.uptimeMs,
|
|
212
|
+
connectCount: core.connectCount,
|
|
213
|
+
reconnectCount: core.reconnectCount,
|
|
214
|
+
errorCount: core.errorCount,
|
|
215
|
+
lastError: core.lastError,
|
|
216
|
+
recentErrors: core.recentErrors,
|
|
217
|
+
lastConnectMs: core.lastConnectMs,
|
|
218
|
+
pingMs,
|
|
219
|
+
pingOk,
|
|
220
|
+
pingError,
|
|
221
|
+
latency: core.latency,
|
|
222
|
+
storage,
|
|
223
|
+
pool,
|
|
224
|
+
extra
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Construit le rapport de **flux ORM** (per-instance) : débit (via `total`),
|
|
231
|
+
* latence (moyenne + EWMA), pire latence et requêtes lentes récentes, par
|
|
232
|
+
* connecteur enregistré. Lecture pure du {@link queryFlowMonitor} (aucune
|
|
233
|
+
* requête émise — contrairement à `buildConnectionHealth` qui ping) → bon marché.
|
|
234
|
+
* Réutilisé par l'endpoint `flow` ET le ticker hub realtime de Studio.
|
|
235
|
+
*
|
|
236
|
+
* @param filter - nom de connecteur (optionnel) pour ne rapporter que celui-ci.
|
|
237
|
+
* @returns le rapport (`enabled=false` en prod → connecteurs à 0).
|
|
238
|
+
*/
|
|
239
|
+
function buildOrmFlow(filter) {
|
|
240
|
+
const connectors = ormRegistry.list().filter((n) => !filter || n === filter).map((name) => {
|
|
241
|
+
let vendor = "";
|
|
242
|
+
try {
|
|
243
|
+
vendor = vendorOf(ormRegistry.get(name));
|
|
244
|
+
} catch {
|
|
245
|
+
vendor = "";
|
|
246
|
+
}
|
|
247
|
+
return queryFlowMonitor.snapshot(name, vendor);
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
enabled: queryFlowMonitor.enabled,
|
|
251
|
+
ts: Date.now(),
|
|
252
|
+
instanceId: String(process.pid),
|
|
253
|
+
slowMs: queryFlowMonitor.slowMs,
|
|
254
|
+
connectors
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Échappe un identifiant DBML s'il contient autre chose que `[A-Za-z0-9_]`. */
|
|
258
|
+
function dbmlId(name) {
|
|
259
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `"${name}"`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Sérialise un graphe en **DBML** (Database Markup Language) — format pivot lu
|
|
263
|
+
* par dbdiagram.io, les outils IA et convertible en SQL. Les `Ref:` sont dérivés
|
|
264
|
+
* des relations selon la convention FK des adapters (`<source>Id` sur la cible
|
|
265
|
+
* pour 1-N ; `<target>Id` sur la source pour N-1/1-1). Le many-to-many est
|
|
266
|
+
* annoté en commentaire (table de jonction non portable).
|
|
267
|
+
*
|
|
268
|
+
* @param graph - graphe canonique.
|
|
269
|
+
* @returns texte DBML.
|
|
270
|
+
*/
|
|
271
|
+
function toDbml(graph) {
|
|
272
|
+
const lines = [];
|
|
273
|
+
for (const node of graph.entities) {
|
|
274
|
+
lines.push(`Table ${dbmlId(node.name)} {`);
|
|
275
|
+
if (node.columns.length === 0) lines.push(" // colonnes non introspectées par l'adapter");
|
|
276
|
+
for (const col of node.columns) {
|
|
277
|
+
const settings = [];
|
|
278
|
+
if (col.primaryKey) settings.push("pk");
|
|
279
|
+
if (col.unique && !col.primaryKey) settings.push("unique");
|
|
280
|
+
if (!col.nullable && !col.primaryKey) settings.push("not null");
|
|
281
|
+
const suffix = settings.length ? ` [${settings.join(", ")}]` : "";
|
|
282
|
+
lines.push(` ${dbmlId(col.name)} ${col.type || "unknown"}${suffix}`);
|
|
283
|
+
}
|
|
284
|
+
lines.push("}");
|
|
285
|
+
lines.push("");
|
|
286
|
+
}
|
|
287
|
+
const refs = /* @__PURE__ */ new Set();
|
|
288
|
+
for (const node of graph.entities) for (const rel of node.relations) {
|
|
289
|
+
if (rel.type === "many-to-many") {
|
|
290
|
+
refs.add(`// many-to-many ${node.name}.${rel.field} <> ${rel.target} (table de jonction)`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const camel = (n) => `${n.charAt(0).toLowerCase()}${n.slice(1)}Id`;
|
|
294
|
+
if (rel.type === "one-to-many") {
|
|
295
|
+
const fk = rel.foreignKey ?? camel(node.name);
|
|
296
|
+
refs.add(`Ref: ${dbmlId(rel.target)}.${dbmlId(fk)} > ${dbmlId(node.name)}.id`);
|
|
297
|
+
} else {
|
|
298
|
+
const fk = rel.foreignKey ?? camel(rel.target);
|
|
299
|
+
refs.add(`Ref: ${dbmlId(node.name)}.${dbmlId(fk)} > ${dbmlId(rel.target)}.id`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
lines.push(...refs);
|
|
303
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Mappe un type natif rapporté par l'adapter (`describeEntity`) vers un type
|
|
307
|
+
* JSON Schema. Heuristique tolérante (SQL + Mongoose) : `INTEGER`→integer,
|
|
308
|
+
* `VARCHAR(255)`/`uuid`/`ObjectId`→string, `BOOLEAN`→boolean, `json`→object,
|
|
309
|
+
* `timestamp`→string(date-time). Défaut prudent : `string`.
|
|
310
|
+
*/
|
|
311
|
+
function jsonSchemaType(nativeType) {
|
|
312
|
+
const t = nativeType.toLowerCase();
|
|
313
|
+
if (/\b(serial|bigint|smallint|tinyint|mediumint|int|integer)\b/.test(t)) return { type: "integer" };
|
|
314
|
+
if (/(real|float|double|decimal|numeric|number)/.test(t)) return { type: "number" };
|
|
315
|
+
if (/bool/.test(t)) return { type: "boolean" };
|
|
316
|
+
if (/json/.test(t)) return { type: "object" };
|
|
317
|
+
if (/(date|time|timestamp)/.test(t)) return {
|
|
318
|
+
type: "string",
|
|
319
|
+
format: "date-time"
|
|
320
|
+
};
|
|
321
|
+
return { type: "string" };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Sérialise un graphe en **JSON Schema** (draft 2020-12) — un `$defs` par
|
|
325
|
+
* entité. Format « IA-first » : un agent (text-to-SQL, génération de formulaire,
|
|
326
|
+
* validation de payload) consomme directement ce schéma. Les colonnes
|
|
327
|
+
* non-nullables alimentent `required` ; les relations deviennent des `$ref`
|
|
328
|
+
* (N→1/1→1) ou des tableaux de `$ref` (1→N/N→N) vers les autres `$defs`.
|
|
329
|
+
*
|
|
330
|
+
* @param graph - graphe canonique.
|
|
331
|
+
* @returns document JSON Schema ({@link IJsonSchemaObject} par entité).
|
|
332
|
+
*/
|
|
333
|
+
function toJsonSchema(graph) {
|
|
334
|
+
const $defs = {};
|
|
335
|
+
for (const node of graph.entities) {
|
|
336
|
+
const properties = {};
|
|
337
|
+
const required = [];
|
|
338
|
+
for (const col of node.columns) {
|
|
339
|
+
properties[col.name] = jsonSchemaType(col.type);
|
|
340
|
+
if (!col.nullable) required.push(col.name);
|
|
341
|
+
}
|
|
342
|
+
for (const rel of node.relations) {
|
|
343
|
+
const ref = `#/$defs/${rel.target}`;
|
|
344
|
+
properties[rel.field] = rel.type === "one-to-many" || rel.type === "many-to-many" ? {
|
|
345
|
+
type: "array",
|
|
346
|
+
items: { $ref: ref }
|
|
347
|
+
} : { $ref: ref };
|
|
348
|
+
}
|
|
349
|
+
$defs[node.name] = {
|
|
350
|
+
type: "object",
|
|
351
|
+
title: node.name,
|
|
352
|
+
properties,
|
|
353
|
+
...required.length ? { required } : {},
|
|
354
|
+
additionalProperties: false
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
359
|
+
$defs
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const descriptor = {
|
|
363
|
+
label: "ORM",
|
|
364
|
+
icon: "database",
|
|
365
|
+
order: 4
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Construit le producteur `IAdminApi` du data plane ORM.
|
|
369
|
+
*
|
|
370
|
+
* @returns le `IAdminApi` (namespace `"orm"`).
|
|
371
|
+
*/
|
|
372
|
+
function createOrmAdminApi() {
|
|
373
|
+
const endpoints = [
|
|
374
|
+
{
|
|
375
|
+
path: "orms",
|
|
376
|
+
summary: "ORMs/connecteurs enregistrés (statut + nombre d'entités)",
|
|
377
|
+
handler: () => buildOrmSummaries()
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
path: "entities",
|
|
381
|
+
summary: "Entités du modèle (colonnes + relations) — ?connector= pour filtrer",
|
|
382
|
+
handler: (request) => buildOrmGraph(oneParam(request, "connector")).entities
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
path: "entity/{name}",
|
|
386
|
+
summary: "Une entité (colonnes + relations) — ?connector= si homonymes",
|
|
387
|
+
handler: (request) => {
|
|
388
|
+
const name = request.params.name ?? "";
|
|
389
|
+
const connector = oneParam(request, "connector");
|
|
390
|
+
try {
|
|
391
|
+
const found = entityRegistry.list().find((e) => e.name === name && (!connector || e.connector === connector));
|
|
392
|
+
if (!found) return {
|
|
393
|
+
status: 404,
|
|
394
|
+
body: { error: `entity "${name}" not found` }
|
|
395
|
+
};
|
|
396
|
+
return buildEntityNode(found.connector, found.name);
|
|
397
|
+
} catch {
|
|
398
|
+
return {
|
|
399
|
+
status: 404,
|
|
400
|
+
body: { error: `entity "${name}" not found` }
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
path: "graph",
|
|
407
|
+
summary: "Graphe canonique complet (ORMs + entités) — ?connector= pour filtrer",
|
|
408
|
+
handler: (request) => buildOrmGraph(oneParam(request, "connector"))
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
path: "counts",
|
|
412
|
+
summary: "Nombre de lignes par entité (COUNT(*)) — ?connector= pour filtrer. Lazy : 1 COUNT par table.",
|
|
413
|
+
handler: async (request) => {
|
|
414
|
+
const connector = oneParam(request, "connector");
|
|
415
|
+
const counts = {};
|
|
416
|
+
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;
|
|
422
|
+
}
|
|
423
|
+
return counts;
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
path: "migrations",
|
|
428
|
+
summary: "État des migrations d'un connecteur (?connector=, défaut « default ») — MÊME objet que `orm:migrate:status --json`. 501 si l'ORM ne porte pas de migrations, 404 si le connecteur n'existe pas.",
|
|
429
|
+
handler: async (request) => migrationCapability(request, "migrationStatus", "Ce connecteur ne suit pas de migrations.")
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
path: "migrations/plan",
|
|
433
|
+
summary: "Ce qui S'APPLIQUERAIT, avec son SQL (?connector=) — lecture seule, sert la confirmation avant application.",
|
|
434
|
+
handler: async (request) => migrationCapability(request, "migrationPlan", "Ce connecteur ne sait pas dire ce qui s'appliquerait.")
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
path: "migrations/apply",
|
|
438
|
+
method: "POST",
|
|
439
|
+
summary: "Applique les migrations en attente (?connector=) — DÉVELOPPEMENT seulement : le pilote refuse ailleurs, en le disant. En production, les migrations passent par un travail d'orchestrateur.",
|
|
440
|
+
handler: async (request) => migrationCapability(request, "applyMigrations", "Ce connecteur ne sait pas appliquer de migrations.")
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
path: "connection/health",
|
|
444
|
+
summary: "Diagnostic des connexions (per-instance) — état, ping/latence (fenêtre), erreurs, reconnexions, sondes (stockage/pool). ?connector= pour filtrer.",
|
|
445
|
+
handler: (request) => buildConnectionHealth(oneParam(request, "connector"))
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
path: "flow",
|
|
449
|
+
summary: "Flux des requêtes (per-instance) — débit (via total), latence moy/EWMA, pire latence, requêtes lentes. ?connector= pour filtrer. enabled=false en prod.",
|
|
450
|
+
handler: (request) => buildOrmFlow(oneParam(request, "connector"))
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
path: "export/{format}",
|
|
454
|
+
summary: "Export du modèle — format: dbml | jsonschema (?connector= pour filtrer)",
|
|
455
|
+
handler: (request) => {
|
|
456
|
+
const format = (request.params.format ?? "").toLowerCase();
|
|
457
|
+
const graph = buildOrmGraph(oneParam(request, "connector"));
|
|
458
|
+
if (format === "dbml") return {
|
|
459
|
+
format: "dbml",
|
|
460
|
+
content: toDbml(graph)
|
|
461
|
+
};
|
|
462
|
+
if (format === "jsonschema") return {
|
|
463
|
+
format: "jsonschema",
|
|
464
|
+
content: JSON.stringify(toJsonSchema(graph), null, 2)
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
status: 400,
|
|
468
|
+
body: { error: `unsupported export format "${format}" (dbml, jsonschema)` }
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
];
|
|
473
|
+
return {
|
|
474
|
+
adminNamespace: "orm",
|
|
475
|
+
adminDescriptor: () => descriptor,
|
|
476
|
+
adminEndpoints: () => endpoints
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Enregistre l'`OrmAdminApi` sur le broker admin, **idempotent** (no-op si déjà
|
|
481
|
+
* monté). Appelé par un module driver à son `onKernelBoot` (orm-core est une lib
|
|
482
|
+
* pure et ne peut pas s'auto-monter).
|
|
483
|
+
*
|
|
484
|
+
* @param registry - broker admin (`container.get("adminBroker")`).
|
|
485
|
+
*/
|
|
486
|
+
function registerOrmAdminApi(registry) {
|
|
487
|
+
if (registry.has("orm")) return;
|
|
488
|
+
registry.register(createOrmAdminApi());
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
export { buildConnectionHealth, buildOrmFlow, buildOrmGraph, createOrmAdminApi, registerOrmAdminApi, toDbml, toJsonSchema };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region nodefony/src/OrmRegistry.ts
|
|
2
|
+
/**
|
|
3
|
+
* Registre process-wide des instances ORM enregistrées sous un nom unique.
|
|
4
|
+
*
|
|
5
|
+
* Support multi-ORM natif : chaque driver (`@nodefony/mongoose`,
|
|
6
|
+
* `@nodefony/drizzle`...) s'enregistre à son boot via
|
|
7
|
+
* {@link OrmRegistry.register} sous une clé logique (`"db_principale"`,
|
|
8
|
+
* `"db_logs"`...). Les consommateurs (repositories, session storage, security)
|
|
9
|
+
* résolvent l'ORM voulu par cette clé, jamais par référence directe au driver.
|
|
10
|
+
*
|
|
11
|
+
* Pas de coupling à `nodefony` : structure pure et lazy (la `Map` interne n'est
|
|
12
|
+
* allouée qu'au premier `register`), donc trivialement testable en isolation.
|
|
13
|
+
*/
|
|
14
|
+
var OrmRegistry = class {
|
|
15
|
+
/** Allouée paresseusement au premier enregistrement (zéro coût au boot). */
|
|
16
|
+
#orms = null;
|
|
17
|
+
/**
|
|
18
|
+
* Enregistre une instance ORM sous un nom unique.
|
|
19
|
+
*
|
|
20
|
+
* @param name - clé logique de l'ORM (ex. `"db_principale"`).
|
|
21
|
+
* @param orm - instance implémentant {@link IOrm}.
|
|
22
|
+
* @throws si un ORM du même nom est déjà enregistré (erreur de configuration).
|
|
23
|
+
*/
|
|
24
|
+
register(name, orm) {
|
|
25
|
+
if (this.#orms === null) this.#orms = /* @__PURE__ */ new Map();
|
|
26
|
+
if (this.#orms.has(name)) throw new Error(`OrmRegistry: an ORM named "${name}" is already registered.`);
|
|
27
|
+
this.#orms.set(name, orm);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Résout l'ORM enregistré sous un nom.
|
|
31
|
+
*
|
|
32
|
+
* @param name - clé logique de l'ORM.
|
|
33
|
+
* @returns l'instance {@link IOrm} correspondante.
|
|
34
|
+
* @throws si aucun ORM n'est enregistré sous ce nom.
|
|
35
|
+
*/
|
|
36
|
+
get(name) {
|
|
37
|
+
const orm = this.#orms?.get(name);
|
|
38
|
+
if (orm === void 0) throw new Error(`OrmRegistry: no ORM registered under "${name}".`);
|
|
39
|
+
return orm;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Indique si un ORM est enregistré sous ce nom.
|
|
43
|
+
*
|
|
44
|
+
* @param name - clé logique de l'ORM.
|
|
45
|
+
*/
|
|
46
|
+
has(name) {
|
|
47
|
+
return this.#orms?.has(name) ?? false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Liste les noms des ORM enregistrés.
|
|
51
|
+
*
|
|
52
|
+
* @returns tableau des clés (vide si aucun ORM enregistré).
|
|
53
|
+
*/
|
|
54
|
+
list() {
|
|
55
|
+
return this.#orms === null ? [] : [...this.#orms.keys()];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Retire un ORM du registre (utile au teardown des tests / hot-reload).
|
|
59
|
+
*
|
|
60
|
+
* @param name - clé logique de l'ORM.
|
|
61
|
+
* @returns `true` si un ORM a été retiré, `false` sinon.
|
|
62
|
+
*/
|
|
63
|
+
unregister(name) {
|
|
64
|
+
return this.#orms?.delete(name) ?? false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Singleton process-wide partagé par tous les drivers et consommateurs ORM.
|
|
69
|
+
*
|
|
70
|
+
* Les modules ORM s'enregistrent ici ; la classe {@link OrmRegistry} reste
|
|
71
|
+
* instanciable séparément pour des registres isolés (tests).
|
|
72
|
+
*/
|
|
73
|
+
const ormRegistry = new OrmRegistry();
|
|
74
|
+
//#endregion
|
|
75
|
+
export { OrmRegistry, ormRegistry };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
//#region nodefony/src/QueryFlowMonitor.ts
|
|
2
|
+
/** Taille max du ring de requêtes lentes (borne mémoire). */
|
|
3
|
+
const MAX_SLOW = 20;
|
|
4
|
+
/** Facteur de lissage EWMA (0–1) : plus haut = suit plus vite les variations. */
|
|
5
|
+
const EWMA_ALPHA = .2;
|
|
6
|
+
/** Seuil « lent » par défaut (ms) — au-delà, la requête est capturée. */
|
|
7
|
+
const DEFAULT_SLOW_MS = 50;
|
|
8
|
+
/** Flux neutre d'un connecteur jamais observé (réutilisé, jamais muté). */
|
|
9
|
+
const EMPTY_FLOW = {
|
|
10
|
+
total: 0,
|
|
11
|
+
avgMs: null,
|
|
12
|
+
ewmaMs: null,
|
|
13
|
+
lastMs: null,
|
|
14
|
+
maxMs: 0,
|
|
15
|
+
slowTotal: 0,
|
|
16
|
+
slow: []
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* **QueryFlowMonitor** — sonde de **débit ORM** per-instance (process-local),
|
|
20
|
+
* agrégée et **indépendante de l'ALS** : compte les requêtes, suit leur latence
|
|
21
|
+
* (moyenne + EWMA) et capture les plus lentes, pour le panneau Supervision
|
|
22
|
+
* (« contrôle total » des ORM, patron sondes+hub).
|
|
23
|
+
*
|
|
24
|
+
* Distinct du **profiler par-requête** (debug bar, buffer de scope ALS, dev-only,
|
|
25
|
+
* coût nul hors requête tracée) : ce moniteur observe le débit **global** et doit
|
|
26
|
+
* donc compter en continu → il est **gaté** par {@link enabled} (OFF par défaut)
|
|
27
|
+
* pour rester **coût nul en production** et ne pas pénaliser les bancs de charge
|
|
28
|
+
* (qui instancient les adapters hors kernel → `enabled` reste à `false`). Le
|
|
29
|
+
* module driver l'active au boot en environnement non-prod.
|
|
30
|
+
*
|
|
31
|
+
* Perf (règle ABSOLUE) :
|
|
32
|
+
* - structure lazy (`Map` allouée au 1ᵉʳ enregistrement, ring `slow` au 1ᵉʳ lent) ;
|
|
33
|
+
* - le hot path n'alloue rien hors cas lent et n'appelle **jamais** `toSQL()`
|
|
34
|
+
* (l'appelant ne capture le SQL que sur le chemin lent, rare) ;
|
|
35
|
+
* - le débit/s n'est pas calculé ici (dérivé du delta de `total` à la lecture)
|
|
36
|
+
* → 0 état mutable côté lecture, 0 ring de timestamps sous charge.
|
|
37
|
+
*
|
|
38
|
+
* Cloud-native : per-instance ; la vue multi-pod relève de l'agrégation
|
|
39
|
+
* (Prometheus / fan-out Redis P13), pas de cette classe. Reset au restart.
|
|
40
|
+
*/
|
|
41
|
+
var QueryFlowMonitor = class {
|
|
42
|
+
/** Sonde active ? OFF par défaut → coût nul tant que le driver ne l'active pas. */
|
|
43
|
+
enabled = false;
|
|
44
|
+
/** Seuil « lent » (ms) — modifiable par le driver/config. */
|
|
45
|
+
slowMs = DEFAULT_SLOW_MS;
|
|
46
|
+
/** `null` tant qu'aucune requête n'a été enregistrée (lazy). */
|
|
47
|
+
#stats = null;
|
|
48
|
+
/** Active/désactive la sonde (appelé au boot du module driver selon l'env). */
|
|
49
|
+
setEnabled(on) {
|
|
50
|
+
this.enabled = on;
|
|
51
|
+
}
|
|
52
|
+
/** Crée/retourne les stats mutables d'un connecteur (alloue à la demande). */
|
|
53
|
+
#ensure(connector) {
|
|
54
|
+
if (this.#stats === null) this.#stats = /* @__PURE__ */ new Map();
|
|
55
|
+
let s = this.#stats.get(connector);
|
|
56
|
+
if (s === void 0) {
|
|
57
|
+
s = {
|
|
58
|
+
total: 0,
|
|
59
|
+
sumMs: 0,
|
|
60
|
+
ewmaMs: null,
|
|
61
|
+
lastMs: null,
|
|
62
|
+
maxMs: 0,
|
|
63
|
+
slowTotal: 0,
|
|
64
|
+
slow: null
|
|
65
|
+
};
|
|
66
|
+
this.#stats.set(connector, s);
|
|
67
|
+
}
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Enregistre une requête mesurée. À n'appeler **que** si {@link enabled} (le
|
|
72
|
+
* tap appelant teste le drapeau pour éviter tout coût quand la sonde est OFF).
|
|
73
|
+
*
|
|
74
|
+
* @param connector - clé du connecteur ORM (registre).
|
|
75
|
+
* @param durationMs - durée de la requête (ms).
|
|
76
|
+
* @param sql - SQL paramétré+redacté, fourni **uniquement** si la requête est
|
|
77
|
+
* lente (l'appelant n'extrait le texte que sur le chemin lent — rare).
|
|
78
|
+
*/
|
|
79
|
+
record(connector, durationMs, sql) {
|
|
80
|
+
const s = this.#ensure(connector);
|
|
81
|
+
s.total += 1;
|
|
82
|
+
s.sumMs += durationMs;
|
|
83
|
+
s.lastMs = durationMs;
|
|
84
|
+
if (durationMs > s.maxMs) s.maxMs = durationMs;
|
|
85
|
+
s.ewmaMs = s.ewmaMs === null ? durationMs : EWMA_ALPHA * durationMs + .8 * s.ewmaMs;
|
|
86
|
+
if (durationMs >= this.slowMs) {
|
|
87
|
+
s.slowTotal += 1;
|
|
88
|
+
if (s.slow === null) s.slow = [];
|
|
89
|
+
s.slow.unshift({
|
|
90
|
+
ts: Date.now(),
|
|
91
|
+
durationMs,
|
|
92
|
+
connector,
|
|
93
|
+
sql
|
|
94
|
+
});
|
|
95
|
+
if (s.slow.length > MAX_SLOW) s.slow.length = MAX_SLOW;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Vue figée du flux d'un connecteur (ou flux neutre si jamais observé).
|
|
100
|
+
*
|
|
101
|
+
* @param connector - clé du connecteur ORM.
|
|
102
|
+
* @param vendor - vendor de l'adapter (dérivé par l'appelant).
|
|
103
|
+
*/
|
|
104
|
+
snapshot(connector, vendor) {
|
|
105
|
+
const s = this.#stats?.get(connector);
|
|
106
|
+
if (s === void 0) return {
|
|
107
|
+
connector,
|
|
108
|
+
vendor,
|
|
109
|
+
...EMPTY_FLOW
|
|
110
|
+
};
|
|
111
|
+
const round = (v) => Math.round(v * 100) / 100;
|
|
112
|
+
return {
|
|
113
|
+
connector,
|
|
114
|
+
vendor,
|
|
115
|
+
total: s.total,
|
|
116
|
+
avgMs: s.total ? round(s.sumMs / s.total) : null,
|
|
117
|
+
ewmaMs: s.ewmaMs === null ? null : round(s.ewmaMs),
|
|
118
|
+
lastMs: s.lastMs === null ? null : round(s.lastMs),
|
|
119
|
+
maxMs: round(s.maxMs),
|
|
120
|
+
slowTotal: s.slowTotal,
|
|
121
|
+
slow: s.slow ?? []
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/** Singleton process-wide de la sonde de flux ORM. */
|
|
126
|
+
const queryFlowMonitor = new QueryFlowMonitor();
|
|
127
|
+
//#endregion
|
|
128
|
+
export { queryFlowMonitor };
|