Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
avallete marked this conversation as resolved.
return {
repairableExtensions,
extensionIntents,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <declarative-dir> 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 <declarative-dir> 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
`<declarative-dir>-next`, reviewing it, and adopting it.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@
`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(
[
Expand Down Expand Up @@ -171,4 +178,66 @@
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(

Check failure on line 217 in apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Run end-to-end tests (shard 1/3)

[e2e] src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts > db schema declarative sync (e2e) > renames a cron job and drops a pgmq queue declared in the tree

Error: ENOENT: no such file or directory, open '/tmp/sb-pgdelta-next-e2e-oTMlI0/supabase/schemas/jobs.sql' ❯ src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts:217:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ENOENT', path: '/tmp/sb-pgdelta-next-e2e-oTMlI0/supabase/schemas/jobs.sql', syscall: 'open', errno: -2 }
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')");
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand All @@ -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, {
Expand Down
Loading