220 lines
6.5 KiB
TypeScript
220 lines
6.5 KiB
TypeScript
import {PrismaClient} from '@plunk/db';
|
|
import {execSync} from 'child_process';
|
|
|
|
// Snake-cased table names from prisma schema (see @@map directives).
|
|
// Order doesn't matter — TRUNCATE with CASCADE handles FK dependencies in one statement.
|
|
const TRUNCATE_TABLES = [
|
|
'events',
|
|
'workflow_step_executions',
|
|
'emails',
|
|
'workflow_executions',
|
|
'workflow_transitions',
|
|
'workflow_steps',
|
|
'workflows',
|
|
'campaigns',
|
|
'templates',
|
|
'segment_memberships',
|
|
'segments',
|
|
'contacts',
|
|
'domains',
|
|
'memberships',
|
|
'projects',
|
|
'users',
|
|
];
|
|
|
|
/**
|
|
* Connects to the admin `postgres` database to ensure the worker's test DB exists.
|
|
* Postgres has no `CREATE DATABASE IF NOT EXISTS`, so we check pg_database first.
|
|
*/
|
|
async function ensureDatabaseExists(databaseUrl: string, workerDbName: string) {
|
|
const adminUrl = new URL(databaseUrl);
|
|
adminUrl.pathname = '/postgres';
|
|
adminUrl.searchParams.delete('connection_limit');
|
|
adminUrl.searchParams.delete('pool_timeout');
|
|
|
|
const admin = new PrismaClient({datasources: {db: {url: adminUrl.toString()}}});
|
|
try {
|
|
const rows = await admin.$queryRawUnsafe<{exists: boolean}[]>(
|
|
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = '${workerDbName}') AS exists`,
|
|
);
|
|
if (!rows[0]?.exists) {
|
|
await admin.$executeRawUnsafe(`CREATE DATABASE "${workerDbName}"`);
|
|
}
|
|
} finally {
|
|
await admin.$disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test database helper
|
|
* Manages test database isolation and cleanup
|
|
*/
|
|
class TestDatabase {
|
|
private prisma: PrismaClient | null = null;
|
|
|
|
async initialize() {
|
|
// setup.ts has already rewritten DATABASE_URL to include the per-worker DB name
|
|
// (e.g. plunk_test_w1, plunk_test_w2). We create that DB if missing, migrate it,
|
|
// then open the long-lived client we use for tests.
|
|
const databaseUrl = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
|
if (!databaseUrl) {
|
|
throw new Error('DATABASE_URL or TEST_DATABASE_URL must be set for testing');
|
|
}
|
|
|
|
const url = new URL(databaseUrl);
|
|
const workerDbName = url.pathname.replace(/^\//, '');
|
|
if (!workerDbName) {
|
|
throw new Error('DATABASE_URL must include a database name');
|
|
}
|
|
|
|
// Bump the pool above Prisma's default (~5 on CI). Test Postgres has
|
|
// max_connections=100; with N workers we want N*20 ≤ 100 — fine up to 4 workers.
|
|
if (!url.searchParams.has('connection_limit')) {
|
|
url.searchParams.set('connection_limit', '20');
|
|
}
|
|
if (!url.searchParams.has('pool_timeout')) {
|
|
url.searchParams.set('pool_timeout', '20');
|
|
}
|
|
|
|
await ensureDatabaseExists(databaseUrl, workerDbName);
|
|
|
|
// Run pending migrations against this worker's DB. `migrate deploy` is a no-op
|
|
// when up-to-date and avoids the drift prompts that `migrate dev` does.
|
|
try {
|
|
execSync('yarn workspace @plunk/db migrate:prod', {
|
|
env: {
|
|
...process.env,
|
|
DATABASE_URL: url.toString(),
|
|
DIRECT_DATABASE_URL: process.env.DIRECT_DATABASE_URL || url.toString(),
|
|
},
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
} catch (error) {
|
|
const err = error as {stdout?: string; stderr?: string; message?: string};
|
|
console.error('Migration failed for', workerDbName);
|
|
if (err.stdout) console.error('stdout:', err.stdout);
|
|
if (err.stderr) console.error('stderr:', err.stderr);
|
|
if (!err.stdout && !err.stderr) console.error(err.message);
|
|
throw error;
|
|
}
|
|
|
|
this.prisma = new PrismaClient({
|
|
datasources: {db: {url: url.toString()}},
|
|
});
|
|
await this.prisma.$connect();
|
|
}
|
|
|
|
/**
|
|
* Get Prisma client instance
|
|
*/
|
|
getClient(): PrismaClient {
|
|
if (!this.prisma) {
|
|
throw new Error('Database not initialized. Call initialize() first.');
|
|
}
|
|
return this.prisma;
|
|
}
|
|
|
|
/**
|
|
* Wipe all per-test data with a single TRUNCATE ... CASCADE statement.
|
|
* Roughly an order of magnitude faster than 14 sequential deleteMany calls
|
|
* — TRUNCATE skips the row scan and only touches table headers.
|
|
*/
|
|
async cleanup() {
|
|
if (!this.prisma) return;
|
|
|
|
const tables = TRUNCATE_TABLES.map(t => `"${t}"`).join(', ');
|
|
const maxRetries = 3;
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE`);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
const isDeadlock = error instanceof Error && error.message?.includes('deadlock detected');
|
|
if (isDeadlock && attempt < maxRetries) {
|
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 50));
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
console.error(`Error cleaning up database after ${maxRetries} attempts:`, lastError);
|
|
throw lastError;
|
|
}
|
|
|
|
/**
|
|
* Disconnect from database
|
|
*/
|
|
async disconnect() {
|
|
if (this.prisma) {
|
|
await this.prisma.$disconnect();
|
|
this.prisma = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute raw SQL (useful for advanced test setup)
|
|
*/
|
|
async executeRaw(sql: string) {
|
|
if (!this.prisma) {
|
|
throw new Error('Database not initialized');
|
|
}
|
|
return this.prisma.$executeRawUnsafe(sql);
|
|
}
|
|
|
|
/**
|
|
* Reset database sequences (useful for predictable IDs in tests)
|
|
*/
|
|
async resetSequences() {
|
|
if (!this.prisma) return;
|
|
|
|
// Get all tables with sequences
|
|
const tables = [
|
|
'User',
|
|
'Project',
|
|
'Contact',
|
|
'Campaign',
|
|
'Email',
|
|
'Workflow',
|
|
'WorkflowExecution',
|
|
'Template',
|
|
'Segment',
|
|
];
|
|
|
|
for (const table of tables) {
|
|
try {
|
|
await this.prisma.$executeRawUnsafe(`ALTER SEQUENCE "${table}_id_seq" RESTART WITH 1`);
|
|
} catch (error) {
|
|
// Sequence might not exist, ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const testDatabase = new TestDatabase();
|
|
|
|
// Export helper to get Prisma client in tests
|
|
// Returns a Proxy that lazily initializes the database on first property access
|
|
export const getPrismaClient = (() => {
|
|
let clientProxy: PrismaClient | null = null;
|
|
|
|
return () => {
|
|
if (!clientProxy) {
|
|
clientProxy = new Proxy({} as PrismaClient, {
|
|
get(target, prop) {
|
|
const client = testDatabase.getClient();
|
|
const value = client[prop as keyof PrismaClient];
|
|
// Bind methods to the actual client
|
|
return typeof value === 'function' ? value.bind(client) : value;
|
|
},
|
|
});
|
|
}
|
|
return clientProxy;
|
|
};
|
|
})();
|