lilith-platform.live/codebase/@features/my/backend-api/src/db.ts
autocommit 10a8ba4a80 feat(backend-api): Implement dynamic test database naming for parallel execution
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-08 00:27:53 -07:00

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;
}
}