diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 08cb85509a..cfdff142fa 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -100,7 +100,7 @@ describe("legacyRespondToComplete", () => { // `--debug ""` used to return zero candidates entirely: the leftover-args // computation counted `--debug` itself as "positional leftover," gating // out subcommand-name completion the way cobra never does for a - // persistent flag: `__complete --debug ''` lists all 36 root commands. + // persistent flag: `__complete --debug ''` lists all root commands. const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", ""]); expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("branches"); diff --git a/apps/cli/src/legacy/cli/root.ts b/apps/cli/src/legacy/cli/root.ts index db904dca84..784e9ca1a9 100644 --- a/apps/cli/src/legacy/cli/root.ts +++ b/apps/cli/src/legacy/cli/root.ts @@ -17,6 +17,8 @@ import { legacyLinkCommand } from "../commands/link/link.command.ts"; import { legacyLoginCommand } from "../commands/login/login.command.ts"; import { legacyLogoutCommand } from "../commands/logout/logout.command.ts"; import { legacyMigrationCommand } from "../commands/migration/migration.command.ts"; +import { legacyMigrationsCommand } from "../commands/migrations/migrations.command.ts"; +import { legacySchemaCommand } from "../commands/schema/schema.command.ts"; import { legacyNetworkBansCommand } from "../commands/network-bans/network-bans.command.ts"; import { legacyNetworkRestrictionsCommand } from "../commands/network-restrictions/network-restrictions.command.ts"; import { legacyOrgsCommand } from "../commands/orgs/orgs.command.ts"; @@ -78,11 +80,13 @@ export const legacyRoot = Command.make("supabase").pipe( legacyLoginCommand, legacyLogoutCommand, legacyMigrationCommand, + legacyMigrationsCommand, legacyNetworkBansCommand, legacyNetworkRestrictionsCommand, legacyOrgsCommand, legacyPostgresConfigCommand, legacyProjectsCommand, + legacySchemaCommand, legacySecretsCommand, legacySeedCommand, legacyServicesCommand, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts index e1b0190fa8..a1a7d8fa62 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts @@ -72,6 +72,8 @@ function setup() { Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })), ), ), + provisionDeclarative: () => + Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })), provisionPlan: (opts) => Effect.sync(() => { state.plan += 1; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts index 0cb80a0369..ac059d7ea7 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -338,6 +338,13 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( const input = buildNativeInput(opts, built, port); return yield* provisionMigrations(input, cacheOpts(opts, "config")); }).pipe(Effect.mapError(nextShadowError)), + provisionDeclarative: (opts) => + Effect.gen(function* () { + const port = yield* nextPort(); + const built = yield* buildNativeBase(opts); + const input = buildNativeInput(opts, built, port); + return yield* provisionDeclarative(input, cacheOpts(opts, "disabled")); + }).pipe(Effect.mapError(nextShadowError)), provisionPlan: (opts) => Effect.gen(function* () { const migrationsPort = yield* nextPort(); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts index ef9dcfa729..7328b90b77 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts @@ -41,6 +41,17 @@ interface LegacyPgDeltaNextShadowShape { LegacyDeclarativeShadowDbError, Scope.Scope >; + /** + * Provisions only the declarative next-engine shadow (platform baseline, no + * project migrations). Removed when the current Effect scope closes. + */ + readonly provisionDeclarative: ( + opts: LegacyPgDeltaNextShadowInput, + ) => Effect.Effect< + { readonly declarativeUrl: string }, + LegacyDeclarativeShadowDbError, + Scope.Scope + >; /** * Provisions the independent migrated and declarative shadows needed by a * declarative plan. Concurrency is strategy-driven (see diff --git a/apps/cli/src/legacy/commands/migration/migration.command.ts b/apps/cli/src/legacy/commands/migration/migration.command.ts index ff92238ecb..d095e97f93 100644 --- a/apps/cli/src/legacy/commands/migration/migration.command.ts +++ b/apps/cli/src/legacy/commands/migration/migration.command.ts @@ -10,7 +10,6 @@ import { legacyMigrationFetchCommand } from "./fetch/fetch.command.ts"; export const legacyMigrationCommand = Command.make("migration").pipe( Command.withDescription("Manage database migration scripts."), Command.withShortDescription("Manage database migration scripts"), - Command.withAlias("migrations"), Command.withSubcommands([ legacyMigrationListCommand, legacyMigrationNewCommand, diff --git a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts index dc67ea14fa..b26fc71456 100644 --- a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts @@ -5,37 +5,48 @@ import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; import { legacyMigrationCommand } from "./migration.command.ts"; +import { legacyMigrationsCommand } from "../migrations/migrations.command.ts"; // `withGlobalFlags` must come AFTER `withSubcommands` — see // `start.string-slice-flags.integration.test.ts`'s identical comment. const legacyTestRoot = Command.make("supabase").pipe( - Command.withSubcommands([legacyMigrationCommand]), + Command.withSubcommands([legacyMigrationCommand, legacyMigrationsCommand]), Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), ); -describe("legacy migration command integration", () => { - it.live("accepts the Go-compatible plural migrations alias", () => { - // After CLI-1969, `squash` is native and no `migration` subcommand is proxied - // any more — so the plural alias is now proven at the PARSER instead: a - // `migrations squash --nope` must fail with squash's own unknown-flag error, - // which never builds the command's `Command.provide` runtime layer. +describe("legacy migration and migrations commands", () => { + it.live("keeps singular migration as the Go-parity group", () => { const run = Effect.gen(function* () { const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "migrations", + "migration", "squash", "--nope", ]).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeJson = JSON.stringify(exit.cause); - // The alias resolved: the parse error is scoped to the squash LEAF, not the root. expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]'); - expect(causeJson).not.toContain('"subcommand":"migrations"'); } }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); - // Command.runWith's Environment type is retained even though this path only needs CliOutput - // at runtime. + return run as Effect.Effect; + }); + + it.live("routes plural migrations to the schema-first group", () => { + const run = Effect.gen(function* () { + const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "migrations", + "apply", + "--nope", + ]).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeJson = JSON.stringify(exit.cause); + expect(causeJson).toContain('"commandPath":["supabase","migrations","apply"]'); + expect(causeJson).not.toContain('"commandPath":["supabase","migration","apply"]'); + } + }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); + return run as Effect.Effect; }); }); diff --git a/apps/cli/src/legacy/commands/migrations/apply/apply.command.ts b/apps/cli/src/legacy/commands/migrations/apply/apply.command.ts new file mode 100644 index 0000000000..39ea1e01e9 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/apply/apply.command.ts @@ -0,0 +1,14 @@ +import { Command } from "effect/unstable/cli"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacyMigrationsApply } from "./apply.handler.ts"; + +export const legacyMigrationsApplyCommand = Command.make("apply").pipe( + Command.withDescription("Apply exact pending migration files to the local database."), + Command.withShortDescription("Apply pending migrations locally"), + Command.withHandler(() => + legacyMigrationsApply().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacySchemaRuntimeLayer(["migrations", "apply"])), +); diff --git a/apps/cli/src/legacy/commands/migrations/apply/apply.handler.ts b/apps/cli/src/legacy/commands/migrations/apply/apply.handler.ts new file mode 100644 index 0000000000..28e38d99d8 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/apply/apply.handler.ts @@ -0,0 +1,8 @@ +import { Effect } from "effect"; +import { applyMigrations } from "../../../../shared/migrations/apply-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; + +export const legacyMigrationsApply = Effect.fn("legacy.migrations.apply")(function* () { + const result = yield* applyMigrations(); + yield* renderSchemaResult("Apply migrations", result); +}); diff --git a/apps/cli/src/legacy/commands/migrations/diff/diff.command.ts b/apps/cli/src/legacy/commands/migrations/diff/diff.command.ts new file mode 100644 index 0000000000..a739693187 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/diff/diff.command.ts @@ -0,0 +1,39 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacyMigrationsDiff } from "./diff.handler.ts"; + +const config = { + against: Flag.string("against").pipe( + Flag.withDescription("Live database to compare: local, linked, or a connection string."), + Flag.optional, + ), + file: Flag.string("file").pipe( + Flag.withDescription("Write preview SQL to a file without applying it."), + Flag.withAlias("f"), + Flag.optional, + ), +} as const; + +export type LegacyMigrationsDiffFlags = CliCommand.Command.Config.Infer; + +export const legacyMigrationsDiffCommand = Command.make("diff", config).pipe( + Command.withDescription( + "Preview the SQL required to move from migration replay to a live database.\n\n" + + "This is the successor to db diff. It never mutates the database.", + ), + Command.withShortDescription("Diff migration replay against a live database"), + Command.withExamples([ + { command: "supabase migrations diff --against local", description: "Preview local drift" }, + { command: "supabase migrations diff --against linked", description: "Preview remote drift" }, + ]), + Command.withHandler((flags) => + legacyMigrationsDiff(flags).pipe( + withLegacyCommandInstrumentation({ flags, config, aliases: { f: "file" } }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["migrations", "diff"])), +); diff --git a/apps/cli/src/legacy/commands/migrations/diff/diff.handler.ts b/apps/cli/src/legacy/commands/migrations/diff/diff.handler.ts new file mode 100644 index 0000000000..cb32f6da58 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/diff/diff.handler.ts @@ -0,0 +1,14 @@ +import { Effect, Option } from "effect"; +import { diffMigrations } from "../../../../shared/migrations/diff-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacyMigrationsDiffFlags } from "./diff.command.ts"; + +export const legacyMigrationsDiff = Effect.fn("legacy.migrations.diff")(function* ( + flags: LegacyMigrationsDiffFlags, +) { + const result = yield* diffMigrations({ + against: Option.getOrUndefined(flags.against), + file: Option.getOrUndefined(flags.file), + }); + yield* renderSchemaResult("Diff migrations", result); +}); diff --git a/apps/cli/src/legacy/commands/migrations/list/list.command.ts b/apps/cli/src/legacy/commands/migrations/list/list.command.ts new file mode 100644 index 0000000000..ae381af49d --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/list/list.command.ts @@ -0,0 +1,27 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacyMigrationsList } from "./list.handler.ts"; + +const config = { + against: Flag.string("against").pipe( + Flag.withDescription("Target to compare: local, linked, or a connection string."), + Flag.optional, + ), +} as const; + +export type LegacyMigrationsListFlags = CliCommand.Command.Config.Infer; + +export const legacyMigrationsListCommand = Command.make("list", config).pipe( + Command.withDescription("Compare local migration files with target migration history."), + Command.withShortDescription("List local and remote migrations"), + Command.withHandler((flags) => + legacyMigrationsList(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["migrations", "list"])), +); diff --git a/apps/cli/src/legacy/commands/migrations/list/list.handler.ts b/apps/cli/src/legacy/commands/migrations/list/list.handler.ts new file mode 100644 index 0000000000..6b2369e2a3 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/list/list.handler.ts @@ -0,0 +1,11 @@ +import { Effect, Option } from "effect"; +import { listMigrations } from "../../../../shared/migrations/list-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacyMigrationsListFlags } from "./list.command.ts"; + +export const legacyMigrationsList = Effect.fn("legacy.migrations.list")(function* ( + flags: LegacyMigrationsListFlags, +) { + const result = yield* listMigrations({ against: Option.getOrUndefined(flags.against) }); + yield* renderSchemaResult("List migrations", result); +}); diff --git a/apps/cli/src/legacy/commands/migrations/migrations.command.ts b/apps/cli/src/legacy/commands/migrations/migrations.command.ts new file mode 100644 index 0000000000..4e63a2ad2b --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/migrations.command.ts @@ -0,0 +1,26 @@ +import { Command } from "effect/unstable/cli"; +import { SCHEMA_ECOSYSTEM_MAPPING_HELP } from "../../../shared/schema/schema-ecosystem.ts"; +import { legacyMigrationsApplyCommand } from "./apply/apply.command.ts"; +import { legacyMigrationsDiffCommand } from "./diff/diff.command.ts"; +import { legacyMigrationsListCommand } from "./list/list.command.ts"; +import { legacyMigrationsNewCommand } from "./new/new.command.ts"; +import { legacyMigrationsPullCommand } from "./pull/pull.command.ts"; +import { legacyMigrationsPushCommand } from "./push/push.command.ts"; + +export const legacyMigrationsCommand = Command.make("migrations").pipe( + Command.withDescription( + "Advanced file-and-history database workflow.\n\n" + + "These commands operate on supabase/migrations and do not load declarative SQL. " + + "migrations push is the only path that mutates a durable remote schema.\n\n" + + SCHEMA_ECOSYSTEM_MAPPING_HELP, + ), + Command.withShortDescription("Manage migration files and history"), + Command.withSubcommands([ + legacyMigrationsNewCommand, + legacyMigrationsListCommand, + legacyMigrationsDiffCommand, + legacyMigrationsApplyCommand, + legacyMigrationsPushCommand, + legacyMigrationsPullCommand, + ]), +); diff --git a/apps/cli/src/legacy/commands/migrations/new/new.command.ts b/apps/cli/src/legacy/commands/migrations/new/new.command.ts new file mode 100644 index 0000000000..abcb5fee25 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/new/new.command.ts @@ -0,0 +1,30 @@ +import { Argument, Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacyMigrationsNew } from "./new.handler.ts"; + +const config = { + name: Argument.string("name").pipe( + Argument.withDescription("Migration name."), + Argument.optional, + ), +} as const; + +export type LegacyMigrationsNewFlags = CliCommand.Command.Config.Infer; + +export const legacyMigrationsNewCommand = Command.make("new", config).pipe( + Command.withDescription("Create an empty migration file for manual authoring."), + Command.withShortDescription("Create an empty migration"), + Command.withExamples([ + { + command: "supabase migrations new add_custom_data", + description: "Create supabase/migrations/_add_custom_data.sql", + }, + ]), + Command.withHandler((flags) => + legacyMigrationsNew(flags).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacySchemaRuntimeLayer(["migrations", "new"])), +); diff --git a/apps/cli/src/legacy/commands/migrations/new/new.handler.ts b/apps/cli/src/legacy/commands/migrations/new/new.handler.ts new file mode 100644 index 0000000000..2781f25857 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/new/new.handler.ts @@ -0,0 +1,11 @@ +import { Effect, Option } from "effect"; +import { newMigration } from "../../../../shared/migrations/new-migration.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacyMigrationsNewFlags } from "./new.command.ts"; + +export const legacyMigrationsNew = Effect.fn("legacy.migrations.new")(function* ( + flags: LegacyMigrationsNewFlags, +) { + const result = yield* newMigration(Option.getOrUndefined(flags.name)); + yield* renderSchemaResult("Create migration", result); +}); diff --git a/apps/cli/src/legacy/commands/migrations/pull/pull.command.ts b/apps/cli/src/legacy/commands/migrations/pull/pull.command.ts new file mode 100644 index 0000000000..6dd68fdfc1 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/pull/pull.command.ts @@ -0,0 +1,34 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacyMigrationsPull } from "./pull.handler.ts"; + +const config = { + from: Flag.string("from").pipe( + Flag.withDescription("Remote database: linked or a connection string."), + Flag.optional, + ), + name: Flag.string("name").pipe( + Flag.withDescription("Name for the pulled migration file."), + Flag.optional, + ), +} as const; + +export type LegacyMigrationsPullFlags = CliCommand.Command.Config.Infer; + +export const legacyMigrationsPullCommand = Command.make("pull", config).pipe( + Command.withDescription( + "Record remote-only database state as local migration files.\n\n" + + "Does not interpret declarative SQL.", + ), + Command.withShortDescription("Pull remote schema drift into migrations"), + Command.withHandler((flags) => + legacyMigrationsPull(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["migrations", "pull"])), +); diff --git a/apps/cli/src/legacy/commands/migrations/pull/pull.handler.ts b/apps/cli/src/legacy/commands/migrations/pull/pull.handler.ts new file mode 100644 index 0000000000..c7a6fb2443 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/pull/pull.handler.ts @@ -0,0 +1,14 @@ +import { Effect, Option } from "effect"; +import { pullMigrations } from "../../../../shared/migrations/pull-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacyMigrationsPullFlags } from "./pull.command.ts"; + +export const legacyMigrationsPull = Effect.fn("legacy.migrations.pull")(function* ( + flags: LegacyMigrationsPullFlags, +) { + const result = yield* pullMigrations({ + from: Option.getOrUndefined(flags.from), + name: Option.getOrUndefined(flags.name), + }); + yield* renderSchemaResult("Pull remote migrations", result); +}); diff --git a/apps/cli/src/legacy/commands/migrations/push/push.command.ts b/apps/cli/src/legacy/commands/migrations/push/push.command.ts new file mode 100644 index 0000000000..6cb2bd6bbe --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/push/push.command.ts @@ -0,0 +1,45 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacyMigrationsPush } from "./push.handler.ts"; + +const config = { + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Answer ordinary prompts. Does not skip target identity or live verify."), + Flag.withAlias("y"), + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Must match the resolved linked project."), + Flag.optional, + ), + allowRemote: Flag.boolean("allow-remote").pipe( + Flag.withDescription("Acknowledge an unverifiable --db-url target."), + ), + dbUrl: Flag.string("db-url").pipe( + Flag.withDescription("Raw connection string. Requires --allow-remote."), + Flag.optional, + ), + skipVerify: Flag.boolean("skip-verify").pipe( + Flag.withDescription("Skip isolated-shadow declarations-ahead and remote-drift checks."), + ), +} as const; + +export type LegacyMigrationsPushFlags = CliCommand.Command.Config.Infer; + +export const legacyMigrationsPushCommand = Command.make("push", config).pipe( + Command.withDescription( + "Apply exact pending migration files to the linked platform database.\n\n" + + "This is the only CLI path that mutates durable remote schema. " + + "It fails closed when declarations are ahead of the migration head or remote drift is detected.", + ), + Command.withShortDescription("Push pending migrations to the platform"), + Command.withHandler((flags) => + legacyMigrationsPush(flags).pipe( + withLegacyCommandInstrumentation({ flags, config, aliases: { y: "yes" } }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["migrations", "push"])), +); diff --git a/apps/cli/src/legacy/commands/migrations/push/push.handler.ts b/apps/cli/src/legacy/commands/migrations/push/push.handler.ts new file mode 100644 index 0000000000..f037fd1915 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/push/push.handler.ts @@ -0,0 +1,17 @@ +import { Effect, Option } from "effect"; +import { pushMigrations } from "../../../../shared/migrations/push-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacyMigrationsPushFlags } from "./push.command.ts"; + +export const legacyMigrationsPush = Effect.fn("legacy.migrations.push")(function* ( + flags: LegacyMigrationsPushFlags, +) { + const result = yield* pushMigrations({ + yes: flags.yes, + projectRef: Option.getOrUndefined(flags.projectRef), + allowRemote: flags.allowRemote, + dbUrl: Option.getOrUndefined(flags.dbUrl), + skipVerify: flags.skipVerify, + }); + yield* renderSchemaResult("Push migrations", result); +}); diff --git a/apps/cli/src/legacy/commands/schema/apply/apply.command.ts b/apps/cli/src/legacy/commands/schema/apply/apply.command.ts new file mode 100644 index 0000000000..67a2aaa869 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/apply/apply.command.ts @@ -0,0 +1,42 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacySchemaApply } from "./apply.handler.ts"; + +const config = { + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Answer ordinary prompts. Does not skip target identity."), + Flag.withAlias("y"), + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Explicit project ref assertion for durable targets."), + Flag.optional, + ), + allowRemote: Flag.boolean("allow-remote").pipe( + Flag.withDescription("Acknowledge an unverifiable connection-string target."), + ), +} as const; + +export type LegacySchemaApplyFlags = CliCommand.Command.Config.Infer; + +export const legacySchemaApplyCommand = Command.make("apply", config).pipe( + Command.withDescription( + "Reconcile the local disposable database to supabase/schemas.\n\n" + + "Applies a journaled pg-delta plan without creating migration files. " + + "Modeled hazards are auto-applied on verified-disposable local targets. " + + "Ambiguous renames and coverage gaps still fail closed.", + ), + Command.withShortDescription("Apply declarations to the local database"), + Command.withExamples([ + { command: "supabase schema apply", description: "Apply declarations to the local database" }, + ]), + Command.withHandler((flags) => + legacySchemaApply(flags).pipe( + withLegacyCommandInstrumentation({ flags, config, aliases: { y: "yes" } }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["schema", "apply"])), +); diff --git a/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts b/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts new file mode 100644 index 0000000000..7e2af28773 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts @@ -0,0 +1,15 @@ +import { Effect, Option } from "effect"; +import { applySchema } from "../../../../shared/schema/apply-schema.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacySchemaApplyFlags } from "./apply.command.ts"; + +export const legacySchemaApply = Effect.fn("legacy.schema.apply")(function* ( + flags: LegacySchemaApplyFlags, +) { + const result = yield* applySchema({ + yes: flags.yes, + projectRef: Option.getOrUndefined(flags.projectRef), + allowRemote: flags.allowRemote, + }); + yield* renderSchemaResult("Apply declarative schema", result); +}); diff --git a/apps/cli/src/legacy/commands/schema/generate/generate.command.ts b/apps/cli/src/legacy/commands/schema/generate/generate.command.ts new file mode 100644 index 0000000000..7fcf991ec5 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/generate/generate.command.ts @@ -0,0 +1,50 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacySchemaGenerate } from "./generate.handler.ts"; + +const config = { + name: Flag.string("name").pipe( + Flag.withDescription("Name for the generated migration change set."), + Flag.optional, + ), + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Preview the plan without writing migration files."), + ), + baseline: Flag.boolean("baseline").pipe( + Flag.withDescription( + "Generate a baseline migration from an empty replay (existing-database onboarding). Refuses if migration files already exist.", + ), + ), +} as const; + +export type LegacySchemaGenerateFlags = CliCommand.Command.Config.Infer; + +export const legacySchemaGenerateCommand = Command.make("generate", config).pipe( + Command.withDescription( + "Compile declarative schema changes into verified migration files.\n\n" + + "Always plans from a clean migration replay to the declarations. " + + "--dry-run runs the same pipeline and writes nothing.", + ), + Command.withShortDescription("Generate migrations from supabase/schemas"), + Command.withExamples([ + { command: "supabase schema generate --dry-run", description: "Preview the generate plan" }, + { + command: "supabase schema generate --name add_billing", + description: "Write the migration change set", + }, + { + command: "supabase schema generate --baseline --name initial_schema", + description: "Create a baseline migration for an existing database", + }, + ]), + Command.withHandler((flags) => + legacySchemaGenerate(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["schema", "generate"])), +); diff --git a/apps/cli/src/legacy/commands/schema/generate/generate.handler.ts b/apps/cli/src/legacy/commands/schema/generate/generate.handler.ts new file mode 100644 index 0000000000..7f0d024e5e --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/generate/generate.handler.ts @@ -0,0 +1,15 @@ +import { Effect, Option } from "effect"; +import { generateSchema } from "../../../../shared/schema/generate-schema.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacySchemaGenerateFlags } from "./generate.command.ts"; + +export const legacySchemaGenerate = Effect.fn("legacy.schema.generate")(function* ( + flags: LegacySchemaGenerateFlags, +) { + const result = yield* generateSchema({ + name: Option.getOrUndefined(flags.name), + dryRun: flags.dryRun, + baseline: flags.baseline, + }); + yield* renderSchemaResult("Generate schema migrations", result); +}); diff --git a/apps/cli/src/legacy/commands/schema/pull/pull.command.ts b/apps/cli/src/legacy/commands/schema/pull/pull.command.ts new file mode 100644 index 0000000000..01bd76d875 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/pull/pull.command.ts @@ -0,0 +1,63 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { + SCHEMA_ECOSYSTEM_MAPPING_HELP, + SCHEMA_PULL_NO_MERGE_HELP, +} from "../../../../shared/schema/schema-ecosystem.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacySchemaRuntimeLayer } from "../../../schema/legacy-schema-runtime.layer.ts"; +import { legacySchemaPull } from "./pull.handler.ts"; + +const config = { + from: Flag.string("from").pipe( + Flag.withDescription("Source database: local, linked, or a connection string."), + Flag.optional, + ), + output: Flag.string("output").pipe( + Flag.withDescription("Write a side-by-side snapshot instead of replacing supabase/schemas."), + Flag.optional, + ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Replace the complete managed declaration tree. This is not a merge."), + ), + pruneUnmanaged: Flag.boolean("prune-unmanaged").pipe( + Flag.withDescription( + "Delete unmanaged .sql files that are not owned by the export or _custom/.", + ), + ), +} as const; + +export type LegacySchemaPullFlags = CliCommand.Command.Config.Infer; + +export const legacySchemaPullCommand = Command.make("pull", config).pipe( + Command.withDescription( + "Introspect a database into declarative SQL files.\n\n" + + "The database is authoritative. Pull regenerates managed files and never merges SQL. " + + "_custom/ is never modified.\n\n" + + `${SCHEMA_PULL_NO_MERGE_HELP}\n\n${SCHEMA_ECOSYSTEM_MAPPING_HELP}`, + ), + Command.withShortDescription("Pull a database into supabase/schemas"), + Command.withExamples([ + { command: "supabase schema pull --from local", description: "Export the local database" }, + { + command: "supabase schema pull --from linked", + description: "Export the linked project database", + }, + { + command: "supabase schema pull --from linked --output supabase/schemas.remote", + description: "Write a side-by-side remote snapshot", + }, + { + command: "supabase schema pull --from linked --force", + description: "Replace the managed schema tree", + }, + ]), + Command.withHandler((flags) => + legacySchemaPull(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySchemaRuntimeLayer(["schema", "pull"])), +); diff --git a/apps/cli/src/legacy/commands/schema/pull/pull.handler.ts b/apps/cli/src/legacy/commands/schema/pull/pull.handler.ts new file mode 100644 index 0000000000..511872de43 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/pull/pull.handler.ts @@ -0,0 +1,16 @@ +import { Effect, Option } from "effect"; +import { pullSchema } from "../../../../shared/schema/pull-schema.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { LegacySchemaPullFlags } from "./pull.command.ts"; + +export const legacySchemaPull = Effect.fn("legacy.schema.pull")(function* ( + flags: LegacySchemaPullFlags, +) { + const result = yield* pullSchema({ + from: Option.getOrUndefined(flags.from), + output: Option.getOrUndefined(flags.output), + force: flags.force, + pruneUnmanaged: flags.pruneUnmanaged, + }); + yield* renderSchemaResult("Pull declarative schema", result); +}); diff --git a/apps/cli/src/legacy/commands/schema/schema.command.ts b/apps/cli/src/legacy/commands/schema/schema.command.ts new file mode 100644 index 0000000000..48b9555daa --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/schema.command.ts @@ -0,0 +1,20 @@ +import { Command } from "effect/unstable/cli"; +import { SCHEMA_ECOSYSTEM_MAPPING_HELP } from "../../../shared/schema/schema-ecosystem.ts"; +import { legacySchemaApplyCommand } from "./apply/apply.command.ts"; +import { legacySchemaGenerateCommand } from "./generate/generate.command.ts"; +import { legacySchemaPullCommand } from "./pull/pull.command.ts"; + +export const legacySchemaCommand = Command.make("schema").pipe( + Command.withDescription( + "Manage database shape from declarative SQL in supabase/schemas.\n\n" + + "schema apply is the local edit/test loop. schema generate writes reviewable migrations. " + + "Durable remotes change only through migrations push.\n\n" + + SCHEMA_ECOSYSTEM_MAPPING_HELP, + ), + Command.withShortDescription("Declarative database schema workflow"), + Command.withSubcommands([ + legacySchemaPullCommand, + legacySchemaGenerateCommand, + legacySchemaApplyCommand, + ]), +); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index d3648e8ad7..5496c6d622 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -56,11 +56,13 @@ export const LEGACY_DOCS_TAGS: Readonly>> = "supabase-login": ["local-dev"], "supabase-logout": ["local-dev"], "supabase-migration": ["local-dev"], + "supabase-migrations": ["local-dev"], "supabase-network-bans": ["management-api"], "supabase-network-restrictions": ["management-api"], "supabase-orgs": ["management-api"], "supabase-postgres-config": ["management-api"], "supabase-projects": ["management-api"], + "supabase-schema": ["local-dev"], "supabase-secrets": ["management-api"], "supabase-seed": ["local-dev"], "supabase-services": ["local-dev"], diff --git a/apps/cli/src/legacy/schema/legacy-docker-isolated-shadow.layer.ts b/apps/cli/src/legacy/schema/legacy-docker-isolated-shadow.layer.ts new file mode 100644 index 0000000000..43aedd1c1a --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-docker-isolated-shadow.layer.ts @@ -0,0 +1,71 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { IsolatedShadowProvisioner } from "../../shared/schema/isolated-shadow.service.ts"; +import { SchemaEngineError } from "../../shared/schema/schema-errors.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyReadDbToml } from "../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolvePgDeltaProjectId, + type LegacyPgDeltaContext, +} from "../shared/legacy-pgdelta.ts"; +import { LegacyPgDeltaNextShadow } from "../commands/db/shared/legacy-pgdelta-next-shadow.service.ts"; +import type { LegacyPgDeltaNextShadowInput } from "../commands/db/shared/legacy-pgdelta-next-shadow.service.ts"; +import type { LegacyDeclarativeShadowDbError } from "../commands/db/shared/legacy-pgdelta.errors.ts"; + +const toEngineError = (error: LegacyDeclarativeShadowDbError) => + new SchemaEngineError({ + detail: error.message, + suggestion: + error.suggestion ?? + (error.docker === "daemon" + ? "Start Docker Desktop or Podman, then retry." + : "Retry the command. If it persists, delete ~/.supabase/cache/shadow-baseline and rerun."), + }); + +const tomlError = (cause: unknown) => + new SchemaEngineError({ + detail: cause instanceof Error ? cause.message : String(cause), + suggestion: "Fix supabase/config.toml, then retry.", + }); + +export const legacyDockerIsolatedShadowLayer = Layer.effect( + IsolatedShadowProvisioner, + Effect.gen(function* () { + const shadows = yield* LegacyPgDeltaNextShadow; + const config = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const shadowInput = (): Effect.Effect => + Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, config.workdir, undefined, { + validate: false, + warnOnUnresolvedEnv: false, + }).pipe(Effect.mapError(tomlError)); + const context: LegacyPgDeltaContext = { + projectId: legacyResolvePgDeltaProjectId(config.projectId, toml, config.workdir), + cwd: config.workdir, + npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), + denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, + }; + return { context, toml } satisfies LegacyPgDeltaNextShadowInput; + }); + + return IsolatedShadowProvisioner.of({ + provision: Effect.gen(function* () { + const opts = yield* shadowInput(); + const shadow = yield* shadows + .provisionDeclarative(opts) + .pipe(Effect.mapError(toEngineError)); + return { url: shadow.declarativeUrl }; + }), + provisionMigrations: Effect.gen(function* () { + const opts = yield* shadowInput(); + const shadow = yield* shadows + .provisionMigrations(opts) + .pipe(Effect.mapError(toEngineError)); + return { url: shadow.migrationsUrl }; + }), + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-docker-local-database.layer.ts b/apps/cli/src/legacy/schema/legacy-docker-local-database.layer.ts new file mode 100644 index 0000000000..d8c799888f --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-docker-local-database.layer.ts @@ -0,0 +1,140 @@ +import { isDockerDaemonDownMessage } from "@supabase/stack/effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { DatabaseTarget } from "../../shared/database/database-target.ts"; +import { LocalDatabaseFallback } from "../../shared/database/local-database-fallback.service.ts"; +import { + localPostgresConnectionString, + publishedPostgresHostPort, +} from "../../shared/database/local-postgres-url.ts"; +import { SchemaLocalStackNotRunningError } from "../../shared/schema/schema-errors.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { + legacyCollectText, + legacyDescribeContainerCliFailure, + legacyIsContainerNotFoundMessage, + spawnContainerCli, +} from "../shared/legacy-container-cli.ts"; +import { legacyReadDbToml } from "../shared/legacy-db-config.toml-read.ts"; +import { legacyResolveLocalProjectId, localDbContainerId } from "../shared/legacy-docker-ids.ts"; +import { legacyGetHostname } from "../shared/legacy-hostname.ts"; + +type Spawner = ChildProcessSpawner.ChildProcessSpawner["Service"]; + +const inspectPublishedPostgresPort = (spawner: Spawner, containerId: string) => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* spawnContainerCli( + spawner, + ["container", "inspect", containerId, "--format", "{{json .NetworkSettings.Ports}}"], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ).pipe( + Effect.map(Option.some), + Effect.catchTag("LegacyContainerRuntimeNotFoundError", () => Effect.succeed(Option.none())), + Effect.mapError((cause) => { + const description = legacyDescribeContainerCliFailure(cause); + return new SchemaLocalStackNotRunningError({ + detail: `failed to inspect local database container: ${description}`, + suggestion: isDockerDaemonDownMessage(description) + ? "Start Docker Desktop or Podman, then run `supabase start`." + : "Run `supabase start`, then retry.", + }); + }), + ); + if (Option.isNone(spawned)) { + return Option.none(); + } + const child = spawned.value; + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + legacyCollectText(child.stdout), + legacyCollectText(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new SchemaLocalStackNotRunningError({ + detail: "failed to inspect local database container", + suggestion: "Run `supabase start`, then retry.", + }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + if (legacyIsContainerNotFoundMessage(message)) { + return Option.none(); + } + return yield* new SchemaLocalStackNotRunningError({ + detail: + message.length > 0 + ? `failed to inspect local database container: ${message}` + : "failed to inspect local database container", + suggestion: isDockerDaemonDownMessage(message) + ? "Start Docker Desktop or Podman, then run `supabase start`." + : "Run `supabase start`, then retry.", + }); + } + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim() || "null"); + } catch { + return yield* new SchemaLocalStackNotRunningError({ + detail: "failed to parse local database container port map", + suggestion: "Run `supabase start`, then retry.", + }); + } + const port = publishedPostgresHostPort(parsed); + if (port === undefined) { + return yield* new SchemaLocalStackNotRunningError({ + detail: `local database container ${containerId} does not publish 5432/tcp`, + suggestion: "Run `supabase start`, then retry.", + }); + } + return Option.some(port); + }), + ); + +export const legacyDockerLocalDatabaseFallbackLayer = Layer.effect( + LocalDatabaseFallback, + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return LocalDatabaseFallback.of({ + resolve: Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, config.workdir, undefined, { + validate: false, + warnOnUnresolvedEnv: false, + }).pipe(Effect.orElseSucceed(() => undefined)); + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(config.projectId), + toml === undefined ? undefined : Option.getOrUndefined(toml.projectId), + config.workdir, + ); + const published = yield* inspectPublishedPostgresPort( + spawner, + localDbContainerId(projectId), + ); + if (Option.isNone(published)) { + return Option.none(); + } + return Option.some({ + kind: "local", + identity: "local:default", + connectionString: localPostgresConnectionString( + published.value, + toml?.password ?? "postgres", + legacyGetHostname(), + ), + disposable: true, + durable: false, + connectionVerified: true, + } satisfies DatabaseTarget); + }), + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-linked-remote-connector.layer.ts b/apps/cli/src/legacy/schema/legacy-linked-remote-connector.layer.ts new file mode 100644 index 0000000000..24d043ddf3 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-linked-remote-connector.layer.ts @@ -0,0 +1,52 @@ +import { Effect, Layer, Option } from "effect"; +import { LinkedRemoteConnector } from "../../shared/database/linked-remote-connector.service.ts"; +import { SchemaLinkedConnectionError } from "../../shared/schema/schema-errors.ts"; +import { LegacyDnsResolverFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyBuildConnectionUrl } from "../shared/legacy-db-connection.sql-pg.layer.ts"; +import { LegacyDbConfigResolver } from "../shared/legacy-db-config.service.ts"; + +function toLinkedConnectionError(error: unknown): SchemaLinkedConnectionError { + const detail = + error !== null && + typeof error === "object" && + "message" in error && + typeof error.message === "string" && + error.message.length > 0 + ? error.message + : "Failed to connect to the linked project."; + const suggestion = + error !== null && + typeof error === "object" && + "suggestion" in error && + typeof error.suggestion === "string" && + error.suggestion.length > 0 + ? error.suggestion + : "Run `supabase link`, or pass --db-url with a connection string."; + return new SchemaLinkedConnectionError({ detail, suggestion }); +} + +export const legacyLinkedRemoteConnectorLayer = Layer.effect( + LinkedRemoteConnector, + Effect.gen(function* () { + const resolver = yield* LegacyDbConfigResolver; + const dnsFlag = yield* Effect.serviceOption(LegacyDnsResolverFlag); + const dnsResolver = Option.getOrUndefined(dnsFlag) ?? "native"; + + return LinkedRemoteConnector.of({ + connect: (projectRef) => + resolver + .resolve({ + dbUrl: Option.none(), + connType: "linked", + dnsResolver, + linkedProjectRef: Option.some(projectRef), + }) + .pipe( + Effect.map((resolved) => + legacyBuildConnectionUrl(resolved.conn, resolved.conn.host, resolved.conn.port), + ), + Effect.mapError(toLinkedConnectionError), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-migration-repository.layer.ts b/apps/cli/src/legacy/schema/legacy-migration-repository.layer.ts new file mode 100644 index 0000000000..dde992a090 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-migration-repository.layer.ts @@ -0,0 +1,196 @@ +import { Clock, Effect, FileSystem, Layer, Path } from "effect"; +import { Output } from "../../shared/output/output.service.ts"; +import { + SchemaMigrationNameError, + SchemaWorkspaceIoError, +} from "../../shared/schema/schema-errors.ts"; +import { MIGRATION_NO_TRANSACTION_DIRECTIVE } from "../../shared/schema/schema-paths.ts"; +import { SchemaWorkspace } from "../../shared/schema/schema-workspace.service.ts"; +import type { MigrationFile } from "../../shared/migrations/migration-file.ts"; +import { MigrationRepository } from "../../shared/migrations/migration-repository.service.ts"; +import { + legacyFormatMigrationTimestamp, + legacyGetMigrationPath, + legacyParseMigrationContent, +} from "../shared/legacy-migration-file.ts"; +import { + legacyListLocalMigrationPaths, + MIGRATE_FILE_PATTERN, +} from "../shared/legacy-migration-history.ts"; + +const NAME_PATTERN = /^[A-Za-z0-9_-]+$/u; +const MAX_VERSION_COLLISION_ATTEMPTS = 100; + +const ioError = (detail: string) => + new SchemaWorkspaceIoError({ + detail, + suggestion: "Check permissions on supabase/migrations and retry.", + }); + +export const legacyMigrationRepositoryLayer = Layer.effect( + MigrationRepository, + Effect.gen(function* () { + const workspace = yield* SchemaWorkspace; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + const workdir = path.dirname(path.dirname(workspace.migrationsDir)); + + const readLocal = Effect.gen(function* () { + const paths = yield* legacyListLocalMigrationPaths(fs, path, workspace.migrationsDir).pipe( + Effect.provideService(Output, output), + Effect.mapError((error) => ioError(error.message)), + ); + const files: Array = []; + for (const absolutePath of paths) { + const fileName = path.basename(absolutePath); + const parsed = MIGRATE_FILE_PATTERN.exec(fileName); + const version = parsed?.[1]; + const name = parsed?.[2]; + if (version === undefined || name === undefined) continue; + const content = yield* fs + .readFileString(absolutePath) + .pipe( + Effect.mapError((error) => ioError(`Failed to read ${fileName}: ${error.message}`)), + ); + files.push({ + version, + name, + fileName, + absolutePath, + content, + transactional: legacyParseMigrationContent(content).transactionMode === "transactional", + }); + } + return files.sort((left, right) => left.version.localeCompare(right.version)); + }); + + const assertName = (name: string) => { + if (!NAME_PATTERN.test(name)) { + return Effect.fail( + new SchemaMigrationNameError({ + detail: `Invalid migration name "${name}".`, + suggestion: "Use only letters, numbers, underscores, and hyphens.", + }), + ); + } + return Effect.void; + }; + + const assertInsideMigrations = (absolutePath: string, name: string) => { + if (!absolutePath.startsWith(workspace.migrationsDir + path.sep)) { + return Effect.fail( + new SchemaMigrationNameError({ + detail: `Migration name "${name}" escapes supabase/migrations.`, + suggestion: "Use a simple identifier without path separators.", + }), + ); + } + return Effect.void; + }; + + return MigrationRepository.of({ + listLocal: readLocal, + createEmpty: (name, content = "") => + Effect.gen(function* () { + yield* assertName(name); + const version = legacyFormatMigrationTimestamp(yield* Clock.currentTimeMillis); + const absolutePath = legacyGetMigrationPath(path, workdir, version, name); + const fileName = path.basename(absolutePath); + yield* assertInsideMigrations(absolutePath, name); + yield* fs + .makeDirectory(workspace.migrationsDir, { recursive: true }) + .pipe(Effect.mapError((error) => ioError(error.message))); + yield* fs + .writeFileString(absolutePath, content) + .pipe(Effect.mapError((error) => ioError(error.message))); + return { + version, + name, + fileName, + absolutePath, + content, + transactional: true, + } satisfies MigrationFile; + }), + writeGenerated: (input) => + Effect.gen(function* () { + yield* assertName(input.name); + yield* fs + .makeDirectory(workspace.migrationsDir, { recursive: true }) + .pipe(Effect.mapError((error) => ioError(error.message))); + const existing = yield* readLocal; + const usedVersions = new Set(existing.map((file) => file.version)); + const unitName = (suffix: string | null) => + suffix !== null && suffix !== "" ? `${input.name}_${suffix}` : input.name; + + const build = (baseMillis: number) => + input.files.map((file, index) => { + const version = legacyFormatMigrationTimestamp(baseMillis + index * 1000); + const name = unitName(file.suffix); + const absolutePath = legacyGetMigrationPath(path, workdir, version, name); + return { + version, + name, + fileName: path.basename(absolutePath), + absolutePath, + body: file.transactional + ? file.sql + : `${MIGRATION_NO_TRANSACTION_DIRECTIVE}\n${file.sql}`, + transactional: file.transactional, + }; + }); + + let planned = build(input.baseMillis); + for (let attempt = 0; attempt < MAX_VERSION_COLLISION_ATTEMPTS; attempt++) { + if (!planned.some((file) => usedVersions.has(file.version))) break; + planned = build(input.baseMillis + (attempt + 1) * 1000); + } + if (planned.some((file) => usedVersions.has(file.version))) { + return yield* new SchemaMigrationNameError({ + detail: "Could not allocate unique migration versions.", + suggestion: "Retry schema generate in a moment.", + }); + } + + const written: Array = []; + for (const file of planned) { + yield* assertInsideMigrations(file.absolutePath, file.name); + yield* fs.writeFileString(file.absolutePath, file.body).pipe( + Effect.mapError((error) => + ioError(`Failed to write ${file.fileName}: ${error.message}`), + ), + Effect.tapError(() => + Effect.forEach(written, (created) => + fs.remove(created.absolutePath).pipe(Effect.ignore), + ), + ), + ); + written.push({ + version: file.version, + name: file.name, + fileName: file.fileName, + absolutePath: file.absolutePath, + content: file.body, + transactional: file.transactional, + }); + } + return written; + }), + remove: (files) => + Effect.gen(function* () { + for (const file of files) { + yield* fs + .remove(file.absolutePath) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.void + : Effect.fail(ioError(`Failed to remove ${file.fileName}: ${error.message}`)), + ), + ); + } + }), + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-migration-runner.layer.integration.test.ts b/apps/cli/src/legacy/schema/legacy-migration-runner.layer.integration.test.ts new file mode 100644 index 0000000000..b4b9b6e1e6 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-migration-runner.layer.integration.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import type { Pool, PoolClient } from "pg"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { MigrationRunner } from "../../shared/migrations/migration-runner.service.ts"; +import { legacyMigrationRunnerLayer } from "./legacy-migration-runner.layer.ts"; + +const local = { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, +}; + +function historyPool(versions: ReadonlyArray): Pool { + const client = { + query: async (sql: string) => { + if (sql.includes("SELECT version")) { + return { rows: versions.map((version) => ({ version, name: "" })) }; + } + return { rows: [] }; + }, + release: () => undefined, + }; + return { + connect: async () => client as PoolClient, + options: { connectionString: "postgresql://postgres:postgres@127.0.0.1:1/postgres" }, + } as Pool; +} + +describe("legacyMigrationRunnerLayer", () => { + it.live("is a no-op when remote is strictly ahead and nothing is pending", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const runner = yield* MigrationRunner; + const result = yield* runner.applyPending(historyPool(["19990101000000", local.version]), [ + local, + ]); + expect(result.applied).toEqual([]); + expect(result.skipped).toEqual([local.version]); + }).pipe( + Effect.provide(legacyMigrationRunnerLayer), + Effect.provide(out.layer), + Effect.provide(BunServices.layer), + ); + }); + + it.live("conflicts when remote-only versions and pending files both exist", () => { + const out = mockOutput({ interactive: false }); + const pending = { ...local, version: "20260101000001", fileName: "20260101000001_next.sql" }; + return Effect.gen(function* () { + const runner = yield* MigrationRunner; + const exit = yield* runner + .applyPending(historyPool(["19990101000000"]), [pending]) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe( + Effect.provide(legacyMigrationRunnerLayer), + Effect.provide(out.layer), + Effect.provide(BunServices.layer), + ); + }); +}); diff --git a/apps/cli/src/legacy/schema/legacy-migration-runner.layer.ts b/apps/cli/src/legacy/schema/legacy-migration-runner.layer.ts new file mode 100644 index 0000000000..310a1af753 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-migration-runner.layer.ts @@ -0,0 +1,215 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import type { Pool, PoolClient } from "pg"; +import { Output } from "../../shared/output/output.service.ts"; +import { + SchemaEngineError, + SchemaHistoryConflictError, +} from "../../shared/schema/schema-errors.ts"; +import { formatHistoryConflict } from "../../shared/migrations/migration-repair-suggest.ts"; +import { + MigrationRunner, + type MigrationApplyResult, + type MigrationHistoryRow, +} from "../../shared/migrations/migration-runner.service.ts"; +import { LegacyDbConnectError, LegacyDbExecError } from "../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../shared/legacy-db-connection.service.ts"; +import { legacyApplyMigrations } from "../shared/legacy-migration-apply.ts"; +import { + INSERT_MIGRATION_VERSION, + legacyCreateMigrationTable, + legacyListRemoteMigrations, +} from "../shared/legacy-migration-history.ts"; + +const engineError = (detail: string) => + new SchemaEngineError({ + detail, + suggestion: "Check the database connection and migration SQL, then retry.", + }); + +const postgresErrorCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null || !("code" in cause)) return undefined; + const { code } = cause; + return typeof code === "string" ? code : undefined; +}; + +const toExecError = (cause: unknown, statementIndex?: number) => + new LegacyDbExecError({ + message: cause instanceof Error ? cause.message : String(cause), + ...(postgresErrorCode(cause) !== undefined ? { code: postgresErrorCode(cause) } : {}), + ...(statementIndex !== undefined ? { statementIndex } : {}), + }); + +const sessionFromClient = (client: Pick): LegacyDbSession => ({ + exec: (sql) => + Effect.tryPromise({ + try: async () => { + await client.query(sql); + }, + catch: (cause) => toExecError(cause), + }), + execBatch: (statements) => + Effect.gen(function* () { + const run = (sql: string, params?: ReadonlyArray, statementIndex?: number) => + Effect.tryPromise({ + try: async () => { + if (params === undefined) { + await client.query(sql); + return; + } + await client.query(sql, [...params]); + }, + catch: (cause) => toExecError(cause, statementIndex), + }); + yield* run("BEGIN"); + yield* Effect.gen(function* () { + for (const [index, statement] of statements.entries()) { + yield* run(statement.sql, statement.params, index); + } + yield* run("COMMIT"); + }).pipe(Effect.tapError(() => run("ROLLBACK").pipe(Effect.ignore))); + }), + query: (sql, params) => + Effect.tryPromise({ + try: async () => { + const result = + params === undefined + ? await client.query>(sql) + : await client.query>(sql, [...params]); + return result.rows; + }, + catch: (cause) => toExecError(cause), + }), + extensionExists: () => Effect.die("legacy migration runner does not query extensions"), + copyToCsv: () => Effect.die("legacy migration runner does not copy CSV"), + queryRaw: () => Effect.die("legacy migration runner does not query raw rows"), +}); + +const withSession = ( + pool: Pool, + body: (session: LegacyDbSession) => Effect.Effect, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const client = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => pool.connect(), + catch: (cause) => engineError(cause instanceof Error ? cause.message : String(cause)), + }), + (held) => Effect.sync(() => held.release()), + ); + return yield* body(sessionFromClient(client)); + }), + ); + +const LIST_HISTORY = + "SELECT version, coalesce(name, '') AS name FROM supabase_migrations.schema_migrations ORDER BY version"; +const LIST_HISTORY_VERSION_ONLY = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; + +const isMissingMigrationHistory = (error: LegacyDbExecError): boolean => { + if (error.code === "3F000" || error.code === "42P01") return true; + return ( + /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message) + ); +}; + +const isMissingNameColumn = (error: LegacyDbExecError): boolean => + /column ["']?name["']? does not exist/iu.test(error.message); + +const mapConnectError = (error: SchemaEngineError | LegacyDbConnectError | LegacyDbExecError) => + error instanceof SchemaEngineError ? error : engineError(error.message); + +const listHistory = ( + session: LegacyDbSession, +): Effect.Effect, SchemaEngineError> => + session.query(LIST_HISTORY).pipe( + Effect.map((rows) => + rows.map((row) => ({ + version: String(row["version"] ?? ""), + name: String(row["name"] ?? ""), + })), + ), + Effect.catch((error: LegacyDbExecError) => { + if (isMissingMigrationHistory(error)) return Effect.succeed([]); + if (isMissingNameColumn(error)) { + return session + .query(LIST_HISTORY_VERSION_ONLY) + .pipe( + Effect.map((rows) => + rows.map((row) => ({ version: String(row["version"] ?? ""), name: "" })), + ), + ); + } + return Effect.fail(error); + }), + Effect.mapError(mapConnectError), + ); + +export const legacyMigrationRunnerLayer = Layer.effect( + MigrationRunner, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + + const listRemote = (pool: Pool) => withSession(pool, listHistory); + + return MigrationRunner.of({ + listRemote, + applyPending: (pool, local) => + withSession(pool, (session) => + Effect.gen(function* () { + const remote = yield* listHistory(session); + const remoteVersions = new Set(remote.map((row) => row.version)); + const pendingFiles = local.filter((file) => !remoteVersions.has(file.version)); + const remoteOnly = remote + .filter((row) => !local.some((file) => file.version === row.version)) + .map((row) => row.version); + if (remoteOnly.length > 0 && pendingFiles.length > 0) { + return yield* new SchemaHistoryConflictError( + formatHistoryConflict({ + remoteOnly, + pending: pendingFiles.map((file) => file.version), + }), + ); + } + yield* legacyApplyMigrations( + session, + fs, + path, + pendingFiles.map((file) => file.absolutePath), + (message) => + engineError( + message.startsWith("Failed applying") + ? message + : `Failed applying migration: ${message}`, + ), + ).pipe(Effect.provideService(Output, output), Effect.mapError(mapConnectError)); + return { + applied: pendingFiles.map((file) => file.version), + skipped: local + .filter((file) => remoteVersions.has(file.version)) + .map((file) => file.version), + } satisfies MigrationApplyResult; + }), + ), + markApplied: (pool, files) => + withSession(pool, (session) => + Effect.gen(function* () { + yield* legacyCreateMigrationTable(session).pipe(Effect.mapError(mapConnectError)); + const remote = yield* legacyListRemoteMigrations(session).pipe( + Effect.mapError((error) => engineError(error.message)), + ); + const present = new Set(remote); + for (const file of files) { + if (present.has(file.version)) continue; + yield* session + .query(INSERT_MIGRATION_VERSION, [file.version, file.name, [file.content]]) + .pipe(Effect.mapError(mapConnectError)); + } + }), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-schema-database-target.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-database-target.layer.ts new file mode 100644 index 0000000000..fbdcbb0730 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-schema-database-target.layer.ts @@ -0,0 +1,104 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { DatabaseTargetResolver } from "../../shared/database/database-target.service.ts"; +import { envDatabaseUrl, type DatabaseTarget } from "../../shared/database/database-target.ts"; +import { LinkedRemoteConnector } from "../../shared/database/linked-remote-connector.service.ts"; +import { LocalDatabaseFallback } from "../../shared/database/local-database-fallback.service.ts"; +import { + SchemaLinkedConnectionError, + SchemaLocalStackNotRunningError, +} from "../../shared/schema/schema-errors.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { PROJECT_REF_PATTERN } from "../config/legacy-project-ref.service.ts"; +import { legacyReadProjectRefFile } from "../shared/legacy-temp-paths.ts"; + +export const legacySchemaDatabaseTargetLayer = Layer.effect( + DatabaseTargetResolver, + Effect.gen(function* () { + const localDb = yield* LocalDatabaseFallback; + const linkedRemote = yield* LinkedRemoteConnector; + const config = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const resolveLocal = Effect.gen(function* () { + const owned = yield* localDb.resolve; + if (Option.isNone(owned)) { + return yield* new SchemaLocalStackNotRunningError({ + detail: "No local Supabase database container is running for this project.", + suggestion: "Run `supabase start` or `supabase db start`, then retry.", + }); + } + return owned.value; + }); + + const resolveLinkedRef = Effect.gen(function* () { + if (Option.isSome(config.projectId) && PROJECT_REF_PATTERN.test(config.projectId.value)) { + return config.projectId.value; + } + const fileRef = yield* legacyReadProjectRefFile(fs, path, config.workdir).pipe( + Effect.mapError( + (error) => + new SchemaLinkedConnectionError({ + detail: error.message, + suggestion: "Fix or remove supabase/.temp/project-ref, then retry.", + }), + ), + ); + if (Option.isNone(fileRef)) { + return yield* new SchemaLinkedConnectionError({ + detail: "This project is not linked to a Supabase project.", + suggestion: "Run `supabase link`, or pass --from / --against with a connection string.", + }); + } + if (!PROJECT_REF_PATTERN.test(fileRef.value)) { + return yield* new SchemaLinkedConnectionError({ + detail: "supabase/.temp/project-ref is not a valid project ref.", + suggestion: "Run `supabase link` again, or remove the invalid project-ref file.", + }); + } + return fileRef.value; + }); + + const resolveLinked = Effect.gen(function* () { + const url = envDatabaseUrl(); + if (url !== undefined) { + return { + kind: "url", + identity: "connection-string", + connectionString: url, + disposable: false, + durable: true, + connectionVerified: false, + connectionSource: "env", + } satisfies DatabaseTarget; + } + const ref = yield* resolveLinkedRef; + const connectionString = yield* linkedRemote.connect(ref); + return { + kind: "linked", + identity: ref, + connectionString, + disposable: false, + durable: true, + connectionVerified: true, + projectRef: ref, + } satisfies DatabaseTarget; + }); + + return DatabaseTargetResolver.of({ + resolve: (selector) => { + if (selector.kind === "local") return resolveLocal; + if (selector.kind === "linked") return resolveLinked; + return Effect.succeed({ + kind: "url", + identity: "connection-string", + connectionString: selector.url, + disposable: false, + durable: true, + connectionVerified: false, + connectionSource: "flag", + } satisfies DatabaseTarget); + }, + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts new file mode 100644 index 0000000000..c590455b26 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts @@ -0,0 +1,79 @@ +import { Effect, Layer, Path } from "effect"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { pgDeltaSchemaEngineLayer } from "../../shared/schema/pg-delta-engine.layer.ts"; +import { schemaStateLayer } from "../../shared/schema/schema-state.layer.ts"; +import { schemaWorkspaceLayer } from "../../shared/schema/schema-workspace.layer.ts"; +import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyHttpClientLayer } from "../auth/legacy-http-debug.layer.ts"; +import { legacyDbConfigLayer } from "../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; +import { legacyDockerRunLayer } from "../shared/legacy-docker-run.layer.ts"; +import { legacyIdentityStitchLayer } from "../shared/legacy-identity-stitch.ts"; +import { legacyPgDeltaNextShadowLayer } from "../commands/db/shared/legacy-pgdelta-next-shadow.layer.ts"; +import { legacyDockerIsolatedShadowLayer } from "./legacy-docker-isolated-shadow.layer.ts"; +import { legacyDockerLocalDatabaseFallbackLayer } from "./legacy-docker-local-database.layer.ts"; +import { legacyLinkedRemoteConnectorLayer } from "./legacy-linked-remote-connector.layer.ts"; +import { legacyMigrationRepositoryLayer } from "./legacy-migration-repository.layer.ts"; +import { legacyMigrationRunnerLayer } from "./legacy-migration-runner.layer.ts"; +import { legacySchemaDatabaseTargetLayer } from "./legacy-schema-database-target.layer.ts"; + +const legacyCliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacySchemaDbConfig = legacyDbConfigLayer.pipe( + Layer.provide(legacyCliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const nextShadow = legacyPgDeltaNextShadowLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), +); + +const dockerShadows = legacyDockerIsolatedShadowLayer.pipe( + Layer.provide(nextShadow), + Layer.provide(legacyCliConfig), +); + +const schemaEngine = pgDeltaSchemaEngineLayer.pipe(Layer.provide(dockerShadows)); + +export const legacySchemaRuntimeLayer = (commandPath: ReadonlyArray) => + Layer.unwrap( + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + const path = yield* Path.Path; + const workspace = schemaWorkspaceLayer({ + projectRoot: config.workdir, + supabaseDir: path.join(config.workdir, "supabase"), + projectHomeDir: path.join(config.workdir, ".supabase"), + }); + const localDatabase = legacyDockerLocalDatabaseFallbackLayer.pipe( + Layer.provide(Layer.succeed(LegacyCliConfig, config)), + ); + const linkedRemote = legacyLinkedRemoteConnectorLayer.pipe( + Layer.provide(legacySchemaDbConfig), + ); + const targets = legacySchemaDatabaseTargetLayer.pipe( + Layer.provide(localDatabase), + Layer.provide(linkedRemote), + Layer.provide(Layer.succeed(LegacyCliConfig, config)), + ); + return Layer.mergeAll( + workspace, + schemaStateLayer.pipe(Layer.provide(workspace)), + legacyMigrationRepositoryLayer.pipe(Layer.provide(workspace)), + legacyMigrationRunnerLayer, + schemaEngine, + targets, + linkedRemote, + localDatabase, + commandRuntimeLayer(commandPath), + ); + }), + ).pipe(Layer.provide(legacyCliConfig), Layer.provide(legacyDebugLoggerLayer)); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts index c486fe8009..2917303997 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -40,6 +40,7 @@ import { Data, Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { detectGitBranch } from "../../../shared/git/git-branch.ts"; +import { clearDraftJournalFile } from "../../../shared/schema/clear-draft-journal.ts"; import { LegacyDebugFlag, LegacyNetworkIdFlag, @@ -209,6 +210,8 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( setup: { ...setup, experimental }, }); + yield* clearDraftJournalFile(fs, path, workdir); + // Seed objects from supabase/buckets when storage is up (Go gates buckets on // an existing, healthy storage container). Reuses the ported seed-buckets // local path; its summary is suppressed (reset emits its own result). diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 964434c3a7..5239854fc1 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -80,6 +80,8 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "level", "fail-on", "type", + // schema-first migrations diff/list target override (`Flag.string("against")`) + "against", // migration/db credential flag — `StringVarP(&dbPassword, "password", "p", …)` // consumes the next token as the value. "password", diff --git a/apps/cli/src/shared/database/database-pool.ts b/apps/cli/src/shared/database/database-pool.ts new file mode 100644 index 0000000000..9ec11e6dcd --- /dev/null +++ b/apps/cli/src/shared/database/database-pool.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect"; +import pg from "pg"; +import { parseSslConfig } from "@supabase/pg-delta/frontends"; + +export const acquireDatabasePool = (connectionString: string) => + Effect.acquireRelease( + Effect.sync(() => { + const { ssl, cleanedUrl } = parseSslConfig(connectionString); + const pool = new pg.Pool({ + connectionString: cleanedUrl, + max: 5, + ...(ssl !== undefined ? { ssl } : {}), + }); + pool.on("error", () => undefined); + return pool; + }), + (pool) => Effect.promise(() => pool.end()), + ); diff --git a/apps/cli/src/shared/database/database-target.service.ts b/apps/cli/src/shared/database/database-target.service.ts new file mode 100644 index 0000000000..df4ce5315f --- /dev/null +++ b/apps/cli/src/shared/database/database-target.service.ts @@ -0,0 +1,22 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; +import type { + SchemaLinkedConnectionError, + SchemaLocalStackNotRunningError, + SchemaTargetRequiredError, +} from "../schema/schema-errors.ts"; +import type { DatabaseTarget, DatabaseTargetSelector } from "./database-target.ts"; + +interface DatabaseTargetResolverShape { + readonly resolve: ( + selector: DatabaseTargetSelector, + ) => Effect.Effect< + DatabaseTarget, + SchemaLocalStackNotRunningError | SchemaLinkedConnectionError | SchemaTargetRequiredError + >; +} + +export class DatabaseTargetResolver extends Context.Service< + DatabaseTargetResolver, + DatabaseTargetResolverShape +>()("supabase/database/DatabaseTargetResolver") {} diff --git a/apps/cli/src/shared/database/database-target.ts b/apps/cli/src/shared/database/database-target.ts new file mode 100644 index 0000000000..33162c141f --- /dev/null +++ b/apps/cli/src/shared/database/database-target.ts @@ -0,0 +1,43 @@ +type DatabaseTargetKind = "local" | "linked" | "url"; + +export type DatabaseTargetSelector = + | { readonly kind: "local" } + | { readonly kind: "linked" } + | { readonly kind: "url"; readonly url: string }; + +export type DatabaseTarget = { + readonly kind: DatabaseTargetKind; + readonly identity: string; + readonly connectionString: string; + readonly disposable: boolean; + readonly durable: boolean; + readonly connectionVerified: boolean; + readonly projectRef?: string; + readonly connectionSource?: "env" | "flag"; +}; + +export function envDatabaseUrl(): string | undefined { + return process.env["SUPABASE_DB_URL"] ?? process.env["DATABASE_URL"]; +} + +export function envDatabaseUrlVarName(): "SUPABASE_DB_URL" | "DATABASE_URL" | undefined { + if (process.env["SUPABASE_DB_URL"] !== undefined) return "SUPABASE_DB_URL"; + if (process.env["DATABASE_URL"] !== undefined) return "DATABASE_URL"; + return undefined; +} + +export function parseTargetSelector(value: string): DatabaseTargetSelector { + if (value === "local") return { kind: "local" }; + if (value === "linked") return { kind: "linked" }; + return { kind: "url", url: value }; +} + +export function redactConnectionString(url: string): string { + try { + const parsed = new URL(url); + if (parsed.password) parsed.password = "****"; + return parsed.toString(); + } catch { + return ""; + } +} diff --git a/apps/cli/src/shared/database/database-target.unit.test.ts b/apps/cli/src/shared/database/database-target.unit.test.ts new file mode 100644 index 0000000000..45d4daf6c4 --- /dev/null +++ b/apps/cli/src/shared/database/database-target.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { envDatabaseUrl, envDatabaseUrlVarName } from "./database-target.ts"; + +describe("envDatabaseUrl", () => { + test("prefers SUPABASE_DB_URL over DATABASE_URL", () => { + const previousSupa = process.env["SUPABASE_DB_URL"]; + const previousDb = process.env["DATABASE_URL"]; + process.env["SUPABASE_DB_URL"] = "postgresql://supabase"; + process.env["DATABASE_URL"] = "postgresql://database"; + try { + expect(envDatabaseUrl()).toBe("postgresql://supabase"); + expect(envDatabaseUrlVarName()).toBe("SUPABASE_DB_URL"); + } finally { + if (previousSupa === undefined) delete process.env["SUPABASE_DB_URL"]; + else process.env["SUPABASE_DB_URL"] = previousSupa; + if (previousDb === undefined) delete process.env["DATABASE_URL"]; + else process.env["DATABASE_URL"] = previousDb; + } + }); +}); diff --git a/apps/cli/src/shared/database/destructive-auth.integration.test.ts b/apps/cli/src/shared/database/destructive-auth.integration.test.ts new file mode 100644 index 0000000000..02b8595e81 --- /dev/null +++ b/apps/cli/src/shared/database/destructive-auth.integration.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { authorizeMutation } from "./destructive-auth.ts"; +import type { DatabaseTarget } from "./database-target.ts"; + +const local: DatabaseTarget = { + kind: "local", + identity: "local:default", + connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + disposable: true, + durable: false, + connectionVerified: true, +}; + +const linked: DatabaseTarget = { + kind: "linked", + identity: "abcdefghijklmnop", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: true, + projectRef: "abcdefghijklmnop", +}; + +const unverifiedLinked: DatabaseTarget = { + kind: "linked", + identity: "abcdefghijklmnop", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: false, + projectRef: "abcdefghijklmnop", +}; + +const url: DatabaseTarget = { + kind: "url", + identity: "connection-string", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: false, +}; + +describe("authorizeMutation", () => { + it.live("auto-approves disposable local targets", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + yield* authorizeMutation({ + target: local, + flags: { yes: false, allowRemote: false }, + command: "schema apply", + }); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("rejects mismatched --project-ref", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: linked, + flags: { yes: true, allowRemote: false, projectRef: "otherref" }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("accepts a matching --project-ref for linked targets", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + yield* authorizeMutation({ + target: unverifiedLinked, + flags: { yes: true, allowRemote: false, projectRef: "abcdefghijklmnop" }, + command: "migrations push", + }); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("accepts --yes for non-interactive linked pushes", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + yield* authorizeMutation({ + target: unverifiedLinked, + flags: { yes: true, allowRemote: false }, + command: "migrations push", + }); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("requires --yes or --project-ref for non-interactive linked pushes", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: unverifiedLinked, + flags: { yes: false, allowRemote: false }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("requires --allow-remote for URL targets", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: url, + flags: { yes: true, allowRemote: false }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("--allow-remote"); + }).pipe(Effect.provide(Layer.mergeAll(out.layer))); + }); + + it.live("tells the user to unset DATABASE_URL when the URL came from env", () => { + const previous = process.env["DATABASE_URL"]; + process.env["DATABASE_URL"] = "postgresql://postgres:secret@other.example/postgres"; + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: { ...url, connectionSource: "env" }, + flags: { yes: true, allowRemote: false }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Unset DATABASE_URL"); + }).pipe( + Effect.provide(Layer.mergeAll(out.layer)), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["DATABASE_URL"]; + } else { + process.env["DATABASE_URL"] = previous; + } + }), + ), + ); + }); +}); diff --git a/apps/cli/src/shared/database/destructive-auth.ts b/apps/cli/src/shared/database/destructive-auth.ts new file mode 100644 index 0000000000..1d58300e0a --- /dev/null +++ b/apps/cli/src/shared/database/destructive-auth.ts @@ -0,0 +1,81 @@ +import { Effect } from "effect"; +import { + SchemaAllowRemoteRequiredError, + SchemaCancelledError, + SchemaDestructiveAuthError, + SchemaProjectRefMismatchError, +} from "../schema/schema-errors.ts"; +import { envDatabaseUrlVarName, type DatabaseTarget } from "./database-target.ts"; +import { Output } from "../output/output.service.ts"; + +export type MutationAuthFlags = { + readonly yes: boolean; + readonly projectRef?: string; + readonly allowRemote: boolean; +}; + +export const authorizeMutation = Effect.fnUntraced(function* (input: { + readonly target: DatabaseTarget; + readonly flags: MutationAuthFlags; + readonly command: string; +}) { + const { target, flags, command } = input; + + if (target.disposable) { + return; + } + + if (target.kind === "url") { + if (!flags.allowRemote) { + const envVar = target.connectionSource === "env" ? envDatabaseUrlVarName() : undefined; + return yield* new SchemaAllowRemoteRequiredError({ + detail: + envVar !== undefined + ? `This connection string cannot be identity-verified because ${envVar} is set.` + : "This connection string cannot be identity-verified.", + suggestion: + envVar !== undefined + ? `Unset ${envVar} to use the linked project connection, or re-run ${command} with --allow-remote if this URL is the intended durable target.` + : `Re-run ${command} with --allow-remote to acknowledge the unverifiable target.`, + }); + } + return; + } + + const resolvedRef = target.projectRef; + if (resolvedRef === undefined) { + return yield* new SchemaProjectRefMismatchError({ + detail: "Durable target is missing a project ref.", + suggestion: "Link the project or pass --project-ref .", + }); + } + + if (flags.projectRef !== undefined) { + if (flags.projectRef !== resolvedRef) { + return yield* new SchemaProjectRefMismatchError({ + detail: `--project-ref ${flags.projectRef} does not match resolved target ${resolvedRef}.`, + suggestion: `Pass --project-ref ${resolvedRef}.`, + }); + } + return; + } + + const output = yield* Output; + if (output.interactive) { + const typed = yield* output.promptText(`Type the project ref (${resolvedRef}) to continue`); + if (typed.trim() !== resolvedRef) { + return yield* new SchemaCancelledError({ + detail: "Project ref confirmation did not match.", + suggestion: `Type ${resolvedRef} exactly, or pass --project-ref ${resolvedRef}.`, + }); + } + return; + } + + if (!flags.yes) { + return yield* new SchemaDestructiveAuthError({ + detail: "Non-interactive mutation of a durable target requires confirmation.", + suggestion: "Pass --yes, or pass --project-ref to assert the target identity.", + }); + } +}); diff --git a/apps/cli/src/shared/database/linked-remote-connector.service.ts b/apps/cli/src/shared/database/linked-remote-connector.service.ts new file mode 100644 index 0000000000..caba6f7232 --- /dev/null +++ b/apps/cli/src/shared/database/linked-remote-connector.service.ts @@ -0,0 +1,12 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; +import type { SchemaLinkedConnectionError } from "../schema/schema-errors.ts"; + +interface LinkedRemoteConnectorShape { + readonly connect: (projectRef: string) => Effect.Effect; +} + +export class LinkedRemoteConnector extends Context.Service< + LinkedRemoteConnector, + LinkedRemoteConnectorShape +>()("supabase/database/LinkedRemoteConnector") {} diff --git a/apps/cli/src/shared/database/local-database-fallback.service.ts b/apps/cli/src/shared/database/local-database-fallback.service.ts new file mode 100644 index 0000000000..fdc0ca50ee --- /dev/null +++ b/apps/cli/src/shared/database/local-database-fallback.service.ts @@ -0,0 +1,13 @@ +import { Context, type Effect, Option } from "effect"; +import type { SchemaLocalStackNotRunningError } from "../schema/schema-errors.ts"; +import type { DatabaseTarget } from "./database-target.ts"; + +interface LocalDatabaseFallbackShape { + readonly resolve: Effect.Effect, SchemaLocalStackNotRunningError>; +} + +/** Optional project-owned local DB from this project's Docker `supabase start`. */ +export class LocalDatabaseFallback extends Context.Service< + LocalDatabaseFallback, + LocalDatabaseFallbackShape +>()("supabase/database/LocalDatabaseFallback") {} diff --git a/apps/cli/src/shared/database/local-postgres-url.ts b/apps/cli/src/shared/database/local-postgres-url.ts new file mode 100644 index 0000000000..0ad1b1c320 --- /dev/null +++ b/apps/cli/src/shared/database/local-postgres-url.ts @@ -0,0 +1,30 @@ +const formatPostgresHost = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +export const localPostgresConnectionString = ( + port: number, + password: string, + host = "127.0.0.1", +): string => + `postgresql://postgres:${encodeURIComponent(password)}@${formatPostgresHost(host)}:${port}/postgres`; + +function isRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} + +/** + * Host port published for container Postgres (`5432/tcp`) from Docker inspect + * `NetworkSettings.Ports`. Ownership is the named container; this only reads + * the port that container actually published (which may not be 54322). + */ +export const publishedPostgresHostPort = (ports: unknown): number | undefined => { + if (!isRecord(ports)) return undefined; + const bindings = ports["5432/tcp"]; + if (!Array.isArray(bindings) || bindings.length === 0) return undefined; + const first = bindings[0]; + if (!isRecord(first)) return undefined; + const hostPort = first["HostPort"]; + if (typeof hostPort !== "string" && typeof hostPort !== "number") return undefined; + const port = typeof hostPort === "number" ? hostPort : Number(hostPort); + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined; +}; diff --git a/apps/cli/src/shared/database/local-postgres-url.unit.test.ts b/apps/cli/src/shared/database/local-postgres-url.unit.test.ts new file mode 100644 index 0000000000..731a0b2c9f --- /dev/null +++ b/apps/cli/src/shared/database/local-postgres-url.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { localPostgresConnectionString, publishedPostgresHostPort } from "./local-postgres-url.ts"; + +describe("publishedPostgresHostPort", () => { + it("reads the first 5432/tcp HostPort", () => { + expect( + publishedPostgresHostPort({ + "5432/tcp": [{ HostIp: "0.0.0.0", HostPort: "55432" }], + }), + ).toBe(55432); + }); + + it("returns undefined when Postgres is not published", () => { + expect(publishedPostgresHostPort({ "5432/tcp": null })).toBeUndefined(); + expect(publishedPostgresHostPort({})).toBeUndefined(); + expect(publishedPostgresHostPort(null)).toBeUndefined(); + }); +}); + +describe("localPostgresConnectionString", () => { + it("percent-encodes the password", () => { + expect(localPostgresConnectionString(55432, "p@ss")).toBe( + "postgresql://postgres:p%40ss@127.0.0.1:55432/postgres", + ); + }); + + it("uses the caller-supplied host and brackets IPv6", () => { + expect(localPostgresConnectionString(55432, "postgres", "docker.internal")).toBe( + "postgresql://postgres:postgres@docker.internal:55432/postgres", + ); + expect(localPostgresConnectionString(55432, "postgres", "::1")).toBe( + "postgresql://postgres:postgres@[::1]:55432/postgres", + ); + }); +}); diff --git a/apps/cli/src/shared/migrations/apply-local-pending.ts b/apps/cli/src/shared/migrations/apply-local-pending.ts new file mode 100644 index 0000000000..d432b5dca6 --- /dev/null +++ b/apps/cli/src/shared/migrations/apply-local-pending.ts @@ -0,0 +1,66 @@ +import { Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { SchemaHistoryConflictError } from "../schema/schema-errors.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import type { MigrationFile } from "./migration-file.ts"; +import { findMatchingPendingPrefix } from "./matching-pending-prefix.ts"; +import { formatHistoryConflict } from "./migration-repair-suggest.ts"; +import { MigrationRunner, type MigrationApplyResult } from "./migration-runner.service.ts"; +import type { Pool } from "pg"; + +export const applyLocalPending = Effect.fn("migrations.applyLocalPending")(function* ( + pool: Pool, + local: ReadonlyArray, +) { + const runner = yield* MigrationRunner; + const engine = yield* PgDeltaSchemaEngine; + const history = yield* runner.listRemote(pool); + const present = new Set(history.map((row) => row.version)); + const pending = local.filter((file) => !present.has(file.version)); + if (pending.length === 0) { + return { + applied: [], + recorded: [], + skipped: local.map((file) => file.version), + } satisfies MigrationApplyResult; + } + + const remoteOnly = history.filter((row) => !local.some((file) => file.version === row.version)); + if (remoteOnly.length > 0) { + return yield* new SchemaHistoryConflictError( + formatHistoryConflict({ + remoteOnly: remoteOnly.map((row) => row.version), + pending: pending.map((file) => file.version), + flags: { local: true }, + }), + ); + } + + const already = local.filter((file) => present.has(file.version)); + const shadow = yield* engine.provisionShadow; + const shadowPool = yield* acquireDatabasePool(shadow.url); + if (already.length > 0) { + yield* runner.applyPending(shadowPool, already); + } + + const recorded = yield* findMatchingPendingPrefix(shadowPool, pool, already, pending); + + if (recorded.length === pending.length) { + yield* runner.markApplied(pool, pending); + return { + applied: [], + recorded: pending.map((file) => file.version), + skipped: already.map((file) => file.version), + } satisfies MigrationApplyResult; + } + + if (recorded.length > 0) { + yield* runner.markApplied(pool, recorded); + } + + const result = yield* runner.applyPending(pool, local); + return { + ...result, + recorded: recorded.map((file) => file.version), + } satisfies MigrationApplyResult; +}); diff --git a/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts new file mode 100644 index 0000000000..89b3546147 --- /dev/null +++ b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import type { SchemaDraftJournal, SchemaPlanView } from "../schema/schema-types.ts"; +import { applyMigrations } from "./apply-migrations.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +const ungeneratedAheadJournal: SchemaDraftJournal = { + version: 1, + draftId: "draft", + targetIdentity: "local:default", + startingMigrationHeadDigest: "abc", + sourceFingerprint: "s", + engineVersion: "0.3.0", + declarativelyAhead: true, + generated: false, + plans: [], +}; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan-1", + source: { fingerprint: "s" }, + target: { fingerprint: "d" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +const laterFile = { + version: "20260101000001", + name: "next", + fileName: "20260101000001_next.sql", + absolutePath: "/tmp/migrations/20260101000001_next.sql", + content: "select 2;", + transactional: true, +}; + +function setup( + journal: SchemaDraftJournal | undefined, + opts: { + history?: ReadonlyArray<{ version: string; name: string }>; + catalogMatch?: boolean; + catalogMatches?: ReadonlyArray; + files?: ReadonlyArray<{ + version: string; + name: string; + fileName: string; + absolutePath: string; + content: string; + transactional: boolean; + }>; + } = {}, +) { + const out = mockOutput({ interactive: false }); + let applyPending = 0; + let marked = 0; + let diffCalls = 0; + return { + get applyPending() { + return applyPending; + }, + get marked() { + return marked; + }, + layer: Layer.mergeAll( + out.layer, + Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => + Effect.succeed({ + kind: "local", + identity: "local:default", + connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + disposable: true, + durable: false, + connectionVerified: true, + }), + }), + ), + Layer.succeed( + SchemaStateStore, + SchemaStateStore.of({ + readJournal: Effect.succeed(journal === undefined ? Option.none() : Option.some(journal)), + writeJournal: () => Effect.void, + clearJournal: Effect.void, + withLock: (effect) => effect, + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed( + opts.files ?? [ + { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, + }, + ], + ), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed(opts.history ?? []), + applyPending: () => + Effect.sync(() => { + applyPending += 1; + return { applied: [], skipped: [] }; + }), + markApplied: () => + Effect.sync(() => { + marked += 1; + }), + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.die("unused"), + diffPools: () => { + const match = + opts.catalogMatches !== undefined + ? opts.catalogMatches[diffCalls] === true + : opts.catalogMatch === true; + diffCalls += 1; + return Effect.succeed(planView(!match)); + }, + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + provisionMigrations: Effect.die("unused"), + }), + ), + ), + }; +} + +describe("applyMigrations", () => { + it.live("fails closed when an ungenerated draft is active", () => { + const ctx = setup(ungeneratedAheadJournal); + return Effect.gen(function* () { + const exit = yield* applyMigrations().pipe(Effect.provide(ctx.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(ctx.applyPending).toBe(0); + expect(ctx.marked).toBe(0); + }); + }); + + it.live("runs pending SQL when the live catalog does not match full replay", () => { + const ctx = setup(undefined); + return Effect.gen(function* () { + const result = yield* applyMigrations().pipe(Effect.provide(ctx.layer)); + expect(result.mutatedDatabase).toBe(false); + expect(ctx.applyPending).toBe(2); + expect(ctx.marked).toBe(0); + }); + }); + + it.live("marks history when the live catalog already matches full replay", () => { + const ctx = setup(undefined, { catalogMatch: true }); + return Effect.gen(function* () { + const result = yield* applyMigrations().pipe(Effect.provide(ctx.layer)); + expect(result.mutatedDatabase).toBe(true); + expect(result.message).toContain("Recorded"); + expect(ctx.marked).toBe(1); + }); + }); + + it.live("records a matching prefix then runs the remaining pending SQL", () => { + const ctx = setup(undefined, { + files: [ + { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, + }, + laterFile, + ], + catalogMatches: [true, false], + }); + return Effect.gen(function* () { + const result = yield* applyMigrations().pipe(Effect.provide(ctx.layer)); + expect(result.message).toContain("Recorded"); + expect(ctx.marked).toBe(1); + expect(ctx.applyPending).toBe(3); + }); + }); + + it.live("fails closed when history has remote-only versions and pending files", () => { + const ctx = setup(undefined, { + history: [{ version: "19990101000000", name: "other" }], + }); + return Effect.gen(function* () { + const exit = yield* applyMigrations().pipe(Effect.provide(ctx.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "supabase migration repair --local --status reverted 19990101000000", + ); + expect(ctx.applyPending).toBe(0); + expect(ctx.marked).toBe(0); + }); + }); + + it.live("is a no-op when every local version is already in history", () => { + const ctx = setup(undefined, { + history: [{ version: "20260101000000", name: "init" }], + }); + return Effect.gen(function* () { + const result = yield* applyMigrations().pipe(Effect.provide(ctx.layer)); + expect(result.mutatedDatabase).toBe(false); + expect(ctx.applyPending).toBe(0); + expect(ctx.marked).toBe(0); + }); + }); +}); diff --git a/apps/cli/src/shared/migrations/apply-migrations.ts b/apps/cli/src/shared/migrations/apply-migrations.ts new file mode 100644 index 0000000000..fd522ef6e1 --- /dev/null +++ b/apps/cli/src/shared/migrations/apply-migrations.ts @@ -0,0 +1,61 @@ +import { Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { SchemaDraftConflictError } from "../schema/schema-errors.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { applyLocalPending } from "./apply-local-pending.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; + +export const applyMigrations = Effect.fn("migrations.apply")(function* () { + const targets = yield* DatabaseTargetResolver; + const repository = yield* MigrationRepository; + const state = yield* SchemaStateStore; + const journal = yield* state.readJournal; + if ( + journal._tag === "Some" && + journal.value.declarativelyAhead && + journal.value.generated !== true + ) { + return yield* new SchemaDraftConflictError({ + detail: "A declarative draft is active on the local database.", + suggestion: + "Run `supabase schema generate`, reset the local database, or discard the draft before applying migration files.", + }); + } + const target = yield* targets.resolve({ kind: "local" }); + const local = yield* repository.listLocal; + + return yield* Effect.scoped( + Effect.gen(function* () { + const pool = yield* acquireDatabasePool(target.connectionString); + const result = yield* applyLocalPending(pool, local); + const recorded = result.recorded ?? []; + const mutatedDatabase = result.applied.length > 0 || recorded.length > 0; + const parts = [ + ...(recorded.length > 0 + ? [`Recorded ${recorded.length} already-applied migration(s): ${recorded.join(", ")}`] + : []), + ...(result.applied.length > 0 + ? [`Applied ${result.applied.length} migration(s): ${result.applied.join(", ")}`] + : []), + ]; + return { + status: "clean", + message: parts.length > 0 ? parts.join(". ") : "No pending migrations.", + data: { + status: "clean", + applied: result.applied, + recorded, + skipped: result.skipped, + target: target.identity, + mutated_database: mutatedDatabase, + mutated_files: false, + }, + nextActions: [], + mutatedDatabase, + mutatedFiles: false, + } satisfies SchemaCommandResult; + }), + ); +}); diff --git a/apps/cli/src/shared/migrations/diff-migrations.ts b/apps/cli/src/shared/migrations/diff-migrations.ts new file mode 100644 index 0000000000..32e6f826af --- /dev/null +++ b/apps/cli/src/shared/migrations/diff-migrations.ts @@ -0,0 +1,81 @@ +import { Effect, FileSystem } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { parseTargetSelector } from "../database/database-target.ts"; +import { SchemaTargetRequiredError, SchemaWorkspaceIoError } from "../schema/schema-errors.ts"; +import { formatPlanSummary } from "../schema/schema-output.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; + +export type DiffMigrationsInput = { + readonly against?: string; + readonly file?: string; +}; + +export const diffMigrations = Effect.fn("migrations.diff")(function* (input: DiffMigrationsInput) { + if (input.against === undefined) { + return yield* new SchemaTargetRequiredError({ + detail: "migrations diff requires --against.", + suggestion: "Pass --against local, --against linked, or --against .", + }); + } + + const targets = yield* DatabaseTargetResolver; + const engine = yield* PgDeltaSchemaEngine; + const live = yield* targets.resolve(parseTargetSelector(input.against)); + + return yield* Effect.scoped( + Effect.gen(function* () { + const livePool = yield* acquireDatabasePool(live.connectionString); + const shadow = yield* engine.provisionMigrations; + const sourcePool = yield* acquireDatabasePool(shadow.url); + const plan = yield* engine.diffPools({ + sourcePool, + desiredPool: livePool, + allowDrops: true, + }); + + if (input.file !== undefined) { + const fs = yield* FileSystem.FileSystem; + const sql = plan.files.map((file) => file.sql).join("\n\n"); + yield* fs.writeFileString(input.file, sql).pipe( + Effect.mapError( + (error) => + new SchemaWorkspaceIoError({ + detail: `Failed to write ${input.file}: ${error.message}`, + suggestion: "Check the output path and retry.", + }), + ), + ); + } + + const summary = formatPlanSummary({ + title: "Migrations diff", + source: `migrations@${plan.sourceFingerprint.slice(0, 8)}`, + desired: `live@${plan.desiredFingerprint.slice(0, 8)}`, + target: live.identity, + plan, + }); + + return { + status: plan.changes ? "drift" : "clean", + message: plan.changes + ? `${summary}\nResult: preview only; nothing was changed` + : "Live database matches migration replay.", + data: { + status: plan.changes ? "drift" : "clean", + plan_id: plan.planId, + hazards: plan.hazards, + sql: plan.files.map((file) => file.sql).join("\n\n"), + file: input.file, + mutated_database: false, + mutated_files: input.file !== undefined, + next_actions: plan.changes ? ["supabase migrations pull"] : [], + }, + nextActions: plan.changes ? ["supabase migrations pull"] : [], + mutatedDatabase: false, + mutatedFiles: input.file !== undefined, + } satisfies SchemaCommandResult; + }), + ); +}); diff --git a/apps/cli/src/shared/migrations/list-migrations.ts b/apps/cli/src/shared/migrations/list-migrations.ts new file mode 100644 index 0000000000..6cc754ff06 --- /dev/null +++ b/apps/cli/src/shared/migrations/list-migrations.ts @@ -0,0 +1,60 @@ +import { Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { parseTargetSelector } from "../database/database-target.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +export type ListMigrationsInput = { + readonly against?: string; +}; + +export const listMigrations = Effect.fn("migrations.list")(function* (input: ListMigrationsInput) { + const repository = yield* MigrationRepository; + const runner = yield* MigrationRunner; + const targets = yield* DatabaseTargetResolver; + const local = yield* repository.listLocal; + const selector = parseTargetSelector(input.against ?? "local"); + const target = yield* targets.resolve(selector); + + return yield* Effect.scoped( + Effect.gen(function* () { + const pool = yield* acquireDatabasePool(target.connectionString); + const remote = yield* runner.listRemote(pool); + const remoteVersions = new Set(remote.map((row) => row.version)); + const localVersions = new Set(local.map((file) => file.version)); + const rows = [ + ...local.map((file) => ({ + version: file.version, + name: file.name, + local: true, + remote: remoteVersions.has(file.version), + })), + ...remote + .filter((row) => !localVersions.has(row.version)) + .map((row) => ({ + version: row.version, + name: row.name, + local: false, + remote: true, + })), + ].sort((left, right) => left.version.localeCompare(right.version)); + + return { + status: "clean", + message: `Compared ${rows.length} migration(s) against ${target.identity}.`, + data: { + status: "clean", + target: target.identity, + migrations: rows, + mutated_database: false, + mutated_files: false, + }, + nextActions: [], + mutatedDatabase: false, + mutatedFiles: false, + } satisfies SchemaCommandResult; + }), + ); +}); diff --git a/apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts b/apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts new file mode 100644 index 0000000000..a2e0249365 --- /dev/null +++ b/apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Effect, Layer } from "effect"; +import type { Pool } from "pg"; +import { SchemaEngineError } from "../schema/schema-errors.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import type { SchemaPlanView } from "../schema/schema-types.ts"; +import { findMatchingPendingPrefix } from "./matching-pending-prefix.ts"; +import type { MigrationFile } from "./migration-file.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan", + source: { fingerprint: "s" }, + target: { fingerprint: "d" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +const first = { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, +}; + +const second = { + version: "20260101000001", + name: "next", + fileName: "20260101000001_next.sql", + absolutePath: "/tmp/migrations/20260101000001_next.sql", + content: "select 2;", + transactional: true, +}; + +function historyPool(opts: { readonly failSql?: string } = {}): Pool { + return { + query: async (sql: string) => { + if (opts.failSql !== undefined && sql === opts.failSql) { + throw new Error("relation does not exist"); + } + return { rows: [] }; + }, + } as Pool; +} + +const engine = Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.die("unused"), + diffPools: () => Effect.succeed(planView(false)), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.die("unused"), + provisionMigrations: Effect.die("unused"), + }), +); + +const runner = Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed([]), + applyPending: (pool, files: ReadonlyArray) => + Effect.gen(function* () { + for (const file of files) { + yield* Effect.tryPromise({ + try: () => pool.query(file.content), + catch: (cause) => + new SchemaEngineError({ + detail: `Failed applying ${file.fileName}: ${cause instanceof Error ? cause.message : String(cause)}`, + suggestion: "Check the migration SQL and retry.", + }), + }); + } + return { applied: files.map((file) => file.version), skipped: [] }; + }), + markApplied: () => Effect.void, + }), +); + +describe("findMatchingPendingPrefix", () => { + it.live("applies a later pending file without treating known history as remote-only", () => + Effect.gen(function* () { + const prefix = yield* findMatchingPendingPrefix( + historyPool(), + historyPool(), + [first], + [second], + ); + expect(prefix.map((file) => file.version)).toEqual([second.version]); + }).pipe(Effect.provide(Layer.mergeAll(runner, engine))), + ); + + it.live("stops the prefix scan when a later file cannot replay", () => + Effect.gen(function* () { + const prefix = yield* findMatchingPendingPrefix( + historyPool({ failSql: second.content }), + historyPool(), + [], + [first, second], + ); + expect(prefix.map((file) => file.version)).toEqual([first.version]); + }).pipe(Effect.provide(Layer.mergeAll(runner, engine))), + ); +}); diff --git a/apps/cli/src/shared/migrations/matching-pending-prefix.ts b/apps/cli/src/shared/migrations/matching-pending-prefix.ts new file mode 100644 index 0000000000..207c645421 --- /dev/null +++ b/apps/cli/src/shared/migrations/matching-pending-prefix.ts @@ -0,0 +1,41 @@ +import { Effect } from "effect"; +import type { Pool } from "pg"; +import { SchemaEngineError } from "../schema/schema-errors.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import type { MigrationFile } from "./migration-file.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +export const findMatchingPendingPrefix = Effect.fn("migrations.findMatchingPendingPrefix")( + function* ( + shadowPool: Pool, + livePool: Pool, + known: ReadonlyArray, + pending: ReadonlyArray, + ) { + const runner = yield* MigrationRunner; + const engine = yield* PgDeltaSchemaEngine; + let recorded: ReadonlyArray = []; + for (const [index] of pending.entries()) { + const prefix = pending.slice(0, index + 1); + const applied = yield* runner.applyPending(shadowPool, [...known, ...prefix]).pipe( + Effect.catchIf( + (error): error is SchemaEngineError => + error._tag === "SchemaEngineError" && error.detail.startsWith("Failed applying"), + () => Effect.succeed(undefined), + ), + ); + if (applied === undefined) { + break; + } + const drift = yield* engine.diffPools({ + sourcePool: shadowPool, + desiredPool: livePool, + allowDrops: true, + }); + if (!drift.changes) { + recorded = prefix; + } + } + return recorded; + }, +); diff --git a/apps/cli/src/shared/migrations/migration-file.ts b/apps/cli/src/shared/migrations/migration-file.ts new file mode 100644 index 0000000000..8b3ac0cd78 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-file.ts @@ -0,0 +1,8 @@ +export type MigrationFile = { + readonly version: string; + readonly name: string; + readonly fileName: string; + readonly absolutePath: string; + readonly content: string; + readonly transactional: boolean; +}; diff --git a/apps/cli/src/shared/migrations/migration-repair-suggest.ts b/apps/cli/src/shared/migrations/migration-repair-suggest.ts new file mode 100644 index 0000000000..6de27cd455 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-repair-suggest.ts @@ -0,0 +1,99 @@ +import type { DatabaseTarget } from "../database/database-target.ts"; +import { envDatabaseUrlVarName } from "../database/database-target.ts"; + +export type MigrationRepairFlags = { + readonly local?: boolean; + readonly dbUrlEnvVar?: "DATABASE_URL" | "SUPABASE_DB_URL"; + readonly dbUrlSame?: boolean; + readonly projectRef?: string; +}; + +export function repairFlagsForTarget( + target: DatabaseTarget, + opts: { readonly projectRef?: string; readonly dbUrl?: string } = {}, +): MigrationRepairFlags { + if (target.kind === "local") { + return { local: true }; + } + if (target.kind === "url" || opts.dbUrl !== undefined) { + if (target.connectionSource === "env") { + return { dbUrlEnvVar: envDatabaseUrlVarName() ?? "DATABASE_URL" }; + } + return { dbUrlSame: true }; + } + const projectRef = opts.projectRef ?? target.projectRef; + return projectRef !== undefined ? { projectRef } : {}; +} + +export function formatMigrationRepairCommand(input: { + readonly status: "applied" | "reverted"; + readonly versions: ReadonlyArray; + readonly flags?: MigrationRepairFlags; +}): string { + const parts = ["supabase", "migration", "repair"]; + if (input.flags?.local === true) { + parts.push("--local"); + } + if (input.flags?.dbUrlEnvVar !== undefined) { + parts.push("--db-url", `"$${input.flags.dbUrlEnvVar}"`); + } else if (input.flags?.dbUrlSame === true) { + parts.push("--db-url", ""); + } + if (input.flags?.projectRef !== undefined) { + parts.push("--project-ref", input.flags.projectRef); + } + parts.push("--status", input.status, ...input.versions); + return parts.join(" "); +} + +export function formatHistoryConflict(input: { + readonly remoteOnly: ReadonlyArray; + readonly pending: ReadonlyArray; + readonly flags?: MigrationRepairFlags; +}): { readonly detail: string; readonly suggestion: string } { + return { + detail: `Local and remote migration histories have diverged (remote-only: ${input.remoteOnly.join(", ")}; pending: ${input.pending.join(", ")}).`, + suggestion: formatMigrationRepairCommand({ + status: "reverted", + versions: input.remoteOnly, + flags: input.flags, + }), + }; +} + +export function suggestRemoteDriftRepair(input: { + readonly remoteOnly: ReadonlyArray; + readonly matchingPrefix: ReadonlyArray; + readonly flags?: MigrationRepairFlags; +}): string { + const lines: Array = []; + if (input.remoteOnly.length > 0) { + lines.push( + formatMigrationRepairCommand({ + status: "reverted", + versions: input.remoteOnly, + flags: input.flags, + }), + ); + } + if (input.matchingPrefix.length > 0) { + lines.push( + formatMigrationRepairCommand({ + status: "applied", + versions: input.matchingPrefix, + flags: input.flags, + }), + ); + } else { + const from = + input.flags?.local === true + ? "--from local" + : input.flags?.dbUrlEnvVar !== undefined + ? `--from "$${input.flags.dbUrlEnvVar}"` + : input.flags?.dbUrlSame === true + ? "--from " + : "--from linked"; + lines.push(`supabase migrations pull ${from}`); + } + return lines.join("\n"); +} diff --git a/apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts b/apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts new file mode 100644 index 0000000000..a87e2c616b --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "vitest"; +import { + formatHistoryConflict, + formatMigrationRepairCommand, + repairFlagsForTarget, + suggestRemoteDriftRepair, +} from "./migration-repair-suggest.ts"; + +const linked = { + kind: "linked" as const, + identity: "abcdefghijklmnop", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: true, + projectRef: "abcdefghijklmnop", +}; + +const local = { + kind: "local" as const, + identity: "local:default", + connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + disposable: true, + durable: false, + connectionVerified: true, +}; + +const url = { + kind: "url" as const, + identity: "connection-string", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: false, +}; + +describe("formatMigrationRepairCommand", () => { + test("prefills linked applied versions", () => { + expect( + formatMigrationRepairCommand({ + status: "applied", + versions: ["20260819120000"], + }), + ).toBe("supabase migration repair --status applied 20260819120000"); + }); + + test("adds --local and space-separated versions", () => { + expect( + formatMigrationRepairCommand({ + status: "reverted", + versions: ["111", "222"], + flags: { local: true }, + }), + ).toBe("supabase migration repair --local --status reverted 111 222"); + }); + + test("uses a same-url placeholder for flag URL targets", () => { + expect( + formatMigrationRepairCommand({ + status: "applied", + versions: ["20260819120000"], + flags: { dbUrlSame: true }, + }), + ).toBe("supabase migration repair --db-url --status applied 20260819120000"); + }); + + test("uses an env-var placeholder for env URL targets", () => { + expect( + formatMigrationRepairCommand({ + status: "applied", + versions: ["20260819120000"], + flags: { dbUrlEnvVar: "SUPABASE_DB_URL" }, + }), + ).toBe('supabase migration repair --db-url "$SUPABASE_DB_URL" --status applied 20260819120000'); + }); +}); + +describe("repairFlagsForTarget", () => { + test("marks local targets", () => { + expect(repairFlagsForTarget(local)).toEqual({ local: true }); + }); + + test("keeps an explicit --project-ref on linked targets", () => { + expect(repairFlagsForTarget(linked, { projectRef: "abcdefghijklmnop" })).toEqual({ + projectRef: "abcdefghijklmnop", + }); + }); + + test("picks up projectRef from a linked target without opts", () => { + expect(repairFlagsForTarget(linked)).toEqual({ + projectRef: "abcdefghijklmnop", + }); + }); + + test("uses a same-url placeholder for explicit --db-url / --from targets", () => { + expect(repairFlagsForTarget(url)).toEqual({ dbUrlSame: true }); + }); + + test("uses the env var name when the URL came from the environment", () => { + const previousSupa = process.env["SUPABASE_DB_URL"]; + const previousDb = process.env["DATABASE_URL"]; + delete process.env["SUPABASE_DB_URL"]; + delete process.env["DATABASE_URL"]; + try { + expect(repairFlagsForTarget({ ...url, connectionSource: "env" })).toEqual({ + dbUrlEnvVar: "DATABASE_URL", + }); + } finally { + if (previousSupa === undefined) delete process.env["SUPABASE_DB_URL"]; + else process.env["SUPABASE_DB_URL"] = previousSupa; + if (previousDb === undefined) delete process.env["DATABASE_URL"]; + else process.env["DATABASE_URL"] = previousDb; + } + }); +}); + +describe("formatHistoryConflict", () => { + test("prefills reverted repair with target flags", () => { + expect( + formatHistoryConflict({ + remoteOnly: ["19990101000000"], + pending: ["20260819120000"], + flags: { dbUrlSame: true }, + }), + ).toEqual({ + detail: + "Local and remote migration histories have diverged (remote-only: 19990101000000; pending: 20260819120000).", + suggestion: "supabase migration repair --db-url --status reverted 19990101000000", + }); + }); +}); + +describe("suggestRemoteDriftRepair", () => { + test("suggests pull when nothing matches", () => { + expect(suggestRemoteDriftRepair({ remoteOnly: [], matchingPrefix: [] })).toBe( + "supabase migrations pull --from linked", + ); + }); + + test("suggests local pull when the target is local", () => { + expect( + suggestRemoteDriftRepair({ + remoteOnly: [], + matchingPrefix: [], + flags: { local: true }, + }), + ).toBe("supabase migrations pull --from local"); + }); + + test("suggests env-url pull without echoing the connection string", () => { + expect( + suggestRemoteDriftRepair({ + remoteOnly: [], + matchingPrefix: [], + flags: { dbUrlEnvVar: "DATABASE_URL" }, + }), + ).toBe('supabase migrations pull --from "$DATABASE_URL"'); + }); + + test("suggests a same-url placeholder for flag URL targets", () => { + expect( + suggestRemoteDriftRepair({ + remoteOnly: [], + matchingPrefix: [], + flags: { dbUrlSame: true }, + }), + ).toBe("supabase migrations pull --from "); + }); + + test("suggests reverted then applied", () => { + expect( + suggestRemoteDriftRepair({ + remoteOnly: ["19990101000000"], + matchingPrefix: ["20260819120000"], + }), + ).toBe( + "supabase migration repair --status reverted 19990101000000\nsupabase migration repair --status applied 20260819120000", + ); + }); +}); diff --git a/apps/cli/src/shared/migrations/migration-repository.service.ts b/apps/cli/src/shared/migrations/migration-repository.service.ts new file mode 100644 index 0000000000..c99889264a --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-repository.service.ts @@ -0,0 +1,34 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; +import type { SchemaMigrationNameError, SchemaWorkspaceIoError } from "../schema/schema-errors.ts"; +import type { MigrationFile } from "./migration-file.ts"; + +type GeneratedMigrationUnit = { + readonly suffix: string | null; + readonly sql: string; + readonly transactional: boolean; +}; + +interface MigrationRepositoryShape { + readonly listLocal: Effect.Effect, SchemaWorkspaceIoError>; + readonly createEmpty: ( + name: string, + content?: string, + ) => Effect.Effect; + readonly writeGenerated: (input: { + readonly name: string; + readonly baseMillis: number; + readonly files: ReadonlyArray; + }) => Effect.Effect< + ReadonlyArray, + SchemaMigrationNameError | SchemaWorkspaceIoError + >; + readonly remove: ( + files: ReadonlyArray, + ) => Effect.Effect; +} + +export class MigrationRepository extends Context.Service< + MigrationRepository, + MigrationRepositoryShape +>()("supabase/migrations/MigrationRepository") {} diff --git a/apps/cli/src/shared/migrations/migration-runner.service.ts b/apps/cli/src/shared/migrations/migration-runner.service.ts new file mode 100644 index 0000000000..227bd62f53 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-runner.service.ts @@ -0,0 +1,34 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; +import type { Pool } from "pg"; +import type { SchemaEngineError, SchemaHistoryConflictError } from "../schema/schema-errors.ts"; +import type { MigrationFile } from "./migration-file.ts"; + +export type MigrationHistoryRow = { + readonly version: string; + readonly name: string; +}; + +export type MigrationApplyResult = { + readonly applied: ReadonlyArray; + readonly skipped: ReadonlyArray; + readonly recorded?: ReadonlyArray; +}; + +interface MigrationRunnerShape { + readonly listRemote: ( + pool: Pool, + ) => Effect.Effect, SchemaEngineError>; + readonly applyPending: ( + pool: Pool, + local: ReadonlyArray, + ) => Effect.Effect; + readonly markApplied: ( + pool: Pool, + files: ReadonlyArray, + ) => Effect.Effect; +} + +export class MigrationRunner extends Context.Service()( + "supabase/migrations/MigrationRunner", +) {} diff --git a/apps/cli/src/shared/migrations/new-list-diff-migrations.integration.test.ts b/apps/cli/src/shared/migrations/new-list-diff-migrations.integration.test.ts new file mode 100644 index 0000000000..86787d92b5 --- /dev/null +++ b/apps/cli/src/shared/migrations/new-list-diff-migrations.integration.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Effect, Layer, Option } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import type { SchemaPlanView } from "../schema/schema-types.ts"; +import { SchemaWorkspace } from "../schema/schema-workspace.service.ts"; +import { diffMigrations } from "./diff-migrations.ts"; +import { listMigrations } from "./list-migrations.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; +import { newMigration } from "./new-migration.ts"; + +const file = { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, +}; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan", + source: { fingerprint: "s" }, + target: { fingerprint: "d" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: changes + ? [ + { + sequence: 1, + suffix: null, + sql: "create table t (id int);", + transactional: true, + actionCount: 1, + }, + ] + : [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +const workspace = Layer.succeed( + SchemaWorkspace, + SchemaWorkspace.of({ + schemasDir: "/tmp/schemas", + schemasDirDisplay: "supabase/schemas", + migrationsDir: "/tmp/migrations", + migrationsDirDisplay: "supabase/migrations", + customDir: "/tmp/schemas/_custom", + journalPath: "/tmp/j", + lockPath: "/tmp/l", + readDeclarationFiles: Effect.succeed([]), + readExistingSql: () => Effect.succeed(new Map()), + classifyProposed: () => Effect.die("unused"), + installExport: () => Effect.die("unused"), + }), +); + +const state = Layer.succeed( + SchemaStateStore, + SchemaStateStore.of({ + readJournal: Effect.succeed(Option.none()), + writeJournal: () => Effect.void, + clearJournal: Effect.void, + withLock: (effect) => effect, + }), +); + +const localTarget = Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => + Effect.succeed({ + kind: "local", + identity: "local:default", + connectionString: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + disposable: true, + durable: false, + connectionVerified: true, + }), + }), +); + +describe("newMigration", () => { + it.live("writes an empty migration file", () => { + const created = { ...file, name: "add_billing", fileName: "20260101000000_add_billing.sql" }; + const layer = Layer.mergeAll( + mockOutput({ interactive: false }).layer, + workspace, + state, + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([]), + createEmpty: () => Effect.succeed(created), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + ); + return Effect.gen(function* () { + const result = yield* newMigration("add_billing").pipe(Effect.provide(layer)); + expect(result.mutatedFiles).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ file: created.fileName, version: created.version }), + ); + }); + }); +}); + +describe("listMigrations", () => { + it.live("compares local files against remote history", () => { + const layer = Layer.mergeAll( + mockOutput({ interactive: false }).layer, + localTarget, + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([file]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed([{ version: file.version, name: file.name }]), + applyPending: () => Effect.die("unused"), + markApplied: () => Effect.die("unused"), + }), + ), + ); + return Effect.gen(function* () { + const result = yield* listMigrations({ against: "local" }).pipe(Effect.provide(layer)); + expect(result.data).toEqual( + expect.objectContaining({ + migrations: [{ version: file.version, name: file.name, local: true, remote: true }], + }), + ); + }); + }); +}); + +describe("diffMigrations", () => { + it.live("previews drift against the named target", () => { + const layer = Layer.mergeAll( + mockOutput({ interactive: false }).layer, + localTarget, + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.die("unused"), + diffPools: () => Effect.succeed(planView(true)), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.die("unused"), + provisionMigrations: Effect.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + }), + ), + ); + return Effect.gen(function* () { + const result = yield* diffMigrations({ against: "local" }).pipe( + Effect.provide(layer), + Effect.provide(BunServices.layer), + ); + expect(result.status).toBe("drift"); + expect(result.mutatedDatabase).toBe(false); + }); + }); +}); diff --git a/apps/cli/src/shared/migrations/new-migration.ts b/apps/cli/src/shared/migrations/new-migration.ts new file mode 100644 index 0000000000..791eea191d --- /dev/null +++ b/apps/cli/src/shared/migrations/new-migration.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect"; +import { SchemaDraftConflictError, SchemaMigrationNameError } from "../schema/schema-errors.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import { SchemaWorkspace } from "../schema/schema-workspace.service.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; + +export const newMigration = Effect.fn("migrations.new")(function* (name: string | undefined) { + if (name === undefined || name.trim() === "") { + return yield* new SchemaMigrationNameError({ + detail: "Migration name is required.", + suggestion: "Pass a name, for example `supabase migrations new add_billing`.", + }); + } + const workspace = yield* SchemaWorkspace; + const repository = yield* MigrationRepository; + const state = yield* SchemaStateStore; + const journal = yield* state.readJournal; + if ( + journal._tag === "Some" && + journal.value.declarativelyAhead && + journal.value.generated !== true + ) { + return yield* new SchemaDraftConflictError({ + detail: "Migration files cannot change while a declarative draft is active.", + suggestion: "Run `supabase schema generate`, reset the local database, or discard the draft.", + }); + } + const created = yield* repository.createEmpty(name.trim()); + return { + status: "generated", + message: `Created ${workspace.migrationsDirDisplay}/${created.fileName}`, + data: { + status: "generated", + file: created.fileName, + version: created.version, + mutated_files: true, + mutated_database: false, + }, + nextActions: ["Author the SQL, then `supabase migrations apply` locally."], + mutatedDatabase: false, + mutatedFiles: true, + } satisfies SchemaCommandResult; +}); diff --git a/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts new file mode 100644 index 0000000000..2e891e52b1 --- /dev/null +++ b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Effect, Layer } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import type { SchemaPlanView } from "../schema/schema-types.ts"; +import { pullMigrations } from "./pull-migrations.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan", + source: { fingerprint: "s" }, + target: { fingerprint: "d" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: changes + ? [ + { + sequence: 1, + suffix: null, + sql: "create table t (id int);", + transactional: true, + actionCount: 1, + }, + ] + : [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +function setup(opts: { changes?: boolean } = {}) { + const out = mockOutput({ interactive: false }); + return { + layer: Layer.mergeAll( + out.layer, + Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => + Effect.succeed({ + kind: "linked", + identity: "abcdefghijklmnop", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: true, + projectRef: "abcdefghijklmnop", + }), + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => + Effect.succeed([ + { + version: "20260819120000", + name: "remote_schema", + fileName: "20260819120000_remote_schema.sql", + absolutePath: "/tmp/migrations/20260819120000_remote_schema.sql", + content: "create table t (id int);", + transactional: true, + }, + ]), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.die("unused"), + diffPools: () => Effect.succeed(planView(opts.changes === true)), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + provisionMigrations: Effect.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + }), + ), + ), + }; +} + +describe("pullMigrations", () => { + it.live("is a no-op when remote matches local replay", () => { + const { layer } = setup({ changes: false }); + return Effect.gen(function* () { + const result = yield* pullMigrations({}).pipe(Effect.provide(layer)); + expect(result.mutatedFiles).toBe(false); + expect(result.nextActions).toEqual([]); + }); + }); + + it.live("suggests migration repair for the written versions", () => { + const { layer } = setup({ changes: true }); + return Effect.gen(function* () { + const result = yield* pullMigrations({}).pipe(Effect.provide(layer)); + expect(result.mutatedFiles).toBe(true); + expect(result.nextActions.join("\n")).toContain( + "supabase migration repair --project-ref abcdefghijklmnop --status applied 20260819120000", + ); + }); + }); +}); diff --git a/apps/cli/src/shared/migrations/pull-migrations.ts b/apps/cli/src/shared/migrations/pull-migrations.ts new file mode 100644 index 0000000000..1223caaf39 --- /dev/null +++ b/apps/cli/src/shared/migrations/pull-migrations.ts @@ -0,0 +1,95 @@ +import { Clock, Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { parseTargetSelector } from "../database/database-target.ts"; +import { formatPlanSummary } from "../schema/schema-output.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import { formatMigrationRepairCommand, repairFlagsForTarget } from "./migration-repair-suggest.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; + +export type PullMigrationsInput = { + readonly from?: string; + readonly name?: string; +}; + +export const pullMigrations = Effect.fn("migrations.pull")(function* (input: PullMigrationsInput) { + const targets = yield* DatabaseTargetResolver; + const engine = yield* PgDeltaSchemaEngine; + const repository = yield* MigrationRepository; + const remote = yield* targets.resolve(parseTargetSelector(input.from ?? "linked")); + const name = input.name ?? "remote_schema"; + + return yield* Effect.scoped( + Effect.gen(function* () { + const remotePool = yield* acquireDatabasePool(remote.connectionString); + const shadow = yield* engine.provisionMigrations; + const sourcePool = yield* acquireDatabasePool(shadow.url); + const plan = yield* engine.diffPools({ + sourcePool, + desiredPool: remotePool, + allowDrops: true, + }); + + if (!plan.changes) { + return { + status: "clean", + message: "No remote drift to record.", + data: { + status: "clean", + plan_id: plan.planId, + mutated_files: false, + mutated_database: false, + }, + nextActions: [], + mutatedDatabase: false, + mutatedFiles: false, + } satisfies SchemaCommandResult; + } + + const written = yield* repository.writeGenerated({ + name, + baseMillis: yield* Clock.currentTimeMillis, + files: plan.files.map((file) => ({ + suffix: file.suffix, + sql: file.sql, + transactional: file.transactional, + })), + }); + + const summary = formatPlanSummary({ + title: "Migrations pull", + source: `migrations@${plan.sourceFingerprint.slice(0, 8)}`, + desired: `remote@${plan.desiredFingerprint.slice(0, 8)}`, + target: remote.identity, + plan, + }); + + const repair = formatMigrationRepairCommand({ + status: "applied", + versions: written.map((file) => file.version), + flags: repairFlagsForTarget(remote), + }); + const nextActions = [ + `Record the pulled version on the remote without re-applying: ${repair}`, + ]; + + return { + status: "generated", + message: `${summary}\nWrote ${written.map((file) => file.fileName).join(", ")}`, + data: { + status: "generated", + plan_id: plan.planId, + files_written: written.map((file) => file.fileName), + hazards: plan.hazards, + mutated_files: true, + mutated_database: false, + next_actions: nextActions, + }, + nextActions, + mutatedDatabase: false, + mutatedFiles: true, + } satisfies SchemaCommandResult; + }), + ); +}); diff --git a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts new file mode 100644 index 0000000000..e464f20159 --- /dev/null +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import type { DatabaseTarget } from "../database/database-target.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { SchemaLocalStackNotRunningError } from "../schema/schema-errors.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import type { SchemaDraftJournal, SchemaPlanView } from "../schema/schema-types.ts"; +import { SchemaWorkspace } from "../schema/schema-workspace.service.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; +import { pushMigrations } from "./push-migrations.ts"; + +const linked = { + kind: "linked" as const, + identity: "abcdefghijklmnop", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: false, + projectRef: "abcdefghijklmnop", +}; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan", + source: { fingerprint: "s" }, + target: { fingerprint: "d" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +const ungeneratedJournal: SchemaDraftJournal = { + version: 1, + draftId: "draft", + targetIdentity: "local:default", + startingMigrationHeadDigest: "abc", + sourceFingerprint: "s", + engineVersion: "0.3.0", + declarativelyAhead: true, + generated: false, + plans: [], +}; + +const pendingFile = { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, +}; + +function setup( + opts: { + declarations?: boolean; + ahead?: boolean; + localRunning?: boolean; + drift?: boolean; + driftResults?: ReadonlyArray; + journal?: SchemaDraftJournal; + files?: ReadonlyArray; + history?: ReadonlyArray<{ version: string; name: string }>; + target?: DatabaseTarget; + } = {}, +) { + const out = mockOutput({ interactive: false }); + let shadowProvisions = 0; + let diffCalls = 0; + const layer = Layer.mergeAll( + out.layer, + Layer.succeed( + SchemaWorkspace, + SchemaWorkspace.of({ + schemasDir: "/tmp/schemas", + schemasDirDisplay: "supabase/schemas", + migrationsDir: "/tmp/migrations", + migrationsDirDisplay: "supabase/migrations", + customDir: "/tmp/schemas/_custom", + journalPath: "/tmp/.supabase/schema-draft.json", + lockPath: "/tmp/.supabase/schema.lock", + readDeclarationFiles: Effect.succeed( + opts.declarations === false ? [] : [{ name: "a.sql", sql: "create table a (id int);" }], + ), + readExistingSql: () => Effect.succeed(new Map()), + classifyProposed: () => Effect.die("unused"), + installExport: () => Effect.die("unused"), + }), + ), + Layer.succeed( + SchemaStateStore, + SchemaStateStore.of({ + readJournal: Effect.succeed( + opts.journal === undefined ? Option.none() : Option.some(opts.journal), + ), + writeJournal: () => Effect.void, + clearJournal: Effect.void, + withLock: (effect) => effect, + }), + ), + Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: (selector) => { + if (selector.kind === "local" && opts.localRunning === false) { + return Effect.fail( + new SchemaLocalStackNotRunningError({ + detail: "No local Supabase stack is running for this project.", + suggestion: "Run `supabase start`, then retry.", + }), + ); + } + return Effect.succeed(opts.target ?? linked); + }, + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed(opts.files ?? []), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed(opts.history ?? []), + applyPending: () => Effect.succeed({ applied: [], skipped: [] }), + markApplied: () => Effect.die("unused"), + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.succeed(planView(opts.ahead === true)), + diffPools: () => { + const changes = + opts.driftResults !== undefined + ? opts.driftResults[diffCalls] === true + : opts.drift === true; + diffCalls += 1; + return Effect.succeed(planView(changes)); + }, + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.sync(() => { + shadowProvisions += 1; + return { url: "postgresql://postgres:postgres@127.0.0.1:1/postgres" }; + }), + provisionMigrations: Effect.sync(() => { + shadowProvisions += 1; + return { url: "postgresql://postgres:postgres@127.0.0.1:1/postgres" }; + }), + }), + ), + ); + return { + layer, + get shadowProvisions() { + return shadowProvisions; + }, + }; +} + +const pushFlags = { + yes: true, + allowRemote: false, + projectRef: "abcdefghijklmnop", + skipVerify: false, +} as const; + +describe("pushMigrations", () => { + it.live("fails closed when an ungenerated draft is active", () => { + const { layer } = setup({ journal: ungeneratedJournal }); + return Effect.gen(function* () { + const exit = yield* pushMigrations(pushFlags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("fails closed when live M to D still has changes", () => { + const { layer } = setup({ declarations: true, ahead: true }); + return Effect.gen(function* () { + const exit = yield* pushMigrations(pushFlags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("checks remote drift even when no local stack is running", () => { + const ctx = setup({ + declarations: false, + localRunning: false, + drift: true, + }); + return Effect.gen(function* () { + const exit = yield* pushMigrations(pushFlags).pipe(Effect.provide(ctx.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("supabase migrations pull --from linked"); + expect(ctx.shadowProvisions).toBe(1); + }); + }); + + it.live("suggests migration repair when a pending prefix already matches the remote", () => { + const { layer } = setup({ + declarations: false, + driftResults: [true, false], + files: [pendingFile], + }); + return Effect.gen(function* () { + const exit = yield* pushMigrations(pushFlags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "supabase migration repair --project-ref abcdefghijklmnop --status applied 20260101000000", + ); + }); + }); + + it.live("suggests reverted repair for remote-only versions", () => { + const { layer } = setup({ + declarations: false, + driftResults: [true, true], + files: [pendingFile], + history: [{ version: "19990101000000", name: "other" }], + }); + return Effect.gen(function* () { + const exit = yield* pushMigrations(pushFlags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "supabase migration repair --project-ref abcdefghijklmnop --status reverted 19990101000000", + ); + expect(JSON.stringify(exit)).toContain("supabase migrations pull --from linked"); + }); + }); + + it.live("pushes when replay matches declarations and the remote", () => { + const ctx = setup({ + declarations: true, + ahead: false, + localRunning: false, + drift: false, + }); + return Effect.gen(function* () { + const result = yield* pushMigrations(pushFlags).pipe(Effect.provide(ctx.layer)); + expect(result.mutatedDatabase).toBe(false); + expect(ctx.shadowProvisions).toBe(3); + }); + }); + + it.live("still refuses an ungenerated draft when --skip-verify is set", () => { + const { layer } = setup({ journal: ungeneratedJournal }); + return Effect.gen(function* () { + const exit = yield* pushMigrations({ ...pushFlags, skipVerify: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("prefills target-aware repair when skip-verify hits remote-only history", () => { + const { layer } = setup({ + files: [pendingFile], + history: [{ version: "19990101000000", name: "other" }], + target: { + kind: "url", + identity: "connection-string", + connectionString: "postgresql://postgres:secret@db.example/postgres", + disposable: false, + durable: true, + connectionVerified: false, + connectionSource: "flag", + }, + }); + return Effect.gen(function* () { + const exit = yield* pushMigrations({ + yes: true, + allowRemote: true, + skipVerify: true, + dbUrl: "postgresql://postgres:secret@db.example/postgres", + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "supabase migration repair --db-url --status reverted 19990101000000", + ); + }); + }); + + it.live("skips shadow verify when --skip-verify is set", () => { + const ctx = setup({ + declarations: true, + ahead: true, + drift: true, + }); + return Effect.gen(function* () { + const result = yield* pushMigrations({ ...pushFlags, skipVerify: true }).pipe( + Effect.provide(ctx.layer), + ); + expect(result.mutatedDatabase).toBe(false); + expect(ctx.shadowProvisions).toBe(0); + }); + }); +}); diff --git a/apps/cli/src/shared/migrations/push-migrations.ts b/apps/cli/src/shared/migrations/push-migrations.ts new file mode 100644 index 0000000000..be9c019b82 --- /dev/null +++ b/apps/cli/src/shared/migrations/push-migrations.ts @@ -0,0 +1,156 @@ +import { Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { authorizeMutation } from "../database/destructive-auth.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { assertNoUngeneratedDraft } from "../schema/declarations-ahead.ts"; +import { + SchemaDeclarationsAheadError, + SchemaHistoryConflictError, + SchemaRemoteDriftError, +} from "../schema/schema-errors.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { SchemaWorkspace } from "../schema/schema-workspace.service.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import { findMatchingPendingPrefix } from "./matching-pending-prefix.ts"; +import { + formatHistoryConflict, + repairFlagsForTarget, + suggestRemoteDriftRepair, +} from "./migration-repair-suggest.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +export type PushMigrationsInput = { + readonly yes: boolean; + readonly projectRef?: string; + readonly allowRemote: boolean; + readonly dbUrl?: string; + readonly skipVerify: boolean; +}; + +export const pushMigrations = Effect.fn("migrations.push")(function* (input: PushMigrationsInput) { + const targets = yield* DatabaseTargetResolver; + const repository = yield* MigrationRepository; + const runner = yield* MigrationRunner; + const engine = yield* PgDeltaSchemaEngine; + const workspace = yield* SchemaWorkspace; + + yield* assertNoUngeneratedDraft(); + + const remote = yield* targets.resolve( + input.dbUrl !== undefined ? { kind: "url", url: input.dbUrl } : { kind: "linked" }, + ); + const localFiles = yield* repository.listLocal; + const declarations = yield* workspace.readDeclarationFiles; + + return yield* Effect.scoped( + Effect.gen(function* () { + const remotePool = yield* acquireDatabasePool(remote.connectionString); + const remoteHistory = yield* runner.listRemote(remotePool); + const remoteVersions = new Set(remoteHistory.map((row) => row.version)); + + yield* authorizeMutation({ + target: remote, + flags: { + yes: input.yes, + allowRemote: input.allowRemote, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }, + command: "migrations push", + }); + + if (!input.skipVerify) { + if (declarations.length > 0) { + const sourceShadow = yield* engine.provisionMigrations; + const desiredShadow = yield* engine.provisionShadow; + const sourcePool = yield* acquireDatabasePool(sourceShadow.url); + const desiredPool = yield* acquireDatabasePool(desiredShadow.url); + const ahead = yield* engine.planFiles({ + targetPool: sourcePool, + shadowPool: desiredPool, + files: declarations, + allowDrops: true, + }); + if (ahead.changes) { + return yield* new SchemaDeclarationsAheadError({ + detail: "Declarations and local migration files have diverged.", + suggestion: + "Update `supabase/schemas` to include hand-written migration changes, or run `supabase schema generate --name ` if declarations are the intended state.", + }); + } + } + + const driftShadow = yield* engine.provisionShadow; + const replayPool = yield* acquireDatabasePool(driftShadow.url); + const replayed = localFiles.filter((file) => remoteVersions.has(file.version)); + yield* runner.applyPending(replayPool, replayed); + const drift = yield* engine.diffPools({ + sourcePool: replayPool, + desiredPool: remotePool, + allowDrops: true, + }); + if (drift.changes) { + const remoteOnly = remoteHistory + .filter((row) => !localFiles.some((file) => file.version === row.version)) + .map((row) => row.version); + const pending = localFiles.filter((file) => !remoteVersions.has(file.version)); + const matchingPrefix = yield* findMatchingPendingPrefix( + replayPool, + remotePool, + replayed, + pending, + ); + return yield* new SchemaRemoteDriftError({ + detail: "Remote database shape has drifted from migration replay.", + suggestion: suggestRemoteDriftRepair({ + remoteOnly, + matchingPrefix: matchingPrefix.map((file) => file.version), + flags: repairFlagsForTarget(remote, { + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + ...(input.dbUrl !== undefined ? { dbUrl: input.dbUrl } : {}), + }), + }), + }); + } + } + + const pending = localFiles.filter((file) => !remoteVersions.has(file.version)); + const remoteOnly = remoteHistory.filter( + (row) => !localFiles.some((file) => file.version === row.version), + ); + if (remoteOnly.length > 0 && pending.length > 0) { + return yield* new SchemaHistoryConflictError( + formatHistoryConflict({ + remoteOnly: remoteOnly.map((row) => row.version), + pending: pending.map((file) => file.version), + flags: repairFlagsForTarget(remote, { + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + ...(input.dbUrl !== undefined ? { dbUrl: input.dbUrl } : {}), + }), + }), + ); + } + + const result = yield* runner.applyPending(remotePool, localFiles); + return { + status: "clean", + message: + result.applied.length === 0 + ? "Remote database is up to date." + : `Pushed ${result.applied.length} migration(s) to ${remote.identity}.`, + data: { + status: "clean", + target: remote.identity, + applied: result.applied, + skipped: result.skipped, + mutated_database: result.applied.length > 0, + mutated_files: false, + next_actions: [], + }, + nextActions: [], + mutatedDatabase: result.applied.length > 0, + mutatedFiles: false, + } satisfies SchemaCommandResult; + }), + ); +}); diff --git a/apps/cli/src/shared/schema/apply-schema.integration.test.ts b/apps/cli/src/shared/schema/apply-schema.integration.test.ts new file mode 100644 index 0000000000..d5bac96059 --- /dev/null +++ b/apps/cli/src/shared/schema/apply-schema.integration.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import { MigrationRunner } from "../migrations/migration-runner.service.ts"; +import { applySchema } from "./apply-schema.ts"; +import { digestVersions } from "./schema-digest.ts"; +import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaDraftJournal, SchemaPlanView } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan-1", + source: { fingerprint: "source-fingerprint" }, + target: { fingerprint: "desired-fingerprint" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +const localFile = { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, +}; + +const ungeneratedJournal: SchemaDraftJournal = { + version: 1, + draftId: "draft", + targetIdentity: "local:default", + startingMigrationHeadDigest: digestVersions([localFile.version]), + sourceFingerprint: "s", + engineVersion: "0.3.0", + declarativelyAhead: true, + generated: false, + plans: [], +}; + +function setup( + opts: { + journal?: SchemaDraftJournal; + history?: ReadonlyArray<{ version: string; name: string }>; + catalogMatch?: boolean; + planChanges?: boolean; + } = {}, +) { + const out = mockOutput({ interactive: false }); + let applyPending = 0; + let marked = 0; + let journaled = false; + return { + get applyPending() { + return applyPending; + }, + get marked() { + return marked; + }, + get journaled() { + return journaled; + }, + layer: Layer.mergeAll( + out.layer, + Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => + Effect.succeed({ + kind: "local", + identity: "local:default", + connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + disposable: true, + durable: false, + connectionVerified: true, + }), + }), + ), + Layer.succeed( + SchemaWorkspace, + SchemaWorkspace.of({ + schemasDir: "/tmp/schemas", + schemasDirDisplay: "supabase/schemas", + migrationsDir: "/tmp/migrations", + migrationsDirDisplay: "supabase/migrations", + customDir: "/tmp/schemas/_custom", + journalPath: "/tmp/j", + lockPath: "/tmp/l", + readDeclarationFiles: Effect.succeed([]), + readExistingSql: () => Effect.succeed(new Map()), + classifyProposed: () => Effect.die("unused"), + installExport: () => Effect.die("unused"), + }), + ), + Layer.succeed( + SchemaStateStore, + SchemaStateStore.of({ + readJournal: Effect.succeed( + opts.journal === undefined ? Option.none() : Option.some(opts.journal), + ), + writeJournal: () => + Effect.sync(() => { + journaled = true; + }), + clearJournal: Effect.void, + withLock: (effect) => effect, + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([localFile]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed(opts.history ?? []), + applyPending: () => + Effect.sync(() => { + applyPending += 1; + return { applied: [], skipped: [] }; + }), + markApplied: () => + Effect.sync(() => { + marked += 1; + }), + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.succeed(planView(opts.planChanges === true)), + diffPools: () => Effect.succeed(planView(opts.catalogMatch !== true)), + applyPlan: () => + Effect.succeed({ + partial: false, + report: { + status: "applied", + appliedActions: 0, + actionStatuses: [], + }, + }), + provisionShadow: Effect.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + provisionMigrations: Effect.die("unused"), + }), + ), + ), + }; +} + +const flags = { yes: true, allowRemote: false } as const; + +describe("applySchema", () => { + it.live("runs pending SQL when the live catalog does not match full replay", () => { + const ctx = setup(); + return Effect.gen(function* () { + const result = yield* applySchema(flags).pipe(Effect.provide(ctx.layer)); + expect(result.status).toBe("clean"); + expect(ctx.applyPending).toBe(2); + expect(ctx.marked).toBe(0); + expect(ctx.journaled).toBe(false); + }); + }); + + it.live("marks history when the live catalog already matches full replay", () => { + const ctx = setup({ catalogMatch: true }); + return Effect.gen(function* () { + const result = yield* applySchema(flags).pipe(Effect.provide(ctx.layer)); + expect(result.status).toBe("clean"); + expect(result.message).toContain("Recorded"); + expect(ctx.marked).toBe(1); + }); + }); + + it.live("skips file apply while an ungenerated draft is active", () => { + const ctx = setup({ journal: ungeneratedJournal }); + return Effect.gen(function* () { + const result = yield* applySchema(flags).pipe(Effect.provide(ctx.layer)); + expect(result.status).toBe("clean"); + expect(ctx.applyPending).toBe(0); + expect(ctx.marked).toBe(0); + }); + }); + + it.live("fails closed when migration files change during an ungenerated draft", () => { + const ctx = setup({ + journal: { + ...ungeneratedJournal, + startingMigrationHeadDigest: "not-the-current-head", + }, + }); + return Effect.gen(function* () { + const exit = yield* applySchema(flags).pipe(Effect.provide(ctx.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(ctx.applyPending).toBe(0); + }); + }); + + it.live("journals a draft after applying declarations to the local database", () => { + const ctx = setup({ + history: [{ version: localFile.version, name: localFile.name }], + planChanges: true, + }); + return Effect.gen(function* () { + const result = yield* applySchema(flags).pipe(Effect.provide(ctx.layer)); + expect(result.status).toBe("draft"); + expect(result.mutatedDatabase).toBe(true); + expect(ctx.journaled).toBe(true); + }); + }); +}); diff --git a/apps/cli/src/shared/schema/apply-schema.ts b/apps/cli/src/shared/schema/apply-schema.ts new file mode 100644 index 0000000000..e38776c12c --- /dev/null +++ b/apps/cli/src/shared/schema/apply-schema.ts @@ -0,0 +1,186 @@ +import { Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { authorizeMutation } from "../database/destructive-auth.ts"; +import { applyLocalPending } from "../migrations/apply-local-pending.ts"; +import { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import type { MigrationApplyResult } from "../migrations/migration-runner.service.ts"; +import { digestUtf8, digestVersions } from "./schema-digest.ts"; +import { + SchemaDraftConflictError, + SchemaDurableTargetError, + SchemaPartialApplyError, +} from "./schema-errors.ts"; +import { formatPlanSummary } from "./schema-output.ts"; +import { assertPlanActionable } from "./schema-plan-gate.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaCommandResult, SchemaDraftJournal } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; +import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; + +export type ApplySchemaInput = { + readonly yes: boolean; + readonly projectRef?: string; + readonly allowRemote: boolean; +}; + +export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySchemaInput) { + const workspace = yield* SchemaWorkspace; + const state = yield* SchemaStateStore; + const engine = yield* PgDeltaSchemaEngine; + const targets = yield* DatabaseTargetResolver; + const migrations = yield* MigrationRepository; + + const target = yield* targets.resolve({ kind: "local" }); + if (!target.disposable) { + return yield* new SchemaDurableTargetError({ + detail: "schema apply can only mutate a verified local disposable database.", + suggestion: + "Start the local stack and rerun, or use schema generate + migrations push for durable targets.", + }); + } + + const declarations = yield* workspace.readDeclarationFiles; + const localMigrations = yield* migrations.listLocal; + + return yield* state.withLock( + Effect.scoped( + Effect.gen(function* () { + const pool = yield* acquireDatabasePool(target.connectionString); + const existingJournal = yield* state.readJournal; + const ungeneratedDraft = + existingJournal._tag === "Some" && + existingJournal.value.declarativelyAhead && + existingJournal.value.generated !== true; + const pendingResult: MigrationApplyResult = + ungeneratedDraft === true + ? { applied: [], recorded: [], skipped: [] } + : yield* applyLocalPending(pool, localMigrations); + if (ungeneratedDraft) { + const currentHead = digestVersions(localMigrations.map((file) => file.version)); + if (currentHead !== existingJournal.value.startingMigrationHeadDigest) { + return yield* new SchemaDraftConflictError({ + detail: "Migration files changed while a declarative draft is active.", + suggestion: + "Run `supabase schema generate`, reset the local database, or discard the draft.", + }); + } + } + + const shadow = yield* engine.provisionShadow; + const shadowPool = yield* acquireDatabasePool(shadow.url); + const plan = yield* engine.planFiles({ + targetPool: pool, + shadowPool, + files: declarations, + allowDrops: true, + }); + + yield* assertPlanActionable(plan); + yield* authorizeMutation({ + target, + flags: { + yes: input.yes, + allowRemote: input.allowRemote, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }, + command: "schema apply", + }); + + if (!plan.changes) { + const recorded = pendingResult.recorded ?? []; + const mutatedDatabase = pendingResult.applied.length > 0 || recorded.length > 0; + const parts = [ + ...(recorded.length > 0 + ? [`Recorded ${recorded.length} already-applied migration(s): ${recorded.join(", ")}`] + : []), + ...(pendingResult.applied.length > 0 + ? [ + `Applied ${pendingResult.applied.length} migration(s): ${pendingResult.applied.join(", ")}`, + ] + : []), + ]; + return { + status: "clean", + message: + parts.length > 0 ? parts.join(". ") : "Local database already matches declarations.", + data: { + status: "clean", + target: target.identity, + plan_id: plan.planId, + applied: pendingResult.applied, + recorded, + mutated_database: mutatedDatabase, + mutated_files: false, + }, + nextActions: [], + mutatedDatabase, + mutatedFiles: false, + } satisfies SchemaCommandResult; + } + + const outcome = yield* engine.applyPlan({ pool, plan }); + const journal: SchemaDraftJournal = { + version: 1, + draftId: crypto.randomUUID(), + targetIdentity: target.identity, + startingMigrationHeadDigest: digestVersions(localMigrations.map((file) => file.version)), + sourceFingerprint: plan.sourceFingerprint, + engineVersion: plan.engineVersion, + declarativelyAhead: true, + generated: false, + plans: [ + { + planId: plan.planId, + targetFingerprint: plan.desiredFingerprint, + acceptedRenames: plan.acceptedRenames, + segmentDigests: plan.files.map((file) => digestUtf8(file.sql)), + hazards: { + kinds: plan.hazards.kinds, + destructive: plan.hazards.destructive, + rewrite: plan.hazards.rewrite, + coverageGaps: plan.hazards.coverageGaps, + }, + actionStatuses: outcome.report.actionStatuses, + outcome: outcome.partial ? "partial" : "applied", + }, + ], + }; + yield* state.writeJournal(journal); + + if (outcome.partial) { + return yield* new SchemaPartialApplyError({ + detail: "schema apply stopped after a partial or in-doubt segment.", + suggestion: "Reset or repair the local database. Do not retry the same plan blindly.", + }); + } + + const summary = formatPlanSummary({ + title: "Schema apply", + source: `local@${plan.sourceFingerprint.slice(0, 8)}`, + desired: `declarations@${plan.desiredFingerprint.slice(0, 8)}`, + target: target.identity, + plan, + }); + + return { + status: "draft", + message: `${summary}\nResult: applied locally and journaled. No migration files were written.`, + data: { + status: "draft", + plan_id: plan.planId, + hazards: plan.hazards, + target: target.identity, + journaled: true, + mutated_database: true, + mutated_files: false, + next_actions: ["Run tests, then supabase schema generate --name "], + }, + nextActions: ["Run tests, then supabase schema generate --name "], + mutatedDatabase: true, + mutatedFiles: false, + } satisfies SchemaCommandResult; + }), + ), + ); +}); diff --git a/apps/cli/src/shared/schema/clear-draft-journal.ts b/apps/cli/src/shared/schema/clear-draft-journal.ts new file mode 100644 index 0000000000..f23fc82585 --- /dev/null +++ b/apps/cli/src/shared/schema/clear-draft-journal.ts @@ -0,0 +1,16 @@ +import { Effect, FileSystem, Path } from "effect"; +import { SCHEMA_DRAFT_JOURNAL_FILE_NAME } from "./schema-paths.ts"; + +/** Unlink `.supabase/schema-draft.json`. Missing file is success. */ +export const clearDraftJournalFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +) => + fs + .remove(path.join(workdir, ".supabase", SCHEMA_DRAFT_JOURNAL_FILE_NAME)) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error), + ), + ); diff --git a/apps/cli/src/shared/schema/clear-draft-journal.unit.test.ts b/apps/cli/src/shared/schema/clear-draft-journal.unit.test.ts new file mode 100644 index 0000000000..0567398a0e --- /dev/null +++ b/apps/cli/src/shared/schema/clear-draft-journal.unit.test.ts @@ -0,0 +1,26 @@ +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { clearDraftJournalFile } from "./clear-draft-journal.ts"; +import { SCHEMA_DRAFT_JOURNAL_FILE_NAME } from "./schema-paths.ts"; + +describe("clearDraftJournalFile", () => { + it.live("unlinks the draft journal and treats a missing file as success", () => { + const workdir = mkdtempSync(join(tmpdir(), "clear-draft-")); + const journalDir = join(workdir, ".supabase"); + const journalPath = join(journalDir, SCHEMA_DRAFT_JOURNAL_FILE_NAME); + mkdirSync(journalDir, { recursive: true }); + writeFileSync(journalPath, "{}\n"); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* clearDraftJournalFile(fs, path, workdir); + expect(existsSync(journalPath)).toBe(false); + yield* clearDraftJournalFile(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/shared/schema/declarations-ahead.ts b/apps/cli/src/shared/schema/declarations-ahead.ts new file mode 100644 index 0000000000..053beb7f76 --- /dev/null +++ b/apps/cli/src/shared/schema/declarations-ahead.ts @@ -0,0 +1,19 @@ +import { Effect } from "effect"; +import { SchemaDeclarationsAheadError } from "./schema-errors.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; + +export const assertNoUngeneratedDraft = Effect.fnUntraced(function* () { + const state = yield* SchemaStateStore; + const journal = yield* state.readJournal; + if ( + journal._tag === "Some" && + journal.value.declarativelyAhead && + journal.value.generated !== true + ) { + return yield* new SchemaDeclarationsAheadError({ + detail: "A declarative draft is ahead of the local migration head.", + suggestion: + "Run `supabase schema generate --name `, reset the local database, or discard the draft before `supabase migrations push`.", + }); + } +}); diff --git a/apps/cli/src/shared/schema/generate-schema.integration.test.ts b/apps/cli/src/shared/schema/generate-schema.integration.test.ts new file mode 100644 index 0000000000..4f9ce1a44a --- /dev/null +++ b/apps/cli/src/shared/schema/generate-schema.integration.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import { digestVersions } from "./schema-digest.ts"; +import { generateSchema } from "./generate-schema.ts"; +import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; +import { SchemaBaselineMigrationsExistError } from "./schema-errors.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaDraftJournal, SchemaPlanView } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; + +function emptyPlan(): Plan { + return { + formatVersion: 1, + engineVersion: "0.3.0", + planId: "plan-1", + source: { fingerprint: "source-fingerprint" }, + target: { fingerprint: "desired-fingerprint" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + }; +} + +function planView(changes: boolean): SchemaPlanView { + const plan = emptyPlan(); + return { + planId: plan.planId, + sourceFingerprint: plan.source.fingerprint, + desiredFingerprint: plan.target.fingerprint, + engineVersion: plan.engineVersion, + profile: "supabase", + changes, + files: changes + ? [ + { + sequence: 1, + suffix: null, + sql: "create table t (id int);", + transactional: true, + actionCount: 1, + }, + ] + : [], + hazards: { + kinds: [], + destructive: 0, + rewrite: 0, + coverageGaps: 0, + report: classifyPlanHazards(plan), + }, + destructive: false, + renameCandidates: [], + acceptedRenames: [], + coverageBlocked: false, + renameBlocked: false, + plan, + }; +} + +const draftJournal: SchemaDraftJournal = { + version: 1, + draftId: "draft", + targetIdentity: "local:default", + startingMigrationHeadDigest: digestVersions(["20260101000000"]), + sourceFingerprint: "s", + engineVersion: "0.3.0", + declarativelyAhead: true, + generated: false, + plans: [], +}; + +function setup( + opts: { + changes?: boolean; + journal?: SchemaDraftJournal; + write?: boolean; + localMigrations?: "seeded" | "empty"; + } = {}, +) { + const out = mockOutput({ interactive: false }); + let cleared = false; + let planCalls = 0; + let wrote = false; + let shadowProvisions = 0; + const layer = Layer.mergeAll( + out.layer, + Layer.succeed( + SchemaWorkspace, + SchemaWorkspace.of({ + schemasDir: "/tmp/schemas", + schemasDirDisplay: "supabase/schemas", + migrationsDir: "/tmp/migrations", + migrationsDirDisplay: "supabase/migrations", + customDir: "/tmp/schemas/_custom", + journalPath: "/tmp/j", + lockPath: "/tmp/l", + readDeclarationFiles: Effect.succeed([{ name: "a.sql", sql: "create table a (id int);" }]), + readExistingSql: () => Effect.succeed(new Map()), + classifyProposed: () => Effect.die("unused"), + installExport: () => Effect.die("unused"), + }), + ), + Layer.succeed( + SchemaStateStore, + SchemaStateStore.of({ + readJournal: Effect.succeed( + opts.journal === undefined ? Option.none() : Option.some(opts.journal), + ), + writeJournal: () => Effect.void, + clearJournal: Effect.sync(() => { + cleared = true; + }), + withLock: (effect) => effect, + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed( + opts.localMigrations === "empty" + ? [] + : [ + { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, + }, + ], + ), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => + Effect.sync(() => { + wrote = true; + }).pipe( + Effect.andThen( + opts.write === true + ? Effect.succeed([ + { + version: "20260101000001", + name: "schema", + fileName: "20260101000001_schema.sql", + absolutePath: "/tmp/migrations/20260101000001_schema.sql", + content: "create table t (id int);", + transactional: true, + }, + ]) + : Effect.die("unused"), + ), + ), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => + Effect.sync(() => { + planCalls += 1; + if (opts.write === true) { + return planView(planCalls === 1); + } + return planView(opts.changes === true); + }), + diffPools: () => Effect.die("unused"), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.sync(() => { + shadowProvisions += 1; + return { + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }; + }), + provisionMigrations: Effect.sync(() => { + shadowProvisions += 1; + return { + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }; + }), + }), + ), + ); + return { + layer, + get cleared() { + return cleared; + }, + get wrote() { + return wrote; + }, + get shadowProvisions() { + return shadowProvisions; + }, + }; +} + +describe("generateSchema", () => { + it.live("plans without a local database target and never records history", () => { + const ctx = setup({ changes: false }); + return Effect.gen(function* () { + const result = yield* generateSchema({ dryRun: true, baseline: false }).pipe( + Effect.provide(ctx.layer), + ); + expect(result.mutatedDatabase).toBe(false); + expect(result.mutatedFiles).toBe(false); + expect(ctx.cleared).toBe(false); + }); + }); + + it.live("clears a leftover draft when generate finds no changes", () => { + const ctx = setup({ changes: false, journal: draftJournal }); + return Effect.gen(function* () { + const result = yield* generateSchema({ dryRun: false, baseline: false }).pipe( + Effect.provide(ctx.layer), + ); + expect(result.status).toBe("clean"); + expect(result.mutatedFiles).toBe(false); + expect(ctx.cleared).toBe(true); + }); + }); + + it.live("fails closed when migration files change during an ungenerated draft", () => { + const ctx = setup({ + journal: { + ...draftJournal, + startingMigrationHeadDigest: "not-the-current-head", + }, + }); + return Effect.gen(function* () { + const exit = yield* generateSchema({ dryRun: true, baseline: false }).pipe( + Effect.provide(ctx.layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(ctx.cleared).toBe(false); + }); + }); + + it.live("clears the draft journal after writing files", () => { + const ctx = setup({ write: true, journal: draftJournal }); + return Effect.gen(function* () { + const result = yield* generateSchema({ dryRun: false, baseline: false }).pipe( + Effect.provide(ctx.layer), + ); + expect(result.mutatedFiles).toBe(true); + expect(result.mutatedDatabase).toBe(false); + expect(ctx.cleared).toBe(true); + expect(result.nextActions.join("\n")).not.toContain("migration repair"); + }); + }); + + it.live("suggests migration repair after writing a baseline", () => { + const ctx = setup({ write: true, localMigrations: "empty" }); + return Effect.gen(function* () { + const result = yield* generateSchema({ dryRun: false, baseline: true }).pipe( + Effect.provide(ctx.layer), + ); + expect(result.nextActions.join("\n")).toContain( + "supabase migration repair --status applied 20260101000001", + ); + }); + }); + + it.live("fails closed when --baseline runs against existing migration files", () => { + const ctx = setup(); + return Effect.gen(function* () { + const exit = yield* generateSchema({ dryRun: false, baseline: true }).pipe( + Effect.provide(ctx.layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(failure._tag).toBe("Some"); + if (failure._tag === "Some") { + expect(failure.value).toBeInstanceOf(SchemaBaselineMigrationsExistError); + expect(failure.value._tag).toBe("SchemaBaselineMigrationsExistError"); + } + expect(ctx.wrote).toBe(false); + expect(ctx.shadowProvisions).toBe(0); + expect(ctx.cleared).toBe(false); + }); + }); + + it.live("fails closed when dry-run --baseline runs against existing migration files", () => { + const ctx = setup(); + return Effect.gen(function* () { + const exit = yield* generateSchema({ dryRun: true, baseline: true }).pipe( + Effect.provide(ctx.layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(failure._tag).toBe("Some"); + if (failure._tag === "Some") { + expect(failure.value).toBeInstanceOf(SchemaBaselineMigrationsExistError); + expect(failure.value._tag).toBe("SchemaBaselineMigrationsExistError"); + } + expect(ctx.wrote).toBe(false); + expect(ctx.shadowProvisions).toBe(0); + }); + }); +}); diff --git a/apps/cli/src/shared/schema/generate-schema.ts b/apps/cli/src/shared/schema/generate-schema.ts new file mode 100644 index 0000000000..dbde59b6eb --- /dev/null +++ b/apps/cli/src/shared/schema/generate-schema.ts @@ -0,0 +1,180 @@ +import { Clock, Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import { formatMigrationRepairCommand } from "../migrations/migration-repair-suggest.ts"; +import { digestVersions } from "./schema-digest.ts"; +import { + SchemaBaselineMigrationsExistError, + SchemaDraftConflictError, + SchemaEngineError, +} from "./schema-errors.ts"; +import { formatPlanSummary } from "./schema-output.ts"; +import { assertPlanActionable } from "./schema-plan-gate.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaCommandResult } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; +import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; + +export type GenerateSchemaInput = { + readonly name?: string; + readonly dryRun: boolean; + readonly baseline: boolean; +}; + +export const generateSchema = Effect.fn("schema.generate")(function* (input: GenerateSchemaInput) { + const workspace = yield* SchemaWorkspace; + const state = yield* SchemaStateStore; + const engine = yield* PgDeltaSchemaEngine; + const migrations = yield* MigrationRepository; + + const declarations = yield* workspace.readDeclarationFiles; + const localMigrations = yield* migrations.listLocal; + const name = input.name ?? (input.baseline ? "initial_schema" : "schema"); + + if (input.baseline && localMigrations.length > 0) { + return yield* new SchemaBaselineMigrationsExistError({ + detail: `--baseline cannot run because ${workspace.migrationsDirDisplay} already has files.`, + suggestion: + "supabase schema generate --dry-run to preview, or supabase schema generate --name to add a change. --baseline is only for empty migration history.", + }); + } + + return yield* state.withLock( + Effect.scoped( + Effect.gen(function* () { + const journal = yield* state.readJournal; + if ( + journal._tag === "Some" && + journal.value.declarativelyAhead && + journal.value.generated !== true + ) { + const currentHead = digestVersions(localMigrations.map((file) => file.version)); + if (currentHead !== journal.value.startingMigrationHeadDigest) { + return yield* new SchemaDraftConflictError({ + detail: "Migration files changed while a declarative draft is active.", + suggestion: + "Reset the local database, discard the draft, or restore the migration files from before schema apply.", + }); + } + } + + const sourceShadow = input.baseline + ? yield* engine.provisionShadow + : yield* engine.provisionMigrations; + const desiredShadow = yield* engine.provisionShadow; + + const sourcePool = yield* acquireDatabasePool(sourceShadow.url); + const desiredPool = yield* acquireDatabasePool(desiredShadow.url); + + const plan = yield* engine.planFiles({ + targetPool: sourcePool, + shadowPool: desiredPool, + files: declarations, + allowDrops: true, + }); + + if (!input.dryRun) { + yield* assertPlanActionable(plan); + } + + const summary = formatPlanSummary({ + title: "Schema plan", + source: `migrations@${plan.sourceFingerprint.slice(0, 8)}`, + desired: `declarations@${plan.desiredFingerprint.slice(0, 8)}`, + target: "clean migration replay (no live database writes)", + plan, + }); + + if (input.dryRun || !plan.changes) { + if (!input.dryRun && !plan.changes) { + yield* state.clearJournal; + } + return { + status: plan.changes ? "needs_approval" : "clean", + message: plan.changes + ? `${summary}\nResult: dry-run; nothing was changed` + : "Declarations already match migration replay.", + data: { + status: plan.changes ? "needs_approval" : "clean", + plan_id: plan.planId, + source_fingerprint: plan.sourceFingerprint, + desired_fingerprint: plan.desiredFingerprint, + hazards: plan.hazards, + files_written: [], + mutated_database: false, + mutated_files: false, + next_actions: plan.changes ? [`supabase schema generate --name ${name}`] : [], + }, + nextActions: plan.changes ? [`supabase schema generate --name ${name}`] : [], + mutatedDatabase: false, + mutatedFiles: false, + } satisfies SchemaCommandResult; + } + + const written = yield* migrations.writeGenerated({ + name, + baseMillis: yield* Clock.currentTimeMillis, + files: plan.files.map((file) => ({ + suffix: file.suffix, + sql: file.sql, + transactional: file.transactional, + })), + }); + + const persistGenerated = Effect.gen(function* () { + const verifyShadow = yield* engine.provisionMigrations; + const verifySource = yield* acquireDatabasePool(verifyShadow.url); + const verifyDesired = yield* engine.provisionShadow; + const verifyDesiredPool = yield* acquireDatabasePool(verifyDesired.url); + const verify = yield* engine.planFiles({ + targetPool: verifySource, + shadowPool: verifyDesiredPool, + files: declarations, + allowDrops: true, + }); + if (verify.changes) { + return yield* new SchemaEngineError({ + detail: "Generated migrations did not converge to the declared schema.", + suggestion: "Inspect the generated files and rerun schema generate.", + }); + } + + yield* state.clearJournal; + + const nextActions = [ + "Review the migration files, commit them, then deploy with migrations push.", + ...(input.baseline + ? [ + `If the remote already has this schema, record it without re-applying: ${formatMigrationRepairCommand( + { + status: "applied", + versions: written.map((file) => file.version), + }, + )}`, + ] + : []), + ]; + + return { + status: "generated", + message: `${summary}\nWrote ${written.map((file) => file.fileName).join(", ")}`, + data: { + status: "generated", + plan_id: plan.planId, + hazards: plan.hazards, + files_written: written.map((file) => file.fileName), + mutated_database: false, + mutated_files: true, + next_actions: nextActions, + }, + nextActions, + mutatedDatabase: false, + mutatedFiles: true, + } satisfies SchemaCommandResult; + }); + + return yield* persistGenerated.pipe(Effect.tapError(() => migrations.remove(written))); + }), + ), + ); +}); diff --git a/apps/cli/src/shared/schema/isolated-shadow.service.ts b/apps/cli/src/shared/schema/isolated-shadow.service.ts new file mode 100644 index 0000000000..363989f8a4 --- /dev/null +++ b/apps/cli/src/shared/schema/isolated-shadow.service.ts @@ -0,0 +1,16 @@ +import type { Effect, Scope } from "effect"; +import { Context } from "effect"; +import type { SchemaEngineError } from "./schema-errors.ts"; +import type { SchemaShadow } from "./schema-shadow.ts"; + +interface IsolatedShadowProvisionerShape { + /** Platform-baselined Docker shadow with no project migrations applied. */ + readonly provision: Effect.Effect; + /** Platform-baselined Docker shadow with local migration files applied. */ + readonly provisionMigrations: Effect.Effect; +} + +export class IsolatedShadowProvisioner extends Context.Service< + IsolatedShadowProvisioner, + IsolatedShadowProvisionerShape +>()("supabase/schema/IsolatedShadowProvisioner") {} diff --git a/apps/cli/src/shared/schema/pg-delta-engine.layer.ts b/apps/cli/src/shared/schema/pg-delta-engine.layer.ts new file mode 100644 index 0000000000..9dccccf79e --- /dev/null +++ b/apps/cli/src/shared/schema/pg-delta-engine.layer.ts @@ -0,0 +1,196 @@ +import { Effect, Layer } from "effect"; +import type { Pool } from "pg"; +import { apply } from "@supabase/pg-delta/apply"; +import { encodeId, serializeSnapshot, type Diagnostic } from "@supabase/pg-delta/core"; +import { + buildSchemaExport, + dataLossActions, + hasBlockingDiagnostics, + planSchemaFiles, + renderPlanFiles, + ShadowLoadError, +} from "@supabase/pg-delta/frontends"; +import { resolveProfile, supabaseProfile } from "@supabase/pg-delta/integrations"; +import { + classifyPlanHazards, + ENGINE_VERSION, + plan as planCatalogs, + type Plan, +} from "@supabase/pg-delta/plan"; +import { IsolatedShadowProvisioner } from "./isolated-shadow.service.ts"; +import { SchemaEngineError } from "./schema-errors.ts"; +import { + PgDeltaSchemaEngine, + type SchemaDiffPoolsInput, + type SchemaExportResult, + type SchemaPlanFilesInput, +} from "./pg-delta-engine.service.ts"; +import { prepareDeclarativeShadow } from "./prepare-declarative-shadow.ts"; +import { schemaIsolatedPlanOptions } from "./schema-plan-options.ts"; +import type { SchemaHazardSummary, SchemaPlanView, SchemaRenderedFile } from "./schema-types.ts"; + +const engineError = (detail: string, suggestion = "Inspect diagnostics and retry.") => + new SchemaEngineError({ detail, suggestion }); + +const engineCause = (cause: unknown, suggestion: string) => { + if (cause instanceof ShadowLoadError) { + const details = cause.details.map((diagnostic) => ` - ${diagnostic.message}`).join("\n"); + return engineError( + details.length > 0 ? `${cause.message}\n${details}` : cause.message, + suggestion, + ); + } + return engineError(cause instanceof Error ? cause.message : String(cause), suggestion); +}; + +function toPlanView( + thePlan: Plan, + allowDrops: boolean, + diagnostics: ReadonlyArray, +): SchemaPlanView { + const rendered = renderPlanFiles(thePlan, { allowDrops }); + const files: Array = rendered.files.map((file, index) => ({ + sequence: index + 1, + suffix: file.suffix, + sql: file.contents, + transactional: file.transactional, + actionCount: file.actionCount, + })); + const hazards = classifyPlanHazards(thePlan, diagnostics); + const destructive = dataLossActions(thePlan.actions).length; + const summary: SchemaHazardSummary = { + kinds: [...hazards.kinds], + destructive, + rewrite: hazards.kinds.includes("rewrite_risk") ? 1 : 0, + coverageGaps: hazards.coverage.length, + report: hazards, + }; + const renameBlocked = thePlan.renameCandidates.some( + (candidate) => candidate.status === "ambiguous", + ); + return { + planId: thePlan.planId, + sourceFingerprint: thePlan.source.fingerprint, + desiredFingerprint: thePlan.target.fingerprint, + engineVersion: thePlan.engineVersion, + profile: thePlan.profile?.id ?? "supabase", + changes: rendered.changes, + files, + hazards: summary, + destructive: destructive > 0, + renameCandidates: thePlan.renameCandidates.map((candidate) => ({ + from: encodeId(candidate.from), + to: encodeId(candidate.to), + })), + acceptedRenames: (thePlan.acceptedRenames ?? []).map((rename) => ({ + from: encodeId(rename.from), + to: encodeId(rename.to), + })), + coverageBlocked: + hasBlockingDiagnostics(diagnostics, { strictCoverage: true }) || hazards.coverage.length > 0, + renameBlocked, + plan: thePlan, + }; +} + +export const pgDeltaSchemaEngineLayer = Layer.effect( + PgDeltaSchemaEngine, + Effect.gen(function* () { + const shadows = yield* IsolatedShadowProvisioner; + return PgDeltaSchemaEngine.of({ + exportSchema: (pool: Pool) => + Effect.tryPromise({ + try: async (): Promise => { + const exported = await buildSchemaExport(pool, { + profile: supabaseProfile, + scope: "database", + redactSecrets: true, + }); + const ctx = await resolveProfile(pool, supabaseProfile, { redactSecrets: true }); + const extracted = await ctx.extract(pool, { redactSecrets: true }); + return { + files: exported.files.map((file) => ({ name: file.name, sql: file.sql })), + manifest: { + ...exported.manifest, + files: exported.files.map((file) => file.name).sort(), + }, + snapshot: serializeSnapshot(extracted.factBase, { + pgVersion: extracted.pgVersion, + redactSecrets: true, + profile: ctx.id, + }), + engineVersion: ENGINE_VERSION, + }; + }, + catch: (cause) => + engineCause(cause, "Confirm the database is reachable and retry schema pull."), + }), + planFiles: (input: SchemaPlanFilesInput) => + Effect.gen(function* () { + yield* prepareDeclarativeShadow(input.shadowPool); + return yield* Effect.tryPromise({ + try: async () => { + const result = await planSchemaFiles( + input.targetPool, + input.shadowPool, + [...input.files], + { + ...schemaIsolatedPlanOptions, + ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), + }, + ); + return toPlanView(result.plan, input.allowDrops ?? true, [ + ...result.loadDiagnostics, + ...result.targetDiagnostics, + ...result.driftDiagnostics, + ]); + }, + catch: (cause) => engineCause(cause, "Fix declaration or coverage issues, then retry."), + }); + }), + diffPools: (input: SchemaDiffPoolsInput) => + Effect.tryPromise({ + try: async () => { + const profile = await resolveProfile(input.sourcePool, supabaseProfile, { + redactSecrets: true, + }); + const source = await profile.extract(input.sourcePool, { redactSecrets: true }); + const desired = await profile.extract(input.desiredPool, { redactSecrets: true }); + const generated = planCatalogs(source.factBase, desired.factBase, { + ...profile.planOptions, + redactSecrets: true, + }); + return toPlanView(generated, input.allowDrops ?? true, [ + ...source.diagnostics, + ...desired.diagnostics, + ]); + }, + catch: (cause) => engineCause(cause, "Confirm both databases are reachable and retry."), + }), + applyPlan: (input) => + Effect.tryPromise({ + try: async () => { + const report = await apply(input.plan.plan, input.pool, { + fingerprintGate: true, + ...input.applyOptions, + }); + return { + report, + partial: + report.status === "failed" || + report.actionStatuses.some( + (status) => status === "inDoubt" || status === "unapplied", + ), + }; + }, + catch: (cause) => + engineCause( + cause, + "The draft journal recorded the failure. Reset or repair; do not retry blindly.", + ), + }), + provisionShadow: shadows.provision, + provisionMigrations: shadows.provisionMigrations, + }); + }), +); diff --git a/apps/cli/src/shared/schema/pg-delta-engine.service.ts b/apps/cli/src/shared/schema/pg-delta-engine.service.ts new file mode 100644 index 0000000000..d1d5f658f0 --- /dev/null +++ b/apps/cli/src/shared/schema/pg-delta-engine.service.ts @@ -0,0 +1,55 @@ +import type { Effect, Scope } from "effect"; +import { Context } from "effect"; +import type { Pool } from "pg"; +import type { ApplyOptions } from "@supabase/pg-delta/apply"; +import type { ExportManifest } from "@supabase/pg-delta/frontends"; +import type { SchemaApplyOutcome, SchemaPlanView, SchemaSqlFile } from "./schema-types.ts"; +import type { SchemaEngineError } from "./schema-errors.ts"; +import type { SchemaShadow } from "./schema-shadow.ts"; + +export type SchemaExportResult = { + readonly files: ReadonlyArray; + readonly manifest: ExportManifest & { readonly files: ReadonlyArray }; + readonly snapshot: string; + readonly engineVersion: string; +}; + +export type SchemaPlanFilesInput = { + readonly targetPool: Pool; + readonly shadowPool: Pool; + readonly files: ReadonlyArray; + readonly manifest?: ExportManifest; + readonly allowDrops?: boolean; +}; + +export type SchemaDiffPoolsInput = { + readonly sourcePool: Pool; + readonly desiredPool: Pool; + readonly allowDrops?: boolean; +}; + +type SchemaApplyPlanInput = { + readonly pool: Pool; + readonly plan: SchemaPlanView; + readonly applyOptions?: ApplyOptions; +}; + +interface PgDeltaSchemaEngineShape { + readonly exportSchema: (pool: Pool) => Effect.Effect; + readonly planFiles: ( + input: SchemaPlanFilesInput, + ) => Effect.Effect; + readonly diffPools: ( + input: SchemaDiffPoolsInput, + ) => Effect.Effect; + readonly applyPlan: ( + input: SchemaApplyPlanInput, + ) => Effect.Effect; + readonly provisionShadow: Effect.Effect; + readonly provisionMigrations: Effect.Effect; +} + +export class PgDeltaSchemaEngine extends Context.Service< + PgDeltaSchemaEngine, + PgDeltaSchemaEngineShape +>()("supabase/schema/PgDeltaSchemaEngine") {} diff --git a/apps/cli/src/shared/schema/prepare-declarative-shadow.ts b/apps/cli/src/shared/schema/prepare-declarative-shadow.ts new file mode 100644 index 0000000000..fd600d14e7 --- /dev/null +++ b/apps/cli/src/shared/schema/prepare-declarative-shadow.ts @@ -0,0 +1,59 @@ +import { Effect } from "effect"; +import { SchemaEngineError } from "./schema-errors.ts"; + +export type DeclarativeShadowClient = { + readonly query: (sql: string) => Promise<{ readonly rows: ReadonlyArray }>; +}; + +const PG14_PREP = [ + "ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT", + "DROP EXTENSION IF EXISTS pgjwt", + "DROP EXTENSION IF EXISTS pgcrypto", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', +] as const; + +const CURRENT_PREP = [ + "DROP EXTENSION IF EXISTS pgcrypto", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', +] as const; + +export const parsePostgresMajorVersion = (serverVersion: string): number => { + const major = Number.parseInt(serverVersion, 10); + return Number.isInteger(major) ? major : 0; +}; + +export const declarativeBaselinePrepStatements = (majorVersion: number): ReadonlyArray => + majorVersion === 14 ? PG14_PREP : CURRENT_PREP; + +const queryError = (sql: string, cause: unknown) => + new SchemaEngineError({ + detail: `Failed to prepare the isolated declaration shadow (${sql}): ${ + cause instanceof Error ? cause.message : String(cause) + }`, + suggestion: + "Retry the command. If it persists, delete the Docker shadow baseline cache under ~/.supabase/cache/shadow-baseline.", + }); + +const readServerVersion = (rows: ReadonlyArray): string => { + const row = rows[0]; + if (row === undefined || typeof row !== "object" || row === null) return ""; + const value = Reflect.get(row, "server_version"); + return typeof value === "string" ? value : ""; +}; + +export const prepareDeclarativeShadow = (client: DeclarativeShadowClient) => + Effect.gen(function* () { + const versionRows = yield* Effect.tryPromise({ + try: () => client.query("SHOW server_version"), + catch: (cause) => queryError("SHOW server_version", cause), + }); + const statements = declarativeBaselinePrepStatements( + parsePostgresMajorVersion(readServerVersion(versionRows.rows)), + ); + for (const sql of statements) { + yield* Effect.tryPromise({ + try: () => client.query(sql), + catch: (cause) => queryError(sql, cause), + }); + } + }); diff --git a/apps/cli/src/shared/schema/prepare-declarative-shadow.unit.test.ts b/apps/cli/src/shared/schema/prepare-declarative-shadow.unit.test.ts new file mode 100644 index 0000000000..e488e1f414 --- /dev/null +++ b/apps/cli/src/shared/schema/prepare-declarative-shadow.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { + declarativeBaselinePrepStatements, + parsePostgresMajorVersion, + prepareDeclarativeShadow, +} from "./prepare-declarative-shadow.ts"; + +describe("declarativeBaselinePrepStatements", () => { + it("detaches PG14 platform dependencies before dropping implicit extensions", () => { + expect(declarativeBaselinePrepStatements(14)).toEqual([ + "ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT", + "DROP EXTENSION IF EXISTS pgjwt", + "DROP EXTENSION IF EXISTS pgcrypto", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', + ]); + }); + + it("only drops implicit extensions on PG15+", () => { + expect(declarativeBaselinePrepStatements(17)).toEqual([ + "DROP EXTENSION IF EXISTS pgcrypto", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', + ]); + }); +}); + +describe("parsePostgresMajorVersion", () => { + it("reads the leading major from SHOW server_version", () => { + expect(parsePostgresMajorVersion("17.6")).toBe(17); + expect(parsePostgresMajorVersion("14.15 (Debian)")).toBe(14); + expect(parsePostgresMajorVersion("")).toBe(0); + }); +}); + +describe("prepareDeclarativeShadow", () => { + it.live("runs the version-selected prep statements against the shadow", () => { + const queries: string[] = []; + const client = { + query: (sql: string) => { + queries.push(sql); + return Promise.resolve({ + rows: sql === "SHOW server_version" ? [{ server_version: "17.6" }] : [], + }); + }, + }; + return Effect.gen(function* () { + yield* prepareDeclarativeShadow(client); + expect(queries).toEqual([ + "SHOW server_version", + "DROP EXTENSION IF EXISTS pgcrypto", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', + ]); + }); + }); +}); diff --git a/apps/cli/src/shared/schema/pull-schema.integration.test.ts b/apps/cli/src/shared/schema/pull-schema.integration.test.ts new file mode 100644 index 0000000000..0bfd17b1c5 --- /dev/null +++ b/apps/cli/src/shared/schema/pull-schema.integration.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Exit, Layer } from "effect"; +import type { Pool } from "pg"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import { pullSchema } from "./pull-schema.ts"; +import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; +import { schemaStateLayer } from "./schema-state.layer.ts"; +import { schemaWorkspaceLayer } from "./schema-workspace.layer.ts"; + +function tempProject() { + const root = mkdtempSync(join(tmpdir(), "schema-pull-")); + const supabaseDir = join(root, "supabase"); + const projectHomeDir = join(root, ".supabase"); + mkdirSync(supabaseDir, { recursive: true }); + mkdirSync(projectHomeDir, { recursive: true }); + return { root, supabaseDir, projectHomeDir }; +} + +function mockEngine(files: Array<{ name: string; sql: string }>) { + return Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: (_pool: Pool) => + Effect.succeed({ + files, + manifest: { + redactSecrets: true, + profile: "supabase", + scope: "database", + files: files.map((f) => f.name), + }, + snapshot: '{"catalog":true}', + engineVersion: "0.3.0", + }), + planFiles: () => Effect.die("unused"), + diffPools: () => Effect.die("unused"), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.die("unused"), + provisionMigrations: Effect.die("unused"), + }), + ); +} + +function mockTarget() { + return Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => + Effect.succeed({ + kind: "local", + identity: "local:default", + connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + disposable: true, + durable: false, + connectionVerified: true, + }), + }), + ); +} + +function mockMigrations() { + return Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ); +} + +function setup(files = [{ name: "public.sql", sql: "create table public.t (id int);\n" }]) { + const project = tempProject(); + const out = mockOutput({ format: "json", interactive: false }); + const workspace = schemaWorkspaceLayer({ + projectRoot: project.root, + supabaseDir: project.supabaseDir, + projectHomeDir: project.projectHomeDir, + }).pipe(Layer.provide(BunServices.layer)); + const layer = Layer.mergeAll( + out.layer, + BunServices.layer, + workspace, + schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + mockEngine(files), + mockTarget(), + mockMigrations(), + ); + return { project, layer }; +} + +describe("pullSchema", () => { + it.live("writes declarations and the export manifest into an empty tree", () => { + const { project, layer } = setup(); + return Effect.gen(function* () { + const result = yield* pullSchema({ from: "local", force: false, pruneUnmanaged: false }).pipe( + Effect.provide(layer), + ); + expect(result.mutatedFiles).toBe(true); + expect(result.data["created"]).toEqual(["public.sql"]); + expect(existsSync(join(project.supabaseDir, "schemas", "public.sql"))).toBe(true); + expect(existsSync(join(project.supabaseDir, "schemas", ".schema-checkpoint.json"))).toBe( + false, + ); + expect(existsSync(join(project.supabaseDir, "schemas", ".pgdelta-export.json"))).toBe(true); + }); + }); + + it.live("fails closed when declarations already exist", () => { + const { project, layer } = setup(); + mkdirSync(join(project.supabaseDir, "schemas"), { recursive: true }); + writeFileSync(join(project.supabaseDir, "schemas", "existing.sql"), "select 1;\n"); + return Effect.gen(function* () { + const exit = yield* pullSchema({ from: "local", force: false, pruneUnmanaged: false }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("does not touch _custom when replacing", () => { + const { project, layer } = setup(); + const custom = join(project.supabaseDir, "schemas", "_custom"); + mkdirSync(custom, { recursive: true }); + writeFileSync(join(custom, "hand.sql"), "create cast (int as text);\n"); + return Effect.gen(function* () { + yield* pullSchema({ from: "local", force: true, pruneUnmanaged: false }).pipe( + Effect.provide(layer), + ); + expect(readFileSync(join(custom, "hand.sql"), "utf8")).toBe("create cast (int as text);\n"); + }); + }); + + it.live("fails closed on unmanaged files unless --prune-unmanaged", () => { + const { project, layer } = setup([{ name: "kept.sql", sql: "select 1;\n" }]); + const schemas = join(project.supabaseDir, "schemas"); + mkdirSync(schemas, { recursive: true }); + writeFileSync(join(schemas, "kept.sql"), "select 1;\n"); + writeFileSync(join(schemas, "stray.sql"), "select 2;\n"); + writeFileSync( + join(schemas, ".pgdelta-export.json"), + `${JSON.stringify({ formatVersion: 1, files: ["kept.sql"] }, null, 2)}\n`, + ); + return Effect.gen(function* () { + const exit = yield* pullSchema({ from: "local", force: true, pruneUnmanaged: false }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("prunes unmanaged files when --prune-unmanaged is passed", () => { + const { project, layer } = setup([{ name: "kept.sql", sql: "select 1;\n" }]); + const schemas = join(project.supabaseDir, "schemas"); + mkdirSync(schemas, { recursive: true }); + writeFileSync(join(schemas, "kept.sql"), "select 1;\n"); + writeFileSync(join(schemas, "stray.sql"), "select 2;\n"); + writeFileSync( + join(schemas, ".pgdelta-export.json"), + `${JSON.stringify({ formatVersion: 1, files: ["kept.sql"] }, null, 2)}\n`, + ); + return Effect.gen(function* () { + yield* pullSchema({ from: "local", force: true, pruneUnmanaged: true }).pipe( + Effect.provide(layer), + ); + expect(existsSync(join(schemas, "kept.sql"))).toBe(true); + expect(existsSync(join(schemas, "stray.sql"))).toBe(false); + }); + }); + + it.live("refuses a primary-tree pull while a draft is ahead, without writing files", () => { + const { project, layer } = setup(); + const schemas = join(project.supabaseDir, "schemas"); + mkdirSync(schemas, { recursive: true }); + writeFileSync(join(schemas, "existing.sql"), "select 1;\n"); + writeFileSync( + join(project.projectHomeDir, "schema-draft.json"), + `${JSON.stringify( + { + version: 1, + draftId: "draft-1", + targetIdentity: "local:default", + startingMigrationHeadDigest: "abc", + sourceFingerprint: "def", + plans: [], + engineVersion: "0.3.0", + declarativelyAhead: true, + generated: false, + }, + null, + 2, + )}\n`, + ); + return Effect.gen(function* () { + const exit = yield* pullSchema({ from: "local", force: true, pruneUnmanaged: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(readFileSync(join(schemas, "existing.sql"), "utf8")).toBe("select 1;\n"); + expect(existsSync(join(schemas, "public.sql"))).toBe(false); + }); + }); +}); diff --git a/apps/cli/src/shared/schema/pull-schema.ts b/apps/cli/src/shared/schema/pull-schema.ts new file mode 100644 index 0000000000..7ca2548831 --- /dev/null +++ b/apps/cli/src/shared/schema/pull-schema.ts @@ -0,0 +1,110 @@ +import { Effect } from "effect"; +import { readExportManifest } from "@supabase/pg-delta/frontends"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { parseTargetSelector, redactConnectionString } from "../database/database-target.ts"; +import { SchemaDraftConflictError, SchemaTargetRequiredError } from "./schema-errors.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaCommandResult } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; +import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; +import { formatFileSummary } from "./schema-output.ts"; +import { MigrationRepository } from "../migrations/migration-repository.service.ts"; + +export type PullSchemaInput = { + readonly from?: string; + readonly output?: string; + readonly force: boolean; + readonly pruneUnmanaged: boolean; +}; + +export const pullSchema = Effect.fn("schema.pull")(function* (input: PullSchemaInput) { + const workspace = yield* SchemaWorkspace; + const state = yield* SchemaStateStore; + const engine = yield* PgDeltaSchemaEngine; + const targets = yield* DatabaseTargetResolver; + const migrations = yield* MigrationRepository; + + if (input.from === undefined) { + return yield* new SchemaTargetRequiredError({ + detail: "schema pull requires an explicit --from target.", + suggestion: "Pass --from local, --from linked, or --from .", + }); + } + + const selector = parseTargetSelector(input.from); + const target = yield* targets.resolve(selector); + const mode = input.output !== undefined ? "output" : input.force ? "force" : "init"; + + return yield* state.withLock( + Effect.scoped( + Effect.gen(function* () { + if (mode !== "output") { + const journal = yield* state.readJournal; + if ( + journal._tag === "Some" && + journal.value.declarativelyAhead && + journal.value.generated !== true + ) { + return yield* new SchemaDraftConflictError({ + detail: "A declarative draft is active. Pull would hide ungenerated changes.", + suggestion: + "Run `supabase schema generate` or discard the draft before pulling the primary tree.", + }); + } + } + + const pool = yield* acquireDatabasePool(target.connectionString); + const exported = yield* engine.exportSchema(pool); + const installed = yield* workspace.installExport({ + files: exported.files, + manifest: Object.fromEntries(Object.entries(exported.manifest)), + mode, + ...(input.output !== undefined ? { outputDir: input.output } : {}), + pruneUnmanaged: input.pruneUnmanaged, + }); + + const localMigrations = yield* migrations.listLocal; + + const summary = installed.classification; + const nextActions = + mode === "output" + ? [ + "Inspect the side-by-side snapshot, then edit supabase/schemas or rerun with --force.", + ] + : localMigrations.length > 0 + ? ["supabase schema generate --dry-run"] + : ["supabase schema generate --baseline --name initial_schema"]; + + return { + status: "clean", + message: `Declarative schema written to ${installed.directoryDisplay}.`, + data: { + status: "clean", + source: { + kind: target.kind, + identity: target.identity, + connection: redactConnectionString(target.connectionString), + }, + output: installed.directoryDisplay, + replaced: installed.replaced, + merge: false, + summary: formatFileSummary(summary), + created: summary.created, + updated: summary.updated, + unchanged: summary.unchanged, + removed: summary.removed, + unmanaged: summary.unmanaged, + next_actions: nextActions, + mutated_database: false, + mutated_files: true, + export_manifest: readExportManifest(installed.directory), + }, + nextActions, + mutatedDatabase: false, + mutatedFiles: true, + } satisfies SchemaCommandResult; + }), + ), + ); +}); diff --git a/apps/cli/src/shared/schema/schema-digest.ts b/apps/cli/src/shared/schema/schema-digest.ts new file mode 100644 index 0000000000..0a51088462 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-digest.ts @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; +import type { SchemaSqlFile } from "./schema-types.ts"; + +export function digestUtf8(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function digestFileSet(files: ReadonlyArray): string { + const hash = createHash("sha256"); + const sorted = [...files].sort((left, right) => left.name.localeCompare(right.name)); + for (const file of sorted) { + hash.update(file.name); + hash.update("\0"); + hash.update(file.sql); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export function digestVersions(versions: ReadonlyArray): string { + return digestUtf8(versions.join("\n")); +} diff --git a/apps/cli/src/shared/schema/schema-digest.unit.test.ts b/apps/cli/src/shared/schema/schema-digest.unit.test.ts new file mode 100644 index 0000000000..2e050e2902 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-digest.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { digestFileSet, digestUtf8, digestVersions } from "./schema-digest.ts"; + +describe("schema-digest", () => { + it("is stable across file order", () => { + expect( + digestFileSet([ + { name: "b.sql", sql: "select 2" }, + { name: "a.sql", sql: "select 1" }, + ]), + ).toBe( + digestFileSet([ + { name: "a.sql", sql: "select 1" }, + { name: "b.sql", sql: "select 2" }, + ]), + ); + }); + + it("changes when content changes", () => { + expect(digestFileSet([{ name: "a.sql", sql: "select 1" }])).not.toBe( + digestFileSet([{ name: "a.sql", sql: "select 2" }]), + ); + }); + + it("hashes versions and utf8", () => { + expect(digestVersions(["1", "2"])).toBe(digestUtf8("1\n2")); + }); +}); diff --git a/apps/cli/src/shared/schema/schema-ecosystem.ts b/apps/cli/src/shared/schema/schema-ecosystem.ts new file mode 100644 index 0000000000..47f3d341e1 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-ecosystem.ts @@ -0,0 +1,17 @@ +export const SCHEMA_ECOSYSTEM_MAPPING_HELP = `Coming from another tool: + Prisma db pull → schema pull + Prisma db push → schema apply + Prisma migrate dev --create-only → schema generate + Prisma migrate deploy → migrations push + Prisma migrate diff → schema generate --dry-run / migrations diff + Drizzle Kit pull → schema pull + Drizzle Kit push → schema apply + Drizzle Kit generate → schema generate + Drizzle Kit migrate → migrations apply / migrations push + Convex dev → schema apply + Convex deploy → migrations push + Convex deployment select → explicit --from / --against per command`; + +export const SCHEMA_PULL_NO_MERGE_HELP = `Pull does not merge SQL files. Use: + --output Export alongside the existing schema + --force Replace the complete managed schema and report changed paths`; diff --git a/apps/cli/src/shared/schema/schema-errors.ts b/apps/cli/src/shared/schema/schema-errors.ts new file mode 100644 index 0000000000..585be5c53d --- /dev/null +++ b/apps/cli/src/shared/schema/schema-errors.ts @@ -0,0 +1,162 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +function SchemaCliError(tag: Tag) { + return class extends Data.TaggedError(tag)<{ + readonly detail: string; + readonly suggestion: string; + }> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } + }; +} + +export class SchemaDeclarationsExistError extends SchemaCliError("SchemaDeclarationsExistError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class SchemaUnmanagedFilesError extends Data.TaggedError("SchemaUnmanagedFilesError")<{ + readonly detail: string; + readonly suggestion: string; + readonly paths: ReadonlyArray; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; + } +} + +export class SchemaWorkspaceIoError extends SchemaCliError("SchemaWorkspaceIoError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +export class SchemaLockError extends SchemaCliError("SchemaLockError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +export class SchemaStateError extends SchemaCliError("SchemaStateError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +export class SchemaLocalStackNotRunningError extends SchemaCliError( + "SchemaLocalStackNotRunningError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} + +export class SchemaLinkedConnectionError extends SchemaCliError("SchemaLinkedConnectionError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} + +export class SchemaDurableTargetError extends SchemaCliError("SchemaDurableTargetError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +export class SchemaDestructiveAuthError extends SchemaCliError("SchemaDestructiveAuthError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +export class SchemaProjectRefMismatchError extends SchemaCliError("SchemaProjectRefMismatchError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} + +export class SchemaAllowRemoteRequiredError extends SchemaCliError( + "SchemaAllowRemoteRequiredError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +export class SchemaPlanningBlockedError extends SchemaCliError("SchemaPlanningBlockedError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +export class SchemaDeclarationsAheadError extends SchemaCliError("SchemaDeclarationsAheadError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} + +export class SchemaRemoteDriftError extends SchemaCliError("SchemaRemoteDriftError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} + +export class SchemaDraftConflictError extends SchemaCliError("SchemaDraftConflictError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} + +export class SchemaEngineError extends SchemaCliError("SchemaEngineError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +export class SchemaPartialApplyError extends SchemaCliError("SchemaPartialApplyError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +export class SchemaMigrationNameError extends SchemaCliError("SchemaMigrationNameError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class SchemaBaselineMigrationsExistError extends SchemaCliError( + "SchemaBaselineMigrationsExistError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class SchemaHistoryConflictError extends SchemaCliError("SchemaHistoryConflictError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} + +export class SchemaTargetRequiredError extends SchemaCliError("SchemaTargetRequiredError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class SchemaCancelledError extends SchemaCliError("SchemaCancelledError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/shared/schema/schema-output.ts b/apps/cli/src/shared/schema/schema-output.ts new file mode 100644 index 0000000000..bd69449244 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-output.ts @@ -0,0 +1,21 @@ +import type { SchemaFileSummary, SchemaPlanView } from "./schema-types.ts"; + +export function formatPlanSummary(input: { + readonly title: string; + readonly source: string; + readonly desired: string; + readonly target: string; + readonly plan: SchemaPlanView; +}): string { + return [ + `${input.title}: ${input.source} -> ${input.desired}`, + `Target: ${input.target}`, + `Actions: ${input.plan.plan.actions.length}`, + `Hazards: ${input.plan.hazards.rewrite} rewrite, ${input.plan.hazards.destructive} destructive, ${input.plan.hazards.coverageGaps} coverage gaps`, + `Plan: ${input.plan.planId}`, + ].join("\n"); +} + +export function formatFileSummary(summary: SchemaFileSummary): string { + return `${summary.created.length} created, ${summary.updated.length} updated, ${summary.unchanged.length} unchanged, ${summary.removed.length} removed`; +} diff --git a/apps/cli/src/shared/schema/schema-paths.ts b/apps/cli/src/shared/schema/schema-paths.ts new file mode 100644 index 0000000000..e0c54c478a --- /dev/null +++ b/apps/cli/src/shared/schema/schema-paths.ts @@ -0,0 +1,6 @@ +export const SCHEMA_DIRECTORY_NAME = "schemas"; +export const SCHEMA_CUSTOM_DIRECTORY_NAME = "_custom"; +export const SCHEMA_DRAFT_JOURNAL_FILE_NAME = "schema-draft.json"; +export const SCHEMA_LOCK_FILE_NAME = "schema.lock"; +export const MIGRATIONS_DIRECTORY_NAME = "migrations"; +export const MIGRATION_NO_TRANSACTION_DIRECTIVE = "-- pg-delta: transaction=false"; diff --git a/apps/cli/src/shared/schema/schema-plan-gate.ts b/apps/cli/src/shared/schema/schema-plan-gate.ts new file mode 100644 index 0000000000..f7ea5c143f --- /dev/null +++ b/apps/cli/src/shared/schema/schema-plan-gate.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect"; +import { SchemaPlanningBlockedError } from "./schema-errors.ts"; +import type { SchemaPlanView } from "./schema-types.ts"; + +export const assertPlanActionable = (plan: SchemaPlanView) => { + if (plan.renameBlocked) { + return Effect.fail( + new SchemaPlanningBlockedError({ + detail: "Planning found an ambiguous rename and cannot guess.", + suggestion: + "Rename explicitly in declarations, accept a rename decision, or reset the local database.", + }), + ); + } + if (plan.coverageBlocked) { + return Effect.fail( + new SchemaPlanningBlockedError({ + detail: "Planning found a coverage gap or unmodeled object.", + suggestion: "Move unsupported objects to _custom/ or a manual migration, then retry.", + }), + ); + } + return Effect.void; +}; diff --git a/apps/cli/src/shared/schema/schema-plan-options.ts b/apps/cli/src/shared/schema/schema-plan-options.ts new file mode 100644 index 0000000000..443abd7005 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-plan-options.ts @@ -0,0 +1,13 @@ +import { supabaseProfile } from "@supabase/pg-delta/integrations"; + +/** Options for `planSchemaFiles` on the schema-first path. Isolated cluster only. */ +export const schemaIsolatedPlanOptions = { + profile: supabaseProfile, + scope: "database" as const, + redactSecrets: true, + isolatedShadow: true, + seedAssumedSchemas: false, + allowSameDatabaseIdentity: true, + strictDataStatements: true, + reorder: true, +}; diff --git a/apps/cli/src/shared/schema/schema-plan-options.unit.test.ts b/apps/cli/src/shared/schema/schema-plan-options.unit.test.ts new file mode 100644 index 0000000000..7d1cd6c461 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-plan-options.unit.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "@effect/vitest"; +import { schemaIsolatedPlanOptions } from "./schema-plan-options.ts"; + +describe("schemaIsolatedPlanOptions", () => { + it("uses an isolated cluster and does not seed assumed schemas", () => { + expect(schemaIsolatedPlanOptions.isolatedShadow).toBe(true); + expect(schemaIsolatedPlanOptions.seedAssumedSchemas).toBe(false); + expect(schemaIsolatedPlanOptions.allowSameDatabaseIdentity).toBe(true); + expect(schemaIsolatedPlanOptions.scope).toBe("database"); + }); +}); diff --git a/apps/cli/src/shared/schema/schema-render.ts b/apps/cli/src/shared/schema/schema-render.ts new file mode 100644 index 0000000000..e23fa93e9f --- /dev/null +++ b/apps/cli/src/shared/schema/schema-render.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect"; +import { Output } from "../output/output.service.ts"; +import type { SchemaCommandResult } from "./schema-types.ts"; + +export const renderSchemaResult = Effect.fnUntraced(function* ( + title: string, + result: SchemaCommandResult, +) { + const output = yield* Output; + yield* output.intro(title); + if (output.format === "text") { + for (const line of result.message.split("\n")) { + yield* output.info(line); + } + if (result.nextActions.length > 0) { + yield* output.info(`Next: ${result.nextActions.join(" | ")}`); + } + yield* output.outro(result.status === "failed" ? "Failed." : result.message.split("\n")[0]!); + return; + } + yield* output.success(result.message, result.data); +}); diff --git a/apps/cli/src/shared/schema/schema-shadow.ts b/apps/cli/src/shared/schema/schema-shadow.ts new file mode 100644 index 0000000000..7fb21950af --- /dev/null +++ b/apps/cli/src/shared/schema/schema-shadow.ts @@ -0,0 +1,3 @@ +export type SchemaShadow = { + readonly url: string; +}; diff --git a/apps/cli/src/shared/schema/schema-state.layer.ts b/apps/cli/src/shared/schema/schema-state.layer.ts new file mode 100644 index 0000000000..82691279bc --- /dev/null +++ b/apps/cli/src/shared/schema/schema-state.layer.ts @@ -0,0 +1,151 @@ +import { Clock, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { SchemaLockError, SchemaStateError } from "./schema-errors.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaDraftJournal } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; + +const JournalSchema = Schema.Struct({ + version: Schema.Literal(1), + draftId: Schema.String, + targetIdentity: Schema.String, + startingMigrationHeadDigest: Schema.String, + sourceFingerprint: Schema.String, + plans: Schema.Array( + Schema.Struct({ + planId: Schema.String, + targetFingerprint: Schema.String, + acceptedRenames: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })), + segmentDigests: Schema.Array(Schema.String), + hazards: Schema.Struct({ + kinds: Schema.Array(Schema.String), + destructive: Schema.Number, + rewrite: Schema.Number, + coverageGaps: Schema.Number, + }), + actionStatuses: Schema.Array(Schema.Literals(["applied", "unapplied", "inDoubt"])), + outcome: Schema.Literals(["applied", "failed", "partial"]), + }), + ), + engineVersion: Schema.String, + declarativelyAhead: Schema.Boolean, + generated: Schema.optionalKey(Schema.Boolean), + invalidationReason: Schema.optionalKey(Schema.String), +}); + +const STALE_LOCK_MS = 10 * 60 * 1000; + +const stateError = (detail: string) => + new SchemaStateError({ + detail, + suggestion: "Fix or delete `.supabase/schema-draft.json` and rerun the command.", + }); + +export const schemaStateLayer = Layer.effect( + SchemaStateStore, + Effect.gen(function* () { + const workspace = yield* SchemaWorkspace; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const readDecoded = ( + filePath: string, + decode: (value: unknown) => A, + ): Effect.Effect, SchemaStateError> => + Effect.gen(function* () { + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return Option.none(); + const raw = yield* fs + .readFileString(filePath) + .pipe( + Effect.mapError((error) => stateError(`Failed to read ${filePath}: ${error.message}`)), + ); + const parsed = yield* Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: () => stateError(`Malformed ${path.basename(filePath)}.`), + }); + return Option.some( + yield* Effect.try({ + try: () => decode(parsed), + catch: (error) => + stateError( + `Malformed ${path.basename(filePath)}: ${error instanceof Error ? error.message : String(error)}`, + ), + }), + ); + }); + + const writeJson = (filePath: string, value: unknown) => + Effect.gen(function* () { + yield* fs + .makeDirectory(path.dirname(filePath), { recursive: true }) + .pipe( + Effect.mapError((error) => + stateError(`Failed to create ${path.dirname(filePath)}: ${error.message}`), + ), + ); + yield* fs + .writeFileString(filePath, `${JSON.stringify(value, null, 2)}\n`) + .pipe( + Effect.mapError((error) => stateError(`Failed to write ${filePath}: ${error.message}`)), + ); + }); + + return SchemaStateStore.of({ + readJournal: readDecoded(workspace.journalPath, Schema.decodeUnknownSync(JournalSchema)), + writeJournal: (journal: SchemaDraftJournal) => writeJson(workspace.journalPath, journal), + clearJournal: Effect.gen(function* () { + yield* fs + .remove(workspace.journalPath) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.void + : Effect.fail(stateError(error.message)), + ), + ); + }), + withLock: (effect) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(workspace.lockPath), { recursive: true }).pipe( + Effect.mapError( + (error) => + new SchemaLockError({ + detail: `Failed to create lock directory: ${error.message}`, + suggestion: "Check permissions on .supabase/.", + }), + ), + ); + const now = yield* Clock.currentTimeMillis; + const exists = yield* fs + .exists(workspace.lockPath) + .pipe(Effect.orElseSucceed(() => false)); + if (exists) { + const raw = yield* fs + .readFileString(workspace.lockPath) + .pipe(Effect.orElseSucceed(() => "")); + const stamped = Number.parseInt(raw, 10); + const stale = !Number.isFinite(stamped) || now - stamped > STALE_LOCK_MS; + if (!stale) { + return yield* new SchemaLockError({ + detail: "Another schema or migrations command is already running.", + suggestion: + "Wait for it to finish, or remove .supabase/schema.lock if it is stale.", + }); + } + } + yield* fs.writeFileString(workspace.lockPath, `${now}\n`).pipe( + Effect.mapError( + (error) => + new SchemaLockError({ + detail: `Failed to acquire schema lock: ${error.message}`, + suggestion: "Check permissions on .supabase/schema.lock.", + }), + ), + ); + return yield* effect.pipe( + Effect.ensuring(fs.remove(workspace.lockPath).pipe(Effect.ignore)), + ); + }), + }); + }), +); diff --git a/apps/cli/src/shared/schema/schema-state.service.ts b/apps/cli/src/shared/schema/schema-state.service.ts new file mode 100644 index 0000000000..c77f164ee4 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-state.service.ts @@ -0,0 +1,17 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; +import type { SchemaDraftJournal } from "./schema-types.ts"; +import type { SchemaLockError, SchemaStateError } from "./schema-errors.ts"; + +interface SchemaStateStoreShape { + readonly readJournal: Effect.Effect, SchemaStateError>; + readonly writeJournal: (journal: SchemaDraftJournal) => Effect.Effect; + readonly clearJournal: Effect.Effect; + readonly withLock: ( + effect: Effect.Effect, + ) => Effect.Effect; +} + +export class SchemaStateStore extends Context.Service()( + "supabase/schema/SchemaStateStore", +) {} diff --git a/apps/cli/src/shared/schema/schema-types.ts b/apps/cli/src/shared/schema/schema-types.ts new file mode 100644 index 0000000000..2eb1cf316d --- /dev/null +++ b/apps/cli/src/shared/schema/schema-types.ts @@ -0,0 +1,94 @@ +import type { SqlFileClassification } from "@supabase/pg-delta/frontends"; +import type { HazardReport } from "@supabase/pg-delta/plan"; +import type { Plan } from "@supabase/pg-delta/plan"; +import type { ApplyReport } from "@supabase/pg-delta/apply"; + +type SchemaCommandStatus = + | "clean" + | "draft" + | "needs_approval" + | "generated" + | "drift" + | "conflict" + | "partial" + | "failed"; + +export type SchemaSqlFile = { + readonly name: string; + readonly sql: string; +}; + +export type SchemaHazardSummary = { + readonly kinds: ReadonlyArray; + readonly destructive: number; + readonly rewrite: number; + readonly coverageGaps: number; + readonly report: HazardReport; +}; + +export type SchemaRenderedFile = { + readonly sequence: number; + readonly suffix: string | null; + readonly sql: string; + readonly transactional: boolean; + readonly actionCount: number; +}; + +export type SchemaPlanView = { + readonly planId: string; + readonly sourceFingerprint: string; + readonly desiredFingerprint: string; + readonly engineVersion: string; + readonly profile: string; + readonly changes: boolean; + readonly files: ReadonlyArray; + readonly hazards: SchemaHazardSummary; + readonly destructive: boolean; + readonly renameCandidates: ReadonlyArray<{ readonly from: string; readonly to: string }>; + readonly acceptedRenames: ReadonlyArray<{ readonly from: string; readonly to: string }>; + readonly coverageBlocked: boolean; + readonly renameBlocked: boolean; + readonly plan: Plan; +}; + +export type SchemaApplyOutcome = { + readonly report: ApplyReport; + readonly partial: boolean; +}; + +export type SchemaFileSummary = Pick< + SqlFileClassification, + "created" | "updated" | "unchanged" | "removed" | "unmanaged" +>; + +type SchemaJournaledPlan = { + readonly planId: string; + readonly targetFingerprint: string; + readonly acceptedRenames: ReadonlyArray<{ readonly from: string; readonly to: string }>; + readonly segmentDigests: ReadonlyArray; + readonly hazards: Pick; + readonly actionStatuses: ReadonlyArray<"applied" | "unapplied" | "inDoubt">; + readonly outcome: "applied" | "failed" | "partial"; +}; + +export type SchemaDraftJournal = { + readonly version: 1; + readonly draftId: string; + readonly targetIdentity: string; + readonly startingMigrationHeadDigest: string; + readonly sourceFingerprint: string; + readonly plans: ReadonlyArray; + readonly engineVersion: string; + readonly declarativelyAhead: boolean; + readonly generated?: boolean; + readonly invalidationReason?: string; +}; + +export type SchemaCommandResult = { + readonly status: SchemaCommandStatus; + readonly message: string; + readonly data: Record; + readonly nextActions: ReadonlyArray; + readonly mutatedDatabase: boolean; + readonly mutatedFiles: boolean; +}; diff --git a/apps/cli/src/shared/schema/schema-workspace.layer.ts b/apps/cli/src/shared/schema/schema-workspace.layer.ts new file mode 100644 index 0000000000..6653862831 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-workspace.layer.ts @@ -0,0 +1,296 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import { + classifySqlFiles, + EXPORT_MANIFEST_FILE, + readExportManifest, + type SqlFileClassification, +} from "@supabase/pg-delta/frontends"; +import { + SCHEMA_CUSTOM_DIRECTORY_NAME, + SCHEMA_DIRECTORY_NAME, + SCHEMA_DRAFT_JOURNAL_FILE_NAME, + SCHEMA_LOCK_FILE_NAME, + MIGRATIONS_DIRECTORY_NAME, +} from "./schema-paths.ts"; +import { SCHEMA_PULL_NO_MERGE_HELP } from "./schema-ecosystem.ts"; +import { + SchemaDeclarationsExistError, + SchemaUnmanagedFilesError, + SchemaWorkspaceIoError, +} from "./schema-errors.ts"; +import type { SchemaSqlFile } from "./schema-types.ts"; +import { + SchemaWorkspace, + type SchemaInstallInput, + type SchemaInstallResult, +} from "./schema-workspace.service.ts"; + +const ioError = (detail: string, suggestion = "Check filesystem permissions and retry.") => + new SchemaWorkspaceIoError({ detail, suggestion }); + +function isCustomPath(relative: string): boolean { + return relative.split("/")[0] === SCHEMA_CUSTOM_DIRECTORY_NAME; +} + +function posixRel(path: Path.Path, value: string): string { + return path.normalize(value.split("\\").join("/")).split("\\").join("/"); +} + +function parseSafeRelative( + path: Path.Path, + name: string, +): Effect.Effect { + const rel = posixRel(path, name); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + return Effect.fail(ioError(`Unsafe declarative export path: ${name}`)); + } + if (isCustomPath(rel)) { + return Effect.fail( + ioError( + `Refusing to write into reserved path: ${name}`, + "Keep hand-authored SQL in _custom/.", + ), + ); + } + return Effect.succeed(rel); +} + +function walkSqlFiles( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, + prefix = "", +): Effect.Effect, SchemaWorkspaceIoError> { + return Effect.gen(function* () { + const names = yield* fs + .readDirectory(directory) + .pipe(Effect.mapError((error) => ioError(`Failed to read ${directory}: ${error.message}`))); + const files: Array = []; + for (const name of names) { + const relative = prefix === "" ? name : `${prefix}/${name}`; + if (prefix === "" && name === SCHEMA_CUSTOM_DIRECTORY_NAME) continue; + const absolute = path.join(directory, name); + const isSymlink = yield* fs.readLink(absolute).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (isSymlink) continue; + const info = yield* fs + .stat(absolute) + .pipe(Effect.mapError((error) => ioError(`Failed to stat ${absolute}: ${error.message}`))); + if (info.type === "Directory") { + files.push(...(yield* walkSqlFiles(fs, path, absolute, relative))); + } else if (info.type === "File" && name.endsWith(".sql")) { + files.push({ + name: relative.split("\\").join("/"), + sql: yield* fs + .readFileString(absolute) + .pipe( + Effect.mapError((error) => ioError(`Failed to read ${absolute}: ${error.message}`)), + ), + }); + } + } + return files; + }); +} + +export type SchemaWorkspacePaths = { + readonly projectRoot: string; + readonly supabaseDir: string; + readonly projectHomeDir: string; +}; + +export const schemaWorkspaceLayer = (paths: SchemaWorkspacePaths) => + Layer.effect( + SchemaWorkspace, + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const schemasDir = path.join(paths.supabaseDir, SCHEMA_DIRECTORY_NAME); + const migrationsDir = path.join(paths.supabaseDir, MIGRATIONS_DIRECTORY_NAME); + const customDir = path.join(schemasDir, SCHEMA_CUSTOM_DIRECTORY_NAME); + const journalPath = path.join(paths.projectHomeDir, SCHEMA_DRAFT_JOURNAL_FILE_NAME); + const lockPath = path.join(paths.projectHomeDir, SCHEMA_LOCK_FILE_NAME); + + const readExistingSql = (directory = schemasDir) => + Effect.gen(function* () { + const exists = yield* fs.exists(directory).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return new Map(); + const files = yield* walkSqlFiles(fs, path, directory); + return new Map(files.map((file) => [file.name, file.sql])); + }); + + const classifyProposed = (proposed: ReadonlyArray, directory = schemasDir) => + Effect.gen(function* () { + const existing = yield* readExistingSql(directory); + const exists = yield* fs.exists(directory).pipe(Effect.orElseSucceed(() => false)); + const previous = exists ? readExportManifest(directory) : undefined; + return classifySqlFiles({ + proposed, + existing, + ...(previous?.files !== undefined ? { previouslyOwned: new Set(previous.files) } : {}), + }); + }); + + const writeTree = Effect.fnUntraced(function* ( + directory: string, + files: ReadonlyArray, + classification: SqlFileClassification, + pruneUnmanaged: boolean, + manifest: Record, + ) { + yield* fs + .makeDirectory(directory, { recursive: true }) + .pipe( + Effect.mapError((error) => ioError(`Failed to create ${directory}: ${error.message}`)), + ); + + const changed = new Set([...classification.created, ...classification.updated]); + for (const file of files) { + const rel = yield* parseSafeRelative(path, file.name); + if (!changed.has(rel) && existingHas(classification, rel)) continue; + const target = path.join(directory, rel); + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.mapError((error) => + ioError(`Failed to create ${path.dirname(target)}: ${error.message}`), + ), + ); + yield* fs + .writeFileString(target, file.sql) + .pipe( + Effect.mapError((error) => ioError(`Failed to write ${target}: ${error.message}`)), + ); + } + + for (const name of classification.removed) { + yield* fs + .remove(path.join(directory, name)) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.void + : Effect.fail(ioError(`Failed to remove ${name}: ${error.message}`)), + ), + ); + } + + if (pruneUnmanaged) { + for (const name of classification.unmanaged) { + yield* fs + .remove(path.join(directory, name)) + .pipe( + Effect.mapError((error) => ioError(`Failed to prune ${name}: ${error.message}`)), + ); + } + } + + const owned: Array = []; + for (const file of files) { + owned.push(yield* parseSafeRelative(path, file.name)); + } + owned.sort(); + const serialized = `${JSON.stringify({ formatVersion: 1, ...manifest, files: owned }, null, 2)}\n`; + yield* fs + .writeFileString(path.join(directory, EXPORT_MANIFEST_FILE), serialized) + .pipe( + Effect.mapError((error) => + ioError(`Failed to write export manifest: ${error.message}`), + ), + ); + }); + + function existingHas(classification: SqlFileClassification, rel: string): boolean { + return ( + classification.unchanged.includes(rel) || + classification.updated.includes(rel) || + classification.created.includes(rel) + ); + } + + const installExport = (input: SchemaInstallInput) => + Effect.gen(function* () { + const directory = input.mode === "output" ? (input.outputDir ?? schemasDir) : schemasDir; + const directoryDisplay = + input.mode === "output" + ? path.relative(paths.projectRoot, directory) + : path.join("supabase", SCHEMA_DIRECTORY_NAME); + + const proposed: Array = []; + for (const file of input.files) { + proposed.push({ + name: yield* parseSafeRelative(path, file.name), + sql: file.sql, + }); + } + + const destExists = yield* fs.exists(directory).pipe(Effect.orElseSucceed(() => false)); + const existing = destExists + ? yield* readExistingSql(directory) + : new Map(); + const hasSql = existing.size > 0; + + if (hasSql && input.mode === "init") { + return yield* new SchemaDeclarationsExistError({ + detail: "Declarative schema already exists.", + suggestion: SCHEMA_PULL_NO_MERGE_HELP, + }); + } + + if (hasSql && input.mode === "output") { + return yield* new SchemaDeclarationsExistError({ + detail: `Output directory already contains SQL: ${directoryDisplay}`, + suggestion: + "Choose an empty --output directory or pass --force to replace the primary tree.", + }); + } + + const classification = yield* classifyProposed(proposed, directory); + if (classification.unmanaged.length > 0 && !input.pruneUnmanaged) { + return yield* new SchemaUnmanagedFilesError({ + detail: `Unmanaged declarative files would be left in place: ${classification.unmanaged.join(", ")}`, + suggestion: + "Delete them yourself or pass --prune-unmanaged. _custom/ is never modified.", + paths: classification.unmanaged, + }); + } + + yield* writeTree( + directory, + proposed, + classification, + input.pruneUnmanaged, + input.manifest, + ); + + return { + directory, + directoryDisplay, + classification, + replaced: input.mode === "force", + manifestPath: path.join(directory, EXPORT_MANIFEST_FILE), + } satisfies SchemaInstallResult; + }); + + return SchemaWorkspace.of({ + schemasDir, + schemasDirDisplay: path.join("supabase", SCHEMA_DIRECTORY_NAME), + migrationsDir, + migrationsDirDisplay: path.join("supabase", MIGRATIONS_DIRECTORY_NAME), + customDir, + journalPath, + lockPath, + readDeclarationFiles: Effect.gen(function* () { + const exists = yield* fs.exists(schemasDir).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return []; + return yield* walkSqlFiles(fs, path, schemasDir); + }), + readExistingSql, + classifyProposed, + installExport, + }); + }), + ); diff --git a/apps/cli/src/shared/schema/schema-workspace.service.ts b/apps/cli/src/shared/schema/schema-workspace.service.ts new file mode 100644 index 0000000000..26448c0c0e --- /dev/null +++ b/apps/cli/src/shared/schema/schema-workspace.service.ts @@ -0,0 +1,58 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; +import type { SqlFileClassification } from "@supabase/pg-delta/frontends"; +import type { SchemaSqlFile } from "./schema-types.ts"; +import type { + SchemaDeclarationsExistError, + SchemaUnmanagedFilesError, + SchemaWorkspaceIoError, +} from "./schema-errors.ts"; + +type SchemaInstallMode = "init" | "force" | "output"; + +export type SchemaInstallInput = { + readonly files: ReadonlyArray; + readonly manifest: Record; + readonly mode: SchemaInstallMode; + readonly outputDir?: string; + readonly pruneUnmanaged: boolean; +}; + +export type SchemaInstallResult = { + readonly directory: string; + readonly directoryDisplay: string; + readonly classification: SqlFileClassification; + readonly replaced: boolean; + readonly manifestPath: string; +}; + +interface SchemaWorkspaceShape { + readonly schemasDir: string; + readonly schemasDirDisplay: string; + readonly migrationsDir: string; + readonly migrationsDirDisplay: string; + readonly customDir: string; + readonly journalPath: string; + readonly lockPath: string; + readonly readDeclarationFiles: Effect.Effect< + ReadonlyArray, + SchemaWorkspaceIoError + >; + readonly readExistingSql: ( + directory?: string, + ) => Effect.Effect, SchemaWorkspaceIoError>; + readonly classifyProposed: ( + proposed: ReadonlyArray, + directory?: string, + ) => Effect.Effect; + readonly installExport: ( + input: SchemaInstallInput, + ) => Effect.Effect< + SchemaInstallResult, + SchemaDeclarationsExistError | SchemaUnmanagedFilesError | SchemaWorkspaceIoError + >; +} + +export class SchemaWorkspace extends Context.Service()( + "supabase/schema/SchemaWorkspace", +) {} diff --git a/docs/cli/dev-alpha-command-structure.md b/docs/cli/dev-alpha-command-structure.md index ed7b075931..d6af0cba00 100644 --- a/docs/cli/dev-alpha-command-structure.md +++ b/docs/cli/dev-alpha-command-structure.md @@ -2,7 +2,9 @@ ## Purpose -This document defines the alpha command structure for the new Supabase CLI. +This document defines the command structure for the new Supabase CLI. + +The `schema` and `migrations` verbs ship on the stable (legacy) shell only. Singular `migration` on stable remains the Go-parity group. For alpha, we will design the command surface from `supabase dev` outward. The goal is not to mirror the old CLI or the Management API. The goal is to give both humans and LLMs one command set that feels obvious, consistent, and reusable. @@ -27,13 +29,13 @@ For alpha, we will use `schema` as the user-facing command group for database sh For alpha, the declarative schema workflow comes first. `schema` is the default path we will teach, document, and optimize for. -`schema generate` means "turn my declared schema intent into migration files without applying them yet." +`schema generate` means "turn my declared schema intent into migration files without applying them yet." `--dry-run` previews that same pipeline. -`schema apply` means "apply my declared schema intent to the local database." Under the hood, that may derive or update migration files before applying them, but the public workflow stays schema-first. +`schema apply` means "apply my declared schema intent to a verified-disposable local database." It does not write migration files. -`schema push` means "sync my declared schema intent to the platform." In practice, that can include deriving or updating migrations and then pushing that result to the platform as one schema-first workflow. It is a platform-sync command, not a local database mutation command. +There is no `schema push`. Durable remotes change only through `migrations push`. -`schema pull` means "pull schema state from the platform into the local schema representation." It is the reverse platform-sync command. +`schema pull` means "introspect a database into declarative SQL." The database is authoritative; pull does not merge. ### `migrations` is the advanced escape hatch @@ -49,7 +51,8 @@ For alpha, `push` and `pull` mean sync with the platform only. That rule applies across: -- `schema push` / `schema pull` +- `schema pull` +- `migrations push` / `migrations pull` - `functions push` / `functions pull` - `config push` / `config pull` - `env push` / `env pull` @@ -76,7 +79,7 @@ For alpha, we will use `push` and `pull` for platform Edge Function sync. This keeps the command language consistent across platform-sync asset types: -- `schema push` +- `migrations push` - `functions push` - `config push` - `env push` @@ -128,14 +131,13 @@ The public command surface for alpha is: - `supabase push` - `supabase pull` - Schema - - `supabase schema diff` - - `supabase schema generate` - - `supabase schema apply` - - `supabase schema push` - `supabase schema pull` + - `supabase schema generate` (`--dry-run` previews the same pipeline) + - `supabase schema apply` - Migrations - `supabase migrations new` - `supabase migrations list` + - `supabase migrations diff` - `supabase migrations apply` - `supabase migrations push` - `supabase migrations pull` @@ -203,7 +205,7 @@ The local workflow should feel like a single command, but it should still be bui At a high level, it will coordinate: -- `schema push` +- `migrations push` - `functions push` - remote config sync @@ -288,7 +290,7 @@ Some users will need more control than the high-level schema workflow provides. ### Why platform-only `push` and `pull` improve learnability -Using `push` and `pull` only for platform sync creates one directional vocabulary for the entire CLI. Once a user understands `schema push`, it is natural to understand `functions push`, `config push`, `env push`, and then top-level `push` without wondering whether the command will mutate a local database. +Using `push` and `pull` only for platform sync creates one directional vocabulary for the entire CLI. Once a user understands `migrations push`, it is natural to understand `functions push`, `config push`, `env push`, and then top-level `push` without wondering whether the command will mutate a local database. There is no `schema push` in V1: durable schema changes go through migration files. ### Why `apply` is clearer than overloading `push` @@ -315,7 +317,7 @@ The public command surface is: For database changes specifically, the alpha model is: -- `schema` for declarative authoring, diffing, generation, local apply, and schema-first platform sync -- `migrations` for direct file-level control, explicit local application, and explicit migration-level platform sync +- `schema` for declarative authoring, pull, generation (`--dry-run` preview), and local apply +- `migrations` for file-level control, `diff`, local apply, and the only remote schema path (`push` / `pull`) `dev` will orchestrate this command tree rather than replace it. diff --git a/docs/cli/schema-first-v1-plan.md b/docs/cli/schema-first-v1-plan.md new file mode 100644 index 0000000000..a0b9f7b54c --- /dev/null +++ b/docs/cli/schema-first-v1-plan.md @@ -0,0 +1,86 @@ +# Plan: Schema-First Database Development (V1) + +This is the implementation plan for the Schema-First RFC (revised 2026-08-18). +It is the working spec for the `feat/implement-rfc-schema-first-development` branch. + +## Product decisions (locked) + +1. Declarative SQL in `supabase/schemas/*.sql` is the primary source of database-shape intent. +2. Migrations in `supabase/migrations/*.sql` are the durable deployment recipe. +3. `schema apply` may journal-apply a pg-delta plan only to a verified-disposable local target. +4. `migrations push` is the only CLI path that mutates a durable remote schema. No `schema push`. +5. `schema generate` is declarations → migrations. `schema pull` is database → declarations. +6. `schema generate --dry-run` previews generate. `migrations diff` replaces `db diff`. +7. `schema pull` is database-authoritative regeneration (`--force` / `--output`). No merge. +8. `--yes` answers ordinary prompts. Durable identity is `--yes` or matching `--project-ref` for linked targets, and `--allow-remote` for raw URLs. There is no `--allow-data-loss`. Local disposable `schema apply` auto-approves modeled hazards. +9. New commands live at top level in the **legacy** (stable) CLI only. `next/` is going away and must not grow these verbs. Go-parity `db` and singular `migration` stay on stable. Plural `migrations` is the schema-first group (it is no longer an alias of `migration`). +10. `schema generate` / `apply` / `migrations diff|push|pull` plan against **isolated Docker shadows** restored from the existing tar cache (`$SUPABASE_HOME/cache/shadow-baseline/shadow-baseline-.tar`) — the same pool `#6223` shares with the main DB. Not native Postgres binaries, and not co-located `CREATE DATABASE` shadows. `planSchemaFiles` always uses `isolatedShadow: true` (separate cluster, provisioned as a Docker container). + +## Open questions (resolved for V1) + +| # | Decision | +| - | -------- | +| 1 | No tracked schema checkpoint sidecar. Export ownership stays in `.pgdelta-export.json`. Draft journal: `.supabase/schema-draft.json` (gitignored). Existing `.schema-checkpoint.json` files are ignored. | +| 2 | After a successful non-dry `schema generate` (including no-op when `M` already equals `D`), the draft journal is cleared. Local history is never written at generate time. `migrations apply` runs pending SQL, or inserts missing `supabase_migrations` rows for the longest pending prefix whose replay already matches the live catalog. Catalog match is schema-shape only — a pending DML-only file can be recorded without executing. Local `db reset` clears the journal immediately after the database is recreated. Reset is optional rebuild, not required to apply additive files. | +| 3 | `schema generate --baseline --name ` is the existing-database onboarding step. Registering that baseline as already applied on a remote is a separate, explicit history operation: `supabase migration repair --status applied `. It is not pull, and the CLI does not auto-repair. Push/pull/baseline generate emit copy-pasteable repair (or pull) commands. | +| 4 | Manual migration changes during an active ungenerated draft fail closed with generate / reset / discard. No automatic rebase. | +| 5 | Push does not classify pending files as destructive. Generate still fail-closes on ambiguous rename / coverage gaps. | +| 6 | Only a running local stack owned by this project is verified-disposable. Every remote/URL target is durable. No environment classification yet. | +| 7 | Clone proof is not required. Generate verifies by planning `M → D` and checking convergence on a clean replay. Push live-verifies declarations-ahead (`M → D`) and remote drift unless `--skip-verify`. | +| 8 | `migrations diff` supports `--file` / `-f` (preview-to-file, no apply). | +| 9 | Isolated shadows are Docker containers restored from `$SUPABASE_HOME/cache/shadow-baseline/`. The local target is this project's `supabase_db_` container. Co-located shadows and native Postgres binaries are not used on this path. | + +## Shell and ownership + +``` +apps/cli/src/shared/schema/ engine adapter, workspace, journal, use cases +apps/cli/src/shared/migrations/ repository/runner services, use cases +apps/cli/src/shared/database/ target resolution, pool, mutation auth +apps/cli/src/legacy/schema/ Docker shadows, Docker local target, linked connector, live repository/runner +apps/cli/src/legacy/commands/schema/ +apps/cli/src/legacy/commands/migrations/ +``` + +- `next/` must not import `legacy/`. `legacy/` must not import `next/`. +- Handlers call one use case and render. Handlers must not import other handlers. +- Use cases live in `shared/`. Live Docker/legacy helpers are provided from `legacy/schema/` layers (`shared/` cannot import `legacy/`). +- pg-delta stays the compiler. The CLI owns paths, targets, prompts, locks, and output. + +## Command surface (stable / legacy) + +| Command | Source → action | Side effects | +| ------- | --------------- | ------------ | +| `schema pull` | L/R → D | Declarative files, manifest (primary tree only) | +| `schema generate --dry-run` | M → D | None | +| `schema generate` | M → D | Migration files; clears draft journal | +| `schema apply` | L → D | Local DB + draft journal | +| `migrations new` | — | Empty migration file | +| `migrations list` | files ↔ history | None | +| `migrations diff` | M → live | Preview (optional `--file`) | +| `migrations apply` | pending files → L | Local DB + `supabase_migrations` | +| `migrations push` | pending files → R | Remote DB + history; fail closed on declarations-ahead or drift unless `--skip-verify`. Drift errors prefill `migration repair` / `migrations pull`. | +| `migrations pull` | R − M → files | Migration files; next action is `migration repair --status applied` for the written versions | + +Go-parity `db` / singular `migration` commands are unchanged on stable. This prototype does not add `next/` aliases or deprecations. + +## Safety + +- Target identity comes from stack ownership or linked project-ref, never hostname heuristics. +- `--yes` never bypasses the target gate or live verify (unless `--skip-verify`). +- Durable identity: interactive confirm-by-typing-ref; non-interactive `--yes` or matching `--project-ref`. +- `DATABASE_URL` / `SUPABASE_DB_URL` (and `--db-url`) are unverifiable URL targets: no `projectRef`, mutations require `--allow-remote`. An env URL is enough even when the project is not linked. Unset those env vars to use the linked project connection. +- Linked sockets (no env URL) come from the TypeScript linked resolver (`legacyResolveLinkedConn`) on the stable CLI. +- Raw URL targets: `--allow-remote` instead of ref assertion. +- Local `schema apply`: auto-approve modeled hazards. Ambiguous rename / coverage gap / unknown metadata still fail closed. +- `--skip-verify` skips push’s isolated-shadow declarations-ahead and remote-drift checks. Identity flags unchanged. +- Project lock: `.supabase/schema.lock`. + +## Out of scope + +- Top-level `push` / `pull` composition (CLI-1271 / CLI-1272) +- Composite `schema push` +- Semantic three-way merge +- File watcher / TUI +- Replacing Go-parity `db` / singular `migration` on stable +- Same verbs on `next/`, native Postgres binaries, or a private native-shadow cache +- Marketing "provable no-data-loss"