Migration tests

This commit is contained in:
Konstantin Wohlwend 2026-02-17 15:57:25 -08:00
parent fa27c80319
commit 08c3447477
12 changed files with 468 additions and 2 deletions

View File

@ -23,7 +23,7 @@ You should ALWAYS add new E2E tests when you change the API or SDK interface. Ge
- **Run some tests**: `pnpm test run <file-filters>`
### Database Commands
- **Generate migration**: `pnpm db:migration-gen`
- **Generate migration**: `pnpm db:migration-gen` — NOTE: don't forget to create tests for the migrations!
- **Reset database** (rarely used): `pnpm db:reset`
- **Seed database** (rarely used): `pnpm db:seed`
- **Initialize database** (rarely used): `pnpm db:init`
@ -100,8 +100,10 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust.
- Fail early, fail loud. Fail fast with an error instead of silently continuing.
- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible.
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples.
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. One common pattern is to add a temporary extra boolean column
- Each migration file runs in its own transaction with a relatively short timeout. Split long-running operations into separate migration files to avoid timeouts. For example, when adding CHECK constraints, use `NOT VALID` in one migration, then `VALIDATE CONSTRAINT` in a separate migration file.
- Note that each database migration file is executed in a single transaction. Even with the run-outside-transaction sentinel, the transaction will still continue during the entire migration file. If you want to split things up into multiple transactions, put it into their own migration files.
- When writing database migration files, ALWAYS ALWAYS add tests for all the potential edge cases! See the folder structure of the other migrations to see how that works.
- **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed.
- Any design components you add or modify in the dashboard, update the Playground page accordingly to showcase the changes.
- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`.

View File

@ -0,0 +1,31 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
const tenancyId = randomUUID();
const userId1 = randomUUID();
const userId2 = randomUUID();
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`;
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${userId1}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${userId2}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
return { projectId };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
const rows = await sql`
SELECT "restrictedByAdmin", "restrictedByAdminReason"
FROM "ProjectUser"
WHERE "mirroredProjectId" = ${ctx.projectId}
`;
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.restrictedByAdmin).toBe(false);
expect(row.restrictedByAdminReason).toBeNull();
}
};

View File

@ -0,0 +1,60 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
const tenancyId = randomUUID();
const unrestricted = randomUUID();
const restrictedWithReason = randomUUID();
const restrictedNoReason = randomUUID();
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`;
// Unrestricted user (valid: false + null reason)
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${unrestricted}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
// Restricted with reason
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "restrictedByAdmin", "restrictedByAdminReason") VALUES (${restrictedWithReason}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW(), true, 'spam')`;
// Restricted without reason
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "restrictedByAdmin") VALUES (${restrictedNoReason}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW(), true)`;
return { projectId, tenancyId, unrestricted, restrictedWithReason, restrictedNoReason };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
// Existing valid rows should still be there
const rows = await sql`
SELECT "projectUserId", "restrictedByAdmin", "restrictedByAdminReason", "restrictedByAdminPrivateDetails"
FROM "ProjectUser"
WHERE "mirroredProjectId" = ${ctx.projectId}
ORDER BY "projectUserId"
`;
expect(rows).toHaveLength(3);
for (const row of rows) {
expect(row.restrictedByAdminPrivateDetails).toBeNull();
}
// Restricted user can have private details set
await sql`UPDATE "ProjectUser" SET "restrictedByAdminPrivateDetails" = 'internal notes' WHERE "projectUserId" = ${ctx.restrictedWithReason}::uuid`;
// INVALID: unrestricted user with a reason should fail
await expect(sql`
UPDATE "ProjectUser" SET "restrictedByAdminReason" = 'should fail' WHERE "projectUserId" = ${ctx.unrestricted}::uuid
`).rejects.toThrow(/ProjectUser_restricted_by_admin_consistency/);
// INVALID: unrestricted user with private details should fail
await expect(sql`
UPDATE "ProjectUser" SET "restrictedByAdminPrivateDetails" = 'should fail' WHERE "projectUserId" = ${ctx.unrestricted}::uuid
`).rejects.toThrow(/ProjectUser_restricted_by_admin_consistency/);
// VALID: new restricted user with all fields
const newUser = randomUUID();
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "restrictedByAdmin", "restrictedByAdminReason", "restrictedByAdminPrivateDetails") VALUES (${newUser}::uuid, ${ctx.tenancyId}::uuid, ${ctx.projectId}, 'main', NOW(), NOW(), NOW(), true, 'test', 'details')`;
// VALID: un-restricting clears reason and details
await sql`UPDATE "ProjectUser" SET "restrictedByAdmin" = false, "restrictedByAdminReason" = NULL, "restrictedByAdminPrivateDetails" = NULL WHERE "projectUserId" = ${newUser}::uuid`;
};

View File

@ -0,0 +1,38 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const dedupKey = `dedup-${randomUUID()}`;
const fulfilledKey = `fulfilled-${randomUUID()}`;
// Pending request
await sql`INSERT INTO "OutgoingRequest" ("id", "deduplicationKey", "qstashOptions") VALUES (${randomUUID()}::uuid, ${dedupKey}, '{"url":"http://test"}'::jsonb)`;
// Fulfilled request with a different key
await sql`INSERT INTO "OutgoingRequest" ("id", "deduplicationKey", "qstashOptions", "startedFulfillingAt") VALUES (${randomUUID()}::uuid, ${fulfilledKey}, '{"url":"http://test"}'::jsonb, NOW())`;
return { dedupKey, fulfilledKey };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
// Duplicate pending requests should still be rejected
await expect(sql`
INSERT INTO "OutgoingRequest" ("id", "deduplicationKey", "qstashOptions") VALUES (${randomUUID()}::uuid, ${ctx.dedupKey}, '{"url":"http://test2"}'::jsonb)
`).rejects.toThrow(/OutgoingRequest_deduplicationKey_pending_key/);
// Fulfill the original pending request
await sql`UPDATE "OutgoingRequest" SET "startedFulfillingAt" = NOW() WHERE "deduplicationKey" = ${ctx.dedupKey}`;
// Now we CAN insert a new pending request with the same dedup key
await sql`INSERT INTO "OutgoingRequest" ("id", "deduplicationKey", "qstashOptions") VALUES (${randomUUID()}::uuid, ${ctx.dedupKey}, '{"url":"http://test3"}'::jsonb)`;
// Fulfilled requests can share dedup keys freely
await sql`INSERT INTO "OutgoingRequest" ("id", "deduplicationKey", "qstashOptions", "startedFulfillingAt") VALUES (${randomUUID()}::uuid, ${ctx.fulfilledKey}, '{"url":"http://test4"}'::jsonb, NOW())`;
const pending = await sql`SELECT COUNT(*) as count FROM "OutgoingRequest" WHERE "deduplicationKey" = ${ctx.dedupKey} AND "startedFulfillingAt" IS NULL`;
expect(Number(pending[0].count)).toBe(1);
const fulfilled = await sql`SELECT COUNT(*) as count FROM "OutgoingRequest" WHERE "deduplicationKey" = ${ctx.dedupKey} AND "startedFulfillingAt" IS NOT NULL`;
expect(Number(fulfilled[0].count)).toBe(1);
};

View File

@ -0,0 +1,30 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
const domainId = randomUUID();
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
// Config that already has both parent AND child keys (correct format)
const config = {
[`domains.trustedDomains.${domainId}`]: {},
[`domains.trustedDomains.${domainId}.baseUrl`]: 'https://correct.com',
[`domains.trustedDomains.${domainId}.handlerPath`]: '/api',
'some.other.key': 'untouched',
};
await sql`INSERT INTO "EnvironmentConfigOverride" ("projectId", "branchId", "createdAt", "updatedAt", "config") VALUES (${projectId}, 'main', NOW(), NOW(), ${sql.json(config)})`;
return { projectId, domainId };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
const [row] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId}`;
expect(row.config[`domains.trustedDomains.${ctx.domainId}`]).toEqual({});
expect(row.config[`domains.trustedDomains.${ctx.domainId}.baseUrl`]).toBe('https://correct.com');
expect(row.config[`domains.trustedDomains.${ctx.domainId}.handlerPath`]).toBe('/api');
expect(row.config['some.other.key']).toBe('untouched');
};

View File

@ -0,0 +1,32 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
const domainId = randomUUID();
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
// Config with child keys but MISSING parent key
const config = {
[`domains.trustedDomains.${domainId}.baseUrl`]: 'https://example.com',
[`domains.trustedDomains.${domainId}.handlerPath`]: '/handler',
};
await sql`INSERT INTO "EnvironmentConfigOverride" ("projectId", "branchId", "createdAt", "updatedAt", "config") VALUES (${projectId}, 'main', NOW(), NOW(), ${sql.json(config)})`;
return { projectId, domainId };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
const [row] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId}`;
const parentKey = `domains.trustedDomains.${ctx.domainId}`;
// Parent key should now exist as an empty object
expect(row.config).toHaveProperty(parentKey);
expect(row.config[parentKey]).toEqual({});
// Child keys should still be present and unchanged
expect(row.config[`${parentKey}.baseUrl`]).toBe('https://example.com');
expect(row.config[`${parentKey}.handlerPath`]).toBe('/handler');
};

View File

@ -0,0 +1,40 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId1 = `test-${randomUUID()}`;
const projectId2 = `test-${randomUUID()}`;
const domainOk = randomUUID();
const domainBroken = randomUUID();
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId1}, NOW(), NOW(), 'Test1', '', false)`;
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId2}, NOW(), NOW(), 'Test2', '', false)`;
// Project 1: correctly formatted (parent key present)
const config1 = {
[`domains.trustedDomains.${domainOk}`]: {},
[`domains.trustedDomains.${domainOk}.baseUrl`]: 'https://ok.com',
};
await sql`INSERT INTO "EnvironmentConfigOverride" ("projectId", "branchId", "createdAt", "updatedAt", "config") VALUES (${projectId1}, 'main', NOW(), NOW(), ${sql.json(config1)})`;
// Project 2: broken (parent key missing)
const config2 = {
[`domains.trustedDomains.${domainBroken}.baseUrl`]: 'https://broken.com',
};
await sql`INSERT INTO "EnvironmentConfigOverride" ("projectId", "branchId", "createdAt", "updatedAt", "config") VALUES (${projectId2}, 'main', NOW(), NOW(), ${sql.json(config2)})`;
return { projectId1, projectId2, domainOk, domainBroken };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
// Project 1: unchanged
const [row1] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId1}`;
expect(row1.config[`domains.trustedDomains.${ctx.domainOk}`]).toEqual({});
expect(row1.config[`domains.trustedDomains.${ctx.domainOk}.baseUrl`]).toBe('https://ok.com');
// Project 2: parent key added
const [row2] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId2}`;
expect(row2.config[`domains.trustedDomains.${ctx.domainBroken}`]).toEqual({});
expect(row2.config[`domains.trustedDomains.${ctx.domainBroken}.baseUrl`]).toBe('https://broken.com');
};

View File

@ -0,0 +1,38 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
const domainId1 = randomUUID();
const domainId2 = randomUUID();
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
// Two different domains, both missing parent keys
const config = {
[`domains.trustedDomains.${domainId1}.baseUrl`]: 'https://one.com',
[`domains.trustedDomains.${domainId1}.handlerPath`]: '/one',
[`domains.trustedDomains.${domainId2}.baseUrl`]: 'https://two.com',
[`domains.trustedDomains.${domainId2}.handlerPath`]: '/two',
[`domains.trustedDomains.${domainId2}.extra`]: 'data',
};
await sql`INSERT INTO "EnvironmentConfigOverride" ("projectId", "branchId", "createdAt", "updatedAt", "config") VALUES (${projectId}, 'main', NOW(), NOW(), ${sql.json(config)})`;
return { projectId, domainId1, domainId2 };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
const [row] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId}`;
// Both parent keys should be added
expect(row.config[`domains.trustedDomains.${ctx.domainId1}`]).toEqual({});
expect(row.config[`domains.trustedDomains.${ctx.domainId2}`]).toEqual({});
// All child keys preserved
expect(row.config[`domains.trustedDomains.${ctx.domainId1}.baseUrl`]).toBe('https://one.com');
expect(row.config[`domains.trustedDomains.${ctx.domainId1}.handlerPath`]).toBe('/one');
expect(row.config[`domains.trustedDomains.${ctx.domainId2}.baseUrl`]).toBe('https://two.com');
expect(row.config[`domains.trustedDomains.${ctx.domainId2}.handlerPath`]).toBe('/two');
expect(row.config[`domains.trustedDomains.${ctx.domainId2}.extra`]).toBe('data');
};

View File

@ -0,0 +1,19 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
const config = { 'auth.allowSignUp': true, 'payments.testMode': true, 'some.nested.value': 42 };
await sql`INSERT INTO "EnvironmentConfigOverride" ("projectId", "branchId", "createdAt", "updatedAt", "config") VALUES (${projectId}, 'main', NOW(), NOW(), ${sql.json(config)})`;
return { projectId, originalConfig: config };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
const [row] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId}`;
expect(row.config).toEqual(ctx.originalConfig);
};

View File

@ -0,0 +1,21 @@
import type { Sql } from 'postgres';
import { expect } from 'vitest';
// No preMigration needed - we just verify cleanup after the migration runs
export const postMigration = async (sql: Sql) => {
// Temporary column should NOT exist after migration
const columns = await sql`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'EnvironmentConfigOverride'
AND column_name = 'temp_trusted_domains_checked'
`;
expect(columns).toHaveLength(0);
// Temporary index should NOT exist after migration
const indices = await sql`
SELECT indexname FROM pg_indexes
WHERE indexname = 'temp_eco_trusted_domains_checked_idx'
`;
expect(indices).toHaveLength(0);
};

View File

@ -0,0 +1,154 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from '@prisma/adapter-pg';
import fs from 'fs';
import path from 'path';
import postgres from 'postgres';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { applyMigrations } from "./index";
import { getMigrationFiles } from "./utils";
// Resolve migrations dir relative to this file, not process.cwd()
const MIGRATIONS_DIR = path.resolve(__dirname, '../../prisma/migrations');
const TEST_DB_PREFIX = 'stack_migration_test';
const getTestDbURL = (testDbName: string) => {
// @ts-ignore - ImportMeta.env is provided by Vite
const connString: string = import.meta.env.STACK_DATABASE_CONNECTION_STRING;
const base = connString.replace(/\/[^/]*(\?.*)?$/, '');
const query = connString.split('?')[1] ?? '';
return { full: `${base}/${testDbName}`, base, query };
};
type MigrationTestModule = {
preMigration?: (sql: postgres.Sql) => Promise<unknown>,
postMigration?: (sql: postgres.Sql, ctx: unknown) => Promise<void>,
};
type TestInfo = {
fileName: string,
modulePath: string,
};
type MigrationWithTests = {
migrationName: string,
migrationIndex: number,
tests: TestInfo[],
};
function discoverTestFiles(): { allMigrations: { migrationName: string, sql: string }[], migrationsWithTests: MigrationWithTests[] } {
const allMigrations = getMigrationFiles(MIGRATIONS_DIR);
const migrationsWithTests: MigrationWithTests[] = [];
for (const [i, mf] of allMigrations.entries()) {
const testsDir = path.join(MIGRATIONS_DIR, mf.migrationName, 'tests');
if (!fs.existsSync(testsDir)) continue;
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.ts') || f.endsWith('.js'));
if (files.length === 0) continue;
migrationsWithTests.push({
migrationName: mf.migrationName,
migrationIndex: i,
tests: files.map(f => ({ fileName: f, modulePath: path.join(testsDir, f) })),
});
}
return { allMigrations, migrationsWithTests };
}
const { allMigrations, migrationsWithTests } = discoverTestFiles();
describe.sequential('database migration tests', { timeout: 600_000 }, () => {
let sql: postgres.Sql;
let prismaClient: PrismaClient;
let testDbName: string;
let appliedUpTo = 0;
async function applyMigrationsUpTo(targetIndex: number) {
if (appliedUpTo >= targetIndex) return;
const batch = allMigrations.slice(0, targetIndex);
await applyMigrations({ prismaClient, migrationFiles: batch, schema: 'public' });
appliedUpTo = targetIndex;
}
async function applySingleMigration(index: number) {
await applyMigrations({ prismaClient, migrationFiles: allMigrations.slice(0, index + 1), schema: 'public' });
appliedUpTo = index + 1;
}
beforeAll(async () => {
const randomSuffix = Math.random().toString(16).substring(2, 12);
testDbName = `${TEST_DB_PREFIX}_${randomSuffix}`;
const dbURL = getTestDbURL(testDbName);
const adminSql = postgres(dbURL.base);
try {
await adminSql.unsafe(`CREATE DATABASE ${testDbName}`);
} finally {
await adminSql.end();
}
const connectionString = `${dbURL.full}?${dbURL.query}`;
sql = postgres(connectionString);
const adapter = new PrismaPg({ connectionString });
prismaClient = new PrismaClient({ adapter });
await prismaClient.$connect();
}, 60_000);
afterAll(async () => {
await sql.end();
await prismaClient.$disconnect();
if (testDbName) {
const dbURL = getTestDbURL(testDbName);
const adminSql = postgres(dbURL.base);
try {
await adminSql.unsafe(`
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = '${testDbName}'
AND pid <> pg_backend_pid()
`);
await adminSql.unsafe(`DROP DATABASE IF EXISTS ${testDbName}`);
} finally {
await adminSql.end();
}
}
}, 60_000);
if (migrationsWithTests.length === 0) {
test('no migration tests found', () => {
expect(true).toBe(true);
});
return;
}
for (const mwt of migrationsWithTests) {
describe(mwt.migrationName, () => {
const preResults = new Map<string, unknown>();
beforeAll(async () => {
// Apply all migrations up to (but not including) this one
await applyMigrationsUpTo(mwt.migrationIndex);
// Run preMigration for each test file
for (const t of mwt.tests) {
const mod: MigrationTestModule = await import(t.modulePath);
if (mod.preMigration) {
preResults.set(t.fileName, await mod.preMigration(sql));
}
}
// Apply this migration
await applySingleMigration(mwt.migrationIndex);
}, 600_000);
for (const t of mwt.tests) {
test(t.fileName.replace(/\.[tj]s$/, ''), async () => {
const mod: MigrationTestModule = await import(t.modulePath);
if (mod.postMigration) {
await mod.postMigration(sql, preResults.get(t.fileName));
}
});
}
});
}
});

View File

@ -31,6 +31,7 @@
"start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-0} pnpm run start-deps:no-delay",
"restart-deps": "pnpm pre && pnpm run stop-deps && pnpm run start-deps",
"restart-deps:no-delay": "pnpm pre && pnpm run stop-deps && pnpm run start-deps:no-delay",
"restart-deps:with-tests": "pnpm run restart-deps && pnpm test run auto-migrations/migration-tests",
"psql": "pnpm pre && pnpm run --filter=@stackframe/stack-backend psql",
"clickhouse": "pnpm pre && pnpm run --filter=@stackframe/stack-backend clickhouse",
"explain-query": "pnpm pre && echo 'Paste your query (end with Ctrl-D):' && query=$(cat) && echo 'Connecting to Postgres...' && printf \"EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)\n$query\" | pnpm run --silent psql -qAt | sed -n '/\\[/,$p' > explained-query.untracked.json && echo 'Explained query saved to explained-query.untracked.json. To analyze it, open it in the query analyzer at https://tatiyants.com/pev/#/plans/new'",