From bc356f3024ffebadf3c328a31f642d19a807ee86 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 3 Sep 2026 09:00:55 +0000 Subject: [PATCH 1/4] fix(cli): honour deletion of extension-managed objects in declarative sync Removing a pg_cron job or pgmq queue declaration from a manifest-less declarative tree was refused as a legacy pg-delta export, and the refusal then blocked every later sync of that tree, unrelated schema work included. The engine already plans the cron.unschedule / pgmq.drop_queue calls; the refusal was CLI-side classification. - Only a missing CREATE EXTENSION declaration is legacy-export evidence now. An extension-managed object removal whose owning extension the tree still declares is an intentional delete or rename and flows through the destructive-changes warning instead, as `pg_cron job ` / `pgmq queue ` lines appended to the engine's data-loss statements (pg-delta does not flag cron.unschedule as data loss itself). - Whole-extension removals keep the gate, and its evidence still enumerates the jobs/queues at risk when their owner is not declared. - The staged-export prompt now offers "Continue with removals", and the new `sync --allow-removals` flag is its non-interactive equivalent, named in the gate's suggestion. `--yes` deliberately does not double as it. CLI-2282 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wh8sCVwt29sYWSYdcDUbWN --- .../db/schema/declarative/declarative.flow.ts | 64 +++++++-- .../declarative/declarative.flow.unit.test.ts | 114 +++++++++++++++- ...eclarative.orchestrate.integration.test.ts | 4 + .../declarative/declarative.orchestrate.ts | 25 +++- .../schema/declarative/sync/SIDE_EFFECTS.md | 40 ++++-- .../schema/declarative/sync/sync.command.ts | 10 +- .../schema/declarative/sync/sync.handler.ts | 14 +- .../declarative/sync/sync.integration.test.ts | 122 +++++++++++++++++- 8 files changed, 352 insertions(+), 41 deletions(-) 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..f4888fced5 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 @@ -35,11 +35,26 @@ type LegacyDeclarativeCompatibilityAction = "none" | "repair-extensions" | "stag export interface LegacyDeclarativeCompatibilityGap { readonly repairableExtensions: ReadonlyArray; + /** + * Extension-managed object removals (cron jobs, pgmq queues) whose owning + * extension the tree does not declare. A removal whose owner IS declared is an + * intentional delete on a maintained tree and never appears here — it flows + * through the destructive-changes warning instead. + */ readonly extensionIntents: LegacyPgDeltaRemovalSummary["extensionIntents"]; readonly ambiguousRemovals: ReadonlyArray; readonly recommendedAction: LegacyDeclarativeCompatibilityAction; } +/** + * The destructive-changes warning line for an extension-managed object removal + * (`pg_cron job `, `pgmq queue `); also the evidence form the + * plan-refuse gate enumerates. + */ +export const legacyFormatExtensionIntentRemoval = ( + intent: LegacyPgDeltaRemovalSummary["extensionIntents"][number], +): string => `${intent.extension} ${intent.intentKind} ${intent.key}`; + /** * Pure control-flow helpers ported from the legacy Go implementation (deleted * in CLI-1970; last present at commit 7b469f5b3) and kept free of @@ -73,11 +88,23 @@ const emptyCompatibilityGap = (): LegacyDeclarativeCompatibilityGap => ({ recommendedAction: "none", }); -/** Classifies manifest-less pg-delta next removals without performing any I/O. */ +/** + * Classifies manifest-less pg-delta next removals without performing any I/O. + * + * Only a missing `CREATE EXTENSION` declaration is legacy-export evidence: legacy + * exports omitted platform extensions wholesale, and a tree that omits `pg_cron` + * also plans the pg_cron *extension* removal, so the gate still fires for it and + * still enumerates the jobs at risk. An extension-managed object removal whose + * owner the tree declares (`declaredExtensions`, from the loaded SQL files) is an + * intentional delete or rename on a maintained tree and must not trip the gate + * (CLI-2282); the caller surfaces it through the destructive-changes warning. + */ export function legacyClassifyDeclarativeCompatibilityGap(opts: { readonly implementation: LegacyPgDeltaImplementation; readonly manifestPresent: boolean; readonly removals: LegacyPgDeltaRemovalSummary; + /** Lower-cased extension names the declarative tree declares. */ + readonly declaredExtensions: ReadonlySet; }): LegacyDeclarativeCompatibilityGap { if (opts.implementation !== "next" || opts.manifestPresent) return emptyCompatibilityGap(); @@ -88,7 +115,9 @@ export function legacyClassifyDeclarativeCompatibilityGap(opts: { const ambiguousRemovals = extensions.filter( (extension) => !LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), ); - const extensionIntents = opts.removals.extensionIntents; + const extensionIntents = opts.removals.extensionIntents.filter( + (intent) => !opts.declaredExtensions.has(intent.extension.toLowerCase()), + ); if (extensions.length === 0 && extensionIntents.length === 0) return emptyCompatibilityGap(); const repairable = @@ -297,8 +326,9 @@ function schemaArguments(schema: ReadonlyArray, platform: LegacyShellPla export const legacyFormatDeclarativeSyncCommand = ( schema: ReadonlyArray, platform: LegacyShellPlatform, + options: { readonly allowRemovals?: boolean } = {}, ): string => - ` supabase db schema declarative sync --no-apply${schemaArguments(schema, platform)} --experimental`; + ` supabase db schema declarative sync --no-apply${options.allowRemovals === true ? " --allow-removals" : ""}${schemaArguments(schema, platform)} --experimental`; const adoptionCommand = ( declarativeDir: string, @@ -367,7 +397,7 @@ export function legacyFormatDeclarativeGapEvidence( ...(gap.extensionIntents.length > 0 ? [ `Extension-managed objects: ${gap.extensionIntents - .map((intent) => `${intent.extension} ${intent.intentKind} ${intent.key}`) + .map(legacyFormatExtensionIntentRemoval) .join(", ")}`, ] : []), @@ -386,17 +416,23 @@ export interface LegacyDeclarativeUpgradeGateText { * `suggestion` so `Output.fail` prints them instead of the generic * "rerun with --debug" footer — a deliberate gate is not a crash. * - * Deliberately offers exactly ONE non-interactive recovery: the staged - * regenerate. Telling a non-interactive user to hand-add an extension - * declaration is a false trail — on a real legacy tree each declaration only - * unlocks the next refusal. Interactive flows still offer the repair as an - * advanced choice. + * Deliberately offers exactly ONE non-interactive recovery for the legacy-tree + * reading: the staged regenerate. Telling a non-interactive user to hand-add an + * extension declaration is a false trail — on a real legacy tree each + * declaration only unlocks the next refusal. Interactive flows still offer the + * repair as an advanced choice. + * + * The plan-refuse gate additionally names `--allow-removals` (`offerAllowRemovals`) + * for the other reading — the removals are intentional — which downgrades the + * gate to the destructive-changes warning. The load-fail gate cannot offer it: + * a tree that does not load has nothing to sync. */ export function legacyFormatDeclarativeUpgradeGate(opts: { readonly evidence: ReadonlyArray; readonly context: LegacyStagedExportContext; + readonly offerAllowRemovals?: boolean; }): LegacyDeclarativeUpgradeGateText { - const { declarativeDir } = opts.context; + const { declarativeDir, schema, platform } = opts.context; return { message: [ `This ${declarativeDir} tree looks like a legacy pg-delta export.`, @@ -410,6 +446,14 @@ export function legacyFormatDeclarativeUpgradeGate(opts: { `Upgrade without changing the active ${declarativeDir} tree:`, "", ...stagedExportCommands(opts.context), + ...(opts.offerAllowRemovals === true + ? [ + "", + "If these removals are intentional, keep the tree and rerun with --allow-removals to review them as destructive changes:", + "", + legacyFormatDeclarativeSyncCommand(schema, platform, { allowRemovals: true }), + ] + : []), ].join("\n"), }; } 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..151180c493 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 @@ -6,6 +6,7 @@ import { legacyExtensionDeclaration, legacyFormatDeclarativeGapEvidence, legacyFormatDeclarativeUpgradeGate, + legacyFormatExtensionIntentRemoval, legacyFormatStagedExportAdoption, legacyResolveDeclarativeMigrationName, legacyResolveDeclarativeSyncApplyDecision, @@ -18,12 +19,12 @@ const stuck = (message: string) => ({ message, }); +const cronJob = { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }; +const pgmqQueue = { extension: "pgmq", intentKind: "queue", key: "emails" }; + const removals = { extensions: ["pgcrypto", "uuid-ossp"], - extensionIntents: [ - { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, - { extension: "pgmq", intentKind: "queue", key: "emails" }, - ], + extensionIntents: [cronJob, pgmqQueue], }; const classifyGap = ( @@ -33,6 +34,7 @@ const classifyGap = ( implementation: "next", manifestPresent: false, removals, + declaredExtensions: new Set(), ...overrides, }); @@ -72,9 +74,62 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { }, }, { - name: "stages extension intents", - overrides: {}, - expected: { recommendedAction: "stage-next-export" }, + name: "stages extension intents whose owning extension the tree does not declare", + overrides: { removals: { extensions: [], extensionIntents: [cronJob] } }, + expected: { + recommendedAction: "stage-next-export", + repairableExtensions: [], + ambiguousRemovals: [], + extensionIntents: [cronJob], + }, + }, + { + name: "does not gate an intent removal whose owning extension the tree declares", + overrides: { + removals: { extensions: [], extensionIntents: [cronJob, pgmqQueue] }, + declaredExtensions: new Set(["pg_cron", "pgmq"]), + }, + expected: { recommendedAction: "none", extensionIntents: [] }, + }, + { + name: "matches declared owners case-insensitively", + overrides: { + removals: { extensions: [], extensionIntents: [{ ...cronJob, extension: "PG_CRON" }] }, + declaredExtensions: new Set(["pg_cron"]), + }, + expected: { recommendedAction: "none" }, + }, + { + name: "gates a mix of declared and undeclared owners on the undeclared ones only", + overrides: { + removals: { extensions: [], extensionIntents: [cronJob, pgmqQueue] }, + declaredExtensions: new Set(["pg_cron"]), + }, + expected: { recommendedAction: "stage-next-export", extensionIntents: [pgmqQueue] }, + }, + { + name: "keeps the whole-extension gate when a declared-owner intent is also removed", + overrides: { + removals: { extensions: ["postgis"], extensionIntents: [cronJob] }, + declaredExtensions: new Set(["pg_cron"]), + }, + expected: { + recommendedAction: "stage-next-export", + ambiguousRemovals: ["postgis"], + extensionIntents: [], + }, + }, + { + name: "still offers the in-place repair when only declared-owner intents accompany it", + overrides: { + removals: { extensions: ["pgcrypto"], extensionIntents: [cronJob] }, + declaredExtensions: new Set(["pg_cron"]), + }, + expected: { + recommendedAction: "repair-extensions", + repairableExtensions: ["pgcrypto"], + extensionIntents: [], + }, }, { name: "trusts a next export manifest", @@ -108,6 +163,24 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { ); }); + it("formats an extension-managed object removal as a destructive-changes line", () => { + expect(legacyFormatExtensionIntentRemoval(cronJob)).toBe( + "pg_cron job refresh download metrics", + ); + expect(legacyFormatExtensionIntentRemoval(pgmqQueue)).toBe("pgmq queue emails"); + }); + + it("omits declared-owner intent removals from the gate evidence", () => { + expect( + legacyFormatDeclarativeGapEvidence( + classifyGap({ + removals: { extensions: ["postgis"], extensionIntents: [cronJob, pgmqQueue] }, + declaredExtensions: new Set(["pg_cron"]), + }), + ), + ).toEqual(["Extensions: postgis", "Extension-managed objects: pgmq queue emails"]); + }); + it("derives staged-export commands from a custom declarative path", () => { const { suggestion } = legacyFormatDeclarativeUpgradeGate({ evidence: legacyFormatDeclarativeGapEvidence(classifyGap()), @@ -171,6 +244,33 @@ describe("legacyFormatDeclarativeUpgradeGate", () => { ); }); + it("names --allow-removals only when the plan-refuse gate offers it", () => { + const context = { + declarativeDir: "supabase/schemas", + schema: ["app"], + platform: "posix" as const, + }; + const evidence = legacyFormatDeclarativeGapEvidence(classifyGap()); + const offered = legacyFormatDeclarativeUpgradeGate({ + evidence, + context, + offerAllowRemovals: true, + }); + expect( + offered.suggestion.endsWith( + [ + "", + "If these removals are intentional, keep the tree and rerun with --allow-removals to review them as destructive changes:", + "", + " supabase db schema declarative sync --no-apply --allow-removals --schema app --experimental", + ].join("\n"), + ), + ).toBe(true); + // The load-fail gate renders the same template but cannot honour the flag. + const withheld = legacyFormatDeclarativeUpgradeGate({ evidence, context }); + expect(withheld.suggestion).not.toContain("--allow-removals"); + }); + it("offers no extension.sql alternative — the staged upgrade is the only recovery", () => { const gate = legacyFormatDeclarativeUpgradeGate({ evidence: [ diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 0fda4fa2a3..519d3bfa57 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -245,13 +245,17 @@ describe("legacyDiffDeclarativeToMigrations", () => { expect(calls[0]?.noCache).toBe(true); expect(calls[0]?.strictCoverage).toBe(true); expect(result.manifestPresent).toBe(true); + // Data-loss actions first, then every extension-managed object removal in + // words — pg-delta does not flag `cron.unschedule` as data loss itself. expect(result.dropWarnings).toEqual([ "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;", + "pg_cron job refresh metrics", ]); expect(result.removals).toEqual({ extensions: ["pgcrypto"], extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], }); + expect(result.declaredExtensions).toEqual(new Set()); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index d065b1bf5a..64fddc4437 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -34,7 +34,9 @@ import { import { legacyClassifyDeclarativeLoadCompatibility, legacyCurrentShellPlatform, + legacyDeclaredExtensions, legacyFormatDeclarativeUpgradeGate, + legacyFormatExtensionIntentRemoval, type LegacyDeclarativeLoadCompatibilityFinding, type LegacyDeclarativeUpgradeGateText, } from "./declarative.flow.ts"; @@ -60,9 +62,16 @@ export interface LegacyDeclarativeSyncResult { readonly files: ReadonlyArray; readonly sourceRef: string; readonly targetRef: string; + /** + * Lines for the destructive-changes warning: the engine's data-loss actions (or, + * under the legacy engine, its DROP statements) followed by one line per + * extension-managed object removal (`pg_cron job `, `pgmq queue `). + */ readonly dropWarnings: ReadonlyArray; readonly manifestPresent: boolean; readonly removals: LegacyPgDeltaRemovalSummary; + /** Lower-cased extension names the loaded declarative tree declares. */ + readonly declaredExtensions: ReadonlySet; } const declarativeError = (message: string) => new LegacyDeclarativeDiffError({ message }); @@ -157,17 +166,25 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( }); }), ); + const removals = result.removals ?? { extensions: [], extensionIntents: [] }; return { diffSQL: result.sql, files: result.files, sourceRef: result.sourceRef, targetRef: result.targetRef, - dropWarnings: - engine.implementation === "next" && result.hazards !== undefined + // pg-delta's hazard report does not flag `cron.unschedule` as data loss and + // cannot name the object a data-loss action removes, so every extension-managed + // object removal is appended in words — the same confirmation path a DROP + // COLUMN takes, whether or not the tree carries an export manifest (CLI-2282). + dropWarnings: [ + ...(engine.implementation === "next" && result.hazards !== undefined ? result.hazards.dataLoss.map((action) => action.sql) - : legacyFindDropStatements(result.sql), + : legacyFindDropStatements(result.sql)), + ...removals.extensionIntents.map(legacyFormatExtensionIntentRemoval), + ], manifestPresent: manifest !== undefined, - removals: result.removals ?? { extensions: [], extensionIntents: [] }, + removals, + declaredExtensions: legacyDeclaredExtensions(files), } satisfies LegacyDeclarativeSyncResult; }); 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..21ff0b212f 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 @@ -73,7 +73,7 @@ disabling safe compaction. | `1` | no declarative schema files found | | `1` | shadow-database / selected pg-delta engine / diff failure | | `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | -| `1` | repairable legacy extension omissions in non-interactive mode | +| `1` | legacy extension omissions in non-interactive mode (unless `--allow-removals`) | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs @@ -92,21 +92,33 @@ 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 -there and modifies nothing; the only recommended recovery is regenerating into -`-next`, reviewing it, and adopting it. +tree fails to load on the bundled engine's shadow, one when the plan removes an +extension the tree no longer declares (`CREATE EXTENSION` missing). Both render +the same message (`This tree looks like a legacy pg-delta +export.` plus an indented evidence block, which for the plan gate enumerates the +extensions and the extension-managed objects — cron jobs, pgmq queues — at risk) +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 recommended recovery is regenerating into `-next`, reviewing +it, and adopting it; the plan gate's suggestion additionally names +`--allow-removals`, which keeps the tree and downgrades that gate to the +destructive-changes warning below (`--yes` never doubles as this override). + +Removing or renaming an extension-managed object (a `pg_cron` job, a `pgmq` +queue) whose owning extension the tree still declares is an intentional change +on a maintained tree, not legacy-export evidence: it never trips the gate. The +generated migration carries the `cron.unschedule`/`pgmq.drop_queue` call and the +destructive-changes warning lists it in words (`pg_cron job `, +`pgmq queue `) after the engine's data-loss statements — with or without an +export manifest. In a TTY both gates additionally offer to generate that staged export -(recommended), and — when the gap is only `pgcrypto`, `uuid-ossp`, or `pg_net` — -to append those declarations to `/extension.sql` and re-plan, or -to continue with the removals, or cancel. The in-place repair is an advanced -choice (it may surface another gap on the next plan); it never overwrites -existing SQL or creates an export manifest. +(recommended), to continue with the removals, or cancel; when the gap is only +`pgcrypto`, `uuid-ossp`, or `pg_net` the plan gate also offers to append those +declarations to `/extension.sql` and re-plan. The in-place +repair is an advanced choice (it may surface another gap on the next plan); it +never overwrites existing SQL or creates an export manifest. ## Notes diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index 0d14ba8b4d..9e7b250042 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -47,6 +47,13 @@ const config = { ), Flag.optional, ), + // Deliberately separate from `--yes`: `--yes` already auto-confirms every prompt, + // so it must not double as the override for a plan that drops extensions. + allowRemovals: Flag.boolean("allow-removals").pipe( + Flag.withDescription( + "Continue when the planned removals make the declarative tree look like a legacy pg-delta export, reviewing them as destructive changes instead of refusing the sync.", + ), + ), } as const; // `--no-cache` is a shared flag on the `declarative` group (read from the parent), @@ -58,7 +65,7 @@ export type LegacyDbSchemaDeclarativeSyncFlags = CliCommand.Command.Config.Infer export const legacyDbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( Command.withDescription( - "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", + "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing; --allow-removals accepts the removals as destructive changes instead. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", ), Command.withShortDescription("Generate a new migration from declarative schema"), Command.withHandler((flags) => @@ -80,6 +87,7 @@ export const legacyDbSchemaDeclarativeSyncCommand = Command.make("sync", config) name: merged.name, apply: merged.apply, "no-apply": merged.noApply, + "allow-removals": merged.allowRemovals, }, // Go registers `--schema`/`-s` (StringSliceVarP) and `--file`/`-f` // (StringVarP) (`cmd/db_schema_declarative.go:484-485`); telemetry reports diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 74d73d93a0..0fb1aa044f 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -524,14 +524,19 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara implementation: engine.implementation, manifestPresent: result.manifestPresent, removals: result.removals, + declaredExtensions: result.declaredExtensions, }); if (compatibility.recommendedAction === "none") break; + // `--allow-removals` is the scripted form of "Continue with removals": the + // planned removals are reviewed as destructive changes below instead of + // being refused as legacy-export evidence. + if (flags.allowRemovals) break; // Both recommended actions mean the same thing to the user — the tree is a // legacy export — so they render one shared template and differ only in the - // choices offered. Non-interactively there is exactly one recovery: the - // staged regenerate, carried on `suggestion` so `Output.fail` prints it - // instead of the "rerun with --debug" footer. + // choices offered. Non-interactively the recoveries are the staged + // regenerate and `--allow-removals`, carried on `suggestion` so `Output.fail` + // prints them instead of the "rerun with --debug" footer. const gate = legacyFormatDeclarativeUpgradeGate({ evidence: legacyFormatDeclarativeGapEvidence(compatibility), context: { @@ -539,6 +544,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, platform: legacyCurrentShellPlatform(), }, + offerAllowRemovals: true, }); if (!tty.stdinIsTty || yes) { return yield* Effect.fail( @@ -557,8 +563,10 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara label: `Generate next export to ${stagedDirRel}`, hint: "recommended", }, + { value: "continue", label: "Continue with removals" }, { value: "cancel", label: "Cancel" }, ]); + if (choice === "continue") break; if (choice === "stage") yield* stageNextExport(); return; } 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..18c18dc5b6 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 @@ -401,15 +401,31 @@ const flags = ( name: over.name ?? Option.none(), apply: over.apply ?? Option.none(), noApply: over.noApply ?? Option.none(), + allowRemovals: over.allowRemovals ?? false, }); const failError = (exit: Exit.Exit) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; -const seedDeclarative = (workdir: string) => { +const seedDeclarative = (workdir: string, sql = "create table a();") => { const dir = join(workdir, "supabase", "schemas"); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "public.sql"), "create table a();"); + writeFileSync(join(dir, "public.sql"), sql); +}; + +/** A maintained, converged tree that declares the extensions whose objects it manages. */ +const MAINTAINED_TREE_SQL = [ + "create extension if not exists pg_cron with schema pg_catalog;", + "create extension if not exists pgmq;", + "create table a();", + "", +].join("\n"); + +const migrationSql = (workdir: string) => { + const dir = join(workdir, "supabase", "migrations"); + const [file] = readdirSync(dir); + expect(file).toBeDefined(); + return readFileSync(join(dir, file ?? ""), "utf8"); }; const seedLegacyUuidDeclarative = (workdir: string, directory = "schemas") => { @@ -1250,11 +1266,113 @@ describe("legacy db schema declarative sync integration", () => { }); expect(failError(exit)).toMatchObject({ message: expect.stringContaining(" Extension-managed objects: pg_cron job refresh"), + // The scripted escape for intentional removals rides on the suggestion too. + suggestion: expect.stringContaining( + "supabase db schema declarative sync --no-apply --allow-removals --experimental", + ), }); expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); }); + it.effect("honours deleting a pgmq queue from a maintained tree as a destructive change", () => { + // Dogfooding scenario (CLI-2282): the tree declares pgmq, one queue declaration + // is removed alongside unrelated schema work. Previously the whole sync — the + // unrelated work included — was refused as a legacy export. + seedDeclarative(tmp.current, MAINTAINED_TREE_SQL); + const s = setup(tmp.current, { + engineImplementation: "next", + yes: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\nselect pgmq.drop_queue('emails');\n", + removals: { + extensions: [], + extensionIntents: [{ extension: "pgmq", intentKind: "queue", key: "emails" }], + }, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(migrationSql(tmp.current)).toContain("pgmq.drop_queue('emails')"); + const stderr = stripAnsi(s.out.stderrText); + expect(stderr).not.toContain("looks like a legacy pg-delta export"); + expect(stderr).toContain( + "Found destructive changes in schema diff. Please double check if these are expected:\npgmq queue emails", + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("renames a pg_cron job on a maintained tree without refusing", () => { + seedDeclarative(tmp.current, MAINTAINED_TREE_SQL); + const s = setup(tmp.current, { + engineImplementation: "next", + yes: true, + diffSql: [ + "select cron.unschedule('refresh metrics');", + "select cron.schedule('refresh download metrics', '0 * * * *', $$select 1$$);", + "", + ].join("\n"), + removals: { + extensions: [], + extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], + }, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const sql = migrationSql(tmp.current); + expect(sql).toContain("cron.unschedule('refresh metrics')"); + expect(sql).toContain("cron.schedule('refresh download metrics'"); + expect(stripAnsi(s.out.stderrText)).toContain("pg_cron job refresh metrics"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--allow-removals downgrades the non-interactive gate to the destructive warning", + () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + diffSql: "select cron.unschedule('refresh metrics');\nDROP EXTENSION \"postgis\";\n", + removals: { + extensions: ["postgis"], + extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], + }, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync( + flags({ noApply: Option.some(true), allowRemovals: true }), + ); + expect(migrationSql(tmp.current)).toContain('DROP EXTENSION "postgis"'); + const stderr = stripAnsi(s.out.stderrText); + expect(stderr).not.toContain("looks like a legacy pg-delta export"); + expect(stderr).toContain("Found destructive changes in schema diff"); + expect(stderr).toContain('DROP EXTENSION "postgis"'); + expect(stderr).toContain("pg_cron job refresh metrics"); + expect(s.out.promptSelectCalls).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("offers to continue with removals from the staged-export prompt", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + stdinIsTty: true, + diffSql: 'DROP EXTENSION "postgis";\n', + removals: { extensions: ["postgis"], extensionIntents: [] }, + promptSelectResponses: ["continue"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ + "stage", + "continue", + "cancel", + ]); + expect(migrationSql(tmp.current)).toContain('DROP EXTENSION "postgis"'); + expect(existsSync(join(tmp.current, "supabase", "schemas-next"))).toBe(false); + expect(stripAnsi(s.out.stderrText)).toContain("Found destructive changes in schema diff"); + }).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, { From 86e1cb8eb64bb846fa08f1dae91ea95115b0a810 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 3 Sep 2026 11:08:20 +0000 Subject: [PATCH 2/4] fix(cli): drop the --allow-removals sync flag Keep the classification fix and the interactive "Continue with removals" choice; non-interactive runs that still hit the legacy-export gate refuse with the staged-export recipe as before. CLI-2282 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wh8sCVwt29sYWSYdcDUbWN --- .../db/schema/declarative/declarative.flow.ts | 29 ++++------------- .../declarative/declarative.flow.unit.test.ts | 27 ---------------- .../schema/declarative/sync/SIDE_EFFECTS.md | 10 +++--- .../schema/declarative/sync/sync.command.ts | 10 +----- .../schema/declarative/sync/sync.handler.ts | 11 ++----- .../declarative/sync/sync.integration.test.ts | 32 ------------------- 6 files changed, 15 insertions(+), 104 deletions(-) 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 f4888fced5..7f912cc69b 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 @@ -326,9 +326,8 @@ function schemaArguments(schema: ReadonlyArray, platform: LegacyShellPla export const legacyFormatDeclarativeSyncCommand = ( schema: ReadonlyArray, platform: LegacyShellPlatform, - options: { readonly allowRemovals?: boolean } = {}, ): string => - ` supabase db schema declarative sync --no-apply${options.allowRemovals === true ? " --allow-removals" : ""}${schemaArguments(schema, platform)} --experimental`; + ` supabase db schema declarative sync --no-apply${schemaArguments(schema, platform)} --experimental`; const adoptionCommand = ( declarativeDir: string, @@ -416,23 +415,17 @@ export interface LegacyDeclarativeUpgradeGateText { * `suggestion` so `Output.fail` prints them instead of the generic * "rerun with --debug" footer — a deliberate gate is not a crash. * - * Deliberately offers exactly ONE non-interactive recovery for the legacy-tree - * reading: the staged regenerate. Telling a non-interactive user to hand-add an - * extension declaration is a false trail — on a real legacy tree each - * declaration only unlocks the next refusal. Interactive flows still offer the - * repair as an advanced choice. - * - * The plan-refuse gate additionally names `--allow-removals` (`offerAllowRemovals`) - * for the other reading — the removals are intentional — which downgrades the - * gate to the destructive-changes warning. The load-fail gate cannot offer it: - * a tree that does not load has nothing to sync. + * Deliberately offers exactly ONE non-interactive recovery: the staged + * regenerate. Telling a non-interactive user to hand-add an extension + * declaration is a false trail — on a real legacy tree each declaration only + * unlocks the next refusal. Interactive flows still offer the repair as an + * advanced choice, and — for the plan-refuse gate — continuing with the removals. */ export function legacyFormatDeclarativeUpgradeGate(opts: { readonly evidence: ReadonlyArray; readonly context: LegacyStagedExportContext; - readonly offerAllowRemovals?: boolean; }): LegacyDeclarativeUpgradeGateText { - const { declarativeDir, schema, platform } = opts.context; + const { declarativeDir } = opts.context; return { message: [ `This ${declarativeDir} tree looks like a legacy pg-delta export.`, @@ -446,14 +439,6 @@ export function legacyFormatDeclarativeUpgradeGate(opts: { `Upgrade without changing the active ${declarativeDir} tree:`, "", ...stagedExportCommands(opts.context), - ...(opts.offerAllowRemovals === true - ? [ - "", - "If these removals are intentional, keep the tree and rerun with --allow-removals to review them as destructive changes:", - "", - legacyFormatDeclarativeSyncCommand(schema, platform, { allowRemovals: true }), - ] - : []), ].join("\n"), }; } 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 151180c493..8973dcdad0 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 @@ -244,33 +244,6 @@ describe("legacyFormatDeclarativeUpgradeGate", () => { ); }); - it("names --allow-removals only when the plan-refuse gate offers it", () => { - const context = { - declarativeDir: "supabase/schemas", - schema: ["app"], - platform: "posix" as const, - }; - const evidence = legacyFormatDeclarativeGapEvidence(classifyGap()); - const offered = legacyFormatDeclarativeUpgradeGate({ - evidence, - context, - offerAllowRemovals: true, - }); - expect( - offered.suggestion.endsWith( - [ - "", - "If these removals are intentional, keep the tree and rerun with --allow-removals to review them as destructive changes:", - "", - " supabase db schema declarative sync --no-apply --allow-removals --schema app --experimental", - ].join("\n"), - ), - ).toBe(true); - // The load-fail gate renders the same template but cannot honour the flag. - const withheld = legacyFormatDeclarativeUpgradeGate({ evidence, context }); - expect(withheld.suggestion).not.toContain("--allow-removals"); - }); - it("offers no extension.sql alternative — the staged upgrade is the only recovery", () => { const gate = legacyFormatDeclarativeUpgradeGate({ evidence: [ 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 21ff0b212f..99a6cb63cd 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 @@ -73,7 +73,7 @@ disabling safe compaction. | `1` | no declarative schema files found | | `1` | shadow-database / selected pg-delta engine / diff failure | | `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | -| `1` | legacy extension omissions in non-interactive mode (unless `--allow-removals`) | +| `1` | legacy extension omissions in non-interactive mode | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs @@ -99,11 +99,9 @@ export.` plus an indented evidence block, which for the plan gate enumerates the extensions and the extension-managed objects — cron jobs, pgmq queues — at risk) 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 recommended recovery is regenerating into `-next`, reviewing -it, and adopting it; the plan gate's suggestion additionally names -`--allow-removals`, which keeps the tree and downgrades that gate to the -destructive-changes warning below (`--yes` never doubles as this override). +Non-interactive execution (including `--yes`) stops there and modifies nothing; +the only recommended recovery is regenerating into `-next`, +reviewing it, and adopting it. Removing or renaming an extension-managed object (a `pg_cron` job, a `pgmq` queue) whose owning extension the tree still declares is an intentional change diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index 9e7b250042..0d14ba8b4d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -47,13 +47,6 @@ const config = { ), Flag.optional, ), - // Deliberately separate from `--yes`: `--yes` already auto-confirms every prompt, - // so it must not double as the override for a plan that drops extensions. - allowRemovals: Flag.boolean("allow-removals").pipe( - Flag.withDescription( - "Continue when the planned removals make the declarative tree look like a legacy pg-delta export, reviewing them as destructive changes instead of refusing the sync.", - ), - ), } as const; // `--no-cache` is a shared flag on the `declarative` group (read from the parent), @@ -65,7 +58,7 @@ export type LegacyDbSchemaDeclarativeSyncFlags = CliCommand.Command.Config.Infer export const legacyDbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( Command.withDescription( - "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing; --allow-removals accepts the removals as destructive changes instead. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", + "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", ), Command.withShortDescription("Generate a new migration from declarative schema"), Command.withHandler((flags) => @@ -87,7 +80,6 @@ export const legacyDbSchemaDeclarativeSyncCommand = Command.make("sync", config) name: merged.name, apply: merged.apply, "no-apply": merged.noApply, - "allow-removals": merged.allowRemovals, }, // Go registers `--schema`/`-s` (StringSliceVarP) and `--file`/`-f` // (StringVarP) (`cmd/db_schema_declarative.go:484-485`); telemetry reports diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 0fb1aa044f..8421659c10 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -527,16 +527,12 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara declaredExtensions: result.declaredExtensions, }); if (compatibility.recommendedAction === "none") break; - // `--allow-removals` is the scripted form of "Continue with removals": the - // planned removals are reviewed as destructive changes below instead of - // being refused as legacy-export evidence. - if (flags.allowRemovals) break; // Both recommended actions mean the same thing to the user — the tree is a // legacy export — so they render one shared template and differ only in the - // choices offered. Non-interactively the recoveries are the staged - // regenerate and `--allow-removals`, carried on `suggestion` so `Output.fail` - // prints them instead of the "rerun with --debug" footer. + // choices offered. Non-interactively there is exactly one recovery: the + // staged regenerate, carried on `suggestion` so `Output.fail` prints it + // instead of the "rerun with --debug" footer. const gate = legacyFormatDeclarativeUpgradeGate({ evidence: legacyFormatDeclarativeGapEvidence(compatibility), context: { @@ -544,7 +540,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, platform: legacyCurrentShellPlatform(), }, - offerAllowRemovals: true, }); if (!tty.stdinIsTty || yes) { return yield* Effect.fail( 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 18c18dc5b6..7c6e1336ea 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 @@ -401,7 +401,6 @@ const flags = ( name: over.name ?? Option.none(), apply: over.apply ?? Option.none(), noApply: over.noApply ?? Option.none(), - allowRemovals: over.allowRemovals ?? false, }); const failError = (exit: Exit.Exit) => @@ -1266,10 +1265,6 @@ describe("legacy db schema declarative sync integration", () => { }); expect(failError(exit)).toMatchObject({ message: expect.stringContaining(" Extension-managed objects: pg_cron job refresh"), - // The scripted escape for intentional removals rides on the suggestion too. - suggestion: expect.stringContaining( - "supabase db schema declarative sync --no-apply --allow-removals --experimental", - ), }); expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -1324,33 +1319,6 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect( - "--allow-removals downgrades the non-interactive gate to the destructive warning", - () => { - seedDeclarative(tmp.current); - const s = setup(tmp.current, { - engineImplementation: "next", - diffSql: "select cron.unschedule('refresh metrics');\nDROP EXTENSION \"postgis\";\n", - removals: { - extensions: ["postgis"], - extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], - }, - }); - return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeSync( - flags({ noApply: Option.some(true), allowRemovals: true }), - ); - expect(migrationSql(tmp.current)).toContain('DROP EXTENSION "postgis"'); - const stderr = stripAnsi(s.out.stderrText); - expect(stderr).not.toContain("looks like a legacy pg-delta export"); - expect(stderr).toContain("Found destructive changes in schema diff"); - expect(stderr).toContain('DROP EXTENSION "postgis"'); - expect(stderr).toContain("pg_cron job refresh metrics"); - expect(s.out.promptSelectCalls).toHaveLength(0); - }).pipe(Effect.provide(s.layer)); - }, - ); - it.effect("offers to continue with removals from the staged-export prompt", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { From 8324632ee74b401d783b90f593a4e8a39a5c91a6 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 3 Sep 2026 14:46:33 +0000 Subject: [PATCH 3/4] fix(cli): narrow the legacy-export gate to dropped extensions Only a dropped extension is legacy-export evidence; removing or renaming a pg_cron job or pgmq queue declaration is an ordinary change and no longer refuses the sync. Reverts the destructive-warning lines, the interactive "Continue with removals" choice, and the extra unit coverage in favour of one integration scenario and an e2e scenario that removes a queue and renames a job from the declarative tree. CLI-2282 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wh8sCVwt29sYWSYdcDUbWN --- .../db/schema/declarative/declarative.flow.ts | 48 +++------- .../declarative/declarative.flow.unit.test.ts | 86 +++--------------- ...eclarative.orchestrate.integration.test.ts | 4 - .../declarative/declarative.orchestrate.ts | 25 +---- .../schema/declarative/sync/SIDE_EFFECTS.md | 39 +++----- .../schema/declarative/sync/sync.e2e.test.ts | 58 ++++++++++++ .../schema/declarative/sync/sync.handler.ts | 3 - .../declarative/sync/sync.integration.test.ts | 91 +++---------------- 8 files changed, 118 insertions(+), 236 deletions(-) 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 7f912cc69b..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 @@ -35,26 +35,11 @@ type LegacyDeclarativeCompatibilityAction = "none" | "repair-extensions" | "stag export interface LegacyDeclarativeCompatibilityGap { readonly repairableExtensions: ReadonlyArray; - /** - * Extension-managed object removals (cron jobs, pgmq queues) whose owning - * extension the tree does not declare. A removal whose owner IS declared is an - * intentional delete on a maintained tree and never appears here — it flows - * through the destructive-changes warning instead. - */ readonly extensionIntents: LegacyPgDeltaRemovalSummary["extensionIntents"]; readonly ambiguousRemovals: ReadonlyArray; readonly recommendedAction: LegacyDeclarativeCompatibilityAction; } -/** - * The destructive-changes warning line for an extension-managed object removal - * (`pg_cron job `, `pgmq queue `); also the evidence form the - * plan-refuse gate enumerates. - */ -export const legacyFormatExtensionIntentRemoval = ( - intent: LegacyPgDeltaRemovalSummary["extensionIntents"][number], -): string => `${intent.extension} ${intent.intentKind} ${intent.key}`; - /** * Pure control-flow helpers ported from the legacy Go implementation (deleted * in CLI-1970; last present at commit 7b469f5b3) and kept free of @@ -88,23 +73,11 @@ const emptyCompatibilityGap = (): LegacyDeclarativeCompatibilityGap => ({ recommendedAction: "none", }); -/** - * Classifies manifest-less pg-delta next removals without performing any I/O. - * - * Only a missing `CREATE EXTENSION` declaration is legacy-export evidence: legacy - * exports omitted platform extensions wholesale, and a tree that omits `pg_cron` - * also plans the pg_cron *extension* removal, so the gate still fires for it and - * still enumerates the jobs at risk. An extension-managed object removal whose - * owner the tree declares (`declaredExtensions`, from the loaded SQL files) is an - * intentional delete or rename on a maintained tree and must not trip the gate - * (CLI-2282); the caller surfaces it through the destructive-changes warning. - */ +/** Classifies manifest-less pg-delta next removals without performing any I/O. */ export function legacyClassifyDeclarativeCompatibilityGap(opts: { readonly implementation: LegacyPgDeltaImplementation; readonly manifestPresent: boolean; readonly removals: LegacyPgDeltaRemovalSummary; - /** Lower-cased extension names the declarative tree declares. */ - readonly declaredExtensions: ReadonlySet; }): LegacyDeclarativeCompatibilityGap { if (opts.implementation !== "next" || opts.manifestPresent) return emptyCompatibilityGap(); @@ -115,15 +88,16 @@ export function legacyClassifyDeclarativeCompatibilityGap(opts: { const ambiguousRemovals = extensions.filter( (extension) => !LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), ); - const extensionIntents = opts.removals.extensionIntents.filter( - (intent) => !opts.declaredExtensions.has(intent.extension.toLowerCase()), + // 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, @@ -396,7 +370,7 @@ export function legacyFormatDeclarativeGapEvidence( ...(gap.extensionIntents.length > 0 ? [ `Extension-managed objects: ${gap.extensionIntents - .map(legacyFormatExtensionIntentRemoval) + .map((intent) => `${intent.extension} ${intent.intentKind} ${intent.key}`) .join(", ")}`, ] : []), @@ -419,7 +393,7 @@ export interface LegacyDeclarativeUpgradeGateText { * regenerate. Telling a non-interactive user to hand-add an extension * declaration is a false trail — on a real legacy tree each declaration only * unlocks the next refusal. Interactive flows still offer the repair as an - * advanced choice, and — for the plan-refuse gate — continuing with the removals. + * advanced choice. */ export function legacyFormatDeclarativeUpgradeGate(opts: { readonly evidence: ReadonlyArray; 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 8973dcdad0..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 @@ -6,7 +6,6 @@ import { legacyExtensionDeclaration, legacyFormatDeclarativeGapEvidence, legacyFormatDeclarativeUpgradeGate, - legacyFormatExtensionIntentRemoval, legacyFormatStagedExportAdoption, legacyResolveDeclarativeMigrationName, legacyResolveDeclarativeSyncApplyDecision, @@ -19,12 +18,12 @@ const stuck = (message: string) => ({ message, }); -const cronJob = { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }; -const pgmqQueue = { extension: "pgmq", intentKind: "queue", key: "emails" }; - const removals = { - extensions: ["pgcrypto", "uuid-ossp"], - extensionIntents: [cronJob, pgmqQueue], + extensions: ["pg_cron", "pgcrypto", "pgmq", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + { extension: "pgmq", intentKind: "queue", key: "emails" }, + ], }; const classifyGap = ( @@ -34,7 +33,6 @@ const classifyGap = ( implementation: "next", manifestPresent: false, removals, - declaredExtensions: new Set(), ...overrides, }); @@ -74,63 +72,26 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { }, }, { - name: "stages extension intents whose owning extension the tree does not declare", - overrides: { removals: { extensions: [], extensionIntents: [cronJob] } }, + name: "stages dropped extensions along with the objects they manage", + overrides: {}, expected: { recommendedAction: "stage-next-export", - repairableExtensions: [], - ambiguousRemovals: [], - extensionIntents: [cronJob], + ambiguousRemovals: ["pg_cron", "pgmq"], + extensionIntents: removals.extensionIntents, }, }, { - name: "does not gate an intent removal whose owning extension the tree declares", + name: "ignores cron job and pgmq queue removals whose extension stays", overrides: { - removals: { extensions: [], extensionIntents: [cronJob, pgmqQueue] }, - declaredExtensions: new Set(["pg_cron", "pgmq"]), + removals: { extensions: ["pgcrypto"], extensionIntents: removals.extensionIntents }, }, - expected: { recommendedAction: "none", extensionIntents: [] }, + expected: { recommendedAction: "repair-extensions", extensionIntents: [] }, }, { - name: "matches declared owners case-insensitively", - overrides: { - removals: { extensions: [], extensionIntents: [{ ...cronJob, extension: "PG_CRON" }] }, - declaredExtensions: new Set(["pg_cron"]), - }, + name: "never gates on cron job and pgmq queue removals alone", + overrides: { removals: { extensions: [], extensionIntents: removals.extensionIntents } }, expected: { recommendedAction: "none" }, }, - { - name: "gates a mix of declared and undeclared owners on the undeclared ones only", - overrides: { - removals: { extensions: [], extensionIntents: [cronJob, pgmqQueue] }, - declaredExtensions: new Set(["pg_cron"]), - }, - expected: { recommendedAction: "stage-next-export", extensionIntents: [pgmqQueue] }, - }, - { - name: "keeps the whole-extension gate when a declared-owner intent is also removed", - overrides: { - removals: { extensions: ["postgis"], extensionIntents: [cronJob] }, - declaredExtensions: new Set(["pg_cron"]), - }, - expected: { - recommendedAction: "stage-next-export", - ambiguousRemovals: ["postgis"], - extensionIntents: [], - }, - }, - { - name: "still offers the in-place repair when only declared-owner intents accompany it", - overrides: { - removals: { extensions: ["pgcrypto"], extensionIntents: [cronJob] }, - declaredExtensions: new Set(["pg_cron"]), - }, - expected: { - recommendedAction: "repair-extensions", - repairableExtensions: ["pgcrypto"], - extensionIntents: [], - }, - }, { name: "trusts a next export manifest", overrides: { manifestPresent: true }, @@ -163,24 +124,6 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { ); }); - it("formats an extension-managed object removal as a destructive-changes line", () => { - expect(legacyFormatExtensionIntentRemoval(cronJob)).toBe( - "pg_cron job refresh download metrics", - ); - expect(legacyFormatExtensionIntentRemoval(pgmqQueue)).toBe("pgmq queue emails"); - }); - - it("omits declared-owner intent removals from the gate evidence", () => { - expect( - legacyFormatDeclarativeGapEvidence( - classifyGap({ - removals: { extensions: ["postgis"], extensionIntents: [cronJob, pgmqQueue] }, - declaredExtensions: new Set(["pg_cron"]), - }), - ), - ).toEqual(["Extensions: postgis", "Extension-managed objects: pgmq queue emails"]); - }); - it("derives staged-export commands from a custom declarative path", () => { const { suggestion } = legacyFormatDeclarativeUpgradeGate({ evidence: legacyFormatDeclarativeGapEvidence(classifyGap()), @@ -226,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/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 519d3bfa57..0fda4fa2a3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -245,17 +245,13 @@ describe("legacyDiffDeclarativeToMigrations", () => { expect(calls[0]?.noCache).toBe(true); expect(calls[0]?.strictCoverage).toBe(true); expect(result.manifestPresent).toBe(true); - // Data-loss actions first, then every extension-managed object removal in - // words — pg-delta does not flag `cron.unschedule` as data loss itself. expect(result.dropWarnings).toEqual([ "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;", - "pg_cron job refresh metrics", ]); expect(result.removals).toEqual({ extensions: ["pgcrypto"], extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], }); - expect(result.declaredExtensions).toEqual(new Set()); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index 64fddc4437..d065b1bf5a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -34,9 +34,7 @@ import { import { legacyClassifyDeclarativeLoadCompatibility, legacyCurrentShellPlatform, - legacyDeclaredExtensions, legacyFormatDeclarativeUpgradeGate, - legacyFormatExtensionIntentRemoval, type LegacyDeclarativeLoadCompatibilityFinding, type LegacyDeclarativeUpgradeGateText, } from "./declarative.flow.ts"; @@ -62,16 +60,9 @@ export interface LegacyDeclarativeSyncResult { readonly files: ReadonlyArray; readonly sourceRef: string; readonly targetRef: string; - /** - * Lines for the destructive-changes warning: the engine's data-loss actions (or, - * under the legacy engine, its DROP statements) followed by one line per - * extension-managed object removal (`pg_cron job `, `pgmq queue `). - */ readonly dropWarnings: ReadonlyArray; readonly manifestPresent: boolean; readonly removals: LegacyPgDeltaRemovalSummary; - /** Lower-cased extension names the loaded declarative tree declares. */ - readonly declaredExtensions: ReadonlySet; } const declarativeError = (message: string) => new LegacyDeclarativeDiffError({ message }); @@ -166,25 +157,17 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( }); }), ); - const removals = result.removals ?? { extensions: [], extensionIntents: [] }; return { diffSQL: result.sql, files: result.files, sourceRef: result.sourceRef, targetRef: result.targetRef, - // pg-delta's hazard report does not flag `cron.unschedule` as data loss and - // cannot name the object a data-loss action removes, so every extension-managed - // object removal is appended in words — the same confirmation path a DROP - // COLUMN takes, whether or not the tree carries an export manifest (CLI-2282). - dropWarnings: [ - ...(engine.implementation === "next" && result.hazards !== undefined + dropWarnings: + engine.implementation === "next" && result.hazards !== undefined ? result.hazards.dataLoss.map((action) => action.sql) - : legacyFindDropStatements(result.sql)), - ...removals.extensionIntents.map(legacyFormatExtensionIntentRemoval), - ], + : legacyFindDropStatements(result.sql), manifestPresent: manifest !== undefined, - removals, - declaredExtensions: legacyDeclaredExtensions(files), + removals: result.removals ?? { extensions: [], extensionIntents: [] }, } satisfies LegacyDeclarativeSyncResult; }); 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 99a6cb63cd..5eae51ee02 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 @@ -73,7 +73,7 @@ disabling safe compaction. | `1` | no declarative schema files found | | `1` | shadow-database / selected pg-delta engine / diff failure | | `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | -| `1` | legacy extension omissions in non-interactive mode | +| `1` | repairable legacy extension omissions in non-interactive mode | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs @@ -92,31 +92,22 @@ 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 removes an -extension the tree no longer declares (`CREATE EXTENSION` missing). Both render -the same message (`This tree looks like a legacy pg-delta -export.` plus an indented evidence block, which for the plan gate enumerates the -extensions and the extension-managed objects — cron jobs, pgmq queues — at risk) -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. - -Removing or renaming an extension-managed object (a `pg_cron` job, a `pgmq` -queue) whose owning extension the tree still declares is an intentional change -on a maintained tree, not legacy-export evidence: it never trips the gate. The -generated migration carries the `cron.unschedule`/`pgmq.drop_queue` call and the -destructive-changes warning lists it in words (`pg_cron job `, -`pgmq queue `) after the engine's data-loss statements — with or without an -export manifest. +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. In a TTY both gates additionally offer to generate that staged export -(recommended), to continue with the removals, or cancel; when the gap is only -`pgcrypto`, `uuid-ossp`, or `pg_net` the plan gate also offers to append those -declarations to `/extension.sql` and re-plan. The in-place -repair is an advanced choice (it may surface another gap on the next plan); it -never overwrites existing SQL or creates an export manifest. +(recommended), and — when the gap is only `pgcrypto`, `uuid-ossp`, or `pg_net` — +to append those declarations to `/extension.sql` and re-plan, or +to continue with the removals, or cancel. The in-place repair is an advanced +choice (it may surface another gap on the next plan); it never overwrites +existing SQL or creates an export manifest. ## Notes 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..9507e0fb11 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,55 @@ 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, + }, + ); + const latestMigrationSql = () => { + const latest = migrationFiles(projectDir).at(-1); + if (latest === undefined) throw new Error("sync did not write a migration"); + return readFileSync(path.join(projectDir, "supabase", "migrations", latest), "utf8"); + }; + + writeFileSync( + jobsPath, + [ + "select cron.schedule('nightly_cleanup', '0 3 * * *', $$delete from public.disposable_note$$);", + "select pgmq.create('emails');", + "", + ].join("\n"), + ); + const added = await sync("add_jobs"); + expect(added.exitCode, commandFailure(added)).toBe(0); + expect(latestMigrationSql()).toContain("cron.schedule('nightly_cleanup'"); + expect(latestMigrationSql()).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 sync("rename_job_drop_queue"); + expect(removed.exitCode, commandFailure(removed)).toBe(0); + expect(`${removed.stdout}${removed.stderr}`).not.toContain("legacy pg-delta export"); + const sql = latestMigrationSql(); + expect(sql).toContain("cron.unschedule('nightly_cleanup')"); + expect(sql).toContain("cron.schedule('weekly_cleanup'"); + expect(sql).toContain("pgmq.drop_queue('emails')"); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 8421659c10..74d73d93a0 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -524,7 +524,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara implementation: engine.implementation, manifestPresent: result.manifestPresent, removals: result.removals, - declaredExtensions: result.declaredExtensions, }); if (compatibility.recommendedAction === "none") break; @@ -558,10 +557,8 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara label: `Generate next export to ${stagedDirRel}`, hint: "recommended", }, - { value: "continue", label: "Continue with removals" }, { value: "cancel", label: "Cancel" }, ]); - if (choice === "continue") break; if (choice === "stage") yield* stageNextExport(); return; } 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 7c6e1336ea..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 @@ -406,25 +406,10 @@ const flags = ( const failError = (exit: Exit.Exit) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; -const seedDeclarative = (workdir: string, sql = "create table a();") => { +const seedDeclarative = (workdir: string) => { const dir = join(workdir, "supabase", "schemas"); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "public.sql"), sql); -}; - -/** A maintained, converged tree that declares the extensions whose objects it manages. */ -const MAINTAINED_TREE_SQL = [ - "create extension if not exists pg_cron with schema pg_catalog;", - "create extension if not exists pgmq;", - "create table a();", - "", -].join("\n"); - -const migrationSql = (workdir: string) => { - const dir = join(workdir, "supabase", "migrations"); - const [file] = readdirSync(dir); - expect(file).toBeDefined(); - return readFileSync(join(dir, file ?? ""), "utf8"); + writeFileSync(join(dir, "public.sql"), "create table a();"); }; const seedLegacyUuidDeclarative = (workdir: string, directory = "schemas") => { @@ -1245,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" }, ], @@ -1270,74 +1255,28 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("honours deleting a pgmq queue from a maintained tree as a destructive change", () => { - // Dogfooding scenario (CLI-2282): the tree declares pgmq, one queue declaration - // is removed alongside unrelated schema work. Previously the whole sync — the - // unrelated work included — was refused as a legacy export. - seedDeclarative(tmp.current, MAINTAINED_TREE_SQL); + 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: "ALTER TABLE a ADD COLUMN b int;\nselect pgmq.drop_queue('emails');\n", + diffSql: "select cron.unschedule('refresh metrics');\nselect pgmq.drop_queue('emails');\n", removals: { extensions: [], - extensionIntents: [{ extension: "pgmq", intentKind: "queue", key: "emails" }], - }, - }); - return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect(migrationSql(tmp.current)).toContain("pgmq.drop_queue('emails')"); - const stderr = stripAnsi(s.out.stderrText); - expect(stderr).not.toContain("looks like a legacy pg-delta export"); - expect(stderr).toContain( - "Found destructive changes in schema diff. Please double check if these are expected:\npgmq queue emails", - ); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("renames a pg_cron job on a maintained tree without refusing", () => { - seedDeclarative(tmp.current, MAINTAINED_TREE_SQL); - const s = setup(tmp.current, { - engineImplementation: "next", - yes: true, - diffSql: [ - "select cron.unschedule('refresh metrics');", - "select cron.schedule('refresh download metrics', '0 * * * *', $$select 1$$);", - "", - ].join("\n"), - removals: { - extensions: [], - extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], + 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 sql = migrationSql(tmp.current); + 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("cron.schedule('refresh download metrics'"); - expect(stripAnsi(s.out.stderrText)).toContain("pg_cron job refresh metrics"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("offers to continue with removals from the staged-export prompt", () => { - seedDeclarative(tmp.current); - const s = setup(tmp.current, { - engineImplementation: "next", - stdinIsTty: true, - diffSql: 'DROP EXTENSION "postgis";\n', - removals: { extensions: ["postgis"], extensionIntents: [] }, - promptSelectResponses: ["continue"], - }); - return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ - "stage", - "continue", - "cancel", - ]); - expect(migrationSql(tmp.current)).toContain('DROP EXTENSION "postgis"'); - expect(existsSync(join(tmp.current, "supabase", "schemas-next"))).toBe(false); - expect(stripAnsi(s.out.stderrText)).toContain("Found destructive changes in schema diff"); + expect(sql).toContain("pgmq.drop_queue('emails')"); + expect(stripAnsi(s.out.stderrText)).not.toContain("legacy pg-delta export"); }).pipe(Effect.provide(s.layer)); }); From cc83f6009d2386484590c4acfa0cffea00f362a3 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 3 Sep 2026 19:31:10 +0000 Subject: [PATCH 4/4] test(cli): read every migration file a declarative sync writes in the e2e A next-engine plan may span several ordered migration files; assert on the concatenation of the files each sync added instead of the last one. Also rewraps a SIDE_EFFECTS.md paragraph. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wh8sCVwt29sYWSYdcDUbWN --- .../schema/declarative/sync/SIDE_EFFECTS.md | 10 ++--- .../schema/declarative/sync/sync.e2e.test.ts | 41 ++++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) 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 5eae51ee02..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 @@ -94,11 +94,11 @@ 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 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 +`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 9507e0fb11..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 @@ -196,10 +196,22 @@ describe("db schema declarative sync (e2e)", () => { exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); - const latestMigrationSql = () => { - const latest = migrationFiles(projectDir).at(-1); - if (latest === undefined) throw new Error("sync did not write a migration"); - return readFileSync(path.join(projectDir, "supabase", "migrations", latest), "utf8"); + // 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( @@ -210,23 +222,22 @@ describe("db schema declarative sync (e2e)", () => { "", ].join("\n"), ); - const added = await sync("add_jobs"); - expect(added.exitCode, commandFailure(added)).toBe(0); - expect(latestMigrationSql()).toContain("cron.schedule('nightly_cleanup'"); - expect(latestMigrationSql()).toContain("pgmq.create('emails')"); + 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 sync("rename_job_drop_queue"); - expect(removed.exitCode, commandFailure(removed)).toBe(0); - expect(`${removed.stdout}${removed.stderr}`).not.toContain("legacy pg-delta export"); - const sql = latestMigrationSql(); - expect(sql).toContain("cron.unschedule('nightly_cleanup')"); - expect(sql).toContain("cron.schedule('weekly_cleanup'"); - expect(sql).toContain("pgmq.drop_queue('emails')"); + 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')"); }, ); });