feat(F-002): database foundation with migrations and dev compose

- node-pg-migrate + pg: baseline migration (extensions, app_meta) with working down
- src/infrastructure/db fail-fast pool and typed query helper
- docker-compose: postgres:16-alpine + redis:7-alpine with one-command up
- table naming convention <module>_<table> documented in README
- integration tests (6) against real PostgreSQL; strict identifier validation
  for test DDL after security-gate hardening round
- deps justified in spec/tech.md; all gates approved; verify.sh green
This commit is contained in:
rikrdo
2026-08-14 22:00:16 +02:00
parent 1d4eebca54
commit 425fedd13e
30 changed files with 1197 additions and 14 deletions

View File

@@ -0,0 +1,24 @@
import pg from 'pg';
/**
* Create a connection pool from environment.
* Fail fast and loud when configuration is missing: no silent defaults.
*/
export function createPoolFromEnv(env: NodeJS.ProcessEnv = process.env): pg.Pool {
const connectionString = env.DATABASE_URL;
if (!connectionString) {
throw new Error(
'DATABASE_URL is required. Copy .env.example to .env and start docker compose.',
);
}
return new pg.Pool({ connectionString, max: 10 });
}
/** Thin typed query helper. Modules get data through repositories, not raw pools. */
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
pool: pg.Pool,
text: string,
params: unknown[] = [],
): Promise<pg.QueryResult<T>> {
return pool.query<T>(text, params);
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { adminUrlFrom, dbNameFromUrl } from './db-test-support.js';
describe('db-test-support pure helpers', () => {
it('extracts the database name from a url', () => {
expect(dbNameFromUrl('postgres://user:pass@localhost:5432/mdv_test')).toBe('mdv_test');
});
it('rejects database names that are not safe identifiers', () => {
expect(() => dbNameFromUrl('postgres://u:p@localhost:5432/bad"name')).toThrowError(
/not a safe identifier/,
);
expect(() => dbNameFromUrl('postgres://u:p@localhost:5432/semi;colon')).toThrowError(
/not a safe identifier/,
);
});
it('rejects urls without database name', () => {
expect(() => dbNameFromUrl('postgres://u:p@localhost:5432/')).toThrowError(
/must include a database name/,
);
});
it('rewrites a url to the admin postgres database', () => {
expect(adminUrlFrom('postgres://u:p@localhost:5432/mdv_test')).toBe(
'postgres://u:p@localhost:5432/postgres',
);
});
});

View File

@@ -0,0 +1,66 @@
/**
* Test support for database integration tests.
* Explicit helpers only: no magic fixtures, no hidden state.
*/
import pg from 'pg';
import { runner } from 'node-pg-migrate';
/** Run project migrations programmatically with explicit, boring defaults. */
export async function runMigrations(databaseUrl: string, direction: 'up' | 'down'): Promise<void> {
await runner({
databaseUrl,
dir: 'migrations',
direction,
migrationsTable: 'pgmigrations',
verbose: false,
});
}
export function getTestDbUrl(): string {
const url = process.env.TEST_DATABASE_URL;
if (!url) {
throw new Error('TEST_DATABASE_URL is required for integration tests');
}
return url;
}
export function dbNameFromUrl(url: string): string {
const parsed = new URL(url);
const name = parsed.pathname.replace(/^\//, '');
if (!name) {
throw new Error(`TEST_DATABASE_URL must include a database name: ${url}`);
}
// Strict identifier validation: the name is interpolated into DDL below.
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`TEST_DATABASE_URL database name is not a safe identifier: ${name}`);
}
return name;
}
export function adminUrlFrom(url: string): string {
const parsed = new URL(url);
parsed.pathname = '/postgres';
return parsed.toString();
}
/** Drop and recreate the test database so every run starts fresh. */
export async function recreateDatabase(url: string): Promise<void> {
const dbName = dbNameFromUrl(url);
const admin = new pg.Client({ connectionString: adminUrlFrom(url) });
await admin.connect();
try {
// dbName is validated as a strict identifier in dbNameFromUrl before any DDL use.
await admin.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
await admin.query(`CREATE DATABASE "${dbName}"`);
} finally {
await admin.end();
}
}
export async function tableExists(pool: pg.Pool, table: string): Promise<boolean> {
const result = await pool.query(
'SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2',
['public', table],
);
return result.rowCount !== null && result.rowCount > 0;
}

View File

@@ -0,0 +1,37 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getTestDbUrl, recreateDatabase, runMigrations, tableExists } from './db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
describe.skipIf(!hasDb)('migrations', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
beforeAll(async () => {
await recreateDatabase(url);
pool = new pg.Pool({ connectionString: url, max: 2 });
});
afterAll(async () => {
await pool.end();
});
it('fresh up creates the baseline schema', async () => {
await runMigrations(url, 'up');
expect(await tableExists(pool, 'app_meta')).toBe(true);
});
it('second up is a no-op', async () => {
const before = await pool.query('SELECT count(*)::int AS n FROM pgmigrations');
await runMigrations(url, 'up');
const after = await pool.query('SELECT count(*)::int AS n FROM pgmigrations');
expect(after.rows[0]?.n).toBe(before.rows[0]?.n);
expect(await tableExists(pool, 'app_meta')).toBe(true);
});
it('down rolls back the baseline schema cleanly', async () => {
await runMigrations(url, 'down');
expect(await tableExists(pool, 'app_meta')).toBe(false);
});
});

View File

@@ -0,0 +1,41 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createPoolFromEnv, query } from '../pool.js';
import { getTestDbUrl, recreateDatabase, runMigrations } from './db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
describe.skipIf(!hasDb)('db pool', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPoolFromEnv({ DATABASE_URL: url } as NodeJS.ProcessEnv);
});
afterAll(async () => {
await pool.end();
});
it('connects and runs a trivial query', async () => {
const result = await query(pool, 'SELECT 1 AS ok');
expect(result.rows[0]?.ok).toBe(1);
});
it('supports a full roundtrip on app_meta via the query helper', async () => {
await query(pool, 'INSERT INTO app_meta (key, value) VALUES ($1, $2)', ['k1', 'v1']);
const read = await query(pool, 'SELECT value FROM app_meta WHERE key = $1', ['k1']);
expect(read.rows[0]?.value).toBe('v1');
await query(pool, 'DELETE FROM app_meta WHERE key = $1', ['k1']);
const gone = await query(pool, 'SELECT value FROM app_meta WHERE key = $1', ['k1']);
expect(gone.rowCount).toBe(0);
});
it('fails fast when DATABASE_URL is missing', () => {
expect(() => createPoolFromEnv({} as NodeJS.ProcessEnv)).toThrowError(
/DATABASE_URL is required/,
);
});
});