@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,163 @@
1
+ /**
2
+ * Engine - Core runtime engine with LRU cache
3
+ *
4
+ * Bus exposés :
5
+ * engine.events — cache.hit, cache.miss
6
+ * engine.errors — (extensible)
7
+ */
8
+
9
+ import type { Provider, EngineConfig, CacheStats } from '../types/index.js'
10
+ import { EventBus, ErrorBus } from '../core/EventBus.js'
11
+ import type { CacheHitPayload, CacheMissPayload, GraphErrors } from '../core/GraphEvents.js'
12
+
13
+ interface CacheEntry<T = any> {
14
+ key: string
15
+ value: T
16
+ size: number
17
+ accessCount: number
18
+ lastAccess: number
19
+ }
20
+
21
+ // Events émis par Engine (sous-ensemble de GraphEventMap)
22
+ interface EngineEventMap {
23
+ 'cache.hit': CacheHitPayload
24
+ 'cache.miss': CacheMissPayload
25
+ }
26
+
27
+ export class Engine {
28
+ private provider: Provider
29
+ public maxSize: number
30
+ private cache: Map<string, CacheEntry>
31
+ private hits: number
32
+ private misses: number
33
+
34
+ // ── Bus ──────────────────────────────────────────────────────
35
+ public readonly events: EventBus<EngineEventMap>
36
+ public readonly errors: ErrorBus<GraphErrors>
37
+
38
+ constructor(provider: Provider, config: EngineConfig = {}) {
39
+ this.provider = provider
40
+ this.maxSize = config.cache?.maxSize ?? 10 * 1024 * 1024
41
+ this.cache = new Map()
42
+ this.hits = 0
43
+ this.misses = 0
44
+ this.events = new EventBus<EngineEventMap>()
45
+ this.errors = new ErrorBus<GraphErrors>()
46
+ }
47
+
48
+ async get<T = any>(key: string, fetcher: () => Promise<T>): Promise<T> {
49
+ const cached = this.cache.get(key)
50
+
51
+ if (cached) {
52
+ this.hits++
53
+ cached.accessCount++
54
+ cached.lastAccess = Date.now()
55
+
56
+ // ── Event : cache.hit ──────────────────────────────────
57
+ this.events.emit('cache.hit', {
58
+ key,
59
+ accessCount: cached.accessCount,
60
+ })
61
+
62
+ return cached.value as T
63
+ }
64
+
65
+ this.misses++
66
+
67
+ // ── Event : cache.miss ─────────────────────────────────
68
+ this.events.emit('cache.miss', {
69
+ key,
70
+ requestedAt: Date.now(),
71
+ })
72
+
73
+ const value = await fetcher()
74
+ this.set(key, value)
75
+ return value
76
+ }
77
+
78
+ set<T = any>(key: string, value: T): void {
79
+ const size = this.estimateSize(value)
80
+ this.evictIfNeeded(size)
81
+
82
+ this.cache.set(key, {
83
+ key,
84
+ value,
85
+ size,
86
+ accessCount: 1,
87
+ lastAccess: Date.now(),
88
+ })
89
+
90
+ const formatted = this.formatSize(size)
91
+ const maxFormatted = this.formatSize(this.maxSize)
92
+ console.log(`💾 RAM CACHED: ${key} (${formatted}/${maxFormatted})`)
93
+ }
94
+
95
+ private evictIfNeeded(neededSize: number): void {
96
+ const currentSize = this.getCurrentSize()
97
+ if (currentSize + neededSize <= this.maxSize) return
98
+
99
+ const entries = Array.from(this.cache.values()).sort((a, b) => {
100
+ const scoreA = a.accessCount * 1000 + a.lastAccess
101
+ const scoreB = b.accessCount * 1000 + b.lastAccess
102
+ return scoreA - scoreB
103
+ })
104
+
105
+ let freedSize = 0
106
+ for (const entry of entries) {
107
+ if (currentSize - freedSize + neededSize <= this.maxSize) break
108
+ this.cache.delete(entry.key)
109
+ freedSize += entry.size
110
+ console.log(`🗑️ RAM EVICTED: ${entry.key}`)
111
+ }
112
+ }
113
+
114
+ clearCache(): void {
115
+ this.cache.clear()
116
+ this.hits = 0
117
+ this.misses = 0
118
+ console.log('💾 RAM CLEARED')
119
+ }
120
+
121
+ getStats(): CacheStats {
122
+ const size = this.getCurrentSize()
123
+ const totalAccesses = this.hits + this.misses
124
+ const hitRate = totalAccesses > 0
125
+ ? ((this.hits / totalAccesses) * 100).toFixed(1) + '%'
126
+ : '0%'
127
+
128
+ return {
129
+ entries: this.cache.size,
130
+ size,
131
+ sizeFormatted: this.formatSize(size),
132
+ maxSize: this.maxSize,
133
+ usage: ((size / this.maxSize) * 100).toFixed(1) + '%',
134
+ hits: this.hits,
135
+ misses: this.misses,
136
+ hitRate,
137
+ }
138
+ }
139
+
140
+ private getCurrentSize(): number {
141
+ let total = 0
142
+ for (const entry of this.cache.values()) total += entry.size
143
+ return total
144
+ }
145
+
146
+ private estimateSize(obj: any): number {
147
+ return JSON.stringify(obj).length
148
+ }
149
+
150
+ private formatSize(bytes: number): string {
151
+ if (bytes < 1024) return `${bytes} B`
152
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
153
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
154
+ }
155
+
156
+ async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
157
+ return this.provider.query<T>(sql, params)
158
+ }
159
+
160
+ async close(): Promise<void> {
161
+ await this.provider.close()
162
+ }
163
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * QueryEngine — patch generateSQL pour les routes sémantiques
3
+ *
4
+ * Ajouter ce patch dans generateSQL() de QueryEngine.ts,
5
+ * dans la boucle qui construit les JOIN :
6
+ *
7
+ * for (let i = 0; i < edges.length; i++) {
8
+ * const curr = path[i]
9
+ * const next = path[i + 1]
10
+ * const edge = edges[i]
11
+ *
12
+ * const fromCol = edge.fromCol === 'id' ? pkOf(curr) : edge.fromCol
13
+ * const toCol = edge.toCol === 'id' ? pkOf(next) : edge.toCol
14
+ *
15
+ * // ── PATCH : condition semantic_view ────────────────────────────────
16
+ * const conditionSQL = edge.condition
17
+ * ? ' AND ' + Object.entries(edge.condition)
18
+ * .map(([k, v]) => `${next}.${k} = ${typeof v === 'string' ? `'${v}'` : v}`)
19
+ * .join(' AND ')
20
+ * : ''
21
+ * // ──────────────────────────────────────────────────────────────────
22
+ *
23
+ * sql += `\n INNER JOIN ${next} ON ${curr}.${fromCol} = ${next}.${toCol}${conditionSQL}`
24
+ * }
25
+ *
26
+ * Résultat pour movies→people[actor] (jobId:1) :
27
+ *
28
+ * SELECT DISTINCT people.*
29
+ * FROM movies
30
+ * INNER JOIN credits ON movies.id = credits.movieId
31
+ * INNER JOIN people ON credits.personId = people.id AND people.jobId = 1
32
+ *
33
+ * Note : la condition est sur la table de jonction (credits), pas sur people.
34
+ * Le patch ci-dessus est une approximation — la condition correcte est :
35
+ *
36
+ * INNER JOIN credits ON movies.id = credits.movieId AND credits.jobId = 1
37
+ * INNER JOIN people ON credits.personId = people.id
38
+ *
39
+ * Voir generateSQLSemantic() ci-dessous pour la version correcte.
40
+ */
41
+
42
+ import type { CompiledGraph, RouteInfo } from '../types/index.js'
43
+ import { shim } from '../instrumentation/TelemetryShim.js'
44
+
45
+ export interface QueryOptions {
46
+ from: string
47
+ to: string
48
+ filters?: Record<string, any>
49
+ trail?: string
50
+ traceId?: string
51
+ // ── NOUVEAU : forcer une route sémantique spécifique
52
+ semantic?: string // ex: 'actor', 'director' — choisit la semantic_view correspondante
53
+ }
54
+
55
+ export class QueryEngine {
56
+ // public pour que DataLoader puisse accéder aux nodes (résolution PK)
57
+ constructor(public compiledGraph: CompiledGraph) {}
58
+
59
+ public getRoute(from: string, to: string, semantic?: string): RouteInfo {
60
+ // Si semantic fourni → chercher la route sémantique correspondante
61
+ if (semantic) {
62
+ const semRoute = (this.compiledGraph.routes as any[]).find(
63
+ r => r.from === from && r.to === to && r.semantic && r.label === semantic
64
+ )
65
+ if (semRoute) return semRoute
66
+ }
67
+
68
+ // Route physique par défaut (première trouvée)
69
+ const route = this.compiledGraph.routes.find(r => r.from === from && r.to === to)
70
+ if (!route) throw new Error(`LinkLab: No route found between ${from} and ${to}`)
71
+ return route
72
+ }
73
+
74
+ public generateSQL(options: QueryOptions): string {
75
+ const { from, to, filters = {}, semantic } = options
76
+ const route = this.getRoute(from, to, semantic)
77
+ const { path, edges } = route.primary
78
+
79
+ const pkOf = (tableId: string): string => {
80
+ const node = this.compiledGraph.nodes.find((n: any) => n.id === tableId)
81
+ const pk = (node as any)?.primaryKey
82
+ return Array.isArray(pk) ? pk[0] : (pk ?? tableId + '_id')
83
+ }
84
+
85
+ let sql = `SELECT DISTINCT ${to}.*\nFROM ${from}`
86
+
87
+ for (let i = 0; i < edges.length; i++) {
88
+ const curr = path[i]
89
+ const next = path[i + 1]
90
+ const edge = edges[i] as any
91
+
92
+ const fromCol = edge.fromCol === 'id' ? pkOf(curr) : edge.fromCol
93
+ const toCol = edge.toCol === 'id' ? pkOf(next) : edge.toCol
94
+
95
+ // Condition semantic_view — appliquée sur la table de jonction (curr)
96
+ // ex: credits.jobId = 1 (pas sur people)
97
+ const conditionSQL = edge.condition
98
+ ? ' AND ' + Object.entries(edge.condition as Record<string, unknown>)
99
+ .map(([k, v]) => `${curr}.${k} = ${typeof v === 'string' ? `'${v}'` : v}`)
100
+ .join(' AND ')
101
+ : ''
102
+
103
+ sql += `\n INNER JOIN ${next} ON ${curr}.${fromCol} = ${next}.${toCol}${conditionSQL}`
104
+ }
105
+
106
+ const sourcePK = pkOf(from)
107
+ const whereClauses = Object.entries(filters).map(([key, val]) => {
108
+ const col = key === 'id' ? sourcePK : key
109
+ const v = val === null ? null : (typeof val === 'string' ? `'${val}'` : val)
110
+ return v === null ? `${from}.${col} IS NULL` : `${from}.${col} = ${v}`
111
+ })
112
+
113
+ if (whereClauses.length > 0) sql += `\nWHERE ${whereClauses.join(' AND ')}`
114
+
115
+ return sql
116
+ }
117
+
118
+ public executeInMemory(options: QueryOptions, dataset: Record<string, any[]>) {
119
+ const { from, to, filters = {}, trail, traceId, semantic } = options
120
+
121
+ const spanBuilder = shim.startSpan({ trail: trail ?? `${from}.${to}`, from, to, filters, traceId })
122
+ spanBuilder?.stepStart('QueryEngine')
123
+
124
+ try {
125
+ const result = this._executeInMemoryCore(from, to, filters, dataset, semantic)
126
+
127
+ spanBuilder?.stepEnd('QueryEngine')
128
+ if (spanBuilder) {
129
+ try {
130
+ const route = this.getRoute(from, to, semantic)
131
+ ;(spanBuilder as any).withPath?.(route.primary.path)
132
+ } catch {}
133
+ const span = spanBuilder.end({ rowCount: result.length })
134
+ shim.emitEnd(span)
135
+ }
136
+ return result
137
+ } catch (err) {
138
+ spanBuilder?.stepEnd('QueryEngine')
139
+ if (spanBuilder) {
140
+ const span = spanBuilder.endWithError(err as Error, {
141
+ compiledGraphHash: (this.compiledGraph as any).version ?? 'unknown',
142
+ weights: {}, cacheState: { l1HitRate:0, l2HitRate:0, globalHitRate:0, yoyoEvents:0 },
143
+ })
144
+ shim.emitError(span)
145
+ }
146
+ throw err
147
+ }
148
+ }
149
+
150
+ private _executeInMemoryCore(
151
+ from: string, to: string,
152
+ filters: Record<string, any>,
153
+ dataset: Record<string, any[]>,
154
+ semantic?: string
155
+ ): any[] {
156
+ const route = this.getRoute(from, to, semantic)
157
+ const { path, edges } = route.primary
158
+
159
+ // Appliquer les filtres sur la table source
160
+ const sourceRows = dataset[from] ?? []
161
+ const filtered = Object.entries(filters).reduce((rows, [key, val]) => {
162
+ return rows.filter((r: any) => r[key] === val)
163
+ }, sourceRows)
164
+
165
+ // Jointures successives
166
+ let current: any[] = filtered
167
+
168
+ for (let i = 0; i < edges.length; i++) {
169
+ const currTable = path[i]
170
+ const nextTable = path[i + 1]
171
+ const edge = edges[i] as any
172
+ const nextRows = dataset[nextTable] ?? []
173
+
174
+ const fromCol = edge.fromCol === 'id' ? 'id' : edge.fromCol
175
+ const toCol = edge.toCol === 'id' ? 'id' : edge.toCol
176
+
177
+ // Condition semantic_view (ex: { jobId: 1 })
178
+ const condition: Record<string, unknown> = edge.condition ?? {}
179
+
180
+ current = current.flatMap(row => {
181
+ const val = row[fromCol]
182
+ return nextRows.filter((next: any) => {
183
+ if (next[toCol] !== val) return false
184
+ // Condition semantic_view appliquée sur 'next' (table de jonction)
185
+ // ex: credits.jobId = 1 — credits est la table 'next' à ce step
186
+ for (const [k, v] of Object.entries(condition)) {
187
+ if (next[k] !== v) return false
188
+ }
189
+ return true
190
+ })
191
+ })
192
+
193
+ // Dédoublonnage sur id
194
+ const seen = new Set<unknown>()
195
+ current = current.filter(r => {
196
+ if (seen.has(r.id)) return false
197
+ seen.add(r.id)
198
+ return true
199
+ })
200
+ }
201
+
202
+ return current
203
+ }
204
+
205
+ public generateJSONPipeline(options: QueryOptions) {
206
+ const { from, to, filters = {}, semantic } = options
207
+ const route = this.getRoute(from, to, semantic)
208
+ const { path, edges } = route.primary
209
+ return {
210
+ metadata: { from, to, steps: path.length, semantic: semantic ?? null },
211
+ executionPlan: path.map((table, index) => {
212
+ const isSource = index === 0
213
+ return {
214
+ step: index + 1,
215
+ action: isSource ? 'FETCH_AND_FILTER' : 'JOIN',
216
+ table,
217
+ config: isSource ? { filters } : { joinWith: path[index-1], on: edges[index-1] },
218
+ }
219
+ }),
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "mode": "PATHFIND",
3
+ "description": "Métro Parisien — 312 stations, 14 lignes, poids en minutes réels RATP",
4
+ "defaultQuery": "chatelet-opera",
5
+ "queriesFile": "./queries.ts"
6
+ }