#!/usr/bin/env node /** * Database chores for TravelHub, driven through psql. * * Credentials come from backend/.env (or the real environment, which wins), so * this works the same on a developer machine and in CI. * * node scripts/db.mjs create create the application database * node scripts/db.mjs migrate apply every migration in order * node scripts/db.mjs reset drop, recreate and migrate (destructive) * node scripts/db.mjs test-db create the empty database the Go tests use */ import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const MIGRATIONS = join(ROOT, 'backend', 'migrations'); // --- configuration --------------------------------------------------------- /** Reads backend/.env without overriding anything already in the environment. */ function loadDotEnv() { const path = join(ROOT, 'backend', '.env'); if (!existsSync(path)) { return; } for (const line of readFileSync(path, 'utf8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; } const eq = trimmed.indexOf('='); if (eq === -1) { continue; } const key = trimmed.slice(0, eq).trim(); let value = trimmed.slice(eq + 1).trim(); if (value.length >= 2 && /^["'].*["']$/.test(value)) { value = value.slice(1, -1); } if (process.env[key] === undefined) { process.env[key] = value; } } } loadDotEnv(); const CONFIG = { host: process.env.DB_HOST ?? 'localhost', port: process.env.DB_PORT ?? '5432', user: process.env.DB_USER ?? 'postgres', password: process.env.DB_PASSWORD ?? '', database: process.env.DB_NAME ?? 'travelhub', testDatabase: process.env.TEST_DB_NAME ?? 'travelhub_test', }; /** * Finds psql. It is routinely absent from PATH on Windows, so fall back to the * standard install location before giving up. */ function findPsql() { if (process.env.PSQL) { return process.env.PSQL; } try { execFileSync('psql', ['--version'], { stdio: 'ignore' }); return 'psql'; } catch { // Not on PATH; try the usual Windows install root, newest version first. const base = 'C:\\Program Files\\PostgreSQL'; if (existsSync(base)) { const versions = readdirSync(base).sort((a, b) => Number(b) - Number(a)); for (const version of versions) { const candidate = join(base, version, 'bin', 'psql.exe'); if (existsSync(candidate)) { return candidate; } } } } throw new Error( 'psql was not found. Add it to PATH, or set PSQL to its full path.\n' + 'On Windows it usually lives in C:\\Program Files\\PostgreSQL\\17\\bin', ); } const PSQL = findPsql(); // --- psql helpers ---------------------------------------------------------- function run(args, { database = 'postgres', capture = false } = {}) { return execFileSync( PSQL, ['-U', CONFIG.user, '-h', CONFIG.host, '-p', CONFIG.port, '-d', database, ...args], { env: { ...process.env, PGPASSWORD: CONFIG.password }, encoding: 'utf8', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', }, ); } function databaseExists(name) { const out = run(['-tAc', `SELECT 1 FROM pg_database WHERE datname='${name}'`], { capture: true }); return out.trim() === '1'; } function createDatabase(name) { if (databaseExists(name)) { console.log(`· ${name} already exists`); return false; } run(['-c', `CREATE DATABASE "${name}"`], { capture: true }); console.log(`✓ created ${name}`); return true; } function dropDatabase(name) { if (!databaseExists(name)) { return; } // Existing sessions would block the drop, so close them first. run( [ '-c', `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${name}' AND pid <> pg_backend_pid()`, ], { capture: true }, ); run(['-c', `DROP DATABASE "${name}"`], { capture: true }); console.log(`✓ dropped ${name}`); } function migrate(name) { const files = readdirSync(MIGRATIONS) .filter((f) => f.endsWith('.sql')) .sort(); for (const file of files) { process.stdout.write(` applying ${file} ... `); run(['-v', 'ON_ERROR_STOP=1', '-q', '-f', join(MIGRATIONS, file)], { database: name }); console.log('ok'); } console.log(`✓ ${name} is up to date (${files.length} migrations)`); } // --- commands -------------------------------------------------------------- const commands = { create() { createDatabase(CONFIG.database); }, migrate() { createDatabase(CONFIG.database); migrate(CONFIG.database); }, reset() { console.log(`Resetting ${CONFIG.database}. All data in it will be lost.`); dropDatabase(CONFIG.database); createDatabase(CONFIG.database); migrate(CONFIG.database); }, 'test-db'() { // The Go tests apply the schema themselves; they only need the database. createDatabase(CONFIG.testDatabase); console.log(`✓ ${CONFIG.testDatabase} ready for go test ./...`); }, // Freshness guard for the e2e suite. The seed generates flights relative to // now(), so after ~3 weeks all seeded departures are in the past and the // browser tests fail on empty search results. Exit non-zero when stale so a // runner can re-seed before running. check() { if (!databaseExists(CONFIG.database)) { console.error(`✗ ${CONFIG.database} does not exist`); process.exit(2); } const out = run( ['-tAc', 'SELECT count(*) FROM flights WHERE departure_time > now()'], { database: CONFIG.database, capture: true }, ); const future = Number((out || '').trim()); if (Number.isFinite(future) && future > 0) { console.log(`✓ inventory fresh: ${future} future flights`); process.exit(0); } console.error('✗ inventory stale: no future flights — re-seed needed (npm run db:reset)'); process.exit(1); }, }; const command = process.argv[2]; if (!command || !(command in commands)) { console.error(`Usage: node scripts/db.mjs <${Object.keys(commands).join('|')}>`); process.exit(1); } if (!CONFIG.password) { console.error('No database password. Set DB_PASSWORD, or fill in backend/.env.'); process.exit(1); } try { commands[command](); } catch (err) { console.error(`\n✗ ${command} failed: ${err.message}`); process.exit(1); }