From 789ab8fca8838f8d3f9de1ed76a4ffda41bb6f57 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 31 May 2026 02:05:53 +1000 Subject: [PATCH] feat: db pool + migration runner with idempotency Co-Authored-By: Claude Sonnet 4.6 --- lib/db/migrate.js | 49 ++++++++++++++++++++++++++++++++++++++++ lib/db/pool.js | 8 +++++++ tests/db/migrate.test.js | 28 +++++++++++++++++++++++ tests/helpers/db.js | 16 +++++++++++++ tests/helpers/setup.js | 1 + 5 files changed, 102 insertions(+) create mode 100644 lib/db/migrate.js create mode 100644 lib/db/pool.js create mode 100644 tests/db/migrate.test.js create mode 100644 tests/helpers/db.js create mode 100644 tests/helpers/setup.js diff --git a/lib/db/migrate.js b/lib/db/migrate.js new file mode 100644 index 0000000..5fdd8bd --- /dev/null +++ b/lib/db/migrate.js @@ -0,0 +1,49 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { pool } from './pool.js'; +import { log } from '../log.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIG_DIR = path.join(__dirname, 'migrations'); + +export async function migrateUp() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ); + `); + + const files = (await fs.readdir(MIG_DIR)).filter(f => f.endsWith('.sql')).sort(); + const { rows } = await client.query('SELECT name FROM schema_migrations'); + const applied = new Set(rows.map(r => r.name)); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = await fs.readFile(path.join(MIG_DIR, file), 'utf8'); + log.info({ migration: file }, 'applying migration'); + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query( + 'INSERT INTO schema_migrations(name) VALUES ($1)', [file] + ); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } + } finally { + client.release(); + } +} + +if (process.argv[2] === 'up') { + migrateUp().then(() => process.exit(0)).catch((e) => { + log.error(e); process.exit(1); + }); +} diff --git a/lib/db/pool.js b/lib/db/pool.js new file mode 100644 index 0000000..ceb5f96 --- /dev/null +++ b/lib/db/pool.js @@ -0,0 +1,8 @@ +import pg from 'pg'; +import 'dotenv/config'; + +export const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + max: 10, + idleTimeoutMillis: 30_000 +}); diff --git a/tests/db/migrate.test.js b/tests/db/migrate.test.js new file mode 100644 index 0000000..8307b7a --- /dev/null +++ b/tests/db/migrate.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb, withClient } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +describe('migrate', () => { + beforeAll(async () => { await resetDb(); }); + + it('creates schema_migrations table on first run', async () => { + await migrateUp(); + await withClient(async (c) => { + const { rows } = await c.query( + `SELECT to_regclass('public.schema_migrations') AS t;` + ); + expect(rows[0].t).toBe('schema_migrations'); + }); + }); + + it('is idempotent — second run is a no-op', async () => { + await migrateUp(); + await migrateUp(); + await withClient(async (c) => { + const { rows } = await c.query( + `SELECT count(*)::int AS n FROM schema_migrations;` + ); + expect(rows[0].n).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/tests/helpers/db.js b/tests/helpers/db.js new file mode 100644 index 0000000..e061765 --- /dev/null +++ b/tests/helpers/db.js @@ -0,0 +1,16 @@ +import { pool } from '../../lib/db/pool.js'; + +export async function resetDb() { + await pool.query(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE EXTENSION IF NOT EXISTS vector; + `); +} + +export async function withClient(fn) { + const client = await pool.connect(); + try { await fn(client); } + finally { client.release(); } +} diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js new file mode 100644 index 0000000..0b172b7 --- /dev/null +++ b/tests/helpers/setup.js @@ -0,0 +1 @@ +import 'dotenv/config';