diff --git a/AGENTS.md b/AGENTS.md index f93b60dfb..e43ddf587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` ### 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`. diff --git a/apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/tests/default-values.ts b/apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/tests/default-values.ts new file mode 100644 index 000000000..8c9875ae2 --- /dev/null +++ b/apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/tests/default-values.ts @@ -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>) => { + 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(); + } +}; diff --git a/apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/tests/constraint-enforcement.ts b/apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/tests/constraint-enforcement.ts new file mode 100644 index 000000000..7ad091bd8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/tests/constraint-enforcement.ts @@ -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>) => { + // 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`; +}; diff --git a/apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/tests/partial-unique-enforcement.ts b/apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/tests/partial-unique-enforcement.ts new file mode 100644 index 000000000..bc46511e6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/tests/partial-unique-enforcement.ts @@ -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>) => { + // 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); +}; diff --git a/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/already-correct.ts b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/already-correct.ts new file mode 100644 index 000000000..976b02448 --- /dev/null +++ b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/already-correct.ts @@ -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>) => { + 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'); +}; diff --git a/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/fix-missing-parent-keys.ts b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/fix-missing-parent-keys.ts new file mode 100644 index 000000000..3bbdceb2a --- /dev/null +++ b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/fix-missing-parent-keys.ts @@ -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>) => { + 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'); +}; diff --git a/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/mixed-entries.ts b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/mixed-entries.ts new file mode 100644 index 000000000..b805bb07c --- /dev/null +++ b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/mixed-entries.ts @@ -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>) => { + // 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'); +}; diff --git a/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/multiple-children.ts b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/multiple-children.ts new file mode 100644 index 000000000..da8e6dd91 --- /dev/null +++ b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/multiple-children.ts @@ -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>) => { + 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'); +}; diff --git a/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/no-trusted-domains.ts b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/no-trusted-domains.ts new file mode 100644 index 000000000..979adc16c --- /dev/null +++ b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/no-trusted-domains.ts @@ -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>) => { + const [row] = await sql`SELECT "config" FROM "EnvironmentConfigOverride" WHERE "projectId" = ${ctx.projectId}`; + expect(row.config).toEqual(ctx.originalConfig); +}; diff --git a/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/temp-cleanup.ts b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/temp-cleanup.ts new file mode 100644 index 000000000..4e96764ed --- /dev/null +++ b/apps/backend/prisma/migrations/20260214000000_fix_trusted_domains_config/tests/temp-cleanup.ts @@ -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); +}; diff --git a/apps/backend/src/auto-migrations/migration-tests.test.ts b/apps/backend/src/auto-migrations/migration-tests.test.ts new file mode 100644 index 000000000..f572372d2 --- /dev/null +++ b/apps/backend/src/auto-migrations/migration-tests.test.ts @@ -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, + postMigration?: (sql: postgres.Sql, ctx: unknown) => Promise, +}; + +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(); + + 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)); + } + }); + } + }); + } +}); diff --git a/package.json b/package.json index 6ed82b4f4..28e812855 100644 --- a/package.json +++ b/package.json @@ -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'",