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/.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 1f963051..00000000 --- a/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts +++ /dev/null @@ -1,247 +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"; -import { applySupabaseBaseInit, waitForPool } from "../utils.ts"; - -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) - // - // Use plain `supabase_admin` pools only for the shared base-init replay: - // `applySupabaseBaseInit(...)` models the normal test/runtime bootstrap - // path before we intentionally switch into the bug-repro connection shape. - const setupMainPool = createPool(containerMain.getConnectionUri(), { - onError: suppressShutdownError, - }); - const setupBranchPool = createPool(containerBranch.getConnectionUri(), { - onError: suppressShutdownError, - }); - // These are the pools the actual roundtrip uses. Every connection issues - // `SET ROLE postgres` so `createPlan(...)` sees the same effective user as - // the real CLI path that originally triggered the regressions above. - const mainPool = createPostgresRolePool(containerMain.getConnectionUri()); - const branchPool = createPostgresRolePool( - containerBranch.getConnectionUri(), - ); - - try { - // First bring both databases up to the shared generated Supabase - // baseline. Only after that do we apply dbdev-specific migrations to the - // branch side and ask pg-delta to export the difference. - await Promise.all([ - waitForPool(setupMainPool), - waitForPool(setupBranchPool), - ]); - await Promise.all([ - applySupabaseBaseInit(setupMainPool, pgVersion), - applySupabaseBaseInit(setupBranchPool, pgVersion), - ]); - - // Now layer dbdev on top of that shared baseline: `main` stays at - // "Supabase base-init only", while `branch` becomes the desired state we - // expect declarative export/apply to reproduce. - 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", - ); - } - - // Export from "Supabase base-init only" -> "Supabase base-init + dbdev". - // This mirrors real usage where project schemas sit on top of - // service-managed Supabase objects that already exist. - 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 }, - ); - } - - // Final assertion: after applying the exported declarative schema to the - // clean baseline, the Supabase-filtered diff should be empty. - 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(setupMainPool), - endPool(setupBranchPool), - 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/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..7de2cc1b --- /dev/null +++ b/packages/pg-delta/tests/integration/fixtures/supabase-projects/dbdev/project.ts @@ -0,0 +1,36 @@ +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: { + // 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: { + 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-adjacent.test.ts b/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts new file mode 100644 index 00000000..6d251542 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-adjacent.test.ts @@ -0,0 +1,17 @@ +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..ce7b63fb --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-declarative.test.ts @@ -0,0 +1,17 @@ +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..f77bd552 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-fixture.ts @@ -0,0 +1,146 @@ +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"; + /** + * **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 = { + 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", +); + +/** + * 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 { + 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-fixtures.md b/packages/pg-delta/tests/integration/supabase-project-fixtures.md new file mode 100644 index 00000000..e562494b --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-fixtures.md @@ -0,0 +1,107 @@ +# 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). +- `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 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: + +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 new file mode 100644 index 00000000..6d3524fe --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-progressive.test.ts @@ -0,0 +1,17 @@ +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..644bd412 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-report.test.ts @@ -0,0 +1,58 @@ +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", + 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", + 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( + "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."); + + const metadata = JSON.parse( + await readFile(path.join(result.directory, "metadata.json"), "utf-8"), + ); + 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 new file mode 100644 index 00000000..bc7b7fcb --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-report.ts @@ -0,0 +1,204 @@ +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" + | "adjacent"; + +export type WriteSupabaseSmokeFailureArtifactsInput = { + baseDir?: string; + fixtureId: string; + fixtureDisplayName: string; + scenarioName: SupabaseSmokeScenarioName; + artifactId?: string; + 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[]; + 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}\`` : "", + input.migrationFilePath + ? `- Migration file (from packages/pg-delta/): \`${input.migrationFilePath}\`` + : "", + "", + "## 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, + migrationFilePath: input.migrationFilePath, + 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"), + jsonStringifySupabaseCatalog(input.sourceCatalog), + "utf-8", + ) + : Promise.resolve(), + input.targetCatalog + ? writeFile( + path.join(directory, "target-catalog.json"), + jsonStringifySupabaseCatalog(input.targetCatalog), + "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..f31c9450 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-runners.test.ts @@ -0,0 +1,88 @@ +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", () => { + 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); +}); + +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 new file mode 100644 index 00000000..77f775b2 --- /dev/null +++ b/packages/pg-delta/tests/integration/supabase-project-runners.ts @@ -0,0 +1,907 @@ +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 { + runWithSupabaseIsolatedBaseInit, + suppressShutdownError, +} from "../utils.ts"; +import { + getSupabaseProjectMigrationRelativePath, + loadSupabaseProjectMigrations, + type SupabaseProjectFixture, + type SupabaseProjectMigration, +} from "./supabase-project-fixture.ts"; +import { + type SupabaseSmokeScenarioName, + writeSupabaseSmokeFailureArtifacts, +} from "./supabase-project-report.ts"; + +type ProjectPools = { + mainPool: Pool; + branchPool: Pool; + image: string; +}; + +type AppliedMigrationResult = { + appliedMigrations: SupabaseProjectMigration[]; + skippedMigrations: string[]; +}; + +export type SupabaseSmokeStepResult = { + step: number; + migrationApplied: string; + planStatus: "no_changes" | "success" | "error" | "skipped"; + planError?: string; + changeCount?: number; + statementCount?: number; + applyStatus?: "success" | "error" | "skipped"; + applyError?: string; + applyFailedStatement?: string; + remainingChanges?: number; + planSql?: string; + remainingSql?: string; + sourceCatalog?: unknown; + targetCatalog?: unknown; +}; + +type SmokeStepResult = SupabaseSmokeStepResult; + +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 { + return runWithSupabaseIsolatedBaseInit( + fixture.supabasePostgresVersion, + async (ctx) => { + if (!fixture.setRole) { + return await fn({ + mainPool: ctx.main, + branchPool: ctx.branch, + image: ctx.image, + }); + } + + 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( + 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 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: SupabaseSmokeStepResult[], + skippedMigrations: string[], +): string { + 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)`, + `Image: ${image}`, + ]; + + if (skippedMigrations.length > 0) { + lines.push(`Skipped migrations:\n${skippedMigrations.join("\n")}`); + } + + 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}"${pathNote}`, + 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"), + ); + } + + 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 (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"), + ); + + 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, + migrationFilePath: getSupabaseProjectMigrationRelativePath( + input.fixture.id, + 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; + } + + 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, + 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}`, + ); + } + }, + ); +} 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 }), + ); }; }