Resolve IDFM service catalog with bounded importer (#11)Unverified
Add frozen catalog contracts (routes, transport modes, validators), a node:sqlite-backed CatalogManager with accent-tolerant FTS search and opaque service resolution, and a streaming IDFM importer that gates every service on an exact realtime perimeter pair. Operator commands cover refresh (atomic candidate activation, TOCTOU snapshot check), measurement (>=1000 warm searches, p95 <= 200 ms), and the operator-only five-call PRIM probe. Fixture-derived offline tests prove five-mode coverage, stable IDs across revisions, removal as INVALID_SERVICE, and rollback on failed replacement.
acf5bba parent: ffd859a modified
.gitignore +1 -0 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | node_modules/ |
| 2 | +var/ | |
| 2 | 3 | packages/watch/build/ |
| 3 | 4 | packages/watch/src/pkjs/ |
| 4 | 5 | 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 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "scripts": { |
| 13 | 13 | "build": "node scripts/build.mjs", |
| 14 | + "catalog:probe": "node scripts/probe-catalog.mjs", | |
| 15 | + "catalog:refresh": "node scripts/refresh-catalog.mjs", | |
| 14 | 16 | "measure:radio": "node scripts/measure-radio.mjs", |
| 17 | + "measure:catalog": "node scripts/measure-catalog.mjs", | |
| 15 | 18 | "test": "node scripts/test.mjs", |
| 16 | 19 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 17 | 20 | "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 @@ | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "exports": "./src/index.ts", |
| 7 | + "dependencies": { | |
| 8 | + "csv-parse": "7.0.2", | |
| 9 | + "yauzl": "3.4.0" | |
| 10 | + }, | |
| 7 | 11 | "scripts": { |
| 8 | - "test": "node --test test/backend.test.ts" | |
| 12 | + "test": "node --test test/*.test.ts" | |
| 9 | 13 | } |
| 10 | 14 | } |
| @@ -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 | new file mode 100644 | ||
| @@ -0,0 +1,1462 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { | |
| 3 | + closeSync, | |
| 4 | + createReadStream, | |
| 5 | + existsSync, | |
| 6 | + fsyncSync, | |
| 7 | + mkdirSync, | |
| 8 | + openSync, | |
| 9 | + renameSync, | |
| 10 | + statSync, | |
| 11 | + unlinkSync, | |
| 12 | +} from "node:fs"; | |
| 13 | +import { open as openFile } from "node:fs/promises"; | |
| 14 | +import type { FileHandle } from "node:fs/promises"; | |
| 15 | +import { basename, dirname, resolve } from "node:path"; | |
| 16 | +import { Readable, Transform } from "node:stream"; | |
| 17 | +import { DatabaseSync } from "node:sqlite"; | |
| 18 | +import { parse } from "csv-parse"; | |
| 19 | +import yauzl from "yauzl"; | |
| 20 | +import { | |
| 21 | + LIMITS, | |
| 22 | + SCHEMA_VERSION, | |
| 23 | + TRANSPORT_MODE, | |
| 24 | +} from "../../contracts/src/index.ts"; | |
| 25 | +import type { TransportMode } from "../../contracts/src/index.ts"; | |
| 26 | + | |
| 27 | +export const CATALOG_VERSION = 1 as const; | |
| 28 | +export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000; | |
| 29 | +export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3; | |
| 30 | +export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3; | |
| 31 | +export const MAX_SELECTED_ARCHIVE_BYTES = 16 * 1024 ** 3; | |
| 32 | +export const MAX_CSV_RECORD_BYTES = 1024 * 1024; | |
| 33 | +export const IMPORT_TRANSACTION_ROWS = 10_000; | |
| 34 | +export const IDFM_CATALOG_SOURCE_URL = { | |
| 35 | + gtfsRecord: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/offre-horaires-tc-gtfs-idfm/records?limit=1", | |
| 36 | + perimeter: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/perimetre-des-donnees-tr-disponibles-plateforme-idfm/exports/csv?limit=-1", | |
| 37 | + arrets: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets/exports/csv?limit=-1", | |
| 38 | + zones: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/zones-d-arrets/exports/csv?limit=-1", | |
| 39 | + relations: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/relations/exports/csv?limit=-1", | |
| 40 | + lines: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/exports/csv?limit=-1", | |
| 41 | + arretsLignes: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets-lignes/exports/csv?limit=-1", | |
| 42 | +} as const; | |
| 43 | + | |
| 44 | +const SUPPORTED_MODE: Readonly<Record<TransportMode, true>> = { | |
| 45 | + BUS: true, | |
| 46 | + METRO: true, | |
| 47 | + TRAM: true, | |
| 48 | + RER: true, | |
| 49 | + TRANSILIEN: true, | |
| 50 | +}; | |
| 51 | +const REQUIRED_GTFS_ENTRIES = [ | |
| 52 | + "routes.txt", | |
| 53 | + "trips.txt", | |
| 54 | + "stop_times.txt", | |
| 55 | + "stops.txt", | |
| 56 | + "agency.txt", | |
| 57 | +] as const; | |
| 58 | +const CODE_ENTRY_NAME: Readonly<Record<string, true>> = { | |
| 59 | + "object_codes_extension.txt": true, | |
| 60 | + "objects_codes_extension.txt": true, | |
| 61 | +}; | |
| 62 | + | |
| 63 | +export class CatalogImportError extends Error { | |
| 64 | + readonly code: string; | |
| 65 | + | |
| 66 | + constructor(code: string, message: string, options?: ErrorOptions) { | |
| 67 | + super(message, options); | |
| 68 | + this.name = "CatalogImportError"; | |
| 69 | + this.code = code; | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +export type CsvValue = string | number | boolean | null | undefined; | |
| 74 | +export type CsvRecord = Readonly<Record<string, CsvValue>>; | |
| 75 | + | |
| 76 | +export interface CatalogRecordSourceSet { | |
| 77 | + readonly routes: readonly CsvRecord[]; | |
| 78 | + readonly trips: readonly CsvRecord[]; | |
| 79 | + readonly stopTimes: readonly CsvRecord[]; | |
| 80 | + readonly stops: readonly CsvRecord[]; | |
| 81 | + readonly agency: readonly CsvRecord[]; | |
| 82 | + readonly objectCodes: readonly CsvRecord[]; | |
| 83 | + readonly perimeter: readonly CsvRecord[]; | |
| 84 | + readonly arrets: readonly CsvRecord[]; | |
| 85 | + readonly zones: readonly CsvRecord[]; | |
| 86 | + readonly relations: readonly CsvRecord[]; | |
| 87 | + readonly lines: readonly CsvRecord[]; | |
| 88 | + readonly arretsLignes: readonly CsvRecord[]; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export interface CsvFileSource { | |
| 92 | + readonly path: string; | |
| 93 | + readonly delimiter?: "," | ";"; | |
| 94 | + readonly expectedRows?: number; | |
| 95 | +} | |
| 96 | + | |
| 97 | +export interface CatalogFileSourceSet { | |
| 98 | + readonly gtfsZipPath: string; | |
| 99 | + readonly perimeter: CsvFileSource; | |
| 100 | + readonly arrets: CsvFileSource; | |
| 101 | + readonly zones: CsvFileSource; | |
| 102 | + readonly relations: CsvFileSource; | |
| 103 | + readonly lines: CsvFileSource; | |
| 104 | + readonly arretsLignes: CsvFileSource; | |
| 105 | +} | |
| 106 | + | |
| 107 | +export interface CatalogSourceAttribution { | |
| 108 | + readonly dataset: string; | |
| 109 | + readonly url: string; | |
| 110 | + readonly retrievedAt: string; | |
| 111 | + readonly license: string; | |
| 112 | + readonly restricted?: boolean; | |
| 113 | +} | |
| 114 | + | |
| 115 | +export interface DownloadCatalogSourceOptions { | |
| 116 | + readonly source: CatalogSourceAttribution; | |
| 117 | + readonly destinationPath: string; | |
| 118 | + readonly expectedContentTypes: readonly string[]; | |
| 119 | + readonly datasetToken?: string; | |
| 120 | + readonly fetch?: typeof globalThis.fetch; | |
| 121 | +} | |
| 122 | + | |
| 123 | +export interface DownloadedCatalogSource { | |
| 124 | + readonly path: string; | |
| 125 | + readonly bytes: number; | |
| 126 | + readonly sha256: string; | |
| 127 | + readonly source: CatalogSourceAttribution; | |
| 128 | +} | |
| 129 | +export interface ResolvedGtfsDownload { | |
| 130 | + readonly filename: string; | |
| 131 | + readonly url: string; | |
| 132 | +} | |
| 133 | + | |
| 134 | +export interface BuildCatalogOptions { | |
| 135 | + readonly candidatePath: string; | |
| 136 | + readonly sourceRevision: string; | |
| 137 | + readonly createdAt?: string; | |
| 138 | + readonly sources: CatalogFileSourceSet; | |
| 139 | +} | |
| 140 | + | |
| 141 | +export interface BuildCatalogFromRecordsOptions { | |
| 142 | + readonly candidatePath: string; | |
| 143 | + readonly sourceRevision: string; | |
| 144 | + readonly createdAt?: string; | |
| 145 | + readonly sources: CatalogRecordSourceSet; | |
| 146 | +} | |
| 147 | + | |
| 148 | +export interface PlaceResolutionCounts { | |
| 149 | + readonly arrets: number; | |
| 150 | + readonly relations: number; | |
| 151 | + readonly direct: number; | |
| 152 | +} | |
| 153 | + | |
| 154 | +export interface CatalogBuildResult { | |
| 155 | + readonly candidatePath: string; | |
| 156 | + readonly sourceRevision: string; | |
| 157 | + readonly createdAt: string; | |
| 158 | + readonly placeCount: number; | |
| 159 | + readonly serviceCount: number; | |
| 160 | + readonly countsByMode: Readonly<Record<TransportMode, number>>; | |
| 161 | + readonly placeResolution?: PlaceResolutionCounts; | |
| 162 | + readonly revalidatedAfterRenameFailure?: true; | |
| 163 | +} | |
| 164 | + | |
| 165 | +interface CsvSpec { | |
| 166 | + readonly required: readonly string[]; | |
| 167 | + readonly insert: (row: Readonly<Record<string, string>>) => void; | |
| 168 | +} | |
| 169 | + | |
| 170 | +interface ZipEntryLike { | |
| 171 | + readonly fileName: string; | |
| 172 | + readonly uncompressedSize: number; | |
| 173 | + readonly generalPurposeBitFlag: number; | |
| 174 | +} | |
| 175 | + | |
| 176 | +interface ZipFileLike { | |
| 177 | + readEntry(): void; | |
| 178 | + close(): void; | |
| 179 | + once(event: "entry", listener: (entry: ZipEntryLike) => void): this; | |
| 180 | + once(event: "end" | "error", listener: (...args: unknown[]) => void): this; | |
| 181 | + removeListener(event: string, listener: (...args: never[]) => void): this; | |
| 182 | + openReadStream( | |
| 183 | + entry: ZipEntryLike, | |
| 184 | + callback: (error: Error | null, stream?: Readable) => void, | |
| 185 | + ): void; | |
| 186 | +} | |
| 187 | + | |
| 188 | +interface QueryRow { | |
| 189 | + readonly [key: string]: unknown; | |
| 190 | +} | |
| 191 | + | |
| 192 | +function fail(code: string, message: string, cause?: unknown): never { | |
| 193 | + throw new CatalogImportError(code, message, cause === undefined ? undefined : { cause }); | |
| 194 | +} | |
| 195 | + | |
| 196 | +function nonEmpty(value: unknown, field: string): string { | |
| 197 | + const text = String(value ?? "").trim(); | |
| 198 | + if (text.length === 0 || text.includes("\0")) fail("INVALID_SOURCE", `${field} must be non-empty`); | |
| 199 | + return text; | |
| 200 | +} | |
| 201 | + | |
| 202 | +function optionalText(value: unknown): string { | |
| 203 | + const text = String(value ?? "").trim(); | |
| 204 | + if (text.includes("\0")) fail("INVALID_SOURCE", "source text contains NUL"); | |
| 205 | + return text; | |
| 206 | +} | |
| 207 | + | |
| 208 | +function integer(value: unknown, field: string): number { | |
| 209 | + const text = nonEmpty(value, field); | |
| 210 | + if (!/^-?\d+$/u.test(text)) fail("INVALID_SOURCE", `${field} must be an integer`); | |
| 211 | + const parsed = Number(text); | |
| 212 | + if (!Number.isSafeInteger(parsed)) fail("INVALID_SOURCE", `${field} exceeds safe integer range`); | |
| 213 | + return parsed; | |
| 214 | +} | |
| 215 | + | |
| 216 | +function transportMode(value: unknown): TransportMode { | |
| 217 | + const mode = nonEmpty(value, "mode"); | |
| 218 | + if (!Object.hasOwn(SUPPORTED_MODE, mode)) fail("INVALID_MODE", `unsupported transport mode ${mode}`); | |
| 219 | + return mode as TransportMode; | |
| 220 | +} | |
| 221 | + | |
| 222 | +function canonicalLineId(value: unknown, field: string): string { | |
| 223 | + const text = nonEmpty(value, field); | |
| 224 | + return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text; | |
| 225 | +} | |
| 226 | + | |
| 227 | +function isoTimestamp(value: string, field: string): string { | |
| 228 | + if (value.length === 0 || !Number.isFinite(Date.parse(value))) { | |
| 229 | + fail("INVALID_METADATA", `${field} must be an ISO timestamp`); | |
| 230 | + } | |
| 231 | + return value; | |
| 232 | +} | |
| 233 | + | |
| 234 | +function assertSourceAttribution(source: CatalogSourceAttribution): void { | |
| 235 | + nonEmpty(source.dataset, "source.dataset"); | |
| 236 | + nonEmpty(source.license, "source.license"); | |
| 237 | + isoTimestamp(source.retrievedAt, "source.retrievedAt"); | |
| 238 | + let url: URL; | |
| 239 | + try { | |
| 240 | + url = new URL(source.url); | |
| 241 | + } catch (error) { | |
| 242 | + fail("INVALID_SOURCE_URL", "source URL is invalid", error); | |
| 243 | + } | |
| 244 | + if (url.protocol !== "https:" || url.username !== "" || url.password !== "") { | |
| 245 | + fail("INVALID_SOURCE_URL", "source URL must be credential-free HTTPS"); | |
| 246 | + } | |
| 247 | +} | |
| 248 | + | |
| 249 | +function safeUnlink(path: string): void { | |
| 250 | + try { | |
| 251 | + unlinkSync(path); | |
| 252 | + } catch (error) { | |
| 253 | + const code = (error as NodeJS.ErrnoException).code; | |
| 254 | + if (code !== "ENOENT") throw error; | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +function removeCandidateFiles(path: string): void { | |
| 259 | + safeUnlink(path); | |
| 260 | + safeUnlink(`${path}-journal`); | |
| 261 | + safeUnlink(`${path}-wal`); | |
| 262 | + safeUnlink(`${path}-shm`); | |
| 263 | +} | |
| 264 | + | |
| 265 | +function fsyncFile(path: string): void { | |
| 266 | + const descriptor = openSync(path, "r"); | |
| 267 | + try { | |
| 268 | + fsyncSync(descriptor); | |
| 269 | + } finally { | |
| 270 | + closeSync(descriptor); | |
| 271 | + } | |
| 272 | +} | |
| 273 | + | |
| 274 | +function fsyncDirectory(path: string): void { | |
| 275 | + const descriptor = openSync(path, "r"); | |
| 276 | + try { | |
| 277 | + fsyncSync(descriptor); | |
| 278 | + } finally { | |
| 279 | + closeSync(descriptor); | |
| 280 | + } | |
| 281 | +} | |
| 282 | + | |
| 283 | +export function normalizeCatalogSearchText(value: string): string { | |
| 284 | + return value | |
| 285 | + .normalize("NFKD") | |
| 286 | + .replace(/\p{M}+/gu, "") | |
| 287 | + .toLocaleLowerCase("fr-FR") | |
| 288 | + .replace(/[^\p{L}\p{N}]+/gu, " ") | |
| 289 | + .trim() | |
| 290 | + .replace(/\s+/gu, " "); | |
| 291 | +} | |
| 292 | + | |
| 293 | +function canonicalTuple(namespace: "place" | "service", fields: readonly string[]): string { | |
| 294 | + let tuple = `lapin-fute:${namespace}:v1`; | |
| 295 | + for (const field of fields) tuple += `|${Buffer.byteLength(field, "utf8")}:${field}`; | |
| 296 | + return tuple; | |
| 297 | +} | |
| 298 | + | |
| 299 | +function opaqueId(prefix: "plc_" | "svc_", tuple: string): string { | |
| 300 | + return prefix + createHash("sha256").update(tuple, "utf8").digest("base64url"); | |
| 301 | +} | |
| 302 | + | |
| 303 | +export function createPlaceIdentity(mode: TransportMode, zdaId: string): { | |
| 304 | + readonly placeId: string; | |
| 305 | + readonly canonicalTuple: string; | |
| 306 | +} { | |
| 307 | + const tuple = canonicalTuple("place", [transportMode(mode), nonEmpty(zdaId, "zdaId")]); | |
| 308 | + return { placeId: opaqueId("plc_", tuple), canonicalTuple: tuple }; | |
| 309 | +} | |
| 310 | + | |
| 311 | +export interface ServiceIdentityInput { | |
| 312 | + readonly mode: TransportMode; | |
| 313 | + readonly lineId: string; | |
| 314 | + readonly monitoringRef: string; | |
| 315 | + readonly directionId: string; | |
| 316 | + readonly destinationRef: string; | |
| 317 | +} | |
| 318 | + | |
| 319 | +export function createServiceIdentity(input: ServiceIdentityInput): { | |
| 320 | + readonly serviceId: string; | |
| 321 | + readonly canonicalTuple: string; | |
| 322 | +} { | |
| 323 | + const tuple = canonicalTuple("service", [ | |
| 324 | + transportMode(input.mode), | |
| 325 | + nonEmpty(input.lineId, "lineId"), | |
| 326 | + nonEmpty(input.monitoringRef, "monitoringRef"), | |
| 327 | + nonEmpty(input.directionId, "directionId"), | |
| 328 | + nonEmpty(input.destinationRef, "destinationRef"), | |
| 329 | + ]); | |
| 330 | + return { serviceId: opaqueId("svc_", tuple), canonicalTuple: tuple }; | |
| 331 | +} | |
| 332 | + | |
| 333 | +export async function resolveIdfmGtfsDownload( | |
| 334 | + datasetToken: string, | |
| 335 | + fetchSource: typeof globalThis.fetch = globalThis.fetch, | |
| 336 | +): Promise<ResolvedGtfsDownload> { | |
| 337 | + const token = nonEmpty(datasetToken, "datasetToken"); | |
| 338 | + const controller = new AbortController(); | |
| 339 | + const timeout = setTimeout(() => controller.abort(new Error("GTFS metadata request timed out")), DOWNLOAD_TIMEOUT_MS); | |
| 340 | + try { | |
| 341 | + const response = await fetchSource(IDFM_CATALOG_SOURCE_URL.gtfsRecord, { | |
| 342 | + method: "GET", | |
| 343 | + redirect: "manual", | |
| 344 | + credentials: "omit", | |
| 345 | + cache: "no-store", | |
| 346 | + headers: { Authorization: `apikey ${token}` }, | |
| 347 | + signal: controller.signal, | |
| 348 | + }); | |
| 349 | + if (response.status !== 200) fail("SOURCE_HTTP_STATUS", `GTFS metadata returned HTTP ${response.status}`); | |
| 350 | + const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase(); | |
| 351 | + if (mediaType !== "application/json") fail("SOURCE_CONTENT_TYPE", "GTFS metadata is not JSON"); | |
| 352 | + if (response.body === null) fail("SOURCE_EMPTY_BODY", "GTFS metadata returned no body"); | |
| 353 | + | |
| 354 | + const chunks: Uint8Array[] = []; | |
| 355 | + let bytes = 0; | |
| 356 | + for await (const chunk of response.body) { | |
| 357 | + bytes += chunk.byteLength; | |
| 358 | + if (bytes > 262_144) fail("SOURCE_METADATA_TOO_LARGE", "GTFS metadata exceeds 256 KiB"); | |
| 359 | + chunks.push(chunk); | |
| 360 | + } | |
| 361 | + let value: unknown; | |
| 362 | + try { | |
| 363 | + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))); | |
| 364 | + } catch (error) { | |
| 365 | + fail("INVALID_SOURCE_METADATA", "GTFS metadata is not valid UTF-8 JSON", error); | |
| 366 | + } | |
| 367 | + if (typeof value !== "object" || value === null || Array.isArray(value)) { | |
| 368 | + fail("INVALID_SOURCE_METADATA", "GTFS metadata root is invalid"); | |
| 369 | + } | |
| 370 | + const results = (value as Record<string, unknown>).results; | |
| 371 | + if (!Array.isArray(results) || results.length !== 1 || typeof results[0] !== "object" || results[0] === null) { | |
| 372 | + fail("INVALID_SOURCE_METADATA", "GTFS metadata must resolve exactly one file"); | |
| 373 | + } | |
| 374 | + const record = results[0] as Record<string, unknown>; | |
| 375 | + const filename = nonEmpty(record.filename, "GTFS metadata filename"); | |
| 376 | + const urlValue = record.url; | |
| 377 | + const url = typeof urlValue === "string" | |
| 378 | + ? urlValue | |
| 379 | + : typeof urlValue === "object" && urlValue !== null | |
| 380 | + ? String((urlValue as Record<string, unknown>).href ?? "") | |
| 381 | + : ""; | |
| 382 | + let resolvedUrl: URL; | |
| 383 | + try { | |
| 384 | + resolvedUrl = new URL(nonEmpty(url, "GTFS metadata URL")); | |
| 385 | + } catch (error) { | |
| 386 | + fail("INVALID_SOURCE_URL", "GTFS metadata URL is invalid", error); | |
| 387 | + } | |
| 388 | + if (resolvedUrl.protocol !== "https:" || resolvedUrl.username !== "" || resolvedUrl.password !== "" | |
| 389 | + || resolvedUrl.href.includes(token) || resolvedUrl.href.includes(encodeURIComponent(token))) { | |
| 390 | + fail("INVALID_SOURCE_URL", "GTFS metadata URL must be credential-free HTTPS"); | |
| 391 | + } | |
| 392 | + return { filename, url: resolvedUrl.href }; | |
| 393 | + } catch (error) { | |
| 394 | + if (error instanceof CatalogImportError) throw error; | |
| 395 | + fail("SOURCE_METADATA_FAILED", "GTFS metadata request failed", error); | |
| 396 | + } finally { | |
| 397 | + clearTimeout(timeout); | |
| 398 | + } | |
| 399 | +} | |
| 400 | + | |
| 401 | +export async function downloadCatalogSource( | |
| 402 | + options: DownloadCatalogSourceOptions, | |
| 403 | +): Promise<DownloadedCatalogSource> { | |
| 404 | + assertSourceAttribution(options.source); | |
| 405 | + if (existsSync(options.destinationPath)) fail("DESTINATION_EXISTS", "download destination already exists"); | |
| 406 | + | |
| 407 | + const token = options.datasetToken; | |
| 408 | + if (options.source.restricted && !token) fail("DATASET_TOKEN_REQUIRED", "restricted source needs IDFM_DATASET_TOKEN"); | |
| 409 | + if (!options.source.restricted && token !== undefined) { | |
| 410 | + fail("UNEXPECTED_DATASET_TOKEN", "credentials are forbidden for public catalog sources"); | |
| 411 | + } | |
| 412 | + if (token !== undefined) { | |
| 413 | + nonEmpty(token, "datasetToken"); | |
| 414 | + const encoded = encodeURIComponent(token); | |
| 415 | + if (options.source.url.includes(token) || options.source.url.includes(encoded)) { | |
| 416 | + fail("CREDENTIAL_IN_URL", "dataset token must not appear in a URL"); | |
| 417 | + } | |
| 418 | + } | |
| 419 | + | |
| 420 | + mkdirSync(dirname(options.destinationPath), { recursive: true }); | |
| 421 | + const controller = new AbortController(); | |
| 422 | + const timeout = setTimeout(() => controller.abort(new Error("catalog download timed out")), DOWNLOAD_TIMEOUT_MS); | |
| 423 | + let handle: FileHandle | undefined; | |
| 424 | + try { | |
| 425 | + const fetchSource = options.fetch ?? globalThis.fetch; | |
| 426 | + const response = await fetchSource(options.source.url, { | |
| 427 | + method: "GET", | |
| 428 | + redirect: "manual", | |
| 429 | + credentials: "omit", | |
| 430 | + cache: "no-store", | |
| 431 | + headers: token === undefined ? {} : { Authorization: `apikey ${token}` }, | |
| 432 | + signal: controller.signal, | |
| 433 | + }); | |
| 434 | + if (response.status !== 200) fail("SOURCE_HTTP_STATUS", `catalog source returned HTTP ${response.status}`); | |
| 435 | + const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase(); | |
| 436 | + const accepted = options.expectedContentTypes.map((value) => value.toLowerCase()); | |
| 437 | + if (!accepted.includes(mediaType)) fail("SOURCE_CONTENT_TYPE", `unexpected catalog content type ${mediaType || "<missing>"}`); | |
| 438 | + if (response.body === null) fail("SOURCE_EMPTY_BODY", "catalog source returned no body"); | |
| 439 | + | |
| 440 | + handle = await openFile(options.destinationPath, "wx", 0o600); | |
| 441 | + const hash = createHash("sha256"); | |
| 442 | + let bytes = 0; | |
| 443 | + for await (const chunkValue of response.body) { | |
| 444 | + const chunk = chunkValue instanceof Uint8Array ? chunkValue : new Uint8Array(chunkValue as ArrayBuffer); | |
| 445 | + bytes += chunk.byteLength; | |
| 446 | + if (bytes > MAX_COMPRESSED_DOWNLOAD_BYTES) { | |
| 447 | + controller.abort(new Error("catalog download exceeded byte limit")); | |
| 448 | + fail("SOURCE_TOO_LARGE", "catalog source exceeds the 2 GiB download limit"); | |
| 449 | + } | |
| 450 | + hash.update(chunk); | |
| 451 | + let offset = 0; | |
| 452 | + while (offset < chunk.byteLength) { | |
| 453 | + const result = await handle.write(chunk, offset, chunk.byteLength - offset); | |
| 454 | + offset += result.bytesWritten; | |
| 455 | + } | |
| 456 | + } | |
| 457 | + await handle.sync(); | |
| 458 | + await handle.close(); | |
| 459 | + handle = undefined; | |
| 460 | + return { | |
| 461 | + path: options.destinationPath, | |
| 462 | + bytes, | |
| 463 | + sha256: hash.digest("hex"), | |
| 464 | + source: options.source, | |
| 465 | + }; | |
| 466 | + } catch (error) { | |
| 467 | + try { | |
| 468 | + await handle?.close(); | |
| 469 | + } finally { | |
| 470 | + safeUnlink(options.destinationPath); | |
| 471 | + } | |
| 472 | + if (error instanceof CatalogImportError) throw error; | |
| 473 | + fail("SOURCE_DOWNLOAD_FAILED", "catalog source download failed", error); | |
| 474 | + } finally { | |
| 475 | + clearTimeout(timeout); | |
| 476 | + } | |
| 477 | +} | |
| 478 | + | |
| 479 | +function createSchema(database: DatabaseSync): void { | |
| 480 | + database.exec(` | |
| 481 | + PRAGMA foreign_keys = ON; | |
| 482 | + PRAGMA journal_mode = DELETE; | |
| 483 | + PRAGMA synchronous = FULL; | |
| 484 | + PRAGMA temp_store = FILE; | |
| 485 | + | |
| 486 | + CREATE TABLE metadata ( | |
| 487 | + key TEXT PRIMARY KEY, | |
| 488 | + value TEXT NOT NULL | |
| 489 | + ) STRICT; | |
| 490 | + CREATE TABLE places ( | |
| 491 | + place_id TEXT PRIMARY KEY, | |
| 492 | + stop_label TEXT NOT NULL, | |
| 493 | + locality_label TEXT, | |
| 494 | + mode TEXT NOT NULL, | |
| 495 | + canonical_tuple TEXT NOT NULL UNIQUE | |
| 496 | + ) STRICT; | |
| 497 | + CREATE TABLE services ( | |
| 498 | + service_id TEXT PRIMARY KEY, | |
| 499 | + place_id TEXT NOT NULL REFERENCES places(place_id), | |
| 500 | + stop_label TEXT NOT NULL, | |
| 501 | + line_label TEXT NOT NULL, | |
| 502 | + destination_label TEXT NOT NULL, | |
| 503 | + monitoring_ref TEXT NOT NULL, | |
| 504 | + line_ref TEXT NOT NULL, | |
| 505 | + direction_id TEXT NOT NULL, | |
| 506 | + destination_ref TEXT NOT NULL, | |
| 507 | + canonical_tuple TEXT NOT NULL UNIQUE | |
| 508 | + ) STRICT; | |
| 509 | + CREATE VIRTUAL TABLE place_search USING fts5( | |
| 510 | + search_text, | |
| 511 | + place_id UNINDEXED, | |
| 512 | + tokenize = 'unicode61 remove_diacritics 2' | |
| 513 | + ); | |
| 514 | + | |
| 515 | + CREATE TABLE stage_routes ( | |
| 516 | + route_id TEXT PRIMARY KEY, | |
| 517 | + route_short_name TEXT NOT NULL, | |
| 518 | + route_long_name TEXT NOT NULL | |
| 519 | + ) STRICT; | |
| 520 | + CREATE TABLE stage_trips ( | |
| 521 | + trip_id TEXT PRIMARY KEY, | |
| 522 | + route_id TEXT NOT NULL, | |
| 523 | + trip_headsign TEXT NOT NULL, | |
| 524 | + direction_id TEXT NOT NULL | |
| 525 | + ) STRICT; | |
| 526 | + CREATE TABLE stage_stop_times ( | |
| 527 | + trip_id TEXT NOT NULL, | |
| 528 | + stop_id TEXT NOT NULL, | |
| 529 | + stop_sequence INTEGER NOT NULL, | |
| 530 | + pickup_type INTEGER NOT NULL, | |
| 531 | + PRIMARY KEY (trip_id, stop_sequence) | |
| 532 | + ) STRICT; | |
| 533 | + CREATE TABLE stage_stops ( | |
| 534 | + stop_id TEXT PRIMARY KEY, | |
| 535 | + stop_name TEXT NOT NULL, | |
| 536 | + parent_station TEXT NOT NULL | |
| 537 | + ) STRICT; | |
| 538 | + CREATE TABLE stage_agency ( | |
| 539 | + agency_name TEXT PRIMARY KEY | |
| 540 | + ) STRICT; | |
| 541 | + CREATE TABLE stage_codes ( | |
| 542 | + object_type TEXT NOT NULL, | |
| 543 | + object_id TEXT NOT NULL, | |
| 544 | + object_system TEXT NOT NULL, | |
| 545 | + object_code TEXT NOT NULL, | |
| 546 | + PRIMARY KEY (object_type, object_id, object_system, object_code) | |
| 547 | + ) STRICT; | |
| 548 | + CREATE TABLE stage_perimeter ( | |
| 549 | + line TEXT NOT NULL, | |
| 550 | + id_line TEXT NOT NULL, | |
| 551 | + name_line TEXT NOT NULL, | |
| 552 | + monitoring_ref TEXT NOT NULL, | |
| 553 | + stop_name TEXT NOT NULL, | |
| 554 | + operator_name TEXT NOT NULL, | |
| 555 | + PRIMARY KEY (line, monitoring_ref) | |
| 556 | + ) STRICT; | |
| 557 | + CREATE TABLE stage_arrets ( | |
| 558 | + arrid TEXT PRIMARY KEY, | |
| 559 | + zdaid TEXT NOT NULL | |
| 560 | + ) STRICT; | |
| 561 | + CREATE TABLE stage_zones ( | |
| 562 | + zdaid TEXT PRIMARY KEY, | |
| 563 | + zdaname TEXT NOT NULL, | |
| 564 | + zdatown TEXT NOT NULL | |
| 565 | + ) STRICT; | |
| 566 | + CREATE TABLE stage_relations ( | |
| 567 | + zdaid TEXT NOT NULL, | |
| 568 | + arrid TEXT NOT NULL, | |
| 569 | + artid TEXT NOT NULL, | |
| 570 | + PRIMARY KEY (zdaid, arrid, artid) | |
| 571 | + ) STRICT; | |
| 572 | + CREATE TABLE stage_lines ( | |
| 573 | + id_line TEXT PRIMARY KEY, | |
| 574 | + name_line TEXT NOT NULL, | |
| 575 | + shortname_line TEXT NOT NULL, | |
| 576 | + transport_mode TEXT NOT NULL, | |
| 577 | + transport_submode TEXT NOT NULL, | |
| 578 | + status TEXT NOT NULL | |
| 579 | + ) STRICT; | |
| 580 | + CREATE TABLE stage_arrets_lignes ( | |
| 581 | + route_id TEXT NOT NULL, | |
| 582 | + stop_id TEXT NOT NULL, | |
| 583 | + PRIMARY KEY (route_id, stop_id) | |
| 584 | + ) STRICT; | |
| 585 | + `); | |
| 586 | +} | |
| 587 | + | |
| 588 | +function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>> { | |
| 589 | + const route = database.prepare("INSERT INTO stage_routes VALUES (?, ?, ?)"); | |
| 590 | + const trip = database.prepare("INSERT INTO stage_trips VALUES (?, ?, ?, ?)"); | |
| 591 | + const stopTime = database.prepare("INSERT INTO stage_stop_times VALUES (?, ?, ?, ?)"); | |
| 592 | + const stop = database.prepare("INSERT INTO stage_stops VALUES (?, ?, ?)"); | |
| 593 | + const agency = database.prepare("INSERT OR IGNORE INTO stage_agency VALUES (?)"); | |
| 594 | + const code = database.prepare("INSERT OR IGNORE INTO stage_codes VALUES (?, ?, ?, ?)"); | |
| 595 | + const perimeter = database.prepare("INSERT OR IGNORE INTO stage_perimeter VALUES (?, ?, ?, ?, ?, ?)"); | |
| 596 | + const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)"); | |
| 597 | + const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)"); | |
| 598 | + const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)"); | |
| 599 | + const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?)"); | |
| 600 | + const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)"); | |
| 601 | + | |
| 602 | + return { | |
| 603 | + routes: { | |
| 604 | + required: ["route_id", "route_short_name", "route_long_name"], | |
| 605 | + insert: (row) => route.run(canonicalLineId(row.route_id, "routes.route_id"), optionalText(row.route_short_name), optionalText(row.route_long_name)), | |
| 606 | + }, | |
| 607 | + trips: { | |
| 608 | + required: ["route_id", "trip_id", "trip_headsign", "direction_id"], | |
| 609 | + insert: (row) => trip.run(nonEmpty(row.trip_id, "trips.trip_id"), nonEmpty(row.route_id, "trips.route_id"), optionalText(row.trip_headsign), nonEmpty(row.direction_id, "trips.direction_id")), | |
| 610 | + }, | |
| 611 | + stopTimes: { | |
| 612 | + required: ["trip_id", "stop_id", "stop_sequence", "pickup_type"], | |
| 613 | + insert: (row) => { | |
| 614 | + const pickupType = row.pickup_type === "" ? 0 : integer(row.pickup_type, "stop_times.pickup_type"); | |
| 615 | + if (pickupType < 0 || pickupType > 3) fail("INVALID_SOURCE", "stop_times.pickup_type is outside GTFS range"); | |
| 616 | + stopTime.run(nonEmpty(row.trip_id, "stop_times.trip_id"), nonEmpty(row.stop_id, "stop_times.stop_id"), integer(row.stop_sequence, "stop_times.stop_sequence"), pickupType); | |
| 617 | + }, | |
| 618 | + }, | |
| 619 | + stops: { | |
| 620 | + required: ["stop_id", "stop_name", "parent_station"], | |
| 621 | + insert: (row) => stop.run(nonEmpty(row.stop_id, "stops.stop_id"), nonEmpty(row.stop_name, "stops.stop_name"), optionalText(row.parent_station)), | |
| 622 | + }, | |
| 623 | + agency: { | |
| 624 | + required: ["agency_name", "agency_url", "agency_timezone"], | |
| 625 | + insert: (row) => { | |
| 626 | + nonEmpty(row.agency_url, "agency.agency_url"); | |
| 627 | + nonEmpty(row.agency_timezone, "agency.agency_timezone"); | |
| 628 | + agency.run(nonEmpty(row.agency_name, "agency.agency_name")); | |
| 629 | + }, | |
| 630 | + }, | |
| 631 | + objectCodes: { | |
| 632 | + required: ["object_type", "object_id", "object_system", "object_code"], | |
| 633 | + insert: (row) => code.run(nonEmpty(row.object_type, "codes.object_type"), nonEmpty(row.object_id, "codes.object_id"), nonEmpty(row.object_system, "codes.object_system"), nonEmpty(row.object_code, "codes.object_code")), | |
| 634 | + }, | |
| 635 | + perimeter: { | |
| 636 | + required: ["line", "name_line", "ns2_stoppointref", "ns2_stopname", "operatorname"], | |
| 637 | + insert: (row) => perimeter.run(nonEmpty(row.line, "perimeter.line"), canonicalLineId(row.line, "perimeter.line"), nonEmpty(row.name_line, "perimeter.name_line"), nonEmpty(row.ns2_stoppointref, "perimeter.ns2_stoppointref"), nonEmpty(row.ns2_stopname, "perimeter.ns2_stopname"), nonEmpty(row.operatorname, "perimeter.operatorname")), | |
| 638 | + }, | |
| 639 | + arrets: { | |
| 640 | + required: ["arrid", "zdaid"], | |
| 641 | + insert: (row) => arret.run(nonEmpty(row.arrid, "arrets.arrid"), nonEmpty(row.zdaid, "arrets.zdaid")), | |
| 642 | + }, | |
| 643 | + zones: { | |
| 644 | + required: ["zdaid", "zdaname", "zdatown"], | |
| 645 | + insert: (row) => zone.run(nonEmpty(row.zdaid, "zones.zdaid"), nonEmpty(row.zdaname, "zones.zdaname"), optionalText(row.zdatown)), | |
| 646 | + }, | |
| 647 | + relations: { | |
| 648 | + required: ["zdaid", "arrid", "artid"], | |
| 649 | + insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")), | |
| 650 | + }, | |
| 651 | + lines: { | |
| 652 | + required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"], | |
| 653 | + insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status")), | |
| 654 | + }, | |
| 655 | + arretsLignes: { | |
| 656 | + required: ["id", "stop_id"], | |
| 657 | + insert: (row) => arretLigne.run(canonicalLineId(row.id, "arrets_lignes.id"), nonEmpty(row.stop_id, "arrets_lignes.stop_id")), | |
| 658 | + }, | |
| 659 | + }; | |
| 660 | +} | |
| 661 | + | |
| 662 | +function runBatched(database: DatabaseSync, rows: Iterable<Readonly<Record<string, string>>>, spec: CsvSpec): void { | |
| 663 | + let pending = 0; | |
| 664 | + database.exec("BEGIN IMMEDIATE"); | |
| 665 | + try { | |
| 666 | + for (const row of rows) { | |
| 667 | + spec.insert(row); | |
| 668 | + pending += 1; | |
| 669 | + if (pending === IMPORT_TRANSACTION_ROWS) { | |
| 670 | + database.exec("COMMIT; BEGIN IMMEDIATE"); | |
| 671 | + pending = 0; | |
| 672 | + } | |
| 673 | + } | |
| 674 | + database.exec("COMMIT"); | |
| 675 | + } catch (error) { | |
| 676 | + database.exec("ROLLBACK"); | |
| 677 | + throw error; | |
| 678 | + } | |
| 679 | +} | |
| 680 | + | |
| 681 | +function stringRecord(record: CsvRecord, required: readonly string[]): Readonly<Record<string, string>> { | |
| 682 | + const result: Record<string, string> = {}; | |
| 683 | + for (const key of required) { | |
| 684 | + if (!Object.hasOwn(record, key)) fail("MISSING_COLUMN", `record is missing ${key}`); | |
| 685 | + result[key] = String(record[key] ?? ""); | |
| 686 | + } | |
| 687 | + return result; | |
| 688 | +} | |
| 689 | + | |
| 690 | +function loadRecordSources(database: DatabaseSync, sources: CatalogRecordSourceSet): void { | |
| 691 | + const specs = prepareSpecs(database); | |
| 692 | + for (const key of Object.keys(specs) as (keyof CatalogRecordSourceSet)[]) { | |
| 693 | + const spec = specs[key]; | |
| 694 | + const rows = sources[key].map((row) => stringRecord(row, spec.required)); | |
| 695 | + runBatched(database, rows, spec); | |
| 696 | + } | |
| 697 | +} | |
| 698 | + | |
| 699 | +class Utf8Decoder extends Transform { | |
| 700 | + readonly #decoder = new TextDecoder("utf-8", { fatal: true }); | |
| 701 | + | |
| 702 | + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { | |
| 703 | + try { | |
| 704 | + this.push(this.#decoder.decode(chunk, { stream: true })); | |
| 705 | + callback(); | |
| 706 | + } catch (error) { | |
| 707 | + callback(error as Error); | |
| 708 | + } | |
| 709 | + } | |
| 710 | + | |
| 711 | + override _flush(callback: (error?: Error | null) => void): void { | |
| 712 | + try { | |
| 713 | + this.push(this.#decoder.decode()); | |
| 714 | + callback(); | |
| 715 | + } catch (error) { | |
| 716 | + callback(error as Error); | |
| 717 | + } | |
| 718 | + } | |
| 719 | +} | |
| 720 | + | |
| 721 | +class ByteLimit extends Transform { | |
| 722 | + #bytes = 0; | |
| 723 | + readonly #limit: number; | |
| 724 | + readonly #onBytes: (bytes: number) => void; | |
| 725 | + | |
| 726 | + constructor(limit: number, onBytes: (bytes: number) => void) { | |
| 727 | + super(); | |
| 728 | + this.#limit = limit; | |
| 729 | + this.#onBytes = onBytes; | |
| 730 | + } | |
| 731 | + | |
| 732 | + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { | |
| 733 | + this.#bytes += chunk.byteLength; | |
| 734 | + if (this.#bytes > this.#limit) { | |
| 735 | + callback(new CatalogImportError("ZIP_ENTRY_TOO_LARGE", "selected GTFS entry exceeds the 8 GiB limit")); | |
| 736 | + return; | |
| 737 | + } | |
| 738 | + try { | |
| 739 | + this.#onBytes(chunk.byteLength); | |
| 740 | + this.push(chunk); | |
| 741 | + callback(); | |
| 742 | + } catch (error) { | |
| 743 | + callback(error as Error); | |
| 744 | + } | |
| 745 | + } | |
| 746 | +} | |
| 747 | + | |
| 748 | +async function loadCsvStream( | |
| 749 | + database: DatabaseSync, | |
| 750 | + input: Readable, | |
| 751 | + delimiter: "," | ";", | |
| 752 | + spec: CsvSpec, | |
| 753 | +): Promise<number> { | |
| 754 | + const decoder = new Utf8Decoder(); | |
| 755 | + const parser = parse({ | |
| 756 | + bom: true, | |
| 757 | + columns: false, | |
| 758 | + delimiter, | |
| 759 | + max_record_size: MAX_CSV_RECORD_BYTES, | |
| 760 | + relax_column_count: false, | |
| 761 | + relax_quotes: false, | |
| 762 | + skip_empty_lines: true, | |
| 763 | + }); | |
| 764 | + const destroyParser = (error: Error): void => parser.destroy(error); | |
| 765 | + input.once("error", destroyParser); | |
| 766 | + decoder.once("error", destroyParser); | |
| 767 | + input.pipe(decoder).pipe(parser); | |
| 768 | + | |
| 769 | + let indexes: Readonly<Record<string, number>> | undefined; | |
| 770 | + let pending = 0; | |
| 771 | + let rows = 0; | |
| 772 | + database.exec("BEGIN IMMEDIATE"); | |
| 773 | + try { | |
| 774 | + for await (const value of parser) { | |
| 775 | + if (!Array.isArray(value) || !value.every((field) => typeof field === "string")) { | |
| 776 | + fail("INVALID_CSV", "CSV parser returned a non-string record"); | |
| 777 | + } | |
| 778 | + const fields = value as string[]; | |
| 779 | + if (indexes === undefined) { | |
| 780 | + const header = fields.map((field) => field.trim()); | |
| 781 | + if (header.length === 0 || header.some((field) => field.length === 0)) fail("INVALID_HEADER", "CSV header is empty"); | |
| 782 | + if (new Set(header).size !== header.length) fail("DUPLICATE_HEADER", "CSV contains duplicate columns"); | |
| 783 | + const mapped: Record<string, number> = {}; | |
| 784 | + for (const required of spec.required) { | |
| 785 | + const index = header.indexOf(required); | |
| 786 | + if (index === -1) fail("MISSING_COLUMN", `CSV is missing required column ${required}`); | |
| 787 | + mapped[required] = index; | |
| 788 | + } | |
| 789 | + indexes = mapped; | |
| 790 | + continue; | |
| 791 | + } | |
| 792 | + const row: Record<string, string> = {}; | |
| 793 | + for (const [key, index] of Object.entries(indexes)) row[key] = fields[index] ?? ""; | |
| 794 | + spec.insert(row); | |
| 795 | + rows += 1; | |
| 796 | + pending += 1; | |
| 797 | + if (pending === IMPORT_TRANSACTION_ROWS) { | |
| 798 | + database.exec("COMMIT; BEGIN IMMEDIATE"); | |
| 799 | + pending = 0; | |
| 800 | + } | |
| 801 | + } | |
| 802 | + if (indexes === undefined) fail("INVALID_HEADER", "CSV source is empty"); | |
| 803 | + database.exec("COMMIT"); | |
| 804 | + return rows; | |
| 805 | + } catch (error) { | |
| 806 | + input.destroy(); | |
| 807 | + database.exec("ROLLBACK"); | |
| 808 | + if (error instanceof CatalogImportError) throw error; | |
| 809 | + fail("CSV_PARSE_FAILED", "CSV source could not be parsed", error); | |
| 810 | + } | |
| 811 | +} | |
| 812 | + | |
| 813 | +function assertSourceFile(path: string): void { | |
| 814 | + const size = statSync(path).size; | |
| 815 | + if (size > MAX_COMPRESSED_DOWNLOAD_BYTES) fail("SOURCE_TOO_LARGE", `${basename(path)} exceeds the 2 GiB source limit`); | |
| 816 | +} | |
| 817 | + | |
| 818 | +function openZip(path: string): Promise<ZipFileLike> { | |
| 819 | + const { promise, resolve: resolveZip, reject } = Promise.withResolvers<ZipFileLike>(); | |
| 820 | + yauzl.open(path, { | |
| 821 | + autoClose: false, | |
| 822 | + decodeStrings: true, | |
| 823 | + lazyEntries: true, | |
| 824 | + strictFileNames: true, | |
| 825 | + validateEntrySizes: true, | |
| 826 | + }, (error: Error | null, zipFile?: ZipFileLike) => { | |
| 827 | + if (error || zipFile === undefined) reject(error ?? new Error("ZIP did not open")); | |
| 828 | + else resolveZip(zipFile); | |
| 829 | + }); | |
| 830 | + return promise; | |
| 831 | +} | |
| 832 | + | |
| 833 | +function nextZipEntry(zip: ZipFileLike): Promise<ZipEntryLike | undefined> { | |
| 834 | + const { promise, resolve: resolveEntry, reject } = Promise.withResolvers<ZipEntryLike | undefined>(); | |
| 835 | + const cleanup = (): void => { | |
| 836 | + zip.removeListener("entry", onEntry as (...args: never[]) => void); | |
| 837 | + zip.removeListener("end", onEnd as (...args: never[]) => void); | |
| 838 | + zip.removeListener("error", onError as (...args: never[]) => void); | |
| 839 | + }; | |
| 840 | + const onEntry = (entry: ZipEntryLike): void => { | |
| 841 | + cleanup(); | |
| 842 | + resolveEntry(entry); | |
| 843 | + }; | |
| 844 | + const onEnd = (): void => { | |
| 845 | + cleanup(); | |
| 846 | + resolveEntry(undefined); | |
| 847 | + }; | |
| 848 | + const onError = (error: unknown): void => { | |
| 849 | + cleanup(); | |
| 850 | + reject(error); | |
| 851 | + }; | |
| 852 | + zip.once("entry", onEntry); | |
| 853 | + zip.once("end", onEnd); | |
| 854 | + zip.once("error", onError); | |
| 855 | + zip.readEntry(); | |
| 856 | + return promise; | |
| 857 | +} | |
| 858 | + | |
| 859 | +function openZipEntry(zip: ZipFileLike, entry: ZipEntryLike): Promise<Readable> { | |
| 860 | + const { promise, resolve: resolveStream, reject } = Promise.withResolvers<Readable>(); | |
| 861 | + zip.openReadStream(entry, (error, stream) => { | |
| 862 | + if (error || stream === undefined) reject(error ?? new Error("ZIP entry did not open")); | |
| 863 | + else resolveStream(stream); | |
| 864 | + }); | |
| 865 | + return promise; | |
| 866 | +} | |
| 867 | + | |
| 868 | +function gtfsKey(name: string): keyof CatalogRecordSourceSet | undefined { | |
| 869 | + switch (name) { | |
| 870 | + case "routes.txt": return "routes"; | |
| 871 | + case "trips.txt": return "trips"; | |
| 872 | + case "stop_times.txt": return "stopTimes"; | |
| 873 | + case "stops.txt": return "stops"; | |
| 874 | + case "agency.txt": return "agency"; | |
| 875 | + case "object_codes_extension.txt": | |
| 876 | + case "objects_codes_extension.txt": return "objectCodes"; | |
| 877 | + default: return undefined; | |
| 878 | + } | |
| 879 | +} | |
| 880 | + | |
| 881 | +async function loadGtfsZip(database: DatabaseSync, path: string, specs: Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>>): Promise<void> { | |
| 882 | + assertSourceFile(path); | |
| 883 | + let zip: ZipFileLike | undefined; | |
| 884 | + const seen = new Set<string>(); | |
| 885 | + let selectedBytes = 0; | |
| 886 | + try { | |
| 887 | + zip = await openZip(path); | |
| 888 | + while (true) { | |
| 889 | + const entry = await nextZipEntry(zip); | |
| 890 | + if (entry === undefined) break; | |
| 891 | + const normalizedName = entry.fileName.replaceAll("\\", "/").split("/").at(-1)!.toLowerCase(); | |
| 892 | + const key = gtfsKey(normalizedName); | |
| 893 | + if (key === undefined) continue; | |
| 894 | + if ((entry.generalPurposeBitFlag & 1) !== 0) fail("ENCRYPTED_ZIP_ENTRY", `selected GTFS entry ${normalizedName} is encrypted`); | |
| 895 | + const conceptualName = Object.hasOwn(CODE_ENTRY_NAME, normalizedName) ? "codes-extension" : normalizedName; | |
| 896 | + if (seen.has(conceptualName)) fail("DUPLICATE_ZIP_ENTRY", `GTFS contains duplicate required entry ${normalizedName}`); | |
| 897 | + seen.add(conceptualName); | |
| 898 | + if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize < 0 || entry.uncompressedSize > MAX_SELECTED_ENTRY_BYTES) { | |
| 899 | + fail("ZIP_ENTRY_TOO_LARGE", `selected GTFS entry ${normalizedName} exceeds its byte limit`); | |
| 900 | + } | |
| 901 | + const stream = await openZipEntry(zip, entry); | |
| 902 | + const limiter = new ByteLimit(MAX_SELECTED_ENTRY_BYTES, (bytes) => { | |
| 903 | + selectedBytes += bytes; | |
| 904 | + if (selectedBytes > MAX_SELECTED_ARCHIVE_BYTES) { | |
| 905 | + fail("ZIP_AGGREGATE_TOO_LARGE", "selected GTFS entries exceed the 16 GiB aggregate limit"); | |
| 906 | + } | |
| 907 | + }); | |
| 908 | + stream.once("error", (error) => limiter.destroy(error)); | |
| 909 | + await loadCsvStream(database, stream.pipe(limiter), ",", specs[key]); | |
| 910 | + } | |
| 911 | + for (const name of REQUIRED_GTFS_ENTRIES) { | |
| 912 | + if (!seen.has(name)) fail("MISSING_ZIP_ENTRY", `GTFS is missing required entry ${name}`); | |
| 913 | + } | |
| 914 | + if (!seen.has("codes-extension")) fail("MISSING_ZIP_ENTRY", "GTFS is missing its codes-extension entry"); | |
| 915 | + } catch (error) { | |
| 916 | + if (error instanceof CatalogImportError) throw error; | |
| 917 | + fail("GTFS_ZIP_FAILED", "GTFS ZIP could not be imported", error); | |
| 918 | + } finally { | |
| 919 | + zip?.close(); | |
| 920 | + } | |
| 921 | +} | |
| 922 | + | |
| 923 | +async function loadFileSources(database: DatabaseSync, sources: CatalogFileSourceSet): Promise<void> { | |
| 924 | + const specs = prepareSpecs(database); | |
| 925 | + await loadGtfsZip(database, sources.gtfsZipPath, specs); | |
| 926 | + const csvSources: readonly [keyof CatalogRecordSourceSet, CsvFileSource][] = [ | |
| 927 | + ["perimeter", sources.perimeter], | |
| 928 | + ["arrets", sources.arrets], | |
| 929 | + ["zones", sources.zones], | |
| 930 | + ["relations", sources.relations], | |
| 931 | + ["lines", sources.lines], | |
| 932 | + ["arretsLignes", sources.arretsLignes], | |
| 933 | + ]; | |
| 934 | + for (const [key, source] of csvSources) { | |
| 935 | + assertSourceFile(source.path); | |
| 936 | + const rows = await loadCsvStream(database, createReadStream(source.path), source.delimiter ?? ";", specs[key]); | |
| 937 | + if (source.expectedRows !== undefined && rows !== source.expectedRows) { | |
| 938 | + fail("SOURCE_ROW_COUNT", `${key} row count ${rows} does not match metadata count ${source.expectedRows}`); | |
| 939 | + } | |
| 940 | + } | |
| 941 | +} | |
| 942 | + | |
| 943 | +function createIndexes(database: DatabaseSync): void { | |
| 944 | + database.exec(` | |
| 945 | + CREATE INDEX stage_trips_route ON stage_trips(route_id); | |
| 946 | + CREATE INDEX stage_stop_times_trip ON stage_stop_times(trip_id, stop_sequence); | |
| 947 | + CREATE INDEX stage_stop_times_stop ON stage_stop_times(stop_id); | |
| 948 | + CREATE INDEX stage_codes_object ON stage_codes(object_id, object_code); | |
| 949 | + CREATE INDEX stage_codes_code ON stage_codes(object_code, object_id); | |
| 950 | + CREATE INDEX stage_perimeter_line ON stage_perimeter(id_line, monitoring_ref); | |
| 951 | + CREATE INDEX stage_arrets_zda ON stage_arrets(zdaid); | |
| 952 | + CREATE INDEX stage_relations_art ON stage_relations(artid, zdaid); | |
| 953 | + CREATE INDEX stage_arrets_lignes_stop ON stage_arrets_lignes(stop_id, route_id); | |
| 954 | + `); | |
| 955 | +} | |
| 956 | + | |
| 957 | +function deriveCandidates(database: DatabaseSync): void { | |
| 958 | + database.exec(` | |
| 959 | + CREATE TABLE stage_line_modes ( | |
| 960 | + id_line TEXT PRIMARY KEY, | |
| 961 | + mode TEXT NOT NULL, | |
| 962 | + line_label TEXT NOT NULL | |
| 963 | + ) STRICT; | |
| 964 | + | |
| 965 | + INSERT INTO stage_line_modes | |
| 966 | + SELECT id_line, | |
| 967 | + CASE | |
| 968 | + WHEN lower(trim(transport_mode)) = 'bus' THEN 'BUS' | |
| 969 | + WHEN lower(trim(transport_mode)) = 'metro' THEN 'METRO' | |
| 970 | + WHEN lower(trim(transport_mode)) = 'tram' THEN 'TRAM' | |
| 971 | + WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER' | |
| 972 | + WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN' | |
| 973 | + END, | |
| 974 | + coalesce(nullif(trim(shortname_line), ''), trim(name_line)) | |
| 975 | + FROM stage_lines | |
| 976 | + WHERE lower(trim(status)) = 'active' | |
| 977 | + AND ( | |
| 978 | + lower(trim(transport_mode)) IN ('bus', 'metro', 'tram') | |
| 979 | + OR (lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) IN ('local', 'suburbanrailway')) | |
| 980 | + ); | |
| 981 | + | |
| 982 | + CREATE TABLE stage_stop_zda ( | |
| 983 | + stop_id TEXT PRIMARY KEY, | |
| 984 | + zdaid TEXT, | |
| 985 | + rail_zdaid TEXT NOT NULL, | |
| 986 | + resolution TEXT NOT NULL | |
| 987 | + ) STRICT; | |
| 988 | + | |
| 989 | + INSERT INTO stage_stop_zda (stop_id, zdaid, rail_zdaid, resolution) | |
| 990 | + SELECT | |
| 991 | + stop.stop_id, | |
| 992 | + coalesce(area.zdaid, relation.zdaid), | |
| 993 | + CASE | |
| 994 | + WHEN coalesce(nullif(stop.parent_station, ''), stop.stop_id) LIKE 'IDFM:monomodalStopPlace:%' | |
| 995 | + THEN substr(coalesce(nullif(stop.parent_station, ''), stop.stop_id), length('IDFM:monomodalStopPlace:') + 1) | |
| 996 | + WHEN coalesce(nullif(stop.parent_station, ''), stop.stop_id) LIKE 'IDFM:%' | |
| 997 | + THEN substr(coalesce(nullif(stop.parent_station, ''), stop.stop_id), 6) | |
| 998 | + ELSE coalesce(nullif(stop.parent_station, ''), stop.stop_id) | |
| 999 | + END, | |
| 1000 | + CASE WHEN area.arrid IS NULL THEN 'relations' ELSE 'arrets' END | |
| 1001 | + FROM stage_stops AS stop | |
| 1002 | + LEFT JOIN stage_arrets AS area | |
| 1003 | + ON area.arrid = CASE | |
| 1004 | + WHEN coalesce(nullif(stop.parent_station, ''), stop.stop_id) LIKE 'IDFM:%' | |
| 1005 | + THEN substr(coalesce(nullif(stop.parent_station, ''), stop.stop_id), 6) | |
| 1006 | + ELSE coalesce(nullif(stop.parent_station, ''), stop.stop_id) | |
| 1007 | + END | |
| 1008 | + LEFT JOIN ( | |
| 1009 | + SELECT artid, min(zdaid) AS zdaid | |
| 1010 | + FROM stage_relations | |
| 1011 | + GROUP BY artid | |
| 1012 | + ) AS relation | |
| 1013 | + ON relation.artid = CASE | |
| 1014 | + WHEN stop.stop_id LIKE 'IDFM:%' THEN substr(stop.stop_id, 6) | |
| 1015 | + ELSE stop.stop_id | |
| 1016 | + END; | |
| 1017 | + | |
| 1018 | + CREATE TABLE stage_terminals AS | |
| 1019 | + SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name | |
| 1020 | + FROM stage_stop_times AS stop_time | |
| 1021 | + JOIN stage_stops AS stop ON stop.stop_id = stop_time.stop_id | |
| 1022 | + JOIN ( | |
| 1023 | + SELECT trip_id, max(stop_sequence) AS stop_sequence | |
| 1024 | + FROM stage_stop_times | |
| 1025 | + GROUP BY trip_id | |
| 1026 | + ) AS terminal | |
| 1027 | + ON terminal.trip_id = stop_time.trip_id | |
| 1028 | + AND terminal.stop_sequence = stop_time.stop_sequence; | |
| 1029 | + CREATE UNIQUE INDEX stage_terminals_trip ON stage_terminals(trip_id); | |
| 1030 | + | |
| 1031 | + CREATE TABLE stage_candidates AS | |
| 1032 | + WITH joined AS ( | |
| 1033 | + SELECT DISTINCT | |
| 1034 | + line_mode.mode AS mode, | |
| 1035 | + CASE | |
| 1036 | + WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN stop_zda.rail_zdaid | |
| 1037 | + ELSE stop_zda.zdaid | |
| 1038 | + END AS zdaid, | |
| 1039 | + zone.zdaname AS stop_label, | |
| 1040 | + zone.zdatown AS locality_label, | |
| 1041 | + line_mode.id_line AS id_line, | |
| 1042 | + line_mode.line_label AS line_label, | |
| 1043 | + CASE | |
| 1044 | + WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name | |
| 1045 | + ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name) | |
| 1046 | + END AS destination_label, | |
| 1047 | + perimeter.monitoring_ref AS monitoring_ref, | |
| 1048 | + perimeter.line AS line_ref, | |
| 1049 | + trip.direction_id AS direction_id, | |
| 1050 | + destination_code.object_code AS destination_ref, | |
| 1051 | + stop_zda.resolution AS resolution | |
| 1052 | + FROM stage_stop_times AS current_stop_time | |
| 1053 | + JOIN stage_trips AS trip ON trip.trip_id = current_stop_time.trip_id | |
| 1054 | + JOIN stage_routes AS route ON route.route_id = CASE WHEN trip.route_id LIKE 'IDFM:%' THEN substr(trip.route_id, 6) ELSE trip.route_id END | |
| 1055 | + JOIN stage_line_modes AS line_mode ON line_mode.id_line = route.route_id | |
| 1056 | + JOIN stage_stops AS current_stop ON current_stop.stop_id = current_stop_time.stop_id | |
| 1057 | + JOIN stage_stop_zda AS stop_zda ON stop_zda.stop_id = current_stop.stop_id | |
| 1058 | + JOIN stage_terminals AS terminal | |
| 1059 | + ON terminal.trip_id = current_stop_time.trip_id | |
| 1060 | + JOIN stage_codes AS destination_code | |
| 1061 | + ON destination_code.object_id = terminal.stop_id | |
| 1062 | + AND lower(trim(destination_code.object_system)) = 'source' | |
| 1063 | + JOIN stage_arrets_lignes AS attached | |
| 1064 | + ON attached.route_id = route.route_id | |
| 1065 | + AND attached.stop_id IN (current_stop.stop_id, current_stop.parent_station) | |
| 1066 | + JOIN stage_perimeter AS perimeter | |
| 1067 | + ON perimeter.id_line = line_mode.id_line | |
| 1068 | + AND ( | |
| 1069 | + EXISTS ( | |
| 1070 | + SELECT 1 | |
| 1071 | + FROM stage_codes AS monitoring_code | |
| 1072 | + WHERE monitoring_code.object_id IN (current_stop.stop_id, current_stop.parent_station) | |
| 1073 | + AND monitoring_code.object_code = perimeter.monitoring_ref | |
| 1074 | + ) | |
| 1075 | + OR instr(perimeter.monitoring_ref, ':' || stop_zda.rail_zdaid || ':') > 0 | |
| 1076 | + ) | |
| 1077 | + JOIN stage_zones AS zone | |
| 1078 | + ON zone.zdaid = CASE | |
| 1079 | + WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN stop_zda.rail_zdaid | |
| 1080 | + ELSE stop_zda.zdaid | |
| 1081 | + END | |
| 1082 | + WHERE current_stop_time.pickup_type <> 1 | |
| 1083 | + AND EXISTS ( | |
| 1084 | + SELECT 1 FROM stage_stop_times AS later | |
| 1085 | + WHERE later.trip_id = current_stop_time.trip_id | |
| 1086 | + AND later.stop_sequence > current_stop_time.stop_sequence | |
| 1087 | + ) | |
| 1088 | + AND trim(trip.direction_id) <> '' | |
| 1089 | + AND trim(destination_code.object_code) <> '' | |
| 1090 | + ) | |
| 1091 | + SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, | |
| 1092 | + id_line, min(line_label) AS line_label, min(destination_label) AS destination_label, | |
| 1093 | + monitoring_ref, line_ref, direction_id, destination_ref, | |
| 1094 | + max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets | |
| 1095 | + FROM joined | |
| 1096 | + WHERE zdaid IS NOT NULL AND trim(zdaid) <> '' | |
| 1097 | + GROUP BY mode, zdaid, monitoring_ref, id_line, line_ref, direction_id, destination_ref; | |
| 1098 | + `); | |
| 1099 | +} | |
| 1100 | + | |
| 1101 | +function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, createdAt: string): PlaceResolutionCounts { | |
| 1102 | + const metadata = database.prepare("INSERT INTO metadata VALUES (?, ?)"); | |
| 1103 | + metadata.run("schema_version", String(SCHEMA_VERSION)); | |
| 1104 | + metadata.run("catalog_version", String(CATALOG_VERSION)); | |
| 1105 | + metadata.run("source_revision", sourceRevision); | |
| 1106 | + metadata.run("created_at", createdAt); | |
| 1107 | + | |
| 1108 | + const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | |
| 1109 | + const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | |
| 1110 | + const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 1111 | + const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?"); | |
| 1112 | + const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?"); | |
| 1113 | + | |
| 1114 | + const places = database.prepare(` | |
| 1115 | + SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, | |
| 1116 | + max(via_arrets) AS via_arrets | |
| 1117 | + FROM stage_candidates | |
| 1118 | + GROUP BY mode, zdaid | |
| 1119 | + ORDER BY mode, zdaid | |
| 1120 | + `).all() as QueryRow[]; | |
| 1121 | + | |
| 1122 | + const placeResolution: PlaceResolutionCounts = { arrets: 0, relations: 0, direct: 0 }; | |
| 1123 | + | |
| 1124 | + database.exec("BEGIN IMMEDIATE"); | |
| 1125 | + try { | |
| 1126 | + for (const row of places) { | |
| 1127 | + const mode = transportMode(row.mode); | |
| 1128 | + const zdaId = nonEmpty(row.zdaid, "candidate.zdaid"); | |
| 1129 | + const stopLabel = nonEmpty(row.stop_label, "candidate.stop_label"); | |
| 1130 | + const localityLabel = optionalText(row.locality_label); | |
| 1131 | + const identity = createPlaceIdentity(mode, zdaId); | |
| 1132 | + const collision = selectPlace.get(identity.placeId) as QueryRow | undefined; | |
| 1133 | + if (collision !== undefined && collision.canonical_tuple !== identity.canonicalTuple) { | |
| 1134 | + fail("HASH_COLLISION", "place ID collision detected"); | |
| 1135 | + } | |
| 1136 | + if (collision === undefined) { | |
| 1137 | + const searchText = normalizeCatalogSearchText(`${stopLabel} ${localityLabel}`); | |
| 1138 | + if (searchText.length === 0) fail("CATALOG_SEARCH", "place label produces empty search text"); | |
| 1139 | + insertPlace.run(identity.placeId, stopLabel, localityLabel || null, mode, identity.canonicalTuple); | |
| 1140 | + insertSearch.run(searchText, identity.placeId); | |
| 1141 | + } | |
| 1142 | + if (mode === "RER" || mode === "TRANSILIEN") placeResolution.direct += 1; | |
| 1143 | + else if (Number(row.via_arrets) === 1) placeResolution.arrets += 1; | |
| 1144 | + else placeResolution.relations += 1; | |
| 1145 | + } | |
| 1146 | + | |
| 1147 | + const candidates = database.prepare(` | |
| 1148 | + SELECT mode, zdaid, stop_label, line_label, destination_label, | |
| 1149 | + id_line, monitoring_ref, line_ref, direction_id, destination_ref | |
| 1150 | + FROM stage_candidates | |
| 1151 | + ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref | |
| 1152 | + `).iterate() as Iterable<QueryRow>; | |
| 1153 | + for (const row of candidates) { | |
| 1154 | + const mode = transportMode(row.mode); | |
| 1155 | + const place = createPlaceIdentity(mode, nonEmpty(row.zdaid, "candidate.zdaid")); | |
| 1156 | + const service = createServiceIdentity({ | |
| 1157 | + mode, | |
| 1158 | + lineId: nonEmpty(row.id_line, "candidate.id_line"), | |
| 1159 | + monitoringRef: nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), | |
| 1160 | + directionId: nonEmpty(row.direction_id, "candidate.direction_id"), | |
| 1161 | + destinationRef: nonEmpty(row.destination_ref, "candidate.destination_ref"), | |
| 1162 | + }); | |
| 1163 | + const collision = selectService.get(service.serviceId) as QueryRow | undefined; | |
| 1164 | + if (collision !== undefined && collision.canonical_tuple !== service.canonicalTuple) { | |
| 1165 | + fail("HASH_COLLISION", "service ID collision detected"); | |
| 1166 | + } | |
| 1167 | + if (collision !== undefined && collision.place_id !== place.placeId) { | |
| 1168 | + fail("INVALID_SERVICE_PLACE", "one semantic service resolved to multiple places"); | |
| 1169 | + } | |
| 1170 | + if (collision === undefined) { | |
| 1171 | + insertService.run( | |
| 1172 | + service.serviceId, | |
| 1173 | + place.placeId, | |
| 1174 | + nonEmpty(row.stop_label, "candidate.stop_label"), | |
| 1175 | + nonEmpty(row.line_label, "candidate.line_label"), | |
| 1176 | + nonEmpty(row.destination_label, "candidate.destination_label"), | |
| 1177 | + nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), | |
| 1178 | + nonEmpty(row.line_ref, "candidate.line_ref"), | |
| 1179 | + nonEmpty(row.direction_id, "candidate.direction_id"), | |
| 1180 | + nonEmpty(row.destination_ref, "candidate.destination_ref"), | |
| 1181 | + service.canonicalTuple, | |
| 1182 | + ); | |
| 1183 | + } | |
| 1184 | + } | |
| 1185 | + database.exec("COMMIT"); | |
| 1186 | + } catch (error) { | |
| 1187 | + database.exec("ROLLBACK"); | |
| 1188 | + throw error; | |
| 1189 | + } | |
| 1190 | + return placeResolution; | |
| 1191 | +} | |
| 1192 | + | |
| 1193 | +function validateByteBounds(database: DatabaseSync): void { | |
| 1194 | + const invalidPlace = database.prepare(` | |
| 1195 | + SELECT place_id FROM places | |
| 1196 | + WHERE length(CAST(place_id AS BLOB)) > ? | |
| 1197 | + OR length(CAST(stop_label AS BLOB)) > ? | |
| 1198 | + OR (locality_label IS NOT NULL AND length(CAST(locality_label AS BLOB)) > ?) | |
| 1199 | + LIMIT 1 | |
| 1200 | + `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); | |
| 1201 | + if (invalidPlace !== undefined) fail("CATALOG_LIMIT", "place exceeds a shared UTF-8 byte limit"); | |
| 1202 | + | |
| 1203 | + const invalidService = database.prepare(` | |
| 1204 | + SELECT service_id FROM services | |
| 1205 | + WHERE length(CAST(service_id AS BLOB)) > ? | |
| 1206 | + OR length(CAST(stop_label AS BLOB)) > ? | |
| 1207 | + OR length(CAST(line_label AS BLOB)) > ? | |
| 1208 | + OR length(CAST(destination_label AS BLOB)) > ? | |
| 1209 | + LIMIT 1 | |
| 1210 | + `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); | |
| 1211 | + if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit"); | |
| 1212 | +} | |
| 1213 | + | |
| 1214 | +function exactColumns(database: DatabaseSync, table: string, expected: readonly string[]): void { | |
| 1215 | + const rows = database.prepare(`PRAGMA table_info(${table})`).all() as QueryRow[]; | |
| 1216 | + const actual = rows.map((row) => String(row.name)); | |
| 1217 | + if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) { | |
| 1218 | + fail("CATALOG_SCHEMA", `${table} has an unexpected column contract`); | |
| 1219 | + } | |
| 1220 | +} | |
| 1221 | + | |
| 1222 | +function metadataValue(database: DatabaseSync, key: string): string | undefined { | |
| 1223 | + const row = database.prepare("SELECT value FROM metadata WHERE key = ?").get(key) as QueryRow | undefined; | |
| 1224 | + return row === undefined ? undefined : String(row.value); | |
| 1225 | +} | |
| 1226 | + | |
| 1227 | +function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | |
| 1228 | + database.exec("PRAGMA foreign_keys = ON"); | |
| 1229 | + exactColumns(database, "metadata", ["key", "value"]); | |
| 1230 | + exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]); | |
| 1231 | + exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]); | |
| 1232 | + exactColumns(database, "place_search", ["search_text", "place_id"]); | |
| 1233 | + | |
| 1234 | + const sourceRevision = metadataValue(database, "source_revision"); | |
| 1235 | + const createdAt = metadataValue(database, "created_at"); | |
| 1236 | + if (metadataValue(database, "schema_version") !== String(SCHEMA_VERSION) | |
| 1237 | + || metadataValue(database, "catalog_version") !== String(CATALOG_VERSION) | |
| 1238 | + || sourceRevision === undefined || sourceRevision.trim() === "" | |
| 1239 | + || createdAt === undefined) { | |
| 1240 | + fail("CATALOG_METADATA", "catalog metadata is incomplete or incompatible"); | |
| 1241 | + } | |
| 1242 | + isoTimestamp(createdAt, "metadata.created_at"); | |
| 1243 | + | |
| 1244 | + const integrity = database.prepare("PRAGMA integrity_check").all() as QueryRow[]; | |
| 1245 | + if (integrity.length !== 1 || String(integrity[0]?.integrity_check) !== "ok") fail("CATALOG_INTEGRITY", "SQLite integrity_check failed"); | |
| 1246 | + if ((database.prepare("PRAGMA foreign_key_check").all() as QueryRow[]).length !== 0) fail("CATALOG_FOREIGN_KEY", "SQLite foreign_key_check failed"); | |
| 1247 | + validateByteBounds(database); | |
| 1248 | + | |
| 1249 | + const missingSearch = database.prepare(` | |
| 1250 | + SELECT 1 FROM places AS place | |
| 1251 | + LEFT JOIN place_search AS search ON search.place_id = place.place_id | |
| 1252 | + WHERE search.place_id IS NULL | |
| 1253 | + LIMIT 1 | |
| 1254 | + `).get(); | |
| 1255 | + const extraSearch = database.prepare(` | |
| 1256 | + SELECT 1 FROM place_search AS search | |
| 1257 | + LEFT JOIN places AS place ON place.place_id = search.place_id | |
| 1258 | + WHERE place.place_id IS NULL | |
| 1259 | + LIMIT 1 | |
| 1260 | + `).get(); | |
| 1261 | + if (missingSearch !== undefined || extraSearch !== undefined) fail("CATALOG_SEARCH", "FTS place index does not match places"); | |
| 1262 | + const invalidPlaceService = database.prepare(` | |
| 1263 | + SELECT 1 | |
| 1264 | + FROM places AS place | |
| 1265 | + LEFT JOIN services AS service ON service.place_id = place.place_id | |
| 1266 | + WHERE service.service_id IS NULL OR service.stop_label <> place.stop_label | |
| 1267 | + LIMIT 1 | |
| 1268 | + `).get(); | |
| 1269 | + if (invalidPlaceService !== undefined) { | |
| 1270 | + fail("CATALOG_PLACE_SERVICE", "catalog place is empty or has inconsistent service labels"); | |
| 1271 | + } | |
| 1272 | + if (database.prepare("SELECT 1 FROM place_search WHERE trim(search_text) = '' LIMIT 1").get() !== undefined) { | |
| 1273 | + fail("CATALOG_SEARCH", "FTS place index contains empty search text"); | |
| 1274 | + } | |
| 1275 | + | |
| 1276 | + const unresolved = database.prepare(` | |
| 1277 | + SELECT 1 FROM services | |
| 1278 | + WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = '' | |
| 1279 | + LIMIT 1 | |
| 1280 | + `).get(); | |
| 1281 | + if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable offered service"); | |
| 1282 | + | |
| 1283 | + for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) { | |
| 1284 | + if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple"); | |
| 1285 | + } | |
| 1286 | + for (const row of database.prepare("SELECT service_id, canonical_tuple FROM services").iterate() as Iterable<QueryRow>) { | |
| 1287 | + if (opaqueId("svc_", String(row.canonical_tuple)) !== row.service_id) fail("HASH_COLLISION", "service ID does not match its canonical tuple"); | |
| 1288 | + } | |
| 1289 | + | |
| 1290 | + const counts = Object.fromEntries(TRANSPORT_MODE.map((mode) => [mode, 0])) as Record<TransportMode, number>; | |
| 1291 | + for (const row of database.prepare(` | |
| 1292 | + SELECT place.mode AS mode, count(service.service_id) AS service_count | |
| 1293 | + FROM places AS place | |
| 1294 | + JOIN services AS service ON service.place_id = place.place_id | |
| 1295 | + GROUP BY place.mode | |
| 1296 | + `).all() as QueryRow[]) { | |
| 1297 | + const mode = transportMode(row.mode); | |
| 1298 | + counts[mode] = Number(row.service_count); | |
| 1299 | + } | |
| 1300 | + for (const mode of TRANSPORT_MODE) { | |
| 1301 | + if (counts[mode] < 1) fail("MODE_COVERAGE", `catalog has no resolvable ${mode} service`); | |
| 1302 | + } | |
| 1303 | + | |
| 1304 | + const placeCount = Number((database.prepare("SELECT count(*) AS count FROM places").get() as QueryRow).count); | |
| 1305 | + const serviceCount = Number((database.prepare("SELECT count(*) AS count FROM services").get() as QueryRow).count); | |
| 1306 | + return { candidatePath: "", sourceRevision, createdAt, placeCount, serviceCount, countsByMode: counts }; | |
| 1307 | +} | |
| 1308 | + | |
| 1309 | +function validateStagedPerimeter(database: DatabaseSync): void { | |
| 1310 | + const missing = database.prepare(` | |
| 1311 | + SELECT 1 FROM stage_candidates AS candidate | |
| 1312 | + WHERE NOT EXISTS ( | |
| 1313 | + SELECT 1 FROM stage_perimeter AS perimeter | |
| 1314 | + WHERE perimeter.line = candidate.line_ref | |
| 1315 | + AND perimeter.monitoring_ref = candidate.monitoring_ref | |
| 1316 | + ) | |
| 1317 | + LIMIT 1 | |
| 1318 | + `).get(); | |
| 1319 | + if (missing !== undefined) fail("PERIMETER_MISMATCH", "offered service lacks its exact perimeter pair"); | |
| 1320 | +} | |
| 1321 | + | |
| 1322 | +function validateCandidateSemanticTuples(database: DatabaseSync): void { | |
| 1323 | + const ambiguous = database.prepare(` | |
| 1324 | + SELECT 1 | |
| 1325 | + FROM stage_candidates | |
| 1326 | + GROUP BY mode, id_line, monitoring_ref, direction_id, destination_ref | |
| 1327 | + HAVING count(*) <> 1 OR count(DISTINCT zdaid) <> 1 | |
| 1328 | + LIMIT 1 | |
| 1329 | + `).get(); | |
| 1330 | + if (ambiguous !== undefined) { | |
| 1331 | + fail("AMBIGUOUS_SERVICE", "a semantic service tuple resolves to multiple candidate rows or places"); | |
| 1332 | + } | |
| 1333 | +} | |
| 1334 | + | |
| 1335 | +function dropStaging(database: DatabaseSync): void { | |
| 1336 | + database.exec(` | |
| 1337 | + DROP TABLE stage_candidates; | |
| 1338 | + DROP TABLE stage_terminals; | |
| 1339 | + DROP TABLE stage_stop_zda; | |
| 1340 | + DROP TABLE stage_line_modes; | |
| 1341 | + DROP TABLE stage_arrets_lignes; | |
| 1342 | + DROP TABLE stage_lines; | |
| 1343 | + DROP TABLE stage_relations; | |
| 1344 | + DROP TABLE stage_zones; | |
| 1345 | + DROP TABLE stage_arrets; | |
| 1346 | + DROP TABLE stage_perimeter; | |
| 1347 | + DROP TABLE stage_codes; | |
| 1348 | + DROP TABLE stage_agency; | |
| 1349 | + DROP TABLE stage_stops; | |
| 1350 | + DROP TABLE stage_stop_times; | |
| 1351 | + DROP TABLE stage_trips; | |
| 1352 | + DROP TABLE stage_routes; | |
| 1353 | + PRAGMA optimize; | |
| 1354 | + `); | |
| 1355 | +} | |
| 1356 | + | |
| 1357 | +async function build( | |
| 1358 | + candidatePath: string, | |
| 1359 | + sourceRevisionValue: string, | |
| 1360 | + createdAtValue: string | undefined, | |
| 1361 | + load: (database: DatabaseSync) => void | Promise<void>, | |
| 1362 | +): Promise<CatalogBuildResult> { | |
| 1363 | + const candidate = resolve(candidatePath); | |
| 1364 | + const sourceRevision = nonEmpty(sourceRevisionValue, "sourceRevision"); | |
| 1365 | + const createdAt = isoTimestamp(createdAtValue ?? new Date().toISOString(), "createdAt"); | |
| 1366 | + if (existsSync(candidate)) fail("CANDIDATE_EXISTS", "catalog candidate path already exists"); | |
| 1367 | + mkdirSync(dirname(candidate), { recursive: true }); | |
| 1368 | + | |
| 1369 | + let database: DatabaseSync | undefined; | |
| 1370 | + try { | |
| 1371 | + database = new DatabaseSync(candidate); | |
| 1372 | + createSchema(database); | |
| 1373 | + await load(database); | |
| 1374 | + createIndexes(database); | |
| 1375 | + deriveCandidates(database); | |
| 1376 | + validateStagedPerimeter(database); | |
| 1377 | + validateCandidateSemanticTuples(database); | |
| 1378 | + const placeResolution = insertFinalCatalog(database, sourceRevision, createdAt); | |
| 1379 | + dropStaging(database); | |
| 1380 | + const result = validateOpenCatalog(database); | |
| 1381 | + database.close(); | |
| 1382 | + database = undefined; | |
| 1383 | + fsyncFile(candidate); | |
| 1384 | + return { ...result, candidatePath: candidate, placeResolution }; | |
| 1385 | + } catch (error) { | |
| 1386 | + try { | |
| 1387 | + database?.close(); | |
| 1388 | + } finally { | |
| 1389 | + removeCandidateFiles(candidate); | |
| 1390 | + } | |
| 1391 | + if (error instanceof CatalogImportError) throw error; | |
| 1392 | + fail("CATALOG_BUILD_FAILED", "catalog candidate build failed", error); | |
| 1393 | + } | |
| 1394 | +} | |
| 1395 | + | |
| 1396 | +export function buildCatalogCandidate(options: BuildCatalogOptions): Promise<CatalogBuildResult> { | |
| 1397 | + return build(options.candidatePath, options.sourceRevision, options.createdAt, (database) => loadFileSources(database, options.sources)); | |
| 1398 | +} | |
| 1399 | + | |
| 1400 | +export function buildCatalogCandidateFromRecords(options: BuildCatalogFromRecordsOptions): Promise<CatalogBuildResult> { | |
| 1401 | + return build(options.candidatePath, options.sourceRevision, options.createdAt, (database) => loadRecordSources(database, options.sources)); | |
| 1402 | +} | |
| 1403 | + | |
| 1404 | +export function validateCatalogCandidate(path: string): CatalogBuildResult { | |
| 1405 | + const candidate = resolve(path); | |
| 1406 | + let database: DatabaseSync | undefined; | |
| 1407 | + try { | |
| 1408 | + database = new DatabaseSync(candidate, { readOnly: true }); | |
| 1409 | + const result = validateOpenCatalog(database); | |
| 1410 | + return { ...result, candidatePath: candidate }; | |
| 1411 | + } catch (error) { | |
| 1412 | + if (error instanceof CatalogImportError) throw error; | |
| 1413 | + fail("CATALOG_VALIDATION_FAILED", "catalog candidate validation failed", error); | |
| 1414 | + } finally { | |
| 1415 | + database?.close(); | |
| 1416 | + } | |
| 1417 | +} | |
| 1418 | + | |
| 1419 | +export function activateCatalogCandidate(candidatePath: string, activePath: string): CatalogBuildResult { | |
| 1420 | + const candidate = resolve(candidatePath); | |
| 1421 | + const active = resolve(activePath); | |
| 1422 | + if (candidate === active) fail("INVALID_ACTIVATION", "candidate and active paths must differ"); | |
| 1423 | + mkdirSync(dirname(active), { recursive: true }); | |
| 1424 | + let result: CatalogBuildResult; | |
| 1425 | + try { | |
| 1426 | + result = validateCatalogCandidate(candidate); | |
| 1427 | + if (statSync(candidate).dev !== statSync(dirname(active)).dev) { | |
| 1428 | + fail("CROSS_DEVICE_ACTIVATION", "catalog candidate and active directory must share a filesystem"); | |
| 1429 | + } | |
| 1430 | + fsyncFile(candidate); | |
| 1431 | + } catch (error) { | |
| 1432 | + removeCandidateFiles(candidate); | |
| 1433 | + throw error; | |
| 1434 | + } | |
| 1435 | + try { | |
| 1436 | + renameSync(candidate, active); | |
| 1437 | + } catch (error) { | |
| 1438 | + removeCandidateFiles(candidate); | |
| 1439 | + throw error; | |
| 1440 | + } | |
| 1441 | + try { | |
| 1442 | + fsyncDirectory(dirname(active)); | |
| 1443 | + } catch { | |
| 1444 | + // The rename already replaced the active path; never unlink either file. | |
| 1445 | + // Revalidate what is actually active and report the applied activation. | |
| 1446 | + const reapplied = validateCatalogCandidate(active); | |
| 1447 | + return { ...reapplied, candidatePath: active, revalidatedAfterRenameFailure: true }; | |
| 1448 | + } | |
| 1449 | + return { ...result, candidatePath: active }; | |
| 1450 | +} | |
| 1451 | + | |
| 1452 | +export async function refreshCatalog(options: BuildCatalogOptions & { readonly activePath: string }): Promise<CatalogBuildResult> { | |
| 1453 | + await buildCatalogCandidate(options); | |
| 1454 | + return activateCatalogCandidate(options.candidatePath, options.activePath); | |
| 1455 | +} | |
| 1456 | + | |
| 1457 | +export async function refreshCatalogFromRecords( | |
| 1458 | + options: BuildCatalogFromRecordsOptions & { readonly activePath: string }, | |
| 1459 | +): Promise<CatalogBuildResult> { | |
| 1460 | + await buildCatalogCandidateFromRecords(options); | |
| 1461 | + return activateCatalogCandidate(options.candidatePath, options.activePath); | |
| 1462 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,1462 @@ | |||
| 1 | +import { createHash } from "node:crypto"; | ||
| 2 | +import { | ||
| 3 | + closeSync, | ||
| 4 | + createReadStream, | ||
| 5 | + existsSync, | ||
| 6 | + fsyncSync, | ||
| 7 | + mkdirSync, | ||
| 8 | + openSync, | ||
| 9 | + renameSync, | ||
| 10 | + statSync, | ||
| 11 | + unlinkSync, | ||
| 12 | +} from "node:fs"; | ||
| 13 | +import { open as openFile } from "node:fs/promises"; | ||
| 14 | +import type { FileHandle } from "node:fs/promises"; | ||
| 15 | +import { basename, dirname, resolve } from "node:path"; | ||
| 16 | +import { Readable, Transform } from "node:stream"; | ||
| 17 | +import { DatabaseSync } from "node:sqlite"; | ||
| 18 | +import { parse } from "csv-parse"; | ||
| 19 | +import yauzl from "yauzl"; | ||
| 20 | +import { | ||
| 21 | + LIMITS, | ||
| 22 | + SCHEMA_VERSION, | ||
| 23 | + TRANSPORT_MODE, | ||
| 24 | +} from "../../contracts/src/index.ts"; | ||
| 25 | +import type { TransportMode } from "../../contracts/src/index.ts"; | ||
| 26 | + | ||
| 27 | +export const CATALOG_VERSION = 1 as const; | ||
| 28 | +export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000; | ||
| 29 | +export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3; | ||
| 30 | +export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3; | ||
| 31 | +export const MAX_SELECTED_ARCHIVE_BYTES = 16 * 1024 ** 3; | ||
| 32 | +export const MAX_CSV_RECORD_BYTES = 1024 * 1024; | ||
| 33 | +export const IMPORT_TRANSACTION_ROWS = 10_000; | ||
| 34 | +export const IDFM_CATALOG_SOURCE_URL = { | ||
| 35 | + gtfsRecord: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/offre-horaires-tc-gtfs-idfm/records?limit=1", | ||
| 36 | + perimeter: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/perimetre-des-donnees-tr-disponibles-plateforme-idfm/exports/csv?limit=-1", | ||
| 37 | + arrets: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets/exports/csv?limit=-1", | ||
| 38 | + zones: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/zones-d-arrets/exports/csv?limit=-1", | ||
| 39 | + relations: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/relations/exports/csv?limit=-1", | ||
| 40 | + lines: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/exports/csv?limit=-1", | ||
| 41 | + arretsLignes: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets-lignes/exports/csv?limit=-1", | ||
| 42 | +} as const; | ||
| 43 | + | ||
| 44 | +const SUPPORTED_MODE: Readonly<Record<TransportMode, true>> = { | ||
| 45 | + BUS: true, | ||
| 46 | + METRO: true, | ||
| 47 | + TRAM: true, | ||
| 48 | + RER: true, | ||
| 49 | + TRANSILIEN: true, | ||
| 50 | +}; | ||
| 51 | +const REQUIRED_GTFS_ENTRIES = [ | ||
| 52 | + "routes.txt", | ||
| 53 | + "trips.txt", | ||
| 54 | + "stop_times.txt", | ||
| 55 | + "stops.txt", | ||
| 56 | + "agency.txt", | ||
| 57 | +] as const; | ||
| 58 | +const CODE_ENTRY_NAME: Readonly<Record<string, true>> = { | ||
| 59 | + "object_codes_extension.txt": true, | ||
| 60 | + "objects_codes_extension.txt": true, | ||
| 61 | +}; | ||
| 62 | + | ||
| 63 | +export class CatalogImportError extends Error { | ||
| 64 | + readonly code: string; | ||
| 65 | + | ||
| 66 | + constructor(code: string, message: string, options?: ErrorOptions) { | ||
| 67 | + super(message, options); | ||
| 68 | + this.name = "CatalogImportError"; | ||
| 69 | + this.code = code; | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +export type CsvValue = string | number | boolean | null | undefined; | ||
| 74 | +export type CsvRecord = Readonly<Record<string, CsvValue>>; | ||
| 75 | + | ||
| 76 | +export interface CatalogRecordSourceSet { | ||
| 77 | + readonly routes: readonly CsvRecord[]; | ||
| 78 | + readonly trips: readonly CsvRecord[]; | ||
| 79 | + readonly stopTimes: readonly CsvRecord[]; | ||
| 80 | + readonly stops: readonly CsvRecord[]; | ||
| 81 | + readonly agency: readonly CsvRecord[]; | ||
| 82 | + readonly objectCodes: readonly CsvRecord[]; | ||
| 83 | + readonly perimeter: readonly CsvRecord[]; | ||
| 84 | + readonly arrets: readonly CsvRecord[]; | ||
| 85 | + readonly zones: readonly CsvRecord[]; | ||
| 86 | + readonly relations: readonly CsvRecord[]; | ||
| 87 | + readonly lines: readonly CsvRecord[]; | ||
| 88 | + readonly arretsLignes: readonly CsvRecord[]; | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +export interface CsvFileSource { | ||
| 92 | + readonly path: string; | ||
| 93 | + readonly delimiter?: "," | ";"; | ||
| 94 | + readonly expectedRows?: number; | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +export interface CatalogFileSourceSet { | ||
| 98 | + readonly gtfsZipPath: string; | ||
| 99 | + readonly perimeter: CsvFileSource; | ||
| 100 | + readonly arrets: CsvFileSource; | ||
| 101 | + readonly zones: CsvFileSource; | ||
| 102 | + readonly relations: CsvFileSource; | ||
| 103 | + readonly lines: CsvFileSource; | ||
| 104 | + readonly arretsLignes: CsvFileSource; | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +export interface CatalogSourceAttribution { | ||
| 108 | + readonly dataset: string; | ||
| 109 | + readonly url: string; | ||
| 110 | + readonly retrievedAt: string; | ||
| 111 | + readonly license: string; | ||
| 112 | + readonly restricted?: boolean; | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +export interface DownloadCatalogSourceOptions { | ||
| 116 | + readonly source: CatalogSourceAttribution; | ||
| 117 | + readonly destinationPath: string; | ||
| 118 | + readonly expectedContentTypes: readonly string[]; | ||
| 119 | + readonly datasetToken?: string; | ||
| 120 | + readonly fetch?: typeof globalThis.fetch; | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +export interface DownloadedCatalogSource { | ||
| 124 | + readonly path: string; | ||
| 125 | + readonly bytes: number; | ||
| 126 | + readonly sha256: string; | ||
| 127 | + readonly source: CatalogSourceAttribution; | ||
| 128 | +} | ||
| 129 | +export interface ResolvedGtfsDownload { | ||
| 130 | + readonly filename: string; | ||
| 131 | + readonly url: string; | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +export interface BuildCatalogOptions { | ||
| 135 | + readonly candidatePath: string; | ||
| 136 | + readonly sourceRevision: string; | ||
| 137 | + readonly createdAt?: string; | ||
| 138 | + readonly sources: CatalogFileSourceSet; | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +export interface BuildCatalogFromRecordsOptions { | ||
| 142 | + readonly candidatePath: string; | ||
| 143 | + readonly sourceRevision: string; | ||
| 144 | + readonly createdAt?: string; | ||
| 145 | + readonly sources: CatalogRecordSourceSet; | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +export interface PlaceResolutionCounts { | ||
| 149 | + readonly arrets: number; | ||
| 150 | + readonly relations: number; | ||
| 151 | + readonly direct: number; | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +export interface CatalogBuildResult { | ||
| 155 | + readonly candidatePath: string; | ||
| 156 | + readonly sourceRevision: string; | ||
| 157 | + readonly createdAt: string; | ||
| 158 | + readonly placeCount: number; | ||
| 159 | + readonly serviceCount: number; | ||
| 160 | + readonly countsByMode: Readonly<Record<TransportMode, number>>; | ||
| 161 | + readonly placeResolution?: PlaceResolutionCounts; | ||
| 162 | + readonly revalidatedAfterRenameFailure?: true; | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +interface CsvSpec { | ||
| 166 | + readonly required: readonly string[]; | ||
| 167 | + readonly insert: (row: Readonly<Record<string, string>>) => void; | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +interface ZipEntryLike { | ||
| 171 | + readonly fileName: string; | ||
| 172 | + readonly uncompressedSize: number; | ||
| 173 | + readonly generalPurposeBitFlag: number; | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +interface ZipFileLike { | ||
| 177 | + readEntry(): void; | ||
| 178 | + close(): void; | ||
| 179 | + once(event: "entry", listener: (entry: ZipEntryLike) => void): this; | ||
| 180 | + once(event: "end" | "error", listener: (...args: unknown[]) => void): this; | ||
| 181 | + removeListener(event: string, listener: (...args: never[]) => void): this; | ||
| 182 | + openReadStream( | ||
| 183 | + entry: ZipEntryLike, | ||
| 184 | + callback: (error: Error | null, stream?: Readable) => void, | ||
| 185 | + ): void; | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +interface QueryRow { | ||
| 189 | + readonly [key: string]: unknown; | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +function fail(code: string, message: string, cause?: unknown): never { | ||
| 193 | + throw new CatalogImportError(code, message, cause === undefined ? undefined : { cause }); | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +function nonEmpty(value: unknown, field: string): string { | ||
| 197 | + const text = String(value ?? "").trim(); | ||
| 198 | + if (text.length === 0 || text.includes("\0")) fail("INVALID_SOURCE", `${field} must be non-empty`); | ||
| 199 | + return text; | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +function optionalText(value: unknown): string { | ||
| 203 | + const text = String(value ?? "").trim(); | ||
| 204 | + if (text.includes("\0")) fail("INVALID_SOURCE", "source text contains NUL"); | ||
| 205 | + return text; | ||
| 206 | +} | ||
| 207 | + | ||
| 208 | +function integer(value: unknown, field: string): number { | ||
| 209 | + const text = nonEmpty(value, field); | ||
| 210 | + if (!/^-?\d+$/u.test(text)) fail("INVALID_SOURCE", `${field} must be an integer`); | ||
| 211 | + const parsed = Number(text); | ||
| 212 | + if (!Number.isSafeInteger(parsed)) fail("INVALID_SOURCE", `${field} exceeds safe integer range`); | ||
| 213 | + return parsed; | ||
| 214 | +} | ||
| 215 | + | ||
| 216 | +function transportMode(value: unknown): TransportMode { | ||
| 217 | + const mode = nonEmpty(value, "mode"); | ||
| 218 | + if (!Object.hasOwn(SUPPORTED_MODE, mode)) fail("INVALID_MODE", `unsupported transport mode ${mode}`); | ||
| 219 | + return mode as TransportMode; | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +function canonicalLineId(value: unknown, field: string): string { | ||
| 223 | + const text = nonEmpty(value, field); | ||
| 224 | + return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text; | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +function isoTimestamp(value: string, field: string): string { | ||
| 228 | + if (value.length === 0 || !Number.isFinite(Date.parse(value))) { | ||
| 229 | + fail("INVALID_METADATA", `${field} must be an ISO timestamp`); | ||
| 230 | + } | ||
| 231 | + return value; | ||
| 232 | +} | ||
| 233 | + | ||
| 234 | +function assertSourceAttribution(source: CatalogSourceAttribution): void { | ||
| 235 | + nonEmpty(source.dataset, "source.dataset"); | ||
| 236 | + nonEmpty(source.license, "source.license"); | ||
| 237 | + isoTimestamp(source.retrievedAt, "source.retrievedAt"); | ||
| 238 | + let url: URL; | ||
| 239 | + try { | ||
| 240 | + url = new URL(source.url); | ||
| 241 | + } catch (error) { | ||
| 242 | + fail("INVALID_SOURCE_URL", "source URL is invalid", error); | ||
| 243 | + } | ||
| 244 | + if (url.protocol !== "https:" || url.username !== "" || url.password !== "") { | ||
| 245 | + fail("INVALID_SOURCE_URL", "source URL must be credential-free HTTPS"); | ||
| 246 | + } | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +function safeUnlink(path: string): void { | ||
| 250 | + try { | ||
| 251 | + unlinkSync(path); | ||
| 252 | + } catch (error) { | ||
| 253 | + const code = (error as NodeJS.ErrnoException).code; | ||
| 254 | + if (code !== "ENOENT") throw error; | ||
| 255 | + } | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +function removeCandidateFiles(path: string): void { | ||
| 259 | + safeUnlink(path); | ||
| 260 | + safeUnlink(`${path}-journal`); | ||
| 261 | + safeUnlink(`${path}-wal`); | ||
| 262 | + safeUnlink(`${path}-shm`); | ||
| 263 | +} | ||
| 264 | + | ||
| 265 | +function fsyncFile(path: string): void { | ||
| 266 | + const descriptor = openSync(path, "r"); | ||
| 267 | + try { | ||
| 268 | + fsyncSync(descriptor); | ||
| 269 | + } finally { | ||
| 270 | + closeSync(descriptor); | ||
| 271 | + } | ||
| 272 | +} | ||
| 273 | + | ||
| 274 | +function fsyncDirectory(path: string): void { | ||
| 275 | + const descriptor = openSync(path, "r"); | ||
| 276 | + try { | ||
| 277 | + fsyncSync(descriptor); | ||
| 278 | + } finally { | ||
| 279 | + closeSync(descriptor); | ||
| 280 | + } | ||
| 281 | +} | ||
| 282 | + | ||
| 283 | +export function normalizeCatalogSearchText(value: string): string { | ||
| 284 | + return value | ||
| 285 | + .normalize("NFKD") | ||
| 286 | + .replace(/\p{M}+/gu, "") | ||
| 287 | + .toLocaleLowerCase("fr-FR") | ||
| 288 | + .replace(/[^\p{L}\p{N}]+/gu, " ") | ||
| 289 | + .trim() | ||
| 290 | + .replace(/\s+/gu, " "); | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +function canonicalTuple(namespace: "place" | "service", fields: readonly string[]): string { | ||
| 294 | + let tuple = `lapin-fute:${namespace}:v1`; | ||
| 295 | + for (const field of fields) tuple += `|${Buffer.byteLength(field, "utf8")}:${field}`; | ||
| 296 | + return tuple; | ||
| 297 | +} | ||
| 298 | + | ||
| 299 | +function opaqueId(prefix: "plc_" | "svc_", tuple: string): string { | ||
| 300 | + return prefix + createHash("sha256").update(tuple, "utf8").digest("base64url"); | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +export function createPlaceIdentity(mode: TransportMode, zdaId: string): { | ||
| 304 | + readonly placeId: string; | ||
| 305 | + readonly canonicalTuple: string; | ||
| 306 | +} { | ||
| 307 | + const tuple = canonicalTuple("place", [transportMode(mode), nonEmpty(zdaId, "zdaId")]); | ||
| 308 | + return { placeId: opaqueId("plc_", tuple), canonicalTuple: tuple }; | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +export interface ServiceIdentityInput { | ||
| 312 | + readonly mode: TransportMode; | ||
| 313 | + readonly lineId: string; | ||
| 314 | + readonly monitoringRef: string; | ||
| 315 | + readonly directionId: string; | ||
| 316 | + readonly destinationRef: string; | ||
| 317 | +} | ||
| 318 | + | ||
| 319 | +export function createServiceIdentity(input: ServiceIdentityInput): { | ||
| 320 | + readonly serviceId: string; | ||
| 321 | + readonly canonicalTuple: string; | ||
| 322 | +} { | ||
| 323 | + const tuple = canonicalTuple("service", [ | ||
| 324 | + transportMode(input.mode), | ||
| 325 | + nonEmpty(input.lineId, "lineId"), | ||
| 326 | + nonEmpty(input.monitoringRef, "monitoringRef"), | ||
| 327 | + nonEmpty(input.directionId, "directionId"), | ||
| 328 | + nonEmpty(input.destinationRef, "destinationRef"), | ||
| 329 | + ]); | ||
| 330 | + return { serviceId: opaqueId("svc_", tuple), canonicalTuple: tuple }; | ||
| 331 | +} | ||
| 332 | + | ||
| 333 | +export async function resolveIdfmGtfsDownload( | ||
| 334 | + datasetToken: string, | ||
| 335 | + fetchSource: typeof globalThis.fetch = globalThis.fetch, | ||
| 336 | +): Promise<ResolvedGtfsDownload> { | ||
| 337 | + const token = nonEmpty(datasetToken, "datasetToken"); | ||
| 338 | + const controller = new AbortController(); | ||
| 339 | + const timeout = setTimeout(() => controller.abort(new Error("GTFS metadata request timed out")), DOWNLOAD_TIMEOUT_MS); | ||
| 340 | + try { | ||
| 341 | + const response = await fetchSource(IDFM_CATALOG_SOURCE_URL.gtfsRecord, { | ||
| 342 | + method: "GET", | ||
| 343 | + redirect: "manual", | ||
| 344 | + credentials: "omit", | ||
| 345 | + cache: "no-store", | ||
| 346 | + headers: { Authorization: `apikey ${token}` }, | ||
| 347 | + signal: controller.signal, | ||
| 348 | + }); | ||
| 349 | + if (response.status !== 200) fail("SOURCE_HTTP_STATUS", `GTFS metadata returned HTTP ${response.status}`); | ||
| 350 | + const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase(); | ||
| 351 | + if (mediaType !== "application/json") fail("SOURCE_CONTENT_TYPE", "GTFS metadata is not JSON"); | ||
| 352 | + if (response.body === null) fail("SOURCE_EMPTY_BODY", "GTFS metadata returned no body"); | ||
| 353 | + | ||
| 354 | + const chunks: Uint8Array[] = []; | ||
| 355 | + let bytes = 0; | ||
| 356 | + for await (const chunk of response.body) { | ||
| 357 | + bytes += chunk.byteLength; | ||
| 358 | + if (bytes > 262_144) fail("SOURCE_METADATA_TOO_LARGE", "GTFS metadata exceeds 256 KiB"); | ||
| 359 | + chunks.push(chunk); | ||
| 360 | + } | ||
| 361 | + let value: unknown; | ||
| 362 | + try { | ||
| 363 | + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))); | ||
| 364 | + } catch (error) { | ||
| 365 | + fail("INVALID_SOURCE_METADATA", "GTFS metadata is not valid UTF-8 JSON", error); | ||
| 366 | + } | ||
| 367 | + if (typeof value !== "object" || value === null || Array.isArray(value)) { | ||
| 368 | + fail("INVALID_SOURCE_METADATA", "GTFS metadata root is invalid"); | ||
| 369 | + } | ||
| 370 | + const results = (value as Record<string, unknown>).results; | ||
| 371 | + if (!Array.isArray(results) || results.length !== 1 || typeof results[0] !== "object" || results[0] === null) { | ||
| 372 | + fail("INVALID_SOURCE_METADATA", "GTFS metadata must resolve exactly one file"); | ||
| 373 | + } | ||
| 374 | + const record = results[0] as Record<string, unknown>; | ||
| 375 | + const filename = nonEmpty(record.filename, "GTFS metadata filename"); | ||
| 376 | + const urlValue = record.url; | ||
| 377 | + const url = typeof urlValue === "string" | ||
| 378 | + ? urlValue | ||
| 379 | + : typeof urlValue === "object" && urlValue !== null | ||
| 380 | + ? String((urlValue as Record<string, unknown>).href ?? "") | ||
| 381 | + : ""; | ||
| 382 | + let resolvedUrl: URL; | ||
| 383 | + try { | ||
| 384 | + resolvedUrl = new URL(nonEmpty(url, "GTFS metadata URL")); | ||
| 385 | + } catch (error) { | ||
| 386 | + fail("INVALID_SOURCE_URL", "GTFS metadata URL is invalid", error); | ||
| 387 | + } | ||
| 388 | + if (resolvedUrl.protocol !== "https:" || resolvedUrl.username !== "" || resolvedUrl.password !== "" | ||
| 389 | + || resolvedUrl.href.includes(token) || resolvedUrl.href.includes(encodeURIComponent(token))) { | ||
| 390 | + fail("INVALID_SOURCE_URL", "GTFS metadata URL must be credential-free HTTPS"); | ||
| 391 | + } | ||
| 392 | + return { filename, url: resolvedUrl.href }; | ||
| 393 | + } catch (error) { | ||
| 394 | + if (error instanceof CatalogImportError) throw error; | ||
| 395 | + fail("SOURCE_METADATA_FAILED", "GTFS metadata request failed", error); | ||
| 396 | + } finally { | ||
| 397 | + clearTimeout(timeout); | ||
| 398 | + } | ||
| 399 | +} | ||
| 400 | + | ||
| 401 | +export async function downloadCatalogSource( | ||
| 402 | + options: DownloadCatalogSourceOptions, | ||
| 403 | +): Promise<DownloadedCatalogSource> { | ||
| 404 | + assertSourceAttribution(options.source); | ||
| 405 | + if (existsSync(options.destinationPath)) fail("DESTINATION_EXISTS", "download destination already exists"); | ||
| 406 | + | ||
| 407 | + const token = options.datasetToken; | ||
| 408 | + if (options.source.restricted && !token) fail("DATASET_TOKEN_REQUIRED", "restricted source needs IDFM_DATASET_TOKEN"); | ||
| 409 | + if (!options.source.restricted && token !== undefined) { | ||
| 410 | + fail("UNEXPECTED_DATASET_TOKEN", "credentials are forbidden for public catalog sources"); | ||
| 411 | + } | ||
| 412 | + if (token !== undefined) { | ||
| 413 | + nonEmpty(token, "datasetToken"); | ||
| 414 | + const encoded = encodeURIComponent(token); | ||
| 415 | + if (options.source.url.includes(token) || options.source.url.includes(encoded)) { | ||
| 416 | + fail("CREDENTIAL_IN_URL", "dataset token must not appear in a URL"); | ||
| 417 | + } | ||
| 418 | + } | ||
| 419 | + | ||
| 420 | + mkdirSync(dirname(options.destinationPath), { recursive: true }); | ||
| 421 | + const controller = new AbortController(); | ||
| 422 | + const timeout = setTimeout(() => controller.abort(new Error("catalog download timed out")), DOWNLOAD_TIMEOUT_MS); | ||
| 423 | + let handle: FileHandle | undefined; | ||
| 424 | + try { | ||
| 425 | + const fetchSource = options.fetch ?? globalThis.fetch; | ||
| 426 | + const response = await fetchSource(options.source.url, { | ||
| 427 | + method: "GET", | ||
| 428 | + redirect: "manual", | ||
| 429 | + credentials: "omit", | ||
| 430 | + cache: "no-store", | ||
| 431 | + headers: token === undefined ? {} : { Authorization: `apikey ${token}` }, | ||
| 432 | + signal: controller.signal, | ||
| 433 | + }); | ||
| 434 | + if (response.status !== 200) fail("SOURCE_HTTP_STATUS", `catalog source returned HTTP ${response.status}`); | ||
| 435 | + const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase(); | ||
| 436 | + const accepted = options.expectedContentTypes.map((value) => value.toLowerCase()); | ||
| 437 | + if (!accepted.includes(mediaType)) fail("SOURCE_CONTENT_TYPE", `unexpected catalog content type ${mediaType || "<missing>"}`); | ||
| 438 | + if (response.body === null) fail("SOURCE_EMPTY_BODY", "catalog source returned no body"); | ||
| 439 | + | ||
| 440 | + handle = await openFile(options.destinationPath, "wx", 0o600); | ||
| 441 | + const hash = createHash("sha256"); | ||
| 442 | + let bytes = 0; | ||
| 443 | + for await (const chunkValue of response.body) { | ||
| 444 | + const chunk = chunkValue instanceof Uint8Array ? chunkValue : new Uint8Array(chunkValue as ArrayBuffer); | ||
| 445 | + bytes += chunk.byteLength; | ||
| 446 | + if (bytes > MAX_COMPRESSED_DOWNLOAD_BYTES) { | ||
| 447 | + controller.abort(new Error("catalog download exceeded byte limit")); | ||
| 448 | + fail("SOURCE_TOO_LARGE", "catalog source exceeds the 2 GiB download limit"); | ||
| 449 | + } | ||
| 450 | + hash.update(chunk); | ||
| 451 | + let offset = 0; | ||
| 452 | + while (offset < chunk.byteLength) { | ||
| 453 | + const result = await handle.write(chunk, offset, chunk.byteLength - offset); | ||
| 454 | + offset += result.bytesWritten; | ||
| 455 | + } | ||
| 456 | + } | ||
| 457 | + await handle.sync(); | ||
| 458 | + await handle.close(); | ||
| 459 | + handle = undefined; | ||
| 460 | + return { | ||
| 461 | + path: options.destinationPath, | ||
| 462 | + bytes, | ||
| 463 | + sha256: hash.digest("hex"), | ||
| 464 | + source: options.source, | ||
| 465 | + }; | ||
| 466 | + } catch (error) { | ||
| 467 | + try { | ||
| 468 | + await handle?.close(); | ||
| 469 | + } finally { | ||
| 470 | + safeUnlink(options.destinationPath); | ||
| 471 | + } | ||
| 472 | + if (error instanceof CatalogImportError) throw error; | ||
| 473 | + fail("SOURCE_DOWNLOAD_FAILED", "catalog source download failed", error); | ||
| 474 | + } finally { | ||
| 475 | + clearTimeout(timeout); | ||
| 476 | + } | ||
| 477 | +} | ||
| 478 | + | ||
| 479 | +function createSchema(database: DatabaseSync): void { | ||
| 480 | + database.exec(` | ||
| 481 | + PRAGMA foreign_keys = ON; | ||
| 482 | + PRAGMA journal_mode = DELETE; | ||
| 483 | + PRAGMA synchronous = FULL; | ||
| 484 | + PRAGMA temp_store = FILE; | ||
| 485 | + | ||
| 486 | + CREATE TABLE metadata ( | ||
| 487 | + key TEXT PRIMARY KEY, | ||
| 488 | + value TEXT NOT NULL | ||
| 489 | + ) STRICT; | ||
| 490 | + CREATE TABLE places ( | ||
| 491 | + place_id TEXT PRIMARY KEY, | ||
| 492 | + stop_label TEXT NOT NULL, | ||
| 493 | + locality_label TEXT, | ||
| 494 | + mode TEXT NOT NULL, | ||
| 495 | + canonical_tuple TEXT NOT NULL UNIQUE | ||
| 496 | + ) STRICT; | ||
| 497 | + CREATE TABLE services ( | ||
| 498 | + service_id TEXT PRIMARY KEY, | ||
| 499 | + place_id TEXT NOT NULL REFERENCES places(place_id), | ||
| 500 | + stop_label TEXT NOT NULL, | ||
| 501 | + line_label TEXT NOT NULL, | ||
| 502 | + destination_label TEXT NOT NULL, | ||
| 503 | + monitoring_ref TEXT NOT NULL, | ||
| 504 | + line_ref TEXT NOT NULL, | ||
| 505 | + direction_id TEXT NOT NULL, | ||
| 506 | + destination_ref TEXT NOT NULL, | ||
| 507 | + canonical_tuple TEXT NOT NULL UNIQUE | ||
| 508 | + ) STRICT; | ||
| 509 | + CREATE VIRTUAL TABLE place_search USING fts5( | ||
| 510 | + search_text, | ||
| 511 | + place_id UNINDEXED, | ||
| 512 | + tokenize = 'unicode61 remove_diacritics 2' | ||
| 513 | + ); | ||
| 514 | + | ||
| 515 | + CREATE TABLE stage_routes ( | ||
| 516 | + route_id TEXT PRIMARY KEY, | ||
| 517 | + route_short_name TEXT NOT NULL, | ||
| 518 | + route_long_name TEXT NOT NULL | ||
| 519 | + ) STRICT; | ||
| 520 | + CREATE TABLE stage_trips ( | ||
| 521 | + trip_id TEXT PRIMARY KEY, | ||
| 522 | + route_id TEXT NOT NULL, | ||
| 523 | + trip_headsign TEXT NOT NULL, | ||
| 524 | + direction_id TEXT NOT NULL | ||
| 525 | + ) STRICT; | ||
| 526 | + CREATE TABLE stage_stop_times ( | ||
| 527 | + trip_id TEXT NOT NULL, | ||
| 528 | + stop_id TEXT NOT NULL, | ||
| 529 | + stop_sequence INTEGER NOT NULL, | ||
| 530 | + pickup_type INTEGER NOT NULL, | ||
| 531 | + PRIMARY KEY (trip_id, stop_sequence) | ||
| 532 | + ) STRICT; | ||
| 533 | + CREATE TABLE stage_stops ( | ||
| 534 | + stop_id TEXT PRIMARY KEY, | ||
| 535 | + stop_name TEXT NOT NULL, | ||
| 536 | + parent_station TEXT NOT NULL | ||
| 537 | + ) STRICT; | ||
| 538 | + CREATE TABLE stage_agency ( | ||
| 539 | + agency_name TEXT PRIMARY KEY | ||
| 540 | + ) STRICT; | ||
| 541 | + CREATE TABLE stage_codes ( | ||
| 542 | + object_type TEXT NOT NULL, | ||
| 543 | + object_id TEXT NOT NULL, | ||
| 544 | + object_system TEXT NOT NULL, | ||
| 545 | + object_code TEXT NOT NULL, | ||
| 546 | + PRIMARY KEY (object_type, object_id, object_system, object_code) | ||
| 547 | + ) STRICT; | ||
| 548 | + CREATE TABLE stage_perimeter ( | ||
| 549 | + line TEXT NOT NULL, | ||
| 550 | + id_line TEXT NOT NULL, | ||
| 551 | + name_line TEXT NOT NULL, | ||
| 552 | + monitoring_ref TEXT NOT NULL, | ||
| 553 | + stop_name TEXT NOT NULL, | ||
| 554 | + operator_name TEXT NOT NULL, | ||
| 555 | + PRIMARY KEY (line, monitoring_ref) | ||
| 556 | + ) STRICT; | ||
| 557 | + CREATE TABLE stage_arrets ( | ||
| 558 | + arrid TEXT PRIMARY KEY, | ||
| 559 | + zdaid TEXT NOT NULL | ||
| 560 | + ) STRICT; | ||
| 561 | + CREATE TABLE stage_zones ( | ||
| 562 | + zdaid TEXT PRIMARY KEY, | ||
| 563 | + zdaname TEXT NOT NULL, | ||
| 564 | + zdatown TEXT NOT NULL | ||
| 565 | + ) STRICT; | ||
| 566 | + CREATE TABLE stage_relations ( | ||
| 567 | + zdaid TEXT NOT NULL, | ||
| 568 | + arrid TEXT NOT NULL, | ||
| 569 | + artid TEXT NOT NULL, | ||
| 570 | + PRIMARY KEY (zdaid, arrid, artid) | ||
| 571 | + ) STRICT; | ||
| 572 | + CREATE TABLE stage_lines ( | ||
| 573 | + id_line TEXT PRIMARY KEY, | ||
| 574 | + name_line TEXT NOT NULL, | ||
| 575 | + shortname_line TEXT NOT NULL, | ||
| 576 | + transport_mode TEXT NOT NULL, | ||
| 577 | + transport_submode TEXT NOT NULL, | ||
| 578 | + status TEXT NOT NULL | ||
| 579 | + ) STRICT; | ||
| 580 | + CREATE TABLE stage_arrets_lignes ( | ||
| 581 | + route_id TEXT NOT NULL, | ||
| 582 | + stop_id TEXT NOT NULL, | ||
| 583 | + PRIMARY KEY (route_id, stop_id) | ||
| 584 | + ) STRICT; | ||
| 585 | + `); | ||
| 586 | +} | ||
| 587 | + | ||
| 588 | +function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>> { | ||
| 589 | + const route = database.prepare("INSERT INTO stage_routes VALUES (?, ?, ?)"); | ||
| 590 | + const trip = database.prepare("INSERT INTO stage_trips VALUES (?, ?, ?, ?)"); | ||
| 591 | + const stopTime = database.prepare("INSERT INTO stage_stop_times VALUES (?, ?, ?, ?)"); | ||
| 592 | + const stop = database.prepare("INSERT INTO stage_stops VALUES (?, ?, ?)"); | ||
| 593 | + const agency = database.prepare("INSERT OR IGNORE INTO stage_agency VALUES (?)"); | ||
| 594 | + const code = database.prepare("INSERT OR IGNORE INTO stage_codes VALUES (?, ?, ?, ?)"); | ||
| 595 | + const perimeter = database.prepare("INSERT OR IGNORE INTO stage_perimeter VALUES (?, ?, ?, ?, ?, ?)"); | ||
| 596 | + const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)"); | ||
| 597 | + const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)"); | ||
| 598 | + const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)"); | ||
| 599 | + const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?)"); | ||
| 600 | + const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)"); | ||
| 601 | + | ||
| 602 | + return { | ||
| 603 | + routes: { | ||
| 604 | + required: ["route_id", "route_short_name", "route_long_name"], | ||
| 605 | + insert: (row) => route.run(canonicalLineId(row.route_id, "routes.route_id"), optionalText(row.route_short_name), optionalText(row.route_long_name)), | ||
| 606 | + }, | ||
| 607 | + trips: { | ||
| 608 | + required: ["route_id", "trip_id", "trip_headsign", "direction_id"], | ||
| 609 | + insert: (row) => trip.run(nonEmpty(row.trip_id, "trips.trip_id"), nonEmpty(row.route_id, "trips.route_id"), optionalText(row.trip_headsign), nonEmpty(row.direction_id, "trips.direction_id")), | ||
| 610 | + }, | ||
| 611 | + stopTimes: { | ||
| 612 | + required: ["trip_id", "stop_id", "stop_sequence", "pickup_type"], | ||
| 613 | + insert: (row) => { | ||
| 614 | + const pickupType = row.pickup_type === "" ? 0 : integer(row.pickup_type, "stop_times.pickup_type"); | ||
| 615 | + if (pickupType < 0 || pickupType > 3) fail("INVALID_SOURCE", "stop_times.pickup_type is outside GTFS range"); | ||
| 616 | + stopTime.run(nonEmpty(row.trip_id, "stop_times.trip_id"), nonEmpty(row.stop_id, "stop_times.stop_id"), integer(row.stop_sequence, "stop_times.stop_sequence"), pickupType); | ||
| 617 | + }, | ||
| 618 | + }, | ||
| 619 | + stops: { | ||
| 620 | + required: ["stop_id", "stop_name", "parent_station"], | ||
| 621 | + insert: (row) => stop.run(nonEmpty(row.stop_id, "stops.stop_id"), nonEmpty(row.stop_name, "stops.stop_name"), optionalText(row.parent_station)), | ||
| 622 | + }, | ||
| 623 | + agency: { | ||
| 624 | + required: ["agency_name", "agency_url", "agency_timezone"], | ||
| 625 | + insert: (row) => { | ||
| 626 | + nonEmpty(row.agency_url, "agency.agency_url"); | ||
| 627 | + nonEmpty(row.agency_timezone, "agency.agency_timezone"); | ||
| 628 | + agency.run(nonEmpty(row.agency_name, "agency.agency_name")); | ||
| 629 | + }, | ||
| 630 | + }, | ||
| 631 | + objectCodes: { | ||
| 632 | + required: ["object_type", "object_id", "object_system", "object_code"], | ||
| 633 | + insert: (row) => code.run(nonEmpty(row.object_type, "codes.object_type"), nonEmpty(row.object_id, "codes.object_id"), nonEmpty(row.object_system, "codes.object_system"), nonEmpty(row.object_code, "codes.object_code")), | ||
| 634 | + }, | ||
| 635 | + perimeter: { | ||
| 636 | + required: ["line", "name_line", "ns2_stoppointref", "ns2_stopname", "operatorname"], | ||
| 637 | + insert: (row) => perimeter.run(nonEmpty(row.line, "perimeter.line"), canonicalLineId(row.line, "perimeter.line"), nonEmpty(row.name_line, "perimeter.name_line"), nonEmpty(row.ns2_stoppointref, "perimeter.ns2_stoppointref"), nonEmpty(row.ns2_stopname, "perimeter.ns2_stopname"), nonEmpty(row.operatorname, "perimeter.operatorname")), | ||
| 638 | + }, | ||
| 639 | + arrets: { | ||
| 640 | + required: ["arrid", "zdaid"], | ||
| 641 | + insert: (row) => arret.run(nonEmpty(row.arrid, "arrets.arrid"), nonEmpty(row.zdaid, "arrets.zdaid")), | ||
| 642 | + }, | ||
| 643 | + zones: { | ||
| 644 | + required: ["zdaid", "zdaname", "zdatown"], | ||
| 645 | + insert: (row) => zone.run(nonEmpty(row.zdaid, "zones.zdaid"), nonEmpty(row.zdaname, "zones.zdaname"), optionalText(row.zdatown)), | ||
| 646 | + }, | ||
| 647 | + relations: { | ||
| 648 | + required: ["zdaid", "arrid", "artid"], | ||
| 649 | + insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")), | ||
| 650 | + }, | ||
| 651 | + lines: { | ||
| 652 | + required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"], | ||
| 653 | + insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status")), | ||
| 654 | + }, | ||
| 655 | + arretsLignes: { | ||
| 656 | + required: ["id", "stop_id"], | ||
| 657 | + insert: (row) => arretLigne.run(canonicalLineId(row.id, "arrets_lignes.id"), nonEmpty(row.stop_id, "arrets_lignes.stop_id")), | ||
| 658 | + }, | ||
| 659 | + }; | ||
| 660 | +} | ||
| 661 | + | ||
| 662 | +function runBatched(database: DatabaseSync, rows: Iterable<Readonly<Record<string, string>>>, spec: CsvSpec): void { | ||
| 663 | + let pending = 0; | ||
| 664 | + database.exec("BEGIN IMMEDIATE"); | ||
| 665 | + try { | ||
| 666 | + for (const row of rows) { | ||
| 667 | + spec.insert(row); | ||
| 668 | + pending += 1; | ||
| 669 | + if (pending === IMPORT_TRANSACTION_ROWS) { | ||
| 670 | + database.exec("COMMIT; BEGIN IMMEDIATE"); | ||
| 671 | + pending = 0; | ||
| 672 | + } | ||
| 673 | + } | ||
| 674 | + database.exec("COMMIT"); | ||
| 675 | + } catch (error) { | ||
| 676 | + database.exec("ROLLBACK"); | ||
| 677 | + throw error; | ||
| 678 | + } | ||
| 679 | +} | ||
| 680 | + | ||
| 681 | +function stringRecord(record: CsvRecord, required: readonly string[]): Readonly<Record<string, string>> { | ||
| 682 | + const result: Record<string, string> = {}; | ||
| 683 | + for (const key of required) { | ||
| 684 | + if (!Object.hasOwn(record, key)) fail("MISSING_COLUMN", `record is missing ${key}`); | ||
| 685 | + result[key] = String(record[key] ?? ""); | ||
| 686 | + } | ||
| 687 | + return result; | ||
| 688 | +} | ||
| 689 | + | ||
| 690 | +function loadRecordSources(database: DatabaseSync, sources: CatalogRecordSourceSet): void { | ||
| 691 | + const specs = prepareSpecs(database); | ||
| 692 | + for (const key of Object.keys(specs) as (keyof CatalogRecordSourceSet)[]) { | ||
| 693 | + const spec = specs[key]; | ||
| 694 | + const rows = sources[key].map((row) => stringRecord(row, spec.required)); | ||
| 695 | + runBatched(database, rows, spec); | ||
| 696 | + } | ||
| 697 | +} | ||
| 698 | + | ||
| 699 | +class Utf8Decoder extends Transform { | ||
| 700 | + readonly #decoder = new TextDecoder("utf-8", { fatal: true }); | ||
| 701 | + | ||
| 702 | + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { | ||
| 703 | + try { | ||
| 704 | + this.push(this.#decoder.decode(chunk, { stream: true })); | ||
| 705 | + callback(); | ||
| 706 | + } catch (error) { | ||
| 707 | + callback(error as Error); | ||
| 708 | + } | ||
| 709 | + } | ||
| 710 | + | ||
| 711 | + override _flush(callback: (error?: Error | null) => void): void { | ||
| 712 | + try { | ||
| 713 | + this.push(this.#decoder.decode()); | ||
| 714 | + callback(); | ||
| 715 | + } catch (error) { | ||
| 716 | + callback(error as Error); | ||
| 717 | + } | ||
| 718 | + } | ||
| 719 | +} | ||
| 720 | + | ||
| 721 | +class ByteLimit extends Transform { | ||
| 722 | + #bytes = 0; | ||
| 723 | + readonly #limit: number; | ||
| 724 | + readonly #onBytes: (bytes: number) => void; | ||
| 725 | + | ||
| 726 | + constructor(limit: number, onBytes: (bytes: number) => void) { | ||
| 727 | + super(); | ||
| 728 | + this.#limit = limit; | ||
| 729 | + this.#onBytes = onBytes; | ||
| 730 | + } | ||
| 731 | + | ||
| 732 | + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { | ||
| 733 | + this.#bytes += chunk.byteLength; | ||
| 734 | + if (this.#bytes > this.#limit) { | ||
| 735 | + callback(new CatalogImportError("ZIP_ENTRY_TOO_LARGE", "selected GTFS entry exceeds the 8 GiB limit")); | ||
| 736 | + return; | ||
| 737 | + } | ||
| 738 | + try { | ||
| 739 | + this.#onBytes(chunk.byteLength); | ||
| 740 | + this.push(chunk); | ||
| 741 | + callback(); | ||
| 742 | + } catch (error) { | ||
| 743 | + callback(error as Error); | ||
| 744 | + } | ||
| 745 | + } | ||
| 746 | +} | ||
| 747 | + | ||
| 748 | +async function loadCsvStream( | ||
| 749 | + database: DatabaseSync, | ||
| 750 | + input: Readable, | ||
| 751 | + delimiter: "," | ";", | ||
| 752 | + spec: CsvSpec, | ||
| 753 | +): Promise<number> { | ||
| 754 | + const decoder = new Utf8Decoder(); | ||
| 755 | + const parser = parse({ | ||
| 756 | + bom: true, | ||
| 757 | + columns: false, | ||
| 758 | + delimiter, | ||
| 759 | + max_record_size: MAX_CSV_RECORD_BYTES, | ||
| 760 | + relax_column_count: false, | ||
| 761 | + relax_quotes: false, | ||
| 762 | + skip_empty_lines: true, | ||
| 763 | + }); | ||
| 764 | + const destroyParser = (error: Error): void => parser.destroy(error); | ||
| 765 | + input.once("error", destroyParser); | ||
| 766 | + decoder.once("error", destroyParser); | ||
| 767 | + input.pipe(decoder).pipe(parser); | ||
| 768 | + | ||
| 769 | + let indexes: Readonly<Record<string, number>> | undefined; | ||
| 770 | + let pending = 0; | ||
| 771 | + let rows = 0; | ||
| 772 | + database.exec("BEGIN IMMEDIATE"); | ||
| 773 | + try { | ||
| 774 | + for await (const value of parser) { | ||
| 775 | + if (!Array.isArray(value) || !value.every((field) => typeof field === "string")) { | ||
| 776 | + fail("INVALID_CSV", "CSV parser returned a non-string record"); | ||
| 777 | + } | ||
| 778 | + const fields = value as string[]; | ||
| 779 | + if (indexes === undefined) { | ||
| 780 | + const header = fields.map((field) => field.trim()); | ||
| 781 | + if (header.length === 0 || header.some((field) => field.length === 0)) fail("INVALID_HEADER", "CSV header is empty"); | ||
| 782 | + if (new Set(header).size !== header.length) fail("DUPLICATE_HEADER", "CSV contains duplicate columns"); | ||
| 783 | + const mapped: Record<string, number> = {}; | ||
| 784 | + for (const required of spec.required) { | ||
| 785 | + const index = header.indexOf(required); | ||
| 786 | + if (index === -1) fail("MISSING_COLUMN", `CSV is missing required column ${required}`); | ||
| 787 | + mapped[required] = index; | ||
| 788 | + } | ||
| 789 | + indexes = mapped; | ||
| 790 | + continue; | ||
| 791 | + } | ||
| 792 | + const row: Record<string, string> = {}; | ||
| 793 | + for (const [key, index] of Object.entries(indexes)) row[key] = fields[index] ?? ""; | ||
| 794 | + spec.insert(row); | ||
| 795 | + rows += 1; | ||
| 796 | + pending += 1; | ||
| 797 | + if (pending === IMPORT_TRANSACTION_ROWS) { | ||
| 798 | + database.exec("COMMIT; BEGIN IMMEDIATE"); | ||
| 799 | + pending = 0; | ||
| 800 | + } | ||
| 801 | + } | ||
| 802 | + if (indexes === undefined) fail("INVALID_HEADER", "CSV source is empty"); | ||
| 803 | + database.exec("COMMIT"); | ||
| 804 | + return rows; | ||
| 805 | + } catch (error) { | ||
| 806 | + input.destroy(); | ||
| 807 | + database.exec("ROLLBACK"); | ||
| 808 | + if (error instanceof CatalogImportError) throw error; | ||
| 809 | + fail("CSV_PARSE_FAILED", "CSV source could not be parsed", error); | ||
| 810 | + } | ||
| 811 | +} | ||
| 812 | + | ||
| 813 | +function assertSourceFile(path: string): void { | ||
| 814 | + const size = statSync(path).size; | ||
| 815 | + if (size > MAX_COMPRESSED_DOWNLOAD_BYTES) fail("SOURCE_TOO_LARGE", `${basename(path)} exceeds the 2 GiB source limit`); | ||
| 816 | +} | ||
| 817 | + | ||
| 818 | +function openZip(path: string): Promise<ZipFileLike> { | ||
| 819 | + const { promise, resolve: resolveZip, reject } = Promise.withResolvers<ZipFileLike>(); | ||
| 820 | + yauzl.open(path, { | ||
| 821 | + autoClose: false, | ||
| 822 | + decodeStrings: true, | ||
| 823 | + lazyEntries: true, | ||
| 824 | + strictFileNames: true, | ||
| 825 | + validateEntrySizes: true, | ||
| 826 | + }, (error: Error | null, zipFile?: ZipFileLike) => { | ||
| 827 | + if (error || zipFile === undefined) reject(error ?? new Error("ZIP did not open")); | ||
| 828 | + else resolveZip(zipFile); | ||
| 829 | + }); | ||
| 830 | + return promise; | ||
| 831 | +} | ||
| 832 | + | ||
| 833 | +function nextZipEntry(zip: ZipFileLike): Promise<ZipEntryLike | undefined> { | ||
| 834 | + const { promise, resolve: resolveEntry, reject } = Promise.withResolvers<ZipEntryLike | undefined>(); | ||
| 835 | + const cleanup = (): void => { | ||
| 836 | + zip.removeListener("entry", onEntry as (...args: never[]) => void); | ||
| 837 | + zip.removeListener("end", onEnd as (...args: never[]) => void); | ||
| 838 | + zip.removeListener("error", onError as (...args: never[]) => void); | ||
| 839 | + }; | ||
| 840 | + const onEntry = (entry: ZipEntryLike): void => { | ||
| 841 | + cleanup(); | ||
| 842 | + resolveEntry(entry); | ||
| 843 | + }; | ||
| 844 | + const onEnd = (): void => { | ||
| 845 | + cleanup(); | ||
| 846 | + resolveEntry(undefined); | ||
| 847 | + }; | ||
| 848 | + const onError = (error: unknown): void => { | ||
| 849 | + cleanup(); | ||
| 850 | + reject(error); | ||
| 851 | + }; | ||
| 852 | + zip.once("entry", onEntry); | ||
| 853 | + zip.once("end", onEnd); | ||
| 854 | + zip.once("error", onError); | ||
| 855 | + zip.readEntry(); | ||
| 856 | + return promise; | ||
| 857 | +} | ||
| 858 | + | ||
| 859 | +function openZipEntry(zip: ZipFileLike, entry: ZipEntryLike): Promise<Readable> { | ||
| 860 | + const { promise, resolve: resolveStream, reject } = Promise.withResolvers<Readable>(); | ||
| 861 | + zip.openReadStream(entry, (error, stream) => { | ||
| 862 | + if (error || stream === undefined) reject(error ?? new Error("ZIP entry did not open")); | ||
| 863 | + else resolveStream(stream); | ||
| 864 | + }); | ||
| 865 | + return promise; | ||
| 866 | +} | ||
| 867 | + | ||
| 868 | +function gtfsKey(name: string): keyof CatalogRecordSourceSet | undefined { | ||
| 869 | + switch (name) { | ||
| 870 | + case "routes.txt": return "routes"; | ||
| 871 | + case "trips.txt": return "trips"; | ||
| 872 | + case "stop_times.txt": return "stopTimes"; | ||
| 873 | + case "stops.txt": return "stops"; | ||
| 874 | + case "agency.txt": return "agency"; | ||
| 875 | + case "object_codes_extension.txt": | ||
| 876 | + case "objects_codes_extension.txt": return "objectCodes"; | ||
| 877 | + default: return undefined; | ||
| 878 | + } | ||
| 879 | +} | ||
| 880 | + | ||
| 881 | +async function loadGtfsZip(database: DatabaseSync, path: string, specs: Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>>): Promise<void> { | ||
| 882 | + assertSourceFile(path); | ||
| 883 | + let zip: ZipFileLike | undefined; | ||
| 884 | + const seen = new Set<string>(); | ||
| 885 | + let selectedBytes = 0; | ||
| 886 | + try { | ||
| 887 | + zip = await openZip(path); | ||
| 888 | + while (true) { | ||
| 889 | + const entry = await nextZipEntry(zip); | ||
| 890 | + if (entry === undefined) break; | ||
| 891 | + const normalizedName = entry.fileName.replaceAll("\\", "/").split("/").at(-1)!.toLowerCase(); | ||
| 892 | + const key = gtfsKey(normalizedName); | ||
| 893 | + if (key === undefined) continue; | ||
| 894 | + if ((entry.generalPurposeBitFlag & 1) !== 0) fail("ENCRYPTED_ZIP_ENTRY", `selected GTFS entry ${normalizedName} is encrypted`); | ||
| 895 | + const conceptualName = Object.hasOwn(CODE_ENTRY_NAME, normalizedName) ? "codes-extension" : normalizedName; | ||
| 896 | + if (seen.has(conceptualName)) fail("DUPLICATE_ZIP_ENTRY", `GTFS contains duplicate required entry ${normalizedName}`); | ||
| 897 | + seen.add(conceptualName); | ||
| 898 | + if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize < 0 || entry.uncompressedSize > MAX_SELECTED_ENTRY_BYTES) { | ||
| 899 | + fail("ZIP_ENTRY_TOO_LARGE", `selected GTFS entry ${normalizedName} exceeds its byte limit`); | ||
| 900 | + } | ||
| 901 | + const stream = await openZipEntry(zip, entry); | ||
| 902 | + const limiter = new ByteLimit(MAX_SELECTED_ENTRY_BYTES, (bytes) => { | ||
| 903 | + selectedBytes += bytes; | ||
| 904 | + if (selectedBytes > MAX_SELECTED_ARCHIVE_BYTES) { | ||
| 905 | + fail("ZIP_AGGREGATE_TOO_LARGE", "selected GTFS entries exceed the 16 GiB aggregate limit"); | ||
| 906 | + } | ||
| 907 | + }); | ||
| 908 | + stream.once("error", (error) => limiter.destroy(error)); | ||
| 909 | + await loadCsvStream(database, stream.pipe(limiter), ",", specs[key]); | ||
| 910 | + } | ||
| 911 | + for (const name of REQUIRED_GTFS_ENTRIES) { | ||
| 912 | + if (!seen.has(name)) fail("MISSING_ZIP_ENTRY", `GTFS is missing required entry ${name}`); | ||
| 913 | + } | ||
| 914 | + if (!seen.has("codes-extension")) fail("MISSING_ZIP_ENTRY", "GTFS is missing its codes-extension entry"); | ||
| 915 | + } catch (error) { | ||
| 916 | + if (error instanceof CatalogImportError) throw error; | ||
| 917 | + fail("GTFS_ZIP_FAILED", "GTFS ZIP could not be imported", error); | ||
| 918 | + } finally { | ||
| 919 | + zip?.close(); | ||
| 920 | + } | ||
| 921 | +} | ||
| 922 | + | ||
| 923 | +async function loadFileSources(database: DatabaseSync, sources: CatalogFileSourceSet): Promise<void> { | ||
| 924 | + const specs = prepareSpecs(database); | ||
| 925 | + await loadGtfsZip(database, sources.gtfsZipPath, specs); | ||
| 926 | + const csvSources: readonly [keyof CatalogRecordSourceSet, CsvFileSource][] = [ | ||
| 927 | + ["perimeter", sources.perimeter], | ||
| 928 | + ["arrets", sources.arrets], | ||
| 929 | + ["zones", sources.zones], | ||
| 930 | + ["relations", sources.relations], | ||
| 931 | + ["lines", sources.lines], | ||
| 932 | + ["arretsLignes", sources.arretsLignes], | ||
| 933 | + ]; | ||
| 934 | + for (const [key, source] of csvSources) { | ||
| 935 | + assertSourceFile(source.path); | ||
| 936 | + const rows = await loadCsvStream(database, createReadStream(source.path), source.delimiter ?? ";", specs[key]); | ||
| 937 | + if (source.expectedRows !== undefined && rows !== source.expectedRows) { | ||
| 938 | + fail("SOURCE_ROW_COUNT", `${key} row count ${rows} does not match metadata count ${source.expectedRows}`); | ||
| 939 | + } | ||
| 940 | + } | ||
| 941 | +} | ||
| 942 | + | ||
| 943 | +function createIndexes(database: DatabaseSync): void { | ||
| 944 | + database.exec(` | ||
| 945 | + CREATE INDEX stage_trips_route ON stage_trips(route_id); | ||
| 946 | + CREATE INDEX stage_stop_times_trip ON stage_stop_times(trip_id, stop_sequence); | ||
| 947 | + CREATE INDEX stage_stop_times_stop ON stage_stop_times(stop_id); | ||
| 948 | + CREATE INDEX stage_codes_object ON stage_codes(object_id, object_code); | ||
| 949 | + CREATE INDEX stage_codes_code ON stage_codes(object_code, object_id); | ||
| 950 | + CREATE INDEX stage_perimeter_line ON stage_perimeter(id_line, monitoring_ref); | ||
| 951 | + CREATE INDEX stage_arrets_zda ON stage_arrets(zdaid); | ||
| 952 | + CREATE INDEX stage_relations_art ON stage_relations(artid, zdaid); | ||
| 953 | + CREATE INDEX stage_arrets_lignes_stop ON stage_arrets_lignes(stop_id, route_id); | ||
| 954 | + `); | ||
| 955 | +} | ||
| 956 | + | ||
| 957 | +function deriveCandidates(database: DatabaseSync): void { | ||
| 958 | + database.exec(` | ||
| 959 | + CREATE TABLE stage_line_modes ( | ||
| 960 | + id_line TEXT PRIMARY KEY, | ||
| 961 | + mode TEXT NOT NULL, | ||
| 962 | + line_label TEXT NOT NULL | ||
| 963 | + ) STRICT; | ||
| 964 | + | ||
| 965 | + INSERT INTO stage_line_modes | ||
| 966 | + SELECT id_line, | ||
| 967 | + CASE | ||
| 968 | + WHEN lower(trim(transport_mode)) = 'bus' THEN 'BUS' | ||
| 969 | + WHEN lower(trim(transport_mode)) = 'metro' THEN 'METRO' | ||
| 970 | + WHEN lower(trim(transport_mode)) = 'tram' THEN 'TRAM' | ||
| 971 | + WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER' | ||
| 972 | + WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN' | ||
| 973 | + END, | ||
| 974 | + coalesce(nullif(trim(shortname_line), ''), trim(name_line)) | ||
| 975 | + FROM stage_lines | ||
| 976 | + WHERE lower(trim(status)) = 'active' | ||
| 977 | + AND ( | ||
| 978 | + lower(trim(transport_mode)) IN ('bus', 'metro', 'tram') | ||
| 979 | + OR (lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) IN ('local', 'suburbanrailway')) | ||
| 980 | + ); | ||
| 981 | + | ||
| 982 | + CREATE TABLE stage_stop_zda ( | ||
| 983 | + stop_id TEXT PRIMARY KEY, | ||
| 984 | + zdaid TEXT, | ||
| 985 | + rail_zdaid TEXT NOT NULL, | ||
| 986 | + resolution TEXT NOT NULL | ||
| 987 | + ) STRICT; | ||
| 988 | + | ||
| 989 | + INSERT INTO stage_stop_zda (stop_id, zdaid, rail_zdaid, resolution) | ||
| 990 | + SELECT | ||
| 991 | + stop.stop_id, | ||
| 992 | + coalesce(area.zdaid, relation.zdaid), | ||
| 993 | + CASE | ||
| 994 | + WHEN coalesce(nullif(stop.parent_station, ''), stop.stop_id) LIKE 'IDFM:monomodalStopPlace:%' | ||
| 995 | + THEN substr(coalesce(nullif(stop.parent_station, ''), stop.stop_id), length('IDFM:monomodalStopPlace:') + 1) | ||
| 996 | + WHEN coalesce(nullif(stop.parent_station, ''), stop.stop_id) LIKE 'IDFM:%' | ||
| 997 | + THEN substr(coalesce(nullif(stop.parent_station, ''), stop.stop_id), 6) | ||
| 998 | + ELSE coalesce(nullif(stop.parent_station, ''), stop.stop_id) | ||
| 999 | + END, | ||
| 1000 | + CASE WHEN area.arrid IS NULL THEN 'relations' ELSE 'arrets' END | ||
| 1001 | + FROM stage_stops AS stop | ||
| 1002 | + LEFT JOIN stage_arrets AS area | ||
| 1003 | + ON area.arrid = CASE | ||
| 1004 | + WHEN coalesce(nullif(stop.parent_station, ''), stop.stop_id) LIKE 'IDFM:%' | ||
| 1005 | + THEN substr(coalesce(nullif(stop.parent_station, ''), stop.stop_id), 6) | ||
| 1006 | + ELSE coalesce(nullif(stop.parent_station, ''), stop.stop_id) | ||
| 1007 | + END | ||
| 1008 | + LEFT JOIN ( | ||
| 1009 | + SELECT artid, min(zdaid) AS zdaid | ||
| 1010 | + FROM stage_relations | ||
| 1011 | + GROUP BY artid | ||
| 1012 | + ) AS relation | ||
| 1013 | + ON relation.artid = CASE | ||
| 1014 | + WHEN stop.stop_id LIKE 'IDFM:%' THEN substr(stop.stop_id, 6) | ||
| 1015 | + ELSE stop.stop_id | ||
| 1016 | + END; | ||
| 1017 | + | ||
| 1018 | + CREATE TABLE stage_terminals AS | ||
| 1019 | + SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name | ||
| 1020 | + FROM stage_stop_times AS stop_time | ||
| 1021 | + JOIN stage_stops AS stop ON stop.stop_id = stop_time.stop_id | ||
| 1022 | + JOIN ( | ||
| 1023 | + SELECT trip_id, max(stop_sequence) AS stop_sequence | ||
| 1024 | + FROM stage_stop_times | ||
| 1025 | + GROUP BY trip_id | ||
| 1026 | + ) AS terminal | ||
| 1027 | + ON terminal.trip_id = stop_time.trip_id | ||
| 1028 | + AND terminal.stop_sequence = stop_time.stop_sequence; | ||
| 1029 | + CREATE UNIQUE INDEX stage_terminals_trip ON stage_terminals(trip_id); | ||
| 1030 | + | ||
| 1031 | + CREATE TABLE stage_candidates AS | ||
| 1032 | + WITH joined AS ( | ||
| 1033 | + SELECT DISTINCT | ||
| 1034 | + line_mode.mode AS mode, | ||
| 1035 | + CASE | ||
| 1036 | + WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN stop_zda.rail_zdaid | ||
| 1037 | + ELSE stop_zda.zdaid | ||
| 1038 | + END AS zdaid, | ||
| 1039 | + zone.zdaname AS stop_label, | ||
| 1040 | + zone.zdatown AS locality_label, | ||
| 1041 | + line_mode.id_line AS id_line, | ||
| 1042 | + line_mode.line_label AS line_label, | ||
| 1043 | + CASE | ||
| 1044 | + WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name | ||
| 1045 | + ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name) | ||
| 1046 | + END AS destination_label, | ||
| 1047 | + perimeter.monitoring_ref AS monitoring_ref, | ||
| 1048 | + perimeter.line AS line_ref, | ||
| 1049 | + trip.direction_id AS direction_id, | ||
| 1050 | + destination_code.object_code AS destination_ref, | ||
| 1051 | + stop_zda.resolution AS resolution | ||
| 1052 | + FROM stage_stop_times AS current_stop_time | ||
| 1053 | + JOIN stage_trips AS trip ON trip.trip_id = current_stop_time.trip_id | ||
| 1054 | + JOIN stage_routes AS route ON route.route_id = CASE WHEN trip.route_id LIKE 'IDFM:%' THEN substr(trip.route_id, 6) ELSE trip.route_id END | ||
| 1055 | + JOIN stage_line_modes AS line_mode ON line_mode.id_line = route.route_id | ||
| 1056 | + JOIN stage_stops AS current_stop ON current_stop.stop_id = current_stop_time.stop_id | ||
| 1057 | + JOIN stage_stop_zda AS stop_zda ON stop_zda.stop_id = current_stop.stop_id | ||
| 1058 | + JOIN stage_terminals AS terminal | ||
| 1059 | + ON terminal.trip_id = current_stop_time.trip_id | ||
| 1060 | + JOIN stage_codes AS destination_code | ||
| 1061 | + ON destination_code.object_id = terminal.stop_id | ||
| 1062 | + AND lower(trim(destination_code.object_system)) = 'source' | ||
| 1063 | + JOIN stage_arrets_lignes AS attached | ||
| 1064 | + ON attached.route_id = route.route_id | ||
| 1065 | + AND attached.stop_id IN (current_stop.stop_id, current_stop.parent_station) | ||
| 1066 | + JOIN stage_perimeter AS perimeter | ||
| 1067 | + ON perimeter.id_line = line_mode.id_line | ||
| 1068 | + AND ( | ||
| 1069 | + EXISTS ( | ||
| 1070 | + SELECT 1 | ||
| 1071 | + FROM stage_codes AS monitoring_code | ||
| 1072 | + WHERE monitoring_code.object_id IN (current_stop.stop_id, current_stop.parent_station) | ||
| 1073 | + AND monitoring_code.object_code = perimeter.monitoring_ref | ||
| 1074 | + ) | ||
| 1075 | + OR instr(perimeter.monitoring_ref, ':' || stop_zda.rail_zdaid || ':') > 0 | ||
| 1076 | + ) | ||
| 1077 | + JOIN stage_zones AS zone | ||
| 1078 | + ON zone.zdaid = CASE | ||
| 1079 | + WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN stop_zda.rail_zdaid | ||
| 1080 | + ELSE stop_zda.zdaid | ||
| 1081 | + END | ||
| 1082 | + WHERE current_stop_time.pickup_type <> 1 | ||
| 1083 | + AND EXISTS ( | ||
| 1084 | + SELECT 1 FROM stage_stop_times AS later | ||
| 1085 | + WHERE later.trip_id = current_stop_time.trip_id | ||
| 1086 | + AND later.stop_sequence > current_stop_time.stop_sequence | ||
| 1087 | + ) | ||
| 1088 | + AND trim(trip.direction_id) <> '' | ||
| 1089 | + AND trim(destination_code.object_code) <> '' | ||
| 1090 | + ) | ||
| 1091 | + SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, | ||
| 1092 | + id_line, min(line_label) AS line_label, min(destination_label) AS destination_label, | ||
| 1093 | + monitoring_ref, line_ref, direction_id, destination_ref, | ||
| 1094 | + max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets | ||
| 1095 | + FROM joined | ||
| 1096 | + WHERE zdaid IS NOT NULL AND trim(zdaid) <> '' | ||
| 1097 | + GROUP BY mode, zdaid, monitoring_ref, id_line, line_ref, direction_id, destination_ref; | ||
| 1098 | + `); | ||
| 1099 | +} | ||
| 1100 | + | ||
| 1101 | +function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, createdAt: string): PlaceResolutionCounts { | ||
| 1102 | + const metadata = database.prepare("INSERT INTO metadata VALUES (?, ?)"); | ||
| 1103 | + metadata.run("schema_version", String(SCHEMA_VERSION)); | ||
| 1104 | + metadata.run("catalog_version", String(CATALOG_VERSION)); | ||
| 1105 | + metadata.run("source_revision", sourceRevision); | ||
| 1106 | + metadata.run("created_at", createdAt); | ||
| 1107 | + | ||
| 1108 | + const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | ||
| 1109 | + const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | ||
| 1110 | + const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | ||
| 1111 | + const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?"); | ||
| 1112 | + const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?"); | ||
| 1113 | + | ||
| 1114 | + const places = database.prepare(` | ||
| 1115 | + SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, | ||
| 1116 | + max(via_arrets) AS via_arrets | ||
| 1117 | + FROM stage_candidates | ||
| 1118 | + GROUP BY mode, zdaid | ||
| 1119 | + ORDER BY mode, zdaid | ||
| 1120 | + `).all() as QueryRow[]; | ||
| 1121 | + | ||
| 1122 | + const placeResolution: PlaceResolutionCounts = { arrets: 0, relations: 0, direct: 0 }; | ||
| 1123 | + | ||
| 1124 | + database.exec("BEGIN IMMEDIATE"); | ||
| 1125 | + try { | ||
| 1126 | + for (const row of places) { | ||
| 1127 | + const mode = transportMode(row.mode); | ||
| 1128 | + const zdaId = nonEmpty(row.zdaid, "candidate.zdaid"); | ||
| 1129 | + const stopLabel = nonEmpty(row.stop_label, "candidate.stop_label"); | ||
| 1130 | + const localityLabel = optionalText(row.locality_label); | ||
| 1131 | + const identity = createPlaceIdentity(mode, zdaId); | ||
| 1132 | + const collision = selectPlace.get(identity.placeId) as QueryRow | undefined; | ||
| 1133 | + if (collision !== undefined && collision.canonical_tuple !== identity.canonicalTuple) { | ||
| 1134 | + fail("HASH_COLLISION", "place ID collision detected"); | ||
| 1135 | + } | ||
| 1136 | + if (collision === undefined) { | ||
| 1137 | + const searchText = normalizeCatalogSearchText(`${stopLabel} ${localityLabel}`); | ||
| 1138 | + if (searchText.length === 0) fail("CATALOG_SEARCH", "place label produces empty search text"); | ||
| 1139 | + insertPlace.run(identity.placeId, stopLabel, localityLabel || null, mode, identity.canonicalTuple); | ||
| 1140 | + insertSearch.run(searchText, identity.placeId); | ||
| 1141 | + } | ||
| 1142 | + if (mode === "RER" || mode === "TRANSILIEN") placeResolution.direct += 1; | ||
| 1143 | + else if (Number(row.via_arrets) === 1) placeResolution.arrets += 1; | ||
| 1144 | + else placeResolution.relations += 1; | ||
| 1145 | + } | ||
| 1146 | + | ||
| 1147 | + const candidates = database.prepare(` | ||
| 1148 | + SELECT mode, zdaid, stop_label, line_label, destination_label, | ||
| 1149 | + id_line, monitoring_ref, line_ref, direction_id, destination_ref | ||
| 1150 | + FROM stage_candidates | ||
| 1151 | + ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref | ||
| 1152 | + `).iterate() as Iterable<QueryRow>; | ||
| 1153 | + for (const row of candidates) { | ||
| 1154 | + const mode = transportMode(row.mode); | ||
| 1155 | + const place = createPlaceIdentity(mode, nonEmpty(row.zdaid, "candidate.zdaid")); | ||
| 1156 | + const service = createServiceIdentity({ | ||
| 1157 | + mode, | ||
| 1158 | + lineId: nonEmpty(row.id_line, "candidate.id_line"), | ||
| 1159 | + monitoringRef: nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), | ||
| 1160 | + directionId: nonEmpty(row.direction_id, "candidate.direction_id"), | ||
| 1161 | + destinationRef: nonEmpty(row.destination_ref, "candidate.destination_ref"), | ||
| 1162 | + }); | ||
| 1163 | + const collision = selectService.get(service.serviceId) as QueryRow | undefined; | ||
| 1164 | + if (collision !== undefined && collision.canonical_tuple !== service.canonicalTuple) { | ||
| 1165 | + fail("HASH_COLLISION", "service ID collision detected"); | ||
| 1166 | + } | ||
| 1167 | + if (collision !== undefined && collision.place_id !== place.placeId) { | ||
| 1168 | + fail("INVALID_SERVICE_PLACE", "one semantic service resolved to multiple places"); | ||
| 1169 | + } | ||
| 1170 | + if (collision === undefined) { | ||
| 1171 | + insertService.run( | ||
| 1172 | + service.serviceId, | ||
| 1173 | + place.placeId, | ||
| 1174 | + nonEmpty(row.stop_label, "candidate.stop_label"), | ||
| 1175 | + nonEmpty(row.line_label, "candidate.line_label"), | ||
| 1176 | + nonEmpty(row.destination_label, "candidate.destination_label"), | ||
| 1177 | + nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), | ||
| 1178 | + nonEmpty(row.line_ref, "candidate.line_ref"), | ||
| 1179 | + nonEmpty(row.direction_id, "candidate.direction_id"), | ||
| 1180 | + nonEmpty(row.destination_ref, "candidate.destination_ref"), | ||
| 1181 | + service.canonicalTuple, | ||
| 1182 | + ); | ||
| 1183 | + } | ||
| 1184 | + } | ||
| 1185 | + database.exec("COMMIT"); | ||
| 1186 | + } catch (error) { | ||
| 1187 | + database.exec("ROLLBACK"); | ||
| 1188 | + throw error; | ||
| 1189 | + } | ||
| 1190 | + return placeResolution; | ||
| 1191 | +} | ||
| 1192 | + | ||
| 1193 | +function validateByteBounds(database: DatabaseSync): void { | ||
| 1194 | + const invalidPlace = database.prepare(` | ||
| 1195 | + SELECT place_id FROM places | ||
| 1196 | + WHERE length(CAST(place_id AS BLOB)) > ? | ||
| 1197 | + OR length(CAST(stop_label AS BLOB)) > ? | ||
| 1198 | + OR (locality_label IS NOT NULL AND length(CAST(locality_label AS BLOB)) > ?) | ||
| 1199 | + LIMIT 1 | ||
| 1200 | + `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); | ||
| 1201 | + if (invalidPlace !== undefined) fail("CATALOG_LIMIT", "place exceeds a shared UTF-8 byte limit"); | ||
| 1202 | + | ||
| 1203 | + const invalidService = database.prepare(` | ||
| 1204 | + SELECT service_id FROM services | ||
| 1205 | + WHERE length(CAST(service_id AS BLOB)) > ? | ||
| 1206 | + OR length(CAST(stop_label AS BLOB)) > ? | ||
| 1207 | + OR length(CAST(line_label AS BLOB)) > ? | ||
| 1208 | + OR length(CAST(destination_label AS BLOB)) > ? | ||
| 1209 | + LIMIT 1 | ||
| 1210 | + `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); | ||
| 1211 | + if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit"); | ||
| 1212 | +} | ||
| 1213 | + | ||
| 1214 | +function exactColumns(database: DatabaseSync, table: string, expected: readonly string[]): void { | ||
| 1215 | + const rows = database.prepare(`PRAGMA table_info(${table})`).all() as QueryRow[]; | ||
| 1216 | + const actual = rows.map((row) => String(row.name)); | ||
| 1217 | + if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) { | ||
| 1218 | + fail("CATALOG_SCHEMA", `${table} has an unexpected column contract`); | ||
| 1219 | + } | ||
| 1220 | +} | ||
| 1221 | + | ||
| 1222 | +function metadataValue(database: DatabaseSync, key: string): string | undefined { | ||
| 1223 | + const row = database.prepare("SELECT value FROM metadata WHERE key = ?").get(key) as QueryRow | undefined; | ||
| 1224 | + return row === undefined ? undefined : String(row.value); | ||
| 1225 | +} | ||
| 1226 | + | ||
| 1227 | +function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | ||
| 1228 | + database.exec("PRAGMA foreign_keys = ON"); | ||
| 1229 | + exactColumns(database, "metadata", ["key", "value"]); | ||
| 1230 | + exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]); | ||
| 1231 | + exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]); | ||
| 1232 | + exactColumns(database, "place_search", ["search_text", "place_id"]); | ||
| 1233 | + | ||
| 1234 | + const sourceRevision = metadataValue(database, "source_revision"); | ||
| 1235 | + const createdAt = metadataValue(database, "created_at"); | ||
| 1236 | + if (metadataValue(database, "schema_version") !== String(SCHEMA_VERSION) | ||
| 1237 | + || metadataValue(database, "catalog_version") !== String(CATALOG_VERSION) | ||
| 1238 | + || sourceRevision === undefined || sourceRevision.trim() === "" | ||
| 1239 | + || createdAt === undefined) { | ||
| 1240 | + fail("CATALOG_METADATA", "catalog metadata is incomplete or incompatible"); | ||
| 1241 | + } | ||
| 1242 | + isoTimestamp(createdAt, "metadata.created_at"); | ||
| 1243 | + | ||
| 1244 | + const integrity = database.prepare("PRAGMA integrity_check").all() as QueryRow[]; | ||
| 1245 | + if (integrity.length !== 1 || String(integrity[0]?.integrity_check) !== "ok") fail("CATALOG_INTEGRITY", "SQLite integrity_check failed"); | ||
| 1246 | + if ((database.prepare("PRAGMA foreign_key_check").all() as QueryRow[]).length !== 0) fail("CATALOG_FOREIGN_KEY", "SQLite foreign_key_check failed"); | ||
| 1247 | + validateByteBounds(database); | ||
| 1248 | + | ||
| 1249 | + const missingSearch = database.prepare(` | ||
| 1250 | + SELECT 1 FROM places AS place | ||
| 1251 | + LEFT JOIN place_search AS search ON search.place_id = place.place_id | ||
| 1252 | + WHERE search.place_id IS NULL | ||
| 1253 | + LIMIT 1 | ||
| 1254 | + `).get(); | ||
| 1255 | + const extraSearch = database.prepare(` | ||
| 1256 | + SELECT 1 FROM place_search AS search | ||
| 1257 | + LEFT JOIN places AS place ON place.place_id = search.place_id | ||
| 1258 | + WHERE place.place_id IS NULL | ||
| 1259 | + LIMIT 1 | ||
| 1260 | + `).get(); | ||
| 1261 | + if (missingSearch !== undefined || extraSearch !== undefined) fail("CATALOG_SEARCH", "FTS place index does not match places"); | ||
| 1262 | + const invalidPlaceService = database.prepare(` | ||
| 1263 | + SELECT 1 | ||
| 1264 | + FROM places AS place | ||
| 1265 | + LEFT JOIN services AS service ON service.place_id = place.place_id | ||
| 1266 | + WHERE service.service_id IS NULL OR service.stop_label <> place.stop_label | ||
| 1267 | + LIMIT 1 | ||
| 1268 | + `).get(); | ||
| 1269 | + if (invalidPlaceService !== undefined) { | ||
| 1270 | + fail("CATALOG_PLACE_SERVICE", "catalog place is empty or has inconsistent service labels"); | ||
| 1271 | + } | ||
| 1272 | + if (database.prepare("SELECT 1 FROM place_search WHERE trim(search_text) = '' LIMIT 1").get() !== undefined) { | ||
| 1273 | + fail("CATALOG_SEARCH", "FTS place index contains empty search text"); | ||
| 1274 | + } | ||
| 1275 | + | ||
| 1276 | + const unresolved = database.prepare(` | ||
| 1277 | + SELECT 1 FROM services | ||
| 1278 | + WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = '' | ||
| 1279 | + LIMIT 1 | ||
| 1280 | + `).get(); | ||
| 1281 | + if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable offered service"); | ||
| 1282 | + | ||
| 1283 | + for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) { | ||
| 1284 | + if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple"); | ||
| 1285 | + } | ||
| 1286 | + for (const row of database.prepare("SELECT service_id, canonical_tuple FROM services").iterate() as Iterable<QueryRow>) { | ||
| 1287 | + if (opaqueId("svc_", String(row.canonical_tuple)) !== row.service_id) fail("HASH_COLLISION", "service ID does not match its canonical tuple"); | ||
| 1288 | + } | ||
| 1289 | + | ||
| 1290 | + const counts = Object.fromEntries(TRANSPORT_MODE.map((mode) => [mode, 0])) as Record<TransportMode, number>; | ||
| 1291 | + for (const row of database.prepare(` | ||
| 1292 | + SELECT place.mode AS mode, count(service.service_id) AS service_count | ||
| 1293 | + FROM places AS place | ||
| 1294 | + JOIN services AS service ON service.place_id = place.place_id | ||
| 1295 | + GROUP BY place.mode | ||
| 1296 | + `).all() as QueryRow[]) { | ||
| 1297 | + const mode = transportMode(row.mode); | ||
| 1298 | + counts[mode] = Number(row.service_count); | ||
| 1299 | + } | ||
| 1300 | + for (const mode of TRANSPORT_MODE) { | ||
| 1301 | + if (counts[mode] < 1) fail("MODE_COVERAGE", `catalog has no resolvable ${mode} service`); | ||
| 1302 | + } | ||
| 1303 | + | ||
| 1304 | + const placeCount = Number((database.prepare("SELECT count(*) AS count FROM places").get() as QueryRow).count); | ||
| 1305 | + const serviceCount = Number((database.prepare("SELECT count(*) AS count FROM services").get() as QueryRow).count); | ||
| 1306 | + return { candidatePath: "", sourceRevision, createdAt, placeCount, serviceCount, countsByMode: counts }; | ||
| 1307 | +} | ||
| 1308 | + | ||
| 1309 | +function validateStagedPerimeter(database: DatabaseSync): void { | ||
| 1310 | + const missing = database.prepare(` | ||
| 1311 | + SELECT 1 FROM stage_candidates AS candidate | ||
| 1312 | + WHERE NOT EXISTS ( | ||
| 1313 | + SELECT 1 FROM stage_perimeter AS perimeter | ||
| 1314 | + WHERE perimeter.line = candidate.line_ref | ||
| 1315 | + AND perimeter.monitoring_ref = candidate.monitoring_ref | ||
| 1316 | + ) | ||
| 1317 | + LIMIT 1 | ||
| 1318 | + `).get(); | ||
| 1319 | + if (missing !== undefined) fail("PERIMETER_MISMATCH", "offered service lacks its exact perimeter pair"); | ||
| 1320 | +} | ||
| 1321 | + | ||
| 1322 | +function validateCandidateSemanticTuples(database: DatabaseSync): void { | ||
| 1323 | + const ambiguous = database.prepare(` | ||
| 1324 | + SELECT 1 | ||
| 1325 | + FROM stage_candidates | ||
| 1326 | + GROUP BY mode, id_line, monitoring_ref, direction_id, destination_ref | ||
| 1327 | + HAVING count(*) <> 1 OR count(DISTINCT zdaid) <> 1 | ||
| 1328 | + LIMIT 1 | ||
| 1329 | + `).get(); | ||
| 1330 | + if (ambiguous !== undefined) { | ||
| 1331 | + fail("AMBIGUOUS_SERVICE", "a semantic service tuple resolves to multiple candidate rows or places"); | ||
| 1332 | + } | ||
| 1333 | +} | ||
| 1334 | + | ||
| 1335 | +function dropStaging(database: DatabaseSync): void { | ||
| 1336 | + database.exec(` | ||
| 1337 | + DROP TABLE stage_candidates; | ||
| 1338 | + DROP TABLE stage_terminals; | ||
| 1339 | + DROP TABLE stage_stop_zda; | ||
| 1340 | + DROP TABLE stage_line_modes; | ||
| 1341 | + DROP TABLE stage_arrets_lignes; | ||
| 1342 | + DROP TABLE stage_lines; | ||
| 1343 | + DROP TABLE stage_relations; | ||
| 1344 | + DROP TABLE stage_zones; | ||
| 1345 | + DROP TABLE stage_arrets; | ||
| 1346 | + DROP TABLE stage_perimeter; | ||
| 1347 | + DROP TABLE stage_codes; | ||
| 1348 | + DROP TABLE stage_agency; | ||
| 1349 | + DROP TABLE stage_stops; | ||
| 1350 | + DROP TABLE stage_stop_times; | ||
| 1351 | + DROP TABLE stage_trips; | ||
| 1352 | + DROP TABLE stage_routes; | ||
| 1353 | + PRAGMA optimize; | ||
| 1354 | + `); | ||
| 1355 | +} | ||
| 1356 | + | ||
| 1357 | +async function build( | ||
| 1358 | + candidatePath: string, | ||
| 1359 | + sourceRevisionValue: string, | ||
| 1360 | + createdAtValue: string | undefined, | ||
| 1361 | + load: (database: DatabaseSync) => void | Promise<void>, | ||
| 1362 | +): Promise<CatalogBuildResult> { | ||
| 1363 | + const candidate = resolve(candidatePath); | ||
| 1364 | + const sourceRevision = nonEmpty(sourceRevisionValue, "sourceRevision"); | ||
| 1365 | + const createdAt = isoTimestamp(createdAtValue ?? new Date().toISOString(), "createdAt"); | ||
| 1366 | + if (existsSync(candidate)) fail("CANDIDATE_EXISTS", "catalog candidate path already exists"); | ||
| 1367 | + mkdirSync(dirname(candidate), { recursive: true }); | ||
| 1368 | + | ||
| 1369 | + let database: DatabaseSync | undefined; | ||
| 1370 | + try { | ||
| 1371 | + database = new DatabaseSync(candidate); | ||
| 1372 | + createSchema(database); | ||
| 1373 | + await load(database); | ||
| 1374 | + createIndexes(database); | ||
| 1375 | + deriveCandidates(database); | ||
| 1376 | + validateStagedPerimeter(database); | ||
| 1377 | + validateCandidateSemanticTuples(database); | ||
| 1378 | + const placeResolution = insertFinalCatalog(database, sourceRevision, createdAt); | ||
| 1379 | + dropStaging(database); | ||
| 1380 | + const result = validateOpenCatalog(database); | ||
| 1381 | + database.close(); | ||
| 1382 | + database = undefined; | ||
| 1383 | + fsyncFile(candidate); | ||
| 1384 | + return { ...result, candidatePath: candidate, placeResolution }; | ||
| 1385 | + } catch (error) { | ||
| 1386 | + try { | ||
| 1387 | + database?.close(); | ||
| 1388 | + } finally { | ||
| 1389 | + removeCandidateFiles(candidate); | ||
| 1390 | + } | ||
| 1391 | + if (error instanceof CatalogImportError) throw error; | ||
| 1392 | + fail("CATALOG_BUILD_FAILED", "catalog candidate build failed", error); | ||
| 1393 | + } | ||
| 1394 | +} | ||
| 1395 | + | ||
| 1396 | +export function buildCatalogCandidate(options: BuildCatalogOptions): Promise<CatalogBuildResult> { | ||
| 1397 | + return build(options.candidatePath, options.sourceRevision, options.createdAt, (database) => loadFileSources(database, options.sources)); | ||
| 1398 | +} | ||
| 1399 | + | ||
| 1400 | +export function buildCatalogCandidateFromRecords(options: BuildCatalogFromRecordsOptions): Promise<CatalogBuildResult> { | ||
| 1401 | + return build(options.candidatePath, options.sourceRevision, options.createdAt, (database) => loadRecordSources(database, options.sources)); | ||
| 1402 | +} | ||
| 1403 | + | ||
| 1404 | +export function validateCatalogCandidate(path: string): CatalogBuildResult { | ||
| 1405 | + const candidate = resolve(path); | ||
| 1406 | + let database: DatabaseSync | undefined; | ||
| 1407 | + try { | ||
| 1408 | + database = new DatabaseSync(candidate, { readOnly: true }); | ||
| 1409 | + const result = validateOpenCatalog(database); | ||
| 1410 | + return { ...result, candidatePath: candidate }; | ||
| 1411 | + } catch (error) { | ||
| 1412 | + if (error instanceof CatalogImportError) throw error; | ||
| 1413 | + fail("CATALOG_VALIDATION_FAILED", "catalog candidate validation failed", error); | ||
| 1414 | + } finally { | ||
| 1415 | + database?.close(); | ||
| 1416 | + } | ||
| 1417 | +} | ||
| 1418 | + | ||
| 1419 | +export function activateCatalogCandidate(candidatePath: string, activePath: string): CatalogBuildResult { | ||
| 1420 | + const candidate = resolve(candidatePath); | ||
| 1421 | + const active = resolve(activePath); | ||
| 1422 | + if (candidate === active) fail("INVALID_ACTIVATION", "candidate and active paths must differ"); | ||
| 1423 | + mkdirSync(dirname(active), { recursive: true }); | ||
| 1424 | + let result: CatalogBuildResult; | ||
| 1425 | + try { | ||
| 1426 | + result = validateCatalogCandidate(candidate); | ||
| 1427 | + if (statSync(candidate).dev !== statSync(dirname(active)).dev) { | ||
| 1428 | + fail("CROSS_DEVICE_ACTIVATION", "catalog candidate and active directory must share a filesystem"); | ||
| 1429 | + } | ||
| 1430 | + fsyncFile(candidate); | ||
| 1431 | + } catch (error) { | ||
| 1432 | + removeCandidateFiles(candidate); | ||
| 1433 | + throw error; | ||
| 1434 | + } | ||
| 1435 | + try { | ||
| 1436 | + renameSync(candidate, active); | ||
| 1437 | + } catch (error) { | ||
| 1438 | + removeCandidateFiles(candidate); | ||
| 1439 | + throw error; | ||
| 1440 | + } | ||
| 1441 | + try { | ||
| 1442 | + fsyncDirectory(dirname(active)); | ||
| 1443 | + } catch { | ||
| 1444 | + // The rename already replaced the active path; never unlink either file. | ||
| 1445 | + // Revalidate what is actually active and report the applied activation. | ||
| 1446 | + const reapplied = validateCatalogCandidate(active); | ||
| 1447 | + return { ...reapplied, candidatePath: active, revalidatedAfterRenameFailure: true }; | ||
| 1448 | + } | ||
| 1449 | + return { ...result, candidatePath: active }; | ||
| 1450 | +} | ||
| 1451 | + | ||
| 1452 | +export async function refreshCatalog(options: BuildCatalogOptions & { readonly activePath: string }): Promise<CatalogBuildResult> { | ||
| 1453 | + await buildCatalogCandidate(options); | ||
| 1454 | + return activateCatalogCandidate(options.candidatePath, options.activePath); | ||
| 1455 | +} | ||
| 1456 | + | ||
| 1457 | +export async function refreshCatalogFromRecords( | ||
| 1458 | + options: BuildCatalogFromRecordsOptions & { readonly activePath: string }, | ||
| 1459 | +): Promise<CatalogBuildResult> { | ||
| 1460 | + await buildCatalogCandidateFromRecords(options); | ||
| 1461 | + return activateCatalogCandidate(options.candidatePath, options.activePath); | ||
| 1462 | +} | ||
added
packages/backend/src/catalog.ts +686 -0 | new file mode 100644 | ||
| @@ -0,0 +1,686 @@ | ||
| 1 | +import { DatabaseSync, type StatementSync } from "node:sqlite"; | |
| 2 | +import { | |
| 3 | + CATALOG_ROUTE, | |
| 4 | + LIMITS, | |
| 5 | + SCHEMA_VERSION, | |
| 6 | + TRANSPORT_MODE, | |
| 7 | + type CatalogErrorResult, | |
| 8 | + type PlaceSearchItem, | |
| 9 | + type PlaceSearchResult, | |
| 10 | + type ServiceOption, | |
| 11 | + type ServiceOptionsResult, | |
| 12 | + type TransportMode, | |
| 13 | +} from "../../contracts/src/index.ts"; | |
| 14 | + | |
| 15 | +import { normalizeCatalogSearchText } from "./catalog-import.ts"; | |
| 16 | + | |
| 17 | +export type CatalogServiceResolution = | |
| 18 | + | { | |
| 19 | + readonly status: "RESOLVED"; | |
| 20 | + readonly monitoringRef: string; | |
| 21 | + readonly lineRef: string; | |
| 22 | + readonly directionId: string; | |
| 23 | + readonly destinationRef: string; | |
| 24 | + readonly destinationLabel: string; | |
| 25 | + } | |
| 26 | + | { readonly status: "UNRESOLVED"; readonly code: "INVALID_SERVICE" }; | |
| 27 | + | |
| 28 | +export interface CatalogReader { | |
| 29 | + searchPlaces(query: string): PlaceSearchItem[]; | |
| 30 | + listServices(placeId: string): ServiceOption[] | undefined; | |
| 31 | + resolveService(serviceId: string): CatalogServiceResolution; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export interface CatalogHandlerResponse { | |
| 35 | + readonly status: 200 | 400 | 404 | 405 | 503; | |
| 36 | + readonly body: PlaceSearchResult | ServiceOptionsResult | CatalogErrorResult; | |
| 37 | +} | |
| 38 | + | |
| 39 | +interface PlaceRow { | |
| 40 | + readonly placeId: string; | |
| 41 | + readonly stopLabel: string; | |
| 42 | + readonly localityLabel: string | null; | |
| 43 | + readonly mode: TransportMode; | |
| 44 | +} | |
| 45 | + | |
| 46 | +interface ServiceRow { | |
| 47 | + readonly serviceId: string; | |
| 48 | + readonly stopLabel: string; | |
| 49 | + readonly lineLabel: string; | |
| 50 | + readonly destinationLabel: string; | |
| 51 | +} | |
| 52 | + | |
| 53 | +interface ResolutionRow { | |
| 54 | + readonly monitoringRef: string; | |
| 55 | + readonly lineRef: string; | |
| 56 | + readonly directionId: string; | |
| 57 | + readonly destinationRef: string; | |
| 58 | + readonly destinationLabel: string; | |
| 59 | +} | |
| 60 | + | |
| 61 | +interface MetadataRow { | |
| 62 | + readonly key: string; | |
| 63 | + readonly value: string; | |
| 64 | +} | |
| 65 | + | |
| 66 | +interface SchemaRow { | |
| 67 | + readonly name: string; | |
| 68 | + readonly sql: string | null; | |
| 69 | +} | |
| 70 | + | |
| 71 | +interface ColumnRow { | |
| 72 | + readonly cid: number; | |
| 73 | + readonly name: string; | |
| 74 | + readonly type: string; | |
| 75 | + readonly notNull: number; | |
| 76 | + readonly primaryKey: number; | |
| 77 | +} | |
| 78 | + | |
| 79 | +interface ExpectedColumn { | |
| 80 | + readonly name: string; | |
| 81 | + readonly type: string; | |
| 82 | + readonly notNull?: boolean; | |
| 83 | + readonly primaryKey?: number; | |
| 84 | +} | |
| 85 | + | |
| 86 | + | |
| 87 | +const CLOSE_READER = Symbol("closeCatalogReader"); | |
| 88 | +const OPAQUE_ID_BYTES = 47; | |
| 89 | +const CATALOG_SCHEMA_VERSION = "1"; | |
| 90 | +const CATALOG_FORMAT_VERSION = "1"; | |
| 91 | +const REQUIRED_METADATA = [ | |
| 92 | + "schema_version", | |
| 93 | + "catalog_version", | |
| 94 | + "source_revision", | |
| 95 | + "created_at", | |
| 96 | +] as const; | |
| 97 | +const EXPECTED_TABLES: Readonly<Record<string, true>> = { | |
| 98 | + metadata: true, | |
| 99 | + places: true, | |
| 100 | + services: true, | |
| 101 | + place_search: true, | |
| 102 | + place_search_data: true, | |
| 103 | + place_search_idx: true, | |
| 104 | + place_search_content: true, | |
| 105 | + place_search_docsize: true, | |
| 106 | + place_search_config: true, | |
| 107 | +}; | |
| 108 | +const TABLE_COLUMNS: Readonly<Record<string, readonly ExpectedColumn[]>> = { | |
| 109 | + metadata: [ | |
| 110 | + { name: "key", type: "TEXT", primaryKey: 1 }, | |
| 111 | + { name: "value", type: "TEXT", notNull: true }, | |
| 112 | + ], | |
| 113 | + places: [ | |
| 114 | + { name: "place_id", type: "TEXT", primaryKey: 1 }, | |
| 115 | + { name: "stop_label", type: "TEXT", notNull: true }, | |
| 116 | + { name: "locality_label", type: "TEXT" }, | |
| 117 | + { name: "mode", type: "TEXT", notNull: true }, | |
| 118 | + { name: "canonical_tuple", type: "TEXT", notNull: true }, | |
| 119 | + ], | |
| 120 | + services: [ | |
| 121 | + { name: "service_id", type: "TEXT", primaryKey: 1 }, | |
| 122 | + { name: "place_id", type: "TEXT", notNull: true }, | |
| 123 | + { name: "stop_label", type: "TEXT", notNull: true }, | |
| 124 | + { name: "line_label", type: "TEXT", notNull: true }, | |
| 125 | + { name: "destination_label", type: "TEXT", notNull: true }, | |
| 126 | + { name: "monitoring_ref", type: "TEXT", notNull: true }, | |
| 127 | + { name: "line_ref", type: "TEXT", notNull: true }, | |
| 128 | + { name: "direction_id", type: "TEXT", notNull: true }, | |
| 129 | + { name: "destination_ref", type: "TEXT", notNull: true }, | |
| 130 | + { name: "canonical_tuple", type: "TEXT", notNull: true }, | |
| 131 | + ], | |
| 132 | + place_search: [ | |
| 133 | + { name: "search_text", type: "" }, | |
| 134 | + { name: "place_id", type: "" }, | |
| 135 | + ], | |
| 136 | +}; | |
| 137 | + | |
| 138 | +class CatalogUnavailableError extends Error { | |
| 139 | + constructor() { | |
| 140 | + super("No complete catalog is active"); | |
| 141 | + this.name = "CatalogUnavailableError"; | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +class InvalidCatalogQueryError extends RangeError { | |
| 146 | + constructor() { | |
| 147 | + super("Catalog query is outside the supported bounds"); | |
| 148 | + this.name = "InvalidCatalogQueryError"; | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 152 | +function invalidCandidate(reason: string): never { | |
| 153 | + throw new Error(`Invalid catalog candidate: ${reason}`); | |
| 154 | +} | |
| 155 | + | |
| 156 | +function rows<T>(statement: StatementSync, ...parameters: (string | number)[]): T[] { | |
| 157 | + return statement.all(...parameters) as unknown as T[]; | |
| 158 | +} | |
| 159 | + | |
| 160 | +function row<T>(statement: StatementSync, ...parameters: (string | number)[]): T | undefined { | |
| 161 | + return statement.get(...parameters) as unknown as T | undefined; | |
| 162 | +} | |
| 163 | + | |
| 164 | +function validateColumns(database: DatabaseSync): void { | |
| 165 | + const statement = database.prepare(` | |
| 166 | + SELECT | |
| 167 | + cid, | |
| 168 | + name, | |
| 169 | + type, | |
| 170 | + "notnull" AS "notNull", | |
| 171 | + pk AS "primaryKey" | |
| 172 | + FROM pragma_table_info(?) | |
| 173 | + ORDER BY cid | |
| 174 | + `); | |
| 175 | + | |
| 176 | + for (const [table, expected] of Object.entries(TABLE_COLUMNS)) { | |
| 177 | + const actual = rows<ColumnRow>(statement, table); | |
| 178 | + if (actual.length !== expected.length) invalidCandidate(`${table} columns`); | |
| 179 | + | |
| 180 | + for (let index = 0; index < expected.length; index += 1) { | |
| 181 | + const actualColumn = actual[index]; | |
| 182 | + const expectedColumn = expected[index]; | |
| 183 | + if ( | |
| 184 | + actualColumn === undefined | |
| 185 | + || expectedColumn === undefined | |
| 186 | + || actualColumn.cid !== index | |
| 187 | + || actualColumn.name !== expectedColumn.name | |
| 188 | + || actualColumn.type.toUpperCase() !== expectedColumn.type | |
| 189 | + || (expectedColumn.notNull === true && actualColumn.notNull !== 1) | |
| 190 | + || (expectedColumn.primaryKey !== undefined | |
| 191 | + && actualColumn.primaryKey !== expectedColumn.primaryKey) | |
| 192 | + ) { | |
| 193 | + invalidCandidate(`${table} columns`); | |
| 194 | + } | |
| 195 | + } | |
| 196 | + } | |
| 197 | +} | |
| 198 | + | |
| 199 | +function validateSchema(database: DatabaseSync): void { | |
| 200 | + const schema = rows<SchemaRow>(database.prepare(` | |
| 201 | + SELECT name, sql | |
| 202 | + FROM sqlite_schema | |
| 203 | + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' | |
| 204 | + ORDER BY name | |
| 205 | + `)); | |
| 206 | + const names = new Set(schema.map((entry) => entry.name)); | |
| 207 | + | |
| 208 | + if (schema.some((entry) => EXPECTED_TABLES[entry.name] !== true)) { | |
| 209 | + invalidCandidate("unexpected table"); | |
| 210 | + } | |
| 211 | + for (const name of Object.keys(EXPECTED_TABLES)) { | |
| 212 | + if (!names.has(name)) invalidCandidate(`missing ${name}`); | |
| 213 | + } | |
| 214 | + | |
| 215 | + const searchSql = schema.find((entry) => entry.name === "place_search")?.sql; | |
| 216 | + if ( | |
| 217 | + typeof searchSql !== "string" | |
| 218 | + || !/\bUSING\s+fts5\s*\(/iu.test(searchSql) | |
| 219 | + || !/\bplace_id\s+UNINDEXED\b/iu.test(searchSql) | |
| 220 | + ) { | |
| 221 | + invalidCandidate("place_search definition"); | |
| 222 | + } | |
| 223 | + | |
| 224 | + validateColumns(database); | |
| 225 | + | |
| 226 | + const uniqueIndex = database.prepare(` | |
| 227 | + SELECT indexes.name | |
| 228 | + FROM pragma_index_list(?) AS indexes | |
| 229 | + JOIN pragma_index_info(indexes.name) AS columns | |
| 230 | + WHERE indexes."unique" = 1 | |
| 231 | + GROUP BY indexes.name | |
| 232 | + HAVING COUNT(*) = 1 AND MAX(columns.name) = ? | |
| 233 | + LIMIT 1 | |
| 234 | + `); | |
| 235 | + if (row(uniqueIndex, "places", "canonical_tuple") === undefined) { | |
| 236 | + invalidCandidate("places canonical uniqueness"); | |
| 237 | + } | |
| 238 | + if (row(uniqueIndex, "services", "canonical_tuple") === undefined) { | |
| 239 | + invalidCandidate("services canonical uniqueness"); | |
| 240 | + } | |
| 241 | + | |
| 242 | + const foreignKeys = rows<{ targetTable: string; sourceColumn: string; targetColumn: string }>( | |
| 243 | + database.prepare(` | |
| 244 | + SELECT | |
| 245 | + "table" AS targetTable, | |
| 246 | + "from" AS sourceColumn, | |
| 247 | + "to" AS targetColumn | |
| 248 | + FROM pragma_foreign_key_list('services') | |
| 249 | + `), | |
| 250 | + ); | |
| 251 | + if ( | |
| 252 | + foreignKeys.length !== 1 | |
| 253 | + || foreignKeys[0]?.targetTable !== "places" | |
| 254 | + || foreignKeys[0].sourceColumn !== "place_id" | |
| 255 | + || foreignKeys[0].targetColumn !== "place_id" | |
| 256 | + ) { | |
| 257 | + invalidCandidate("services foreign key"); | |
| 258 | + } | |
| 259 | +} | |
| 260 | + | |
| 261 | +function validateMetadata(database: DatabaseSync): void { | |
| 262 | + const statement = database.prepare(` | |
| 263 | + SELECT key, value | |
| 264 | + FROM metadata | |
| 265 | + WHERE key IN (?, ?, ?, ?) | |
| 266 | + `); | |
| 267 | + const metadata = new Map( | |
| 268 | + rows<MetadataRow>(statement, ...REQUIRED_METADATA).map((entry) => [entry.key, entry.value]), | |
| 269 | + ); | |
| 270 | + | |
| 271 | + if (metadata.size !== REQUIRED_METADATA.length) invalidCandidate("required metadata"); | |
| 272 | + if (metadata.get("schema_version") !== CATALOG_SCHEMA_VERSION) { | |
| 273 | + invalidCandidate("schema version"); | |
| 274 | + } | |
| 275 | + if (metadata.get("catalog_version") !== CATALOG_FORMAT_VERSION) { | |
| 276 | + invalidCandidate("catalog version"); | |
| 277 | + } | |
| 278 | + if (metadata.get("source_revision")?.trim() === "") { | |
| 279 | + invalidCandidate("source revision"); | |
| 280 | + } | |
| 281 | + if (metadata.get("created_at")?.trim() === "") invalidCandidate("creation time"); | |
| 282 | +} | |
| 283 | + | |
| 284 | +function validateIntegrity(database: DatabaseSync): void { | |
| 285 | + const integrity = row<{ integrityCheck: string }>(database.prepare(` | |
| 286 | + SELECT integrity_check AS integrityCheck | |
| 287 | + FROM pragma_integrity_check | |
| 288 | + `)); | |
| 289 | + if (integrity?.integrityCheck !== "ok") invalidCandidate("integrity check"); | |
| 290 | + if (database.prepare("PRAGMA foreign_key_check").get() !== undefined) { | |
| 291 | + invalidCandidate("foreign key check"); | |
| 292 | + } | |
| 293 | +} | |
| 294 | + | |
| 295 | +function validateData(database: DatabaseSync): void { | |
| 296 | + const invalidPlace = database.prepare(` | |
| 297 | + SELECT 1 | |
| 298 | + FROM places | |
| 299 | + WHERE | |
| 300 | + typeof(place_id) <> 'text' | |
| 301 | + OR length(CAST(place_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | |
| 302 | + OR substr(place_id, 1, 4) <> 'plc_' | |
| 303 | + OR substr(place_id, 5) GLOB '*[^A-Za-z0-9_-]*' | |
| 304 | + OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | |
| 305 | + OR (locality_label IS NOT NULL | |
| 306 | + AND length(CAST(locality_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}) | |
| 307 | + OR mode NOT IN ('BUS', 'METRO', 'TRAM', 'RER', 'TRANSILIEN') | |
| 308 | + OR length(CAST(canonical_tuple AS BLOB)) < 1 | |
| 309 | + LIMIT 1 | |
| 310 | + `).get(); | |
| 311 | + if (invalidPlace !== undefined) invalidCandidate("place data"); | |
| 312 | + | |
| 313 | + const invalidService = database.prepare(` | |
| 314 | + SELECT 1 | |
| 315 | + FROM services | |
| 316 | + WHERE | |
| 317 | + typeof(service_id) <> 'text' | |
| 318 | + OR length(CAST(service_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | |
| 319 | + OR substr(service_id, 1, 4) <> 'svc_' | |
| 320 | + OR substr(service_id, 5) GLOB '*[^A-Za-z0-9_-]*' | |
| 321 | + OR length(CAST(place_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | |
| 322 | + OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | |
| 323 | + OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | |
| 324 | + OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | |
| 325 | + OR length(CAST(monitoring_ref AS BLOB)) < 1 | |
| 326 | + OR length(CAST(line_ref AS BLOB)) < 1 | |
| 327 | + OR length(CAST(direction_id AS BLOB)) < 1 | |
| 328 | + OR length(CAST(destination_ref AS BLOB)) < 1 | |
| 329 | + OR length(CAST(canonical_tuple AS BLOB)) < 1 | |
| 330 | + LIMIT 1 | |
| 331 | + `).get(); | |
| 332 | + if (invalidService !== undefined) invalidCandidate("service data"); | |
| 333 | + | |
| 334 | + if (database.prepare(` | |
| 335 | + SELECT 1 | |
| 336 | + FROM places | |
| 337 | + WHERE NOT EXISTS ( | |
| 338 | + SELECT 1 FROM services WHERE services.place_id = places.place_id | |
| 339 | + ) | |
| 340 | + LIMIT 1 | |
| 341 | + `).get() !== undefined) { | |
| 342 | + invalidCandidate("place without service"); | |
| 343 | + } | |
| 344 | + | |
| 345 | + if (database.prepare(` | |
| 346 | + SELECT 1 | |
| 347 | + FROM services | |
| 348 | + JOIN places ON places.place_id = services.place_id | |
| 349 | + WHERE services.stop_label <> places.stop_label | |
| 350 | + LIMIT 1 | |
| 351 | + `).get() !== undefined) { | |
| 352 | + invalidCandidate("inconsistent stop label"); | |
| 353 | + } | |
| 354 | + | |
| 355 | + | |
| 356 | + if (database.prepare(` | |
| 357 | + SELECT 1 | |
| 358 | + FROM places | |
| 359 | + GROUP BY canonical_tuple | |
| 360 | + HAVING COUNT(*) <> 1 | |
| 361 | + LIMIT 1 | |
| 362 | + `).get() !== undefined || database.prepare(` | |
| 363 | + SELECT 1 | |
| 364 | + FROM services | |
| 365 | + GROUP BY canonical_tuple | |
| 366 | + HAVING COUNT(*) <> 1 | |
| 367 | + LIMIT 1 | |
| 368 | + `).get() !== undefined) { | |
| 369 | + invalidCandidate("canonical collision"); | |
| 370 | + } | |
| 371 | + | |
| 372 | + if (database.prepare(` | |
| 373 | + SELECT 1 | |
| 374 | + FROM place_search | |
| 375 | + WHERE | |
| 376 | + length(CAST(search_text AS BLOB)) < 1 | |
| 377 | + OR length(CAST(place_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | |
| 378 | + LIMIT 1 | |
| 379 | + `).get() !== undefined) { | |
| 380 | + invalidCandidate("search data"); | |
| 381 | + } | |
| 382 | + | |
| 383 | + if (database.prepare(` | |
| 384 | + SELECT 1 | |
| 385 | + FROM place_search | |
| 386 | + GROUP BY place_id | |
| 387 | + HAVING COUNT(*) <> 1 | |
| 388 | + LIMIT 1 | |
| 389 | + `).get() !== undefined || database.prepare(` | |
| 390 | + SELECT 1 | |
| 391 | + FROM places | |
| 392 | + LEFT JOIN place_search ON place_search.place_id = places.place_id | |
| 393 | + WHERE place_search.place_id IS NULL | |
| 394 | + LIMIT 1 | |
| 395 | + `).get() !== undefined || database.prepare(` | |
| 396 | + SELECT 1 | |
| 397 | + FROM place_search | |
| 398 | + LEFT JOIN places ON places.place_id = place_search.place_id | |
| 399 | + WHERE places.place_id IS NULL | |
| 400 | + LIMIT 1 | |
| 401 | + `).get() !== undefined) { | |
| 402 | + invalidCandidate("search consistency"); | |
| 403 | + } | |
| 404 | + | |
| 405 | + const coverage = new Set(rows<{ mode: TransportMode }>(database.prepare(` | |
| 406 | + SELECT places.mode AS mode | |
| 407 | + FROM places | |
| 408 | + JOIN services ON services.place_id = places.place_id | |
| 409 | + GROUP BY places.mode | |
| 410 | + `)).map((entry) => entry.mode)); | |
| 411 | + if (TRANSPORT_MODE.some((mode) => !coverage.has(mode))) { | |
| 412 | + invalidCandidate("transport mode coverage"); | |
| 413 | + } | |
| 414 | +} | |
| 415 | + | |
| 416 | +function validateCandidate(database: DatabaseSync): void { | |
| 417 | + validateIntegrity(database); | |
| 418 | + validateSchema(database); | |
| 419 | + validateMetadata(database); | |
| 420 | + validateData(database); | |
| 421 | +} | |
| 422 | + | |
| 423 | +function normalizedMatchQuery(query: string): string { | |
| 424 | + const trimmed = query.trim(); | |
| 425 | + const codePoints = [...trimmed].length; | |
| 426 | + if ( | |
| 427 | + codePoints < LIMITS.catalogQueryMinCharacters | |
| 428 | + || codePoints > LIMITS.catalogQueryMaxCharacters | |
| 429 | + ) { | |
| 430 | + throw new InvalidCatalogQueryError(); | |
| 431 | + } | |
| 432 | + | |
| 433 | + const normalized = normalizeCatalogSearchText(trimmed); | |
| 434 | + if (normalized === "") throw new InvalidCatalogQueryError(); | |
| 435 | + | |
| 436 | + return normalized | |
| 437 | + .split(" ") | |
| 438 | + .map((token) => `"${token.replaceAll('"', '""')}"*`) | |
| 439 | + .join(" AND "); | |
| 440 | +} | |
| 441 | + | |
| 442 | +function placeItem(entry: PlaceRow): PlaceSearchItem { | |
| 443 | + const item: PlaceSearchItem = { | |
| 444 | + placeId: entry.placeId, | |
| 445 | + stopLabel: entry.stopLabel, | |
| 446 | + mode: entry.mode, | |
| 447 | + }; | |
| 448 | + if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel; | |
| 449 | + return item; | |
| 450 | +} | |
| 451 | + | |
| 452 | +export class SqliteCatalogReader implements CatalogReader { | |
| 453 | + readonly #database: DatabaseSync; | |
| 454 | + readonly #searchPlacesStatement: StatementSync; | |
| 455 | + readonly #placeExistsStatement: StatementSync; | |
| 456 | + readonly #listServicesStatement: StatementSync; | |
| 457 | + readonly #resolveServiceStatement: StatementSync; | |
| 458 | + #closed = false; | |
| 459 | + | |
| 460 | + private constructor(database: DatabaseSync) { | |
| 461 | + this.#database = database; | |
| 462 | + this.#searchPlacesStatement = database.prepare(` | |
| 463 | + SELECT | |
| 464 | + places.place_id AS placeId, | |
| 465 | + places.stop_label AS stopLabel, | |
| 466 | + places.locality_label AS localityLabel, | |
| 467 | + places.mode AS mode | |
| 468 | + FROM place_search | |
| 469 | + JOIN places ON places.place_id = place_search.place_id | |
| 470 | + WHERE place_search MATCH ? | |
| 471 | + ORDER BY | |
| 472 | + bm25(place_search), | |
| 473 | + places.stop_label COLLATE NOCASE, | |
| 474 | + places.place_id | |
| 475 | + LIMIT ? | |
| 476 | + `); | |
| 477 | + this.#placeExistsStatement = database.prepare(` | |
| 478 | + SELECT 1 | |
| 479 | + FROM places | |
| 480 | + WHERE place_id = ? | |
| 481 | + LIMIT 1 | |
| 482 | + `); | |
| 483 | + this.#listServicesStatement = database.prepare(` | |
| 484 | + SELECT | |
| 485 | + service_id AS serviceId, | |
| 486 | + stop_label AS stopLabel, | |
| 487 | + line_label AS lineLabel, | |
| 488 | + destination_label AS destinationLabel | |
| 489 | + FROM services | |
| 490 | + WHERE place_id = ? | |
| 491 | + ORDER BY | |
| 492 | + line_label COLLATE NOCASE, | |
| 493 | + destination_label COLLATE NOCASE, | |
| 494 | + service_id | |
| 495 | + `); | |
| 496 | + this.#resolveServiceStatement = database.prepare(` | |
| 497 | + SELECT | |
| 498 | + monitoring_ref AS monitoringRef, | |
| 499 | + line_ref AS lineRef, | |
| 500 | + direction_id AS directionId, | |
| 501 | + destination_ref AS destinationRef, | |
| 502 | + destination_label AS destinationLabel | |
| 503 | + FROM services | |
| 504 | + WHERE service_id = ? | |
| 505 | + LIMIT 1 | |
| 506 | + `); | |
| 507 | + } | |
| 508 | + | |
| 509 | + static open(path: string): SqliteCatalogReader { | |
| 510 | + const database = new DatabaseSync(path, { | |
| 511 | + readOnly: true, | |
| 512 | + enableForeignKeyConstraints: true, | |
| 513 | + enableDoubleQuotedStringLiterals: false, | |
| 514 | + allowExtension: false, | |
| 515 | + defensive: true, | |
| 516 | + }); | |
| 517 | + | |
| 518 | + try { | |
| 519 | + validateCandidate(database); | |
| 520 | + return new SqliteCatalogReader(database); | |
| 521 | + } catch (error) { | |
| 522 | + try { | |
| 523 | + database.close(); | |
| 524 | + } catch { | |
| 525 | + // Preserve the candidate validation/open error. | |
| 526 | + } | |
| 527 | + throw error; | |
| 528 | + } | |
| 529 | + } | |
| 530 | + | |
| 531 | + searchPlaces(query: string): PlaceSearchItem[] { | |
| 532 | + const matchQuery = normalizedMatchQuery(query); | |
| 533 | + return rows<PlaceRow>( | |
| 534 | + this.#searchPlacesStatement, | |
| 535 | + matchQuery, | |
| 536 | + LIMITS.catalogSearchResults, | |
| 537 | + ).map(placeItem); | |
| 538 | + } | |
| 539 | + | |
| 540 | + listServices(placeId: string): ServiceOption[] | undefined { | |
| 541 | + if (row(this.#placeExistsStatement, placeId) === undefined) return undefined; | |
| 542 | + return rows<ServiceRow>(this.#listServicesStatement, placeId).map((entry) => ({ | |
| 543 | + serviceId: entry.serviceId, | |
| 544 | + stopLabel: entry.stopLabel, | |
| 545 | + lineLabel: entry.lineLabel, | |
| 546 | + destinationLabel: entry.destinationLabel, | |
| 547 | + })); | |
| 548 | + } | |
| 549 | + | |
| 550 | + resolveService(serviceId: string): CatalogServiceResolution { | |
| 551 | + const resolution = row<ResolutionRow>(this.#resolveServiceStatement, serviceId); | |
| 552 | + if (resolution === undefined) return { status: "UNRESOLVED", code: "INVALID_SERVICE" }; | |
| 553 | + return { status: "RESOLVED", ...resolution }; | |
| 554 | + } | |
| 555 | + | |
| 556 | + [CLOSE_READER](): void { | |
| 557 | + if (this.#closed) return; | |
| 558 | + this.#closed = true; | |
| 559 | + this.#database.close(); | |
| 560 | + } | |
| 561 | +} | |
| 562 | + | |
| 563 | +export class CatalogManager implements CatalogReader { | |
| 564 | + #reader: SqliteCatalogReader | undefined; | |
| 565 | + | |
| 566 | + reload(path: string): void { | |
| 567 | + const candidate = SqliteCatalogReader.open(path); | |
| 568 | + const previous = this.#reader; | |
| 569 | + this.#reader = candidate; | |
| 570 | + if (previous !== undefined) { | |
| 571 | + try { | |
| 572 | + previous[CLOSE_READER](); | |
| 573 | + } catch { | |
| 574 | + // The validated replacement is already active; an old close failure cannot roll it back. | |
| 575 | + } | |
| 576 | + } | |
| 577 | + } | |
| 578 | + | |
| 579 | + searchPlaces(query: string): PlaceSearchItem[] { | |
| 580 | + return this.#activeReader().searchPlaces(query); | |
| 581 | + } | |
| 582 | + | |
| 583 | + listServices(placeId: string): ServiceOption[] | undefined { | |
| 584 | + return this.#activeReader().listServices(placeId); | |
| 585 | + } | |
| 586 | + | |
| 587 | + resolveService(serviceId: string): CatalogServiceResolution { | |
| 588 | + return this.#activeReader().resolveService(serviceId); | |
| 589 | + } | |
| 590 | + | |
| 591 | + #activeReader(): SqliteCatalogReader { | |
| 592 | + if (this.#reader === undefined) throw new CatalogUnavailableError(); | |
| 593 | + return this.#reader; | |
| 594 | + } | |
| 595 | +} | |
| 596 | + | |
| 597 | +function catalogError(code: CatalogErrorResult["code"]): CatalogErrorResult { | |
| 598 | + return { schemaVersion: SCHEMA_VERSION, code }; | |
| 599 | +} | |
| 600 | + | |
| 601 | +function response( | |
| 602 | + status: CatalogHandlerResponse["status"], | |
| 603 | + body: CatalogHandlerResponse["body"], | |
| 604 | +): CatalogHandlerResponse { | |
| 605 | + return { status, body }; | |
| 606 | +} | |
| 607 | + | |
| 608 | +function requestUrl(value: string): URL | undefined { | |
| 609 | + try { | |
| 610 | + return new URL(value, "http://catalog.invalid"); | |
| 611 | + } catch { | |
| 612 | + return undefined; | |
| 613 | + } | |
| 614 | +} | |
| 615 | + | |
| 616 | +function servicePlaceId(pathname: string): string | undefined { | |
| 617 | + const prefix = `${CATALOG_ROUTE.places}/`; | |
| 618 | + const suffix = "/services"; | |
| 619 | + if (!pathname.startsWith(prefix) || !pathname.endsWith(suffix)) return undefined; | |
| 620 | + | |
| 621 | + const encoded = pathname.slice(prefix.length, -suffix.length); | |
| 622 | + if (encoded === "" || encoded.includes("/")) return undefined; | |
| 623 | + try { | |
| 624 | + const placeId = decodeURIComponent(encoded); | |
| 625 | + const bytes = new TextEncoder().encode(placeId).byteLength; | |
| 626 | + return bytes >= 1 && bytes <= LIMITS.idUtf8Bytes ? placeId : undefined; | |
| 627 | + } catch { | |
| 628 | + return undefined; | |
| 629 | + } | |
| 630 | +} | |
| 631 | + | |
| 632 | +function validSearchParameters(url: URL): string | undefined { | |
| 633 | + const entries = [...url.searchParams.entries()]; | |
| 634 | + if (entries.length !== 1 || entries[0]?.[0] !== "q") return undefined; | |
| 635 | + const query = entries[0][1]; | |
| 636 | + try { | |
| 637 | + normalizedMatchQuery(query); | |
| 638 | + return query; | |
| 639 | + } catch { | |
| 640 | + return undefined; | |
| 641 | + } | |
| 642 | +} | |
| 643 | + | |
| 644 | +export function handleCatalogRequest( | |
| 645 | + request: { readonly method: string; readonly url: string }, | |
| 646 | + dependencies: { readonly catalog: CatalogManager }, | |
| 647 | +): CatalogHandlerResponse { | |
| 648 | + const url = requestUrl(request.url); | |
| 649 | + if (url === undefined) return response(400, catalogError("INVALID_QUERY")); | |
| 650 | + | |
| 651 | + if (url.pathname === CATALOG_ROUTE.places) { | |
| 652 | + if (request.method !== "GET") return response(405, catalogError("METHOD_NOT_ALLOWED")); | |
| 653 | + const query = validSearchParameters(url); | |
| 654 | + if (query === undefined) return response(400, catalogError("INVALID_QUERY")); | |
| 655 | + | |
| 656 | + try { | |
| 657 | + return response(200, { | |
| 658 | + schemaVersion: SCHEMA_VERSION, | |
| 659 | + places: dependencies.catalog.searchPlaces(query), | |
| 660 | + }); | |
| 661 | + } catch (error) { | |
| 662 | + if (error instanceof InvalidCatalogQueryError) { | |
| 663 | + return response(400, catalogError("INVALID_QUERY")); | |
| 664 | + } | |
| 665 | + return response(503, catalogError("CATALOG_UNAVAILABLE")); | |
| 666 | + } | |
| 667 | + } | |
| 668 | + | |
| 669 | + const placeId = servicePlaceId(url.pathname); | |
| 670 | + if (placeId !== undefined) { | |
| 671 | + if (request.method !== "GET") return response(405, catalogError("METHOD_NOT_ALLOWED")); | |
| 672 | + if ([...url.searchParams].length !== 0) { | |
| 673 | + return response(400, catalogError("INVALID_QUERY")); | |
| 674 | + } | |
| 675 | + | |
| 676 | + try { | |
| 677 | + const services = dependencies.catalog.listServices(placeId); | |
| 678 | + if (services === undefined) return response(404, catalogError("PLACE_NOT_FOUND")); | |
| 679 | + return response(200, { schemaVersion: SCHEMA_VERSION, placeId, services }); | |
| 680 | + } catch { | |
| 681 | + return response(503, catalogError("CATALOG_UNAVAILABLE")); | |
| 682 | + } | |
| 683 | + } | |
| 684 | + | |
| 685 | + return response(404, catalogError("PLACE_NOT_FOUND")); | |
| 686 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,686 @@ | |||
| 1 | +import { DatabaseSync, type StatementSync } from "node:sqlite"; | ||
| 2 | +import { | ||
| 3 | + CATALOG_ROUTE, | ||
| 4 | + LIMITS, | ||
| 5 | + SCHEMA_VERSION, | ||
| 6 | + TRANSPORT_MODE, | ||
| 7 | + type CatalogErrorResult, | ||
| 8 | + type PlaceSearchItem, | ||
| 9 | + type PlaceSearchResult, | ||
| 10 | + type ServiceOption, | ||
| 11 | + type ServiceOptionsResult, | ||
| 12 | + type TransportMode, | ||
| 13 | +} from "../../contracts/src/index.ts"; | ||
| 14 | + | ||
| 15 | +import { normalizeCatalogSearchText } from "./catalog-import.ts"; | ||
| 16 | + | ||
| 17 | +export type CatalogServiceResolution = | ||
| 18 | + | { | ||
| 19 | + readonly status: "RESOLVED"; | ||
| 20 | + readonly monitoringRef: string; | ||
| 21 | + readonly lineRef: string; | ||
| 22 | + readonly directionId: string; | ||
| 23 | + readonly destinationRef: string; | ||
| 24 | + readonly destinationLabel: string; | ||
| 25 | + } | ||
| 26 | + | { readonly status: "UNRESOLVED"; readonly code: "INVALID_SERVICE" }; | ||
| 27 | + | ||
| 28 | +export interface CatalogReader { | ||
| 29 | + searchPlaces(query: string): PlaceSearchItem[]; | ||
| 30 | + listServices(placeId: string): ServiceOption[] | undefined; | ||
| 31 | + resolveService(serviceId: string): CatalogServiceResolution; | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +export interface CatalogHandlerResponse { | ||
| 35 | + readonly status: 200 | 400 | 404 | 405 | 503; | ||
| 36 | + readonly body: PlaceSearchResult | ServiceOptionsResult | CatalogErrorResult; | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +interface PlaceRow { | ||
| 40 | + readonly placeId: string; | ||
| 41 | + readonly stopLabel: string; | ||
| 42 | + readonly localityLabel: string | null; | ||
| 43 | + readonly mode: TransportMode; | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +interface ServiceRow { | ||
| 47 | + readonly serviceId: string; | ||
| 48 | + readonly stopLabel: string; | ||
| 49 | + readonly lineLabel: string; | ||
| 50 | + readonly destinationLabel: string; | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +interface ResolutionRow { | ||
| 54 | + readonly monitoringRef: string; | ||
| 55 | + readonly lineRef: string; | ||
| 56 | + readonly directionId: string; | ||
| 57 | + readonly destinationRef: string; | ||
| 58 | + readonly destinationLabel: string; | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +interface MetadataRow { | ||
| 62 | + readonly key: string; | ||
| 63 | + readonly value: string; | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +interface SchemaRow { | ||
| 67 | + readonly name: string; | ||
| 68 | + readonly sql: string | null; | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +interface ColumnRow { | ||
| 72 | + readonly cid: number; | ||
| 73 | + readonly name: string; | ||
| 74 | + readonly type: string; | ||
| 75 | + readonly notNull: number; | ||
| 76 | + readonly primaryKey: number; | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +interface ExpectedColumn { | ||
| 80 | + readonly name: string; | ||
| 81 | + readonly type: string; | ||
| 82 | + readonly notNull?: boolean; | ||
| 83 | + readonly primaryKey?: number; | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | + | ||
| 87 | +const CLOSE_READER = Symbol("closeCatalogReader"); | ||
| 88 | +const OPAQUE_ID_BYTES = 47; | ||
| 89 | +const CATALOG_SCHEMA_VERSION = "1"; | ||
| 90 | +const CATALOG_FORMAT_VERSION = "1"; | ||
| 91 | +const REQUIRED_METADATA = [ | ||
| 92 | + "schema_version", | ||
| 93 | + "catalog_version", | ||
| 94 | + "source_revision", | ||
| 95 | + "created_at", | ||
| 96 | +] as const; | ||
| 97 | +const EXPECTED_TABLES: Readonly<Record<string, true>> = { | ||
| 98 | + metadata: true, | ||
| 99 | + places: true, | ||
| 100 | + services: true, | ||
| 101 | + place_search: true, | ||
| 102 | + place_search_data: true, | ||
| 103 | + place_search_idx: true, | ||
| 104 | + place_search_content: true, | ||
| 105 | + place_search_docsize: true, | ||
| 106 | + place_search_config: true, | ||
| 107 | +}; | ||
| 108 | +const TABLE_COLUMNS: Readonly<Record<string, readonly ExpectedColumn[]>> = { | ||
| 109 | + metadata: [ | ||
| 110 | + { name: "key", type: "TEXT", primaryKey: 1 }, | ||
| 111 | + { name: "value", type: "TEXT", notNull: true }, | ||
| 112 | + ], | ||
| 113 | + places: [ | ||
| 114 | + { name: "place_id", type: "TEXT", primaryKey: 1 }, | ||
| 115 | + { name: "stop_label", type: "TEXT", notNull: true }, | ||
| 116 | + { name: "locality_label", type: "TEXT" }, | ||
| 117 | + { name: "mode", type: "TEXT", notNull: true }, | ||
| 118 | + { name: "canonical_tuple", type: "TEXT", notNull: true }, | ||
| 119 | + ], | ||
| 120 | + services: [ | ||
| 121 | + { name: "service_id", type: "TEXT", primaryKey: 1 }, | ||
| 122 | + { name: "place_id", type: "TEXT", notNull: true }, | ||
| 123 | + { name: "stop_label", type: "TEXT", notNull: true }, | ||
| 124 | + { name: "line_label", type: "TEXT", notNull: true }, | ||
| 125 | + { name: "destination_label", type: "TEXT", notNull: true }, | ||
| 126 | + { name: "monitoring_ref", type: "TEXT", notNull: true }, | ||
| 127 | + { name: "line_ref", type: "TEXT", notNull: true }, | ||
| 128 | + { name: "direction_id", type: "TEXT", notNull: true }, | ||
| 129 | + { name: "destination_ref", type: "TEXT", notNull: true }, | ||
| 130 | + { name: "canonical_tuple", type: "TEXT", notNull: true }, | ||
| 131 | + ], | ||
| 132 | + place_search: [ | ||
| 133 | + { name: "search_text", type: "" }, | ||
| 134 | + { name: "place_id", type: "" }, | ||
| 135 | + ], | ||
| 136 | +}; | ||
| 137 | + | ||
| 138 | +class CatalogUnavailableError extends Error { | ||
| 139 | + constructor() { | ||
| 140 | + super("No complete catalog is active"); | ||
| 141 | + this.name = "CatalogUnavailableError"; | ||
| 142 | + } | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +class InvalidCatalogQueryError extends RangeError { | ||
| 146 | + constructor() { | ||
| 147 | + super("Catalog query is outside the supported bounds"); | ||
| 148 | + this.name = "InvalidCatalogQueryError"; | ||
| 149 | + } | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +function invalidCandidate(reason: string): never { | ||
| 153 | + throw new Error(`Invalid catalog candidate: ${reason}`); | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +function rows<T>(statement: StatementSync, ...parameters: (string | number)[]): T[] { | ||
| 157 | + return statement.all(...parameters) as unknown as T[]; | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +function row<T>(statement: StatementSync, ...parameters: (string | number)[]): T | undefined { | ||
| 161 | + return statement.get(...parameters) as unknown as T | undefined; | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +function validateColumns(database: DatabaseSync): void { | ||
| 165 | + const statement = database.prepare(` | ||
| 166 | + SELECT | ||
| 167 | + cid, | ||
| 168 | + name, | ||
| 169 | + type, | ||
| 170 | + "notnull" AS "notNull", | ||
| 171 | + pk AS "primaryKey" | ||
| 172 | + FROM pragma_table_info(?) | ||
| 173 | + ORDER BY cid | ||
| 174 | + `); | ||
| 175 | + | ||
| 176 | + for (const [table, expected] of Object.entries(TABLE_COLUMNS)) { | ||
| 177 | + const actual = rows<ColumnRow>(statement, table); | ||
| 178 | + if (actual.length !== expected.length) invalidCandidate(`${table} columns`); | ||
| 179 | + | ||
| 180 | + for (let index = 0; index < expected.length; index += 1) { | ||
| 181 | + const actualColumn = actual[index]; | ||
| 182 | + const expectedColumn = expected[index]; | ||
| 183 | + if ( | ||
| 184 | + actualColumn === undefined | ||
| 185 | + || expectedColumn === undefined | ||
| 186 | + || actualColumn.cid !== index | ||
| 187 | + || actualColumn.name !== expectedColumn.name | ||
| 188 | + || actualColumn.type.toUpperCase() !== expectedColumn.type | ||
| 189 | + || (expectedColumn.notNull === true && actualColumn.notNull !== 1) | ||
| 190 | + || (expectedColumn.primaryKey !== undefined | ||
| 191 | + && actualColumn.primaryKey !== expectedColumn.primaryKey) | ||
| 192 | + ) { | ||
| 193 | + invalidCandidate(`${table} columns`); | ||
| 194 | + } | ||
| 195 | + } | ||
| 196 | + } | ||
| 197 | +} | ||
| 198 | + | ||
| 199 | +function validateSchema(database: DatabaseSync): void { | ||
| 200 | + const schema = rows<SchemaRow>(database.prepare(` | ||
| 201 | + SELECT name, sql | ||
| 202 | + FROM sqlite_schema | ||
| 203 | + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' | ||
| 204 | + ORDER BY name | ||
| 205 | + `)); | ||
| 206 | + const names = new Set(schema.map((entry) => entry.name)); | ||
| 207 | + | ||
| 208 | + if (schema.some((entry) => EXPECTED_TABLES[entry.name] !== true)) { | ||
| 209 | + invalidCandidate("unexpected table"); | ||
| 210 | + } | ||
| 211 | + for (const name of Object.keys(EXPECTED_TABLES)) { | ||
| 212 | + if (!names.has(name)) invalidCandidate(`missing ${name}`); | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + const searchSql = schema.find((entry) => entry.name === "place_search")?.sql; | ||
| 216 | + if ( | ||
| 217 | + typeof searchSql !== "string" | ||
| 218 | + || !/\bUSING\s+fts5\s*\(/iu.test(searchSql) | ||
| 219 | + || !/\bplace_id\s+UNINDEXED\b/iu.test(searchSql) | ||
| 220 | + ) { | ||
| 221 | + invalidCandidate("place_search definition"); | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + validateColumns(database); | ||
| 225 | + | ||
| 226 | + const uniqueIndex = database.prepare(` | ||
| 227 | + SELECT indexes.name | ||
| 228 | + FROM pragma_index_list(?) AS indexes | ||
| 229 | + JOIN pragma_index_info(indexes.name) AS columns | ||
| 230 | + WHERE indexes."unique" = 1 | ||
| 231 | + GROUP BY indexes.name | ||
| 232 | + HAVING COUNT(*) = 1 AND MAX(columns.name) = ? | ||
| 233 | + LIMIT 1 | ||
| 234 | + `); | ||
| 235 | + if (row(uniqueIndex, "places", "canonical_tuple") === undefined) { | ||
| 236 | + invalidCandidate("places canonical uniqueness"); | ||
| 237 | + } | ||
| 238 | + if (row(uniqueIndex, "services", "canonical_tuple") === undefined) { | ||
| 239 | + invalidCandidate("services canonical uniqueness"); | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + const foreignKeys = rows<{ targetTable: string; sourceColumn: string; targetColumn: string }>( | ||
| 243 | + database.prepare(` | ||
| 244 | + SELECT | ||
| 245 | + "table" AS targetTable, | ||
| 246 | + "from" AS sourceColumn, | ||
| 247 | + "to" AS targetColumn | ||
| 248 | + FROM pragma_foreign_key_list('services') | ||
| 249 | + `), | ||
| 250 | + ); | ||
| 251 | + if ( | ||
| 252 | + foreignKeys.length !== 1 | ||
| 253 | + || foreignKeys[0]?.targetTable !== "places" | ||
| 254 | + || foreignKeys[0].sourceColumn !== "place_id" | ||
| 255 | + || foreignKeys[0].targetColumn !== "place_id" | ||
| 256 | + ) { | ||
| 257 | + invalidCandidate("services foreign key"); | ||
| 258 | + } | ||
| 259 | +} | ||
| 260 | + | ||
| 261 | +function validateMetadata(database: DatabaseSync): void { | ||
| 262 | + const statement = database.prepare(` | ||
| 263 | + SELECT key, value | ||
| 264 | + FROM metadata | ||
| 265 | + WHERE key IN (?, ?, ?, ?) | ||
| 266 | + `); | ||
| 267 | + const metadata = new Map( | ||
| 268 | + rows<MetadataRow>(statement, ...REQUIRED_METADATA).map((entry) => [entry.key, entry.value]), | ||
| 269 | + ); | ||
| 270 | + | ||
| 271 | + if (metadata.size !== REQUIRED_METADATA.length) invalidCandidate("required metadata"); | ||
| 272 | + if (metadata.get("schema_version") !== CATALOG_SCHEMA_VERSION) { | ||
| 273 | + invalidCandidate("schema version"); | ||
| 274 | + } | ||
| 275 | + if (metadata.get("catalog_version") !== CATALOG_FORMAT_VERSION) { | ||
| 276 | + invalidCandidate("catalog version"); | ||
| 277 | + } | ||
| 278 | + if (metadata.get("source_revision")?.trim() === "") { | ||
| 279 | + invalidCandidate("source revision"); | ||
| 280 | + } | ||
| 281 | + if (metadata.get("created_at")?.trim() === "") invalidCandidate("creation time"); | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +function validateIntegrity(database: DatabaseSync): void { | ||
| 285 | + const integrity = row<{ integrityCheck: string }>(database.prepare(` | ||
| 286 | + SELECT integrity_check AS integrityCheck | ||
| 287 | + FROM pragma_integrity_check | ||
| 288 | + `)); | ||
| 289 | + if (integrity?.integrityCheck !== "ok") invalidCandidate("integrity check"); | ||
| 290 | + if (database.prepare("PRAGMA foreign_key_check").get() !== undefined) { | ||
| 291 | + invalidCandidate("foreign key check"); | ||
| 292 | + } | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +function validateData(database: DatabaseSync): void { | ||
| 296 | + const invalidPlace = database.prepare(` | ||
| 297 | + SELECT 1 | ||
| 298 | + FROM places | ||
| 299 | + WHERE | ||
| 300 | + typeof(place_id) <> 'text' | ||
| 301 | + OR length(CAST(place_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | ||
| 302 | + OR substr(place_id, 1, 4) <> 'plc_' | ||
| 303 | + OR substr(place_id, 5) GLOB '*[^A-Za-z0-9_-]*' | ||
| 304 | + OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | ||
| 305 | + OR (locality_label IS NOT NULL | ||
| 306 | + AND length(CAST(locality_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}) | ||
| 307 | + OR mode NOT IN ('BUS', 'METRO', 'TRAM', 'RER', 'TRANSILIEN') | ||
| 308 | + OR length(CAST(canonical_tuple AS BLOB)) < 1 | ||
| 309 | + LIMIT 1 | ||
| 310 | + `).get(); | ||
| 311 | + if (invalidPlace !== undefined) invalidCandidate("place data"); | ||
| 312 | + | ||
| 313 | + const invalidService = database.prepare(` | ||
| 314 | + SELECT 1 | ||
| 315 | + FROM services | ||
| 316 | + WHERE | ||
| 317 | + typeof(service_id) <> 'text' | ||
| 318 | + OR length(CAST(service_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | ||
| 319 | + OR substr(service_id, 1, 4) <> 'svc_' | ||
| 320 | + OR substr(service_id, 5) GLOB '*[^A-Za-z0-9_-]*' | ||
| 321 | + OR length(CAST(place_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | ||
| 322 | + OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | ||
| 323 | + OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | ||
| 324 | + OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | ||
| 325 | + OR length(CAST(monitoring_ref AS BLOB)) < 1 | ||
| 326 | + OR length(CAST(line_ref AS BLOB)) < 1 | ||
| 327 | + OR length(CAST(direction_id AS BLOB)) < 1 | ||
| 328 | + OR length(CAST(destination_ref AS BLOB)) < 1 | ||
| 329 | + OR length(CAST(canonical_tuple AS BLOB)) < 1 | ||
| 330 | + LIMIT 1 | ||
| 331 | + `).get(); | ||
| 332 | + if (invalidService !== undefined) invalidCandidate("service data"); | ||
| 333 | + | ||
| 334 | + if (database.prepare(` | ||
| 335 | + SELECT 1 | ||
| 336 | + FROM places | ||
| 337 | + WHERE NOT EXISTS ( | ||
| 338 | + SELECT 1 FROM services WHERE services.place_id = places.place_id | ||
| 339 | + ) | ||
| 340 | + LIMIT 1 | ||
| 341 | + `).get() !== undefined) { | ||
| 342 | + invalidCandidate("place without service"); | ||
| 343 | + } | ||
| 344 | + | ||
| 345 | + if (database.prepare(` | ||
| 346 | + SELECT 1 | ||
| 347 | + FROM services | ||
| 348 | + JOIN places ON places.place_id = services.place_id | ||
| 349 | + WHERE services.stop_label <> places.stop_label | ||
| 350 | + LIMIT 1 | ||
| 351 | + `).get() !== undefined) { | ||
| 352 | + invalidCandidate("inconsistent stop label"); | ||
| 353 | + } | ||
| 354 | + | ||
| 355 | + | ||
| 356 | + if (database.prepare(` | ||
| 357 | + SELECT 1 | ||
| 358 | + FROM places | ||
| 359 | + GROUP BY canonical_tuple | ||
| 360 | + HAVING COUNT(*) <> 1 | ||
| 361 | + LIMIT 1 | ||
| 362 | + `).get() !== undefined || database.prepare(` | ||
| 363 | + SELECT 1 | ||
| 364 | + FROM services | ||
| 365 | + GROUP BY canonical_tuple | ||
| 366 | + HAVING COUNT(*) <> 1 | ||
| 367 | + LIMIT 1 | ||
| 368 | + `).get() !== undefined) { | ||
| 369 | + invalidCandidate("canonical collision"); | ||
| 370 | + } | ||
| 371 | + | ||
| 372 | + if (database.prepare(` | ||
| 373 | + SELECT 1 | ||
| 374 | + FROM place_search | ||
| 375 | + WHERE | ||
| 376 | + length(CAST(search_text AS BLOB)) < 1 | ||
| 377 | + OR length(CAST(place_id AS BLOB)) <> ${OPAQUE_ID_BYTES} | ||
| 378 | + LIMIT 1 | ||
| 379 | + `).get() !== undefined) { | ||
| 380 | + invalidCandidate("search data"); | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + if (database.prepare(` | ||
| 384 | + SELECT 1 | ||
| 385 | + FROM place_search | ||
| 386 | + GROUP BY place_id | ||
| 387 | + HAVING COUNT(*) <> 1 | ||
| 388 | + LIMIT 1 | ||
| 389 | + `).get() !== undefined || database.prepare(` | ||
| 390 | + SELECT 1 | ||
| 391 | + FROM places | ||
| 392 | + LEFT JOIN place_search ON place_search.place_id = places.place_id | ||
| 393 | + WHERE place_search.place_id IS NULL | ||
| 394 | + LIMIT 1 | ||
| 395 | + `).get() !== undefined || database.prepare(` | ||
| 396 | + SELECT 1 | ||
| 397 | + FROM place_search | ||
| 398 | + LEFT JOIN places ON places.place_id = place_search.place_id | ||
| 399 | + WHERE places.place_id IS NULL | ||
| 400 | + LIMIT 1 | ||
| 401 | + `).get() !== undefined) { | ||
| 402 | + invalidCandidate("search consistency"); | ||
| 403 | + } | ||
| 404 | + | ||
| 405 | + const coverage = new Set(rows<{ mode: TransportMode }>(database.prepare(` | ||
| 406 | + SELECT places.mode AS mode | ||
| 407 | + FROM places | ||
| 408 | + JOIN services ON services.place_id = places.place_id | ||
| 409 | + GROUP BY places.mode | ||
| 410 | + `)).map((entry) => entry.mode)); | ||
| 411 | + if (TRANSPORT_MODE.some((mode) => !coverage.has(mode))) { | ||
| 412 | + invalidCandidate("transport mode coverage"); | ||
| 413 | + } | ||
| 414 | +} | ||
| 415 | + | ||
| 416 | +function validateCandidate(database: DatabaseSync): void { | ||
| 417 | + validateIntegrity(database); | ||
| 418 | + validateSchema(database); | ||
| 419 | + validateMetadata(database); | ||
| 420 | + validateData(database); | ||
| 421 | +} | ||
| 422 | + | ||
| 423 | +function normalizedMatchQuery(query: string): string { | ||
| 424 | + const trimmed = query.trim(); | ||
| 425 | + const codePoints = [...trimmed].length; | ||
| 426 | + if ( | ||
| 427 | + codePoints < LIMITS.catalogQueryMinCharacters | ||
| 428 | + || codePoints > LIMITS.catalogQueryMaxCharacters | ||
| 429 | + ) { | ||
| 430 | + throw new InvalidCatalogQueryError(); | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + const normalized = normalizeCatalogSearchText(trimmed); | ||
| 434 | + if (normalized === "") throw new InvalidCatalogQueryError(); | ||
| 435 | + | ||
| 436 | + return normalized | ||
| 437 | + .split(" ") | ||
| 438 | + .map((token) => `"${token.replaceAll('"', '""')}"*`) | ||
| 439 | + .join(" AND "); | ||
| 440 | +} | ||
| 441 | + | ||
| 442 | +function placeItem(entry: PlaceRow): PlaceSearchItem { | ||
| 443 | + const item: PlaceSearchItem = { | ||
| 444 | + placeId: entry.placeId, | ||
| 445 | + stopLabel: entry.stopLabel, | ||
| 446 | + mode: entry.mode, | ||
| 447 | + }; | ||
| 448 | + if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel; | ||
| 449 | + return item; | ||
| 450 | +} | ||
| 451 | + | ||
| 452 | +export class SqliteCatalogReader implements CatalogReader { | ||
| 453 | + readonly #database: DatabaseSync; | ||
| 454 | + readonly #searchPlacesStatement: StatementSync; | ||
| 455 | + readonly #placeExistsStatement: StatementSync; | ||
| 456 | + readonly #listServicesStatement: StatementSync; | ||
| 457 | + readonly #resolveServiceStatement: StatementSync; | ||
| 458 | + #closed = false; | ||
| 459 | + | ||
| 460 | + private constructor(database: DatabaseSync) { | ||
| 461 | + this.#database = database; | ||
| 462 | + this.#searchPlacesStatement = database.prepare(` | ||
| 463 | + SELECT | ||
| 464 | + places.place_id AS placeId, | ||
| 465 | + places.stop_label AS stopLabel, | ||
| 466 | + places.locality_label AS localityLabel, | ||
| 467 | + places.mode AS mode | ||
| 468 | + FROM place_search | ||
| 469 | + JOIN places ON places.place_id = place_search.place_id | ||
| 470 | + WHERE place_search MATCH ? | ||
| 471 | + ORDER BY | ||
| 472 | + bm25(place_search), | ||
| 473 | + places.stop_label COLLATE NOCASE, | ||
| 474 | + places.place_id | ||
| 475 | + LIMIT ? | ||
| 476 | + `); | ||
| 477 | + this.#placeExistsStatement = database.prepare(` | ||
| 478 | + SELECT 1 | ||
| 479 | + FROM places | ||
| 480 | + WHERE place_id = ? | ||
| 481 | + LIMIT 1 | ||
| 482 | + `); | ||
| 483 | + this.#listServicesStatement = database.prepare(` | ||
| 484 | + SELECT | ||
| 485 | + service_id AS serviceId, | ||
| 486 | + stop_label AS stopLabel, | ||
| 487 | + line_label AS lineLabel, | ||
| 488 | + destination_label AS destinationLabel | ||
| 489 | + FROM services | ||
| 490 | + WHERE place_id = ? | ||
| 491 | + ORDER BY | ||
| 492 | + line_label COLLATE NOCASE, | ||
| 493 | + destination_label COLLATE NOCASE, | ||
| 494 | + service_id | ||
| 495 | + `); | ||
| 496 | + this.#resolveServiceStatement = database.prepare(` | ||
| 497 | + SELECT | ||
| 498 | + monitoring_ref AS monitoringRef, | ||
| 499 | + line_ref AS lineRef, | ||
| 500 | + direction_id AS directionId, | ||
| 501 | + destination_ref AS destinationRef, | ||
| 502 | + destination_label AS destinationLabel | ||
| 503 | + FROM services | ||
| 504 | + WHERE service_id = ? | ||
| 505 | + LIMIT 1 | ||
| 506 | + `); | ||
| 507 | + } | ||
| 508 | + | ||
| 509 | + static open(path: string): SqliteCatalogReader { | ||
| 510 | + const database = new DatabaseSync(path, { | ||
| 511 | + readOnly: true, | ||
| 512 | + enableForeignKeyConstraints: true, | ||
| 513 | + enableDoubleQuotedStringLiterals: false, | ||
| 514 | + allowExtension: false, | ||
| 515 | + defensive: true, | ||
| 516 | + }); | ||
| 517 | + | ||
| 518 | + try { | ||
| 519 | + validateCandidate(database); | ||
| 520 | + return new SqliteCatalogReader(database); | ||
| 521 | + } catch (error) { | ||
| 522 | + try { | ||
| 523 | + database.close(); | ||
| 524 | + } catch { | ||
| 525 | + // Preserve the candidate validation/open error. | ||
| 526 | + } | ||
| 527 | + throw error; | ||
| 528 | + } | ||
| 529 | + } | ||
| 530 | + | ||
| 531 | + searchPlaces(query: string): PlaceSearchItem[] { | ||
| 532 | + const matchQuery = normalizedMatchQuery(query); | ||
| 533 | + return rows<PlaceRow>( | ||
| 534 | + this.#searchPlacesStatement, | ||
| 535 | + matchQuery, | ||
| 536 | + LIMITS.catalogSearchResults, | ||
| 537 | + ).map(placeItem); | ||
| 538 | + } | ||
| 539 | + | ||
| 540 | + listServices(placeId: string): ServiceOption[] | undefined { | ||
| 541 | + if (row(this.#placeExistsStatement, placeId) === undefined) return undefined; | ||
| 542 | + return rows<ServiceRow>(this.#listServicesStatement, placeId).map((entry) => ({ | ||
| 543 | + serviceId: entry.serviceId, | ||
| 544 | + stopLabel: entry.stopLabel, | ||
| 545 | + lineLabel: entry.lineLabel, | ||
| 546 | + destinationLabel: entry.destinationLabel, | ||
| 547 | + })); | ||
| 548 | + } | ||
| 549 | + | ||
| 550 | + resolveService(serviceId: string): CatalogServiceResolution { | ||
| 551 | + const resolution = row<ResolutionRow>(this.#resolveServiceStatement, serviceId); | ||
| 552 | + if (resolution === undefined) return { status: "UNRESOLVED", code: "INVALID_SERVICE" }; | ||
| 553 | + return { status: "RESOLVED", ...resolution }; | ||
| 554 | + } | ||
| 555 | + | ||
| 556 | + [CLOSE_READER](): void { | ||
| 557 | + if (this.#closed) return; | ||
| 558 | + this.#closed = true; | ||
| 559 | + this.#database.close(); | ||
| 560 | + } | ||
| 561 | +} | ||
| 562 | + | ||
| 563 | +export class CatalogManager implements CatalogReader { | ||
| 564 | + #reader: SqliteCatalogReader | undefined; | ||
| 565 | + | ||
| 566 | + reload(path: string): void { | ||
| 567 | + const candidate = SqliteCatalogReader.open(path); | ||
| 568 | + const previous = this.#reader; | ||
| 569 | + this.#reader = candidate; | ||
| 570 | + if (previous !== undefined) { | ||
| 571 | + try { | ||
| 572 | + previous[CLOSE_READER](); | ||
| 573 | + } catch { | ||
| 574 | + // The validated replacement is already active; an old close failure cannot roll it back. | ||
| 575 | + } | ||
| 576 | + } | ||
| 577 | + } | ||
| 578 | + | ||
| 579 | + searchPlaces(query: string): PlaceSearchItem[] { | ||
| 580 | + return this.#activeReader().searchPlaces(query); | ||
| 581 | + } | ||
| 582 | + | ||
| 583 | + listServices(placeId: string): ServiceOption[] | undefined { | ||
| 584 | + return this.#activeReader().listServices(placeId); | ||
| 585 | + } | ||
| 586 | + | ||
| 587 | + resolveService(serviceId: string): CatalogServiceResolution { | ||
| 588 | + return this.#activeReader().resolveService(serviceId); | ||
| 589 | + } | ||
| 590 | + | ||
| 591 | + #activeReader(): SqliteCatalogReader { | ||
| 592 | + if (this.#reader === undefined) throw new CatalogUnavailableError(); | ||
| 593 | + return this.#reader; | ||
| 594 | + } | ||
| 595 | +} | ||
| 596 | + | ||
| 597 | +function catalogError(code: CatalogErrorResult["code"]): CatalogErrorResult { | ||
| 598 | + return { schemaVersion: SCHEMA_VERSION, code }; | ||
| 599 | +} | ||
| 600 | + | ||
| 601 | +function response( | ||
| 602 | + status: CatalogHandlerResponse["status"], | ||
| 603 | + body: CatalogHandlerResponse["body"], | ||
| 604 | +): CatalogHandlerResponse { | ||
| 605 | + return { status, body }; | ||
| 606 | +} | ||
| 607 | + | ||
| 608 | +function requestUrl(value: string): URL | undefined { | ||
| 609 | + try { | ||
| 610 | + return new URL(value, "http://catalog.invalid"); | ||
| 611 | + } catch { | ||
| 612 | + return undefined; | ||
| 613 | + } | ||
| 614 | +} | ||
| 615 | + | ||
| 616 | +function servicePlaceId(pathname: string): string | undefined { | ||
| 617 | + const prefix = `${CATALOG_ROUTE.places}/`; | ||
| 618 | + const suffix = "/services"; | ||
| 619 | + if (!pathname.startsWith(prefix) || !pathname.endsWith(suffix)) return undefined; | ||
| 620 | + | ||
| 621 | + const encoded = pathname.slice(prefix.length, -suffix.length); | ||
| 622 | + if (encoded === "" || encoded.includes("/")) return undefined; | ||
| 623 | + try { | ||
| 624 | + const placeId = decodeURIComponent(encoded); | ||
| 625 | + const bytes = new TextEncoder().encode(placeId).byteLength; | ||
| 626 | + return bytes >= 1 && bytes <= LIMITS.idUtf8Bytes ? placeId : undefined; | ||
| 627 | + } catch { | ||
| 628 | + return undefined; | ||
| 629 | + } | ||
| 630 | +} | ||
| 631 | + | ||
| 632 | +function validSearchParameters(url: URL): string | undefined { | ||
| 633 | + const entries = [...url.searchParams.entries()]; | ||
| 634 | + if (entries.length !== 1 || entries[0]?.[0] !== "q") return undefined; | ||
| 635 | + const query = entries[0][1]; | ||
| 636 | + try { | ||
| 637 | + normalizedMatchQuery(query); | ||
| 638 | + return query; | ||
| 639 | + } catch { | ||
| 640 | + return undefined; | ||
| 641 | + } | ||
| 642 | +} | ||
| 643 | + | ||
| 644 | +export function handleCatalogRequest( | ||
| 645 | + request: { readonly method: string; readonly url: string }, | ||
| 646 | + dependencies: { readonly catalog: CatalogManager }, | ||
| 647 | +): CatalogHandlerResponse { | ||
| 648 | + const url = requestUrl(request.url); | ||
| 649 | + if (url === undefined) return response(400, catalogError("INVALID_QUERY")); | ||
| 650 | + | ||
| 651 | + if (url.pathname === CATALOG_ROUTE.places) { | ||
| 652 | + if (request.method !== "GET") return response(405, catalogError("METHOD_NOT_ALLOWED")); | ||
| 653 | + const query = validSearchParameters(url); | ||
| 654 | + if (query === undefined) return response(400, catalogError("INVALID_QUERY")); | ||
| 655 | + | ||
| 656 | + try { | ||
| 657 | + return response(200, { | ||
| 658 | + schemaVersion: SCHEMA_VERSION, | ||
| 659 | + places: dependencies.catalog.searchPlaces(query), | ||
| 660 | + }); | ||
| 661 | + } catch (error) { | ||
| 662 | + if (error instanceof InvalidCatalogQueryError) { | ||
| 663 | + return response(400, catalogError("INVALID_QUERY")); | ||
| 664 | + } | ||
| 665 | + return response(503, catalogError("CATALOG_UNAVAILABLE")); | ||
| 666 | + } | ||
| 667 | + } | ||
| 668 | + | ||
| 669 | + const placeId = servicePlaceId(url.pathname); | ||
| 670 | + if (placeId !== undefined) { | ||
| 671 | + if (request.method !== "GET") return response(405, catalogError("METHOD_NOT_ALLOWED")); | ||
| 672 | + if ([...url.searchParams].length !== 0) { | ||
| 673 | + return response(400, catalogError("INVALID_QUERY")); | ||
| 674 | + } | ||
| 675 | + | ||
| 676 | + try { | ||
| 677 | + const services = dependencies.catalog.listServices(placeId); | ||
| 678 | + if (services === undefined) return response(404, catalogError("PLACE_NOT_FOUND")); | ||
| 679 | + return response(200, { schemaVersion: SCHEMA_VERSION, placeId, services }); | ||
| 680 | + } catch { | ||
| 681 | + return response(503, catalogError("CATALOG_UNAVAILABLE")); | ||
| 682 | + } | ||
| 683 | + } | ||
| 684 | + | ||
| 685 | + return response(404, catalogError("PLACE_NOT_FOUND")); | ||
| 686 | +} | ||
modified
packages/backend/src/index.ts +9 -0 | @@ -13,6 +13,15 @@ import { | ||
| 13 | 13 | type ErrorResult, |
| 14 | 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 | 25 | export const REDACTED_SECRET = "[REDACTED]" as const; |
| 17 | 26 | |
| 18 | 27 | 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 | new file mode 100644 | ||
| @@ -0,0 +1,467 @@ | ||
| 1 | +import test from "node:test"; | |
| 2 | +import assert from "node:assert/strict"; | |
| 3 | +import { createHash } from "node:crypto"; | |
| 4 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 5 | +import { tmpdir } from "node:os"; | |
| 6 | +import { join } from "node:path"; | |
| 7 | +import { DatabaseSync } from "node:sqlite"; | |
| 8 | +import { LIMITS, type TransportMode } from "../../contracts/src/index.ts"; | |
| 9 | +import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts"; | |
| 10 | + | |
| 11 | +interface PlaceSeed { | |
| 12 | + readonly key: string; | |
| 13 | + readonly mode: TransportMode; | |
| 14 | + readonly sourceCode: string; | |
| 15 | + readonly stopLabel: string; | |
| 16 | + readonly localityLabel?: string; | |
| 17 | +} | |
| 18 | + | |
| 19 | +interface ServiceSeed { | |
| 20 | + readonly key: string; | |
| 21 | + readonly placeKey: string; | |
| 22 | + readonly mode: TransportMode; | |
| 23 | + readonly stopLabel: string; | |
| 24 | + readonly lineLabel: string; | |
| 25 | + readonly destinationLabel: string; | |
| 26 | + readonly monitoringRef: string; | |
| 27 | + readonly lineRef: string; | |
| 28 | + readonly directionId: string; | |
| 29 | + readonly destinationRef: string; | |
| 30 | +} | |
| 31 | + | |
| 32 | +interface CatalogSeed { | |
| 33 | + readonly revision?: string; | |
| 34 | + readonly places?: readonly PlaceSeed[]; | |
| 35 | + readonly services?: readonly ServiceSeed[]; | |
| 36 | + readonly omitServiceKeys?: readonly string[]; | |
| 37 | + readonly catalogVersion?: string; | |
| 38 | + readonly omitSearchFor?: string; | |
| 39 | +} | |
| 40 | + | |
| 41 | +interface BuiltCatalog { | |
| 42 | + readonly path: string; | |
| 43 | + readonly placeIds: Readonly<Record<string, string>>; | |
| 44 | + readonly serviceIds: Readonly<Record<string, string>>; | |
| 45 | +} | |
| 46 | + | |
| 47 | +const temporaryDirectories: string[] = []; | |
| 48 | + | |
| 49 | +const BASE_PLACES: readonly PlaceSeed[] = [ | |
| 50 | + { | |
| 51 | + key: "bus", | |
| 52 | + mode: "BUS", | |
| 53 | + sourceCode: "zda-bus-republique", | |
| 54 | + stopLabel: "République", | |
| 55 | + localityLabel: "Paris", | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + key: "metro", | |
| 59 | + mode: "METRO", | |
| 60 | + sourceCode: "zda-metro-chatelet", | |
| 61 | + stopLabel: "Châtelet", | |
| 62 | + localityLabel: "Paris", | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + key: "tram", | |
| 66 | + mode: "TRAM", | |
| 67 | + sourceCode: "zda-tram-versailles", | |
| 68 | + stopLabel: "Porte de Versailles", | |
| 69 | + localityLabel: "Paris", | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + key: "rer", | |
| 73 | + mode: "RER", | |
| 74 | + sourceCode: "zda-rer-chatelet-halles", | |
| 75 | + stopLabel: "Châtelet – Les Halles", | |
| 76 | + localityLabel: "Paris", | |
| 77 | + }, | |
| 78 | + { | |
| 79 | + key: "transilien", | |
| 80 | + mode: "TRANSILIEN", | |
| 81 | + sourceCode: "zda-train-saint-lazare", | |
| 82 | + stopLabel: "Saint-Lazare", | |
| 83 | + localityLabel: "Paris", | |
| 84 | + }, | |
| 85 | +]; | |
| 86 | + | |
| 87 | +const BASE_SERVICES: readonly ServiceSeed[] = [ | |
| 88 | + { | |
| 89 | + key: "bus-east", | |
| 90 | + placeKey: "bus", | |
| 91 | + mode: "BUS", | |
| 92 | + stopLabel: "République", | |
| 93 | + lineLabel: "96", | |
| 94 | + destinationLabel: "Porte des Lilas", | |
| 95 | + monitoringRef: "raw-monitoring-bus-east", | |
| 96 | + lineRef: "raw-line-bus-96", | |
| 97 | + directionId: "east", | |
| 98 | + destinationRef: "terminal-bus-east", | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + key: "metro-south", | |
| 102 | + placeKey: "metro", | |
| 103 | + mode: "METRO", | |
| 104 | + stopLabel: "Châtelet", | |
| 105 | + lineLabel: "4", | |
| 106 | + destinationLabel: "Bagneux – Lucie Aubrac", | |
| 107 | + monitoringRef: "raw-monitoring-metro-south", | |
| 108 | + lineRef: "raw-line-metro-4", | |
| 109 | + directionId: "south", | |
| 110 | + destinationRef: "terminal-metro-south", | |
| 111 | + }, | |
| 112 | + { | |
| 113 | + key: "tram-west", | |
| 114 | + placeKey: "tram", | |
| 115 | + mode: "TRAM", | |
| 116 | + stopLabel: "Porte de Versailles", | |
| 117 | + lineLabel: "T2", | |
| 118 | + destinationLabel: "Porte de Versailles", | |
| 119 | + monitoringRef: "raw-monitoring-tram-west", | |
| 120 | + lineRef: "raw-line-tram-t2", | |
| 121 | + directionId: "west", | |
| 122 | + destinationRef: "terminal-tram-west", | |
| 123 | + }, | |
| 124 | + { | |
| 125 | + key: "rer-north", | |
| 126 | + placeKey: "rer", | |
| 127 | + mode: "RER", | |
| 128 | + stopLabel: "Châtelet – Les Halles", | |
| 129 | + lineLabel: "B", | |
| 130 | + destinationLabel: "Aéroport Charles de Gaulle 2", | |
| 131 | + monitoringRef: "raw-shared-rer-station", | |
| 132 | + lineRef: "raw-line-rer-b", | |
| 133 | + directionId: "north", | |
| 134 | + destinationRef: "terminal-rer-north", | |
| 135 | + }, | |
| 136 | + { | |
| 137 | + key: "train-west", | |
| 138 | + placeKey: "transilien", | |
| 139 | + mode: "TRANSILIEN", | |
| 140 | + stopLabel: "Saint-Lazare", | |
| 141 | + lineLabel: "L", | |
| 142 | + destinationLabel: "Versailles Rive Droite", | |
| 143 | + monitoringRef: "raw-monitoring-train-west", | |
| 144 | + lineRef: "raw-line-train-l", | |
| 145 | + directionId: "west", | |
| 146 | + destinationRef: "terminal-train-west", | |
| 147 | + }, | |
| 148 | +]; | |
| 149 | + | |
| 150 | +function canonicalTuple(namespace: string, values: readonly string[]): string { | |
| 151 | + const framed = values.map((value) => `${Buffer.byteLength(value, "utf8")}:${value}`).join("|"); | |
| 152 | + return `${namespace}:v1|${framed}`; | |
| 153 | +} | |
| 154 | + | |
| 155 | +function opaqueId(prefix: "plc_" | "svc_", canonical: string): string { | |
| 156 | + return prefix + createHash("sha256").update(canonical).digest("base64url"); | |
| 157 | +} | |
| 158 | + | |
| 159 | +function placeIdentity(place: PlaceSeed): { id: string; canonical: string } { | |
| 160 | + const canonical = canonicalTuple("place", [place.mode, place.sourceCode]); | |
| 161 | + return { id: opaqueId("plc_", canonical), canonical }; | |
| 162 | +} | |
| 163 | + | |
| 164 | +function serviceIdentity(service: ServiceSeed): { id: string; canonical: string } { | |
| 165 | + const canonical = canonicalTuple("service", [ | |
| 166 | + service.mode, | |
| 167 | + service.lineRef, | |
| 168 | + service.monitoringRef, | |
| 169 | + service.directionId, | |
| 170 | + service.destinationRef, | |
| 171 | + ]); | |
| 172 | + return { id: opaqueId("svc_", canonical), canonical }; | |
| 173 | +} | |
| 174 | + | |
| 175 | +function searchText(place: PlaceSeed): string { | |
| 176 | + return [place.stopLabel, place.localityLabel, place.mode] | |
| 177 | + .filter((value): value is string => value !== undefined) | |
| 178 | + .join(" ") | |
| 179 | + .normalize("NFKD") | |
| 180 | + .replace(/\p{M}+/gu, "") | |
| 181 | + .toLowerCase() | |
| 182 | + .replace(/[^\p{L}\p{N}]+/gu, " ") | |
| 183 | + .trim() | |
| 184 | + .replace(/\s+/gu, " "); | |
| 185 | +} | |
| 186 | + | |
| 187 | +function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | |
| 188 | + const directory = mkdtempSync(join(tmpdir(), "lapin-catalog-runtime-")); | |
| 189 | + temporaryDirectories.push(directory); | |
| 190 | + const path = join(directory, "catalog.sqlite"); | |
| 191 | + const database = new DatabaseSync(path); | |
| 192 | + database.exec(` | |
| 193 | + PRAGMA foreign_keys = ON; | |
| 194 | + CREATE TABLE metadata( | |
| 195 | + key TEXT PRIMARY KEY, | |
| 196 | + value TEXT NOT NULL | |
| 197 | + ); | |
| 198 | + CREATE TABLE places( | |
| 199 | + place_id TEXT PRIMARY KEY, | |
| 200 | + stop_label TEXT NOT NULL, | |
| 201 | + locality_label TEXT, | |
| 202 | + mode TEXT NOT NULL, | |
| 203 | + canonical_tuple TEXT NOT NULL UNIQUE | |
| 204 | + ); | |
| 205 | + CREATE TABLE services( | |
| 206 | + service_id TEXT PRIMARY KEY, | |
| 207 | + place_id TEXT NOT NULL REFERENCES places(place_id), | |
| 208 | + stop_label TEXT NOT NULL, | |
| 209 | + line_label TEXT NOT NULL, | |
| 210 | + destination_label TEXT NOT NULL, | |
| 211 | + monitoring_ref TEXT NOT NULL, | |
| 212 | + line_ref TEXT NOT NULL, | |
| 213 | + direction_id TEXT NOT NULL, | |
| 214 | + destination_ref TEXT NOT NULL, | |
| 215 | + canonical_tuple TEXT NOT NULL UNIQUE | |
| 216 | + ); | |
| 217 | + CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); | |
| 218 | + `); | |
| 219 | + | |
| 220 | + const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | |
| 221 | + metadata.run("schema_version", "1"); | |
| 222 | + metadata.run("catalog_version", seed.catalogVersion ?? "1"); | |
| 223 | + metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); | |
| 224 | + metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | |
| 225 | + | |
| 226 | + const places = [...BASE_PLACES, ...(seed.places ?? [])]; | |
| 227 | + const omittedServiceKeys = new Set(seed.omitServiceKeys ?? []); | |
| 228 | + const services = [ | |
| 229 | + ...BASE_SERVICES.filter((service) => !omittedServiceKeys.has(service.key)), | |
| 230 | + ...(seed.services ?? []), | |
| 231 | + ]; | |
| 232 | + const placeIds: Record<string, string> = {}; | |
| 233 | + const serviceIds: Record<string, string> = {}; | |
| 234 | + const insertPlace = database.prepare(` | |
| 235 | + INSERT INTO places(place_id, stop_label, locality_label, mode, canonical_tuple) | |
| 236 | + VALUES (?, ?, ?, ?, ?) | |
| 237 | + `); | |
| 238 | + const insertSearch = database.prepare( | |
| 239 | + "INSERT INTO place_search(search_text, place_id) VALUES (?, ?)", | |
| 240 | + ); | |
| 241 | + for (const place of places) { | |
| 242 | + const identity = placeIdentity(place); | |
| 243 | + placeIds[place.key] = identity.id; | |
| 244 | + insertPlace.run( | |
| 245 | + identity.id, | |
| 246 | + place.stopLabel, | |
| 247 | + place.localityLabel ?? null, | |
| 248 | + place.mode, | |
| 249 | + identity.canonical, | |
| 250 | + ); | |
| 251 | + if (place.key !== seed.omitSearchFor) insertSearch.run(searchText(place), identity.id); | |
| 252 | + } | |
| 253 | + | |
| 254 | + const insertService = database.prepare(` | |
| 255 | + INSERT INTO services( | |
| 256 | + service_id, | |
| 257 | + place_id, | |
| 258 | + stop_label, | |
| 259 | + line_label, | |
| 260 | + destination_label, | |
| 261 | + monitoring_ref, | |
| 262 | + line_ref, | |
| 263 | + direction_id, | |
| 264 | + destination_ref, | |
| 265 | + canonical_tuple | |
| 266 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 267 | + `); | |
| 268 | + for (const service of services) { | |
| 269 | + const identity = serviceIdentity(service); | |
| 270 | + serviceIds[service.key] = identity.id; | |
| 271 | + insertService.run( | |
| 272 | + identity.id, | |
| 273 | + placeIds[service.placeKey], | |
| 274 | + service.stopLabel, | |
| 275 | + service.lineLabel, | |
| 276 | + service.destinationLabel, | |
| 277 | + service.monitoringRef, | |
| 278 | + service.lineRef, | |
| 279 | + service.directionId, | |
| 280 | + service.destinationRef, | |
| 281 | + identity.canonical, | |
| 282 | + ); | |
| 283 | + } | |
| 284 | + database.close(); | |
| 285 | + return { path, placeIds, serviceIds }; | |
| 286 | +} | |
| 287 | + | |
| 288 | +test.afterEach(() => { | |
| 289 | + while (temporaryDirectories.length > 0) { | |
| 290 | + const directory = temporaryDirectories.pop(); | |
| 291 | + if (directory !== undefined) rmSync(directory, { recursive: true, force: true }); | |
| 292 | + } | |
| 293 | +}); | |
| 294 | + | |
| 295 | +test("search is accent tolerant, punctuation safe, and bounded by Unicode code points", () => { | |
| 296 | + const catalog = buildCatalog(); | |
| 297 | + const reader = SqliteCatalogReader.open(catalog.path); | |
| 298 | + | |
| 299 | + assert.deepEqual( | |
| 300 | + reader.searchPlaces("chatelet").map((place) => place.placeId), | |
| 301 | + [catalog.placeIds.metro, catalog.placeIds.rer], | |
| 302 | + ); | |
| 303 | + assert.deepEqual( | |
| 304 | + reader.searchPlaces(" ChÂtElEt---LES...HALLES ").map((place) => place.placeId), | |
| 305 | + [catalog.placeIds.rer], | |
| 306 | + ); | |
| 307 | + assert.throws(() => reader.searchPlaces("a"), RangeError); | |
| 308 | + assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError); | |
| 309 | + assert.throws(() => reader.searchPlaces("--"), RangeError); | |
| 310 | + assert.doesNotThrow(() => reader.searchPlaces("😀a")); | |
| 311 | + assert.doesNotThrow(() => reader.searchPlaces("z".repeat(100))); | |
| 312 | +}); | |
| 313 | + | |
| 314 | +test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => { | |
| 315 | + const homonyms: PlaceSeed[] = [ | |
| 316 | + { | |
| 317 | + key: "homonym-b", | |
| 318 | + mode: "BUS", | |
| 319 | + sourceCode: "zda-homonym-b", | |
| 320 | + stopLabel: "Place Centrale", | |
| 321 | + localityLabel: "Saint-Denis", | |
| 322 | + }, | |
| 323 | + { | |
| 324 | + key: "homonym-a", | |
| 325 | + mode: "BUS", | |
| 326 | + sourceCode: "zda-homonym-a", | |
| 327 | + stopLabel: "Place Centrale", | |
| 328 | + localityLabel: "Saint-Denis", | |
| 329 | + }, | |
| 330 | + ]; | |
| 331 | + const rankedPlaces: PlaceSeed[] = Array.from({ length: 25 }, (_, index) => ({ | |
| 332 | + key: `rank-${index}`, | |
| 333 | + mode: "BUS" as const, | |
| 334 | + sourceCode: `zda-rank-${index}`, | |
| 335 | + stopLabel: `Alpha ${String(index).padStart(2, "0")}`, | |
| 336 | + localityLabel: "Paris", | |
| 337 | + })); | |
| 338 | + const services: ServiceSeed[] = [...homonyms, ...rankedPlaces].map((place, index) => ({ | |
| 339 | + key: `extra-service-${index}`, | |
| 340 | + placeKey: place.key, | |
| 341 | + mode: place.mode, | |
| 342 | + stopLabel: place.stopLabel, | |
| 343 | + lineLabel: `X${index}`, | |
| 344 | + destinationLabel: "Terminus", | |
| 345 | + monitoringRef: `raw-monitoring-extra-${index}`, | |
| 346 | + lineRef: `raw-line-extra-${index}`, | |
| 347 | + directionId: "outbound", | |
| 348 | + destinationRef: `terminal-extra-${index}`, | |
| 349 | + })); | |
| 350 | + const catalog = buildCatalog({ places: [...homonyms, ...rankedPlaces], services }); | |
| 351 | + const reader = SqliteCatalogReader.open(catalog.path); | |
| 352 | + | |
| 353 | + const homonymResults = reader.searchPlaces("place centrale"); | |
| 354 | + assert.equal(homonymResults.length, 2); | |
| 355 | + assert.deepEqual( | |
| 356 | + homonymResults.map((place) => place.placeId), | |
| 357 | + [catalog.placeIds["homonym-a"], catalog.placeIds["homonym-b"]].sort(), | |
| 358 | + ); | |
| 359 | + | |
| 360 | + const ranked = reader.searchPlaces("alpha"); | |
| 361 | + assert.equal(ranked.length, LIMITS.catalogSearchResults); | |
| 362 | + assert.deepEqual( | |
| 363 | + ranked.map((place) => place.stopLabel), | |
| 364 | + Array.from({ length: 20 }, (_, index) => `Alpha ${String(index).padStart(2, "0")}`), | |
| 365 | + ); | |
| 366 | +}); | |
| 367 | + | |
| 368 | +test("service options return every valid direction without applying the place-search bound", () => { | |
| 369 | + const extraServices: ServiceSeed[] = Array.from({ length: 25 }, (_, index) => ({ | |
| 370 | + key: `bus-option-${index}`, | |
| 371 | + placeKey: "bus", | |
| 372 | + mode: "BUS", | |
| 373 | + stopLabel: "République", | |
| 374 | + lineLabel: `X${String(index).padStart(2, "0")}`, | |
| 375 | + destinationLabel: `Terminus ${index}`, | |
| 376 | + monitoringRef: `raw-monitoring-bus-option-${index}`, | |
| 377 | + lineRef: `raw-line-bus-option-${index}`, | |
| 378 | + directionId: `direction-${index}`, | |
| 379 | + destinationRef: `terminal-bus-option-${index}`, | |
| 380 | + })); | |
| 381 | + const catalog = buildCatalog({ services: extraServices }); | |
| 382 | + const reader = SqliteCatalogReader.open(catalog.path); | |
| 383 | + | |
| 384 | + const options = reader.listServices(catalog.placeIds.bus); | |
| 385 | + assert.equal(options?.length, extraServices.length + 1); | |
| 386 | +}); | |
| 387 | + | |
| 388 | +test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => { | |
| 389 | + const southbound: ServiceSeed = { | |
| 390 | + key: "rer-south", | |
| 391 | + placeKey: "rer", | |
| 392 | + mode: "RER", | |
| 393 | + stopLabel: "Châtelet – Les Halles", | |
| 394 | + lineLabel: "B", | |
| 395 | + destinationLabel: "Saint-Rémy-lès-Chevreuse", | |
| 396 | + monitoringRef: "raw-shared-rer-station", | |
| 397 | + lineRef: "raw-line-rer-b", | |
| 398 | + directionId: "south", | |
| 399 | + destinationRef: "terminal-rer-south", | |
| 400 | + }; | |
| 401 | + const first = buildCatalog({ services: [southbound] }); | |
| 402 | + const manager = new CatalogManager(); | |
| 403 | + manager.reload(first.path); | |
| 404 | + | |
| 405 | + const northId = first.serviceIds["rer-north"]; | |
| 406 | + const southId = first.serviceIds["rer-south"]; | |
| 407 | + assert.equal(Buffer.byteLength(northId, "utf8"), 47); | |
| 408 | + assert.ok(Buffer.byteLength(northId, "utf8") <= LIMITS.idUtf8Bytes); | |
| 409 | + assert.notEqual(northId, southId); | |
| 410 | + assert.deepEqual(manager.resolveService(northId), { | |
| 411 | + status: "RESOLVED", | |
| 412 | + monitoringRef: "raw-shared-rer-station", | |
| 413 | + lineRef: "raw-line-rer-b", | |
| 414 | + directionId: "north", | |
| 415 | + destinationRef: "terminal-rer-north", | |
| 416 | + destinationLabel: "Aéroport Charles de Gaulle 2", | |
| 417 | + }); | |
| 418 | + assert.deepEqual(manager.resolveService(southId), { | |
| 419 | + status: "RESOLVED", | |
| 420 | + monitoringRef: "raw-shared-rer-station", | |
| 421 | + lineRef: "raw-line-rer-b", | |
| 422 | + directionId: "south", | |
| 423 | + destinationRef: "terminal-rer-south", | |
| 424 | + destinationLabel: "Saint-Rémy-lès-Chevreuse", | |
| 425 | + }); | |
| 426 | + | |
| 427 | + const invalid = buildCatalog({ catalogVersion: "2" }); | |
| 428 | + assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u); | |
| 429 | + assert.equal(manager.resolveService(southId).status, "RESOLVED"); | |
| 430 | + | |
| 431 | + const renamedNorth: ServiceSeed = { | |
| 432 | + ...BASE_SERVICES.find((service) => service.key === "rer-north")!, | |
| 433 | + destinationLabel: "Aéroport CDG 2", | |
| 434 | + }; | |
| 435 | + const replacement = buildCatalog({ | |
| 436 | + revision: "fixture-revision-b", | |
| 437 | + omitServiceKeys: ["rer-north"], | |
| 438 | + services: [renamedNorth], | |
| 439 | + }); | |
| 440 | + manager.reload(replacement.path); | |
| 441 | + | |
| 442 | + assert.equal(replacement.serviceIds["rer-north"], northId); | |
| 443 | + assert.deepEqual(manager.resolveService(northId), { | |
| 444 | + status: "RESOLVED", | |
| 445 | + monitoringRef: "raw-shared-rer-station", | |
| 446 | + lineRef: "raw-line-rer-b", | |
| 447 | + directionId: "north", | |
| 448 | + destinationRef: "terminal-rer-north", | |
| 449 | + destinationLabel: "Aéroport CDG 2", | |
| 450 | + }); | |
| 451 | + assert.deepEqual( | |
| 452 | + manager.resolveService(southId), | |
| 453 | + { status: "UNRESOLVED", code: "INVALID_SERVICE" }, | |
| 454 | + ); | |
| 455 | + assert.equal(manager.listServices("plc_missing"), undefined); | |
| 456 | + assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ | |
| 457 | + "destinationLabel", | |
| 458 | + "lineLabel", | |
| 459 | + "serviceId", | |
| 460 | + "stopLabel", | |
| 461 | + ]); | |
| 462 | +}); | |
| 463 | + | |
| 464 | +test("candidate validation rejects incomplete FTS coverage", () => { | |
| 465 | + const invalid = buildCatalog({ omitSearchFor: "metro" }); | |
| 466 | + assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u); | |
| 467 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,467 @@ | |||
| 1 | +import test from "node:test"; | ||
| 2 | +import assert from "node:assert/strict"; | ||
| 3 | +import { createHash } from "node:crypto"; | ||
| 4 | +import { mkdtempSync, rmSync } from "node:fs"; | ||
| 5 | +import { tmpdir } from "node:os"; | ||
| 6 | +import { join } from "node:path"; | ||
| 7 | +import { DatabaseSync } from "node:sqlite"; | ||
| 8 | +import { LIMITS, type TransportMode } from "../../contracts/src/index.ts"; | ||
| 9 | +import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts"; | ||
| 10 | + | ||
| 11 | +interface PlaceSeed { | ||
| 12 | + readonly key: string; | ||
| 13 | + readonly mode: TransportMode; | ||
| 14 | + readonly sourceCode: string; | ||
| 15 | + readonly stopLabel: string; | ||
| 16 | + readonly localityLabel?: string; | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +interface ServiceSeed { | ||
| 20 | + readonly key: string; | ||
| 21 | + readonly placeKey: string; | ||
| 22 | + readonly mode: TransportMode; | ||
| 23 | + readonly stopLabel: string; | ||
| 24 | + readonly lineLabel: string; | ||
| 25 | + readonly destinationLabel: string; | ||
| 26 | + readonly monitoringRef: string; | ||
| 27 | + readonly lineRef: string; | ||
| 28 | + readonly directionId: string; | ||
| 29 | + readonly destinationRef: string; | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +interface CatalogSeed { | ||
| 33 | + readonly revision?: string; | ||
| 34 | + readonly places?: readonly PlaceSeed[]; | ||
| 35 | + readonly services?: readonly ServiceSeed[]; | ||
| 36 | + readonly omitServiceKeys?: readonly string[]; | ||
| 37 | + readonly catalogVersion?: string; | ||
| 38 | + readonly omitSearchFor?: string; | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +interface BuiltCatalog { | ||
| 42 | + readonly path: string; | ||
| 43 | + readonly placeIds: Readonly<Record<string, string>>; | ||
| 44 | + readonly serviceIds: Readonly<Record<string, string>>; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +const temporaryDirectories: string[] = []; | ||
| 48 | + | ||
| 49 | +const BASE_PLACES: readonly PlaceSeed[] = [ | ||
| 50 | + { | ||
| 51 | + key: "bus", | ||
| 52 | + mode: "BUS", | ||
| 53 | + sourceCode: "zda-bus-republique", | ||
| 54 | + stopLabel: "République", | ||
| 55 | + localityLabel: "Paris", | ||
| 56 | + }, | ||
| 57 | + { | ||
| 58 | + key: "metro", | ||
| 59 | + mode: "METRO", | ||
| 60 | + sourceCode: "zda-metro-chatelet", | ||
| 61 | + stopLabel: "Châtelet", | ||
| 62 | + localityLabel: "Paris", | ||
| 63 | + }, | ||
| 64 | + { | ||
| 65 | + key: "tram", | ||
| 66 | + mode: "TRAM", | ||
| 67 | + sourceCode: "zda-tram-versailles", | ||
| 68 | + stopLabel: "Porte de Versailles", | ||
| 69 | + localityLabel: "Paris", | ||
| 70 | + }, | ||
| 71 | + { | ||
| 72 | + key: "rer", | ||
| 73 | + mode: "RER", | ||
| 74 | + sourceCode: "zda-rer-chatelet-halles", | ||
| 75 | + stopLabel: "Châtelet – Les Halles", | ||
| 76 | + localityLabel: "Paris", | ||
| 77 | + }, | ||
| 78 | + { | ||
| 79 | + key: "transilien", | ||
| 80 | + mode: "TRANSILIEN", | ||
| 81 | + sourceCode: "zda-train-saint-lazare", | ||
| 82 | + stopLabel: "Saint-Lazare", | ||
| 83 | + localityLabel: "Paris", | ||
| 84 | + }, | ||
| 85 | +]; | ||
| 86 | + | ||
| 87 | +const BASE_SERVICES: readonly ServiceSeed[] = [ | ||
| 88 | + { | ||
| 89 | + key: "bus-east", | ||
| 90 | + placeKey: "bus", | ||
| 91 | + mode: "BUS", | ||
| 92 | + stopLabel: "République", | ||
| 93 | + lineLabel: "96", | ||
| 94 | + destinationLabel: "Porte des Lilas", | ||
| 95 | + monitoringRef: "raw-monitoring-bus-east", | ||
| 96 | + lineRef: "raw-line-bus-96", | ||
| 97 | + directionId: "east", | ||
| 98 | + destinationRef: "terminal-bus-east", | ||
| 99 | + }, | ||
| 100 | + { | ||
| 101 | + key: "metro-south", | ||
| 102 | + placeKey: "metro", | ||
| 103 | + mode: "METRO", | ||
| 104 | + stopLabel: "Châtelet", | ||
| 105 | + lineLabel: "4", | ||
| 106 | + destinationLabel: "Bagneux – Lucie Aubrac", | ||
| 107 | + monitoringRef: "raw-monitoring-metro-south", | ||
| 108 | + lineRef: "raw-line-metro-4", | ||
| 109 | + directionId: "south", | ||
| 110 | + destinationRef: "terminal-metro-south", | ||
| 111 | + }, | ||
| 112 | + { | ||
| 113 | + key: "tram-west", | ||
| 114 | + placeKey: "tram", | ||
| 115 | + mode: "TRAM", | ||
| 116 | + stopLabel: "Porte de Versailles", | ||
| 117 | + lineLabel: "T2", | ||
| 118 | + destinationLabel: "Porte de Versailles", | ||
| 119 | + monitoringRef: "raw-monitoring-tram-west", | ||
| 120 | + lineRef: "raw-line-tram-t2", | ||
| 121 | + directionId: "west", | ||
| 122 | + destinationRef: "terminal-tram-west", | ||
| 123 | + }, | ||
| 124 | + { | ||
| 125 | + key: "rer-north", | ||
| 126 | + placeKey: "rer", | ||
| 127 | + mode: "RER", | ||
| 128 | + stopLabel: "Châtelet – Les Halles", | ||
| 129 | + lineLabel: "B", | ||
| 130 | + destinationLabel: "Aéroport Charles de Gaulle 2", | ||
| 131 | + monitoringRef: "raw-shared-rer-station", | ||
| 132 | + lineRef: "raw-line-rer-b", | ||
| 133 | + directionId: "north", | ||
| 134 | + destinationRef: "terminal-rer-north", | ||
| 135 | + }, | ||
| 136 | + { | ||
| 137 | + key: "train-west", | ||
| 138 | + placeKey: "transilien", | ||
| 139 | + mode: "TRANSILIEN", | ||
| 140 | + stopLabel: "Saint-Lazare", | ||
| 141 | + lineLabel: "L", | ||
| 142 | + destinationLabel: "Versailles Rive Droite", | ||
| 143 | + monitoringRef: "raw-monitoring-train-west", | ||
| 144 | + lineRef: "raw-line-train-l", | ||
| 145 | + directionId: "west", | ||
| 146 | + destinationRef: "terminal-train-west", | ||
| 147 | + }, | ||
| 148 | +]; | ||
| 149 | + | ||
| 150 | +function canonicalTuple(namespace: string, values: readonly string[]): string { | ||
| 151 | + const framed = values.map((value) => `${Buffer.byteLength(value, "utf8")}:${value}`).join("|"); | ||
| 152 | + return `${namespace}:v1|${framed}`; | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +function opaqueId(prefix: "plc_" | "svc_", canonical: string): string { | ||
| 156 | + return prefix + createHash("sha256").update(canonical).digest("base64url"); | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +function placeIdentity(place: PlaceSeed): { id: string; canonical: string } { | ||
| 160 | + const canonical = canonicalTuple("place", [place.mode, place.sourceCode]); | ||
| 161 | + return { id: opaqueId("plc_", canonical), canonical }; | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +function serviceIdentity(service: ServiceSeed): { id: string; canonical: string } { | ||
| 165 | + const canonical = canonicalTuple("service", [ | ||
| 166 | + service.mode, | ||
| 167 | + service.lineRef, | ||
| 168 | + service.monitoringRef, | ||
| 169 | + service.directionId, | ||
| 170 | + service.destinationRef, | ||
| 171 | + ]); | ||
| 172 | + return { id: opaqueId("svc_", canonical), canonical }; | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +function searchText(place: PlaceSeed): string { | ||
| 176 | + return [place.stopLabel, place.localityLabel, place.mode] | ||
| 177 | + .filter((value): value is string => value !== undefined) | ||
| 178 | + .join(" ") | ||
| 179 | + .normalize("NFKD") | ||
| 180 | + .replace(/\p{M}+/gu, "") | ||
| 181 | + .toLowerCase() | ||
| 182 | + .replace(/[^\p{L}\p{N}]+/gu, " ") | ||
| 183 | + .trim() | ||
| 184 | + .replace(/\s+/gu, " "); | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | ||
| 188 | + const directory = mkdtempSync(join(tmpdir(), "lapin-catalog-runtime-")); | ||
| 189 | + temporaryDirectories.push(directory); | ||
| 190 | + const path = join(directory, "catalog.sqlite"); | ||
| 191 | + const database = new DatabaseSync(path); | ||
| 192 | + database.exec(` | ||
| 193 | + PRAGMA foreign_keys = ON; | ||
| 194 | + CREATE TABLE metadata( | ||
| 195 | + key TEXT PRIMARY KEY, | ||
| 196 | + value TEXT NOT NULL | ||
| 197 | + ); | ||
| 198 | + CREATE TABLE places( | ||
| 199 | + place_id TEXT PRIMARY KEY, | ||
| 200 | + stop_label TEXT NOT NULL, | ||
| 201 | + locality_label TEXT, | ||
| 202 | + mode TEXT NOT NULL, | ||
| 203 | + canonical_tuple TEXT NOT NULL UNIQUE | ||
| 204 | + ); | ||
| 205 | + CREATE TABLE services( | ||
| 206 | + service_id TEXT PRIMARY KEY, | ||
| 207 | + place_id TEXT NOT NULL REFERENCES places(place_id), | ||
| 208 | + stop_label TEXT NOT NULL, | ||
| 209 | + line_label TEXT NOT NULL, | ||
| 210 | + destination_label TEXT NOT NULL, | ||
| 211 | + monitoring_ref TEXT NOT NULL, | ||
| 212 | + line_ref TEXT NOT NULL, | ||
| 213 | + direction_id TEXT NOT NULL, | ||
| 214 | + destination_ref TEXT NOT NULL, | ||
| 215 | + canonical_tuple TEXT NOT NULL UNIQUE | ||
| 216 | + ); | ||
| 217 | + CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); | ||
| 218 | + `); | ||
| 219 | + | ||
| 220 | + const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | ||
| 221 | + metadata.run("schema_version", "1"); | ||
| 222 | + metadata.run("catalog_version", seed.catalogVersion ?? "1"); | ||
| 223 | + metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); | ||
| 224 | + metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | ||
| 225 | + | ||
| 226 | + const places = [...BASE_PLACES, ...(seed.places ?? [])]; | ||
| 227 | + const omittedServiceKeys = new Set(seed.omitServiceKeys ?? []); | ||
| 228 | + const services = [ | ||
| 229 | + ...BASE_SERVICES.filter((service) => !omittedServiceKeys.has(service.key)), | ||
| 230 | + ...(seed.services ?? []), | ||
| 231 | + ]; | ||
| 232 | + const placeIds: Record<string, string> = {}; | ||
| 233 | + const serviceIds: Record<string, string> = {}; | ||
| 234 | + const insertPlace = database.prepare(` | ||
| 235 | + INSERT INTO places(place_id, stop_label, locality_label, mode, canonical_tuple) | ||
| 236 | + VALUES (?, ?, ?, ?, ?) | ||
| 237 | + `); | ||
| 238 | + const insertSearch = database.prepare( | ||
| 239 | + "INSERT INTO place_search(search_text, place_id) VALUES (?, ?)", | ||
| 240 | + ); | ||
| 241 | + for (const place of places) { | ||
| 242 | + const identity = placeIdentity(place); | ||
| 243 | + placeIds[place.key] = identity.id; | ||
| 244 | + insertPlace.run( | ||
| 245 | + identity.id, | ||
| 246 | + place.stopLabel, | ||
| 247 | + place.localityLabel ?? null, | ||
| 248 | + place.mode, | ||
| 249 | + identity.canonical, | ||
| 250 | + ); | ||
| 251 | + if (place.key !== seed.omitSearchFor) insertSearch.run(searchText(place), identity.id); | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + const insertService = database.prepare(` | ||
| 255 | + INSERT INTO services( | ||
| 256 | + service_id, | ||
| 257 | + place_id, | ||
| 258 | + stop_label, | ||
| 259 | + line_label, | ||
| 260 | + destination_label, | ||
| 261 | + monitoring_ref, | ||
| 262 | + line_ref, | ||
| 263 | + direction_id, | ||
| 264 | + destination_ref, | ||
| 265 | + canonical_tuple | ||
| 266 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 267 | + `); | ||
| 268 | + for (const service of services) { | ||
| 269 | + const identity = serviceIdentity(service); | ||
| 270 | + serviceIds[service.key] = identity.id; | ||
| 271 | + insertService.run( | ||
| 272 | + identity.id, | ||
| 273 | + placeIds[service.placeKey], | ||
| 274 | + service.stopLabel, | ||
| 275 | + service.lineLabel, | ||
| 276 | + service.destinationLabel, | ||
| 277 | + service.monitoringRef, | ||
| 278 | + service.lineRef, | ||
| 279 | + service.directionId, | ||
| 280 | + service.destinationRef, | ||
| 281 | + identity.canonical, | ||
| 282 | + ); | ||
| 283 | + } | ||
| 284 | + database.close(); | ||
| 285 | + return { path, placeIds, serviceIds }; | ||
| 286 | +} | ||
| 287 | + | ||
| 288 | +test.afterEach(() => { | ||
| 289 | + while (temporaryDirectories.length > 0) { | ||
| 290 | + const directory = temporaryDirectories.pop(); | ||
| 291 | + if (directory !== undefined) rmSync(directory, { recursive: true, force: true }); | ||
| 292 | + } | ||
| 293 | +}); | ||
| 294 | + | ||
| 295 | +test("search is accent tolerant, punctuation safe, and bounded by Unicode code points", () => { | ||
| 296 | + const catalog = buildCatalog(); | ||
| 297 | + const reader = SqliteCatalogReader.open(catalog.path); | ||
| 298 | + | ||
| 299 | + assert.deepEqual( | ||
| 300 | + reader.searchPlaces("chatelet").map((place) => place.placeId), | ||
| 301 | + [catalog.placeIds.metro, catalog.placeIds.rer], | ||
| 302 | + ); | ||
| 303 | + assert.deepEqual( | ||
| 304 | + reader.searchPlaces(" ChÂtElEt---LES...HALLES ").map((place) => place.placeId), | ||
| 305 | + [catalog.placeIds.rer], | ||
| 306 | + ); | ||
| 307 | + assert.throws(() => reader.searchPlaces("a"), RangeError); | ||
| 308 | + assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError); | ||
| 309 | + assert.throws(() => reader.searchPlaces("--"), RangeError); | ||
| 310 | + assert.doesNotThrow(() => reader.searchPlaces("😀a")); | ||
| 311 | + assert.doesNotThrow(() => reader.searchPlaces("z".repeat(100))); | ||
| 312 | +}); | ||
| 313 | + | ||
| 314 | +test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => { | ||
| 315 | + const homonyms: PlaceSeed[] = [ | ||
| 316 | + { | ||
| 317 | + key: "homonym-b", | ||
| 318 | + mode: "BUS", | ||
| 319 | + sourceCode: "zda-homonym-b", | ||
| 320 | + stopLabel: "Place Centrale", | ||
| 321 | + localityLabel: "Saint-Denis", | ||
| 322 | + }, | ||
| 323 | + { | ||
| 324 | + key: "homonym-a", | ||
| 325 | + mode: "BUS", | ||
| 326 | + sourceCode: "zda-homonym-a", | ||
| 327 | + stopLabel: "Place Centrale", | ||
| 328 | + localityLabel: "Saint-Denis", | ||
| 329 | + }, | ||
| 330 | + ]; | ||
| 331 | + const rankedPlaces: PlaceSeed[] = Array.from({ length: 25 }, (_, index) => ({ | ||
| 332 | + key: `rank-${index}`, | ||
| 333 | + mode: "BUS" as const, | ||
| 334 | + sourceCode: `zda-rank-${index}`, | ||
| 335 | + stopLabel: `Alpha ${String(index).padStart(2, "0")}`, | ||
| 336 | + localityLabel: "Paris", | ||
| 337 | + })); | ||
| 338 | + const services: ServiceSeed[] = [...homonyms, ...rankedPlaces].map((place, index) => ({ | ||
| 339 | + key: `extra-service-${index}`, | ||
| 340 | + placeKey: place.key, | ||
| 341 | + mode: place.mode, | ||
| 342 | + stopLabel: place.stopLabel, | ||
| 343 | + lineLabel: `X${index}`, | ||
| 344 | + destinationLabel: "Terminus", | ||
| 345 | + monitoringRef: `raw-monitoring-extra-${index}`, | ||
| 346 | + lineRef: `raw-line-extra-${index}`, | ||
| 347 | + directionId: "outbound", | ||
| 348 | + destinationRef: `terminal-extra-${index}`, | ||
| 349 | + })); | ||
| 350 | + const catalog = buildCatalog({ places: [...homonyms, ...rankedPlaces], services }); | ||
| 351 | + const reader = SqliteCatalogReader.open(catalog.path); | ||
| 352 | + | ||
| 353 | + const homonymResults = reader.searchPlaces("place centrale"); | ||
| 354 | + assert.equal(homonymResults.length, 2); | ||
| 355 | + assert.deepEqual( | ||
| 356 | + homonymResults.map((place) => place.placeId), | ||
| 357 | + [catalog.placeIds["homonym-a"], catalog.placeIds["homonym-b"]].sort(), | ||
| 358 | + ); | ||
| 359 | + | ||
| 360 | + const ranked = reader.searchPlaces("alpha"); | ||
| 361 | + assert.equal(ranked.length, LIMITS.catalogSearchResults); | ||
| 362 | + assert.deepEqual( | ||
| 363 | + ranked.map((place) => place.stopLabel), | ||
| 364 | + Array.from({ length: 20 }, (_, index) => `Alpha ${String(index).padStart(2, "0")}`), | ||
| 365 | + ); | ||
| 366 | +}); | ||
| 367 | + | ||
| 368 | +test("service options return every valid direction without applying the place-search bound", () => { | ||
| 369 | + const extraServices: ServiceSeed[] = Array.from({ length: 25 }, (_, index) => ({ | ||
| 370 | + key: `bus-option-${index}`, | ||
| 371 | + placeKey: "bus", | ||
| 372 | + mode: "BUS", | ||
| 373 | + stopLabel: "République", | ||
| 374 | + lineLabel: `X${String(index).padStart(2, "0")}`, | ||
| 375 | + destinationLabel: `Terminus ${index}`, | ||
| 376 | + monitoringRef: `raw-monitoring-bus-option-${index}`, | ||
| 377 | + lineRef: `raw-line-bus-option-${index}`, | ||
| 378 | + directionId: `direction-${index}`, | ||
| 379 | + destinationRef: `terminal-bus-option-${index}`, | ||
| 380 | + })); | ||
| 381 | + const catalog = buildCatalog({ services: extraServices }); | ||
| 382 | + const reader = SqliteCatalogReader.open(catalog.path); | ||
| 383 | + | ||
| 384 | + const options = reader.listServices(catalog.placeIds.bus); | ||
| 385 | + assert.equal(options?.length, extraServices.length + 1); | ||
| 386 | +}); | ||
| 387 | + | ||
| 388 | +test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => { | ||
| 389 | + const southbound: ServiceSeed = { | ||
| 390 | + key: "rer-south", | ||
| 391 | + placeKey: "rer", | ||
| 392 | + mode: "RER", | ||
| 393 | + stopLabel: "Châtelet – Les Halles", | ||
| 394 | + lineLabel: "B", | ||
| 395 | + destinationLabel: "Saint-Rémy-lès-Chevreuse", | ||
| 396 | + monitoringRef: "raw-shared-rer-station", | ||
| 397 | + lineRef: "raw-line-rer-b", | ||
| 398 | + directionId: "south", | ||
| 399 | + destinationRef: "terminal-rer-south", | ||
| 400 | + }; | ||
| 401 | + const first = buildCatalog({ services: [southbound] }); | ||
| 402 | + const manager = new CatalogManager(); | ||
| 403 | + manager.reload(first.path); | ||
| 404 | + | ||
| 405 | + const northId = first.serviceIds["rer-north"]; | ||
| 406 | + const southId = first.serviceIds["rer-south"]; | ||
| 407 | + assert.equal(Buffer.byteLength(northId, "utf8"), 47); | ||
| 408 | + assert.ok(Buffer.byteLength(northId, "utf8") <= LIMITS.idUtf8Bytes); | ||
| 409 | + assert.notEqual(northId, southId); | ||
| 410 | + assert.deepEqual(manager.resolveService(northId), { | ||
| 411 | + status: "RESOLVED", | ||
| 412 | + monitoringRef: "raw-shared-rer-station", | ||
| 413 | + lineRef: "raw-line-rer-b", | ||
| 414 | + directionId: "north", | ||
| 415 | + destinationRef: "terminal-rer-north", | ||
| 416 | + destinationLabel: "Aéroport Charles de Gaulle 2", | ||
| 417 | + }); | ||
| 418 | + assert.deepEqual(manager.resolveService(southId), { | ||
| 419 | + status: "RESOLVED", | ||
| 420 | + monitoringRef: "raw-shared-rer-station", | ||
| 421 | + lineRef: "raw-line-rer-b", | ||
| 422 | + directionId: "south", | ||
| 423 | + destinationRef: "terminal-rer-south", | ||
| 424 | + destinationLabel: "Saint-Rémy-lès-Chevreuse", | ||
| 425 | + }); | ||
| 426 | + | ||
| 427 | + const invalid = buildCatalog({ catalogVersion: "2" }); | ||
| 428 | + assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u); | ||
| 429 | + assert.equal(manager.resolveService(southId).status, "RESOLVED"); | ||
| 430 | + | ||
| 431 | + const renamedNorth: ServiceSeed = { | ||
| 432 | + ...BASE_SERVICES.find((service) => service.key === "rer-north")!, | ||
| 433 | + destinationLabel: "Aéroport CDG 2", | ||
| 434 | + }; | ||
| 435 | + const replacement = buildCatalog({ | ||
| 436 | + revision: "fixture-revision-b", | ||
| 437 | + omitServiceKeys: ["rer-north"], | ||
| 438 | + services: [renamedNorth], | ||
| 439 | + }); | ||
| 440 | + manager.reload(replacement.path); | ||
| 441 | + | ||
| 442 | + assert.equal(replacement.serviceIds["rer-north"], northId); | ||
| 443 | + assert.deepEqual(manager.resolveService(northId), { | ||
| 444 | + status: "RESOLVED", | ||
| 445 | + monitoringRef: "raw-shared-rer-station", | ||
| 446 | + lineRef: "raw-line-rer-b", | ||
| 447 | + directionId: "north", | ||
| 448 | + destinationRef: "terminal-rer-north", | ||
| 449 | + destinationLabel: "Aéroport CDG 2", | ||
| 450 | + }); | ||
| 451 | + assert.deepEqual( | ||
| 452 | + manager.resolveService(southId), | ||
| 453 | + { status: "UNRESOLVED", code: "INVALID_SERVICE" }, | ||
| 454 | + ); | ||
| 455 | + assert.equal(manager.listServices("plc_missing"), undefined); | ||
| 456 | + assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ | ||
| 457 | + "destinationLabel", | ||
| 458 | + "lineLabel", | ||
| 459 | + "serviceId", | ||
| 460 | + "stopLabel", | ||
| 461 | + ]); | ||
| 462 | +}); | ||
| 463 | + | ||
| 464 | +test("candidate validation rejects incomplete FTS coverage", () => { | ||
| 465 | + const invalid = buildCatalog({ omitSearchFor: "metro" }); | ||
| 466 | + assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u); | ||
| 467 | +}); | ||
modified
packages/companion/src/contracts.js +4 -1 | @@ -8,7 +8,10 @@ var LIMITS = Object.freeze({ | ||
| 8 | 8 | favorites: 8, |
| 9 | 9 | departures: 4, |
| 10 | 10 | httpTimeoutMs: 8000, |
| 11 | - httpResponseBytes: 262144 | |
| 11 | + httpResponseBytes: 262144, | |
| 12 | + catalogQueryMinCharacters: 2, | |
| 13 | + catalogQueryMaxCharacters: 100, | |
| 14 | + catalogSearchResults: 20 | |
| 12 | 15 | }); |
| 13 | 16 | var CACHE_FRESH_SECONDS = 60; |
| 14 | 17 | 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: 262144 | 11 | + 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 = { | ||
| 8 | 8 | departures: 4, |
| 9 | 9 | httpTimeoutMs: 8_000, |
| 10 | 10 | httpResponseBytes: 262_144, |
| 11 | + catalogQueryMinCharacters: 2, | |
| 12 | + catalogQueryMaxCharacters: 100, | |
| 13 | + catalogSearchResults: 20, | |
| 11 | 14 | } as const; |
| 12 | 15 | |
| 13 | 16 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; |
| @@ -24,6 +27,9 @@ export const ERROR_CODE = [ | ||
| 24 | 27 | "RATE_LIMITED", |
| 25 | 28 | "INVALID_RESPONSE", |
| 26 | 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 | 33 | export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const; |
| 28 | 34 | export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const; |
| 29 | 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 | 103 | export type Freshness = (typeof FRESHNESS)[number]; |
| 98 | 104 | export type DepartureStatus = (typeof DEPARTURE_STATUS)[number]; |
| 99 | 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 | 108 | export type ApiKeyAction = (typeof API_KEY_ACTION)[number]; |
| 101 | 109 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; |
| 102 | 110 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; |
| @@ -138,6 +146,35 @@ export interface ErrorResult { | ||
| 138 | 146 | occurredAt: number; |
| 139 | 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 | 179 | export type ApiKeyUpdate = |
| 143 | 180 | | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" } |
| @@ -155,6 +192,11 @@ const domainKeys = { | ||
| 155 | 192 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], |
| 156 | 193 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], |
| 157 | 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 | 200 | } as const; |
| 159 | 201 | |
| 160 | 202 | function object(value: unknown): value is Record<string, unknown> { |
| @@ -234,6 +276,44 @@ export function isErrorResult(value: unknown): value is ErrorResult { | ||
| 234 | 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 | 317 | export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate { |
| 238 | 318 | if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false; |
| 239 | 319 | 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 | @@ -4,6 +4,8 @@ import { | ||
| 4 | 4 | API_KEY_ACTION, |
| 5 | 5 | APP_MESSAGE_KEY, |
| 6 | 6 | APP_MESSAGE_KEY_ORDER, |
| 7 | + CATALOG_ERROR_CODE, | |
| 8 | + CATALOG_ROUTE, | |
| 7 | 9 | DEPARTURE_STATUS, |
| 8 | 10 | ERROR_CODE, |
| 9 | 11 | FRESHNESS, |
| @@ -13,14 +15,20 @@ import { | ||
| 13 | 15 | ProtocolReceiver, |
| 14 | 16 | REQUEST_TRIGGER, |
| 15 | 17 | SCHEMA_VERSION, |
| 18 | + TRANSPORT_MODE, | |
| 16 | 19 | cstringBytes, |
| 17 | 20 | dictionaryBytes, |
| 18 | 21 | isApiKeyUpdate, |
| 19 | 22 | isAppMessage, |
| 23 | + isCatalogErrorResult, | |
| 20 | 24 | isDepartureResult, |
| 21 | 25 | isErrorResult, |
| 22 | 26 | isFavorite, |
| 23 | 27 | isPersonalApiKey, |
| 28 | + isPlaceSearchItem, | |
| 29 | + isPlaceSearchResult, | |
| 30 | + isServiceOption, | |
| 31 | + isServiceOptionsResult, | |
| 24 | 32 | utf8Bytes, |
| 25 | 33 | type AppMessage, |
| 26 | 34 | } from "../src/index.ts"; |
| @@ -219,3 +227,94 @@ test("result staging requires request and favorite binding and preserves committ | ||
| 219 | 227 | assert.equal(receiver.receive({ ...begin, SCHEMA_VERSION: 99 }), false); |
| 220 | 228 | assert.equal(receiver.committed.result, committed); |
| 221 | 229 | }); |
| 230 | + | |
| 231 | +test("catalog contracts freeze routes, transport modes, error codes, and limits", () => { | |
| 232 | + assert.deepEqual(CATALOG_ROUTE, { | |
| 233 | + places: "/api/catalog/places", | |
| 234 | + placeServices: "/api/catalog/places/:placeId/services", | |
| 235 | + }); | |
| 236 | + assert.equal(Object.values(CATALOG_ROUTE).every((route) => route.startsWith("/api/catalog/")), true); | |
| 237 | + assert.deepEqual(TRANSPORT_MODE, ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]); | |
| 238 | + assert.deepEqual(CATALOG_ERROR_CODE, [ | |
| 239 | + "INVALID_QUERY", | |
| 240 | + "PLACE_NOT_FOUND", | |
| 241 | + "CATALOG_UNAVAILABLE", | |
| 242 | + "METHOD_NOT_ALLOWED", | |
| 243 | + ]); | |
| 244 | + assert.equal(LIMITS.catalogQueryMinCharacters, 2); | |
| 245 | + assert.equal(LIMITS.catalogQueryMaxCharacters, 100); | |
| 246 | + assert.equal(LIMITS.catalogSearchResults, 20); | |
| 247 | +}); | |
| 248 | + | |
| 249 | +test("catalog validators enforce exact public fields, bounds, and no raw source identifiers", () => { | |
| 250 | + const opaque64 = "x".repeat(LIMITS.idUtf8Bytes); | |
| 251 | + const placeItem = { | |
| 252 | + placeId: `plc_${"A".repeat(43)}`, | |
| 253 | + stopLabel: "Châtelet", | |
| 254 | + localityLabel: "Paris", | |
| 255 | + mode: "METRO", | |
| 256 | + }; | |
| 257 | + const serviceOption = { | |
| 258 | + serviceId: `svc_${"B".repeat(43)}`, | |
| 259 | + stopLabel: "Châtelet", | |
| 260 | + lineLabel: "Métro 1", | |
| 261 | + destinationLabel: "La Défense", | |
| 262 | + }; | |
| 263 | + const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode }; | |
| 264 | + | |
| 265 | + assert.equal(isPlaceSearchItem(placeItem), true); | |
| 266 | + assert.equal(isPlaceSearchItem(requiredPlace), true); | |
| 267 | + assert.equal(isPlaceSearchItem({ ...placeItem, localityLabel: undefined }), true); | |
| 268 | + assert.equal(isPlaceSearchItem({ ...placeItem, placeId: opaque64 }), true); | |
| 269 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [placeItem] }), true); | |
| 270 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [] }), true); | |
| 271 | + assert.equal(isServiceOption(serviceOption), true); | |
| 272 | + assert.equal(isServiceOptionsResult({ | |
| 273 | + schemaVersion: SCHEMA_VERSION, | |
| 274 | + placeId: placeItem.placeId, | |
| 275 | + services: [serviceOption], | |
| 276 | + }), true); | |
| 277 | + CATALOG_ERROR_CODE.forEach((code) => { | |
| 278 | + assert.equal(isCatalogErrorResult({ schemaVersion: SCHEMA_VERSION, code }), true); | |
| 279 | + }); | |
| 280 | + | |
| 281 | + assert.equal(isPlaceSearchItem({ ...placeItem, placeId: "x".repeat(LIMITS.idUtf8Bytes + 1) }), false); | |
| 282 | + assert.equal(isPlaceSearchItem({ ...placeItem, stopLabel: "" }), false); | |
| 283 | + assert.equal(isPlaceSearchItem({ ...placeItem, stopLabel: "é".repeat(LIMITS.labelUtf8Bytes / 2 + 1) }), false); | |
| 284 | + assert.equal(isPlaceSearchItem({ ...placeItem, mode: "TER" }), false); | |
| 285 | + assert.equal(isPlaceSearchItem({ ...placeItem, mode: "bus" }), false); | |
| 286 | + assert.equal(isPlaceSearchItem({ ...placeItem, monitoringRef: "StopPoint:Q-1" }), false); | |
| 287 | + assert.equal(isPlaceSearchItem({ ...requiredPlace, lineRef: "C-01234" }), false); | |
| 288 | + assert.equal(isPlaceSearchItem({ ...requiredPlace, q: "chatelet" }), false); | |
| 289 | + | |
| 290 | + const places20 = Array.from({ length: LIMITS.catalogSearchResults }, () => placeItem); | |
| 291 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: places20 }), true); | |
| 292 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [...places20, placeItem] }), false); | |
| 293 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [{ ...placeItem, unknown: true }] }), false); | |
| 294 | + assert.equal(isPlaceSearchResult({ schemaVersion: 2, places: [] }), false); | |
| 295 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION }), false); | |
| 296 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [], extra: 1 }), false); | |
| 297 | + | |
| 298 | + assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true); | |
| 299 | + assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false); | |
| 300 | + assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false); | |
| 301 | + assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false); | |
| 302 | + assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false); | |
| 303 | + const servicesBeyondLimit = Array.from({ length: LIMITS.catalogSearchResults + 1 }, () => serviceOption); | |
| 304 | + assert.equal(isServiceOptionsResult({ | |
| 305 | + schemaVersion: SCHEMA_VERSION, | |
| 306 | + placeId: opaque64, | |
| 307 | + services: servicesBeyondLimit, | |
| 308 | + }), true); | |
| 309 | + assert.equal(isServiceOptionsResult({ | |
| 310 | + schemaVersion: SCHEMA_VERSION, | |
| 311 | + placeId: placeItem.placeId, | |
| 312 | + services: servicesBeyondLimit, | |
| 313 | + apiKey: "secret", | |
| 314 | + }), false); | |
| 315 | + assert.equal(isServiceOptionsResult({ schemaVersion: SCHEMA_VERSION, placeId: placeItem.placeId, services: "all" }), false); | |
| 316 | + | |
| 317 | + assert.equal(isCatalogErrorResult({ schemaVersion: SCHEMA_VERSION, code: "INVALID_SERVICE" }), false); | |
| 318 | + assert.equal(isCatalogErrorResult({ schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY", requestId: "r" }), false); | |
| 319 | + assert.equal(isCatalogErrorResult({ code: "INVALID_QUERY" }), false); | |
| 320 | +}); | |
| @@ -4,6 +4,8 @@ import { | |||
| 4 | API_KEY_ACTION, | 4 | API_KEY_ACTION, |
| 5 | APP_MESSAGE_KEY, | 5 | APP_MESSAGE_KEY, |
| 6 | APP_MESSAGE_KEY_ORDER, | 6 | APP_MESSAGE_KEY_ORDER, |
| 7 | + CATALOG_ERROR_CODE, | ||
| 8 | + CATALOG_ROUTE, | ||
| 7 | DEPARTURE_STATUS, | 9 | DEPARTURE_STATUS, |
| 8 | ERROR_CODE, | 10 | ERROR_CODE, |
| 9 | FRESHNESS, | 11 | FRESHNESS, |
| @@ -13,14 +15,20 @@ import { | |||
| 13 | ProtocolReceiver, | 15 | ProtocolReceiver, |
| 14 | REQUEST_TRIGGER, | 16 | REQUEST_TRIGGER, |
| 15 | SCHEMA_VERSION, | 17 | SCHEMA_VERSION, |
| 18 | + TRANSPORT_MODE, | ||
| 16 | cstringBytes, | 19 | cstringBytes, |
| 17 | dictionaryBytes, | 20 | dictionaryBytes, |
| 18 | isApiKeyUpdate, | 21 | isApiKeyUpdate, |
| 19 | isAppMessage, | 22 | isAppMessage, |
| 23 | + isCatalogErrorResult, | ||
| 20 | isDepartureResult, | 24 | isDepartureResult, |
| 21 | isErrorResult, | 25 | isErrorResult, |
| 22 | isFavorite, | 26 | isFavorite, |
| 23 | isPersonalApiKey, | 27 | isPersonalApiKey, |
| 28 | + isPlaceSearchItem, | ||
| 29 | + isPlaceSearchResult, | ||
| 30 | + isServiceOption, | ||
| 31 | + isServiceOptionsResult, | ||
| 24 | utf8Bytes, | 32 | utf8Bytes, |
| 25 | type AppMessage, | 33 | type AppMessage, |
| 26 | } from "../src/index.ts"; | 34 | } from "../src/index.ts"; |
| @@ -219,3 +227,94 @@ test("result staging requires request and favorite binding and preserves committ | |||
| 219 | assert.equal(receiver.receive({ ...begin, SCHEMA_VERSION: 99 }), false); | 227 | assert.equal(receiver.receive({ ...begin, SCHEMA_VERSION: 99 }), false); |
| 220 | assert.equal(receiver.committed.result, committed); | 228 | assert.equal(receiver.committed.result, committed); |
| 221 | }); | 229 | }); |
| 230 | + | ||
| 231 | +test("catalog contracts freeze routes, transport modes, error codes, and limits", () => { | ||
| 232 | + assert.deepEqual(CATALOG_ROUTE, { | ||
| 233 | + places: "/api/catalog/places", | ||
| 234 | + placeServices: "/api/catalog/places/:placeId/services", | ||
| 235 | + }); | ||
| 236 | + assert.equal(Object.values(CATALOG_ROUTE).every((route) => route.startsWith("/api/catalog/")), true); | ||
| 237 | + assert.deepEqual(TRANSPORT_MODE, ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]); | ||
| 238 | + assert.deepEqual(CATALOG_ERROR_CODE, [ | ||
| 239 | + "INVALID_QUERY", | ||
| 240 | + "PLACE_NOT_FOUND", | ||
| 241 | + "CATALOG_UNAVAILABLE", | ||
| 242 | + "METHOD_NOT_ALLOWED", | ||
| 243 | + ]); | ||
| 244 | + assert.equal(LIMITS.catalogQueryMinCharacters, 2); | ||
| 245 | + assert.equal(LIMITS.catalogQueryMaxCharacters, 100); | ||
| 246 | + assert.equal(LIMITS.catalogSearchResults, 20); | ||
| 247 | +}); | ||
| 248 | + | ||
| 249 | +test("catalog validators enforce exact public fields, bounds, and no raw source identifiers", () => { | ||
| 250 | + const opaque64 = "x".repeat(LIMITS.idUtf8Bytes); | ||
| 251 | + const placeItem = { | ||
| 252 | + placeId: `plc_${"A".repeat(43)}`, | ||
| 253 | + stopLabel: "Châtelet", | ||
| 254 | + localityLabel: "Paris", | ||
| 255 | + mode: "METRO", | ||
| 256 | + }; | ||
| 257 | + const serviceOption = { | ||
| 258 | + serviceId: `svc_${"B".repeat(43)}`, | ||
| 259 | + stopLabel: "Châtelet", | ||
| 260 | + lineLabel: "Métro 1", | ||
| 261 | + destinationLabel: "La Défense", | ||
| 262 | + }; | ||
| 263 | + const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode }; | ||
| 264 | + | ||
| 265 | + assert.equal(isPlaceSearchItem(placeItem), true); | ||
| 266 | + assert.equal(isPlaceSearchItem(requiredPlace), true); | ||
| 267 | + assert.equal(isPlaceSearchItem({ ...placeItem, localityLabel: undefined }), true); | ||
| 268 | + assert.equal(isPlaceSearchItem({ ...placeItem, placeId: opaque64 }), true); | ||
| 269 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [placeItem] }), true); | ||
| 270 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [] }), true); | ||
| 271 | + assert.equal(isServiceOption(serviceOption), true); | ||
| 272 | + assert.equal(isServiceOptionsResult({ | ||
| 273 | + schemaVersion: SCHEMA_VERSION, | ||
| 274 | + placeId: placeItem.placeId, | ||
| 275 | + services: [serviceOption], | ||
| 276 | + }), true); | ||
| 277 | + CATALOG_ERROR_CODE.forEach((code) => { | ||
| 278 | + assert.equal(isCatalogErrorResult({ schemaVersion: SCHEMA_VERSION, code }), true); | ||
| 279 | + }); | ||
| 280 | + | ||
| 281 | + assert.equal(isPlaceSearchItem({ ...placeItem, placeId: "x".repeat(LIMITS.idUtf8Bytes + 1) }), false); | ||
| 282 | + assert.equal(isPlaceSearchItem({ ...placeItem, stopLabel: "" }), false); | ||
| 283 | + assert.equal(isPlaceSearchItem({ ...placeItem, stopLabel: "é".repeat(LIMITS.labelUtf8Bytes / 2 + 1) }), false); | ||
| 284 | + assert.equal(isPlaceSearchItem({ ...placeItem, mode: "TER" }), false); | ||
| 285 | + assert.equal(isPlaceSearchItem({ ...placeItem, mode: "bus" }), false); | ||
| 286 | + assert.equal(isPlaceSearchItem({ ...placeItem, monitoringRef: "StopPoint:Q-1" }), false); | ||
| 287 | + assert.equal(isPlaceSearchItem({ ...requiredPlace, lineRef: "C-01234" }), false); | ||
| 288 | + assert.equal(isPlaceSearchItem({ ...requiredPlace, q: "chatelet" }), false); | ||
| 289 | + | ||
| 290 | + const places20 = Array.from({ length: LIMITS.catalogSearchResults }, () => placeItem); | ||
| 291 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: places20 }), true); | ||
| 292 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [...places20, placeItem] }), false); | ||
| 293 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [{ ...placeItem, unknown: true }] }), false); | ||
| 294 | + assert.equal(isPlaceSearchResult({ schemaVersion: 2, places: [] }), false); | ||
| 295 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION }), false); | ||
| 296 | + assert.equal(isPlaceSearchResult({ schemaVersion: SCHEMA_VERSION, places: [], extra: 1 }), false); | ||
| 297 | + | ||
| 298 | + assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true); | ||
| 299 | + assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false); | ||
| 300 | + assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false); | ||
| 301 | + assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false); | ||
| 302 | + assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false); | ||
| 303 | + const servicesBeyondLimit = Array.from({ length: LIMITS.catalogSearchResults + 1 }, () => serviceOption); | ||
| 304 | + assert.equal(isServiceOptionsResult({ | ||
| 305 | + schemaVersion: SCHEMA_VERSION, | ||
| 306 | + placeId: opaque64, | ||
| 307 | + services: servicesBeyondLimit, | ||
| 308 | + }), true); | ||
| 309 | + assert.equal(isServiceOptionsResult({ | ||
| 310 | + schemaVersion: SCHEMA_VERSION, | ||
| 311 | + placeId: placeItem.placeId, | ||
| 312 | + services: servicesBeyondLimit, | ||
| 313 | + apiKey: "secret", | ||
| 314 | + }), false); | ||
| 315 | + assert.equal(isServiceOptionsResult({ schemaVersion: SCHEMA_VERSION, placeId: placeItem.placeId, services: "all" }), false); | ||
| 316 | + | ||
| 317 | + assert.equal(isCatalogErrorResult({ schemaVersion: SCHEMA_VERSION, code: "INVALID_SERVICE" }), false); | ||
| 318 | + assert.equal(isCatalogErrorResult({ schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY", requestId: "r" }), false); | ||
| 319 | + assert.equal(isCatalogErrorResult({ code: "INVALID_QUERY" }), false); | ||
| 320 | +}); | ||
added
scripts/measure-catalog.mjs +262 -0 | new file mode 100644 | ||
| @@ -0,0 +1,262 @@ | ||
| 1 | +import { | |
| 2 | + existsSync, | |
| 3 | + mkdirSync, | |
| 4 | + renameSync, | |
| 5 | + rmSync, | |
| 6 | + writeFileSync, | |
| 7 | +} from "node:fs"; | |
| 8 | +import { dirname, join, resolve } from "node:path"; | |
| 9 | +import { performance } from "node:perf_hooks"; | |
| 10 | +import { DatabaseSync } from "node:sqlite"; | |
| 11 | +import { fileURLToPath } from "node:url"; | |
| 12 | +import { redactSecrets, SqliteCatalogReader } from "../packages/backend/src/index.ts"; | |
| 13 | +import { normalizeCatalogSearchText } from "../packages/backend/src/catalog-import.ts"; | |
| 14 | +import { | |
| 15 | + LIMITS, | |
| 16 | + TRANSPORT_MODE, | |
| 17 | +} from "../packages/contracts/src/index.ts"; | |
| 18 | + | |
| 19 | +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 20 | +const DEFAULT_ACTIVE_PATH = join(PROJECT_ROOT, "var/catalog/catalog.sqlite"); | |
| 21 | +const ACTIVE_PATH = resolve(process.env.CATALOG_PATH ?? DEFAULT_ACTIVE_PATH); | |
| 22 | +const EVIDENCE_PATH = join(PROJECT_ROOT, "var/catalog/evidence/measure-catalog.json"); | |
| 23 | +const QUERY_COUNT = 1_000; | |
| 24 | +const MIN_QUERY_COUNT = 1_000; | |
| 25 | +let executedQueries = 0; | |
| 26 | + | |
| 27 | +function record(value) { | |
| 28 | + return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 29 | + ? value | |
| 30 | + : undefined; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function boundedPublicQuery(stopLabel, localityLabel) { | |
| 34 | + const stop = typeof stopLabel === "string" ? stopLabel.trim() : ""; | |
| 35 | + let points = [...stop]; | |
| 36 | + if (points.length < LIMITS.catalogQueryMinCharacters) { | |
| 37 | + const locality = typeof localityLabel === "string" ? localityLabel.trim() : ""; | |
| 38 | + points = [...`${stop} ${locality}`.trim()]; | |
| 39 | + } | |
| 40 | + if (points.length < LIMITS.catalogQueryMinCharacters) return undefined; | |
| 41 | + return points.slice(0, LIMITS.catalogQueryMaxCharacters).join(""); | |
| 42 | +} | |
| 43 | + | |
| 44 | +function selectRepresentativeQueries(database) { | |
| 45 | + const statement = database.prepare(` | |
| 46 | + SELECT stop_label AS stopLabel, locality_label AS localityLabel | |
| 47 | + FROM places | |
| 48 | + WHERE mode = ? | |
| 49 | + ORDER BY stop_label COLLATE NOCASE, place_id | |
| 50 | + `); | |
| 51 | + const representatives = []; | |
| 52 | + | |
| 53 | + for (const mode of TRANSPORT_MODE) { | |
| 54 | + let fallback; | |
| 55 | + let accented; | |
| 56 | + for (const rowValue of statement.iterate(mode)) { | |
| 57 | + const row = record(rowValue); | |
| 58 | + const query = boundedPublicQuery(row?.stopLabel, row?.localityLabel); | |
| 59 | + if (query === undefined) continue; | |
| 60 | + fallback ??= query; | |
| 61 | + const withoutAccents = normalizeCatalogSearchText(query); | |
| 62 | + if (withoutAccents !== "" && withoutAccents !== query) { | |
| 63 | + accented = { mode, accented: query, unaccented: withoutAccents }; | |
| 64 | + break; | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + const query = accented ?? (fallback === undefined | |
| 69 | + ? undefined | |
| 70 | + : { mode, accented: fallback, unaccented: normalizeCatalogSearchText(fallback) }); | |
| 71 | + if (query === undefined) { | |
| 72 | + throw new Error(`Active catalog has no usable public ${mode} label`); | |
| 73 | + } | |
| 74 | + representatives.push(query); | |
| 75 | + } | |
| 76 | + | |
| 77 | + if (!representatives.some((query) => query.accented !== query.unaccented)) { | |
| 78 | + throw new Error("Active catalog has no accented public label for the measurement set"); | |
| 79 | + } | |
| 80 | + return representatives; | |
| 81 | +} | |
| 82 | + | |
| 83 | +function catalogFacts() { | |
| 84 | + const database = new DatabaseSync(ACTIVE_PATH, { | |
| 85 | + readOnly: true, | |
| 86 | + enableForeignKeyConstraints: true, | |
| 87 | + enableDoubleQuotedStringLiterals: false, | |
| 88 | + allowExtension: false, | |
| 89 | + defensive: true, | |
| 90 | + }); | |
| 91 | + | |
| 92 | + try { | |
| 93 | + const metadataRows = database.prepare(` | |
| 94 | + SELECT key, value | |
| 95 | + FROM metadata | |
| 96 | + WHERE key IN ('schema_version', 'catalog_version', 'source_revision', 'created_at') | |
| 97 | + `).all(); | |
| 98 | + const metadata = Object.fromEntries(metadataRows.map((row) => [row.key, row.value])); | |
| 99 | + for (const key of ["schema_version", "catalog_version", "source_revision", "created_at"]) { | |
| 100 | + if (typeof metadata[key] !== "string" || metadata[key].trim() === "") { | |
| 101 | + throw new Error(`Active catalog metadata is missing ${key}`); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + const placeCount = Number(database.prepare("SELECT count(*) AS count FROM places").get().count); | |
| 106 | + const serviceCount = Number(database.prepare("SELECT count(*) AS count FROM services").get().count); | |
| 107 | + if (!Number.isSafeInteger(placeCount) || placeCount < 1 | |
| 108 | + || !Number.isSafeInteger(serviceCount) || serviceCount < 1) { | |
| 109 | + throw new Error("Active catalog counts are invalid"); | |
| 110 | + } | |
| 111 | + | |
| 112 | + const countsByMode = Object.fromEntries(TRANSPORT_MODE.map((mode) => [mode, 0])); | |
| 113 | + for (const row of database.prepare(` | |
| 114 | + SELECT places.mode AS mode, count(services.service_id) AS count | |
| 115 | + FROM places | |
| 116 | + JOIN services ON services.place_id = places.place_id | |
| 117 | + GROUP BY places.mode | |
| 118 | + `).all()) { | |
| 119 | + if (Object.hasOwn(countsByMode, row.mode)) countsByMode[row.mode] = Number(row.count); | |
| 120 | + } | |
| 121 | + for (const mode of TRANSPORT_MODE) { | |
| 122 | + if (!Number.isSafeInteger(countsByMode[mode]) || countsByMode[mode] < 1) { | |
| 123 | + throw new Error(`Active catalog has no ${mode} services`); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + return { | |
| 128 | + metadata, | |
| 129 | + placeCount, | |
| 130 | + serviceCount, | |
| 131 | + countsByMode, | |
| 132 | + representatives: selectRepresentativeQueries(database), | |
| 133 | + }; | |
| 134 | + } finally { | |
| 135 | + database.close(); | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +function percentile(sorted, percentage) { | |
| 140 | + const index = Math.max(0, Math.ceil((percentage / 100) * sorted.length) - 1); | |
| 141 | + return sorted[index]; | |
| 142 | +} | |
| 143 | + | |
| 144 | +function milliseconds(value) { | |
| 145 | + return Math.round(value * 1_000) / 1_000; | |
| 146 | +} | |
| 147 | + | |
| 148 | +function evidenceSecrets() { | |
| 149 | + return [process.env.IDFM_DATASET_TOKEN, process.env.PRIM_API_KEY] | |
| 150 | + .filter((value) => typeof value === "string" && value.length > 0); | |
| 151 | +} | |
| 152 | + | |
| 153 | +function writeEvidence(report) { | |
| 154 | + mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); | |
| 155 | + const temporaryPath = `${EVIDENCE_PATH}.${process.pid}.tmp`; | |
| 156 | + const serialized = `${JSON.stringify(report, null, 2)}\n`; | |
| 157 | + try { | |
| 158 | + writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 }); | |
| 159 | + renameSync(temporaryPath, EVIDENCE_PATH); | |
| 160 | + } finally { | |
| 161 | + rmSync(temporaryPath, { force: true }); | |
| 162 | + } | |
| 163 | + process.stdout.write(serialized); | |
| 164 | +} | |
| 165 | + | |
| 166 | +function measure() { | |
| 167 | + if (!existsSync(ACTIVE_PATH)) { | |
| 168 | + throw new Error("Active catalog is missing; run npm run catalog:refresh first"); | |
| 169 | + } | |
| 170 | + | |
| 171 | + const facts = catalogFacts(); | |
| 172 | + const queries = facts.representatives.flatMap((entry) => [ | |
| 173 | + { mode: entry.mode, variant: "accented", value: entry.accented }, | |
| 174 | + { mode: entry.mode, variant: "unaccented", value: entry.unaccented }, | |
| 175 | + ]); | |
| 176 | + const catalog = SqliteCatalogReader.open(ACTIVE_PATH); | |
| 177 | + | |
| 178 | + let warmMaxRows = 0; | |
| 179 | + for (const query of queries) { | |
| 180 | + const results = catalog.searchPlaces(query.value); | |
| 181 | + executedQueries += 1; | |
| 182 | + if (results.length === 0) { | |
| 183 | + throw new Error(`Representative ${query.mode} ${query.variant} query returned no place`); | |
| 184 | + } | |
| 185 | + warmMaxRows = Math.max(warmMaxRows, results.length); | |
| 186 | + } | |
| 187 | + | |
| 188 | + const latencies = new Array(QUERY_COUNT); | |
| 189 | + let maxRows = warmMaxRows; | |
| 190 | + for (let index = 0; index < QUERY_COUNT; index += 1) { | |
| 191 | + const query = queries[index % queries.length]; | |
| 192 | + const started = performance.now(); | |
| 193 | + const results = catalog.searchPlaces(query.value); | |
| 194 | + latencies[index] = performance.now() - started; | |
| 195 | + executedQueries += 1; | |
| 196 | + maxRows = Math.max(maxRows, results.length); | |
| 197 | + } | |
| 198 | + | |
| 199 | + const sorted = [...latencies].sort((left, right) => left - right); | |
| 200 | + const p50 = percentile(sorted, 50); | |
| 201 | + const p95 = percentile(sorted, 95); | |
| 202 | + const maximum = sorted[sorted.length - 1]; | |
| 203 | + const memory = process.memoryUsage(); | |
| 204 | + const pass = executedQueries >= MIN_QUERY_COUNT | |
| 205 | + && p95 <= 200 | |
| 206 | + && maxRows <= LIMITS.catalogSearchResults; | |
| 207 | + | |
| 208 | + return { | |
| 209 | + command: "measure:catalog", | |
| 210 | + status: pass ? "ok" : "failed", | |
| 211 | + measuredAt: new Date().toISOString(), | |
| 212 | + catalog: { | |
| 213 | + schemaVersion: facts.metadata.schema_version, | |
| 214 | + catalogVersion: facts.metadata.catalog_version, | |
| 215 | + sourceRevision: facts.metadata.source_revision, | |
| 216 | + createdAt: facts.metadata.created_at, | |
| 217 | + placeCount: facts.placeCount, | |
| 218 | + serviceCount: facts.serviceCount, | |
| 219 | + countsByMode: facts.countsByMode, | |
| 220 | + }, | |
| 221 | + representativeQueries: facts.representatives, | |
| 222 | + queryCount: executedQueries, | |
| 223 | + p50Ms: milliseconds(p50), | |
| 224 | + p95Ms: milliseconds(p95), | |
| 225 | + maxMs: milliseconds(maximum), | |
| 226 | + maxRows, | |
| 227 | + maxRSSKiB: process.resourceUsage().maxRSS, | |
| 228 | + heapUsedBytes: memory.heapUsed, | |
| 229 | + heapTotalBytes: memory.heapTotal, | |
| 230 | + gates: { | |
| 231 | + queriesAtLeast: MIN_QUERY_COUNT, | |
| 232 | + p95MsAtMost: 200, | |
| 233 | + maxRowsAtMost: LIMITS.catalogSearchResults, | |
| 234 | + }, | |
| 235 | + pass, | |
| 236 | + }; | |
| 237 | +} | |
| 238 | + | |
| 239 | +try { | |
| 240 | + const report = measure(); | |
| 241 | + writeEvidence(report); | |
| 242 | + if (!report.pass) process.exitCode = 1; | |
| 243 | +} catch (error) { | |
| 244 | + const message = redactSecrets( | |
| 245 | + error instanceof Error && error.message ? error.message : "Catalog measurement failed", | |
| 246 | + evidenceSecrets(), | |
| 247 | + ); | |
| 248 | + process.stderr.write(`measure:catalog failed: ${message}\n`); | |
| 249 | + try { | |
| 250 | + writeEvidence({ | |
| 251 | + command: "measure:catalog", | |
| 252 | + status: "failed", | |
| 253 | + failedAt: new Date().toISOString(), | |
| 254 | + activePath: ACTIVE_PATH, | |
| 255 | + queryCount: executedQueries, | |
| 256 | + error: message, | |
| 257 | + }); | |
| 258 | + } catch { | |
| 259 | + // Reporting evidence must not mask the primary failure. | |
| 260 | + } | |
| 261 | + process.exitCode = 1; | |
| 262 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,262 @@ | |||
| 1 | +import { | ||
| 2 | + existsSync, | ||
| 3 | + mkdirSync, | ||
| 4 | + renameSync, | ||
| 5 | + rmSync, | ||
| 6 | + writeFileSync, | ||
| 7 | +} from "node:fs"; | ||
| 8 | +import { dirname, join, resolve } from "node:path"; | ||
| 9 | +import { performance } from "node:perf_hooks"; | ||
| 10 | +import { DatabaseSync } from "node:sqlite"; | ||
| 11 | +import { fileURLToPath } from "node:url"; | ||
| 12 | +import { redactSecrets, SqliteCatalogReader } from "../packages/backend/src/index.ts"; | ||
| 13 | +import { normalizeCatalogSearchText } from "../packages/backend/src/catalog-import.ts"; | ||
| 14 | +import { | ||
| 15 | + LIMITS, | ||
| 16 | + TRANSPORT_MODE, | ||
| 17 | +} from "../packages/contracts/src/index.ts"; | ||
| 18 | + | ||
| 19 | +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | ||
| 20 | +const DEFAULT_ACTIVE_PATH = join(PROJECT_ROOT, "var/catalog/catalog.sqlite"); | ||
| 21 | +const ACTIVE_PATH = resolve(process.env.CATALOG_PATH ?? DEFAULT_ACTIVE_PATH); | ||
| 22 | +const EVIDENCE_PATH = join(PROJECT_ROOT, "var/catalog/evidence/measure-catalog.json"); | ||
| 23 | +const QUERY_COUNT = 1_000; | ||
| 24 | +const MIN_QUERY_COUNT = 1_000; | ||
| 25 | +let executedQueries = 0; | ||
| 26 | + | ||
| 27 | +function record(value) { | ||
| 28 | + return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 29 | + ? value | ||
| 30 | + : undefined; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +function boundedPublicQuery(stopLabel, localityLabel) { | ||
| 34 | + const stop = typeof stopLabel === "string" ? stopLabel.trim() : ""; | ||
| 35 | + let points = [...stop]; | ||
| 36 | + if (points.length < LIMITS.catalogQueryMinCharacters) { | ||
| 37 | + const locality = typeof localityLabel === "string" ? localityLabel.trim() : ""; | ||
| 38 | + points = [...`${stop} ${locality}`.trim()]; | ||
| 39 | + } | ||
| 40 | + if (points.length < LIMITS.catalogQueryMinCharacters) return undefined; | ||
| 41 | + return points.slice(0, LIMITS.catalogQueryMaxCharacters).join(""); | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +function selectRepresentativeQueries(database) { | ||
| 45 | + const statement = database.prepare(` | ||
| 46 | + SELECT stop_label AS stopLabel, locality_label AS localityLabel | ||
| 47 | + FROM places | ||
| 48 | + WHERE mode = ? | ||
| 49 | + ORDER BY stop_label COLLATE NOCASE, place_id | ||
| 50 | + `); | ||
| 51 | + const representatives = []; | ||
| 52 | + | ||
| 53 | + for (const mode of TRANSPORT_MODE) { | ||
| 54 | + let fallback; | ||
| 55 | + let accented; | ||
| 56 | + for (const rowValue of statement.iterate(mode)) { | ||
| 57 | + const row = record(rowValue); | ||
| 58 | + const query = boundedPublicQuery(row?.stopLabel, row?.localityLabel); | ||
| 59 | + if (query === undefined) continue; | ||
| 60 | + fallback ??= query; | ||
| 61 | + const withoutAccents = normalizeCatalogSearchText(query); | ||
| 62 | + if (withoutAccents !== "" && withoutAccents !== query) { | ||
| 63 | + accented = { mode, accented: query, unaccented: withoutAccents }; | ||
| 64 | + break; | ||
| 65 | + } | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + const query = accented ?? (fallback === undefined | ||
| 69 | + ? undefined | ||
| 70 | + : { mode, accented: fallback, unaccented: normalizeCatalogSearchText(fallback) }); | ||
| 71 | + if (query === undefined) { | ||
| 72 | + throw new Error(`Active catalog has no usable public ${mode} label`); | ||
| 73 | + } | ||
| 74 | + representatives.push(query); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + if (!representatives.some((query) => query.accented !== query.unaccented)) { | ||
| 78 | + throw new Error("Active catalog has no accented public label for the measurement set"); | ||
| 79 | + } | ||
| 80 | + return representatives; | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +function catalogFacts() { | ||
| 84 | + const database = new DatabaseSync(ACTIVE_PATH, { | ||
| 85 | + readOnly: true, | ||
| 86 | + enableForeignKeyConstraints: true, | ||
| 87 | + enableDoubleQuotedStringLiterals: false, | ||
| 88 | + allowExtension: false, | ||
| 89 | + defensive: true, | ||
| 90 | + }); | ||
| 91 | + | ||
| 92 | + try { | ||
| 93 | + const metadataRows = database.prepare(` | ||
| 94 | + SELECT key, value | ||
| 95 | + FROM metadata | ||
| 96 | + WHERE key IN ('schema_version', 'catalog_version', 'source_revision', 'created_at') | ||
| 97 | + `).all(); | ||
| 98 | + const metadata = Object.fromEntries(metadataRows.map((row) => [row.key, row.value])); | ||
| 99 | + for (const key of ["schema_version", "catalog_version", "source_revision", "created_at"]) { | ||
| 100 | + if (typeof metadata[key] !== "string" || metadata[key].trim() === "") { | ||
| 101 | + throw new Error(`Active catalog metadata is missing ${key}`); | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + const placeCount = Number(database.prepare("SELECT count(*) AS count FROM places").get().count); | ||
| 106 | + const serviceCount = Number(database.prepare("SELECT count(*) AS count FROM services").get().count); | ||
| 107 | + if (!Number.isSafeInteger(placeCount) || placeCount < 1 | ||
| 108 | + || !Number.isSafeInteger(serviceCount) || serviceCount < 1) { | ||
| 109 | + throw new Error("Active catalog counts are invalid"); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + const countsByMode = Object.fromEntries(TRANSPORT_MODE.map((mode) => [mode, 0])); | ||
| 113 | + for (const row of database.prepare(` | ||
| 114 | + SELECT places.mode AS mode, count(services.service_id) AS count | ||
| 115 | + FROM places | ||
| 116 | + JOIN services ON services.place_id = places.place_id | ||
| 117 | + GROUP BY places.mode | ||
| 118 | + `).all()) { | ||
| 119 | + if (Object.hasOwn(countsByMode, row.mode)) countsByMode[row.mode] = Number(row.count); | ||
| 120 | + } | ||
| 121 | + for (const mode of TRANSPORT_MODE) { | ||
| 122 | + if (!Number.isSafeInteger(countsByMode[mode]) || countsByMode[mode] < 1) { | ||
| 123 | + throw new Error(`Active catalog has no ${mode} services`); | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + return { | ||
| 128 | + metadata, | ||
| 129 | + placeCount, | ||
| 130 | + serviceCount, | ||
| 131 | + countsByMode, | ||
| 132 | + representatives: selectRepresentativeQueries(database), | ||
| 133 | + }; | ||
| 134 | + } finally { | ||
| 135 | + database.close(); | ||
| 136 | + } | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +function percentile(sorted, percentage) { | ||
| 140 | + const index = Math.max(0, Math.ceil((percentage / 100) * sorted.length) - 1); | ||
| 141 | + return sorted[index]; | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +function milliseconds(value) { | ||
| 145 | + return Math.round(value * 1_000) / 1_000; | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +function evidenceSecrets() { | ||
| 149 | + return [process.env.IDFM_DATASET_TOKEN, process.env.PRIM_API_KEY] | ||
| 150 | + .filter((value) => typeof value === "string" && value.length > 0); | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +function writeEvidence(report) { | ||
| 154 | + mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); | ||
| 155 | + const temporaryPath = `${EVIDENCE_PATH}.${process.pid}.tmp`; | ||
| 156 | + const serialized = `${JSON.stringify(report, null, 2)}\n`; | ||
| 157 | + try { | ||
| 158 | + writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 }); | ||
| 159 | + renameSync(temporaryPath, EVIDENCE_PATH); | ||
| 160 | + } finally { | ||
| 161 | + rmSync(temporaryPath, { force: true }); | ||
| 162 | + } | ||
| 163 | + process.stdout.write(serialized); | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +function measure() { | ||
| 167 | + if (!existsSync(ACTIVE_PATH)) { | ||
| 168 | + throw new Error("Active catalog is missing; run npm run catalog:refresh first"); | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + const facts = catalogFacts(); | ||
| 172 | + const queries = facts.representatives.flatMap((entry) => [ | ||
| 173 | + { mode: entry.mode, variant: "accented", value: entry.accented }, | ||
| 174 | + { mode: entry.mode, variant: "unaccented", value: entry.unaccented }, | ||
| 175 | + ]); | ||
| 176 | + const catalog = SqliteCatalogReader.open(ACTIVE_PATH); | ||
| 177 | + | ||
| 178 | + let warmMaxRows = 0; | ||
| 179 | + for (const query of queries) { | ||
| 180 | + const results = catalog.searchPlaces(query.value); | ||
| 181 | + executedQueries += 1; | ||
| 182 | + if (results.length === 0) { | ||
| 183 | + throw new Error(`Representative ${query.mode} ${query.variant} query returned no place`); | ||
| 184 | + } | ||
| 185 | + warmMaxRows = Math.max(warmMaxRows, results.length); | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + const latencies = new Array(QUERY_COUNT); | ||
| 189 | + let maxRows = warmMaxRows; | ||
| 190 | + for (let index = 0; index < QUERY_COUNT; index += 1) { | ||
| 191 | + const query = queries[index % queries.length]; | ||
| 192 | + const started = performance.now(); | ||
| 193 | + const results = catalog.searchPlaces(query.value); | ||
| 194 | + latencies[index] = performance.now() - started; | ||
| 195 | + executedQueries += 1; | ||
| 196 | + maxRows = Math.max(maxRows, results.length); | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + const sorted = [...latencies].sort((left, right) => left - right); | ||
| 200 | + const p50 = percentile(sorted, 50); | ||
| 201 | + const p95 = percentile(sorted, 95); | ||
| 202 | + const maximum = sorted[sorted.length - 1]; | ||
| 203 | + const memory = process.memoryUsage(); | ||
| 204 | + const pass = executedQueries >= MIN_QUERY_COUNT | ||
| 205 | + && p95 <= 200 | ||
| 206 | + && maxRows <= LIMITS.catalogSearchResults; | ||
| 207 | + | ||
| 208 | + return { | ||
| 209 | + command: "measure:catalog", | ||
| 210 | + status: pass ? "ok" : "failed", | ||
| 211 | + measuredAt: new Date().toISOString(), | ||
| 212 | + catalog: { | ||
| 213 | + schemaVersion: facts.metadata.schema_version, | ||
| 214 | + catalogVersion: facts.metadata.catalog_version, | ||
| 215 | + sourceRevision: facts.metadata.source_revision, | ||
| 216 | + createdAt: facts.metadata.created_at, | ||
| 217 | + placeCount: facts.placeCount, | ||
| 218 | + serviceCount: facts.serviceCount, | ||
| 219 | + countsByMode: facts.countsByMode, | ||
| 220 | + }, | ||
| 221 | + representativeQueries: facts.representatives, | ||
| 222 | + queryCount: executedQueries, | ||
| 223 | + p50Ms: milliseconds(p50), | ||
| 224 | + p95Ms: milliseconds(p95), | ||
| 225 | + maxMs: milliseconds(maximum), | ||
| 226 | + maxRows, | ||
| 227 | + maxRSSKiB: process.resourceUsage().maxRSS, | ||
| 228 | + heapUsedBytes: memory.heapUsed, | ||
| 229 | + heapTotalBytes: memory.heapTotal, | ||
| 230 | + gates: { | ||
| 231 | + queriesAtLeast: MIN_QUERY_COUNT, | ||
| 232 | + p95MsAtMost: 200, | ||
| 233 | + maxRowsAtMost: LIMITS.catalogSearchResults, | ||
| 234 | + }, | ||
| 235 | + pass, | ||
| 236 | + }; | ||
| 237 | +} | ||
| 238 | + | ||
| 239 | +try { | ||
| 240 | + const report = measure(); | ||
| 241 | + writeEvidence(report); | ||
| 242 | + if (!report.pass) process.exitCode = 1; | ||
| 243 | +} catch (error) { | ||
| 244 | + const message = redactSecrets( | ||
| 245 | + error instanceof Error && error.message ? error.message : "Catalog measurement failed", | ||
| 246 | + evidenceSecrets(), | ||
| 247 | + ); | ||
| 248 | + process.stderr.write(`measure:catalog failed: ${message}\n`); | ||
| 249 | + try { | ||
| 250 | + writeEvidence({ | ||
| 251 | + command: "measure:catalog", | ||
| 252 | + status: "failed", | ||
| 253 | + failedAt: new Date().toISOString(), | ||
| 254 | + activePath: ACTIVE_PATH, | ||
| 255 | + queryCount: executedQueries, | ||
| 256 | + error: message, | ||
| 257 | + }); | ||
| 258 | + } catch { | ||
| 259 | + // Reporting evidence must not mask the primary failure. | ||
| 260 | + } | ||
| 261 | + process.exitCode = 1; | ||
| 262 | +} | ||
added
scripts/probe-catalog.mjs +275 -0 | new file mode 100644 | ||
| @@ -0,0 +1,275 @@ | ||
| 1 | +import { | |
| 2 | + existsSync, | |
| 3 | + mkdirSync, | |
| 4 | + renameSync, | |
| 5 | + rmSync, | |
| 6 | + writeFileSync, | |
| 7 | +} from "node:fs"; | |
| 8 | +import { dirname, join, resolve } from "node:path"; | |
| 9 | +import { DatabaseSync } from "node:sqlite"; | |
| 10 | +import { fileURLToPath } from "node:url"; | |
| 11 | +import { | |
| 12 | + parseBearerAuthorization, | |
| 13 | + redactSecrets, | |
| 14 | + relayPrimRequest, | |
| 15 | +} from "../packages/backend/src/index.ts"; | |
| 16 | +import { | |
| 17 | + PRIM_ORIGIN, | |
| 18 | + TRANSPORT_MODE, | |
| 19 | +} from "../packages/contracts/src/index.ts"; | |
| 20 | + | |
| 21 | +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 22 | +const DEFAULT_ACTIVE_PATH = join(PROJECT_ROOT, "var/catalog/catalog.sqlite"); | |
| 23 | +const ACTIVE_PATH = resolve(process.env.CATALOG_PATH ?? DEFAULT_ACTIVE_PATH); | |
| 24 | +const EVIDENCE_PATH = join(PROJECT_ROOT, "var/catalog/evidence/probe-catalog.json"); | |
| 25 | +const STOP_MONITORING_PATH = "/marketplace/stop-monitoring"; | |
| 26 | +const RETURNED_FIELD_NAME = /^(?:Direction|Destination)[A-Za-z0-9_]{0,48}$/u; | |
| 27 | +let callCount = 0; | |
| 28 | + | |
| 29 | +function record(value) { | |
| 30 | + return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 31 | + ? value | |
| 32 | + : undefined; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function requiredText(value, field) { | |
| 36 | + if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { | |
| 37 | + throw new Error(`Active catalog has an invalid ${field}`); | |
| 38 | + } | |
| 39 | + return value; | |
| 40 | +} | |
| 41 | + | |
| 42 | +function siriText(value) { | |
| 43 | + if (typeof value === "string") return value; | |
| 44 | + const object = record(value); | |
| 45 | + return typeof object?.value === "string" ? object.value : undefined; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function validateSiri(bytes, expectedLineRef) { | |
| 49 | + let root; | |
| 50 | + try { | |
| 51 | + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); | |
| 52 | + root = record(JSON.parse(text)); | |
| 53 | + } catch { | |
| 54 | + return { | |
| 55 | + siriValid: false, | |
| 56 | + lineRefMatched: false, | |
| 57 | + directionFields: [], | |
| 58 | + destinationFields: [], | |
| 59 | + }; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const siri = record(root?.Siri) ?? root; | |
| 63 | + const serviceDelivery = record(siri?.ServiceDelivery); | |
| 64 | + const stopMonitoringDelivery = serviceDelivery?.StopMonitoringDelivery; | |
| 65 | + if (serviceDelivery === undefined || !Array.isArray(stopMonitoringDelivery)) { | |
| 66 | + return { | |
| 67 | + siriValid: false, | |
| 68 | + lineRefMatched: false, | |
| 69 | + directionFields: [], | |
| 70 | + destinationFields: [], | |
| 71 | + }; | |
| 72 | + } | |
| 73 | + | |
| 74 | + let validDelivery = stopMonitoringDelivery.length > 0; | |
| 75 | + let lineRefMatched = false; | |
| 76 | + const directionFields = new Set(); | |
| 77 | + const destinationFields = new Set(); | |
| 78 | + | |
| 79 | + for (const deliveryValue of stopMonitoringDelivery) { | |
| 80 | + const delivery = record(deliveryValue); | |
| 81 | + if (delivery === undefined) { | |
| 82 | + validDelivery = false; | |
| 83 | + continue; | |
| 84 | + } | |
| 85 | + const visitsValue = delivery.MonitoredStopVisit; | |
| 86 | + if (visitsValue !== undefined && !Array.isArray(visitsValue)) { | |
| 87 | + validDelivery = false; | |
| 88 | + continue; | |
| 89 | + } | |
| 90 | + | |
| 91 | + for (const visitValue of visitsValue ?? []) { | |
| 92 | + const visit = record(visitValue); | |
| 93 | + const journey = record(visit?.MonitoredVehicleJourney); | |
| 94 | + if (journey === undefined) { | |
| 95 | + validDelivery = false; | |
| 96 | + continue; | |
| 97 | + } | |
| 98 | + if (siriText(journey.LineRef) !== expectedLineRef) continue; | |
| 99 | + lineRefMatched = true; | |
| 100 | + for (const field of Object.keys(journey)) { | |
| 101 | + if (!RETURNED_FIELD_NAME.test(field)) continue; | |
| 102 | + if (field.startsWith("Direction")) directionFields.add(field); | |
| 103 | + if (field.startsWith("Destination")) destinationFields.add(field); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + return { | |
| 109 | + siriValid: validDelivery, | |
| 110 | + lineRefMatched, | |
| 111 | + directionFields: [...directionFields].sort(), | |
| 112 | + destinationFields: [...destinationFields].sort(), | |
| 113 | + }; | |
| 114 | +} | |
| 115 | + | |
| 116 | +function selectServices() { | |
| 117 | + if (!existsSync(ACTIVE_PATH)) { | |
| 118 | + throw new Error("Active catalog is missing; run npm run catalog:refresh first"); | |
| 119 | + } | |
| 120 | + | |
| 121 | + const database = new DatabaseSync(ACTIVE_PATH, { | |
| 122 | + readOnly: true, | |
| 123 | + enableForeignKeyConstraints: true, | |
| 124 | + enableDoubleQuotedStringLiterals: false, | |
| 125 | + allowExtension: false, | |
| 126 | + defensive: true, | |
| 127 | + }); | |
| 128 | + try { | |
| 129 | + const select = database.prepare(` | |
| 130 | + SELECT | |
| 131 | + min(services.service_id) AS serviceId, | |
| 132 | + services.monitoring_ref AS monitoringRef, | |
| 133 | + services.line_ref AS lineRef | |
| 134 | + FROM services | |
| 135 | + JOIN places ON places.place_id = services.place_id | |
| 136 | + WHERE places.mode = ? | |
| 137 | + GROUP BY services.monitoring_ref, services.line_ref | |
| 138 | + ORDER BY count(*) DESC, min(services.service_id) | |
| 139 | + LIMIT 1 | |
| 140 | + `); | |
| 141 | + | |
| 142 | + return TRANSPORT_MODE.map((mode) => { | |
| 143 | + const row = record(select.get(mode)); | |
| 144 | + if (row === undefined) throw new Error(`Active catalog has no resolved ${mode} service`); | |
| 145 | + return { | |
| 146 | + mode, | |
| 147 | + serviceId: requiredText(row.serviceId, `${mode} serviceId`), | |
| 148 | + monitoringRef: requiredText(row.monitoringRef, `${mode} monitoringRef`), | |
| 149 | + lineRef: requiredText(row.lineRef, `${mode} lineRef`), | |
| 150 | + }; | |
| 151 | + }); | |
| 152 | + } finally { | |
| 153 | + database.close(); | |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +function targetFor(monitoringRef) { | |
| 158 | + const target = new URL(STOP_MONITORING_PATH, PRIM_ORIGIN); | |
| 159 | + target.searchParams.set("MonitoringRef", monitoringRef); | |
| 160 | + return target; | |
| 161 | +} | |
| 162 | + | |
| 163 | +function writeEvidence(report, apiKey) { | |
| 164 | + mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); | |
| 165 | + const temporaryPath = `${EVIDENCE_PATH}.${process.pid}.tmp`; | |
| 166 | + const serialized = `${redactSecrets(JSON.stringify(report, null, 2), [apiKey])}\n`; | |
| 167 | + try { | |
| 168 | + writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 }); | |
| 169 | + renameSync(temporaryPath, EVIDENCE_PATH); | |
| 170 | + } finally { | |
| 171 | + rmSync(temporaryPath, { force: true }); | |
| 172 | + } | |
| 173 | + process.stdout.write(serialized); | |
| 174 | +} | |
| 175 | + | |
| 176 | +async function probe(apiKey) { | |
| 177 | + const services = selectServices(); | |
| 178 | + if (services.length !== TRANSPORT_MODE.length) { | |
| 179 | + throw new Error("Active catalog did not resolve exactly one service per mode"); | |
| 180 | + } | |
| 181 | + const requests = services.map((service) => ({ | |
| 182 | + ...service, | |
| 183 | + target: targetFor(service.monitoringRef), | |
| 184 | + })); | |
| 185 | + | |
| 186 | + const countedFetch = (target, init) => { | |
| 187 | + callCount += 1; | |
| 188 | + return globalThis.fetch(target, init); | |
| 189 | + }; | |
| 190 | + const probes = []; | |
| 191 | + | |
| 192 | + for (const request of requests) { | |
| 193 | + let status = null; | |
| 194 | + let validation = { | |
| 195 | + siriValid: false, | |
| 196 | + lineRefMatched: false, | |
| 197 | + directionFields: [], | |
| 198 | + destinationFields: [], | |
| 199 | + }; | |
| 200 | + try { | |
| 201 | + const response = await relayPrimRequest( | |
| 202 | + { | |
| 203 | + target: request.target, | |
| 204 | + authorization: `Bearer ${apiKey}`, | |
| 205 | + }, | |
| 206 | + { fetch: countedFetch }, | |
| 207 | + ); | |
| 208 | + status = response.status; | |
| 209 | + if (status === 200) validation = validateSiri(response.bytes, request.lineRef); | |
| 210 | + } catch { | |
| 211 | + // A failed call is recorded without response content; the probe still spends exactly one call per mode. | |
| 212 | + } | |
| 213 | + | |
| 214 | + probes.push({ | |
| 215 | + mode: request.mode, | |
| 216 | + status, | |
| 217 | + siriValid: validation.siriValid, | |
| 218 | + lineRefMatched: validation.lineRefMatched, | |
| 219 | + directionFields: validation.directionFields, | |
| 220 | + destinationFields: validation.destinationFields, | |
| 221 | + acceptedStoredQuery: status === 200 | |
| 222 | + && validation.siriValid | |
| 223 | + && validation.lineRefMatched, | |
| 224 | + }); | |
| 225 | + } | |
| 226 | + | |
| 227 | + const pass = callCount === TRANSPORT_MODE.length | |
| 228 | + && probes.every((result) => result.acceptedStoredQuery); | |
| 229 | + return { | |
| 230 | + command: "catalog:probe", | |
| 231 | + status: pass ? "ok" : "failed", | |
| 232 | + probedAt: new Date().toISOString(), | |
| 233 | + requestStructure: { | |
| 234 | + origin: PRIM_ORIGIN, | |
| 235 | + path: STOP_MONITORING_PATH, | |
| 236 | + queryParameters: ["MonitoringRef"], | |
| 237 | + headers: ["apikey"], | |
| 238 | + }, | |
| 239 | + callCount, | |
| 240 | + probes, | |
| 241 | + pass, | |
| 242 | + }; | |
| 243 | +} | |
| 244 | + | |
| 245 | +const configuredKey = process.env.PRIM_API_KEY; | |
| 246 | +if (typeof configuredKey !== "string" || configuredKey.length === 0) { | |
| 247 | + process.stderr.write("catalog:probe requires PRIM_API_KEY\n"); | |
| 248 | + process.exitCode = 1; | |
| 249 | +} else { | |
| 250 | + let apiKey; | |
| 251 | + try { | |
| 252 | + apiKey = parseBearerAuthorization(`Bearer ${configuredKey}`); | |
| 253 | + const report = await probe(apiKey); | |
| 254 | + writeEvidence(report, apiKey); | |
| 255 | + if (!report.pass) process.exitCode = 1; | |
| 256 | + } catch (error) { | |
| 257 | + const message = redactSecrets( | |
| 258 | + error instanceof Error && error.message ? error.message : "Catalog probe failed", | |
| 259 | + [configuredKey], | |
| 260 | + ); | |
| 261 | + process.stderr.write(`catalog:probe failed: ${message}\n`); | |
| 262 | + try { | |
| 263 | + writeEvidence({ | |
| 264 | + command: "catalog:probe", | |
| 265 | + status: "failed", | |
| 266 | + failedAt: new Date().toISOString(), | |
| 267 | + callCount, | |
| 268 | + error: message, | |
| 269 | + }, configuredKey); | |
| 270 | + } catch { | |
| 271 | + // Reporting evidence must not mask the primary failure. | |
| 272 | + } | |
| 273 | + process.exitCode = 1; | |
| 274 | + } | |
| 275 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,275 @@ | |||
| 1 | +import { | ||
| 2 | + existsSync, | ||
| 3 | + mkdirSync, | ||
| 4 | + renameSync, | ||
| 5 | + rmSync, | ||
| 6 | + writeFileSync, | ||
| 7 | +} from "node:fs"; | ||
| 8 | +import { dirname, join, resolve } from "node:path"; | ||
| 9 | +import { DatabaseSync } from "node:sqlite"; | ||
| 10 | +import { fileURLToPath } from "node:url"; | ||
| 11 | +import { | ||
| 12 | + parseBearerAuthorization, | ||
| 13 | + redactSecrets, | ||
| 14 | + relayPrimRequest, | ||
| 15 | +} from "../packages/backend/src/index.ts"; | ||
| 16 | +import { | ||
| 17 | + PRIM_ORIGIN, | ||
| 18 | + TRANSPORT_MODE, | ||
| 19 | +} from "../packages/contracts/src/index.ts"; | ||
| 20 | + | ||
| 21 | +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | ||
| 22 | +const DEFAULT_ACTIVE_PATH = join(PROJECT_ROOT, "var/catalog/catalog.sqlite"); | ||
| 23 | +const ACTIVE_PATH = resolve(process.env.CATALOG_PATH ?? DEFAULT_ACTIVE_PATH); | ||
| 24 | +const EVIDENCE_PATH = join(PROJECT_ROOT, "var/catalog/evidence/probe-catalog.json"); | ||
| 25 | +const STOP_MONITORING_PATH = "/marketplace/stop-monitoring"; | ||
| 26 | +const RETURNED_FIELD_NAME = /^(?:Direction|Destination)[A-Za-z0-9_]{0,48}$/u; | ||
| 27 | +let callCount = 0; | ||
| 28 | + | ||
| 29 | +function record(value) { | ||
| 30 | + return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 31 | + ? value | ||
| 32 | + : undefined; | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +function requiredText(value, field) { | ||
| 36 | + if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { | ||
| 37 | + throw new Error(`Active catalog has an invalid ${field}`); | ||
| 38 | + } | ||
| 39 | + return value; | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +function siriText(value) { | ||
| 43 | + if (typeof value === "string") return value; | ||
| 44 | + const object = record(value); | ||
| 45 | + return typeof object?.value === "string" ? object.value : undefined; | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +function validateSiri(bytes, expectedLineRef) { | ||
| 49 | + let root; | ||
| 50 | + try { | ||
| 51 | + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); | ||
| 52 | + root = record(JSON.parse(text)); | ||
| 53 | + } catch { | ||
| 54 | + return { | ||
| 55 | + siriValid: false, | ||
| 56 | + lineRefMatched: false, | ||
| 57 | + directionFields: [], | ||
| 58 | + destinationFields: [], | ||
| 59 | + }; | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + const siri = record(root?.Siri) ?? root; | ||
| 63 | + const serviceDelivery = record(siri?.ServiceDelivery); | ||
| 64 | + const stopMonitoringDelivery = serviceDelivery?.StopMonitoringDelivery; | ||
| 65 | + if (serviceDelivery === undefined || !Array.isArray(stopMonitoringDelivery)) { | ||
| 66 | + return { | ||
| 67 | + siriValid: false, | ||
| 68 | + lineRefMatched: false, | ||
| 69 | + directionFields: [], | ||
| 70 | + destinationFields: [], | ||
| 71 | + }; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + let validDelivery = stopMonitoringDelivery.length > 0; | ||
| 75 | + let lineRefMatched = false; | ||
| 76 | + const directionFields = new Set(); | ||
| 77 | + const destinationFields = new Set(); | ||
| 78 | + | ||
| 79 | + for (const deliveryValue of stopMonitoringDelivery) { | ||
| 80 | + const delivery = record(deliveryValue); | ||
| 81 | + if (delivery === undefined) { | ||
| 82 | + validDelivery = false; | ||
| 83 | + continue; | ||
| 84 | + } | ||
| 85 | + const visitsValue = delivery.MonitoredStopVisit; | ||
| 86 | + if (visitsValue !== undefined && !Array.isArray(visitsValue)) { | ||
| 87 | + validDelivery = false; | ||
| 88 | + continue; | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + for (const visitValue of visitsValue ?? []) { | ||
| 92 | + const visit = record(visitValue); | ||
| 93 | + const journey = record(visit?.MonitoredVehicleJourney); | ||
| 94 | + if (journey === undefined) { | ||
| 95 | + validDelivery = false; | ||
| 96 | + continue; | ||
| 97 | + } | ||
| 98 | + if (siriText(journey.LineRef) !== expectedLineRef) continue; | ||
| 99 | + lineRefMatched = true; | ||
| 100 | + for (const field of Object.keys(journey)) { | ||
| 101 | + if (!RETURNED_FIELD_NAME.test(field)) continue; | ||
| 102 | + if (field.startsWith("Direction")) directionFields.add(field); | ||
| 103 | + if (field.startsWith("Destination")) destinationFields.add(field); | ||
| 104 | + } | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + return { | ||
| 109 | + siriValid: validDelivery, | ||
| 110 | + lineRefMatched, | ||
| 111 | + directionFields: [...directionFields].sort(), | ||
| 112 | + destinationFields: [...destinationFields].sort(), | ||
| 113 | + }; | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +function selectServices() { | ||
| 117 | + if (!existsSync(ACTIVE_PATH)) { | ||
| 118 | + throw new Error("Active catalog is missing; run npm run catalog:refresh first"); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + const database = new DatabaseSync(ACTIVE_PATH, { | ||
| 122 | + readOnly: true, | ||
| 123 | + enableForeignKeyConstraints: true, | ||
| 124 | + enableDoubleQuotedStringLiterals: false, | ||
| 125 | + allowExtension: false, | ||
| 126 | + defensive: true, | ||
| 127 | + }); | ||
| 128 | + try { | ||
| 129 | + const select = database.prepare(` | ||
| 130 | + SELECT | ||
| 131 | + min(services.service_id) AS serviceId, | ||
| 132 | + services.monitoring_ref AS monitoringRef, | ||
| 133 | + services.line_ref AS lineRef | ||
| 134 | + FROM services | ||
| 135 | + JOIN places ON places.place_id = services.place_id | ||
| 136 | + WHERE places.mode = ? | ||
| 137 | + GROUP BY services.monitoring_ref, services.line_ref | ||
| 138 | + ORDER BY count(*) DESC, min(services.service_id) | ||
| 139 | + LIMIT 1 | ||
| 140 | + `); | ||
| 141 | + | ||
| 142 | + return TRANSPORT_MODE.map((mode) => { | ||
| 143 | + const row = record(select.get(mode)); | ||
| 144 | + if (row === undefined) throw new Error(`Active catalog has no resolved ${mode} service`); | ||
| 145 | + return { | ||
| 146 | + mode, | ||
| 147 | + serviceId: requiredText(row.serviceId, `${mode} serviceId`), | ||
| 148 | + monitoringRef: requiredText(row.monitoringRef, `${mode} monitoringRef`), | ||
| 149 | + lineRef: requiredText(row.lineRef, `${mode} lineRef`), | ||
| 150 | + }; | ||
| 151 | + }); | ||
| 152 | + } finally { | ||
| 153 | + database.close(); | ||
| 154 | + } | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +function targetFor(monitoringRef) { | ||
| 158 | + const target = new URL(STOP_MONITORING_PATH, PRIM_ORIGIN); | ||
| 159 | + target.searchParams.set("MonitoringRef", monitoringRef); | ||
| 160 | + return target; | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +function writeEvidence(report, apiKey) { | ||
| 164 | + mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); | ||
| 165 | + const temporaryPath = `${EVIDENCE_PATH}.${process.pid}.tmp`; | ||
| 166 | + const serialized = `${redactSecrets(JSON.stringify(report, null, 2), [apiKey])}\n`; | ||
| 167 | + try { | ||
| 168 | + writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 }); | ||
| 169 | + renameSync(temporaryPath, EVIDENCE_PATH); | ||
| 170 | + } finally { | ||
| 171 | + rmSync(temporaryPath, { force: true }); | ||
| 172 | + } | ||
| 173 | + process.stdout.write(serialized); | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +async function probe(apiKey) { | ||
| 177 | + const services = selectServices(); | ||
| 178 | + if (services.length !== TRANSPORT_MODE.length) { | ||
| 179 | + throw new Error("Active catalog did not resolve exactly one service per mode"); | ||
| 180 | + } | ||
| 181 | + const requests = services.map((service) => ({ | ||
| 182 | + ...service, | ||
| 183 | + target: targetFor(service.monitoringRef), | ||
| 184 | + })); | ||
| 185 | + | ||
| 186 | + const countedFetch = (target, init) => { | ||
| 187 | + callCount += 1; | ||
| 188 | + return globalThis.fetch(target, init); | ||
| 189 | + }; | ||
| 190 | + const probes = []; | ||
| 191 | + | ||
| 192 | + for (const request of requests) { | ||
| 193 | + let status = null; | ||
| 194 | + let validation = { | ||
| 195 | + siriValid: false, | ||
| 196 | + lineRefMatched: false, | ||
| 197 | + directionFields: [], | ||
| 198 | + destinationFields: [], | ||
| 199 | + }; | ||
| 200 | + try { | ||
| 201 | + const response = await relayPrimRequest( | ||
| 202 | + { | ||
| 203 | + target: request.target, | ||
| 204 | + authorization: `Bearer ${apiKey}`, | ||
| 205 | + }, | ||
| 206 | + { fetch: countedFetch }, | ||
| 207 | + ); | ||
| 208 | + status = response.status; | ||
| 209 | + if (status === 200) validation = validateSiri(response.bytes, request.lineRef); | ||
| 210 | + } catch { | ||
| 211 | + // A failed call is recorded without response content; the probe still spends exactly one call per mode. | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + probes.push({ | ||
| 215 | + mode: request.mode, | ||
| 216 | + status, | ||
| 217 | + siriValid: validation.siriValid, | ||
| 218 | + lineRefMatched: validation.lineRefMatched, | ||
| 219 | + directionFields: validation.directionFields, | ||
| 220 | + destinationFields: validation.destinationFields, | ||
| 221 | + acceptedStoredQuery: status === 200 | ||
| 222 | + && validation.siriValid | ||
| 223 | + && validation.lineRefMatched, | ||
| 224 | + }); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + const pass = callCount === TRANSPORT_MODE.length | ||
| 228 | + && probes.every((result) => result.acceptedStoredQuery); | ||
| 229 | + return { | ||
| 230 | + command: "catalog:probe", | ||
| 231 | + status: pass ? "ok" : "failed", | ||
| 232 | + probedAt: new Date().toISOString(), | ||
| 233 | + requestStructure: { | ||
| 234 | + origin: PRIM_ORIGIN, | ||
| 235 | + path: STOP_MONITORING_PATH, | ||
| 236 | + queryParameters: ["MonitoringRef"], | ||
| 237 | + headers: ["apikey"], | ||
| 238 | + }, | ||
| 239 | + callCount, | ||
| 240 | + probes, | ||
| 241 | + pass, | ||
| 242 | + }; | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +const configuredKey = process.env.PRIM_API_KEY; | ||
| 246 | +if (typeof configuredKey !== "string" || configuredKey.length === 0) { | ||
| 247 | + process.stderr.write("catalog:probe requires PRIM_API_KEY\n"); | ||
| 248 | + process.exitCode = 1; | ||
| 249 | +} else { | ||
| 250 | + let apiKey; | ||
| 251 | + try { | ||
| 252 | + apiKey = parseBearerAuthorization(`Bearer ${configuredKey}`); | ||
| 253 | + const report = await probe(apiKey); | ||
| 254 | + writeEvidence(report, apiKey); | ||
| 255 | + if (!report.pass) process.exitCode = 1; | ||
| 256 | + } catch (error) { | ||
| 257 | + const message = redactSecrets( | ||
| 258 | + error instanceof Error && error.message ? error.message : "Catalog probe failed", | ||
| 259 | + [configuredKey], | ||
| 260 | + ); | ||
| 261 | + process.stderr.write(`catalog:probe failed: ${message}\n`); | ||
| 262 | + try { | ||
| 263 | + writeEvidence({ | ||
| 264 | + command: "catalog:probe", | ||
| 265 | + status: "failed", | ||
| 266 | + failedAt: new Date().toISOString(), | ||
| 267 | + callCount, | ||
| 268 | + error: message, | ||
| 269 | + }, configuredKey); | ||
| 270 | + } catch { | ||
| 271 | + // Reporting evidence must not mask the primary failure. | ||
| 272 | + } | ||
| 273 | + process.exitCode = 1; | ||
| 274 | + } | ||
| 275 | +} | ||
added
scripts/refresh-catalog.mjs +442 -0 | new file mode 100644 | ||
| @@ -0,0 +1,442 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { | |
| 3 | + mkdirSync, | |
| 4 | + mkdtempSync, | |
| 5 | + renameSync, | |
| 6 | + rmSync, | |
| 7 | + writeFileSync, | |
| 8 | +} from "node:fs"; | |
| 9 | +import { dirname, join, resolve } from "node:path"; | |
| 10 | +import { fileURLToPath } from "node:url"; | |
| 11 | +import { | |
| 12 | + DOWNLOAD_TIMEOUT_MS, | |
| 13 | + IDFM_CATALOG_SOURCE_URL, | |
| 14 | + activateCatalogCandidate, | |
| 15 | + buildCatalogCandidate, | |
| 16 | + downloadCatalogSource, | |
| 17 | + resolveIdfmGtfsDownload, | |
| 18 | + validateCatalogCandidate, | |
| 19 | +} from "../packages/backend/src/catalog-import.ts"; | |
| 20 | +import { redactSecrets } from "../packages/backend/src/index.ts"; | |
| 21 | + | |
| 22 | +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 23 | +const DEFAULT_ACTIVE_PATH = join(PROJECT_ROOT, "var/catalog/catalog.sqlite"); | |
| 24 | +const ACTIVE_PATH = resolve(process.env.CATALOG_PATH ?? DEFAULT_ACTIVE_PATH); | |
| 25 | +const EVIDENCE_PATH = join(PROJECT_ROOT, "var/catalog/evidence/refresh-catalog.json"); | |
| 26 | +const METADATA_BODY_LIMIT = 1024 * 1024; | |
| 27 | +const DATA_ORIGIN = new URL(IDFM_CATALOG_SOURCE_URL.gtfsRecord).origin; | |
| 28 | + | |
| 29 | +const GTFS_SOURCE = { | |
| 30 | + key: "gtfs", | |
| 31 | + dataset: "offre-horaires-tc-gtfs-idfm", | |
| 32 | + restricted: true, | |
| 33 | + requiredFields: ["filename", "url"], | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const CSV_SOURCES = [ | |
| 37 | + { | |
| 38 | + key: "perimeter", | |
| 39 | + dataset: "perimetre-des-donnees-tr-disponibles-plateforme-idfm", | |
| 40 | + url: IDFM_CATALOG_SOURCE_URL.perimeter, | |
| 41 | + restricted: true, | |
| 42 | + requiredFields: ["line", "name_line", "ns2_stoppointref", "ns2_stopname", "operatorname"], | |
| 43 | + }, | |
| 44 | + { | |
| 45 | + key: "arrets", | |
| 46 | + dataset: "arrets", | |
| 47 | + url: IDFM_CATALOG_SOURCE_URL.arrets, | |
| 48 | + restricted: false, | |
| 49 | + requiredFields: ["arrid", "zdaid"], | |
| 50 | + }, | |
| 51 | + { | |
| 52 | + key: "zones", | |
| 53 | + dataset: "zones-d-arrets", | |
| 54 | + url: IDFM_CATALOG_SOURCE_URL.zones, | |
| 55 | + restricted: false, | |
| 56 | + requiredFields: ["zdaid", "zdaname", "zdatown"], | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + key: "relations", | |
| 60 | + dataset: "relations", | |
| 61 | + url: IDFM_CATALOG_SOURCE_URL.relations, | |
| 62 | + restricted: false, | |
| 63 | + requiredFields: ["zdaid", "arrid", "artid"], | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + key: "lines", | |
| 67 | + dataset: "referentiel-des-lignes", | |
| 68 | + url: IDFM_CATALOG_SOURCE_URL.lines, | |
| 69 | + restricted: false, | |
| 70 | + requiredFields: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"], | |
| 71 | + }, | |
| 72 | + { | |
| 73 | + key: "arretsLignes", | |
| 74 | + dataset: "arrets-lignes", | |
| 75 | + url: IDFM_CATALOG_SOURCE_URL.arretsLignes, | |
| 76 | + restricted: false, | |
| 77 | + requiredFields: ["id", "stop_id"], | |
| 78 | + }, | |
| 79 | +]; | |
| 80 | + | |
| 81 | +function record(value) { | |
| 82 | + return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 83 | + ? value | |
| 84 | + : undefined; | |
| 85 | +} | |
| 86 | + | |
| 87 | +function requiredText(value, field) { | |
| 88 | + if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { | |
| 89 | + throw new Error(`Invalid ${field} in IDFM dataset metadata`); | |
| 90 | + } | |
| 91 | + return value; | |
| 92 | +} | |
| 93 | + | |
| 94 | +async function boundedJson(response, dataset) { | |
| 95 | + if (response.body === null) throw new Error(`IDFM metadata for ${dataset} returned no body`); | |
| 96 | + const chunks = []; | |
| 97 | + let byteLength = 0; | |
| 98 | + for await (const chunkValue of response.body) { | |
| 99 | + const chunk = chunkValue instanceof Uint8Array | |
| 100 | + ? chunkValue | |
| 101 | + : new Uint8Array(chunkValue); | |
| 102 | + byteLength += chunk.byteLength; | |
| 103 | + if (byteLength > METADATA_BODY_LIMIT) { | |
| 104 | + throw new Error(`IDFM metadata for ${dataset} exceeds 1 MiB`); | |
| 105 | + } | |
| 106 | + chunks.push(chunk); | |
| 107 | + } | |
| 108 | + | |
| 109 | + try { | |
| 110 | + const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); | |
| 111 | + return JSON.parse(text); | |
| 112 | + } catch { | |
| 113 | + throw new Error(`IDFM metadata for ${dataset} is not valid UTF-8 JSON`); | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +async function fetchDatasetMetadata(source, datasetToken) { | |
| 118 | + const target = new URL( | |
| 119 | + `/api/explore/v2.1/catalog/datasets/${encodeURIComponent(source.dataset)}`, | |
| 120 | + DATA_ORIGIN, | |
| 121 | + ); | |
| 122 | + const controller = new AbortController(); | |
| 123 | + const timeout = setTimeout( | |
| 124 | + () => controller.abort(new Error("IDFM metadata request timed out")), | |
| 125 | + DOWNLOAD_TIMEOUT_MS, | |
| 126 | + ); | |
| 127 | + | |
| 128 | + try { | |
| 129 | + const response = await fetch(target, { | |
| 130 | + method: "GET", | |
| 131 | + redirect: "manual", | |
| 132 | + credentials: "omit", | |
| 133 | + cache: "no-store", | |
| 134 | + headers: source.restricted | |
| 135 | + ? { Authorization: `apikey ${datasetToken}` } | |
| 136 | + : {}, | |
| 137 | + signal: controller.signal, | |
| 138 | + }); | |
| 139 | + if (response.status !== 200) { | |
| 140 | + throw new Error(`IDFM metadata for ${source.dataset} returned HTTP ${response.status}`); | |
| 141 | + } | |
| 142 | + const mediaType = (response.headers.get("content-type") ?? "") | |
| 143 | + .split(";", 1)[0] | |
| 144 | + .trim() | |
| 145 | + .toLowerCase(); | |
| 146 | + if (mediaType !== "application/json") { | |
| 147 | + throw new Error(`IDFM metadata for ${source.dataset} is not JSON`); | |
| 148 | + } | |
| 149 | + | |
| 150 | + const value = record(await boundedJson(response, source.dataset)); | |
| 151 | + const metas = record(value?.metas); | |
| 152 | + const defaults = record(metas?.default); | |
| 153 | + if (value?.dataset_id !== source.dataset || value?.has_records !== true) { | |
| 154 | + throw new Error(`IDFM metadata identity for ${source.dataset} is invalid`); | |
| 155 | + } | |
| 156 | + if (value.data_visible !== !source.restricted) { | |
| 157 | + throw new Error(`IDFM metadata visibility for ${source.dataset} changed`); | |
| 158 | + } | |
| 159 | + | |
| 160 | + const fields = Array.isArray(value.fields) ? value.fields : []; | |
| 161 | + const fieldNames = new Set(fields.map((field) => record(field)?.name)); | |
| 162 | + for (const requiredField of source.requiredFields) { | |
| 163 | + if (!fieldNames.has(requiredField)) { | |
| 164 | + throw new Error(`IDFM metadata for ${source.dataset} lacks ${requiredField}`); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + const recordsCount = defaults?.records_count; | |
| 169 | + if (!Number.isSafeInteger(recordsCount) || recordsCount < 1) { | |
| 170 | + throw new Error(`IDFM metadata record count for ${source.dataset} is invalid`); | |
| 171 | + } | |
| 172 | + const modified = requiredText(defaults?.modified, `${source.dataset}.modified`); | |
| 173 | + if (!Number.isFinite(Date.parse(modified))) { | |
| 174 | + throw new Error(`IDFM metadata timestamp for ${source.dataset} is invalid`); | |
| 175 | + } | |
| 176 | + | |
| 177 | + return { | |
| 178 | + dataset: source.dataset, | |
| 179 | + datasetUid: requiredText(value.dataset_uid, `${source.dataset}.dataset_uid`), | |
| 180 | + title: requiredText(defaults?.title, `${source.dataset}.title`), | |
| 181 | + license: requiredText(defaults?.license, `${source.dataset}.license`), | |
| 182 | + modified, | |
| 183 | + recordsCount, | |
| 184 | + restricted: source.restricted, | |
| 185 | + retrievedAt: new Date().toISOString(), | |
| 186 | + }; | |
| 187 | + } finally { | |
| 188 | + clearTimeout(timeout); | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +function sourceAttribution(metadata, url) { | |
| 193 | + return { | |
| 194 | + dataset: metadata.dataset, | |
| 195 | + url, | |
| 196 | + retrievedAt: metadata.retrievedAt, | |
| 197 | + license: metadata.license, | |
| 198 | + restricted: metadata.restricted, | |
| 199 | + }; | |
| 200 | +} | |
| 201 | + | |
| 202 | +function revisionFor(downloads) { | |
| 203 | + const sourceState = downloads.map(({ metadata, download }) => ({ | |
| 204 | + dataset: metadata.dataset, | |
| 205 | + modified: metadata.modified, | |
| 206 | + recordsCount: metadata.recordsCount, | |
| 207 | + sha256: download.sha256, | |
| 208 | + })); | |
| 209 | + const digest = createHash("sha256") | |
| 210 | + .update(JSON.stringify(sourceState), "utf8") | |
| 211 | + .digest("hex"); | |
| 212 | + return `idfm-v1-${digest}`; | |
| 213 | +} | |
| 214 | + | |
| 215 | +function assertSameResult(expected, actual, phase) { | |
| 216 | + const sameCounts = expected.placeCount === actual.placeCount | |
| 217 | + && expected.serviceCount === actual.serviceCount | |
| 218 | + && Object.keys(expected.countsByMode).every( | |
| 219 | + (mode) => expected.countsByMode[mode] === actual.countsByMode[mode], | |
| 220 | + ); | |
| 221 | + if (expected.sourceRevision !== actual.sourceRevision || !sameCounts) { | |
| 222 | + throw new Error(`Catalog ${phase} result changed after validation`); | |
| 223 | + } | |
| 224 | +} | |
| 225 | + | |
| 226 | +function evidenceSource({ metadata, download, filename }) { | |
| 227 | + return { | |
| 228 | + dataset: metadata.dataset, | |
| 229 | + datasetUid: metadata.datasetUid, | |
| 230 | + title: metadata.title, | |
| 231 | + license: metadata.license, | |
| 232 | + restricted: metadata.restricted, | |
| 233 | + modified: metadata.modified, | |
| 234 | + retrievedAt: metadata.retrievedAt, | |
| 235 | + recordsCount: metadata.recordsCount, | |
| 236 | + bytes: download.bytes, | |
| 237 | + sha256: download.sha256, | |
| 238 | + ...(filename === undefined ? {} : { filename }), | |
| 239 | + }; | |
| 240 | +} | |
| 241 | + | |
| 242 | +function writeEvidence(report, secrets) { | |
| 243 | + mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); | |
| 244 | + const temporaryPath = `${EVIDENCE_PATH}.${process.pid}.tmp`; | |
| 245 | + const serialized = `${redactSecrets(JSON.stringify(report, null, 2), secrets)}\n`; | |
| 246 | + try { | |
| 247 | + writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 }); | |
| 248 | + renameSync(temporaryPath, EVIDENCE_PATH); | |
| 249 | + } finally { | |
| 250 | + rmSync(temporaryPath, { force: true }); | |
| 251 | + } | |
| 252 | + process.stdout.write(serialized); | |
| 253 | +} | |
| 254 | + | |
| 255 | +// Closes the snapshot/download window: re-fetches each dataset's metadata after | |
| 256 | +// all downloads complete and aborts the refresh when any revision moved, so the | |
| 257 | +// built candidate can never mix content from two source revisions. | |
| 258 | +async function verifySnapshotsUnchanged(downloads, datasetToken) { | |
| 259 | + for (const entry of downloads) { | |
| 260 | + if (entry.filename !== undefined) { | |
| 261 | + const resolved = await resolveIdfmGtfsDownload(datasetToken); | |
| 262 | + if (resolved.filename !== entry.filename) { | |
| 263 | + throw new Error(`IDFM GTFS record changed during refresh (${entry.filename} -> ${resolved.filename})`); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + const source = entry.key === GTFS_SOURCE.key | |
| 267 | + ? GTFS_SOURCE | |
| 268 | + : CSV_SOURCES.find((candidate) => candidate.key === entry.key); | |
| 269 | + if (source === undefined) { | |
| 270 | + throw new Error(`IDFM source ${entry.key} is missing from the refresh snapshot`); | |
| 271 | + } | |
| 272 | + const fresh = await fetchDatasetMetadata(source, source.restricted ? datasetToken : undefined); | |
| 273 | + if (fresh.modified !== entry.metadata.modified) { | |
| 274 | + throw new Error(`IDFM dataset ${entry.metadata.dataset} changed during refresh (modified ${entry.metadata.modified} -> ${fresh.modified})`); | |
| 275 | + } | |
| 276 | + } | |
| 277 | +} | |
| 278 | + | |
| 279 | +async function refreshCatalog(datasetToken, state) { | |
| 280 | + mkdirSync(dirname(ACTIVE_PATH), { recursive: true }); | |
| 281 | + const workspace = mkdtempSync(join(dirname(ACTIVE_PATH), ".refresh-")); | |
| 282 | + | |
| 283 | + let downloads; | |
| 284 | + let activated; | |
| 285 | + try { | |
| 286 | + const gtfsMetadata = await fetchDatasetMetadata(GTFS_SOURCE, datasetToken); | |
| 287 | + const resolvedGtfs = await resolveIdfmGtfsDownload(datasetToken); | |
| 288 | + if (new URL(resolvedGtfs.url).origin !== DATA_ORIGIN) { | |
| 289 | + throw new Error("Resolved IDFM GTFS download left the trusted data origin"); | |
| 290 | + } | |
| 291 | + const gtfsFilename = requiredText(resolvedGtfs.filename, "GTFS filename"); | |
| 292 | + if (Buffer.byteLength(gtfsFilename, "utf8") > 512) { | |
| 293 | + throw new Error("Resolved IDFM GTFS filename exceeds 512 bytes"); | |
| 294 | + } | |
| 295 | + const gtfsDownload = await downloadCatalogSource({ | |
| 296 | + source: sourceAttribution(gtfsMetadata, resolvedGtfs.url), | |
| 297 | + destinationPath: join(workspace, "gtfs.zip"), | |
| 298 | + expectedContentTypes: ["application/zip"], | |
| 299 | + datasetToken, | |
| 300 | + }); | |
| 301 | + downloads = [{ | |
| 302 | + key: GTFS_SOURCE.key, | |
| 303 | + metadata: gtfsMetadata, | |
| 304 | + download: gtfsDownload, | |
| 305 | + filename: gtfsFilename, | |
| 306 | + }]; | |
| 307 | + | |
| 308 | + for (const source of CSV_SOURCES) { | |
| 309 | + const metadata = await fetchDatasetMetadata( | |
| 310 | + source, | |
| 311 | + source.restricted ? datasetToken : undefined, | |
| 312 | + ); | |
| 313 | + const download = await downloadCatalogSource({ | |
| 314 | + source: sourceAttribution(metadata, source.url), | |
| 315 | + destinationPath: join(workspace, `${source.key}.csv`), | |
| 316 | + expectedContentTypes: ["text/csv"], | |
| 317 | + ...(source.restricted ? { datasetToken } : {}), | |
| 318 | + }); | |
| 319 | + downloads.push({ key: source.key, metadata, download }); | |
| 320 | + } | |
| 321 | + await verifySnapshotsUnchanged(downloads, datasetToken); | |
| 322 | + state.metadataRechecked = true; | |
| 323 | + | |
| 324 | + const byKey = Object.fromEntries(downloads.map((entry) => [entry.key, entry])); | |
| 325 | + const sourceRevision = revisionFor(downloads); | |
| 326 | + const createdAt = new Date().toISOString(); | |
| 327 | + const candidatePath = join(workspace, "candidate.sqlite"); | |
| 328 | + const buildResult = await buildCatalogCandidate({ | |
| 329 | + candidatePath, | |
| 330 | + sourceRevision, | |
| 331 | + createdAt, | |
| 332 | + sources: { | |
| 333 | + gtfsZipPath: byKey.gtfs.download.path, | |
| 334 | + perimeter: { | |
| 335 | + path: byKey.perimeter.download.path, | |
| 336 | + expectedRows: byKey.perimeter.metadata.recordsCount, | |
| 337 | + }, | |
| 338 | + arrets: { | |
| 339 | + path: byKey.arrets.download.path, | |
| 340 | + expectedRows: byKey.arrets.metadata.recordsCount, | |
| 341 | + }, | |
| 342 | + zones: { | |
| 343 | + path: byKey.zones.download.path, | |
| 344 | + expectedRows: byKey.zones.metadata.recordsCount, | |
| 345 | + }, | |
| 346 | + relations: { | |
| 347 | + path: byKey.relations.download.path, | |
| 348 | + expectedRows: byKey.relations.metadata.recordsCount, | |
| 349 | + }, | |
| 350 | + lines: { | |
| 351 | + path: byKey.lines.download.path, | |
| 352 | + expectedRows: byKey.lines.metadata.recordsCount, | |
| 353 | + }, | |
| 354 | + arretsLignes: { | |
| 355 | + path: byKey.arretsLignes.download.path, | |
| 356 | + expectedRows: byKey.arretsLignes.metadata.recordsCount, | |
| 357 | + }, | |
| 358 | + }, | |
| 359 | + }); | |
| 360 | + const validated = validateCatalogCandidate(candidatePath); | |
| 361 | + assertSameResult(buildResult, validated, "candidate"); | |
| 362 | + activated = activateCatalogCandidate(candidatePath, ACTIVE_PATH); | |
| 363 | + state.atomicActivation = true; | |
| 364 | + assertSameResult(validated, activated, "activation"); | |
| 365 | + } finally { | |
| 366 | + rmSync(workspace, { recursive: true, force: true }); | |
| 367 | + state.temporaryInputsRemoved = true; | |
| 368 | + } | |
| 369 | + | |
| 370 | + return { | |
| 371 | + command: "catalog:refresh", | |
| 372 | + status: "ok", | |
| 373 | + completedAt: new Date().toISOString(), | |
| 374 | + sourceRevision: activated.sourceRevision, | |
| 375 | + sources: downloads.map(evidenceSource), | |
| 376 | + catalog: { | |
| 377 | + createdAt: activated.createdAt, | |
| 378 | + placeCount: activated.placeCount, | |
| 379 | + serviceCount: activated.serviceCount, | |
| 380 | + countsByMode: activated.countsByMode, | |
| 381 | + placeResolution: buildResult.placeResolution ?? null, | |
| 382 | + }, | |
| 383 | + validation: { | |
| 384 | + sqliteIntegrity: "ok", | |
| 385 | + foreignKeys: "ok", | |
| 386 | + schemaAndCatalogVersions: "ok", | |
| 387 | + allModesCovered: true, | |
| 388 | + everyServiceResolvable: true, | |
| 389 | + metadataRechecked: state.metadataRechecked === true, | |
| 390 | + }, | |
| 391 | + activation: { | |
| 392 | + atomic: state.atomicActivation, | |
| 393 | + temporaryInputsRemoved: state.temporaryInputsRemoved, | |
| 394 | + ...(activated.revalidatedAfterRenameFailure === true | |
| 395 | + ? { revalidatedAfterRenameFailure: true } | |
| 396 | + : {}), | |
| 397 | + }, | |
| 398 | + maxRSSKiB: process.resourceUsage().maxRSS, | |
| 399 | + }; | |
| 400 | +} | |
| 401 | + | |
| 402 | +const startedAt = new Date().toISOString(); | |
| 403 | +const state = { | |
| 404 | + atomicActivation: false, | |
| 405 | + temporaryInputsRemoved: false, | |
| 406 | + metadataRechecked: false, | |
| 407 | +}; | |
| 408 | +const datasetToken = process.env.IDFM_DATASET_TOKEN; | |
| 409 | + | |
| 410 | +if (typeof datasetToken !== "string" || datasetToken.length === 0) { | |
| 411 | + process.stderr.write("catalog:refresh requires IDFM_DATASET_TOKEN\n"); | |
| 412 | + process.exitCode = 1; | |
| 413 | +} else { | |
| 414 | + try { | |
| 415 | + const report = await refreshCatalog(datasetToken, state); | |
| 416 | + writeEvidence({ ...report, startedAt }, [datasetToken]); | |
| 417 | + } catch (error) { | |
| 418 | + const message = redactSecrets( | |
| 419 | + error instanceof Error && error.message ? error.message : "Catalog refresh failed", | |
| 420 | + [datasetToken], | |
| 421 | + ); | |
| 422 | + const report = { | |
| 423 | + command: "catalog:refresh", | |
| 424 | + status: "failed", | |
| 425 | + startedAt, | |
| 426 | + completedAt: new Date().toISOString(), | |
| 427 | + activation: { | |
| 428 | + atomic: state.atomicActivation, | |
| 429 | + temporaryInputsRemoved: state.temporaryInputsRemoved, | |
| 430 | + }, | |
| 431 | + maxRSSKiB: process.resourceUsage().maxRSS, | |
| 432 | + error: message, | |
| 433 | + }; | |
| 434 | + try { | |
| 435 | + writeEvidence(report, [datasetToken]); | |
| 436 | + } catch { | |
| 437 | + // The primary refresh failure remains authoritative if evidence persistence also fails. | |
| 438 | + } | |
| 439 | + process.stderr.write(`catalog:refresh failed: ${message}\n`); | |
| 440 | + process.exitCode = 1; | |
| 441 | + } | |
| 442 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,442 @@ | |||
| 1 | +import { createHash } from "node:crypto"; | ||
| 2 | +import { | ||
| 3 | + mkdirSync, | ||
| 4 | + mkdtempSync, | ||
| 5 | + renameSync, | ||
| 6 | + rmSync, | ||
| 7 | + writeFileSync, | ||
| 8 | +} from "node:fs"; | ||
| 9 | +import { dirname, join, resolve } from "node:path"; | ||
| 10 | +import { fileURLToPath } from "node:url"; | ||
| 11 | +import { | ||
| 12 | + DOWNLOAD_TIMEOUT_MS, | ||
| 13 | + IDFM_CATALOG_SOURCE_URL, | ||
| 14 | + activateCatalogCandidate, | ||
| 15 | + buildCatalogCandidate, | ||
| 16 | + downloadCatalogSource, | ||
| 17 | + resolveIdfmGtfsDownload, | ||
| 18 | + validateCatalogCandidate, | ||
| 19 | +} from "../packages/backend/src/catalog-import.ts"; | ||
| 20 | +import { redactSecrets } from "../packages/backend/src/index.ts"; | ||
| 21 | + | ||
| 22 | +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | ||
| 23 | +const DEFAULT_ACTIVE_PATH = join(PROJECT_ROOT, "var/catalog/catalog.sqlite"); | ||
| 24 | +const ACTIVE_PATH = resolve(process.env.CATALOG_PATH ?? DEFAULT_ACTIVE_PATH); | ||
| 25 | +const EVIDENCE_PATH = join(PROJECT_ROOT, "var/catalog/evidence/refresh-catalog.json"); | ||
| 26 | +const METADATA_BODY_LIMIT = 1024 * 1024; | ||
| 27 | +const DATA_ORIGIN = new URL(IDFM_CATALOG_SOURCE_URL.gtfsRecord).origin; | ||
| 28 | + | ||
| 29 | +const GTFS_SOURCE = { | ||
| 30 | + key: "gtfs", | ||
| 31 | + dataset: "offre-horaires-tc-gtfs-idfm", | ||
| 32 | + restricted: true, | ||
| 33 | + requiredFields: ["filename", "url"], | ||
| 34 | +}; | ||
| 35 | + | ||
| 36 | +const CSV_SOURCES = [ | ||
| 37 | + { | ||
| 38 | + key: "perimeter", | ||
| 39 | + dataset: "perimetre-des-donnees-tr-disponibles-plateforme-idfm", | ||
| 40 | + url: IDFM_CATALOG_SOURCE_URL.perimeter, | ||
| 41 | + restricted: true, | ||
| 42 | + requiredFields: ["line", "name_line", "ns2_stoppointref", "ns2_stopname", "operatorname"], | ||
| 43 | + }, | ||
| 44 | + { | ||
| 45 | + key: "arrets", | ||
| 46 | + dataset: "arrets", | ||
| 47 | + url: IDFM_CATALOG_SOURCE_URL.arrets, | ||
| 48 | + restricted: false, | ||
| 49 | + requiredFields: ["arrid", "zdaid"], | ||
| 50 | + }, | ||
| 51 | + { | ||
| 52 | + key: "zones", | ||
| 53 | + dataset: "zones-d-arrets", | ||
| 54 | + url: IDFM_CATALOG_SOURCE_URL.zones, | ||
| 55 | + restricted: false, | ||
| 56 | + requiredFields: ["zdaid", "zdaname", "zdatown"], | ||
| 57 | + }, | ||
| 58 | + { | ||
| 59 | + key: "relations", | ||
| 60 | + dataset: "relations", | ||
| 61 | + url: IDFM_CATALOG_SOURCE_URL.relations, | ||
| 62 | + restricted: false, | ||
| 63 | + requiredFields: ["zdaid", "arrid", "artid"], | ||
| 64 | + }, | ||
| 65 | + { | ||
| 66 | + key: "lines", | ||
| 67 | + dataset: "referentiel-des-lignes", | ||
| 68 | + url: IDFM_CATALOG_SOURCE_URL.lines, | ||
| 69 | + restricted: false, | ||
| 70 | + requiredFields: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"], | ||
| 71 | + }, | ||
| 72 | + { | ||
| 73 | + key: "arretsLignes", | ||
| 74 | + dataset: "arrets-lignes", | ||
| 75 | + url: IDFM_CATALOG_SOURCE_URL.arretsLignes, | ||
| 76 | + restricted: false, | ||
| 77 | + requiredFields: ["id", "stop_id"], | ||
| 78 | + }, | ||
| 79 | +]; | ||
| 80 | + | ||
| 81 | +function record(value) { | ||
| 82 | + return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 83 | + ? value | ||
| 84 | + : undefined; | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +function requiredText(value, field) { | ||
| 88 | + if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { | ||
| 89 | + throw new Error(`Invalid ${field} in IDFM dataset metadata`); | ||
| 90 | + } | ||
| 91 | + return value; | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +async function boundedJson(response, dataset) { | ||
| 95 | + if (response.body === null) throw new Error(`IDFM metadata for ${dataset} returned no body`); | ||
| 96 | + const chunks = []; | ||
| 97 | + let byteLength = 0; | ||
| 98 | + for await (const chunkValue of response.body) { | ||
| 99 | + const chunk = chunkValue instanceof Uint8Array | ||
| 100 | + ? chunkValue | ||
| 101 | + : new Uint8Array(chunkValue); | ||
| 102 | + byteLength += chunk.byteLength; | ||
| 103 | + if (byteLength > METADATA_BODY_LIMIT) { | ||
| 104 | + throw new Error(`IDFM metadata for ${dataset} exceeds 1 MiB`); | ||
| 105 | + } | ||
| 106 | + chunks.push(chunk); | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + try { | ||
| 110 | + const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); | ||
| 111 | + return JSON.parse(text); | ||
| 112 | + } catch { | ||
| 113 | + throw new Error(`IDFM metadata for ${dataset} is not valid UTF-8 JSON`); | ||
| 114 | + } | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +async function fetchDatasetMetadata(source, datasetToken) { | ||
| 118 | + const target = new URL( | ||
| 119 | + `/api/explore/v2.1/catalog/datasets/${encodeURIComponent(source.dataset)}`, | ||
| 120 | + DATA_ORIGIN, | ||
| 121 | + ); | ||
| 122 | + const controller = new AbortController(); | ||
| 123 | + const timeout = setTimeout( | ||
| 124 | + () => controller.abort(new Error("IDFM metadata request timed out")), | ||
| 125 | + DOWNLOAD_TIMEOUT_MS, | ||
| 126 | + ); | ||
| 127 | + | ||
| 128 | + try { | ||
| 129 | + const response = await fetch(target, { | ||
| 130 | + method: "GET", | ||
| 131 | + redirect: "manual", | ||
| 132 | + credentials: "omit", | ||
| 133 | + cache: "no-store", | ||
| 134 | + headers: source.restricted | ||
| 135 | + ? { Authorization: `apikey ${datasetToken}` } | ||
| 136 | + : {}, | ||
| 137 | + signal: controller.signal, | ||
| 138 | + }); | ||
| 139 | + if (response.status !== 200) { | ||
| 140 | + throw new Error(`IDFM metadata for ${source.dataset} returned HTTP ${response.status}`); | ||
| 141 | + } | ||
| 142 | + const mediaType = (response.headers.get("content-type") ?? "") | ||
| 143 | + .split(";", 1)[0] | ||
| 144 | + .trim() | ||
| 145 | + .toLowerCase(); | ||
| 146 | + if (mediaType !== "application/json") { | ||
| 147 | + throw new Error(`IDFM metadata for ${source.dataset} is not JSON`); | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + const value = record(await boundedJson(response, source.dataset)); | ||
| 151 | + const metas = record(value?.metas); | ||
| 152 | + const defaults = record(metas?.default); | ||
| 153 | + if (value?.dataset_id !== source.dataset || value?.has_records !== true) { | ||
| 154 | + throw new Error(`IDFM metadata identity for ${source.dataset} is invalid`); | ||
| 155 | + } | ||
| 156 | + if (value.data_visible !== !source.restricted) { | ||
| 157 | + throw new Error(`IDFM metadata visibility for ${source.dataset} changed`); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + const fields = Array.isArray(value.fields) ? value.fields : []; | ||
| 161 | + const fieldNames = new Set(fields.map((field) => record(field)?.name)); | ||
| 162 | + for (const requiredField of source.requiredFields) { | ||
| 163 | + if (!fieldNames.has(requiredField)) { | ||
| 164 | + throw new Error(`IDFM metadata for ${source.dataset} lacks ${requiredField}`); | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + const recordsCount = defaults?.records_count; | ||
| 169 | + if (!Number.isSafeInteger(recordsCount) || recordsCount < 1) { | ||
| 170 | + throw new Error(`IDFM metadata record count for ${source.dataset} is invalid`); | ||
| 171 | + } | ||
| 172 | + const modified = requiredText(defaults?.modified, `${source.dataset}.modified`); | ||
| 173 | + if (!Number.isFinite(Date.parse(modified))) { | ||
| 174 | + throw new Error(`IDFM metadata timestamp for ${source.dataset} is invalid`); | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + return { | ||
| 178 | + dataset: source.dataset, | ||
| 179 | + datasetUid: requiredText(value.dataset_uid, `${source.dataset}.dataset_uid`), | ||
| 180 | + title: requiredText(defaults?.title, `${source.dataset}.title`), | ||
| 181 | + license: requiredText(defaults?.license, `${source.dataset}.license`), | ||
| 182 | + modified, | ||
| 183 | + recordsCount, | ||
| 184 | + restricted: source.restricted, | ||
| 185 | + retrievedAt: new Date().toISOString(), | ||
| 186 | + }; | ||
| 187 | + } finally { | ||
| 188 | + clearTimeout(timeout); | ||
| 189 | + } | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +function sourceAttribution(metadata, url) { | ||
| 193 | + return { | ||
| 194 | + dataset: metadata.dataset, | ||
| 195 | + url, | ||
| 196 | + retrievedAt: metadata.retrievedAt, | ||
| 197 | + license: metadata.license, | ||
| 198 | + restricted: metadata.restricted, | ||
| 199 | + }; | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +function revisionFor(downloads) { | ||
| 203 | + const sourceState = downloads.map(({ metadata, download }) => ({ | ||
| 204 | + dataset: metadata.dataset, | ||
| 205 | + modified: metadata.modified, | ||
| 206 | + recordsCount: metadata.recordsCount, | ||
| 207 | + sha256: download.sha256, | ||
| 208 | + })); | ||
| 209 | + const digest = createHash("sha256") | ||
| 210 | + .update(JSON.stringify(sourceState), "utf8") | ||
| 211 | + .digest("hex"); | ||
| 212 | + return `idfm-v1-${digest}`; | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +function assertSameResult(expected, actual, phase) { | ||
| 216 | + const sameCounts = expected.placeCount === actual.placeCount | ||
| 217 | + && expected.serviceCount === actual.serviceCount | ||
| 218 | + && Object.keys(expected.countsByMode).every( | ||
| 219 | + (mode) => expected.countsByMode[mode] === actual.countsByMode[mode], | ||
| 220 | + ); | ||
| 221 | + if (expected.sourceRevision !== actual.sourceRevision || !sameCounts) { | ||
| 222 | + throw new Error(`Catalog ${phase} result changed after validation`); | ||
| 223 | + } | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +function evidenceSource({ metadata, download, filename }) { | ||
| 227 | + return { | ||
| 228 | + dataset: metadata.dataset, | ||
| 229 | + datasetUid: metadata.datasetUid, | ||
| 230 | + title: metadata.title, | ||
| 231 | + license: metadata.license, | ||
| 232 | + restricted: metadata.restricted, | ||
| 233 | + modified: metadata.modified, | ||
| 234 | + retrievedAt: metadata.retrievedAt, | ||
| 235 | + recordsCount: metadata.recordsCount, | ||
| 236 | + bytes: download.bytes, | ||
| 237 | + sha256: download.sha256, | ||
| 238 | + ...(filename === undefined ? {} : { filename }), | ||
| 239 | + }; | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +function writeEvidence(report, secrets) { | ||
| 243 | + mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); | ||
| 244 | + const temporaryPath = `${EVIDENCE_PATH}.${process.pid}.tmp`; | ||
| 245 | + const serialized = `${redactSecrets(JSON.stringify(report, null, 2), secrets)}\n`; | ||
| 246 | + try { | ||
| 247 | + writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 }); | ||
| 248 | + renameSync(temporaryPath, EVIDENCE_PATH); | ||
| 249 | + } finally { | ||
| 250 | + rmSync(temporaryPath, { force: true }); | ||
| 251 | + } | ||
| 252 | + process.stdout.write(serialized); | ||
| 253 | +} | ||
| 254 | + | ||
| 255 | +// Closes the snapshot/download window: re-fetches each dataset's metadata after | ||
| 256 | +// all downloads complete and aborts the refresh when any revision moved, so the | ||
| 257 | +// built candidate can never mix content from two source revisions. | ||
| 258 | +async function verifySnapshotsUnchanged(downloads, datasetToken) { | ||
| 259 | + for (const entry of downloads) { | ||
| 260 | + if (entry.filename !== undefined) { | ||
| 261 | + const resolved = await resolveIdfmGtfsDownload(datasetToken); | ||
| 262 | + if (resolved.filename !== entry.filename) { | ||
| 263 | + throw new Error(`IDFM GTFS record changed during refresh (${entry.filename} -> ${resolved.filename})`); | ||
| 264 | + } | ||
| 265 | + } | ||
| 266 | + const source = entry.key === GTFS_SOURCE.key | ||
| 267 | + ? GTFS_SOURCE | ||
| 268 | + : CSV_SOURCES.find((candidate) => candidate.key === entry.key); | ||
| 269 | + if (source === undefined) { | ||
| 270 | + throw new Error(`IDFM source ${entry.key} is missing from the refresh snapshot`); | ||
| 271 | + } | ||
| 272 | + const fresh = await fetchDatasetMetadata(source, source.restricted ? datasetToken : undefined); | ||
| 273 | + if (fresh.modified !== entry.metadata.modified) { | ||
| 274 | + throw new Error(`IDFM dataset ${entry.metadata.dataset} changed during refresh (modified ${entry.metadata.modified} -> ${fresh.modified})`); | ||
| 275 | + } | ||
| 276 | + } | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +async function refreshCatalog(datasetToken, state) { | ||
| 280 | + mkdirSync(dirname(ACTIVE_PATH), { recursive: true }); | ||
| 281 | + const workspace = mkdtempSync(join(dirname(ACTIVE_PATH), ".refresh-")); | ||
| 282 | + | ||
| 283 | + let downloads; | ||
| 284 | + let activated; | ||
| 285 | + try { | ||
| 286 | + const gtfsMetadata = await fetchDatasetMetadata(GTFS_SOURCE, datasetToken); | ||
| 287 | + const resolvedGtfs = await resolveIdfmGtfsDownload(datasetToken); | ||
| 288 | + if (new URL(resolvedGtfs.url).origin !== DATA_ORIGIN) { | ||
| 289 | + throw new Error("Resolved IDFM GTFS download left the trusted data origin"); | ||
| 290 | + } | ||
| 291 | + const gtfsFilename = requiredText(resolvedGtfs.filename, "GTFS filename"); | ||
| 292 | + if (Buffer.byteLength(gtfsFilename, "utf8") > 512) { | ||
| 293 | + throw new Error("Resolved IDFM GTFS filename exceeds 512 bytes"); | ||
| 294 | + } | ||
| 295 | + const gtfsDownload = await downloadCatalogSource({ | ||
| 296 | + source: sourceAttribution(gtfsMetadata, resolvedGtfs.url), | ||
| 297 | + destinationPath: join(workspace, "gtfs.zip"), | ||
| 298 | + expectedContentTypes: ["application/zip"], | ||
| 299 | + datasetToken, | ||
| 300 | + }); | ||
| 301 | + downloads = [{ | ||
| 302 | + key: GTFS_SOURCE.key, | ||
| 303 | + metadata: gtfsMetadata, | ||
| 304 | + download: gtfsDownload, | ||
| 305 | + filename: gtfsFilename, | ||
| 306 | + }]; | ||
| 307 | + | ||
| 308 | + for (const source of CSV_SOURCES) { | ||
| 309 | + const metadata = await fetchDatasetMetadata( | ||
| 310 | + source, | ||
| 311 | + source.restricted ? datasetToken : undefined, | ||
| 312 | + ); | ||
| 313 | + const download = await downloadCatalogSource({ | ||
| 314 | + source: sourceAttribution(metadata, source.url), | ||
| 315 | + destinationPath: join(workspace, `${source.key}.csv`), | ||
| 316 | + expectedContentTypes: ["text/csv"], | ||
| 317 | + ...(source.restricted ? { datasetToken } : {}), | ||
| 318 | + }); | ||
| 319 | + downloads.push({ key: source.key, metadata, download }); | ||
| 320 | + } | ||
| 321 | + await verifySnapshotsUnchanged(downloads, datasetToken); | ||
| 322 | + state.metadataRechecked = true; | ||
| 323 | + | ||
| 324 | + const byKey = Object.fromEntries(downloads.map((entry) => [entry.key, entry])); | ||
| 325 | + const sourceRevision = revisionFor(downloads); | ||
| 326 | + const createdAt = new Date().toISOString(); | ||
| 327 | + const candidatePath = join(workspace, "candidate.sqlite"); | ||
| 328 | + const buildResult = await buildCatalogCandidate({ | ||
| 329 | + candidatePath, | ||
| 330 | + sourceRevision, | ||
| 331 | + createdAt, | ||
| 332 | + sources: { | ||
| 333 | + gtfsZipPath: byKey.gtfs.download.path, | ||
| 334 | + perimeter: { | ||
| 335 | + path: byKey.perimeter.download.path, | ||
| 336 | + expectedRows: byKey.perimeter.metadata.recordsCount, | ||
| 337 | + }, | ||
| 338 | + arrets: { | ||
| 339 | + path: byKey.arrets.download.path, | ||
| 340 | + expectedRows: byKey.arrets.metadata.recordsCount, | ||
| 341 | + }, | ||
| 342 | + zones: { | ||
| 343 | + path: byKey.zones.download.path, | ||
| 344 | + expectedRows: byKey.zones.metadata.recordsCount, | ||
| 345 | + }, | ||
| 346 | + relations: { | ||
| 347 | + path: byKey.relations.download.path, | ||
| 348 | + expectedRows: byKey.relations.metadata.recordsCount, | ||
| 349 | + }, | ||
| 350 | + lines: { | ||
| 351 | + path: byKey.lines.download.path, | ||
| 352 | + expectedRows: byKey.lines.metadata.recordsCount, | ||
| 353 | + }, | ||
| 354 | + arretsLignes: { | ||
| 355 | + path: byKey.arretsLignes.download.path, | ||
| 356 | + expectedRows: byKey.arretsLignes.metadata.recordsCount, | ||
| 357 | + }, | ||
| 358 | + }, | ||
| 359 | + }); | ||
| 360 | + const validated = validateCatalogCandidate(candidatePath); | ||
| 361 | + assertSameResult(buildResult, validated, "candidate"); | ||
| 362 | + activated = activateCatalogCandidate(candidatePath, ACTIVE_PATH); | ||
| 363 | + state.atomicActivation = true; | ||
| 364 | + assertSameResult(validated, activated, "activation"); | ||
| 365 | + } finally { | ||
| 366 | + rmSync(workspace, { recursive: true, force: true }); | ||
| 367 | + state.temporaryInputsRemoved = true; | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + return { | ||
| 371 | + command: "catalog:refresh", | ||
| 372 | + status: "ok", | ||
| 373 | + completedAt: new Date().toISOString(), | ||
| 374 | + sourceRevision: activated.sourceRevision, | ||
| 375 | + sources: downloads.map(evidenceSource), | ||
| 376 | + catalog: { | ||
| 377 | + createdAt: activated.createdAt, | ||
| 378 | + placeCount: activated.placeCount, | ||
| 379 | + serviceCount: activated.serviceCount, | ||
| 380 | + countsByMode: activated.countsByMode, | ||
| 381 | + placeResolution: buildResult.placeResolution ?? null, | ||
| 382 | + }, | ||
| 383 | + validation: { | ||
| 384 | + sqliteIntegrity: "ok", | ||
| 385 | + foreignKeys: "ok", | ||
| 386 | + schemaAndCatalogVersions: "ok", | ||
| 387 | + allModesCovered: true, | ||
| 388 | + everyServiceResolvable: true, | ||
| 389 | + metadataRechecked: state.metadataRechecked === true, | ||
| 390 | + }, | ||
| 391 | + activation: { | ||
| 392 | + atomic: state.atomicActivation, | ||
| 393 | + temporaryInputsRemoved: state.temporaryInputsRemoved, | ||
| 394 | + ...(activated.revalidatedAfterRenameFailure === true | ||
| 395 | + ? { revalidatedAfterRenameFailure: true } | ||
| 396 | + : {}), | ||
| 397 | + }, | ||
| 398 | + maxRSSKiB: process.resourceUsage().maxRSS, | ||
| 399 | + }; | ||
| 400 | +} | ||
| 401 | + | ||
| 402 | +const startedAt = new Date().toISOString(); | ||
| 403 | +const state = { | ||
| 404 | + atomicActivation: false, | ||
| 405 | + temporaryInputsRemoved: false, | ||
| 406 | + metadataRechecked: false, | ||
| 407 | +}; | ||
| 408 | +const datasetToken = process.env.IDFM_DATASET_TOKEN; | ||
| 409 | + | ||
| 410 | +if (typeof datasetToken !== "string" || datasetToken.length === 0) { | ||
| 411 | + process.stderr.write("catalog:refresh requires IDFM_DATASET_TOKEN\n"); | ||
| 412 | + process.exitCode = 1; | ||
| 413 | +} else { | ||
| 414 | + try { | ||
| 415 | + const report = await refreshCatalog(datasetToken, state); | ||
| 416 | + writeEvidence({ ...report, startedAt }, [datasetToken]); | ||
| 417 | + } catch (error) { | ||
| 418 | + const message = redactSecrets( | ||
| 419 | + error instanceof Error && error.message ? error.message : "Catalog refresh failed", | ||
| 420 | + [datasetToken], | ||
| 421 | + ); | ||
| 422 | + const report = { | ||
| 423 | + command: "catalog:refresh", | ||
| 424 | + status: "failed", | ||
| 425 | + startedAt, | ||
| 426 | + completedAt: new Date().toISOString(), | ||
| 427 | + activation: { | ||
| 428 | + atomic: state.atomicActivation, | ||
| 429 | + temporaryInputsRemoved: state.temporaryInputsRemoved, | ||
| 430 | + }, | ||
| 431 | + maxRSSKiB: process.resourceUsage().maxRSS, | ||
| 432 | + error: message, | ||
| 433 | + }; | ||
| 434 | + try { | ||
| 435 | + writeEvidence(report, [datasetToken]); | ||
| 436 | + } catch { | ||
| 437 | + // The primary refresh failure remains authoritative if evidence persistence also fails. | ||
| 438 | + } | ||
| 439 | + process.stderr.write(`catalog:refresh failed: ${message}\n`); | ||
| 440 | + process.exitCode = 1; | ||
| 441 | + } | ||
| 442 | +} | ||
modified
scripts/test.mjs +3 -0 | @@ -10,6 +10,9 @@ run(process.execPath, [ | ||
| 10 | 10 | "--test", |
| 11 | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | 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 | 16 | "packages/config-page/test/config-page.test.ts", |
| 14 | 17 | "packages/companion/test/protocol.test.js", |
| 15 | 18 | "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", |