import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; // ─── Connection pool ────────────────────────────────────────────────────────── let pool: Pool | null = null; function getPool(): Pool { if (!pool) { const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL environment variable is not set."); } pool = new Pool({ connectionString, max: 10, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, }); pool.on("error", (err) => { console.error("[db] Unexpected pool error:", err); }); } return pool; } // ─── Query helpers ──────────────────────────────────────────────────────────── export async function query>( text: string, params?: unknown[] ): Promise> { return getPool().query(text, params); } /** * Runs a callback inside a transaction. * Commits on success, rolls back on any thrown error. */ export async function withTransaction( fn: (client: PoolClient) => Promise ): Promise { const client = await getPool().connect(); try { await client.query("BEGIN"); const result = await fn(client); await client.query("COMMIT"); return result; } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } // ─── Song queries ───────────────────────────────────────────────────────────── export interface SongRow { mbid: string; title: string; artist: string; duration_seconds: number | null; acousticbrainz_bpm: number | null; acousticbrainz_time_sig_num: number | null; source: string; created_at: Date; updated_at: Date; } export async function getSongByMbid(mbid: string): Promise { const { rows } = await query("SELECT * FROM songs WHERE mbid = $1", [mbid]); return rows[0] ?? null; } export async function searchSongs(q: string, limit = 20): Promise { const { rows } = await query( `SELECT * FROM songs WHERE to_tsvector('english', title || ' ' || artist) @@ plainto_tsquery('english', $1) ORDER BY ts_rank(to_tsvector('english', title || ' ' || artist), plainto_tsquery('english', $1)) DESC LIMIT $2`, [q, limit] ); return rows; } export async function upsertSong(song: Omit): Promise { await query( `INSERT INTO songs (mbid, title, artist, duration_seconds, acousticbrainz_bpm, acousticbrainz_time_sig_num, source) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (mbid) DO UPDATE SET title = EXCLUDED.title, artist = EXCLUDED.artist, duration_seconds = EXCLUDED.duration_seconds, acousticbrainz_bpm = EXCLUDED.acousticbrainz_bpm, acousticbrainz_time_sig_num = EXCLUDED.acousticbrainz_time_sig_num, source = EXCLUDED.source`, [ song.mbid, song.title, song.artist, song.duration_seconds, song.acousticbrainz_bpm, song.acousticbrainz_time_sig_num, song.source, ] ); } // ─── Tempo map queries ──────────────────────────────────────────────────────── export interface TempoMapRow { id: string; song_mbid: string; ctp_data: Record; contributed_by: string; verified: boolean; upvotes: number; created_at: Date; updated_at: Date; } export async function getTempoMapsForSong(mbid: string): Promise { const { rows } = await query( `SELECT * FROM tempo_maps WHERE song_mbid = $1 ORDER BY verified DESC, upvotes DESC, created_at ASC`, [mbid] ); return rows; } export async function insertTempoMap( map: Pick ): Promise { const { rows } = await query( `INSERT INTO tempo_maps (song_mbid, ctp_data, contributed_by) VALUES ($1, $2, $3) RETURNING *`, [map.song_mbid, JSON.stringify(map.ctp_data), map.contributed_by] ); return rows[0]; }