@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,244 @@
1
+ /**
2
+ * LinkBuilder — Génère les liens HATEOAS depuis le graphe
3
+ *
4
+ * Logique pure, sans dépendance à Fastify.
5
+ * Prend un Trail + un Graph, retourne des liens navigables.
6
+ *
7
+ * Trois catégories de liens générés automatiquement :
8
+ *
9
+ * self — l'URL courante (Trail sérialisé)
10
+ * up — le parent (Trail sans la dernière frame)
11
+ * relations — toutes les arêtes sortantes du nœud courant
12
+ *
13
+ * Les liens émergent du graphe — le dev ne configure rien.
14
+ */
15
+
16
+ import type { Graph, GraphEdge } from '../types/index.js'
17
+ import { Trail } from '../navigation/Trail.js'
18
+ import { TrailParser } from '../navigation/TrailParser.js'
19
+
20
+ // ── Types ─────────────────────────────────────────────────────
21
+
22
+ export interface HateoasLink {
23
+ href: string
24
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
25
+ templated?: boolean // true si l'href contient {id}
26
+ title?: string // label lisible — ex: "Films de Nolan"
27
+ rel?: string // relation sémantique — ex: "movies"
28
+ }
29
+
30
+ export interface HateoasLinks {
31
+ self: HateoasLink
32
+ up?: HateoasLink
33
+ [relation: string]: HateoasLink | undefined
34
+ }
35
+
36
+ export interface LinkBuilderOptions {
37
+ /** Préfixe ajouté à toutes les URLs générées — ex: '/api/v1' */
38
+ prefix?: string
39
+ /** Inclure les arêtes inverses (retour vers le parent) */
40
+ includeReverse?: boolean
41
+ /** Exclure certaines relations — ex: ['internal', 'debug'] */
42
+ exclude?: string[]
43
+ }
44
+
45
+ // ── LinkBuilder ───────────────────────────────────────────────
46
+
47
+ export class LinkBuilder {
48
+ private graph: Graph
49
+ private options: Required<LinkBuilderOptions>
50
+
51
+ constructor(graph: Graph, options: LinkBuilderOptions = {}) {
52
+ this.graph = graph
53
+ this.options = {
54
+ prefix: options.prefix ?? '',
55
+ includeReverse: options.includeReverse ?? false,
56
+ exclude: options.exclude ?? [],
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Génère les liens HATEOAS pour un Trail donné.
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * const builder = new LinkBuilder(graph, { prefix: '/api' })
66
+ * const links = builder.from(trail)
67
+ * // {
68
+ * // self: { href: '/api/people/Nolan/movies' },
69
+ * // up: { href: '/api/people/Nolan' },
70
+ * // actors: { href: '/api/people/Nolan/movies/{id}/actors', templated: true },
71
+ * // ratings: { href: '/api/people/Nolan/movies/{id}/ratings', templated: true }
72
+ * // }
73
+ * ```
74
+ */
75
+ from(trail: Trail): HateoasLinks {
76
+ const currentPath = this.prefix(TrailParser.toPath(trail))
77
+
78
+ const links: HateoasLinks = {
79
+ self: {
80
+ href: currentPath,
81
+ method: 'GET',
82
+ rel: 'self',
83
+ }
84
+ }
85
+
86
+ // ── Frame courante ────────────────────────────────────────
87
+ const current = trail.current
88
+
89
+ // ── Lien "up" — parent dans le Trail ou collection ─────────
90
+ // depth = 1 avec id : up vers la collection (ex: /movies/278 → up: /movies)
91
+ // depth > 1 : up vers l'entité parente (ex: /movies/278/people → up: /movies/278)
92
+ if (trail.depth === 1 && current?.id !== undefined) {
93
+ links.up = {
94
+ href: this.prefix('/' + current.entity),
95
+ method: 'GET',
96
+ rel: 'up',
97
+ title: `Collection ${current.entity}`,
98
+ }
99
+ } else if (trail.depth > 1) {
100
+ const parentTrail = trail.slice(trail.depth - 1)
101
+ links.up = {
102
+ href: this.prefix(TrailParser.toPath(parentTrail)),
103
+ method: 'GET',
104
+ rel: 'up',
105
+ title: `Retour vers ${parentTrail.current?.entity}`,
106
+ }
107
+ }
108
+
109
+ // ── Liens sortants — arêtes depuis le nœud courant ────────
110
+ if (!current) return links
111
+
112
+ const outgoing = this.getOutgoingEdges(current.entity)
113
+
114
+ // Grouper les arêtes par entité cible (.to)
115
+ // Plusieurs arêtes peuvent pointer vers la même entité (ex: actor, director, writer → people)
116
+ // On génère un seul lien par entité cible, avec l'arête de poids minimal
117
+ const byTarget = new Map<string, GraphEdge>()
118
+ for (const edge of outgoing) {
119
+ // Ignorer les relations exclues
120
+ if (this.options.exclude.includes(edge.name ?? '')) continue
121
+ if (this.options.exclude.includes(edge.to)) continue
122
+
123
+ const edgeLabel = (edge as any).label ?? edge.name ?? ''
124
+ // Préférer une edge avec un label significatif (pas 'unknow', pas vide)
125
+ const isSignificant = edgeLabel && edgeLabel !== 'unknow'
126
+
127
+ const existing = byTarget.get(edge.to)
128
+ if (!existing) {
129
+ byTarget.set(edge.to, edge)
130
+ } else {
131
+ const existingLabel = (existing as any).label ?? existing.name ?? ''
132
+ const existingSignificant = existingLabel && existingLabel !== 'unknow'
133
+ // Remplacer si : l'actuelle est insignifiante ET la nouvelle est significative,
134
+ // ou les deux sont significatives et la nouvelle est moins lourde
135
+ if ((!existingSignificant && isSignificant) ||
136
+ (existingSignificant && isSignificant && edge.weight < existing.weight)) {
137
+ byTarget.set(edge.to, edge)
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const [targetEntity, edge] of byTarget) {
143
+ // L'URL utilise l'entité cible comme segment — pas le nom de l'arête
144
+ // /api/movies/278/people (pas /api/movies/278/actor)
145
+ const href = current.id !== undefined
146
+ ? this.prefix(TrailParser.toPath(trail) + '/' + targetEntity)
147
+ : this.prefix(TrailParser.toPath(trail) + '/{id}/' + targetEntity)
148
+
149
+ const templated = current.id === undefined || href.includes('{id}')
150
+
151
+ links[targetEntity] = {
152
+ href,
153
+ method: 'GET',
154
+ templated: templated || undefined,
155
+ rel: targetEntity,
156
+ title: this.buildTitle(trail, edge),
157
+ }
158
+ }
159
+
160
+ return links
161
+ }
162
+
163
+ /**
164
+ * Génère les liens pour une collection de résultats.
165
+ * Chaque item reçoit ses propres liens self + relations.
166
+ *
167
+ * @example
168
+ * ```typescript
169
+ * // GET /people/Nolan/movies → liste de films
170
+ * const itemLinks = builder.forItems(trail, movies, 'id')
171
+ * // itemLinks[0] = { self: { href: '/people/Nolan/movies/1' }, actors: {...} }
172
+ * ```
173
+ */
174
+ forItems(
175
+ trail: Trail,
176
+ items: any[],
177
+ idField: string = 'id'
178
+ ): HateoasLinks[] {
179
+ return items.map(item => {
180
+ const id = item[idField]
181
+ if (id === undefined) return this.from(trail)
182
+
183
+ // Construire un Trail avec l'id de l'item
184
+ const itemTrail = trail.clone()
185
+ const last = itemTrail.current
186
+ if (last) {
187
+ // Remplacer la dernière frame avec l'id
188
+ itemTrail.pop()
189
+ itemTrail.push({ ...last, id, state: 'RESOLVED' })
190
+ }
191
+
192
+ return this.from(itemTrail)
193
+ })
194
+ }
195
+
196
+ /**
197
+ * Vérifie si une relation existe depuis un nœud donné.
198
+ * Utile pour les hooks d'access.check.
199
+ */
200
+ hasRelation(fromEntity: string, relation: string): boolean {
201
+ return this.graph.edges.some(
202
+ e => e.from === fromEntity && (e.name === relation || e.to === relation)
203
+ )
204
+ }
205
+
206
+ /**
207
+ * Retourne toutes les entités accessibles depuis un nœud.
208
+ */
209
+ reachableFrom(entity: string): string[] {
210
+ return this.getOutgoingEdges(entity).map(e => e.name ?? e.to)
211
+ }
212
+
213
+ // ── Privé ──────────────────────────────────────────────────
214
+
215
+ private getOutgoingEdges(entity: string): GraphEdge[] {
216
+ return this.graph.edges
217
+ .filter(e => e.from === entity)
218
+ .sort((a, b) => b.weight - a.weight) // les plus utilisées en premier
219
+ }
220
+
221
+ private prefix(path: string): string {
222
+ if (!this.options.prefix) return path
223
+ return this.options.prefix.replace(/\/$/, '') + path
224
+ }
225
+
226
+ private buildTitle(trail: Trail, edge: GraphEdge): string {
227
+ const current = trail.current
228
+
229
+ // Résoudre un label lisible pour l'edge :
230
+ // 1. edge.name explicite et non générique (ex: 'LIST_OF_CREDITS', 'actor')
231
+ // 2. edge.to comme fallback neutre (ex: 'people', 'movies')
232
+ const rawName = edge.name ?? ''
233
+ const isGeneric = !rawName || rawName === 'unknow' || rawName === 'unknow_in'
234
+ const edgeLabel = isGeneric ? edge.to : rawName
235
+
236
+ if (!current) return edgeLabel
237
+
238
+ const from = current.id !== undefined
239
+ ? `${current.entity}(${current.id})`
240
+ : current.entity
241
+
242
+ return `${edgeLabel} de ${from}`
243
+ }
244
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TrailRequest — Augmentation du type Request Fastify
3
+ *
4
+ * Ajoute `request.trail` et `request.linkBuilder`
5
+ * sur chaque requête décorée par le plugin LinkLab.
6
+ *
7
+ * Usage :
8
+ * ```typescript
9
+ * fastify.get('/*', async (req, reply) => {
10
+ * const trail = req.trail // Trail parsé depuis l'URL
11
+ * const links = req.linkBuilder // LinkBuilder prêt à l'emploi
12
+ * })
13
+ * ```
14
+ */
15
+
16
+ import type { FastifyRequest } from 'fastify'
17
+ import type { Trail } from '../navigation/Trail.js'
18
+ import type { LinkBuilder } from './LinkBuilder.js'
19
+
20
+ /**
21
+ * Déclaration d'augmentation du module Fastify.
22
+ * TypeScript merge automatiquement avec FastifyRequest.
23
+ */
24
+ declare module 'fastify' {
25
+ interface FastifyRequest {
26
+ /** Trail parsé depuis l'URL de la requête */
27
+ trail: Trail
28
+
29
+ /** LinkBuilder configuré avec le graphe de l'instance */
30
+ linkBuilder: LinkBuilder
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extrait le contexte utilisateur depuis une requête Fastify.
36
+ * Extensible par le dev via les options du plugin.
37
+ */
38
+ export type UserContextExtractor = (
39
+ req: FastifyRequest
40
+ ) => Promise<Record<string, any>> | Record<string, any>
41
+
42
+ /**
43
+ * Extracteur par défaut — lit req.user si présent (JWT/session)
44
+ */
45
+ export const defaultUserExtractor: UserContextExtractor = (req) => {
46
+ const r = req as any
47
+ return r.user ?? r.session?.user ?? {}
48
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Exemple d'intégration Netflix
3
+ * Fichier à titre d'exemple uniquement, non exporté.
4
+ */
5
+
6
+ import { createRequire } from 'module'
7
+ import { fileURLToPath } from 'url'
8
+ import path from 'path'
9
+ import Fastify from 'fastify'
10
+ import { linklabPlugin } from './index.js'
11
+
12
+ const require = createRequire(import.meta.url)
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
+ const dataDir = path.join(__dirname, '../examples/netflix/data')
15
+
16
+ const compiledGraph = require('../examples/netflix/compiled-graph.json')
17
+ const movies = require(path.join(dataDir, 'movies.json'))
18
+ const people = require(path.join(dataDir, 'people.json'))
19
+ const credits = require(path.join(dataDir, 'credits.json'))
20
+ const categories = require(path.join(dataDir, 'categories.json'))
21
+ const departments = require(path.join(dataDir, 'departments.json'))
22
+ const jobs = require(path.join(dataDir, 'jobs.json'))
23
+
24
+ const fastify = Fastify({ logger: true })
25
+
26
+ await fastify.register(linklabPlugin, {
27
+ graph: compiledGraph,
28
+ prefix: '/api',
29
+ global: { domain: 'netflix', version: 'v1' },
30
+
31
+ dataLoader: {
32
+ dataset: { movies, people, credits, categories, departments, jobs }
33
+ },
34
+
35
+ extractUser: async (req) => {
36
+ const auth = req.headers.authorization
37
+ if (!auth) return {}
38
+ return { userId: 'u_123', subscription: 'premium', locale: 'fr-FR' }
39
+ },
40
+
41
+ onEngine: (engine, req) => {
42
+ const accessHandler: any = async (ctx: any) => {
43
+ const protected_ = ['movies', 'series', 'episodes']
44
+ if (protected_.includes(ctx.node) && !ctx.trail?.user?.subscription) {
45
+ return { cancelled: true, reason: 'subscription_required' }
46
+ }
47
+ return undefined
48
+ }
49
+ engine.hooks.on('access.check', accessHandler)
50
+ engine.events.on('traversal.complete', ({ routeUsed, durationMs }) => {
51
+ fastify.log.info({ routeUsed, durationMs }, 'traversal')
52
+ })
53
+ engine.errors.on('route.notfound', ({ from, to }) => {
54
+ fastify.log.warn({ from, to }, 'route not found')
55
+ })
56
+ },
57
+ })
58
+
59
+ await fastify.listen({ port: 3000 })
@@ -0,0 +1,87 @@
1
+ 1. packages/linklab-http/README.md
2
+ Ce document présente le bridge entre le moteur LinkLab et le monde Web via Fastify.
3
+
4
+ Markdown
5
+
6
+ # @linklab/http
7
+
8
+ Ce package fournit l'intégration HTTP officielle pour LinkLab, permettant d'exposer un graphe sémantique sous forme d'API **HATEOAS** automatisée.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pnpm add @linklab/http
14
+ ```
15
+
16
+ Usage (Fastify Plugin)
17
+ Le plugin linklabPlugin transforme vos définitions de graphes en routes API prêtes à l'emploi.
18
+
19
+ ```typeScript
20
+ import Fastify from 'fastify'
21
+ import { linklabPlugin } from '@linklab/http'
22
+
23
+ const fastify = Fastify()
24
+
25
+ await fastify.register(linklabPlugin, {
26
+ graph: semanticGraph, // Le graphe logique (noeuds/arêtes)
27
+ compiledGraph: compiledGraph, // Le graphe compilé pour la navigation
28
+ prefix: '/api', // Préfixe de l'API
29
+ dataLoader: {
30
+ dataset: { movies, people, credits, ... }
31
+ }
32
+ })
33
+
34
+ fastify.listen({ port: 3000 })
35
+ ```
36
+
37
+ ## 1. Fonctionnalités
38
+
39
+ - HATEOAS Automatique : Injection de l'objet \_links dans chaque réponse.
40
+ - Navigation Récursive : Support des chemins complexes (ex: /movies/278/credits/people).
41
+ - Découvrabilité : Navigation fluide de parent à enfant (self, up, et relations nommées).
42
+ - Instrumentation : Compatible avec @linklab/telemetry pour le monitoring de la tension API.
43
+
44
+ ---
45
+
46
+ ## 2. docs/architecture/hateoas.md
47
+
48
+ Ce document détaille la logique de navigation par liens hypermédias.
49
+
50
+ ````markdown
51
+ # Architecture HATEOAS dans LinkLab
52
+
53
+ L'implémentation HATEOAS (_Hypermedia as the Engine of Application State_) de LinkLab repose sur la structure du graphe sémantique pour générer dynamiquement des liens de navigation.
54
+
55
+ ## Concept de Navigation
56
+
57
+ Contrairement aux API REST classiques où les URLs sont codées en dur, LinkLab utilise les arêtes (edges) du graphe pour déterminer les transitions d'état possibles.
58
+
59
+ ### Structure d'une Réponse
60
+
61
+ Chaque entité renvoyée par le `linklabPlugin` est enrichie d'un bloc `_links` :
62
+
63
+ - **self** : L'URL unique de la ressource actuelle.
64
+ - **up** : Le lien vers la collection parente (nœud précédent dans la hiérarchie).
65
+ - **Relations** : Liens vers les nœuds adjacents définis dans le graphe (ex: `credits`, `categories`, `people`).
66
+
67
+ ### Exemple de Payload
68
+
69
+ Pour une requête sur `/api/movies/278` :
70
+
71
+ ```json
72
+ {
73
+ "id": 278,
74
+ "title": "The Shawshank Redemption",
75
+ "_links": {
76
+ "self": { "href": "/api/movies/278", "method": "GET" },
77
+ "up": { "href": "/api/movies", "title": "Collection movies" },
78
+ "credits": { "href": "/api/movies/278/credits", "title": "LIST_OF_CREDITS" },
79
+ "people": { "href": "/api/movies/278/people", "title": "actor" }
80
+ }
81
+ }
82
+ ```
83
+ ````
84
+
85
+ ## 3. Résolution des Chemins
86
+
87
+ Le NavigationEngine de LinkLab est sollicité à chaque requête pour valider que le chemin demandé existe dans le graphe compilé avant de déléguer l'extraction des données au QueryEngine.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * http — Exports du module HTTP LinkLab
3
+ *
4
+ * Point d'entrée unique pour le plugin Fastify et
5
+ * les utilitaires HTTP associés.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { linklabPlugin, LinkBuilder, TrailParser } from '@linklab/http'
10
+ *
11
+ * await fastify.register(linklabPlugin, {
12
+ * graph: compiledGraph,
13
+ * prefix: '/api',
14
+ * onEngine: (engine, req) => {
15
+ * engine.hooks.on('access.check', async (ctx) => {
16
+ * if (!ctx.trail.user.userId) {
17
+ * return { cancelled: true, reason: 'unauthenticated' }
18
+ * }
19
+ * })
20
+ * }
21
+ * })
22
+ * ```
23
+ */
24
+
25
+ export { linklabPlugin } from './plugin.js'
26
+ export { LinkBuilder } from './LinkBuilder.js'
27
+ export { DataLoader } from '../runtime/DataLoader.js'
28
+
29
+ export type { LinklabPluginOptions } from './plugin.js'
30
+ export type { TrailResponse, ResponseMeta } from './plugin.js'
31
+ export type { HateoasLink, HateoasLinks, LinkBuilderOptions } from './LinkBuilder.js'
32
+ export type { UserContextExtractor } from './TrailRequest.js'
33
+ export type { DataLoaderOptions } from '../runtime/DataLoader.js'