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:
24
project/src/infrastructure/db/pool.ts
Normal file
24
project/src/infrastructure/db/pool.ts
Normal 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);
|
||||
}
|
||||
29
project/src/infrastructure/db/tests/db-test-support.test.ts
Normal file
29
project/src/infrastructure/db/tests/db-test-support.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
66
project/src/infrastructure/db/tests/db-test-support.ts
Normal file
66
project/src/infrastructure/db/tests/db-test-support.ts
Normal 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;
|
||||
}
|
||||
37
project/src/infrastructure/db/tests/migrations.itest.ts
Normal file
37
project/src/infrastructure/db/tests/migrations.itest.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
project/src/infrastructure/db/tests/pool.itest.ts
Normal file
41
project/src/infrastructure/db/tests/pool.itest.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user