From b573e8e3b710f1736d17104bbeb842690dbede1b Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:37:47 +0530 Subject: [PATCH 1/3] refactor(cli): cover migration with effect lint (CLI-2413) --- .oxlintrc.effect.json | 2 + .../commands/migration/down/down.handler.ts | 60 ++-- .../migration/down/down.integration.test.ts | 67 ++-- .../migration/fetch/fetch.e2e.test.ts | 70 ++-- .../migration/fetch/fetch.integration.test.ts | 107 +++--- .../migration/fetch/fetch.live.test.ts | 136 ++++---- .../commands/migration/list/list.handler.ts | 28 +- .../migration/list/list.integration.test.ts | 40 +-- .../commands/migration/list/list.live.test.ts | 81 +++-- .../migration/migration.integration.test.ts | 44 ++- .../commands/migration/new/new.e2e.test.ts | 51 ++- .../src/commands/migration/new/new.handler.ts | 8 +- .../migration/new/new.integration.test.ts | 96 +++-- .../migration/repair/repair.handler.ts | 72 ++-- .../repair/repair.integration.test.ts | 61 ++-- .../migration/repair/repair.live.test.ts | 155 +++++---- .../migration/squash/squash.diff.unit.test.ts | 26 +- .../commands/migration/squash/squash.dump.ts | 8 +- .../migration/squash/squash.e2e.test.ts | 102 +++--- .../migration/squash/squash.handler.ts | 56 +-- .../squash/squash.integration.test.ts | 327 ++++++++++-------- .../src/commands/migration/up/up.handler.ts | 46 +-- .../migration/up/up.integration.test.ts | 76 ++-- .../src/commands/migration/up/up.live.test.ts | 186 +++++----- apps/cli/tests/helpers/migration-live.ts | 33 ++ 25 files changed, 1057 insertions(+), 881 deletions(-) create mode 100644 apps/cli/tests/helpers/migration-live.ts diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index d3eb9f4d8a..06617e7ea8 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -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/**", @@ -48,6 +49,7 @@ "!apps/cli/tests/helpers/postgres-config-live.ts", "!apps/cli/tests/helpers/secrets-live.ts", "!apps/cli/tests/helpers/storage-live.ts", + "!apps/cli/tests/helpers/migration-live.ts", "!apps/cli/src/command-internal/experimental-feature.ts", "!apps/cli/src/command-internal/postgres-client.run.ts", "!apps/cli/src/command-internal/stack-api.ts", diff --git a/apps/cli/src/commands/migration/down/down.handler.ts b/apps/cli/src/commands/migration/down/down.handler.ts index 5339036fb3..06bd00771a 100644 --- a/apps/cli/src/commands/migration/down/down.handler.ts +++ b/apps/cli/src/commands/migration/down/down.handler.ts @@ -46,11 +46,9 @@ 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"; @@ -58,12 +56,10 @@ const runDown = Effect.fnUntraced(function* ( // `--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 @@ -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()); @@ -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( @@ -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]!; @@ -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) { diff --git a/apps/cli/src/commands/migration/down/down.integration.test.ts b/apps/cli/src/commands/migration/down/down.integration.test.ts index 1e0277708e..b7363ae29e 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -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 { @@ -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], @@ -159,11 +152,25 @@ const flags = (over: Partial = {}): 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(); @@ -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"); @@ -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) }), ); @@ -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)) { @@ -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({ @@ -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({ @@ -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)) { @@ -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( @@ -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)) { @@ -395,9 +402,7 @@ 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, @@ -405,6 +410,8 @@ describe("migration down", () => { 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( @@ -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( @@ -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)); diff --git a/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts index 1014a88d22..91fe8d014f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts @@ -1,47 +1,45 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase migration fetch", () => { - let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-mig-fetch-e2e-")); - mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); - writeFileSync( - join(workdir, "supabase", "migrations", "20240101000000_existing.sql"), - "select 1;\n", - ); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); - // Exercises the real Stdin wiring; in-process tests inject a mock Stdin and can't // catch a missing real-stdin layer. - test( + it.live( "reads a piped 'n' answer to the overwrite prompt and cancels", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase(["migration", "fetch", "--local"], { - cwd: workdir, - stdin: "n\n", - }); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-mig-fetch-e2e-" }); + const migrations = path.join(workdir, "supabase", "migrations"); + yield* fs.makeDirectory(migrations, { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + "[db]\nport = 54322\n", + ); + yield* fs.writeFileString( + path.join(migrations, "20240101000000_existing.sql"), + "select 1;\n", + ); + + const { exitCode, stderr } = yield* runSupabaseEffect(["migration", "fetch", "--local"], { + cwd: workdir, + stdin: "n\n", + }); - expect(exitCode).toBe(1); - expect(stripAnsi(stderr)).toContain("[Y/n]"); - // A declined prompt exits with a lone "context canceled" line and no --debug hint. - const lines = stripAnsi(stderr).trimEnd().split("\n"); - expect(lines.at(-1)).toBe("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); - expect(readdirSync(join(workdir, "supabase", "migrations"))).toEqual([ - "20240101000000_existing.sql", - ]); - }, + expect(exitCode).toBe(1); + expect(stripAnsi(stderr)).toContain("[Y/n]"); + // A declined prompt exits with a lone "context canceled" line and no --debug hint. + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + expect(yield* fs.readDirectory(migrations)).toEqual(["20240101000000_existing.sql"]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index 840da7649f..b54f72c94e 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -1,8 +1,6 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, 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 { VALID_REF, @@ -133,7 +131,45 @@ const flags = (over: Partial = {}): MigrationFetchFlags => projectRef: over.projectRef ?? Option.none(), }); -const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); +const migrationsDir = Effect.fnUntraced(function* (workdir: string) { + const path = yield* Path.Path; + return path.join(workdir, "supabase", "migrations"); +}); + +const migrationPath = Effect.fnUntraced(function* (workdir: string, file: string) { + const path = yield* Path.Path; + return path.join(yield* migrationsDir(workdir), file); +}); + +const listMigrations = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(yield* migrationsDir(workdir)); +}); + +const readMigration = Effect.fnUntraced(function* (workdir: string, file: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(yield* migrationPath(workdir, file)); +}); + +const seedExistingMigration = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(yield* migrationsDir(workdir), { recursive: true }); + yield* fs.writeFileString(yield* migrationPath(workdir, "existing.sql"), "select 1;\n"); +}); + +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 migrationsDirExists = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(yield* migrationsDir(workdir)).pipe(Effect.orElseSucceed(() => false)); +}); + const tmp = useTempWorkdir(); describe("migration fetch", () => { @@ -150,10 +186,11 @@ describe("migration fetch", () => { return Effect.gen(function* () { yield* migrationFetch(flags()); expect(out.stderrText).toContain("Connecting to remote database..."); - const dir = migrationsDir(tmp.current); - const files = readdirSync(dir); + const files = yield* listMigrations(tmp.current); expect(files).toEqual(["20240101000000_init.sql"]); - expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("create table a;\ncreate index b;\n"); + expect(yield* readMigration(tmp.current, files[0]!)).toBe( + "create table a;\ncreate index b;\n", + ); }).pipe(Effect.provide(layer)); }); @@ -165,73 +202,68 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* migrationFetch(flags()); - const dir = migrationsDir(tmp.current); - expect(readFileSync(join(dir, "20240101000000_empty.sql"), "utf8")).toBe(";\n"); + expect(yield* readMigration(tmp.current, "20240101000000_empty.sql")).toBe(";\n"); }).pipe(Effect.provide(layer)); }); it.live("prompts before overwriting a non-empty directory and proceeds on yes", () => { - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer } = setup(tmp.current, { confirm: true, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); yield* migrationFetch(flags()); - expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + expect(yield* listMigrations(tmp.current)).toContain("20240101000000_init.sql"); }).pipe(Effect.provide(layer)); }); it.live("cancels with context canceled when the overwrite prompt is declined", () => { - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer } = setup(tmp.current, { confirm: false, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); const exit = yield* migrationFetch(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); it.live("honors a piped 'n' answer without a TTY (cancels the overwrite)", () => { // The overwrite prompt defaults to YES; piped stdin still overrides it without a TTY. - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer } = setup(tmp.current, { isTTY: false, pipedInput: "n\n", rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); const exit = yield* migrationFetch(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); it.live("bypasses the overwrite prompt with --yes (echoes the auto-answer)", () => { - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer, out } = setup(tmp.current, { yes: true, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); yield* migrationFetch(flags()); expect(out.stderrText).toContain("[Y/n] y"); - expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + expect(yield* listMigrations(tmp.current)).toContain("20240101000000_init.sql"); }).pipe(Effect.provide(layer)); }); @@ -240,16 +272,15 @@ describe("migration fetch", () => { () => { // SUPABASE_YES lives only in supabase/.env; the project env loads before the // overwrite prompt. - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); const { layer, out } = setup(tmp.current, { rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + yield* writeProjectFile(tmp.current, ".env", "SUPABASE_YES=true\n"); yield* migrationFetch(flags()); expect(out.stderrText).toContain("[Y/n] y"); - expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + expect(yield* listMigrations(tmp.current)).toContain("20240101000000_init.sql"); }).pipe(Effect.provide(layer)); }, ); @@ -257,42 +288,40 @@ describe("migration fetch", () => { it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { // The overwrite prompt still writes to stderr and reads stdin in json mode; it must // not silently auto-accept. - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer, out } = setup(tmp.current, { format: "json", pipedInput: "y\n", rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); yield* migrationFetch(flags()); expect(out.stderrText).toContain("[Y/n]"); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Migration history fetched", - data: { files: [join(migrationsDir(tmp.current), "20240101000000_init.sql")] }, + data: { files: [yield* migrationPath(tmp.current, "20240101000000_init.sql")] }, }), ); }).pipe(Effect.provide(layer)); }); it.live("honors a piped no in json mode (cancels the overwrite, no auto-accept)", () => { - mkdirSync(migrationsDir(tmp.current), { recursive: true }); - writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer } = setup(tmp.current, { format: "json", pipedInput: "n\n", rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); const exit = yield* migrationFetch(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); @@ -309,7 +338,7 @@ describe("migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFetchWriteError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + expect(yield* listMigrations(tmp.current)).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -321,7 +350,7 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* migrationFetch(flags()); - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["-1_legacy.sql"]); + expect(yield* listMigrations(tmp.current)).toEqual(["-1_legacy.sql"]); }).pipe(Effect.provide(layer)); }); @@ -337,7 +366,7 @@ describe("migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFetchWriteError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + expect(yield* listMigrations(tmp.current)).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -345,10 +374,9 @@ describe("migration fetch", () => { // A file at .../migrations makes makeDirectory fail; supabase itself must stay a // real directory, since the handler's project-env load reads supabase/.env* before // this mkdir and would hit ENOTDIR first otherwise. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const { layer } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { + yield* writeProjectFile(tmp.current, "migrations", "not a directory"); const exit = yield* migrationFetch(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -367,7 +395,7 @@ describe("migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("DbConfigLoadError"); } - expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(yield* migrationsDirExists(tmp.current)).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); @@ -377,12 +405,11 @@ describe("migration fetch", () => { () => { // A flag conflict must surface even when supabase/.env is malformed, which would // otherwise abort with a different error (DbConfigLoadError) if the env load ran first. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "!=broken\n"); const { layer } = setup(tmp.current, { cliArgs: ["--db-url", "postgresql://x", "--linked"], }); return Effect.gen(function* () { + yield* writeProjectFile(tmp.current, ".env", "!=broken\n"); const exit = yield* migrationFetch(flags({ dbUrl: Option.some("postgresql://x") })).pipe( Effect.exit, ); @@ -426,7 +453,7 @@ describe("migration fetch", () => { "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } - expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(yield* migrationsDirExists(tmp.current)).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts index 7f861e08ee..78c015830a 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts @@ -1,6 +1,7 @@ -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { MigrationLiveError } from "../../../../tests/helpers/migration-live.ts"; + +import { BunServices } from "@effect/platform-bun"; +import { Cause, DateTime, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; @@ -9,9 +10,9 @@ const LIVE_TIMEOUT_MS = 120_000; const NAME = "cli_live_fetch"; -function liveMigrationVersion(): string { - return new Date().toISOString().replace(/\D/gu, "").slice(0, 14); -} +const liveMigrationVersion = Effect.map(DateTime.now, (now) => + DateTime.formatIso(now).replace(/\D/gu, "").slice(0, 14), +); // Destructive: repairs remote migration history in setup and reverts that row in // teardown. @@ -23,60 +24,77 @@ function liveMigrationVersion(): string { test( "fetches a seeded remote migration into the local migrations directory", { timeout: LIVE_TIMEOUT_MS }, - async ({ cli, project }) => { - const targetArgs = ["--db-url", project.dbUrl]; - const version = liveMigrationVersion(); - const migrationFile = `${version}_${NAME}.sql`; - const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-seed-live-")); - const fetchDir = await mkdtemp(path.join(tmpdir(), "sb-migration-fetch-live-")); - let targetError: unknown; - const cleanupErrors: Array = []; - try { - // repair --status applied reads the local file for name/statements, so write it - // before running repair. - await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); - await writeFile( - path.join(seedDir, "supabase", "migrations", migrationFile), - "create table if not exists public.cli_live_roundtrip (id int);\n", - ); - const repairResult = await cli( - ["migration", "repair", version, "--status", "applied", ...targetArgs], - { cwd: seedDir }, - ); - requireLiveSuccess(repairResult, "migration repair setup"); + ({ cliEffect, project, signal }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetArgs = ["--db-url", project.dbUrl]; + const version = yield* liveMigrationVersion; + const migrationFile = `${version}_${NAME}.sql`; + const seedDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-seed-live-" }); + const fetchDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-fetch-live-" }); - // A fresh, empty dir avoids the overwrite prompt. - const fetched = await cli(["migration", "fetch", ...targetArgs], { cwd: fetchDir }); - expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); + const target = Effect.gen(function* () { + // repair --status applied reads the local file for name/statements, so write it + // before running repair. + yield* fs.makeDirectory(path.join(seedDir, "supabase", "migrations"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(seedDir, "supabase", "migrations", migrationFile), + "create table if not exists public.cli_live_roundtrip (id int);\n", + ); + const repairResult = yield* cliEffect( + ["migration", "repair", version, "--status", "applied", ...targetArgs], + { cwd: seedDir }, + ); + requireLiveSuccess(repairResult, "migration repair setup"); - const files = await readdir(path.join(fetchDir, "supabase", "migrations")); - expect(files).toContain(migrationFile); - } catch (error) { - targetError = error; - } finally { - try { - const reverted = await cli( - ["migration", "repair", version, "--status", "reverted", ...targetArgs], - { cwd: seedDir }, - ); - if ( - reverted.exitCode !== 0 && - !/not found|does not exist/i.test(`${reverted.stdout}\n${reverted.stderr}`) - ) { - cleanupErrors.push( - new Error(`migration repair cleanup failed:\n${reverted.stdout}\n${reverted.stderr}`), + // A fresh, empty dir avoids the overwrite prompt. + const fetched = yield* cliEffect(["migration", "fetch", ...targetArgs], { + cwd: fetchDir, + }); + expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe( + 0, ); - } - } catch (error) { - cleanupErrors.push(error); - } - await rm(seedDir, { recursive: true, force: true }).catch((error) => - cleanupErrors.push(error), - ); - await rm(fetchDir, { recursive: true, force: true }).catch((error) => - cleanupErrors.push(error), - ); - } - throwWithCleanup(targetError, cleanupErrors); - }, + + const files = yield* fs.readDirectory(path.join(fetchDir, "supabase", "migrations")); + expect(files).toContain(migrationFile); + }); + + const revert = Effect.gen(function* () { + const reverted = yield* cliEffect( + ["migration", "repair", version, "--status", "reverted", ...targetArgs], + { cwd: seedDir }, + ); + if ( + reverted.exitCode !== 0 && + !/not found|does not exist/i.test(`${reverted.stdout}\n${reverted.stderr}`) + ) { + return yield* new MigrationLiveError({ + message: `migration repair cleanup failed:\n${reverted.stdout}\n${reverted.stderr}`, + }); + } + }); + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const targetExit = yield* Effect.exit(restore(target)); + const cleanupExits: ReadonlyArray> = [ + yield* Effect.exit(revert), + yield* Effect.exit(fs.remove(seedDir, { recursive: true, force: true })), + yield* Effect.exit(fs.remove(fetchDir, { recursive: true, force: true })), + ]; + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: cleanupExits + .filter(Exit.isFailure) + .map((exit) => Cause.squash(exit.cause)), + }; + }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + { signal }, + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors)), ); diff --git a/apps/cli/src/commands/migration/list/list.handler.ts b/apps/cli/src/commands/migration/list/list.handler.ts index 937add8997..0d7068d2f5 100644 --- a/apps/cli/src/commands/migration/list/list.handler.ts +++ b/apps/cli/src/commands/migration/list/list.handler.ts @@ -36,30 +36,24 @@ const runList = Effect.fnUntraced(function* ( // Mutually-exclusive flag groups, checked target group first, then {db-url, // password}; `setFlags` is already sorted, matching the established error format. 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`, + }); } if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { - return yield* Effect.fail( - new MigrationPasswordFlagsError({ - message: - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - }), - ); + return yield* new MigrationPasswordFlagsError({ + message: + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + }); } // `--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) && (target.connType ?? "linked") !== "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)", + }); } const listBody = Effect.gen(function* () { diff --git a/apps/cli/src/commands/migration/list/list.integration.test.ts b/apps/cli/src/commands/migration/list/list.integration.test.ts index c1bc93f0cb..bdf87b9e28 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -1,8 +1,6 @@ -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 { @@ -120,21 +118,23 @@ const flags = (over: Partial = {}): MigrationListFlags => ({ password: over.password ?? Option.none(), }); -const seedMigrations = (workdir: string, names: ReadonlyArray) => { - const dir = join(workdir, "supabase", "migrations"); - mkdirSync(dir, { recursive: true }); - for (const name of names) writeFileSync(join(dir, name), "select 1;\n"); -}; +const seedMigrations = Effect.fnUntraced(function* (workdir: string, names: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(workdir, "supabase", "migrations"); + yield* fs.makeDirectory(dir, { recursive: true }); + for (const name of names) yield* fs.writeFileString(path.join(dir, name), "select 1;\n"); +}); const tmp = useTempWorkdir(); describe("migration list", () => { it.live("lists merged local + remote migrations for the linked project by default", () => { - seedMigrations(tmp.current, ["20240101000000_a.sql", "20240103000000_c.sql"]); const ctx = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql", "20240103000000_c.sql"]); yield* migrationList(flags()); expect(stripAnsi(ctx.out.stderrText)).toContain("Connecting to remote database..."); const stdout = stripAnsi(ctx.out.stdoutText); @@ -149,7 +149,6 @@ describe("migration list", () => { }); it.live("shows an empty Remote column when the history table is absent (42P01)", () => { - seedMigrations(tmp.current, ["20240101000000_a.sql"]); const { layer, out } = setup(tmp.current, { remoteError: new DbExecError({ message: 'relation "supabase_migrations.schema_migrations" does not exist', @@ -157,6 +156,7 @@ describe("migration list", () => { }), }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); yield* migrationList(flags()); const stdout = stripAnsi(out.stdoutText); expect(stdout).toContain("`20240101000000`"); @@ -165,13 +165,13 @@ describe("migration list", () => { }); it.live("skips init-schema and non-migration files when loading local versions", () => { - seedMigrations(tmp.current, [ - "20211208000000_init.sql", // pre-cutoff init → skipped - "not-a-migration.txt", // non-matching → skipped - "20240105000000_keep.sql", - ]); const { layer, out } = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, [ + "20211208000000_init.sql", // pre-cutoff init → skipped + "not-a-migration.txt", // non-matching → skipped + "20240105000000_keep.sql", + ]); yield* migrationList(flags()); const stdout = stripAnsi(out.stdoutText); expect(stdout).toContain("`20240105000000`"); @@ -183,9 +183,9 @@ describe("migration list", () => { // VALID_REF is the fake resolver's fallback; the flag must win over it and drive // the cached ref. const FLAG_REF = "flagflagflagflagflag"; - seedMigrations(tmp.current, ["20240101000000_a.sql"]); const ctx = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); yield* migrationList(flags({ projectRef: Option.some(FLAG_REF) })); expect(ctx.cache.cachedRef).toBe(FLAG_REF); expect(ctx.cache.cachedRef).not.toBe(VALID_REF); @@ -194,9 +194,9 @@ describe("migration list", () => { it.live("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; - seedMigrations(tmp.current, ["20240101000000_a.sql"]); const ctx = setup(tmp.current, { args: ["--local"], isLocal: true, remote: [] }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); const exit = yield* migrationList( flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), ).pipe(Effect.exit); @@ -214,13 +214,13 @@ describe("migration list", () => { }); it.live("targets the local database with --local and skips the linked cache", () => { - seedMigrations(tmp.current, ["20240101000000_a.sql"]); const ctx = setup(tmp.current, { args: ["--local"], isLocal: true, remote: [], }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); yield* migrationList(flags({ linked: false, local: true })); expect(ctx.resolverCalls[0]?.connType).toBe("local"); expect(ctx.cache.cachedRef).toBeUndefined(); @@ -256,9 +256,9 @@ describe("migration list", () => { }); it.live("emits structured migrations in json", () => { - seedMigrations(tmp.current, ["20240103000000_c.sql"]); const { layer, out } = setup(tmp.current, { format: "json", remote: ["20240102000000"] }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240103000000_c.sql"]); yield* migrationList(flags()); expect(out.stdoutText).toBe(""); // no glamour table on stdout in json mode expect(out.messages).toContainEqual( @@ -277,7 +277,6 @@ describe("migration list", () => { }); it.live("propagates a non-undefined-table remote read failure", () => { - seedMigrations(tmp.current, ["20240101000000_a.sql"]); const { layer } = setup(tmp.current, { remoteError: new DbExecError({ message: "permission denied for schema", @@ -285,6 +284,7 @@ describe("migration list", () => { }), }); return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); const exit = yield* migrationList(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { diff --git a/apps/cli/src/commands/migration/list/list.live.test.ts b/apps/cli/src/commands/migration/list/list.live.test.ts index 8eb48e6cb2..a7a1e62c86 100644 --- a/apps/cli/src/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/commands/migration/list/list.live.test.ts @@ -1,43 +1,58 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { removeMigration } from "../../../../tests/helpers/migration-live.ts"; + +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; import { liveMigrationVersion, - removeLiveMigration, requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("lists a seeded remote migration", async ({ cli, project }) => { - const targetArgs = ["--db-url", project.dbUrl]; - const version = liveMigrationVersion(); - // Seeded outside the workspace so the version can only reach stdout through the remote column. - const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-list-live-")); - let targetError: unknown; - const cleanupErrors: Array = []; - try { - await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); - await writeFile( - path.join(seedDir, "supabase", "migrations", `${version}_cli_live_list.sql`), - "select 1;\n", - ); - const seeded = await cli( - ["migration", "repair", version, "--status", "applied", ...targetArgs], - { cwd: seedDir }, - ); - requireLiveSuccess(seeded, "migration repair setup"); +test("lists a seeded remote migration", ({ cli, cliEffect, project, signal }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetArgs = ["--db-url", project.dbUrl]; + const version = liveMigrationVersion(); + // Seeded outside the workspace so the version can only reach stdout through the remote column. + const seedDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-list-live-" }); + + const target = Effect.gen(function* () { + yield* fs.makeDirectory(path.join(seedDir, "supabase", "migrations"), { recursive: true }); + yield* fs.writeFileString( + path.join(seedDir, "supabase", "migrations", `${version}_cli_live_list.sql`), + "select 1;\n", + ); + const seeded = yield* cliEffect( + ["migration", "repair", version, "--status", "applied", ...targetArgs], + { cwd: seedDir }, + ); + requireLiveSuccess(seeded, "migration repair setup"); + + const result = yield* cliEffect(["migration", "list", ...targetArgs]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout, result.stderr).toContain(version); + }); - const result = await cli(["migration", "list", ...targetArgs]); - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout, result.stderr).toContain(version); - } catch (error) { - targetError = error; - } finally { - await removeLiveMigration(cli, project, version).catch((error) => cleanupErrors.push(error)); - await rm(seedDir, { recursive: true, force: true }).catch((error) => cleanupErrors.push(error)); - } - throwWithCleanup(targetError, cleanupErrors); -}); + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const targetExit = yield* Effect.exit(restore(target)); + const cleanupExits: ReadonlyArray> = [ + yield* Effect.exit(removeMigration(cli, project, version)), + yield* Effect.exit(fs.remove(seedDir, { recursive: true, force: true })), + ]; + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: cleanupExits + .filter(Exit.isFailure) + .map((exit) => Cause.squash(exit.cause)), + }; + }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + { signal }, + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/src/commands/migration/migration.integration.test.ts b/apps/cli/src/commands/migration/migration.integration.test.ts index ca961827dd..fbe8d31216 100644 --- a/apps/cli/src/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/commands/migration/migration.integration.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Cause, Effect, Exit, Layer, Predicate } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; import { GLOBAL_FLAGS } from "../../command-internal/global-flags.ts"; +import { + buildTestRuntime, + mockCommandPlatformApi, + mockCommandSettings, + useTempWorkdir, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockTelemetryRuntime } from "../../../tests/helpers/mocks.ts"; import { migrationCommand } from "./migration.command.ts"; // `withGlobalFlags` must come after `withSubcommands` — see @@ -13,12 +20,26 @@ const testRoot = Command.make("supabase").pipe( Command.withGlobalFlags(GLOBAL_FLAGS), ); +const tmp = useTempWorkdir("supabase-migration-alias-int-"); + describe("migration command integration", () => { it.live("accepts the Go-compatible plural migrations alias", () => { + const layer = Layer.mergeAll( + buildTestRuntime({ + out: mockOutput({ format: "text" }), + api: mockCommandPlatformApi(), + cliSettings: mockCommandSettings({ workdir: tmp.current }), + }), + CliOutput.layer(textCliOutputFormatter()), + mockTelemetryRuntime({ + configDir: `${tmp.current}/.supabase`, + tracesDir: `${tmp.current}/.supabase/traces`, + }), + ); // No subcommand is proxied, so the plural alias is proven at the parser: // `migrations squash --nope` must fail with squash's own unknown-flag error, // before the command's runtime layer ever builds. - const run = Effect.gen(function* () { + return Effect.gen(function* () { const exit = yield* Command.runWith(testRoot, { version: "0.0.0-test" })([ "migrations", "squash", @@ -26,14 +47,19 @@ describe("migration command integration", () => { ]).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeJson = JSON.stringify(exit.cause); + const failures = exit.cause.reasons + .filter(Cause.isFailReason) + .map((reason) => reason.error); + const showHelp = failures.filter((error) => Predicate.isTagged(error, "ShowHelp")); // The alias resolved: the parse error is scoped to the squash subcommand, not the root. - expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]'); - expect(causeJson).not.toContain('"subcommand":"migrations"'); + expect(showHelp.map((error) => [...error.commandPath])).toEqual([ + ["supabase", "migration", "squash"], + ]); + const reported = [...failures, ...showHelp.flatMap((error) => [...error.errors])]; + expect(reported.filter((error) => Predicate.isTagged(error, "UnknownSubcommand"))).toEqual( + [], + ); } - }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); - - // Command.runWith's Environment type is retained even though only CliOutput is needed. - return run as Effect.Effect; + }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/migration/new/new.e2e.test.ts b/apps/cli/src/commands/migration/new/new.e2e.test.ts index 549ed04842..ab06e08ced 100644 --- a/apps/cli/src/commands/migration/new/new.e2e.test.ts +++ b/apps/cli/src/commands/migration/new/new.e2e.test.ts @@ -1,38 +1,35 @@ -import { mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase migration new", () => { - let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-mig-new-e2e-")); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); - // Primary golden path: a real subprocess creates the migration file under the // working directory and prints the workdir-relative path. No infra required. - test( + it.live( "creates a timestamped migration file and prints its path", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["migration", "new", "create_widgets"], { - cwd: workdir, - }); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-mig-new-e2e-" }); + + const { exitCode, stdout } = yield* runSupabaseEffect( + ["migration", "new", "create_widgets"], + { cwd: workdir }, + ); - expect(exitCode).toBe(0); - const files = readdirSync(join(workdir, "supabase", "migrations")); - expect(files).toHaveLength(1); - expect(files[0]).toMatch(/^\d{14}_create_widgets\.sql$/u); - expect(stripAnsi(stdout)).toContain( - `Created new migration at supabase/migrations/${files[0]}`, - ); - }, + expect(exitCode).toBe(0); + const files = yield* fs.readDirectory(path.join(workdir, "supabase", "migrations")); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^\d{14}_create_widgets\.sql$/u); + expect(stripAnsi(stdout)).toContain( + `Created new migration at supabase/migrations/${files[0]}`, + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/migration/new/new.handler.ts b/apps/cli/src/commands/migration/new/new.handler.ts index 0273e63685..95a68cf461 100644 --- a/apps/cli/src/commands/migration/new/new.handler.ts +++ b/apps/cli/src/commands/migration/new/new.handler.ts @@ -39,11 +39,9 @@ export const migrationNew = Effect.fn("migration.new")(function* (flags: Migrati // simple identifiers. const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); if (!migrationPath.startsWith(migrationsDir + path.sep)) { - return yield* Effect.fail( - new MigrationNewWriteError({ - message: `invalid migration name: "${flags.migrationName}" must not escape the ${path.join("supabase", "migrations")} directory`, - }), - ); + return yield* new MigrationNewWriteError({ + message: `invalid migration name: "${flags.migrationName}" must not escape the ${path.join("supabase", "migrations")} directory`, + }); } yield* fs diff --git a/apps/cli/src/commands/migration/new/new.integration.test.ts b/apps/cli/src/commands/migration/new/new.integration.test.ts index 651800f289..b60c45aee5 100644 --- a/apps/cli/src/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/commands/migration/new/new.integration.test.ts @@ -1,8 +1,6 @@ -import { existsSync, readdirSync, readFileSync, 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, FileSystem, Layer, Option, Stream } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Stream } from "effect"; import { badArgument } from "effect/PlatformError"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; @@ -31,17 +29,21 @@ function nonMaterializingFsLayer( ): Layer.Layer { return Layer.effect( FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (real) => - FileSystem.FileSystem.of({ + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return FileSystem.FileSystem.of({ ...real, - open: (path, options) => + open: (filePath, options) => opts.openDoesNotMaterialize === true - ? real.open(join(workdir, ".deferred-open"), options) - : real.open(path, options), - writeFile: (path, data, options) => - opts.writeDoesNotMaterialize === true ? Effect.void : real.writeFile(path, data, options), - }), - ), + ? real.open(path.join(workdir, ".deferred-open"), options) + : real.open(filePath, options), + writeFile: (filePath, data, options) => + opts.writeDoesNotMaterialize === true + ? Effect.void + : real.writeFile(filePath, data, options), + }); + }), ).pipe(Layer.provide(BunServices.layer)); } @@ -63,12 +65,41 @@ function setup(workdir: string, opts: SetupOpts = {}) { const tmp = useTempWorkdir(); -const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); -const onlyMigration = (workdir: string) => { - const files = readdirSync(migrationsDir(workdir)); +const migrationsDir = Effect.fnUntraced(function* (workdir: string) { + const path = yield* Path.Path; + return path.join(workdir, "supabase", "migrations"); +}); + +const migrationPath = Effect.fnUntraced(function* (workdir: string, file: string) { + const path = yield* Path.Path; + return path.join(yield* migrationsDir(workdir), file); +}); + +const listMigrations = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(yield* migrationsDir(workdir)); +}); + +const onlyMigration = Effect.fnUntraced(function* (workdir: string) { + const files = yield* listMigrations(workdir); expect(files).toHaveLength(1); return files[0]!; -}; +}); + +const readMigration = Effect.fnUntraced(function* (workdir: string, file: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(yield* migrationPath(workdir, file)); +}); + +const pathExists = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); +}); + +const projectPath = Effect.fnUntraced(function* (workdir: string) { + const path = yield* Path.Path; + return path.join(workdir, "supabase"); +}); describe("migration new", () => { it.live("creates a timestamped migration file and prints its relative path", () => { @@ -76,9 +107,9 @@ describe("migration new", () => { return Effect.gen(function* () { yield* migrationNew({ migrationName: "create_widgets" }); - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); expect(file).toMatch(/^\d{14}_create_widgets\.sql$/u); - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); + expect(yield* readMigration(tmp.current, file)).toBe(""); expect(stripAnsi(out.stdoutText)).toBe( `Created new migration at supabase/migrations/${file}\n`, ); @@ -92,8 +123,8 @@ describe("migration new", () => { return Effect.gen(function* () { yield* migrationNew({ migrationName: "from_stdin" }); - const file = onlyMigration(tmp.current); - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(script); + const file = yield* onlyMigration(tmp.current); + expect(yield* readMigration(tmp.current, file)).toBe(script); expect(stripAnsi(out.stdoutText)).toContain(`Created new migration at supabase/migrations/`); }).pipe(Effect.provide(layer)); }); @@ -102,8 +133,8 @@ describe("migration new", () => { const { layer } = setup(tmp.current, { isTTY: false }); return Effect.gen(function* () { yield* migrationNew({ migrationName: "empty_pipe" }); - const file = onlyMigration(tmp.current); - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); + const file = yield* onlyMigration(tmp.current); + expect(yield* readMigration(tmp.current, file)).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -111,8 +142,8 @@ describe("migration new", () => { const { layer } = setup(tmp.current, { openDoesNotMaterialize: true }); return Effect.gen(function* () { yield* migrationNew({ migrationName: "windows_open" }); - const file = onlyMigration(tmp.current); - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); + const file = yield* onlyMigration(tmp.current); + expect(yield* readMigration(tmp.current, file)).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -134,7 +165,7 @@ describe("migration new", () => { } } } - expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + expect(yield* listMigrations(tmp.current)).toEqual([]); expect(out.stdoutText).toBe(""); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -145,13 +176,13 @@ describe("migration new", () => { return Effect.gen(function* () { yield* migrationNew({ migrationName: "as_json" }); - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); expect(out.stdoutText).toBe(""); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Migration created", - data: { path: join(migrationsDir(tmp.current), file) }, + data: { path: yield* migrationPath(tmp.current, file) }, }), ); }).pipe(Effect.provide(layer)); @@ -168,10 +199,11 @@ describe("migration new", () => { }); it.live("reports a write failure and still flushes telemetry", () => { - // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. - writeFileSync(join(tmp.current, "supabase"), "not a directory"); const { layer, telemetry } = setup(tmp.current); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. + yield* fs.writeFileString(yield* projectPath(tmp.current), "not a directory"); const exit = yield* migrationNew({ migrationName: "doomed" }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -181,7 +213,7 @@ describe("migration new", () => { expect(failure.value).toBeInstanceOf(MigrationNewWriteError); } } - expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(yield* pathExists(yield* migrationsDir(tmp.current))).toBe(false); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -200,7 +232,7 @@ describe("migration new", () => { expect(failure.value).toBeInstanceOf(MigrationNewWriteError); } } - expect(existsSync(join(tmp.current, "supabase"))).toBe(false); + expect(yield* pathExists(yield* projectPath(tmp.current))).toBe(false); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -239,7 +271,7 @@ describe("migration new", () => { } } } - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); expect(stripAnsi(out.stdoutText)).toBe( `Created new migration at supabase/migrations/${file}\n`, ); diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index aafb26a7e8..0ef0bd0163 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -68,11 +68,9 @@ const updateMigrationTable = Effect.fnUntraced(function* ( for (const version of versions) { const resolved = yield* resolveMigrationFile(fs, path, migrationsDir, version); if (Option.isNone(resolved)) { - return yield* Effect.fail( - new MigrationFileNotFoundError({ - message: `glob supabase/migrations/${version}_*.sql: file does not exist`, - }), - ); + return yield* new MigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }); } appliedFiles.push(yield* readMigrationFile(fs, path, resolved.value)); } @@ -122,19 +120,15 @@ const runRepair = Effect.fnUntraced(function* ( const dnsResolver = yield* DnsResolverFlag; 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`, + }); } if (Option.isSome(input.dbUrl) && Option.isSome(input.password)) { - return yield* Effect.fail( - new MigrationPasswordFlagsError({ - message: - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - }), - ); + return yield* new MigrationPasswordFlagsError({ + message: + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + }); } const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); @@ -144,12 +138,10 @@ const runRepair = Effect.fnUntraced(function* ( // `--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(input.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 the DB config (and, for the linked default, the project ref) before the @@ -168,27 +160,13 @@ const runRepair = Effect.fnUntraced(function* ( const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Attached to the whole flow via `Effect.ensuring` below so the cache write still - // runs even when the version parse fails or the repair-all prompt is declined. - const cacheLinkedRef = - connType === "linked" - ? yield* Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(input.projectRef); - return linkedProjectCache.cache(ref); - }) - : undefined; - const repairFlow = Effect.gen(function* () { // Rejects non-numeric and out-of-int64-range values. for (const version of input.versions) { if (parseMigrationVersion(version) === undefined) { - return yield* Effect.fail( - new MigrationInvalidVersionError({ - message: `failed to parse ${version}: invalid version number`, - }), - ); + return yield* new MigrationInvalidVersionError({ + message: `failed to parse ${version}: invalid version number`, + }); } } @@ -200,9 +178,7 @@ const runRepair = Effect.fnUntraced(function* ( { defaultValue: false, yes }, ); if (!confirmed) { - return yield* Effect.fail( - new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } versions = yield* loadLocalVersions(fs, path, migrationsDir); } @@ -244,9 +220,15 @@ const runRepair = Effect.fnUntraced(function* ( } }); - return yield* cacheLinkedRef === undefined - ? repairFlow - : repairFlow.pipe(Effect.ensuring(cacheLinkedRef)); + // Attached to the whole flow via `Effect.ensuring` below so the cache write still + // runs even when the version parse fails or the repair-all prompt is declined. + if (connType === "linked") { + const projectRef = yield* ProjectRefResolver; + const linkedProjectCache = yield* LinkedProjectCache; + const ref = yield* projectRef.loadProjectRef(input.projectRef); + return yield* repairFlow.pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + } + return yield* repairFlow; }); export const migrationRepair = Effect.fn("migration.repair")(function* ( diff --git a/apps/cli/src/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/commands/migration/repair/repair.integration.test.ts index af99e5696e..ccac92aae7 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -1,8 +1,6 @@ -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 { @@ -12,6 +10,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, sequentialExecBatch, + withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -141,19 +140,29 @@ const input = (over: Partial = {}): MigrationRepairInput = password: over.password ?? Option.none(), }); -const seedMigration = (workdir: string, name: string, body: string) => { - const dir = join(workdir, "supabase", "migrations"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, name), body); -}; +const seedMigration = 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", "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(); describe("migration repair", () => { it.live("marks a version as applied by upserting from its local file", () => { - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries, out } = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: ["20240101000000"], status: "applied" })); expect(stripAnsi(out.stderrText)).toContain("Connecting to remote database..."); expect(execs).toContain("BEGIN"); @@ -240,9 +249,9 @@ describe("migration repair", () => { }); it.live("repair-all truncates and reapplies local files on confirm", () => { - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries } = setup(tmp.current, { confirm: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: [], status: "applied" })); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); @@ -254,9 +263,9 @@ describe("migration repair", () => { () => { // repair-all + reverted only queues TRUNCATE; DELETE is the non-repair-all path // and UPSERT is the applied path. - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries } = setup(tmp.current, { confirm: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: [], status: "reverted" })); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(false); @@ -266,9 +275,9 @@ describe("migration repair", () => { ); it.live("repair-all cancels on a declined prompt", () => { - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs } = setup(tmp.current, { confirm: false }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( Effect.exit, ); @@ -299,9 +308,9 @@ describe("migration repair", () => { it.live("repair-all honors a piped 'y' answer without a TTY (proceeds)", () => { // Piped stdin is read even without a TTY, overriding the default no. - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries } = setup(tmp.current, { isTTY: false, pipedInput: "y\n" }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: [], status: "applied" })); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); @@ -309,33 +318,23 @@ describe("migration repair", () => { }); it.live("auto-confirms repair-all via SUPABASE_YES (no --yes flag)", () => { - const previous = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: [], status: "applied" })); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = previous; - }), - ), - ); + }).pipe(Effect.provide(layer), (body) => withEnvVar("SUPABASE_YES", "1", body)); }); it.live( "auto-confirms repair-all via SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* writeProjectFile(tmp.current, ".env", "SUPABASE_YES=true\n"); yield* migrationRepair(input({ versions: [], status: "applied" })); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); @@ -359,9 +358,9 @@ describe("migration repair", () => { }); it.live("prints the repaired, finished, and suggestion lines on success", () => { - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, out } = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: ["20240101000000"], status: "applied" })); const stderr = stripAnsi(out.stderrText); const stdout = stripAnsi(out.stdoutText); @@ -376,10 +375,10 @@ describe("migration repair", () => { it.live("prints multiple repaired versions using Go's %v slice format", () => { // The established format is space-separated and bracketed, with no commas; a // `.join(", ")` cleanup would silently change established output. - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - seedMigration(tmp.current, "20240102000000_more.sql", "create table b;\n"); const { layer, out } = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* seedMigration(tmp.current, "20240102000000_more.sql", "create table b;\n"); yield* migrationRepair( input({ versions: ["20240101000000", "20240102000000"], status: "applied" }), ); @@ -440,9 +439,9 @@ describe("migration repair", () => { // VALID_REF is the fake resolver's fallback; the flag must win over it and drive // the cached ref. const FLAG_REF = "flagflagflagflagflag"; - seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, cache } = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair( input({ versions: ["20240101000000"], diff --git a/apps/cli/src/commands/migration/repair/repair.live.test.ts b/apps/cli/src/commands/migration/repair/repair.live.test.ts index 2b6c70aafd..7d9fb42ad8 100644 --- a/apps/cli/src/commands/migration/repair/repair.live.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.live.test.ts @@ -1,78 +1,60 @@ -import { mkdir, unlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { queryMigrationDb } from "../../../../tests/helpers/migration-live.ts"; + +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; import { liveMigrationVersion, - queryLiveDb, requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("amends the migration history status on the remote database", async ({ - cli, +test("amends the migration history status on the remote database", ({ + cliEffect, project, workspace, -}) => { - const version = liveMigrationVersion(); - const migrations = join(workspace.path, "supabase", "migrations"); - await mkdir(migrations, { recursive: true }); - const migrationFile = join(migrations, `${version}_e2e_repair.sql`); - // `repair --status applied` records the file's statements in migration - // history without executing them, so this table is never actually created. - await writeFile(migrationFile, `create table if not exists e2e_repair_${version} (id int);\n`); - - let targetError: unknown; - let versionReverted = false; - const cleanupErrors: Array = []; - try { - const applied = await cli([ - "migration", - "repair", - version, - "--status", - "applied", - "--db-url", - project.dbUrl, - ]); - expect(applied.exitCode, applied.stderr).toBe(0); - expect(applied.stderr, applied.stdout).toContain("=> applied"); - await unlink(migrationFile); + signal, +}) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const version = liveMigrationVersion(); + const migrations = path.join(workspace.path, "supabase", "migrations"); + yield* fs.makeDirectory(migrations, { recursive: true }); + const migrationFile = path.join(migrations, `${version}_e2e_repair.sql`); + // `repair --status applied` records the file's statements in migration + // history without executing them, so this table is never actually created. + yield* fs.writeFileString( + migrationFile, + `create table if not exists e2e_repair_${version} (id int);\n`, + ); - const recorded = await queryLiveDb( - project.dbUrl, - "select version from supabase_migrations.schema_migrations where version = $1", - [version], - ); - expect(recorded).toHaveLength(1); + let versionReverted = false; + const target = Effect.gen(function* () { + const applied = yield* cliEffect([ + "migration", + "repair", + version, + "--status", + "applied", + "--db-url", + project.dbUrl, + ]); + expect(applied.exitCode, applied.stderr).toBe(0); + expect(applied.stderr, applied.stdout).toContain("=> applied"); + yield* fs.remove(migrationFile); - const reverted = await cli([ - "migration", - "repair", - version, - "--status", - "reverted", - "--db-url", - project.dbUrl, - ]); - expect(reverted.exitCode, reverted.stderr).toBe(0); - expect(reverted.stderr, reverted.stdout).toContain("=> reverted"); + const recorded = yield* queryMigrationDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations where version = $1", + [version], + ); + expect(recorded).toHaveLength(1); - const remaining = await queryLiveDb( - project.dbUrl, - "select version from supabase_migrations.schema_migrations where version = $1", - [version], - ); - expect(remaining).toHaveLength(0); - // Only skip the teardown revert once the row is verifiably gone. - versionReverted = true; - } catch (error) { - targetError = error; - } finally { - if (!versionReverted) { - try { - const cleanup = await cli([ + const reverted = yield* cliEffect([ "migration", "repair", version, @@ -81,11 +63,46 @@ test("amends the migration history status on the remote database", async ({ "--db-url", project.dbUrl, ]); - requireLiveSuccess(cleanup, "migration repair cleanup"); - } catch (error) { - cleanupErrors.push(error); - } - } - } - throwWithCleanup(targetError, cleanupErrors); -}); + expect(reverted.exitCode, reverted.stderr).toBe(0); + expect(reverted.stderr, reverted.stdout).toContain("=> reverted"); + + const remaining = yield* queryMigrationDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations where version = $1", + [version], + ); + expect(remaining).toHaveLength(0); + // Only skip the teardown revert once the row is verifiably gone. + versionReverted = true; + }); + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const targetExit = yield* Effect.exit(restore(target)); + const cleanupExit = yield* Effect.exit( + Effect.suspend(() => + versionReverted + ? Effect.void + : Effect.gen(function* () { + const cleanup = yield* cliEffect([ + "migration", + "repair", + version, + "--status", + "reverted", + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(cleanup, "migration repair cleanup"); + }), + ), + ); + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], + }; + }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + { signal }, + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts index 83e22dd3d8..9ebf53e64c 100644 --- a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts @@ -1,21 +1,29 @@ -import { readFileSync } from "node:fs"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; import { SQUASH_SEPARATOR_COMMENT, squashLineByLineDiff, squashScanLines } from "./squash.diff.ts"; // before.sql/after.sql/diff.sql are vendored real pg_dump output, not hand-transcribed // literals, since manual transcription would silently corrupt whitespace/quoting. const testdataDir = fileURLToPath(new URL("./testdata/", import.meta.url)); -const readGoFixture = (name: string) => readFileSync(`${testdataDir}${name}`, "utf8"); + +const readGoFixture = Effect.fnUntraced(function* (name: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(testdataDir, name)); +}); describe("squashLineByLineDiff", () => { - it("diffs real pg_dump output into Go's exact diff.sql bytes", () => { - const before = readGoFixture("before.sql"); - const after = readGoFixture("after.sql"); - const expected = readGoFixture("diff.sql"); - expect(squashLineByLineDiff(before, after)).toBe(expected); - }); + it.live("diffs real pg_dump output into Go's exact diff.sql bytes", () => + Effect.gen(function* () { + const before = yield* readGoFixture("before.sql"); + const after = yield* readGoFixture("after.sql"); + const expected = yield* readGoFixture("diff.sql"); + expect(squashLineByLineDiff(before, after)).toBe(expected); + }).pipe(Effect.provide(BunServices.layer)), + ); it("keeps only after-only lines when before is shorter", () => { const before = "select 1;"; diff --git a/apps/cli/src/commands/migration/squash/squash.dump.ts b/apps/cli/src/commands/migration/squash/squash.dump.ts index b56fff6de3..2b53782217 100644 --- a/apps/cli/src/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/commands/migration/squash/squash.dump.ts @@ -60,11 +60,9 @@ export const squashDumpSchema = Effect.fnUntraced(function* (params: SquashDu forceHostNetwork: backend.kind === "stack", }); if (result.exitCode !== 0) { - return yield* Effect.fail( - new MigrationSquashDumpError({ - message: pgDumpClientExitMessage(client, result.exitCode), - }), - ); + return yield* new MigrationSquashDumpError({ + message: pgDumpClientExitMessage(client, result.exitCode), + }); } }); diff --git a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts index db401330dd..e176660152 100644 --- a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts @@ -1,68 +1,72 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -describe("supabase migration squash", () => { - let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-mig-squash-e2e-")); - mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); +const makeWorkdir = Effect.fnUntraced(function* (prefix: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix }); + yield* fs.makeDirectory(path.join(workdir, "supabase", "migrations"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); + return workdir; +}); +describe("supabase migration squash", () => { // Exercises the real migrationSquashRuntimeLayer end to end without touching // Docker/Postgres. This is a validation error, not a cancellation, so the usual // --debug hint still follows it. - test( + it.live( "rejects a non-numeric --version with the bare Go message", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase( - ["migration", "squash", "--version", "0_init"], - { - cwd: workdir, - }, - ); + () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("sb-mig-squash-version-e2e-"); + + const { exitCode, stderr } = yield* runSupabaseEffect( + ["migration", "squash", "--version", "0_init"], + { cwd: workdir }, + ); - expect(exitCode).toBe(1); - const text = stripAnsi(stderr); - expect(text).toContain("invalid version number"); - expect(text).not.toContain("failed to parse"); - expect(text).toContain("Try rerunning the command with --debug to troubleshoot the error."); - }, + expect(exitCode).toBe(1); + const text = stripAnsi(stderr); + expect(text).toContain("invalid version number"); + expect(text).not.toContain("failed to parse"); + expect(text).toContain("Try rerunning the command with --debug to troubleshoot the error."); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); // A single local migration short-circuits squashToVersion before any // shadow-database work, exercising the local no-op + suggestion path end to end. - test( + it.live( "no-ops on a single local migration and suggests migration repair", - { timeout: E2E_TIMEOUT_MS }, - async () => { - writeFileSync( - join(workdir, "supabase", "migrations", "20240101000000_init.sql"), - "select 1;\n", - ); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* makeWorkdir("sb-mig-squash-noop-e2e-"); + yield* fs.writeFileString( + path.join(workdir, "supabase", "migrations", "20240101000000_init.sql"), + "select 1;\n", + ); - const { exitCode, stdout, stderr } = await runSupabase(["migration", "squash", "--local"], { - cwd: workdir, - }); + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["migration", "squash", "--local"], + { cwd: workdir }, + ); - expect(exitCode).toBe(0); - expect(stripAnsi(stderr)).toContain( - "supabase/migrations/20240101000000_init.sql is already the earliest migration.", - ); - expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); - expect(stripAnsi(stderr)).toContain( - "Run supabase migration repair --status applied to update your remote migration history table.", - ); - }, + expect(exitCode).toBe(0); + expect(stripAnsi(stderr)).toContain( + "supabase/migrations/20240101000000_init.sql is already the earliest migration.", + ); + expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); + expect(stripAnsi(stderr)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index cf02f3e25d..9966cdba33 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -367,9 +367,7 @@ const squashToVersion = Effect.fnUntraced(function* ( const output = yield* Output; const migrations = yield* loadPartialMigrations(fs, path, migrationsDir, version); if (migrations.length === 0) { - return yield* Effect.fail( - new MigrationSquashMissingVersionError({ message: "version not found" }), - ); + return yield* new MigrationSquashMissingVersionError({ message: "version not found" }); } const local = migrations[migrations.length - 1]!; @@ -458,11 +456,9 @@ const baselineMigrations = Effect.fnUntraced(function* ( const resolvedFile = yield* resolveMigrationFile(fs, path, migrationsDir, resolvedVersion); if (Option.isNone(resolvedFile)) { - return yield* Effect.fail( - new MigrationFileNotFoundError({ - message: `glob supabase/migrations/${resolvedVersion}_*.sql: file does not exist`, - }), - ); + return yield* new MigrationFileNotFoundError({ + message: `glob supabase/migrations/${resolvedVersion}_*.sql: file does not exist`, + }); } const m = yield* readMigrationFile(fs, path, resolvedFile.value); @@ -512,24 +508,14 @@ const runSquash = Effect.fnUntraced(function* ( yield* Effect.gen(function* () { // Checked here, ahead of the root pre-run. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new MigrationTargetFlagsError({ - message: cobraMutuallyExclusiveErrorMessage( - ["db-url", "linked", "local"], - target.setFlags, - ), - }), - ); + return yield* new MigrationTargetFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(["db-url", "linked", "local"], target.setFlags), + }); } if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { - return yield* Effect.fail( - new MigrationPasswordFlagsError({ - message: cobraMutuallyExclusiveErrorMessage( - ["db-url", "password"], - ["db-url", "password"], - ), - }), - ); + return yield* new MigrationPasswordFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(["db-url", "password"], ["db-url", "password"]), + }); } const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); @@ -538,12 +524,10 @@ const runSquash = Effect.fnUntraced(function* ( // `--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 and caches the project ref, then reads the remote-merged config before @@ -597,17 +581,13 @@ const runSquash = Effect.fnUntraced(function* ( if (version.length > 0) { if (parseMigrationVersion(version) === undefined) { // Bare message; squash does not inherit repair's "failed to parse :" prefix. - return yield* Effect.fail( - new MigrationInvalidVersionError({ message: "invalid version number" }), - ); + return yield* new MigrationInvalidVersionError({ message: "invalid version number" }); } const versionFile = yield* resolveMigrationFile(fs, path, migrationsDir, version); if (Option.isNone(versionFile)) { - return yield* Effect.fail( - new MigrationFileNotFoundError({ - message: `glob supabase/migrations/${version}_*.sql: file does not exist`, - }), - ); + return yield* new MigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }); } } diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 2d4a6cb9bf..774250f626 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1,8 +1,16 @@ -import { existsSync, mkdirSync, readFileSync, 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, FileSystem, Layer, Option } from "effect"; +import { + Cause, + Config, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, +} from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -17,6 +25,7 @@ import { mockTelemetryStateTracked, useShadowCacheDisabled, useTempWorkdir, + withEnvVar, sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; import { @@ -116,36 +125,37 @@ const simulatedFsError = (path: string, method: string) => ); interface FsFaultOpts { - /** Makes `fs.open(path, { flag: "w" })` itself fail — squash's one target-file open call. */ - readonly failOpenPath?: string; + /** Makes `fs.open(file, { flag: "w" })` itself fail — squash's one target-file open call. */ + readonly failOpenFile?: string; /** - * Lets the Nth+ `writeAll` call on the open handle for `path` fail (1-indexed), + * Lets the Nth+ `writeAll` call on the open handle for `file` fail (1-indexed), * succeeding on every earlier call, so the full-dump write (call 1) and the * separator/diff tail write (call 2) can be failed independently. */ - readonly failWriteAllFromCall?: { readonly path: string; readonly fromCall: number }; - readonly failRemovePath?: string; - readonly failReadDirectoryAtCall?: { readonly path: string; readonly atCall: number }; + readonly failWriteAllFromCall?: { readonly file: string; readonly fromCall: number }; + readonly failRemoveFile?: string; + readonly failMigrationsReadDirectoryAtCall?: number; } -function faultyFsLayer(opts: FsFaultOpts): Layer.Layer { +function faultyFsLayer(workdir: string, opts: FsFaultOpts): Layer.Layer { return Layer.effect( FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (real) => { + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const migrationsDir = pathService.join(workdir, "supabase", "migrations"); + const migrationFile = (file: string) => pathService.join(migrationsDir, file); let readDirCallsForPath = 0; return FileSystem.FileSystem.of({ ...real, remove: (path, removeOpts) => - opts.failRemovePath !== undefined && path === opts.failRemovePath + opts.failRemoveFile !== undefined && path === migrationFile(opts.failRemoveFile) ? Effect.fail(simulatedFsError(path, "remove")) : real.remove(path, removeOpts), readDirectory: (path, readOpts) => { - if ( - opts.failReadDirectoryAtCall !== undefined && - path === opts.failReadDirectoryAtCall.path - ) { + if (opts.failMigrationsReadDirectoryAtCall !== undefined && path === migrationsDir) { readDirCallsForPath += 1; - if (readDirCallsForPath === opts.failReadDirectoryAtCall.atCall) { + if (readDirCallsForPath === opts.failMigrationsReadDirectoryAtCall) { return Effect.fail(simulatedFsError(path, "readDirectory")); } } @@ -153,8 +163,8 @@ function faultyFsLayer(opts: FsFaultOpts): Layer.Layer { }, open: (path, openOpts) => { if ( - opts.failOpenPath !== undefined && - path === opts.failOpenPath && + opts.failOpenFile !== undefined && + path === migrationFile(opts.failOpenFile) && openOpts?.flag === "w" ) { return Effect.fail(simulatedFsError(path, "open")); @@ -163,7 +173,7 @@ function faultyFsLayer(opts: FsFaultOpts): Layer.Layer { Effect.map((file) => { if ( opts.failWriteAllFromCall === undefined || - path !== opts.failWriteAllFromCall.path + path !== migrationFile(opts.failWriteAllFromCall.file) ) { return file; } @@ -364,7 +374,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); const layer = - opts.fsFaults === undefined ? baseLayer : Layer.merge(baseLayer, faultyFsLayer(opts.fsFaults)); + opts.fsFaults === undefined + ? baseLayer + : Layer.merge(baseLayer, faultyFsLayer(workdir, opts.fsFaults)); return { layer, @@ -392,11 +404,51 @@ const flags = (over: Partial = {}): MigrationSquashFlags = projectRef: over.projectRef ?? Option.none(), }); -const seedMigration = (workdir: string, name: string, body = "create table t (id int);\n") => { - const dir = join(workdir, "supabase", "migrations"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, name), body); -}; +const migrationsDirPath = Effect.fnUntraced(function* (workdir: string) { + const path = yield* Path.Path; + return path.join(workdir, "supabase", "migrations"); +}); + +const migrationPath = Effect.fnUntraced(function* (workdir: string, file: string) { + const path = yield* Path.Path; + return path.join(yield* migrationsDirPath(workdir), file); +}); + +const seedMigration = Effect.fnUntraced(function* ( + workdir: string, + name: string, + body = "create table t (id int);\n", +) { + const fs = yield* FileSystem.FileSystem; + const dir = yield* migrationsDirPath(workdir); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(yield* migrationPath(workdir, name), body); +}); + +const seedHappyPathMigrations = Effect.fnUntraced(function* (workdir: string) { + yield* seedMigration(workdir, "0_init.sql", "create table a (id int);\n"); + yield* seedMigration(workdir, "1_target.sql", "create table b (id int);\n"); +}); + +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 readMigration = Effect.fnUntraced(function* (workdir: string, file: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(yield* migrationPath(workdir, file)); +}); + +const migrationExists = Effect.fnUntraced(function* (workdir: string, file: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs + .exists(yield* migrationPath(workdir, file)) + .pipe(Effect.orElseSucceed(() => false)); +}); const stdout = (out: ReturnType) => stripAnsi(out.stdoutText); const stderr = (out: ReturnType) => stripAnsi(out.stderrText); @@ -526,9 +578,9 @@ describe("migration squash", () => { }); it.effect("fails with a glob not-found error when --version matches no local file", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); const exit = yield* migrationSquash(flags({ version: Option.some("9") })).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationFileNotFoundError"); if (Exit.isFailure(exit)) { @@ -553,10 +605,10 @@ describe("migration squash", () => { }); it.effect("defaults to the local database when no target flag is given", () => { - seedMigration(tmp.current, "0_init.sql"); // omitRef matches the real resolver's --local shape: no ref at all, not merely None. const s = setup(tmp.current, { args: [], omitRef: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(s.resolverCalls[0]?.connType).toBe("local"); }).pipe(Effect.provide(s.layer)); @@ -581,9 +633,9 @@ describe("migration squash", () => { it.effect( "fails with 'version not found' when the only file is a deprecated <14-digit>_init.sql", () => { - seedMigration(tmp.current, "20211208000000_init.sql"); const s = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20211208000000_init.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashMissingVersionError"); }).pipe(Effect.provide(s.layer)); @@ -593,10 +645,9 @@ describe("migration squash", () => { it.effect( "surfaces 'failed to read directory' when supabase/migrations is a file, not a directory", () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const s = setup(tmp.current); return Effect.gen(function* () { + yield* writeProjectFile(tmp.current, "migrations", "not a directory"); const error = yield* migrationSquash(flags()).pipe(Effect.flip); expect((error as { message: string }).message).toContain("failed to read directory"); }).pipe(Effect.provide(s.layer)); @@ -606,9 +657,9 @@ describe("migration squash", () => { it.effect( "no-ops on a single migration: prints the earliest-migration line, spawns no container, and still finishes", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(stderr(s.out)).toContain( "supabase/migrations/0_init.sql is already the earliest migration.", @@ -631,8 +682,6 @@ describe("migration squash", () => { const FULL_SQL = "CREATE TABLE t (id int);\n"; function setupHappyPath(opts: SetupOpts = {}) { - seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); - seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); return setup(tmp.current, { beforeDumpSql: BEFORE_SQL, afterDumpSql: AFTER_SQL, @@ -646,6 +695,7 @@ describe("migration squash", () => { () => { const s = setupHappyPath(); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); @@ -657,18 +707,15 @@ describe("migration squash", () => { "Squashed local migrations to supabase/migrations/1_target.sql", ); - const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); - expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + expect(yield* migrationExists(tmp.current, "0_init.sql")).toBe(false); + expect(yield* migrationExists(tmp.current, "1_target.sql")).toBe(true); // Hardcoded rather than recomputed via squash.diff.ts's helpers, so a regression // in the separator constant or the diff algorithm itself still fails this // assertion. const expectedTail = "\n--\n-- Dumped schema changes for auth and storage\n--\n\n" + "new auth object;\n"; - expect(readFileSync(join(migrationsDir, "1_target.sql"), "utf8")).toBe( - FULL_SQL + expectedTail, - ); + expect(yield* readMigration(tmp.current, "1_target.sql")).toBe(FULL_SQL + expectedTail); expect(stdout(s.out)).toContain("Finished supabase migration squash."); }).pipe(Effect.provide(s.layer)); @@ -680,6 +727,7 @@ describe("migration squash", () => { () => { const s = setupHappyPath(); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); expect(s.dumpCalls).toHaveLength(3); const [before, after, full] = s.dumpCalls; @@ -698,7 +746,9 @@ describe("migration squash", () => { () => { const s = setupHappyPath(); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); + const expectedImage = yield* getRegistryImageUrl(dockerfileServiceImage("pg")); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { expect(call.env["PGPORT"]).toBe("54320"); @@ -706,9 +756,7 @@ describe("migration squash", () => { expect(call.env["PGDATABASE"]).toBe("postgres"); expect(call.network).toEqual({ _tag: "host" }); expect(call.cmd).toEqual(["bash", "-c", dumpSchemaScript, "--"]); - expect(call.image).toBe( - Effect.runSync(getRegistryImageUrl(dockerfileServiceImage("pg"))), - ); + expect(call.image).toBe(expectedImage); } // Every dump dials the same shadow host, whatever this machine's Docker context // resolves (getHostname); checked for self-consistency rather than a hardcoded @@ -726,6 +774,7 @@ describe("migration squash", () => { () => { const s = setupHappyPath(); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); const expectedHost = FAKE_SHADOW_CONTAINER_ID.slice(0, 12); expect(s.setupJobCalls.length).toBeGreaterThan(0); @@ -752,6 +801,7 @@ describe("migration squash", () => { () => { const s = setupHappyPath({ networkId: "custom-net" }); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { @@ -765,14 +815,14 @@ describe("migration squash", () => { "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", () => { // The project env is passed explicitly to each pg_dump invocation. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; const s = setupHappyPath(); - writeFileSync( - join(tmp.current, "supabase", ".env"), - "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", - ); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); + yield* writeProjectFile( + tmp.current, + ".env", + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + ); yield* migrationSquash(flags()); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { @@ -780,15 +830,12 @@ describe("migration squash", () => { } // Reverted once the command's scope closes; never leaks into a later command in // the same process. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - Effect.provide(s.layer), + const ambient = yield* Config.option( + Config.string("SUPABASE_INTERNAL_IMAGE_REGISTRY"), + ).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnv())); + expect(Option.isNone(ambient)).toBe(true); + }).pipe(Effect.provide(s.layer), (body) => + withEnvVar("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, body), ); }, ); @@ -798,45 +845,35 @@ describe("migration squash", () => { () => { // Host networking is the default; an explicit network id overrides it whenever // it resolves non-empty, even when sourced only from supabase/.env. - const prev = process.env["SUPABASE_NETWORK_ID"]; - delete process.env["SUPABASE_NETWORK_ID"]; const s = setupHappyPath(); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); return Effect.gen(function* () { + yield* seedHappyPathMigrations(tmp.current); + yield* writeProjectFile(tmp.current, ".env", "SUPABASE_NETWORK_ID=dotenv-net\n"); yield* migrationSquash(flags()); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { expect(call.network).toEqual({ _tag: "named", name: "dotenv-net" }); } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; - else process.env["SUPABASE_NETWORK_ID"] = prev; - }), - ), - Effect.provide(s.layer), + }).pipe(Effect.provide(s.layer), (body) => + withEnvVar("SUPABASE_NETWORK_ID", undefined, body), ); }, ); it.effect("squashes only the migrations up to --version, leaving newer ones untouched", () => { - seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); - seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); - seedMigration(tmp.current, "2_after.sql", "create table c (id int);\n"); const s = setup(tmp.current, { beforeDumpSql: BEFORE_SQL, afterDumpSql: AFTER_SQL, fullDumpSql: FULL_SQL, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + yield* seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + yield* seedMigration(tmp.current, "2_after.sql", "create table c (id int);\n"); yield* migrationSquash(flags({ version: Option.some("1") })); - const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); - expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); - expect(readFileSync(join(migrationsDir, "2_after.sql"), "utf8")).toBe( - "create table c (id int);\n", - ); + expect(yield* migrationExists(tmp.current, "0_init.sql")).toBe(false); + expect(yield* migrationExists(tmp.current, "1_target.sql")).toBe(true); + expect(yield* readMigration(tmp.current, "2_after.sql")).toBe("create table c (id int);\n"); }).pipe(Effect.provide(s.layer)); }); }); @@ -845,10 +882,10 @@ describe("migration squash", () => { // established leak-on-create-failure behavior). describe("squashMigrations failure paths", () => { it.effect("fails when the shadow container cannot be created and never attempts a dump", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); const s = setup(tmp.current, { failCreateShadow: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("ShadowDbError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); @@ -861,17 +898,13 @@ describe("migration squash", () => { it.effect( "fails with a health-check timeout when the shadow never becomes healthy, and removes it", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); // A zero-second health timeout means zero retries after the first failed // probe — an immediate, deterministic timeout with no real/virtual delay. - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - '[db]\nhealth_timeout = "0s"\n', - ); const s = setup(tmp.current, { neverHealthyShadow: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); + yield* writeProjectFile(tmp.current, "config.toml", '[db]\nhealth_timeout = "0s"\n'); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("HealthCheckTimeoutError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); @@ -884,10 +917,10 @@ describe("migration squash", () => { it.effect( "fails when the shadow's platform-baseline setup job exits non-zero, and removes it", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); const s = setup(tmp.current, { failSetupJob: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("DbSetupError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); @@ -898,10 +931,10 @@ describe("migration squash", () => { ); it.effect("fails when applying a migration to the shadow errors, and removes it", () => { - seedMigration(tmp.current, "0_init.sql", "create table boom;\n"); - seedMigration(tmp.current, "1_target.sql"); const s = setup(tmp.current, { failSql: "create table boom" }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql", "create table boom;\n"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationApplyError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); @@ -912,10 +945,10 @@ describe("migration squash", () => { it.effect( "fails with 'error running container: exit 1' when the before/after dump container exits non-zero", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); const s = setup(tmp.current, { failDumpKind: "before" }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashDumpError"); if (Exit.isFailure(exit)) { @@ -932,8 +965,6 @@ describe("migration squash", () => { it.effect( "fails with 'error running container: exit 1' when the full-schema dump exits non-zero, leaving the target file truncated", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); const s = setup(tmp.current, { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", @@ -941,13 +972,16 @@ describe("migration squash", () => { failDumpKind: "full", }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashDumpError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); // Truncated by the earlier O_TRUNC, then only the partial stream the dying // container wrote before failing; no separator/diff was ever appended. - expect(readFileSync(targetPath, "utf8")).toBe("partial output before the container died"); + expect(yield* readMigration(tmp.current, "1_target.sql")).toBe( + "partial output before the container died", + ); }).pipe(Effect.provide(s.layer)); }, ); @@ -957,16 +991,15 @@ describe("migration squash", () => { () => { // Squash's single O_TRUNC-equivalent open call, so there is exactly one failure // site here, not two. - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); const s = setup(tmp.current, { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - fsFaults: { failOpenPath: targetPath }, + fsFaults: { failOpenFile: "1_target.sql" }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashWriteError"); if (Exit.isFailure(exit)) { @@ -988,16 +1021,15 @@ describe("migration squash", () => { // The underlying failure here is the docker-log-stream write into the target // file, so it reports "failed to copy docker logs:", not lineByLineDiff's // "failed to write line:". - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); const s = setup(tmp.current, { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 1 } }, + fsFaults: { failWriteAllFromCall: { file: "1_target.sql", fromCall: 1 } }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashWriteError"); if (Exit.isFailure(exit)) { @@ -1014,18 +1046,17 @@ describe("migration squash", () => { it.effect( "fails with 'failed to write line' when appending the separator/diff tail fails (the full dump itself wrote fine)", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); const s = setup(tmp.current, { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", // fromCall: 2 lets the full-dump write (call 1) succeed, isolating the tail // write (call 2). - fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 2 } }, + fsFaults: { failWriteAllFromCall: { file: "1_target.sql", fromCall: 2 } }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashWriteError"); if (Exit.isFailure(exit)) { @@ -1037,43 +1068,41 @@ describe("migration squash", () => { expect(message).not.toContain(tmp.current); } expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - expect(readFileSync(targetPath, "utf8")).toBe("full;\n"); + expect(yield* readMigration(tmp.current, "1_target.sql")).toBe("full;\n"); }).pipe(Effect.provide(s.layer)); }, ); it.effect("prints a merged-file removal error to stderr non-fatally and still succeeds", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); const s = setup(tmp.current, { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - fsFaults: { failRemovePath: earlierPath }, + fsFaults: { failRemoveFile: "0_init.sql" }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); yield* migrationSquash(flags()); expect(stdout(s.out)).toContain("Finished supabase migration squash."); expect(stderr(s.out)).toContain("FileSystem.remove (supabase/migrations/0_init.sql)"); - expect(existsSync(earlierPath)).toBe(true); + expect(yield* migrationExists(tmp.current, "0_init.sql")).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect( "reports the removal failure in the machine-mode payload's removeFailures, leaving removed empty", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); const s = setup(tmp.current, { format: "json", beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - fsFaults: { failRemovePath: earlierPath }, + fsFaults: { failRemoveFile: "0_init.sql" }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); yield* migrationSquash(flags()); const success = s.out.messages.find((m) => m.type === "success"); const data = success?.data as { @@ -1094,8 +1123,6 @@ describe("migration squash", () => { ); it.effect("reports a shadow cleanup failure without failing the command", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); const s = setup(tmp.current, { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", @@ -1103,6 +1130,8 @@ describe("migration squash", () => { failRemoveShadow: true, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); yield* migrationSquash(flags()); expect(stdout(s.out)).toContain("Finished supabase migration squash."); expect(stderr(s.out)).toContain(`Failed to remove container: ${FAKE_SHADOW_CONTAINER_ID}`); @@ -1114,9 +1143,9 @@ describe("migration squash", () => { it.effect( "prints Finished on stdout and the repair suggestion on stderr, and never prompts", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { isLocal: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(stdout(s.out)).toContain("Finished supabase migration squash."); expect(stderr(s.out)).toContain( @@ -1128,9 +1157,9 @@ describe("migration squash", () => { ); it.effect("a --db-url pointing at the local stack also takes the local-suggestion path", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { isLocal: true, args: ["--db-url", "postgresql://local"] }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags({ dbUrl: Option.some("postgresql://local") })); expect(stdout(s.out)).toContain("Finished supabase migration squash."); }).pipe(Effect.provide(s.layer)); @@ -1139,13 +1168,13 @@ describe("migration squash", () => { describe("remote target", () => { function setupRemote(opts: SetupOpts = {}) { - seedMigration(tmp.current, "0_init.sql"); return setup(tmp.current, { isLocal: false, linkedRef: VALID_REF, ...opts }); } it.effect("prompts to update the remote history table and baselines on 'y'", () => { const s = setupRemote({ confirm: true, args: ["--linked"] }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags({ linked: true })); expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); expect(s.queries.some((q) => q.sql.includes("DELETE FROM supabase_migrations"))).toBe(true); @@ -1158,6 +1187,7 @@ describe("migration squash", () => { () => { const s = setupRemote({ confirm: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); const text = stderr(s.out); const baseliningAt = text.indexOf("Baselining migration history to 0"); @@ -1173,6 +1203,7 @@ describe("migration squash", () => { () => { const s = setupRemote({ confirm: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); // A single ordered log, since execs/queries alone are order-blind and would // still pass an INSERT-before-DELETE regression; the baseline transaction is @@ -1195,6 +1226,7 @@ describe("migration squash", () => { () => { const s = setupRemote({ confirm: true, failSql: "INSERT INTO supabase_migrations" }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("MigrationSquashBaselineError"); if (Exit.isFailure(exit)) { @@ -1217,6 +1249,7 @@ describe("migration squash", () => { () => { const s = setupRemote({ confirm: false }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(s.execs).not.toContain("BEGIN"); @@ -1229,6 +1262,7 @@ describe("migration squash", () => { it.effect("--yes auto-confirms by echoing the prompt with 'y' and reads no stdin", () => { const s = setupRemote({ yes: true, pipedInput: undefined }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] y"); expect(s.execs).toContain("BEGIN"); @@ -1238,6 +1272,7 @@ describe("migration squash", () => { it.effect("a non-TTY run with no piped answer takes the default (yes) and baselines", () => { const s = setupRemote({ isTTY: false, pipedInput: undefined }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(s.execs).toContain("BEGIN"); expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); @@ -1247,14 +1282,14 @@ describe("migration squash", () => { it.effect( "--version 0 baselines exactly version 0 even though a newer migration survives", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_newer.sql"); const s = setup(tmp.current, { isLocal: false, linkedRef: VALID_REF, confirm: true, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_newer.sql"); yield* migrationSquash(flags({ version: Option.some("0") })); const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); expect(insert?.params?.[0]).toBe("0"); @@ -1266,9 +1301,6 @@ describe("migration squash", () => { // Local versions are re-listed after the file removals, so a failed removal leaves // the older merged file's version as what an empty --version baselines to, not the // squash target. - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); const s = setup(tmp.current, { isLocal: false, linkedRef: VALID_REF, @@ -1276,9 +1308,11 @@ describe("migration squash", () => { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - fsFaults: { failRemovePath: earlierPath }, + fsFaults: { failRemoveFile: "0_init.sql" }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); yield* migrationSquash(flags()); const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); expect(insert?.params?.[0]).toBe("0"); @@ -1288,9 +1322,6 @@ describe("migration squash", () => { it.effect( "debug-logs and baselines with an empty version when the post-squash version reload fails", () => { - seedMigration(tmp.current, "0_init.sql"); - seedMigration(tmp.current, "1_target.sql"); - const migrationsDir = join(tmp.current, "supabase", "migrations"); const s = setup(tmp.current, { isLocal: false, linkedRef: VALID_REF, @@ -1301,9 +1332,11 @@ describe("migration squash", () => { // Call 1 is squashToVersion's own listing (must succeed); call 2 is // baselineMigrations's post-removal re-list, which this fails; call 3 (inside // resolveMigrationFile) must succeed again to isolate the reload failure. - fsFaults: { failReadDirectoryAtCall: { path: migrationsDir, atCall: 2 } }, + fsFaults: { failMigrationsReadDirectoryAtCall: 2 }, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* seedMigration(tmp.current, "1_target.sql"); const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(s.debugLogs).toHaveLength(1); expect(s.debugLogs[0]).toContain("failed to read directory"); @@ -1365,12 +1398,6 @@ describe("migration squash", () => { it.effect( "--linked reads [remotes.] and prints the config-override line before resolving", () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[remotes.dev]", `project_id = "${VALID_REF}"`, ""].join("\n"), - ); - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { isLocal: false, linkedRef: VALID_REF, @@ -1378,6 +1405,12 @@ describe("migration squash", () => { args: ["--linked"], }); return Effect.gen(function* () { + yield* writeProjectFile( + tmp.current, + "config.toml", + ["[remotes.dev]", `project_id = "${VALID_REF}"`, ""].join("\n"), + ); + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags({ linked: true })); const text = stderr(s.out); expect(text).toContain("Loading config override: [remotes.dev]"); @@ -1391,9 +1424,9 @@ describe("migration squash", () => { describe("output formats", () => { it.effect("json emits the squash payload on stdout and keeps progress on stderr", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { format: "json", isLocal: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(s.out.messages).toContainEqual( expect.objectContaining({ @@ -1413,9 +1446,9 @@ describe("migration squash", () => { }); it.effect("json suppresses the Finished line and the repair suggestion", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { format: "json", isLocal: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(stdout(s.out)).not.toContain("Finished"); expect(stderr(s.out)).not.toContain("Run supabase migration repair"); @@ -1423,9 +1456,9 @@ describe("migration squash", () => { }); it.effect("stream-json emits the result event on stdout with progress lines on stderr", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { format: "stream-json", isLocal: true }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(s.out.messages.some((m) => m.type === "success")).toBe(true); expect(stderr(s.out)).toContain("is already the earliest migration."); @@ -1433,7 +1466,6 @@ describe("migration squash", () => { }); it.effect("json still writes the prompt label to stderr and reads the piped answer", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { format: "json", isLocal: false, @@ -1441,6 +1473,7 @@ describe("migration squash", () => { confirm: true, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); expect(s.execs).toContain("BEGIN"); @@ -1450,7 +1483,6 @@ describe("migration squash", () => { it.effect( "json on the declined-prompt path reports success with baselinedVersion: null", () => { - seedMigration(tmp.current, "0_init.sql"); const s = setup(tmp.current, { format: "json", isLocal: false, @@ -1458,6 +1490,7 @@ describe("migration squash", () => { confirm: false, }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ isLocal: false, baselinedVersion: null }); @@ -1466,8 +1499,6 @@ describe("migration squash", () => { ); it.effect("json on the remote-confirmed 2-migration path reports the full real payload", () => { - seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); - seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); const s = setup(tmp.current, { format: "json", isLocal: false, @@ -1478,6 +1509,8 @@ describe("migration squash", () => { fullDumpSql: "full;\n", }); return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + yield* seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); yield* migrationSquash(flags()); const success = s.out.messages.find((m) => m.type === "success"); // "1_target.sql" is the sole surviving file once "0_init.sql" is removed, so the diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index de2ffb7f64..14dfe33933 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -45,22 +45,18 @@ const runUp = Effect.fnUntraced(function* ( const dnsResolver = yield* DnsResolverFlag; 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`, + }); } // `--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) && (target.connType ?? "local") !== "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)", + }); } const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); @@ -92,24 +88,20 @@ const runUp = Effect.fnUntraced(function* ( let pending: ReadonlyArray; if (result.kind === "missing-local") { - return yield* Effect.fail( - new MigrationMissingLocalError({ - message: "Remote migration versions not found in local migrations directory.", - suggestion: suggestRevertHistory( - result.versions, - (target.connType ?? "local") === "local", - ), - }), - ); + return yield* new MigrationMissingLocalError({ + message: "Remote migration versions not found in local migrations directory.", + suggestion: suggestRevertHistory( + result.versions, + (target.connType ?? "local") === "local", + ), + }); } else if (result.kind === "missing-remote") { if (!flags.includeAll) { - return yield* Effect.fail( - new MigrationMissingRemoteError({ - message: - "Found local migration files to be inserted before the last migration on remote database.", - suggestion: suggestIgnoreFlag(result.paths), - }), - ); + return yield* new MigrationMissingRemoteError({ + message: + "Found local migration files to be inserted before the last migration on remote database.", + suggestion: suggestIgnoreFlag(result.paths), + }); } // Slices the same version-ordered list `result.paths` was taken from; indexing // a name-ordered list with this offset would skip a pending migration and diff --git a/apps/cli/src/commands/migration/up/up.integration.test.ts b/apps/cli/src/commands/migration/up/up.integration.test.ts index c90a54a8c1..9edbb425fc 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -1,8 +1,6 @@ -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 { @@ -23,6 +21,7 @@ import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/ import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; import { MigrationVaultError } from "../../../command-internal/vault.ts"; +import { MigrationMissingLocalError } from "./up.errors.ts"; import { migrationUp } from "./up.handler.ts"; import type { MigrationUpFlags } from "./up.command.ts"; @@ -35,15 +34,10 @@ interface SetupOpts { readonly remote?: ReadonlyArray; readonly failApply?: boolean; readonly failVault?: boolean; - readonly config?: string; readonly existingVault?: ReadonlyArray<{ id: string; name: string }>; } 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" }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); @@ -135,11 +129,25 @@ const flags = (over: Partial = {}): MigrationUpFlags => ({ 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 insertedVersions = (queries: Array<{ sql: string; params?: ReadonlyArray }>) => queries .filter((q) => q.sql.includes("INSERT INTO supabase_migrations")) @@ -149,11 +157,11 @@ const tmp = useTempWorkdir(); describe("migration up", () => { it.live("applies pending migrations in order and prints progress", () => { - seed(tmp.current, "20240101000000_a.sql"); - seed(tmp.current, "20240102000000_b.sql"); - seed(tmp.current, "20240103000000_c.sql"); const { layer, out, queries } = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* seed(tmp.current, "20240102000000_b.sql"); + yield* seed(tmp.current, "20240103000000_c.sql"); yield* migrationUp(flags()); const stderr = stripAnsi(out.stderrText); const stdout = stripAnsi(out.stdoutText); @@ -168,25 +176,29 @@ describe("migration up", () => { }); it.live("errors with a revert suggestion when a remote version is missing locally", () => { - seed(tmp.current, "20240101000000_a.sql"); const { layer } = setup(tmp.current, { remote: ["20240101000000", "20240199000000"] }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); const exit = yield* migrationUp(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationMissingLocalError"); - expect(JSON.stringify(exit.cause)).toContain("migration repair --local --status reverted"); - expect(JSON.stringify(exit.cause)).toContain("supabase db pull --local"); + const suggestion = + Option.isSome(failure) && failure.value instanceof MigrationMissingLocalError + ? failure.value.suggestion + : ""; + expect(suggestion).toContain("migration repair --local --status reverted"); + expect(suggestion).toContain("supabase db pull --local"); } }).pipe(Effect.provide(layer)); }); it.live("errors with an --include-all suggestion on an out-of-order local migration", () => { - seed(tmp.current, "20240101000000_a.sql"); - seed(tmp.current, "20240102000000_b.sql"); const { layer } = setup(tmp.current, { remote: ["20240102000000"] }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* seed(tmp.current, "20240102000000_b.sql"); const exit = yield* migrationUp(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -197,23 +209,23 @@ describe("migration up", () => { }); it.live("applies out-of-order migrations with --include-all in the right order", () => { - seed(tmp.current, "20240101000000_a.sql"); // out-of-order (before applied 02) - seed(tmp.current, "20240102000000_b.sql"); // already applied on remote - seed(tmp.current, "20240103000000_c.sql"); // trailing pending const { layer, queries } = setup(tmp.current, { remote: ["20240102000000"] }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); // out-of-order (before applied 02) + yield* seed(tmp.current, "20240102000000_b.sql"); // already applied on remote + yield* seed(tmp.current, "20240103000000_c.sql"); // trailing pending yield* migrationUp(flags({ includeAll: true })); expect(insertedVersions(queries)).toEqual(["20240101000000", "20240103000000"]); }).pipe(Effect.provide(layer)); }); it.live("creates a new [db.vault] secret before applying migrations", () => { - seed(tmp.current, "20240101000000_a.sql"); const { layer, out, queries } = setup(tmp.current, { remote: [], - config: '[db.vault]\nmy_secret = "shhh"\n', }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* writeProjectFile(tmp.current, "config.toml", '[db.vault]\nmy_secret = "shhh"\n'); yield* migrationUp(flags()); expect(stripAnsi(out.stderrText)).toContain("Updating vault secrets..."); const create = queries.find((q) => q.sql.includes("create_secret")); @@ -222,13 +234,13 @@ describe("migration up", () => { }); it.live("reports a vault upsert failure", () => { - seed(tmp.current, "20240101000000_a.sql"); const { layer } = setup(tmp.current, { remote: [], - config: '[db.vault]\nmy_secret = "shhh"\n', failVault: true, }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* writeProjectFile(tmp.current, "config.toml", '[db.vault]\nmy_secret = "shhh"\n'); const exit = yield* migrationUp(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -239,13 +251,13 @@ describe("migration up", () => { }); it.live("updates an existing [db.vault] secret by id", () => { - seed(tmp.current, "20240101000000_a.sql"); const { layer, queries } = setup(tmp.current, { remote: [], - config: '[db.vault]\nmy_secret = "shhh"\n', existingVault: [{ id: "vault-id-1", name: "my_secret" }], }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* writeProjectFile(tmp.current, "config.toml", '[db.vault]\nmy_secret = "shhh"\n'); yield* migrationUp(flags()); const update = queries.find((q) => q.sql.includes("update_secret")); expect(update?.params).toEqual(["vault-id-1", "shhh"]); @@ -306,9 +318,9 @@ describe("migration up", () => { }); it.live("emits a structured result in json", () => { - seed(tmp.current, "20240101000000_a.sql"); const { layer, out } = setup(tmp.current, { format: "json", remote: [] }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); yield* migrationUp(flags()); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Migrations applied" }), @@ -317,9 +329,9 @@ describe("migration up", () => { }); it.live("surfaces an apply failure", () => { - seed(tmp.current, "20240101000000_a.sql", "create table boom;\n"); const { layer } = setup(tmp.current, { remote: [], failApply: true }); return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql", "create table boom;\n"); const exit = yield* migrationUp(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { diff --git a/apps/cli/src/commands/migration/up/up.live.test.ts b/apps/cli/src/commands/migration/up/up.live.test.ts index 5ac6ada43d..5f44fbc741 100644 --- a/apps/cli/src/commands/migration/up/up.live.test.ts +++ b/apps/cli/src/commands/migration/up/up.live.test.ts @@ -1,99 +1,119 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { queryMigrationDb } from "../../../../tests/helpers/migration-live.ts"; + +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, FileSystem, Path, Predicate } from "effect"; import { expect } from "vitest"; import { liveMigrationVersion, - queryLiveDb, requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("applies a test-written migration to the remote database", async ({ - cli, +test("applies a test-written migration to the remote database", ({ + cliEffect, project, workspace, -}) => { - const version = liveMigrationVersion(); - const migrations = join(workspace.path, "supabase", "migrations"); - await mkdir(migrations, { recursive: true }); + signal, +}) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const version = liveMigrationVersion(); + const migrations = path.join(workspace.path, "supabase", "migrations"); + yield* fs.makeDirectory(migrations, { recursive: true }); - // The serial suite shares one project; seed a stub for every version already in remote - // history, or `migration up` rejects it as missing locally. - let remoteVersions: Array<{ version: string }> = []; - try { - remoteVersions = await queryLiveDb( - project.dbUrl, - "select version from supabase_migrations.schema_migrations order by version", - ); - } catch (error) { - // 42P01 = undefined relation, i.e. a fresh project without the migrations table yet. - if ((error as { code?: string }).code !== "42P01") throw error; - remoteVersions = []; - } - for (const row of remoteVersions) { - await writeFile( - join(migrations, `${row.version}_preexisting_remote.sql`), - "-- stub for a version already in remote history\n", - ); - } + // The serial suite shares one project; seed a stub for every version already in remote + // history, or `migration up` rejects it as missing locally. + const remoteVersions = yield* queryMigrationDb<{ version: string }>( + project.dbUrl, + "select version from supabase_migrations.schema_migrations order by version", + ).pipe( + Effect.catch((error) => + // 42P01 = undefined relation, i.e. a fresh project without the migrations table yet. + Predicate.hasProperty(error.cause, "code") && error.cause.code === "42P01" + ? Effect.succeed>([]) + : Effect.fail(error), + ), + ); + for (const row of remoteVersions) { + yield* fs.writeFileString( + path.join(migrations, `${row.version}_preexisting_remote.sql`), + "-- stub for a version already in remote history\n", + ); + } - const migrationFile = join(migrations, `${version}_e2e_up.sql`); - await writeFile(migrationFile, `create table if not exists e2e_up_${version} (id int);\n`); + const migrationFile = path.join(migrations, `${version}_e2e_up.sql`); + yield* fs.writeFileString( + migrationFile, + `create table if not exists e2e_up_${version} (id int);\n`, + ); - let targetError: unknown; - const cleanupErrors: Array = []; - try { - const applied = await cli(["migration", "up", "--db-url", project.dbUrl]); - expect(applied.exitCode, applied.stderr).toBe(0); - expect(applied.stderr, applied.stdout).toContain("Applying migration"); + const target = Effect.gen(function* () { + const applied = yield* cliEffect(["migration", "up", "--db-url", project.dbUrl]); + expect(applied.exitCode, applied.stderr).toBe(0); + expect(applied.stderr, applied.stdout).toContain("Applying migration"); - const history = await queryLiveDb( - project.dbUrl, - "select version from supabase_migrations.schema_migrations where version = $1", - [version], - ); - expect(history).toHaveLength(1); + const history = yield* queryMigrationDb( + project.dbUrl, + "select version from supabase_migrations.schema_migrations where version = $1", + [version], + ); + expect(history).toHaveLength(1); - const created = await queryLiveDb(project.dbUrl, "select to_regclass($1) as table_oid", [ - `public.e2e_up_${version}`, - ]); - expect(created[0]?.["table_oid"], "migration up must execute the migration sql").not.toBeNull(); - } catch (error) { - targetError = error; - } finally { - try { - await rm(migrationFile, { force: true }); - } catch (error) { - cleanupErrors.push(error); - } - try { - const dropped = await cli([ - "db", - "query", - `drop table if exists e2e_up_${version}`, - "--db-url", - project.dbUrl, - ]); - requireLiveSuccess(dropped, "db query cleanup after migration up"); - } catch (error) { - cleanupErrors.push(error); - } - try { - const reverted = await cli([ - "migration", - "repair", - version, - "--status", - "reverted", - "--db-url", - project.dbUrl, - ]); - requireLiveSuccess(reverted, "migration repair cleanup after migration up"); - } catch (error) { - cleanupErrors.push(error); - } - } - throwWithCleanup(targetError, cleanupErrors); -}); + const created = yield* queryMigrationDb( + project.dbUrl, + "select to_regclass($1) as table_oid", + [`public.e2e_up_${version}`], + ); + expect( + created[0]?.["table_oid"], + "migration up must execute the migration sql", + ).not.toBeNull(); + }); + + const dropTable = Effect.gen(function* () { + const dropped = yield* cliEffect([ + "db", + "query", + `drop table if exists e2e_up_${version}`, + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(dropped, "db query cleanup after migration up"); + }); + + const revertHistory = Effect.gen(function* () { + const reverted = yield* cliEffect([ + "migration", + "repair", + version, + "--status", + "reverted", + "--db-url", + project.dbUrl, + ]); + requireLiveSuccess(reverted, "migration repair cleanup after migration up"); + }); + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const targetExit = yield* Effect.exit(restore(target)); + const cleanupExits: ReadonlyArray> = [ + yield* Effect.exit(fs.remove(migrationFile, { force: true })), + yield* Effect.exit(dropTable), + yield* Effect.exit(revertHistory), + ]; + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: cleanupExits + .filter(Exit.isFailure) + .map((exit) => Cause.squash(exit.cause)), + }; + }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + { signal }, + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/tests/helpers/migration-live.ts b/apps/cli/tests/helpers/migration-live.ts new file mode 100644 index 0000000000..76a61436f8 --- /dev/null +++ b/apps/cli/tests/helpers/migration-live.ts @@ -0,0 +1,33 @@ +import { Data, Effect } from "effect"; + +import { type LiveFixtures, type LiveProject, queryLiveDb, removeLiveMigration } from "./live.ts"; + +/** Typed live boundary failure; preserve the foreign error for SQLSTATE checks. */ +export class MigrationLiveError extends Data.TaggedError("MigrationLiveError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const liveFailure = (cause: unknown) => + new MigrationLiveError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + +export function queryMigrationDb>( + dbUrl: string, + query: string, + values?: ReadonlyArray, +) { + return Effect.tryPromise({ + try: () => queryLiveDb(dbUrl, query, values), + catch: liveFailure, + }); +} + +export function removeMigration(cli: LiveFixtures["cli"], project: LiveProject, version: string) { + return Effect.tryPromise({ + try: () => removeLiveMigration(cli, project, version), + catch: liveFailure, + }); +} From a1aba15538d694c79cf3439cc77c1fcdb72680e8 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:26:12 +0530 Subject: [PATCH 2/3] test(cli): fix migration live cleanup and review nits --- .oxlintrc.effect.json | 2 +- .../migration/fetch/fetch.live.test.ts | 20 +++++++++---------- .../commands/migration/list/list.live.test.ts | 4 +--- .../migration/repair/repair.live.test.ts | 3 +-- .../migration/squash/squash.diff.unit.test.ts | 3 ++- .../squash/squash.integration.test.ts | 7 ++++++- .../src/commands/migration/up/up.live.test.ts | 3 +-- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 06617e7ea8..b24525ae39 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -46,10 +46,10 @@ "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", - "!apps/cli/tests/helpers/migration-live.ts", "!apps/cli/src/command-internal/experimental-feature.ts", "!apps/cli/src/command-internal/postgres-client.run.ts", "!apps/cli/src/command-internal/stack-api.ts", diff --git a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts index 78c015830a..eb6b112888 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts @@ -1,19 +1,19 @@ -import { MigrationLiveError } from "../../../../tests/helpers/migration-live.ts"; - import { BunServices } from "@effect/platform-bun"; -import { Cause, DateTime, Effect, Exit, FileSystem, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; +import { + liveMigrationVersion, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../tests/helpers/live.ts"; +import { MigrationLiveError } from "../../../../tests/helpers/migration-live.ts"; const LIVE_TIMEOUT_MS = 120_000; const NAME = "cli_live_fetch"; -const liveMigrationVersion = Effect.map(DateTime.now, (now) => - DateTime.formatIso(now).replace(/\D/gu, "").slice(0, 14), -); - // Destructive: repairs remote migration history in setup and reverts that row in // teardown. // @@ -30,7 +30,7 @@ test( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const targetArgs = ["--db-url", project.dbUrl]; - const version = yield* liveMigrationVersion; + const version = liveMigrationVersion(); const migrationFile = `${version}_${NAME}.sql`; const seedDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-seed-live-" }); const fetchDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-fetch-live-" }); @@ -83,8 +83,6 @@ test( const targetExit = yield* Effect.exit(restore(target)); const cleanupExits: ReadonlyArray> = [ yield* Effect.exit(revert), - yield* Effect.exit(fs.remove(seedDir, { recursive: true, force: true })), - yield* Effect.exit(fs.remove(fetchDir, { recursive: true, force: true })), ]; return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, diff --git a/apps/cli/src/commands/migration/list/list.live.test.ts b/apps/cli/src/commands/migration/list/list.live.test.ts index a7a1e62c86..3d32733ce3 100644 --- a/apps/cli/src/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/commands/migration/list/list.live.test.ts @@ -1,5 +1,3 @@ -import { removeMigration } from "../../../../tests/helpers/migration-live.ts"; - import { BunServices } from "@effect/platform-bun"; import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; @@ -10,6 +8,7 @@ import { test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; +import { removeMigration } from "../../../../tests/helpers/migration-live.ts"; test("lists a seeded remote migration", ({ cli, cliEffect, project, signal }) => Effect.runPromise( @@ -43,7 +42,6 @@ test("lists a seeded remote migration", ({ cli, cliEffect, project, signal }) => const targetExit = yield* Effect.exit(restore(target)); const cleanupExits: ReadonlyArray> = [ yield* Effect.exit(removeMigration(cli, project, version)), - yield* Effect.exit(fs.remove(seedDir, { recursive: true, force: true })), ]; return { targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, diff --git a/apps/cli/src/commands/migration/repair/repair.live.test.ts b/apps/cli/src/commands/migration/repair/repair.live.test.ts index 7d9fb42ad8..5ba9237af2 100644 --- a/apps/cli/src/commands/migration/repair/repair.live.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.live.test.ts @@ -1,5 +1,3 @@ -import { queryMigrationDb } from "../../../../tests/helpers/migration-live.ts"; - import { BunServices } from "@effect/platform-bun"; import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; @@ -10,6 +8,7 @@ import { test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; +import { queryMigrationDb } from "../../../../tests/helpers/migration-live.ts"; test("amends the migration history status on the remote database", ({ cliEffect, diff --git a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts index 9ebf53e64c..1a50ba91a8 100644 --- a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts @@ -1,7 +1,8 @@ +import { fileURLToPath } from "node:url"; + import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; -import { fileURLToPath } from "node:url"; import { SQUASH_SEPARATOR_COMMENT, squashLineByLineDiff, squashScanLines } from "./squash.diff.ts"; diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 774250f626..80ff3930e6 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -832,7 +832,12 @@ describe("migration squash", () => { // the same process. const ambient = yield* Config.option( Config.string("SUPABASE_INTERNAL_IMAGE_REGISTRY"), - ).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnv())); + ).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), + ), + ); expect(Option.isNone(ambient)).toBe(true); }).pipe(Effect.provide(s.layer), (body) => withEnvVar("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, body), diff --git a/apps/cli/src/commands/migration/up/up.live.test.ts b/apps/cli/src/commands/migration/up/up.live.test.ts index 5f44fbc741..cc3bb27e72 100644 --- a/apps/cli/src/commands/migration/up/up.live.test.ts +++ b/apps/cli/src/commands/migration/up/up.live.test.ts @@ -1,5 +1,3 @@ -import { queryMigrationDb } from "../../../../tests/helpers/migration-live.ts"; - import { BunServices } from "@effect/platform-bun"; import { Cause, Effect, Exit, FileSystem, Path, Predicate } from "effect"; import { expect } from "vitest"; @@ -10,6 +8,7 @@ import { test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; +import { queryMigrationDb } from "../../../../tests/helpers/migration-live.ts"; test("applies a test-written migration to the remote database", ({ cliEffect, From f9e739af7beefc34de5372e52a8b1ec312f28c8d Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:52:12 +0530 Subject: [PATCH 3/3] test(cli): correct migration alias comment --- apps/cli/src/commands/migration/migration.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/commands/migration/migration.integration.test.ts b/apps/cli/src/commands/migration/migration.integration.test.ts index fbe8d31216..14bee8197a 100644 --- a/apps/cli/src/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/commands/migration/migration.integration.test.ts @@ -38,7 +38,7 @@ describe("migration command integration", () => { ); // No subcommand is proxied, so the plural alias is proven at the parser: // `migrations squash --nope` must fail with squash's own unknown-flag error, - // before the command's runtime layer ever builds. + // scoped to the squash subcommand rather than the root. return Effect.gen(function* () { const exit = yield* Command.runWith(testRoot, { version: "0.0.0-test" })([ "migrations",