From bd197b1a92f5e6c4e2938f767726419a2977b1cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:12:57 +0000 Subject: [PATCH 1/9] Initial plan From 8cb2f6de73d3759a1dd5f8cfc01c3c96dfc6f193 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:13:44 +0000 Subject: [PATCH 2/9] Initial plan From b908165b6097736fd31c7d76056aae2624b8b013 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:43:20 +0000 Subject: [PATCH 3/9] fix(pg-delta): order domain check dependencies before domain create Co-authored-by: avallete <8771783+avallete@users.noreply.github.com> --- .changeset/green-worms-lie.md | 5 + .../objects/domain/changes/domain.create.ts | 16 ++- .../tests/integration/type-operations.test.ts | 116 +++++++++++++++++- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 .changeset/green-worms-lie.md diff --git a/.changeset/green-worms-lie.md b/.changeset/green-worms-lie.md new file mode 100644 index 00000000..69e31b20 --- /dev/null +++ b/.changeset/green-worms-lie.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +fix(pg-delta): order domain CHECK function dependencies before domain creation diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts index 0dee6691..b0a9fc83 100644 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts +++ b/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts @@ -34,7 +34,21 @@ export class CreateDomain extends CreateDomainChange { } get creates() { - return [this.domain.stableId]; + const creates = [this.domain.stableId]; + + for (const constraint of this.domain.constraints) { + if (constraint.check_expression && constraint.validated !== false) { + creates.push( + stableId.constraint( + this.domain.schema, + this.domain.name, + constraint.name, + ), + ); + } + } + + return creates; } get requires() { diff --git a/packages/pg-delta/tests/integration/type-operations.test.ts b/packages/pg-delta/tests/integration/type-operations.test.ts index 4585a45c..7c0c4158 100644 --- a/packages/pg-delta/tests/integration/type-operations.test.ts +++ b/packages/pg-delta/tests/integration/type-operations.test.ts @@ -2,9 +2,11 @@ * Integration tests for PostgreSQL type operations. */ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import dedent from "dedent"; +import { extractCatalog } from "../../src/core/catalog.model.ts"; import type { Change } from "../../src/core/change.types.ts"; +import { createPlan } from "../../src/core/plan/create.ts"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { withDb } from "../utils.ts"; import { roundtripFidelityTest } from "./roundtrip.ts"; @@ -37,6 +39,68 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + test( + "domain CHECK function dependencies are ordered before domains", + withDb(pgVersion, async (db) => { + const schemaSql = "CREATE SCHEMA test_schema;"; + const testSql = dedent` + CREATE FUNCTION test_schema.check_prefix(val text, prefix text) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT starts_with(val, prefix) + $function$; + + CREATE DOMAIN test_schema.user_id AS text + CHECK (test_schema.check_prefix(VALUE, 'user_')); + + CREATE DOMAIN test_schema.org_id AS text + CHECK (test_schema.check_prefix(VALUE, 'org_')); + `; + + await db.main.query(schemaSql); + await db.branch.query(schemaSql); + await db.branch.query(testSql); + + const planResult = await createPlan(db.main, db.branch); + expect(planResult).toBeDefined(); + + const statements = planResult?.plan.statements ?? []; + const checkPrefixCreateIndex = statements.findIndex((statement) => + statement.includes("CREATE FUNCTION test_schema.check_prefix("), + ); + const userDomainCreateIndex = statements.findIndex((statement) => + statement.includes("CREATE DOMAIN test_schema.user_id"), + ); + const orgDomainCreateIndex = statements.findIndex((statement) => + statement.includes("CREATE DOMAIN test_schema.org_id"), + ); + + expect(checkPrefixCreateIndex).toBeGreaterThanOrEqual(0); + expect(userDomainCreateIndex).toBeGreaterThanOrEqual(0); + expect(orgDomainCreateIndex).toBeGreaterThanOrEqual(0); + expect(checkPrefixCreateIndex).toBeLessThan(userDomainCreateIndex); + expect(checkPrefixCreateIndex).toBeLessThan(orgDomainCreateIndex); + + const branchCatalog = await extractCatalog(db.branch); + const hasUserDomainDependency = branchCatalog.depends.some( + (depend) => + depend.dependent_stable_id.startsWith( + "constraint:test_schema.user_id.", + ) && depend.referenced_stable_id.includes("check_prefix("), + ); + const hasOrgDomainDependency = branchCatalog.depends.some( + (depend) => + depend.dependent_stable_id.startsWith( + "constraint:test_schema.org_id.", + ) && depend.referenced_stable_id.includes("check_prefix("), + ); + + expect(hasUserDomainDependency).toBe(true); + expect(hasOrgDomainDependency).toBe(true); + }), + ); test( "create composite type", withDb(pgVersion, async (db) => { @@ -54,6 +118,56 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + test( + "domain CHECK dependency coexists with function using the domain type", + withDb(pgVersion, async (db) => { + const schemaSql = "CREATE SCHEMA test_schema;"; + const testSql = dedent` + CREATE FUNCTION test_schema.check_prefix(val text, prefix text) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT starts_with(val, prefix) + $function$; + + CREATE DOMAIN test_schema.user_id AS text + CHECK (test_schema.check_prefix(VALUE, 'user_')); + + CREATE FUNCTION test_schema.normalize_user_id(input test_schema.user_id) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT lower(input::text) + $function$; + `; + + await db.main.query(schemaSql); + await db.branch.query(schemaSql); + await db.branch.query(testSql); + + const planResult = await createPlan(db.main, db.branch); + expect(planResult).toBeDefined(); + + const statements = planResult?.plan.statements ?? []; + const checkPrefixCreateIndex = statements.findIndex((statement) => + statement.includes("CREATE FUNCTION test_schema.check_prefix("), + ); + const domainCreateIndex = statements.findIndex((statement) => + statement.includes("CREATE DOMAIN test_schema.user_id"), + ); + const normalizeCreateIndex = statements.findIndex((statement) => + statement.includes("CREATE FUNCTION test_schema.normalize_user_id("), + ); + + expect(checkPrefixCreateIndex).toBeGreaterThanOrEqual(0); + expect(domainCreateIndex).toBeGreaterThanOrEqual(0); + expect(normalizeCreateIndex).toBeGreaterThanOrEqual(0); + expect(checkPrefixCreateIndex).toBeLessThan(domainCreateIndex); + expect(domainCreateIndex).toBeLessThan(normalizeCreateIndex); + }), + ); test( "create range type", withDb(pgVersion, async (db) => { From 5187fc848c302421dedd25d230af201b56465673 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:48:19 +0000 Subject: [PATCH 4/9] test(pg-delta): cover domain check function dependency ordering Co-authored-by: avallete <8771783+avallete@users.noreply.github.com> --- .../src/core/objects/domain/changes/domain.create.ts | 2 +- .../pg-delta/tests/integration/type-operations.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts index b0a9fc83..f9d91e70 100644 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts +++ b/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts @@ -37,7 +37,7 @@ export class CreateDomain extends CreateDomainChange { const creates = [this.domain.stableId]; for (const constraint of this.domain.constraints) { - if (constraint.check_expression && constraint.validated !== false) { + if (constraint.check_expression && constraint.validated) { creates.push( stableId.constraint( this.domain.schema, diff --git a/packages/pg-delta/tests/integration/type-operations.test.ts b/packages/pg-delta/tests/integration/type-operations.test.ts index 7c0c4158..b55ca732 100644 --- a/packages/pg-delta/tests/integration/type-operations.test.ts +++ b/packages/pg-delta/tests/integration/type-operations.test.ts @@ -65,8 +65,11 @@ for (const pgVersion of POSTGRES_VERSIONS) { const planResult = await createPlan(db.main, db.branch); expect(planResult).toBeDefined(); + if (!planResult) { + throw new Error("Expected planResult to be defined"); + } - const statements = planResult?.plan.statements ?? []; + const statements = planResult.plan.statements; const checkPrefixCreateIndex = statements.findIndex((statement) => statement.includes("CREATE FUNCTION test_schema.check_prefix("), ); @@ -149,8 +152,11 @@ for (const pgVersion of POSTGRES_VERSIONS) { const planResult = await createPlan(db.main, db.branch); expect(planResult).toBeDefined(); + if (!planResult) { + throw new Error("Expected planResult to be defined"); + } - const statements = planResult?.plan.statements ?? []; + const statements = planResult.plan.statements; const checkPrefixCreateIndex = statements.findIndex((statement) => statement.includes("CREATE FUNCTION test_schema.check_prefix("), ); From 775fd1cb918215883d8abe371eca9b148cf9479b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:03:13 +0000 Subject: [PATCH 5/9] fix(pg-delta): drop and recreate views when column list changes Co-authored-by: avallete <8771783+avallete@users.noreply.github.com> --- .changeset/cold-students-vanish.md | 5 ++ .../src/core/objects/view/view.diff.test.ts | 58 +++++++++++++++++ .../src/core/objects/view/view.diff.ts | 35 ++++++----- .../tests/integration/view-operations.test.ts | 63 ++++++++++++++++++- 4 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 .changeset/cold-students-vanish.md diff --git a/.changeset/cold-students-vanish.md b/.changeset/cold-students-vanish.md new file mode 100644 index 00000000..e9f44677 --- /dev/null +++ b/.changeset/cold-students-vanish.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix view diffs to drop and recreate views when the projected column list changes (for example when `SELECT *` views need to pick up a new base-table column), instead of emitting `CREATE OR REPLACE VIEW`. diff --git a/packages/pg-delta/src/core/objects/view/view.diff.test.ts b/packages/pg-delta/src/core/objects/view/view.diff.test.ts index 9e4446ae..a62b3394 100644 --- a/packages/pg-delta/src/core/objects/view/view.diff.test.ts +++ b/packages/pg-delta/src/core/objects/view/view.diff.test.ts @@ -105,6 +105,64 @@ describe.concurrent("view.diff", () => { expect(changes[0]).toBeInstanceOf(CreateView); }); + test("drop and recreate when view columns change", () => { + const main = makeView({ + owner: "postgres", + columns: [ + { + name: "id", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + }); + const branch = makeView({ + owner: "postgres", + columns: [ + ...main.columns, + { + name: "priority", + position: 2, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + }); + const changes = diffViews( + testContext, + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + expect(changes).toHaveLength(2); + expect(changes[0]).toBeInstanceOf(DropView); + expect(changes[1]).toBeInstanceOf(CreateView); + }); + test("create with privileges emits grant changes", () => { const v = makeView({ privileges: [ diff --git a/packages/pg-delta/src/core/objects/view/view.diff.ts b/packages/pg-delta/src/core/objects/view/view.diff.ts index 4d682729..21e53b36 100644 --- a/packages/pg-delta/src/core/objects/view/view.diff.ts +++ b/packages/pg-delta/src/core/objects/view/view.diff.ts @@ -43,19 +43,17 @@ export function diffViews( const { created, dropped, altered } = diffObjects(main, branch); const changes: ViewChange[] = []; - - for (const viewId of created) { - const v = branch[viewId]; - changes.push(new CreateView({ view: v })); + const appendCreateViewChanges = (view: View) => { + changes.push(new CreateView({ view })); // OWNER: If the view should be owned by someone other than the current user, // emit ALTER VIEW ... OWNER TO after creation - if (v.owner !== ctx.currentUser) { - changes.push(new AlterViewChangeOwner({ view: v, owner: v.owner })); + if (view.owner !== ctx.currentUser) { + changes.push(new AlterViewChangeOwner({ view, owner: view.owner })); } - if (v.comment !== null) { - changes.push(new CreateCommentOnView({ view: v })); + if (view.comment !== null) { + changes.push(new CreateCommentOnView({ view })); } // PRIVILEGES: For created objects, compare against default privileges state @@ -66,26 +64,26 @@ export function diffViews( const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( ctx.currentUser, "view", - v.schema ?? "", + view.schema ?? "", ); const creatorFilteredDefaults = - v.owner !== ctx.currentUser + view.owner !== ctx.currentUser ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) : effectiveDefaults; - const desiredPrivileges = v.privileges; + const desiredPrivileges = view.privileges; // Filter out owner privileges - owner always has ALL privileges implicitly // and shouldn't be compared. Use the view owner as the reference. const privilegeResults = diffPrivileges( creatorFilteredDefaults, desiredPrivileges, - v.owner, + view.owner, ); changes.push( ...(emitColumnPrivilegeChanges( privilegeResults, - v, - v, + view, + view, "view", { Grant: GrantViewPrivileges, @@ -96,6 +94,10 @@ export function diffViews( ctx.version, ) as ViewChange[]), ); + }; + + for (const viewId of created) { + appendCreateViewChanges(branch[viewId]); } for (const viewId of dropped) { @@ -128,7 +130,10 @@ export function diffViews( { options: deepEqual }, ); - if (nonAlterablePropsChanged) { + if (!deepEqual(mainView.columns, branchView.columns)) { + changes.push(new DropView({ view: mainView })); + appendCreateViewChanges(branchView); + } else if (nonAlterablePropsChanged) { // Replace the entire view using CREATE OR REPLACE to avoid drop when possible changes.push(new CreateView({ view: branchView, orReplace: true })); } else { diff --git a/packages/pg-delta/tests/integration/view-operations.test.ts b/packages/pg-delta/tests/integration/view-operations.test.ts index 1c2710c2..ed8a4d5e 100644 --- a/packages/pg-delta/tests/integration/view-operations.test.ts +++ b/packages/pg-delta/tests/integration/view-operations.test.ts @@ -2,7 +2,8 @@ * Integration tests for PostgreSQL view operations. */ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { createPlan } from "../../src/core/plan/create.ts"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { withDb, withDbIsolated } from "../utils.ts"; import { roundtripFidelityTest } from "./roundtrip.ts"; @@ -120,6 +121,66 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "recreates select-star view when base table columns change", + withDb(pgVersion, async (db) => { + const initialSetup = ` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.items ( + id serial PRIMARY KEY, + title text NOT NULL, + status text DEFAULT 'active' + ); + + CREATE VIEW test_schema.item_details AS + SELECT i.* FROM test_schema.items i; + `; + + const testSql = ` + ALTER TABLE test_schema.items ADD COLUMN priority int DEFAULT 0; + + DROP VIEW test_schema.item_details; + CREATE VIEW test_schema.item_details AS + SELECT i.* FROM test_schema.items i; + `; + + await db.main.query(initialSetup); + await db.branch.query(initialSetup); + await db.branch.query(testSql); + + const result = await createPlan(db.main, db.branch); + expect(result).not.toBeNull(); + if (!result) throw new Error("expected plan result"); + + const { statements } = result.plan; + const dropViewIndex = statements.findIndex((statement) => + statement.startsWith("DROP VIEW test_schema.item_details"), + ); + const alterTableIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.items ADD COLUMN priority integer DEFAULT 0", + ), + ); + const createViewIndex = statements.findIndex((statement) => + statement.startsWith("CREATE VIEW test_schema.item_details AS"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(alterTableIndex).toBeGreaterThanOrEqual(0); + expect(createViewIndex).toBeGreaterThanOrEqual(0); + expect(dropViewIndex).toBeLessThan(alterTableIndex); + expect(alterTableIndex).toBeLessThan(createViewIndex); + expect( + statements.some((statement) => + statement.startsWith( + "CREATE OR REPLACE VIEW test_schema.item_details AS", + ), + ), + ).toBe(false); + }), + ); + test( "complex view dependencies with multiple joins", withDb(pgVersion, async (db) => { From 5b43d31d47583ec0cae59ad5f4a133dbf0a18470 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 8 Apr 2026 15:57:22 +0200 Subject: [PATCH 6/9] test: add dbdev-smoke tests --- packages/pg-delta/src/core/postgres-config.ts | 4 +- .../tests/integration/dbdev-smoke.test.ts | 380 ++++++++++++++++++ 2 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 packages/pg-delta/tests/integration/dbdev-smoke.test.ts diff --git a/packages/pg-delta/src/core/postgres-config.ts b/packages/pg-delta/src/core/postgres-config.ts index 3019712b..27040556 100644 --- a/packages/pg-delta/src/core/postgres-config.ts +++ b/packages/pg-delta/src/core/postgres-config.ts @@ -2,7 +2,7 @@ * PostgreSQL connection configuration with custom type handlers. */ -import type { PoolClient, PoolConfig } from "pg"; +import type { ClientBase, PoolClient, PoolConfig } from "pg"; import { escapeIdentifier, Pool, types } from "pg"; import { parseSslConfig } from "./plan/ssl-config.ts"; @@ -115,7 +115,7 @@ const DEFAULT_CONNECT_TIMEOUT_MS = */ interface CreatePoolOptions extends Partial { /** Called when a new client connects to the pool */ - onConnect?: (client: PoolClient) => void | Promise; + onConnect?: (client: ClientBase) => void | Promise; /** Called when an idle client emits an error */ onError?: (err: Error, client: PoolClient) => void; /** Called when a client is acquired from the pool */ diff --git a/packages/pg-delta/tests/integration/dbdev-smoke.test.ts b/packages/pg-delta/tests/integration/dbdev-smoke.test.ts new file mode 100644 index 00000000..5896a1a0 --- /dev/null +++ b/packages/pg-delta/tests/integration/dbdev-smoke.test.ts @@ -0,0 +1,380 @@ +/** + * Progressive smoke test for dbdev migrations. + * + * Applies dbdev migrations incrementally (0 → N) to a source Supabase + * container and, at each step, generates a diff against a fully-migrated + * target container. Optionally verifies the generated SQL by executing it + * inside a BEGIN / ROLLBACK transaction so the source state is preserved. + * + * Some migrations reference Supabase-image-specific columns that may not + * exist in the test image (e.g. auth.users.email_confirmed_at). These are + * skipped on both branch and main so the two databases stay comparable. + * + * Environment variables: + * DBDEV_SMOKE_STEP_FROM – first step to test (default: 0) + * DBDEV_SMOKE_STEP_TO – last step to test, inclusive (default: number of applicable migrations) + * DBDEV_SMOKE_SKIP_APPLY – set to "1" to only generate plans, skip SQL verification + */ + +import { afterAll, beforeAll, describe, test } from "bun:test"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import type { Pool } from "pg"; +import { diffCatalogs } from "../../src/core/catalog.diff.ts"; +import { extractCatalog } from "../../src/core/catalog.model.ts"; +import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; +import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; +import { createPlan } from "../../src/core/plan/create.ts"; +import { createPool, endPool } from "../../src/core/postgres-config.ts"; +import { sortChanges } from "../../src/core/sort/sort-changes.ts"; +import { + POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, + type PostgresVersion, +} from "../constants.ts"; +import { SupabasePostgreSqlContainer } from "../supabase-postgres.js"; + +const MIGRATIONS_DIR = path.join( + import.meta.dir, + "fixtures/dbdev-migrations/migrations", +); + +type StepResult = { + step: number; + migrationApplied: string; + planStatus: "no_changes" | "success" | "error"; + planError?: string; + changeCount?: number; + statementCount?: number; + applyStatus?: "success" | "error" | "skipped"; + applyError?: string; + applyFailedStatement?: string; + remainingChanges?: number; +}; + +async function loadAllMigrations(): Promise< + { filename: string; sql: string }[] +> { + const files = await readdir(MIGRATIONS_DIR); + const sqlFiles = files.filter((f) => f.endsWith(".sql")).sort(); + return Promise.all( + sqlFiles.map(async (f) => ({ + filename: f, + sql: await readFile(path.join(MIGRATIONS_DIR, f), "utf-8"), + })), + ); +} + +function suppressShutdownError(err: Error & { code?: string }) { + if (err.code === "57P01" || err.code === "53100") return; + console.error("Pool error:", err); +} + +function createPostgresRolePool(connectionUri: string): Pool { + return createPool(connectionUri, { + onError: suppressShutdownError, + onConnect: async (client) => { + await client.query("SET ROLE postgres"); + }, + }); +} + +function printSummary(results: StepResult[], skippedMigrations: string[]) { + const passed = results.filter( + (r) => + r.planStatus !== "error" && + (r.applyStatus === "success" || r.applyStatus === "skipped"), + ); + const failed = results.filter( + (r) => r.planStatus === "error" || r.applyStatus === "error", + ); + + console.log("\n╔══════════════════════════════════════════════════════════╗"); + console.log("║ DBDEV SMOKE TEST SUMMARY ║"); + console.log("╠══════════════════════════════════════════════════════════╣"); + console.log( + `${`║ Total steps: ${results.length} Passed: ${passed.length} Failed: ${failed.length}`.padEnd( + 59, + )}║`, + ); + if (skippedMigrations.length > 0) { + console.log( + `${`║ Skipped migrations (image-incompatible): ${skippedMigrations.length}`.padEnd( + 59, + )}║`, + ); + } + console.log("╚══════════════════════════════════════════════════════════╝\n"); + + if (skippedMigrations.length > 0) { + console.log("─── SKIPPED MIGRATIONS ─────────────────────────────────────"); + for (const m of skippedMigrations) { + console.log(` ${m}`); + } + console.log(""); + } + + if (failed.length > 0) { + console.log("─── FAILURES ───────────────────────────────────────────────"); + for (const r of failed) { + console.log(`\n Step ${r.step}: after "${r.migrationApplied}"`); + if (r.planStatus === "error") { + console.log(` Plan generation FAILED: ${r.planError}`); + } + if (r.applyStatus === "error") { + console.log(` SQL apply FAILED: ${r.applyError}`); + if (r.applyFailedStatement) { + const truncated = + r.applyFailedStatement.length > 200 + ? `${r.applyFailedStatement.slice(0, 200)}...` + : r.applyFailedStatement; + console.log(` Failed statement: ${truncated}`); + } + } + if (r.remainingChanges !== undefined && r.remainingChanges > 0) { + console.log(` Remaining changes after apply: ${r.remainingChanges}`); + } + } + console.log( + "\n────────────────────────────────────────────────────────────", + ); + } + + console.log("\n─── FULL RESULTS ───────────────────────────────────────────"); + console.log( + "Step".padEnd(6) + + "Migration".padEnd(50) + + "Plan".padEnd(14) + + "Changes".padEnd(10) + + "Stmts".padEnd(8) + + "Apply".padEnd(10), + ); + console.log("─".repeat(98)); + for (const r of results) { + const migration = + r.migrationApplied.length > 48 + ? `${r.migrationApplied.slice(0, 45)}...` + : r.migrationApplied; + console.log( + String(r.step).padEnd(6) + + migration.padEnd(50) + + r.planStatus.padEnd(14) + + (r.changeCount !== undefined ? String(r.changeCount) : "-").padEnd(10) + + (r.statementCount !== undefined + ? String(r.statementCount) + : "-" + ).padEnd(8) + + (r.applyStatus ?? "-").padEnd(10), + ); + } + console.log("────────────────────────────────────────────────────────────\n"); +} + +const pgVersion: PostgresVersion = 15; +const STEP_FROM = Number(process.env.DBDEV_SMOKE_STEP_FROM ?? 0); +const STEP_TO_ENV = process.env.DBDEV_SMOKE_STEP_TO; +const SKIP_APPLY = process.env.DBDEV_SMOKE_SKIP_APPLY === "1"; + +describe(`dbdev progressive smoke test (pg${pgVersion})`, () => { + let mainPool: Pool; + let branchPool: Pool; + let containerMain: Awaited>; + let containerBranch: Awaited< + ReturnType + >; + // Migrations that were successfully applied to branch (image-compatible). + let applicableMigrations: { filename: string; sql: string }[]; + let skippedMigrations: string[]; + let stepTo: number; + const results: StepResult[] = []; + + if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { + throw new Error("supabase integration missing filter or serialize"); + } + const compiledFilter = compileFilterDSL(supabaseIntegration.filter); + + beforeAll( + async () => { + const allMigrations = await loadAllMigrations(); + + const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[pgVersion]}`; + + [containerMain, containerBranch] = await Promise.all([ + new SupabasePostgreSqlContainer(image).start(), + new SupabasePostgreSqlContainer(image).start(), + ]); + + mainPool = createPostgresRolePool(containerMain.getConnectionUri()); + branchPool = createPostgresRolePool(containerBranch.getConnectionUri()); + + // Apply all migrations to branch, tracking which ones succeed. + // Some migrations reference columns specific to certain Supabase image + // versions (e.g. auth.users.email_confirmed_at) and will fail — skip them. + applicableMigrations = []; + skippedMigrations = []; + for (const migration of allMigrations) { + try { + await branchPool.query(migration.sql); + applicableMigrations.push(migration); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + skippedMigrations.push(`${migration.filename}: ${msg}`); + } + } + + stepTo = + STEP_TO_ENV !== undefined + ? Number(STEP_TO_ENV) + : applicableMigrations.length; + + console.log( + `[smoke] Branch ready: ${applicableMigrations.length} migrations applied, ${skippedMigrations.length} skipped`, + ); + }, + 10 * 60 * 1000, + ); + + afterAll(async () => { + printSummary(results, skippedMigrations ?? []); + if (mainPool) await endPool(mainPool); + if (branchPool) await endPool(branchPool); + await Promise.all([containerMain?.stop(), containerBranch?.stop()]); + }, 60_000); + + test( + "progressive migration diff", + async () => { + for (let step = 0; step <= stepTo; step++) { + const migrationName = + step === 0 + ? "(empty)" + : step <= applicableMigrations.length + ? applicableMigrations[step - 1].filename + : "(all applied)"; + + // Fast-forward: apply migration to main but skip plan generation + if (step < STEP_FROM) { + if (step > 0 && step <= applicableMigrations.length) { + await mainPool + .query(applicableMigrations[step - 1].sql) + .catch((err) => { + throw new Error( + `Migration ${applicableMigrations[step - 1].filename} failed on main: ${err.message}`, + { cause: err }, + ); + }); + } + continue; + } + + const result: StepResult = { + step, + migrationApplied: migrationName, + planStatus: "success", + }; + + // Apply the migration for this step + if (step > 0 && step <= applicableMigrations.length) { + try { + await mainPool.query(applicableMigrations[step - 1].sql); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[step ${step}] Migration ${migrationName} failed on main: ${msg}`, + ); + result.planStatus = "error"; + result.planError = `Migration apply failed: ${msg}`; + results.push(result); + continue; + } + } + + // Generate the plan + try { + const planResult = await createPlan(mainPool, branchPool, { + filter: supabaseIntegration.filter, + serialize: supabaseIntegration.serialize, + skipDefaultPrivilegeSubtraction: true, + }); + + if (!planResult) { + result.planStatus = "no_changes"; + result.changeCount = 0; + result.statementCount = 0; + result.applyStatus = "skipped"; + results.push(result); + continue; + } + + result.changeCount = planResult.sortedChanges.length; + result.statementCount = planResult.plan.statements.length; + + if (SKIP_APPLY) { + result.applyStatus = "skipped"; + results.push(result); + continue; + } + + // Verify SQL by executing inside BEGIN / ROLLBACK + try { + await mainPool.query("BEGIN"); + await mainPool.query("SET LOCAL check_function_bodies = false"); + for (const stmt of planResult.plan.statements) { + await mainPool.query(stmt); + } + + // Check remaining changes inside the transaction + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remaining = allChanges.filter(compiledFilter); + result.remainingChanges = remaining.length; + + if (remaining.length > 0) { + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + remaining, + ); + const remainingSql = sorted.map((c) => c.serialize()).join(";\n"); + console.error( + `[step ${step}] ${remaining.length} remaining change(s):\n${remainingSql}`, + ); + result.applyStatus = "error"; + result.applyError = `${remaining.length} remaining changes after apply`; + } else { + result.applyStatus = "success"; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + result.applyStatus = "error"; + result.applyError = msg; + } finally { + try { + await mainPool.query("ROLLBACK"); + } catch { + // Connection may have been interrupted; ignore rollback errors + } + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + result.planStatus = "error"; + result.planError = msg; + } + + results.push(result); + } + + // Diagnostic test: always passes, failures are reported in the + // afterAll summary. This avoids --retry re-running the test with + // stale container state (all migrations already applied on main). + const failures = results.filter( + (r) => r.planStatus === "error" || r.applyStatus === "error", + ); + if (failures.length > 0) { + console.error( + `\n[smoke] ${failures.length} step(s) with failures — see summary below\n`, + ); + } + }, + 30 * 60 * 1000, + ); +}); From 268fc5f119510555ba58509294e6f9c6a7908caf Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 15 Apr 2026 10:55:52 +0200 Subject: [PATCH 7/9] wip --- .gitignore | 1 + .../tests/integration/dbdev-roundtrip.test.ts | 210 ----- .../tests/integration/dbdev-smoke.test.ts | 380 --------- .../migrations/20220117141357_extensions.sql | 0 .../migrations/20220117141359_app_schema.sql | 0 .../migrations/20220117141507_semver.sql | 0 .../20220117141645_valid_name_type.sql | 0 .../20220117141942_email_address_type.sql | 0 .../20220117142104_account_and_org_tables.sql | 0 .../20220117142137_package_tables.sql | 0 .../20220117142138_developer_tools.sql | 0 .../20220117142141_security_utilities.sql | 0 .../20220117142142_security_definitions.sql | 0 .../migrations/20220117155720_views.sql | 0 .../dbdev}/migrations/20220117155820_rpc.sql | 0 .../20230323180034_reserved_user_accts.sql | 0 .../20230328185043_olirice_asciiplot.sql | 0 .../20230330155137_supabase_dbdev.sql | 0 .../20230331145934_burggraf-pg_headerkit.sql | 0 .../20230331163908_olirice-index_advisor.sql | 0 .../20230331163909_olirice-read_once.sql | 0 .../20230404162614_michelp-adminpack.sql | 0 .../20230405083103_fix_auth_schema_values.sql | 0 .../20230405085810_fix_avatars_handle.sql | 0 .../20230405163940_download_metrics.sql | 0 ...448_download_metrics_computed_relation.sql | 0 ...30411175952_langchain-embedding_search.sql | 0 ...20230411175953_langchain-hybrid_search.sql | 0 ...230413130634_popular_packages_function.sql | 0 ...20230413140356_update_profile_function.sql | 0 .../20230417141004_dbdev_short_desc_typo.sql | 0 .../20230508165641_packages_order_version.sql | 0 ...75952_langchain-embedding_search-1_1_0.sql | 0 ...08175953_langchain-hybrid_search-1_1_0.sql | 0 ...212339_langchain_headerkit_config_dump.sql | 0 ...81432_dbdev_supports_multiple_versions.sql | 0 .../20230829125510_fix_view_permissions.sql | 0 ...0830083255_olirice-index_advisor-0_2_0.sql | 0 ...915_allow_anon_access_to_package_views.sql | 0 .../20230906110845_access_token.sql | 0 .../20230906111353_publish_package.sql | 0 ...ow_publishing_relocatable_and_requires.sql | 0 .../20231205051816_add_default_version.sql | 0 ...5101809_dbdev_supports_default_version.sql | 0 .../20231207071422_new_package_name.sql | 0 ...73048_dbdev_supports_new_package_names.sql | 0 ...11703_langchain@embedding_search-1.1.1.sql | 0 ...07112129_langchain@hybrid_search-1.1.1.sql | 0 ...20231207112942_michelp@adminpack-0.0.2.sql | 0 ...1207113329_olirice@index_advisor-0.2.1.sql | 0 ...20231207113857_olirice@read_once-0.3.2.sql | 0 .../20240108072747_update_provider_id.sql | 0 .../20240605122023_fix_view_permissions.sql | 0 .../20240705083738_remove_contact_email.sql | 0 .../20250106073735_jwt_secret_from_vault.sql | 0 ...50217100252_restrict_accounts_and_orgs.sql | 0 ...152_remove_dbdev_from_popular_packages.sql | 0 .../supabase-projects/dbdev/project.ts | 26 + .../supabase-project-adjacent.test.ts | 20 + .../supabase-project-declarative.test.ts | 20 + .../supabase-project-fixture.test.ts | 27 + .../integration/supabase-project-fixture.ts | 114 +++ .../supabase-project-progressive.test.ts | 20 + .../supabase-project-report.test.ts | 52 ++ .../integration/supabase-project-report.ts | 179 ++++ .../supabase-project-runners.test.ts | 41 + .../integration/supabase-project-runners.ts | 791 ++++++++++++++++++ 67 files changed, 1291 insertions(+), 590 deletions(-) delete mode 100644 packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts delete mode 100644 packages/pg-delta/tests/integration/dbdev-smoke.test.ts rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117141357_extensions.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117141359_app_schema.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117141507_semver.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117141645_valid_name_type.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117141942_email_address_type.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117142104_account_and_org_tables.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117142137_package_tables.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117142138_developer_tools.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117142141_security_utilities.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117142142_security_definitions.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117155720_views.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20220117155820_rpc.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230323180034_reserved_user_accts.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230328185043_olirice_asciiplot.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230330155137_supabase_dbdev.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230331145934_burggraf-pg_headerkit.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230331163908_olirice-index_advisor.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230331163909_olirice-read_once.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230404162614_michelp-adminpack.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230405083103_fix_auth_schema_values.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230405085810_fix_avatars_handle.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230405163940_download_metrics.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230411104448_download_metrics_computed_relation.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230411175952_langchain-embedding_search.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230411175953_langchain-hybrid_search.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230413130634_popular_packages_function.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230413140356_update_profile_function.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230417141004_dbdev_short_desc_typo.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230508165641_packages_order_version.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230508175952_langchain-embedding_search-1_1_0.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230622212339_langchain_headerkit_config_dump.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230623181432_dbdev_supports_multiple_versions.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230829125510_fix_view_permissions.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230830083255_olirice-index_advisor-0_2_0.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230831172915_allow_anon_access_to_package_views.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230906110845_access_token.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20230906111353_publish_package.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231205051816_add_default_version.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231205101809_dbdev_supports_default_version.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207071422_new_package_name.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207073048_dbdev_supports_new_package_names.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207111703_langchain@embedding_search-1.1.1.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207112942_michelp@adminpack-0.0.2.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207113329_olirice@index_advisor-0.2.1.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20231207113857_olirice@read_once-0.3.2.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20240108072747_update_provider_id.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20240605122023_fix_view_permissions.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20240705083738_remove_contact_email.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20250106073735_jwt_secret_from_vault.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20250217100252_restrict_accounts_and_orgs.sql (100%) rename packages/pg-delta/tests/integration/fixtures/{dbdev-migrations => supabase-projects/dbdev}/migrations/20250804111152_remove_dbdev_from_popular_packages.sql (100%) create mode 100644 packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-declarative.test.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-fixture.test.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-fixture.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-progressive.test.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-report.test.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-report.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-runners.test.ts create mode 100644 packages/pg-delta/tests/integration/supabase-project-runners.ts diff --git a/.gitignore b/.gitignore index 008d51aa..6181662e 100644 --- a/.gitignore +++ b/.gitignore @@ -167,6 +167,7 @@ dev-debug.log # Declarative schemas declarative-schemas/ +packages/pg-delta/test-results/ # TypeDoc generated output diff --git a/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts b/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts deleted file mode 100644 index 7095110a..00000000 --- a/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Integration test: dbdev declarative schema roundtrip with Supabase integration. - * - * Reproduces two bugs in the declarative export CLI command that cause a - * roundtrip verification to fail on the real dbdev Supabase project: - * - * Bug 1 (Missing GRANT SELECT): - * The CLI connects as `postgres`, which is the same role that ran - * `ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO authenticated, anon`. - * When createPlan computes the default privilege state, it subtracts these - * defaults from the explicit GRANTs, so GRANT SELECT is never emitted. On - * re-apply, those GRANTs are missing because ALTER DEFAULT PRIVILEGES runs - * after CREATE TABLE (no explicit ordering in pg-topo). - * - * Bug 2 (Missing RLS policies with auth.uid()): - * The CLI pre-compiles the supabase filter DSL to a function before passing - * it to createPlan. When a function filter is used, cascading is enabled by - * default. The supabase filter excludes the `auth` schema, and the cascade - * logic removes all changes that depend on excluded auth objects via pg_depend. - * RLS policies with `auth.uid()` expressions have a pg_depend on auth.uid(), - * so they get cascade-excluded and never appear in the export. - */ - -import { describe, expect, test } from "bun:test"; -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import type { Pool } from "pg"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; -import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.ts"; -import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { createPool, endPool } from "../../src/core/postgres-config.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { - POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, - type PostgresVersion, -} from "../constants.ts"; -import { SupabasePostgreSqlContainer } from "../supabase-postgres.js"; - -const MIGRATIONS_DIR = path.join( - import.meta.dir, - "fixtures/dbdev-migrations/migrations", -); - -/** - * Load the core schema migrations that are sufficient to reproduce both bugs. - * - * We only apply the initial 20220117* migrations, which create all the - * tables, types, functions, GRANTs, and RLS policies needed. Later migrations - * are data-only inserts that reference columns that changed across Supabase image - * versions (e.g. storage.buckets.public, auth.users.email_confirmed_at) and are - * not required to demonstrate the export bugs. - */ -async function loadMigrations(): Promise<{ filename: string; sql: string }[]> { - const files = await readdir(MIGRATIONS_DIR); - // Only the foundational 20220117 schema migrations -- sufficient to reproduce - // both Bug 1 (GRANT SELECT subtraction) and Bug 2 (auth.uid() cascade). - const sqlFiles = files - .filter((f) => f.endsWith(".sql") && f.startsWith("20220117")) - .sort(); - return Promise.all( - sqlFiles.map(async (f) => ({ - filename: f, - sql: await readFile(path.join(MIGRATIONS_DIR, f), "utf-8"), - })), - ); -} - -function suppressShutdownError(err: Error & { code?: string }) { - if (err.code === "57P01" || err.code === "53100") return; - console.error("Pool error:", err); -} - -/** - * Create a pool that connects as supabase_admin but immediately sets - * the role to postgres on each connection. This makes currentUser = postgres - * in catalog extractions, matching the real production scenario where the - * CLI runs as the postgres superuser (reproduces Bug 1). - */ -function createPostgresRolePool(connectionUri: string): Pool { - return createPool(connectionUri, { - onError: suppressShutdownError, - onConnect: async (client) => { - await client.query("SET ROLE postgres"); - }, - }); -} - -// dbdev targets PG15 -- only run this test against that version. -const pgVersion: PostgresVersion = 15; - -describe(`dbdev declarative roundtrip (pg${pgVersion})`, () => { - test( - "exported schema roundtrips to 0 remaining changes with supabase integration", - async () => { - const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[pgVersion]}`; - - // Start two fresh Supabase containers: - // containerMain = clean baseline (no user migrations) - // containerBranch = desired state (all dbdev migrations applied) - const [containerMain, containerBranch] = await Promise.all([ - new SupabasePostgreSqlContainer(image).start(), - new SupabasePostgreSqlContainer(image).start(), - ]); - - // Pools connect via supabase_admin but operate as postgres role so that: - // - Tables are owned by postgres (not a SUPABASE_SYSTEM_ROLE, so not filtered out) - // - ALTER DEFAULT PRIVILEGES is set for postgres - // - catalog.currentUser = postgres (triggering Bug 1 in the unfixed CLI path) - const mainPool = createPostgresRolePool(containerMain.getConnectionUri()); - const branchPool = createPostgresRolePool( - containerBranch.getConnectionUri(), - ); - - try { - // Apply all dbdev migrations to branch in chronological (filename-sorted) order - const migrations = await loadMigrations(); - for (const { filename, sql } of migrations) { - await branchPool.query(sql).catch((err) => { - throw new Error(`Migration ${filename} failed: ${err}`, { - cause: err, - }); - }); - } - - // ── Use the fixed CLI code path ───────────────────────────────────── - // - // Pass raw DSL (not compiled functions) to createPlan and enable - // skipDefaultPrivilegeSubtraction. This matches the fixed - // declarative-export.ts behavior: - // - Raw DSL → createPlan correctly disables cascading (Bug 2 fix) - // - skipDefaultPrivilegeSubtraction → all GRANTs emitted explicitly (Bug 1 fix) - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - const compiledFilter = compileFilterDSL(supabaseIntegration.filter); - const compiledSerialize = compileSerializeDSL( - supabaseIntegration.serialize, - ); - - const planResult = await createPlan(mainPool, branchPool, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - skipDefaultPrivilegeSubtraction: true, - }); - - if (!planResult) { - throw new Error( - "createPlan returned null -- no changes detected between baseline and branch", - ); - } - - const output = exportDeclarativeSchema(planResult, { - integration: { serialize: compiledSerialize }, - }); - - // Apply the exported declarative schema files to the clean main DB. - // Disable final function body validation: functions reference auth.uid() - // and other auth schema objects that exist in Supabase but aren't created - // by the declarative export itself (they're system objects). - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((f) => ({ filePath: f.path, sql: f.sql })), - pool: mainPool, - disableCheckFunctionBodies: true, - validateFunctionBodies: false, - }); - - if (applyResult.apply.status !== "success") { - const stuckSql = applyResult.apply.stuckStatements - ?.map((s) => `[${s.code}] ${s.message}\n SQL: ${s.statement.sql}`) - .join("\n"); - const errorSql = applyResult.apply.errors - ?.map((s) => `[${s.code}] ${s.message}\n SQL: ${s.statement.sql}`) - .join("\n"); - throw new Error( - `Declarative apply failed (${applyResult.apply.status}):\n${stuckSql ?? errorSql ?? "(no detail)"}`, - { cause: applyResult }, - ); - } - - // Diff main (post-apply) vs branch -- the supabase filter should see 0 changes - const mainCatalog = await extractCatalog(mainPool); - const branchCatalog = await extractCatalog(branchPool); - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = allChanges.filter(compiledFilter); - - if (remainingChanges.length > 0) { - const sorted = sortChanges( - { mainCatalog, branchCatalog }, - remainingChanges, - ); - const remainingSql = sorted.map((c) => c.serialize()).join(";\n"); - console.error( - `[dbdev-roundtrip] ${remainingChanges.length} remaining change(s) after roundtrip:\n${remainingSql}`, - ); - } - - expect(remainingChanges).toHaveLength(0); - } finally { - await Promise.all([endPool(mainPool), endPool(branchPool)]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } - }, - 5 * 60 * 1000, // 5 min -- two Supabase containers + 54 migrations - ); -}); diff --git a/packages/pg-delta/tests/integration/dbdev-smoke.test.ts b/packages/pg-delta/tests/integration/dbdev-smoke.test.ts deleted file mode 100644 index 5896a1a0..00000000 --- a/packages/pg-delta/tests/integration/dbdev-smoke.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -/** - * Progressive smoke test for dbdev migrations. - * - * Applies dbdev migrations incrementally (0 → N) to a source Supabase - * container and, at each step, generates a diff against a fully-migrated - * target container. Optionally verifies the generated SQL by executing it - * inside a BEGIN / ROLLBACK transaction so the source state is preserved. - * - * Some migrations reference Supabase-image-specific columns that may not - * exist in the test image (e.g. auth.users.email_confirmed_at). These are - * skipped on both branch and main so the two databases stay comparable. - * - * Environment variables: - * DBDEV_SMOKE_STEP_FROM – first step to test (default: 0) - * DBDEV_SMOKE_STEP_TO – last step to test, inclusive (default: number of applicable migrations) - * DBDEV_SMOKE_SKIP_APPLY – set to "1" to only generate plans, skip SQL verification - */ - -import { afterAll, beforeAll, describe, test } from "bun:test"; -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import type { Pool } from "pg"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; -import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { createPool, endPool } from "../../src/core/postgres-config.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { - POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, - type PostgresVersion, -} from "../constants.ts"; -import { SupabasePostgreSqlContainer } from "../supabase-postgres.js"; - -const MIGRATIONS_DIR = path.join( - import.meta.dir, - "fixtures/dbdev-migrations/migrations", -); - -type StepResult = { - step: number; - migrationApplied: string; - planStatus: "no_changes" | "success" | "error"; - planError?: string; - changeCount?: number; - statementCount?: number; - applyStatus?: "success" | "error" | "skipped"; - applyError?: string; - applyFailedStatement?: string; - remainingChanges?: number; -}; - -async function loadAllMigrations(): Promise< - { filename: string; sql: string }[] -> { - const files = await readdir(MIGRATIONS_DIR); - const sqlFiles = files.filter((f) => f.endsWith(".sql")).sort(); - return Promise.all( - sqlFiles.map(async (f) => ({ - filename: f, - sql: await readFile(path.join(MIGRATIONS_DIR, f), "utf-8"), - })), - ); -} - -function suppressShutdownError(err: Error & { code?: string }) { - if (err.code === "57P01" || err.code === "53100") return; - console.error("Pool error:", err); -} - -function createPostgresRolePool(connectionUri: string): Pool { - return createPool(connectionUri, { - onError: suppressShutdownError, - onConnect: async (client) => { - await client.query("SET ROLE postgres"); - }, - }); -} - -function printSummary(results: StepResult[], skippedMigrations: string[]) { - const passed = results.filter( - (r) => - r.planStatus !== "error" && - (r.applyStatus === "success" || r.applyStatus === "skipped"), - ); - const failed = results.filter( - (r) => r.planStatus === "error" || r.applyStatus === "error", - ); - - console.log("\n╔══════════════════════════════════════════════════════════╗"); - console.log("║ DBDEV SMOKE TEST SUMMARY ║"); - console.log("╠══════════════════════════════════════════════════════════╣"); - console.log( - `${`║ Total steps: ${results.length} Passed: ${passed.length} Failed: ${failed.length}`.padEnd( - 59, - )}║`, - ); - if (skippedMigrations.length > 0) { - console.log( - `${`║ Skipped migrations (image-incompatible): ${skippedMigrations.length}`.padEnd( - 59, - )}║`, - ); - } - console.log("╚══════════════════════════════════════════════════════════╝\n"); - - if (skippedMigrations.length > 0) { - console.log("─── SKIPPED MIGRATIONS ─────────────────────────────────────"); - for (const m of skippedMigrations) { - console.log(` ${m}`); - } - console.log(""); - } - - if (failed.length > 0) { - console.log("─── FAILURES ───────────────────────────────────────────────"); - for (const r of failed) { - console.log(`\n Step ${r.step}: after "${r.migrationApplied}"`); - if (r.planStatus === "error") { - console.log(` Plan generation FAILED: ${r.planError}`); - } - if (r.applyStatus === "error") { - console.log(` SQL apply FAILED: ${r.applyError}`); - if (r.applyFailedStatement) { - const truncated = - r.applyFailedStatement.length > 200 - ? `${r.applyFailedStatement.slice(0, 200)}...` - : r.applyFailedStatement; - console.log(` Failed statement: ${truncated}`); - } - } - if (r.remainingChanges !== undefined && r.remainingChanges > 0) { - console.log(` Remaining changes after apply: ${r.remainingChanges}`); - } - } - console.log( - "\n────────────────────────────────────────────────────────────", - ); - } - - console.log("\n─── FULL RESULTS ───────────────────────────────────────────"); - console.log( - "Step".padEnd(6) + - "Migration".padEnd(50) + - "Plan".padEnd(14) + - "Changes".padEnd(10) + - "Stmts".padEnd(8) + - "Apply".padEnd(10), - ); - console.log("─".repeat(98)); - for (const r of results) { - const migration = - r.migrationApplied.length > 48 - ? `${r.migrationApplied.slice(0, 45)}...` - : r.migrationApplied; - console.log( - String(r.step).padEnd(6) + - migration.padEnd(50) + - r.planStatus.padEnd(14) + - (r.changeCount !== undefined ? String(r.changeCount) : "-").padEnd(10) + - (r.statementCount !== undefined - ? String(r.statementCount) - : "-" - ).padEnd(8) + - (r.applyStatus ?? "-").padEnd(10), - ); - } - console.log("────────────────────────────────────────────────────────────\n"); -} - -const pgVersion: PostgresVersion = 15; -const STEP_FROM = Number(process.env.DBDEV_SMOKE_STEP_FROM ?? 0); -const STEP_TO_ENV = process.env.DBDEV_SMOKE_STEP_TO; -const SKIP_APPLY = process.env.DBDEV_SMOKE_SKIP_APPLY === "1"; - -describe(`dbdev progressive smoke test (pg${pgVersion})`, () => { - let mainPool: Pool; - let branchPool: Pool; - let containerMain: Awaited>; - let containerBranch: Awaited< - ReturnType - >; - // Migrations that were successfully applied to branch (image-compatible). - let applicableMigrations: { filename: string; sql: string }[]; - let skippedMigrations: string[]; - let stepTo: number; - const results: StepResult[] = []; - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - const compiledFilter = compileFilterDSL(supabaseIntegration.filter); - - beforeAll( - async () => { - const allMigrations = await loadAllMigrations(); - - const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[pgVersion]}`; - - [containerMain, containerBranch] = await Promise.all([ - new SupabasePostgreSqlContainer(image).start(), - new SupabasePostgreSqlContainer(image).start(), - ]); - - mainPool = createPostgresRolePool(containerMain.getConnectionUri()); - branchPool = createPostgresRolePool(containerBranch.getConnectionUri()); - - // Apply all migrations to branch, tracking which ones succeed. - // Some migrations reference columns specific to certain Supabase image - // versions (e.g. auth.users.email_confirmed_at) and will fail — skip them. - applicableMigrations = []; - skippedMigrations = []; - for (const migration of allMigrations) { - try { - await branchPool.query(migration.sql); - applicableMigrations.push(migration); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - skippedMigrations.push(`${migration.filename}: ${msg}`); - } - } - - stepTo = - STEP_TO_ENV !== undefined - ? Number(STEP_TO_ENV) - : applicableMigrations.length; - - console.log( - `[smoke] Branch ready: ${applicableMigrations.length} migrations applied, ${skippedMigrations.length} skipped`, - ); - }, - 10 * 60 * 1000, - ); - - afterAll(async () => { - printSummary(results, skippedMigrations ?? []); - if (mainPool) await endPool(mainPool); - if (branchPool) await endPool(branchPool); - await Promise.all([containerMain?.stop(), containerBranch?.stop()]); - }, 60_000); - - test( - "progressive migration diff", - async () => { - for (let step = 0; step <= stepTo; step++) { - const migrationName = - step === 0 - ? "(empty)" - : step <= applicableMigrations.length - ? applicableMigrations[step - 1].filename - : "(all applied)"; - - // Fast-forward: apply migration to main but skip plan generation - if (step < STEP_FROM) { - if (step > 0 && step <= applicableMigrations.length) { - await mainPool - .query(applicableMigrations[step - 1].sql) - .catch((err) => { - throw new Error( - `Migration ${applicableMigrations[step - 1].filename} failed on main: ${err.message}`, - { cause: err }, - ); - }); - } - continue; - } - - const result: StepResult = { - step, - migrationApplied: migrationName, - planStatus: "success", - }; - - // Apply the migration for this step - if (step > 0 && step <= applicableMigrations.length) { - try { - await mainPool.query(applicableMigrations[step - 1].sql); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error( - `[step ${step}] Migration ${migrationName} failed on main: ${msg}`, - ); - result.planStatus = "error"; - result.planError = `Migration apply failed: ${msg}`; - results.push(result); - continue; - } - } - - // Generate the plan - try { - const planResult = await createPlan(mainPool, branchPool, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - skipDefaultPrivilegeSubtraction: true, - }); - - if (!planResult) { - result.planStatus = "no_changes"; - result.changeCount = 0; - result.statementCount = 0; - result.applyStatus = "skipped"; - results.push(result); - continue; - } - - result.changeCount = planResult.sortedChanges.length; - result.statementCount = planResult.plan.statements.length; - - if (SKIP_APPLY) { - result.applyStatus = "skipped"; - results.push(result); - continue; - } - - // Verify SQL by executing inside BEGIN / ROLLBACK - try { - await mainPool.query("BEGIN"); - await mainPool.query("SET LOCAL check_function_bodies = false"); - for (const stmt of planResult.plan.statements) { - await mainPool.query(stmt); - } - - // Check remaining changes inside the transaction - const mainCatalog = await extractCatalog(mainPool); - const branchCatalog = await extractCatalog(branchPool); - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remaining = allChanges.filter(compiledFilter); - result.remainingChanges = remaining.length; - - if (remaining.length > 0) { - const sorted = sortChanges( - { mainCatalog, branchCatalog }, - remaining, - ); - const remainingSql = sorted.map((c) => c.serialize()).join(";\n"); - console.error( - `[step ${step}] ${remaining.length} remaining change(s):\n${remainingSql}`, - ); - result.applyStatus = "error"; - result.applyError = `${remaining.length} remaining changes after apply`; - } else { - result.applyStatus = "success"; - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - result.applyStatus = "error"; - result.applyError = msg; - } finally { - try { - await mainPool.query("ROLLBACK"); - } catch { - // Connection may have been interrupted; ignore rollback errors - } - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - result.planStatus = "error"; - result.planError = msg; - } - - results.push(result); - } - - // Diagnostic test: always passes, failures are reported in the - // afterAll summary. This avoids --retry re-running the test with - // stale container state (all migrations already applied on main). - const failures = results.filter( - (r) => r.planStatus === "error" || r.applyStatus === "error", - ); - if (failures.length > 0) { - console.error( - `\n[smoke] ${failures.length} step(s) with failures — see summary below\n`, - ); - } - }, - 30 * 60 * 1000, - ); -}); diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141357_extensions.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141357_extensions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141357_extensions.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141357_extensions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141359_app_schema.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141359_app_schema.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141359_app_schema.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141359_app_schema.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141507_semver.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141507_semver.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141507_semver.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141507_semver.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141645_valid_name_type.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141645_valid_name_type.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141645_valid_name_type.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141645_valid_name_type.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141942_email_address_type.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141942_email_address_type.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141942_email_address_type.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141942_email_address_type.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142104_account_and_org_tables.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142104_account_and_org_tables.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142104_account_and_org_tables.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142104_account_and_org_tables.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142137_package_tables.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142137_package_tables.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142137_package_tables.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142137_package_tables.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142138_developer_tools.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142138_developer_tools.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142138_developer_tools.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142138_developer_tools.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142141_security_utilities.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142141_security_utilities.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142141_security_utilities.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142141_security_utilities.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142142_security_definitions.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142142_security_definitions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142142_security_definitions.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117142142_security_definitions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155720_views.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117155720_views.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155720_views.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117155720_views.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155820_rpc.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117155820_rpc.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155820_rpc.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117155820_rpc.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230323180034_reserved_user_accts.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230323180034_reserved_user_accts.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230323180034_reserved_user_accts.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230323180034_reserved_user_accts.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230328185043_olirice_asciiplot.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230328185043_olirice_asciiplot.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230328185043_olirice_asciiplot.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230328185043_olirice_asciiplot.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230330155137_supabase_dbdev.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230330155137_supabase_dbdev.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230330155137_supabase_dbdev.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230330155137_supabase_dbdev.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331145934_burggraf-pg_headerkit.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230331145934_burggraf-pg_headerkit.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331145934_burggraf-pg_headerkit.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230331145934_burggraf-pg_headerkit.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163908_olirice-index_advisor.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230331163908_olirice-index_advisor.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163908_olirice-index_advisor.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230331163908_olirice-index_advisor.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163909_olirice-read_once.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230331163909_olirice-read_once.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163909_olirice-read_once.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230331163909_olirice-read_once.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230404162614_michelp-adminpack.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230404162614_michelp-adminpack.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230404162614_michelp-adminpack.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230404162614_michelp-adminpack.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405083103_fix_auth_schema_values.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230405083103_fix_auth_schema_values.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405083103_fix_auth_schema_values.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230405083103_fix_auth_schema_values.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405085810_fix_avatars_handle.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230405085810_fix_avatars_handle.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405085810_fix_avatars_handle.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230405085810_fix_avatars_handle.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405163940_download_metrics.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230405163940_download_metrics.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405163940_download_metrics.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230405163940_download_metrics.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411104448_download_metrics_computed_relation.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230411104448_download_metrics_computed_relation.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411104448_download_metrics_computed_relation.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230411104448_download_metrics_computed_relation.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175952_langchain-embedding_search.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230411175952_langchain-embedding_search.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175952_langchain-embedding_search.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230411175952_langchain-embedding_search.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175953_langchain-hybrid_search.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230411175953_langchain-hybrid_search.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175953_langchain-hybrid_search.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230411175953_langchain-hybrid_search.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413130634_popular_packages_function.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230413130634_popular_packages_function.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413130634_popular_packages_function.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230413130634_popular_packages_function.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413140356_update_profile_function.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230413140356_update_profile_function.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413140356_update_profile_function.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230413140356_update_profile_function.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230417141004_dbdev_short_desc_typo.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230417141004_dbdev_short_desc_typo.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230417141004_dbdev_short_desc_typo.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230417141004_dbdev_short_desc_typo.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508165641_packages_order_version.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230508165641_packages_order_version.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508165641_packages_order_version.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230508165641_packages_order_version.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175952_langchain-embedding_search-1_1_0.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230508175952_langchain-embedding_search-1_1_0.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175952_langchain-embedding_search-1_1_0.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230508175952_langchain-embedding_search-1_1_0.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230622212339_langchain_headerkit_config_dump.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230622212339_langchain_headerkit_config_dump.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230622212339_langchain_headerkit_config_dump.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230622212339_langchain_headerkit_config_dump.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230623181432_dbdev_supports_multiple_versions.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230623181432_dbdev_supports_multiple_versions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230623181432_dbdev_supports_multiple_versions.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230623181432_dbdev_supports_multiple_versions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230829125510_fix_view_permissions.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230829125510_fix_view_permissions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230829125510_fix_view_permissions.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230829125510_fix_view_permissions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230830083255_olirice-index_advisor-0_2_0.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230830083255_olirice-index_advisor-0_2_0.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230830083255_olirice-index_advisor-0_2_0.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230830083255_olirice-index_advisor-0_2_0.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230831172915_allow_anon_access_to_package_views.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230831172915_allow_anon_access_to_package_views.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230831172915_allow_anon_access_to_package_views.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230831172915_allow_anon_access_to_package_views.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906110845_access_token.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230906110845_access_token.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906110845_access_token.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230906110845_access_token.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906111353_publish_package.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230906111353_publish_package.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906111353_publish_package.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20230906111353_publish_package.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205051816_add_default_version.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231205051816_add_default_version.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205051816_add_default_version.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231205051816_add_default_version.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205101809_dbdev_supports_default_version.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231205101809_dbdev_supports_default_version.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205101809_dbdev_supports_default_version.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231205101809_dbdev_supports_default_version.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207071422_new_package_name.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207071422_new_package_name.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207071422_new_package_name.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207071422_new_package_name.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207073048_dbdev_supports_new_package_names.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207073048_dbdev_supports_new_package_names.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207073048_dbdev_supports_new_package_names.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207073048_dbdev_supports_new_package_names.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207111703_langchain@embedding_search-1.1.1.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207111703_langchain@embedding_search-1.1.1.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207111703_langchain@embedding_search-1.1.1.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207111703_langchain@embedding_search-1.1.1.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112942_michelp@adminpack-0.0.2.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207112942_michelp@adminpack-0.0.2.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112942_michelp@adminpack-0.0.2.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207112942_michelp@adminpack-0.0.2.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113329_olirice@index_advisor-0.2.1.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207113329_olirice@index_advisor-0.2.1.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113329_olirice@index_advisor-0.2.1.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207113329_olirice@index_advisor-0.2.1.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113857_olirice@read_once-0.3.2.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207113857_olirice@read_once-0.3.2.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113857_olirice@read_once-0.3.2.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20231207113857_olirice@read_once-0.3.2.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240108072747_update_provider_id.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20240108072747_update_provider_id.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240108072747_update_provider_id.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20240108072747_update_provider_id.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240605122023_fix_view_permissions.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20240605122023_fix_view_permissions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240605122023_fix_view_permissions.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20240605122023_fix_view_permissions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240705083738_remove_contact_email.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20240705083738_remove_contact_email.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240705083738_remove_contact_email.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20240705083738_remove_contact_email.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250106073735_jwt_secret_from_vault.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20250106073735_jwt_secret_from_vault.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250106073735_jwt_secret_from_vault.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20250106073735_jwt_secret_from_vault.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250217100252_restrict_accounts_and_orgs.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20250217100252_restrict_accounts_and_orgs.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250217100252_restrict_accounts_and_orgs.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20250217100252_restrict_accounts_and_orgs.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250804111152_remove_dbdev_from_popular_packages.sql b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20250804111152_remove_dbdev_from_popular_packages.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250804111152_remove_dbdev_from_popular_packages.sql rename to packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/migrations/20250804111152_remove_dbdev_from_popular_packages.sql diff --git a/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts new file mode 100644 index 00000000..2020680f --- /dev/null +++ b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts @@ -0,0 +1,26 @@ +import { supabase } from "../../../../../src/core/integrations/supabase.ts"; +import { defineSupabaseProjectFixture } from "../../../supabase-project-fixture.ts"; + +export default defineSupabaseProjectFixture({ + id: "dbdev", + displayName: "dbdev", + supabasePostgresVersion: 15, + integration: supabase, + migrationsDir: new URL("./migrations/", import.meta.url), + setRole: "postgres", + skipDefaultPrivilegeSubtraction: true, + candidateRegressionNote: + "Reduce the failing prefix to the smallest migration slice that reproduces the issue, then turn the generated SQL or remaining diff into a focused pg-delta integration test.", + scenarios: { + declarative: { + include: (filename) => filename.startsWith("20220117"), + onApplyError: "fail", + }, + progressive: { + onApplyError: "skip", + }, + adjacent: { + onApplyError: "skip", + }, + }, +}); diff --git a/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts b/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts new file mode 100644 index 00000000..a2b6a9b1 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts @@ -0,0 +1,20 @@ +import { describe, test } from "bun:test"; +import { discoverSupabaseProjectFixtures } from "./supabase-project-fixture.ts"; +import { runSupabaseProjectAdjacentSmoke } from "./supabase-project-runners.ts"; + +const fixtures = await discoverSupabaseProjectFixtures(); + +for (const fixture of fixtures) { + describe( + `${fixture.displayName} adjacent smoke (pg${fixture.supabasePostgresVersion})`, + () => { + test( + "each individual migration step plans and applies cleanly against the next prefix", + async () => { + await runSupabaseProjectAdjacentSmoke(fixture); + }, + 30 * 60 * 1000, + ); + }, + ); +} diff --git a/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts b/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts new file mode 100644 index 00000000..719cdf2a --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts @@ -0,0 +1,20 @@ +import { describe, test } from "bun:test"; +import { discoverSupabaseProjectFixtures } from "./supabase-project-fixture.ts"; +import { runSupabaseProjectDeclarativeRoundtrip } from "./supabase-project-runners.ts"; + +const fixtures = await discoverSupabaseProjectFixtures(); + +for (const fixture of fixtures) { + describe( + `${fixture.displayName} declarative roundtrip (pg${fixture.supabasePostgresVersion})`, + () => { + test( + "exported schema roundtrips to 0 remaining changes with supabase integration", + async () => { + await runSupabaseProjectDeclarativeRoundtrip(fixture); + }, + 5 * 60 * 1000, + ); + }, + ); +} diff --git a/packages/pg-delta/tests/integration/supabase-project-fixture.test.ts b/packages/pg-delta/tests/integration/supabase-project-fixture.test.ts new file mode 100644 index 00000000..c27978e8 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-fixture.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +test("dbdev fixture uses the standardized supabase-project layout", async () => { + const projectDir = path.join( + import.meta.dir, + "fixtures/supabase-projects/dbdev", + ); + const projectFile = path.join(projectDir, "project.ts"); + const migrationsDir = path.join(projectDir, "migrations"); + + expect(await Bun.file(projectFile).exists()).toBe(true); + expect((await stat(migrationsDir)).isDirectory()).toBe(true); + + const module = await import(pathToFileURL(projectFile).href); + const fixture = module.default; + const sqlFiles = (await readdir(migrationsDir)) + .filter((file) => file.endsWith(".sql")) + .sort(); + + expect(fixture.id).toBe("dbdev"); + expect(fixture.supabasePostgresVersion).toBe(15); + expect(sqlFiles.length).toBeGreaterThan(0); + expect(sqlFiles[0]).toBe("20220117141357_extensions.sql"); +}); diff --git a/packages/pg-delta/tests/integration/supabase-project-fixture.ts b/packages/pg-delta/tests/integration/supabase-project-fixture.ts new file mode 100644 index 00000000..9a0920dc --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-fixture.ts @@ -0,0 +1,114 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { IntegrationDSL } from "../../src/core/integrations/integration-dsl.ts"; +import { + SUPABASE_POSTGRES_VERSIONS, + type SupabasePostgresVersion, +} from "../constants.ts"; + +export type SupabaseProjectScenarioName = + | "declarative" + | "progressive" + | "adjacent"; + +export type SupabaseProjectMigration = { + filename: string; + sql: string; +}; + +export type SupabaseProjectScenario = { + include?: (filename: string) => boolean; + onApplyError?: "fail" | "skip"; +}; + +export type SupabaseProjectFixture = { + id: string; + displayName: string; + supabasePostgresVersion: SupabasePostgresVersion; + integration: IntegrationDSL; + migrationsDir: string | URL; + setRole?: string; + skipDefaultPrivilegeSubtraction?: boolean; + validateFunctionBodies?: boolean; + candidateRegressionNote?: string; + scenarios: Record; +}; + +export const SUPABASE_PROJECTS_DIR = path.join( + import.meta.dir, + "fixtures/supabase-projects", +); + +export function defineSupabaseProjectFixture( + fixture: SupabaseProjectFixture, +): SupabaseProjectFixture { + return fixture; +} + +export function resolveSupabaseProjectPath( + basePath: string | URL, + ...parts: string[] +): string { + const resolved = + basePath instanceof URL ? fileURLToPath(basePath) : path.resolve(basePath); + return parts.length > 0 ? path.join(resolved, ...parts) : resolved; +} + +export async function discoverSupabaseProjectFixtures(): Promise< + SupabaseProjectFixture[] +> { + const selectedProject = process.env.PGDELTA_SUPABASE_PROJECT; + const entries = await readdir(SUPABASE_PROJECTS_DIR, { withFileTypes: true }); + const fixtures: SupabaseProjectFixture[] = []; + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue; + + const projectFile = path.join(SUPABASE_PROJECTS_DIR, entry.name, "project.ts"); + if (!(await Bun.file(projectFile).exists())) { + continue; + } + + const module = await import(pathToFileURL(projectFile).href); + const fixture = module.default as SupabaseProjectFixture; + + if (selectedProject && fixture.id !== selectedProject) { + continue; + } + + if (!SUPABASE_POSTGRES_VERSIONS.includes(fixture.supabasePostgresVersion)) { + continue; + } + + fixtures.push(fixture); + } + + if (selectedProject && fixtures.length === 0) { + throw new Error( + `No Supabase project fixture matched PGDELTA_SUPABASE_PROJECT=${selectedProject}`, + ); + } + + return fixtures; +} + +export async function loadSupabaseProjectMigrations( + fixture: SupabaseProjectFixture, + scenarioName: SupabaseProjectScenarioName, +): Promise { + const migrationsDir = resolveSupabaseProjectPath(fixture.migrationsDir); + const files = await readdir(migrationsDir); + const scenario = fixture.scenarios[scenarioName]; + const sqlFiles = files + .filter((file) => file.endsWith(".sql")) + .sort() + .filter((file) => (scenario.include ? scenario.include(file) : true)); + + return Promise.all( + sqlFiles.map(async (filename) => ({ + filename, + sql: await readFile(path.join(migrationsDir, filename), "utf-8"), + })), + ); +} diff --git a/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts b/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts new file mode 100644 index 00000000..017bc473 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts @@ -0,0 +1,20 @@ +import { describe, test } from "bun:test"; +import { discoverSupabaseProjectFixtures } from "./supabase-project-fixture.ts"; +import { runSupabaseProjectProgressiveSmoke } from "./supabase-project-runners.ts"; + +const fixtures = await discoverSupabaseProjectFixtures(); + +for (const fixture of fixtures) { + describe( + `${fixture.displayName} progressive smoke (pg${fixture.supabasePostgresVersion})`, + () => { + test( + "each migration prefix plans and applies cleanly against the fully migrated target", + async () => { + await runSupabaseProjectProgressiveSmoke(fixture); + }, + 30 * 60 * 1000, + ); + }, + ); +} diff --git a/packages/pg-delta/tests/integration/supabase-project-report.test.ts b/packages/pg-delta/tests/integration/supabase-project-report.test.ts new file mode 100644 index 00000000..cc056706 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-report.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { writeSupabaseSmokeFailureArtifacts } from "./supabase-project-report.ts"; + +test("writes markdown and companion artifacts for smoke failures", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "pg-delta-supabase-smoke-"), + ); + const result = await writeSupabaseSmokeFailureArtifacts({ + baseDir: tempDir, + fixtureId: "dbdev", + fixtureDisplayName: "dbdev", + scenarioName: "progressive", + artifactId: "step-0001", + image: "supabase/postgres:15.14.1.018", + step: 1, + migrationName: "20220117141357_extensions.sql", + errorMessage: "simulated failure", + reproCommand: + "PGDELTA_TEST_POSTGRES_VERSIONS=15 bun run test tests/integration/supabase-project-progressive.test.ts", + planSql: "CREATE TABLE public.example (id integer);", + remainingSql: "ALTER TABLE public.example ADD COLUMN name text;", + sourceCatalog: { schemas: ["public"] }, + targetCatalog: { schemas: ["public", "app"] }, + candidateRegressionNote: "Shrink the fixture to the smallest prefix first.", + }); + + expect(result.reportPath).toContain("report.md"); + expect(await Bun.file(result.reportPath).exists()).toBe(true); + expect(await Bun.file(path.join(result.directory, "metadata.json")).exists()).toBe( + true, + ); + expect(await Bun.file(path.join(result.directory, "plan.sql")).exists()).toBe( + true, + ); + expect(await Bun.file(path.join(result.directory, "remaining.sql")).exists()).toBe( + true, + ); + + const report = await readFile(result.reportPath, "utf-8"); + expect(report).toContain("## Summary"); + expect(report).toContain("simulated failure"); + expect(report).toContain("Shrink the fixture to the smallest prefix first."); + + const metadata = JSON.parse( + await readFile(path.join(result.directory, "metadata.json"), "utf-8"), + ); + expect(metadata.fixtureId).toBe("dbdev"); + expect(metadata.scenarioName).toBe("progressive"); +}); diff --git a/packages/pg-delta/tests/integration/supabase-project-report.ts b/packages/pg-delta/tests/integration/supabase-project-report.ts new file mode 100644 index 00000000..67a8b3d5 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-report.ts @@ -0,0 +1,179 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export type SupabaseSmokeScenarioName = + | "declarative" + | "progressive" + | "adjacent"; + +export type WriteSupabaseSmokeFailureArtifactsInput = { + baseDir?: string; + fixtureId: string; + fixtureDisplayName: string; + scenarioName: SupabaseSmokeScenarioName; + artifactId?: string; + image: string; + step?: number; + migrationName?: string; + errorMessage: string; + reproCommand: string; + skippedMigrations?: string[]; + planSql?: string; + remainingSql?: string; + sourceCatalog?: unknown; + targetCatalog?: unknown; + candidateRegressionNote?: string; +}; + +export type SupabaseSmokeFailureArtifacts = { + directory: string; + reportPath: string; +}; + +const DEFAULT_RESULTS_DIR = path.join( + import.meta.dir, + "..", + "..", + "test-results", + "supabase-smoke", +); + +function sanitizeSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function createArtifactId(input: WriteSupabaseSmokeFailureArtifactsInput): string { + if (input.artifactId) { + return sanitizeSegment(input.artifactId); + } + + const stepLabel = + input.step !== undefined ? `step-${String(input.step).padStart(4, "0")}` : "step-na"; + const migrationLabel = input.migrationName + ? sanitizeSegment(input.migrationName.replace(/\.sql$/i, "")) + : "migration-na"; + return `${stepLabel}-${migrationLabel}`; +} + +function buildMarkdown( + input: WriteSupabaseSmokeFailureArtifactsInput, + directory: string, +): string { + const sections = [ + "# Supabase Smoke Failure", + "", + "## Summary", + `- Fixture: \`${input.fixtureDisplayName}\``, + `- Scenario: \`${input.scenarioName}\``, + `- Image: \`${input.image}\``, + input.step !== undefined ? `- Step: \`${input.step}\`` : "", + input.migrationName ? `- Migration: \`${input.migrationName}\`` : "", + "", + "## Error", + "```text", + input.errorMessage, + "```", + "", + "## Repro", + "```bash", + input.reproCommand, + "```", + "", + input.skippedMigrations && input.skippedMigrations.length > 0 + ? ["## Skipped Migrations", "```text", ...input.skippedMigrations, "```", ""].join( + "\n", + ) + : "", + input.planSql + ? ["## Plan SQL", "```sql", input.planSql, "```", ""].join("\n") + : "", + input.remainingSql + ? ["## Remaining SQL", "```sql", input.remainingSql, "```", ""].join("\n") + : "", + input.candidateRegressionNote + ? [ + "## Candidate Regression Test", + input.candidateRegressionNote, + "", + ].join("\n") + : "", + "## Artifact Files", + `- Directory: \`${directory}\``, + "- `metadata.json`", + input.planSql ? "- `plan.sql`" : "", + input.remainingSql ? "- `remaining.sql`" : "", + input.sourceCatalog ? "- `source-catalog.json`" : "", + input.targetCatalog ? "- `target-catalog.json`" : "", + ]; + + return sections.filter(Boolean).join("\n"); +} + +export async function writeSupabaseSmokeFailureArtifacts( + input: WriteSupabaseSmokeFailureArtifactsInput, +): Promise { + const baseDir = + input.baseDir ?? + process.env.PGDELTA_SUPABASE_SMOKE_REPORT_DIR ?? + DEFAULT_RESULTS_DIR; + const directory = path.join( + baseDir, + sanitizeSegment(input.fixtureId), + sanitizeSegment(input.scenarioName), + createArtifactId(input), + ); + + await mkdir(directory, { recursive: true }); + + const metadata = { + fixtureId: input.fixtureId, + fixtureDisplayName: input.fixtureDisplayName, + scenarioName: input.scenarioName, + image: input.image, + step: input.step, + migrationName: input.migrationName, + errorMessage: input.errorMessage, + reproCommand: input.reproCommand, + skippedMigrations: input.skippedMigrations ?? [], + candidateRegressionNote: input.candidateRegressionNote, + }; + + const reportPath = path.join(directory, "report.md"); + await Promise.all([ + writeFile(reportPath, buildMarkdown(input, directory), "utf-8"), + writeFile( + path.join(directory, "metadata.json"), + `${JSON.stringify(metadata, null, 2)}\n`, + "utf-8", + ), + input.planSql + ? writeFile(path.join(directory, "plan.sql"), `${input.planSql}\n`, "utf-8") + : Promise.resolve(), + input.remainingSql + ? writeFile( + path.join(directory, "remaining.sql"), + `${input.remainingSql}\n`, + "utf-8", + ) + : Promise.resolve(), + input.sourceCatalog + ? writeFile( + path.join(directory, "source-catalog.json"), + `${JSON.stringify(input.sourceCatalog, null, 2)}\n`, + "utf-8", + ) + : Promise.resolve(), + input.targetCatalog + ? writeFile( + path.join(directory, "target-catalog.json"), + `${JSON.stringify(input.targetCatalog, null, 2)}\n`, + "utf-8", + ) + : Promise.resolve(), + ]); + + return { + directory, + reportPath, + }; +} diff --git a/packages/pg-delta/tests/integration/supabase-project-runners.test.ts b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts new file mode 100644 index 00000000..4f88a795 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import dbdev from "./fixtures/supabase-projects/dbdev/project.ts"; +import { + buildSupabaseSmokeReproCommand, + resolveSupabaseSmokeStepConfig, +} from "./supabase-project-runners.ts"; + +test("repro command targets the pg-delta package test script", () => { + const command = buildSupabaseSmokeReproCommand(dbdev, "progressive", 2); + + expect(command).toContain("bun run --filter '@supabase/pg-delta' test"); + expect(command).toContain("PGDELTA_SUPABASE_PROJECT=dbdev"); + expect(command).toContain("PGDELTA_SUPABASE_SMOKE_STEP_FROM=2"); + expect(command).toContain("tests/integration/supabase-project-progressive.test.ts"); +}); + +test("step config rejects invalid or empty smoke ranges", () => { + expect(() => + resolveSupabaseSmokeStepConfig(4, { + stepFromEnv: "3", + stepToEnv: "1", + skipApplyEnv: undefined, + }), + ).toThrow(/No smoke steps selected/i); + + expect(() => + resolveSupabaseSmokeStepConfig(4, { + stepFromEnv: "not-a-number", + stepToEnv: "1", + skipApplyEnv: undefined, + }), + ).toThrow(/Invalid smoke step/i); + + expect(() => + resolveSupabaseSmokeStepConfig(4, { + stepFromEnv: "0.5", + stepToEnv: "1", + skipApplyEnv: undefined, + }), + ).toThrow(/Invalid smoke step/i); +}); diff --git a/packages/pg-delta/tests/integration/supabase-project-runners.ts b/packages/pg-delta/tests/integration/supabase-project-runners.ts new file mode 100644 index 00000000..74d604e6 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-runners.ts @@ -0,0 +1,791 @@ +import type { Pool } from "pg"; +import { diffCatalogs } from "../../src/core/catalog.diff.ts"; +import { extractCatalog } from "../../src/core/catalog.model.ts"; +import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; +import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; +import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; +import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.ts"; +import { createPlan } from "../../src/core/plan/create.ts"; +import { createPool, endPool } from "../../src/core/postgres-config.ts"; +import { sortChanges } from "../../src/core/sort/sort-changes.ts"; +import { POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG } from "../constants.ts"; +import { SupabasePostgreSqlContainer } from "../supabase-postgres.js"; +import { + loadSupabaseProjectMigrations, + type SupabaseProjectFixture, + type SupabaseProjectMigration, +} from "./supabase-project-fixture.ts"; +import { + writeSupabaseSmokeFailureArtifacts, + type SupabaseSmokeScenarioName, +} from "./supabase-project-report.ts"; + +type ProjectPools = { + mainPool: Pool; + branchPool: Pool; + image: string; +}; + +type AppliedMigrationResult = { + appliedMigrations: SupabaseProjectMigration[]; + skippedMigrations: string[]; +}; + +type SmokeStepResult = { + step: number; + migrationApplied: string; + planStatus: "no_changes" | "success" | "error"; + planError?: string; + changeCount?: number; + statementCount?: number; + applyStatus?: "success" | "error" | "skipped"; + applyError?: string; + applyFailedStatement?: string; + remainingChanges?: number; + planSql?: string; + remainingSql?: string; + sourceCatalog?: unknown; + targetCatalog?: unknown; +}; + +function suppressShutdownError(err: Error & { code?: string }) { + if (err.code === "57P01" || err.code === "53100") return; + console.error("Pool error:", err); +} + +function createProjectPool( + fixture: SupabaseProjectFixture, + connectionUri: string, +): Pool { + return createPool(connectionUri, { + onError: suppressShutdownError, + onConnect: async (client) => { + if (fixture.setRole) { + await client.query(`SET ROLE ${fixture.setRole}`); + } + }, + }); +} + +async function withSupabaseProjectPools( + fixture: SupabaseProjectFixture, + fn: (pools: ProjectPools) => Promise, +): Promise { + const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[fixture.supabasePostgresVersion]}`; + const [containerMain, containerBranch] = await Promise.all([ + new SupabasePostgreSqlContainer(image).start(), + new SupabasePostgreSqlContainer(image).start(), + ]); + const mainPool = createProjectPool(fixture, containerMain.getConnectionUri()); + const branchPool = createProjectPool( + fixture, + containerBranch.getConnectionUri(), + ); + + try { + return await fn({ mainPool, branchPool, image }); + } finally { + await Promise.all([endPool(mainPool), endPool(branchPool)]); + await Promise.all([containerMain.stop(), containerBranch.stop()]); + } +} + +async function applyProjectMigrations( + pool: Pool, + migrations: SupabaseProjectMigration[], + onApplyError: "fail" | "skip" = "fail", +): Promise { + const appliedMigrations: SupabaseProjectMigration[] = []; + const skippedMigrations: string[] = []; + + for (const migration of migrations) { + try { + await pool.query(migration.sql); + appliedMigrations.push(migration); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (onApplyError === "skip") { + skippedMigrations.push(`${migration.filename}: ${message}`); + continue; + } + + throw new Error(`Migration ${migration.filename} failed: ${message}`, { + cause: err, + }); + } + } + + return { appliedMigrations, skippedMigrations }; +} + +function formatRemainingChanges( + mainCatalog: Awaited>, + branchCatalog: Awaited>, + remainingChanges: ReturnType, +) { + const sorted = sortChanges({ mainCatalog, branchCatalog }, remainingChanges); + const remainingSql = sorted.map((change) => change.serialize()).join(";\n"); + return { + remainingSql, + remainingSummary: sorted.map((change) => ({ + change: change.constructor.name, + op: change.operation, + objectType: change.objectType, + scope: change.scope || "object", + creates: change.creates, + drops: change.drops, + requires: change.requires, + })), + }; +} + +function formatDeclarativeApplyFailure( + applyResult: Awaited>, +): string { + const stuckSql = applyResult.apply.stuckStatements + ?.map((statement) => `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`) + .join("\n"); + const errorSql = applyResult.apply.errors + ?.map((statement) => `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`) + .join("\n"); + const validationSql = applyResult.apply.validationErrors + ?.map((statement) => `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`) + .join("\n"); + + return stuckSql ?? errorSql ?? validationSql ?? "(no detail)"; +} + +export function resolveSupabaseSmokeStepConfig( + totalSteps: number, + env: { + stepFromEnv?: string; + stepToEnv?: string; + skipApplyEnv?: string; + } = {}, +) { + const stepFromRaw = env.stepFromEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_STEP_FROM; + const stepToRaw = env.stepToEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_STEP_TO; + const skipApplyRaw = + env.skipApplyEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_SKIP_APPLY; + const stepFrom = stepFromRaw !== undefined ? Number(stepFromRaw) : 0; + const stepTo = stepToRaw !== undefined ? Number(stepToRaw) : totalSteps; + + if (Number.isNaN(stepFrom) || Number.isNaN(stepTo)) { + throw new Error( + `Invalid smoke step range: from=${stepFromRaw ?? "0"} to=${stepToRaw ?? String(totalSteps)}`, + ); + } + + if (!Number.isInteger(stepFrom) || !Number.isInteger(stepTo)) { + throw new Error( + `Invalid smoke step range: from=${stepFromRaw ?? "0"} to=${stepToRaw ?? String(totalSteps)}`, + ); + } + + if (stepFrom < 0 || stepTo < 0) { + throw new Error(`Invalid smoke step range: from=${stepFrom} to=${stepTo}`); + } + + const boundedStepTo = Math.min(Math.max(stepTo, 0), totalSteps); + + if (stepFrom > boundedStepTo || stepFrom > totalSteps) { + throw new Error( + `No smoke steps selected: from=${stepFrom} to=${boundedStepTo} total=${totalSteps}`, + ); + } + + return { + stepFrom, + stepTo: boundedStepTo, + skipApply: skipApplyRaw === "1", + }; +} + +function formatSmokeResultsSummary( + fixture: SupabaseProjectFixture, + scenarioName: "progressive" | "adjacent", + image: string, + results: SmokeStepResult[], + skippedMigrations: string[], +): string { + const failures = results.filter( + (result) => + result.planStatus === "error" || result.applyStatus === "error", + ); + + const lines = [ + `${fixture.id} ${scenarioName} smoke failed on ${failures.length} step(s)`, + `Image: ${image}`, + ]; + + if (skippedMigrations.length > 0) { + lines.push(`Skipped migrations:\n${skippedMigrations.join("\n")}`); + } + + for (const failure of failures) { + lines.push( + [ + `Step ${failure.step}: after "${failure.migrationApplied}"`, + failure.planStatus === "error" + ? `Plan generation failed: ${failure.planError}` + : "", + failure.applyStatus === "error" + ? `Apply verification failed: ${failure.applyError}` + : "", + failure.applyFailedStatement + ? `Failed statement:\n${failure.applyFailedStatement}` + : "", + failure.remainingChanges !== undefined + ? `Remaining changes: ${failure.remainingChanges}` + : "", + ] + .filter(Boolean) + .join("\n"), + ); + } + + lines.push( + [ + "Full results:", + ...results.map((result) => + [ + `- step=${result.step}`, + `migration=${result.migrationApplied}`, + `plan=${result.planStatus}`, + `changes=${result.changeCount ?? "-"}`, + `statements=${result.statementCount ?? "-"}`, + `apply=${result.applyStatus ?? "-"}`, + ].join(" "), + ), + ].join("\n"), + ); + + return lines.join("\n\n"); +} + +function getScenarioTestPath(scenarioName: SupabaseSmokeScenarioName): string { + switch (scenarioName) { + case "declarative": + return "tests/integration/supabase-project-declarative.test.ts"; + case "progressive": + return "tests/integration/supabase-project-progressive.test.ts"; + case "adjacent": + return "tests/integration/supabase-project-adjacent.test.ts"; + } +} + +export function buildSupabaseSmokeReproCommand( + fixture: SupabaseProjectFixture, + scenarioName: SupabaseSmokeScenarioName, + step?: number, +): string { + const parts = [ + `PGDELTA_TEST_POSTGRES_VERSIONS=${fixture.supabasePostgresVersion}`, + `PGDELTA_SUPABASE_PROJECT=${fixture.id}`, + ]; + + if (step !== undefined && scenarioName !== "declarative") { + parts.push(`PGDELTA_SUPABASE_SMOKE_STEP_FROM=${step}`); + parts.push(`PGDELTA_SUPABASE_SMOKE_STEP_TO=${step}`); + } + + parts.push("bun run --filter '@supabase/pg-delta' test"); + parts.push(getScenarioTestPath(scenarioName)); + return parts.join(" "); +} + +async function writeScenarioFailureArtifacts(input: { + fixture: SupabaseProjectFixture; + scenarioName: SupabaseSmokeScenarioName; + image: string; + errorMessage: string; + step?: number; + migrationName?: string; + skippedMigrations?: string[]; + planSql?: string; + remainingSql?: string; + sourceCatalog?: unknown; + targetCatalog?: unknown; +}) { + return writeSupabaseSmokeFailureArtifacts({ + fixtureId: input.fixture.id, + fixtureDisplayName: input.fixture.displayName, + scenarioName: input.scenarioName, + image: input.image, + step: input.step, + migrationName: input.migrationName, + errorMessage: input.errorMessage, + skippedMigrations: input.skippedMigrations, + reproCommand: buildSupabaseSmokeReproCommand( + input.fixture, + input.scenarioName, + input.step, + ), + planSql: input.planSql, + remainingSql: input.remainingSql, + sourceCatalog: input.sourceCatalog, + targetCatalog: input.targetCatalog, + candidateRegressionNote: input.fixture.candidateRegressionNote, + }); +} + +export async function runSupabaseProjectDeclarativeRoundtrip( + fixture: SupabaseProjectFixture, +): Promise { + const scenario = fixture.scenarios.declarative; + const migrations = await loadSupabaseProjectMigrations(fixture, "declarative"); + + await withSupabaseProjectPools(fixture, async ({ mainPool, branchPool, image }) => { + let skippedMigrations: string[] = []; + let planSql: string | undefined; + let remainingSql: string | undefined; + let sourceCatalog: unknown; + let targetCatalog: unknown; + + try { + const applied = await applyProjectMigrations( + branchPool, + migrations, + scenario.onApplyError ?? "fail", + ); + skippedMigrations = applied.skippedMigrations; + + if (applied.appliedMigrations.length === 0) { + throw new Error( + `No migrations were applied for ${fixture.id} declarative scenario`, + ); + } + + const compiledFilter = fixture.integration.filter + ? compileFilterDSL(fixture.integration.filter) + : undefined; + const compiledSerialize = fixture.integration.serialize + ? compileSerializeDSL(fixture.integration.serialize) + : undefined; + + const planResult = await createPlan(mainPool, branchPool, { + filter: fixture.integration.filter, + serialize: fixture.integration.serialize, + skipDefaultPrivilegeSubtraction: + fixture.skipDefaultPrivilegeSubtraction ?? false, + }); + + if (!planResult) { + throw new Error( + `createPlan returned null for ${fixture.id} declarative scenario using ${image}`, + ); + } + + const output = exportDeclarativeSchema(planResult, { + integration: compiledSerialize + ? { serialize: compiledSerialize } + : undefined, + }); + planSql = output.files + .map((file) => `-- ${file.path}\n${file.sql}`) + .join("\n\n"); + + const applyResult = await applyDeclarativeSchema({ + content: output.files.map((file) => ({ filePath: file.path, sql: file.sql })), + pool: mainPool, + disableCheckFunctionBodies: true, + validateFunctionBodies: fixture.validateFunctionBodies ?? false, + }); + + if (applyResult.apply.status !== "success") { + throw new Error( + [ + `Declarative apply failed for ${fixture.id} (${applyResult.apply.status})`, + skippedMigrations.length > 0 + ? `Skipped migrations:\n${skippedMigrations.join("\n")}` + : "", + formatDeclarativeApplyFailure(applyResult), + ] + .filter(Boolean) + .join("\n\n"), + { cause: applyResult }, + ); + } + + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + sourceCatalog = mainCatalog; + targetCatalog = branchCatalog; + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remainingChanges = compiledFilter + ? allChanges.filter(compiledFilter) + : allChanges; + + if (remainingChanges.length > 0) { + const formatted = formatRemainingChanges( + mainCatalog, + branchCatalog, + remainingChanges, + ); + remainingSql = formatted.remainingSql; + + throw new Error( + [ + `Declarative roundtrip left ${remainingChanges.length} change(s) for ${fixture.id}`, + `Image: ${image}`, + skippedMigrations.length > 0 + ? `Skipped migrations:\n${skippedMigrations.join("\n")}` + : "", + `Remaining summary: ${JSON.stringify(formatted.remainingSummary, null, 2)}`, + `Remaining SQL:\n${formatted.remainingSql || "(no SQL generated)"}`, + ] + .filter(Boolean) + .join("\n\n"), + ); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const artifacts = await writeScenarioFailureArtifacts({ + fixture, + scenarioName: "declarative", + image, + errorMessage: message, + skippedMigrations, + planSql, + remainingSql, + sourceCatalog, + targetCatalog, + }); + + throw new Error(`${message}\n\nFailure artifacts: ${artifacts.reportPath}`, { + cause: err, + }); + } + }); +} + +export async function runSupabaseProjectProgressiveSmoke( + fixture: SupabaseProjectFixture, +): Promise { + const scenario = fixture.scenarios.progressive; + const migrations = await loadSupabaseProjectMigrations(fixture, "progressive"); + + await withSupabaseProjectPools(fixture, async ({ mainPool, branchPool, image }) => { + const { appliedMigrations, skippedMigrations } = await applyProjectMigrations( + branchPool, + migrations, + scenario.onApplyError ?? "fail", + ); + const { stepFrom, stepTo, skipApply } = resolveSupabaseSmokeStepConfig( + appliedMigrations.length, + ); + const compiledFilter = fixture.integration.filter + ? compileFilterDSL(fixture.integration.filter) + : undefined; + const results: SmokeStepResult[] = []; + + for (let step = 0; step <= stepTo; step += 1) { + const migrationName = + step === 0 ? "(empty)" : appliedMigrations[step - 1].filename; + + if (step < stepFrom) { + if (step > 0) { + await mainPool.query(appliedMigrations[step - 1].sql); + } + continue; + } + + const result: SmokeStepResult = { + step, + migrationApplied: migrationName, + planStatus: "success", + }; + + if (step > 0) { + try { + await mainPool.query(appliedMigrations[step - 1].sql); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.planStatus = "error"; + result.planError = `Migration apply failed on main: ${message}`; + results.push(result); + continue; + } + } + + try { + const planResult = await createPlan(mainPool, branchPool, { + filter: fixture.integration.filter, + serialize: fixture.integration.serialize, + skipDefaultPrivilegeSubtraction: + fixture.skipDefaultPrivilegeSubtraction ?? false, + }); + + if (!planResult) { + result.planStatus = "no_changes"; + result.changeCount = 0; + result.statementCount = 0; + result.applyStatus = "skipped"; + results.push(result); + continue; + } + + result.changeCount = planResult.sortedChanges.length; + result.statementCount = planResult.plan.statements.length; + result.planSql = planResult.plan.statements.join(";\n\n"); + + if (skipApply) { + result.applyStatus = "skipped"; + results.push(result); + continue; + } + + let failedStatement: string | undefined; + + try { + await mainPool.query("BEGIN"); + await mainPool.query("SET LOCAL check_function_bodies = false"); + + for (const statement of planResult.plan.statements) { + failedStatement = statement; + await mainPool.query(statement); + } + + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + result.sourceCatalog = mainCatalog; + result.targetCatalog = branchCatalog; + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remainingChanges = compiledFilter + ? allChanges.filter(compiledFilter) + : allChanges; + + result.remainingChanges = remainingChanges.length; + result.applyStatus = + remainingChanges.length === 0 ? "success" : "error"; + + if (remainingChanges.length > 0) { + const { remainingSql } = formatRemainingChanges( + mainCatalog, + branchCatalog, + remainingChanges, + ); + result.remainingSql = remainingSql; + result.applyError = remainingSql || "Remaining changes after apply"; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.applyStatus = "error"; + result.applyError = message; + result.applyFailedStatement = failedStatement; + } finally { + try { + await mainPool.query("ROLLBACK"); + } catch { + // Connection may have been interrupted; ignore rollback errors. + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.planStatus = "error"; + result.planError = message; + } + + results.push(result); + } + + const failures = results.filter( + (result) => + result.planStatus === "error" || result.applyStatus === "error", + ); + if (failures.length > 0) { + const summary = formatSmokeResultsSummary( + fixture, + "progressive", + image, + results, + skippedMigrations, + ); + const firstFailure = failures[0]; + const artifacts = await writeScenarioFailureArtifacts({ + fixture, + scenarioName: "progressive", + image, + errorMessage: summary, + step: firstFailure.step, + migrationName: firstFailure.migrationApplied, + skippedMigrations, + planSql: firstFailure.planSql, + remainingSql: firstFailure.remainingSql, + sourceCatalog: firstFailure.sourceCatalog, + targetCatalog: firstFailure.targetCatalog, + }); + throw new Error( + `${summary}\n\nFailure artifacts: ${artifacts.reportPath}`, + ); + } + }); +} + +export async function runSupabaseProjectAdjacentSmoke( + fixture: SupabaseProjectFixture, +): Promise { + const scenario = fixture.scenarios.adjacent; + const migrations = await loadSupabaseProjectMigrations(fixture, "adjacent"); + const applicable = await withSupabaseProjectPools( + fixture, + async ({ branchPool }) => + applyProjectMigrations(branchPool, migrations, scenario.onApplyError ?? "fail"), + ); + + if (applicable.appliedMigrations.length === 0) { + throw new Error(`No migrations were applied for ${fixture.id} adjacent scenario`); + } + + await withSupabaseProjectPools(fixture, async ({ mainPool, branchPool, image }) => { + const maxStep = Math.max(applicable.appliedMigrations.length - 1, 0); + const { stepFrom, stepTo, skipApply } = resolveSupabaseSmokeStepConfig( + maxStep, + ); + const compiledFilter = fixture.integration.filter + ? compileFilterDSL(fixture.integration.filter) + : undefined; + const results: SmokeStepResult[] = []; + + for ( + let step = 0; + step < applicable.appliedMigrations.length && step <= stepTo; + step += 1 + ) { + const migration = applicable.appliedMigrations[step]; + + await branchPool.query(migration.sql).catch((err) => { + throw new Error( + `Migration ${migration.filename} failed on branch during adjacent smoke: ${err.message}`, + { cause: err }, + ); + }); + + if (step < stepFrom) { + await mainPool.query(migration.sql); + continue; + } + + const result: SmokeStepResult = { + step, + migrationApplied: migration.filename, + planStatus: "success", + }; + + try { + const planResult = await createPlan(mainPool, branchPool, { + filter: fixture.integration.filter, + serialize: fixture.integration.serialize, + skipDefaultPrivilegeSubtraction: + fixture.skipDefaultPrivilegeSubtraction ?? false, + }); + + if (!planResult) { + result.planStatus = "no_changes"; + result.changeCount = 0; + result.statementCount = 0; + result.applyStatus = "skipped"; + } else { + result.changeCount = planResult.sortedChanges.length; + result.statementCount = planResult.plan.statements.length; + result.planSql = planResult.plan.statements.join(";\n\n"); + + if (skipApply) { + result.applyStatus = "skipped"; + } else { + let failedStatement: string | undefined; + + try { + await mainPool.query("BEGIN"); + await mainPool.query("SET LOCAL check_function_bodies = false"); + + for (const statement of planResult.plan.statements) { + failedStatement = statement; + await mainPool.query(statement); + } + + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + result.sourceCatalog = mainCatalog; + result.targetCatalog = branchCatalog; + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remainingChanges = compiledFilter + ? allChanges.filter(compiledFilter) + : allChanges; + + result.remainingChanges = remainingChanges.length; + result.applyStatus = + remainingChanges.length === 0 ? "success" : "error"; + + if (remainingChanges.length > 0) { + const { remainingSql } = formatRemainingChanges( + mainCatalog, + branchCatalog, + remainingChanges, + ); + result.remainingSql = remainingSql; + result.applyError = + remainingSql || "Remaining changes after adjacent apply"; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.applyStatus = "error"; + result.applyError = message; + result.applyFailedStatement = failedStatement; + } finally { + try { + await mainPool.query("ROLLBACK"); + } catch { + // Connection may have been interrupted; ignore rollback errors. + } + } + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.planStatus = "error"; + result.planError = message; + } + + results.push(result); + + await mainPool.query(migration.sql).catch((err) => { + throw new Error( + `Migration ${migration.filename} failed while advancing main during adjacent smoke: ${err.message}`, + { cause: err }, + ); + }); + } + + const failures = results.filter( + (result) => + result.planStatus === "error" || result.applyStatus === "error", + ); + if (failures.length > 0) { + const summary = formatSmokeResultsSummary( + fixture, + "adjacent", + image, + results, + applicable.skippedMigrations, + ); + const firstFailure = failures[0]; + const artifacts = await writeScenarioFailureArtifacts({ + fixture, + scenarioName: "adjacent", + image, + errorMessage: summary, + step: firstFailure.step, + migrationName: firstFailure.migrationApplied, + skippedMigrations: applicable.skippedMigrations, + planSql: firstFailure.planSql, + remainingSql: firstFailure.remainingSql, + sourceCatalog: firstFailure.sourceCatalog, + targetCatalog: firstFailure.targetCatalog, + }); + throw new Error( + `${summary}\n\nFailure artifacts: ${artifacts.reportPath}`, + ); + } + }); +} From b3999339c7657b2dcaa625b2659c5b56cbb8f7d7 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 27 Apr 2026 18:19:07 +0200 Subject: [PATCH 8/9] wip: merge main --- .github/agents/pg-toolbelt.md | 15 +- .../supabase-projects/dbdev/project.ts | 6 + .../supabase-project-adjacent.test.ts | 21 +- .../supabase-project-declarative.test.ts | 21 +- .../integration/supabase-project-fixture.ts | 6 +- .../integration/supabase-project-fixtures.md | 106 +++ .../supabase-project-progressive.test.ts | 21 +- .../supabase-project-report.test.ts | 12 +- .../integration/supabase-project-report.ts | 32 +- .../supabase-project-runners.test.ts | 4 +- .../integration/supabase-project-runners.ts | 870 +++++++++--------- packages/pg-delta/tests/utils.ts | 88 +- 12 files changed, 693 insertions(+), 509 deletions(-) create mode 100644 packages/pg-delta/tests/integration/supabase-project-fixtures.md diff --git a/.github/agents/pg-toolbelt.md b/.github/agents/pg-toolbelt.md index 69d7c8ca..dcc7a9f1 100644 --- a/.github/agents/pg-toolbelt.md +++ b/.github/agents/pg-toolbelt.md @@ -188,18 +188,6 @@ Wait for user approval before implementing. When implementing a **fix**, **feat**, or any change that affects package behavior (patch/minor/major), add a changeset before considering the work complete. Run `bunx changeset`, select the affected package(s), pick the appropriate bump type, and commit the generated `.changeset/*.md` file with your changes. -<<<<<<< copilot/handle-cascade-dependencies-mv -### Test-Driven Fixes - -Every bug fix must land as two commits (or one clearly described TDD history in the commit body): - -1. **Red.** Add a test that reproduces the bug and fails against the current code. Run the focused command and paste the failure output into the commit body so reviewers can see the regression shape. -2. **Green.** Apply the minimum code change that makes the red test pass. Do not touch unrelated code in the same commit. - -A fix without a failing test first is not complete. If the bug genuinely cannot be reproduced in a test (e.g. a race, a user-environment-only issue), say so explicitly in the PR and explain what manual verification was performed instead. - -This rule applies to every `fix(...)` and to any `feat(...)` that changes existing behavior. New `feat(...)` work follows the usual coverage expectations in the _Test Coverage Expectations_ section. -======= See also **Test-Driven Fixes** below — the regression test must exist (and fail) before the fix that the changeset describes. ### Test-Driven Fixes @@ -225,7 +213,6 @@ If a repository policy or reviewer asks for a single squashed commit, keep the R - Refactors that claim to preserve behavior: if there is doubt, pin the current behavior with a passing test first, then refactor. **Don't:** write the production code first and then "backfill" a test that already passes. That test cannot prove the fix was necessary. ->>>>>>> main ### Testing Discipline @@ -269,7 +256,7 @@ baseline fixtures as part of the upgrade. - After upgrading the image tags, rerun the focused regression tests before considering the upgrade done: - `cd packages/pg-delta && PGDELTA_TEST_POSTGRES_VERSIONS=15,17 bun run test tests/integration/supabase-base-init.test.ts tests/integration/catalog-model.test.ts tests/integration/supabase-dsl-e2e.test.ts` - - `cd packages/pg-delta && PGDELTA_TEST_POSTGRES_VERSIONS=15 bun run test tests/integration/dbdev-roundtrip.test.ts` + - `cd packages/pg-delta && PGDELTA_TEST_POSTGRES_VERSIONS=15 PGDELTA_SUPABASE_PROJECT=dbdev bun run test tests/integration/supabase-project-declarative.test.ts` - If the sync script or focused tests reveal new schemas, roles, grants, or comments, update pg-delta’s Supabase handling (for example `packages/pg-delta/src/core/integrations/supabase.ts` or the relevant diff --git a/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts index 2020680f..df4c07a3 100644 --- a/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts +++ b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts @@ -17,6 +17,12 @@ export default defineSupabaseProjectFixture({ onApplyError: "fail", }, progressive: { + // The runner compares stepping main against a *fully* migrated branch. With + // the full history, the plan is a "catch up" to head that cannot be + // applied as one plan (statements like ADD COLUMN without migration-time + // DEFAULT/backfill from later files). Scope to the same prefix as + // declarative so the target branch and incremental main can converge. + include: (filename) => filename.startsWith("20220117"), onApplyError: "skip", }, adjacent: { diff --git a/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts b/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts index a2b6a9b1..6d251542 100644 --- a/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts @@ -5,16 +5,13 @@ import { runSupabaseProjectAdjacentSmoke } from "./supabase-project-runners.ts"; const fixtures = await discoverSupabaseProjectFixtures(); for (const fixture of fixtures) { - describe( - `${fixture.displayName} adjacent smoke (pg${fixture.supabasePostgresVersion})`, - () => { - test( - "each individual migration step plans and applies cleanly against the next prefix", - async () => { - await runSupabaseProjectAdjacentSmoke(fixture); - }, - 30 * 60 * 1000, - ); - }, - ); + describe(`${fixture.displayName} adjacent smoke (pg${fixture.supabasePostgresVersion})`, () => { + test( + "each individual migration step plans and applies cleanly against the next prefix", + async () => { + await runSupabaseProjectAdjacentSmoke(fixture); + }, + 30 * 60 * 1000, + ); + }); } diff --git a/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts b/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts index 719cdf2a..ce7b63fb 100644 --- a/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts @@ -5,16 +5,13 @@ import { runSupabaseProjectDeclarativeRoundtrip } from "./supabase-project-runne const fixtures = await discoverSupabaseProjectFixtures(); for (const fixture of fixtures) { - describe( - `${fixture.displayName} declarative roundtrip (pg${fixture.supabasePostgresVersion})`, - () => { - test( - "exported schema roundtrips to 0 remaining changes with supabase integration", - async () => { - await runSupabaseProjectDeclarativeRoundtrip(fixture); - }, - 5 * 60 * 1000, - ); - }, - ); + describe(`${fixture.displayName} declarative roundtrip (pg${fixture.supabasePostgresVersion})`, () => { + test( + "exported schema roundtrips to 0 remaining changes with supabase integration", + async () => { + await runSupabaseProjectDeclarativeRoundtrip(fixture); + }, + 5 * 60 * 1000, + ); + }); } diff --git a/packages/pg-delta/tests/integration/supabase-project-fixture.ts b/packages/pg-delta/tests/integration/supabase-project-fixture.ts index 9a0920dc..2d0c4cbc 100644 --- a/packages/pg-delta/tests/integration/supabase-project-fixture.ts +++ b/packages/pg-delta/tests/integration/supabase-project-fixture.ts @@ -65,7 +65,11 @@ export async function discoverSupabaseProjectFixtures(): Promise< for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory()) continue; - const projectFile = path.join(SUPABASE_PROJECTS_DIR, entry.name, "project.ts"); + const projectFile = path.join( + SUPABASE_PROJECTS_DIR, + entry.name, + "project.ts", + ); if (!(await Bun.file(projectFile).exists())) { continue; } diff --git a/packages/pg-delta/tests/integration/supabase-project-fixtures.md b/packages/pg-delta/tests/integration/supabase-project-fixtures.md new file mode 100644 index 00000000..e491f559 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-fixtures.md @@ -0,0 +1,106 @@ +# Supabase project integration fixtures + +This document describes the **Supabase project fixture** layout and tests added to replace the single-file `dbdev-roundtrip.test.ts` integration. + +## Why this exists + +The previous pattern bundled the **dbdev** migration SQL under `fixtures/dbdev-migrations/` and encoded the full declarative roundtrip in one long test. The new design: + +- **Scales to more real-world projects** — each project is a directory with a `project.ts` manifest and a `migrations/` tree. +- **Separates concerns** — discovery (`supabase-project-fixture.ts`), scenario runners (`supabase-project-runners.ts`), and failure reporting (`supabase-project-report.ts`) are reusable. +- **Supports multiple validation modes** — not only “export then apply and diff to zero,” but also stepwise **smoke** tests over migration history. + +## Directory layout + +``` +tests/integration/ + fixtures/supabase-projects/ + / + project.ts # default export: SupabaseProjectFixture + migrations/ # ordered *.sql files (lexicographic name order) + ... + supabase-project-fixture.ts + supabase-project-runners.ts + supabase-project-report.ts + supabase-project-*.test.ts +``` + +**Current projects:** `dbdev` (Supabase Postgres 15), migrated from the old `dbdev-migrations` path without changing SQL contents (paths-only rename). + +## Fixture manifest (`project.ts`) + +A fixture is created with `defineSupabaseProjectFixture({ ... })` and exported as `default`. Important fields: + +| Field | Purpose | +|--------|--------| +| `id` | Stable id used in `PGDELTA_SUPABASE_PROJECT` and artifact names. | +| `supabasePostgresVersion` | Which Supabase image line to use (must be listed in `SUPABASE_POSTGRES_VERSIONS` in `tests/constants.ts`). | +| `integration` | e.g. the shared `supabase` integration (filter + serialize DSL). | +| `migrationsDir` | URL or path to the `migrations/` folder. | +| `setRole` | Optional per-connection `SET ROLE` (dbdev uses `postgres` to mirror CLI behavior around default privileges for `createPlan` / export / apply). The generated `*_fullstack_container_init.sql` is replayed **before** any `SET ROLE`, using a bootstrap connection (so `auth` and service schemas can be altered). Fixture migration files then run on the same pools that perform planning (with `SET ROLE` when this field is set), matching the old `dbdev-roundtrip` behavior for schema ownership. | +| `skipDefaultPrivilegeSubtraction` | Passed to `createPlan` (dbdev sets `true` for the known GRANT / `ALTER DEFAULT PRIVILEGES` interaction). | +| `validateFunctionBodies` | Declarative apply: whether to validate function bodies (default false for Supabase projects that reference `auth` and friends). | +| `candidateRegressionNote` | Shown in failure `report.md` — guidance for turning a failure into a smaller regression test. | +| `scenarios` | Per-scenario options (see below). | + +### Scenarios + +Each scenario name maps to optional: + +- `include` — filter migration **filenames** (e.g. dbdev **declarative** only runs `20220117*` to keep the test fast and avoid later data-only migrations that break across image versions). +- `onApplyError` — `fail` (default) or `skip` when applying migrations to the branch database (useful when some files are expected to fail in CI until images catch up). + +**Progressive scope (dbdev):** The progressive runner keeps the **branch** on the fully migrated set while **main** advances one file at a time, then `createPlan`/`apply` must reconcile main to *that* branch. Comparing a half-migrated `main` to a branch that already includes *later* migrations (e.g. multi-statement `ADD COLUMN` + backfill in one migration file) is not, in general, a single plan that applies cleanly, so the dbdev fixture **limits the progressive `include` filter** to the same `20220117*` prefix as the declarative scenario. Adjacent mode applies one migration at a time on both sides, so it does not need the same cap. + +Three scenario **names** are built in: + +1. **`declarative`** — Full **declarative roundtrip**: base Supabase init on both sides, apply scenario migrations to **branch** only, `createPlan` → `exportDeclarativeSchema` → `applyDeclarativeSchema` on **main**, then assert filtered diff is empty. Entry: `supabase-project-declarative.test.ts`. + +2. **`progressive`** — After all branch migrations apply, **advance main one migration at a time** (step 0 = empty main). At each step, `createPlan`, optionally apply plan in a transaction, and assert no remaining filtered changes. Entry: `supabase-project-progressive.test.ts`. + +3. **`adjacent`** — For each migration in order, apply it on **branch**, then plan/apply on **main** and catch up **main** with the same migration after the check (pairwise “adjacent” state). Entry: `supabase-project-adjacent.test.ts`. + +## Discovery and env vars + +`discoverSupabaseProjectFixtures()` scans `fixtures/supabase-projects/*/` for `project.ts` and returns every fixture whose `supabasePostgresVersion` is in the current test matrix. + +| Variable | Effect | +|----------|--------| +| `PGDELTA_SUPABASE_PROJECT` | If set, only the fixture with matching `id` is loaded (faster local runs). | +| `PGDELTA_SUPABASE_SMOKE_STEP_FROM` / `PGDELTA_SUPABASE_SMOKE_STEP_TO` | Limit which steps run in **progressive** / **adjacent** smoke (0-based; inclusive range). | +| `PGDELTA_SUPABASE_SMOKE_SKIP_APPLY` | `1` = only plan, do not apply statements in smoke scenarios. | +| `PGDELTA_SUPABASE_SMOKE_REPORT_DIR` | Override base directory for failure artifacts (default under `packages/pg-delta/test-results/supabase-smoke/`). | + +Repro commands for a failed step are built in `buildSupabaseSmokeReproCommand()` (see `supabase-project-runners.test.ts`). + +## Failure artifacts + +On failure, `writeSupabaseSmokeFailureArtifacts()` writes a directory with: + +- `report.md` — human-readable summary, error, repro command, optional plan/remaining SQL, candidate regression note. +- `metadata.json` — structured fields for tooling. +- Optional `plan.sql`, `remaining.sql`, `source-catalog.json`, `target-catalog.json`. + +`packages/pg-delta/test-results/` is gitignored (see root `.gitignore`). + +## Tests (file map) + +| File | Role | +|------|------| +| `supabase-project-fixture.test.ts` | Asserts dbdev layout and manifest basics. | +| `supabase-project-declarative.test.ts` | Declarative roundtrip for all discovered fixtures. | +| `supabase-project-progressive.test.ts` | Progressive smoke. | +| `supabase-project-adjacent.test.ts` | Adjacent smoke. | +| `supabase-project-runners.test.ts` | Unit tests for repro command and step range validation. | +| `supabase-project-report.test.ts` | Unit test for artifact writer. | + +## Adding a new project + +1. Create `fixtures/supabase-projects//migrations/` with SQL files. +2. Add `project.ts` exporting `defineSupabaseProjectFixture({ ... })` with the right `supabasePostgresVersion`, `integration`, and `scenarios`. +3. Ensure the version is in `SUPABASE_POSTGRES_VERSIONS` if the tests should run in CI. +4. Run a focused check, e.g. `PGDELTA_TEST_POSTGRES_VERSIONS= PGDELTA_SUPABASE_PROJECT= bun run test tests/integration/supabase-project-declarative.test.ts`. + +## Relationship to `applySupabaseBaseInit` + +The runners start `SupabasePostgreSqlContainer` and use the same **base-init** replay as other Supabase integration tests (via the shared test utilities / container setup). Any new project that depends on stock Supabase objects must keep that bootstrap path consistent with `tests/utils.ts` and the generated `supabase-base-init` fixtures when images change. diff --git a/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts b/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts index 017bc473..6d3524fe 100644 --- a/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts @@ -5,16 +5,13 @@ import { runSupabaseProjectProgressiveSmoke } from "./supabase-project-runners.t const fixtures = await discoverSupabaseProjectFixtures(); for (const fixture of fixtures) { - describe( - `${fixture.displayName} progressive smoke (pg${fixture.supabasePostgresVersion})`, - () => { - test( - "each migration prefix plans and applies cleanly against the fully migrated target", - async () => { - await runSupabaseProjectProgressiveSmoke(fixture); - }, - 30 * 60 * 1000, - ); - }, - ); + describe(`${fixture.displayName} progressive smoke (pg${fixture.supabasePostgresVersion})`, () => { + test( + "each migration prefix plans and applies cleanly against the fully migrated target", + async () => { + await runSupabaseProjectProgressiveSmoke(fixture); + }, + 30 * 60 * 1000, + ); + }); } diff --git a/packages/pg-delta/tests/integration/supabase-project-report.test.ts b/packages/pg-delta/tests/integration/supabase-project-report.test.ts index cc056706..d546a566 100644 --- a/packages/pg-delta/tests/integration/supabase-project-report.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-report.test.ts @@ -29,15 +29,15 @@ test("writes markdown and companion artifacts for smoke failures", async () => { expect(result.reportPath).toContain("report.md"); expect(await Bun.file(result.reportPath).exists()).toBe(true); - expect(await Bun.file(path.join(result.directory, "metadata.json")).exists()).toBe( - true, - ); + expect( + await Bun.file(path.join(result.directory, "metadata.json")).exists(), + ).toBe(true); expect(await Bun.file(path.join(result.directory, "plan.sql")).exists()).toBe( true, ); - expect(await Bun.file(path.join(result.directory, "remaining.sql")).exists()).toBe( - true, - ); + expect( + await Bun.file(path.join(result.directory, "remaining.sql")).exists(), + ).toBe(true); const report = await readFile(result.reportPath, "utf-8"); expect(report).toContain("## Summary"); diff --git a/packages/pg-delta/tests/integration/supabase-project-report.ts b/packages/pg-delta/tests/integration/supabase-project-report.ts index 67a8b3d5..4ba5a48b 100644 --- a/packages/pg-delta/tests/integration/supabase-project-report.ts +++ b/packages/pg-delta/tests/integration/supabase-project-report.ts @@ -1,6 +1,10 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; +function jsonStringifySupabaseCatalog(value: unknown): string { + return `${JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? v.toString() : v), 2)}\n`; +} + export type SupabaseSmokeScenarioName = | "declarative" | "progressive" @@ -42,13 +46,17 @@ function sanitizeSegment(value: string): string { return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); } -function createArtifactId(input: WriteSupabaseSmokeFailureArtifactsInput): string { +function createArtifactId( + input: WriteSupabaseSmokeFailureArtifactsInput, +): string { if (input.artifactId) { return sanitizeSegment(input.artifactId); } const stepLabel = - input.step !== undefined ? `step-${String(input.step).padStart(4, "0")}` : "step-na"; + input.step !== undefined + ? `step-${String(input.step).padStart(4, "0")}` + : "step-na"; const migrationLabel = input.migrationName ? sanitizeSegment(input.migrationName.replace(/\.sql$/i, "")) : "migration-na"; @@ -80,9 +88,13 @@ function buildMarkdown( "```", "", input.skippedMigrations && input.skippedMigrations.length > 0 - ? ["## Skipped Migrations", "```text", ...input.skippedMigrations, "```", ""].join( - "\n", - ) + ? [ + "## Skipped Migrations", + "```text", + ...input.skippedMigrations, + "```", + "", + ].join("\n") : "", input.planSql ? ["## Plan SQL", "```sql", input.planSql, "```", ""].join("\n") @@ -147,7 +159,11 @@ export async function writeSupabaseSmokeFailureArtifacts( "utf-8", ), input.planSql - ? writeFile(path.join(directory, "plan.sql"), `${input.planSql}\n`, "utf-8") + ? writeFile( + path.join(directory, "plan.sql"), + `${input.planSql}\n`, + "utf-8", + ) : Promise.resolve(), input.remainingSql ? writeFile( @@ -159,14 +175,14 @@ export async function writeSupabaseSmokeFailureArtifacts( input.sourceCatalog ? writeFile( path.join(directory, "source-catalog.json"), - `${JSON.stringify(input.sourceCatalog, null, 2)}\n`, + jsonStringifySupabaseCatalog(input.sourceCatalog), "utf-8", ) : Promise.resolve(), input.targetCatalog ? writeFile( path.join(directory, "target-catalog.json"), - `${JSON.stringify(input.targetCatalog, null, 2)}\n`, + jsonStringifySupabaseCatalog(input.targetCatalog), "utf-8", ) : Promise.resolve(), diff --git a/packages/pg-delta/tests/integration/supabase-project-runners.test.ts b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts index 4f88a795..130901c3 100644 --- a/packages/pg-delta/tests/integration/supabase-project-runners.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts @@ -11,7 +11,9 @@ test("repro command targets the pg-delta package test script", () => { expect(command).toContain("bun run --filter '@supabase/pg-delta' test"); expect(command).toContain("PGDELTA_SUPABASE_PROJECT=dbdev"); expect(command).toContain("PGDELTA_SUPABASE_SMOKE_STEP_FROM=2"); - expect(command).toContain("tests/integration/supabase-project-progressive.test.ts"); + expect(command).toContain( + "tests/integration/supabase-project-progressive.test.ts", + ); }); test("step config rejects invalid or empty smoke ranges", () => { diff --git a/packages/pg-delta/tests/integration/supabase-project-runners.ts b/packages/pg-delta/tests/integration/supabase-project-runners.ts index 74d604e6..9cf76dd2 100644 --- a/packages/pg-delta/tests/integration/supabase-project-runners.ts +++ b/packages/pg-delta/tests/integration/supabase-project-runners.ts @@ -8,16 +8,18 @@ import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.t import { createPlan } from "../../src/core/plan/create.ts"; import { createPool, endPool } from "../../src/core/postgres-config.ts"; import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG } from "../constants.ts"; -import { SupabasePostgreSqlContainer } from "../supabase-postgres.js"; +import { + runWithSupabaseIsolatedBaseInit, + suppressShutdownError, +} from "../utils.ts"; import { loadSupabaseProjectMigrations, type SupabaseProjectFixture, type SupabaseProjectMigration, } from "./supabase-project-fixture.ts"; import { - writeSupabaseSmokeFailureArtifacts, type SupabaseSmokeScenarioName, + writeSupabaseSmokeFailureArtifacts, } from "./supabase-project-report.ts"; type ProjectPools = { @@ -48,11 +50,6 @@ type SmokeStepResult = { targetCatalog?: unknown; }; -function suppressShutdownError(err: Error & { code?: string }) { - if (err.code === "57P01" || err.code === "53100") return; - console.error("Pool error:", err); -} - function createProjectPool( fixture: SupabaseProjectFixture, connectionUri: string, @@ -71,23 +68,30 @@ async function withSupabaseProjectPools( fixture: SupabaseProjectFixture, fn: (pools: ProjectPools) => Promise, ): Promise { - const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[fixture.supabasePostgresVersion]}`; - const [containerMain, containerBranch] = await Promise.all([ - new SupabasePostgreSqlContainer(image).start(), - new SupabasePostgreSqlContainer(image).start(), - ]); - const mainPool = createProjectPool(fixture, containerMain.getConnectionUri()); - const branchPool = createProjectPool( - fixture, - containerBranch.getConnectionUri(), - ); + return runWithSupabaseIsolatedBaseInit( + fixture.supabasePostgresVersion, + async (ctx) => { + if (!fixture.setRole) { + return await fn({ + mainPool: ctx.main, + branchPool: ctx.branch, + image: ctx.image, + }); + } - try { - return await fn({ mainPool, branchPool, image }); - } finally { - await Promise.all([endPool(mainPool), endPool(branchPool)]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } + const mainPool = createProjectPool(fixture, ctx.mainUri); + const branchPool = createProjectPool(fixture, ctx.branchUri); + try { + return await fn({ + mainPool, + branchPool, + image: ctx.image, + }); + } finally { + await Promise.all([endPool(mainPool), endPool(branchPool)]); + } + }, + ); } async function applyProjectMigrations( @@ -143,13 +147,22 @@ function formatDeclarativeApplyFailure( applyResult: Awaited>, ): string { const stuckSql = applyResult.apply.stuckStatements - ?.map((statement) => `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`) + ?.map( + (statement) => + `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`, + ) .join("\n"); const errorSql = applyResult.apply.errors - ?.map((statement) => `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`) + ?.map( + (statement) => + `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`, + ) .join("\n"); const validationSql = applyResult.apply.validationErrors - ?.map((statement) => `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`) + ?.map( + (statement) => + `[${statement.code}] ${statement.message}\n SQL: ${statement.statement.sql}`, + ) .join("\n"); return stuckSql ?? errorSql ?? validationSql ?? "(no detail)"; @@ -163,7 +176,8 @@ export function resolveSupabaseSmokeStepConfig( skipApplyEnv?: string; } = {}, ) { - const stepFromRaw = env.stepFromEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_STEP_FROM; + const stepFromRaw = + env.stepFromEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_STEP_FROM; const stepToRaw = env.stepToEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_STEP_TO; const skipApplyRaw = env.skipApplyEnv ?? process.env.PGDELTA_SUPABASE_SMOKE_SKIP_APPLY; @@ -209,8 +223,7 @@ function formatSmokeResultsSummary( skippedMigrations: string[], ): string { const failures = results.filter( - (result) => - result.planStatus === "error" || result.applyStatus === "error", + (result) => result.planStatus === "error" || result.applyStatus === "error", ); const lines = [ @@ -333,293 +346,313 @@ export async function runSupabaseProjectDeclarativeRoundtrip( fixture: SupabaseProjectFixture, ): Promise { const scenario = fixture.scenarios.declarative; - const migrations = await loadSupabaseProjectMigrations(fixture, "declarative"); - - await withSupabaseProjectPools(fixture, async ({ mainPool, branchPool, image }) => { - let skippedMigrations: string[] = []; - let planSql: string | undefined; - let remainingSql: string | undefined; - let sourceCatalog: unknown; - let targetCatalog: unknown; + const migrations = await loadSupabaseProjectMigrations( + fixture, + "declarative", + ); - try { - const applied = await applyProjectMigrations( - branchPool, - migrations, - scenario.onApplyError ?? "fail", - ); - skippedMigrations = applied.skippedMigrations; + await withSupabaseProjectPools( + fixture, + async ({ mainPool, branchPool, image }) => { + let skippedMigrations: string[] = []; + let planSql: string | undefined; + let remainingSql: string | undefined; + let sourceCatalog: unknown; + let targetCatalog: unknown; - if (applied.appliedMigrations.length === 0) { - throw new Error( - `No migrations were applied for ${fixture.id} declarative scenario`, + try { + const applied = await applyProjectMigrations( + branchPool, + migrations, + scenario.onApplyError ?? "fail", ); - } + skippedMigrations = applied.skippedMigrations; - const compiledFilter = fixture.integration.filter - ? compileFilterDSL(fixture.integration.filter) - : undefined; - const compiledSerialize = fixture.integration.serialize - ? compileSerializeDSL(fixture.integration.serialize) - : undefined; + if (applied.appliedMigrations.length === 0) { + throw new Error( + `No migrations were applied for ${fixture.id} declarative scenario`, + ); + } - const planResult = await createPlan(mainPool, branchPool, { - filter: fixture.integration.filter, - serialize: fixture.integration.serialize, - skipDefaultPrivilegeSubtraction: - fixture.skipDefaultPrivilegeSubtraction ?? false, - }); + const compiledFilter = fixture.integration.filter + ? compileFilterDSL(fixture.integration.filter) + : undefined; + const compiledSerialize = fixture.integration.serialize + ? compileSerializeDSL(fixture.integration.serialize) + : undefined; - if (!planResult) { - throw new Error( - `createPlan returned null for ${fixture.id} declarative scenario using ${image}`, - ); - } + const planResult = await createPlan(mainPool, branchPool, { + filter: fixture.integration.filter, + serialize: fixture.integration.serialize, + skipDefaultPrivilegeSubtraction: + fixture.skipDefaultPrivilegeSubtraction ?? false, + }); - const output = exportDeclarativeSchema(planResult, { - integration: compiledSerialize - ? { serialize: compiledSerialize } - : undefined, - }); - planSql = output.files - .map((file) => `-- ${file.path}\n${file.sql}`) - .join("\n\n"); - - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((file) => ({ filePath: file.path, sql: file.sql })), - pool: mainPool, - disableCheckFunctionBodies: true, - validateFunctionBodies: fixture.validateFunctionBodies ?? false, - }); + if (!planResult) { + throw new Error( + `createPlan returned null for ${fixture.id} declarative scenario using ${image}`, + ); + } - if (applyResult.apply.status !== "success") { - throw new Error( - [ - `Declarative apply failed for ${fixture.id} (${applyResult.apply.status})`, - skippedMigrations.length > 0 - ? `Skipped migrations:\n${skippedMigrations.join("\n")}` - : "", - formatDeclarativeApplyFailure(applyResult), - ] - .filter(Boolean) - .join("\n\n"), - { cause: applyResult }, - ); - } + const output = exportDeclarativeSchema(planResult, { + integration: compiledSerialize + ? { serialize: compiledSerialize } + : undefined, + }); + planSql = output.files + .map((file) => `-- ${file.path}\n${file.sql}`) + .join("\n\n"); + + const applyResult = await applyDeclarativeSchema({ + content: output.files.map((file) => ({ + filePath: file.path, + sql: file.sql, + })), + pool: mainPool, + disableCheckFunctionBodies: true, + validateFunctionBodies: fixture.validateFunctionBodies ?? false, + }); - const mainCatalog = await extractCatalog(mainPool); - const branchCatalog = await extractCatalog(branchPool); - sourceCatalog = mainCatalog; - targetCatalog = branchCatalog; - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = compiledFilter - ? allChanges.filter(compiledFilter) - : allChanges; - - if (remainingChanges.length > 0) { - const formatted = formatRemainingChanges( - mainCatalog, - branchCatalog, - remainingChanges, - ); - remainingSql = formatted.remainingSql; + if (applyResult.apply.status !== "success") { + throw new Error( + [ + `Declarative apply failed for ${fixture.id} (${applyResult.apply.status})`, + skippedMigrations.length > 0 + ? `Skipped migrations:\n${skippedMigrations.join("\n")}` + : "", + formatDeclarativeApplyFailure(applyResult), + ] + .filter(Boolean) + .join("\n\n"), + { cause: applyResult }, + ); + } + + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + sourceCatalog = mainCatalog; + targetCatalog = branchCatalog; + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remainingChanges = compiledFilter + ? allChanges.filter(compiledFilter) + : allChanges; + + if (remainingChanges.length > 0) { + const formatted = formatRemainingChanges( + mainCatalog, + branchCatalog, + remainingChanges, + ); + remainingSql = formatted.remainingSql; + + throw new Error( + [ + `Declarative roundtrip left ${remainingChanges.length} change(s) for ${fixture.id}`, + `Image: ${image}`, + skippedMigrations.length > 0 + ? `Skipped migrations:\n${skippedMigrations.join("\n")}` + : "", + `Remaining summary: ${JSON.stringify(formatted.remainingSummary, null, 2)}`, + `Remaining SQL:\n${formatted.remainingSql || "(no SQL generated)"}`, + ] + .filter(Boolean) + .join("\n\n"), + ); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const artifacts = await writeScenarioFailureArtifacts({ + fixture, + scenarioName: "declarative", + image, + errorMessage: message, + skippedMigrations, + planSql, + remainingSql, + sourceCatalog, + targetCatalog, + }); throw new Error( - [ - `Declarative roundtrip left ${remainingChanges.length} change(s) for ${fixture.id}`, - `Image: ${image}`, - skippedMigrations.length > 0 - ? `Skipped migrations:\n${skippedMigrations.join("\n")}` - : "", - `Remaining summary: ${JSON.stringify(formatted.remainingSummary, null, 2)}`, - `Remaining SQL:\n${formatted.remainingSql || "(no SQL generated)"}`, - ] - .filter(Boolean) - .join("\n\n"), + `${message}\n\nFailure artifacts: ${artifacts.reportPath}`, + { + cause: err, + }, ); } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - const artifacts = await writeScenarioFailureArtifacts({ - fixture, - scenarioName: "declarative", - image, - errorMessage: message, - skippedMigrations, - planSql, - remainingSql, - sourceCatalog, - targetCatalog, - }); - - throw new Error(`${message}\n\nFailure artifacts: ${artifacts.reportPath}`, { - cause: err, - }); - } - }); + }, + ); } export async function runSupabaseProjectProgressiveSmoke( fixture: SupabaseProjectFixture, ): Promise { const scenario = fixture.scenarios.progressive; - const migrations = await loadSupabaseProjectMigrations(fixture, "progressive"); - - await withSupabaseProjectPools(fixture, async ({ mainPool, branchPool, image }) => { - const { appliedMigrations, skippedMigrations } = await applyProjectMigrations( - branchPool, - migrations, - scenario.onApplyError ?? "fail", - ); - const { stepFrom, stepTo, skipApply } = resolveSupabaseSmokeStepConfig( - appliedMigrations.length, - ); - const compiledFilter = fixture.integration.filter - ? compileFilterDSL(fixture.integration.filter) - : undefined; - const results: SmokeStepResult[] = []; - - for (let step = 0; step <= stepTo; step += 1) { - const migrationName = - step === 0 ? "(empty)" : appliedMigrations[step - 1].filename; + const migrations = await loadSupabaseProjectMigrations( + fixture, + "progressive", + ); - if (step < stepFrom) { - if (step > 0) { - await mainPool.query(appliedMigrations[step - 1].sql); - } - continue; - } + await withSupabaseProjectPools( + fixture, + async ({ mainPool, branchPool, image }) => { + const { appliedMigrations, skippedMigrations } = + await applyProjectMigrations( + branchPool, + migrations, + scenario.onApplyError ?? "fail", + ); + const { stepFrom, stepTo, skipApply } = resolveSupabaseSmokeStepConfig( + appliedMigrations.length, + ); + const compiledFilter = fixture.integration.filter + ? compileFilterDSL(fixture.integration.filter) + : undefined; + const results: SmokeStepResult[] = []; - const result: SmokeStepResult = { - step, - migrationApplied: migrationName, - planStatus: "success", - }; + for (let step = 0; step <= stepTo; step += 1) { + const migrationName = + step === 0 ? "(empty)" : appliedMigrations[step - 1].filename; - if (step > 0) { - try { - await mainPool.query(appliedMigrations[step - 1].sql); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - result.planStatus = "error"; - result.planError = `Migration apply failed on main: ${message}`; - results.push(result); + if (step < stepFrom) { + if (step > 0) { + await mainPool.query(appliedMigrations[step - 1].sql); + } continue; } - } - try { - const planResult = await createPlan(mainPool, branchPool, { - filter: fixture.integration.filter, - serialize: fixture.integration.serialize, - skipDefaultPrivilegeSubtraction: - fixture.skipDefaultPrivilegeSubtraction ?? false, - }); + const result: SmokeStepResult = { + step, + migrationApplied: migrationName, + planStatus: "success", + }; - if (!planResult) { - result.planStatus = "no_changes"; - result.changeCount = 0; - result.statementCount = 0; - result.applyStatus = "skipped"; - results.push(result); - continue; + if (step > 0) { + try { + await mainPool.query(appliedMigrations[step - 1].sql); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.planStatus = "error"; + result.planError = `Migration apply failed on main: ${message}`; + results.push(result); + continue; + } } - result.changeCount = planResult.sortedChanges.length; - result.statementCount = planResult.plan.statements.length; - result.planSql = planResult.plan.statements.join(";\n\n"); + try { + const planResult = await createPlan(mainPool, branchPool, { + filter: fixture.integration.filter, + serialize: fixture.integration.serialize, + skipDefaultPrivilegeSubtraction: + fixture.skipDefaultPrivilegeSubtraction ?? false, + }); + + if (!planResult) { + result.planStatus = "no_changes"; + result.changeCount = 0; + result.statementCount = 0; + result.applyStatus = "skipped"; + results.push(result); + continue; + } - if (skipApply) { - result.applyStatus = "skipped"; - results.push(result); - continue; - } + result.changeCount = planResult.sortedChanges.length; + result.statementCount = planResult.plan.statements.length; + result.planSql = planResult.plan.statements.join(";\n\n"); - let failedStatement: string | undefined; + if (skipApply) { + result.applyStatus = "skipped"; + results.push(result); + continue; + } - try { - await mainPool.query("BEGIN"); - await mainPool.query("SET LOCAL check_function_bodies = false"); + let failedStatement: string | undefined; - for (const statement of planResult.plan.statements) { - failedStatement = statement; - await mainPool.query(statement); - } + try { + await mainPool.query("BEGIN"); + await mainPool.query("SET LOCAL check_function_bodies = false"); + + for (const statement of planResult.plan.statements) { + failedStatement = statement; + await mainPool.query(statement); + } - const mainCatalog = await extractCatalog(mainPool); - const branchCatalog = await extractCatalog(branchPool); - result.sourceCatalog = mainCatalog; - result.targetCatalog = branchCatalog; - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = compiledFilter - ? allChanges.filter(compiledFilter) - : allChanges; - - result.remainingChanges = remainingChanges.length; - result.applyStatus = - remainingChanges.length === 0 ? "success" : "error"; - - if (remainingChanges.length > 0) { - const { remainingSql } = formatRemainingChanges( - mainCatalog, - branchCatalog, - remainingChanges, - ); - result.remainingSql = remainingSql; - result.applyError = remainingSql || "Remaining changes after apply"; + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + result.sourceCatalog = mainCatalog; + result.targetCatalog = branchCatalog; + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remainingChanges = compiledFilter + ? allChanges.filter(compiledFilter) + : allChanges; + + result.remainingChanges = remainingChanges.length; + result.applyStatus = + remainingChanges.length === 0 ? "success" : "error"; + + if (remainingChanges.length > 0) { + const { remainingSql } = formatRemainingChanges( + mainCatalog, + branchCatalog, + remainingChanges, + ); + result.remainingSql = remainingSql; + result.applyError = + remainingSql || "Remaining changes after apply"; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.applyStatus = "error"; + result.applyError = message; + result.applyFailedStatement = failedStatement; + } finally { + try { + await mainPool.query("ROLLBACK"); + } catch { + // Connection may have been interrupted; ignore rollback errors. + } } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - result.applyStatus = "error"; - result.applyError = message; - result.applyFailedStatement = failedStatement; - } finally { - try { - await mainPool.query("ROLLBACK"); - } catch { - // Connection may have been interrupted; ignore rollback errors. - } + result.planStatus = "error"; + result.planError = message; } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - result.planStatus = "error"; - result.planError = message; - } - results.push(result); - } + results.push(result); + } - const failures = results.filter( - (result) => - result.planStatus === "error" || result.applyStatus === "error", - ); - if (failures.length > 0) { - const summary = formatSmokeResultsSummary( - fixture, - "progressive", - image, - results, - skippedMigrations, - ); - const firstFailure = failures[0]; - const artifacts = await writeScenarioFailureArtifacts({ - fixture, - scenarioName: "progressive", - image, - errorMessage: summary, - step: firstFailure.step, - migrationName: firstFailure.migrationApplied, - skippedMigrations, - planSql: firstFailure.planSql, - remainingSql: firstFailure.remainingSql, - sourceCatalog: firstFailure.sourceCatalog, - targetCatalog: firstFailure.targetCatalog, - }); - throw new Error( - `${summary}\n\nFailure artifacts: ${artifacts.reportPath}`, + const failures = results.filter( + (result) => + result.planStatus === "error" || result.applyStatus === "error", ); - } - }); + if (failures.length > 0) { + const summary = formatSmokeResultsSummary( + fixture, + "progressive", + image, + results, + skippedMigrations, + ); + const firstFailure = failures[0]; + const artifacts = await writeScenarioFailureArtifacts({ + fixture, + scenarioName: "progressive", + image, + errorMessage: summary, + step: firstFailure.step, + migrationName: firstFailure.migrationApplied, + skippedMigrations, + planSql: firstFailure.planSql, + remainingSql: firstFailure.remainingSql, + sourceCatalog: firstFailure.sourceCatalog, + targetCatalog: firstFailure.targetCatalog, + }); + throw new Error( + `${summary}\n\nFailure artifacts: ${artifacts.reportPath}`, + ); + } + }, + ); } export async function runSupabaseProjectAdjacentSmoke( @@ -630,162 +663,171 @@ export async function runSupabaseProjectAdjacentSmoke( const applicable = await withSupabaseProjectPools( fixture, async ({ branchPool }) => - applyProjectMigrations(branchPool, migrations, scenario.onApplyError ?? "fail"), + applyProjectMigrations( + branchPool, + migrations, + scenario.onApplyError ?? "fail", + ), ); if (applicable.appliedMigrations.length === 0) { - throw new Error(`No migrations were applied for ${fixture.id} adjacent scenario`); - } - - await withSupabaseProjectPools(fixture, async ({ mainPool, branchPool, image }) => { - const maxStep = Math.max(applicable.appliedMigrations.length - 1, 0); - const { stepFrom, stepTo, skipApply } = resolveSupabaseSmokeStepConfig( - maxStep, + throw new Error( + `No migrations were applied for ${fixture.id} adjacent scenario`, ); - const compiledFilter = fixture.integration.filter - ? compileFilterDSL(fixture.integration.filter) - : undefined; - const results: SmokeStepResult[] = []; - - for ( - let step = 0; - step < applicable.appliedMigrations.length && step <= stepTo; - step += 1 - ) { - const migration = applicable.appliedMigrations[step]; - - await branchPool.query(migration.sql).catch((err) => { - throw new Error( - `Migration ${migration.filename} failed on branch during adjacent smoke: ${err.message}`, - { cause: err }, - ); - }); - - if (step < stepFrom) { - await mainPool.query(migration.sql); - continue; - } - - const result: SmokeStepResult = { - step, - migrationApplied: migration.filename, - planStatus: "success", - }; + } - try { - const planResult = await createPlan(mainPool, branchPool, { - filter: fixture.integration.filter, - serialize: fixture.integration.serialize, - skipDefaultPrivilegeSubtraction: - fixture.skipDefaultPrivilegeSubtraction ?? false, + await withSupabaseProjectPools( + fixture, + async ({ mainPool, branchPool, image }) => { + const maxStep = Math.max(applicable.appliedMigrations.length - 1, 0); + const { stepFrom, stepTo, skipApply } = + resolveSupabaseSmokeStepConfig(maxStep); + const compiledFilter = fixture.integration.filter + ? compileFilterDSL(fixture.integration.filter) + : undefined; + const results: SmokeStepResult[] = []; + + for ( + let step = 0; + step < applicable.appliedMigrations.length && step <= stepTo; + step += 1 + ) { + const migration = applicable.appliedMigrations[step]; + + await branchPool.query(migration.sql).catch((err) => { + throw new Error( + `Migration ${migration.filename} failed on branch during adjacent smoke: ${err.message}`, + { cause: err }, + ); }); - if (!planResult) { - result.planStatus = "no_changes"; - result.changeCount = 0; - result.statementCount = 0; - result.applyStatus = "skipped"; - } else { - result.changeCount = planResult.sortedChanges.length; - result.statementCount = planResult.plan.statements.length; - result.planSql = planResult.plan.statements.join(";\n\n"); + if (step < stepFrom) { + await mainPool.query(migration.sql); + continue; + } - if (skipApply) { + const result: SmokeStepResult = { + step, + migrationApplied: migration.filename, + planStatus: "success", + }; + + try { + const planResult = await createPlan(mainPool, branchPool, { + filter: fixture.integration.filter, + serialize: fixture.integration.serialize, + skipDefaultPrivilegeSubtraction: + fixture.skipDefaultPrivilegeSubtraction ?? false, + }); + + if (!planResult) { + result.planStatus = "no_changes"; + result.changeCount = 0; + result.statementCount = 0; result.applyStatus = "skipped"; } else { - let failedStatement: string | undefined; + result.changeCount = planResult.sortedChanges.length; + result.statementCount = planResult.plan.statements.length; + result.planSql = planResult.plan.statements.join(";\n\n"); - try { - await mainPool.query("BEGIN"); - await mainPool.query("SET LOCAL check_function_bodies = false"); - - for (const statement of planResult.plan.statements) { - failedStatement = statement; - await mainPool.query(statement); - } + if (skipApply) { + result.applyStatus = "skipped"; + } else { + let failedStatement: string | undefined; - const mainCatalog = await extractCatalog(mainPool); - const branchCatalog = await extractCatalog(branchPool); - result.sourceCatalog = mainCatalog; - result.targetCatalog = branchCatalog; - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = compiledFilter - ? allChanges.filter(compiledFilter) - : allChanges; - - result.remainingChanges = remainingChanges.length; - result.applyStatus = - remainingChanges.length === 0 ? "success" : "error"; - - if (remainingChanges.length > 0) { - const { remainingSql } = formatRemainingChanges( - mainCatalog, - branchCatalog, - remainingChanges, - ); - result.remainingSql = remainingSql; - result.applyError = - remainingSql || "Remaining changes after adjacent apply"; - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - result.applyStatus = "error"; - result.applyError = message; - result.applyFailedStatement = failedStatement; - } finally { try { - await mainPool.query("ROLLBACK"); - } catch { - // Connection may have been interrupted; ignore rollback errors. + await mainPool.query("BEGIN"); + await mainPool.query("SET LOCAL check_function_bodies = false"); + + for (const statement of planResult.plan.statements) { + failedStatement = statement; + await mainPool.query(statement); + } + + const mainCatalog = await extractCatalog(mainPool); + const branchCatalog = await extractCatalog(branchPool); + result.sourceCatalog = mainCatalog; + result.targetCatalog = branchCatalog; + const allChanges = diffCatalogs(mainCatalog, branchCatalog); + const remainingChanges = compiledFilter + ? allChanges.filter(compiledFilter) + : allChanges; + + result.remainingChanges = remainingChanges.length; + result.applyStatus = + remainingChanges.length === 0 ? "success" : "error"; + + if (remainingChanges.length > 0) { + const { remainingSql } = formatRemainingChanges( + mainCatalog, + branchCatalog, + remainingChanges, + ); + result.remainingSql = remainingSql; + result.applyError = + remainingSql || "Remaining changes after adjacent apply"; + } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : String(err); + result.applyStatus = "error"; + result.applyError = message; + result.applyFailedStatement = failedStatement; + } finally { + try { + await mainPool.query("ROLLBACK"); + } catch { + // Connection may have been interrupted; ignore rollback errors. + } } } } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.planStatus = "error"; + result.planError = message; } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - result.planStatus = "error"; - result.planError = message; - } - results.push(result); + results.push(result); - await mainPool.query(migration.sql).catch((err) => { - throw new Error( - `Migration ${migration.filename} failed while advancing main during adjacent smoke: ${err.message}`, - { cause: err }, - ); - }); - } + await mainPool.query(migration.sql).catch((err) => { + throw new Error( + `Migration ${migration.filename} failed while advancing main during adjacent smoke: ${err.message}`, + { cause: err }, + ); + }); + } - const failures = results.filter( - (result) => - result.planStatus === "error" || result.applyStatus === "error", - ); - if (failures.length > 0) { - const summary = formatSmokeResultsSummary( - fixture, - "adjacent", - image, - results, - applicable.skippedMigrations, - ); - const firstFailure = failures[0]; - const artifacts = await writeScenarioFailureArtifacts({ - fixture, - scenarioName: "adjacent", - image, - errorMessage: summary, - step: firstFailure.step, - migrationName: firstFailure.migrationApplied, - skippedMigrations: applicable.skippedMigrations, - planSql: firstFailure.planSql, - remainingSql: firstFailure.remainingSql, - sourceCatalog: firstFailure.sourceCatalog, - targetCatalog: firstFailure.targetCatalog, - }); - throw new Error( - `${summary}\n\nFailure artifacts: ${artifacts.reportPath}`, + const failures = results.filter( + (result) => + result.planStatus === "error" || result.applyStatus === "error", ); - } - }); + if (failures.length > 0) { + const summary = formatSmokeResultsSummary( + fixture, + "adjacent", + image, + results, + applicable.skippedMigrations, + ); + const firstFailure = failures[0]; + const artifacts = await writeScenarioFailureArtifacts({ + fixture, + scenarioName: "adjacent", + image, + errorMessage: summary, + step: firstFailure.step, + migrationName: firstFailure.migrationApplied, + skippedMigrations: applicable.skippedMigrations, + planSql: firstFailure.planSql, + remainingSql: firstFailure.remainingSql, + sourceCatalog: firstFailure.sourceCatalog, + targetCatalog: firstFailure.targetCatalog, + }); + throw new Error( + `${summary}\n\nFailure artifacts: ${artifacts.reportPath}`, + ); + } + }, + ); } diff --git a/packages/pg-delta/tests/utils.ts b/packages/pg-delta/tests/utils.ts index cfa5c2af..ace5b213 100644 --- a/packages/pg-delta/tests/utils.ts +++ b/packages/pg-delta/tests/utils.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Pool } from "pg"; -import { createPool } from "../src/core/postgres-config.ts"; +import { createPool, endPool } from "../src/core/postgres-config.ts"; import { POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, type PostgresVersion, @@ -15,7 +15,7 @@ import { SupabasePostgreSqlContainer } from "./supabase-postgres.js"; * 57P01 = admin_shutdown (container stopped while connection open) * 53100 = disk_full (container out of disk under heavy concurrent tests) */ -function suppressShutdownError(err: Error & { code?: string }) { +export function suppressShutdownError(err: Error & { code?: string }) { if (err.code === "57P01" || err.code === "53100") return; console.error("Pool error:", err); } @@ -117,6 +117,56 @@ export async function waitForPool( } } +/** Bootstrap pool for an isolated Supabase test container (no per-connection `SET ROLE`). */ +export function createSupabaseIsolatedBootstrapPool( + connectionUri: string, +): Pool { + return createPool(connectionUri, { + onError: suppressShutdownError, + connectionTimeoutMillis: 20_000, + }); +} + +export type SupabaseIsolatedBootstrapContext = { + image: string; + mainUri: string; + branchUri: string; + main: Pool; + branch: Pool; +}; + +/** + * Start two isolated Supabase containers, run the generated fullstack base-init + * SQL on both, then call `fn` with connection URIs and bootstrap pools. + * Callback may create additional pools to the same URIs (e.g. `SET ROLE` for + * `createPlan`); it must `end` those in a `try` / `finally` before returning. + * This function ends the bootstrap pools and stops containers in its own `finally`. + */ +export async function runWithSupabaseIsolatedBaseInit( + postgresVersion: SupabasePostgresVersion, + fn: (ctx: SupabaseIsolatedBootstrapContext) => Promise, +): Promise { + const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[postgresVersion]}`; + const [containerMain, containerBranch] = await Promise.all([ + new SupabasePostgreSqlContainer(image).start(), + new SupabasePostgreSqlContainer(image).start(), + ]); + const mainUri = containerMain.getConnectionUri(); + const branchUri = containerBranch.getConnectionUri(); + const main = createSupabaseIsolatedBootstrapPool(mainUri); + const branch = createSupabaseIsolatedBootstrapPool(branchUri); + + await Promise.all([waitForPool(main), waitForPool(branch)]); + + try { + await applySupabaseBaseInitToFixture({ main, branch }, postgresVersion); + return await fn({ image, mainUri, branchUri, main, branch }); + } finally { + await Promise.all([endPool(main), endPool(branch)]); + await Promise.all([containerMain.stop(), containerBranch.stop()]); + } +} + /** * Default test utility using Alpine PostgreSQL containers with single container per version. * Uses CREATE/DROP DATABASE for isolation instead of creating new containers. @@ -172,32 +222,12 @@ export function withDbSupabaseIsolated( fn: (db: DbFixture) => Promise, ): () => Promise { return async () => { - const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[postgresVersion]}`; - const [containerMain, containerBranch] = await Promise.all([ - new SupabasePostgreSqlContainer(image).start(), - new SupabasePostgreSqlContainer(image).start(), - ]); - const main = createPool(containerMain.getConnectionUri(), { - onError: suppressShutdownError, - connectionTimeoutMillis: 20_000, - }); - const branch = createPool(containerBranch.getConnectionUri(), { - onError: suppressShutdownError, - connectionTimeoutMillis: 20_000, - }); - - await Promise.all([waitForPool(main), waitForPool(branch)]); - - try { - // The raw image is no longer the intended Supabase test baseline. Before - // running test code, replay the generated base-init SQL onto both - // databases so service-owned objects such as `auth`, `storage`, and - // `realtime` match what `supabase start` would have initialized. - await applySupabaseBaseInitToFixture({ main, branch }, postgresVersion); - await fn({ main, branch }); - } finally { - await Promise.all([main.end(), branch.end()]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } + // The raw image is no longer the intended Supabase test baseline. Before + // running test code, replay the generated base-init SQL onto both + // databases so service-owned objects such as `auth`, `storage`, and + // `realtime` match what `supabase start` would have initialized. + await runWithSupabaseIsolatedBaseInit(postgresVersion, async (ctx) => + fn({ main: ctx.main, branch: ctx.branch }), + ); }; } From a80fc19e15f44e3ca37595c9c7a1900b1c7b447b Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 27 Apr 2026 19:00:05 +0200 Subject: [PATCH 9/9] fix: adjacent apply and report --- .../supabase-projects/dbdev/project.ts | 4 + .../integration/supabase-project-fixture.ts | 28 +++++ .../integration/supabase-project-fixtures.md | 3 +- .../supabase-project-report.test.ts | 6 + .../integration/supabase-project-report.ts | 9 ++ .../supabase-project-runners.test.ts | 45 +++++++ .../integration/supabase-project-runners.ts | 112 +++++++++++++++--- 7 files changed, 187 insertions(+), 20 deletions(-) diff --git a/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts index df4c07a3..7de2cc1b 100644 --- a/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts +++ b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts @@ -27,6 +27,10 @@ export default defineSupabaseProjectFixture({ }, adjacent: { onApplyError: "skip", + // Migration uses DEFAULT + UPDATE + DROP DEFAULT; the final column has no + // default, so plan/apply from catalog diff cannot replay on non-empty tables. + skipAdjacentPlanApply: (filename) => + filename === "20231205051816_add_default_version.sql", }, }, }); diff --git a/packages/pg-delta/tests/integration/supabase-project-fixture.ts b/packages/pg-delta/tests/integration/supabase-project-fixture.ts index 2d0c4cbc..f77bd552 100644 --- a/packages/pg-delta/tests/integration/supabase-project-fixture.ts +++ b/packages/pg-delta/tests/integration/supabase-project-fixture.ts @@ -20,6 +20,16 @@ export type SupabaseProjectMigration = { export type SupabaseProjectScenario = { include?: (filename: string) => boolean; onApplyError?: "fail" | "skip"; + /** + * **Adjacent smoke only.** If this returns `true` for a migration file, the + * runner still applies the migration to branch and then to main, but **skips** + * `createPlan` / apply / zero-diff verification for that step. + * + * Use when a migration’s real path is DML or DEFAULT → backfill → drop + * default, so the final catalog matches a pure `ADD NOT NULL` that would + * still fail to apply on non-empty tables (pg-delta only sees the end state). + */ + skipAdjacentPlanApply?: (filename: string) => boolean; }; export type SupabaseProjectFixture = { @@ -40,6 +50,24 @@ export const SUPABASE_PROJECTS_DIR = path.join( "fixtures/supabase-projects", ); +/** + * Stable path to a SQL migration from the `packages/pg-delta/` root (for docs and + * failure reports). Returns `undefined` for synthetic labels like `(empty)`. + */ +export function getSupabaseProjectMigrationRelativePath( + fixtureId: string, + migrationFilename: string | undefined, +): string | undefined { + if ( + !migrationFilename || + migrationFilename === "(empty)" || + !migrationFilename.endsWith(".sql") + ) { + return undefined; + } + return `tests/integration/fixtures/supabase-projects/${fixtureId}/migrations/${migrationFilename}`; +} + export function defineSupabaseProjectFixture( fixture: SupabaseProjectFixture, ): SupabaseProjectFixture { diff --git a/packages/pg-delta/tests/integration/supabase-project-fixtures.md b/packages/pg-delta/tests/integration/supabase-project-fixtures.md index e491f559..e562494b 100644 --- a/packages/pg-delta/tests/integration/supabase-project-fixtures.md +++ b/packages/pg-delta/tests/integration/supabase-project-fixtures.md @@ -49,8 +49,9 @@ Each scenario name maps to optional: - `include` — filter migration **filenames** (e.g. dbdev **declarative** only runs `20220117*` to keep the test fast and avoid later data-only migrations that break across image versions). - `onApplyError` — `fail` (default) or `skip` when applying migrations to the branch database (useful when some files are expected to fail in CI until images catch up). +- `skipAdjacentPlanApply` — **adjacent** only. If the predicate returns `true` for a migration file, the runner still applies that migration to branch and main, but **skips** the pairwise `createPlan` / apply / zero-diff check for that step. Use when the real SQL uses a **DEFAULT → DML backfill → DROP DEFAULT** (or other data path) so the final catalog looks like a plain `ADD NOT NULL` with no default; a schema-only plan cannot apply safely to non-empty tables. The dbdev migration `20231205051816_add_default_version.sql` is an example and is listed in that fixture’s `project.ts`. -**Progressive scope (dbdev):** The progressive runner keeps the **branch** on the fully migrated set while **main** advances one file at a time, then `createPlan`/`apply` must reconcile main to *that* branch. Comparing a half-migrated `main` to a branch that already includes *later* migrations (e.g. multi-statement `ADD COLUMN` + backfill in one migration file) is not, in general, a single plan that applies cleanly, so the dbdev fixture **limits the progressive `include` filter** to the same `20220117*` prefix as the declarative scenario. Adjacent mode applies one migration at a time on both sides, so it does not need the same cap. +**Progressive scope (dbdev):** The progressive runner keeps the **branch** on the fully migrated set while **main** advances one migration at a time, then `createPlan`/`apply` must reconcile main to *that* branch. Comparing a half-migrated `main` to a branch that already includes *later* migrations (e.g. multi-statement `ADD COLUMN` + backfill in one migration file) is not, in general, a single plan that applies cleanly, so the dbdev fixture **limits the progressive `include` filter** to the same `20220117*` prefix as the declarative scenario. **Adjacent** applies one migration at a time, so it does not need a filename prefix cap; use `skipAdjacentPlanApply` for individual files whose author migration cannot be approximated from catalog state alone. Three scenario **names** are built in: diff --git a/packages/pg-delta/tests/integration/supabase-project-report.test.ts b/packages/pg-delta/tests/integration/supabase-project-report.test.ts index d546a566..644bd412 100644 --- a/packages/pg-delta/tests/integration/supabase-project-report.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-report.test.ts @@ -17,6 +17,8 @@ test("writes markdown and companion artifacts for smoke failures", async () => { image: "supabase/postgres:15.14.1.018", step: 1, migrationName: "20220117141357_extensions.sql", + migrationFilePath: + "tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141357_extensions.sql", errorMessage: "simulated failure", reproCommand: "PGDELTA_TEST_POSTGRES_VERSIONS=15 bun run test tests/integration/supabase-project-progressive.test.ts", @@ -41,6 +43,9 @@ test("writes markdown and companion artifacts for smoke failures", async () => { const report = await readFile(result.reportPath, "utf-8"); expect(report).toContain("## Summary"); + expect(report).toContain( + "tests/integration/fixtures/supabase-projects/dbdev/migrations/20220117141357_extensions.sql", + ); expect(report).toContain("simulated failure"); expect(report).toContain("Shrink the fixture to the smallest prefix first."); @@ -49,4 +54,5 @@ test("writes markdown and companion artifacts for smoke failures", async () => { ); expect(metadata.fixtureId).toBe("dbdev"); expect(metadata.scenarioName).toBe("progressive"); + expect(metadata.migrationFilePath).toContain("dbdev/migrations"); }); diff --git a/packages/pg-delta/tests/integration/supabase-project-report.ts b/packages/pg-delta/tests/integration/supabase-project-report.ts index 4ba5a48b..bc7b7fcb 100644 --- a/packages/pg-delta/tests/integration/supabase-project-report.ts +++ b/packages/pg-delta/tests/integration/supabase-project-report.ts @@ -19,6 +19,11 @@ export type WriteSupabaseSmokeFailureArtifactsInput = { image: string; step?: number; migrationName?: string; + /** + * Path to the migration under `packages/pg-delta/`, e.g. + * `tests/integration/fixtures/supabase-projects/dbdev/migrations/foo.sql`. + */ + migrationFilePath?: string; errorMessage: string; reproCommand: string; skippedMigrations?: string[]; @@ -76,6 +81,9 @@ function buildMarkdown( `- Image: \`${input.image}\``, input.step !== undefined ? `- Step: \`${input.step}\`` : "", input.migrationName ? `- Migration: \`${input.migrationName}\`` : "", + input.migrationFilePath + ? `- Migration file (from packages/pg-delta/): \`${input.migrationFilePath}\`` + : "", "", "## Error", "```text", @@ -144,6 +152,7 @@ export async function writeSupabaseSmokeFailureArtifacts( image: input.image, step: input.step, migrationName: input.migrationName, + migrationFilePath: input.migrationFilePath, errorMessage: input.errorMessage, reproCommand: input.reproCommand, skippedMigrations: input.skippedMigrations ?? [], diff --git a/packages/pg-delta/tests/integration/supabase-project-runners.test.ts b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts index 130901c3..f31c9450 100644 --- a/packages/pg-delta/tests/integration/supabase-project-runners.test.ts +++ b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts @@ -2,7 +2,9 @@ import { expect, test } from "bun:test"; import dbdev from "./fixtures/supabase-projects/dbdev/project.ts"; import { buildSupabaseSmokeReproCommand, + formatSmokeResultsSummary, resolveSupabaseSmokeStepConfig, + type SupabaseSmokeStepResult, } from "./supabase-project-runners.ts"; test("repro command targets the pg-delta package test script", () => { @@ -41,3 +43,46 @@ test("step config rejects invalid or empty smoke ranges", () => { }), ).toThrow(/Invalid smoke step/i); }); + +test("formatSmokeResultsSummary lists totals, icon per step, and apply= first", () => { + const results: SupabaseSmokeStepResult[] = [ + { + step: 0, + migrationApplied: "(empty)", + planStatus: "success", + changeCount: 1, + statementCount: 1, + applyStatus: "success", + }, + { + step: 1, + migrationApplied: "20220117141359_app_schema.sql", + planStatus: "no_changes", + changeCount: 0, + statementCount: 0, + applyStatus: "skipped", + }, + { + step: 2, + migrationApplied: "20220117141507_semver.sql", + planStatus: "error", + planError: "boom", + }, + ]; + + const text = formatSmokeResultsSummary( + dbdev, + "progressive", + "supabase/postgres:15", + results, + [], + ); + + expect(text).toContain("Totals — passed:"); + expect(text).toContain("failed: 1"); + expect(text).toMatch(/verification skipped.*0/); + expect(text).toMatch(/✅ \*\*apply=success\*\*/); + expect(text).toMatch(/✅ \*\*apply=skipped\*\*/); + expect(text).toMatch(/❌ \*\*apply=— \(not run, plan error\)\*\*/); + expect(text).toContain("Full results (per step:"); +}); diff --git a/packages/pg-delta/tests/integration/supabase-project-runners.ts b/packages/pg-delta/tests/integration/supabase-project-runners.ts index 9cf76dd2..77f775b2 100644 --- a/packages/pg-delta/tests/integration/supabase-project-runners.ts +++ b/packages/pg-delta/tests/integration/supabase-project-runners.ts @@ -13,6 +13,7 @@ import { suppressShutdownError, } from "../utils.ts"; import { + getSupabaseProjectMigrationRelativePath, loadSupabaseProjectMigrations, type SupabaseProjectFixture, type SupabaseProjectMigration, @@ -33,10 +34,10 @@ type AppliedMigrationResult = { skippedMigrations: string[]; }; -type SmokeStepResult = { +export type SupabaseSmokeStepResult = { step: number; migrationApplied: string; - planStatus: "no_changes" | "success" | "error"; + planStatus: "no_changes" | "success" | "error" | "skipped"; planError?: string; changeCount?: number; statementCount?: number; @@ -50,6 +51,8 @@ type SmokeStepResult = { targetCatalog?: unknown; }; +type SmokeStepResult = SupabaseSmokeStepResult; + function createProjectPool( fixture: SupabaseProjectFixture, connectionUri: string, @@ -215,16 +218,65 @@ export function resolveSupabaseSmokeStepConfig( }; } -function formatSmokeResultsSummary( +function smokeStepStatusIcon( + r: SupabaseSmokeStepResult, +): "✅" | "❌" | "⏭️" { + if (r.planStatus === "error" || r.applyStatus === "error") { + return "❌"; + } + if (r.planStatus === "skipped") { + return "⏭️"; + } + return "✅"; +} + +function isSmokeStepFailed(r: SupabaseSmokeStepResult): boolean { + return r.planStatus === "error" || r.applyStatus === "error"; +} + +function smokeStepApplyLabel(r: SupabaseSmokeStepResult): string { + if (r.planStatus === "error") { + return "apply=— (not run, plan error)"; + } + if (r.applyStatus === "error" && r.applyError) { + return `apply=error (${r.applyError.slice(0, 200)}${ + r.applyError.length > 200 ? "…" : "" + })`; + } + if (r.applyStatus === "success") { + return "apply=success"; + } + if (r.applyStatus === "skipped") { + return "apply=skipped"; + } + return `apply=${r.applyStatus ?? "—"}`; +} + +/** + * User-facing full listing for progressive/adjacent smoke; also embedded in + * `report.md` as `## Error` text. + */ +export function formatSmokeResultsSummary( fixture: SupabaseProjectFixture, scenarioName: "progressive" | "adjacent", image: string, - results: SmokeStepResult[], + results: SupabaseSmokeStepResult[], skippedMigrations: string[], ): string { - const failures = results.filter( - (result) => result.planStatus === "error" || result.applyStatus === "error", - ); + const failures = results.filter(isSmokeStepFailed); + + let passCount = 0; + let failCount = 0; + let notVerifiedCount = 0; + for (const r of results) { + if (r.planStatus === "skipped") { + notVerifiedCount += 1; + } else if (isSmokeStepFailed(r)) { + failCount += 1; + } else { + passCount += 1; + } + } const lines = [ `${fixture.id} ${scenarioName} smoke failed on ${failures.length} step(s)`, @@ -236,9 +288,14 @@ function formatSmokeResultsSummary( } for (const failure of failures) { + const rel = getSupabaseProjectMigrationRelativePath( + fixture.id, + failure.migrationApplied, + ); + const pathNote = rel ? `\nMigration file: ${rel}` : ""; lines.push( [ - `Step ${failure.step}: after "${failure.migrationApplied}"`, + `Step ${failure.step}: after "${failure.migrationApplied}"${pathNote}`, failure.planStatus === "error" ? `Plan generation failed: ${failure.planError}` : "", @@ -257,19 +314,16 @@ function formatSmokeResultsSummary( ); } + const detailLines = results.map((result) => { + const icon = smokeStepStatusIcon(result); + return `${icon} **${smokeStepApplyLabel(result)}** · step=${result.step} · migration=\`${result.migrationApplied}\` · plan=${result.planStatus} · changes=${result.changeCount ?? "—"} · statements=${result.statementCount ?? "—"}`; + }); + lines.push( [ - "Full results:", - ...results.map((result) => - [ - `- step=${result.step}`, - `migration=${result.migrationApplied}`, - `plan=${result.planStatus}`, - `changes=${result.changeCount ?? "-"}`, - `statements=${result.statementCount ?? "-"}`, - `apply=${result.applyStatus ?? "-"}`, - ].join(" "), - ), + "Full results (per step: icon = pass / fail / plan+apply check skipped, **apply=** is first):", + `Totals — passed: ${passCount} · failed: ${failCount} · verification skipped (e.g. adjacent hook): ${notVerifiedCount} · steps listed: ${results.length}`, + ...detailLines, ].join("\n"), ); @@ -327,6 +381,10 @@ async function writeScenarioFailureArtifacts(input: { image: input.image, step: input.step, migrationName: input.migrationName, + migrationFilePath: getSupabaseProjectMigrationRelativePath( + input.fixture.id, + input.migrationName, + ), errorMessage: input.errorMessage, skippedMigrations: input.skippedMigrations, reproCommand: buildSupabaseSmokeReproCommand( @@ -706,6 +764,22 @@ export async function runSupabaseProjectAdjacentSmoke( continue; } + if (scenario.skipAdjacentPlanApply?.(migration.filename)) { + results.push({ + step, + migrationApplied: migration.filename, + planStatus: "skipped", + applyStatus: "skipped", + }); + await mainPool.query(migration.sql).catch((err) => { + throw new Error( + `Migration ${migration.filename} failed while advancing main during adjacent smoke: ${err.message}`, + { cause: err }, + ); + }); + continue; + } + const result: SmokeStepResult = { step, migrationApplied: migration.filename,