refactor(server): fold airports and distance, drop the boot-time backfill cycle (#1834)

Two thirds of be-weather-airports-fold. weatherService (513 LOC, a process
cache and a setInterval at import) is left for its own PR.

airportService split along the line that was already there. The dataset lookup
touches no database — it reads one JSON file and keeps it in memory — and both
MCP registrars call it from outside the container, so findByIata/searchAirports
are free functions in nest/airports/airports.data.ts. backfillFlightEndpoints
needs the connection and became a method on AirportsService.

That backfill was the interesting part. db/database.ts ran it at module eval
through a bare require() back into services/, so the module graph closed a cycle
that only held because `db` happened to be initialised by the time the require
resolved. It runs in onApplicationBootstrap now: it repairs rows, nothing boots
on it, and the container is fully built by then.

The cycle had a visible cost. tests/setup.console-noise.ts carried a suppression
for `Cannot find module '../services/airportService'`, because vitest's
transform pipeline could not resolve that require at all — it fired in roughly
sixty test files. That suppression is now narrowed to the only case left: an e2e
TestingModule whose fixture DB has no reservations table.

distanceService split the same way: haversineKm is arithmetic and moved to
common/geo.ts; getFlightDistanceKm reads the database and became a private
method on AuthService, its only caller.

AirportsModule imports DatabaseModule explicitly. @Global only reaches modules
that are in the graph, and the airports e2e builds its container from
AirportsModule alone — exactly the trap the nest README now documents.

The search ranking gets its first direct tests. It sat in services/, outside the
coverage gate, and only ever ran incidentally through the route; moving it into
src/nest made the gap measurable (16% branches). The ten cases pin the scoring
ladder — exact IATA short-circuit, ICAO over prefix, IATA prefix over city
prefix, city prefix over substring — and the missing-dataset fallback, which no
test had reached. 90% branches now.
This commit is contained in:
Maurice
2026-08-07 21:07:14 +02:00
committed by GitHub
parent c7fed4b1f8
commit 9c46f31c57
15 changed files with 296 additions and 180 deletions
-6
View File
@@ -167,12 +167,6 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
try {
const { backfillFlightEndpoints } = require('../services/airportService');
backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
export type { TripAccess, PlaceWithTags };
+1 -1
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { findByIata, searchAirports } from '../../services/airportService';
import { findByIata, searchAirports } from '../../nest/airports/airports.data';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import {
TOOL_ANNOTATIONS_READONLY,
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from '../../nest/reservations/reservations.bridge';
import { linkBudgetItemToReservation } from '../../nest/budget/budget.bridge';
import { getDay } from '../../nest/days/days.bridge';
import { findByIata } from '../../services/airportService';
import { findByIata } from '../../nest/airports/airports.data';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
+71
View File
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* The bundled airport dataset — a JSON file read once and kept in memory.
*
* Free functions rather than a provider: nothing here touches the database, and
* the two MCP registrars (mcp/tools/mapsWeather.ts, mcp/tools/transports.ts)
* call them from outside the container. AirportsService is the in-container
* face; the DB-touching backfill lives there.
*/
export interface Airport {
iata: string;
icao: string | null;
name: string;
city: string;
country: string;
lat: number;
lng: number;
tz: string;
}
let cache: Airport[] | null = null;
let byIata: Map<string, Airport> | null = null;
export function load(): Airport[] {
if (cache) return cache;
const file = path.join(__dirname, '..', '..', '..', 'assets', 'airports.json');
if (!fs.existsSync(file)) {
console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`');
cache = [];
byIata = new Map();
return cache;
}
const raw = fs.readFileSync(file, 'utf8');
cache = JSON.parse(raw) as Airport[];
byIata = new Map(cache.map(a => [a.iata, a]));
return cache;
}
export function findByIata(code: string): Airport | null {
load();
return byIata!.get(code.toUpperCase()) ?? null;
}
export function searchAirports(query: string, limit = 12): Airport[] {
const all = load();
const q = query.trim().toLowerCase();
if (!q) return [];
const upper = q.toUpperCase();
if (q.length === 3) {
const exact = byIata!.get(upper);
if (exact) return [exact];
}
const matches: Array<{ a: Airport; score: number }> = [];
for (const a of all) {
let score = 0;
if (a.iata === upper) score = 100;
else if (a.icao === upper) score = 90;
else if (a.iata.startsWith(upper)) score = 70;
else if (a.city.toLowerCase().startsWith(q)) score = 60;
else if (a.name.toLowerCase().startsWith(q)) score = 50;
else if (a.city.toLowerCase().includes(q)) score = 30;
else if (a.name.toLowerCase().includes(q)) score = 20;
if (score > 0) matches.push({ a, score });
}
matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata));
return matches.slice(0, limit).map(m => m.a);
}
+6 -1
View File
@@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { AirportsController } from './airports.controller';
import { AirportsService } from './airports.service';
import { DatabaseModule } from '../database/database.module';
/** Airports domain (L2 leaf module). Registered in AppModule. */
/** Airports domain (L2 leaf module). Registered in AppModule.
* DatabaseModule is imported explicitly: the flight-endpoint backfill needs the
* connection, and @Global only reaches modules that are in the graph — which
* the airports e2e TestingModule is not. */
@Module({
imports: [DatabaseModule],
controllers: [AirportsController],
providers: [AirportsService],
})
+66 -6
View File
@@ -1,14 +1,29 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { Airport } from '@trek/shared';
import { searchAirports, findByIata } from '../../services/airportService';
import { searchAirports, findByIata, load } from './airports.data';
import { DatabaseService } from '../database/database.service';
/**
* Thin Nest wrapper around the existing airport service. It delegates to the
* same `searchAirports` / `findByIata` functions the legacy route uses, so the
* in-memory dataset and lookup behaviour stay identical and unduplicated.
* The in-container face of the airport dataset, plus the flight-endpoint
* backfill that needs the database.
*
* The backfill used to run from db/database.ts at module load, through a
* require() back into services/ — a cycle that only held because `db` happened
* to be initialised by then. It runs on application bootstrap now: it repairs
* rows, nothing boots on it, and the container is fully built by that point.
*/
@Injectable()
export class AirportsService {
export class AirportsService implements OnApplicationBootstrap {
constructor(private readonly db: DatabaseService) {}
onApplicationBootstrap(): void {
try {
this.backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
}
search(query: string): Airport[] {
return searchAirports(query) as Airport[];
}
@@ -16,4 +31,49 @@ export class AirportsService {
findByIata(code: string): Airport | null {
return findByIata(code) as Airport | null;
}
backfillFlightEndpoints(): void {
const pending = this.db.prepare(`
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
FROM reservations r
WHERE r.type = 'flight'
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
if (pending.length === 0) return;
load();
const insert = this.db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const markReview = this.db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?');
let filled = 0;
let flagged = 0;
for (const r of pending) {
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
let meta: any;
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
const split = (iso: string | null) => {
if (!iso) return { date: null as string | null, time: null as string | null };
const [date, time] = iso.split('T');
return { date: date || null, time: time ? time.slice(0, 5) : null };
};
const depParts = split(r.reservation_time);
const arrParts = split(r.reservation_end_time);
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
filled++;
}
console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`);
}
}
+33 -2
View File
@@ -24,7 +24,7 @@ import { revokeUserSessions } from '../../mcp/sessionManager';
import { startTripReminders } from '../../scheduler';
import { deleteUserCompletely } from '../../services/userCleanupService';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { getFlightDistanceKm } from '../../services/distanceService';
import { haversineKm } from '../common/geo';
import { verifyJwtAndLoadUser } from './jwt-verify';
import { User } from '../../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from '../common/demo';
@@ -991,10 +991,41 @@ export class AuthService {
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
totalDistanceKm: getFlightDistanceKm(userId),
totalDistanceKm: this.flightDistanceKm(userId),
};
}
/**
* Total flight distance a user has covered, summed across every non-cancelled
* flight reservation in their trips. Each flight stores its waypoints in
* reservation_endpoints (from → stops → to, ordered by sequence); the legs
* between consecutive points are added up so multi-stop flights count
* correctly.
*/
private flightDistanceKm(userId: number): number {
const rows = this.db.all<{ reservation_id: number; lat: number; lng: number }>(`
SELECT re.reservation_id, re.lat, re.lng
FROM reservation_endpoints re
JOIN reservations r ON r.id = re.reservation_id
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND r.type = 'flight'
AND r.status != 'cancelled'
ORDER BY re.reservation_id, re.sequence
`, userId, userId);
let total = 0;
let prev: { id: number; lat: number; lng: number } | null = null;
for (const point of rows) {
if (prev && prev.id === point.reservation_id) {
total += haversineKm(prev.lat, prev.lng, point.lat, point.lng);
}
prev = { id: point.reservation_id, lat: point.lat, lng: point.lng };
}
return Math.round(total);
}
// -------------------------------------------------------------------------
// MFA
// -------------------------------------------------------------------------
@@ -1,4 +1,4 @@
import { findByIata } from '../../services/airportService';
import { findByIata } from '../airports/airports.data';
import type {
KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip,
KiLodgingBusiness, KiFoodEstablishment, KiRentalCar, KiEvent,
+11
View File
@@ -0,0 +1,11 @@
/** Great-circle distance between two points in kilometres. */
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
@@ -7,7 +7,7 @@
* transit.mcp.ts's @Tool inputSchema decorators, which evaluate at module
* load — before any DI container exists — so they must stay module-level.
*/
import { haversineKm } from '../../services/distanceService';
import { haversineKm } from '../common/geo';
import type { EndpointInput } from '../reservations/reservations.service';
import { localParts, resolveTimeZone } from '../common/timezoneService';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
-109
View File
@@ -1,109 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../db/database';
export interface Airport {
iata: string;
icao: string | null;
name: string;
city: string;
country: string;
lat: number;
lng: number;
tz: string;
}
let cache: Airport[] | null = null;
let byIata: Map<string, Airport> | null = null;
function load(): Airport[] {
if (cache) return cache;
const file = path.join(__dirname, '..', '..', 'assets', 'airports.json');
if (!fs.existsSync(file)) {
console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`');
cache = [];
byIata = new Map();
return cache;
}
const raw = fs.readFileSync(file, 'utf8');
cache = JSON.parse(raw) as Airport[];
byIata = new Map(cache.map(a => [a.iata, a]));
return cache;
}
export function findByIata(code: string): Airport | null {
load();
return byIata!.get(code.toUpperCase()) ?? null;
}
export function searchAirports(query: string, limit = 12): Airport[] {
const all = load();
const q = query.trim().toLowerCase();
if (!q) return [];
const upper = q.toUpperCase();
if (q.length === 3) {
const exact = byIata!.get(upper);
if (exact) return [exact];
}
const matches: Array<{ a: Airport; score: number }> = [];
for (const a of all) {
let score = 0;
if (a.iata === upper) score = 100;
else if (a.icao === upper) score = 90;
else if (a.iata.startsWith(upper)) score = 70;
else if (a.city.toLowerCase().startsWith(q)) score = 60;
else if (a.name.toLowerCase().startsWith(q)) score = 50;
else if (a.city.toLowerCase().includes(q)) score = 30;
else if (a.name.toLowerCase().includes(q)) score = 20;
if (score > 0) matches.push({ a, score });
}
matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata));
return matches.slice(0, limit).map(m => m.a);
}
export function backfillFlightEndpoints(): void {
const pending = db.prepare(`
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
FROM reservations r
WHERE r.type = 'flight'
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
if (pending.length === 0) return;
load();
const insert = db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const markReview = db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?');
let filled = 0;
let flagged = 0;
for (const r of pending) {
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
let meta: any;
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
const split = (iso: string | null) => {
if (!iso) return { date: null as string | null, time: null as string | null };
const [date, time] = iso.split('T');
return { date: date || null, time: time ? time.slice(0, 5) : null };
};
const depParts = split(r.reservation_time);
const arrParts = split(r.reservation_end_time);
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
filled++;
}
console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`);
}
-43
View File
@@ -1,43 +0,0 @@
import { db } from '../db/database';
// Great-circle distance between two points in kilometres.
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Total flight distance a user has covered, summed across every non-cancelled
* flight reservation in their trips. Each flight stores its waypoints in
* reservation_endpoints (from → stops → to, ordered by sequence); we add up the
* legs between consecutive points so multi-stop flights count correctly.
*/
export function getFlightDistanceKm(userId: number): number {
const rows = db.prepare(`
SELECT re.reservation_id, re.lat, re.lng
FROM reservation_endpoints re
JOIN reservations r ON r.id = re.reservation_id
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND r.type = 'flight'
AND r.status != 'cancelled'
ORDER BY re.reservation_id, re.sequence
`).all(userId, userId) as { reservation_id: number; lat: number; lng: number }[];
let total = 0;
let prev: { id: number; lat: number; lng: number } | null = null;
for (const point of rows) {
if (prev && prev.id === point.reservation_id) {
total += haversineKm(prev.lat, prev.lng, point.lat, point.lng);
}
prev = { id: point.reservation_id, lat: point.lat, lng: point.lng };
}
return Math.round(total);
}