diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts index bdf22189e6..fc00a22126 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts @@ -88,13 +88,16 @@ export function legacyClassifyDeclarativeCompatibilityGap(opts: { const ambiguousRemovals = extensions.filter( (extension) => !LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), ); - const extensionIntents = opts.removals.extensionIntents; + // Removing a pg_cron job or pgmq queue declaration is an ordinary delete or + // rename on a maintained tree, not legacy-export evidence: only a dropped + // extension trips the gate (CLI-2282). Their removals are kept as evidence + // solely to enumerate the objects a dropped owning extension takes with it. + const extensionIntents = opts.removals.extensionIntents.filter((intent) => + extensions.includes(intent.extension), + ); - if (extensions.length === 0 && extensionIntents.length === 0) return emptyCompatibilityGap(); - const repairable = - repairableExtensions.length > 0 && - ambiguousRemovals.length === 0 && - extensionIntents.length === 0; + if (extensions.length === 0) return emptyCompatibilityGap(); + const repairable = repairableExtensions.length > 0 && ambiguousRemovals.length === 0; return { repairableExtensions, extensionIntents, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts index 198f9459e2..7d35d95c97 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts @@ -19,7 +19,7 @@ const stuck = (message: string) => ({ }); const removals = { - extensions: ["pgcrypto", "uuid-ossp"], + extensions: ["pg_cron", "pgcrypto", "pgmq", "uuid-ossp"], extensionIntents: [ { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, { extension: "pgmq", intentKind: "queue", key: "emails" }, @@ -72,9 +72,25 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { }, }, { - name: "stages extension intents", + name: "stages dropped extensions along with the objects they manage", overrides: {}, - expected: { recommendedAction: "stage-next-export" }, + expected: { + recommendedAction: "stage-next-export", + ambiguousRemovals: ["pg_cron", "pgmq"], + extensionIntents: removals.extensionIntents, + }, + }, + { + name: "ignores cron job and pgmq queue removals whose extension stays", + overrides: { + removals: { extensions: ["pgcrypto"], extensionIntents: removals.extensionIntents }, + }, + expected: { recommendedAction: "repair-extensions", extensionIntents: [] }, + }, + { + name: "never gates on cron job and pgmq queue removals alone", + overrides: { removals: { extensions: [], extensionIntents: removals.extensionIntents } }, + expected: { recommendedAction: "none" }, }, { name: "trusts a next export manifest", @@ -153,6 +169,7 @@ describe("legacyFormatDeclarativeUpgradeGate", () => { "platform extensions and extension-managed objects like cron jobs.", "", " Legacy-implicit extensions: pgcrypto, uuid-ossp", + " Extensions: pg_cron, pgmq", " Extension-managed objects: pg_cron job refresh download metrics, pgmq queue emails", "", "Do not apply a sync generated from this tree — it can drop extensions or unschedule jobs.", diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index b340f4d61f..456a5c1276 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -92,12 +92,13 @@ without prompting; both override the global `--yes`. `--no-apply` and `--apply` are mutually exclusive. A manifest-less legacy tree is refused by two compatibility gates — one when the -tree fails to load on the bundled engine's shadow, one when the plan's removals -reveal legacy-implicit extensions or extension-managed objects. Both render the -same message (`This tree looks like a legacy pg-delta export.` -plus an indented evidence block) and both carry the staged-upgrade recipe on the -error's suggestion, so the generic `Try rerunning the command with --debug` -footer is **not** printed. Non-interactive execution (including `--yes`) stops +tree fails to load on the bundled engine's shadow, one when the plan drops an +extension the tree no longer declares (removing or renaming a `pg_cron` job or +`pgmq` queue declaration is an ordinary change and is never refused). Both +render the same message (`This tree looks like a legacy +pg-delta export.` plus an indented evidence block) and both carry the +staged-upgrade recipe on the error's suggestion, so the generic `Try rerunning +the command with --debug` footer is **not** printed. Non-interactive execution (including `--yes`) stops there and modifies nothing; the only recommended recovery is regenerating into `-next`, reviewing it, and adopting it. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts index 947664f7fc..76d7e86889 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -84,6 +84,13 @@ describe("db schema declarative sync (e2e)", () => { `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";\n`, ); } + // Both install into a fixed schema, so no `WITH SCHEMA` clause. + for (const extension of ["pg_cron", "pgmq"]) { + writeFileSync( + path.join(extensionsDir, `${extension}.sql`), + `CREATE EXTENSION IF NOT EXISTS "${extension}";\n`, + ); + } const start = await runSupabase( [ @@ -171,4 +178,66 @@ describe("db schema declarative sync (e2e)", () => { expect(`${converged.stdout}${converged.stderr}`).toContain("No schema changes found"); }, ); + + test( + "renames a cron job and drops a pgmq queue declared in the tree", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const projectDir = project?.dir; + if (projectDir === undefined) throw new Error("declarative sync project was not initialized"); + const jobsPath = path.join(projectDir, "supabase", "schemas", "jobs.sql"); + const sync = (name: string) => + runSupabase( + ["db", "schema", "declarative", "sync", "--no-apply", "--name", name, "--experimental"], + { + entrypoint: "legacy", + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }, + ); + // A next-engine plan may span several ordered migration files; read every + // file a sync added rather than only the last one. + const syncAndReadSql = async (name: string) => { + const before = new Set(migrationFiles(projectDir)); + const result = await sync(name); + expect(result.exitCode, commandFailure(result)).toBe(0); + const added = migrationFiles(projectDir).filter((file) => !before.has(file)); + expect(added.length, "sync did not write a migration").toBeGreaterThan(0); + return { + result, + sql: added + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"), + }; + }; + + writeFileSync( + jobsPath, + [ + "select cron.schedule('nightly_cleanup', '0 3 * * *', $$delete from public.disposable_note$$);", + "select pgmq.create('emails');", + "", + ].join("\n"), + ); + const added = await syncAndReadSql("add_jobs"); + expect(added.sql).toContain("cron.schedule('nightly_cleanup'"); + expect(added.sql).toContain("pgmq.create('emails')"); + + // Rename the job and drop the queue: previously refused as a legacy export. + writeFileSync( + jobsPath, + "select cron.schedule('weekly_cleanup', '0 3 * * 0', $$delete from public.disposable_note$$);\n", + ); + const removed = await syncAndReadSql("rename_job_drop_queue"); + expect(`${removed.result.stdout}${removed.result.stderr}`).not.toContain( + "legacy pg-delta export", + ); + expect(removed.sql).toContain("cron.unschedule('nightly_cleanup')"); + expect(removed.sql).toContain("cron.schedule('weekly_cleanup'"); + expect(removed.sql).toContain("pgmq.drop_queue('emails')"); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 267a9c6b69..9642ad8b97 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -1230,7 +1230,7 @@ describe("legacy db schema declarative sync integration", () => { diffSql: "select cron.unschedule('refresh download metrics');\nDROP EXTENSION \"pgcrypto\";\n", removals: { - extensions: ["pgcrypto", "uuid-ossp"], + extensions: ["pg_cron", "pgcrypto", "uuid-ossp"], extensionIntents: [ { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, ], @@ -1255,6 +1255,31 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("writes cron job and pgmq queue removals without a legacy-export refusal", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + yes: true, + diffSql: "select cron.unschedule('refresh metrics');\nselect pgmq.drop_queue('emails');\n", + removals: { + extensions: [], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh metrics" }, + { extension: "pgmq", intentKind: "queue", key: "emails" }, + ], + }, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + const [migration] = readdirSync(migrationsDir); + const sql = readFileSync(join(migrationsDir, migration ?? ""), "utf8"); + expect(sql).toContain("cron.unschedule('refresh metrics')"); + expect(sql).toContain("pgmq.drop_queue('emails')"); + expect(stripAnsi(s.out.stderrText)).not.toContain("legacy pg-delta export"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("directs pg_net users to enable Database Webhooks before writing", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, {