mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
9c46f31c57
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.
173 lines
5.6 KiB
TypeScript
173 lines
5.6 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { readEnv } from '../app-config';
|
|
import { applyDurabilityPragmas } from './durability';
|
|
import { createTables } from './schema';
|
|
import { runMigrations } from './migrations';
|
|
import { runSeeds } from './seeds';
|
|
import { Place, Tag } from '../types';
|
|
|
|
// In test mode each vitest worker gets an isolated in-memory DB so that
|
|
// parallel forks can't race on the same file or share migration state.
|
|
const isTest = readEnv().app.isTest;
|
|
|
|
let dbPath: string;
|
|
if (isTest) {
|
|
dbPath = ':memory:';
|
|
} else if (readEnv().db.trekDbFile) {
|
|
// Explicit DB file (used by the Playwright E2E harness to run against an
|
|
// isolated, throwaway database instead of the real data/travel.db). Purely
|
|
// additive — when unset the default path below is used exactly as before.
|
|
dbPath = readEnv().db.trekDbFile!;
|
|
const dir = path.dirname(dbPath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
} else {
|
|
const dataDir = path.join(__dirname, '../../data');
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
dbPath = path.join(dataDir, 'travel.db');
|
|
}
|
|
|
|
let _db: Database.Database | null = null;
|
|
|
|
function initDb(): void {
|
|
if (_db) {
|
|
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
|
try { _db.close(); } catch (e) {}
|
|
_db = null;
|
|
}
|
|
|
|
_db = new Database(dbPath);
|
|
// Ahead of the journal switch now: changing journal_mode needs an exclusive
|
|
// lock, which a sibling process (reset-admin, the rotation script) may hold.
|
|
_db.exec('PRAGMA busy_timeout = 5000');
|
|
const durability = applyDurabilityPragmas(_db);
|
|
_db.exec('PRAGMA foreign_keys = ON');
|
|
// Reported so an operator can see whether their setting took — the test DB is
|
|
// :memory: and has no journal file, so there is nothing to report there.
|
|
if (dbPath !== ':memory:') {
|
|
console.log(`[DB] journal_mode=${durability.journalMode}, synchronous=${durability.synchronous}`);
|
|
}
|
|
|
|
createTables(_db);
|
|
runMigrations(_db);
|
|
|
|
runSeeds(_db);
|
|
}
|
|
|
|
initDb();
|
|
|
|
const db = new Proxy({} as Database.Database, {
|
|
get(_, prop: string | symbol) {
|
|
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
|
|
const val = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
|
return typeof val === 'function' ? val.bind(_db) : val;
|
|
},
|
|
set(_, prop: string | symbol, val: unknown) {
|
|
(_db as unknown as Record<string | symbol, unknown>)[prop] = val;
|
|
return true;
|
|
},
|
|
});
|
|
|
|
if (readEnv().demo.enabled) {
|
|
try {
|
|
const { seedDemoData } = require('../demo/demo-seed');
|
|
seedDemoData(_db);
|
|
} catch (err: unknown) {
|
|
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
function closeDb(): void {
|
|
if (_db) {
|
|
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
|
try { _db.close(); } catch (e) {}
|
|
_db = null;
|
|
console.log('[DB] Database connection closed');
|
|
}
|
|
}
|
|
|
|
function reinitialize(): void {
|
|
console.log('[DB] Reinitializing database connection after restore...');
|
|
if (_db) closeDb();
|
|
initDb();
|
|
console.log('[DB] Database reinitialized successfully');
|
|
}
|
|
|
|
interface PlaceWithCategory extends Place {
|
|
category_name: string | null;
|
|
category_color: string | null;
|
|
category_icon: string | null;
|
|
}
|
|
|
|
interface PlaceWithTags extends Place {
|
|
category: { id: number; name: string; color: string; icon: string } | null;
|
|
tags: Tag[];
|
|
ratings: { user_id: number; username: string; avatar: string | null; rating: number }[];
|
|
rating_avg: number | null;
|
|
rating_count: number;
|
|
}
|
|
|
|
function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
|
|
const place = db.prepare(`
|
|
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
|
FROM places p
|
|
LEFT JOIN categories c ON p.category_id = c.id
|
|
WHERE p.id = ?
|
|
`).get(placeId) as PlaceWithCategory | undefined;
|
|
|
|
if (!place) return null;
|
|
|
|
const tags = db.prepare(`
|
|
SELECT t.* FROM tags t
|
|
JOIN place_tags pt ON t.id = pt.tag_id
|
|
WHERE pt.place_id = ?
|
|
`).all(placeId) as Tag[];
|
|
|
|
// Collaborative ratings (#1435): every voter with username/avatar for the
|
|
// who-voted tooltip; the displayed value is the average.
|
|
const ratings = db.prepare(`
|
|
SELECT pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr
|
|
JOIN users u ON pr.user_id = u.id
|
|
WHERE pr.place_id = ? ORDER BY pr.created_at
|
|
`).all(placeId) as { user_id: number; username: string; avatar: string | null; rating: number }[];
|
|
|
|
return {
|
|
...place,
|
|
category: place.category_id ? {
|
|
id: place.category_id,
|
|
name: place.category_name!,
|
|
color: place.category_color!,
|
|
icon: place.category_icon!,
|
|
} : null,
|
|
tags,
|
|
ratings,
|
|
rating_avg: ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : null,
|
|
rating_count: ratings.length,
|
|
};
|
|
}
|
|
|
|
interface TripAccess {
|
|
id: number;
|
|
user_id: number;
|
|
currency: string | null;
|
|
}
|
|
|
|
function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined {
|
|
return db.prepare(`
|
|
SELECT t.id, t.user_id, t.currency FROM trips t
|
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
|
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
|
`).get(userId, tripId, userId) as TripAccess | undefined;
|
|
}
|
|
|
|
function isOwner(tripId: number | string, userId: number): boolean {
|
|
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
|
}
|
|
|
|
|
|
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
|
|
export type { TripAccess, PlaceWithTags };
|