@linklabjs/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +411 -0
  2. package/package.json +48 -0
  3. package/src/api/DomainNode.ts +1433 -0
  4. package/src/api/Graph.ts +271 -0
  5. package/src/api/PathBuilder.ts +247 -0
  6. package/src/api/index.ts +15 -0
  7. package/src/api/loadGraph.ts +207 -0
  8. package/src/api/test-api.ts +153 -0
  9. package/src/api/test-domain.ts +119 -0
  10. package/src/api/types.ts +88 -0
  11. package/src/config/synonyms.json +28 -0
  12. package/src/core/EventBus.ts +187 -0
  13. package/src/core/GraphEvents.ts +153 -0
  14. package/src/core/PathFinder.ts +283 -0
  15. package/src/formatters/BaseFormatter.ts +17 -0
  16. package/src/graph/GraphAssembler.ts +50 -0
  17. package/src/graph/GraphCompiler.ts +412 -0
  18. package/src/graph/GraphExtractor.ts +191 -0
  19. package/src/graph/GraphOptimizer.ts +404 -0
  20. package/src/graph/GraphTrainer.ts +247 -0
  21. package/src/http/LinkBuilder.ts +244 -0
  22. package/src/http/TrailRequest.ts +48 -0
  23. package/src/http/example-netflix.ts +59 -0
  24. package/src/http/hateoas/README.md +87 -0
  25. package/src/http/index.ts +33 -0
  26. package/src/http/plugin.ts +360 -0
  27. package/src/index.ts +121 -0
  28. package/src/instrumentation/TelemetryShim.ts +172 -0
  29. package/src/navigation/NavigationEngine.ts +441 -0
  30. package/src/navigation/Resolver.ts +134 -0
  31. package/src/navigation/Scheduler.ts +136 -0
  32. package/src/navigation/Trail.ts +252 -0
  33. package/src/navigation/TrailParser.ts +207 -0
  34. package/src/navigation/index.ts +11 -0
  35. package/src/providers/MockProvider.ts +68 -0
  36. package/src/providers/PostgresProvider.ts +187 -0
  37. package/src/runtime/CompiledGraphEngine.ts +274 -0
  38. package/src/runtime/DataLoader.ts +236 -0
  39. package/src/runtime/Engine.ts +163 -0
  40. package/src/runtime/QueryEngine.ts +222 -0
  41. package/src/scenarios/test-metro-paris/config.json +6 -0
  42. package/src/scenarios/test-metro-paris/graph.json +16325 -0
  43. package/src/scenarios/test-metro-paris/queries.ts +152 -0
  44. package/src/scenarios/test-metro-paris/stack.json +1 -0
  45. package/src/scenarios/test-musicians/config.json +10 -0
  46. package/src/scenarios/test-musicians/graph.json +20 -0
  47. package/src/scenarios/test-musicians/stack.json +1 -0
  48. package/src/scenarios/test-netflix/MIGRATION.md +23 -0
  49. package/src/scenarios/test-netflix/README.md +138 -0
  50. package/src/scenarios/test-netflix/actions.ts +92 -0
  51. package/src/scenarios/test-netflix/config.json +6 -0
  52. package/src/scenarios/test-netflix/data/categories.json +1 -0
  53. package/src/scenarios/test-netflix/data/companies.json +1 -0
  54. package/src/scenarios/test-netflix/data/credits.json +19797 -0
  55. package/src/scenarios/test-netflix/data/departments.json +18 -0
  56. package/src/scenarios/test-netflix/data/jobs.json +142 -0
  57. package/src/scenarios/test-netflix/data/movies.json +3497 -0
  58. package/src/scenarios/test-netflix/data/people.json +1 -0
  59. package/src/scenarios/test-netflix/data/synonyms.json +8 -0
  60. package/src/scenarios/test-netflix/data/users.json +70 -0
  61. package/src/scenarios/test-netflix/graph.json +1017 -0
  62. package/src/scenarios/test-netflix/queries.ts +159 -0
  63. package/src/scenarios/test-netflix/stack.json +14 -0
  64. package/src/schema/GraphBuilder.ts +106 -0
  65. package/src/schema/JsonSchemaExtractor.ts +107 -0
  66. package/src/schema/SchemaAnalyzer.ts +175 -0
  67. package/src/schema/SchemaExtractor.ts +102 -0
  68. package/src/schema/SynonymResolver.ts +143 -0
  69. package/src/scripts/dictionary.json +796 -0
  70. package/src/scripts/graph.json +664 -0
  71. package/src/scripts/regenerate.ts +248 -0
  72. package/src/types/index.ts +506 -0
@@ -0,0 +1,412 @@
1
+ /**
2
+ * GraphCompiler — v2.0.0
3
+ *
4
+ * Changements vs v1 :
5
+ * - Routes sémantiques (semantic_view) compilées et incluses
6
+ * - compiled-graph contient physical + semantic routes
7
+ * - version bump : '2.0.0'
8
+ *
9
+ * v2.1 :
10
+ * - Support expose config (ADR-0010)
11
+ * - node.exposed compilé depuis CompilerConfig.expose
12
+ */
13
+
14
+ import type { Graph, CompiledGraph, CompilerConfig, RouteInfo, MetricsMap, GraphNode, ExposeConfig } from '../types/index.js'
15
+ import { PathFinder } from '../core/PathFinder.js'
16
+
17
+ export interface EdgeMetadata {
18
+ fromCol: string
19
+ toCol: string
20
+ condition?: Record<string, unknown>
21
+ label?: string
22
+ }
23
+
24
+ export class GraphCompiler {
25
+ private config: Required<Omit<CompilerConfig, 'expose'>> & { expose: ExposeConfig }
26
+
27
+ constructor(config: Partial<CompilerConfig> = {}) {
28
+ this.config = {
29
+ weightThreshold: config.weightThreshold ?? 1000,
30
+ minUsage: config.minUsage ?? 0,
31
+ keepFallbacks: config.keepFallbacks ?? true,
32
+ maxFallbacks: config.maxFallbacks ?? 2,
33
+ expose: config.expose ?? 'none'
34
+ }
35
+ }
36
+
37
+ compile(graph: Graph, metrics: MetricsMap): CompiledGraph {
38
+ console.log('🔧 Compiling optimized graph (v2 — physical + semantic)...\n')
39
+
40
+ const compiled: CompiledGraph = {
41
+ version: '2.0.0',
42
+ compiledAt: new Date().toISOString(),
43
+ config: this.config,
44
+ nodes: this.compileNodes(graph.nodes, this.config.expose),
45
+ routes: [],
46
+ stats: { totalPairs: 0, routesCompiled: 0, routesFiltered: 0, compressionRatio: '0%' }
47
+ }
48
+
49
+ // ── Nœuds réels (depuis les edges) ───────────────────────────────────────
50
+ const realNodes = new Set<string>()
51
+ graph.edges.forEach(e => {
52
+ realNodes.add(e.from)
53
+ realNodes.add(e.to)
54
+ })
55
+ const nodeIds = Array.from(realNodes)
56
+
57
+ // ── 1. Routes physiques ───────────────────────────────────────────────────
58
+ const fkEdges = graph.edges.filter(
59
+ (e: any) =>
60
+ e.metadata?.type !== 'semantic_view' &&
61
+ e.metadata?.type !== 'virtual' &&
62
+ e.metadata?.type !== 'SEMANTIC'
63
+ )
64
+ const existingPairs = new Set(fkEdges.map((e: any) => `${e.from}→${e.to}`))
65
+ const inverseEdges = fkEdges
66
+ .filter((e: any) => !existingPairs.has(`${e.to}→${e.from}`))
67
+ .map((e: any) => ({
68
+ ...e,
69
+ from: e.to,
70
+ to: e.from,
71
+ name: `${e.name}_inv`,
72
+ metadata: { ...e.metadata, type: 'physical_reverse' }
73
+ }))
74
+ const physicalGraph = { ...graph, edges: [...fkEdges, ...inverseEdges] }
75
+
76
+ let kept = 0,
77
+ filtered = 0
78
+ const pairs = this.getAllPairs(nodeIds)
79
+
80
+ for (const { from, to } of pairs) {
81
+ const route = this.compilePath(from, to, physicalGraph, metrics)
82
+ if (route) {
83
+ compiled.routes.push(route as any)
84
+ kept++
85
+ } else {
86
+ filtered++
87
+ }
88
+ }
89
+
90
+ // ── 2. Routes sémantiques ─────────────────────────────────────────────────
91
+ const semanticEdges = graph.edges.filter(
92
+ (e: any) => e.metadata?.type === 'semantic_view' && e.metadata?.condition != null
93
+ )
94
+
95
+ let semanticKept = 0
96
+ for (const edge of semanticEdges as any[]) {
97
+ const route = this.compileSemanticRoute(edge, graph)
98
+ if (route) {
99
+ compiled.routes.push(route as any)
100
+ semanticKept++
101
+ }
102
+ }
103
+
104
+ // ── 2b. Routes virtuelles ─────────────────────────────────────────────────
105
+ const virtualEdges = graph.edges.filter((e: any) => e.metadata?.type === 'virtual')
106
+
107
+ let virtualKept = 0
108
+ for (const edge of virtualEdges as any[]) {
109
+ const { from, to, via, name, weight } = edge as any
110
+ if (!nodeIds.includes(from) || !nodeIds.includes(to)) continue
111
+
112
+ const viaTable = via && nodeIds.includes(via) ? via : null
113
+ const path = viaTable ? [from, viaTable, to] : [from, to]
114
+ const edges = viaTable
115
+ ? [
116
+ { fromCol: 'id', toCol: 'id' },
117
+ { fromCol: 'id', toCol: 'id' }
118
+ ]
119
+ : [{ fromCol: 'id', toCol: 'id' }]
120
+
121
+ compiled.routes.push({
122
+ from,
123
+ to,
124
+ semantic: false,
125
+ composed: false,
126
+ label: name ?? `virtual_${from}_${to}`,
127
+ virtual: true,
128
+ primary: {
129
+ path,
130
+ edges,
131
+ weight: weight ?? 1,
132
+ joins: path.length - 1,
133
+ avgTime: weight ?? 1
134
+ },
135
+ fallbacks: [],
136
+ alternativesDiscarded: 0
137
+ } as any)
138
+ virtualKept++
139
+ }
140
+
141
+ // ── 3. Routes composées ───────────────────────────────────────────────────
142
+ const compiledSemRoutes = compiled.routes.filter((r: any) => r.semantic) as any[]
143
+ let composedKept = 0
144
+
145
+ const semByFrom = new Map<string, any[]>()
146
+ for (const r of compiledSemRoutes) {
147
+ if (!semByFrom.has(r.from)) semByFrom.set(r.from, [])
148
+ semByFrom.get(r.from)!.push(r)
149
+ }
150
+
151
+ for (const [entityId, outRoutes] of semByFrom) {
152
+ const inRoutes = compiledSemRoutes.filter((r: any) => r.to === entityId)
153
+ if (!inRoutes.length) continue
154
+
155
+ for (const rOut of outRoutes) {
156
+ const pivot = rOut.to
157
+ const matchingIn = inRoutes.filter((r: any) => r.from === pivot)
158
+
159
+ for (const rIn of matchingIn) {
160
+ if (rOut.label === rIn.label) continue
161
+
162
+ const composedLabel = `${rOut.label}→${rIn.label}`
163
+ const composedWeight = rOut.primary.weight + rIn.primary.weight
164
+ const composedPath = [...rOut.primary.path, ...rIn.primary.path.slice(1)]
165
+ const composedEdges = [...rOut.primary.edges, ...rIn.primary.edges]
166
+
167
+ const metricKey = `composed:${entityId}→${entityId}:${composedLabel}`
168
+ const metric = metrics.get(metricKey)
169
+ if (metrics.size > 0) {
170
+ if (!metric) continue
171
+ const w = metric.avgTime ?? composedWeight
172
+ if (!metric.used || w > this.config.weightThreshold) continue
173
+ }
174
+
175
+ compiled.routes.push({
176
+ from: entityId,
177
+ to: entityId,
178
+ semantic: true,
179
+ composed: true,
180
+ label: composedLabel,
181
+ primary: {
182
+ path: composedPath,
183
+ edges: composedEdges,
184
+ weight: composedWeight,
185
+ joins: composedPath.length - 1,
186
+ avgTime: composedWeight
187
+ },
188
+ fallbacks: [],
189
+ alternativesDiscarded: 0
190
+ } as any)
191
+ composedKept++
192
+ }
193
+ }
194
+ }
195
+
196
+ compiled.stats = {
197
+ totalPairs: pairs.length,
198
+ routesCompiled: kept + semanticKept + virtualKept + composedKept,
199
+ routesFiltered: filtered,
200
+ compressionRatio: '—'
201
+ }
202
+
203
+ console.log('\n✅ Compilation complete:')
204
+ console.log(` Physical routes: ${kept}`)
205
+ console.log(` Semantic routes: ${semanticKept}`)
206
+ console.log(` Composed routes: ${composedKept}`)
207
+ console.log(` Filtered: ${filtered}`)
208
+
209
+ return compiled
210
+ }
211
+
212
+ // ── Compile exposed flag sur chaque node ────────────────────────────────────
213
+
214
+ private compileNodes(nodes: GraphNode[], expose: ExposeConfig): GraphNode[] {
215
+ return nodes.map(node => {
216
+ let exposed: boolean
217
+
218
+ if (expose === 'all') {
219
+ exposed = true
220
+ } else if (expose === 'none') {
221
+ exposed = false
222
+ } else if ('include' in expose) {
223
+ exposed = expose.include.includes(node.id)
224
+ } else {
225
+ exposed = !expose.exclude.includes(node.id)
226
+ }
227
+
228
+ return { ...node, exposed }
229
+ })
230
+ }
231
+
232
+ // ── Route sémantique ─────────────────────────────────────────────────────────
233
+
234
+ private compileSemanticRoute(edge: any, graph: Graph): any | null {
235
+ const { from, to, via, metadata } = edge
236
+ const condition: Record<string, unknown> = metadata.condition ?? {}
237
+ const label: string = edge.name ?? metadata.label ?? 'view'
238
+
239
+ const e1Raw = graph.edges.find(
240
+ (e: any) =>
241
+ ((e.from === from && e.to === via) || (e.from === via && e.to === from)) &&
242
+ (e.metadata?.type === 'physical' || e.metadata?.type === 'physical_reverse')
243
+ )
244
+ const e2Raw = graph.edges.find(
245
+ (e: any) =>
246
+ ((e.from === via && e.to === to) || (e.from === to && e.to === via)) &&
247
+ (e.metadata?.type === 'physical' || e.metadata?.type === 'physical_reverse')
248
+ )
249
+
250
+ if (!e1Raw || !e2Raw) return null
251
+
252
+ const e1IsReversed = e1Raw.from === via
253
+ const e1: EdgeMetadata = {
254
+ fromCol: e1IsReversed ? 'id' : (e1Raw.via ?? 'id'),
255
+ toCol: e1IsReversed ? (e1Raw.via ?? 'id') : 'id',
256
+ condition,
257
+ label
258
+ }
259
+
260
+ const e2IsReversed = e2Raw.from === to
261
+ const e2: EdgeMetadata = {
262
+ fromCol: e2IsReversed ? 'id' : (e2Raw.via ?? 'id'),
263
+ toCol: e2IsReversed ? (e2Raw.via ?? 'id') : 'id'
264
+ }
265
+
266
+ return {
267
+ from,
268
+ to,
269
+ semantic: true,
270
+ label,
271
+ primary: {
272
+ path: [from, via, to],
273
+ edges: [e1, e2],
274
+ weight: edge.weight ?? 0.8,
275
+ joins: 2,
276
+ avgTime: edge.weight ?? 0.8
277
+ },
278
+ fallbacks: [],
279
+ alternativesDiscarded: 0
280
+ }
281
+ }
282
+
283
+ // ── Routes physiques ──────────────────────────────────────────────────────────
284
+
285
+ private getAllPairs(nodeIds: string[]): Array<{ from: string; to: string }> {
286
+ const pairs: Array<{ from: string; to: string }> = []
287
+ for (const from of nodeIds) for (const to of nodeIds) if (from !== to) pairs.push({ from, to })
288
+ return pairs
289
+ }
290
+
291
+ private compilePath(from: string, to: string, graph: Graph, metrics: MetricsMap): any | null {
292
+ const finder = new PathFinder(graph)
293
+ const allPaths = finder.findAllPaths(from, to, 5)
294
+ if (!allPaths.length) return null
295
+
296
+ const pathsWithMetrics = allPaths.map(path => {
297
+ const key = path.join('→')
298
+ const metric = metrics.get(key)
299
+ let w = 0
300
+ for (let i = 0; i < path.length - 1; i++) {
301
+ const ee = graph.edges.filter(
302
+ e =>
303
+ (e.from === path[i] && e.to === path[i + 1]) ||
304
+ (e.from === path[i + 1] && e.to === path[i])
305
+ )
306
+ const ws = ee.map(e => Number(e.weight)).filter(x => !isNaN(x))
307
+ w += ws.length ? Math.min(...ws) : 1
308
+ }
309
+ const finalWeight = metric && !isNaN(metric.avgTime) ? metric.avgTime : w
310
+ return {
311
+ path,
312
+ key,
313
+ weight: finalWeight,
314
+ failed: metric?.failed === true,
315
+ used: metric ? metric.used : true
316
+ }
317
+ })
318
+
319
+ const valid = pathsWithMetrics
320
+ .filter(p => !p.failed && p.used !== false)
321
+ .filter(p => !isNaN(p.weight) && p.weight <= this.config.weightThreshold)
322
+ .sort((a, b) => a.weight - b.weight)
323
+
324
+ if (!valid.length) return null
325
+
326
+ const unique: typeof valid = []
327
+ const seen = new Set<string>()
328
+ for (const p of valid) {
329
+ if (!seen.has(p.key)) {
330
+ unique.push(p)
331
+ seen.add(p.key)
332
+ }
333
+ }
334
+
335
+ const best = unique[0]
336
+ const fallbacks = this.config.keepFallbacks ? unique.slice(1, this.config.maxFallbacks + 1) : []
337
+
338
+ const primaryEdges = this.resolveEdges(best.path, graph)
339
+ if (!primaryEdges) return null
340
+
341
+ return {
342
+ from,
343
+ to,
344
+ primary: {
345
+ path: best.path,
346
+ edges: primaryEdges,
347
+ weight: best.weight,
348
+ joins: best.path.length - 1,
349
+ avgTime: best.weight
350
+ },
351
+ fallbacks: fallbacks
352
+ .map(fb => {
353
+ const ee = this.resolveEdges(fb.path, graph)
354
+ if (!ee) return null
355
+ return {
356
+ path: fb.path,
357
+ edges: ee,
358
+ weight: fb.weight,
359
+ joins: fb.path.length - 1,
360
+ avgTime: fb.weight
361
+ }
362
+ })
363
+ .filter((fb): fb is NonNullable<typeof fb> => fb !== null),
364
+ alternativesDiscarded: unique.length - 1 - fallbacks.length
365
+ }
366
+ }
367
+
368
+ private resolveEdges(path: string[], graph: any): EdgeMetadata[] | null {
369
+ const result: EdgeMetadata[] = []
370
+ const nodeNames = new Set(graph.nodes.map((n: any) => n.id))
371
+
372
+ for (let i = 0; i < path.length - 1; i++) {
373
+ const from = path[i],
374
+ to = path[i + 1]
375
+ const edgeDirect = graph.edges.find((e: any) => e.from === from && e.to === to)
376
+ const edgeReverse = graph.edges.find((e: any) => e.from === to && e.to === from)
377
+ const edge = edgeDirect ?? edgeReverse
378
+ const isReversed = !edgeDirect && !!edgeReverse
379
+
380
+ if (!edge) {
381
+ result.push({ fromCol: 'id', toCol: `${from.toLowerCase()}Id` })
382
+ continue
383
+ }
384
+
385
+ if (edge.metadata?.type === 'semantic_view' && nodeNames.has(edge.via)) return null
386
+
387
+ const flipCols = edge.metadata?.type === 'physical_reverse' || isReversed
388
+ result.push({
389
+ fromCol: flipCols ? 'id' : edge.via || 'id',
390
+ toCol: flipCols ? edge.via || 'id' : 'id'
391
+ })
392
+ }
393
+ return result
394
+ }
395
+
396
+ static getStats(compiled: CompiledGraph) {
397
+ const routes = compiled.routes as any[]
398
+ const semantic = routes.filter(r => r.semantic && !r.composed).length
399
+ const composed = routes.filter(r => r.composed).length
400
+ const physical = routes.length - semantic - composed
401
+ if (!routes.length)
402
+ return { totalRoutes: 0, fallbackRatio: '0%', semantic: 0, physical: 0, composed: 0 }
403
+ const withFallbacks = routes.filter(r => r.fallbacks.length > 0).length
404
+ return {
405
+ totalRoutes: routes.length,
406
+ physical,
407
+ semantic,
408
+ composed,
409
+ fallbackRatio: ((withFallbacks / routes.length) * 100).toFixed(1) + '%'
410
+ }
411
+ }
412
+ }
@@ -0,0 +1,191 @@
1
+ import fs from 'fs'
2
+ import type {
3
+ Graph,
4
+ GraphNode,
5
+ GraphEdge,
6
+ Provider,
7
+ Column,
8
+ ActionRegistry
9
+ } from '../types/index.js'
10
+
11
+ interface TableInfo {
12
+ name: string
13
+ columns: Column[]
14
+ rowCount: number
15
+ description?: string
16
+ }
17
+
18
+ interface ForeignKeyInfo {
19
+ fromTable: string
20
+ toTable: string
21
+ column: string
22
+ }
23
+
24
+ export class GraphExtractor {
25
+ private provider: Provider
26
+ private actionRegistry?: ActionRegistry
27
+
28
+ constructor(provider: Provider, actionRegistry?: ActionRegistry) {
29
+ this.provider = provider
30
+ this.actionRegistry = actionRegistry
31
+ }
32
+
33
+ /**
34
+ * Extrait le graphe complet : Tables + Actions + Relations
35
+ */
36
+ async extract(): Promise<Graph> {
37
+ console.log('📊 LinkLab : Extraction du graphe sémantique...')
38
+
39
+ // 1. Extraction des tables et leurs métadonnées
40
+ const tables = await this.getTables()
41
+ console.log(` Found ${tables.length} tables`)
42
+
43
+ // 2. Extraction des clés étrangères (Relations natives)
44
+ const foreignKeys = await this.getForeignKeys()
45
+ console.log(` Found ${foreignKeys.length} foreign keys`)
46
+
47
+ // 3. Construction des Nœuds (Tables)
48
+ const nodes: GraphNode[] = tables.map(t => ({
49
+ id: t.name,
50
+ type: 'table' as const,
51
+ columns: t.columns,
52
+ rowCount: t.rowCount,
53
+ description: t.description || ''
54
+ }))
55
+
56
+ // 4. Injection des Nœuds (Actions) - Si présentes dans le registre
57
+ if (this.actionRegistry) {
58
+ const actions = this.actionRegistry.getAll()
59
+ actions.forEach(action => {
60
+ nodes.push({
61
+ id: action.id,
62
+ type: 'action' as const,
63
+ description: action.description || 'Action système',
64
+ params: action.requiredParams
65
+ })
66
+ })
67
+ }
68
+
69
+ // 5. Construction des Edges (Liaisons)
70
+ const edges: GraphEdge[] = foreignKeys.map(fk => ({
71
+ name: `rel_${fk.fromTable}_${fk.toTable}`,
72
+ from: fk.fromTable,
73
+ to: fk.toTable,
74
+ via: fk.column,
75
+ type: 'foreign_key' as const,
76
+ weight: this.calculateInitialWeight(fk, tables)
77
+ }))
78
+
79
+ const graph: Graph = { nodes, edges }
80
+
81
+ console.log('✅ Graphe LinkLab extrait avec succès')
82
+ fs.writeFileSync('./graph.json', JSON.stringify(graph, null, 2))
83
+
84
+ return graph
85
+ }
86
+
87
+ private async getTables(): Promise<TableInfo[]> {
88
+ // On récupère aussi la description de la table (COMMENT ON TABLE)
89
+ const query = `
90
+ SELECT
91
+ t.table_name as name,
92
+ obj_description(pgc.oid, 'pg_class') as description
93
+ FROM information_schema.tables t
94
+ JOIN pg_class pgc ON t.table_name = pgc.relname
95
+ JOIN pg_namespace pgn ON pgc.relnamespace = pgn.oid
96
+ WHERE t.table_schema = 'public'
97
+ AND t.table_type = 'BASE TABLE'
98
+ AND pgn.nspname = 'public'
99
+ `
100
+
101
+ const result = await this.provider.query<{ name: string; description: string }>(query)
102
+ const tables: TableInfo[] = []
103
+
104
+ for (const table of result) {
105
+ const columns = await this.getColumns(table.name)
106
+ const rowCount = await this.getRowCount(table.name)
107
+
108
+ tables.push({
109
+ name: table.name,
110
+ columns,
111
+ rowCount,
112
+ description: table.description
113
+ })
114
+ }
115
+
116
+ return tables
117
+ }
118
+
119
+ private async getColumns(tableName: string): Promise<Column[]> {
120
+ // Récupère les colonnes ET leurs descriptions (COMMENT ON COLUMN)
121
+ const query = `
122
+ SELECT
123
+ cols.column_name,
124
+ cols.data_type,
125
+ (SELECT pg_catalog.col_description(c.oid, cols.ordinal_position::int)
126
+ FROM pg_catalog.pg_class c
127
+ WHERE c.relname = cols.table_name) as description
128
+ FROM information_schema.columns cols
129
+ WHERE table_name = $1
130
+ `
131
+
132
+ const result = await this.provider.query<{
133
+ column_name: string
134
+ data_type: string
135
+ description: string
136
+ }>(query, [tableName])
137
+
138
+ return result.map(c => ({
139
+ name: c.column_name,
140
+ type: c.data_type,
141
+ description: c.description || ''
142
+ }))
143
+ }
144
+
145
+ private async getRowCount(tableName: string): Promise<number> {
146
+ try {
147
+ const query = `SELECT reltuples::bigint as count FROM pg_class WHERE relname = $1`
148
+ const result = await this.provider.query<{ count: string }>(query, [tableName])
149
+ return parseInt(result[0]?.count || '0', 10)
150
+ } catch {
151
+ return 0
152
+ }
153
+ }
154
+
155
+ private async getForeignKeys(): Promise<any[]> {
156
+ const query = `
157
+ SELECT
158
+ tc.table_name as from_table,
159
+ kcu.column_name as column,
160
+ ccu.table_name as to_table,
161
+ -- Vérifie si la colonne est unique ou PK pour la cardinalité
162
+ (SELECT COUNT(*)
163
+ FROM information_schema.table_constraints i_tc
164
+ JOIN information_schema.key_column_usage i_kcu
165
+ ON i_tc.constraint_name = i_kcu.constraint_name
166
+ WHERE i_tc.table_name = tc.table_name
167
+ AND i_kcu.column_name = kcu.column_name
168
+ AND (i_tc.constraint_type = 'PRIMARY KEY' OR i_tc.constraint_type = 'UNIQUE')
169
+ ) > 0 as is_unique
170
+ FROM information_schema.table_constraints tc
171
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
172
+ JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
173
+ WHERE tc.constraint_type = 'FOREIGN KEY'
174
+ `
175
+
176
+ return await this.provider.query(query)
177
+ }
178
+
179
+ /**
180
+ * Calcul du poids initial (Physique de la donnée)
181
+ * On utilise le logarithme de la taille pour ne pas pénaliser trop lourdement
182
+ * les grosses tables, mais garder une notion de "frais de déplacement".
183
+ */
184
+ private calculateInitialWeight(fk: ForeignKeyInfo, tables: TableInfo[]): number {
185
+ const targetTable = tables.find(t => t.name === fk.toTable)
186
+ if (!targetTable || targetTable.rowCount <= 0) return 1
187
+
188
+ // Formule : 1 + log10(n) -> 100 lignes = poids 3, 1M lignes = poids 7.
189
+ return 1 + Math.log10(targetTable.rowCount)
190
+ }
191
+ }