@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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkLab Fastify Plugin
|
|
3
|
+
*
|
|
4
|
+
* Transforme automatiquement chaque requête HTTP en Trail,
|
|
5
|
+
* résout la navigation via le graphe, et retourne une réponse
|
|
6
|
+
* HATEOAS Level 3 — liens générés automatiquement depuis le graphe.
|
|
7
|
+
*
|
|
8
|
+
* Usage minimal :
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import Fastify from 'fastify'
|
|
11
|
+
* import { linklabPlugin } from '@linklab/http'
|
|
12
|
+
*
|
|
13
|
+
* const fastify = Fastify()
|
|
14
|
+
* await fastify.register(linklabPlugin, {
|
|
15
|
+
* graph: compiledGraph,
|
|
16
|
+
* prefix: '/api'
|
|
17
|
+
* })
|
|
18
|
+
* await fastify.listen({ port: 3000 })
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Toutes ces routes fonctionnent sans configuration supplémentaire :
|
|
22
|
+
* GET /api/people
|
|
23
|
+
* GET /api/people/Nolan
|
|
24
|
+
* GET /api/people/Nolan/movies
|
|
25
|
+
* GET /api/people/Nolan/movies/1/actors
|
|
26
|
+
*
|
|
27
|
+
* Hooks disponibles sur chaque requête :
|
|
28
|
+
* ```typescript
|
|
29
|
+
* fastify.register(linklabPlugin, {
|
|
30
|
+
* graph,
|
|
31
|
+
* onEngine: (engine, req) => {
|
|
32
|
+
* engine.hooks.on('access.check', async (ctx) => {
|
|
33
|
+
* if (!ctx.trail.user.userId) {
|
|
34
|
+
* return { cancelled: true, reason: 'unauthenticated' }
|
|
35
|
+
* }
|
|
36
|
+
* })
|
|
37
|
+
* }
|
|
38
|
+
* })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type {
|
|
43
|
+
FastifyPluginAsync,
|
|
44
|
+
FastifyRequest,
|
|
45
|
+
FastifyReply,
|
|
46
|
+
} from 'fastify'
|
|
47
|
+
import fp from 'fastify-plugin'
|
|
48
|
+
|
|
49
|
+
import type { Graph, CompiledGraph } from '../types/index.js'
|
|
50
|
+
import { Trail } from '../navigation/Trail.js'
|
|
51
|
+
import { TrailParser } from '../navigation/TrailParser.js'
|
|
52
|
+
import { NavigationEngine } from '../navigation/NavigationEngine.js'
|
|
53
|
+
import { LinkBuilder } from './LinkBuilder.js'
|
|
54
|
+
import { DataLoader, type DataLoaderOptions } from '../runtime/DataLoader.js'
|
|
55
|
+
import {
|
|
56
|
+
defaultUserExtractor,
|
|
57
|
+
type UserContextExtractor,
|
|
58
|
+
} from './TrailRequest.js'
|
|
59
|
+
|
|
60
|
+
// ── Types ─────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface LinklabPluginOptions {
|
|
63
|
+
/** Le graphe sémantique — navigation, LinkBuilder, Resolver */
|
|
64
|
+
graph: Graph
|
|
65
|
+
|
|
66
|
+
/** Le graphe compilé — routes SQL optimales pour DataLoader/QueryEngine */
|
|
67
|
+
compiledGraph?: CompiledGraph
|
|
68
|
+
|
|
69
|
+
/** Préfixe URL — ex: '/api' ou '/api/v1' */
|
|
70
|
+
prefix?: string
|
|
71
|
+
|
|
72
|
+
/** Contexte global injecté dans chaque Trail */
|
|
73
|
+
global?: Record<string, any>
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extracteur de contexte utilisateur.
|
|
77
|
+
* Par défaut : lit req.user ou req.session.user
|
|
78
|
+
*/
|
|
79
|
+
extractUser?: UserContextExtractor
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Hook appelé après création du moteur, avant résolution.
|
|
83
|
+
* C'est ici que Netflix branche sa logique métier.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* onEngine: (engine, req) => {
|
|
88
|
+
* engine.hooks.on('access.check', async (ctx) => {
|
|
89
|
+
* if (!ctx.trail.user.subscription) {
|
|
90
|
+
* return { cancelled: true, reason: 'subscription_required' }
|
|
91
|
+
* }
|
|
92
|
+
* })
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
onEngine?: (engine: NavigationEngine, req: FastifyRequest) => void | Promise<void>
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Options du DataLoader — source de données réelles.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* // Mode JSON (Netflix mock / tests)
|
|
104
|
+
* dataLoader: { dataset: { movies, people, credits } }
|
|
105
|
+
*
|
|
106
|
+
* // Mode SQL (PostgreSQL)
|
|
107
|
+
* dataLoader: { provider: postgresProvider }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
dataLoader?: DataLoaderOptions
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Transforme les données avant envoi.
|
|
114
|
+
* Utile pour la pagination, la sérialisation custom, etc.
|
|
115
|
+
*/
|
|
116
|
+
transformData?: (data: any, trail: Trail) => any
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Format de réponse ─────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface TrailResponse {
|
|
122
|
+
data: any
|
|
123
|
+
_links: Record<string, any>
|
|
124
|
+
_trail: string
|
|
125
|
+
_meta: ResponseMeta
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ResponseMeta {
|
|
129
|
+
entity: string
|
|
130
|
+
depth: number
|
|
131
|
+
resolved: number
|
|
132
|
+
timing: number
|
|
133
|
+
count?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Helper — vérifie qu'un node est exposé ───────────────────
|
|
137
|
+
// Un node sans flag exposed (graphe ancien) est considéré exposé
|
|
138
|
+
// pour assurer la rétrocompatibilité.
|
|
139
|
+
// Un node avec exposed: false est bloqué.
|
|
140
|
+
|
|
141
|
+
function isExposed(graph: Graph, entity: string): boolean {
|
|
142
|
+
const node = graph.nodes.find(n => n.id === entity)
|
|
143
|
+
if (!node) return false
|
|
144
|
+
// Rétrocompatibilité : si exposed n'est pas défini, on expose
|
|
145
|
+
if (node.exposed === undefined) return true
|
|
146
|
+
return node.exposed === true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Plugin ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const linklabPluginImpl: FastifyPluginAsync<LinklabPluginOptions> = async (
|
|
152
|
+
fastify,
|
|
153
|
+
options
|
|
154
|
+
) => {
|
|
155
|
+
const {
|
|
156
|
+
graph,
|
|
157
|
+
prefix = '',
|
|
158
|
+
global = {},
|
|
159
|
+
extractUser = defaultUserExtractor,
|
|
160
|
+
onEngine,
|
|
161
|
+
dataLoader,
|
|
162
|
+
transformData,
|
|
163
|
+
} = options
|
|
164
|
+
|
|
165
|
+
const linkBuilder = new LinkBuilder(graph, { prefix })
|
|
166
|
+
|
|
167
|
+
// compiledGraph peut être passé séparément (semantic graph + compiled graph distincts)
|
|
168
|
+
// ou graph peut lui-même être un CompiledGraph (avec .routes)
|
|
169
|
+
const effectiveCompiled: CompiledGraph | null =
|
|
170
|
+
options.compiledGraph
|
|
171
|
+
?? ('routes' in graph && Array.isArray((graph as any).routes) ? graph as any : null)
|
|
172
|
+
|
|
173
|
+
const loader = (dataLoader && effectiveCompiled)
|
|
174
|
+
? new DataLoader(effectiveCompiled, dataLoader)
|
|
175
|
+
: null
|
|
176
|
+
|
|
177
|
+
if (dataLoader && !effectiveCompiled) {
|
|
178
|
+
fastify.log.warn('[LinkLab] dataLoader ignoré : graph doit être un CompiledGraph (avec .routes)')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Décorer chaque request avec trail + linkBuilder ─────────
|
|
182
|
+
fastify.decorateRequest('trail', null as any)
|
|
183
|
+
fastify.decorateRequest('linkBuilder', null as any)
|
|
184
|
+
|
|
185
|
+
// ── Hook preHandler — parse le Trail avant chaque requête ───
|
|
186
|
+
fastify.addHook('preHandler', async (req: FastifyRequest) => {
|
|
187
|
+
const userCtx = await extractUser(req)
|
|
188
|
+
|
|
189
|
+
const rawPath = req.url.split('?')[0]
|
|
190
|
+
const path = prefix ? rawPath.replace(new RegExp(`^${prefix}`), '') : rawPath
|
|
191
|
+
|
|
192
|
+
const trail = TrailParser.fromPath(path, {
|
|
193
|
+
global: { ...global },
|
|
194
|
+
user: userCtx,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
;(req as any).trail = trail
|
|
198
|
+
;(req as any).linkBuilder = linkBuilder
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ── Routes génériques — capture tous les paths ─────────────
|
|
202
|
+
const routePath = prefix ? `${prefix}/*` : '/*'
|
|
203
|
+
const rootPath = prefix || '/'
|
|
204
|
+
|
|
205
|
+
// Route pour le prefix exact ex: GET /api
|
|
206
|
+
fastify.get(rootPath, async (
|
|
207
|
+
req: FastifyRequest,
|
|
208
|
+
reply: FastifyReply
|
|
209
|
+
) => {
|
|
210
|
+
const links = buildRootLinks(graph, prefix)
|
|
211
|
+
return { data: null, _links: links, _trail: '', _meta: { entity: 'root', depth: 0, resolved: 0, timing: 0 } }
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
fastify.get(routePath, async (
|
|
215
|
+
req: FastifyRequest,
|
|
216
|
+
reply: FastifyReply
|
|
217
|
+
): Promise<TrailResponse> => {
|
|
218
|
+
const trail = (req as any).trail as Trail
|
|
219
|
+
const start = Date.now()
|
|
220
|
+
|
|
221
|
+
// Trail vide = index — retourner les nœuds racines du graphe
|
|
222
|
+
if (trail.depth === 0) {
|
|
223
|
+
const rootLinks = buildRootLinks(graph, prefix)
|
|
224
|
+
return {
|
|
225
|
+
data: null,
|
|
226
|
+
_links: rootLinks,
|
|
227
|
+
_trail: '',
|
|
228
|
+
_meta: { entity: 'root', depth: 0, resolved: 0, timing: Date.now() - start },
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Vérifier que toutes les frames du Trail pointent vers des nodes exposés
|
|
233
|
+
// On vérifie chaque entité du Trail — si l'une est non exposée → 404.
|
|
234
|
+
// Cela couvre aussi les vues sémantiques : leur entité cible résolue
|
|
235
|
+
// est vérifiée au moment de la résolution du Trail.
|
|
236
|
+
for (const frame of trail.frames) {
|
|
237
|
+
if (!isExposed(graph, frame.entity)) {
|
|
238
|
+
reply.code(404)
|
|
239
|
+
return reply.send({
|
|
240
|
+
error: 'NOT_FOUND',
|
|
241
|
+
reason: `Entity '${frame.entity}' is not exposed`,
|
|
242
|
+
_trail: TrailParser.toFluent(trail),
|
|
243
|
+
}) as any
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Créer le moteur de navigation ─────────────────────────
|
|
248
|
+
const engine = NavigationEngine.forNavigation(graph, { trail })
|
|
249
|
+
|
|
250
|
+
// Laisser Netflix (ou tout autre app) brancher ses hooks
|
|
251
|
+
if (onEngine) {
|
|
252
|
+
await onEngine(engine, req)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Résoudre le Trail ─────────────────────────────────────
|
|
256
|
+
const results = await engine.run(trail.depth + 1)
|
|
257
|
+
const lastResult = results[results.length - 1]
|
|
258
|
+
|
|
259
|
+
// ── Gérer les erreurs de navigation ───────────────────────
|
|
260
|
+
if (lastResult?.result?.type === 'FAIL') {
|
|
261
|
+
const reason = lastResult.result.reason ?? 'Navigation failed'
|
|
262
|
+
|
|
263
|
+
if (reason.includes('notfound') || reason.includes('Aucun chemin')) {
|
|
264
|
+
reply.code(404)
|
|
265
|
+
return reply.send({
|
|
266
|
+
error: 'NOT_FOUND',
|
|
267
|
+
reason,
|
|
268
|
+
_trail: TrailParser.toFluent(trail),
|
|
269
|
+
}) as any
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (reason.includes('forbidden') || reason.includes('denied') || reason.includes('unauthenticated')) {
|
|
273
|
+
reply.code(403)
|
|
274
|
+
return reply.send({
|
|
275
|
+
error: 'FORBIDDEN',
|
|
276
|
+
reason,
|
|
277
|
+
_trail: TrailParser.toFluent(trail),
|
|
278
|
+
}) as any
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
reply.code(400)
|
|
282
|
+
return reply.send({
|
|
283
|
+
error: 'BAD_REQUEST',
|
|
284
|
+
reason,
|
|
285
|
+
_trail: TrailParser.toFluent(trail),
|
|
286
|
+
}) as any
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Charger les données via DataLoader ───────────────────
|
|
290
|
+
if (loader) {
|
|
291
|
+
await loader.load(engine.trail)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Récupérer les données de la frame courante ────────────
|
|
295
|
+
const current = engine.trail.current
|
|
296
|
+
const rawData = current?.data ?? null
|
|
297
|
+
const data = transformData ? transformData(rawData, engine.trail) : rawData
|
|
298
|
+
|
|
299
|
+
// ── Générer les liens HATEOAS ─────────────────────────────
|
|
300
|
+
const links = linkBuilder.from(engine.trail)
|
|
301
|
+
|
|
302
|
+
// Si data est une liste, enrichir chaque item avec ses liens
|
|
303
|
+
const isCollection = Array.isArray(data)
|
|
304
|
+
let responseData = data
|
|
305
|
+
|
|
306
|
+
if (isCollection && data.length > 0) {
|
|
307
|
+
const itemLinks = linkBuilder.forItems(engine.trail, data)
|
|
308
|
+
responseData = data.map((item: any, i: number) => ({
|
|
309
|
+
...item,
|
|
310
|
+
_links: itemLinks[i],
|
|
311
|
+
}))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Construire la réponse ─────────────────────────────────
|
|
315
|
+
const resolved = engine.trail.frames.filter(f => f.state === 'RESOLVED').length
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
data: responseData,
|
|
319
|
+
_links: links,
|
|
320
|
+
_trail: TrailParser.toFluent(engine.trail),
|
|
321
|
+
_meta: {
|
|
322
|
+
entity: current?.entity ?? '',
|
|
323
|
+
depth: engine.trail.depth,
|
|
324
|
+
resolved,
|
|
325
|
+
timing: Date.now() - start,
|
|
326
|
+
count: isCollection ? data.length : undefined,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Helper — liens racines ────────────────────────────────────
|
|
333
|
+
// Filtre les nodes non exposés des liens racines.
|
|
334
|
+
|
|
335
|
+
function buildRootLinks(graph: Graph, prefix: string): Record<string, any> {
|
|
336
|
+
const hasIncoming = new Set(graph.edges.map(e => e.to))
|
|
337
|
+
const roots = graph.nodes.filter(n =>
|
|
338
|
+
!hasIncoming.has(n.id) && isExposed(graph, n.id)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
const links: Record<string, any> = {
|
|
342
|
+
self: { href: prefix || '/', method: 'GET' }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const node of roots) {
|
|
346
|
+
links[node.id] = {
|
|
347
|
+
href: `${prefix}/${node.id}`,
|
|
348
|
+
method: 'GET',
|
|
349
|
+
rel: node.id,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return links
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Export avec fastify-plugin (preserve encapsulation) ───────
|
|
357
|
+
export const linklabPlugin = fp(linklabPluginImpl, {
|
|
358
|
+
fastify: '4.x || 5.x',
|
|
359
|
+
name: 'linklab',
|
|
360
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @linklab/core — Point d'entrée public
|
|
3
|
+
*
|
|
4
|
+
* Trois zones d'export, du plus utilisé au plus technique :
|
|
5
|
+
*
|
|
6
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ API — ce que 80% des utilisateurs importent │
|
|
8
|
+
* │ BUILD — pipeline schema → graph (setup, CI) │
|
|
9
|
+
* │ HTTP — plugin Fastify HATEOAS (optionnel) │
|
|
10
|
+
* │ ADVANCED — internals pour extensions et tests │
|
|
11
|
+
* └─────────────────────────────────────────────────────────┘
|
|
12
|
+
*
|
|
13
|
+
* Usage typique :
|
|
14
|
+
*
|
|
15
|
+
* import { Graph, Strategy } from '@linklab/core'
|
|
16
|
+
*
|
|
17
|
+
* const cinema = new Graph(graphJson, { compiled, dataset }).domain()
|
|
18
|
+
* const cast = await cinema.movies(278).people
|
|
19
|
+
* const route = cinema.from('Pigalle').to('Alesia').path(Strategy.Comfort())
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ══════════════════════════════════════════════════════════════
|
|
23
|
+
// API — Surface publique principale
|
|
24
|
+
// import { Graph, Strategy } from '@linklab/core'
|
|
25
|
+
// ══════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
export { Graph } from './api/Graph.js'
|
|
28
|
+
export { Strategy } from './api/types.js'
|
|
29
|
+
export type {
|
|
30
|
+
PathResult,
|
|
31
|
+
ResolvedPath,
|
|
32
|
+
PathStep,
|
|
33
|
+
QueryResult,
|
|
34
|
+
PathBuilderOptions
|
|
35
|
+
} from './api/types.js'
|
|
36
|
+
|
|
37
|
+
// PathBuilder est retourné par graph.from() — exposé pour le typage
|
|
38
|
+
export { PathBuilder } from './api/PathBuilder.js'
|
|
39
|
+
|
|
40
|
+
// ══════════════════════════════════════════════════════════════
|
|
41
|
+
// BUILD — Pipeline schema → graph
|
|
42
|
+
// Usage : scripts de génération, CI, pipeline.ts
|
|
43
|
+
// import { SchemaExtractor, GraphCompiler } from '@linklab/core/build'
|
|
44
|
+
// ══════════════════════════════════════════════════════════════
|
|
45
|
+
|
|
46
|
+
export { SchemaExtractor } from './schema/SchemaExtractor.js'
|
|
47
|
+
export { JsonSchemaExtractor } from './schema/JsonSchemaExtractor.js'
|
|
48
|
+
export { SchemaAnalyzer } from './schema/SchemaAnalyzer.js'
|
|
49
|
+
export { GraphBuilder } from './schema/GraphBuilder.js'
|
|
50
|
+
export { GraphAssembler } from './graph/GraphAssembler.js'
|
|
51
|
+
export { GraphCompiler } from './graph/GraphCompiler.js'
|
|
52
|
+
export { GraphTrainer } from './graph/GraphTrainer.js'
|
|
53
|
+
export { GraphOptimizer } from './graph/GraphOptimizer.js'
|
|
54
|
+
export { GraphExtractor } from './graph/GraphExtractor.js'
|
|
55
|
+
|
|
56
|
+
// ══════════════════════════════════════════════════════════════
|
|
57
|
+
// HTTP — Plugin Fastify HATEOAS
|
|
58
|
+
// Usage : apps serveur
|
|
59
|
+
// import { linklabPlugin } from '@linklab/core'
|
|
60
|
+
// ══════════════════════════════════════════════════════════════
|
|
61
|
+
|
|
62
|
+
export { linklabPlugin } from './http/plugin.js'
|
|
63
|
+
export { LinkBuilder } from './http/LinkBuilder.js'
|
|
64
|
+
export type { LinklabPluginOptions } from './http/plugin.js'
|
|
65
|
+
|
|
66
|
+
// ══════════════════════════════════════════════════════════════
|
|
67
|
+
// ADVANCED — Internals pour extensions, providers, tests
|
|
68
|
+
// Stable mais sans garantie de compatibilité entre versions
|
|
69
|
+
// ══════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
// Moteur de navigation bas niveau
|
|
72
|
+
export { NavigationEngine } from './navigation/NavigationEngine.js'
|
|
73
|
+
export { Resolver } from './navigation/Resolver.js'
|
|
74
|
+
export { Scheduler } from './navigation/Scheduler.js'
|
|
75
|
+
export { Trail } from './navigation/Trail.js'
|
|
76
|
+
export { TrailParser } from './navigation/TrailParser.js'
|
|
77
|
+
|
|
78
|
+
// Runtime
|
|
79
|
+
export { QueryEngine } from './runtime/QueryEngine.js'
|
|
80
|
+
export { Engine } from './runtime/Engine.js'
|
|
81
|
+
export { DataLoader } from './runtime/DataLoader.js'
|
|
82
|
+
|
|
83
|
+
// Providers
|
|
84
|
+
export { PostgresProvider } from './providers/PostgresProvider.js'
|
|
85
|
+
export { MockProvider } from './providers/MockProvider.js'
|
|
86
|
+
|
|
87
|
+
// Formatters
|
|
88
|
+
export type { PathFormatter } from './formatters/BaseFormatter.js'
|
|
89
|
+
|
|
90
|
+
// PathFinder — algorithme Dijkstra brut
|
|
91
|
+
export { PathFinder } from './core/PathFinder.js'
|
|
92
|
+
|
|
93
|
+
// ══════════════════════════════════════════════════════════════
|
|
94
|
+
// TYPES — tous les types internes
|
|
95
|
+
// Pour les utilisateurs qui construisent sur les internals
|
|
96
|
+
// ══════════════════════════════════════════════════════════════
|
|
97
|
+
|
|
98
|
+
export type {
|
|
99
|
+
Graph as GraphData, // renommé pour éviter le conflit avec la classe Graph
|
|
100
|
+
GraphNode,
|
|
101
|
+
GraphEdge,
|
|
102
|
+
CompiledGraph,
|
|
103
|
+
RouteInfo,
|
|
104
|
+
Dictionary,
|
|
105
|
+
Frame,
|
|
106
|
+
Path,
|
|
107
|
+
PathQuery
|
|
108
|
+
} from './types/index.js'
|
|
109
|
+
|
|
110
|
+
export type { MetricsMap, TrainingMetrics, UseCase } from './types/index.js'
|
|
111
|
+
|
|
112
|
+
// ── Instrumentation — opt-in telemetry bridge ─────────────────────────────
|
|
113
|
+
export {
|
|
114
|
+
injectTelemetry,
|
|
115
|
+
resetTelemetry,
|
|
116
|
+
preloadTelemetry,
|
|
117
|
+
shim
|
|
118
|
+
} from './instrumentation/TelemetryShim.js'
|
|
119
|
+
export type { TelemetryModule } from './instrumentation/TelemetryShim.js'
|
|
120
|
+
|
|
121
|
+
export type { ExposeConfig } from './types/index.js'
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TelemetryShim.ts — Pont opt-in entre @linklab/core et @linklab/telemetry
|
|
3
|
+
*
|
|
4
|
+
* @linklab/core ne dépend PAS de @linklab/telemetry.
|
|
5
|
+
*
|
|
6
|
+
* Deux modes d'activation :
|
|
7
|
+
*
|
|
8
|
+
* 1. INJECTION (recommandé, toujours fiable) :
|
|
9
|
+
* L'appelant qui connaît les deux packages injecte les modules directement.
|
|
10
|
+
* Utilisé dans les tests (@linklab/telemetry) et en production (Netflix-backend).
|
|
11
|
+
*
|
|
12
|
+
* import { injectTelemetry } from '@linklab/core'
|
|
13
|
+
* import { SpanBuilder, traceBus } from '@linklab/telemetry'
|
|
14
|
+
* injectTelemetry({ SpanBuilder, traceBus })
|
|
15
|
+
*
|
|
16
|
+
* 2. PRELOAD (production uniquement) :
|
|
17
|
+
* Import dynamique — fonctionne si @linklab/telemetry est installé ET
|
|
18
|
+
* accessible depuis le même module resolver que @linklab/core.
|
|
19
|
+
* Ne pas utiliser dans les tests (résolution cross-package impossible sous Vitest).
|
|
20
|
+
*
|
|
21
|
+
* import { preloadTelemetry } from '@linklab/core'
|
|
22
|
+
* await preloadTelemetry()
|
|
23
|
+
*
|
|
24
|
+
* Sans activation → toutes les opérations sont des no-ops silencieux.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ── Types minimaux inlinés — pas d'import depuis @linklab/telemetry ───────────
|
|
28
|
+
|
|
29
|
+
interface MinimalSpanBuilder {
|
|
30
|
+
stepStart(step: string): this
|
|
31
|
+
stepEnd(step: string): this
|
|
32
|
+
addCacheEvent(event: {
|
|
33
|
+
level: 'L1' | 'L2' | 'MISS'
|
|
34
|
+
hit: boolean
|
|
35
|
+
entity?: string
|
|
36
|
+
promoted: boolean
|
|
37
|
+
yoyo?: boolean
|
|
38
|
+
}): this
|
|
39
|
+
end(opts: { rowCount: number }): MinimalSpan
|
|
40
|
+
endWithError(err: Error, state: MinimalEngineState): MinimalSpan
|
|
41
|
+
withPath?(path: string[]): this
|
|
42
|
+
withFilters?(filters: Record<string, any>): this
|
|
43
|
+
readonly routeKey: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MinimalSpan {
|
|
47
|
+
spanId: string
|
|
48
|
+
traceId: string
|
|
49
|
+
timestamp: number
|
|
50
|
+
trail: string
|
|
51
|
+
from: string
|
|
52
|
+
to: string
|
|
53
|
+
path: string[]
|
|
54
|
+
filters: Record<string, any>
|
|
55
|
+
timings: any[]
|
|
56
|
+
totalMs: number
|
|
57
|
+
cacheEvents: any[]
|
|
58
|
+
rowCount: number
|
|
59
|
+
error?: any
|
|
60
|
+
metrics?: any
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface MinimalEngineState {
|
|
64
|
+
compiledGraphHash: string
|
|
65
|
+
weights: Record<string, number>
|
|
66
|
+
cacheState: {
|
|
67
|
+
l1HitRate: number
|
|
68
|
+
l2HitRate: number
|
|
69
|
+
globalHitRate: number
|
|
70
|
+
yoyoEvents: number
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TelemetryModule {
|
|
75
|
+
SpanBuilder: {
|
|
76
|
+
start(opts: { trail: string; from: string; to: string; traceId?: string }): MinimalSpanBuilder
|
|
77
|
+
}
|
|
78
|
+
traceBus: {
|
|
79
|
+
emit(event: 'span:end' | 'span:error', span: MinimalSpan): void
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Registre interne ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
let _module: TelemetryModule | null = null
|
|
86
|
+
let _attempted = false
|
|
87
|
+
|
|
88
|
+
// ── API d'injection ───────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Injecte les composants de @linklab/telemetry dans le shim.
|
|
92
|
+
* Méthode universelle — fonctionne dans tous les contextes (tests, prod).
|
|
93
|
+
* Prend effet immédiatement et de manière synchrone.
|
|
94
|
+
*/
|
|
95
|
+
export function injectTelemetry(module: TelemetryModule): void {
|
|
96
|
+
_module = module
|
|
97
|
+
_attempted = true // bloquer tout preloadTelemetry() ultérieur
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Réinitialise le shim — utile pour les tests d'isolation.
|
|
102
|
+
*/
|
|
103
|
+
export function resetTelemetry(): void {
|
|
104
|
+
_module = null
|
|
105
|
+
_attempted = false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Précharge le module telemetry via import dynamique.
|
|
110
|
+
* Uniquement pour la production (Netflix-backend) où les deux packages
|
|
111
|
+
* partagent le même module resolver Node.js.
|
|
112
|
+
* Ne pas utiliser dans les tests — préférer injectTelemetry().
|
|
113
|
+
*/
|
|
114
|
+
export async function preloadTelemetry(): Promise<void> {
|
|
115
|
+
if (_attempted) return
|
|
116
|
+
_attempted = true
|
|
117
|
+
try {
|
|
118
|
+
const specifier = '@linklab/telemetry'
|
|
119
|
+
const mod = await new Function('s', 'return import(s)')(specifier)
|
|
120
|
+
_module = mod as TelemetryModule
|
|
121
|
+
} catch {
|
|
122
|
+
_module = null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── API du shim ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export const shim = {
|
|
129
|
+
startSpan(opts: {
|
|
130
|
+
trail: string
|
|
131
|
+
from: string
|
|
132
|
+
to: string
|
|
133
|
+
traceId?: string
|
|
134
|
+
path?: string[]
|
|
135
|
+
filters?: Record<string, any>
|
|
136
|
+
}): MinimalSpanBuilder | null {
|
|
137
|
+
if (!_module) return null
|
|
138
|
+
try {
|
|
139
|
+
const builder = _module.SpanBuilder.start({
|
|
140
|
+
trail: opts.trail,
|
|
141
|
+
from: opts.from,
|
|
142
|
+
to: opts.to,
|
|
143
|
+
traceId: opts.traceId
|
|
144
|
+
})
|
|
145
|
+
if (opts.path) builder.withPath?.(opts.path)
|
|
146
|
+
if (opts.filters) builder.withFilters?.(opts.filters)
|
|
147
|
+
return builder
|
|
148
|
+
} catch {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
emitEnd(span: MinimalSpan): void {
|
|
154
|
+
if (!_module) return
|
|
155
|
+
try {
|
|
156
|
+
_module.traceBus.emit('span:end', span)
|
|
157
|
+
} catch {}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
emitError(span: MinimalSpan): void {
|
|
161
|
+
if (!_module) return
|
|
162
|
+
try {
|
|
163
|
+
_module.traceBus.emit('span:error', span)
|
|
164
|
+
} catch {}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
get active(): boolean {
|
|
168
|
+
return _module !== null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type { MinimalSpanBuilder, MinimalSpan, MinimalEngineState }
|