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; }; })();