@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,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
+ }