@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,252 @@
1
+ /**
2
+ * Trail — Contexte de navigation vivant
3
+ *
4
+ * Trois niveaux de contexte, trois durées de vie :
5
+ *
6
+ * global — vit aussi longtemps que l'instance LinkLab
7
+ * config, feature flags, métriques globales
8
+ *
9
+ * user — vit le temps d'une session
10
+ * userId, permissions, préférences, historique récent
11
+ *
12
+ * frames — vit le temps d'une navigation
13
+ * le chemin parcouru, position courante
14
+ *
15
+ * Deux niveaux d'API :
16
+ *
17
+ * Bas niveau — trail.push(frame), trail.pop(), trail.compact()
18
+ * fondation sur laquelle tout repose
19
+ *
20
+ * Haut niveau — API fluente, construite sur push()
21
+ * cinema.people('Nolan').movies
22
+ *
23
+ * Contrat de sérialisation :
24
+ * global et user ne contiennent que des données — jamais de fonctions.
25
+ * Un Trail sérialisé peut être rejoué exactement.
26
+ */
27
+
28
+ import type { Frame } from '../types/index.js'
29
+
30
+ // ── Types ─────────────────────────────────────────────────────
31
+
32
+ export interface TrailInit {
33
+ global?: Record<string, any>
34
+ user?: Record<string, any>
35
+ frames?: Frame[]
36
+ }
37
+
38
+ /** Format de sérialisation — versionné pour les migrations futures */
39
+ export interface SerializedTrail {
40
+ v: number
41
+ global: Record<string, any>
42
+ user: Record<string, any>
43
+ frames: Frame[]
44
+ savedAt: string
45
+ }
46
+
47
+ // ── Trail ─────────────────────────────────────────────────────
48
+
49
+ export class Trail {
50
+ /** Contexte global — long terme */
51
+ public readonly global: Record<string, any>
52
+
53
+ /** Contexte utilisateur — session */
54
+ public readonly user: Record<string, any>
55
+
56
+ /** Frames de navigation — readonly depuis l'extérieur */
57
+ private _frames: Frame[]
58
+
59
+ private constructor(init: TrailInit = {}) {
60
+ this.global = init.global ?? {}
61
+ this.user = init.user ?? {}
62
+ this._frames = init.frames ? init.frames.map(f => ({ ...f })) : []
63
+ }
64
+
65
+ // ── Factories ──────────────────────────────────────────────
66
+
67
+ /** Crée un Trail vierge, avec contextes optionnels */
68
+ static create(init: TrailInit = {}): Trail {
69
+ return new Trail(init)
70
+ }
71
+
72
+ /** Restaure un Trail depuis sa forme sérialisée */
73
+ static from(serialized: string | SerializedTrail): Trail {
74
+ const data: SerializedTrail = typeof serialized === 'string'
75
+ ? JSON.parse(serialized)
76
+ : serialized
77
+
78
+ if (data.v !== 1) {
79
+ throw new Error(`Trail.from: version ${data.v} non supportée`)
80
+ }
81
+
82
+ return new Trail({
83
+ global: data.global ?? {},
84
+ user: data.user ?? {},
85
+ frames: data.frames ?? [],
86
+ })
87
+ }
88
+
89
+ // ── Accesseurs frames ──────────────────────────────────────
90
+
91
+ /** Toutes les frames — tableau immuable depuis l'extérieur */
92
+ get frames(): readonly Frame[] {
93
+ return this._frames
94
+ }
95
+
96
+ /** Dernière frame — position courante */
97
+ get current(): Frame | undefined {
98
+ return this._frames[this._frames.length - 1]
99
+ }
100
+
101
+ /** Nombre de frames */
102
+ get depth(): number {
103
+ return this._frames.length
104
+ }
105
+
106
+ /** Vrai si toutes les frames sont résolues */
107
+ get isFullyResolved(): boolean {
108
+ return this._frames.every(f => f.state === 'RESOLVED')
109
+ }
110
+
111
+ /** Frames non résolues */
112
+ get unresolved(): Frame[] {
113
+ return this._frames.filter(f => f.state === 'UNRESOLVED')
114
+ }
115
+
116
+ // ── API bas niveau ─────────────────────────────────────────
117
+
118
+ /**
119
+ * Pousse une frame sur le Trail.
120
+ * Retourne this pour le chaînage.
121
+ *
122
+ * Si state n'est pas précisé :
123
+ * - id fourni → RESOLVED
124
+ * - id absent → UNRESOLVED
125
+ */
126
+ push(frame: Frame): this {
127
+ const normalized: Frame = {
128
+ ...frame,
129
+ state: frame.state ?? (frame.id !== undefined ? 'RESOLVED' : 'UNRESOLVED'),
130
+ }
131
+ this._frames.push(normalized)
132
+ return this
133
+ }
134
+
135
+ /**
136
+ * Retire et retourne la dernière frame.
137
+ * Retourne undefined si le Trail est vide.
138
+ */
139
+ pop(): Frame | undefined {
140
+ return this._frames.pop()
141
+ }
142
+
143
+ /**
144
+ * Met à jour une frame existante par index ou par entity.
145
+ * Réservé à LinkLab — permet au moteur de synchroniser les frames résolues.
146
+ *
147
+ * @param entity - L'entité de la frame à mettre à jour
148
+ * @param updated - Les nouvelles valeurs à merger
149
+ */
150
+ updateFrame(entity: string, updated: Partial<Frame>): boolean {
151
+ const idx = this._frames.findIndex(
152
+ f => f.entity === entity && f.state === 'UNRESOLVED'
153
+ )
154
+ if (idx === -1) return false
155
+ this._frames[idx] = { ...this._frames[idx], ...updated }
156
+ return true
157
+ }
158
+
159
+ /**
160
+ * Compacte le Trail — supprime les frames non-discriminantes
161
+ * en conservant uniquement les frames qui portent un id
162
+ * ou qui sont la position courante.
163
+ *
164
+ * Exemple :
165
+ * [cinema][people(Nolan)][movies(Interstellar)][actors]
166
+ * → [people(Nolan)][movies(Interstellar)][actors]
167
+ *
168
+ * Note : réservé à LinkLab — appelé par le moteur, pas par les hooks.
169
+ */
170
+ compact(): this {
171
+ const last = this._frames.length - 1
172
+ this._frames = this._frames.filter((f, i) =>
173
+ i === last || f.id !== undefined
174
+ )
175
+ return this
176
+ }
177
+
178
+ /**
179
+ * Retourne la frame à l'index donné (0 = première).
180
+ * Accepte les index négatifs (-1 = dernière).
181
+ */
182
+ at(index: number): Frame | undefined {
183
+ if (index < 0) index = this._frames.length + index
184
+ return this._frames[index]
185
+ }
186
+
187
+ /**
188
+ * Retourne la dernière frame dont l'entity correspond.
189
+ */
190
+ find(entity: string): Frame | undefined {
191
+ return [...this._frames].reverse().find(f => f.entity === entity)
192
+ }
193
+
194
+ /**
195
+ * Retourne un Trail tronqué jusqu'à l'index donné (non inclus).
196
+ * Utile pour le replay partiel.
197
+ */
198
+ slice(end: number): Trail {
199
+ return new Trail({
200
+ global: { ...this.global },
201
+ user: { ...this.user },
202
+ frames: this._frames.slice(0, end),
203
+ })
204
+ }
205
+
206
+ // ── Sérialisation ──────────────────────────────────────────
207
+
208
+ /**
209
+ * Sérialise le Trail en JSON.
210
+ * global et user ne doivent contenir que des données — les fonctions
211
+ * sont silencieusement ignorées par JSON.stringify.
212
+ */
213
+ serialize(): string {
214
+ return JSON.stringify(this.toJSON())
215
+ }
216
+
217
+ toJSON(): SerializedTrail {
218
+ return {
219
+ v: 1,
220
+ global: this.global,
221
+ user: this.user,
222
+ frames: [...this._frames],
223
+ savedAt: new Date().toISOString(),
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Deep copy — utile pour le replay ou les tests.
229
+ * Le clone est indépendant — modifier l'un ne modifie pas l'autre.
230
+ */
231
+ clone(): Trail {
232
+ return Trail.from(this.toJSON())
233
+ }
234
+
235
+ // ── Debug ──────────────────────────────────────────────────
236
+
237
+ /**
238
+ * Représentation lisible du Trail courant.
239
+ * ex: [people(Nolan)] → [movies(Interstellar)] → [actors?]
240
+ */
241
+ toString(): string {
242
+ if (this._frames.length === 0) return '(trail vide)'
243
+
244
+ return this._frames
245
+ .map(f => {
246
+ const id = f.id !== undefined ? `(${f.id})` : ''
247
+ const state = f.state === 'UNRESOLVED' ? '?' : f.state === 'DEFERRED' ? '…' : ''
248
+ return `[${f.entity}${id}${state}]`
249
+ })
250
+ .join(' → ')
251
+ }
252
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * TrailParser — Désérialise des représentations externes vers un Trail
3
+ *
4
+ * Trois sources supportées :
5
+ *
6
+ * URL path → /cinema/people/Nolan/movies/Interstellar/actors
7
+ * URL fluent → cinema.people(Nolan).movies(Interstellar).actors
8
+ * JSON → SerializedTrail (via Trail.from)
9
+ *
10
+ * Le parser est stateless — toutes les méthodes sont statiques.
11
+ * Il ne valide pas les entités contre le graphe — c'est le rôle du moteur.
12
+ */
13
+
14
+ import { Trail } from './Trail.js'
15
+ import type { Frame } from '../types/index.js'
16
+
17
+ export class TrailParser {
18
+
19
+ // ── URL Path ───────────────────────────────────────────────
20
+
21
+ /**
22
+ * Parse un path HTTP en Trail.
23
+ *
24
+ * Convention :
25
+ * /entity → Frame(entity, UNRESOLVED)
26
+ * /entity/id → Frame(entity, id, RESOLVED)
27
+ * /entity/id/other → Frame(entity, id) + Frame(other, UNRESOLVED)
28
+ *
29
+ * Exemples :
30
+ * /people → [people?]
31
+ * /people/Nolan → [people(Nolan)]
32
+ * /people/Nolan/movies → [people(Nolan)] → [movies?]
33
+ * /people/Nolan/movies/2 → [people(Nolan)] → [movies(2)]
34
+ * /cinema/people/Nolan/movies → [cinema] → [people(Nolan)] → [movies?]
35
+ *
36
+ * @param path - URL path, avec ou sans slash initial
37
+ * @param init - Contextes global/user à injecter
38
+ */
39
+ static fromPath(
40
+ path: string,
41
+ init: { global?: Record<string, any>; user?: Record<string, any> } = {}
42
+ ): Trail {
43
+ const trail = Trail.create(init)
44
+ const parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
45
+
46
+ let i = 0
47
+ while (i < parts.length) {
48
+ const entity = parts[i]
49
+ const next = parts[i + 1]
50
+
51
+ // Si le prochain segment existe et n'est pas une entité connue
52
+ // (heuristique : commence par une lettre minuscule = entité, sinon = id)
53
+ const nextIsId = next !== undefined && !TrailParser.looksLikeEntity(next)
54
+
55
+ if (nextIsId) {
56
+ trail.push({ entity, id: TrailParser.coerceId(next), state: 'RESOLVED' })
57
+ i += 2
58
+ } else {
59
+ trail.push({ entity, state: 'UNRESOLVED' })
60
+ i += 1
61
+ }
62
+ }
63
+
64
+ return trail
65
+ }
66
+
67
+ /**
68
+ * Parse une expression fluente en Trail.
69
+ *
70
+ * Syntaxe :
71
+ * entity → Frame(entity, UNRESOLVED)
72
+ * entity(id) → Frame(entity, id, RESOLVED)
73
+ * entity.other → Frame(entity) + Frame(other)
74
+ * entity(id).other(id2) → Frame(entity,id) + Frame(other,id2)
75
+ *
76
+ * Exemples :
77
+ * people → [people?]
78
+ * people(Nolan) → [people(Nolan)]
79
+ * people(Nolan).movies → [people(Nolan)] → [movies?]
80
+ * cinema.people(Nolan).movies(2) → [cinema] → [people(Nolan)] → [movies(2)]
81
+ *
82
+ * @param expr - Expression fluente
83
+ * @param init - Contextes global/user à injecter
84
+ */
85
+ static fromFluent(
86
+ expr: string,
87
+ init: { global?: Record<string, any>; user?: Record<string, any> } = {}
88
+ ): Trail {
89
+ const trail = Trail.create(init)
90
+
91
+ // Tokenise : split sur les points, mais pas ceux dans les parenthèses
92
+ const tokens = TrailParser.tokenizeFluent(expr)
93
+
94
+ for (const token of tokens) {
95
+ const frame = TrailParser.parseToken(token)
96
+ trail.push(frame)
97
+ }
98
+
99
+ return trail
100
+ }
101
+
102
+ /**
103
+ * Sérialise un Trail en path HTTP.
104
+ *
105
+ * Exemple :
106
+ * Trail([people(Nolan)][movies?]) → /people/Nolan/movies
107
+ */
108
+ static toPath(trail: Trail): string {
109
+ const parts: string[] = []
110
+
111
+ for (const frame of trail.frames) {
112
+ parts.push(frame.entity)
113
+ if (frame.id !== undefined) {
114
+ parts.push(String(frame.id))
115
+ }
116
+ }
117
+
118
+ return '/' + parts.join('/')
119
+ }
120
+
121
+ /**
122
+ * Sérialise un Trail en expression fluente.
123
+ *
124
+ * Exemple :
125
+ * Trail([people(Nolan)][movies?]) → people(Nolan).movies
126
+ */
127
+ static toFluent(trail: Trail): string {
128
+ return trail.frames
129
+ .map(f => f.id !== undefined ? `${f.entity}(${f.id})` : f.entity)
130
+ .join('.')
131
+ }
132
+
133
+ // ── Helpers privés ─────────────────────────────────────────
134
+
135
+ /**
136
+ * Heuristique : un segment ressemble-t-il à un nom d'entité ?
137
+ * Les entités commencent par une lettre minuscule et ne contiennent
138
+ * que des lettres, chiffres et tirets.
139
+ */
140
+ private static looksLikeEntity(segment: string): boolean {
141
+ return /^[a-z][a-zA-Z0-9-_]*$/.test(segment)
142
+ }
143
+
144
+ /**
145
+ * Essaie de convertir un id en nombre, sinon garde la string.
146
+ */
147
+ private static coerceId(value: string): string | number {
148
+ const n = Number(value)
149
+ return Number.isFinite(n) && value.trim() !== '' ? n : value
150
+ }
151
+
152
+ /**
153
+ * Tokenise une expression fluente en segments.
154
+ * Préserve le contenu des parenthèses (les ids peuvent contenir des points).
155
+ *
156
+ * ex: "cinema.people(Nolan.Jr).movies"
157
+ * → ["cinema", "people(Nolan.Jr)", "movies"]
158
+ */
159
+ private static tokenizeFluent(expr: string): string[] {
160
+ const tokens: string[] = []
161
+ let current = ''
162
+ let depth = 0
163
+
164
+ for (const ch of expr) {
165
+ if (ch === '(') {
166
+ depth++
167
+ current += ch
168
+ } else if (ch === ')') {
169
+ depth--
170
+ current += ch
171
+ } else if (ch === '.' && depth === 0) {
172
+ if (current) tokens.push(current)
173
+ current = ''
174
+ } else {
175
+ current += ch
176
+ }
177
+ }
178
+
179
+ if (current) tokens.push(current)
180
+ return tokens
181
+ }
182
+
183
+ /**
184
+ * Parse un token "entity" ou "entity(id)" en Frame.
185
+ */
186
+ private static parseToken(token: string): Frame {
187
+ const match = token.match(/^([^(]+)(?:\(([^)]*)\))?$/)
188
+
189
+ if (!match) {
190
+ // Token malformé — on le traite comme une entité sans id
191
+ return { entity: token, state: 'UNRESOLVED' }
192
+ }
193
+
194
+ const entity = match[1].trim()
195
+ const rawId = match[2]
196
+
197
+ if (rawId === undefined || rawId === '') {
198
+ return { entity, state: 'UNRESOLVED' }
199
+ }
200
+
201
+ return {
202
+ entity,
203
+ id: TrailParser.coerceId(rawId),
204
+ state: 'RESOLVED',
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * navigation — Exports du module de navigation
3
+ */
4
+
5
+ export { Trail } from './Trail.js'
6
+ export { TrailParser } from './TrailParser.js'
7
+ export { NavigationEngine } from './NavigationEngine.js'
8
+ export { Resolver } from './Resolver.js'
9
+ export { Scheduler } from './Scheduler.js'
10
+
11
+ export type { TrailInit, SerializedTrail } from './Trail.js'
@@ -0,0 +1,68 @@
1
+ /**
2
+ * MockProvider - In-memory provider for testing
3
+ */
4
+
5
+ import type { Provider } from '../types/index.js'
6
+
7
+ export class MockProvider implements Provider {
8
+ private data: Map<string, any[]>
9
+
10
+ constructor() {
11
+ this.data = new Map()
12
+ }
13
+
14
+ /**
15
+ * Set mock data for a table
16
+ */
17
+ setData(table: string, rows: any[]): void {
18
+ this.data.set(table, rows)
19
+ }
20
+
21
+ /**
22
+ * Execute query (simplified parsing)
23
+ */
24
+ async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
25
+ // Parse table name
26
+ const selectMatch = sql.match(/SELECT .* FROM (\w+)/i)
27
+ if (!selectMatch) {
28
+ return []
29
+ }
30
+
31
+ const tableName = selectMatch[1]
32
+ const data = this.data.get(tableName) || []
33
+
34
+ // Apply WHERE clause (simplified)
35
+ let filtered = data
36
+
37
+ const whereMatch = sql.match(/WHERE (\w+) = \?/i)
38
+ if (whereMatch && params.length > 0) {
39
+ const column = whereMatch[1]
40
+ const value = params[0]
41
+
42
+ filtered = data.filter((row: any) => row[column] === value)
43
+ }
44
+
45
+ return filtered as T[]
46
+ }
47
+
48
+ /**
49
+ * Close (no-op for mock)
50
+ */
51
+ async close(): Promise<void> {
52
+ // No-op
53
+ }
54
+
55
+ /**
56
+ * Clear all data
57
+ */
58
+ clear(): void {
59
+ this.data.clear()
60
+ }
61
+
62
+ /**
63
+ * Get data for a table
64
+ */
65
+ getData(table: string): any[] | undefined {
66
+ return this.data.get(table)
67
+ }
68
+ }