diff --git a/src/services/airport.ts b/src/services/airport.ts index 300cf1d..2a4a9f1 100644 --- a/src/services/airport.ts +++ b/src/services/airport.ts @@ -31,14 +31,22 @@ interface AirportData { } export class AirportService { - private dbSession: DatabaseSessionService; - constructor( private db: D1Database, private apiToken: string, private posthog?: PostHogService, - ) { - this.dbSession = new DatabaseSessionService(db); + ) {} + + // D1 sessions should be scoped to one logical request/operation. + // Reusing them across Worker requests can carry old bookmarks forward and + // cause later reads to wait for replica catch-up. + private async withDbSession(operation: (dbSession: DatabaseSessionService) => Promise): Promise { + const dbSession = new DatabaseSessionService(this.db); + try { + return await operation(dbSession); + } finally { + dbSession.closeSession(); + } } async getAirport(icao: string) { @@ -47,186 +55,8 @@ export class AirportService { return null; } - const airportResult = await this.dbSession.executeRead<{ - icao: string; - latitude: number | null; - longitude: number | null; - name: string; - continent: string; - country_code: string | null; - country_name: string | null; - region_name: string | null; - elevation_ft: number | null; - elevation_m: number | null; - bbox_min_lat: number | null; - bbox_min_lon: number | null; - bbox_max_lat: number | null; - bbox_max_lon: number | null; - }>('SELECT * FROM airports WHERE icao = ?', [uppercaseIcao]); - const airportFromDb = airportResult.results[0]; - - if (airportFromDb) { - const fetchPromises: Promise[] = []; - let needsReread = false; - - if (airportFromDb.elevation_ft == null) { - needsReread = true; - fetchPromises.push( - this.fetchAndStoreElevation(uppercaseIcao) - .then(() => {}) - .catch(() => {}), - ); - } - if (airportFromDb.country_code == null || airportFromDb.country_name == null || airportFromDb.region_name == null) { - needsReread = true; - fetchPromises.push( - this.fetchAndStoreLocationMeta(uppercaseIcao) - .then(() => {}) - .catch(() => {}), - ); - } - if ( - airportFromDb.bbox_min_lat == null || - airportFromDb.bbox_min_lon == null || - airportFromDb.bbox_max_lat == null || - airportFromDb.bbox_max_lon == null - ) { - needsReread = true; - fetchPromises.push( - this.fetchAndStoreBoundingBox(uppercaseIcao) - .then(() => {}) - .catch((err) => { - try { - this.posthog?.track('Airport Bounding Box Unavailable', { - source: 'db-cache-miss', - icao: uppercaseIcao, - error: err instanceof Error ? err.message : String(err), - }); - } catch { - /* ignore analytics errors */ - } - }), - ); - } - - if (fetchPromises.length > 0) { - await Promise.all(fetchPromises); - } - - if (needsReread) { - const reread = await this.dbSession.executeRead<{ - icao: string; - latitude: number | null; - longitude: number | null; - name: string; - continent: string; - country_code: string | null; - country_name: string | null; - region_name: string | null; - elevation_ft: number | null; - elevation_m: number | null; - bbox_min_lat: number | null; - bbox_min_lon: number | null; - bbox_max_lat: number | null; - bbox_max_lon: number | null; - }>('SELECT * FROM airports WHERE icao = ?', [uppercaseIcao]); - if (reread.results[0]) Object.assign(airportFromDb, reread.results[0]); - } - const runwaysResult = await this.dbSession.executeRead<{ - length_ft: string; - width_ft: string; - le_ident: string; - le_latitude_deg: string; - le_longitude_deg: string; - he_ident: string; - he_latitude_deg: string; - he_longitude_deg: string; - }>( - `SELECT - length_ft, - width_ft, - le_ident, - le_latitude_deg, - le_longitude_deg, - he_ident, - he_latitude_deg, - he_longitude_deg - FROM runways WHERE airport_icao = ?`, - [uppercaseIcao], - ); - return { ...airportFromDb, runways: runwaysResult.results }; - } - - try { - const response = await fetch(`https://airportdb.io/api/v1/airport/${uppercaseIcao}?apiToken=${this.apiToken}`, { - method: 'GET', - }); - if (!response.ok) { - if (response.status === 404) return null; - throw new HttpError(503, 'Bounding box unavailable'); - } - const airportData = (await response.json()) as AirportData; - - const hasCoords = Number.isFinite(airportData.latitude_deg) && Number.isFinite(airportData.longitude_deg); - if (!hasCoords) { - try { - this.posthog?.track('Airport External Fetch MissingCoords', { icao: uppercaseIcao }); - } catch { - /* ignore */ - } - return null; - } - - const elevation_ft = airportData.elevation_ft ? parseInt(airportData.elevation_ft, 10) : null; - const elevation_m = elevation_ft != null && !Number.isNaN(elevation_ft) ? Math.round(elevation_ft * 0.3048 * 100) / 100 : null; - const country_code = airportData.iso_country?.trim().toUpperCase() || airportData.country?.code?.trim().toUpperCase() || null; - const country_name = airportData.country?.name?.trim() || null; - const region_name = airportData.region?.name?.trim() || null; - - const airport = { - icao: uppercaseIcao, - latitude: airportData.latitude_deg!, - longitude: airportData.longitude_deg!, - name: airportData.name || '', - continent: airportData.continent || 'UNKNOWN', - country_code, - country_name, - region_name, - elevation_ft: !Number.isNaN(elevation_ft) ? elevation_ft : null, - elevation_m, - }; - - await this.dbSession.executeWrite( - 'INSERT INTO airports (icao, latitude, longitude, name, continent, country_code, country_name, region_name, elevation_ft, elevation_m) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - airport.icao, - airport.latitude, - airport.longitude, - airport.name, - airport.continent, - airport.country_code, - airport.country_name, - airport.region_name, - airport.elevation_ft, - airport.elevation_m, - ], - ); - - try { - await this.fetchAndStoreBoundingBox(uppercaseIcao); - } catch (err) { - try { - this.posthog?.track('Airport Bounding Box Unavailable', { - source: 'external-api', - icao: uppercaseIcao, - error: err instanceof Error ? err.message : String(err), - }); - } catch { - /* ignore analytics errors */ - } - } - - const reread = await this.dbSession.executeRead<{ + return this.withDbSession(async (dbSession) => { + const airportResult = await dbSession.executeRead<{ icao: string; latitude: number | null; longitude: number | null; @@ -242,34 +72,76 @@ export class AirportService { bbox_max_lat: number | null; bbox_max_lon: number | null; }>('SELECT * FROM airports WHERE icao = ?', [uppercaseIcao]); - const mergedAirport = { ...airport, ...reread.results[0] }; - - if (airportData.runways && airportData.runways.length > 0) { - const openRunways = airportData.runways.filter((r) => r.closed !== '1'); - const runwayStatements = openRunways.map((runway) => ({ - query: ` - INSERT INTO runways ( - airport_icao, length_ft, width_ft, - le_ident, le_latitude_deg, le_longitude_deg, - he_ident, he_latitude_deg, he_longitude_deg - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - params: [ - uppercaseIcao, - runway.length_ft, - runway.width_ft, - runway.le_ident, - runway.le_latitude_deg, - runway.le_longitude_deg, - runway.he_ident, - runway.he_latitude_deg, - runway.he_longitude_deg, - ], - })); + const airportFromDb = airportResult.results[0]; + + if (airportFromDb) { + const fetchPromises: Promise[] = []; + let needsReread = false; + + if (airportFromDb.elevation_ft == null) { + needsReread = true; + fetchPromises.push( + this.fetchAndStoreElevation(uppercaseIcao, dbSession) + .then(() => {}) + .catch(() => {}), + ); + } + if (airportFromDb.country_code == null || airportFromDb.country_name == null || airportFromDb.region_name == null) { + needsReread = true; + fetchPromises.push( + this.fetchAndStoreLocationMeta(uppercaseIcao, dbSession) + .then(() => {}) + .catch(() => {}), + ); + } + if ( + airportFromDb.bbox_min_lat == null || + airportFromDb.bbox_min_lon == null || + airportFromDb.bbox_max_lat == null || + airportFromDb.bbox_max_lon == null + ) { + needsReread = true; + fetchPromises.push( + this.fetchAndStoreBoundingBox(uppercaseIcao, dbSession) + .then(() => {}) + .catch((err) => { + try { + this.posthog?.track('Airport Bounding Box Unavailable', { + source: 'db-cache-miss', + icao: uppercaseIcao, + error: err instanceof Error ? err.message : String(err), + }); + } catch { + /* ignore analytics errors */ + } + }), + ); + } - await this.dbSession.executeBatch(runwayStatements); + if (fetchPromises.length > 0) { + await Promise.all(fetchPromises); + } - const runwaysResult = await this.dbSession.executeRead<{ + if (needsReread) { + const reread = await dbSession.executeRead<{ + icao: string; + latitude: number | null; + longitude: number | null; + name: string; + continent: string; + country_code: string | null; + country_name: string | null; + region_name: string | null; + elevation_ft: number | null; + elevation_m: number | null; + bbox_min_lat: number | null; + bbox_min_lon: number | null; + bbox_max_lat: number | null; + bbox_max_lon: number | null; + }>('SELECT * FROM airports WHERE icao = ?', [uppercaseIcao]); + if (reread.results[0]) Object.assign(airportFromDb, reread.results[0]); + } + const runwaysResult = await dbSession.executeRead<{ length_ft: string; width_ft: string; le_ident: string; @@ -291,37 +163,176 @@ export class AirportService { FROM runways WHERE airport_icao = ?`, [uppercaseIcao], ); + return { ...airportFromDb, runways: runwaysResult.results }; + } + + try { + const response = await fetch(`https://airportdb.io/api/v1/airport/${uppercaseIcao}?apiToken=${this.apiToken}`, { + method: 'GET', + }); + if (!response.ok) { + if (response.status === 404) return null; + throw new HttpError(503, 'Bounding box unavailable'); + } + const airportData = (await response.json()) as AirportData; + + const hasCoords = Number.isFinite(airportData.latitude_deg) && Number.isFinite(airportData.longitude_deg); + if (!hasCoords) { + try { + this.posthog?.track('Airport External Fetch MissingCoords', { icao: uppercaseIcao }); + } catch { + /* ignore */ + } + return null; + } + + const elevation_ft = airportData.elevation_ft ? parseInt(airportData.elevation_ft, 10) : null; + const elevation_m = + elevation_ft != null && !Number.isNaN(elevation_ft) ? Math.round(elevation_ft * 0.3048 * 100) / 100 : null; + const country_code = airportData.iso_country?.trim().toUpperCase() || airportData.country?.code?.trim().toUpperCase() || null; + const country_name = airportData.country?.name?.trim() || null; + const region_name = airportData.region?.name?.trim() || null; + + const airport = { + icao: uppercaseIcao, + latitude: airportData.latitude_deg!, + longitude: airportData.longitude_deg!, + name: airportData.name || '', + continent: airportData.continent || 'UNKNOWN', + country_code, + country_name, + region_name, + elevation_ft: !Number.isNaN(elevation_ft) ? elevation_ft : null, + elevation_m, + }; + + await dbSession.executeWrite( + 'INSERT INTO airports (icao, latitude, longitude, name, continent, country_code, country_name, region_name, elevation_ft, elevation_m) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + airport.icao, + airport.latitude, + airport.longitude, + airport.name, + airport.continent, + airport.country_code, + airport.country_name, + airport.region_name, + airport.elevation_ft, + airport.elevation_m, + ], + ); + + try { + await this.fetchAndStoreBoundingBox(uppercaseIcao, dbSession); + } catch (err) { + try { + this.posthog?.track('Airport Bounding Box Unavailable', { + source: 'external-api', + icao: uppercaseIcao, + error: err instanceof Error ? err.message : String(err), + }); + } catch { + /* ignore analytics errors */ + } + } + + const reread = await dbSession.executeRead<{ + icao: string; + latitude: number | null; + longitude: number | null; + name: string; + continent: string; + country_code: string | null; + country_name: string | null; + region_name: string | null; + elevation_ft: number | null; + elevation_m: number | null; + bbox_min_lat: number | null; + bbox_min_lon: number | null; + bbox_max_lat: number | null; + bbox_max_lon: number | null; + }>('SELECT * FROM airports WHERE icao = ?', [uppercaseIcao]); + const mergedAirport = { ...airport, ...reread.results[0] }; + + if (airportData.runways && airportData.runways.length > 0) { + const openRunways = airportData.runways.filter((r) => r.closed !== '1'); + const runwayStatements = openRunways.map((runway) => ({ + query: ` + INSERT INTO runways ( + airport_icao, length_ft, width_ft, + le_ident, le_latitude_deg, le_longitude_deg, + he_ident, he_latitude_deg, he_longitude_deg + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + params: [ + uppercaseIcao, + runway.length_ft, + runway.width_ft, + runway.le_ident, + runway.le_latitude_deg, + runway.le_longitude_deg, + runway.he_ident, + runway.he_latitude_deg, + runway.he_longitude_deg, + ], + })); + + await dbSession.executeBatch(runwayStatements); + + const runwaysResult = await dbSession.executeRead<{ + length_ft: string; + width_ft: string; + le_ident: string; + le_latitude_deg: string; + le_longitude_deg: string; + he_ident: string; + he_latitude_deg: string; + he_longitude_deg: string; + }>( + `SELECT + length_ft, + width_ft, + le_ident, + le_latitude_deg, + le_longitude_deg, + he_ident, + he_latitude_deg, + he_longitude_deg + FROM runways WHERE airport_icao = ?`, + [uppercaseIcao], + ); + + try { + this.posthog?.track('Airport Fetched From External API', { + icao: uppercaseIcao, + hasRunways: !!openRunways.length, + runwaysFilteredClosed: airportData.runways.length - openRunways.length || 0, + }); + } catch (e) { + console.warn('Posthog track failed (Airport Fetched From External API)', e); + } + return { ...mergedAirport, runways: runwaysResult.results }; + } try { this.posthog?.track('Airport Fetched From External API', { icao: uppercaseIcao, - hasRunways: !!openRunways.length, - runwaysFilteredClosed: airportData.runways.length - openRunways.length || 0, + hasRunways: !!airportData.runways?.length, }); } catch (e) { console.warn('Posthog track failed (Airport Fetched From External API)', e); } - return { ...mergedAirport, runways: runwaysResult.results }; - } - - try { - this.posthog?.track('Airport Fetched From External API', { - icao: uppercaseIcao, - hasRunways: !!airportData.runways?.length, - }); + return mergedAirport; } catch (e) { - console.warn('Posthog track failed (Airport Fetched From External API)', e); - } - return mergedAirport; - } catch (e) { - try { - this.posthog?.track('Airport External Fetch Failed', { icao: uppercaseIcao, error: (e as Error).message }); - } catch { - /* ignore */ + try { + this.posthog?.track('Airport External Fetch Failed', { icao: uppercaseIcao, error: (e as Error).message }); + } catch { + /* ignore */ + } + if (e instanceof HttpError) throw e; + throw new HttpError(503, 'Bounding box unavailable'); } - if (e instanceof HttpError) throw e; - throw new HttpError(503, 'Bounding box unavailable'); - } + }); } async getAirports(icaos: string[]) { @@ -344,19 +355,21 @@ export class AirportService { } async getAirportsByContinent(continent: string) { - const result = await this.dbSession.executeRead<{ - icao: string; - latitude: number | null; - longitude: number | null; - name: string; - continent: string; - country_code: string | null; - country_name: string | null; - region_name: string | null; - elevation_ft: number | null; - elevation_m: number | null; - }>('SELECT * FROM airports WHERE continent = ? ORDER BY icao', [continent.toUpperCase()]); - return { results: result.results }; + return this.withDbSession(async (dbSession) => { + const result = await dbSession.executeRead<{ + icao: string; + latitude: number | null; + longitude: number | null; + name: string; + continent: string; + country_code: string | null; + country_name: string | null; + region_name: string | null; + elevation_ft: number | null; + elevation_m: number | null; + }>('SELECT * FROM airports WHERE continent = ? ORDER BY icao', [continent.toUpperCase()]); + return { results: result.results }; + }); } /** @@ -375,56 +388,58 @@ export class AirportService { const minLon = lon - LON_BOX; const maxLon = lon + LON_BOX; - const cosLat = Math.cos((lat * Math.PI) / 180); - const cosLatSq = cosLat * cosLat; - const approx = await this.dbSession.executeRead<{ - icao: string; - latitude: number; - longitude: number; - name: string; - continent: string; - country_code: string | null; - country_name: string | null; - region_name: string | null; - elevation_ft: number | null; - elevation_m: number | null; - distance_score: number; - }>( - `SELECT icao, latitude, longitude, name, continent, country_code, country_name, region_name, elevation_ft, elevation_m, - ((latitude - ?) * (latitude - ?) + ((longitude - ?) * (longitude - ?) * ?)) AS distance_score - FROM airports - WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ? - ORDER BY distance_score - LIMIT 1`, - [lat, lat, lon, lon, cosLatSq, minLat, maxLat, minLon, maxLon], - ); - - const row = approx.results?.[0]; - if (!row) return null; - - const distance_m = calculateDistance({ lat, lon }, { lat: row.latitude, lon: row.longitude }); - const distance_nm = distance_m / 1852; + return this.withDbSession(async (dbSession) => { + const cosLat = Math.cos((lat * Math.PI) / 180); + const cosLatSq = cosLat * cosLat; + const approx = await dbSession.executeRead<{ + icao: string; + latitude: number; + longitude: number; + name: string; + continent: string; + country_code: string | null; + country_name: string | null; + region_name: string | null; + elevation_ft: number | null; + elevation_m: number | null; + distance_score: number; + }>( + `SELECT icao, latitude, longitude, name, continent, country_code, country_name, region_name, elevation_ft, elevation_m, + ((latitude - ?) * (latitude - ?) + ((longitude - ?) * (longitude - ?) * ?)) AS distance_score + FROM airports + WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ? + ORDER BY distance_score + LIMIT 1`, + [lat, lat, lon, lon, cosLatSq, minLat, maxLat, minLon, maxLon], + ); - try { - this.posthog?.track('Nearest Airport Lookup', { icao: row.icao }); - } catch (e) { - console.warn('Posthog track failed (Nearest Airport Lookup)', e); - } + const row = approx.results?.[0]; + if (!row) return null; + + const distance_m = calculateDistance({ lat, lon }, { lat: row.latitude, lon: row.longitude }); + const distance_nm = distance_m / 1852; - return { - icao: row.icao, - latitude: row.latitude, - longitude: row.longitude, - name: row.name, - continent: row.continent, - country_code: row.country_code, - country_name: row.country_name, - region_name: row.region_name, - elevation_ft: row.elevation_ft, - elevation_m: row.elevation_m, - distance_m: Math.round(distance_m), - distance_nm: Number(distance_nm.toFixed(2)), - }; + try { + this.posthog?.track('Nearest Airport Lookup', { icao: row.icao }); + } catch (e) { + console.warn('Posthog track failed (Nearest Airport Lookup)', e); + } + + return { + icao: row.icao, + latitude: row.latitude, + longitude: row.longitude, + name: row.name, + continent: row.continent, + country_code: row.country_code, + country_name: row.country_name, + region_name: row.region_name, + elevation_ft: row.elevation_ft, + elevation_m: row.elevation_m, + distance_m: Math.round(distance_m), + distance_nm: Number(distance_nm.toFixed(2)), + }; + }); } /** @@ -433,6 +448,7 @@ export class AirportService { */ private async fetchAndStoreBoundingBox( icao: string, + dbSession: DatabaseSessionService, ): Promise<{ bbox_min_lat: number; bbox_min_lon: number; bbox_max_lat: number; bbox_max_lon: number }> { const escaped = icao.replace(/"/g, ''); const overpassQuery = `data=[out:json][timeout:25];(\n nwr["aeroway"="aerodrome"]["icao"="${escaped}"];\n nwr["aeroway"="aerodrome"]["ref"="${escaped}"];\n nwr["aeroway"="aerodrome"]["ref:icao"="${escaped}"];\n );out body geom;`; @@ -508,7 +524,7 @@ export class AirportService { bbox_max_lat: bounds.maxlat, bbox_max_lon: bounds.maxlon, }; - await this.dbSession.executeWrite( + await dbSession.executeWrite( 'UPDATE airports SET bbox_min_lat = ?, bbox_min_lon = ?, bbox_max_lat = ?, bbox_max_lon = ? WHERE icao = ?', [bbox.bbox_min_lat, bbox.bbox_min_lon, bbox.bbox_max_lat, bbox.bbox_max_lon, icao], ); @@ -544,6 +560,7 @@ export class AirportService { */ private async fetchAndStoreLocationMeta( icao: string, + dbSession: DatabaseSessionService, ): Promise<{ country_code: string | null; country_name: string | null; region_name: string | null } | null> { try { const response = await fetch(`https://airportdb.io/api/v1/airport/${icao}?apiToken=${this.apiToken}`, { method: 'GET' }); @@ -556,7 +573,7 @@ export class AirportService { if (!country_code && !country_name && !region_name) return null; - await this.dbSession.executeWrite('UPDATE airports SET country_code = ?, country_name = ?, region_name = ? WHERE icao = ?', [ + await dbSession.executeWrite('UPDATE airports SET country_code = ?, country_name = ?, region_name = ? WHERE icao = ?', [ country_code, country_name, region_name, @@ -573,7 +590,10 @@ export class AirportService { * Fetch elevation from AirportDB and store in database. * Returns the elevation data if successful, null otherwise. */ - private async fetchAndStoreElevation(icao: string): Promise<{ elevation_ft: number; elevation_m: number } | null> { + private async fetchAndStoreElevation( + icao: string, + dbSession: DatabaseSessionService, + ): Promise<{ elevation_ft: number; elevation_m: number } | null> { try { const response = await fetch(`https://airportdb.io/api/v1/airport/${icao}?apiToken=${this.apiToken}`, { method: 'GET' }); if (!response.ok) return null; @@ -585,7 +605,7 @@ export class AirportService { const elevation_m = Math.round(elevation_ft * 0.3048 * 100) / 100; - await this.dbSession.executeWrite('UPDATE airports SET elevation_ft = ?, elevation_m = ? WHERE icao = ?', [ + await dbSession.executeWrite('UPDATE airports SET elevation_ft = ?, elevation_m = ? WHERE icao = ?', [ elevation_ft, elevation_m, icao, diff --git a/src/services/auth.ts b/src/services/auth.ts index bfd175e..b647560 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,6 +1,6 @@ import { VatsimUser, UserRecord } from '../types'; import { VatsimService } from './vatsim'; -import { DatabaseSessionService } from './database-session'; +import { DatabaseSessionService, type SessionOptions } from './database-session'; import { PostHogService } from './posthog'; type DisplayModeUser = Pick; @@ -11,14 +11,25 @@ interface ExistingUserLookup { } export class AuthService { - private dbSession: DatabaseSessionService; - constructor( private db: D1Database, private vatsim: VatsimService, private posthog?: PostHogService, - ) { - this.dbSession = new DatabaseSessionService(db); + ) {} + + private async withDbSession( + operation: (dbSession: DatabaseSessionService) => Promise, + options?: SessionOptions, + ): Promise { + const dbSession = new DatabaseSessionService(this.db); + if (options) { + dbSession.startSession(options); + } + try { + return await operation(dbSession); + } finally { + dbSession.closeSession(); + } } async handleCallback(code: string, executionCtx?: ExecutionContext): Promise<{ vatsimToken: string }> { @@ -87,13 +98,10 @@ export class AuthService { return { user: lookup.user, created: false }; } - if (lookup?.bookmark) { - this.dbSession.startSession({ bookmark: lookup.bookmark }); - } else { - this.dbSession.startSession({ mode: 'first-primary' }); - } - - const newUser = await this.createNewUser(vatsimUser); + const newUser = await this.withDbSession( + (dbSession) => this.createNewUser(vatsimUser, dbSession), + lookup?.bookmark ? { bookmark: lookup.bookmark } : { mode: 'first-primary' }, + ); return { user: newUser, created: true }; } @@ -106,9 +114,9 @@ export class AuthService { return `BARS_${key}`; } - private async createNewUser(vatsimUser: VatsimUser) { + private async createNewUser(vatsimUser: VatsimUser, dbSession: DatabaseSessionService) { // Check for existing VATSIM user using session - const existingVatsimUserResult = await this.dbSession.executeRead('SELECT id FROM users WHERE vatsim_id = ?', [ + const existingVatsimUserResult = await dbSession.executeRead('SELECT id FROM users WHERE vatsim_id = ?', [ vatsimUser.id, ]); @@ -119,7 +127,7 @@ export class AuthService { let apiKey = this.generateApiKey(); while (true) { - const existingKeyResult = await this.dbSession.executeRead('SELECT id FROM users WHERE api_key = ?', [apiKey]); + const existingKeyResult = await dbSession.executeRead('SELECT id FROM users WHERE api_key = ?', [apiKey]); if (!existingKeyResult.results[0]) break; apiKey = this.generateApiKey(); @@ -142,7 +150,7 @@ export class AuthService { vatsimUser, ); const { regionId, regionName, divisionId, divisionName, subdivisionId, subdivisionName } = this.normalizeLocationFields(vatsimUser); - const result = await this.dbSession.executeWrite( + const result = await dbSession.executeWrite( 'INSERT INTO users (vatsim_id, api_key, email, full_name, display_mode, display_name, region_id, region_name, division_id, division_name, subdivision_id, subdivision_name, created_at, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *', [ vatsimUser.id, @@ -176,32 +184,34 @@ export class AuthService { } // Only update when new values are present; do not overwrite with nulls - await this.dbSession.executeWrite( - `UPDATE users - SET - region_id = COALESCE(?, region_id), - region_name = COALESCE(?, region_name), - division_id = COALESCE(?, division_id), - division_name = COALESCE(?, division_name), - subdivision_id = COALESCE(?, subdivision_id), - subdivision_name = COALESCE(?, subdivision_name) - WHERE id = ?`, - [regionId, regionName, divisionId, divisionName, subdivisionId, subdivisionName, userId], - ); + await this.withDbSession(async (dbSession) => { + await dbSession.executeWrite( + `UPDATE users + SET + region_id = COALESCE(?, region_id), + region_name = COALESCE(?, region_name), + division_id = COALESCE(?, division_id), + division_name = COALESCE(?, division_name), + subdivision_id = COALESCE(?, subdivision_id), + subdivision_name = COALESCE(?, subdivision_name) + WHERE id = ?`, + [regionId, regionName, divisionId, divisionName, subdivisionId, subdivisionName, userId], + ); + }); } async deleteUserAccount(vatsimId: string): Promise { - // Use primary mode for write operations - this.dbSession.startSession({ mode: 'first-primary' }); + const deleted = await this.withDbSession(async (dbSession) => { + await dbSession.executeBatch([ + { query: 'DELETE FROM division_members WHERE vatsim_id = ?', params: [vatsimId] }, + { query: 'DELETE FROM staff WHERE user_id IN (SELECT id FROM users WHERE vatsim_id = ?)', params: [vatsimId] }, + { query: 'DELETE FROM users WHERE vatsim_id = ?', params: [vatsimId] }, + ]); - await this.dbSession.executeBatch([ - { query: 'DELETE FROM division_members WHERE vatsim_id = ?', params: [vatsimId] }, - { query: 'DELETE FROM staff WHERE user_id IN (SELECT id FROM users WHERE vatsim_id = ?)', params: [vatsimId] }, - { query: 'DELETE FROM users WHERE vatsim_id = ?', params: [vatsimId] }, - ]); + const userResult = await dbSession.executeRead<{ id: number }>('SELECT id FROM users WHERE vatsim_id = ? LIMIT 1', [vatsimId]); + return !userResult.results[0]; + }, { mode: 'first-primary' }); - const userExists = await this.getUserByVatsimId(vatsimId); - const deleted = !userExists; if (deleted) { try { this.posthog?.track('User Deleted', { vatsimId }); @@ -213,39 +223,45 @@ export class AuthService { } async getUserByApiKey(apiKey: string): Promise { - const result = await this.dbSession.executeRead( - `SELECT u.* - FROM users u - LEFT JOIN bans b ON b.vatsim_id = u.vatsim_id - WHERE u.api_key = ? - AND ( - b.vatsim_id IS NULL - OR (b.expires_at IS NOT NULL AND datetime(b.expires_at) < datetime('now')) - )`, - [apiKey], - ); - return result.results[0] || null; + return this.withDbSession(async (dbSession) => { + const result = await dbSession.executeRead( + `SELECT u.* + FROM users u + LEFT JOIN bans b ON b.vatsim_id = u.vatsim_id + WHERE u.api_key = ? + AND ( + b.vatsim_id IS NULL + OR (b.expires_at IS NOT NULL AND datetime(b.expires_at) < datetime('now')) + )`, + [apiKey], + ); + return result.results[0] || null; + }); } async getUserByVatsimId(vatsimId: string): Promise { - const result = await this.dbSession.executeRead( - `SELECT u.* - FROM users u - LEFT JOIN bans b ON b.vatsim_id = u.vatsim_id - WHERE u.vatsim_id = ? - AND ( - b.vatsim_id IS NULL - OR (b.expires_at IS NOT NULL AND datetime(b.expires_at) < datetime('now')) - )`, - [vatsimId], - ); - return result.results[0] || null; + return this.withDbSession(async (dbSession) => { + const result = await dbSession.executeRead( + `SELECT u.* + FROM users u + LEFT JOIN bans b ON b.vatsim_id = u.vatsim_id + WHERE u.vatsim_id = ? + AND ( + b.vatsim_id IS NULL + OR (b.expires_at IS NOT NULL AND datetime(b.expires_at) < datetime('now')) + )`, + [vatsimId], + ); + return result.results[0] || null; + }); } // Explicitly fetch user without applying ban filter (for account page visibility) async getUserByVatsimIdEvenIfBanned(vatsimId: string): Promise { - const result = await this.dbSession.executeRead('SELECT * FROM users WHERE vatsim_id = ?', [vatsimId]); - return result.results[0] || null; + return this.withDbSession(async (dbSession) => { + const result = await dbSession.executeRead('SELECT * FROM users WHERE vatsim_id = ?', [vatsimId]); + return result.results[0] || null; + }); } computeDisplayName(user: UserRecord, vatsimUser?: VatsimUser): string { @@ -266,43 +282,44 @@ export class AuthService { async updateDisplayMode(userId: number, mode: number, existing?: DisplayModeUser) { if (![0, 1, 2].includes(mode)) throw new Error('Invalid display mode'); - // Use primary for consistency on write - this.dbSession.startSession({ mode: 'first-primary' }); - - let user: DisplayModeUser | null = null; - if (existing && existing.id === userId) { - user = existing; - } else { - const current = await this.dbSession.executeRead( - 'SELECT id, vatsim_id, full_name, display_mode, display_name FROM users WHERE id = ?', - [userId], - ); - user = current.results[0] ?? null; - } - if (!user) return; + await this.withDbSession(async (dbSession) => { + let user: DisplayModeUser | null = null; + if (existing && existing.id === userId) { + user = existing; + } else { + const current = await dbSession.executeRead( + 'SELECT id, vatsim_id, full_name, display_mode, display_name FROM users WHERE id = ?', + [userId], + ); + user = current.results[0] ?? null; + } + if (!user) return; - if (user.display_mode === mode) return; // nothing to do + if (user.display_mode === mode) return; // nothing to do - const displayName = this.computeDisplayName({ ...user, display_mode: mode } as UserRecord); + const displayName = this.computeDisplayName({ ...user, display_mode: mode } as UserRecord); - await this.dbSession.executeWrite('UPDATE users SET display_mode = ?, display_name = ? WHERE id = ?', [mode, displayName, userId]); + await dbSession.executeWrite('UPDATE users SET display_mode = ?, display_name = ? WHERE id = ?', [mode, displayName, userId]); + }, { mode: 'first-primary' }); } async updateFullName(userId: number, fullName: string) { - await this.dbSession.executeWrite('UPDATE users SET full_name = ? WHERE id = ?', [fullName, userId]); - // Recompute display_name after updating full_name using existing display_mode - const current = await this.dbSession.executeRead('SELECT * FROM users WHERE id = ?', [userId]); - const user = current.results[0]; - if (user) { - const vatsimUser: VatsimUser = { - id: user.vatsim_id, - email: user.email, - first_name: fullName.split(' ')[0], - last_name: fullName.split(' ').slice(1).join(' '), - }; - const displayName = this.computeDisplayName(user, vatsimUser); - await this.dbSession.executeWrite('UPDATE users SET display_name = ? WHERE id = ?', [displayName, userId]); - } + await this.withDbSession(async (dbSession) => { + await dbSession.executeWrite('UPDATE users SET full_name = ? WHERE id = ?', [fullName, userId]); + // Recompute display_name after updating full_name using existing display_mode + const current = await dbSession.executeRead('SELECT * FROM users WHERE id = ?', [userId]); + const user = current.results[0]; + if (user) { + const vatsimUser: VatsimUser = { + id: user.vatsim_id, + email: user.email, + first_name: fullName.split(' ')[0], + last_name: fullName.split(' ').slice(1).join(' '), + }; + const displayName = this.computeDisplayName(user, vatsimUser); + await dbSession.executeWrite('UPDATE users SET display_name = ? WHERE id = ?', [displayName, userId]); + } + }, { mode: 'first-primary' }); } private async refreshLoginMetadata(userId: number, vatsimUser: VatsimUser): Promise { @@ -351,31 +368,31 @@ export class AuthService { } async regenerateApiKey(userId: number): Promise { - // Use primary mode for API key regeneration - this.dbSession.startSession({ mode: 'first-primary' }); + const apiKey = await this.withDbSession(async (dbSession) => { + let newApiKey = this.generateApiKey(); - let newApiKey = this.generateApiKey(); + // Make sure the new API key is unique + while (true) { + const existingKeyResult = await dbSession.executeRead('SELECT id FROM users WHERE api_key = ?', [newApiKey]); - // Make sure the new API key is unique - while (true) { - const existingKeyResult = await this.dbSession.executeRead('SELECT id FROM users WHERE api_key = ?', [newApiKey]); + if (!existingKeyResult.results[0]) break; + newApiKey = this.generateApiKey(); + } - if (!existingKeyResult.results[0]) break; - newApiKey = this.generateApiKey(); - } + // Update the user's API key in the database + const result = await dbSession.executeWrite('UPDATE users SET api_key = ? WHERE id = ? RETURNING api_key', [ + newApiKey, + userId, + ]); - // Update the user's API key in the database - const result = await this.dbSession.executeWrite('UPDATE users SET api_key = ? WHERE id = ? RETURNING api_key', [ - newApiKey, - userId, - ]); + const rows = result.results as unknown as Array<{ api_key: string }> | null; + if (!rows || !rows[0]) { + throw new Error('Failed to update API key'); + } - const rows = result.results as unknown as Array<{ api_key: string }> | null; - if (!rows || !rows[0]) { - throw new Error('Failed to update API key'); - } + return rows[0].api_key; + }, { mode: 'first-primary' }); - const apiKey = rows[0].api_key; try { this.posthog?.track('User API Key Regenerated', { userId }); } catch (e) { @@ -390,79 +407,78 @@ export class AuthService { /** Create or update a ban for a vatsim id. Account is kept so UI can surface ban. */ async banUser(vatsimId: string, reason: string | null, issuedBy: string, expiresAt?: string | null): Promise { - this.dbSession.startSession({ mode: 'first-primary' }); - const nowIso = new Date().toISOString(); - const cid = String(vatsimId).trim(); - if (!/^\d{3,10}$/.test(cid)) throw new Error('Invalid VATSIM ID format'); - await this.dbSession.executeWrite( - `INSERT INTO bans (vatsim_id, reason, issued_by, created_at, expires_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(vatsim_id) DO UPDATE SET reason=excluded.reason, issued_by=excluded.issued_by, created_at=?, expires_at=excluded.expires_at`, - [cid, reason ?? null, issuedBy, nowIso, expiresAt ?? null, nowIso], - ); + await this.withDbSession(async (dbSession) => { + const nowIso = new Date().toISOString(); + const cid = String(vatsimId).trim(); + if (!/^\d{3,10}$/.test(cid)) throw new Error('Invalid VATSIM ID format'); + await dbSession.executeWrite( + `INSERT INTO bans (vatsim_id, reason, issued_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(vatsim_id) DO UPDATE SET reason=excluded.reason, issued_by=excluded.issued_by, created_at=?, expires_at=excluded.expires_at`, + [cid, reason ?? null, issuedBy, nowIso, expiresAt ?? null, nowIso], + ); + }, { mode: 'first-primary' }); } /** Remove a ban for a vatsim id */ async unbanUser(vatsimId: string): Promise { - this.dbSession.startSession({ mode: 'first-primary' }); - await this.dbSession.executeWrite('DELETE FROM bans WHERE vatsim_id = ?', [vatsimId]); + await this.withDbSession(async (dbSession) => { + await dbSession.executeWrite('DELETE FROM bans WHERE vatsim_id = ?', [vatsimId]); + }, { mode: 'first-primary' }); } /** List bans */ async listBans(): Promise< Array<{ vatsim_id: string; reason: string | null; issued_by: string; created_at: string; expires_at: string | null }> > { - const res = await this.dbSession.executeRead<{ - vatsim_id: string; - reason: string | null; - issued_by: string; - created_at: string; - expires_at: string | null; - }>('SELECT vatsim_id, reason, issued_by, created_at, expires_at FROM bans ORDER BY created_at DESC'); - return res.results; + return this.withDbSession(async (dbSession) => { + const res = await dbSession.executeRead<{ + vatsim_id: string; + reason: string | null; + issued_by: string; + created_at: string; + expires_at: string | null; + }>('SELECT vatsim_id, reason, issued_by, created_at, expires_at FROM bans ORDER BY created_at DESC'); + return res.results; + }); } /** Return ban details if banned, otherwise null */ async getBanInfo( vatsimId: string, ): Promise<{ vatsim_id: string; reason: string | null; created_at: string; expires_at: string | null } | null> { - const res = await this.dbSession.executeRead<{ - vatsim_id: string; - reason: string | null; - created_at: string; - expires_at: string | null; - }>('SELECT vatsim_id, reason, created_at, expires_at FROM bans WHERE vatsim_id = ?', [vatsimId]); - const row = res.results[0]; - if (!row) return null; - if (!row.expires_at) return row; // permanent - const now = Date.now(); - const exp = new Date(row.expires_at).getTime(); - return now <= exp ? row : null; + return this.withDbSession(async (dbSession) => { + const res = await dbSession.executeRead<{ + vatsim_id: string; + reason: string | null; + created_at: string; + expires_at: string | null; + }>('SELECT vatsim_id, reason, created_at, expires_at FROM bans WHERE vatsim_id = ?', [vatsimId]); + const row = res.results[0]; + if (!row) return null; + if (!row.expires_at) return row; // permanent + const now = Date.now(); + const exp = new Date(row.expires_at).getTime(); + return now <= exp ? row : null; + }); } private async fetchExistingUser(vatsimId: string): Promise { - const session = new DatabaseSessionService(this.db); - try { - session.startSession({ mode: 'first-primary' }); - const result = await session.executeRead('SELECT * FROM users WHERE vatsim_id = ?', [vatsimId]); - const bookmark = session.getSessionInfo().bookmark; + return this.withDbSession(async (dbSession) => { + const result = await dbSession.executeRead('SELECT * FROM users WHERE vatsim_id = ?', [vatsimId]); + const bookmark = dbSession.getSessionInfo().bookmark; return { user: result.results[0] ?? null, bookmark }; - } finally { - session.closeSession(); - } + }, { mode: 'first-primary' }); } private async fetchBanRecord(vatsimId: string): Promise<{ vatsim_id: string; expires_at: string | null } | null> { - const session = new DatabaseSessionService(this.db); - try { - const res = await session.executeRead<{ vatsim_id: string; expires_at: string | null }>( + return this.withDbSession(async (dbSession) => { + const res = await dbSession.executeRead<{ vatsim_id: string; expires_at: string | null }>( 'SELECT vatsim_id, expires_at FROM bans WHERE vatsim_id = ?', [vatsimId], ); return res.results[0] ?? null; - } finally { - session.closeSession(); - } + }); } private isBanRecordActive(record: { expires_at: string | null } | null): boolean { diff --git a/src/services/service-pool.ts b/src/services/service-pool.ts index 0476ced..c2623ae 100644 --- a/src/services/service-pool.ts +++ b/src/services/service-pool.ts @@ -22,24 +22,11 @@ import { VatSysProfilesService } from './vatsys-profiles'; export const ServicePool = (() => { let vatsim: VatsimService; - let auth: AuthService; - let roles: RoleService; let cache: CacheService; - let airport: AirportService; - let divisions: DivisionService; let id: IDService; - let points: PointsService; - let polygons: PolygonService; - let support: SupportService; - let notam: NotamService; - let contributions: ContributionService; let storage: StorageService; let github: GitHubService; let posthog: PostHogService; - let faqs: FAQService; - let releases: ReleaseService; - let contact: ContactService; - let downloads: DownloadsService; let vatsysProfiles: VatSysProfilesService; return { @@ -49,17 +36,12 @@ export const ServicePool = (() => { } return vatsim; }, + // DB-backed services stay request-scoped so D1 session state never leaks across requests. getAuth(env: Env) { - if (!auth) { - auth = new AuthService(env.DB, this.getVatsim(env), this.getPostHog(env)); - } - return auth; + return new AuthService(env.DB, this.getVatsim(env), this.getPostHog(env)); }, getRoles(env: Env) { - if (!roles) { - roles = new RoleService(env.DB); - } - return roles; + return new RoleService(env.DB); }, getCache(env: Env) { if (!cache) { @@ -68,16 +50,10 @@ export const ServicePool = (() => { return cache; }, getAirport(env: Env) { - if (!airport) { - airport = new AirportService(env.DB, env.AIRPORTDB_API_KEY, this.getPostHog(env)); - } - return airport; + return new AirportService(env.DB, env.AIRPORTDB_API_KEY, this.getPostHog(env)); }, getDivisions(env: Env) { - if (!divisions) { - divisions = new DivisionService(env.DB, this.getPostHog(env)); - } - return divisions; + return new DivisionService(env.DB, this.getPostHog(env)); }, getID() { if (!id) { @@ -86,40 +62,25 @@ export const ServicePool = (() => { return id; }, getPoints(env: Env) { - if (!points) { - points = new PointsService(env.DB, this.getID(), this.getDivisions(env), this.getPostHog(env)); - } - return points; + return new PointsService(env.DB, this.getID(), this.getDivisions(env), this.getPostHog(env)); }, getPolygons(env: Env) { - if (!polygons) { - polygons = new PolygonService(env.DB, undefined, this.getPostHog(env)); - } - return polygons; + return new PolygonService(env.DB, undefined, this.getPostHog(env)); }, getSupport(env: Env) { - if (!support) { - support = new SupportService(env.DB); - } - return support; + return new SupportService(env.DB); }, getNotam(env: Env) { - if (!notam) { - notam = new NotamService(env.DB); - } - return notam; + return new NotamService(env.DB); }, getContributions(env: Env) { - if (!contributions) { - contributions = new ContributionService( - env.DB, - this.getRoles(env), - env.AIRPORTDB_API_KEY, - env.BARS_STORAGE, - this.getPostHog(env), - ); - } - return contributions; + return new ContributionService( + env.DB, + this.getRoles(env), + env.AIRPORTDB_API_KEY, + env.BARS_STORAGE, + this.getPostHog(env), + ); }, getStorage(env: Env) { if (!storage) { @@ -140,28 +101,16 @@ export const ServicePool = (() => { return posthog; }, getFAQs(env: Env) { - if (!faqs) { - faqs = new FAQService(env.DB); - } - return faqs; + return new FAQService(env.DB); }, getReleases(env: Env) { - if (!releases) { - releases = new ReleaseService(env.DB, this.getStorage(env)); - } - return releases; + return new ReleaseService(env.DB, this.getStorage(env)); }, getContact(env: Env) { - if (!contact) { - contact = new ContactService(env.DB); - } - return contact; + return new ContactService(env.DB); }, getDownloads(env: Env) { - if (!downloads) { - downloads = new DownloadsService(env.DB); - } - return downloads; + return new DownloadsService(env.DB); }, getVatSysProfiles(env: Env) { if (!vatsysProfiles) {