@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.
- package/README.md +411 -0
- package/package.json +48 -0
- package/src/api/DomainNode.ts +1433 -0
- package/src/api/Graph.ts +271 -0
- package/src/api/PathBuilder.ts +247 -0
- package/src/api/index.ts +15 -0
- package/src/api/loadGraph.ts +207 -0
- package/src/api/test-api.ts +153 -0
- package/src/api/test-domain.ts +119 -0
- package/src/api/types.ts +88 -0
- package/src/config/synonyms.json +28 -0
- package/src/core/EventBus.ts +187 -0
- package/src/core/GraphEvents.ts +153 -0
- package/src/core/PathFinder.ts +283 -0
- package/src/formatters/BaseFormatter.ts +17 -0
- package/src/graph/GraphAssembler.ts +50 -0
- package/src/graph/GraphCompiler.ts +412 -0
- package/src/graph/GraphExtractor.ts +191 -0
- package/src/graph/GraphOptimizer.ts +404 -0
- package/src/graph/GraphTrainer.ts +247 -0
- package/src/http/LinkBuilder.ts +244 -0
- package/src/http/TrailRequest.ts +48 -0
- package/src/http/example-netflix.ts +59 -0
- package/src/http/hateoas/README.md +87 -0
- package/src/http/index.ts +33 -0
- package/src/http/plugin.ts +360 -0
- package/src/index.ts +121 -0
- package/src/instrumentation/TelemetryShim.ts +172 -0
- package/src/navigation/NavigationEngine.ts +441 -0
- package/src/navigation/Resolver.ts +134 -0
- package/src/navigation/Scheduler.ts +136 -0
- package/src/navigation/Trail.ts +252 -0
- package/src/navigation/TrailParser.ts +207 -0
- package/src/navigation/index.ts +11 -0
- package/src/providers/MockProvider.ts +68 -0
- package/src/providers/PostgresProvider.ts +187 -0
- package/src/runtime/CompiledGraphEngine.ts +274 -0
- package/src/runtime/DataLoader.ts +236 -0
- package/src/runtime/Engine.ts +163 -0
- package/src/runtime/QueryEngine.ts +222 -0
- package/src/scenarios/test-metro-paris/config.json +6 -0
- package/src/scenarios/test-metro-paris/graph.json +16325 -0
- package/src/scenarios/test-metro-paris/queries.ts +152 -0
- package/src/scenarios/test-metro-paris/stack.json +1 -0
- package/src/scenarios/test-musicians/config.json +10 -0
- package/src/scenarios/test-musicians/graph.json +20 -0
- package/src/scenarios/test-musicians/stack.json +1 -0
- package/src/scenarios/test-netflix/MIGRATION.md +23 -0
- package/src/scenarios/test-netflix/README.md +138 -0
- package/src/scenarios/test-netflix/actions.ts +92 -0
- package/src/scenarios/test-netflix/config.json +6 -0
- package/src/scenarios/test-netflix/data/categories.json +1 -0
- package/src/scenarios/test-netflix/data/companies.json +1 -0
- package/src/scenarios/test-netflix/data/credits.json +19797 -0
- package/src/scenarios/test-netflix/data/departments.json +18 -0
- package/src/scenarios/test-netflix/data/jobs.json +142 -0
- package/src/scenarios/test-netflix/data/movies.json +3497 -0
- package/src/scenarios/test-netflix/data/people.json +1 -0
- package/src/scenarios/test-netflix/data/synonyms.json +8 -0
- package/src/scenarios/test-netflix/data/users.json +70 -0
- package/src/scenarios/test-netflix/graph.json +1017 -0
- package/src/scenarios/test-netflix/queries.ts +159 -0
- package/src/scenarios/test-netflix/stack.json +14 -0
- package/src/schema/GraphBuilder.ts +106 -0
- package/src/schema/JsonSchemaExtractor.ts +107 -0
- package/src/schema/SchemaAnalyzer.ts +175 -0
- package/src/schema/SchemaExtractor.ts +102 -0
- package/src/schema/SynonymResolver.ts +143 -0
- package/src/scripts/dictionary.json +796 -0
- package/src/scripts/graph.json +664 -0
- package/src/scripts/regenerate.ts +248 -0
- package/src/types/index.ts +506 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgresProvider - PostgreSQL database provider with JSON fallback
|
|
3
|
+
*
|
|
4
|
+
* Dual-mode: real PostgreSQL or mock JSON files
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Provider, ProviderConfig } from '../types/index.js'
|
|
8
|
+
import { ProviderError } from '../types/index.js'
|
|
9
|
+
import * as fs from 'fs'
|
|
10
|
+
import * as path from 'path'
|
|
11
|
+
import { Pool } from 'pg'
|
|
12
|
+
|
|
13
|
+
interface PostgresConfig extends ProviderConfig {
|
|
14
|
+
host?: string
|
|
15
|
+
port?: number
|
|
16
|
+
user?: string
|
|
17
|
+
password?: string
|
|
18
|
+
connectionString?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PostgresProvider implements Provider {
|
|
22
|
+
private useMock: boolean
|
|
23
|
+
private pool: any | null = null
|
|
24
|
+
private dbPath: string
|
|
25
|
+
private tables: Map<string, any[]>
|
|
26
|
+
|
|
27
|
+
constructor(config: PostgresConfig) {
|
|
28
|
+
this.useMock = config.mock ?? false
|
|
29
|
+
console.log(' useMock: ', this.useMock)
|
|
30
|
+
this.dbPath = config.database ? `./db/postgres/${config.database}` : './db/postgres'
|
|
31
|
+
this.tables = new Map()
|
|
32
|
+
|
|
33
|
+
if (!this.useMock) {
|
|
34
|
+
try {
|
|
35
|
+
// Try to load pg module
|
|
36
|
+
// const { Pool } = require('pg')
|
|
37
|
+
|
|
38
|
+
this.pool = new Pool({
|
|
39
|
+
host: config.host ?? 'localhost',
|
|
40
|
+
port: config.port ?? 5432,
|
|
41
|
+
database: config.database,
|
|
42
|
+
user: config.user ?? 'postgres',
|
|
43
|
+
password: config.password,
|
|
44
|
+
connectionString: config.connectionString
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
console.log(`🐘 Postgres connected: ${config.database}`)
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn('⚠️ pg module not found, falling back to mock mode')
|
|
50
|
+
this.useMock = true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.useMock) {
|
|
55
|
+
console.log(`🐘 Postgres connected (MOCK mode): ${config.database}`)
|
|
56
|
+
this.loadTables()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load tables from JSON files (mock mode)
|
|
62
|
+
*/
|
|
63
|
+
private loadTables(): void {
|
|
64
|
+
if (!fs.existsSync(this.dbPath)) {
|
|
65
|
+
this.tables = new Map()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const files = fs.readdirSync(this.dbPath)
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (file.endsWith('.json')) {
|
|
73
|
+
const tableName = file.replace('.json', '')
|
|
74
|
+
const content = fs.readFileSync(path.join(this.dbPath, file), 'utf-8')
|
|
75
|
+
this.tables.set(tableName, JSON.parse(content))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Execute query
|
|
82
|
+
*/
|
|
83
|
+
async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
|
84
|
+
if (this.useMock) {
|
|
85
|
+
return this.queryMock<T>(sql, params)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!this.pool) {
|
|
89
|
+
throw new ProviderError('PostgreSQL pool not initialized')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
console.log('🐘 Postgres:', sql)
|
|
94
|
+
if (params.length > 0) {
|
|
95
|
+
console.log(' Params:', params)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = await this.pool.query(sql, params)
|
|
99
|
+
|
|
100
|
+
console.log(`✅ Postgres result: ${result.rows.length} rows`)
|
|
101
|
+
|
|
102
|
+
return result.rows as T[]
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new ProviderError(`PostgreSQL query failed: ${(err as Error).message}`, {
|
|
105
|
+
sql,
|
|
106
|
+
params,
|
|
107
|
+
error: err
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Execute query in mock mode
|
|
114
|
+
*/
|
|
115
|
+
private async queryMock<T = any>(sql: string, params: any[]): Promise<T[]> {
|
|
116
|
+
console.log('🐘 Postgres:', sql)
|
|
117
|
+
if (params.length > 0) {
|
|
118
|
+
console.log(' Params:', params)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Parse SQL (simplified)
|
|
122
|
+
const selectMatch = sql.match(/SELECT .* FROM (\w+)/i)
|
|
123
|
+
if (!selectMatch) {
|
|
124
|
+
console.log('✅ Postgres result (MOCK): 0 rows')
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tableName = selectMatch[1]
|
|
129
|
+
const data = this.tables.get(tableName) || []
|
|
130
|
+
|
|
131
|
+
// Apply WHERE clause (simplified)
|
|
132
|
+
let filtered = data
|
|
133
|
+
|
|
134
|
+
const whereMatch = sql.match(/WHERE (\w+)\.(\w+) = \$(\d+)/i)
|
|
135
|
+
if (whereMatch && params.length > 0) {
|
|
136
|
+
const column = whereMatch[2]
|
|
137
|
+
const paramIndex = parseInt(whereMatch[3]) - 1
|
|
138
|
+
const value = params[paramIndex]
|
|
139
|
+
|
|
140
|
+
filtered = data.filter((row: any) => row[column] === value)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log(`✅ Postgres result (MOCK): ${filtered.length} rows`)
|
|
144
|
+
return filtered as T[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Close connection
|
|
149
|
+
*/
|
|
150
|
+
async close(): Promise<void> {
|
|
151
|
+
if (this.pool) {
|
|
152
|
+
await this.pool.end()
|
|
153
|
+
console.log('🐘 Postgres closed')
|
|
154
|
+
} else {
|
|
155
|
+
console.log('🐘 Postgres closed')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Save data (mock mode only)
|
|
161
|
+
*/
|
|
162
|
+
async save(tableName: string, data: any[]): Promise<void> {
|
|
163
|
+
if (!this.useMock) {
|
|
164
|
+
throw new ProviderError('Save only available in mock mode')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!fs.existsSync(this.dbPath)) {
|
|
168
|
+
fs.mkdirSync(this.dbPath, { recursive: true })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const filepath = path.join(this.dbPath, `${tableName}.json`)
|
|
172
|
+
fs.writeFileSync(filepath, JSON.stringify(data, null, 2))
|
|
173
|
+
|
|
174
|
+
this.tables.set(tableName, data)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get table data (mock mode only)
|
|
179
|
+
*/
|
|
180
|
+
getTable(tableName: string): any[] | undefined {
|
|
181
|
+
if (!this.useMock) {
|
|
182
|
+
throw new ProviderError('getTable only available in mock mode')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return this.tables.get(tableName)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompiledGraphEngine - Production engine using precompiled graph
|
|
3
|
+
*
|
|
4
|
+
* - O(1) route lookup
|
|
5
|
+
* - Automatic fallback
|
|
6
|
+
* - Live metrics
|
|
7
|
+
* - Hot reload support
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CompiledGraph, RouteInfo, PathMetrics, Provider } from '../types/index.js'
|
|
11
|
+
|
|
12
|
+
interface LiveMetrics {
|
|
13
|
+
path: string[]
|
|
14
|
+
executions: number
|
|
15
|
+
successes: number
|
|
16
|
+
failures: number
|
|
17
|
+
totalTime: number
|
|
18
|
+
avgTime: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CompiledGraphEngine {
|
|
22
|
+
private compiled: CompiledGraph
|
|
23
|
+
private provider: Provider
|
|
24
|
+
private liveMetrics: Map<string, LiveMetrics>
|
|
25
|
+
private routeCache: Map<string, RouteInfo>
|
|
26
|
+
|
|
27
|
+
constructor(compiled: CompiledGraph, provider: Provider) {
|
|
28
|
+
this.compiled = compiled
|
|
29
|
+
this.provider = provider
|
|
30
|
+
this.liveMetrics = new Map()
|
|
31
|
+
this.routeCache = this.buildRouteCache(compiled)
|
|
32
|
+
|
|
33
|
+
console.log('🚀 Compiled Graph Engine initialized')
|
|
34
|
+
console.log(` Routes loaded: ${compiled.routes.length}`)
|
|
35
|
+
console.log(` Nodes: ${compiled.nodes.length}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build fast lookup cache
|
|
40
|
+
*/
|
|
41
|
+
private buildRouteCache(compiled: CompiledGraph): Map<string, RouteInfo> {
|
|
42
|
+
const cache = new Map<string, RouteInfo>()
|
|
43
|
+
|
|
44
|
+
for (const route of compiled.routes) {
|
|
45
|
+
const key = `${route.from}→${route.to}`
|
|
46
|
+
cache.set(key, route)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cache
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Execute query from -> to
|
|
54
|
+
*/
|
|
55
|
+
async query(from: string, to: string, data: Record<string, any> = {}): Promise<any[]> {
|
|
56
|
+
const key = `${from}→${to}`
|
|
57
|
+
|
|
58
|
+
// O(1) lookup!
|
|
59
|
+
const route = this.routeCache.get(key)
|
|
60
|
+
|
|
61
|
+
if (!route) {
|
|
62
|
+
throw new Error(`No compiled route from ${from} to ${to}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Try primary path
|
|
66
|
+
try {
|
|
67
|
+
const start = performance.now()
|
|
68
|
+
const result = await this.executePath(route.primary.path, data)
|
|
69
|
+
const duration = performance.now() - start
|
|
70
|
+
|
|
71
|
+
// Update metrics
|
|
72
|
+
this.updateMetrics(route.primary.path, duration, true)
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn(`⚠️ Primary path failed: ${(err as Error).message}`)
|
|
77
|
+
|
|
78
|
+
// Try fallbacks
|
|
79
|
+
return await this.fallback(route, data)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute path with JOINs
|
|
85
|
+
*/
|
|
86
|
+
private async executePath(path: string[], data: Record<string, any>): Promise<any[]> {
|
|
87
|
+
// Build SQL with JOINs
|
|
88
|
+
let sql = `SELECT * FROM ${path[0]}`
|
|
89
|
+
const params: any[] = []
|
|
90
|
+
|
|
91
|
+
// Add JOINs
|
|
92
|
+
for (let i = 1; i < path.length; i++) {
|
|
93
|
+
const from = path[i - 1]
|
|
94
|
+
const to = path[i]
|
|
95
|
+
|
|
96
|
+
// Find edge in compiled graph
|
|
97
|
+
const edge = this.findEdge(from, to)
|
|
98
|
+
|
|
99
|
+
if (edge) {
|
|
100
|
+
sql += ` JOIN ${to} ON ${from}.${edge.via} = ${to}.id`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Add WHERE if ID provided
|
|
105
|
+
if (data.id) {
|
|
106
|
+
sql += ` WHERE ${path[0]}.id = $${params.length + 1}`
|
|
107
|
+
params.push(data.id)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Execute
|
|
111
|
+
return await this.provider.query(sql, params)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find edge between nodes
|
|
116
|
+
*/
|
|
117
|
+
private findEdge(from: string, to: string): { via: string } | null {
|
|
118
|
+
// Simplified - would look in compiled graph edges
|
|
119
|
+
return { via: 'id' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fallback to alternative paths
|
|
124
|
+
*/
|
|
125
|
+
private async fallback(route: RouteInfo, data: Record<string, any>): Promise<any[]> {
|
|
126
|
+
for (const [index, fallback] of route.fallbacks.entries()) {
|
|
127
|
+
try {
|
|
128
|
+
console.log(` Trying fallback ${index + 1}/${route.fallbacks.length}...`)
|
|
129
|
+
|
|
130
|
+
const start = performance.now()
|
|
131
|
+
const result = await this.executePath(fallback.path, data)
|
|
132
|
+
const duration = performance.now() - start
|
|
133
|
+
|
|
134
|
+
// Success!
|
|
135
|
+
console.log(` ✅ Fallback worked: ${fallback.path.join('→')}`)
|
|
136
|
+
|
|
137
|
+
this.updateMetrics(fallback.path, duration, true)
|
|
138
|
+
|
|
139
|
+
// Maybe promote this fallback?
|
|
140
|
+
this.considerPromotion(route, index)
|
|
141
|
+
|
|
142
|
+
return result
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.warn(` ✗ Fallback ${index + 1} failed: ${(err as Error).message}`)
|
|
145
|
+
this.updateMetrics(fallback.path, 0, false)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error('All paths failed')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Update live metrics
|
|
155
|
+
*/
|
|
156
|
+
private updateMetrics(path: string[], duration: number, success: boolean): void {
|
|
157
|
+
const key = path.join('→')
|
|
158
|
+
|
|
159
|
+
if (!this.liveMetrics.has(key)) {
|
|
160
|
+
this.liveMetrics.set(key, {
|
|
161
|
+
path,
|
|
162
|
+
executions: 0,
|
|
163
|
+
successes: 0,
|
|
164
|
+
failures: 0,
|
|
165
|
+
totalTime: 0,
|
|
166
|
+
avgTime: 0
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const metric = this.liveMetrics.get(key)!
|
|
171
|
+
metric.executions++
|
|
172
|
+
|
|
173
|
+
if (success) {
|
|
174
|
+
metric.successes++
|
|
175
|
+
metric.totalTime += duration
|
|
176
|
+
metric.avgTime = metric.totalTime / metric.successes
|
|
177
|
+
} else {
|
|
178
|
+
metric.failures++
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Consider promoting a fallback to primary
|
|
184
|
+
*/
|
|
185
|
+
private considerPromotion(route: RouteInfo, fallbackIndex: number): void {
|
|
186
|
+
const fallback = route.fallbacks[fallbackIndex]
|
|
187
|
+
const fallbackKey = fallback.path.join('→')
|
|
188
|
+
const primaryKey = route.primary.path.join('→')
|
|
189
|
+
|
|
190
|
+
const fallbackMetric = this.liveMetrics.get(fallbackKey)
|
|
191
|
+
const primaryMetric = this.liveMetrics.get(primaryKey)
|
|
192
|
+
|
|
193
|
+
if (!fallbackMetric || !primaryMetric) return
|
|
194
|
+
|
|
195
|
+
// Promote if fallback is faster AND more reliable
|
|
196
|
+
const fallbackBetter =
|
|
197
|
+
fallbackMetric.avgTime < primaryMetric.avgTime && fallbackMetric.successes > 5 // Min sample size
|
|
198
|
+
|
|
199
|
+
if (fallbackBetter) {
|
|
200
|
+
console.log(`🔄 Promoting fallback to primary: ${fallbackKey}`)
|
|
201
|
+
|
|
202
|
+
// Swap
|
|
203
|
+
const temp = route.primary
|
|
204
|
+
route.primary = fallback
|
|
205
|
+
route.fallbacks[fallbackIndex] = temp
|
|
206
|
+
|
|
207
|
+
// Update cache
|
|
208
|
+
const key = `${route.from}→${route.to}`
|
|
209
|
+
this.routeCache.set(key, route)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get live statistics
|
|
215
|
+
*/
|
|
216
|
+
getStats(): {
|
|
217
|
+
totalExecutions: number
|
|
218
|
+
totalSuccesses: number
|
|
219
|
+
successRate: string
|
|
220
|
+
avgTime: string
|
|
221
|
+
uniquePaths: number
|
|
222
|
+
fastest?: LiveMetrics
|
|
223
|
+
slowest?: LiveMetrics
|
|
224
|
+
} {
|
|
225
|
+
const metrics = Array.from(this.liveMetrics.values())
|
|
226
|
+
|
|
227
|
+
if (metrics.length === 0) {
|
|
228
|
+
return {
|
|
229
|
+
totalExecutions: 0,
|
|
230
|
+
totalSuccesses: 0,
|
|
231
|
+
successRate: '0%',
|
|
232
|
+
avgTime: '0ms',
|
|
233
|
+
uniquePaths: 0
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const totalExecutions = metrics.reduce((sum, m) => sum + m.executions, 0)
|
|
238
|
+
const totalSuccesses = metrics.reduce((sum, m) => sum + m.successes, 0)
|
|
239
|
+
const avgTime = metrics.reduce((sum, m) => sum + m.avgTime * m.successes, 0) / totalSuccesses
|
|
240
|
+
|
|
241
|
+
const fastest = metrics.reduce((min, m) => (m.avgTime < min.avgTime ? m : min))
|
|
242
|
+
|
|
243
|
+
const slowest = metrics.reduce((max, m) => (m.avgTime > max.avgTime ? m : max))
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
totalExecutions,
|
|
247
|
+
totalSuccesses,
|
|
248
|
+
successRate: ((totalSuccesses / totalExecutions) * 100).toFixed(1) + '%',
|
|
249
|
+
avgTime: avgTime.toFixed(2) + 'ms',
|
|
250
|
+
uniquePaths: metrics.length,
|
|
251
|
+
fastest,
|
|
252
|
+
slowest
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Export metrics for recompilation
|
|
258
|
+
*/
|
|
259
|
+
exportMetrics(): Map<string, LiveMetrics> {
|
|
260
|
+
return new Map(this.liveMetrics)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Hot reload compiled graph
|
|
265
|
+
*/
|
|
266
|
+
reload(compiled: CompiledGraph): void {
|
|
267
|
+
console.log('🔄 Hot reloading compiled graph...')
|
|
268
|
+
|
|
269
|
+
this.compiled = compiled
|
|
270
|
+
this.routeCache = this.buildRouteCache(compiled)
|
|
271
|
+
|
|
272
|
+
console.log(`✅ Reloaded: ${compiled.routes.length} routes`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataLoader — Fetch les données pour un Trail résolu
|
|
3
|
+
*
|
|
4
|
+
* Fait le pont entre :
|
|
5
|
+
* Trail (sémantique — où on est, d'où on vient)
|
|
6
|
+
* QueryEngine (technique — comment fetcher les données)
|
|
7
|
+
* Provider (physique — SQL ou JSON en mémoire)
|
|
8
|
+
*
|
|
9
|
+
* Principe :
|
|
10
|
+
* Pour chaque frame RESOLVED dans le Trail, DataLoader
|
|
11
|
+
* construit la requête optimale depuis le graphe compilé
|
|
12
|
+
* et remplit frame.data avec les résultats.
|
|
13
|
+
*
|
|
14
|
+
* Deux modes de fetch :
|
|
15
|
+
* SQL — via Provider (PostgreSQL, MySQL...)
|
|
16
|
+
* JSON — via dataset en mémoire (mock, tests, Netflix JSON)
|
|
17
|
+
*
|
|
18
|
+
* Usage :
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const loader = new DataLoader(compiledGraph, { dataset })
|
|
21
|
+
* await loader.load(trail)
|
|
22
|
+
* // trail.current.data contient maintenant les données
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { CompiledGraph, Frame } from '../types/index.js'
|
|
27
|
+
import type { Trail } from '../navigation/Trail.js'
|
|
28
|
+
import { QueryEngine } from './QueryEngine.js'
|
|
29
|
+
|
|
30
|
+
// ── Types ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export interface DataLoaderOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Dataset JSON en mémoire — pour les providers mock ou Netflix JSON.
|
|
35
|
+
* Clé = nom de l'entité, valeur = tableau de rows.
|
|
36
|
+
*/
|
|
37
|
+
dataset?: Record<string, any[]>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Provider SQL — pour PostgreSQL, MySQL, etc.
|
|
41
|
+
* Si fourni, prend la priorité sur dataset.
|
|
42
|
+
*/
|
|
43
|
+
provider?: {
|
|
44
|
+
query<T = any>(sql: string, params?: any[]): Promise<T[]>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Transforme les filtres d'une frame en paramètres SQL.
|
|
49
|
+
* Par défaut : { field: 'id', value: 1 } → WHERE entity.id = 1
|
|
50
|
+
*/
|
|
51
|
+
buildFilters?: (frame: Frame) => Record<string, any>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── DataLoader ────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export class DataLoader {
|
|
57
|
+
private queryEngine: QueryEngine
|
|
58
|
+
private options: DataLoaderOptions
|
|
59
|
+
|
|
60
|
+
constructor(compiledGraph: CompiledGraph, options: DataLoaderOptions = {}) {
|
|
61
|
+
this.queryEngine = new QueryEngine(compiledGraph)
|
|
62
|
+
this.options = options
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Charge les données pour la frame courante du Trail.
|
|
67
|
+
*
|
|
68
|
+
* Stratégie :
|
|
69
|
+
* 1. Si la frame courante est UNRESOLVED → rien à fetcher
|
|
70
|
+
* 2. Si depth === 1 → fetch direct de l'entité (avec id si présent)
|
|
71
|
+
* 3. Si depth > 1 → traverse depuis le dernier ancêtre résolu
|
|
72
|
+
*
|
|
73
|
+
* Mutate trail.current.data avec les résultats.
|
|
74
|
+
*/
|
|
75
|
+
async load(trail: Trail): Promise<void> {
|
|
76
|
+
const current = trail.current
|
|
77
|
+
if (!current) return
|
|
78
|
+
if (current.state === 'UNRESOLVED') return
|
|
79
|
+
|
|
80
|
+
// Trouver l'ancêtre — point d'entrée de la traversée
|
|
81
|
+
const anchor = this.findAnchor(trail)
|
|
82
|
+
|
|
83
|
+
let data: any[]
|
|
84
|
+
|
|
85
|
+
if (!anchor || anchor.entity === current.entity) {
|
|
86
|
+
const filters = current.id !== undefined ? { id: current.id } : {}
|
|
87
|
+
data = await this.fetchDirect(current.entity, filters)
|
|
88
|
+
} else {
|
|
89
|
+
const filters = anchor.id !== undefined ? { id: anchor.id } : {}
|
|
90
|
+
data = await this.fetchViaRoute(anchor.entity, current.entity, filters)
|
|
91
|
+
}
|
|
92
|
+
;(current as any).data = data
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Charge les données pour toutes les frames RESOLVED du Trail.
|
|
97
|
+
* Utile pour les réponses enrichies (chaque frame a ses données).
|
|
98
|
+
*/
|
|
99
|
+
async loadAll(trail: Trail): Promise<void> {
|
|
100
|
+
for (let i = 0; i < trail.depth; i++) {
|
|
101
|
+
const frame = trail.at(i)
|
|
102
|
+
if (!frame || frame.state !== 'RESOLVED') continue
|
|
103
|
+
|
|
104
|
+
const subTrail = trail.slice(i + 1)
|
|
105
|
+
await this.load(subTrail)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Privé ──────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Trouve le premier ancêtre résolu avec un id dans le Trail.
|
|
113
|
+
* C'est le point de départ de la traversée.
|
|
114
|
+
*/
|
|
115
|
+
private findAnchor(trail: Trail): Frame | undefined {
|
|
116
|
+
// Remonter le Trail depuis l'avant-dernière frame
|
|
117
|
+
for (let i = trail.depth - 2; i >= 0; i--) {
|
|
118
|
+
const frame = trail.at(i)
|
|
119
|
+
if (frame?.state === 'RESOLVED' && frame.id !== undefined) {
|
|
120
|
+
return frame
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Construit les filtres depuis le Trail.
|
|
128
|
+
* Combine les filtres de resolvedBy + l'id de l'ancêtre.
|
|
129
|
+
*/
|
|
130
|
+
private buildFilters(trail: Trail): Record<string, any> {
|
|
131
|
+
const current = trail.current!
|
|
132
|
+
const filters: Record<string, any> = {}
|
|
133
|
+
|
|
134
|
+
// Filtre sur l'id de la frame courante si présent
|
|
135
|
+
if (current.id !== undefined) {
|
|
136
|
+
filters['id'] = current.id
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Filtres portés par resolvedBy (conditions sémantiques)
|
|
140
|
+
if (current.resolvedBy?.filters) {
|
|
141
|
+
for (const f of current.resolvedBy.filters) {
|
|
142
|
+
if (f.operator === 'equals') {
|
|
143
|
+
filters[f.field] = f.value
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Override par buildFilters custom si fourni
|
|
149
|
+
if (this.options.buildFilters) {
|
|
150
|
+
return { ...filters, ...this.options.buildFilters(current) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return filters
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Résout la clé primaire d'une entité depuis le graphe compilé.
|
|
158
|
+
* Fallback : {entity}_id (convention dvdrental, PostgreSQL standard).
|
|
159
|
+
*/
|
|
160
|
+
private pkOf(entity: string): string {
|
|
161
|
+
const node = this.queryEngine.compiledGraph.nodes.find((n: any) => n.id === entity)
|
|
162
|
+
const pk = (node as any)?.primaryKey
|
|
163
|
+
return Array.isArray(pk) ? pk[0] : (pk ?? `${entity}_id`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fetch direct — une seule entité, sans traversée.
|
|
168
|
+
*/
|
|
169
|
+
private async fetchDirect(
|
|
170
|
+
entity: string,
|
|
171
|
+
filters: Record<string, any>
|
|
172
|
+
): Promise<any[]> {
|
|
173
|
+
if (this.options.provider) {
|
|
174
|
+
// SQL via provider — résoudre la PK réelle au lieu de supposer 'id'
|
|
175
|
+
const conditions = Object.entries(filters)
|
|
176
|
+
.map(([k, v], i) => {
|
|
177
|
+
const col = k === 'id' ? this.pkOf(entity) : k
|
|
178
|
+
return `${entity}.${col} = $${i + 1}`
|
|
179
|
+
})
|
|
180
|
+
.join(' AND ')
|
|
181
|
+
|
|
182
|
+
const sql = conditions
|
|
183
|
+
? `SELECT * FROM ${entity} WHERE ${conditions}`
|
|
184
|
+
: `SELECT * FROM ${entity}`
|
|
185
|
+
const params = Object.values(filters)
|
|
186
|
+
|
|
187
|
+
return this.options.provider.query(sql, params)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (this.options.dataset) {
|
|
191
|
+
// JSON en mémoire — chercher sur la PK réelle ou 'id'
|
|
192
|
+
const pk = this.pkOf(entity)
|
|
193
|
+
const rows = this.options.dataset[entity] ?? []
|
|
194
|
+
return rows.filter(row =>
|
|
195
|
+
Object.entries(filters).every(([k, v]) => {
|
|
196
|
+
const col = k === 'id' ? pk : k
|
|
197
|
+
return row[col] === v
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return []
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fetch via route compilée — traverse from → to.
|
|
207
|
+
*/
|
|
208
|
+
private async fetchViaRoute(
|
|
209
|
+
from: string,
|
|
210
|
+
to: string,
|
|
211
|
+
filters: Record<string, any>
|
|
212
|
+
): Promise<any[]> {
|
|
213
|
+
// Vérifier que la route existe
|
|
214
|
+
let route
|
|
215
|
+
try {
|
|
216
|
+
route = this.queryEngine.getRoute(from, to)
|
|
217
|
+
} catch {
|
|
218
|
+
return this.fetchDirect(to, filters)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (this.options.provider) {
|
|
222
|
+
const sql = this.queryEngine.generateSQL({ from, to, filters })
|
|
223
|
+
return this.options.provider.query(sql)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.options.dataset) {
|
|
227
|
+
const result = this.queryEngine.executeInMemory(
|
|
228
|
+
{ from, to, filters },
|
|
229
|
+
this.options.dataset
|
|
230
|
+
)
|
|
231
|
+
return result
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return []
|
|
235
|
+
}
|
|
236
|
+
}
|