Files

68 lines
4.1 KiB
TypeScript

import 'dotenv/config';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import pg from 'pg';
import { checksum, loadMigrationInputs, migrateDatabase, DATABASE_ASSETS } from '../src/db/migrations.js';
export async function withScratchDatabase<T>(operation: (pool: pg.Pool, url: URL) => Promise<T>): Promise<T> {
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required; use a disposable PostgreSQL service with CREATE DATABASE permission');
const base = new URL(process.env.DATABASE_URL);
const admin = new pg.Pool({ connectionString: base.toString() });
const name = `tgvault_schema_${randomBytes(10).toString('hex')}`;
const url = new URL(base); url.pathname = `/${name}`;
let scratch: pg.Pool | undefined;
let created = false;
try {
await admin.query(`CREATE DATABASE "${name}" TEMPLATE template0`);
created = true;
scratch = new pg.Pool({ connectionString: url.toString() });
return await operation(scratch, url);
} finally {
await scratch?.end();
if (created) await admin.query(`DROP DATABASE "${name}"`);
await admin.end();
}
}
export async function dumpSchema(pool: pg.Pool, url: URL): Promise<string> {
const extensions = (await pool.query("SELECT extname FROM pg_extension WHERE extname <> 'plpgsql' ORDER BY extname")).rows;
const result = spawnSync(process.env.PG_DUMP || 'pg_dump', [
'--schema-only', '--no-owner', '--no-privileges', '--no-tablespaces', '--no-comments', '--schema=public',
], { encoding: 'utf8', env: { ...process.env, PGHOST: url.hostname, PGPORT: url.port || '5432', PGUSER: decodeURIComponent(url.username),
PGPASSWORD: decodeURIComponent(url.password), PGDATABASE: url.pathname.slice(1), PGSSLMODE: url.searchParams.get('sslmode') || 'prefer' } });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`pg_dump failed: ${result.stderr}`);
const lines = result.stdout.split('\n').filter(line => !line.startsWith('--') && !line.startsWith('\\')
&& !line.startsWith('SET ') && line !== 'SELECT pg_catalog.set_config(\'search_path\', \'\', false);'
&& line !== 'CREATE SCHEMA public;');
const sql = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
return '-- GENERATED by npm run schema:generate from baseline.sql + migrations; DO NOT EDIT.\n'
+ extensions.map(row => `CREATE EXTENSION IF NOT EXISTS "${String(row.extname).replace(/"/g, '""')}" WITH SCHEMA public;`).join('\n')
+ '\n\n' + sql + '\n';
}
export async function generateSnapshot(check: boolean): Promise<void> {
const inputs = await loadMigrationInputs();
const schema = await withScratchDatabase(async (pool, url) => {
await migrateDatabase(pool, { replay: true });
return dumpSchema(pool, url);
});
const manifest = JSON.stringify({ baselineChecksum: checksum(inputs.baseline), schemaChecksum: checksum(schema),
migrations: inputs.migrations.map(({ version, name, checksum }) => ({ version, name, checksum })) }, null, 2) + '\n';
const initial = '-- GENERATED FROM backend/src/db/schema.sql; DO NOT EDIT init.sql DIRECTLY.\n' + schema;
const outputs = [[new URL('schema.sql', DATABASE_ASSETS), schema], [new URL('snapshot.json', DATABASE_ASSETS), manifest],
[new URL('../../init.sql', import.meta.url), initial]] as const;
for (const [file, expected] of outputs) {
if (check) assert.equal(await readFile(file, 'utf8'), expected, `${file.pathname} is stale; run npm run schema:generate`);
else await writeFile(file, expected);
}
console.log(check ? 'Generated schema, manifest and init.sql match migration replay' : 'Generated schema.sql, snapshot.json and init.sql');
}
if (process.argv[1]?.endsWith('database-snapshot.ts')) {
if (process.argv.slice(2).some(arg => arg !== '--check')) throw new Error('Usage: database-snapshot.ts [--check]');
await generateSnapshot(process.argv.includes('--check'));
}