Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 2 additions & 49 deletions tests/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,10 @@
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import path from "node:path";

let container: StartedPostgreSqlContainer;

// Every test file clones this database instead of replaying the migration
// chain itself. Shared with tests/integration/setup.ts via TEST_TEMPLATE_DB.
const TEMPLATE_DB = "ledgr_test_template";

export async function setup() {
container = await new PostgreSqlContainer("postgres:17-alpine")
.withCommand([
"postgres",
// One throwaway database per test file, all on this single server. The
// default max_connections (100) sits below what the fork pool can demand
// — 14 workers holding a pool each exhausts it, and Postgres starts
// terminating connections mid-run (57P01).
"-c",
"max_connections=300",
// Durability buys nothing for a server destroyed at teardown.
"-c",
"fsync=off",
"-c",
"synchronous_commit=off",
"-c",
"full_page_writes=off",
])
.start();

const connectionString = container.getConnectionUri();
process.env.DATABASE_URL = connectionString;
process.env.TEST_TEMPLATE_DB = TEMPLATE_DB;

// Migrate once, here, into a template database. Cloning that template per
// test file replaces one full migration run per file with a file copy.
const admin = new Pool({ connectionString, max: 1 });
await admin.query(`CREATE DATABASE "${TEMPLATE_DB}"`);
await admin.end();

const templateUrl = new URL(connectionString);
templateUrl.pathname = `/${TEMPLATE_DB}`;
const pool = new Pool({ connectionString: templateUrl.toString(), max: 1 });
try {
await migrate(drizzle({ client: pool }), {
migrationsFolder: path.join(process.cwd(), "src/db/migrations"),
});
} finally {
// The template must have no open connections, or CREATE DATABASE ...
// TEMPLATE fails with 55006 for every test file that follows.
await pool.end();
}
container = await new PostgreSqlContainer("postgres:17-alpine").start();
process.env.DATABASE_URL = container.getConnectionUri();
}

export async function teardown() {
Expand Down
47 changes: 7 additions & 40 deletions tests/integration/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,71 +5,38 @@ import { randomUUID } from "crypto";
import * as schema from "../../src/db/schema";
import path from "node:path";

// Each worker holds a pool for the life of its test file. Left at pg's default
// of 10 these overrun the server's connection limit once vitest scales forks to
// the core count; the suite needs only a couple of concurrent queries per file.
const POOL_MAX = 4;

// CREATE DATABASE ... TEMPLATE briefly conflicts when several workers clone the
// same template at once (55006). It clears on its own, so retry rather than
// serialize every worker behind a lock.
const CLONE_RETRIES = 10;

async function cloneTemplate(admin: Pool, dbName: string, template: string) {
for (let attempt = 0; ; attempt++) {
try {
await admin.query(`CREATE DATABASE "${dbName}" TEMPLATE "${template}"`);
return;
} catch (error) {
const code = (error as { code?: string }).code;
if (code !== "55006" || attempt >= CLONE_RETRIES) throw error;
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
}
}
}

export async function createTestDb() {
const connectionString =
process.env.DATABASE_URL || "postgresql://ledgr:ledgr@localhost:5432/ledgr_test";

const dbName = `test_${randomUUID().replace(/-/g, "")}`;
const template = process.env.TEST_TEMPLATE_DB;

// Isolate each test file in its own *database* (not a schema). Migrations
// reference tables as `"public"."<table>"`, which only resolves when the
// objects live in the public schema — a per-schema/search_path approach breaks
// on those qualified references. A throwaway database per file gives every test
// its own public schema, keeps the concurrent suite isolated, and is robust to
// future migrations regardless of how they qualify identifiers.
const admin = new Pool({ connectionString, max: 1 });
if (template) {
await cloneTemplate(admin, dbName, template);
} else {
await admin.query(`CREATE DATABASE "${dbName}"`);
}
const admin = new Pool({ connectionString });
await admin.query(`CREATE DATABASE "${dbName}"`);
await admin.end();

const url = new URL(connectionString);
url.pathname = `/${dbName}`;

const pool = new Pool({ connectionString: url.toString(), max: POOL_MAX });
const pool = new Pool({ connectionString: url.toString() });
const db = drizzle({ client: pool, schema });

// The template arrives already migrated. Without one (a direct DATABASE_URL,
// no global setup) fall back to replaying the chain.
if (!template) {
await migrate(db, {
migrationsFolder: path.join(process.cwd(), "src/db/migrations"),
});
}
await migrate(db, {
migrationsFolder: path.join(process.cwd(), "src/db/migrations"),
});

return {
db,
async close() {
await pool.end();
// DROP DATABASE cannot run while connections are open; FORCE terminates any
// stragglers (Postgres 13+).
const admin2 = new Pool({ connectionString, max: 1 });
const admin2 = new Pool({ connectionString });
await admin2.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
await admin2.end();
},
Expand Down
Loading