Skip to content
Merged
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: 2 additions & 0 deletions .oxlintrc.effect.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"!apps/cli/src/commands/link/**",
"!apps/cli/src/commands/login/**",
"!apps/cli/src/commands/logout/**",
"!apps/cli/src/commands/migration/**",
"!apps/cli/src/commands/network-bans/**",
"!apps/cli/src/commands/network-restrictions/**",
"!apps/cli/src/commands/orgs/**",
Expand All @@ -45,6 +46,7 @@
"apps/cli/src/shared/compute/stacks/**",
"!apps/cli/tests/helpers/branches-live.ts",
"!apps/cli/tests/helpers/compute.ts",
"!apps/cli/tests/helpers/migration-live.ts",
"!apps/cli/tests/helpers/postgres-config-live.ts",
"!apps/cli/tests/helpers/secrets-live.ts",
"!apps/cli/tests/helpers/storage-live.ts",
Expand Down
60 changes: 22 additions & 38 deletions apps/cli/src/commands/migration/down/down.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,20 @@ const runDown = Effect.fnUntraced(function* (

// Checked here, ahead of the root pre-run.
if (target.setFlags.length > 1) {
return yield* Effect.fail(
new MigrationTargetFlagsError({
message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`,
}),
);
return yield* new MigrationTargetFlagsError({
message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`,
});
}

const connType = target.connType ?? "local";

// `--project-ref` never implies `--linked` and must not be silently
// discarded on a non-linked target; see push.handler.ts's identical guard.
if (Option.isSome(flags.projectRef) && connType !== "linked") {
return yield* Effect.fail(
new MigrationTargetFlagsError({
message:
"--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)",
}),
);
return yield* new MigrationTargetFlagsError({
message:
"--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)",
});
}

// Resolves before `--last` validation, so an unlinked/invalid target error
Expand All @@ -80,23 +76,9 @@ const runDown = Effect.fnUntraced(function* (
const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir);
const yes = yield* resolveYesWithProjectEnv(projectEnv);

// Attached to the whole flow via `Effect.ensuring` so the cache write still
// runs on the `--last`/cancel failure paths.
const cacheLinkedRef =
connType === "linked"
? yield* Effect.gen(function* () {
const projectRef = yield* ProjectRefResolver;
const linkedProjectCache = yield* LinkedProjectCache;
const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef);
return linkedProjectCache.cache(linkedRef);
})
: undefined;

const downFlow = Effect.gen(function* () {
if (flags.last === 0) {
return yield* Effect.fail(
new MigrationLastZeroError({ message: "--last must be greater than 0" }),
);
return yield* new MigrationLastZeroError({ message: "--last must be greater than 0" });
}

const ref = Option.getOrUndefined(cfg.ref ?? Option.none());
Expand All @@ -116,12 +98,10 @@ const runDown = Effect.fnUntraced(function* (
const remote = yield* listRemoteMigrations(session);
const total = remote.length;
if (total <= flags.last) {
return yield* Effect.fail(
new MigrationLastTooLargeError({
message: `--last must be smaller than total applied migrations: ${total}`,
suggestion: `Try ${aqua("supabase db reset")} if you want to revert all migrations.`,
}),
);
return yield* new MigrationLastTooLargeError({
message: `--last must be smaller than total applied migrations: ${total}`,
suggestion: `Try ${aqua("supabase db reset")} if you want to revert all migrations.`,
});
}

const confirmed = yield* migrationConfirm(
Expand All @@ -132,9 +112,7 @@ const runDown = Effect.fnUntraced(function* (
},
);
if (!confirmed) {
return yield* Effect.fail(
new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }),
);
return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE });
}

const version = remote[total - flags.last - 1]!;
Expand All @@ -158,9 +136,15 @@ const runDown = Effect.fnUntraced(function* (
);
});

return yield* cacheLinkedRef === undefined
? downFlow
: downFlow.pipe(Effect.ensuring(cacheLinkedRef));
// Attached to the whole flow via `Effect.ensuring` so the cache write still
// runs on the `--last`/cancel failure paths.
if (connType === "linked") {
const projectRef = yield* ProjectRefResolver;
const linkedProjectCache = yield* LinkedProjectCache;
const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef);
return yield* downFlow.pipe(Effect.ensuring(linkedProjectCache.cache(linkedRef)));
}
return yield* downFlow;
});

export const migrationDown = Effect.fn("migration.down")(function* (flags: MigrationDownFlags) {
Expand Down
67 changes: 37 additions & 30 deletions apps/cli/src/commands/migration/down/down.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { Cause, Effect, Exit, Layer, Option } from "effect";
import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect";

import { stripAnsi } from "../../../../tests/helpers/ansi.ts";
import {
Expand Down Expand Up @@ -42,17 +40,12 @@ interface SetupOpts {
readonly failResolve?: boolean;
readonly failDrop?: boolean;
readonly failSeed?: boolean;
readonly config?: string;
readonly seedTable?: ReadonlyArray<{ path: string; hash: string }>;
}

const SELECT_SEED = "SELECT path, hash FROM supabase_migrations.seed_files";

function setup(workdir: string, opts: SetupOpts = {}) {
if (opts.config !== undefined) {
mkdirSync(join(workdir, "supabase"), { recursive: true });
writeFileSync(join(workdir, "supabase", "config.toml"), opts.config);
}
const out = mockOutput({
format: opts.format ?? "text",
promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm],
Expand Down Expand Up @@ -159,11 +152,25 @@ const flags = (over: Partial<MigrationDownFlags> = {}): MigrationDownFlags => ({
projectRef: over.projectRef ?? Option.none(),
});

const seed = (workdir: string, name: string, body = "create table a;\n") => {
const dir = join(workdir, "supabase", "migrations");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), body);
};
const seed = Effect.fnUntraced(function* (
workdir: string,
name: string,
body = "create table a;\n",
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = path.join(workdir, "supabase", "migrations");
yield* fs.makeDirectory(dir, { recursive: true });
yield* fs.writeFileString(path.join(dir, name), body);
});

const writeProjectFile = Effect.fnUntraced(function* (workdir: string, name: string, body: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = path.join(workdir, "supabase");
yield* fs.makeDirectory(dir, { recursive: true });
yield* fs.writeFileString(path.join(dir, name), body);
});

const tmp = useTempWorkdir();

Expand Down Expand Up @@ -205,12 +212,12 @@ describe("migration down", () => {
});

it.live("reverts to the target version on confirm (drop + migrate&seed)", () => {
seed(tmp.current, "20240101000000_a.sql");
const { layer, out, execs, queries } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* migrationDown(flags({ last: 1 }));
expect(stripAnsi(out.stderrText)).toContain("Connecting to local database...");
expect(stripAnsi(out.stderrText)).toContain("Resetting database to version: 20240101000000");
Expand All @@ -230,13 +237,13 @@ describe("migration down", () => {
// VALID_REF is the fake resolver's fallback, representing whatever the
// workdir would resolve to without the flag override.
const FLAG_REF = "flagflagflagflagflag";
seed(tmp.current, "20240101000000_a.sql");
const { layer, cache } = setup(tmp.current, {
args: ["--linked"],
confirm: true,
remote: ["20240101000000", "20240102000000"],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* migrationDown(
flags({ last: 1, linked: true, local: false, projectRef: Option.some(FLAG_REF) }),
);
Expand Down Expand Up @@ -271,12 +278,12 @@ describe("migration down", () => {
});

it.live("cancels on a declined prompt", () => {
seed(tmp.current, "20240101000000_a.sql");
const { layer, execs } = setup(tmp.current, {
confirm: false,
remote: ["20240101000000", "20240102000000"],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
Expand Down Expand Up @@ -305,13 +312,13 @@ describe("migration down", () => {
});

it.live("emits a structured result in json with --yes", () => {
seed(tmp.current, "20240101000000_a.sql");
const { layer, out } = setup(tmp.current, {
format: "json",
yes: true,
remote: ["20240101000000", "20240102000000"],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* migrationDown(flags({ last: 1 }));
expect(out.messages).toContainEqual(
expect.objectContaining({
Expand All @@ -324,14 +331,14 @@ describe("migration down", () => {
});

it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => {
seed(tmp.current, "20240101000000_a.sql");
// SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt.
writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n");
const { layer, out } = setup(tmp.current, {
format: "json",
remote: ["20240101000000", "20240102000000"],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* writeProjectFile(tmp.current, ".env", "SUPABASE_YES=true\n");
yield* migrationDown(flags({ last: 1 }));
expect(out.messages).toContainEqual(
expect.objectContaining({
Expand All @@ -344,13 +351,13 @@ describe("migration down", () => {
});

it.live("reports a drop-schema failure", () => {
seed(tmp.current, "20240101000000_a.sql");
const { layer } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
failDrop: true,
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
Expand All @@ -361,13 +368,13 @@ describe("migration down", () => {
});

it.live("seeds data from a new seed file and records its hash", () => {
seed(tmp.current, "20240101000000_a.sql");
writeFileSync(join(tmp.current, "supabase", "seed.sql"), "insert into a values (1);\n");
const { layer, out, queries } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* writeProjectFile(tmp.current, "seed.sql", "insert into a values (1);\n");
yield* migrationDown(flags({ last: 1 }));
expect(stripAnsi(out.stderrText)).toContain("Seeding data from supabase/seed.sql...");
expect(
Expand All @@ -377,14 +384,14 @@ describe("migration down", () => {
});

it.live("reports a seed-apply failure", () => {
seed(tmp.current, "20240101000000_a.sql");
writeFileSync(join(tmp.current, "supabase", "seed.sql"), "insert into a values (1);\n");
const { layer } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
failSeed: true,
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* writeProjectFile(tmp.current, "seed.sql", "insert into a values (1);\n");
const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
Expand All @@ -395,16 +402,16 @@ describe("migration down", () => {
});

it.live("skips an unchanged seed file", () => {
seed(tmp.current, "20240101000000_a.sql");
const body = "insert into a values (1);\n";
writeFileSync(join(tmp.current, "supabase", "seed.sql"), body);
const hash = createHash("sha256").update(body).digest("hex");
const { layer, out, queries } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
seedTable: [{ path: "supabase/seed.sql", hash }],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* writeProjectFile(tmp.current, "seed.sql", body);
yield* migrationDown(flags({ last: 1 }));
expect(stripAnsi(out.stderrText)).not.toContain("Seeding data from");
expect(
Expand All @@ -414,14 +421,14 @@ describe("migration down", () => {
});

it.live("updates the recorded hash (without re-running) for a changed seed file", () => {
seed(tmp.current, "20240101000000_a.sql");
writeFileSync(join(tmp.current, "supabase", "seed.sql"), "insert into a values (2);\n");
const { layer, out, execs, queries } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
seedTable: [{ path: "supabase/seed.sql", hash: "stale-hash-does-not-match" }],
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* writeProjectFile(tmp.current, "seed.sql", "insert into a values (2);\n");
yield* migrationDown(flags({ last: 1 }));
expect(stripAnsi(out.stderrText)).toContain("Updating seed hash to supabase/seed.sql...");
expect(
Expand All @@ -432,13 +439,13 @@ describe("migration down", () => {
});

it.live("skips migration apply when db.migrations.enabled = false", () => {
seed(tmp.current, "20240101000000_a.sql");
const { layer, queries } = setup(tmp.current, {
confirm: true,
remote: ["20240101000000", "20240102000000"],
config: "[db.migrations]\nenabled = false\n",
});
return Effect.gen(function* () {
yield* seed(tmp.current, "20240101000000_a.sql");
yield* writeProjectFile(tmp.current, "config.toml", "[db.migrations]\nenabled = false\n");
yield* migrationDown(flags({ last: 1 }));
expect(queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(false);
}).pipe(Effect.provide(layer));
Expand Down
Loading
Loading