153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
// quinn.my backend-api — PostgreSQL database layer (postgres.js)
|
|
// DB: quinn.db at localhost:25435 (port-forwarded from vps-0)
|
|
|
|
import postgres from 'postgres';
|
|
import { authMigrations } from './db/schema-auth';
|
|
import { bookingsMigrations } from './db/schema-bookings';
|
|
import { calendarMigrations } from './db/schema-calendar';
|
|
import { clientsMigrations } from './db/schema-clients';
|
|
import { credentialsMigrations } from './db/schema-credentials';
|
|
import { financialsMigrations } from './db/schema-financials';
|
|
import { hotelsMigrations } from './db/schema-hotels';
|
|
import { flightMonitorMigrations } from './db/schema-flight-monitor';
|
|
import { journalMigrations } from './db/schema-journal';
|
|
import { photosMigrations } from './db/schema-photos';
|
|
import { platformsMigrations } from './db/schema-platforms';
|
|
import { projectsMigrations, projectClientsMigrations } from './db/schema-projects';
|
|
import { remindersMigrations } from './db/schema-reminders';
|
|
import { rosterMigrations } from './db/schema-roster';
|
|
import { taskmasterMigrations } from './db/schema-taskmaster';
|
|
import { tasksMigrations } from './db/schema-tasks';
|
|
import { vigilMigrations } from './db/schema-vigil';
|
|
import { travelMigrations } from './db/schema-travel';
|
|
import { extraMigrations } from './db/migrations';
|
|
import { logger } from './logger';
|
|
|
|
export type Sql = postgres.Sql;
|
|
export type SqlBase = postgres.ISql;
|
|
|
|
export interface Migration { readonly id: string; up(sql: SqlBase): Promise<void>; }
|
|
|
|
const REQUIRED_COLUMNS = {
|
|
journal_entries: ['content', 'entry_date'],
|
|
calendar_events: ['source_id']
|
|
} as const;
|
|
|
|
// Dependency-ordered: tables referenced by FK must come first
|
|
const allMigrations: readonly Migration[] = [
|
|
...projectsMigrations,
|
|
...clientsMigrations, // base clients table — many features FK-reference it
|
|
...projectClientsMigrations, // junction → projects + clients (after both exist)
|
|
...financialsMigrations,
|
|
...platformsMigrations,
|
|
...tasksMigrations,
|
|
...vigilMigrations,
|
|
...taskmasterMigrations,
|
|
...bookingsMigrations,
|
|
...credentialsMigrations,
|
|
...photosMigrations,
|
|
...rosterMigrations,
|
|
...travelMigrations,
|
|
...calendarMigrations,
|
|
...journalMigrations,
|
|
...remindersMigrations,
|
|
...authMigrations,
|
|
...hotelsMigrations,
|
|
...flightMonitorMigrations,
|
|
...extraMigrations, // includes priceWatchesMigrations (ordered after tour_legs)
|
|
];
|
|
|
|
let singleton: Sql | undefined;
|
|
|
|
export function openDb(url: string): Sql {
|
|
if (singleton) return singleton;
|
|
singleton = postgres(url, {
|
|
max: 10,
|
|
idle_timeout: 30,
|
|
connect_timeout: 10,
|
|
onnotice: () => {},
|
|
});
|
|
return singleton;
|
|
}
|
|
|
|
export function getDb(): Sql {
|
|
if (!singleton) throw new Error('db not opened — call openDb() in server.ts first');
|
|
return singleton;
|
|
}
|
|
|
|
export async function closeDb(): Promise<void> {
|
|
if (singleton) {
|
|
await singleton.end({ timeout: 5 });
|
|
singleton = undefined;
|
|
}
|
|
}
|
|
|
|
// Stable key for the migration advisory lock (arbitrary constant, shared by all
|
|
// initializers of this DB). Serializes concurrent boots so migrations run once.
|
|
const MIGRATION_LOCK_KEY = 776_2025_0608;
|
|
|
|
export async function runMigrations(sql: Sql, migrations: readonly Migration[]): Promise<void> {
|
|
try {
|
|
await sql`CREATE TABLE IF NOT EXISTS _my_migrations (
|
|
id TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`;
|
|
// Multiple initializers (separate pools) boot concurrently. Take a
|
|
// transaction-scoped advisory lock so only one applies pending migrations;
|
|
// the others block, then read an up-to-date applied set and skip. This
|
|
// guarantees each migration's up() runs exactly once regardless of whether
|
|
// it is individually idempotent, and avoids the _my_migrations duplicate-key
|
|
// rollback that previously failed schema init.
|
|
await sql.begin(async tx => {
|
|
await tx`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`;
|
|
const rows = await tx<Array<{ id: string }>>`SELECT id FROM _my_migrations`;
|
|
const applied = new Set(rows.map(r => r.id));
|
|
for (const m of migrations) {
|
|
if (applied.has(m.id)) continue;
|
|
await m.up(tx);
|
|
await tx`INSERT INTO _my_migrations (id) VALUES (${m.id})`;
|
|
logger.info('Applied migration', { id: m.id });
|
|
}
|
|
});
|
|
} catch (err) {
|
|
logger.error('Migration failed', { error: err });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function assertSchemaHealth(sql: Sql): Promise<void> {
|
|
try {
|
|
const missing: Array<string> = [];
|
|
for (const [table, requiredCols] of Object.entries(REQUIRED_COLUMNS)) {
|
|
const existing = await sql<Array<{ column_name: string }>>`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = ${table}
|
|
`;
|
|
const existingNames = new Set(existing.map(r => r.column_name));
|
|
for (const col of requiredCols) {
|
|
if (!existingNames.has(col)) {
|
|
missing.push(`${table}.${col}`);
|
|
}
|
|
}
|
|
}
|
|
if (missing.length > 0) {
|
|
throw new Error(`Missing required columns: ${missing.join(', ')}`);
|
|
}
|
|
} catch (err) {
|
|
logger.error('assertSchemaHealth failed', { error: err });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function initSchema(): Promise<void> {
|
|
try {
|
|
const url = process.env['QUINN_MY_DB_URL'] ?? 'postgres://quinn:quinn@localhost:25435/quinn';
|
|
const sql = openDb(url);
|
|
await runMigrations(sql, allMigrations);
|
|
await assertSchemaHealth(sql);
|
|
logger.info('Database schema initialized');
|
|
} catch (err) {
|
|
logger.error('Schema initialization failed', { error: err });
|
|
throw err;
|
|
}
|
|
}
|