diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.live.test.ts b/apps/cli/src/legacy/commands/migration/repair/repair.live.test.ts new file mode 100644 index 0000000000..a069a882a5 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/repair/repair.live.test.ts @@ -0,0 +1,91 @@ +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { + liveMigrationVersion, + queryLiveDb, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; + +test("amends the migration history status on the remote database", async ({ + cli, + project, + workspace, +}) => { + const version = liveMigrationVersion(); + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const migrationFile = join(migrations, `${version}_e2e_repair.sql`); + // `repair --status applied` records the file's statements in migration + // history without executing them, so this table is never actually created. + await writeFile(migrationFile, `create table if not exists e2e_repair_${version} (id int);\n`); + + let targetError: unknown; + let versionReverted = false; + const cleanupErrors: Array = []; + try { + const applied = await cli([ + "migration", + "repair", + version, + "--status", + "applied", + "--db-url", + project.dbUrl, + ]); + expect(applied.exitCode, applied.stderr).toBe(0); + expect(applied.stderr, applied.stdout).toContain("=> applied"); + await unlink(migrationFile); + + const recorded = await queryLiveDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations where version = $1", + [version], + ); + expect(recorded).toHaveLength(1); + + const reverted = await cli([ + "migration", + "repair", + version, + "--status", + "reverted", + "--db-url", + project.dbUrl, + ]); + expect(reverted.exitCode, reverted.stderr).toBe(0); + expect(reverted.stderr, reverted.stdout).toContain("=> reverted"); + + const remaining = await queryLiveDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations where version = $1", + [version], + ); + expect(remaining).toHaveLength(0); + // Only skip the teardown revert once the row is verifiably gone. + versionReverted = true; + } catch (error) { + targetError = error; + } finally { + if (!versionReverted) { + try { + const cleanup = await cli([ + "migration", + "repair", + version, + "--status", + "reverted", + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(cleanup, "migration repair cleanup"); + } catch (error) { + cleanupErrors.push(error); + } + } + } + throwWithCleanup(targetError, cleanupErrors); +}); diff --git a/apps/cli/src/legacy/commands/migration/up/up.live.test.ts b/apps/cli/src/legacy/commands/migration/up/up.live.test.ts new file mode 100644 index 0000000000..9abb5790a3 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/up/up.live.test.ts @@ -0,0 +1,102 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { + liveMigrationVersion, + queryLiveDb, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; + +test("applies a test-written migration to the remote database", async ({ + cli, + project, + workspace, +}) => { + const version = liveMigrationVersion(); + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + + // The serial suite shares one remote project, so seed a local stub for every + // version already in remote history — otherwise `migration up` rejects them + // as missing locally. The history table may not exist yet on a fresh project. + let remoteVersions: Array<{ version: string }> = []; + try { + remoteVersions = await queryLiveDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations order by version", + ); + } catch (error) { + // 42P01 (undefined relation) covers the fresh-project case where the + // history table or its schema does not exist yet; anything else is a real + // failure the test must surface. + if ((error as { code?: string }).code !== "42P01") throw error; + remoteVersions = []; + } + for (const row of remoteVersions) { + await writeFile( + join(migrations, `${row.version}_preexisting_remote.sql`), + "-- stub for a version already in remote history\n", + ); + } + + const migrationFile = join(migrations, `${version}_e2e_up.sql`); + await writeFile(migrationFile, `create table if not exists e2e_up_${version} (id int);\n`); + + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const applied = await cli(["migration", "up", "--db-url", project.dbUrl]); + expect(applied.exitCode, applied.stderr).toBe(0); + expect(applied.stderr, applied.stdout).toContain("Applying migration"); + + const history = await queryLiveDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations where version = $1", + [version], + ); + expect(history).toHaveLength(1); + + const created = await queryLiveDb(project.dbUrl, "select to_regclass($1) as table_oid", [ + `public.e2e_up_${version}`, + ]); + expect(created[0]?.["table_oid"], "migration up must execute the migration sql").not.toBeNull(); + } catch (error) { + targetError = error; + } finally { + try { + await rm(migrationFile, { force: true }); + } catch (error) { + cleanupErrors.push(error); + } + try { + const dropped = await cli([ + "db", + "query", + `drop table if exists e2e_up_${version}`, + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(dropped, "db query cleanup after migration up"); + } catch (error) { + cleanupErrors.push(error); + } + try { + const reverted = await cli([ + "migration", + "repair", + version, + "--status", + "reverted", + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(reverted, "migration repair cleanup after migration up"); + } catch (error) { + cleanupErrors.push(error); + } + } + throwWithCleanup(targetError, cleanupErrors); +}); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index d17ea1bbb1..a1a7e99b94 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import pg from "pg"; import { inject, test as vitestTest } from "vitest"; import { makeTempHome, runSupabase } from "./cli.ts"; @@ -144,6 +145,41 @@ export async function removeStorageLiveObject( } } +/** + * Unique migration version for a live test: a sortable `YYYYMMDDHHMMSS` UTC + * stamp plus four random digits, so it always orders after any conventional + * timestamp version already in the shared project's migration history. + */ +export function liveMigrationVersion(): string { + const stamp = new Date() + .toISOString() + .replaceAll(/[-:TZ.]/gu, "") + .slice(0, 14); + return `${stamp}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; +} + +/** + * Runs one query against the live project over a direct pg connection, so + * live assertions can verify database state without invoking another CLI + * command. + */ +export async function queryLiveDb>( + dbUrl: string, + query: string, + values?: ReadonlyArray, +): Promise { + const client = new pg.Client({ connectionString: dbUrl }); + await client.connect(); + try { + const result = await client.query(query, values === undefined ? undefined : [...values]); + return result.rows as T[]; + } finally { + await client.end(); + } +} + /** Rethrow a target failure without discarding failures from exact cleanup. */ export function throwWithCleanup(primary: unknown, cleanup: ReadonlyArray): void { if (primary !== undefined) {