Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/cli/legacy-complete.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe("legacyRespondToComplete", () => {
// `--debug ""` used to return zero candidates entirely: the leftover-args
// computation counted `--debug` itself as "positional leftover," gating
// out subcommand-name completion the way cobra never does for a
// persistent flag: `__complete --debug ''` lists all 36 root commands.
// persistent flag: `__complete --debug ''` lists all root commands.
const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", ""]);
expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp);
expect(result?.candidates.map((c) => c.name)).toContain("branches");
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/legacy/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { legacyLinkCommand } from "../commands/link/link.command.ts";
import { legacyLoginCommand } from "../commands/login/login.command.ts";
import { legacyLogoutCommand } from "../commands/logout/logout.command.ts";
import { legacyMigrationCommand } from "../commands/migration/migration.command.ts";
import { legacyMigrationsCommand } from "../commands/migrations/migrations.command.ts";
import { legacySchemaCommand } from "../commands/schema/schema.command.ts";
import { legacyNetworkBansCommand } from "../commands/network-bans/network-bans.command.ts";
import { legacyNetworkRestrictionsCommand } from "../commands/network-restrictions/network-restrictions.command.ts";
import { legacyOrgsCommand } from "../commands/orgs/orgs.command.ts";
Expand Down Expand Up @@ -78,11 +80,13 @@ export const legacyRoot = Command.make("supabase").pipe(
legacyLoginCommand,
legacyLogoutCommand,
legacyMigrationCommand,
legacyMigrationsCommand,
legacyNetworkBansCommand,
legacyNetworkRestrictionsCommand,
legacyOrgsCommand,
legacyPostgresConfigCommand,
legacyProjectsCommand,
legacySchemaCommand,
legacySecretsCommand,
legacySeedCommand,
legacyServicesCommand,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ function setup() {
Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })),
),
),
provisionPlatform: () =>
Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })),
provisionDeclarative: () =>
Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })),
provisionPlan: (opts) =>
Effect.sync(() => {
state.plan += 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
import {
LegacyPgDeltaNextShadow,
type LegacyPgDeltaNextMigrationsShadow,
type LegacyPgDeltaNextPlatformShadow,
type LegacyPgDeltaNextPlanShadows,
type LegacyPgDeltaNextShadowInput,
} from "./legacy-pgdelta-next-shadow.service.ts";
import {
DECLARATIVE_SHADOW_PREP_FAILURE_SUGGESTION,
declarativeBaselinePrepStatements,
} from "../../../../shared/schema/prepare-declarative-shadow.ts";
import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts";

const allocateFreeHostPort = Effect.callback<Option.Option<number>>((resume) => {
Expand Down Expand Up @@ -116,22 +121,28 @@ export function legacyAllowSameDatabaseIdentityForPlanShadows(opts: {
}

/**
* Removes extensions that the legacy PG14 platform baseline installs implicitly
* so the declarative shadow reflects only extension declarations in schema files.
* `pgjwt` has a hard extension dependency on `pgcrypto`, and `storage.objects.id`
* depends on `uuid-ossp`, so both dependencies must be detached before the
* user-manageable extensions can be dropped with the default RESTRICT behavior.
* Strip implicit platform extensions so the declarative shadow only keeps what
* schema files declare. `pgjwt` still ships in the PG15+ image and DEPENDS ON
* `pgcrypto`; PG14 also needs `storage.objects.id` detached from `uuid-ossp`.
*/
export const legacyPreparePgDeltaNextDeclarativeBaseline = Effect.fnUntraced(function* (
session: Pick<LegacyDbSession, "exec">,
majorVersion: number,
) {
if (majorVersion === 14) {
yield* session.exec("ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT");
yield* session.exec("DROP EXTENSION IF EXISTS pgjwt");
for (const sql of declarativeBaselinePrepStatements(majorVersion)) {
yield* session.exec(sql).pipe(
Effect.mapError((error) => {
const detail =
error.detail !== undefined && error.detail.length > 0
? `\n Detail: ${error.detail}`
: "";
return new LegacyDeclarativeShadowDbError({
message: `Failed to prepare the isolated declaration shadow (${sql}): ${error.message}${detail}`,
suggestion: DECLARATIVE_SHADOW_PREP_FAILURE_SUGGESTION,
});
}),
);
}
yield* session.exec("DROP EXTENSION IF EXISTS pgcrypto");
yield* session.exec('DROP EXTENSION IF EXISTS "uuid-ossp"');
});

const setupRunInput = (input: NativeShadowInput, handle: LegacyShadowAcquiredHandle) => ({
Expand Down Expand Up @@ -297,6 +308,17 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
} satisfies LegacyPgDeltaNextMigrationsShadow;
}).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError));

const provisionPlatform = (input: NativeShadowInput, opts: LegacyShadowCacheOpts) =>
Effect.gen(function* () {
const handle = yield* acquireShadow(input, opts);
yield* awaitShadowReady(input, handle);
const setup = setupRunInput(input, handle);
yield* legacySetupShadowDatabase(input.spawner, setup, {}, handle);
return {
platformUrl: legacyToPostgresURL(setup.connConfig),
} satisfies LegacyPgDeltaNextPlatformShadow;
}).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError));

const provisionDeclarative = (
input: NativeShadowInput,
opts: LegacyShadowCacheOpts,
Expand Down Expand Up @@ -338,6 +360,20 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
const input = buildNativeInput(opts, built, port);
return yield* provisionMigrations(input, cacheOpts(opts, "config"));
}).pipe(Effect.mapError(nextShadowError)),
provisionPlatform: (opts) =>
Effect.gen(function* () {
const port = yield* nextPort();
const built = yield* buildNativeBase(opts);
const input = buildNativeInput(opts, built, port);
return yield* provisionPlatform(input, cacheOpts(opts, "config"));
}).pipe(Effect.mapError(nextShadowError)),
provisionDeclarative: (opts) =>
Effect.gen(function* () {
const port = yield* nextPort();
const built = yield* buildNativeBase(opts);
const input = buildNativeInput(opts, built, port);
return yield* provisionDeclarative(input, cacheOpts(opts, "disabled"));
}).pipe(Effect.mapError(nextShadowError)),
provisionPlan: (opts) =>
Effect.gen(function* () {
const migrationsPort = yield* nextPort();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { it } from "@effect/vitest";
import { Effect } from "effect";
import { Cause, Effect, Exit, Option } from "effect";
import { describe, expect, it as vitestIt } from "vitest";

import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts";
import {
legacyAllowSameDatabaseIdentityForPlanShadows,
legacyPreparePgDeltaNextDeclarativeBaseline,
} from "./legacy-pgdelta-next-shadow.layer.ts";
import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts";

function recordingSession() {
const statements: string[] = [];
Expand Down Expand Up @@ -34,16 +36,51 @@ describe("legacyPreparePgDeltaNextDeclarativeBaseline", () => {
});
});

it.effect("does not modify PG15+ platform objects before dropping extensions", () => {
it.effect("drops pgjwt before pgcrypto on PG15+", () => {
const { session, statements } = recordingSession();
return Effect.gen(function* () {
yield* legacyPreparePgDeltaNextDeclarativeBaseline(session, 17);
expect(statements).toEqual([
"DROP EXTENSION IF EXISTS pgjwt",
"DROP EXTENSION IF EXISTS pgcrypto",
'DROP EXTENSION IF EXISTS "uuid-ossp"',
]);
});
});

it.effect("names the failing prep statement and postgres detail", () => {
const session = {
exec: (sql: string) =>
sql.includes("pgcrypto")
? Effect.fail(
new LegacyDbExecError({
message:
"ERROR: cannot drop extension pgcrypto because other objects depend on it (SQLSTATE 2BP01)",
detail: "extension pgjwt depends on extension pgcrypto",
}),
)
: Effect.void,
};
return Effect.gen(function* () {
const exit = yield* legacyPreparePgDeltaNextDeclarativeBaseline(session, 17).pipe(
Effect.exit,
);
expect(Exit.isFailure(exit)).toBe(true);
const error = Exit.isFailure(exit)
? Option.getOrUndefined(Cause.findErrorOption(exit.cause))
: undefined;
expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError);
expect(error instanceof LegacyDeclarativeShadowDbError ? error.message : "").toContain(
"DROP EXTENSION IF EXISTS pgcrypto",
);
expect(error instanceof LegacyDeclarativeShadowDbError ? error.message : "").toContain(
"extension pgjwt depends on extension pgcrypto",
);
expect(error instanceof LegacyDeclarativeShadowDbError ? error.suggestion : "").toContain(
"supabase issue bug",
);
});
});
});

describe("legacyAllowSameDatabaseIdentityForPlanShadows", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export interface LegacyPgDeltaNextMigrationsShadow {
readonly migrationsUrl: string;
}

/** Platform baseline with no project migrations and no declaration-prep drops. */
export interface LegacyPgDeltaNextPlatformShadow {
readonly platformUrl: string;
}

/** The two live databases needed to plan declarative SQL with pg-delta next. */
export interface LegacyPgDeltaNextPlanShadows extends LegacyPgDeltaNextMigrationsShadow {
/** Independent platform baseline owned by `planSchemaFiles` while loading desired SQL. */
Expand Down Expand Up @@ -41,6 +46,25 @@ interface LegacyPgDeltaNextShadowShape {
LegacyDeclarativeShadowDbError,
Scope.Scope
>;
/**
* Platform baseline only: no project migrations, no pgjwt/pgcrypto/uuid-ossp
* drop. Shares the migrations cache key (`webhooks: config`). Removed when
* the current Effect scope closes.
*/
readonly provisionPlatform: (
opts: LegacyPgDeltaNextShadowInput,
) => Effect.Effect<LegacyPgDeltaNextPlatformShadow, LegacyDeclarativeShadowDbError, Scope.Scope>;
/**
* Provisions only the declarative next-engine shadow (stripped platform
* baseline, no project migrations). Removed when the current Effect scope closes.
*/
readonly provisionDeclarative: (
opts: LegacyPgDeltaNextShadowInput,
) => Effect.Effect<
{ readonly declarativeUrl: string },
LegacyDeclarativeShadowDbError,
Scope.Scope
>;
/**
* Provisions the independent migrated and declarative shadows needed by a
* declarative plan. Concurrency is strategy-driven (see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { legacyMigrationFetchCommand } from "./fetch/fetch.command.ts";
export const legacyMigrationCommand = Command.make("migration").pipe(
Command.withDescription("Manage database migration scripts."),
Command.withShortDescription("Manage database migration scripts"),
Command.withAlias("migrations"),
Command.withSubcommands([
legacyMigrationListCommand,
legacyMigrationNewCommand,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,48 @@ import { CliOutput, Command } from "effect/unstable/cli";
import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts";
import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts";
import { legacyMigrationCommand } from "./migration.command.ts";
import { legacyMigrationsCommand } from "../migrations/migrations.command.ts";

// `withGlobalFlags` must come AFTER `withSubcommands` — see
// `start.string-slice-flags.integration.test.ts`'s identical comment.
const legacyTestRoot = Command.make("supabase").pipe(
Command.withSubcommands([legacyMigrationCommand]),
Command.withSubcommands([legacyMigrationCommand, legacyMigrationsCommand]),
Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS),
);

describe("legacy migration command integration", () => {
it.live("accepts the Go-compatible plural migrations alias", () => {
// After CLI-1969, `squash` is native and no `migration` subcommand is proxied
// any more — so the plural alias is now proven at the PARSER instead: a
// `migrations squash --nope` must fail with squash's own unknown-flag error,
// which never builds the command's `Command.provide` runtime layer.
describe("legacy migration and migrations commands", () => {
it.live("keeps singular migration as the Go-parity group", () => {
const run = Effect.gen(function* () {
const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([
"migrations",
"migration",
"squash",
"--nope",
]).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const causeJson = JSON.stringify(exit.cause);
// The alias resolved: the parse error is scoped to the squash LEAF, not the root.
expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]');
expect(causeJson).not.toContain('"subcommand":"migrations"');
}
}).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter())));

// Command.runWith's Environment type is retained even though this path only needs CliOutput
// at runtime.
return run as Effect.Effect<void>;
});

it.live("routes plural migrations to the schema-first group", () => {
const run = Effect.gen(function* () {
const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([
"migrations",
"apply",
"--nope",
]).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const causeJson = JSON.stringify(exit.cause);
expect(causeJson).toContain('"commandPath":["supabase","migrations","apply"]');
expect(causeJson).not.toContain('"commandPath":["supabase","migration","apply"]');
}
}).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter())));

return run as Effect.Effect<void>;
});
});
14 changes: 14 additions & 0 deletions apps/cli/src/legacy/commands/migrations/apply/apply.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Command } from "effect/unstable/cli";
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts";
import { legacyMigrationsApply } from "./apply.handler.ts";

export const legacyMigrationsApplyCommand = Command.make("apply").pipe(
Command.withDescription("Apply exact pending migration files to the local database."),
Command.withShortDescription("Apply pending migrations locally"),
Command.withHandler(() =>
legacyMigrationsApply().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling),
),
Command.provide(legacySchemaRuntimeLayer(["migrations", "apply"])),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Effect } from "effect";
import { applyMigrations } from "../../../../shared/migrations/apply-migrations.ts";
import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts";

export const legacyMigrationsApply = Effect.fn("legacy.migrations.apply")(function* () {
const result = yield* applyMigrations();
yield* renderSchemaResult("Apply migrations", result);
});
39 changes: 39 additions & 0 deletions apps/cli/src/legacy/commands/migrations/diff/diff.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts";
import { legacyMigrationsDiff } from "./diff.handler.ts";

const config = {
against: Flag.string("against").pipe(
Flag.withDescription("Live database to compare: local, linked, or a connection string."),
Flag.optional,
),
file: Flag.string("file").pipe(
Flag.withDescription("Write preview SQL to a file without applying it."),
Flag.withAlias("f"),
Flag.optional,
),
} as const;

export type LegacyMigrationsDiffFlags = CliCommand.Command.Config.Infer<typeof config>;

export const legacyMigrationsDiffCommand = Command.make("diff", config).pipe(
Command.withDescription(
"Preview the SQL required to move from migration replay to a live database.\n\n" +
"This is the successor to db diff. It never mutates the database.",
),
Command.withShortDescription("Diff migration replay against a live database"),
Command.withExamples([
{ command: "supabase migrations diff --against local", description: "Preview local drift" },
{ command: "supabase migrations diff --against linked", description: "Preview remote drift" },
]),
Command.withHandler((flags) =>
legacyMigrationsDiff(flags).pipe(
withLegacyCommandInstrumentation({ flags, config, aliases: { f: "file" } }),
withJsonErrorHandling,
),
),
Command.provide(legacySchemaRuntimeLayer(["migrations", "diff"])),
);
14 changes: 14 additions & 0 deletions apps/cli/src/legacy/commands/migrations/diff/diff.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Effect, Option } from "effect";
import { diffMigrations } from "../../../../shared/migrations/diff-migrations.ts";
import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts";
import type { LegacyMigrationsDiffFlags } from "./diff.command.ts";

export const legacyMigrationsDiff = Effect.fn("legacy.migrations.diff")(function* (
flags: LegacyMigrationsDiffFlags,
) {
const result = yield* diffMigrations({
against: Option.getOrUndefined(flags.against),
file: Option.getOrUndefined(flags.file),
});
yield* renderSchemaResult("Diff migrations", result);
});
Loading
Loading