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