anthodev/lapin-futepublic Fork
⑂ develop
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Resolve IDFM service catalog with bounded importer #11

Merged@github-anthodev wants to merge feature/resolve-idfm-services/backend/3 into develop
Merged as acf5bbab8ab6.
18 files changed
modified .gitignore +1 -0
@@ -1,4 +1,5 @@
11 node_modules/
2+var/
23 packages/watch/build/
34 packages/watch/src/pkjs/
45 docs/
@@ -1,4 +1,5 @@
1 node_modules/1 node_modules/
2+var/
2 packages/watch/build/3 packages/watch/build/
3 packages/watch/src/pkjs/4 packages/watch/src/pkjs/
4 docs/5 docs/
added fixtures/catalog/idfm-v1.json +167 -0
new file mode 100644
@@ -0,0 +1,167 @@
1+{
2+ "schemaVersion": 1,
3+ "fixtureVersion": "idfm-catalog-v1",
4+ "recordedAt": "2026-08-30T00:00:00.000Z",
5+ "dataClassification": "Schema-faithful deterministic reduction of official source layouts; fixture-only identifiers, no credential and no personal departure response.",
6+ "attribution": [
7+ { "dataset": "offre-horaires-tc-gtfs-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/offre-horaires-tc-gtfs-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" },
8+ { "dataset": "perimetre-des-donnees-tr-disponibles-plateforme-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/perimetre-des-donnees-tr-disponibles-plateforme-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" },
9+ { "dataset": "arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" },
10+ { "dataset": "zones-d-arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/zones-d-arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" },
11+ { "dataset": "relations", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/relations", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" },
12+ { "dataset": "referentiel-des-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" },
13+ { "dataset": "arrets-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" }
14+ ],
15+ "revisions": [
16+ { "id": "fixture-2026-08-a", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },
17+ { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },
18+ { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" }
19+ ],
20+ "sources": {
21+ "agency": [
22+ { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }
23+ ],
24+ "lines": [
25+ { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" },
26+ { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" },
27+ { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" },
28+ { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" },
29+ { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" },
30+ { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" }
31+ ],
32+ "routes": [
33+ { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" },
34+ { "route_id": "IDFM:C200", "route_short_name": "4", "route_long_name": "Métro 4" },
35+ { "route_id": "IDFM:C300", "route_short_name": "T3a", "route_long_name": "Tram T3a" },
36+ { "route_id": "IDFM:C400", "route_short_name": "A", "route_long_name": "RER A" },
37+ { "route_id": "IDFM:C500", "route_short_name": "L", "route_long_name": "Transilien L" },
38+ { "route_id": "IDFM:C600", "route_short_name": "F", "route_long_name": "Funiculaire" }
39+ ],
40+ "zones": [
41+ { "zdaid": "ZDA-BUS-1", "zdaname": "Gare du Nord", "zdatown": "Paris" },
42+ { "zdaid": "ZDA-BUS-2", "zdaname": "Gare du Nord", "zdatown": "Saint-Denis" },
43+ { "zdaid": "ZDA-METRO", "zdaname": "Châtelet", "zdatown": "Paris" },
44+ { "zdaid": "ZDA-TRAM", "zdaname": "Porte de Versailles", "zdatown": "Paris" },
45+ { "zdaid": "ZDA-RER", "zdaname": "Châtelet - Les Halles", "zdatown": "Paris" },
46+ { "zdaid": "ZDA-TRANSILIEN", "zdaname": "Saint-Lazare", "zdatown": "Paris" },
47+ { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" }
48+ ],
49+ "arrets": [
50+ { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1" },
51+ { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2" },
52+ { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO" },
53+ { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO" },
54+ { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM" },
55+ { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR" }
56+ ],
57+ "relations": [
58+ { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" },
59+ { "zdaid": "ZDA-BUS-2", "arrid": "ARR-BUS-2", "artid": "ART-BUS-2" },
60+ { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-EAST", "artid": "ART-METRO-EAST" },
61+ { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-WEST", "artid": "ART-METRO-WEST" },
62+ { "zdaid": "ZDA-TRAM", "arrid": "ARR-TRAM", "artid": "ART-TRAM" },
63+ { "zdaid": "ZDA-FUNICULAR", "arrid": "ARR-FUNICULAR", "artid": "ART-FUNICULAR" }
64+ ],
65+ "stops": [
66+ { "stop_id": "IDFM:ART-BUS-1", "stop_name": "Gare du Nord quai bus", "parent_station": "IDFM:ARR-BUS-1" },
67+ { "stop_id": "IDFM:TERM-BUS-1", "stop_name": "Hôpital Européen", "parent_station": "" },
68+ { "stop_id": "IDFM:ART-BUS-2", "stop_name": "Gare du Nord annexe", "parent_station": "IDFM:ARR-BUS-2" },
69+ { "stop_id": "IDFM:TERM-BUS-2", "stop_name": "Mairie", "parent_station": "" },
70+ { "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_name": "Mismatch", "parent_station": "IDFM:ARR-BUS-1" },
71+ { "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_name": "Wrong perimeter destination", "parent_station": "" },
72+ { "stop_id": "IDFM:ART-METRO-EAST", "stop_name": "Châtelet sud", "parent_station": "IDFM:ARR-METRO-EAST" },
73+ { "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_name": "Bagneux - Lucie Aubrac", "parent_station": "" },
74+ { "stop_id": "IDFM:ART-METRO-WEST", "stop_name": "Châtelet nord", "parent_station": "IDFM:ARR-METRO-WEST" },
75+ { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" },
76+ { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" },
77+ { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" },
78+ { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },
79+ { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" },
80+ { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },
81+ { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" },
82+ { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" },
83+ { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" },
84+ { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" },
85+ { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" }
86+ ],
87+ "trips": [
88+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-preserved", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
89+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-homonym", "trip_headsign": "Mairie", "direction_id": "1" },
90+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-no-exact-perimeter", "trip_headsign": "Wrong perimeter destination", "direction_id": "0" },
91+ { "route_id": "IDFM:C200", "trip_id": "trip-metro-east", "trip_headsign": "Bagneux - Lucie Aubrac", "direction_id": "0" },
92+ { "route_id": "IDFM:C200", "trip_id": "trip-metro-west", "trip_headsign": "Porte de Clignancourt", "direction_id": "1", "revisions": ["fixture-2026-08-a"] },
93+ { "route_id": "IDFM:C300", "trip_id": "trip-tram", "trip_headsign": "Pont du Garigliano", "direction_id": "0" },
94+ { "route_id": "IDFM:C400", "trip_id": "trip-rer-east", "trip_headsign": "NORA", "direction_id": "0" },
95+ { "route_id": "IDFM:C400", "trip_id": "trip-rer-west", "trip_headsign": "ZEBU", "direction_id": "1" },
96+ { "route_id": "IDFM:C500", "trip_id": "trip-transilien", "trip_headsign": "MILA", "direction_id": "0" },
97+ { "route_id": "IDFM:C600", "trip_id": "trip-funicular", "trip_headsign": "Sommet", "direction_id": "0" }
98+ ],
99+ "stopTimes": [
100+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0" },
101+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "2", "pickup_type": "1" },
102+ { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:ART-BUS-2", "stop_sequence": "1", "pickup_type": "0" },
103+ { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:TERM-BUS-2", "stop_sequence": "2", "pickup_type": "1" },
104+ { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_sequence": "1", "pickup_type": "0" },
105+ { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_sequence": "2", "pickup_type": "1" },
106+ { "trip_id": "trip-metro-east", "stop_id": "IDFM:ART-METRO-EAST", "stop_sequence": "1", "pickup_type": "0" },
107+ { "trip_id": "trip-metro-east", "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_sequence": "2", "pickup_type": "1" },
108+ { "trip_id": "trip-metro-west", "stop_id": "IDFM:ART-METRO-WEST", "stop_sequence": "1", "pickup_type": "0", "revisions": ["fixture-2026-08-a"] },
109+ { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] },
110+ { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" },
111+ { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" },
112+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" },
113+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" },
114+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" },
115+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" },
116+ { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" },
117+ { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" },
118+ { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" },
119+ { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" }
120+ ],
121+ "objectCodes": [
122+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" },
123+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" },
124+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" },
125+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" },
126+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" },
127+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" },
128+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" },
129+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" },
130+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] },
131+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] },
132+ { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" },
133+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" },
134+ { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },
135+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" },
136+ { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },
137+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" },
138+ { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" },
139+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" },
140+ { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" },
141+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" }
142+ ],
143+ "arretsLignes": [
144+ { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-1" },
145+ { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-2" },
146+ { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-MISMATCH" },
147+ { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" },
148+ { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" },
149+ { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" },
150+ { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" },
151+ { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" },
152+ { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" },
153+ { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" }
154+ ],
155+ "perimeter": [
156+ { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
157+ { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
158+ { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" },
159+ { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" },
160+ { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] },
161+ { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" },
162+ { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" },
163+ { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] },
164+ { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" }
165+ ]
166+ }
167+}
new file mode 100644
@@ -0,0 +1,167 @@
1+{
2+ "schemaVersion": 1,
3+ "fixtureVersion": "idfm-catalog-v1",
4+ "recordedAt": "2026-08-30T00:00:00.000Z",
5+ "dataClassification": "Schema-faithful deterministic reduction of official source layouts; fixture-only identifiers, no credential and no personal departure response.",
6+ "attribution": [
7+ { "dataset": "offre-horaires-tc-gtfs-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/offre-horaires-tc-gtfs-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" },
8+ { "dataset": "perimetre-des-donnees-tr-disponibles-plateforme-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/perimetre-des-donnees-tr-disponibles-plateforme-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" },
9+ { "dataset": "arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" },
10+ { "dataset": "zones-d-arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/zones-d-arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" },
11+ { "dataset": "relations", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/relations", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" },
12+ { "dataset": "referentiel-des-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" },
13+ { "dataset": "arrets-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" }
14+ ],
15+ "revisions": [
16+ { "id": "fixture-2026-08-a", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },
17+ { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },
18+ { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" }
19+ ],
20+ "sources": {
21+ "agency": [
22+ { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }
23+ ],
24+ "lines": [
25+ { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" },
26+ { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" },
27+ { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" },
28+ { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" },
29+ { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" },
30+ { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" }
31+ ],
32+ "routes": [
33+ { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" },
34+ { "route_id": "IDFM:C200", "route_short_name": "4", "route_long_name": "Métro 4" },
35+ { "route_id": "IDFM:C300", "route_short_name": "T3a", "route_long_name": "Tram T3a" },
36+ { "route_id": "IDFM:C400", "route_short_name": "A", "route_long_name": "RER A" },
37+ { "route_id": "IDFM:C500", "route_short_name": "L", "route_long_name": "Transilien L" },
38+ { "route_id": "IDFM:C600", "route_short_name": "F", "route_long_name": "Funiculaire" }
39+ ],
40+ "zones": [
41+ { "zdaid": "ZDA-BUS-1", "zdaname": "Gare du Nord", "zdatown": "Paris" },
42+ { "zdaid": "ZDA-BUS-2", "zdaname": "Gare du Nord", "zdatown": "Saint-Denis" },
43+ { "zdaid": "ZDA-METRO", "zdaname": "Châtelet", "zdatown": "Paris" },
44+ { "zdaid": "ZDA-TRAM", "zdaname": "Porte de Versailles", "zdatown": "Paris" },
45+ { "zdaid": "ZDA-RER", "zdaname": "Châtelet - Les Halles", "zdatown": "Paris" },
46+ { "zdaid": "ZDA-TRANSILIEN", "zdaname": "Saint-Lazare", "zdatown": "Paris" },
47+ { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" }
48+ ],
49+ "arrets": [
50+ { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1" },
51+ { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2" },
52+ { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO" },
53+ { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO" },
54+ { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM" },
55+ { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR" }
56+ ],
57+ "relations": [
58+ { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" },
59+ { "zdaid": "ZDA-BUS-2", "arrid": "ARR-BUS-2", "artid": "ART-BUS-2" },
60+ { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-EAST", "artid": "ART-METRO-EAST" },
61+ { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-WEST", "artid": "ART-METRO-WEST" },
62+ { "zdaid": "ZDA-TRAM", "arrid": "ARR-TRAM", "artid": "ART-TRAM" },
63+ { "zdaid": "ZDA-FUNICULAR", "arrid": "ARR-FUNICULAR", "artid": "ART-FUNICULAR" }
64+ ],
65+ "stops": [
66+ { "stop_id": "IDFM:ART-BUS-1", "stop_name": "Gare du Nord quai bus", "parent_station": "IDFM:ARR-BUS-1" },
67+ { "stop_id": "IDFM:TERM-BUS-1", "stop_name": "Hôpital Européen", "parent_station": "" },
68+ { "stop_id": "IDFM:ART-BUS-2", "stop_name": "Gare du Nord annexe", "parent_station": "IDFM:ARR-BUS-2" },
69+ { "stop_id": "IDFM:TERM-BUS-2", "stop_name": "Mairie", "parent_station": "" },
70+ { "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_name": "Mismatch", "parent_station": "IDFM:ARR-BUS-1" },
71+ { "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_name": "Wrong perimeter destination", "parent_station": "" },
72+ { "stop_id": "IDFM:ART-METRO-EAST", "stop_name": "Châtelet sud", "parent_station": "IDFM:ARR-METRO-EAST" },
73+ { "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_name": "Bagneux - Lucie Aubrac", "parent_station": "" },
74+ { "stop_id": "IDFM:ART-METRO-WEST", "stop_name": "Châtelet nord", "parent_station": "IDFM:ARR-METRO-WEST" },
75+ { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" },
76+ { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" },
77+ { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" },
78+ { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },
79+ { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" },
80+ { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },
81+ { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" },
82+ { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" },
83+ { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" },
84+ { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" },
85+ { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" }
86+ ],
87+ "trips": [
88+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-preserved", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
89+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-homonym", "trip_headsign": "Mairie", "direction_id": "1" },
90+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-no-exact-perimeter", "trip_headsign": "Wrong perimeter destination", "direction_id": "0" },
91+ { "route_id": "IDFM:C200", "trip_id": "trip-metro-east", "trip_headsign": "Bagneux - Lucie Aubrac", "direction_id": "0" },
92+ { "route_id": "IDFM:C200", "trip_id": "trip-metro-west", "trip_headsign": "Porte de Clignancourt", "direction_id": "1", "revisions": ["fixture-2026-08-a"] },
93+ { "route_id": "IDFM:C300", "trip_id": "trip-tram", "trip_headsign": "Pont du Garigliano", "direction_id": "0" },
94+ { "route_id": "IDFM:C400", "trip_id": "trip-rer-east", "trip_headsign": "NORA", "direction_id": "0" },
95+ { "route_id": "IDFM:C400", "trip_id": "trip-rer-west", "trip_headsign": "ZEBU", "direction_id": "1" },
96+ { "route_id": "IDFM:C500", "trip_id": "trip-transilien", "trip_headsign": "MILA", "direction_id": "0" },
97+ { "route_id": "IDFM:C600", "trip_id": "trip-funicular", "trip_headsign": "Sommet", "direction_id": "0" }
98+ ],
99+ "stopTimes": [
100+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0" },
101+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "2", "pickup_type": "1" },
102+ { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:ART-BUS-2", "stop_sequence": "1", "pickup_type": "0" },
103+ { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:TERM-BUS-2", "stop_sequence": "2", "pickup_type": "1" },
104+ { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_sequence": "1", "pickup_type": "0" },
105+ { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_sequence": "2", "pickup_type": "1" },
106+ { "trip_id": "trip-metro-east", "stop_id": "IDFM:ART-METRO-EAST", "stop_sequence": "1", "pickup_type": "0" },
107+ { "trip_id": "trip-metro-east", "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_sequence": "2", "pickup_type": "1" },
108+ { "trip_id": "trip-metro-west", "stop_id": "IDFM:ART-METRO-WEST", "stop_sequence": "1", "pickup_type": "0", "revisions": ["fixture-2026-08-a"] },
109+ { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] },
110+ { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" },
111+ { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" },
112+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" },
113+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" },
114+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" },
115+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" },
116+ { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" },
117+ { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" },
118+ { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" },
119+ { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" }
120+ ],
121+ "objectCodes": [
122+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" },
123+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" },
124+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" },
125+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" },
126+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" },
127+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" },
128+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" },
129+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" },
130+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] },
131+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] },
132+ { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" },
133+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" },
134+ { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },
135+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" },
136+ { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },
137+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" },
138+ { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" },
139+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" },
140+ { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" },
141+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" }
142+ ],
143+ "arretsLignes": [
144+ { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-1" },
145+ { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-2" },
146+ { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-MISMATCH" },
147+ { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" },
148+ { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" },
149+ { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" },
150+ { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" },
151+ { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" },
152+ { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" },
153+ { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" }
154+ ],
155+ "perimeter": [
156+ { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
157+ { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
158+ { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" },
159+ { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" },
160+ { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] },
161+ { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" },
162+ { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" },
163+ { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] },
164+ { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" }
165+ ]
166+ }
167+}
added package-lock.json +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+{
2+ "name": "lapin-fute",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "lapin-fute",
9+ "version": "0.1.0",
10+ "workspaces": [
11+ "packages/*"
12+ ],
13+ "engines": {
14+ "node": "24.18.0"
15+ }
16+ },
17+ "node_modules/@lapin-fute/backend": {
18+ "resolved": "packages/backend",
19+ "link": true
20+ },
21+ "node_modules/@lapin-fute/companion": {
22+ "resolved": "packages/companion",
23+ "link": true
24+ },
25+ "node_modules/@lapin-fute/config-page": {
26+ "resolved": "packages/config-page",
27+ "link": true
28+ },
29+ "node_modules/csv-parse": {
30+ "version": "7.0.2",
31+ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
32+ "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
33+ "license": "MIT"
34+ },
35+ "node_modules/lapin-fute-watch": {
36+ "resolved": "packages/watch",
37+ "link": true
38+ },
39+ "node_modules/pend": {
40+ "version": "1.2.0",
41+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
42+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
43+ "license": "MIT"
44+ },
45+ "node_modules/yauzl": {
46+ "version": "3.4.0",
47+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
48+ "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
49+ "license": "MIT",
50+ "dependencies": {
51+ "pend": "~1.2.0"
52+ },
53+ "engines": {
54+ "node": ">=12"
55+ }
56+ },
57+ "packages/backend": {
58+ "name": "@lapin-fute/backend",
59+ "version": "0.1.0",
60+ "dependencies": {
61+ "csv-parse": "7.0.2",
62+ "yauzl": "3.4.0"
63+ }
64+ },
65+ "packages/companion": {
66+ "name": "@lapin-fute/companion",
67+ "version": "0.1.0"
68+ },
69+ "packages/config-page": {
70+ "name": "@lapin-fute/config-page",
71+ "version": "0.1.0"
72+ },
73+ "packages/watch": {
74+ "name": "lapin-fute-watch",
75+ "version": "0.1.0"
76+ }
77+ }
78+}
new file mode 100644
@@ -0,0 +1,78 @@
1+{
2+ "name": "lapin-fute",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "lapin-fute",
9+ "version": "0.1.0",
10+ "workspaces": [
11+ "packages/*"
12+ ],
13+ "engines": {
14+ "node": "24.18.0"
15+ }
16+ },
17+ "node_modules/@lapin-fute/backend": {
18+ "resolved": "packages/backend",
19+ "link": true
20+ },
21+ "node_modules/@lapin-fute/companion": {
22+ "resolved": "packages/companion",
23+ "link": true
24+ },
25+ "node_modules/@lapin-fute/config-page": {
26+ "resolved": "packages/config-page",
27+ "link": true
28+ },
29+ "node_modules/csv-parse": {
30+ "version": "7.0.2",
31+ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
32+ "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
33+ "license": "MIT"
34+ },
35+ "node_modules/lapin-fute-watch": {
36+ "resolved": "packages/watch",
37+ "link": true
38+ },
39+ "node_modules/pend": {
40+ "version": "1.2.0",
41+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
42+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
43+ "license": "MIT"
44+ },
45+ "node_modules/yauzl": {
46+ "version": "3.4.0",
47+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
48+ "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
49+ "license": "MIT",
50+ "dependencies": {
51+ "pend": "~1.2.0"
52+ },
53+ "engines": {
54+ "node": ">=12"
55+ }
56+ },
57+ "packages/backend": {
58+ "name": "@lapin-fute/backend",
59+ "version": "0.1.0",
60+ "dependencies": {
61+ "csv-parse": "7.0.2",
62+ "yauzl": "3.4.0"
63+ }
64+ },
65+ "packages/companion": {
66+ "name": "@lapin-fute/companion",
67+ "version": "0.1.0"
68+ },
69+ "packages/config-page": {
70+ "name": "@lapin-fute/config-page",
71+ "version": "0.1.0"
72+ },
73+ "packages/watch": {
74+ "name": "lapin-fute-watch",
75+ "version": "0.1.0"
76+ }
77+ }
78+}
modified package.json +3 -0
@@ -11,7 +11,10 @@
1111 },
1212 "scripts": {
1313 "build": "node scripts/build.mjs",
14+ "catalog:probe": "node scripts/probe-catalog.mjs",
15+ "catalog:refresh": "node scripts/refresh-catalog.mjs",
1416 "measure:radio": "node scripts/measure-radio.mjs",
17+ "measure:catalog": "node scripts/measure-catalog.mjs",
1518 "test": "node scripts/test.mjs",
1619 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
1720 "test:watch": "node --test packages/watch/test/*.test.js",
@@ -11,7 +11,10 @@
11 },11 },
12 "scripts": {12 "scripts": {
13 "build": "node scripts/build.mjs",13 "build": "node scripts/build.mjs",
14+ "catalog:probe": "node scripts/probe-catalog.mjs",
15+ "catalog:refresh": "node scripts/refresh-catalog.mjs",
14 "measure:radio": "node scripts/measure-radio.mjs",16 "measure:radio": "node scripts/measure-radio.mjs",
17+ "measure:catalog": "node scripts/measure-catalog.mjs",
15 "test": "node scripts/test.mjs",18 "test": "node scripts/test.mjs",
16 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",19 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
17 "test:watch": "node --test packages/watch/test/*.test.js",20 "test:watch": "node --test packages/watch/test/*.test.js",
modified packages/backend/package.json +5 -1
@@ -4,7 +4,11 @@
44 "private": true,
55 "type": "module",
66 "exports": "./src/index.ts",
7+ "dependencies": {
8+ "csv-parse": "7.0.2",
9+ "yauzl": "3.4.0"
10+ },
711 "scripts": {
8- "test": "node --test test/backend.test.ts"
12+ "test": "node --test test/*.test.ts"
913 }
1014 }
@@ -4,7 +4,11 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": "./src/index.ts",6 "exports": "./src/index.ts",
7+ "dependencies": {
8+ "csv-parse": "7.0.2",
9+ "yauzl": "3.4.0"
10+ },
7 "scripts": {11 "scripts": {
8- "test": "node --test test/backend.test.ts"12+ "test": "node --test test/*.test.ts"
9 }13 }
10 }14 }
added packages/backend/src/catalog-import.ts +1462 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/backend/src/catalog.ts +686 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/backend/src/index.ts +9 -0
@@ -13,6 +13,15 @@ import {
1313 type ErrorResult,
1414 } from "../../contracts/src/index.ts";
1515
16+export {
17+ CatalogManager,
18+ SqliteCatalogReader,
19+ handleCatalogRequest,
20+ type CatalogHandlerResponse,
21+ type CatalogReader,
22+ type CatalogServiceResolution,
23+} from "./catalog.ts";
24+
1625 export const REDACTED_SECRET = "[REDACTED]" as const;
1726
1827 export const BACKEND_ERROR_CODE = {
@@ -13,6 +13,15 @@ import {
13 type ErrorResult,13 type ErrorResult,
14 } from "../../contracts/src/index.ts";14 } from "../../contracts/src/index.ts";
15 15
16+export {
17+ CatalogManager,
18+ SqliteCatalogReader,
19+ handleCatalogRequest,
20+ type CatalogHandlerResponse,
21+ type CatalogReader,
22+ type CatalogServiceResolution,
23+} from "./catalog.ts";
24+
16 export const REDACTED_SECRET = "[REDACTED]" as const;25 export const REDACTED_SECRET = "[REDACTED]" as const;
17 26
18 export const BACKEND_ERROR_CODE = {27 export const BACKEND_ERROR_CODE = {
added packages/backend/test/catalog-http.test.ts +158 -0
new file mode 100644
@@ -0,0 +1,158 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import { DatabaseSync } from "node:sqlite";
7+import {
8+ CATALOG_ROUTE,
9+ LIMITS,
10+ SCHEMA_VERSION,
11+ isCatalogErrorResult,
12+ isPlaceSearchResult,
13+ isServiceOptionsResult,
14+ type TransportMode,
15+} from "../../contracts/src/index.ts";
16+import { CatalogManager, handleCatalogRequest } from "../src/catalog.ts";
17+
18+interface FixturePlace {
19+ readonly id: string;
20+ readonly label: string;
21+ readonly locality: string;
22+ readonly mode: TransportMode;
23+ readonly serviceId: string;
24+ readonly line: string;
25+ readonly destination: string;
26+ readonly monitoringRef: string;
27+ readonly lineRef: string;
28+}
29+
30+const PLACE_ID = `plc_${"a".repeat(43)}`;
31+const SERVICE_ID = `svc_${"f".repeat(43)}`;
32+const PERSONAL_KEY = "test-only-personal-credential";
33+const temporaryDirectories: string[] = [];
34+
35+const FIXTURE: readonly FixturePlace[] = [
36+ { id: PLACE_ID, label: "Châtelet", locality: "Paris", mode: "METRO", serviceId: SERVICE_ID, line: "4", destination: "Bagneux – Lucie Aubrac", monitoringRef: "raw-monitoring-metro-chatelet", lineRef: "raw-line-metro-4" },
37+ { id: `plc_${"b".repeat(43)}`, label: "République", locality: "Paris", mode: "BUS", serviceId: `svc_${"g".repeat(43)}`, line: "96", destination: "Porte des Lilas", monitoringRef: "raw-monitoring-bus-republique", lineRef: "raw-line-bus-96" },
38+ { id: `plc_${"c".repeat(43)}`, label: "Porte de Versailles", locality: "Paris", mode: "TRAM", serviceId: `svc_${"h".repeat(43)}`, line: "T2", destination: "Pont de Bezons", monitoringRef: "raw-monitoring-tram-versailles", lineRef: "raw-line-tram-t2" },
39+ { id: `plc_${"d".repeat(43)}`, label: "Châtelet – Les Halles", locality: "Paris", mode: "RER", serviceId: `svc_${"i".repeat(43)}`, line: "B", destination: "Aéroport Charles de Gaulle 2", monitoringRef: "raw-monitoring-rer-chatelet", lineRef: "raw-line-rer-b" },
40+ { id: `plc_${"e".repeat(43)}`, label: "Saint-Lazare", locality: "Paris", mode: "TRANSILIEN", serviceId: `svc_${"j".repeat(43)}`, line: "L", destination: "Versailles Rive Droite", monitoringRef: "raw-monitoring-train-saint-lazare", lineRef: "raw-line-train-l" },
41+];
42+
43+function normalizedSearchText(place: FixturePlace): string {
44+ return `${place.label} ${place.locality} ${place.mode}`.normalize("NFKD")
45+ .replace(/\p{M}+/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")
46+ .trim().replace(/\s+/gu, " ");
47+}
48+
49+function fixtureManager(): CatalogManager {
50+ const directory = mkdtempSync(join(tmpdir(), "lapin-catalog-http-"));
51+ temporaryDirectories.push(directory);
52+ const path = join(directory, "catalog.sqlite");
53+ const database = new DatabaseSync(path);
54+ database.exec(`
55+ PRAGMA foreign_keys = ON;
56+ CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
57+ CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
58+ CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
59+ CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED);
60+ `);
61+ const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
62+ metadata.run("schema_version", "1"); metadata.run("catalog_version", "1");
63+ metadata.run("source_revision", "http-fixture-revision");
64+ metadata.run("created_at", "2026-08-30T12:00:00.000Z");
65+ const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
66+ const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
67+ const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
68+ FIXTURE.forEach((entry, index) => {
69+ place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`);
70+ search.run(normalizedSearchText(entry), entry.id);
71+ service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`);
72+ });
73+ database.close();
74+ const manager = new CatalogManager();
75+ manager.reload(path);
76+ return manager;
77+}
78+
79+function request(catalog: CatalogManager, method: string, url: string) {
80+ return handleCatalogRequest({ method, url }, { catalog });
81+}
82+
83+test.afterEach(() => {
84+ while (temporaryDirectories.length > 0) {
85+ const directory = temporaryDirectories.pop();
86+ if (directory !== undefined) rmSync(directory, { recursive: true, force: true });
87+ }
88+});
89+
90+test("GET routes return exact public response bodies without source identifiers or credentials", () => {
91+ const catalog = fixtureManager();
92+ const places = request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`);
93+ assert.equal(places.status, 200);
94+ assert.ok(isPlaceSearchResult(places.body));
95+ assert.deepEqual(places.body, { schemaVersion: SCHEMA_VERSION, places: [
96+ { placeId: PLACE_ID, stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" },
97+ { placeId: FIXTURE[3]!.id, stopLabel: "Châtelet – Les Halles", localityLabel: "Paris", mode: "RER" },
98+ ] });
99+ const services = request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID));
100+ assert.equal(services.status, 200);
101+ assert.ok(isServiceOptionsResult(services.body));
102+ assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [
103+ { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" },
104+ ] });
105+ const publicJson = JSON.stringify([places.body, services.body]);
106+ for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) {
107+ assert.equal(publicJson.includes(forbidden), false);
108+ }
109+});
110+
111+test("search rejects malformed parameters and accepts exact code-point boundaries", () => {
112+ const catalog = fixtureManager();
113+ for (const url of [CATALOG_ROUTE.places, `${CATALOG_ROUTE.places}?q=`, `${CATALOG_ROUTE.places}?q=a`, `${CATALOG_ROUTE.places}?q=${"a".repeat(101)}`, `${CATALOG_ROUTE.places}?q=--`, `${CATALOG_ROUTE.places}?q=ch&q=metro`, `${CATALOG_ROUTE.places}?q=ch&mode=METRO`]) {
114+ const result = request(catalog, "GET", url);
115+ assert.equal(result.status, 400);
116+ assert.deepEqual(result.body, { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" });
117+ assert.ok(isCatalogErrorResult(result.body));
118+ }
119+ assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=ch`).status, 200);
120+ assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=${"z".repeat(100)}`).status, 200);
121+ const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID);
122+ assert.deepEqual(request(catalog, "GET", `${route}?raw=forbidden`), {
123+ status: 400, body: { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" },
124+ });
125+});
126+
127+test("handler distinguishes method, place, route, and unavailable failures", () => {
128+ const catalog = fixtureManager();
129+ const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID);
130+ assert.deepEqual(request(catalog, "POST", CATALOG_ROUTE.places), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } });
131+ assert.deepEqual(request(catalog, "DELETE", route), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } });
132+ assert.deepEqual(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", "plc_unknown")), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } });
133+ assert.deepEqual(request(catalog, "GET", "/api/catalog/unknown"), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } });
134+ const unavailable = new CatalogManager();
135+ assert.deepEqual(request(unavailable, "GET", `${CATALOG_ROUTE.places}?q=ch`), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } });
136+ assert.deepEqual(request(unavailable, "GET", route), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } });
137+});
138+
139+test("catalog browsing never invokes fetch or a PRIM source", { concurrency: false }, () => {
140+ const catalog = fixtureManager();
141+ const originalFetch = globalThis.fetch;
142+ let fetchCalls = 0;
143+ globalThis.fetch = (() => { fetchCalls += 1; throw new Error("network request"); }) as typeof fetch;
144+ try {
145+ assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`).status, 200);
146+ assert.equal(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID)).status, 200);
147+ assert.equal(fetchCalls, 0);
148+ } finally {
149+ globalThis.fetch = originalFetch;
150+ }
151+});
152+
153+test("success collections remain within the shared result bound", () => {
154+ const result = request(fixtureManager(), "GET", `${CATALOG_ROUTE.places}?q=ch`);
155+ assert.equal(result.status, 200);
156+ if (!isPlaceSearchResult(result.body)) assert.fail("expected place search result");
157+ assert.ok(result.body.places.length <= LIMITS.catalogSearchResults);
158+});
new file mode 100644
@@ -0,0 +1,158 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import { DatabaseSync } from "node:sqlite";
7+import {
8+ CATALOG_ROUTE,
9+ LIMITS,
10+ SCHEMA_VERSION,
11+ isCatalogErrorResult,
12+ isPlaceSearchResult,
13+ isServiceOptionsResult,
14+ type TransportMode,
15+} from "../../contracts/src/index.ts";
16+import { CatalogManager, handleCatalogRequest } from "../src/catalog.ts";
17+
18+interface FixturePlace {
19+ readonly id: string;
20+ readonly label: string;
21+ readonly locality: string;
22+ readonly mode: TransportMode;
23+ readonly serviceId: string;
24+ readonly line: string;
25+ readonly destination: string;
26+ readonly monitoringRef: string;
27+ readonly lineRef: string;
28+}
29+
30+const PLACE_ID = `plc_${"a".repeat(43)}`;
31+const SERVICE_ID = `svc_${"f".repeat(43)}`;
32+const PERSONAL_KEY = "test-only-personal-credential";
33+const temporaryDirectories: string[] = [];
34+
35+const FIXTURE: readonly FixturePlace[] = [
36+ { id: PLACE_ID, label: "Châtelet", locality: "Paris", mode: "METRO", serviceId: SERVICE_ID, line: "4", destination: "Bagneux – Lucie Aubrac", monitoringRef: "raw-monitoring-metro-chatelet", lineRef: "raw-line-metro-4" },
37+ { id: `plc_${"b".repeat(43)}`, label: "République", locality: "Paris", mode: "BUS", serviceId: `svc_${"g".repeat(43)}`, line: "96", destination: "Porte des Lilas", monitoringRef: "raw-monitoring-bus-republique", lineRef: "raw-line-bus-96" },
38+ { id: `plc_${"c".repeat(43)}`, label: "Porte de Versailles", locality: "Paris", mode: "TRAM", serviceId: `svc_${"h".repeat(43)}`, line: "T2", destination: "Pont de Bezons", monitoringRef: "raw-monitoring-tram-versailles", lineRef: "raw-line-tram-t2" },
39+ { id: `plc_${"d".repeat(43)}`, label: "Châtelet – Les Halles", locality: "Paris", mode: "RER", serviceId: `svc_${"i".repeat(43)}`, line: "B", destination: "Aéroport Charles de Gaulle 2", monitoringRef: "raw-monitoring-rer-chatelet", lineRef: "raw-line-rer-b" },
40+ { id: `plc_${"e".repeat(43)}`, label: "Saint-Lazare", locality: "Paris", mode: "TRANSILIEN", serviceId: `svc_${"j".repeat(43)}`, line: "L", destination: "Versailles Rive Droite", monitoringRef: "raw-monitoring-train-saint-lazare", lineRef: "raw-line-train-l" },
41+];
42+
43+function normalizedSearchText(place: FixturePlace): string {
44+ return `${place.label} ${place.locality} ${place.mode}`.normalize("NFKD")
45+ .replace(/\p{M}+/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")
46+ .trim().replace(/\s+/gu, " ");
47+}
48+
49+function fixtureManager(): CatalogManager {
50+ const directory = mkdtempSync(join(tmpdir(), "lapin-catalog-http-"));
51+ temporaryDirectories.push(directory);
52+ const path = join(directory, "catalog.sqlite");
53+ const database = new DatabaseSync(path);
54+ database.exec(`
55+ PRAGMA foreign_keys = ON;
56+ CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
57+ CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
58+ CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
59+ CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED);
60+ `);
61+ const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
62+ metadata.run("schema_version", "1"); metadata.run("catalog_version", "1");
63+ metadata.run("source_revision", "http-fixture-revision");
64+ metadata.run("created_at", "2026-08-30T12:00:00.000Z");
65+ const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
66+ const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
67+ const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
68+ FIXTURE.forEach((entry, index) => {
69+ place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`);
70+ search.run(normalizedSearchText(entry), entry.id);
71+ service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`);
72+ });
73+ database.close();
74+ const manager = new CatalogManager();
75+ manager.reload(path);
76+ return manager;
77+}
78+
79+function request(catalog: CatalogManager, method: string, url: string) {
80+ return handleCatalogRequest({ method, url }, { catalog });
81+}
82+
83+test.afterEach(() => {
84+ while (temporaryDirectories.length > 0) {
85+ const directory = temporaryDirectories.pop();
86+ if (directory !== undefined) rmSync(directory, { recursive: true, force: true });
87+ }
88+});
89+
90+test("GET routes return exact public response bodies without source identifiers or credentials", () => {
91+ const catalog = fixtureManager();
92+ const places = request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`);
93+ assert.equal(places.status, 200);
94+ assert.ok(isPlaceSearchResult(places.body));
95+ assert.deepEqual(places.body, { schemaVersion: SCHEMA_VERSION, places: [
96+ { placeId: PLACE_ID, stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" },
97+ { placeId: FIXTURE[3]!.id, stopLabel: "Châtelet – Les Halles", localityLabel: "Paris", mode: "RER" },
98+ ] });
99+ const services = request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID));
100+ assert.equal(services.status, 200);
101+ assert.ok(isServiceOptionsResult(services.body));
102+ assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [
103+ { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" },
104+ ] });
105+ const publicJson = JSON.stringify([places.body, services.body]);
106+ for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) {
107+ assert.equal(publicJson.includes(forbidden), false);
108+ }
109+});
110+
111+test("search rejects malformed parameters and accepts exact code-point boundaries", () => {
112+ const catalog = fixtureManager();
113+ for (const url of [CATALOG_ROUTE.places, `${CATALOG_ROUTE.places}?q=`, `${CATALOG_ROUTE.places}?q=a`, `${CATALOG_ROUTE.places}?q=${"a".repeat(101)}`, `${CATALOG_ROUTE.places}?q=--`, `${CATALOG_ROUTE.places}?q=ch&q=metro`, `${CATALOG_ROUTE.places}?q=ch&mode=METRO`]) {
114+ const result = request(catalog, "GET", url);
115+ assert.equal(result.status, 400);
116+ assert.deepEqual(result.body, { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" });
117+ assert.ok(isCatalogErrorResult(result.body));
118+ }
119+ assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=ch`).status, 200);
120+ assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=${"z".repeat(100)}`).status, 200);
121+ const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID);
122+ assert.deepEqual(request(catalog, "GET", `${route}?raw=forbidden`), {
123+ status: 400, body: { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" },
124+ });
125+});
126+
127+test("handler distinguishes method, place, route, and unavailable failures", () => {
128+ const catalog = fixtureManager();
129+ const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID);
130+ assert.deepEqual(request(catalog, "POST", CATALOG_ROUTE.places), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } });
131+ assert.deepEqual(request(catalog, "DELETE", route), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } });
132+ assert.deepEqual(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", "plc_unknown")), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } });
133+ assert.deepEqual(request(catalog, "GET", "/api/catalog/unknown"), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } });
134+ const unavailable = new CatalogManager();
135+ assert.deepEqual(request(unavailable, "GET", `${CATALOG_ROUTE.places}?q=ch`), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } });
136+ assert.deepEqual(request(unavailable, "GET", route), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } });
137+});
138+
139+test("catalog browsing never invokes fetch or a PRIM source", { concurrency: false }, () => {
140+ const catalog = fixtureManager();
141+ const originalFetch = globalThis.fetch;
142+ let fetchCalls = 0;
143+ globalThis.fetch = (() => { fetchCalls += 1; throw new Error("network request"); }) as typeof fetch;
144+ try {
145+ assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`).status, 200);
146+ assert.equal(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID)).status, 200);
147+ assert.equal(fetchCalls, 0);
148+ } finally {
149+ globalThis.fetch = originalFetch;
150+ }
151+});
152+
153+test("success collections remain within the shared result bound", () => {
154+ const result = request(fixtureManager(), "GET", `${CATALOG_ROUTE.places}?q=ch`);
155+ assert.equal(result.status, 200);
156+ if (!isPlaceSearchResult(result.body)) assert.fail("expected place search result");
157+ assert.ok(result.body.places.length <= LIMITS.catalogSearchResults);
158+});
added packages/backend/test/catalog-import.test.ts +185 -0
new file mode 100644
@@ -0,0 +1,185 @@
1+import test from "node:test";
2+import type { TestContext } from "node:test";
3+import assert from "node:assert/strict";
4+import { createHash } from "node:crypto";
5+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
6+import { tmpdir } from "node:os";
7+import { join } from "node:path";
8+import { TRANSPORT_MODE } from "../../contracts/src/index.ts";
9+import type { TransportMode } from "../../contracts/src/index.ts";
10+import { CatalogManager } from "../src/catalog.ts";
11+import {
12+ activateCatalogCandidate,
13+ buildCatalogCandidateFromRecords,
14+ createPlaceIdentity,
15+ createServiceIdentity,
16+ downloadCatalogSource,
17+ refreshCatalogFromRecords,
18+} from "../src/catalog-import.ts";
19+import type {
20+ CatalogRecordSourceSet,
21+ CsvRecord,
22+} from "../src/catalog-import.ts";
23+
24+interface Fixture {
25+ readonly schemaVersion: number;
26+ readonly fixtureVersion: string;
27+ readonly dataClassification: string;
28+ readonly attribution: readonly { readonly dataset: string; readonly url: string; readonly retrievedAt: string; readonly license: string }[];
29+ readonly sources: Readonly<Record<keyof CatalogRecordSourceSet, readonly Record<string, unknown>[]>>;
30+}
31+
32+const fixtureUrl = new URL("../../../fixtures/catalog/idfm-v1.json", import.meta.url);
33+const fixtureText = readFileSync(fixtureUrl, "utf8");
34+const fixture = JSON.parse(fixtureText) as Fixture;
35+
36+function sourcesFor(revisionId: string): CatalogRecordSourceSet {
37+ return Object.fromEntries(Object.entries(fixture.sources).map(([name, records]) => [
38+ name,
39+ records.filter((record) => !Array.isArray(record.revisions) || record.revisions.includes(revisionId)).map((record) => {
40+ const { revisions: _revisions, ...sourceRecord } = record;
41+ return sourceRecord as CsvRecord;
42+ }),
43+ ])) as unknown as CatalogRecordSourceSet;
44+}
45+
46+function workspace(t: TestContext): string {
47+ const directory = mkdtempSync(join(tmpdir(), "lapin-fute-catalog-import-"));
48+ t.after(() => rmSync(directory, { recursive: true, force: true }));
49+ return directory;
50+}
51+
52+function place(manager: CatalogManager, query: string, mode: TransportMode, locality?: string) {
53+ const found = manager.searchPlaces(query).find((entry) => entry.mode === mode && (locality === undefined || entry.localityLabel === locality));
54+ assert.ok(found, `expected ${mode} place for ${query}`);
55+ return found;
56+}
57+
58+function service(manager: CatalogManager, placeId: string, destination: string) {
59+ const found = manager.listServices(placeId)?.find((entry) => entry.destinationLabel === destination);
60+ assert.ok(found, `expected service to ${destination}`);
61+ return found;
62+}
63+
64+test("fixture is attributed and contains neither credentials nor personal responses", () => {
65+ assert.equal(fixture.schemaVersion, 1);
66+ assert.equal(fixture.fixtureVersion, "idfm-catalog-v1");
67+ assert.match(fixture.dataClassification, /fixture-only/u);
68+ assert.equal(fixture.attribution.length, 7);
69+ for (const source of fixture.attribution) {
70+ assert.equal(new URL(source.url).hostname, "data.iledefrance-mobilites.fr");
71+ assert.ok(Number.isFinite(Date.parse(source.retrievedAt)));
72+ assert.ok(source.license.length > 0);
73+ }
74+ assert.doesNotMatch(fixtureText, /IDFM_DATASET_TOKEN|PRIM_API_KEY|"apikey"|StopMonitoringDelivery|MonitoredStopVisit/iu);
75+});
76+
77+test("opaque IDs use stable full SHA-256 identities and distinguish opposite directions", () => {
78+ const firstPlace = createPlaceIdentity("METRO", "ZDA-METRO");
79+ assert.deepEqual(firstPlace, createPlaceIdentity("METRO", "ZDA-METRO"));
80+ assert.match(firstPlace.placeId, /^plc_[A-Za-z0-9_-]{43}$/u);
81+ const east = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-EAST:", directionId: "0", destinationRef: "DEST:METRO-SOUTH" });
82+ const west = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-WEST:", directionId: "1", destinationRef: "DEST:METRO-NORTH" });
83+ assert.match(east.serviceId, /^svc_[A-Za-z0-9_-]{43}$/u);
84+ assert.notEqual(east.serviceId, west.serviceId);
85+ assert.match(east.canonicalTuple, /^lapin-fute:service:v1\|/u);
86+});
87+
88+test("import covers five modes, gates perimeter and unsupported modes, preserves homonyms, and uses rail terminals", async (t) => {
89+ const directory = workspace(t);
90+ const candidate = join(directory, "candidate.sqlite");
91+ const active = join(directory, "catalog.sqlite");
92+ const result = await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-a", createdAt: "2026-08-30T00:00:00.000Z", sources: sourcesFor("fixture-2026-08-a") });
93+ assert.equal(result.placeCount, 6);
94+ assert.equal(result.serviceCount, 8);
95+ assert.deepEqual(result.countsByMode, { BUS: 2, METRO: 2, TRAM: 1, RER: 2, TRANSILIEN: 1 });
96+ for (const mode of TRANSPORT_MODE) assert.ok(result.countsByMode[mode] > 0);
97+
98+ activateCatalogCandidate(candidate, active);
99+ const manager = new CatalogManager();
100+ manager.reload(active);
101+ const homonyms = manager.searchPlaces("gare du nord").filter((entry) => entry.mode === "BUS");
102+ assert.equal(homonyms.length, 2);
103+ assert.notEqual(homonyms[0]?.placeId, homonyms[1]?.placeId);
104+ assert.deepEqual(new Set(homonyms.map((entry) => entry.localityLabel)), new Set(["Paris", "Saint-Denis"]));
105+
106+ const metro = place(manager, "chatelet", "METRO");
107+ const metroServices = manager.listServices(metro.placeId) ?? [];
108+ assert.equal(metroServices.length, 2);
109+ assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId);
110+ const rer = place(manager, "chatelet les halles", "RER");
111+ const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy");
112+ const west = service(manager, rer.placeId, "Saint-Germain-en-Laye");
113+ const eastResolution = manager.resolveService(east.serviceId);
114+ const westResolution = manager.resolveService(west.serviceId);
115+ assert.equal(eastResolution.status, "RESOLVED");
116+ assert.equal(westResolution.status, "RESOLVED");
117+ if (eastResolution.status === "RESOLVED" && westResolution.status === "RESOLVED") {
118+ assert.equal(eastResolution.monitoringRef, westResolution.monitoringRef);
119+ assert.notEqual(eastResolution.directionId, westResolution.directionId);
120+ assert.notEqual(eastResolution.destinationRef, westResolution.destinationRef);
121+ assert.notEqual(eastResolution.destinationLabel, "NORA");
122+ assert.notEqual(westResolution.destinationLabel, "ZEBU");
123+ }
124+ assert.equal(manager.searchPlaces("montmartre").length, 0);
125+ const bus = place(manager, "gare du nord", "BUS", "Paris");
126+ const busServices = manager.listServices(bus.placeId) ?? [];
127+ assert.deepEqual(busServices.map((entry) => entry.destinationLabel), ["Hôpital Européen"]);
128+ assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false);
129+ assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u);
130+});
131+
132+test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {
133+ const directory = workspace(t);
134+ const active = join(directory, "catalog.sqlite");
135+ const first = join(directory, "first.sqlite");
136+ await buildCatalogCandidateFromRecords({ candidatePath: first, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") });
137+ activateCatalogCandidate(first, active);
138+ const manager = new CatalogManager();
139+ manager.reload(active);
140+ const busId = service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId;
141+ const removedId = service(manager, place(manager, "chatelet", "METRO").placeId, "Porte de Clignancourt").serviceId;
142+
143+ const second = join(directory, "second.sqlite");
144+ await buildCatalogCandidateFromRecords({ candidatePath: second, sourceRevision: "fixture-2026-08-b", sources: sourcesFor("fixture-2026-08-b") });
145+ activateCatalogCandidate(second, active);
146+ assert.equal(manager.resolveService(removedId).status, "RESOLVED");
147+ manager.reload(active);
148+ assert.equal(service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId, busId);
149+ assert.deepEqual(manager.resolveService(removedId), { status: "UNRESOLVED", code: "INVALID_SERVICE" });
150+
151+ const before = createHash("sha256").update(readFileSync(active)).digest("hex");
152+ const invalid = join(directory, "invalid.sqlite");
153+ await assert.rejects(refreshCatalogFromRecords({ candidatePath: invalid, activePath: active, sourceRevision: "fixture-invalid-no-transilien", sources: sourcesFor("fixture-invalid-no-transilien") }), /no resolvable TRANSILIEN/u);
154+ assert.equal(existsSync(invalid), false);
155+ assert.equal(createHash("sha256").update(readFileSync(active)).digest("hex"), before);
156+ assert.equal(manager.resolveService(busId).status, "RESOLVED");
157+});
158+
159+test("restricted source download streams bytes and puts its token only in Authorization", async (t) => {
160+ const directory = workspace(t);
161+ const destination = join(directory, "source.csv");
162+ const bytes = new TextEncoder().encode("id;name\n1;Châtelet\n");
163+ let requested = "";
164+ let authorization: string | null = null;
165+ let redirect: RequestRedirect | undefined;
166+ const fetchSource = (async (input: string | URL | Request, init?: RequestInit) => {
167+ requested = String(input);
168+ authorization = new Headers(init?.headers).get("Authorization");
169+ redirect = init?.redirect;
170+ return new Response(bytes, { status: 200, headers: { "content-type": "text/csv; charset=utf-8" } });
171+ }) as typeof globalThis.fetch;
172+ const result = await downloadCatalogSource({
173+ source: { dataset: "restricted-fixture", url: "https://data.iledefrance-mobilites.fr/source.csv", retrievedAt: "2026-08-30T00:00:00.000Z", license: "Licence Mobilité", restricted: true },
174+ destinationPath: destination,
175+ expectedContentTypes: ["text/csv"],
176+ datasetToken: "operator-test-value",
177+ fetch: fetchSource,
178+ });
179+ assert.equal(requested.includes("operator-test-value"), false);
180+ assert.equal(authorization, "apikey operator-test-value");
181+ assert.equal(redirect, "manual");
182+ assert.equal(result.bytes, bytes.byteLength);
183+ assert.equal(result.sha256, createHash("sha256").update(bytes).digest("hex"));
184+ assert.deepEqual(readFileSync(destination), Buffer.from(bytes));
185+});
new file mode 100644
@@ -0,0 +1,185 @@
1+import test from "node:test";
2+import type { TestContext } from "node:test";
3+import assert from "node:assert/strict";
4+import { createHash } from "node:crypto";
5+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
6+import { tmpdir } from "node:os";
7+import { join } from "node:path";
8+import { TRANSPORT_MODE } from "../../contracts/src/index.ts";
9+import type { TransportMode } from "../../contracts/src/index.ts";
10+import { CatalogManager } from "../src/catalog.ts";
11+import {
12+ activateCatalogCandidate,
13+ buildCatalogCandidateFromRecords,
14+ createPlaceIdentity,
15+ createServiceIdentity,
16+ downloadCatalogSource,
17+ refreshCatalogFromRecords,
18+} from "../src/catalog-import.ts";
19+import type {
20+ CatalogRecordSourceSet,
21+ CsvRecord,
22+} from "../src/catalog-import.ts";
23+
24+interface Fixture {
25+ readonly schemaVersion: number;
26+ readonly fixtureVersion: string;
27+ readonly dataClassification: string;
28+ readonly attribution: readonly { readonly dataset: string; readonly url: string; readonly retrievedAt: string; readonly license: string }[];
29+ readonly sources: Readonly<Record<keyof CatalogRecordSourceSet, readonly Record<string, unknown>[]>>;
30+}
31+
32+const fixtureUrl = new URL("../../../fixtures/catalog/idfm-v1.json", import.meta.url);
33+const fixtureText = readFileSync(fixtureUrl, "utf8");
34+const fixture = JSON.parse(fixtureText) as Fixture;
35+
36+function sourcesFor(revisionId: string): CatalogRecordSourceSet {
37+ return Object.fromEntries(Object.entries(fixture.sources).map(([name, records]) => [
38+ name,
39+ records.filter((record) => !Array.isArray(record.revisions) || record.revisions.includes(revisionId)).map((record) => {
40+ const { revisions: _revisions, ...sourceRecord } = record;
41+ return sourceRecord as CsvRecord;
42+ }),
43+ ])) as unknown as CatalogRecordSourceSet;
44+}
45+
46+function workspace(t: TestContext): string {
47+ const directory = mkdtempSync(join(tmpdir(), "lapin-fute-catalog-import-"));
48+ t.after(() => rmSync(directory, { recursive: true, force: true }));
49+ return directory;
50+}
51+
52+function place(manager: CatalogManager, query: string, mode: TransportMode, locality?: string) {
53+ const found = manager.searchPlaces(query).find((entry) => entry.mode === mode && (locality === undefined || entry.localityLabel === locality));
54+ assert.ok(found, `expected ${mode} place for ${query}`);
55+ return found;
56+}
57+
58+function service(manager: CatalogManager, placeId: string, destination: string) {
59+ const found = manager.listServices(placeId)?.find((entry) => entry.destinationLabel === destination);
60+ assert.ok(found, `expected service to ${destination}`);
61+ return found;
62+}
63+
64+test("fixture is attributed and contains neither credentials nor personal responses", () => {
65+ assert.equal(fixture.schemaVersion, 1);
66+ assert.equal(fixture.fixtureVersion, "idfm-catalog-v1");
67+ assert.match(fixture.dataClassification, /fixture-only/u);
68+ assert.equal(fixture.attribution.length, 7);
69+ for (const source of fixture.attribution) {
70+ assert.equal(new URL(source.url).hostname, "data.iledefrance-mobilites.fr");
71+ assert.ok(Number.isFinite(Date.parse(source.retrievedAt)));
72+ assert.ok(source.license.length > 0);
73+ }
74+ assert.doesNotMatch(fixtureText, /IDFM_DATASET_TOKEN|PRIM_API_KEY|"apikey"|StopMonitoringDelivery|MonitoredStopVisit/iu);
75+});
76+
77+test("opaque IDs use stable full SHA-256 identities and distinguish opposite directions", () => {
78+ const firstPlace = createPlaceIdentity("METRO", "ZDA-METRO");
79+ assert.deepEqual(firstPlace, createPlaceIdentity("METRO", "ZDA-METRO"));
80+ assert.match(firstPlace.placeId, /^plc_[A-Za-z0-9_-]{43}$/u);
81+ const east = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-EAST:", directionId: "0", destinationRef: "DEST:METRO-SOUTH" });
82+ const west = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-WEST:", directionId: "1", destinationRef: "DEST:METRO-NORTH" });
83+ assert.match(east.serviceId, /^svc_[A-Za-z0-9_-]{43}$/u);
84+ assert.notEqual(east.serviceId, west.serviceId);
85+ assert.match(east.canonicalTuple, /^lapin-fute:service:v1\|/u);
86+});
87+
88+test("import covers five modes, gates perimeter and unsupported modes, preserves homonyms, and uses rail terminals", async (t) => {
89+ const directory = workspace(t);
90+ const candidate = join(directory, "candidate.sqlite");
91+ const active = join(directory, "catalog.sqlite");
92+ const result = await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-a", createdAt: "2026-08-30T00:00:00.000Z", sources: sourcesFor("fixture-2026-08-a") });
93+ assert.equal(result.placeCount, 6);
94+ assert.equal(result.serviceCount, 8);
95+ assert.deepEqual(result.countsByMode, { BUS: 2, METRO: 2, TRAM: 1, RER: 2, TRANSILIEN: 1 });
96+ for (const mode of TRANSPORT_MODE) assert.ok(result.countsByMode[mode] > 0);
97+
98+ activateCatalogCandidate(candidate, active);
99+ const manager = new CatalogManager();
100+ manager.reload(active);
101+ const homonyms = manager.searchPlaces("gare du nord").filter((entry) => entry.mode === "BUS");
102+ assert.equal(homonyms.length, 2);
103+ assert.notEqual(homonyms[0]?.placeId, homonyms[1]?.placeId);
104+ assert.deepEqual(new Set(homonyms.map((entry) => entry.localityLabel)), new Set(["Paris", "Saint-Denis"]));
105+
106+ const metro = place(manager, "chatelet", "METRO");
107+ const metroServices = manager.listServices(metro.placeId) ?? [];
108+ assert.equal(metroServices.length, 2);
109+ assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId);
110+ const rer = place(manager, "chatelet les halles", "RER");
111+ const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy");
112+ const west = service(manager, rer.placeId, "Saint-Germain-en-Laye");
113+ const eastResolution = manager.resolveService(east.serviceId);
114+ const westResolution = manager.resolveService(west.serviceId);
115+ assert.equal(eastResolution.status, "RESOLVED");
116+ assert.equal(westResolution.status, "RESOLVED");
117+ if (eastResolution.status === "RESOLVED" && westResolution.status === "RESOLVED") {
118+ assert.equal(eastResolution.monitoringRef, westResolution.monitoringRef);
119+ assert.notEqual(eastResolution.directionId, westResolution.directionId);
120+ assert.notEqual(eastResolution.destinationRef, westResolution.destinationRef);
121+ assert.notEqual(eastResolution.destinationLabel, "NORA");
122+ assert.notEqual(westResolution.destinationLabel, "ZEBU");
123+ }
124+ assert.equal(manager.searchPlaces("montmartre").length, 0);
125+ const bus = place(manager, "gare du nord", "BUS", "Paris");
126+ const busServices = manager.listServices(bus.placeId) ?? [];
127+ assert.deepEqual(busServices.map((entry) => entry.destinationLabel), ["Hôpital Européen"]);
128+ assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false);
129+ assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u);
130+});
131+
132+test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {
133+ const directory = workspace(t);
134+ const active = join(directory, "catalog.sqlite");
135+ const first = join(directory, "first.sqlite");
136+ await buildCatalogCandidateFromRecords({ candidatePath: first, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") });
137+ activateCatalogCandidate(first, active);
138+ const manager = new CatalogManager();
139+ manager.reload(active);
140+ const busId = service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId;
141+ const removedId = service(manager, place(manager, "chatelet", "METRO").placeId, "Porte de Clignancourt").serviceId;
142+
143+ const second = join(directory, "second.sqlite");
144+ await buildCatalogCandidateFromRecords({ candidatePath: second, sourceRevision: "fixture-2026-08-b", sources: sourcesFor("fixture-2026-08-b") });
145+ activateCatalogCandidate(second, active);
146+ assert.equal(manager.resolveService(removedId).status, "RESOLVED");
147+ manager.reload(active);
148+ assert.equal(service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId, busId);
149+ assert.deepEqual(manager.resolveService(removedId), { status: "UNRESOLVED", code: "INVALID_SERVICE" });
150+
151+ const before = createHash("sha256").update(readFileSync(active)).digest("hex");
152+ const invalid = join(directory, "invalid.sqlite");
153+ await assert.rejects(refreshCatalogFromRecords({ candidatePath: invalid, activePath: active, sourceRevision: "fixture-invalid-no-transilien", sources: sourcesFor("fixture-invalid-no-transilien") }), /no resolvable TRANSILIEN/u);
154+ assert.equal(existsSync(invalid), false);
155+ assert.equal(createHash("sha256").update(readFileSync(active)).digest("hex"), before);
156+ assert.equal(manager.resolveService(busId).status, "RESOLVED");
157+});
158+
159+test("restricted source download streams bytes and puts its token only in Authorization", async (t) => {
160+ const directory = workspace(t);
161+ const destination = join(directory, "source.csv");
162+ const bytes = new TextEncoder().encode("id;name\n1;Châtelet\n");
163+ let requested = "";
164+ let authorization: string | null = null;
165+ let redirect: RequestRedirect | undefined;
166+ const fetchSource = (async (input: string | URL | Request, init?: RequestInit) => {
167+ requested = String(input);
168+ authorization = new Headers(init?.headers).get("Authorization");
169+ redirect = init?.redirect;
170+ return new Response(bytes, { status: 200, headers: { "content-type": "text/csv; charset=utf-8" } });
171+ }) as typeof globalThis.fetch;
172+ const result = await downloadCatalogSource({
173+ source: { dataset: "restricted-fixture", url: "https://data.iledefrance-mobilites.fr/source.csv", retrievedAt: "2026-08-30T00:00:00.000Z", license: "Licence Mobilité", restricted: true },
174+ destinationPath: destination,
175+ expectedContentTypes: ["text/csv"],
176+ datasetToken: "operator-test-value",
177+ fetch: fetchSource,
178+ });
179+ assert.equal(requested.includes("operator-test-value"), false);
180+ assert.equal(authorization, "apikey operator-test-value");
181+ assert.equal(redirect, "manual");
182+ assert.equal(result.bytes, bytes.byteLength);
183+ assert.equal(result.sha256, createHash("sha256").update(bytes).digest("hex"));
184+ assert.deepEqual(readFileSync(destination), Buffer.from(bytes));
185+});
added packages/backend/test/catalog.test.ts +467 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/contracts.js +4 -1
@@ -8,7 +8,10 @@ var LIMITS = Object.freeze({
88 favorites: 8,
99 departures: 4,
1010 httpTimeoutMs: 8000,
11- httpResponseBytes: 262144
11+ httpResponseBytes: 262144,
12+ catalogQueryMinCharacters: 2,
13+ catalogQueryMaxCharacters: 100,
14+ catalogSearchResults: 20
1215 });
1316 var CACHE_FRESH_SECONDS = 60;
1417 var FAVORITE_SETTLE_MS = 500;
@@ -8,7 +8,10 @@ var LIMITS = Object.freeze({
8 favorites: 8,8 favorites: 8,
9 departures: 4,9 departures: 4,
10 httpTimeoutMs: 8000,10 httpTimeoutMs: 8000,
11- httpResponseBytes: 26214411+ httpResponseBytes: 262144,
12+ catalogQueryMinCharacters: 2,
13+ catalogQueryMaxCharacters: 100,
14+ catalogSearchResults: 20
12 });15 });
13 var CACHE_FRESH_SECONDS = 60;16 var CACHE_FRESH_SECONDS = 60;
14 var FAVORITE_SETTLE_MS = 500;17 var FAVORITE_SETTLE_MS = 500;
modified packages/contracts/src/index.ts +80 -0
@@ -8,6 +8,9 @@ export const LIMITS = {
88 departures: 4,
99 httpTimeoutMs: 8_000,
1010 httpResponseBytes: 262_144,
11+ catalogQueryMinCharacters: 2,
12+ catalogQueryMaxCharacters: 100,
13+ catalogSearchResults: 20,
1114 } as const;
1215
1316 export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;
@@ -24,6 +27,9 @@ export const ERROR_CODE = [
2427 "RATE_LIMITED",
2528 "INVALID_RESPONSE",
2629 ] as const;
30+export const TRANSPORT_MODE = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"] as const;
31+export const CATALOG_ERROR_CODE = ["INVALID_QUERY", "PLACE_NOT_FOUND", "CATALOG_UNAVAILABLE", "METHOD_NOT_ALLOWED"] as const;
32+export const CATALOG_ROUTE = { places: "/api/catalog/places", placeServices: "/api/catalog/places/:placeId/services" } as const;
2733 export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const;
2834 export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const;
2935 export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const;
@@ -97,6 +103,8 @@ export const WIRE_LANGUAGE = { EN: "en", FR: "fr" } as const;
97103 export type Freshness = (typeof FRESHNESS)[number];
98104 export type DepartureStatus = (typeof DEPARTURE_STATUS)[number];
99105 export type ErrorCode = (typeof ERROR_CODE)[number];
106+export type TransportMode = (typeof TRANSPORT_MODE)[number];
107+export type CatalogErrorCode = (typeof CATALOG_ERROR_CODE)[number];
100108 export type ApiKeyAction = (typeof API_KEY_ACTION)[number];
101109 export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];
102110 export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];
@@ -138,6 +146,35 @@ export interface ErrorResult {
138146 occurredAt: number;
139147 retryAfterSeconds?: number;
140148 }
149+export interface PlaceSearchItem {
150+ placeId: string;
151+ stopLabel: string;
152+ localityLabel?: string;
153+ mode: TransportMode;
154+}
155+
156+export interface PlaceSearchResult {
157+ schemaVersion: typeof SCHEMA_VERSION;
158+ places: PlaceSearchItem[];
159+}
160+
161+export interface ServiceOption {
162+ serviceId: string;
163+ stopLabel: string;
164+ lineLabel: string;
165+ destinationLabel: string;
166+}
167+
168+export interface ServiceOptionsResult {
169+ schemaVersion: typeof SCHEMA_VERSION;
170+ placeId: string;
171+ services: ServiceOption[];
172+}
173+
174+export interface CatalogErrorResult {
175+ schemaVersion: typeof SCHEMA_VERSION;
176+ code: CatalogErrorCode;
177+}
141178
142179 export type ApiKeyUpdate =
143180 | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" }
@@ -155,6 +192,11 @@ const domainKeys = {
155192 result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],
156193 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
157194 keyUpdate: ["schemaVersion", "action", "value"],
195+ placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],
196+ placeSearchResult: ["schemaVersion", "places"],
197+ serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"],
198+ serviceOptionsResult: ["schemaVersion", "placeId", "services"],
199+ catalogError: ["schemaVersion", "code"],
158200 } as const;
159201
160202 function object(value: unknown): value is Record<string, unknown> {
@@ -234,6 +276,44 @@ export function isErrorResult(value: unknown): value is ErrorResult {
234276 && optionalUint32(value.retryAfterSeconds);
235277 }
236278
279+export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem {
280+ if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)) return false;
281+ return boundedString(value.placeId, LIMITS.idUtf8Bytes)
282+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
283+ && optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes)
284+ && TRANSPORT_MODE.includes(value.mode as TransportMode);
285+}
286+
287+export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult {
288+ if (!object(value) || !exactKeys(value, domainKeys.placeSearchResult)) return false;
289+ return value.schemaVersion === SCHEMA_VERSION
290+ && Array.isArray(value.places)
291+ && value.places.length <= LIMITS.catalogSearchResults
292+ && value.places.every(isPlaceSearchItem);
293+}
294+
295+export function isServiceOption(value: unknown): value is ServiceOption {
296+ if (!object(value) || !exactKeys(value, domainKeys.serviceOption)) return false;
297+ return boundedString(value.serviceId, LIMITS.idUtf8Bytes)
298+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
299+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
300+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes);
301+}
302+
303+export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult {
304+ if (!object(value) || !exactKeys(value, domainKeys.serviceOptionsResult)) return false;
305+ return value.schemaVersion === SCHEMA_VERSION
306+ && boundedString(value.placeId, LIMITS.idUtf8Bytes)
307+ && Array.isArray(value.services)
308+ && value.services.every(isServiceOption);
309+}
310+
311+export function isCatalogErrorResult(value: unknown): value is CatalogErrorResult {
312+ if (!object(value) || !exactKeys(value, domainKeys.catalogError)) return false;
313+ return value.schemaVersion === SCHEMA_VERSION
314+ && CATALOG_ERROR_CODE.includes(value.code as CatalogErrorCode);
315+}
316+
237317 export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate {
238318 if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false;
239319 if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes);
@@ -8,6 +8,9 @@ export const LIMITS = {
8 departures: 4,8 departures: 4,
9 httpTimeoutMs: 8_000,9 httpTimeoutMs: 8_000,
10 httpResponseBytes: 262_144,10 httpResponseBytes: 262_144,
11+ catalogQueryMinCharacters: 2,
12+ catalogQueryMaxCharacters: 100,
13+ catalogSearchResults: 20,
11 } as const;14 } as const;
12 15
13 export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;16 export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;
@@ -24,6 +27,9 @@ export const ERROR_CODE = [
24 "RATE_LIMITED",27 "RATE_LIMITED",
25 "INVALID_RESPONSE",28 "INVALID_RESPONSE",
26 ] as const;29 ] as const;
30+export const TRANSPORT_MODE = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"] as const;
31+export const CATALOG_ERROR_CODE = ["INVALID_QUERY", "PLACE_NOT_FOUND", "CATALOG_UNAVAILABLE", "METHOD_NOT_ALLOWED"] as const;
32+export const CATALOG_ROUTE = { places: "/api/catalog/places", placeServices: "/api/catalog/places/:placeId/services" } as const;
27 export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const;33 export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const;
28 export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const;34 export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const;
29 export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const;35 export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const;
@@ -97,6 +103,8 @@ export const WIRE_LANGUAGE = { EN: "en", FR: "fr" } as const;
97 export type Freshness = (typeof FRESHNESS)[number];103 export type Freshness = (typeof FRESHNESS)[number];
98 export type DepartureStatus = (typeof DEPARTURE_STATUS)[number];104 export type DepartureStatus = (typeof DEPARTURE_STATUS)[number];
99 export type ErrorCode = (typeof ERROR_CODE)[number];105 export type ErrorCode = (typeof ERROR_CODE)[number];
106+export type TransportMode = (typeof TRANSPORT_MODE)[number];
107+export type CatalogErrorCode = (typeof CATALOG_ERROR_CODE)[number];
100 export type ApiKeyAction = (typeof API_KEY_ACTION)[number];108 export type ApiKeyAction = (typeof API_KEY_ACTION)[number];
101 export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];109 export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];
102 export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];110 export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];
@@ -138,6 +146,35 @@ export interface ErrorResult {
138 occurredAt: number;146 occurredAt: number;
139 retryAfterSeconds?: number;147 retryAfterSeconds?: number;
140 }148 }
149+export interface PlaceSearchItem {
150+ placeId: string;
151+ stopLabel: string;
152+ localityLabel?: string;
153+ mode: TransportMode;
154+}
155+
156+export interface PlaceSearchResult {
157+ schemaVersion: typeof SCHEMA_VERSION;
158+ places: PlaceSearchItem[];
159+}
160+
161+export interface ServiceOption {
162+ serviceId: string;
163+ stopLabel: string;
164+ lineLabel: string;
165+ destinationLabel: string;
166+}
167+
168+export interface ServiceOptionsResult {
169+ schemaVersion: typeof SCHEMA_VERSION;
170+ placeId: string;
171+ services: ServiceOption[];
172+}
173+
174+export interface CatalogErrorResult {
175+ schemaVersion: typeof SCHEMA_VERSION;
176+ code: CatalogErrorCode;
177+}
141 178
142 export type ApiKeyUpdate =179 export type ApiKeyUpdate =
143 | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" }180 | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" }
@@ -155,6 +192,11 @@ const domainKeys = {
155 result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],192 result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],
156 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],193 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
157 keyUpdate: ["schemaVersion", "action", "value"],194 keyUpdate: ["schemaVersion", "action", "value"],
195+ placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],
196+ placeSearchResult: ["schemaVersion", "places"],
197+ serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"],
198+ serviceOptionsResult: ["schemaVersion", "placeId", "services"],
199+ catalogError: ["schemaVersion", "code"],
158 } as const;200 } as const;
159 201
160 function object(value: unknown): value is Record<string, unknown> {202 function object(value: unknown): value is Record<string, unknown> {
@@ -234,6 +276,44 @@ export function isErrorResult(value: unknown): value is ErrorResult {
234 && optionalUint32(value.retryAfterSeconds);276 && optionalUint32(value.retryAfterSeconds);
235 }277 }
236 278
279+export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem {
280+ if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)) return false;
281+ return boundedString(value.placeId, LIMITS.idUtf8Bytes)
282+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
283+ && optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes)
284+ && TRANSPORT_MODE.includes(value.mode as TransportMode);
285+}
286+
287+export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult {
288+ if (!object(value) || !exactKeys(value, domainKeys.placeSearchResult)) return false;
289+ return value.schemaVersion === SCHEMA_VERSION
290+ && Array.isArray(value.places)
291+ && value.places.length <= LIMITS.catalogSearchResults
292+ && value.places.every(isPlaceSearchItem);
293+}
294+
295+export function isServiceOption(value: unknown): value is ServiceOption {
296+ if (!object(value) || !exactKeys(value, domainKeys.serviceOption)) return false;
297+ return boundedString(value.serviceId, LIMITS.idUtf8Bytes)
298+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
299+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
300+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes);
301+}
302+
303+export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult {
304+ if (!object(value) || !exactKeys(value, domainKeys.serviceOptionsResult)) return false;
305+ return value.schemaVersion === SCHEMA_VERSION
306+ && boundedString(value.placeId, LIMITS.idUtf8Bytes)
307+ && Array.isArray(value.services)
308+ && value.services.every(isServiceOption);
309+}
310+
311+export function isCatalogErrorResult(value: unknown): value is CatalogErrorResult {
312+ if (!object(value) || !exactKeys(value, domainKeys.catalogError)) return false;
313+ return value.schemaVersion === SCHEMA_VERSION
314+ && CATALOG_ERROR_CODE.includes(value.code as CatalogErrorCode);
315+}
316+
237 export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate {317 export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate {
238 if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false;318 if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false;
239 if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes);319 if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes);
modified packages/contracts/test/contracts.test.ts +99 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/measure-catalog.mjs +262 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/probe-catalog.mjs +275 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/refresh-catalog.mjs +442 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/test.mjs +3 -0
@@ -10,6 +10,9 @@ run(process.execPath, [
1010 "--test",
1111 "packages/contracts/test/contracts.test.ts",
1212 "packages/backend/test/backend.test.ts",
13+ "packages/backend/test/catalog.test.ts",
14+ "packages/backend/test/catalog-import.test.ts",
15+ "packages/backend/test/catalog-http.test.ts",
1316 "packages/config-page/test/config-page.test.ts",
1417 "packages/companion/test/protocol.test.js",
1518 "packages/companion/test/companion.test.js",
@@ -10,6 +10,9 @@ run(process.execPath, [
10 "--test",10 "--test",
11 "packages/contracts/test/contracts.test.ts",11 "packages/contracts/test/contracts.test.ts",
12 "packages/backend/test/backend.test.ts",12 "packages/backend/test/backend.test.ts",
13+ "packages/backend/test/catalog.test.ts",
14+ "packages/backend/test/catalog-import.test.ts",
15+ "packages/backend/test/catalog-http.test.ts",
13 "packages/config-page/test/config-page.test.ts",16 "packages/config-page/test/config-page.test.ts",
14 "packages/companion/test/protocol.test.js",17 "packages/companion/test/protocol.test.js",
15 "packages/companion/test/companion.test.js",18 "packages/companion/test/companion.test.js",