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