@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadGraph.ts — Factory universelle pour charger un graphe LinkLab
|
|
3
|
+
*
|
|
4
|
+
* Point d'entrée recommandé pour 80% des cas.
|
|
5
|
+
* Déduit le mode de chargement depuis le paramètre source et l'environnement.
|
|
6
|
+
*
|
|
7
|
+
* Usage :
|
|
8
|
+
* // Node — fichier local
|
|
9
|
+
* const cinema = await loadGraph('./cinema.json', { provider })
|
|
10
|
+
*
|
|
11
|
+
* // Browser ou Node — URL HTTP
|
|
12
|
+
* const cinema = await loadGraph('https://cdn.example.com/cinema.json', { provider })
|
|
13
|
+
*
|
|
14
|
+
* // Données déjà en mémoire (tests, browser, injection)
|
|
15
|
+
* const cinema = await loadGraph({ compiled }, { provider })
|
|
16
|
+
*
|
|
17
|
+
* Convention fichiers (Node) :
|
|
18
|
+
* cinema.json ← graphe compilé (requis)
|
|
19
|
+
* cinema.reference.json ← graphe brut (optionnel — chargé automatiquement)
|
|
20
|
+
* cinema.override.json ← surcharges dev (optionnel — chargé automatiquement)
|
|
21
|
+
*
|
|
22
|
+
* new Graph() reste disponible pour les niveaux 2/3 et les tests unitaires.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Graph } from './Graph.js'
|
|
26
|
+
import type { GraphOptions } from './Graph.js'
|
|
27
|
+
import type { Graph as GraphData, CompiledGraph } from '../types/index.js'
|
|
28
|
+
|
|
29
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Source du graphe compilé */
|
|
32
|
+
export type GraphSource =
|
|
33
|
+
| string // chemin fichier (Node) ou URL http(s) (browser/Node)
|
|
34
|
+
| GraphSourceObject
|
|
35
|
+
|
|
36
|
+
export interface GraphSourceObject {
|
|
37
|
+
compiled: CompiledGraph
|
|
38
|
+
reference?: GraphData
|
|
39
|
+
overrides?: Record<string, any>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Options de loadGraph — étend GraphOptions */
|
|
43
|
+
export interface LoadGraphOptions extends Omit<GraphOptions, 'compiled'> {
|
|
44
|
+
/** Surcharge le chemin du graphe de référence (optionnel) */
|
|
45
|
+
reference?: string
|
|
46
|
+
/** Surcharge le chemin des overrides (optionnel) */
|
|
47
|
+
overrides?: string
|
|
48
|
+
/** Dossier contenant les fichiers JSON de données {entity}.json (mode local) */
|
|
49
|
+
dataDir?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Détection environnement ────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const IS_NODE = typeof process !== 'undefined' && !!process.versions?.node
|
|
55
|
+
const IS_BROWSER = typeof (globalThis as any).window !== 'undefined'
|
|
56
|
+
|
|
57
|
+
// ── Chargement JSON ────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async function loadJSON(source: string): Promise<any> {
|
|
60
|
+
// URL HTTP/HTTPS → fetch (universel)
|
|
61
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
62
|
+
const res = await fetch(source)
|
|
63
|
+
if (!res.ok) throw new Error(`loadGraph: HTTP ${res.status} — ${source}`)
|
|
64
|
+
return res.json()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Chemin fichier → Node uniquement
|
|
68
|
+
if (IS_NODE) {
|
|
69
|
+
const { createRequire } = await import('module')
|
|
70
|
+
const { fileURLToPath } = await import('url')
|
|
71
|
+
const pathModule = await import('path')
|
|
72
|
+
const fsModule = await import('fs')
|
|
73
|
+
|
|
74
|
+
// Résoudre le chemin depuis cwd
|
|
75
|
+
const resolved = pathModule.default.resolve(source)
|
|
76
|
+
if (!fsModule.default.existsSync(resolved)) return null
|
|
77
|
+
|
|
78
|
+
// createRequire depuis cwd pour les JSON
|
|
79
|
+
const req = createRequire(pathModule.default.join(process.cwd(), 'noop.js'))
|
|
80
|
+
return req(resolved)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(`loadGraph: chemin fichier non supporté dans ce contexte — utiliser une URL HTTP`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Derive les chemins convention depuis un chemin de base */
|
|
87
|
+
function deriveConventionPaths(basePath: string): {
|
|
88
|
+
compiled: string
|
|
89
|
+
reference: string
|
|
90
|
+
overrides: string
|
|
91
|
+
dictionary: string
|
|
92
|
+
} {
|
|
93
|
+
// './cinema.json' → base = './cinema'
|
|
94
|
+
// './cinema' → base = './cinema'
|
|
95
|
+
const base = basePath.replace(/\.json$/i, '')
|
|
96
|
+
return {
|
|
97
|
+
compiled: `${base}.json`,
|
|
98
|
+
reference: `${base}.reference.gen.json`,
|
|
99
|
+
overrides: `${base}.override.json`,
|
|
100
|
+
dictionary: `${base}.dictionary.gen.json`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Factory principale ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* loadGraph — charge un graphe LinkLab depuis n'importe quelle source.
|
|
108
|
+
*
|
|
109
|
+
* Retourne directement le proxy sémantique (niveau 1) — prêt à naviguer.
|
|
110
|
+
* Pour accéder au Graph sous-jacent (niveaux 2/3/4) : domain.graph
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* const cinema = await loadGraph('./cinema.json', { provider })
|
|
114
|
+
* await cinema.directors('Nolan').movies
|
|
115
|
+
* cinema.graph.from('movies').to('people').path() // niveau 2
|
|
116
|
+
*/
|
|
117
|
+
export async function loadGraph(
|
|
118
|
+
source: GraphSource,
|
|
119
|
+
options: LoadGraphOptions = {}
|
|
120
|
+
): Promise<ReturnType<Graph['domain']>> {
|
|
121
|
+
|
|
122
|
+
let compiled: CompiledGraph
|
|
123
|
+
let reference: GraphData | null = null
|
|
124
|
+
let overrides: Record<string, any> | null = null
|
|
125
|
+
let dictionary: Record<string, any> | null = null
|
|
126
|
+
|
|
127
|
+
// ── Source objet en mémoire ────────────────────────────────────────────────
|
|
128
|
+
if (typeof source === 'object') {
|
|
129
|
+
compiled = source.compiled
|
|
130
|
+
reference = source.reference ?? null
|
|
131
|
+
overrides = source.overrides ?? null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Source string (chemin ou URL) ─────────────────────────────────────────
|
|
135
|
+
else {
|
|
136
|
+
const isURL = source.startsWith('http://') || source.startsWith('https://')
|
|
137
|
+
|
|
138
|
+
if (isURL) {
|
|
139
|
+
// URL directe — pas de convention (on ne peut pas deviner les URLs sœurs)
|
|
140
|
+
compiled = await loadJSON(source)
|
|
141
|
+
if (!compiled) throw new Error(`loadGraph: impossible de charger ${source}`)
|
|
142
|
+
} else {
|
|
143
|
+
// Chemin fichier — convention {name}.json + {name}.reference.json + {name}.override.json
|
|
144
|
+
const paths = deriveConventionPaths(source)
|
|
145
|
+
|
|
146
|
+
compiled = await loadJSON(options.reference ? source : paths.compiled)
|
|
147
|
+
if (!compiled) throw new Error(`loadGraph: graphe introuvable — ${paths.compiled}`)
|
|
148
|
+
|
|
149
|
+
// Référence (optionnel — chargé silencieusement)
|
|
150
|
+
const refPath = options.reference ?? paths.reference
|
|
151
|
+
reference = await loadJSON(refPath) // null si absent
|
|
152
|
+
|
|
153
|
+
// Overrides (optionnel — chargé silencieusement)
|
|
154
|
+
const ovPath = options.overrides ?? paths.overrides
|
|
155
|
+
overrides = await loadJSON(ovPath) // null si absent
|
|
156
|
+
|
|
157
|
+
// Dictionnaire résolu (optionnel — labels humains des routes)
|
|
158
|
+
dictionary = await loadJSON(paths.dictionary) // null si absent
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Charger dataset depuis dataDir ────────────────────────────────────────
|
|
163
|
+
let dataset = options.dataset ?? null
|
|
164
|
+
|
|
165
|
+
if (!dataset && options.dataDir && IS_NODE) {
|
|
166
|
+
const pathModule = await import('path')
|
|
167
|
+
const fsModule = await import('fs')
|
|
168
|
+
const { createRequire } = await import('module')
|
|
169
|
+
const req = createRequire(pathModule.default.join(process.cwd(), 'noop.js'))
|
|
170
|
+
const dataDirAbs = pathModule.default.resolve(options.dataDir)
|
|
171
|
+
dataset = {}
|
|
172
|
+
for (const node of compiled.nodes) {
|
|
173
|
+
const file = pathModule.default.join(dataDirAbs, `${node.id}.json`)
|
|
174
|
+
if (fsModule.default.existsSync(file)) dataset[node.id] = req(file)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Appliquer les overrides sur le compilé ─────────────────────────────────
|
|
179
|
+
// TODO : deepMerge(compiled, overrides) quand ADR-0008 sera implémenté
|
|
180
|
+
if (overrides && IS_NODE && process.env.LINKLAB_DEBUG) {
|
|
181
|
+
console.warn('[loadGraph] overrides chargés mais pas encore appliqués (ADR-0008 pending)')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Construire le Graph (niveau bas) ──────────────────────────────────────
|
|
185
|
+
const rawGraph: GraphData = reference ?? {
|
|
186
|
+
nodes: compiled.nodes,
|
|
187
|
+
edges: [],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { compiled: _c, reference: _r, overrides: _o, dataDir: _d, dataset: _ds, ...graphOptions } = options as any
|
|
191
|
+
const graph = new Graph(rawGraph, {
|
|
192
|
+
...graphOptions,
|
|
193
|
+
compiled,
|
|
194
|
+
...(dataset ? { dataset } : {}),
|
|
195
|
+
...(dictionary ? { dictionary } : {}),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── Retourner le domain directement ───────────────────────────────────────
|
|
199
|
+
// loadGraph() retourne le proxy sémantique (niveau 1) — pas le Graph brut.
|
|
200
|
+
// Accès au Graph sous-jacent via : const g = domain.graph
|
|
201
|
+
return graph.domain()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Export de commodité ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** Alias — même API, nom plus court pour les imports fréquents */
|
|
207
|
+
export { loadGraph as graph }
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-api.ts — Validation niveau 2 sur les 3 exemples
|
|
3
|
+
*
|
|
4
|
+
* Couvre les deux familles :
|
|
5
|
+
* A) Pathfinding pur — metro, musicians (pas de données)
|
|
6
|
+
* B) Navigation data — netflix (compiled + dataset)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'module'
|
|
10
|
+
import { fileURLToPath } from 'url'
|
|
11
|
+
import path from 'path'
|
|
12
|
+
import { Graph, Strategy } from './index.js'
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url)
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const root = path.join(__dirname, '..')
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const ok = (label: string) => console.log(` ✅ ${label}`)
|
|
21
|
+
const err = (label: string, e: any) => console.log(` ❌ ${label}: ${e?.message ?? e}`)
|
|
22
|
+
const sep = (title: string) => console.log(`\n${'─'.repeat(50)}\n${title}`)
|
|
23
|
+
|
|
24
|
+
// ── Famille A : Pathfinding pur ───────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
sep('METRO — Pathfinding pur')
|
|
27
|
+
try {
|
|
28
|
+
const metro = new Graph(require(`${root}/examples/metro/graph.json`))
|
|
29
|
+
|
|
30
|
+
// Chemin optimal (Shortest)
|
|
31
|
+
const r1 = metro.from('Station-chatelet').to('Station-opera').path()
|
|
32
|
+
r1.found && r1.paths[0].hops > 0
|
|
33
|
+
? ok(`Châtelet→Opéra : ${r1.paths[0].hops} saut(s), poids=${r1.paths[0].weight}`)
|
|
34
|
+
: err('Châtelet→Opéra', 'chemin non trouvé')
|
|
35
|
+
|
|
36
|
+
// Mode confort — pénalise les correspondances
|
|
37
|
+
const r2 = metro.from('Station-republique').to('Station-bastille')
|
|
38
|
+
.paths(Strategy.Comfort())
|
|
39
|
+
r2.found
|
|
40
|
+
? ok(`République→Bastille Comfort : ${r2.paths.length} chemins`)
|
|
41
|
+
: err('République→Bastille', 'non trouvé')
|
|
42
|
+
|
|
43
|
+
// Mode LeastHops
|
|
44
|
+
const r3 = metro.from('Station-gare-du-nord').to('Station-montparnasse-bienvenue')
|
|
45
|
+
.path(Strategy.LeastHops())
|
|
46
|
+
r3.found
|
|
47
|
+
? ok(`GdN→Montparnasse LeastHops : ${r3.paths[0].hops} sauts`)
|
|
48
|
+
: err('GdN→Montparnasse', 'non trouvé')
|
|
49
|
+
|
|
50
|
+
// Introspection
|
|
51
|
+
console.log(`\n graph.entities : ${metro.entities.length} nodes`)
|
|
52
|
+
console.log(` graph.relations : ${metro.relations.length} arêtes`)
|
|
53
|
+
const types = Object.keys(metro.schema)
|
|
54
|
+
console.log(` graph.schema : types = [${types.join(', ')}]`)
|
|
55
|
+
|
|
56
|
+
} catch(e) { err('Metro init', e) }
|
|
57
|
+
|
|
58
|
+
sep('MUSICIANS — Pathfinding avec via + minHops')
|
|
59
|
+
try {
|
|
60
|
+
const music = new Graph(require(`${root}/examples/musicians/graph.json`))
|
|
61
|
+
|
|
62
|
+
// Chaîne sampling Will Smith → Manu Dibango
|
|
63
|
+
const r1 = music.from('artist-will-smith').to('artist-manu-dibango')
|
|
64
|
+
.paths()
|
|
65
|
+
r1.found
|
|
66
|
+
? ok(`Will Smith→Manu Dibango : ${r1.paths.length} chemin(s), meilleur=${r1.paths[0].hops} sauts`)
|
|
67
|
+
: err('Will Smith→Manu Dibango', 'non trouvé')
|
|
68
|
+
|
|
69
|
+
// Chemin d'influence James Brown → Kanye avec minHops
|
|
70
|
+
const builder = music.from('artist-james-brown', { minHops: 1 })
|
|
71
|
+
.to('artist-kanye-west')
|
|
72
|
+
const r2 = builder.paths()
|
|
73
|
+
r2.found
|
|
74
|
+
? ok(`James Brown→Kanye (minHops=1) : ${r2.paths.length} chemin(s)`)
|
|
75
|
+
: err('James Brown→Kanye', 'non trouvé')
|
|
76
|
+
|
|
77
|
+
// .links — vue structurelle
|
|
78
|
+
const l = music.from('artist-daft-punk').to('artist-kanye-west').links
|
|
79
|
+
l.found
|
|
80
|
+
? ok(`.links Daft Punk↔Kanye : ${l.edges.length} arêtes dans le sous-graphe`)
|
|
81
|
+
: err('.links', 'non trouvé')
|
|
82
|
+
|
|
83
|
+
// Steps enrichis — labels lisibles
|
|
84
|
+
if (r1.found && r1.paths[0].steps.length > 0) {
|
|
85
|
+
const labels = r1.paths[0].steps.map(s => s.label ?? s.node).join(' → ')
|
|
86
|
+
ok(`Steps : ${labels}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
} catch(e) { err('Musicians init', e) }
|
|
90
|
+
|
|
91
|
+
sep('NETFLIX — Navigation avec données')
|
|
92
|
+
try {
|
|
93
|
+
const compiled = require(`${root}/examples/netflix/compiled-graph.json`)
|
|
94
|
+
const movies = require(`${root}/scenarios/test-netflix/data/movies.json`)
|
|
95
|
+
const credits = require(`${root}/scenarios/test-netflix/data/credits.json`)
|
|
96
|
+
const people = require(`${root}/scenarios/test-netflix/data/people.json`)
|
|
97
|
+
|
|
98
|
+
const netflix = new Graph(
|
|
99
|
+
require(`${root}/scenarios/test-netflix/graph.json`),
|
|
100
|
+
{ compiled, dataset: { movies, credits, people } }
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Traversée movies → people via execute()
|
|
104
|
+
const r1 = await netflix.from('movies').to('people').execute({ id: 278 })
|
|
105
|
+
r1.data.length > 0
|
|
106
|
+
? ok(`movies(278)→people : ${r1.data.length} personnes en ${r1.timing}ms, path=${r1.path.join('→')}`)
|
|
107
|
+
: err('movies→people', 'data vide')
|
|
108
|
+
|
|
109
|
+
// Traversée people → movies
|
|
110
|
+
const r2 = await netflix.from('people').to('movies').execute({ id: 4027 })
|
|
111
|
+
r2.data.length > 0
|
|
112
|
+
? ok(`people(4027)→movies : ${r2.data.length} films (${(r2.data as any[]).map((m:any)=>m.title).join(', ')})`)
|
|
113
|
+
: err('people→movies', 'data vide')
|
|
114
|
+
|
|
115
|
+
// path() fonctionne aussi (sans données)
|
|
116
|
+
const r3 = netflix.from('movies').to('people').path()
|
|
117
|
+
r3.found
|
|
118
|
+
? ok(`path() movies→people : ${r3.paths[0].nodes.join('→')}`)
|
|
119
|
+
: err('path() movies→people', 'non trouvé')
|
|
120
|
+
|
|
121
|
+
// Introspection
|
|
122
|
+
console.log(`\n graph.entities : ${netflix.entities.length} nodes`)
|
|
123
|
+
console.log(` graph.weights : ${Object.keys(netflix.weights).length} arêtes pondérées`)
|
|
124
|
+
|
|
125
|
+
} catch(e) { err('Netflix init', e) }
|
|
126
|
+
|
|
127
|
+
sep('NIVEAU 4 — Maintenance')
|
|
128
|
+
try {
|
|
129
|
+
const music = new Graph(require(`${root}/examples/musicians/graph.json`))
|
|
130
|
+
|
|
131
|
+
// weight().set()
|
|
132
|
+
const edge = music.relations.find(e => e.name)!
|
|
133
|
+
const g2 = music.weight(edge.name!).set(99)
|
|
134
|
+
const before = music.weights[edge.name!]
|
|
135
|
+
const after = g2.weights[edge.name!]
|
|
136
|
+
before !== after && after === 99
|
|
137
|
+
? ok(`weight('${edge.name}').set(99) : ${before} → ${after}`)
|
|
138
|
+
: err('weight.set', `${before} → ${after}`)
|
|
139
|
+
|
|
140
|
+
// Immuabilité — le graph original n'est pas modifié
|
|
141
|
+
music.weights[edge.name!] === before
|
|
142
|
+
? ok(`Immuabilité préservée : original = ${before}`)
|
|
143
|
+
: err('Immuabilité', 'graph original modifié')
|
|
144
|
+
|
|
145
|
+
// snapshot()
|
|
146
|
+
const snap = music.snapshot()
|
|
147
|
+
snap.graph && !snap.compiled
|
|
148
|
+
? ok(`snapshot() : graph OK, compiled=null (pas de compile())`)
|
|
149
|
+
: err('snapshot', JSON.stringify(snap))
|
|
150
|
+
|
|
151
|
+
} catch(e) { err('Niveau 4', e) }
|
|
152
|
+
|
|
153
|
+
console.log('\n' + '─'.repeat(50))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-domain.ts — Validation niveau 1 : DomainProxy
|
|
3
|
+
*
|
|
4
|
+
* Couvre :
|
|
5
|
+
* - Accès propriété simple cinema.movies
|
|
6
|
+
* - Filtre par ID (number) cinema.people(278)
|
|
7
|
+
* - Filtre par objet cinema.people({ id: 278 })
|
|
8
|
+
* - Traversée thenable await cinema.people(278).movies
|
|
9
|
+
* - Fetch direct await cinema.movies
|
|
10
|
+
* - Chaînage profond await cinema.people(278).movies (depth 2)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createRequire } from 'module'
|
|
14
|
+
import { fileURLToPath } from 'url'
|
|
15
|
+
import path from 'path'
|
|
16
|
+
import { Graph } from './index.js'
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url)
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
const root = path.join(__dirname, '..')
|
|
21
|
+
|
|
22
|
+
const ok = (label: string) => console.log(` ✅ ${label}`)
|
|
23
|
+
const err = (label: string, detail: any) => console.log(` ❌ ${label}: ${detail?.message ?? JSON.stringify(detail)}`)
|
|
24
|
+
const sep = (t: string) => console.log(`\n${'─'.repeat(50)}\n${t}`)
|
|
25
|
+
|
|
26
|
+
// ── Setup Netflix ─────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const compiled = require(`${root}/examples/netflix/compiled-graph.json`)
|
|
29
|
+
const movies = require(`${root}/scenarios/test-netflix/data/movies.json`)
|
|
30
|
+
const credits = require(`${root}/scenarios/test-netflix/data/credits.json`)
|
|
31
|
+
const people = require(`${root}/scenarios/test-netflix/data/people.json`)
|
|
32
|
+
|
|
33
|
+
const cinema = new Graph(
|
|
34
|
+
require(`${root}/scenarios/test-netflix/graph.json`),
|
|
35
|
+
{ compiled, dataset: { movies, credits, people } }
|
|
36
|
+
).domain()
|
|
37
|
+
|
|
38
|
+
sep('NIVEAU 1 — Fetch direct (depth 1)')
|
|
39
|
+
try {
|
|
40
|
+
// await cinema.movies → tous les films
|
|
41
|
+
const r1 = await (cinema as any).movies
|
|
42
|
+
r1.data.length > 0
|
|
43
|
+
? ok(`await cinema.movies → ${r1.data.length} films`)
|
|
44
|
+
: err('cinema.movies', 'vide')
|
|
45
|
+
|
|
46
|
+
// await cinema.people → toutes les personnes
|
|
47
|
+
const r2 = await (cinema as any).people
|
|
48
|
+
r2.data.length > 0
|
|
49
|
+
? ok(`await cinema.people → ${r2.data.length} personnes`)
|
|
50
|
+
: err('cinema.people', 'vide')
|
|
51
|
+
|
|
52
|
+
// await cinema.movies(278) → un seul film
|
|
53
|
+
const r3 = await (cinema as any).movies(278)
|
|
54
|
+
r3.data.length === 1 && r3.data[0].title === 'Les Évadés'
|
|
55
|
+
? ok(`await cinema.movies(278) → "${r3.data[0].title}"`)
|
|
56
|
+
: err('cinema.movies(278)', r3.data)
|
|
57
|
+
|
|
58
|
+
// await cinema.people(4027) → Frank Darabont
|
|
59
|
+
const r4 = await (cinema as any).people(4027)
|
|
60
|
+
r4.data.length === 1 && r4.data[0].name === 'Frank Darabont'
|
|
61
|
+
? ok(`await cinema.people(4027) → "${r4.data[0].name}"`)
|
|
62
|
+
: err('cinema.people(4027)', r4.data)
|
|
63
|
+
|
|
64
|
+
// Filtre par objet
|
|
65
|
+
const r5 = await (cinema as any).people({ id: 4027 })
|
|
66
|
+
r5.data.length === 1
|
|
67
|
+
? ok(`await cinema.people({ id: 4027 }) → "${r5.data[0].name}"`)
|
|
68
|
+
: err('cinema.people({id:4027})', r5.data)
|
|
69
|
+
|
|
70
|
+
} catch(e) { err('Depth 1', e) }
|
|
71
|
+
|
|
72
|
+
sep('NIVEAU 1 — Traversée (depth 2)')
|
|
73
|
+
try {
|
|
74
|
+
// await cinema.people(4027).movies → filmographie Darabont
|
|
75
|
+
const r1 = await (cinema as any).people(4027).movies
|
|
76
|
+
r1.data.length >= 2
|
|
77
|
+
? ok(`await cinema.people(4027).movies → ${r1.data.length} films : ${r1.data.map((m:any)=>m.title).join(', ')}`)
|
|
78
|
+
: err('cinema.people(4027).movies', `${r1.data.length} films`)
|
|
79
|
+
|
|
80
|
+
// await cinema.movies(278).people → cast des Évadés
|
|
81
|
+
const r2 = await (cinema as any).movies(278).people
|
|
82
|
+
r2.data.length >= 10
|
|
83
|
+
? ok(`await cinema.movies(278).people → ${r2.data.length} personnes`)
|
|
84
|
+
: err('cinema.movies(278).people', `${r2.data.length}`)
|
|
85
|
+
|
|
86
|
+
// Trail path
|
|
87
|
+
r1.path.length > 1
|
|
88
|
+
? ok(`path: ${r1.path.join('→')}`)
|
|
89
|
+
: err('path', r1.path)
|
|
90
|
+
|
|
91
|
+
} catch(e) { err('Depth 2', e) }
|
|
92
|
+
|
|
93
|
+
sep('NIVEAU 1 — Clé sémantique (string filter)')
|
|
94
|
+
try {
|
|
95
|
+
// await cinema.movies('Les Évadés') → via title
|
|
96
|
+
// Le semantic_key pour movies devrait être 'title'
|
|
97
|
+
const r1 = await (cinema as any).movies({ title: 'Les Évadés' })
|
|
98
|
+
r1.data.length === 1
|
|
99
|
+
? ok(`await cinema.movies({ title: 'Les Évadés' }) → id=${r1.data[0].id}`)
|
|
100
|
+
: err('cinema.movies({title})', `${r1.data.length} résultats`)
|
|
101
|
+
|
|
102
|
+
} catch(e) { err('Semantic key', e) }
|
|
103
|
+
|
|
104
|
+
sep('NIVEAU 1 — Musicians (nodes par type)')
|
|
105
|
+
try {
|
|
106
|
+
const music = new Graph(
|
|
107
|
+
require(`${root}/examples/musicians/graph.json`)
|
|
108
|
+
).domain()
|
|
109
|
+
|
|
110
|
+
// Les musicians ont type='artist' — pas de données, juste des nodes
|
|
111
|
+
// cinema.artists → devrait résoudre le type 'artist'
|
|
112
|
+
const node = (music as any).artists
|
|
113
|
+
node !== undefined
|
|
114
|
+
? ok(`cinema.artists → DomainNode résolu (entity=${node.entity})`)
|
|
115
|
+
: err('cinema.artists', 'undefined')
|
|
116
|
+
|
|
117
|
+
} catch(e) { err('Musicians domain', e) }
|
|
118
|
+
|
|
119
|
+
console.log('\n' + '─'.repeat(50))
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api/types.ts — Types publics de l'API LinkLab niveau 2+
|
|
3
|
+
*
|
|
4
|
+
* Ces types sont la surface visible pour les utilisateurs du moteur.
|
|
5
|
+
* Les types internes (CompiledGraph, RouteInfo, etc.) restent dans types/index.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphEdge } from '../types/index.js'
|
|
9
|
+
|
|
10
|
+
// ── Stratégies de pathfinding ────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strategy — comment Dijkstra pondère les chemins.
|
|
14
|
+
*
|
|
15
|
+
* Shortest : poids brut des arêtes — temps pur, distance minimale
|
|
16
|
+
* Comfort : pénalité par correspondance (+8 unités) — moins de changements
|
|
17
|
+
* LeastHops : favorise les chemins avec peu d'étapes
|
|
18
|
+
* Custom(n) : pénalité explicite par correspondance
|
|
19
|
+
*/
|
|
20
|
+
export type Strategy =
|
|
21
|
+
| { type: 'Shortest' }
|
|
22
|
+
| { type: 'Comfort' }
|
|
23
|
+
| { type: 'LeastHops' }
|
|
24
|
+
| { type: 'Custom'; transferPenalty: number }
|
|
25
|
+
|
|
26
|
+
// Factories — évitent les objets littéraux à l'usage
|
|
27
|
+
export const Strategy = {
|
|
28
|
+
Shortest: (): Strategy => ({ type: 'Shortest' }),
|
|
29
|
+
Comfort: (): Strategy => ({ type: 'Comfort' }),
|
|
30
|
+
LeastHops: (): Strategy => ({ type: 'LeastHops' }),
|
|
31
|
+
Custom: (transferPenalty: number): Strategy => ({ type: 'Custom', transferPenalty }),
|
|
32
|
+
|
|
33
|
+
toPenalty(s: Strategy): number {
|
|
34
|
+
switch (s.type) {
|
|
35
|
+
case 'Shortest': return 0
|
|
36
|
+
case 'Comfort': return 8
|
|
37
|
+
case 'LeastHops': return 50
|
|
38
|
+
case 'Custom': return s.transferPenalty
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
// ── Résultats de pathfinding ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface PathStep {
|
|
46
|
+
node: string // ID du node
|
|
47
|
+
label?: string // label lisible si présent dans le graph
|
|
48
|
+
via?: GraphEdge // arête empruntée pour arriver ici (absente pour le premier node)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ResolvedPath {
|
|
52
|
+
nodes: string[] // séquence d'IDs : ['Pigalle', 'Liège', 'Europe', ...]
|
|
53
|
+
steps: PathStep[] // version enrichie avec labels et arêtes
|
|
54
|
+
weight: number // poids total selon la stratégie appliquée
|
|
55
|
+
hops: number // nombre d'arêtes traversées
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PathResult {
|
|
59
|
+
from: string
|
|
60
|
+
to: string
|
|
61
|
+
found: boolean
|
|
62
|
+
paths: ResolvedPath[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Résultats de navigation avec données ─────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* QueryResult — retourné par PathBuilder.execute()
|
|
69
|
+
* Uniquement en mode données (netflix, dvdrental) — pas pour metro/musicians.
|
|
70
|
+
*/
|
|
71
|
+
export interface QueryResult<T = Record<string, any>> {
|
|
72
|
+
from: string
|
|
73
|
+
to: string
|
|
74
|
+
filters: Record<string, any>
|
|
75
|
+
data: T[]
|
|
76
|
+
path: string[]
|
|
77
|
+
timing: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Options du PathBuilder ───────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export interface PathBuilderOptions {
|
|
83
|
+
maxPaths?: number
|
|
84
|
+
minHops?: number
|
|
85
|
+
maxHops?: number
|
|
86
|
+
via?: string[]
|
|
87
|
+
strategy?: Strategy
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Singular → plural irregular mappings for FK resolution. Used by JsonSchemaExtractor to resolve *Id columns to their target table. Add project-specific synonyms in examples/<project>/synonyms.json.",
|
|
3
|
+
|
|
4
|
+
"person": "people",
|
|
5
|
+
"man": "men",
|
|
6
|
+
"woman": "women",
|
|
7
|
+
"child": "children",
|
|
8
|
+
|
|
9
|
+
"company": "companies",
|
|
10
|
+
"category": "categories",
|
|
11
|
+
"country": "countries",
|
|
12
|
+
"city": "cities",
|
|
13
|
+
"currency": "currencies",
|
|
14
|
+
"industry": "industries",
|
|
15
|
+
"activity": "activities",
|
|
16
|
+
"facility": "facilities",
|
|
17
|
+
"capability": "capabilities",
|
|
18
|
+
"authority": "authorities",
|
|
19
|
+
"community": "communities",
|
|
20
|
+
|
|
21
|
+
"datum": "data",
|
|
22
|
+
"medium": "media",
|
|
23
|
+
"criterion": "criteria",
|
|
24
|
+
"phenomenon": "phenomena",
|
|
25
|
+
"analysis": "analyses",
|
|
26
|
+
"basis": "bases",
|
|
27
|
+
"thesis": "theses"
|
|
28
|
+
}
|