From 77aa3fc405467ca4280ac28bcf896ababd7e45c8 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 19 Aug 2026 12:42:55 +0200 Subject: [PATCH 1/6] feat(cli): add schema-first schema and migrations commands Stable and alpha share one Effect runtime: declarations in supabase/schemas, durable remote mutations only through migrations push, and --from local resolving this project's native stack or Docker DB. --- .../legacy/cli/legacy-complete.unit.test.ts | 2 +- apps/cli/src/legacy/cli/root.ts | 4 + .../commands/migration/migration.command.ts | 1 - .../migration/migration.integration.test.ts | 35 +- .../migrations/apply/apply.command.ts | 14 + .../migrations/apply/apply.handler.ts | 8 + .../commands/migrations/diff/diff.command.ts | 39 ++ .../commands/migrations/diff/diff.handler.ts | 14 + .../commands/migrations/list/list.command.ts | 27 ++ .../commands/migrations/list/list.handler.ts | 11 + .../commands/migrations/migrations.command.ts | 26 ++ .../commands/migrations/new/new.command.ts | 30 ++ .../commands/migrations/new/new.handler.ts | 11 + .../commands/migrations/pull/pull.command.ts | 34 ++ .../commands/migrations/pull/pull.handler.ts | 14 + .../commands/migrations/push/push.command.ts | 47 ++ .../commands/migrations/push/push.handler.ts | 17 + .../commands/schema/apply/apply.command.ts | 47 ++ .../commands/schema/apply/apply.handler.ts | 16 + .../schema/generate/generate.command.ts | 50 +++ .../schema/generate/generate.handler.ts | 15 + .../commands/schema/pull/pull.command.ts | 63 +++ .../commands/schema/pull/pull.handler.ts | 16 + .../legacy/commands/schema/schema.command.ts | 20 + .../config/legacy-next-cli-config.layer.ts | 53 +++ .../legacy/config/legacy-next-cli-config.ts | 44 ++ .../legacy-next-cli-config.unit.test.ts | 130 ++++++ .../legacy/docs/legacy-docs-spec.tables.ts | 2 + .../legacy-docker-local-database.layer.ts | 140 ++++++ .../schema/legacy-schema-runtime.layer.ts | 43 ++ apps/cli/src/next/cli/root.ts | 8 + apps/cli/src/next/commands/db/db.command.ts | 162 +++++++ .../commands/migration/migration.command.ts | 65 +++ .../migrations/apply/apply.command.ts | 14 + .../migrations/apply/apply.handler.ts | 8 + .../commands/migrations/diff/diff.command.ts | 39 ++ .../commands/migrations/diff/diff.handler.ts | 12 + .../commands/migrations/list/list.command.ts | 27 ++ .../commands/migrations/list/list.handler.ts | 9 + .../commands/migrations/migrations.command.ts | 26 ++ .../commands/migrations/new/new.command.ts | 30 ++ .../commands/migrations/new/new.handler.ts | 9 + .../migrations/new/new.integration.test.ts | 125 ++++++ .../commands/migrations/pull/pull.command.ts | 34 ++ .../commands/migrations/pull/pull.handler.ts | 12 + .../commands/migrations/push/push.command.ts | 47 ++ .../commands/migrations/push/push.handler.ts | 15 + .../commands/schema/apply/apply.command.ts | 47 ++ .../commands/schema/apply/apply.handler.ts | 14 + .../schema/apply/apply.integration.test.ts | 105 +++++ .../schema/generate/generate.command.ts | 50 +++ .../schema/generate/generate.handler.ts | 13 + .../next/commands/schema/pull/pull.command.ts | 63 +++ .../next/commands/schema/pull/pull.handler.ts | 14 + .../next/commands/schema/schema.command.ts | 16 + .../src/next/config/project-runtime.layer.ts | 66 ++- apps/cli/src/shared/database/database-pool.ts | 18 + .../database/database-target.service.ts | 22 + .../src/shared/database/database-target.ts | 32 ++ .../destructive-auth.integration.test.ts | 104 +++++ .../src/shared/database/destructive-auth.ts | 91 ++++ .../local-database-fallback.service.ts | 21 + .../src/shared/database/local-postgres-url.ts | 30 ++ .../database/local-postgres-url.unit.test.ts | 35 ++ .../src/shared/migrations/apply-migrations.ts | 54 +++ .../src/shared/migrations/diff-migrations.ts | 87 ++++ .../src/shared/migrations/list-migrations.ts | 60 +++ .../src/shared/migrations/migration-file.ts | 44 ++ .../migrations/migration-repository.layer.ts | 180 ++++++++ .../migration-repository.service.ts | 34 ++ .../migrations/migration-runner.layer.ts | 117 +++++ .../migrations/migration-runner.service.ts | 33 ++ .../src/shared/migrations/new-migration.ts | 44 ++ .../src/shared/migrations/pull-migrations.ts | 89 ++++ .../push-migrations.integration.test.ts | 122 +++++ .../src/shared/migrations/push-migrations.ts | 105 +++++ apps/cli/src/shared/schema/apply-schema.ts | 172 ++++++++ .../shared/schema/database-target.layer.ts | 121 +++++ .../src/shared/schema/declarations-ahead.ts | 33 ++ apps/cli/src/shared/schema/generate-schema.ts | 210 +++++++++ .../shared/schema/isolated-shadow.service.ts | 13 + .../schema/native-isolated-shadow-key.ts | 52 +++ .../native-isolated-shadow-key.unit.test.ts | 34 ++ .../schema/native-isolated-shadow.layer.ts | 416 ++++++++++++++++++ .../shared/schema/pg-delta-engine.layer.ts | 195 ++++++++ .../shared/schema/pg-delta-engine.service.ts | 54 +++ .../schema/prepare-declarative-shadow.ts | 58 +++ .../prepare-declarative-shadow.unit.test.ts | 55 +++ .../schema/pull-schema.integration.test.ts | 253 +++++++++++ apps/cli/src/shared/schema/pull-schema.ts | 157 +++++++ apps/cli/src/shared/schema/schema-digest.ts | 22 + .../shared/schema/schema-digest.unit.test.ts | 28 ++ .../cli/src/shared/schema/schema-ecosystem.ts | 17 + apps/cli/src/shared/schema/schema-errors.ts | 154 +++++++ apps/cli/src/shared/schema/schema-output.ts | 21 + apps/cli/src/shared/schema/schema-paths.ts | 11 + .../cli/src/shared/schema/schema-plan-gate.ts | 24 + .../src/shared/schema/schema-plan-options.ts | 13 + .../schema/schema-plan-options.unit.test.ts | 11 + apps/cli/src/shared/schema/schema-render.ts | 22 + .../src/shared/schema/schema-runtime.layer.ts | 73 +++ apps/cli/src/shared/schema/schema-shadow.ts | 3 + .../src/shared/schema/schema-state.layer.ts | 187 ++++++++ .../src/shared/schema/schema-state.service.ts | 23 + apps/cli/src/shared/schema/schema-types.ts | 116 +++++ .../shared/schema/schema-workspace.layer.ts | 299 +++++++++++++ .../shared/schema/schema-workspace.service.ts | 59 +++ docs/cli/dev-alpha-command-structure.md | 32 +- docs/cli/schema-first-v1-plan.md | 98 +++++ packages/stack/src/effect.ts | 4 + packages/stack/src/resolve-native-binary.ts | 21 + packages/stack/src/services/postgres-init.ts | 14 +- 112 files changed, 6348 insertions(+), 52 deletions(-) create mode 100644 apps/cli/src/legacy/commands/migrations/apply/apply.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/apply/apply.handler.ts create mode 100644 apps/cli/src/legacy/commands/migrations/diff/diff.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/diff/diff.handler.ts create mode 100644 apps/cli/src/legacy/commands/migrations/list/list.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/list/list.handler.ts create mode 100644 apps/cli/src/legacy/commands/migrations/migrations.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/new/new.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/new/new.handler.ts create mode 100644 apps/cli/src/legacy/commands/migrations/pull/pull.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/pull/pull.handler.ts create mode 100644 apps/cli/src/legacy/commands/migrations/push/push.command.ts create mode 100644 apps/cli/src/legacy/commands/migrations/push/push.handler.ts create mode 100644 apps/cli/src/legacy/commands/schema/apply/apply.command.ts create mode 100644 apps/cli/src/legacy/commands/schema/apply/apply.handler.ts create mode 100644 apps/cli/src/legacy/commands/schema/generate/generate.command.ts create mode 100644 apps/cli/src/legacy/commands/schema/generate/generate.handler.ts create mode 100644 apps/cli/src/legacy/commands/schema/pull/pull.command.ts create mode 100644 apps/cli/src/legacy/commands/schema/pull/pull.handler.ts create mode 100644 apps/cli/src/legacy/commands/schema/schema.command.ts create mode 100644 apps/cli/src/legacy/config/legacy-next-cli-config.layer.ts create mode 100644 apps/cli/src/legacy/config/legacy-next-cli-config.ts create mode 100644 apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts create mode 100644 apps/cli/src/legacy/schema/legacy-docker-local-database.layer.ts create mode 100644 apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts create mode 100644 apps/cli/src/next/commands/db/db.command.ts create mode 100644 apps/cli/src/next/commands/migration/migration.command.ts create mode 100644 apps/cli/src/next/commands/migrations/apply/apply.command.ts create mode 100644 apps/cli/src/next/commands/migrations/apply/apply.handler.ts create mode 100644 apps/cli/src/next/commands/migrations/diff/diff.command.ts create mode 100644 apps/cli/src/next/commands/migrations/diff/diff.handler.ts create mode 100644 apps/cli/src/next/commands/migrations/list/list.command.ts create mode 100644 apps/cli/src/next/commands/migrations/list/list.handler.ts create mode 100644 apps/cli/src/next/commands/migrations/migrations.command.ts create mode 100644 apps/cli/src/next/commands/migrations/new/new.command.ts create mode 100644 apps/cli/src/next/commands/migrations/new/new.handler.ts create mode 100644 apps/cli/src/next/commands/migrations/new/new.integration.test.ts create mode 100644 apps/cli/src/next/commands/migrations/pull/pull.command.ts create mode 100644 apps/cli/src/next/commands/migrations/pull/pull.handler.ts create mode 100644 apps/cli/src/next/commands/migrations/push/push.command.ts create mode 100644 apps/cli/src/next/commands/migrations/push/push.handler.ts create mode 100644 apps/cli/src/next/commands/schema/apply/apply.command.ts create mode 100644 apps/cli/src/next/commands/schema/apply/apply.handler.ts create mode 100644 apps/cli/src/next/commands/schema/apply/apply.integration.test.ts create mode 100644 apps/cli/src/next/commands/schema/generate/generate.command.ts create mode 100644 apps/cli/src/next/commands/schema/generate/generate.handler.ts create mode 100644 apps/cli/src/next/commands/schema/pull/pull.command.ts create mode 100644 apps/cli/src/next/commands/schema/pull/pull.handler.ts create mode 100644 apps/cli/src/next/commands/schema/schema.command.ts create mode 100644 apps/cli/src/shared/database/database-pool.ts create mode 100644 apps/cli/src/shared/database/database-target.service.ts create mode 100644 apps/cli/src/shared/database/database-target.ts create mode 100644 apps/cli/src/shared/database/destructive-auth.integration.test.ts create mode 100644 apps/cli/src/shared/database/destructive-auth.ts create mode 100644 apps/cli/src/shared/database/local-database-fallback.service.ts create mode 100644 apps/cli/src/shared/database/local-postgres-url.ts create mode 100644 apps/cli/src/shared/database/local-postgres-url.unit.test.ts create mode 100644 apps/cli/src/shared/migrations/apply-migrations.ts create mode 100644 apps/cli/src/shared/migrations/diff-migrations.ts create mode 100644 apps/cli/src/shared/migrations/list-migrations.ts create mode 100644 apps/cli/src/shared/migrations/migration-file.ts create mode 100644 apps/cli/src/shared/migrations/migration-repository.layer.ts create mode 100644 apps/cli/src/shared/migrations/migration-repository.service.ts create mode 100644 apps/cli/src/shared/migrations/migration-runner.layer.ts create mode 100644 apps/cli/src/shared/migrations/migration-runner.service.ts create mode 100644 apps/cli/src/shared/migrations/new-migration.ts create mode 100644 apps/cli/src/shared/migrations/pull-migrations.ts create mode 100644 apps/cli/src/shared/migrations/push-migrations.integration.test.ts create mode 100644 apps/cli/src/shared/migrations/push-migrations.ts create mode 100644 apps/cli/src/shared/schema/apply-schema.ts create mode 100644 apps/cli/src/shared/schema/database-target.layer.ts create mode 100644 apps/cli/src/shared/schema/declarations-ahead.ts create mode 100644 apps/cli/src/shared/schema/generate-schema.ts create mode 100644 apps/cli/src/shared/schema/isolated-shadow.service.ts create mode 100644 apps/cli/src/shared/schema/native-isolated-shadow-key.ts create mode 100644 apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts create mode 100644 apps/cli/src/shared/schema/native-isolated-shadow.layer.ts create mode 100644 apps/cli/src/shared/schema/pg-delta-engine.layer.ts create mode 100644 apps/cli/src/shared/schema/pg-delta-engine.service.ts create mode 100644 apps/cli/src/shared/schema/prepare-declarative-shadow.ts create mode 100644 apps/cli/src/shared/schema/prepare-declarative-shadow.unit.test.ts create mode 100644 apps/cli/src/shared/schema/pull-schema.integration.test.ts create mode 100644 apps/cli/src/shared/schema/pull-schema.ts create mode 100644 apps/cli/src/shared/schema/schema-digest.ts create mode 100644 apps/cli/src/shared/schema/schema-digest.unit.test.ts create mode 100644 apps/cli/src/shared/schema/schema-ecosystem.ts create mode 100644 apps/cli/src/shared/schema/schema-errors.ts create mode 100644 apps/cli/src/shared/schema/schema-output.ts create mode 100644 apps/cli/src/shared/schema/schema-paths.ts create mode 100644 apps/cli/src/shared/schema/schema-plan-gate.ts create mode 100644 apps/cli/src/shared/schema/schema-plan-options.ts create mode 100644 apps/cli/src/shared/schema/schema-plan-options.unit.test.ts create mode 100644 apps/cli/src/shared/schema/schema-render.ts create mode 100644 apps/cli/src/shared/schema/schema-runtime.layer.ts create mode 100644 apps/cli/src/shared/schema/schema-shadow.ts create mode 100644 apps/cli/src/shared/schema/schema-state.layer.ts create mode 100644 apps/cli/src/shared/schema/schema-state.service.ts create mode 100644 apps/cli/src/shared/schema/schema-types.ts create mode 100644 apps/cli/src/shared/schema/schema-workspace.layer.ts create mode 100644 apps/cli/src/shared/schema/schema-workspace.service.ts create mode 100644 docs/cli/schema-first-v1-plan.md create mode 100644 packages/stack/src/resolve-native-binary.ts 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/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..988bb9dcd4 --- /dev/null +++ b/apps/cli/src/legacy/commands/migrations/push/push.command.ts @@ -0,0 +1,47 @@ +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. Never authorizes data loss."), + Flag.withAlias("y"), + ), + allowDataLoss: Flag.boolean("allow-data-loss").pipe( + Flag.withDescription("Required when pending migrations are destructive or unclassified."), + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription( + "Must match the resolved linked project for destructive non-interactive runs.", + ), + 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 for destructive plans."), + Flag.optional, + ), +} 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..aa102efb47 --- /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, + allowDataLoss: flags.allowDataLoss, + projectRef: Option.getOrUndefined(flags.projectRef), + allowRemote: flags.allowRemote, + dbUrl: Option.getOrUndefined(flags.dbUrl), + }); + 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..91008e9030 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/apply/apply.command.ts @@ -0,0 +1,47 @@ +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. Never authorizes data loss."), + Flag.withAlias("y"), + ), + allowDataLoss: Flag.boolean("allow-data-loss").pipe( + Flag.withDescription( + "Required for destructive plans on durable targets. Implied on local apply.", + ), + ), + 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..3dd19e6c30 --- /dev/null +++ b/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts @@ -0,0 +1,16 @@ +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, + allowDataLoss: flags.allowDataLoss, + 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..2821363db7 --- /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).", + ), + ), +} 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/config/legacy-next-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-next-cli-config.layer.ts new file mode 100644 index 0000000000..34ef5c7313 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-next-cli-config.layer.ts @@ -0,0 +1,53 @@ +import { Effect, Layer, Option } from "effect"; +import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { ProjectContext } from "../../next/config/project-context.service.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { + LegacyDebugLogger, + type LegacyDebugLoggerShape, +} from "../shared/legacy-debug-logger.service.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; +import { cliConfigFromLegacyProfile } from "./legacy-next-cli-config.ts"; + +const silentDebugLogger: LegacyDebugLoggerShape = { + debug: () => Effect.void, + http: () => Effect.void, +}; + +/** + * Next `CliConfig` for any legacy-hosted command that reuses next services. + * + * `--profile` is a stable global and is already resolved into `LegacyCliConfig`. + * Commands must not rebuild hosts from env-only next config. + */ +export const legacyNextCliConfigLayer = Layer.effect( + CliConfig, + Effect.gen(function* () { + const legacy = yield* LegacyCliConfig; + const runtimeInfo = yield* RuntimeInfo; + const projectContext = yield* Effect.serviceOption(ProjectContext); + const debugLogger = Option.getOrElse( + yield* Effect.serviceOption(LegacyDebugLogger), + () => silentDebugLogger, + ); + const env = Option.match(projectContext, { + onNone: () => process.env, + onSome: (context) => + Option.match(context.projectEnv, { + onNone: () => process.env, + onSome: (projectEnv) => projectEnv.values, + }), + }); + yield* debugLogger.debug(`Using profile: ${legacy.profile} (${legacy.projectHost})`); + return CliConfig.of( + cliConfigFromLegacyProfile({ + apiUrl: legacy.apiUrl, + dashboardUrl: legacy.dashboardUrl, + projectHost: legacy.projectHost, + accessToken: legacy.accessToken, + homeDir: runtimeInfo.homeDir, + env, + }), + ); + }), +); diff --git a/apps/cli/src/legacy/config/legacy-next-cli-config.ts b/apps/cli/src/legacy/config/legacy-next-cli-config.ts new file mode 100644 index 0000000000..89eb3f1926 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-next-cli-config.ts @@ -0,0 +1,44 @@ +import { Option, Redacted } from "effect"; +import { resolvePosthogConfig } from "../../shared/telemetry/posthog-config.ts"; +import { resolveSupabaseHome } from "../../shared/config/supabase-home.ts"; + +function readEnv( + env: Readonly>, + key: string, +): Option.Option { + const value = env[key]; + return value === undefined ? Option.none() : Option.some(value); +} + +/** + * Next `CliConfig` fields sourced from a resolved legacy profile. + * + * `--profile` is a stable global. Commands that reuse next services must not + * ignore it: API host, dashboard, project host, and the env access token come + * from `LegacyCliConfig`, while telemetry / `SUPABASE_HOME` stay env-based. + */ +export const cliConfigFromLegacyProfile = (input: { + readonly apiUrl: string; + readonly dashboardUrl: string; + readonly projectHost: string; + readonly accessToken: Option.Option>; + readonly homeDir: string; + readonly env?: Readonly>; +}) => { + const env = input.env ?? process.env; + const posthogConfig = resolvePosthogConfig(env); + return { + apiUrl: input.apiUrl, + dashboardUrl: input.dashboardUrl, + projectHost: input.projectHost, + telemetryPosthogHost: posthogConfig.host, + telemetryPosthogKey: posthogConfig.key, + accessToken: input.accessToken, + noKeyring: readEnv(env, "SUPABASE_NO_KEYRING"), + supabaseHome: resolveSupabaseHome(env, input.homeDir), + debug: readEnv(env, "SUPABASE_DEBUG"), + telemetryDebug: readEnv(env, "SUPABASE_TELEMETRY_DEBUG"), + telemetryDisabled: readEnv(env, "SUPABASE_TELEMETRY_DISABLED"), + doNotTrack: readEnv(env, "DO_NOT_TRACK"), + }; +}; diff --git a/apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts b/apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts new file mode 100644 index 0000000000..97e207e4ce --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { ProjectContext } from "../../next/config/project-context.service.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; +import { legacyNextCliConfigLayer } from "./legacy-next-cli-config.layer.ts"; +import { cliConfigFromLegacyProfile } from "./legacy-next-cli-config.ts"; + +describe("cliConfigFromLegacyProfile", () => { + it("copies the resolved legacy profile endpoints onto next CliConfig", () => { + const config = cliConfigFromLegacyProfile({ + apiUrl: "https://api.supabase.green", + dashboardUrl: "https://supabase.green/dashboard", + projectHost: "supabase.red", + accessToken: Option.some(Redacted.make("sbp_staging", { label: "token" })), + homeDir: "/Users/me", + env: {}, + }); + expect(config.apiUrl).toBe("https://api.supabase.green"); + expect(config.dashboardUrl).toBe("https://supabase.green/dashboard"); + expect(config.projectHost).toBe("supabase.red"); + expect(config.supabaseHome).toBe("/Users/me/.supabase"); + expect(Option.isSome(config.accessToken)).toBe(true); + if (Option.isSome(config.accessToken)) { + expect(Redacted.value(config.accessToken.value)).toBe("sbp_staging"); + } + }); + + it("honors SUPABASE_HOME from the environment", () => { + const config = cliConfigFromLegacyProfile({ + apiUrl: "https://api.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + projectHost: "supabase.co", + accessToken: Option.none(), + homeDir: "/Users/me", + env: { SUPABASE_HOME: "/tmp/supabase-home" }, + }); + expect(config.supabaseHome).toBe("/tmp/supabase-home"); + }); + + it.live("provides next CliConfig from a resolved staging LegacyCliConfig", () => + Effect.gen(function* () { + const config = yield* CliConfig; + expect(config.apiUrl).toBe("https://api.supabase.green"); + expect(config.dashboardUrl).toBe("https://supabase.green/dashboard"); + expect(config.projectHost).toBe("supabase.red"); + }).pipe( + Effect.provide(legacyNextCliConfigLayer), + Effect.provide( + Layer.succeed(LegacyCliConfig, { + profile: "supabase-staging", + apiUrl: "https://api.supabase.green", + projectHost: "supabase.red", + poolerHost: "supabase.green", + dashboardUrl: "https://supabase.green/dashboard", + accessToken: Option.none(), + projectId: Option.none(), + workdir: "/tmp", + userAgent: "SupabaseCLI/test", + }), + ), + Effect.provide( + Layer.succeed( + RuntimeInfo, + RuntimeInfo.of({ + cwd: "/tmp", + platform: process.platform, + arch: process.arch, + homeDir: "/tmp/home", + execPath: process.execPath, + pid: process.pid, + }), + ), + ), + ), + ); + + it.live("resolves supabaseHome from ProjectContext.projectEnv", () => + Effect.gen(function* () { + const config = yield* CliConfig; + expect(config.supabaseHome).toBe("/tmp/from-project-env"); + }).pipe( + Effect.provide(legacyNextCliConfigLayer), + Effect.provide( + Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + accessToken: Option.none(), + projectId: Option.none(), + workdir: "/tmp", + userAgent: "SupabaseCLI/test", + }), + ), + Effect.provide( + Layer.succeed( + RuntimeInfo, + RuntimeInfo.of({ + cwd: "/tmp", + platform: process.platform, + arch: process.arch, + homeDir: "/tmp/home", + execPath: process.execPath, + pid: process.pid, + }), + ), + ), + Effect.provide( + Layer.succeed(ProjectContext, { + paths: Option.none(), + projectEnv: Option.some({ + paths: { + projectRoot: "/tmp/proj", + supabaseDir: "/tmp/proj/supabase", + configPath: "/tmp/proj/supabase/config.toml", + envPath: "/tmp/proj/supabase/.env", + envLocalPath: "/tmp/proj/supabase/.env.local", + }, + values: { SUPABASE_HOME: "/tmp/from-project-env" }, + loadedPaths: [], + sources: {}, + }), + }), + ), + ), + ); +}); 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-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-schema-runtime.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts new file mode 100644 index 0000000000..f3d269af78 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts @@ -0,0 +1,43 @@ +import { Effect, Layer, Option } from "effect"; +import { schemaRuntimeLayer } from "../../shared/schema/schema-runtime.layer.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyNextCliConfigLayer } from "../config/legacy-next-cli-config.layer.ts"; +import { legacyDockerLocalDatabaseFallbackLayer } from "./legacy-docker-local-database.layer.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; + +const legacyCliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +export const legacySchemaRuntimeLayer = (commandPath: ReadonlyArray) => + Layer.unwrap( + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + const runtimeInfo = yield* RuntimeInfo; + const debugLogger = yield* Effect.serviceOption(LegacyDebugLogger); + const runtime = Layer.succeed( + RuntimeInfo, + RuntimeInfo.of({ + ...runtimeInfo, + cwd: config.workdir, + }), + ); + return schemaRuntimeLayer(commandPath, { + runtimeInfo: runtime, + cliConfig: legacyNextCliConfigLayer.pipe( + Layer.provide(Layer.succeed(LegacyCliConfig, config)), + Layer.provide(runtime), + Layer.provide( + Option.match(debugLogger, { + onNone: () => Layer.empty, + onSome: (logger) => Layer.succeed(LegacyDebugLogger, logger), + }), + ), + ), + localDatabaseFallback: legacyDockerLocalDatabaseFallbackLayer.pipe( + Layer.provide(Layer.succeed(LegacyCliConfig, config)), + ), + }); + }), + ).pipe(Layer.provide(legacyCliConfig), Layer.provide(legacyDebugLoggerLayer)); diff --git a/apps/cli/src/next/cli/root.ts b/apps/cli/src/next/cli/root.ts index 8166d1a70f..01f844c272 100644 --- a/apps/cli/src/next/cli/root.ts +++ b/apps/cli/src/next/cli/root.ts @@ -22,6 +22,10 @@ import { stopCommand } from "../commands/stop/stop.command.ts"; import { telemetryCommand } from "../commands/telemetry/telemetry.command.ts"; import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { updateCommand } from "../commands/update/update.command.ts"; +import { schemaCommand } from "../commands/schema/schema.command.ts"; +import { migrationsCommand } from "../commands/migrations/migrations.command.ts"; +import { dbCommand } from "../commands/db/db.command.ts"; +import { migrationCommand } from "../commands/migration/migration.command.ts"; import { outputLayerFor } from "../../shared/output/output.layer.ts"; import { jsonCliOutputFormatter } from "../../shared/output/json-formatter.ts"; @@ -49,6 +53,10 @@ export const nextRoot = Command.make("supabase").pipe( statusCommand, logsCommand, apiCommand, + schemaCommand, + migrationsCommand, + dbCommand, + migrationCommand, ]), Command.provide( Layer.unwrap( diff --git a/apps/cli/src/next/commands/db/db.command.ts b/apps/cli/src/next/commands/db/db.command.ts new file mode 100644 index 0000000000..b4cf35fb9b --- /dev/null +++ b/apps/cli/src/next/commands/db/db.command.ts @@ -0,0 +1,162 @@ +import { Effect, Option } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { Output } from "../../../shared/output/output.service.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../shared/schema/schema-runtime.layer.ts"; +import { diffMigrations } from "../../../shared/migrations/diff-migrations.ts"; +import { pullMigrations } from "../../../shared/migrations/pull-migrations.ts"; +import { pushMigrations } from "../../../shared/migrations/push-migrations.ts"; +import { pullSchema } from "../../../shared/schema/pull-schema.ts"; +import { generateSchema } from "../../../shared/schema/generate-schema.ts"; +import { applySchema } from "../../../shared/schema/apply-schema.ts"; +import { renderSchemaResult } from "../../../shared/schema/schema-render.ts"; + +const notice = (alias: string, target: string) => + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw(`Command "${alias}" is deprecated, use "${target}" instead.\n`, "stderr"); + }); + +const dbDiffFlags = { + against: Flag.string("against").pipe(Flag.optional), + file: Flag.string("file").pipe(Flag.withAlias("f"), Flag.optional), +} as const; + +const dbDiffCommand = Command.make("diff", dbDiffFlags).pipe( + Command.withDescription("Deprecated alias for migrations diff."), + Command.withShortDescription("Deprecated: use migrations diff"), + Command.withHandler((flags) => + Effect.gen(function* () { + yield* notice("db diff", "migrations diff"); + const result = yield* diffMigrations({ + against: Option.getOrUndefined(flags.against) ?? "local", + file: Option.getOrUndefined(flags.file), + }); + yield* renderSchemaResult("Diff migrations", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["db", "diff"])), +); + +const dbPushFlags = { + yes: Flag.boolean("yes").pipe(Flag.withAlias("y")), + allowDataLoss: Flag.boolean("allow-data-loss"), + projectRef: Flag.string("project-ref").pipe(Flag.optional), + allowRemote: Flag.boolean("allow-remote"), + dbUrl: Flag.string("db-url").pipe(Flag.optional), +} as const; + +const dbPushCommand = Command.make("push", dbPushFlags).pipe( + Command.withDescription("Deprecated alias for migrations push."), + Command.withShortDescription("Deprecated: use migrations push"), + Command.withHandler((flags) => + Effect.gen(function* () { + yield* notice("db push", "migrations push"); + const result = yield* pushMigrations({ + yes: flags.yes, + allowDataLoss: flags.allowDataLoss, + projectRef: Option.getOrUndefined(flags.projectRef), + allowRemote: flags.allowRemote, + dbUrl: Option.getOrUndefined(flags.dbUrl), + }); + yield* renderSchemaResult("Push migrations", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["db", "push"])), +); + +const dbPullFlags = { + from: Flag.string("from").pipe(Flag.optional), + declarative: Flag.boolean("declarative"), + output: Flag.string("output").pipe(Flag.optional), + force: Flag.boolean("force"), + pruneUnmanaged: Flag.boolean("prune-unmanaged"), +} as const; + +const dbPullCommand = Command.make("pull", dbPullFlags).pipe( + Command.withDescription( + "Deprecated alias for migrations pull, or schema pull when --declarative is set.", + ), + Command.withShortDescription("Deprecated: use schema pull or migrations pull"), + Command.withHandler((flags) => + Effect.gen(function* () { + if (flags.declarative) { + yield* notice("db pull --declarative", "schema pull --from linked"); + const result = yield* pullSchema({ + from: Option.getOrUndefined(flags.from) ?? "linked", + output: Option.getOrUndefined(flags.output), + force: flags.force, + pruneUnmanaged: flags.pruneUnmanaged, + }); + yield* renderSchemaResult("Pull declarative schema", result); + return; + } + yield* notice("db pull", "migrations pull"); + const result = yield* pullMigrations({ + from: Option.getOrUndefined(flags.from), + }); + yield* renderSchemaResult("Pull remote migrations", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["db", "pull"])), +); + +const declarativeGenerateCommand = Command.make("generate").pipe( + Command.withDescription("Deprecated alias for schema pull."), + Command.withShortDescription("Deprecated: use schema pull"), + Command.withHandler(() => + Effect.gen(function* () { + yield* notice("db schema declarative generate", "schema pull"); + const result = yield* pullSchema({ from: "local", force: false, pruneUnmanaged: false }); + yield* renderSchemaResult("Pull declarative schema", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["db", "schema", "declarative", "generate"])), +); + +const declarativeSyncFlags = { + apply: Flag.boolean("apply"), + name: Flag.string("name").pipe(Flag.optional), +} as const; + +const declarativeSyncCommand = Command.make("sync", declarativeSyncFlags).pipe( + Command.withDescription("Deprecated alias for schema generate."), + Command.withShortDescription("Deprecated: use schema generate"), + Command.withHandler((flags) => + Effect.gen(function* () { + yield* notice("db schema declarative sync", "schema generate"); + const generated = yield* generateSchema({ + name: Option.getOrUndefined(flags.name), + dryRun: false, + baseline: false, + }); + yield* renderSchemaResult("Generate schema migrations", generated); + if (flags.apply) { + const applied = yield* applySchema({ + yes: true, + allowDataLoss: true, + allowRemote: false, + }); + yield* renderSchemaResult("Apply declarative schema", applied); + } + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["db", "schema", "declarative", "sync"])), +); + +const declarativeCommand = Command.make("declarative").pipe( + Command.withDescription("Deprecated declarative schema aliases."), + Command.withSubcommands([declarativeGenerateCommand, declarativeSyncCommand]), +); + +const dbSchemaCommand = Command.make("schema").pipe( + Command.withDescription("Deprecated schema aliases. Use top-level schema."), + Command.withSubcommands([declarativeCommand]), +); + +export const dbCommand = Command.make("db").pipe( + Command.withDescription("Deprecated database aliases. Prefer schema and migrations."), + Command.withShortDescription("Deprecated: use schema and migrations"), + Command.withSubcommands([dbDiffCommand, dbPushCommand, dbPullCommand, dbSchemaCommand]), +); diff --git a/apps/cli/src/next/commands/migration/migration.command.ts b/apps/cli/src/next/commands/migration/migration.command.ts new file mode 100644 index 0000000000..322b118e88 --- /dev/null +++ b/apps/cli/src/next/commands/migration/migration.command.ts @@ -0,0 +1,65 @@ +import { Effect, Option } from "effect"; +import { Argument, Command } from "effect/unstable/cli"; +import { Output } from "../../../shared/output/output.service.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../shared/schema/schema-runtime.layer.ts"; +import { applyMigrations } from "../../../shared/migrations/apply-migrations.ts"; +import { listMigrations } from "../../../shared/migrations/list-migrations.ts"; +import { newMigration } from "../../../shared/migrations/new-migration.ts"; +import { renderSchemaResult } from "../../../shared/schema/schema-render.ts"; + +const notice = (alias: string, target: string) => + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw(`Command "${alias}" is deprecated, use "${target}" instead.\n`, "stderr"); + }); + +const newArgs = { + name: Argument.string("name").pipe(Argument.optional), +} as const; + +const migrationNewCommand = Command.make("new", newArgs).pipe( + Command.withDescription("Deprecated alias for migrations new."), + Command.withShortDescription("Deprecated: use migrations new"), + Command.withHandler((flags) => + Effect.gen(function* () { + yield* notice("migration new", "migrations new"); + const result = yield* newMigration(Option.getOrUndefined(flags.name)); + yield* renderSchemaResult("Create migration", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["migration", "new"])), +); + +const migrationListCommand = Command.make("list").pipe( + Command.withDescription("Deprecated alias for migrations list."), + Command.withShortDescription("Deprecated: use migrations list"), + Command.withHandler(() => + Effect.gen(function* () { + yield* notice("migration list", "migrations list"); + const result = yield* listMigrations({}); + yield* renderSchemaResult("List migrations", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["migration", "list"])), +); + +const migrationUpCommand = Command.make("up").pipe( + Command.withDescription("Deprecated alias for migrations apply."), + Command.withShortDescription("Deprecated: use migrations apply"), + Command.withHandler(() => + Effect.gen(function* () { + yield* notice("migration up", "migrations apply"); + const result = yield* applyMigrations(); + yield* renderSchemaResult("Apply migrations", result); + }).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["migration", "up"])), +); + +export const migrationCommand = Command.make("migration").pipe( + Command.withDescription("Deprecated singular alias for migrations."), + Command.withShortDescription("Deprecated: use migrations"), + Command.withSubcommands([migrationNewCommand, migrationListCommand, migrationUpCommand]), +); diff --git a/apps/cli/src/next/commands/migrations/apply/apply.command.ts b/apps/cli/src/next/commands/migrations/apply/apply.command.ts new file mode 100644 index 0000000000..4b5b96f764 --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { migrationsApply } from "./apply.handler.ts"; + +export const migrationsApplyCommand = Command.make("apply").pipe( + Command.withDescription("Apply exact pending migration files to the local database."), + Command.withShortDescription("Apply pending migrations locally"), + Command.withHandler(() => + migrationsApply().pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["migrations", "apply"])), +); diff --git a/apps/cli/src/next/commands/migrations/apply/apply.handler.ts b/apps/cli/src/next/commands/migrations/apply/apply.handler.ts new file mode 100644 index 0000000000..c2963c6786 --- /dev/null +++ b/apps/cli/src/next/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 migrationsApply = Effect.fn("migrations.apply")(function* () { + const result = yield* applyMigrations(); + yield* renderSchemaResult("Apply migrations", result); +}); diff --git a/apps/cli/src/next/commands/migrations/diff/diff.command.ts b/apps/cli/src/next/commands/migrations/diff/diff.command.ts new file mode 100644 index 0000000000..b84660a96b --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { migrationsDiff } from "./diff.handler.ts"; + +const flags = { + 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 MigrationsDiffFlags = CliCommand.Command.Config.Infer; + +export const migrationsDiffCommand = Command.make("diff", flags).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((commandFlags) => + migrationsDiff(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["migrations", "diff"])), +); diff --git a/apps/cli/src/next/commands/migrations/diff/diff.handler.ts b/apps/cli/src/next/commands/migrations/diff/diff.handler.ts new file mode 100644 index 0000000000..d7ba218cb6 --- /dev/null +++ b/apps/cli/src/next/commands/migrations/diff/diff.handler.ts @@ -0,0 +1,12 @@ +import { Effect, Option } from "effect"; +import { diffMigrations } from "../../../../shared/migrations/diff-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { MigrationsDiffFlags } from "./diff.command.ts"; + +export const migrationsDiff = Effect.fn("migrations.diff")(function* (flags: MigrationsDiffFlags) { + 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/next/commands/migrations/list/list.command.ts b/apps/cli/src/next/commands/migrations/list/list.command.ts new file mode 100644 index 0000000000..1f7b00103c --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { migrationsList } from "./list.handler.ts"; + +const flags = { + against: Flag.string("against").pipe( + Flag.withDescription("Target to compare: local, linked, or a connection string."), + Flag.optional, + ), +} as const; + +export type MigrationsListFlags = CliCommand.Command.Config.Infer; + +export const migrationsListCommand = Command.make("list", flags).pipe( + Command.withDescription("Compare local migration files with target migration history."), + Command.withShortDescription("List local and remote migrations"), + Command.withHandler((commandFlags) => + migrationsList(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["migrations", "list"])), +); diff --git a/apps/cli/src/next/commands/migrations/list/list.handler.ts b/apps/cli/src/next/commands/migrations/list/list.handler.ts new file mode 100644 index 0000000000..9a484ba01f --- /dev/null +++ b/apps/cli/src/next/commands/migrations/list/list.handler.ts @@ -0,0 +1,9 @@ +import { Effect, Option } from "effect"; +import { listMigrations } from "../../../../shared/migrations/list-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { MigrationsListFlags } from "./list.command.ts"; + +export const migrationsList = Effect.fn("migrations.list")(function* (flags: MigrationsListFlags) { + const result = yield* listMigrations({ against: Option.getOrUndefined(flags.against) }); + yield* renderSchemaResult("List migrations", result); +}); diff --git a/apps/cli/src/next/commands/migrations/migrations.command.ts b/apps/cli/src/next/commands/migrations/migrations.command.ts new file mode 100644 index 0000000000..73c55bf76b --- /dev/null +++ b/apps/cli/src/next/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 { migrationsApplyCommand } from "./apply/apply.command.ts"; +import { migrationsDiffCommand } from "./diff/diff.command.ts"; +import { migrationsListCommand } from "./list/list.command.ts"; +import { migrationsNewCommand } from "./new/new.command.ts"; +import { migrationsPullCommand } from "./pull/pull.command.ts"; +import { migrationsPushCommand } from "./push/push.command.ts"; + +export const migrationsCommand = 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([ + migrationsNewCommand, + migrationsListCommand, + migrationsDiffCommand, + migrationsApplyCommand, + migrationsPushCommand, + migrationsPullCommand, + ]), +); diff --git a/apps/cli/src/next/commands/migrations/new/new.command.ts b/apps/cli/src/next/commands/migrations/new/new.command.ts new file mode 100644 index 0000000000..119ec9d0f9 --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { migrationsNew } from "./new.handler.ts"; + +const args = { + name: Argument.string("name").pipe( + Argument.withDescription("Migration name."), + Argument.optional, + ), +} as const; + +export type MigrationsNewFlags = CliCommand.Command.Config.Infer; + +export const migrationsNewCommand = Command.make("new", args).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) => + migrationsNew(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(schemaRuntimeLayer(["migrations", "new"])), +); diff --git a/apps/cli/src/next/commands/migrations/new/new.handler.ts b/apps/cli/src/next/commands/migrations/new/new.handler.ts new file mode 100644 index 0000000000..270a60d3fe --- /dev/null +++ b/apps/cli/src/next/commands/migrations/new/new.handler.ts @@ -0,0 +1,9 @@ +import { Effect, Option } from "effect"; +import { newMigration } from "../../../../shared/migrations/new-migration.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { MigrationsNewFlags } from "./new.command.ts"; + +export const migrationsNew = Effect.fn("migrations.new")(function* (flags: MigrationsNewFlags) { + const result = yield* newMigration(Option.getOrUndefined(flags.name)); + yield* renderSchemaResult("Create migration", result); +}); diff --git a/apps/cli/src/next/commands/migrations/new/new.integration.test.ts b/apps/cli/src/next/commands/migrations/new/new.integration.test.ts new file mode 100644 index 0000000000..f9a40b46f2 --- /dev/null +++ b/apps/cli/src/next/commands/migrations/new/new.integration.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Exit, Layer } from "effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { newMigration } from "../../../../shared/migrations/new-migration.ts"; +import { MigrationRepository } from "../../../../shared/migrations/migration-repository.service.ts"; +import { migrationRepositoryLayer } from "../../../../shared/migrations/migration-repository.layer.ts"; +import { schemaStateLayer } from "../../../../shared/schema/schema-state.layer.ts"; +import { schemaWorkspaceLayer } from "../../../../shared/schema/schema-workspace.layer.ts"; + +describe("newMigration", () => { + it.live("creates a timestamped migration file", () => { + const root = mkdtempSync(join(tmpdir(), "migrations-new-")); + const supabaseDir = join(root, "supabase"); + const projectHomeDir = join(root, ".supabase"); + mkdirSync(supabaseDir, { recursive: true }); + mkdirSync(projectHomeDir, { recursive: true }); + const workspace = schemaWorkspaceLayer({ + projectRoot: root, + supabaseDir, + projectHomeDir, + }).pipe(Layer.provide(BunServices.layer)); + const out = mockOutput({ format: "json" }); + const layer = Layer.mergeAll( + out.layer, + BunServices.layer, + workspace, + schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + migrationRepositoryLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + ); + + return Effect.gen(function* () { + const result = yield* newMigration("add_billing").pipe(Effect.provide(layer)); + expect(result.mutatedFiles).toBe(true); + const files = readdirSync(join(supabaseDir, "migrations")); + expect(files.some((name) => name.endsWith("_add_billing.sql"))).toBe(true); + expect(existsSync(join(supabaseDir, "migrations", files[0]!))).toBe(true); + }); + }); + + it.live("assigns a unique version to each generated plan unit", () => { + const root = mkdtempSync(join(tmpdir(), "migrations-units-")); + const supabaseDir = join(root, "supabase"); + const projectHomeDir = join(root, ".supabase"); + mkdirSync(supabaseDir, { recursive: true }); + mkdirSync(projectHomeDir, { recursive: true }); + const workspace = schemaWorkspaceLayer({ + projectRoot: root, + supabaseDir, + projectHomeDir, + }).pipe(Layer.provide(BunServices.layer)); + const layer = Layer.mergeAll( + mockOutput({ format: "json" }).layer, + BunServices.layer, + workspace, + schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + migrationRepositoryLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + ); + + return Effect.gen(function* () { + const written = yield* Effect.gen(function* () { + const repository = yield* MigrationRepository; + return yield* repository.writeGenerated({ + name: "split_plan", + baseMillis: Date.parse("2026-01-01T00:00:00.000Z"), + files: [ + { suffix: null, sql: "create table a (id int);\n", transactional: true }, + { suffix: "nt", sql: "grant select on a to anon;\n", transactional: false }, + ], + }); + }).pipe(Effect.provide(layer)); + expect(written.map((file) => file.version)).toEqual(["20260101000000", "20260101000001"]); + expect(written.map((file) => file.fileName)).toEqual([ + "20260101000000_split_plan.sql", + "20260101000001_split_plan_nt.sql", + ]); + }); + }); + + it.live("refuses to create a migration while a declarative draft is active", () => { + const root = mkdtempSync(join(tmpdir(), "migrations-draft-")); + const supabaseDir = join(root, "supabase"); + const projectHomeDir = join(root, ".supabase"); + mkdirSync(supabaseDir, { recursive: true }); + mkdirSync(projectHomeDir, { recursive: true }); + writeFileSync( + join(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`, + ); + const workspace = schemaWorkspaceLayer({ + projectRoot: root, + supabaseDir, + projectHomeDir, + }).pipe(Layer.provide(BunServices.layer)); + const layer = Layer.mergeAll( + mockOutput({ format: "json" }).layer, + BunServices.layer, + workspace, + schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + migrationRepositoryLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), + ); + + return Effect.gen(function* () { + const exit = yield* newMigration("should_fail").pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); +}); diff --git a/apps/cli/src/next/commands/migrations/pull/pull.command.ts b/apps/cli/src/next/commands/migrations/pull/pull.command.ts new file mode 100644 index 0000000000..b6dd80c916 --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { migrationsPull } from "./pull.handler.ts"; + +const flags = { + 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 MigrationsPullFlags = CliCommand.Command.Config.Infer; + +export const migrationsPullCommand = Command.make("pull", flags).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((commandFlags) => + migrationsPull(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["migrations", "pull"])), +); diff --git a/apps/cli/src/next/commands/migrations/pull/pull.handler.ts b/apps/cli/src/next/commands/migrations/pull/pull.handler.ts new file mode 100644 index 0000000000..45f897006b --- /dev/null +++ b/apps/cli/src/next/commands/migrations/pull/pull.handler.ts @@ -0,0 +1,12 @@ +import { Effect, Option } from "effect"; +import { pullMigrations } from "../../../../shared/migrations/pull-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { MigrationsPullFlags } from "./pull.command.ts"; + +export const migrationsPull = Effect.fn("migrations.pull")(function* (flags: MigrationsPullFlags) { + 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/next/commands/migrations/push/push.command.ts b/apps/cli/src/next/commands/migrations/push/push.command.ts new file mode 100644 index 0000000000..621db63e74 --- /dev/null +++ b/apps/cli/src/next/commands/migrations/push/push.command.ts @@ -0,0 +1,47 @@ +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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { migrationsPush } from "./push.handler.ts"; + +const flags = { + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Answer ordinary prompts. Never authorizes data loss."), + Flag.withAlias("y"), + ), + allowDataLoss: Flag.boolean("allow-data-loss").pipe( + Flag.withDescription("Required when pending migrations are destructive or unclassified."), + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription( + "Must match the resolved linked project for destructive non-interactive runs.", + ), + 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 for destructive plans."), + Flag.optional, + ), +} as const; + +export type MigrationsPushFlags = CliCommand.Command.Config.Infer; + +export const migrationsPushCommand = Command.make("push", flags).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((commandFlags) => + migrationsPush(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["migrations", "push"])), +); diff --git a/apps/cli/src/next/commands/migrations/push/push.handler.ts b/apps/cli/src/next/commands/migrations/push/push.handler.ts new file mode 100644 index 0000000000..a6a2c6b8f4 --- /dev/null +++ b/apps/cli/src/next/commands/migrations/push/push.handler.ts @@ -0,0 +1,15 @@ +import { Effect, Option } from "effect"; +import { pushMigrations } from "../../../../shared/migrations/push-migrations.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { MigrationsPushFlags } from "./push.command.ts"; + +export const migrationsPush = Effect.fn("migrations.push")(function* (flags: MigrationsPushFlags) { + const result = yield* pushMigrations({ + yes: flags.yes, + allowDataLoss: flags.allowDataLoss, + projectRef: Option.getOrUndefined(flags.projectRef), + allowRemote: flags.allowRemote, + dbUrl: Option.getOrUndefined(flags.dbUrl), + }); + yield* renderSchemaResult("Push migrations", result); +}); diff --git a/apps/cli/src/next/commands/schema/apply/apply.command.ts b/apps/cli/src/next/commands/schema/apply/apply.command.ts new file mode 100644 index 0000000000..f94fc5c022 --- /dev/null +++ b/apps/cli/src/next/commands/schema/apply/apply.command.ts @@ -0,0 +1,47 @@ +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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { schemaApply } from "./apply.handler.ts"; + +const flags = { + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Answer ordinary prompts. Never authorizes data loss."), + Flag.withAlias("y"), + ), + allowDataLoss: Flag.boolean("allow-data-loss").pipe( + Flag.withDescription( + "Required for destructive plans on durable targets. Implied on local apply.", + ), + ), + 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 SchemaApplyFlags = CliCommand.Command.Config.Infer; + +export const schemaApplyCommand = Command.make("apply", flags).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((commandFlags) => + schemaApply(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["schema", "apply"])), +); diff --git a/apps/cli/src/next/commands/schema/apply/apply.handler.ts b/apps/cli/src/next/commands/schema/apply/apply.handler.ts new file mode 100644 index 0000000000..3efe03df29 --- /dev/null +++ b/apps/cli/src/next/commands/schema/apply/apply.handler.ts @@ -0,0 +1,14 @@ +import { Effect, Option } from "effect"; +import { applySchema } from "../../../../shared/schema/apply-schema.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { SchemaApplyFlags } from "./apply.command.ts"; + +export const schemaApply = Effect.fn("schema.apply")(function* (flags: SchemaApplyFlags) { + const result = yield* applySchema({ + yes: flags.yes, + allowDataLoss: flags.allowDataLoss, + projectRef: Option.getOrUndefined(flags.projectRef), + allowRemote: flags.allowRemote, + }); + yield* renderSchemaResult("Apply declarative schema", result); +}); diff --git a/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts new file mode 100644 index 0000000000..25f9e24e22 --- /dev/null +++ b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { applySchema } from "../../../../shared/schema/apply-schema.ts"; +import { DatabaseTargetResolver } from "../../../../shared/database/database-target.service.ts"; +import { SchemaStateStore } from "../../../../shared/schema/schema-state.service.ts"; +import { SchemaWorkspace } from "../../../../shared/schema/schema-workspace.service.ts"; +import { PgDeltaSchemaEngine } from "../../../../shared/schema/pg-delta-engine.service.ts"; +import { MigrationRepository } from "../../../../shared/migrations/migration-repository.service.ts"; +import { MigrationRunner } from "../../../../shared/migrations/migration-runner.service.ts"; +import { Option } from "effect"; + +function setup(disposable: boolean) { + const out = mockOutput({ interactive: false }); + return { + layer: Layer.mergeAll( + out.layer, + Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => + Effect.succeed({ + kind: disposable ? "local" : "linked", + identity: disposable ? "local:default" : "abcdefghijklmnop", + connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + disposable, + durable: !disposable, + connectionVerified: disposable, + ...(disposable ? {} : { projectRef: "abcdefghijklmnop" }), + }), + }), + ), + Layer.succeed( + SchemaWorkspace, + SchemaWorkspace.of({ + schemasDir: "/tmp/schemas", + schemasDirDisplay: "supabase/schemas", + migrationsDir: "/tmp/migrations", + migrationsDirDisplay: "supabase/migrations", + customDir: "/tmp/schemas/_custom", + checkpointPath: "/tmp/c", + 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({ + readCheckpoint: Effect.succeed(Option.none()), + writeCheckpoint: () => Effect.void, + readJournal: Effect.succeed(Option.none()), + writeJournal: () => Effect.void, + clearJournal: Effect.void, + withLock: (effect) => effect, + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed([]), + applyPending: () => Effect.succeed({ applied: [], skipped: [] }), + recordApplied: () => Effect.void, + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.die("unused"), + diffPools: () => Effect.die("unused"), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.die("unused"), + }), + ), + ), + }; +} + +describe("applySchema", () => { + it.live("refuses durable remote targets", () => { + const { layer } = setup(false); + return Effect.gen(function* () { + const exit = yield* applySchema({ + yes: true, + allowDataLoss: true, + allowRemote: true, + projectRef: "abcdefghijklmnop", + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); +}); diff --git a/apps/cli/src/next/commands/schema/generate/generate.command.ts b/apps/cli/src/next/commands/schema/generate/generate.command.ts new file mode 100644 index 0000000000..26e248a1b5 --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { schemaGenerate } from "./generate.handler.ts"; + +const flags = { + 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).", + ), + ), +} as const; + +export type SchemaGenerateFlags = CliCommand.Command.Config.Infer; + +export const schemaGenerateCommand = Command.make("generate", flags).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((commandFlags) => + schemaGenerate(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["schema", "generate"])), +); diff --git a/apps/cli/src/next/commands/schema/generate/generate.handler.ts b/apps/cli/src/next/commands/schema/generate/generate.handler.ts new file mode 100644 index 0000000000..4f498b8b22 --- /dev/null +++ b/apps/cli/src/next/commands/schema/generate/generate.handler.ts @@ -0,0 +1,13 @@ +import { Effect, Option } from "effect"; +import { generateSchema } from "../../../../shared/schema/generate-schema.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { SchemaGenerateFlags } from "./generate.command.ts"; + +export const schemaGenerate = Effect.fn("schema.generate")(function* (flags: SchemaGenerateFlags) { + 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/next/commands/schema/pull/pull.command.ts b/apps/cli/src/next/commands/schema/pull/pull.command.ts new file mode 100644 index 0000000000..613220c322 --- /dev/null +++ b/apps/cli/src/next/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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { + SCHEMA_ECOSYSTEM_MAPPING_HELP, + SCHEMA_PULL_NO_MERGE_HELP, +} from "../../../../shared/schema/schema-ecosystem.ts"; +import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; +import { schemaPull } from "./pull.handler.ts"; + +const flags = { + 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 SchemaPullFlags = CliCommand.Command.Config.Infer; + +export const schemaPullCommand = Command.make("pull", flags).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((commandFlags) => + schemaPull(commandFlags).pipe( + withCommandInstrumentation({ flags: commandFlags }), + withJsonErrorHandling, + ), + ), + Command.provide(schemaRuntimeLayer(["schema", "pull"])), +); diff --git a/apps/cli/src/next/commands/schema/pull/pull.handler.ts b/apps/cli/src/next/commands/schema/pull/pull.handler.ts new file mode 100644 index 0000000000..70e89f42bd --- /dev/null +++ b/apps/cli/src/next/commands/schema/pull/pull.handler.ts @@ -0,0 +1,14 @@ +import { Effect, Option } from "effect"; +import { pullSchema } from "../../../../shared/schema/pull-schema.ts"; +import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; +import type { SchemaPullFlags } from "./pull.command.ts"; + +export const schemaPull = Effect.fn("schema.pull")(function* (flags: SchemaPullFlags) { + 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/next/commands/schema/schema.command.ts b/apps/cli/src/next/commands/schema/schema.command.ts new file mode 100644 index 0000000000..9faa27445e --- /dev/null +++ b/apps/cli/src/next/commands/schema/schema.command.ts @@ -0,0 +1,16 @@ +import { Command } from "effect/unstable/cli"; +import { SCHEMA_ECOSYSTEM_MAPPING_HELP } from "../../../shared/schema/schema-ecosystem.ts"; +import { schemaApplyCommand } from "./apply/apply.command.ts"; +import { schemaGenerateCommand } from "./generate/generate.command.ts"; +import { schemaPullCommand } from "./pull/pull.command.ts"; + +export const schemaCommand = 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([schemaPullCommand, schemaGenerateCommand, schemaApplyCommand]), +); diff --git a/apps/cli/src/next/config/project-runtime.layer.ts b/apps/cli/src/next/config/project-runtime.layer.ts index beefea3671..35d52e35c9 100644 --- a/apps/cli/src/next/config/project-runtime.layer.ts +++ b/apps/cli/src/next/config/project-runtime.layer.ts @@ -1,25 +1,38 @@ import { BunServices } from "@effect/platform-bun"; import { Layer } from "effect"; import { runtimeInfoLayer } from "../../shared/runtime/runtime-info.layer.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { CliConfig } from "./cli-config.service.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { projectHomeLayer } from "./project-home.layer.ts"; -const discoveredProjectContextLayer = projectContextLayer.pipe( - Layer.provide(runtimeInfoLayer), - Layer.provide(BunServices.layer), -); +export type ProjectCommandRuntimeOptions = { + readonly runtimeInfo?: Layer.Layer; + readonly cliConfig?: Layer.Layer; +}; -export const discoveredCliConfigLayer = cliConfigLayer.pipe( - Layer.provide(discoveredProjectContextLayer), - Layer.provide(runtimeInfoLayer), -); +const makeDiscoveredProjectContextLayer = (runtimeInfo: Layer.Layer) => + projectContextLayer.pipe(Layer.provide(runtimeInfo), Layer.provide(BunServices.layer)); -const discoveredProjectHomeLayer = projectHomeLayer.pipe( - Layer.provide(discoveredProjectContextLayer), - Layer.provide(runtimeInfoLayer), - Layer.provide(BunServices.layer), -); +const makeDiscoveredCliConfigLayer = (runtimeInfo: Layer.Layer) => + cliConfigLayer.pipe( + Layer.provide(makeDiscoveredProjectContextLayer(runtimeInfo)), + Layer.provide(runtimeInfo), + ); + +const makeDiscoveredProjectHomeLayer = (runtimeInfo: Layer.Layer) => + projectHomeLayer.pipe( + Layer.provide(makeDiscoveredProjectContextLayer(runtimeInfo)), + Layer.provide(runtimeInfo), + Layer.provide(BunServices.layer), + ); + +const discoveredProjectContextLayer = makeDiscoveredProjectContextLayer(runtimeInfoLayer); + +export const discoveredCliConfigLayer = makeDiscoveredCliConfigLayer(runtimeInfoLayer); + +const discoveredProjectHomeLayer = makeDiscoveredProjectHomeLayer(runtimeInfoLayer); export const projectCommandBaseLayer = Layer.mergeAll( discoveredProjectHomeLayer, @@ -30,11 +43,26 @@ export const projectCommandBaseLayer = Layer.mergeAll( Layer.provide(BunServices.layer), ); -export const provideProjectCommandRuntime = (layer: Layer.Layer) => - layer.pipe( - Layer.provide(discoveredProjectHomeLayer), - Layer.provide(discoveredCliConfigLayer), - Layer.provide(discoveredProjectContextLayer), - Layer.provide(runtimeInfoLayer), +export const provideProjectCommandRuntime = ( + layer: Layer.Layer, + options?: ProjectCommandRuntimeOptions, +) => { + const runtimeInfo = options?.runtimeInfo ?? runtimeInfoLayer; + const projectContext = makeDiscoveredProjectContextLayer(runtimeInfo); + const cliConfig = + options?.cliConfig ?? + cliConfigLayer.pipe(Layer.provide(projectContext), Layer.provide(runtimeInfo)); + return layer.pipe( + Layer.provide( + projectHomeLayer.pipe( + Layer.provide(projectContext), + Layer.provide(runtimeInfo), + Layer.provide(BunServices.layer), + ), + ), + Layer.provide(cliConfig), + Layer.provide(projectContext), + Layer.provide(runtimeInfo), Layer.provide(BunServices.layer), ); +}; 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..226f066625 --- /dev/null +++ b/apps/cli/src/shared/database/database-target.ts @@ -0,0 +1,32 @@ +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; +}; + +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/destructive-auth.integration.test.ts b/apps/cli/src/shared/database/destructive-auth.integration.test.ts new file mode 100644 index 0000000000..2992c2ef1f --- /dev/null +++ b/apps/cli/src/shared/database/destructive-auth.integration.test.ts @@ -0,0 +1,104 @@ +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 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 even when destructive", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + yield* authorizeMutation({ + target: local, + destructive: true, + flags: { yes: false, allowDataLoss: false, allowRemote: false }, + command: "schema apply", + }); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("rejects destructive durable plans without --allow-data-loss", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: linked, + destructive: true, + flags: { + yes: true, + allowDataLoss: false, + allowRemote: false, + projectRef: "abcdefghijklmnop", + }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).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, + destructive: true, + flags: { yes: true, allowDataLoss: true, allowRemote: false, projectRef: "otherref" }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("requires --allow-remote for any unverifiable URL mutation", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: url, + destructive: false, + flags: { yes: true, allowDataLoss: false, allowRemote: false }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(Layer.mergeAll(out.layer))); + }); + + it.live("requires --allow-remote for destructive URL targets", () => { + const out = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const exit = yield* authorizeMutation({ + target: url, + destructive: true, + flags: { yes: true, allowDataLoss: true, allowRemote: false }, + command: "migrations push", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(Layer.mergeAll(out.layer))); + }); +}); 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..da62961cbd --- /dev/null +++ b/apps/cli/src/shared/database/destructive-auth.ts @@ -0,0 +1,91 @@ +import { Effect } from "effect"; +import { + SchemaAllowRemoteRequiredError, + SchemaCancelledError, + SchemaDestructiveAuthError, + SchemaProjectRefMismatchError, +} from "../schema/schema-errors.ts"; +import type { DatabaseTarget } from "./database-target.ts"; +import { Output } from "../output/output.service.ts"; + +export type DestructiveAuthFlags = { + readonly yes: boolean; + readonly allowDataLoss: boolean; + readonly projectRef?: string; + readonly allowRemote: boolean; +}; + +export const authorizeMutation = Effect.fnUntraced(function* (input: { + readonly target: DatabaseTarget; + readonly destructive: boolean; + readonly flags: DestructiveAuthFlags; + readonly command: string; +}) { + const { target, destructive, flags, command } = input; + + if (target.disposable) { + return; + } + + if (destructive && !flags.allowDataLoss) { + return yield* new SchemaDestructiveAuthError({ + detail: "This plan contains destructive actions.", + suggestion: `Re-run with --allow-data-loss. --yes never authorizes data loss.`, + }); + } + + if (!target.connectionVerified) { + if (!flags.allowRemote) { + return yield* new SchemaAllowRemoteRequiredError({ + detail: "This connection string cannot be identity-verified.", + suggestion: `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 (destructive) { + return yield* new SchemaProjectRefMismatchError({ + detail: `Non-interactive destructive mutation of ${resolvedRef} requires an explicit identity assertion.`, + suggestion: `Pass --project-ref ${resolvedRef}.`, + }); + } + + if (!flags.yes) { + return yield* new SchemaDestructiveAuthError({ + detail: "Non-interactive mutation of a durable target requires confirmation.", + suggestion: + "Pass --yes for ordinary confirmation. Destructive plans also need --allow-data-loss and --project-ref.", + }); + } +}); 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..572d9966a6 --- /dev/null +++ b/apps/cli/src/shared/database/local-database-fallback.service.ts @@ -0,0 +1,21 @@ +import { Context, Effect, Layer, 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 when the native stack is not recorded. + * Used by the stable shell to see a Docker `supabase start` for *this* project. + * Absent on next, which only has native stack state. + */ +export class LocalDatabaseFallback extends Context.Service< + LocalDatabaseFallback, + LocalDatabaseFallbackShape +>()("supabase/database/LocalDatabaseFallback") {} + +export const noLocalDatabaseFallbackLayer = Layer.succeed(LocalDatabaseFallback, { + resolve: Effect.succeed(Option.none()), +}); 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-migrations.ts b/apps/cli/src/shared/migrations/apply-migrations.ts new file mode 100644 index 0000000000..475d03cd71 --- /dev/null +++ b/apps/cli/src/shared/migrations/apply-migrations.ts @@ -0,0 +1,54 @@ +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 { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +export const applyMigrations = Effect.fn("migrations.apply")(function* () { + const targets = yield* DatabaseTargetResolver; + const repository = yield* MigrationRepository; + const runner = yield* MigrationRunner; + 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* runner.applyPending(pool, local); + return { + status: "clean", + message: + result.applied.length === 0 + ? "No pending migrations." + : `Applied ${result.applied.length} migration(s): ${result.applied.join(", ")}`, + data: { + status: "clean", + applied: result.applied, + skipped: result.skipped, + target: target.identity, + mutated_database: result.applied.length > 0, + mutated_files: false, + }, + nextActions: [], + mutatedDatabase: result.applied.length > 0, + 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..82a6e6fdbb --- /dev/null +++ b/apps/cli/src/shared/migrations/diff-migrations.ts @@ -0,0 +1,87 @@ +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"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.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 repository = yield* MigrationRepository; + const runner = yield* MigrationRunner; + const live = yield* targets.resolve(parseTargetSelector(input.against)); + const migrations = yield* repository.listLocal; + + return yield* Effect.scoped( + Effect.gen(function* () { + const livePool = yield* acquireDatabasePool(live.connectionString); + const shadow = yield* engine.provisionShadow; + const sourcePool = yield* acquireDatabasePool(shadow.url); + yield* runner.applyPending(sourcePool, migrations); + 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/migration-file.ts b/apps/cli/src/shared/migrations/migration-file.ts new file mode 100644 index 0000000000..afdcd6c62a --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-file.ts @@ -0,0 +1,44 @@ +import { + MIGRATE_FILE_PATTERN, + MIGRATION_NO_TRANSACTION_DIRECTIVE, +} from "../schema/schema-paths.ts"; + +export type MigrationFile = { + readonly version: string; + readonly name: string; + readonly fileName: string; + readonly absolutePath: string; + readonly content: string; + readonly transactional: boolean; +}; + +export function formatMigrationTimestamp(millis: number): string { + return new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); +} + +export function parseMigrationFileName( + fileName: string, +): { version: string; name: string } | undefined { + const match = MIGRATE_FILE_PATTERN.exec(fileName); + if (match === null) return undefined; + const version = match[1]; + const name = match[2]; + if (version === undefined || name === undefined) return undefined; + return { version, name }; +} + +export function migrationFileName(version: string, name: string, suffix?: string | null): string { + return suffix === undefined || suffix === null || suffix === "" + ? `${version}_${name}.sql` + : `${version}_${name}_${suffix}.sql`; +} + +export function parseMigrationContent(content: string): { + readonly transactional: boolean; +} { + const withoutBom = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; + const firstNewline = withoutBom.indexOf("\n"); + const rawFirstLine = firstNewline < 0 ? withoutBom : withoutBom.slice(0, firstNewline); + const firstLine = rawFirstLine.endsWith("\r") ? rawFirstLine.slice(0, -1) : rawFirstLine; + return { transactional: firstLine !== MIGRATION_NO_TRANSACTION_DIRECTIVE }; +} diff --git a/apps/cli/src/shared/migrations/migration-repository.layer.ts b/apps/cli/src/shared/migrations/migration-repository.layer.ts new file mode 100644 index 0000000000..35a62203bd --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-repository.layer.ts @@ -0,0 +1,180 @@ +import { Clock, Effect, FileSystem, Layer, Path } from "effect"; +import { SchemaMigrationNameError, SchemaWorkspaceIoError } from "../schema/schema-errors.ts"; +import { MIGRATION_NO_TRANSACTION_DIRECTIVE } from "../schema/schema-paths.ts"; +import { SchemaWorkspace } from "../schema/schema-workspace.service.ts"; +import { + formatMigrationTimestamp, + migrationFileName, + parseMigrationContent, + parseMigrationFileName, + type MigrationFile, +} from "./migration-file.ts"; +import { MigrationRepository } from "./migration-repository.service.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 migrationRepositoryLayer = Layer.effect( + MigrationRepository, + Effect.gen(function* () { + const workspace = yield* SchemaWorkspace; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const readLocal = Effect.gen(function* () { + const exists = yield* fs + .exists(workspace.migrationsDir) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) return []; + const names = yield* fs + .readDirectory(workspace.migrationsDir) + .pipe(Effect.mapError((error) => ioError(`Failed to read migrations: ${error.message}`))); + const files: Array = []; + for (const fileName of names) { + const parsed = parseMigrationFileName(fileName); + if (parsed === undefined) continue; + const absolutePath = path.join(workspace.migrationsDir, fileName); + const content = yield* fs + .readFileString(absolutePath) + .pipe( + Effect.mapError((error) => ioError(`Failed to read ${fileName}: ${error.message}`)), + ); + files.push({ + version: parsed.version, + name: parsed.name, + fileName, + absolutePath, + content, + transactional: parseMigrationContent(content).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; + }; + + return MigrationRepository.of({ + listLocal: readLocal, + createEmpty: (name, content = "") => + Effect.gen(function* () { + yield* assertName(name); + const version = formatMigrationTimestamp(yield* Clock.currentTimeMillis); + const fileName = migrationFileName(version, name); + const absolutePath = path.join(workspace.migrationsDir, fileName); + if (!absolutePath.startsWith(workspace.migrationsDir + path.sep)) { + return yield* new SchemaMigrationNameError({ + detail: `Migration name "${name}" escapes supabase/migrations.`, + suggestion: "Use a simple identifier without path separators.", + }); + } + 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 = formatMigrationTimestamp(baseMillis + index * 1000); + const name = unitName(file.suffix); + const fileName = migrationFileName(version, name); + return { + version, + name, + fileName, + absolutePath: path.join(workspace.migrationsDir, fileName), + 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* 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/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.layer.ts b/apps/cli/src/shared/migrations/migration-runner.layer.ts new file mode 100644 index 0000000000..0582b8b371 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-runner.layer.ts @@ -0,0 +1,117 @@ +import { Effect, Layer } from "effect"; +import type { Pool } from "pg"; +import { SchemaEngineError, SchemaHistoryConflictError } from "../schema/schema-errors.ts"; +import type { MigrationFile } from "./migration-file.ts"; +import { + MigrationRunner, + type MigrationApplyResult, + type MigrationHistoryRow, +} from "./migration-runner.service.ts"; + +const ENSURE_HISTORY = ` +BEGIN; +SET LOCAL lock_timeout = '4s'; +CREATE SCHEMA IF NOT EXISTS supabase_migrations; +CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY); +ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]; +ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text; +COMMIT; +`; + +const LIST_HISTORY = + "SELECT version, coalesce(name, '') AS name FROM supabase_migrations.schema_migrations ORDER BY version"; +const INSERT_HISTORY = + "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)"; + +const engineError = (detail: string) => + new SchemaEngineError({ + detail, + suggestion: "Check the database connection and migration SQL, then retry.", + }); + +export const migrationRunnerLayer = Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: (pool: Pool) => + Effect.tryPromise({ + try: async () => { + await pool.query(ENSURE_HISTORY); + const result = await pool.query(LIST_HISTORY); + return result.rows; + }, + catch: (cause) => engineError(cause instanceof Error ? cause.message : String(cause)), + }), + applyPending: (pool: Pool, local: ReadonlyArray) => + Effect.gen(function* () { + const remote = yield* Effect.tryPromise({ + try: async () => { + await pool.query(ENSURE_HISTORY); + const result = await pool.query(LIST_HISTORY); + return result.rows; + }, + catch: (cause) => engineError(cause instanceof Error ? cause.message : String(cause)), + }); + const remoteVersions = new Set(remote.map((row) => row.version)); + const pending = local.filter((file) => !remoteVersions.has(file.version)); + const remoteOnly = remote.filter( + (row) => !local.some((file) => file.version === row.version), + ); + if (remoteOnly.length > 0 && pending.length > 0) { + return yield* new SchemaHistoryConflictError({ + detail: `Local and remote migration histories have diverged (remote-only: ${remoteOnly + .map((row) => row.version) + .join(", ")}; pending: ${pending.map((file) => file.version).join(", ")}).`, + suggestion: "Run `supabase migrations pull` or repair history before applying.", + }); + } + const applied: Array = []; + for (const file of pending) { + yield* Effect.tryPromise({ + try: async () => { + if (file.transactional) { + await pool.query("BEGIN"); + try { + await pool.query(file.content); + await pool.query(INSERT_HISTORY, [file.version, file.name, [file.content]]); + await pool.query("COMMIT"); + } catch (error) { + await pool.query("ROLLBACK").catch(() => undefined); + throw error; + } + } else { + await pool.query(file.content); + await pool.query(INSERT_HISTORY, [file.version, file.name, [file.content]]); + } + }, + catch: (cause) => + engineError( + `Failed applying ${file.fileName}: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + applied.push(file.version); + } + return { + applied, + skipped: local + .filter((file) => remoteVersions.has(file.version)) + .map((file) => file.version), + } satisfies MigrationApplyResult; + }), + recordApplied: (pool: Pool, files: ReadonlyArray) => + Effect.tryPromise({ + try: async () => { + await pool.query(ENSURE_HISTORY); + const existing = await pool.query(LIST_HISTORY); + const present = new Set(existing.rows.map((row) => row.version)); + for (const file of files) { + if (present.has(file.version)) continue; + await pool.query(INSERT_HISTORY, [file.version, file.name, [file.content]]); + } + }, + catch: (cause) => + engineError( + `Failed recording migration history: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }), + }), +); 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..17eb8b02f5 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-runner.service.ts @@ -0,0 +1,33 @@ +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; +}; + +interface MigrationRunnerShape { + readonly listRemote: ( + pool: Pool, + ) => Effect.Effect, SchemaEngineError>; + readonly applyPending: ( + pool: Pool, + local: ReadonlyArray, + ) => Effect.Effect; + readonly recordApplied: ( + pool: Pool, + files: ReadonlyArray, + ) => Effect.Effect; +} + +export class MigrationRunner extends Context.Service()( + "supabase/migrations/MigrationRunner", +) {} 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.ts b/apps/cli/src/shared/migrations/pull-migrations.ts new file mode 100644 index 0000000000..6cd21d13c9 --- /dev/null +++ b/apps/cli/src/shared/migrations/pull-migrations.ts @@ -0,0 +1,89 @@ +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 { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.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 runner = yield* MigrationRunner; + const remote = yield* targets.resolve(parseTargetSelector(input.from ?? "linked")); + const migrations = yield* repository.listLocal; + const name = input.name ?? "remote_schema"; + + return yield* Effect.scoped( + Effect.gen(function* () { + const remotePool = yield* acquireDatabasePool(remote.connectionString); + const shadow = yield* engine.provisionShadow; + const sourcePool = yield* acquireDatabasePool(shadow.url); + yield* runner.applyPending(sourcePool, migrations); + 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, + }); + + 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: ["Review the pulled migration, then apply it locally if needed."], + }, + nextActions: ["Review the pulled migration, then apply it locally if needed."], + 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..a47b3e28ce --- /dev/null +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.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 setup(opts: { declarations?: boolean; digestMatch?: boolean } = {}) { + const out = mockOutput({ interactive: false }); + 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", + checkpointPath: "/tmp/schemas/.schema-checkpoint.json", + 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({ + readCheckpoint: Effect.succeed( + opts.digestMatch === true + ? Option.some({ + version: 1 as const, + declarativeDigest: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + migrationHeadDigest: "b", + profile: "supabase", + scope: "database", + engineVersion: "0.3.0", + artifactFormatVersion: 1, + acceptedRenames: [], + generatedMigrationVersions: ["20260101000000"], + lastGenerateHazards: { kinds: [], destructive: 0, rewrite: 0, coverageGaps: 0 }, + }) + : Option.none(), + ), + writeCheckpoint: () => Effect.void, + readJournal: Effect.succeed(Option.none()), + writeJournal: () => Effect.void, + clearJournal: Effect.void, + withLock: (effect) => effect, + }), + ), + Layer.succeed( + DatabaseTargetResolver, + DatabaseTargetResolver.of({ + resolve: () => Effect.succeed(linked), + }), + ), + Layer.succeed( + MigrationRepository, + MigrationRepository.of({ + listLocal: Effect.succeed([]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }), + ), + Layer.succeed( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed([]), + applyPending: () => Effect.succeed({ applied: [], skipped: [] }), + recordApplied: () => Effect.void, + }), + ), + Layer.succeed( + PgDeltaSchemaEngine, + PgDeltaSchemaEngine.of({ + exportSchema: () => Effect.die("unused"), + planFiles: () => Effect.die("unused"), + diffPools: () => Effect.die("unused"), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.die("unused"), + }), + ), + ); + return { layer }; +} + +describe("pushMigrations", () => { + it.live("fails closed when declarations are ahead of the checkpoint", () => { + const { layer } = setup({ declarations: true, digestMatch: false }); + return Effect.gen(function* () { + const exit = yield* pushMigrations({ + yes: true, + allowDataLoss: true, + allowRemote: false, + projectRef: "abcdefghijklmnop", + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); +}); 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..3eb30e99e4 --- /dev/null +++ b/apps/cli/src/shared/migrations/push-migrations.ts @@ -0,0 +1,105 @@ +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 { assertDeclarationsNotAhead } from "../schema/declarations-ahead.ts"; +import { SchemaRemoteDriftError } from "../schema/schema-errors.ts"; +import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import type { SchemaCommandResult } from "../schema/schema-types.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import { MigrationRepository } from "./migration-repository.service.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +export type PushMigrationsInput = { + readonly yes: boolean; + readonly allowDataLoss: boolean; + readonly projectRef?: string; + readonly allowRemote: boolean; + readonly dbUrl?: string; +}; + +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 state = yield* SchemaStateStore; + + yield* assertDeclarationsNotAhead(); + + const remote = yield* targets.resolve( + input.dbUrl !== undefined ? { kind: "url", url: input.dbUrl } : { kind: "linked" }, + ); + const localFiles = yield* repository.listLocal; + const checkpoint = yield* state.readCheckpoint; + const generated = new Set( + checkpoint._tag === "Some" ? (checkpoint.value.generatedMigrationVersions ?? []) : [], + ); + const destructiveVersions = new Set( + checkpoint._tag === "Some" ? (checkpoint.value.destructiveMigrationVersions ?? []) : [], + ); + + 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)); + const pending = localFiles.filter((file) => !remoteVersions.has(file.version)); + const unclassified = pending.some((file) => !generated.has(file.version)); + const pendingDestructive = pending.some((file) => destructiveVersions.has(file.version)); + const destructive = unclassified || pendingDestructive; + + yield* authorizeMutation({ + target: remote, + destructive, + flags: { + yes: input.yes, + allowDataLoss: input.allowDataLoss, + allowRemote: input.allowRemote, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }, + command: "migrations push", + }); + + const localTarget = yield* targets.resolve({ kind: "local" }).pipe(Effect.option); + if (localTarget._tag === "Some") { + const shadow = yield* engine.provisionShadow; + const sourcePool = yield* acquireDatabasePool(shadow.url); + const replayed = localFiles.filter((file) => remoteVersions.has(file.version)); + yield* runner.applyPending(sourcePool, replayed); + const drift = yield* engine.diffPools({ + sourcePool, + desiredPool: remotePool, + allowDrops: true, + }); + if (drift.changes) { + return yield* new SchemaRemoteDriftError({ + detail: "Remote database shape has drifted from migration replay.", + suggestion: "Run `supabase migrations pull` and reconcile before pushing.", + }); + } + } + + 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.ts b/apps/cli/src/shared/schema/apply-schema.ts new file mode 100644 index 0000000000..a8873ba6dc --- /dev/null +++ b/apps/cli/src/shared/schema/apply-schema.ts @@ -0,0 +1,172 @@ +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 { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import { MigrationRunner } 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 allowDataLoss: 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 runner = yield* MigrationRunner; + + 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; + if ( + existingJournal._tag === "Some" && + existingJournal.value.declarativelyAhead && + existingJournal.value.generated !== true + ) { + 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.", + }); + } + } else { + yield* runner.applyPending(pool, localMigrations); + } + + 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, + destructive: plan.destructive, + flags: { + yes: true, + allowDataLoss: true, + allowRemote: input.allowRemote, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }, + command: "schema apply", + }); + + if (!plan.changes) { + return { + status: "clean", + message: "Local database already matches declarations.", + data: { + status: "clean", + target: target.identity, + plan_id: plan.planId, + mutated_database: false, + mutated_files: false, + }, + nextActions: [], + mutatedDatabase: false, + 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/database-target.layer.ts b/apps/cli/src/shared/schema/database-target.layer.ts new file mode 100644 index 0000000000..967f3e1555 --- /dev/null +++ b/apps/cli/src/shared/schema/database-target.layer.ts @@ -0,0 +1,121 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { + connectLayer, + DEFAULT_MANAGED_STACK_NAME, + Stack, + unixHttpClientLayer, +} from "@supabase/stack/effect"; +import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { ProjectHome } from "../../next/config/project-home.service.ts"; +import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; +import { LocalDatabaseFallback } from "../database/local-database-fallback.service.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import type { DatabaseTarget } from "../database/database-target.ts"; +import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; +import { SchemaLinkedConnectionError, SchemaLocalStackNotRunningError } from "./schema-errors.ts"; + +function envConnectionString(): string | undefined { + return process.env["SUPABASE_DB_URL"] ?? process.env["DATABASE_URL"]; +} + +export const databaseTargetLayer = Layer.effect( + DatabaseTargetResolver, + Effect.gen(function* () { + const projectHome = yield* ProjectHome; + const runtimeInfo = yield* RuntimeInfo; + const cliConfig = yield* CliConfig; + const linkState = yield* ProjectLinkState; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const fallback = yield* LocalDatabaseFallback; + + const resolveLocal = Effect.gen(function* () { + const layer = yield* connectLayer({ + cwd: runtimeInfo.cwd, + cacheRoot: cliConfig.supabaseHome, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: DEFAULT_MANAGED_STACK_NAME, + }).pipe( + Effect.map(Option.some), + Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), + Effect.catchTag("InvalidStackStateError", () => Effect.succeed(Option.none())), + ); + if (layer._tag === "None") { + const owned = yield* fallback.resolve; + if (Option.isSome(owned)) { + return owned.value; + } + return yield* new SchemaLocalStackNotRunningError({ + detail: "No local Supabase stack is running for this project.", + suggestion: "Run `supabase start`, then retry.", + }); + } + const stack = yield* Effect.provide(Stack, layer.value); + const info = yield* stack.getInfo(); + return { + kind: "local", + identity: `local:${DEFAULT_MANAGED_STACK_NAME}`, + connectionString: info.dbUrl, + disposable: true, + durable: false, + connectionVerified: true, + } satisfies DatabaseTarget; + }).pipe( + Effect.provide(unixHttpClientLayer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const resolveLinked = Effect.gen(function* () { + const linked = yield* linkState.load.pipe( + Effect.mapError( + (error) => + new SchemaLinkedConnectionError({ + detail: error.detail, + suggestion: error.suggestion, + }), + ), + ); + if (linked._tag === "None") { + 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.", + }); + } + const url = envConnectionString(); + if (url === undefined) { + return yield* new SchemaLinkedConnectionError({ + detail: `Linked project ${linked.value.project.ref} has no connection string in this environment.`, + suggestion: + "Pass --from / --against / --db-url with a connection string, or set DATABASE_URL / SUPABASE_DB_URL.", + }); + } + return { + kind: "linked", + identity: linked.value.project.ref, + connectionString: url, + disposable: false, + durable: true, + connectionVerified: false, + projectRef: linked.value.project.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, + } satisfies DatabaseTarget); + }, + }); + }), +); 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..2ddb971f86 --- /dev/null +++ b/apps/cli/src/shared/schema/declarations-ahead.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; +import { digestFileSet } from "./schema-digest.ts"; +import { SchemaDeclarationsAheadError } from "./schema-errors.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; + +export const assertDeclarationsNotAhead = Effect.fnUntraced(function* () { + const workspace = yield* SchemaWorkspace; + 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 ` before `supabase migrations push`.", + }); + } + const declarations = yield* workspace.readDeclarationFiles; + if (declarations.length === 0) return; + const checkpoint = yield* state.readCheckpoint; + const current = digestFileSet(declarations); + if (checkpoint._tag === "None" || checkpoint.value.declarativeDigest !== current) { + return yield* new SchemaDeclarationsAheadError({ + detail: "Declarations are ahead of the local migration head.", + suggestion: + "Run `supabase schema generate --name ` before `supabase migrations push`.", + }); + } +}); 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..2c4091dd4a --- /dev/null +++ b/apps/cli/src/shared/schema/generate-schema.ts @@ -0,0 +1,210 @@ +import { Clock, Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.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 { digestFileSet, digestVersions } from "./schema-digest.ts"; +import { SchemaEngineError } from "./schema-errors.ts"; +import { formatPlanSummary } from "./schema-output.ts"; +import { assertPlanActionable } from "./schema-plan-gate.ts"; +import { + SCHEMA_ARTIFACT_FORMAT_VERSION, + SCHEMA_MANAGEMENT_SCOPE, + SCHEMA_PROFILE_ID, +} from "./schema-paths.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaCheckpoint, 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 targets = yield* DatabaseTargetResolver; + const migrations = yield* MigrationRepository; + const runner = yield* MigrationRunner; + + const target = yield* targets.resolve({ kind: "local" }); + const declarations = yield* workspace.readDeclarationFiles; + const localMigrations = yield* migrations.listLocal; + const existingCheckpoint = yield* state.readCheckpoint; + const name = input.name ?? (input.baseline ? "initial_schema" : "schema"); + + return yield* state.withLock( + Effect.scoped( + Effect.gen(function* () { + const sourceShadow = yield* engine.provisionShadow; + const desiredShadow = yield* engine.provisionShadow; + + const sourcePool = yield* acquireDatabasePool(sourceShadow.url); + const desiredPool = yield* acquireDatabasePool(desiredShadow.url); + + if (!input.baseline) { + yield* runner.applyPending(sourcePool, localMigrations); + } + + 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) { + 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.provisionShadow; + const verifySource = yield* acquireDatabasePool(verifyShadow.url); + const verifyDesired = yield* engine.provisionShadow; + const verifyDesiredPool = yield* acquireDatabasePool(verifyDesired.url); + const allMigrations = yield* migrations.listLocal; + yield* runner.applyPending(verifySource, allMigrations); + 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.", + }); + } + + const localPool = yield* acquireDatabasePool(target.connectionString); + // planFiles requires an empty shadow. verifyDesired already holds the + // loaded declarations, so compare the live catalog against that shape. + const liveVsDeclared = yield* engine.diffPools({ + sourcePool: localPool, + desiredPool: verifyDesiredPool, + allowDrops: true, + }); + if (!liveVsDeclared.changes) { + yield* runner.recordApplied(localPool, written); + } + + const previousGenerated = + existingCheckpoint._tag === "Some" + ? (existingCheckpoint.value.generatedMigrationVersions ?? []) + : []; + const previousDestructive = + existingCheckpoint._tag === "Some" + ? (existingCheckpoint.value.destructiveMigrationVersions ?? []) + : []; + const checkpoint: SchemaCheckpoint = { + version: 1, + declarativeDigest: digestFileSet(declarations), + migrationHeadDigest: digestVersions(allMigrations.map((file) => file.version)), + sourceFingerprint: plan.sourceFingerprint, + desiredFingerprint: plan.desiredFingerprint, + profile: SCHEMA_PROFILE_ID, + scope: SCHEMA_MANAGEMENT_SCOPE, + engineVersion: plan.engineVersion, + artifactFormatVersion: SCHEMA_ARTIFACT_FORMAT_VERSION, + acceptedRenames: plan.acceptedRenames, + ...(existingCheckpoint._tag === "Some" && + existingCheckpoint.value.catalogSnapshot !== undefined + ? { catalogSnapshot: existingCheckpoint.value.catalogSnapshot } + : {}), + lastGenerateName: name, + lastGenerateHazards: { + kinds: plan.hazards.kinds, + destructive: plan.hazards.destructive, + rewrite: plan.hazards.rewrite, + coverageGaps: plan.hazards.coverageGaps, + }, + generatedMigrationVersions: [ + ...new Set([...previousGenerated, ...written.map((file) => file.version)]), + ], + destructiveMigrationVersions: plan.destructive + ? [...new Set([...previousDestructive, ...written.map((file) => file.version)])] + : previousDestructive, + }; + yield* state.writeCheckpoint(checkpoint); + const journal = yield* state.readJournal; + if (journal._tag === "Some") { + yield* state.writeJournal({ + ...journal.value, + generated: true, + declarativelyAhead: false, + }); + } + + 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: [ + "Review the migration files, commit them, then deploy with migrations push.", + ], + }, + nextActions: [ + "Review the migration files, commit them, then deploy with migrations push.", + ], + 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..999c1c0d08 --- /dev/null +++ b/apps/cli/src/shared/schema/isolated-shadow.service.ts @@ -0,0 +1,13 @@ +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 { + readonly provision: Effect.Effect; +} + +export class IsolatedShadowProvisioner extends Context.Service< + IsolatedShadowProvisioner, + IsolatedShadowProvisionerShape +>()("supabase/schema/IsolatedShadowProvisioner") {} diff --git a/apps/cli/src/shared/schema/native-isolated-shadow-key.ts b/apps/cli/src/shared/schema/native-isolated-shadow-key.ts new file mode 100644 index 0000000000..d915af877c --- /dev/null +++ b/apps/cli/src/shared/schema/native-isolated-shadow-key.ts @@ -0,0 +1,52 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, type Dirent } from "node:fs"; +import { join } from "node:path"; + +const NATIVE_SHADOW_CACHE_FORMAT = "native-isolated-shadow-v2"; + +export const computeBaselineCacheKey = (input: { + readonly postgresVersion: string; + readonly autoExposeNewTables: boolean; + readonly artifactDigest: string; + readonly initScriptDigest: string; +}): string => + createHash("sha256") + .update(NATIVE_SHADOW_CACHE_FORMAT) + .update("\0") + .update(input.postgresVersion) + .update("\0") + .update(input.autoExposeNewTables ? "1" : "0") + .update("\0") + .update(input.artifactDigest) + .update("\0") + .update(input.initScriptDigest) + .digest("hex") + .slice(0, 16); + +export const digestArtifactTree = (root: string): string => { + const hash = createHash("sha256"); + const walk = (dir: string, prefix: string) => { + let entries: ReadonlyArray; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + const sorted = [...entries].sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of sorted) { + const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + walk(abs, rel); + continue; + } + if (!entry.isFile()) continue; + hash.update(rel); + hash.update("\0"); + hash.update(readFileSync(abs)); + hash.update("\0"); + } + }; + walk(root, ""); + return hash.digest("hex"); +}; diff --git a/apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts b/apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts new file mode 100644 index 0000000000..ea672544f0 --- /dev/null +++ b/apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { computeBaselineCacheKey, digestArtifactTree } from "./native-isolated-shadow-key.ts"; + +describe("computeBaselineCacheKey", () => { + it("changes when the postgres version or expose flag changes", () => { + const base = { + postgresVersion: "17.6.1.158", + autoExposeNewTables: false, + artifactDigest: "abc", + initScriptDigest: "init-a", + }; + const key = computeBaselineCacheKey(base); + expect(key).toHaveLength(16); + expect(computeBaselineCacheKey({ ...base, postgresVersion: "17.6.1.200" })).not.toBe(key); + expect(computeBaselineCacheKey({ ...base, autoExposeNewTables: true })).not.toBe(key); + expect(computeBaselineCacheKey({ ...base, artifactDigest: "def" })).not.toBe(key); + expect(computeBaselineCacheKey({ ...base, initScriptDigest: "init-b" })).not.toBe(key); + }); +}); + +describe("digestArtifactTree", () => { + it("is stable for the same files and changes when contents change", () => { + const root = mkdtempSync(join(tmpdir(), "shadow-digest-")); + mkdirSync(join(root, "migrations"), { recursive: true }); + writeFileSync(join(root, "migrations", "01.sql"), "select 1;\n"); + const first = digestArtifactTree(root); + expect(digestArtifactTree(root)).toBe(first); + writeFileSync(join(root, "migrations", "01.sql"), "select 2;\n"); + expect(digestArtifactTree(root)).not.toBe(first); + }); +}); diff --git a/apps/cli/src/shared/schema/native-isolated-shadow.layer.ts b/apps/cli/src/shared/schema/native-isolated-shadow.layer.ts new file mode 100644 index 0000000000..76dd6d3416 --- /dev/null +++ b/apps/cli/src/shared/schema/native-isolated-shadow.layer.ts @@ -0,0 +1,416 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { loadProjectConfig } from "@supabase/config"; +import { + DEFAULT_VERSIONS, + planStackVersions, + renderNativePostgresInitScript, + resolveNativeBinary, +} from "@supabase/stack/effect"; +import { createHash } from "node:crypto"; +import Net from "node:net"; +import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { ProjectHome } from "../../next/config/project-home.service.ts"; +import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; +import { ProjectLocalServiceVersions } from "../../next/config/project-local-service-versions.service.ts"; +import { IsolatedShadowProvisioner } from "./isolated-shadow.service.ts"; +import { SchemaEngineError } from "./schema-errors.ts"; +import { computeBaselineCacheKey, digestArtifactTree } from "./native-isolated-shadow-key.ts"; + +const NATIVE_POSTGRES_RUNTIME_ARGS = [ + "-c", + "wal_level=logical", + "-c", + "max_wal_senders=5", + "-c", + "max_replication_slots=5", + "-c", + "listen_addresses=127.0.0.1", +] as const; + +const READY_ATTEMPTS = 150; +const READY_DELAY = "200 millis" as const; +const LOCK_ATTEMPTS = 900; +const LOCK_DELAY = "100 millis" as const; + +const isPidAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const shadowError = (detail: string, suggestion: string) => + new SchemaEngineError({ detail, suggestion }); + +const toShadowError = (error: unknown) => { + if (error instanceof SchemaEngineError) return error; + if (typeof error === "object" && error !== null && "_tag" in error) { + const tag = String(Reflect.get(error, "_tag")); + const detailValue = Reflect.get(error, "detail"); + const detail = typeof detailValue === "string" ? detailValue : tag; + return shadowError(detail, "Retry the command."); + } + return shadowError(error instanceof Error ? error.message : String(error), "Retry the command."); +}; + +const postgresEnv = ( + postgresDir: string, + extra: Record = {}, +): Record => ({ + ...process.env, + DYLD_LIBRARY_PATH: `${postgresDir}/lib`, + LD_LIBRARY_PATH: `${postgresDir}/lib`, + PGPASSWORD: "postgres", + POSTGRES_PASSWORD: "postgres", + ...extra, +}); + +const cacheEnabled = () => { + const value = process.env["SUPABASE_SHADOW_CACHE"]; + return value !== "0" && value !== "false"; +}; + +const allocatePort = Effect.callback((resume) => { + const server = Net.createServer(); + server.once("error", (error) => + resume( + Effect.fail( + shadowError(`Failed to allocate a shadow port: ${error.message}`, "Retry the command."), + ), + ), + ); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + server.close(() => { + if (port > 0) { + resume(Effect.succeed(port)); + return; + } + resume(Effect.fail(shadowError("Failed to allocate a shadow port.", "Retry the command."))); + }); + }); +}); + +const startCommand = (postgresDir: string, dataDir: string, port: number) => + ChildProcess.make( + "bash", + [ + `${postgresDir}/share/supabase-cli/bin/supabase-postgres-init.sh`, + "-p", + String(port), + ...NATIVE_POSTGRES_RUNTIME_ARGS, + ], + { env: postgresEnv(postgresDir, { PGDATA: dataDir }) }, + ); + +export const nativeIsolatedShadowLayer = Layer.effect( + IsolatedShadowProvisioner, + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const projectHome = yield* ProjectHome; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const projectLink = yield* ProjectLinkState; + const localVersions = yield* ProjectLocalServiceVersions; + + const requireExitZero = (command: ChildProcess.Command, detail: string) => + Effect.gen(function* () { + const code = yield* spawner + .exitCode(command) + .pipe( + Effect.mapError((error) => + shadowError(`${detail}: ${error.message}`, "Retry the command."), + ), + ); + if (code !== 0) { + return yield* shadowError(`${detail} (exit ${code}).`, "Retry the command."); + } + }); + + const retry = ( + effect: Effect.Effect, + attempts: number, + delay: typeof READY_DELAY | typeof LOCK_DELAY, + ) => + Effect.gen(function* () { + for (let attempt = 0; attempt < attempts; attempt++) { + const result = yield* Effect.result(effect); + if (result._tag === "Success") return result.success; + if (attempt === attempts - 1) { + return yield* Effect.fail(result.failure); + } + yield* Effect.sleep(delay); + } + return yield* shadowError("Retry budget exhausted.", "Retry the command."); + }); + + const waitReady = (postgresDir: string, port: number) => + retry( + requireExitZero( + ChildProcess.make( + `${postgresDir}/bin/pg_isready`, + ["-h", "127.0.0.1", "-p", String(port), "-U", "postgres"], + { env: postgresEnv(postgresDir) }, + ), + "Isolated shadow Postgres did not become ready", + ), + READY_ATTEMPTS, + READY_DELAY, + ); + + const applyPlatformBaseline = ( + postgresDir: string, + port: number, + autoExposeNewTables: boolean, + ) => + requireExitZero( + ChildProcess.make( + "bash", + [ + "-c", + renderNativePostgresInitScript({ + postgresDir, + dbPort: port, + autoExposeNewTables, + }), + ], + { env: postgresEnv(postgresDir) }, + ), + "Failed to apply the platform baseline to the isolated shadow", + ); + + const startOwnedPostgres = (postgresDir: string, dataDir: string, port: number) => + Effect.gen(function* () { + const handle = yield* spawner + .spawn(startCommand(postgresDir, dataDir, port)) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to start isolated shadow Postgres: ${error.message}`, + "Retry the command.", + ), + ), + ); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + yield* waitReady(postgresDir, port); + }); + + const provision = Effect.gen(function* () { + const localState = yield* localVersions.load.pipe(Effect.mapError(toShadowError)); + const linkedState = yield* projectLink.load.pipe(Effect.mapError(toShadowError)); + const versions = planStackVersions({ + candidateBaseline: Option.match(linkedState, { + onNone: () => undefined, + onSome: (state) => state.versions, + }), + localOverrides: Option.match(localState, { + onNone: () => undefined, + onSome: (state) => state.versions, + }), + }); + const postgresVersion = versions.runtimeVersions.postgres ?? DEFAULT_VERSIONS.postgres; + const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError(toShadowError), + ); + const autoExposeNewTables = loadedConfig?.config.api.auto_expose_new_tables ?? false; + const postgresDir = yield* resolveNativeBinary(cliConfig.supabaseHome, { + service: "postgres", + version: postgresVersion, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.mapError((error) => + shadowError( + `Failed to resolve native Postgres ${postgresVersion}: ${error._tag}`, + "Run `supabase start` once so the native Postgres binary is cached, then retry.", + ), + ), + ); + + const artifactDigest = digestArtifactTree(`${postgresDir}/share/supabase-cli`); + const key = computeBaselineCacheKey({ + postgresVersion, + autoExposeNewTables, + artifactDigest, + initScriptDigest: createHash("sha256") + .update(renderNativePostgresInitScript.toString()) + .digest("hex"), + }); + const cacheRoot = path.join(cliConfig.supabaseHome, "cache", "native-shadow-baseline"); + const archivePath = path.join(cacheRoot, `shadow-baseline-${key}.tar`); + const dataDir = yield* fs + .makeTempDirectory({ prefix: "pgdelta-isolated-" }) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to create an isolated shadow directory: ${error.message}`, + "Check disk space and retry.", + ), + ), + ); + yield* Effect.addFinalizer(() => + fs.remove(dataDir, { recursive: true, force: true }).pipe(Effect.ignore), + ); + + const publishBaseline = Effect.scoped( + Effect.gen(function* () { + const buildDir = yield* fs + .makeTempDirectory({ prefix: "pgdelta-baseline-" }) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to create a baseline build directory: ${error.message}`, + "Check disk space and retry.", + ), + ), + ); + yield* Effect.addFinalizer(() => + fs.remove(buildDir, { recursive: true, force: true }).pipe(Effect.ignore), + ); + const buildData = path.join(buildDir, "data"); + yield* fs + .makeDirectory(buildData, { recursive: true }) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to create baseline PGDATA: ${error.message}`, + "Check disk space and retry.", + ), + ), + ); + const port = yield* allocatePort; + yield* startOwnedPostgres(postgresDir, buildData, port); + yield* applyPlatformBaseline(postgresDir, port, autoExposeNewTables); + yield* requireExitZero( + ChildProcess.make( + `${postgresDir}/bin/pg_ctl`, + ["stop", "-D", buildData, "-m", "fast", "-w"], + { env: postgresEnv(postgresDir) }, + ), + "Failed to stop the baseline Postgres after snapshotting", + ); + yield* fs + .makeDirectory(cacheRoot, { recursive: true }) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to create the shadow baseline cache: ${error.message}`, + "Check permissions on SUPABASE_HOME and retry.", + ), + ), + ); + const staging = path.join(cacheRoot, `shadow-baseline-${key}.${process.pid}.partial`); + yield* Effect.addFinalizer(() => fs.remove(staging, { force: true }).pipe(Effect.ignore)); + yield* requireExitZero( + ChildProcess.make("tar", ["-cf", staging, "-C", buildData, "."]), + "Failed to snapshot the isolated shadow baseline", + ); + yield* fs + .rename(staging, archivePath) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to publish the shadow baseline cache: ${error.message}`, + "Retry the command.", + ), + ), + ); + }), + ); + + const withCacheLock = (effect: Effect.Effect) => + Effect.gen(function* () { + yield* fs + .makeDirectory(cacheRoot, { recursive: true }) + .pipe( + Effect.mapError((error) => + shadowError( + `Failed to create the shadow baseline cache: ${error.message}`, + "Check permissions on SUPABASE_HOME and retry.", + ), + ), + ); + const lockDir = path.join(cacheRoot, `.lock-${key}`); + const pidPath = path.join(lockDir, "pid"); + const acquireLock = Effect.gen(function* () { + const created = yield* Effect.result( + fs + .makeDirectory(lockDir) + .pipe( + Effect.mapError((error) => + shadowError( + `Waiting for the isolated shadow baseline cache lock: ${error.message}`, + "Retry the command.", + ), + ), + ), + ); + if (created._tag === "Success") { + yield* fs.writeFileString(pidPath, String(process.pid)).pipe( + Effect.tapError(() => + fs.remove(lockDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + Effect.mapError((error) => + shadowError( + `Failed to write the isolated shadow baseline cache lock: ${error.message}`, + "Retry the command.", + ), + ), + ); + return; + } + const holder = Number.parseInt( + yield* fs.readFileString(pidPath).pipe(Effect.orElseSucceed(() => "")), + 10, + ); + if (Number.isInteger(holder) && holder > 0 && !isPidAlive(holder)) { + yield* fs.remove(lockDir, { recursive: true, force: true }).pipe(Effect.ignore); + } + return yield* Effect.fail(created.failure); + }); + yield* retry(acquireLock, LOCK_ATTEMPTS, LOCK_DELAY); + return yield* effect.pipe( + Effect.ensuring( + fs.remove(lockDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }); + + if (cacheEnabled()) { + yield* withCacheLock( + Effect.gen(function* () { + const exists = yield* fs.exists(archivePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + yield* publishBaseline; + } + }), + ); + yield* requireExitZero( + ChildProcess.make("tar", ["-xf", archivePath, "-C", dataDir]), + "Failed to restore the isolated shadow baseline", + ).pipe(Effect.tapError(() => fs.remove(archivePath, { force: true }).pipe(Effect.ignore))); + const port = yield* allocatePort; + yield* startOwnedPostgres(postgresDir, dataDir, port); + return { url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres` }; + } + + const port = yield* allocatePort; + yield* startOwnedPostgres(postgresDir, dataDir, port); + yield* applyPlatformBaseline(postgresDir, port, autoExposeNewTables); + return { url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres` }; + }).pipe(Effect.mapError(toShadowError)); + + return IsolatedShadowProvisioner.of({ provision }); + }), +); 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..d1a06b4438 --- /dev/null +++ b/apps/cli/src/shared/schema/pg-delta-engine.layer.ts @@ -0,0 +1,195 @@ +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, + }); + }), +); 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..617b9e956a --- /dev/null +++ b/apps/cli/src/shared/schema/pg-delta-engine.service.ts @@ -0,0 +1,54 @@ +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; +} + +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..e501f39714 --- /dev/null +++ b/apps/cli/src/shared/schema/prepare-declarative-shadow.ts @@ -0,0 +1,58 @@ +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 native shadow baseline cache.", + }); + +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..c276b9dcc6 --- /dev/null +++ b/apps/cli/src/shared/schema/pull-schema.integration.test.ts @@ -0,0 +1,253 @@ +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"), + }), + ); +} + +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, manifest, and checkpoint 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( + true, + ); + 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); + }); + }); + + it.live("keeps generated migration versions when force-pulling", () => { + const { project, layer } = setup(); + const schemas = join(project.supabaseDir, "schemas"); + mkdirSync(schemas, { recursive: true }); + writeFileSync( + join(schemas, ".schema-checkpoint.json"), + `${JSON.stringify( + { + version: 1, + declarativeDigest: "old", + migrationHeadDigest: "old-head", + profile: "supabase", + scope: "database", + engineVersion: "0.3.0", + artifactFormatVersion: 1, + acceptedRenames: [], + lastGenerateName: "add_widgets", + generatedMigrationVersions: ["20260101000000"], + destructiveMigrationVersions: ["20260101000000"], + }, + null, + 2, + )}\n`, + ); + return Effect.gen(function* () { + yield* pullSchema({ from: "local", force: true, pruneUnmanaged: false }).pipe( + Effect.provide(layer), + ); + const checkpoint: unknown = JSON.parse( + readFileSync(join(schemas, ".schema-checkpoint.json"), "utf8"), + ); + expect(checkpoint).toEqual( + expect.objectContaining({ + generatedMigrationVersions: ["20260101000000"], + destructiveMigrationVersions: ["20260101000000"], + lastGenerateName: "add_widgets", + }), + ); + }); + }); +}); 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..0bdb1a75d3 --- /dev/null +++ b/apps/cli/src/shared/schema/pull-schema.ts @@ -0,0 +1,157 @@ +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 { digestFileSet } from "./schema-digest.ts"; +import { SchemaDraftConflictError, SchemaTargetRequiredError } from "./schema-errors.ts"; +import { + SCHEMA_ARTIFACT_FORMAT_VERSION, + SCHEMA_MANAGEMENT_SCOPE, + SCHEMA_PROFILE_ID, +} from "./schema-paths.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaCheckpoint, 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"; +import { digestVersions } from "./schema-digest.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; + if (mode !== "output") { + const existingCheckpoint = yield* state.readCheckpoint; + const previousGenerated = + existingCheckpoint._tag === "Some" + ? (existingCheckpoint.value.generatedMigrationVersions ?? []) + : []; + const previousDestructive = + existingCheckpoint._tag === "Some" + ? (existingCheckpoint.value.destructiveMigrationVersions ?? []) + : []; + const checkpoint: SchemaCheckpoint = { + version: 1, + declarativeDigest: digestFileSet(exported.files), + migrationHeadDigest: digestVersions(localMigrations.map((file) => file.version)), + profile: SCHEMA_PROFILE_ID, + scope: SCHEMA_MANAGEMENT_SCOPE, + engineVersion: exported.engineVersion, + artifactFormatVersion: SCHEMA_ARTIFACT_FORMAT_VERSION, + acceptedRenames: [], + exportManifestIdentity: digestFileSet( + exported.manifest.files.map((name) => ({ name, sql: name })), + ), + catalogSnapshot: exported.snapshot, + ...(existingCheckpoint._tag === "Some" && + existingCheckpoint.value.lastGenerateName !== undefined + ? { lastGenerateName: existingCheckpoint.value.lastGenerateName } + : {}), + ...(existingCheckpoint._tag === "Some" && + existingCheckpoint.value.lastGenerateHazards !== undefined + ? { lastGenerateHazards: existingCheckpoint.value.lastGenerateHazards } + : {}), + ...(previousGenerated.length > 0 + ? { generatedMigrationVersions: previousGenerated } + : {}), + ...(previousDestructive.length > 0 + ? { destructiveMigrationVersions: previousDestructive } + : {}), + }; + yield* state.writeCheckpoint(checkpoint); + } + + 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..38ca66a8ea --- /dev/null +++ b/apps/cli/src/shared/schema/schema-errors.ts @@ -0,0 +1,154 @@ +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 SchemaCheckpointError extends SchemaCliError("SchemaCheckpointError") { + 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 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..02115fa828 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-paths.ts @@ -0,0 +1,11 @@ +export const SCHEMA_DIRECTORY_NAME = "schemas"; +export const SCHEMA_CUSTOM_DIRECTORY_NAME = "_custom"; +export const SCHEMA_CHECKPOINT_FILE_NAME = ".schema-checkpoint.json"; +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 SCHEMA_ARTIFACT_FORMAT_VERSION = 1; +export const SCHEMA_PROFILE_ID = "supabase"; +export const SCHEMA_MANAGEMENT_SCOPE = "database"; +export const MIGRATION_NO_TRANSACTION_DIRECTIVE = "-- pg-delta: transaction=false"; +export const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; 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-runtime.layer.ts b/apps/cli/src/shared/schema/schema-runtime.layer.ts new file mode 100644 index 0000000000..ef706bed00 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-runtime.layer.ts @@ -0,0 +1,73 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; +import { projectLinkStateLayer } from "../../next/config/project-link-state.layer.ts"; +import { projectLocalServiceVersionsLayer } from "../../next/config/project-local-service-versions.layer.ts"; +import { + provideProjectCommandRuntime, + type ProjectCommandRuntimeOptions, +} from "../../next/config/project-runtime.layer.ts"; +import { ProjectHome } from "../../next/config/project-home.service.ts"; +import { + noLocalDatabaseFallbackLayer, + type LocalDatabaseFallback, +} from "../database/local-database-fallback.service.ts"; +import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; +import { databaseTargetLayer } from "./database-target.layer.ts"; +import { nativeIsolatedShadowLayer } from "./native-isolated-shadow.layer.ts"; +import { schemaWorkspaceLayer } from "./schema-workspace.layer.ts"; +import { schemaStateLayer } from "./schema-state.layer.ts"; +import { pgDeltaSchemaEngineLayer } from "./pg-delta-engine.layer.ts"; +import { migrationRepositoryLayer } from "../migrations/migration-repository.layer.ts"; +import { migrationRunnerLayer } from "../migrations/migration-runner.layer.ts"; + +const workspaceFromProjectHome = Layer.unwrap( + Effect.gen(function* () { + const projectHome = yield* ProjectHome; + return schemaWorkspaceLayer({ + projectRoot: projectHome.projectRoot, + supabaseDir: projectHome.supabaseDir, + projectHomeDir: projectHome.projectHomeDir, + }); + }), +); + +const schemaEngineLive = pgDeltaSchemaEngineLayer.pipe( + Layer.provide(nativeIsolatedShadowLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(projectLocalServiceVersionsLayer), + Layer.provide(projectLinkStateLayer), +); + +export type SchemaRuntimeOptions = ProjectCommandRuntimeOptions & { + readonly localDatabaseFallback?: Layer.Layer< + LocalDatabaseFallback, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >; +}; + +export const schemaRuntimeLayer = ( + commandPath: ReadonlyArray, + options?: SchemaRuntimeOptions, +) => + provideProjectCommandRuntime( + Layer.mergeAll( + workspaceFromProjectHome, + schemaStateLayer.pipe(Layer.provide(workspaceFromProjectHome)), + migrationRepositoryLayer.pipe(Layer.provide(workspaceFromProjectHome)), + migrationRunnerLayer, + schemaEngineLive, + databaseTargetLayer.pipe( + Layer.provide(options?.localDatabaseFallback ?? noLocalDatabaseFallbackLayer), + Layer.provide(projectLinkStateLayer), + Layer.provide(unixHttpClientLayer), + ), + projectLinkStateLayer, + projectLocalServiceVersionsLayer, + unixHttpClientLayer, + commandRuntimeLayer(commandPath), + ), + options, + ); 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..35e41d84dd --- /dev/null +++ b/apps/cli/src/shared/schema/schema-state.layer.ts @@ -0,0 +1,187 @@ +import { Clock, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { SchemaCheckpointError, SchemaLockError } from "./schema-errors.ts"; +import { SchemaStateStore } from "./schema-state.service.ts"; +import type { SchemaCheckpoint, SchemaDraftJournal } from "./schema-types.ts"; +import { SchemaWorkspace } from "./schema-workspace.service.ts"; + +const CheckpointSchema = Schema.Struct({ + version: Schema.Literal(1), + declarativeDigest: Schema.String, + migrationHeadDigest: Schema.String, + sourceFingerprint: Schema.optionalKey(Schema.String), + desiredFingerprint: Schema.optionalKey(Schema.String), + profile: Schema.String, + scope: Schema.Literal("database"), + engineVersion: Schema.String, + artifactFormatVersion: Schema.Number, + acceptedRenames: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })), + exportManifestIdentity: Schema.optionalKey(Schema.String), + catalogSnapshot: Schema.optionalKey(Schema.String), + lastGenerateName: Schema.optionalKey(Schema.String), + lastGenerateHazards: Schema.optionalKey( + Schema.Struct({ + kinds: Schema.Array(Schema.String), + destructive: Schema.Number, + rewrite: Schema.Number, + coverageGaps: Schema.Number, + }), + ), + generatedMigrationVersions: Schema.optionalKey(Schema.Array(Schema.String)), + destructiveMigrationVersions: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +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 checkpointError = (detail: string) => + new SchemaCheckpointError({ + detail, + suggestion: "Fix or delete the schema checkpoint 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, SchemaCheckpointError> => + 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) => + checkpointError(`Failed to read ${filePath}: ${error.message}`), + ), + ); + const parsed = yield* Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: () => checkpointError(`Malformed ${path.basename(filePath)}.`), + }); + return Option.some( + yield* Effect.try({ + try: () => decode(parsed), + catch: (error) => + checkpointError( + `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) => + checkpointError(`Failed to create ${path.dirname(filePath)}: ${error.message}`), + ), + ); + yield* fs + .writeFileString(filePath, `${JSON.stringify(value, null, 2)}\n`) + .pipe( + Effect.mapError((error) => + checkpointError(`Failed to write ${filePath}: ${error.message}`), + ), + ); + }); + + return SchemaStateStore.of({ + readCheckpoint: readDecoded( + workspace.checkpointPath, + Schema.decodeUnknownSync(CheckpointSchema), + ), + writeCheckpoint: (checkpoint: SchemaCheckpoint) => + writeJson(workspace.checkpointPath, checkpoint), + 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(checkpointError(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..9c1abd977f --- /dev/null +++ b/apps/cli/src/shared/schema/schema-state.service.ts @@ -0,0 +1,23 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; +import type { SchemaCheckpoint, SchemaDraftJournal } from "./schema-types.ts"; +import type { SchemaCheckpointError, SchemaLockError } from "./schema-errors.ts"; + +interface SchemaStateStoreShape { + readonly readCheckpoint: Effect.Effect, SchemaCheckpointError>; + readonly writeCheckpoint: ( + checkpoint: SchemaCheckpoint, + ) => Effect.Effect; + readonly readJournal: Effect.Effect, SchemaCheckpointError>; + 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..e6367cc324 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-types.ts @@ -0,0 +1,116 @@ +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" +>; + +export type SchemaCheckpoint = { + readonly version: 1; + readonly declarativeDigest: string; + readonly migrationHeadDigest: string; + readonly sourceFingerprint?: string; + readonly desiredFingerprint?: string; + readonly profile: string; + readonly scope: "database"; + readonly engineVersion: string; + readonly artifactFormatVersion: number; + readonly acceptedRenames: ReadonlyArray<{ readonly from: string; readonly to: string }>; + readonly exportManifestIdentity?: string; + readonly catalogSnapshot?: string; + readonly lastGenerateName?: string; + readonly lastGenerateHazards?: Pick< + SchemaHazardSummary, + "kinds" | "destructive" | "rewrite" | "coverageGaps" + >; + readonly generatedMigrationVersions?: ReadonlyArray; + readonly destructiveMigrationVersions?: ReadonlyArray; +}; + +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..4be660ce32 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-workspace.layer.ts @@ -0,0 +1,299 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import { + classifySqlFiles, + EXPORT_MANIFEST_FILE, + readExportManifest, + type SqlFileClassification, +} from "@supabase/pg-delta/frontends"; +import { + SCHEMA_CHECKPOINT_FILE_NAME, + 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 checkpointPath = path.join(schemasDir, SCHEMA_CHECKPOINT_FILE_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, + checkpointPath, + 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..ea6f70b663 --- /dev/null +++ b/apps/cli/src/shared/schema/schema-workspace.service.ts @@ -0,0 +1,59 @@ +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 checkpointPath: 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..b0fac46f9e 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 now ship on the stable (legacy) shell as well as next (alpha). 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..38c2064b3f --- /dev/null +++ b/docs/cli/schema-first-v1-plan.md @@ -0,0 +1,98 @@ +# 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 only. Destructive plans need `--allow-data-loss`. Durable targets also need `--project-ref` (or `--allow-remote` for raw URLs). Local disposable `schema apply` auto-approves modeled hazards. +9. New commands live at top level in the **legacy** (stable) CLI. The same verbs also stay on next (alpha). 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 native Postgres clusters** with a cached platform baseline — not co-located `CREATE DATABASE` shadows. `planSchemaFiles` always uses `isolatedShadow: true`. + +## Open questions (resolved for V1) + +| # | Decision | +| - | -------- | +| 1 | Checkpoint: `supabase/schemas/.schema-checkpoint.json` (tracked). Export ownership stays in `.pgdelta-export.json`. Draft journal: `.supabase/schema-draft.json` (gitignored). | +| 2 | After generate, the draft journal is marked `generated`. Local history is never silently inserted. A later `migrations apply` on a draft-ahead database fails closed (reset or explicit fingerprint-gated reconcile). | +| 3 | `schema generate --baseline --name ` is the existing-database onboarding step. Registering that baseline as already applied on a remote is a separate, explicit migration-history operation (not pull). | +| 4 | Manual migration changes during an active draft fail closed with generate / reset / discard. No automatic rebase. | +| 5 | `migrations push` requires `--allow-data-loss` when the checkpoint records destructive hazards for pending generated files, or when pending files are not in the generated set (unclassified). Ambiguous rename / coverage gaps are generate-time failures. | +| 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. | +| 8 | `migrations diff` supports `--file` / `-f` (preview-to-file, no apply). | +| 9 | Isolated shadows are native Postgres (same binaries as `start`), snapshotted under `$SUPABASE_HOME/cache/native-shadow-baseline/`. Co-located shadows are not used on this path. | + +## Shell and ownership + +``` +apps/cli/src/shared/schema/ engine adapter, workspace, checkpoint, journal, use cases, runtime +apps/cli/src/shared/migrations/ repository, history, runner, use cases +apps/cli/src/shared/database/ target resolution, pool, destructive auth +apps/cli/src/legacy/commands/schema/ +apps/cli/src/legacy/commands/migrations/ +apps/cli/src/next/commands/schema/ same verbs on alpha +apps/cli/src/next/commands/migrations/ +apps/cli/src/next/commands/db/ compatibility aliases only +apps/cli/src/next/commands/migration/ compatibility aliases only +``` + +- `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 and schema runtime live in `shared/` so both shells can call them. +- 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, checkpoint (primary tree only) | +| `schema generate --dry-run` | M → D | None | +| `schema generate` | M → D | Migration files + checkpoint | +| `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 + history | +| `migrations push` | pending files → R | Remote DB + history; fail closed on declarations-ahead or drift | +| `migrations pull` | R − M → files | Migration files | + +### Aliases (next only, deprecation notice on stderr) + +| Alias | Target | +| ----- | ------ | +| `db diff` | `migrations diff` | +| `db push` | `migrations push` | +| `db pull` (default) | `migrations pull` | +| `db pull --declarative` | `schema pull --from linked` | +| `db schema declarative generate` | `schema pull` | +| `db schema declarative sync` | `schema generate` (`--apply` also runs `schema apply`) | +| `migration new` / `migrations` already | `migrations new` | +| `migration list` | `migrations list` | +| `migration up` | `migrations apply` | + +## Safety + +- Target identity comes from stack ownership or linked project-ref, never hostname heuristics. +- `--yes` never authorizes data loss, target-gate bypass, or stale plans. +- `--allow-data-loss` is required whenever `dataLossActions(plan)` is non-empty, or when push cannot classify pending files. +- Durable identity: interactive confirm-by-typing-ref; non-interactive `--project-ref` must match. +- Raw URL targets: `--allow-remote` instead of ref assertion. +- Local `schema apply`: behave as `--yes --allow-data-loss`. Ambiguous rename / coverage gap / unknown metadata still fail closed. +- 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 +- Marketing "provable no-data-loss" diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 8a5110f6da..cdf7ebdcd8 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -163,6 +163,10 @@ export { resolveDaemonConfig, } from "./StackConfigResolver.ts"; +export type { BinarySpec } from "./BinaryResolver.ts"; +export { resolveNativeBinary } from "./resolve-native-binary.ts"; +export { renderNativePostgresInitScript } from "./services/postgres-init.ts"; + export { connectLayer, DaemonStartError } from "./layers.ts"; export type { ManagedStack } from "./managed-stack.ts"; export { resolveManagedStack } from "./managed-stack.ts"; diff --git a/packages/stack/src/resolve-native-binary.ts b/packages/stack/src/resolve-native-binary.ts new file mode 100644 index 0000000000..74c53b6cc4 --- /dev/null +++ b/packages/stack/src/resolve-native-binary.ts @@ -0,0 +1,21 @@ +import { Effect, FileSystem, Path } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { BinaryResolver, type BinarySpec } from "./BinaryResolver.ts"; +import type { BinaryNotFoundError, ChecksumMismatchError, DownloadError } from "./errors.ts"; + +export const resolveNativeBinary = ( + cacheRoot: string, + spec: BinarySpec, +): Effect.Effect< + string, + BinaryNotFoundError | DownloadError | ChecksumMismatchError, + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient + | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve(spec); + }).pipe(Effect.provide(BinaryResolver.make(cacheRoot))); diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 63917352b1..c2e3bbd768 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -27,9 +27,12 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `.trim(); -export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { +export const renderNativePostgresInitScript = (opts: { + readonly postgresDir: string; + readonly dbPort: number; + readonly autoExposeNewTables: boolean; +}): string => { const pgBinDir = `${opts.postgresDir}/bin`; - const pgLibDir = `${opts.postgresDir}/lib`; const migrationsDir = `${opts.postgresDir}/share/supabase-cli/migrations`; const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; @@ -48,7 +51,7 @@ EOSQL // Replaces calling migrate.sh (which spawns ~57 separate psql processes) with // chained -f flags that run all SQL files in a single psql session, cutting // postgres-init time from ~5s to ~1s. - const script = ` + return ` export PATH="${pgBinDir}:$PATH" export PGPASSWORD=postgres db="${migrationsDir}" @@ -128,11 +131,14 @@ END \\$\\$; " `; +}; +export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { + const pgLibDir = `${opts.postgresDir}/lib`; return { name: "postgres-init", command: "bash", - args: ["-c", script], + args: ["-c", renderNativePostgresInitScript(opts)], env: { DYLD_LIBRARY_PATH: pgLibDir, LD_LIBRARY_PATH: pgLibDir, From 2866313a8e1157367d72d915717e80de1bd4faad Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 19 Aug 2026 17:09:17 +0200 Subject: [PATCH 2/6] feat(cli): keep the schema journal as a local draft, not the apply gate Drop the checkpoint sidecar and --allow-data-loss. Generate and local reset clear the journal; apply records history when the live catalog already matches; push live-verifies ahead and drift unless --skip-verify. --- .../commands/migrations/push/push.command.ts | 14 +- .../commands/migrations/push/push.handler.ts | 2 +- .../commands/schema/apply/apply.command.ts | 7 +- .../commands/schema/apply/apply.handler.ts | 1 - .../legacy-schema-project-link-state.layer.ts | 187 +++++++++++++ ...hema-project-link-state.layer.unit.test.ts | 173 ++++++++++++ .../schema/legacy-schema-runtime.layer.ts | 4 + .../db-bootstrap/reset-local-database.ts | 3 + apps/cli/src/next/commands/db/db.command.ts | 5 +- .../commands/migrations/push/push.command.ts | 14 +- .../commands/migrations/push/push.handler.ts | 2 +- .../commands/schema/apply/apply.command.ts | 7 +- .../commands/schema/apply/apply.handler.ts | 1 - .../schema/apply/apply.integration.test.ts | 3 +- .../destructive-auth.integration.test.ts | 62 +++-- .../src/shared/database/destructive-auth.ts | 27 +- .../shared/migrations/apply-local-pending.ts | 42 +++ .../apply-migrations.integration.test.ts | 207 ++++++++++++++ .../src/shared/migrations/apply-migrations.ts | 20 +- .../migrations/migration-runner.layer.ts | 47 +++- .../migration-runner.layer.unit.test.ts | 148 ++++++++++ .../migrations/migration-runner.service.ts | 3 +- .../push-migrations.integration.test.ts | 193 ++++++++++--- .../src/shared/migrations/push-migrations.ts | 58 ++-- .../schema/apply-schema.integration.test.ts | 261 ++++++++++++++++++ apps/cli/src/shared/schema/apply-schema.ts | 16 +- .../src/shared/schema/clear-draft-journal.ts | 9 + .../schema/clear-draft-journal.unit.test.ts | 26 ++ .../src/shared/schema/declarations-ahead.ts | 18 +- .../generate-schema.integration.test.ts | 240 ++++++++++++++++ apps/cli/src/shared/schema/generate-schema.ts | 93 ++----- .../schema/pull-schema.integration.test.ts | 45 +-- apps/cli/src/shared/schema/pull-schema.ts | 49 +--- apps/cli/src/shared/schema/schema-paths.ts | 3 - .../src/shared/schema/schema-runtime.layer.ts | 29 +- docs/cli/schema-first-v1-plan.md | 32 +-- 36 files changed, 1677 insertions(+), 374 deletions(-) create mode 100644 apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts create mode 100644 apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts create mode 100644 apps/cli/src/shared/migrations/apply-local-pending.ts create mode 100644 apps/cli/src/shared/migrations/apply-migrations.integration.test.ts create mode 100644 apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts create mode 100644 apps/cli/src/shared/schema/apply-schema.integration.test.ts create mode 100644 apps/cli/src/shared/schema/clear-draft-journal.ts create mode 100644 apps/cli/src/shared/schema/clear-draft-journal.unit.test.ts create mode 100644 apps/cli/src/shared/schema/generate-schema.integration.test.ts diff --git a/apps/cli/src/legacy/commands/migrations/push/push.command.ts b/apps/cli/src/legacy/commands/migrations/push/push.command.ts index 988bb9dcd4..6cb2bd6bbe 100644 --- a/apps/cli/src/legacy/commands/migrations/push/push.command.ts +++ b/apps/cli/src/legacy/commands/migrations/push/push.command.ts @@ -7,25 +7,23 @@ import { legacyMigrationsPush } from "./push.handler.ts"; const config = { yes: Flag.boolean("yes").pipe( - Flag.withDescription("Answer ordinary prompts. Never authorizes data loss."), + Flag.withDescription("Answer ordinary prompts. Does not skip target identity or live verify."), Flag.withAlias("y"), ), - allowDataLoss: Flag.boolean("allow-data-loss").pipe( - Flag.withDescription("Required when pending migrations are destructive or unclassified."), - ), projectRef: Flag.string("project-ref").pipe( - Flag.withDescription( - "Must match the resolved linked project for destructive non-interactive runs.", - ), + 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 for destructive plans."), + 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; diff --git a/apps/cli/src/legacy/commands/migrations/push/push.handler.ts b/apps/cli/src/legacy/commands/migrations/push/push.handler.ts index aa102efb47..f037fd1915 100644 --- a/apps/cli/src/legacy/commands/migrations/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/migrations/push/push.handler.ts @@ -8,10 +8,10 @@ export const legacyMigrationsPush = Effect.fn("legacy.migrations.push")(function ) { const result = yield* pushMigrations({ yes: flags.yes, - allowDataLoss: flags.allowDataLoss, 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 index 91008e9030..67a2aaa869 100644 --- a/apps/cli/src/legacy/commands/schema/apply/apply.command.ts +++ b/apps/cli/src/legacy/commands/schema/apply/apply.command.ts @@ -7,14 +7,9 @@ import { legacySchemaApply } from "./apply.handler.ts"; const config = { yes: Flag.boolean("yes").pipe( - Flag.withDescription("Answer ordinary prompts. Never authorizes data loss."), + Flag.withDescription("Answer ordinary prompts. Does not skip target identity."), Flag.withAlias("y"), ), - allowDataLoss: Flag.boolean("allow-data-loss").pipe( - Flag.withDescription( - "Required for destructive plans on durable targets. Implied on local apply.", - ), - ), projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Explicit project ref assertion for durable targets."), Flag.optional, diff --git a/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts b/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts index 3dd19e6c30..7e2af28773 100644 --- a/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts +++ b/apps/cli/src/legacy/commands/schema/apply/apply.handler.ts @@ -8,7 +8,6 @@ export const legacySchemaApply = Effect.fn("legacy.schema.apply")(function* ( ) { const result = yield* applySchema({ yes: flags.yes, - allowDataLoss: flags.allowDataLoss, projectRef: Option.getOrUndefined(flags.projectRef), allowRemote: flags.allowRemote, }); diff --git a/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts new file mode 100644 index 0000000000..5ceb347151 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts @@ -0,0 +1,187 @@ +import { Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { ProjectHome } from "../../next/config/project-home.service.ts"; +import { + InvalidProjectLinkStateError, + ProjectLinkState, + ProjectLinkStateValueSchema, + ProjectNotLinkedError, + type ActiveBranch, + type LinkedServiceVersions, + type ProjectLinkStateValue, +} from "../../next/config/project-link-state.service.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { PROJECT_REF_PATTERN } from "../config/legacy-project-ref.service.ts"; +import { legacyParseCachedLinkedProject } from "../shared/legacy-parent-project-ref.ts"; +import { legacyReadProjectRefFile, legacyTempPaths } from "../shared/legacy-temp-paths.ts"; + +const decodeProjectLinkStateValue = Schema.decodeUnknownEffect( + Schema.fromJsonString(ProjectLinkStateValueSchema), +); +const encodeProjectLinkStateValue = Schema.encodeUnknownSync(ProjectLinkStateValueSchema); + +function invalidProjectLinkStateError(filePath: string): InvalidProjectLinkStateError { + return new InvalidProjectLinkStateError({ + detail: `The linked project state file at ${filePath} is invalid or unreadable.`, + suggestion: "Fix or remove the file, then retry the command.", + }); +} + +function encodePrettyJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +const readOptionalVersion = (fs: FileSystem.FileSystem, filePath: string) => + fs.readFileString(filePath).pipe( + Effect.map((content) => content.trim()), + Effect.orElseSucceed(() => ""), + Effect.map((value) => (value.length > 0 ? value : undefined)), + ); + +/** + * Stable-shell `ProjectLinkState` for schema/migrations commands. + * + * Prefers next's `.supabase/project.json` when present. Otherwise reconstructs + * identity from the files `supabase link` actually writes: + * `supabase/.temp/project-ref`, `linked-project.json`, and version pins. + */ +export const legacySchemaProjectLinkStateLayer = Layer.effect( + ProjectLinkState, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectHome = yield* ProjectHome; + const cliConfig = yield* LegacyCliConfig; + + const loadFromPath = (filePath: string) => + Effect.gen(function* () { + const exists = yield* fs + .exists(filePath) + .pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); + if (!exists) { + return Option.none(); + } + + const content = yield* fs + .readFileString(filePath) + .pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); + const decoded = yield* decodeProjectLinkStateValue(content).pipe( + Effect.mapError(() => invalidProjectLinkStateError(filePath)), + ); + if (!PROJECT_REF_PATTERN.test(decoded.project.ref)) { + return yield* new InvalidProjectLinkStateError({ + detail: `The linked project state file at ${filePath} has an invalid project ref.`, + suggestion: "Fix or remove the file, then retry the command.", + }); + } + return Option.some(decoded); + }); + + const loadFromLegacyTemp = Effect.gen(function* () { + const workdir = cliConfig.workdir; + const paths = legacyTempPaths(path, workdir); + let ref: string | undefined; + if (Option.isSome(cliConfig.projectId)) { + if (!PROJECT_REF_PATTERN.test(cliConfig.projectId.value)) { + return yield* new InvalidProjectLinkStateError({ + detail: "SUPABASE_PROJECT_ID is not a valid project ref.", + suggestion: + "Set a 20-letter project ref, or unset SUPABASE_PROJECT_ID to use supabase/.temp/project-ref.", + }); + } + ref = cliConfig.projectId.value; + } else { + const fileRef = yield* legacyReadProjectRefFile(fs, path, workdir).pipe( + Effect.mapError( + (error) => + new InvalidProjectLinkStateError({ + detail: error.message, + suggestion: "Fix or remove supabase/.temp/project-ref, then retry.", + }), + ), + ); + if (Option.isSome(fileRef) && !PROJECT_REF_PATTERN.test(fileRef.value)) { + return yield* new InvalidProjectLinkStateError({ + detail: "supabase/.temp/project-ref is not a valid project ref.", + suggestion: "Run `supabase link` again, or remove the invalid project-ref file.", + }); + } + ref = Option.isSome(fileRef) ? fileRef.value : undefined; + } + if (ref === undefined) { + return Option.none(); + } + + const cacheContent = yield* fs + .readFileString(paths.linkedProjectCache) + .pipe(Effect.orElseSucceed(() => "")); + const cached = legacyParseCachedLinkedProject(cacheContent); + const postgres = yield* readOptionalVersion(fs, paths.postgresVersion); + const postgrest = yield* readOptionalVersion(fs, paths.restVersion); + const auth = yield* readOptionalVersion(fs, paths.gotrueVersion); + const storage = yield* readOptionalVersion(fs, paths.storageVersion); + const versions: LinkedServiceVersions = { + ...(postgres === undefined ? {} : { postgres }), + ...(postgrest === undefined ? {} : { postgrest }), + ...(auth === undefined ? {} : { auth }), + ...(storage === undefined ? {} : { storage }), + }; + + return Option.some({ + project: { + ref, + name: Option.isSome(cached) ? (cached.value.name ?? ref) : ref, + organization_id: Option.isSome(cached) ? (cached.value.organizationId ?? "") : "", + organization_slug: Option.isSome(cached) ? (cached.value.organizationSlug ?? "") : "", + }, + active_branch: { + ref, + name: "main", + is_default: true, + }, + fetchedAt: "", + versions, + } satisfies ProjectLinkStateValue); + }); + + const load = Effect.gen(function* () { + const next = yield* loadFromPath(projectHome.projectLinkPath); + if (Option.isSome(next)) return next; + return yield* loadFromLegacyTemp; + }); + + const save = (state: ProjectLinkStateValue) => + Effect.gen(function* () { + yield* projectHome.ensureProjectHomeDir; + const encoded = encodeProjectLinkStateValue(state); + yield* fs.writeFileString(projectHome.projectLinkPath, encodePrettyJson(encoded), { + mode: 0o600, + }); + }).pipe(Effect.orDie); + + const clear = fs.remove(projectHome.projectLinkPath).pipe(Effect.ignore, Effect.orDie); + + const getActiveBranch = load.pipe(Effect.map(Option.map((state) => state.active_branch))); + + const setActiveBranch = (branch: ActiveBranch) => + Effect.gen(function* () { + const current = yield* load; + if (Option.isNone(current)) { + return yield* Effect.fail( + new ProjectNotLinkedError({ + detail: "Cannot set active branch: no linked project found.", + suggestion: "Run `supabase link` to link this checkout to a Supabase project first.", + }), + ); + } + yield* save({ ...current.value, active_branch: branch }); + }); + + return ProjectLinkState.of({ + load, + save, + clear, + getActiveBranch, + setActiveBranch, + }); + }), +); diff --git a/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts b/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts new file mode 100644 index 0000000000..b6665824a9 --- /dev/null +++ b/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts @@ -0,0 +1,173 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option } from "effect"; +import { ProjectHome } from "../../next/config/project-home.service.ts"; +import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; +import { LEGACY_VALID_REF, mockLegacyCliConfig } from "../../../tests/helpers/legacy-mocks.ts"; +import { legacySchemaProjectLinkStateLayer } from "./legacy-schema-project-link-state.layer.ts"; + +function tempWorkdir() { + const workdir = mkdtempSync(join(tmpdir(), "legacy-schema-link-")); + const supabaseDir = join(workdir, "supabase"); + const projectHomeDir = join(workdir, ".supabase"); + mkdirSync(join(supabaseDir, ".temp"), { recursive: true }); + mkdirSync(projectHomeDir, { recursive: true }); + return { workdir, supabaseDir, projectHomeDir }; +} + +function projectHomeLayer(workdir: string, projectHomeDir: string, supabaseDir: string) { + return Layer.succeed( + ProjectHome, + ProjectHome.of({ + projectRoot: workdir, + supabaseDir, + projectHomeDir, + projectLinkPath: join(projectHomeDir, "project.json"), + projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), + ensureProjectHomeDir: Effect.void, + stackDir: (name: string) => join(projectHomeDir, "stacks", name), + stackStatePath: (name: string) => join(projectHomeDir, "stacks", name, "state.json"), + stackMetadataPath: (name: string) => join(projectHomeDir, "stacks", name, "stack.json"), + stackDataDir: (name: string) => join(projectHomeDir, "stacks", name, "data"), + stackLogsDir: (name: string) => join(projectHomeDir, "stacks", name, "logs"), + }), + ); +} + +function setup(workdir: string, projectHomeDir: string, supabaseDir: string) { + return legacySchemaProjectLinkStateLayer.pipe( + Layer.provide(BunServices.layer), + Layer.provide(mockLegacyCliConfig({ workdir, projectId: Option.none() })), + Layer.provide(projectHomeLayer(workdir, projectHomeDir, supabaseDir)), + ); +} + +describe("legacySchemaProjectLinkStateLayer", () => { + it.live("loads a stable link from supabase/.temp/project-ref", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); + writeFileSync( + join(supabaseDir, ".temp", "linked-project.json"), + JSON.stringify({ + ref: LEGACY_VALID_REF, + name: "Demo", + organization_id: "org-1", + organization_slug: "demo-org", + }), + ); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const state = yield* link.load; + expect(Option.isSome(state)).toBe(true); + if (Option.isNone(state)) return; + expect(state.value.project.ref).toBe(LEGACY_VALID_REF); + expect(state.value.project.name).toBe("Demo"); + expect(state.value.project.organization_slug).toBe("demo-org"); + }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); + }); + + it.live("prefers next project.json when both link files exist", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); + writeFileSync( + join(projectHomeDir, "project.json"), + `${JSON.stringify({ + project: { + ref: "zzzzzzzzzzzzzzzzzzzz", + name: "Next", + organization_id: "org-next", + organization_slug: "next-org", + }, + active_branch: { ref: "zzzzzzzzzzzzzzzzzzzz", name: "main", is_default: true }, + fetchedAt: "2026-01-01T00:00:00.000Z", + versions: {}, + })}\n`, + ); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const state = yield* link.load; + expect(Option.isSome(state)).toBe(true); + if (Option.isNone(state)) return; + expect(state.value.project.ref).toBe("zzzzzzzzzzzzzzzzzzzz"); + expect(state.value.project.name).toBe("Next"); + }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); + }); + + it.live("fails closed when project.json has an invalid project ref", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); + writeFileSync( + join(projectHomeDir, "project.json"), + `${JSON.stringify({ + project: { + ref: "not-a-ref", + name: "Broken", + organization_id: "org-1", + organization_slug: "broken", + }, + active_branch: { ref: "not-a-ref", name: "main", is_default: true }, + fetchedAt: "2026-01-01T00:00:00.000Z", + versions: {}, + })}\n`, + ); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const exit = yield* link.load.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); + }); + + it.live("fails closed when SUPABASE_PROJECT_ID is present but invalid", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); + const layer = legacySchemaProjectLinkStateLayer.pipe( + Layer.provide(BunServices.layer), + Layer.provide(mockLegacyCliConfig({ workdir, projectId: Option.some("not-a-ref") })), + Layer.provide(projectHomeLayer(workdir, projectHomeDir, supabaseDir)), + ); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const exit = yield* link.load.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses SUPABASE_PROJECT_ID even when supabase/.temp/project-ref is invalid", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + writeFileSync(join(supabaseDir, ".temp", "project-ref"), "not-a-ref\n"); + const layer = legacySchemaProjectLinkStateLayer.pipe( + Layer.provide(BunServices.layer), + Layer.provide(mockLegacyCliConfig({ workdir, projectId: Option.some(LEGACY_VALID_REF) })), + Layer.provide(projectHomeLayer(workdir, projectHomeDir, supabaseDir)), + ); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const state = yield* link.load; + expect(Option.isSome(state)).toBe(true); + if (Option.isNone(state)) return; + expect(state.value.project.ref).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails closed when supabase/.temp/project-ref is present but invalid", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + writeFileSync(join(supabaseDir, ".temp", "project-ref"), "not-a-ref\n"); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const exit = yield* link.load.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); + }); + + it.live("returns none when the workdir is not linked", () => { + const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); + return Effect.gen(function* () { + const link = yield* ProjectLinkState; + const state = yield* link.load; + expect(Option.isNone(state)).toBe(true); + }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); + }); +}); diff --git a/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts index f3d269af78..7b13eb9d29 100644 --- a/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts +++ b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts @@ -5,6 +5,7 @@ import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyNextCliConfigLayer } from "../config/legacy-next-cli-config.layer.ts"; import { legacyDockerLocalDatabaseFallbackLayer } from "./legacy-docker-local-database.layer.ts"; +import { legacySchemaProjectLinkStateLayer } from "./legacy-schema-project-link-state.layer.ts"; import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; @@ -38,6 +39,9 @@ export const legacySchemaRuntimeLayer = (commandPath: ReadonlyArray) => localDatabaseFallback: legacyDockerLocalDatabaseFallbackLayer.pipe( Layer.provide(Layer.succeed(LegacyCliConfig, config)), ), + projectLinkState: legacySchemaProjectLinkStateLayer.pipe( + Layer.provide(Layer.succeed(LegacyCliConfig, config)), + ), }); }), ).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..5227d4ce2a 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, @@ -252,6 +253,8 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( ); } + yield* clearDraftJournalFile(fs, path, workdir); + // "Finished supabase db reset on branch ." (both Aqua). const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); yield* output.raw( diff --git a/apps/cli/src/next/commands/db/db.command.ts b/apps/cli/src/next/commands/db/db.command.ts index b4cf35fb9b..d1d06d526f 100644 --- a/apps/cli/src/next/commands/db/db.command.ts +++ b/apps/cli/src/next/commands/db/db.command.ts @@ -41,10 +41,10 @@ const dbDiffCommand = Command.make("diff", dbDiffFlags).pipe( const dbPushFlags = { yes: Flag.boolean("yes").pipe(Flag.withAlias("y")), - allowDataLoss: Flag.boolean("allow-data-loss"), projectRef: Flag.string("project-ref").pipe(Flag.optional), allowRemote: Flag.boolean("allow-remote"), dbUrl: Flag.string("db-url").pipe(Flag.optional), + skipVerify: Flag.boolean("skip-verify"), } as const; const dbPushCommand = Command.make("push", dbPushFlags).pipe( @@ -55,10 +55,10 @@ const dbPushCommand = Command.make("push", dbPushFlags).pipe( yield* notice("db push", "migrations push"); const result = yield* pushMigrations({ yes: flags.yes, - allowDataLoss: flags.allowDataLoss, projectRef: Option.getOrUndefined(flags.projectRef), allowRemote: flags.allowRemote, dbUrl: Option.getOrUndefined(flags.dbUrl), + skipVerify: flags.skipVerify, }); yield* renderSchemaResult("Push migrations", result); }).pipe(withCommandInstrumentation(), withJsonErrorHandling), @@ -135,7 +135,6 @@ const declarativeSyncCommand = Command.make("sync", declarativeSyncFlags).pipe( if (flags.apply) { const applied = yield* applySchema({ yes: true, - allowDataLoss: true, allowRemote: false, }); yield* renderSchemaResult("Apply declarative schema", applied); diff --git a/apps/cli/src/next/commands/migrations/push/push.command.ts b/apps/cli/src/next/commands/migrations/push/push.command.ts index 621db63e74..5c3c36cc08 100644 --- a/apps/cli/src/next/commands/migrations/push/push.command.ts +++ b/apps/cli/src/next/commands/migrations/push/push.command.ts @@ -7,25 +7,23 @@ import { migrationsPush } from "./push.handler.ts"; const flags = { yes: Flag.boolean("yes").pipe( - Flag.withDescription("Answer ordinary prompts. Never authorizes data loss."), + Flag.withDescription("Answer ordinary prompts. Does not skip target identity or live verify."), Flag.withAlias("y"), ), - allowDataLoss: Flag.boolean("allow-data-loss").pipe( - Flag.withDescription("Required when pending migrations are destructive or unclassified."), - ), projectRef: Flag.string("project-ref").pipe( - Flag.withDescription( - "Must match the resolved linked project for destructive non-interactive runs.", - ), + 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 for destructive plans."), + 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 MigrationsPushFlags = CliCommand.Command.Config.Infer; diff --git a/apps/cli/src/next/commands/migrations/push/push.handler.ts b/apps/cli/src/next/commands/migrations/push/push.handler.ts index a6a2c6b8f4..4356ddeb8f 100644 --- a/apps/cli/src/next/commands/migrations/push/push.handler.ts +++ b/apps/cli/src/next/commands/migrations/push/push.handler.ts @@ -6,10 +6,10 @@ import type { MigrationsPushFlags } from "./push.command.ts"; export const migrationsPush = Effect.fn("migrations.push")(function* (flags: MigrationsPushFlags) { const result = yield* pushMigrations({ yes: flags.yes, - allowDataLoss: flags.allowDataLoss, 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/next/commands/schema/apply/apply.command.ts b/apps/cli/src/next/commands/schema/apply/apply.command.ts index f94fc5c022..e5bb637f9a 100644 --- a/apps/cli/src/next/commands/schema/apply/apply.command.ts +++ b/apps/cli/src/next/commands/schema/apply/apply.command.ts @@ -7,14 +7,9 @@ import { schemaApply } from "./apply.handler.ts"; const flags = { yes: Flag.boolean("yes").pipe( - Flag.withDescription("Answer ordinary prompts. Never authorizes data loss."), + Flag.withDescription("Answer ordinary prompts. Does not skip target identity."), Flag.withAlias("y"), ), - allowDataLoss: Flag.boolean("allow-data-loss").pipe( - Flag.withDescription( - "Required for destructive plans on durable targets. Implied on local apply.", - ), - ), projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Explicit project ref assertion for durable targets."), Flag.optional, diff --git a/apps/cli/src/next/commands/schema/apply/apply.handler.ts b/apps/cli/src/next/commands/schema/apply/apply.handler.ts index 3efe03df29..49764d01a1 100644 --- a/apps/cli/src/next/commands/schema/apply/apply.handler.ts +++ b/apps/cli/src/next/commands/schema/apply/apply.handler.ts @@ -6,7 +6,6 @@ import type { SchemaApplyFlags } from "./apply.command.ts"; export const schemaApply = Effect.fn("schema.apply")(function* (flags: SchemaApplyFlags) { const result = yield* applySchema({ yes: flags.yes, - allowDataLoss: flags.allowDataLoss, projectRef: Option.getOrUndefined(flags.projectRef), allowRemote: flags.allowRemote, }); diff --git a/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts index 25f9e24e22..5a84c6d49d 100644 --- a/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts +++ b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts @@ -72,7 +72,7 @@ function setup(disposable: boolean) { MigrationRunner.of({ listRemote: () => Effect.succeed([]), applyPending: () => Effect.succeed({ applied: [], skipped: [] }), - recordApplied: () => Effect.void, + markApplied: () => Effect.void, }), ), Layer.succeed( @@ -95,7 +95,6 @@ describe("applySchema", () => { return Effect.gen(function* () { const exit = yield* applySchema({ yes: true, - allowDataLoss: true, allowRemote: true, projectRef: "abcdefghijklmnop", }).pipe(Effect.provide(layer), Effect.exit); diff --git a/apps/cli/src/shared/database/destructive-auth.integration.test.ts b/apps/cli/src/shared/database/destructive-auth.integration.test.ts index 2992c2ef1f..40b4dda03a 100644 --- a/apps/cli/src/shared/database/destructive-auth.integration.test.ts +++ b/apps/cli/src/shared/database/destructive-auth.integration.test.ts @@ -23,6 +23,16 @@ const linked: DatabaseTarget = { 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", @@ -33,69 +43,69 @@ const url: DatabaseTarget = { }; describe("authorizeMutation", () => { - it.live("auto-approves disposable local targets even when destructive", () => { + it.live("auto-approves disposable local targets", () => { const out = mockOutput({ interactive: false }); return Effect.gen(function* () { yield* authorizeMutation({ target: local, - destructive: true, - flags: { yes: false, allowDataLoss: false, allowRemote: false }, + flags: { yes: false, allowRemote: false }, command: "schema apply", }); }).pipe(Effect.provide(out.layer)); }); - it.live("rejects destructive durable plans without --allow-data-loss", () => { + it.live("rejects mismatched --project-ref", () => { const out = mockOutput({ interactive: false }); return Effect.gen(function* () { const exit = yield* authorizeMutation({ target: linked, - destructive: true, - flags: { - yes: true, - allowDataLoss: false, - allowRemote: false, - projectRef: "abcdefghijklmnop", - }, + 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("rejects mismatched --project-ref", () => { + it.live("accepts a matching --project-ref for linked targets", () => { const out = mockOutput({ interactive: false }); return Effect.gen(function* () { - const exit = yield* authorizeMutation({ - target: linked, - destructive: true, - flags: { yes: true, allowDataLoss: true, allowRemote: false, projectRef: "otherref" }, + yield* authorizeMutation({ + target: unverifiedLinked, + flags: { yes: true, allowRemote: false, projectRef: "abcdefghijklmnop" }, command: "migrations push", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + }); + }).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 --allow-remote for any unverifiable URL mutation", () => { + 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: url, - destructive: false, - flags: { yes: true, allowDataLoss: false, allowRemote: false }, + target: unverifiedLinked, + flags: { yes: false, allowRemote: false }, command: "migrations push", }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(Layer.mergeAll(out.layer))); + }).pipe(Effect.provide(out.layer)); }); - it.live("requires --allow-remote for destructive URL targets", () => { + it.live("requires --allow-remote for URL targets", () => { const out = mockOutput({ interactive: false }); return Effect.gen(function* () { const exit = yield* authorizeMutation({ target: url, - destructive: true, - flags: { yes: true, allowDataLoss: true, allowRemote: false }, + flags: { yes: true, allowRemote: false }, command: "migrations push", }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); diff --git a/apps/cli/src/shared/database/destructive-auth.ts b/apps/cli/src/shared/database/destructive-auth.ts index da62961cbd..8ed830f655 100644 --- a/apps/cli/src/shared/database/destructive-auth.ts +++ b/apps/cli/src/shared/database/destructive-auth.ts @@ -8,33 +8,24 @@ import { import type { DatabaseTarget } from "./database-target.ts"; import { Output } from "../output/output.service.ts"; -export type DestructiveAuthFlags = { +export type MutationAuthFlags = { readonly yes: boolean; - readonly allowDataLoss: boolean; readonly projectRef?: string; readonly allowRemote: boolean; }; export const authorizeMutation = Effect.fnUntraced(function* (input: { readonly target: DatabaseTarget; - readonly destructive: boolean; - readonly flags: DestructiveAuthFlags; + readonly flags: MutationAuthFlags; readonly command: string; }) { - const { target, destructive, flags, command } = input; + const { target, flags, command } = input; if (target.disposable) { return; } - if (destructive && !flags.allowDataLoss) { - return yield* new SchemaDestructiveAuthError({ - detail: "This plan contains destructive actions.", - suggestion: `Re-run with --allow-data-loss. --yes never authorizes data loss.`, - }); - } - - if (!target.connectionVerified) { + if (target.kind === "url") { if (!flags.allowRemote) { return yield* new SchemaAllowRemoteRequiredError({ detail: "This connection string cannot be identity-verified.", @@ -74,18 +65,10 @@ export const authorizeMutation = Effect.fnUntraced(function* (input: { return; } - if (destructive) { - return yield* new SchemaProjectRefMismatchError({ - detail: `Non-interactive destructive mutation of ${resolvedRef} requires an explicit identity assertion.`, - suggestion: `Pass --project-ref ${resolvedRef}.`, - }); - } - if (!flags.yes) { return yield* new SchemaDestructiveAuthError({ detail: "Non-interactive mutation of a durable target requires confirmation.", - suggestion: - "Pass --yes for ordinary confirmation. Destructive plans also need --allow-data-loss and --project-ref.", + suggestion: "Pass --yes, or pass --project-ref to assert the target identity.", }); } }); 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..b36583fb2d --- /dev/null +++ b/apps/cli/src/shared/migrations/apply-local-pending.ts @@ -0,0 +1,42 @@ +import { Effect } from "effect"; +import { acquireDatabasePool } from "../database/database-pool.ts"; +import { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import type { MigrationFile } from "./migration-file.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: [], + skipped: local.map((file) => file.version), + } satisfies MigrationApplyResult; + } + + const shadow = yield* engine.provisionShadow; + const shadowPool = yield* acquireDatabasePool(shadow.url); + yield* runner.applyPending(shadowPool, local); + const drift = yield* engine.diffPools({ + sourcePool: shadowPool, + desiredPool: pool, + allowDrops: true, + }); + if (!drift.changes) { + yield* runner.markApplied(pool, pending); + return { + applied: [], + recorded: pending.map((file) => file.version), + skipped: local.filter((file) => present.has(file.version)).map((file) => file.version), + } satisfies MigrationApplyResult; + } + + return yield* runner.applyPending(pool, local); +}); 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..c5d3540f7f --- /dev/null +++ b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts @@ -0,0 +1,207 @@ +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, + }; +} + +function setup( + journal: SchemaDraftJournal | undefined, + opts: { + history?: ReadonlyArray<{ version: string; name: string }>; + catalogMatch?: boolean; + } = {}, +) { + const out = mockOutput({ interactive: false }); + let applyPending = 0; + let marked = 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({ + readCheckpoint: Effect.succeed(Option.none()), + writeCheckpoint: () => Effect.void, + 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([ + { + 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: () => Effect.succeed(planView(opts.catalogMatch !== true)), + applyPlan: () => Effect.die("unused"), + provisionShadow: Effect.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + }), + ), + ), + }; +} + +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("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 index 475d03cd71..2ff8d84c25 100644 --- a/apps/cli/src/shared/migrations/apply-migrations.ts +++ b/apps/cli/src/shared/migrations/apply-migrations.ts @@ -4,13 +4,12 @@ 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"; -import { MigrationRunner } from "./migration-runner.service.ts"; export const applyMigrations = Effect.fn("migrations.apply")(function* () { const targets = yield* DatabaseTargetResolver; const repository = yield* MigrationRepository; - const runner = yield* MigrationRunner; const state = yield* SchemaStateStore; const journal = yield* state.readJournal; if ( @@ -30,23 +29,28 @@ export const applyMigrations = Effect.fn("migrations.apply")(function* () { return yield* Effect.scoped( Effect.gen(function* () { const pool = yield* acquireDatabasePool(target.connectionString); - const result = yield* runner.applyPending(pool, local); + const result = yield* applyLocalPending(pool, local); + const recorded = result.recorded ?? []; + const mutatedDatabase = result.applied.length > 0 || recorded.length > 0; return { status: "clean", message: - result.applied.length === 0 - ? "No pending migrations." - : `Applied ${result.applied.length} migration(s): ${result.applied.join(", ")}`, + recorded.length > 0 + ? `Recorded ${recorded.length} already-applied migration(s): ${recorded.join(", ")}` + : result.applied.length === 0 + ? "No pending migrations." + : `Applied ${result.applied.length} migration(s): ${result.applied.join(", ")}`, data: { status: "clean", applied: result.applied, + recorded, skipped: result.skipped, target: target.identity, - mutated_database: result.applied.length > 0, + mutated_database: mutatedDatabase, mutated_files: false, }, nextActions: [], - mutatedDatabase: result.applied.length > 0, + mutatedDatabase, mutatedFiles: false, } satisfies SchemaCommandResult; }), diff --git a/apps/cli/src/shared/migrations/migration-runner.layer.ts b/apps/cli/src/shared/migrations/migration-runner.layer.ts index 0582b8b371..56c383b0d7 100644 --- a/apps/cli/src/shared/migrations/migration-runner.layer.ts +++ b/apps/cli/src/shared/migrations/migration-runner.layer.ts @@ -20,26 +20,63 @@ COMMIT; 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 INSERT_HISTORY = "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)"; +const MISSING_HISTORY_CODES = new Set(["3F000", "42P01"]); + const engineError = (detail: string) => new SchemaEngineError({ detail, suggestion: "Check the database connection and migration SQL, then retry.", }); +function 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; +} + +function postgresErrorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +function isMissingMigrationHistory(cause: unknown): boolean { + const code = postgresErrorCode(cause); + if (code !== undefined) return MISSING_HISTORY_CODES.has(code); + const message = postgresErrorMessage(cause); + return ( + /relation .* does not exist/iu.test(message) && !/column .* does not exist/iu.test(message) + ); +} + +function isMissingNameColumn(cause: unknown): boolean { + return /column ["']?name["']? does not exist/iu.test(postgresErrorMessage(cause)); +} + export const migrationRunnerLayer = Layer.succeed( MigrationRunner, MigrationRunner.of({ listRemote: (pool: Pool) => Effect.tryPromise({ try: async () => { - await pool.query(ENSURE_HISTORY); - const result = await pool.query(LIST_HISTORY); - return result.rows; + try { + const result = await pool.query(LIST_HISTORY); + return result.rows; + } catch (cause) { + if (isMissingMigrationHistory(cause)) return []; + if (isMissingNameColumn(cause)) { + const result = await pool.query<{ version: string }>(LIST_HISTORY_VERSION_ONLY); + return result.rows.map((row) => ({ version: row.version, name: "" })); + } + throw cause; + } }, - catch: (cause) => engineError(cause instanceof Error ? cause.message : String(cause)), + catch: (cause) => engineError(postgresErrorMessage(cause)), }), applyPending: (pool: Pool, local: ReadonlyArray) => Effect.gen(function* () { @@ -97,7 +134,7 @@ export const migrationRunnerLayer = Layer.succeed( .map((file) => file.version), } satisfies MigrationApplyResult; }), - recordApplied: (pool: Pool, files: ReadonlyArray) => + markApplied: (pool: Pool, files: ReadonlyArray) => Effect.tryPromise({ try: async () => { await pool.query(ENSURE_HISTORY); diff --git a/apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts b/apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts new file mode 100644 index 0000000000..cca7f0ef63 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import type { Pool } from "pg"; +import { migrationRunnerLayer } from "./migration-runner.layer.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; + +class PostgresQueryError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +function fakePool( + query: ( + sql: string, + params?: ReadonlyArray, + ) => Promise<{ rows: Array<{ version: string; name: string }> }>, +): Pool { + return { query } as Pool; +} + +const sampleFile = { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, +}; + +describe("migrationRunnerLayer.listRemote", () => { + it.live("does not create migration history", () => { + const seen: Array = []; + return Effect.gen(function* () { + const runner = yield* MigrationRunner; + const rows = yield* runner.listRemote( + fakePool(async (sql) => { + seen.push(sql); + return { rows: [{ version: "20260101000000", name: "init" }] }; + }), + ); + expect(rows).toEqual([{ version: "20260101000000", name: "init" }]); + expect(seen.some((sql) => sql.includes("CREATE SCHEMA"))).toBe(false); + expect(seen.some((sql) => sql.includes("CREATE TABLE"))).toBe(false); + }).pipe(Effect.provide(migrationRunnerLayer)); + }); + + it.live("returns empty history when the table is missing", () => + Effect.gen(function* () { + const runner = yield* MigrationRunner; + const rows = yield* runner.listRemote( + fakePool(async () => { + throw new PostgresQueryError( + "42P01", + `relation "supabase_migrations.schema_migrations" does not exist`, + ); + }), + ); + expect(rows).toEqual([]); + }).pipe(Effect.provide(migrationRunnerLayer)), + ); + + it.live("returns empty history when the schema is missing", () => + Effect.gen(function* () { + const runner = yield* MigrationRunner; + const rows = yield* runner.listRemote( + fakePool(async () => { + throw new PostgresQueryError("3F000", `schema "supabase_migrations" does not exist`); + }), + ); + expect(rows).toEqual([]); + }).pipe(Effect.provide(migrationRunnerLayer)), + ); + + it.live("reads version-only history when the name column is missing", () => { + const seen: Array = []; + return Effect.gen(function* () { + const runner = yield* MigrationRunner; + const rows = yield* runner.listRemote( + fakePool(async (sql) => { + seen.push(sql); + if (sql.includes("coalesce(name")) { + throw new PostgresQueryError("42703", `column "name" does not exist`); + } + return { rows: [{ version: "20260101000000", name: "" }] }; + }), + ); + expect(rows).toEqual([{ version: "20260101000000", name: "" }]); + expect(seen.some((sql) => sql.includes("CREATE"))).toBe(false); + }).pipe(Effect.provide(migrationRunnerLayer)); + }); + + it.live("propagates other query failures", () => + Effect.gen(function* () { + const runner = yield* MigrationRunner; + const exit = yield* runner + .listRemote( + fakePool(async () => { + throw new PostgresQueryError("42703", `column "version" does not exist`); + }), + ) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(migrationRunnerLayer)), + ); +}); + +describe("migrationRunnerLayer.markApplied", () => { + it.live("inserts missing history rows", () => { + const statements: Array = []; + return Effect.gen(function* () { + const runner = yield* MigrationRunner; + yield* runner.markApplied( + fakePool(async (sql) => { + statements.push(sql); + if (sql.includes("SELECT version")) { + return { rows: [] }; + } + return { rows: [] }; + }), + [sampleFile], + ); + expect(statements.some((sql) => sql.includes("INSERT INTO"))).toBe(true); + }).pipe(Effect.provide(migrationRunnerLayer)); + }); + + it.live("skips versions already present", () => { + const inserts: Array | undefined> = []; + return Effect.gen(function* () { + const runner = yield* MigrationRunner; + yield* runner.markApplied( + fakePool(async (sql, params) => { + if (sql.includes("INSERT INTO")) { + inserts.push(params); + } + if (sql.includes("SELECT version")) { + return { rows: [{ version: sampleFile.version, name: sampleFile.name }] }; + } + return { rows: [] }; + }), + [sampleFile], + ); + expect(inserts).toEqual([]); + }).pipe(Effect.provide(migrationRunnerLayer)); + }); +}); diff --git a/apps/cli/src/shared/migrations/migration-runner.service.ts b/apps/cli/src/shared/migrations/migration-runner.service.ts index 17eb8b02f5..227bd62f53 100644 --- a/apps/cli/src/shared/migrations/migration-runner.service.ts +++ b/apps/cli/src/shared/migrations/migration-runner.service.ts @@ -12,6 +12,7 @@ export type MigrationHistoryRow = { export type MigrationApplyResult = { readonly applied: ReadonlyArray; readonly skipped: ReadonlyArray; + readonly recorded?: ReadonlyArray; }; interface MigrationRunnerShape { @@ -22,7 +23,7 @@ interface MigrationRunnerShape { pool: Pool, local: ReadonlyArray, ) => Effect.Effect; - readonly recordApplied: ( + readonly markApplied: ( pool: Pool, files: ReadonlyArray, ) => Effect.Effect; diff --git a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts index a47b3e28ce..c3ad1ddef9 100644 --- a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -1,8 +1,11 @@ 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 { 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"; @@ -19,8 +22,76 @@ const linked = { projectRef: "abcdefghijklmnop", }; -function setup(opts: { declarations?: boolean; digestMatch?: boolean } = {}) { +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: [], +}; + +function setup( + opts: { + declarations?: boolean; + ahead?: boolean; + localRunning?: boolean; + drift?: boolean; + journal?: SchemaDraftJournal; + } = {}, +) { const out = mockOutput({ interactive: false }); + let shadowProvisions = 0; const layer = Layer.mergeAll( out.layer, Layer.succeed( @@ -45,25 +116,11 @@ function setup(opts: { declarations?: boolean; digestMatch?: boolean } = {}) { Layer.succeed( SchemaStateStore, SchemaStateStore.of({ - readCheckpoint: Effect.succeed( - opts.digestMatch === true - ? Option.some({ - version: 1 as const, - declarativeDigest: - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - migrationHeadDigest: "b", - profile: "supabase", - scope: "database", - engineVersion: "0.3.0", - artifactFormatVersion: 1, - acceptedRenames: [], - generatedMigrationVersions: ["20260101000000"], - lastGenerateHazards: { kinds: [], destructive: 0, rewrite: 0, coverageGaps: 0 }, - }) - : Option.none(), - ), + readCheckpoint: Effect.succeed(Option.none()), writeCheckpoint: () => Effect.void, - readJournal: Effect.succeed(Option.none()), + readJournal: Effect.succeed( + opts.journal === undefined ? Option.none() : Option.some(opts.journal), + ), writeJournal: () => Effect.void, clearJournal: Effect.void, withLock: (effect) => effect, @@ -72,7 +129,17 @@ function setup(opts: { declarations?: boolean; digestMatch?: boolean } = {}) { Layer.succeed( DatabaseTargetResolver, DatabaseTargetResolver.of({ - resolve: () => Effect.succeed(linked), + 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(linked); + }, }), ), Layer.succeed( @@ -89,34 +156,94 @@ function setup(opts: { declarations?: boolean; digestMatch?: boolean } = {}) { MigrationRunner.of({ listRemote: () => Effect.succeed([]), applyPending: () => Effect.succeed({ applied: [], skipped: [] }), - recordApplied: () => Effect.void, + markApplied: () => Effect.die("unused"), }), ), Layer.succeed( PgDeltaSchemaEngine, PgDeltaSchemaEngine.of({ exportSchema: () => Effect.die("unused"), - planFiles: () => Effect.die("unused"), - diffPools: () => Effect.die("unused"), + planFiles: () => Effect.succeed(planView(opts.ahead === true)), + diffPools: () => Effect.succeed(planView(opts.drift === true)), applyPlan: () => Effect.die("unused"), - provisionShadow: Effect.die("unused"), + provisionShadow: Effect.sync(() => { + shadowProvisions += 1; + return { url: "postgresql://postgres:postgres@127.0.0.1:1/postgres" }; + }), }), ), ); - return { layer }; + 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 declarations are ahead of the checkpoint", () => { - const { layer } = setup({ declarations: true, digestMatch: false }); + 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({ - yes: true, - allowDataLoss: true, - allowRemote: false, - projectRef: "abcdefghijklmnop", - }).pipe(Effect.provide(layer), Effect.exit); + 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(ctx.shadowProvisions).toBe(1); + }); + }); + + 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("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 index 3eb30e99e4..ee051b5d9b 100644 --- a/apps/cli/src/shared/migrations/push-migrations.ts +++ b/apps/cli/src/shared/migrations/push-migrations.ts @@ -2,20 +2,20 @@ 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 { assertDeclarationsNotAhead } from "../schema/declarations-ahead.ts"; -import { SchemaRemoteDriftError } from "../schema/schema-errors.ts"; -import { SchemaStateStore } from "../schema/schema-state.service.ts"; +import { assertNoUngeneratedDraft } from "../schema/declarations-ahead.ts"; +import { SchemaDeclarationsAheadError, 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 { MigrationRepository } from "./migration-repository.service.ts"; import { MigrationRunner } from "./migration-runner.service.ts"; export type PushMigrationsInput = { readonly yes: boolean; - readonly allowDataLoss: boolean; readonly projectRef?: string; readonly allowRemote: boolean; readonly dbUrl?: string; + readonly skipVerify: boolean; }; export const pushMigrations = Effect.fn("migrations.push")(function* (input: PushMigrationsInput) { @@ -23,52 +23,60 @@ export const pushMigrations = Effect.fn("migrations.push")(function* (input: Pus const repository = yield* MigrationRepository; const runner = yield* MigrationRunner; const engine = yield* PgDeltaSchemaEngine; - const state = yield* SchemaStateStore; + const workspace = yield* SchemaWorkspace; - yield* assertDeclarationsNotAhead(); + yield* assertNoUngeneratedDraft(); const remote = yield* targets.resolve( input.dbUrl !== undefined ? { kind: "url", url: input.dbUrl } : { kind: "linked" }, ); const localFiles = yield* repository.listLocal; - const checkpoint = yield* state.readCheckpoint; - const generated = new Set( - checkpoint._tag === "Some" ? (checkpoint.value.generatedMigrationVersions ?? []) : [], - ); - const destructiveVersions = new Set( - checkpoint._tag === "Some" ? (checkpoint.value.destructiveMigrationVersions ?? []) : [], - ); + 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)); - const pending = localFiles.filter((file) => !remoteVersions.has(file.version)); - const unclassified = pending.some((file) => !generated.has(file.version)); - const pendingDestructive = pending.some((file) => destructiveVersions.has(file.version)); - const destructive = unclassified || pendingDestructive; yield* authorizeMutation({ target: remote, - destructive, flags: { yes: input.yes, - allowDataLoss: input.allowDataLoss, allowRemote: input.allowRemote, ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), }, command: "migrations push", }); - const localTarget = yield* targets.resolve({ kind: "local" }).pipe(Effect.option); - if (localTarget._tag === "Some") { - const shadow = yield* engine.provisionShadow; - const sourcePool = yield* acquireDatabasePool(shadow.url); + if (!input.skipVerify) { + if (declarations.length > 0) { + const sourceShadow = yield* engine.provisionShadow; + const desiredShadow = yield* engine.provisionShadow; + const sourcePool = yield* acquireDatabasePool(sourceShadow.url); + const desiredPool = yield* acquireDatabasePool(desiredShadow.url); + yield* runner.applyPending(sourcePool, localFiles); + const ahead = yield* engine.planFiles({ + targetPool: sourcePool, + shadowPool: desiredPool, + files: declarations, + allowDrops: true, + }); + if (ahead.changes) { + return yield* new SchemaDeclarationsAheadError({ + detail: "Declarations are ahead of the local migration files.", + suggestion: + "Run `supabase schema generate --name ` before `supabase migrations push`.", + }); + } + } + + const driftShadow = yield* engine.provisionShadow; + const replayPool = yield* acquireDatabasePool(driftShadow.url); const replayed = localFiles.filter((file) => remoteVersions.has(file.version)); - yield* runner.applyPending(sourcePool, replayed); + yield* runner.applyPending(replayPool, replayed); const drift = yield* engine.diffPools({ - sourcePool, + sourcePool: replayPool, desiredPool: remotePool, allowDrops: true, }); 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..222d3bda31 --- /dev/null +++ b/apps/cli/src/shared/schema/apply-schema.integration.test.ts @@ -0,0 +1,261 @@ +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", + checkpointPath: "/tmp/c", + 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({ + readCheckpoint: Effect.succeed(Option.none()), + writeCheckpoint: () => Effect.void, + 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", + }), + }), + ), + ), + }; +} + +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(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 index a8873ba6dc..462e5cdcc7 100644 --- a/apps/cli/src/shared/schema/apply-schema.ts +++ b/apps/cli/src/shared/schema/apply-schema.ts @@ -2,8 +2,8 @@ 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 { MigrationRunner } from "../migrations/migration-runner.service.ts"; import { digestUtf8, digestVersions } from "./schema-digest.ts"; import { SchemaDraftConflictError, @@ -19,7 +19,6 @@ import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; export type ApplySchemaInput = { readonly yes: boolean; - readonly allowDataLoss: boolean; readonly projectRef?: string; readonly allowRemote: boolean; }; @@ -30,7 +29,6 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche const engine = yield* PgDeltaSchemaEngine; const targets = yield* DatabaseTargetResolver; const migrations = yield* MigrationRepository; - const runner = yield* MigrationRunner; const target = yield* targets.resolve({ kind: "local" }); if (!target.disposable) { @@ -49,11 +47,11 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche Effect.gen(function* () { const pool = yield* acquireDatabasePool(target.connectionString); const existingJournal = yield* state.readJournal; - if ( + const ungeneratedDraft = existingJournal._tag === "Some" && existingJournal.value.declarativelyAhead && - existingJournal.value.generated !== true - ) { + existingJournal.value.generated !== true; + if (ungeneratedDraft) { const currentHead = digestVersions(localMigrations.map((file) => file.version)); if (currentHead !== existingJournal.value.startingMigrationHeadDigest) { return yield* new SchemaDraftConflictError({ @@ -63,7 +61,7 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche }); } } else { - yield* runner.applyPending(pool, localMigrations); + yield* applyLocalPending(pool, localMigrations); } const shadow = yield* engine.provisionShadow; @@ -78,10 +76,8 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche yield* assertPlanActionable(plan); yield* authorizeMutation({ target, - destructive: plan.destructive, flags: { - yes: true, - allowDataLoss: true, + yes: input.yes, allowRemote: input.allowRemote, ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), }, 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..7ff384720c --- /dev/null +++ b/apps/cli/src/shared/schema/clear-draft-journal.ts @@ -0,0 +1,9 @@ +import { Effect, FileSystem, Path } from "effect"; +import { SCHEMA_DRAFT_JOURNAL_FILE_NAME } from "./schema-paths.ts"; + +/** Best-effort unlink of `.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.ignore); 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 index 2ddb971f86..053beb7f76 100644 --- a/apps/cli/src/shared/schema/declarations-ahead.ts +++ b/apps/cli/src/shared/schema/declarations-ahead.ts @@ -1,11 +1,8 @@ import { Effect } from "effect"; -import { digestFileSet } from "./schema-digest.ts"; import { SchemaDeclarationsAheadError } from "./schema-errors.ts"; import { SchemaStateStore } from "./schema-state.service.ts"; -import { SchemaWorkspace } from "./schema-workspace.service.ts"; -export const assertDeclarationsNotAhead = Effect.fnUntraced(function* () { - const workspace = yield* SchemaWorkspace; +export const assertNoUngeneratedDraft = Effect.fnUntraced(function* () { const state = yield* SchemaStateStore; const journal = yield* state.readJournal; if ( @@ -16,18 +13,7 @@ export const assertDeclarationsNotAhead = Effect.fnUntraced(function* () { return yield* new SchemaDeclarationsAheadError({ detail: "A declarative draft is ahead of the local migration head.", suggestion: - "Run `supabase schema generate --name ` before `supabase migrations push`.", - }); - } - const declarations = yield* workspace.readDeclarationFiles; - if (declarations.length === 0) return; - const checkpoint = yield* state.readCheckpoint; - const current = digestFileSet(declarations); - if (checkpoint._tag === "None" || checkpoint.value.declarativeDigest !== current) { - return yield* new SchemaDeclarationsAheadError({ - detail: "Declarations are ahead of the local migration head.", - suggestion: - "Run `supabase schema generate --name ` before `supabase migrations push`.", + "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..dbe2bfffba --- /dev/null +++ b/apps/cli/src/shared/schema/generate-schema.integration.test.ts @@ -0,0 +1,240 @@ +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 { MigrationRepository } from "../migrations/migration-repository.service.ts"; +import { MigrationRunner } from "../migrations/migration-runner.service.ts"; +import { digestVersions } from "./schema-digest.ts"; +import { generateSchema } from "./generate-schema.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: 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 } = {}) { + const out = mockOutput({ interactive: false }); + let cleared = false; + let planCalls = 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", + checkpointPath: "/tmp/c", + 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({ + readCheckpoint: Effect.succeed(Option.none()), + writeCheckpoint: () => Effect.void, + 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([ + { + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", + transactional: true, + }, + ]), + createEmpty: () => Effect.die("unused"), + writeGenerated: () => + 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( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed([]), + applyPending: () => Effect.succeed({ applied: [], skipped: [] }), + markApplied: () => 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.succeed({ + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }), + }), + ), + ); + return { + layer, + get cleared() { + return cleared; + }, + }; +} + +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); + }); + }); +}); diff --git a/apps/cli/src/shared/schema/generate-schema.ts b/apps/cli/src/shared/schema/generate-schema.ts index 2c4091dd4a..476a758163 100644 --- a/apps/cli/src/shared/schema/generate-schema.ts +++ b/apps/cli/src/shared/schema/generate-schema.ts @@ -1,19 +1,13 @@ import { Clock, Effect } from "effect"; import { acquireDatabasePool } from "../database/database-pool.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 { digestFileSet, digestVersions } from "./schema-digest.ts"; -import { SchemaEngineError } from "./schema-errors.ts"; +import { digestVersions } from "./schema-digest.ts"; +import { SchemaDraftConflictError, SchemaEngineError } from "./schema-errors.ts"; import { formatPlanSummary } from "./schema-output.ts"; import { assertPlanActionable } from "./schema-plan-gate.ts"; -import { - SCHEMA_ARTIFACT_FORMAT_VERSION, - SCHEMA_MANAGEMENT_SCOPE, - SCHEMA_PROFILE_ID, -} from "./schema-paths.ts"; import { SchemaStateStore } from "./schema-state.service.ts"; -import type { SchemaCheckpoint, SchemaCommandResult } from "./schema-types.ts"; +import type { SchemaCommandResult } from "./schema-types.ts"; import { SchemaWorkspace } from "./schema-workspace.service.ts"; import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; @@ -27,19 +21,32 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen const workspace = yield* SchemaWorkspace; const state = yield* SchemaStateStore; const engine = yield* PgDeltaSchemaEngine; - const targets = yield* DatabaseTargetResolver; const migrations = yield* MigrationRepository; const runner = yield* MigrationRunner; - const target = yield* targets.resolve({ kind: "local" }); const declarations = yield* workspace.readDeclarationFiles; const localMigrations = yield* migrations.listLocal; - const existingCheckpoint = yield* state.readCheckpoint; const name = input.name ?? (input.baseline ? "initial_schema" : "schema"); 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 = yield* engine.provisionShadow; const desiredShadow = yield* engine.provisionShadow; @@ -70,6 +77,9 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen }); if (input.dryRun || !plan.changes) { + if (!input.dryRun && !plan.changes) { + yield* state.clearJournal; + } return { status: plan.changes ? "needs_approval" : "clean", message: plan.changes @@ -122,64 +132,7 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen }); } - const localPool = yield* acquireDatabasePool(target.connectionString); - // planFiles requires an empty shadow. verifyDesired already holds the - // loaded declarations, so compare the live catalog against that shape. - const liveVsDeclared = yield* engine.diffPools({ - sourcePool: localPool, - desiredPool: verifyDesiredPool, - allowDrops: true, - }); - if (!liveVsDeclared.changes) { - yield* runner.recordApplied(localPool, written); - } - - const previousGenerated = - existingCheckpoint._tag === "Some" - ? (existingCheckpoint.value.generatedMigrationVersions ?? []) - : []; - const previousDestructive = - existingCheckpoint._tag === "Some" - ? (existingCheckpoint.value.destructiveMigrationVersions ?? []) - : []; - const checkpoint: SchemaCheckpoint = { - version: 1, - declarativeDigest: digestFileSet(declarations), - migrationHeadDigest: digestVersions(allMigrations.map((file) => file.version)), - sourceFingerprint: plan.sourceFingerprint, - desiredFingerprint: plan.desiredFingerprint, - profile: SCHEMA_PROFILE_ID, - scope: SCHEMA_MANAGEMENT_SCOPE, - engineVersion: plan.engineVersion, - artifactFormatVersion: SCHEMA_ARTIFACT_FORMAT_VERSION, - acceptedRenames: plan.acceptedRenames, - ...(existingCheckpoint._tag === "Some" && - existingCheckpoint.value.catalogSnapshot !== undefined - ? { catalogSnapshot: existingCheckpoint.value.catalogSnapshot } - : {}), - lastGenerateName: name, - lastGenerateHazards: { - kinds: plan.hazards.kinds, - destructive: plan.hazards.destructive, - rewrite: plan.hazards.rewrite, - coverageGaps: plan.hazards.coverageGaps, - }, - generatedMigrationVersions: [ - ...new Set([...previousGenerated, ...written.map((file) => file.version)]), - ], - destructiveMigrationVersions: plan.destructive - ? [...new Set([...previousDestructive, ...written.map((file) => file.version)])] - : previousDestructive, - }; - yield* state.writeCheckpoint(checkpoint); - const journal = yield* state.readJournal; - if (journal._tag === "Some") { - yield* state.writeJournal({ - ...journal.value, - generated: true, - declarativelyAhead: false, - }); - } + yield* state.clearJournal; return { status: "generated", diff --git a/apps/cli/src/shared/schema/pull-schema.integration.test.ts b/apps/cli/src/shared/schema/pull-schema.integration.test.ts index c276b9dcc6..ca42ef2f2d 100644 --- a/apps/cli/src/shared/schema/pull-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/pull-schema.integration.test.ts @@ -96,7 +96,7 @@ function setup(files = [{ name: "public.sql", sql: "create table public.t (id in } describe("pullSchema", () => { - it.live("writes declarations, manifest, and checkpoint into an empty tree", () => { + 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( @@ -106,7 +106,7 @@ describe("pullSchema", () => { 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( - true, + false, ); expect(existsSync(join(project.supabaseDir, "schemas", ".pgdelta-export.json"))).toBe(true); }); @@ -209,45 +209,4 @@ describe("pullSchema", () => { expect(existsSync(join(schemas, "public.sql"))).toBe(false); }); }); - - it.live("keeps generated migration versions when force-pulling", () => { - const { project, layer } = setup(); - const schemas = join(project.supabaseDir, "schemas"); - mkdirSync(schemas, { recursive: true }); - writeFileSync( - join(schemas, ".schema-checkpoint.json"), - `${JSON.stringify( - { - version: 1, - declarativeDigest: "old", - migrationHeadDigest: "old-head", - profile: "supabase", - scope: "database", - engineVersion: "0.3.0", - artifactFormatVersion: 1, - acceptedRenames: [], - lastGenerateName: "add_widgets", - generatedMigrationVersions: ["20260101000000"], - destructiveMigrationVersions: ["20260101000000"], - }, - null, - 2, - )}\n`, - ); - return Effect.gen(function* () { - yield* pullSchema({ from: "local", force: true, pruneUnmanaged: false }).pipe( - Effect.provide(layer), - ); - const checkpoint: unknown = JSON.parse( - readFileSync(join(schemas, ".schema-checkpoint.json"), "utf8"), - ); - expect(checkpoint).toEqual( - expect.objectContaining({ - generatedMigrationVersions: ["20260101000000"], - destructiveMigrationVersions: ["20260101000000"], - lastGenerateName: "add_widgets", - }), - ); - }); - }); }); diff --git a/apps/cli/src/shared/schema/pull-schema.ts b/apps/cli/src/shared/schema/pull-schema.ts index 0bdb1a75d3..7ca2548831 100644 --- a/apps/cli/src/shared/schema/pull-schema.ts +++ b/apps/cli/src/shared/schema/pull-schema.ts @@ -3,20 +3,13 @@ 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 { digestFileSet } from "./schema-digest.ts"; import { SchemaDraftConflictError, SchemaTargetRequiredError } from "./schema-errors.ts"; -import { - SCHEMA_ARTIFACT_FORMAT_VERSION, - SCHEMA_MANAGEMENT_SCOPE, - SCHEMA_PROFILE_ID, -} from "./schema-paths.ts"; import { SchemaStateStore } from "./schema-state.service.ts"; -import type { SchemaCheckpoint, SchemaCommandResult } from "./schema-types.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"; -import { digestVersions } from "./schema-digest.ts"; export type PullSchemaInput = { readonly from?: string; @@ -72,46 +65,6 @@ export const pullSchema = Effect.fn("schema.pull")(function* (input: PullSchemaI }); const localMigrations = yield* migrations.listLocal; - if (mode !== "output") { - const existingCheckpoint = yield* state.readCheckpoint; - const previousGenerated = - existingCheckpoint._tag === "Some" - ? (existingCheckpoint.value.generatedMigrationVersions ?? []) - : []; - const previousDestructive = - existingCheckpoint._tag === "Some" - ? (existingCheckpoint.value.destructiveMigrationVersions ?? []) - : []; - const checkpoint: SchemaCheckpoint = { - version: 1, - declarativeDigest: digestFileSet(exported.files), - migrationHeadDigest: digestVersions(localMigrations.map((file) => file.version)), - profile: SCHEMA_PROFILE_ID, - scope: SCHEMA_MANAGEMENT_SCOPE, - engineVersion: exported.engineVersion, - artifactFormatVersion: SCHEMA_ARTIFACT_FORMAT_VERSION, - acceptedRenames: [], - exportManifestIdentity: digestFileSet( - exported.manifest.files.map((name) => ({ name, sql: name })), - ), - catalogSnapshot: exported.snapshot, - ...(existingCheckpoint._tag === "Some" && - existingCheckpoint.value.lastGenerateName !== undefined - ? { lastGenerateName: existingCheckpoint.value.lastGenerateName } - : {}), - ...(existingCheckpoint._tag === "Some" && - existingCheckpoint.value.lastGenerateHazards !== undefined - ? { lastGenerateHazards: existingCheckpoint.value.lastGenerateHazards } - : {}), - ...(previousGenerated.length > 0 - ? { generatedMigrationVersions: previousGenerated } - : {}), - ...(previousDestructive.length > 0 - ? { destructiveMigrationVersions: previousDestructive } - : {}), - }; - yield* state.writeCheckpoint(checkpoint); - } const summary = installed.classification; const nextActions = diff --git a/apps/cli/src/shared/schema/schema-paths.ts b/apps/cli/src/shared/schema/schema-paths.ts index 02115fa828..dfdf3d6554 100644 --- a/apps/cli/src/shared/schema/schema-paths.ts +++ b/apps/cli/src/shared/schema/schema-paths.ts @@ -4,8 +4,5 @@ export const SCHEMA_CHECKPOINT_FILE_NAME = ".schema-checkpoint.json"; 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 SCHEMA_ARTIFACT_FORMAT_VERSION = 1; -export const SCHEMA_PROFILE_ID = "supabase"; -export const SCHEMA_MANAGEMENT_SCOPE = "database"; export const MIGRATION_NO_TRANSACTION_DIRECTIVE = "-- pg-delta: transaction=false"; export const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; diff --git a/apps/cli/src/shared/schema/schema-runtime.layer.ts b/apps/cli/src/shared/schema/schema-runtime.layer.ts index ef706bed00..8d983dd121 100644 --- a/apps/cli/src/shared/schema/schema-runtime.layer.ts +++ b/apps/cli/src/shared/schema/schema-runtime.layer.ts @@ -3,6 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { unixHttpClientLayer } from "@supabase/stack/effect"; import { projectLinkStateLayer } from "../../next/config/project-link-state.layer.ts"; +import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; import { projectLocalServiceVersionsLayer } from "../../next/config/project-local-service-versions.layer.ts"; import { provideProjectCommandRuntime, @@ -33,26 +34,31 @@ const workspaceFromProjectHome = Layer.unwrap( }), ); -const schemaEngineLive = pgDeltaSchemaEngineLayer.pipe( - Layer.provide(nativeIsolatedShadowLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(projectLocalServiceVersionsLayer), - Layer.provide(projectLinkStateLayer), -); - export type SchemaRuntimeOptions = ProjectCommandRuntimeOptions & { readonly localDatabaseFallback?: Layer.Layer< LocalDatabaseFallback, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >; + readonly projectLinkState?: Layer.Layer< + ProjectLinkState, + never, + FileSystem.FileSystem | Path.Path | ProjectHome + >; }; export const schemaRuntimeLayer = ( commandPath: ReadonlyArray, options?: SchemaRuntimeOptions, -) => - provideProjectCommandRuntime( +) => { + const linkState = options?.projectLinkState ?? projectLinkStateLayer; + const schemaEngineLive = pgDeltaSchemaEngineLayer.pipe( + Layer.provide(nativeIsolatedShadowLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(projectLocalServiceVersionsLayer), + Layer.provide(linkState), + ); + return provideProjectCommandRuntime( Layer.mergeAll( workspaceFromProjectHome, schemaStateLayer.pipe(Layer.provide(workspaceFromProjectHome)), @@ -61,13 +67,14 @@ export const schemaRuntimeLayer = ( schemaEngineLive, databaseTargetLayer.pipe( Layer.provide(options?.localDatabaseFallback ?? noLocalDatabaseFallbackLayer), - Layer.provide(projectLinkStateLayer), + Layer.provide(linkState), Layer.provide(unixHttpClientLayer), ), - projectLinkStateLayer, + linkState, projectLocalServiceVersionsLayer, unixHttpClientLayer, commandRuntimeLayer(commandPath), ), options, ); +}; diff --git a/docs/cli/schema-first-v1-plan.md b/docs/cli/schema-first-v1-plan.md index 38c2064b3f..b80a3ef6bc 100644 --- a/docs/cli/schema-first-v1-plan.md +++ b/docs/cli/schema-first-v1-plan.md @@ -12,7 +12,7 @@ It is the working spec for the `feat/implement-rfc-schema-first-development` bra 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 only. Destructive plans need `--allow-data-loss`. Durable targets also need `--project-ref` (or `--allow-remote` for raw URLs). Local disposable `schema apply` auto-approves modeled hazards. +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. The same verbs also stay on next (alpha). 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 native Postgres clusters** with a cached platform baseline — not co-located `CREATE DATABASE` shadows. `planSchemaFiles` always uses `isolatedShadow: true`. @@ -20,22 +20,22 @@ It is the working spec for the `feat/implement-rfc-schema-first-development` bra | # | Decision | | - | -------- | -| 1 | Checkpoint: `supabase/schemas/.schema-checkpoint.json` (tracked). Export ownership stays in `.pgdelta-export.json`. Draft journal: `.supabase/schema-draft.json` (gitignored). | -| 2 | After generate, the draft journal is marked `generated`. Local history is never silently inserted. A later `migrations apply` on a draft-ahead database fails closed (reset or explicit fingerprint-gated reconcile). | +| 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 when the live catalog already matches full file replay. Catalog match is schema-shape only — a pending DML-only file can be recorded without executing. Local `db reset` clears the journal. 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 migration-history operation (not pull). | -| 4 | Manual migration changes during an active draft fail closed with generate / reset / discard. No automatic rebase. | -| 5 | `migrations push` requires `--allow-data-loss` when the checkpoint records destructive hazards for pending generated files, or when pending files are not in the generated set (unclassified). Ambiguous rename / coverage gaps are generate-time failures. | +| 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. | +| 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 native Postgres (same binaries as `start`), snapshotted under `$SUPABASE_HOME/cache/native-shadow-baseline/`. Co-located shadows are not used on this path. | ## Shell and ownership ``` -apps/cli/src/shared/schema/ engine adapter, workspace, checkpoint, journal, use cases, runtime +apps/cli/src/shared/schema/ engine adapter, workspace, journal, use cases, runtime apps/cli/src/shared/migrations/ repository, history, runner, use cases -apps/cli/src/shared/database/ target resolution, pool, destructive auth +apps/cli/src/shared/database/ target resolution, pool, mutation auth apps/cli/src/legacy/commands/schema/ apps/cli/src/legacy/commands/migrations/ apps/cli/src/next/commands/schema/ same verbs on alpha @@ -53,15 +53,15 @@ apps/cli/src/next/commands/migration/ compatibility aliases only | Command | Source → action | Side effects | | ------- | --------------- | ------------ | -| `schema pull` | L/R → D | Declarative files, manifest, checkpoint (primary tree only) | +| `schema pull` | L/R → D | Declarative files, manifest (primary tree only) | | `schema generate --dry-run` | M → D | None | -| `schema generate` | M → D | Migration files + checkpoint | +| `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 + history | -| `migrations push` | pending files → R | Remote DB + history; fail closed on declarations-ahead or drift | +| `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` | | `migrations pull` | R − M → files | Migration files | ### Aliases (next only, deprecation notice on stderr) @@ -81,11 +81,11 @@ apps/cli/src/next/commands/migration/ compatibility aliases only ## Safety - Target identity comes from stack ownership or linked project-ref, never hostname heuristics. -- `--yes` never authorizes data loss, target-gate bypass, or stale plans. -- `--allow-data-loss` is required whenever `dataLossActions(plan)` is non-empty, or when push cannot classify pending files. -- Durable identity: interactive confirm-by-typing-ref; non-interactive `--project-ref` must match. +- `--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`. - Raw URL targets: `--allow-remote` instead of ref assertion. -- Local `schema apply`: behave as `--yes --allow-data-loss`. Ambiguous rename / coverage gap / unknown metadata still fail closed. +- 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 From 836b871756669ab35b794fe0036bec000ed15456 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 19 Aug 2026 17:23:38 +0200 Subject: [PATCH 3/6] refactor(cli): delete the leftover schema checkpoint store The sidecar is unused. Drop the type, workspace path, and journal-store read/write so existing .schema-checkpoint.json files stay inert. --- .../schema/apply/apply.integration.test.ts | 3 - .../apply-migrations.integration.test.ts | 2 - .../push-migrations.integration.test.ts | 3 - .../schema/apply-schema.integration.test.ts | 3 - .../generate-schema.integration.test.ts | 3 - apps/cli/src/shared/schema/schema-errors.ts | 2 +- apps/cli/src/shared/schema/schema-paths.ts | 1 - .../src/shared/schema/schema-state.layer.ts | 60 ++++--------------- .../src/shared/schema/schema-state.service.ts | 16 ++--- apps/cli/src/shared/schema/schema-types.ts | 22 ------- .../shared/schema/schema-workspace.layer.ts | 3 - .../shared/schema/schema-workspace.service.ts | 1 - 12 files changed, 18 insertions(+), 101 deletions(-) diff --git a/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts index 5a84c6d49d..4f498f9ceb 100644 --- a/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts +++ b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts @@ -38,7 +38,6 @@ function setup(disposable: boolean) { migrationsDir: "/tmp/migrations", migrationsDirDisplay: "supabase/migrations", customDir: "/tmp/schemas/_custom", - checkpointPath: "/tmp/c", journalPath: "/tmp/j", lockPath: "/tmp/l", readDeclarationFiles: Effect.succeed([]), @@ -50,8 +49,6 @@ function setup(disposable: boolean) { Layer.succeed( SchemaStateStore, SchemaStateStore.of({ - readCheckpoint: Effect.succeed(Option.none()), - writeCheckpoint: () => Effect.void, readJournal: Effect.succeed(Option.none()), writeJournal: () => Effect.void, clearJournal: Effect.void, diff --git a/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts index c5d3540f7f..50339a36fa 100644 --- a/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts @@ -105,8 +105,6 @@ function setup( Layer.succeed( SchemaStateStore, SchemaStateStore.of({ - readCheckpoint: Effect.succeed(Option.none()), - writeCheckpoint: () => Effect.void, readJournal: Effect.succeed(journal === undefined ? Option.none() : Option.some(journal)), writeJournal: () => Effect.void, clearJournal: Effect.void, diff --git a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts index c3ad1ddef9..45228b2d67 100644 --- a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -102,7 +102,6 @@ function setup( migrationsDir: "/tmp/migrations", migrationsDirDisplay: "supabase/migrations", customDir: "/tmp/schemas/_custom", - checkpointPath: "/tmp/schemas/.schema-checkpoint.json", journalPath: "/tmp/.supabase/schema-draft.json", lockPath: "/tmp/.supabase/schema.lock", readDeclarationFiles: Effect.succeed( @@ -116,8 +115,6 @@ function setup( Layer.succeed( SchemaStateStore, SchemaStateStore.of({ - readCheckpoint: Effect.succeed(Option.none()), - writeCheckpoint: () => Effect.void, readJournal: Effect.succeed( opts.journal === undefined ? Option.none() : Option.some(opts.journal), ), diff --git a/apps/cli/src/shared/schema/apply-schema.integration.test.ts b/apps/cli/src/shared/schema/apply-schema.integration.test.ts index 222d3bda31..eb947f79b8 100644 --- a/apps/cli/src/shared/schema/apply-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/apply-schema.integration.test.ts @@ -126,7 +126,6 @@ function setup( migrationsDir: "/tmp/migrations", migrationsDirDisplay: "supabase/migrations", customDir: "/tmp/schemas/_custom", - checkpointPath: "/tmp/c", journalPath: "/tmp/j", lockPath: "/tmp/l", readDeclarationFiles: Effect.succeed([]), @@ -138,8 +137,6 @@ function setup( Layer.succeed( SchemaStateStore, SchemaStateStore.of({ - readCheckpoint: Effect.succeed(Option.none()), - writeCheckpoint: () => Effect.void, readJournal: Effect.succeed( opts.journal === undefined ? Option.none() : Option.some(opts.journal), ), diff --git a/apps/cli/src/shared/schema/generate-schema.integration.test.ts b/apps/cli/src/shared/schema/generate-schema.integration.test.ts index dbe2bfffba..f1ed098086 100644 --- a/apps/cli/src/shared/schema/generate-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/generate-schema.integration.test.ts @@ -94,7 +94,6 @@ function setup(opts: { changes?: boolean; journal?: SchemaDraftJournal; write?: migrationsDir: "/tmp/migrations", migrationsDirDisplay: "supabase/migrations", customDir: "/tmp/schemas/_custom", - checkpointPath: "/tmp/c", journalPath: "/tmp/j", lockPath: "/tmp/l", readDeclarationFiles: Effect.succeed([{ name: "a.sql", sql: "create table a (id int);" }]), @@ -106,8 +105,6 @@ function setup(opts: { changes?: boolean; journal?: SchemaDraftJournal; write?: Layer.succeed( SchemaStateStore, SchemaStateStore.of({ - readCheckpoint: Effect.succeed(Option.none()), - writeCheckpoint: () => Effect.void, readJournal: Effect.succeed( opts.journal === undefined ? Option.none() : Option.some(opts.journal), ), diff --git a/apps/cli/src/shared/schema/schema-errors.ts b/apps/cli/src/shared/schema/schema-errors.ts index 38ca66a8ea..27b96eea6e 100644 --- a/apps/cli/src/shared/schema/schema-errors.ts +++ b/apps/cli/src/shared/schema/schema-errors.ts @@ -47,7 +47,7 @@ export class SchemaLockError extends SchemaCliError("SchemaLockError") { } } -export class SchemaCheckpointError extends SchemaCliError("SchemaCheckpointError") { +export class SchemaStateError extends SchemaCliError("SchemaStateError") { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } diff --git a/apps/cli/src/shared/schema/schema-paths.ts b/apps/cli/src/shared/schema/schema-paths.ts index dfdf3d6554..359827024a 100644 --- a/apps/cli/src/shared/schema/schema-paths.ts +++ b/apps/cli/src/shared/schema/schema-paths.ts @@ -1,6 +1,5 @@ export const SCHEMA_DIRECTORY_NAME = "schemas"; export const SCHEMA_CUSTOM_DIRECTORY_NAME = "_custom"; -export const SCHEMA_CHECKPOINT_FILE_NAME = ".schema-checkpoint.json"; export const SCHEMA_DRAFT_JOURNAL_FILE_NAME = "schema-draft.json"; export const SCHEMA_LOCK_FILE_NAME = "schema.lock"; export const MIGRATIONS_DIRECTORY_NAME = "migrations"; diff --git a/apps/cli/src/shared/schema/schema-state.layer.ts b/apps/cli/src/shared/schema/schema-state.layer.ts index 35e41d84dd..82691279bc 100644 --- a/apps/cli/src/shared/schema/schema-state.layer.ts +++ b/apps/cli/src/shared/schema/schema-state.layer.ts @@ -1,35 +1,9 @@ import { Clock, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; -import { SchemaCheckpointError, SchemaLockError } from "./schema-errors.ts"; +import { SchemaLockError, SchemaStateError } from "./schema-errors.ts"; import { SchemaStateStore } from "./schema-state.service.ts"; -import type { SchemaCheckpoint, SchemaDraftJournal } from "./schema-types.ts"; +import type { SchemaDraftJournal } from "./schema-types.ts"; import { SchemaWorkspace } from "./schema-workspace.service.ts"; -const CheckpointSchema = Schema.Struct({ - version: Schema.Literal(1), - declarativeDigest: Schema.String, - migrationHeadDigest: Schema.String, - sourceFingerprint: Schema.optionalKey(Schema.String), - desiredFingerprint: Schema.optionalKey(Schema.String), - profile: Schema.String, - scope: Schema.Literal("database"), - engineVersion: Schema.String, - artifactFormatVersion: Schema.Number, - acceptedRenames: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })), - exportManifestIdentity: Schema.optionalKey(Schema.String), - catalogSnapshot: Schema.optionalKey(Schema.String), - lastGenerateName: Schema.optionalKey(Schema.String), - lastGenerateHazards: Schema.optionalKey( - Schema.Struct({ - kinds: Schema.Array(Schema.String), - destructive: Schema.Number, - rewrite: Schema.Number, - coverageGaps: Schema.Number, - }), - ), - generatedMigrationVersions: Schema.optionalKey(Schema.Array(Schema.String)), - destructiveMigrationVersions: Schema.optionalKey(Schema.Array(Schema.String)), -}); - const JournalSchema = Schema.Struct({ version: Schema.Literal(1), draftId: Schema.String, @@ -60,10 +34,10 @@ const JournalSchema = Schema.Struct({ const STALE_LOCK_MS = 10 * 60 * 1000; -const checkpointError = (detail: string) => - new SchemaCheckpointError({ +const stateError = (detail: string) => + new SchemaStateError({ detail, - suggestion: "Fix or delete the schema checkpoint and rerun the command.", + suggestion: "Fix or delete `.supabase/schema-draft.json` and rerun the command.", }); export const schemaStateLayer = Layer.effect( @@ -76,26 +50,24 @@ export const schemaStateLayer = Layer.effect( const readDecoded = ( filePath: string, decode: (value: unknown) => A, - ): Effect.Effect, SchemaCheckpointError> => + ): 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) => - checkpointError(`Failed to read ${filePath}: ${error.message}`), - ), + Effect.mapError((error) => stateError(`Failed to read ${filePath}: ${error.message}`)), ); const parsed = yield* Effect.try({ try: (): unknown => JSON.parse(raw), - catch: () => checkpointError(`Malformed ${path.basename(filePath)}.`), + catch: () => stateError(`Malformed ${path.basename(filePath)}.`), }); return Option.some( yield* Effect.try({ try: () => decode(parsed), catch: (error) => - checkpointError( + stateError( `Malformed ${path.basename(filePath)}: ${error instanceof Error ? error.message : String(error)}`, ), }), @@ -108,25 +80,17 @@ export const schemaStateLayer = Layer.effect( .makeDirectory(path.dirname(filePath), { recursive: true }) .pipe( Effect.mapError((error) => - checkpointError(`Failed to create ${path.dirname(filePath)}: ${error.message}`), + stateError(`Failed to create ${path.dirname(filePath)}: ${error.message}`), ), ); yield* fs .writeFileString(filePath, `${JSON.stringify(value, null, 2)}\n`) .pipe( - Effect.mapError((error) => - checkpointError(`Failed to write ${filePath}: ${error.message}`), - ), + Effect.mapError((error) => stateError(`Failed to write ${filePath}: ${error.message}`)), ); }); return SchemaStateStore.of({ - readCheckpoint: readDecoded( - workspace.checkpointPath, - Schema.decodeUnknownSync(CheckpointSchema), - ), - writeCheckpoint: (checkpoint: SchemaCheckpoint) => - writeJson(workspace.checkpointPath, checkpoint), readJournal: readDecoded(workspace.journalPath, Schema.decodeUnknownSync(JournalSchema)), writeJournal: (journal: SchemaDraftJournal) => writeJson(workspace.journalPath, journal), clearJournal: Effect.gen(function* () { @@ -136,7 +100,7 @@ export const schemaStateLayer = Layer.effect( Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.void - : Effect.fail(checkpointError(error.message)), + : Effect.fail(stateError(error.message)), ), ); }), diff --git a/apps/cli/src/shared/schema/schema-state.service.ts b/apps/cli/src/shared/schema/schema-state.service.ts index 9c1abd977f..c77f164ee4 100644 --- a/apps/cli/src/shared/schema/schema-state.service.ts +++ b/apps/cli/src/shared/schema/schema-state.service.ts @@ -1,18 +1,12 @@ import type { Effect, Option } from "effect"; import { Context } from "effect"; -import type { SchemaCheckpoint, SchemaDraftJournal } from "./schema-types.ts"; -import type { SchemaCheckpointError, SchemaLockError } from "./schema-errors.ts"; +import type { SchemaDraftJournal } from "./schema-types.ts"; +import type { SchemaLockError, SchemaStateError } from "./schema-errors.ts"; interface SchemaStateStoreShape { - readonly readCheckpoint: Effect.Effect, SchemaCheckpointError>; - readonly writeCheckpoint: ( - checkpoint: SchemaCheckpoint, - ) => Effect.Effect; - readonly readJournal: Effect.Effect, SchemaCheckpointError>; - readonly writeJournal: ( - journal: SchemaDraftJournal, - ) => Effect.Effect; - readonly clearJournal: Effect.Effect; + readonly readJournal: Effect.Effect, SchemaStateError>; + readonly writeJournal: (journal: SchemaDraftJournal) => Effect.Effect; + readonly clearJournal: Effect.Effect; readonly withLock: ( effect: Effect.Effect, ) => Effect.Effect; diff --git a/apps/cli/src/shared/schema/schema-types.ts b/apps/cli/src/shared/schema/schema-types.ts index e6367cc324..2eb1cf316d 100644 --- a/apps/cli/src/shared/schema/schema-types.ts +++ b/apps/cli/src/shared/schema/schema-types.ts @@ -61,28 +61,6 @@ export type SchemaFileSummary = Pick< "created" | "updated" | "unchanged" | "removed" | "unmanaged" >; -export type SchemaCheckpoint = { - readonly version: 1; - readonly declarativeDigest: string; - readonly migrationHeadDigest: string; - readonly sourceFingerprint?: string; - readonly desiredFingerprint?: string; - readonly profile: string; - readonly scope: "database"; - readonly engineVersion: string; - readonly artifactFormatVersion: number; - readonly acceptedRenames: ReadonlyArray<{ readonly from: string; readonly to: string }>; - readonly exportManifestIdentity?: string; - readonly catalogSnapshot?: string; - readonly lastGenerateName?: string; - readonly lastGenerateHazards?: Pick< - SchemaHazardSummary, - "kinds" | "destructive" | "rewrite" | "coverageGaps" - >; - readonly generatedMigrationVersions?: ReadonlyArray; - readonly destructiveMigrationVersions?: ReadonlyArray; -}; - type SchemaJournaledPlan = { readonly planId: string; readonly targetFingerprint: string; diff --git a/apps/cli/src/shared/schema/schema-workspace.layer.ts b/apps/cli/src/shared/schema/schema-workspace.layer.ts index 4be660ce32..6653862831 100644 --- a/apps/cli/src/shared/schema/schema-workspace.layer.ts +++ b/apps/cli/src/shared/schema/schema-workspace.layer.ts @@ -6,7 +6,6 @@ import { type SqlFileClassification, } from "@supabase/pg-delta/frontends"; import { - SCHEMA_CHECKPOINT_FILE_NAME, SCHEMA_CUSTOM_DIRECTORY_NAME, SCHEMA_DIRECTORY_NAME, SCHEMA_DRAFT_JOURNAL_FILE_NAME, @@ -112,7 +111,6 @@ export const schemaWorkspaceLayer = (paths: SchemaWorkspacePaths) => 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 checkpointPath = path.join(schemasDir, SCHEMA_CHECKPOINT_FILE_NAME); const journalPath = path.join(paths.projectHomeDir, SCHEMA_DRAFT_JOURNAL_FILE_NAME); const lockPath = path.join(paths.projectHomeDir, SCHEMA_LOCK_FILE_NAME); @@ -283,7 +281,6 @@ export const schemaWorkspaceLayer = (paths: SchemaWorkspacePaths) => migrationsDir, migrationsDirDisplay: path.join("supabase", MIGRATIONS_DIRECTORY_NAME), customDir, - checkpointPath, journalPath, lockPath, readDeclarationFiles: Effect.gen(function* () { diff --git a/apps/cli/src/shared/schema/schema-workspace.service.ts b/apps/cli/src/shared/schema/schema-workspace.service.ts index ea6f70b663..26448c0c0e 100644 --- a/apps/cli/src/shared/schema/schema-workspace.service.ts +++ b/apps/cli/src/shared/schema/schema-workspace.service.ts @@ -32,7 +32,6 @@ interface SchemaWorkspaceShape { readonly migrationsDir: string; readonly migrationsDirDisplay: string; readonly customDir: string; - readonly checkpointPath: string; readonly journalPath: string; readonly lockPath: string; readonly readDeclarationFiles: Effect.Effect< From fb075606b2249a6a9ad7ada3d82b973fb1f11f17 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 19 Aug 2026 19:16:33 +0200 Subject: [PATCH 4/6] feat(cli): guide schema-first history with migration repair Push, pull, and baseline generate now print copy-pasteable `migration repair` commands instead of looping on pull-and-reconcile. Env URLs are unverifiable `--allow-remote` targets; without them, stable connect uses the TypeScript linked resolver. --- .../legacy-linked-remote-connector.layer.ts | 52 ++++++ .../schema/legacy-schema-runtime.layer.ts | 18 ++- .../db-bootstrap/reset-local-database.ts | 4 +- .../src/shared/database/database-target.ts | 11 ++ .../database/database-target.unit.test.ts | 20 +++ .../destructive-auth.integration.test.ts | 27 ++++ .../src/shared/database/destructive-auth.ts | 13 +- .../linked-remote-connector.service.ts | 21 +++ .../shared/migrations/apply-local-pending.ts | 42 +++-- .../apply-migrations.integration.test.ts | 88 ++++++++-- .../src/shared/migrations/apply-migrations.ts | 15 +- ...atching-pending-prefix.integration.test.ts | 138 ++++++++++++++++ .../migrations/matching-pending-prefix.ts | 41 +++++ .../migrations/migration-repair-suggest.ts | 90 +++++++++++ .../migration-repair-suggest.unit.test.ts | 144 +++++++++++++++++ .../migrations/migration-runner.layer.ts | 13 +- .../pull-migrations.integration.test.ts | 152 ++++++++++++++++++ .../src/shared/migrations/pull-migrations.ts | 14 +- .../push-migrations.integration.test.ts | 102 +++++++++++- .../src/shared/migrations/push-migrations.ts | 52 +++++- .../schema/apply-schema.integration.test.ts | 1 + apps/cli/src/shared/schema/apply-schema.ts | 28 +++- .../src/shared/schema/clear-draft-journal.ts | 11 +- .../database-target.layer.integration.test.ts | 149 +++++++++++++++++ .../shared/schema/database-target.layer.ts | 34 ++-- .../generate-schema.integration.test.ts | 13 ++ apps/cli/src/shared/schema/generate-schema.ts | 23 ++- .../src/shared/schema/schema-runtime.layer.ts | 8 + docs/cli/schema-first-v1-plan.md | 10 +- 29 files changed, 1253 insertions(+), 81 deletions(-) create mode 100644 apps/cli/src/legacy/schema/legacy-linked-remote-connector.layer.ts create mode 100644 apps/cli/src/shared/database/database-target.unit.test.ts create mode 100644 apps/cli/src/shared/database/linked-remote-connector.service.ts create mode 100644 apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts create mode 100644 apps/cli/src/shared/migrations/matching-pending-prefix.ts create mode 100644 apps/cli/src/shared/migrations/migration-repair-suggest.ts create mode 100644 apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts create mode 100644 apps/cli/src/shared/migrations/pull-migrations.integration.test.ts create mode 100644 apps/cli/src/shared/schema/database-target.layer.integration.test.ts 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-schema-runtime.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts index 7b13eb9d29..0b43c258b6 100644 --- a/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts +++ b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts @@ -4,13 +4,24 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyNextCliConfigLayer } from "../config/legacy-next-cli-config.layer.ts"; -import { legacyDockerLocalDatabaseFallbackLayer } from "./legacy-docker-local-database.layer.ts"; -import { legacySchemaProjectLinkStateLayer } from "./legacy-schema-project-link-state.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 { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; +import { legacyIdentityStitchLayer } from "../shared/legacy-identity-stitch.ts"; +import { legacyDockerLocalDatabaseFallbackLayer } from "./legacy-docker-local-database.layer.ts"; +import { legacyLinkedRemoteConnectorLayer } from "./legacy-linked-remote-connector.layer.ts"; +import { legacySchemaProjectLinkStateLayer } from "./legacy-schema-project-link-state.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), +); + export const legacySchemaRuntimeLayer = (commandPath: ReadonlyArray) => Layer.unwrap( Effect.gen(function* () { @@ -42,6 +53,9 @@ export const legacySchemaRuntimeLayer = (commandPath: ReadonlyArray) => projectLinkState: legacySchemaProjectLinkStateLayer.pipe( Layer.provide(Layer.succeed(LegacyCliConfig, config)), ), + linkedRemoteConnector: legacyLinkedRemoteConnectorLayer.pipe( + Layer.provide(legacySchemaDbConfig), + ), }); }), ).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 5227d4ce2a..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 @@ -210,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). @@ -253,8 +255,6 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( ); } - yield* clearDraftJournalFile(fs, path, workdir); - // "Finished supabase db reset on branch ." (both Aqua). const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); yield* output.raw( diff --git a/apps/cli/src/shared/database/database-target.ts b/apps/cli/src/shared/database/database-target.ts index 226f066625..33162c141f 100644 --- a/apps/cli/src/shared/database/database-target.ts +++ b/apps/cli/src/shared/database/database-target.ts @@ -13,8 +13,19 @@ export type DatabaseTarget = { 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" }; 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 index 40b4dda03a..02b8595e81 100644 --- a/apps/cli/src/shared/database/destructive-auth.integration.test.ts +++ b/apps/cli/src/shared/database/destructive-auth.integration.test.ts @@ -109,6 +109,33 @@ describe("authorizeMutation", () => { 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 index 8ed830f655..1d58300e0a 100644 --- a/apps/cli/src/shared/database/destructive-auth.ts +++ b/apps/cli/src/shared/database/destructive-auth.ts @@ -5,7 +5,7 @@ import { SchemaDestructiveAuthError, SchemaProjectRefMismatchError, } from "../schema/schema-errors.ts"; -import type { DatabaseTarget } from "./database-target.ts"; +import { envDatabaseUrlVarName, type DatabaseTarget } from "./database-target.ts"; import { Output } from "../output/output.service.ts"; export type MutationAuthFlags = { @@ -27,9 +27,16 @@ export const authorizeMutation = Effect.fnUntraced(function* (input: { if (target.kind === "url") { if (!flags.allowRemote) { + const envVar = target.connectionSource === "env" ? envDatabaseUrlVarName() : undefined; return yield* new SchemaAllowRemoteRequiredError({ - detail: "This connection string cannot be identity-verified.", - suggestion: `Re-run ${command} with --allow-remote to acknowledge the unverifiable target.`, + 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; 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..f9fe473702 --- /dev/null +++ b/apps/cli/src/shared/database/linked-remote-connector.service.ts @@ -0,0 +1,21 @@ +import type { Effect } from "effect"; +import { Context, Layer } from "effect"; +import { 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") {} + +export const failClosedLinkedRemoteConnectorLayer = Layer.succeed(LinkedRemoteConnector, { + connect: (projectRef) => + new SchemaLinkedConnectionError({ + detail: `Linked project ${projectRef} has no connection string in this environment.`, + suggestion: + "Set DATABASE_URL / SUPABASE_DB_URL and pass --allow-remote, or use the stable CLI to connect via the linked project.", + }), +}); diff --git a/apps/cli/src/shared/migrations/apply-local-pending.ts b/apps/cli/src/shared/migrations/apply-local-pending.ts index b36583fb2d..d432b5dca6 100644 --- a/apps/cli/src/shared/migrations/apply-local-pending.ts +++ b/apps/cli/src/shared/migrations/apply-local-pending.ts @@ -1,7 +1,10 @@ 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"; @@ -17,26 +20,47 @@ export const applyLocalPending = Effect.fn("migrations.applyLocalPending")(funct 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); - yield* runner.applyPending(shadowPool, local); - const drift = yield* engine.diffPools({ - sourcePool: shadowPool, - desiredPool: pool, - allowDrops: true, - }); - if (!drift.changes) { + 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: local.filter((file) => present.has(file.version)).map((file) => file.version), + skipped: already.map((file) => file.version), } satisfies MigrationApplyResult; } - return yield* runner.applyPending(pool, local); + 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 index 50339a36fa..49372972a0 100644 --- a/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts @@ -69,16 +69,35 @@ function planView(changes: boolean): SchemaPlanView { }; } +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; @@ -114,16 +133,18 @@ function setup( Layer.succeed( MigrationRepository, MigrationRepository.of({ - listLocal: Effect.succeed([ - { - version: "20260101000000", - name: "init", - fileName: "20260101000000_init.sql", - absolutePath: "/tmp/migrations/20260101000000_init.sql", - content: "select 1;", - transactional: true, - }, - ]), + 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"), @@ -149,7 +170,14 @@ function setup( PgDeltaSchemaEngine.of({ exportSchema: () => Effect.die("unused"), planFiles: () => Effect.die("unused"), - diffPools: () => Effect.succeed(planView(opts.catalogMatch !== true)), + 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", @@ -191,6 +219,44 @@ describe("applyMigrations", () => { }); }); + 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" }], diff --git a/apps/cli/src/shared/migrations/apply-migrations.ts b/apps/cli/src/shared/migrations/apply-migrations.ts index 2ff8d84c25..fd522ef6e1 100644 --- a/apps/cli/src/shared/migrations/apply-migrations.ts +++ b/apps/cli/src/shared/migrations/apply-migrations.ts @@ -32,14 +32,17 @@ export const applyMigrations = Effect.fn("migrations.apply")(function* () { 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: - recorded.length > 0 - ? `Recorded ${recorded.length} already-applied migration(s): ${recorded.join(", ")}` - : result.applied.length === 0 - ? "No pending migrations." - : `Applied ${result.applied.length} migration(s): ${result.applied.join(", ")}`, + message: parts.length > 0 ? parts.join(". ") : "No pending migrations.", data: { status: "clean", applied: result.applied, 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..4362d0798d --- /dev/null +++ b/apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts @@ -0,0 +1,138 @@ +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 { PgDeltaSchemaEngine } from "../schema/pg-delta-engine.service.ts"; +import type { SchemaPlanView } from "../schema/schema-types.ts"; +import { findMatchingPendingPrefix } from "./matching-pending-prefix.ts"; +import { migrationRunnerLayer } from "./migration-runner.layer.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(seed: ReadonlyArray, opts: { readonly failSql?: string } = {}): Pool { + const versions = [...seed]; + return { + query: async (sql: string, params?: ReadonlyArray) => { + if (opts.failSql !== undefined && sql === opts.failSql) { + throw new Error("relation does not exist"); + } + if (sql.includes("INSERT INTO") && params !== undefined) { + versions.push(String(params[0])); + return { rows: [] }; + } + if (sql.includes("SELECT version")) { + return { rows: versions.map((version) => ({ version, name: "" })) }; + } + return { rows: [] }; + }, + } as Pool; +} + +describe("findMatchingPendingPrefix", () => { + it.live("applies a later pending file without treating known history as remote-only", () => { + 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"), + }), + ); + return Effect.gen(function* () { + const prefix = yield* findMatchingPendingPrefix( + historyPool([first.version]), + historyPool([]), + [first], + [second], + ); + expect(prefix.map((file) => file.version)).toEqual([second.version]); + }).pipe(Effect.provide(Layer.mergeAll(migrationRunnerLayer, engine))); + }); + + it.live("stops the prefix scan when a later file cannot replay", () => { + 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"), + }), + ); + return 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(migrationRunnerLayer, 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-repair-suggest.ts b/apps/cli/src/shared/migrations/migration-repair-suggest.ts new file mode 100644 index 0000000000..a6d3f182ed --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-repair-suggest.ts @@ -0,0 +1,90 @@ +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 }; + } + return opts.projectRef !== undefined ? { projectRef: opts.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 { + lines.push("supabase migrations pull"); + } + 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..4f549df348 --- /dev/null +++ b/apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts @@ -0,0 +1,144 @@ +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("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", + ); + }); + + 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-runner.layer.ts b/apps/cli/src/shared/migrations/migration-runner.layer.ts index 56c383b0d7..ead4d26d05 100644 --- a/apps/cli/src/shared/migrations/migration-runner.layer.ts +++ b/apps/cli/src/shared/migrations/migration-runner.layer.ts @@ -2,6 +2,7 @@ import { Effect, Layer } from "effect"; import type { Pool } from "pg"; import { SchemaEngineError, SchemaHistoryConflictError } from "../schema/schema-errors.ts"; import type { MigrationFile } from "./migration-file.ts"; +import { formatHistoryConflict } from "./migration-repair-suggest.ts"; import { MigrationRunner, type MigrationApplyResult, @@ -94,12 +95,12 @@ export const migrationRunnerLayer = Layer.succeed( (row) => !local.some((file) => file.version === row.version), ); if (remoteOnly.length > 0 && pending.length > 0) { - return yield* new SchemaHistoryConflictError({ - detail: `Local and remote migration histories have diverged (remote-only: ${remoteOnly - .map((row) => row.version) - .join(", ")}; pending: ${pending.map((file) => file.version).join(", ")}).`, - suggestion: "Run `supabase migrations pull` or repair history before applying.", - }); + return yield* new SchemaHistoryConflictError( + formatHistoryConflict({ + remoteOnly: remoteOnly.map((row) => row.version), + pending: pending.map((file) => file.version), + }), + ); } const applied: Array = []; for (const file of pending) { 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..a399d60433 --- /dev/null +++ b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts @@ -0,0 +1,152 @@ +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"; +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: 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( + MigrationRunner, + MigrationRunner.of({ + listRemote: () => Effect.succeed([]), + applyPending: () => Effect.succeed({ applied: [], skipped: [] }), + markApplied: () => 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", + }), + }), + ), + ), + }; +} + +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 --status applied 20260819120000", + ); + }); + }); +}); diff --git a/apps/cli/src/shared/migrations/pull-migrations.ts b/apps/cli/src/shared/migrations/pull-migrations.ts index 6cd21d13c9..79303c4bec 100644 --- a/apps/cli/src/shared/migrations/pull-migrations.ts +++ b/apps/cli/src/shared/migrations/pull-migrations.ts @@ -5,6 +5,7 @@ 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"; import { MigrationRunner } from "./migration-runner.service.ts"; @@ -68,6 +69,15 @@ export const pullMigrations = Effect.fn("migrations.pull")(function* (input: Pul 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(", ")}`, @@ -78,9 +88,9 @@ export const pullMigrations = Effect.fn("migrations.pull")(function* (input: Pul hazards: plan.hazards, mutated_files: true, mutated_database: false, - next_actions: ["Review the pulled migration, then apply it locally if needed."], + next_actions: nextActions, }, - nextActions: ["Review the pulled migration, then apply it locally if needed."], + 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 index 45228b2d67..4383e8fecd 100644 --- a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -2,6 +2,7 @@ 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"; @@ -81,17 +82,31 @@ const ungeneratedJournal: SchemaDraftJournal = { 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( @@ -135,14 +150,14 @@ function setup( }), ); } - return Effect.succeed(linked); + return Effect.succeed(opts.target ?? linked); }, }), ), Layer.succeed( MigrationRepository, MigrationRepository.of({ - listLocal: Effect.succeed([]), + listLocal: Effect.succeed(opts.files ?? []), createEmpty: () => Effect.die("unused"), writeGenerated: () => Effect.die("unused"), remove: () => Effect.die("unused"), @@ -151,7 +166,7 @@ function setup( Layer.succeed( MigrationRunner, MigrationRunner.of({ - listRemote: () => Effect.succeed([]), + listRemote: () => Effect.succeed(opts.history ?? []), applyPending: () => Effect.succeed({ applied: [], skipped: [] }), markApplied: () => Effect.die("unused"), }), @@ -161,7 +176,14 @@ function setup( PgDeltaSchemaEngine.of({ exportSchema: () => Effect.die("unused"), planFiles: () => Effect.succeed(planView(opts.ahead === true)), - diffPools: () => Effect.succeed(planView(opts.drift === 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; @@ -211,10 +233,43 @@ describe("pushMigrations", () => { 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"); 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"); + }); + }); + it.live("pushes when replay matches declarations and the remote", () => { const ctx = setup({ declarations: true, @@ -229,6 +284,45 @@ describe("pushMigrations", () => { }); }); + 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, diff --git a/apps/cli/src/shared/migrations/push-migrations.ts b/apps/cli/src/shared/migrations/push-migrations.ts index ee051b5d9b..f5155681a7 100644 --- a/apps/cli/src/shared/migrations/push-migrations.ts +++ b/apps/cli/src/shared/migrations/push-migrations.ts @@ -3,10 +3,20 @@ 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, SchemaRemoteDriftError } from "../schema/schema-errors.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"; @@ -64,9 +74,9 @@ export const pushMigrations = Effect.fn("migrations.push")(function* (input: Pus }); if (ahead.changes) { return yield* new SchemaDeclarationsAheadError({ - detail: "Declarations are ahead of the local migration files.", + detail: "Declarations and local migration files have diverged.", suggestion: - "Run `supabase schema generate --name ` before `supabase migrations push`.", + "Update `supabase/schemas` to include hand-written migration changes, or run `supabase schema generate --name ` if declarations are the intended state.", }); } } @@ -81,13 +91,47 @@ export const pushMigrations = Effect.fn("migrations.push")(function* (input: Pus 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: "Run `supabase migrations pull` and reconcile before pushing.", + 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", diff --git a/apps/cli/src/shared/schema/apply-schema.integration.test.ts b/apps/cli/src/shared/schema/apply-schema.integration.test.ts index eb947f79b8..c0d865ad4c 100644 --- a/apps/cli/src/shared/schema/apply-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/apply-schema.integration.test.ts @@ -215,6 +215,7 @@ describe("applySchema", () => { 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); }); }); diff --git a/apps/cli/src/shared/schema/apply-schema.ts b/apps/cli/src/shared/schema/apply-schema.ts index 462e5cdcc7..e38776c12c 100644 --- a/apps/cli/src/shared/schema/apply-schema.ts +++ b/apps/cli/src/shared/schema/apply-schema.ts @@ -4,6 +4,7 @@ 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, @@ -51,6 +52,10 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche 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) { @@ -60,8 +65,6 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche "Run `supabase schema generate`, reset the local database, or discard the draft.", }); } - } else { - yield* applyLocalPending(pool, localMigrations); } const shadow = yield* engine.provisionShadow; @@ -85,18 +88,33 @@ export const applySchema = Effect.fn("schema.apply")(function* (input: ApplySche }); 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: "Local database already matches declarations.", + message: + parts.length > 0 ? parts.join(". ") : "Local database already matches declarations.", data: { status: "clean", target: target.identity, plan_id: plan.planId, - mutated_database: false, + applied: pendingResult.applied, + recorded, + mutated_database: mutatedDatabase, mutated_files: false, }, nextActions: [], - mutatedDatabase: false, + mutatedDatabase, 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 index 7ff384720c..f23fc82585 100644 --- a/apps/cli/src/shared/schema/clear-draft-journal.ts +++ b/apps/cli/src/shared/schema/clear-draft-journal.ts @@ -1,9 +1,16 @@ import { Effect, FileSystem, Path } from "effect"; import { SCHEMA_DRAFT_JOURNAL_FILE_NAME } from "./schema-paths.ts"; -/** Best-effort unlink of `.supabase/schema-draft.json`. Missing file is success. */ +/** 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.ignore); +) => + 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/database-target.layer.integration.test.ts b/apps/cli/src/shared/schema/database-target.layer.integration.test.ts new file mode 100644 index 0000000000..c8ac8a1069 --- /dev/null +++ b/apps/cli/src/shared/schema/database-target.layer.integration.test.ts @@ -0,0 +1,149 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { ProjectHome } from "../../next/config/project-home.service.ts"; +import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; +import { LinkedRemoteConnector } from "../database/linked-remote-connector.service.ts"; +import { noLocalDatabaseFallbackLayer } from "../database/local-database-fallback.service.ts"; +import { DatabaseTargetResolver } from "../database/database-target.service.ts"; +import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; +import { databaseTargetLayer } from "./database-target.layer.ts"; +import { SchemaLinkedConnectionError } from "./schema-errors.ts"; + +const linkedState = { + project: { + ref: "abcdefghijklmnop", + name: "demo", + organization_id: "org", + organization_slug: "org", + }, + active_branch: { ref: "abcdefghijklmnop", name: "main", is_default: true }, + fetchedAt: "2026-01-01T00:00:00.000Z", + versions: {}, +}; + +function setup(opts: { readonly connectorUrl?: string; readonly linked?: boolean } = {}) { + const deps = Layer.mergeAll( + BunServices.layer, + noLocalDatabaseFallbackLayer, + Layer.succeed( + ProjectHome, + ProjectHome.of({ + projectRoot: "/tmp/project", + supabaseDir: "/tmp/project/supabase", + projectHomeDir: "/tmp/project/.supabase", + projectLinkPath: "/tmp/project/.supabase/project.json", + projectLocalVersionsPath: "/tmp/project/.supabase/local-versions.json", + ensureProjectHomeDir: Effect.void, + stackDir: () => "/tmp/stack", + stackStatePath: () => "/tmp/stack/state", + stackMetadataPath: () => "/tmp/stack/meta", + stackDataDir: () => "/tmp/stack/data", + stackLogsDir: () => "/tmp/stack/logs", + }), + ), + Layer.succeed( + RuntimeInfo, + RuntimeInfo.of({ + cwd: "/tmp/project", + platform: process.platform, + arch: process.arch, + homeDir: "/tmp/home", + execPath: "/tmp/supabase", + pid: 1, + }), + ), + Layer.succeed( + CliConfig, + CliConfig.of({ + apiUrl: "https://api.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + projectHost: "supabase.co", + telemetryPosthogHost: "https://example.com", + telemetryPosthogKey: Option.none(), + accessToken: Option.none(), + noKeyring: Option.none(), + supabaseHome: "/tmp/home/.supabase", + debug: Option.none(), + telemetryDebug: Option.none(), + telemetryDisabled: Option.none(), + doNotTrack: Option.none(), + }), + ), + Layer.succeed( + ProjectLinkState, + ProjectLinkState.of({ + load: Effect.succeed(opts.linked === false ? Option.none() : Option.some(linkedState)), + save: () => Effect.void, + clear: Effect.void, + getActiveBranch: Effect.succeed(Option.some(linkedState.active_branch)), + setActiveBranch: () => Effect.void, + }), + ), + Layer.succeed( + LinkedRemoteConnector, + LinkedRemoteConnector.of({ + connect: (projectRef) => + opts.connectorUrl !== undefined + ? Effect.succeed(opts.connectorUrl) + : new SchemaLinkedConnectionError({ + detail: `Linked project ${projectRef} has no connection string in this environment.`, + suggestion: "Use the stable CLI.", + }), + }), + ), + ); + return databaseTargetLayer.pipe(Layer.provide(deps)); +} + +function restoreEnv(name: "DATABASE_URL" | "SUPABASE_DB_URL", previous: string | undefined) { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } +} + +describe("databaseTargetLayer linked resolve", () => { + it.live("treats DATABASE_URL as an unverifiable URL target even when unlinked", () => { + const previous = process.env["DATABASE_URL"]; + process.env["DATABASE_URL"] = "postgresql://postgres:secret@other.example/postgres"; + return Effect.gen(function* () { + const targets = yield* DatabaseTargetResolver; + const target = yield* targets.resolve({ kind: "linked" }); + expect(target.kind).toBe("url"); + expect(target.projectRef).toBeUndefined(); + expect(target.connectionSource).toBe("env"); + expect(target.identity).toBe("connection-string"); + }).pipe( + Effect.provide(setup({ linked: false })), + Effect.ensuring(Effect.sync(() => restoreEnv("DATABASE_URL", previous))), + ); + }); + + it.live("builds the socket from the linked connector when no env URL is set", () => { + const previousDb = process.env["DATABASE_URL"]; + const previousSupa = process.env["SUPABASE_DB_URL"]; + delete process.env["DATABASE_URL"]; + delete process.env["SUPABASE_DB_URL"]; + return Effect.gen(function* () { + const targets = yield* DatabaseTargetResolver; + const target = yield* targets.resolve({ kind: "linked" }); + expect(target.kind).toBe("linked"); + expect(target.projectRef).toBe("abcdefghijklmnop"); + expect(target.connectionString).toBe("postgresql://postgres:from-link@db.example/postgres"); + expect(target.connectionVerified).toBe(true); + }).pipe( + Effect.provide( + setup({ connectorUrl: "postgresql://postgres:from-link@db.example/postgres" }), + ), + Effect.ensuring( + Effect.sync(() => { + restoreEnv("DATABASE_URL", previousDb); + restoreEnv("SUPABASE_DB_URL", previousSupa); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/shared/schema/database-target.layer.ts b/apps/cli/src/shared/schema/database-target.layer.ts index 967f3e1555..4f606cc1db 100644 --- a/apps/cli/src/shared/schema/database-target.layer.ts +++ b/apps/cli/src/shared/schema/database-target.layer.ts @@ -9,15 +9,12 @@ import { CliConfig } from "../../next/config/cli-config.service.ts"; import { ProjectHome } from "../../next/config/project-home.service.ts"; import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; import { LocalDatabaseFallback } from "../database/local-database-fallback.service.ts"; +import { LinkedRemoteConnector } from "../database/linked-remote-connector.service.ts"; import { DatabaseTargetResolver } from "../database/database-target.service.ts"; -import type { DatabaseTarget } from "../database/database-target.ts"; +import { envDatabaseUrl, type DatabaseTarget } from "../database/database-target.ts"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { SchemaLinkedConnectionError, SchemaLocalStackNotRunningError } from "./schema-errors.ts"; -function envConnectionString(): string | undefined { - return process.env["SUPABASE_DB_URL"] ?? process.env["DATABASE_URL"]; -} - export const databaseTargetLayer = Layer.effect( DatabaseTargetResolver, Effect.gen(function* () { @@ -29,6 +26,7 @@ export const databaseTargetLayer = Layer.effect( const path = yield* Path.Path; const fallback = yield* LocalDatabaseFallback; + const linkedRemote = yield* LinkedRemoteConnector; const resolveLocal = Effect.gen(function* () { const layer = yield* connectLayer({ @@ -69,6 +67,18 @@ export const databaseTargetLayer = Layer.effect( ); 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 linked = yield* linkState.load.pipe( Effect.mapError( (error) => @@ -84,21 +94,14 @@ export const databaseTargetLayer = Layer.effect( suggestion: "Run `supabase link`, or pass --from / --against with a connection string.", }); } - const url = envConnectionString(); - if (url === undefined) { - return yield* new SchemaLinkedConnectionError({ - detail: `Linked project ${linked.value.project.ref} has no connection string in this environment.`, - suggestion: - "Pass --from / --against / --db-url with a connection string, or set DATABASE_URL / SUPABASE_DB_URL.", - }); - } + const connectionString = yield* linkedRemote.connect(linked.value.project.ref); return { kind: "linked", identity: linked.value.project.ref, - connectionString: url, + connectionString, disposable: false, durable: true, - connectionVerified: false, + connectionVerified: true, projectRef: linked.value.project.ref, } satisfies DatabaseTarget; }); @@ -114,6 +117,7 @@ export const databaseTargetLayer = Layer.effect( disposable: false, durable: true, connectionVerified: false, + connectionSource: "flag", } satisfies DatabaseTarget); }, }); diff --git a/apps/cli/src/shared/schema/generate-schema.integration.test.ts b/apps/cli/src/shared/schema/generate-schema.integration.test.ts index f1ed098086..43a75117c8 100644 --- a/apps/cli/src/shared/schema/generate-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/generate-schema.integration.test.ts @@ -232,6 +232,19 @@ describe("generateSchema", () => { 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 }); + 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", + ); }); }); }); diff --git a/apps/cli/src/shared/schema/generate-schema.ts b/apps/cli/src/shared/schema/generate-schema.ts index 476a758163..2e402b2b9a 100644 --- a/apps/cli/src/shared/schema/generate-schema.ts +++ b/apps/cli/src/shared/schema/generate-schema.ts @@ -2,6 +2,7 @@ import { Clock, Effect } from "effect"; import { acquireDatabasePool } from "../database/database-pool.ts"; import { MigrationRepository } from "../migrations/migration-repository.service.ts"; import { MigrationRunner } from "../migrations/migration-runner.service.ts"; +import { formatMigrationRepairCommand } from "../migrations/migration-repair-suggest.ts"; import { digestVersions } from "./schema-digest.ts"; import { SchemaDraftConflictError, SchemaEngineError } from "./schema-errors.ts"; import { formatPlanSummary } from "./schema-output.ts"; @@ -134,6 +135,20 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen 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(", ")}`, @@ -144,13 +159,9 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen files_written: written.map((file) => file.fileName), mutated_database: false, mutated_files: true, - next_actions: [ - "Review the migration files, commit them, then deploy with migrations push.", - ], + next_actions: nextActions, }, - nextActions: [ - "Review the migration files, commit them, then deploy with migrations push.", - ], + nextActions, mutatedDatabase: false, mutatedFiles: true, } satisfies SchemaCommandResult; diff --git a/apps/cli/src/shared/schema/schema-runtime.layer.ts b/apps/cli/src/shared/schema/schema-runtime.layer.ts index 8d983dd121..f298b4a541 100644 --- a/apps/cli/src/shared/schema/schema-runtime.layer.ts +++ b/apps/cli/src/shared/schema/schema-runtime.layer.ts @@ -14,6 +14,10 @@ import { noLocalDatabaseFallbackLayer, type LocalDatabaseFallback, } from "../database/local-database-fallback.service.ts"; +import { + failClosedLinkedRemoteConnectorLayer, + LinkedRemoteConnector, +} from "../database/linked-remote-connector.service.ts"; import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; import { databaseTargetLayer } from "./database-target.layer.ts"; import { nativeIsolatedShadowLayer } from "./native-isolated-shadow.layer.ts"; @@ -45,6 +49,7 @@ export type SchemaRuntimeOptions = ProjectCommandRuntimeOptions & { never, FileSystem.FileSystem | Path.Path | ProjectHome >; + readonly linkedRemoteConnector?: Layer.Layer; }; export const schemaRuntimeLayer = ( @@ -52,6 +57,7 @@ export const schemaRuntimeLayer = ( options?: SchemaRuntimeOptions, ) => { const linkState = options?.projectLinkState ?? projectLinkStateLayer; + const linkedRemote = options?.linkedRemoteConnector ?? failClosedLinkedRemoteConnectorLayer; const schemaEngineLive = pgDeltaSchemaEngineLayer.pipe( Layer.provide(nativeIsolatedShadowLayer), Layer.provide(FetchHttpClient.layer), @@ -69,7 +75,9 @@ export const schemaRuntimeLayer = ( Layer.provide(options?.localDatabaseFallback ?? noLocalDatabaseFallbackLayer), Layer.provide(linkState), Layer.provide(unixHttpClientLayer), + Layer.provide(linkedRemote), ), + linkedRemote, linkState, projectLocalServiceVersionsLayer, unixHttpClientLayer, diff --git a/docs/cli/schema-first-v1-plan.md b/docs/cli/schema-first-v1-plan.md index b80a3ef6bc..4f8a3274d5 100644 --- a/docs/cli/schema-first-v1-plan.md +++ b/docs/cli/schema-first-v1-plan.md @@ -21,8 +21,8 @@ It is the working spec for the `feat/implement-rfc-schema-first-development` bra | # | 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 when the live catalog already matches full file replay. Catalog match is schema-shape only — a pending DML-only file can be recorded without executing. Local `db reset` clears the journal. 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 migration-history operation (not pull). | +| 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. | @@ -61,8 +61,8 @@ apps/cli/src/next/commands/migration/ compatibility aliases only | `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` | -| `migrations pull` | R − M → files | Migration files | +| `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 | ### Aliases (next only, deprecation notice on stderr) @@ -83,6 +83,8 @@ apps/cli/src/next/commands/migration/ compatibility aliases only - 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. Next/alpha stays fail-closed unless an env URL + `--allow-remote` is supplied. - 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. From c732def47aa70f25c312c4ac30c5044328f1cd7b Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 19 Aug 2026 22:37:06 +0200 Subject: [PATCH 5/6] fix(cli): refuse --baseline on existing history and name pull targets schema generate --baseline now fails closed when migrations already exist, and drift/pull next-actions include pasteable --from / --project-ref flags without echoing secrets. --- .../schema/generate/generate.command.ts | 2 +- .../schema/generate/generate.command.ts | 2 +- .../migrations/migration-repair-suggest.ts | 13 +- .../migration-repair-suggest.unit.test.ts | 38 +++++- .../pull-migrations.integration.test.ts | 2 +- .../push-migrations.integration.test.ts | 4 +- .../generate-schema.integration.test.ts | 120 ++++++++++++++---- apps/cli/src/shared/schema/generate-schema.ts | 14 +- apps/cli/src/shared/schema/schema-errors.ts | 8 ++ 9 files changed, 168 insertions(+), 35 deletions(-) diff --git a/apps/cli/src/legacy/commands/schema/generate/generate.command.ts b/apps/cli/src/legacy/commands/schema/generate/generate.command.ts index 2821363db7..7fcf991ec5 100644 --- a/apps/cli/src/legacy/commands/schema/generate/generate.command.ts +++ b/apps/cli/src/legacy/commands/schema/generate/generate.command.ts @@ -15,7 +15,7 @@ const config = { ), baseline: Flag.boolean("baseline").pipe( Flag.withDescription( - "Generate a baseline migration from an empty replay (existing-database onboarding).", + "Generate a baseline migration from an empty replay (existing-database onboarding). Refuses if migration files already exist.", ), ), } as const; diff --git a/apps/cli/src/next/commands/schema/generate/generate.command.ts b/apps/cli/src/next/commands/schema/generate/generate.command.ts index 26e248a1b5..64dce70cd5 100644 --- a/apps/cli/src/next/commands/schema/generate/generate.command.ts +++ b/apps/cli/src/next/commands/schema/generate/generate.command.ts @@ -15,7 +15,7 @@ const flags = { ), baseline: Flag.boolean("baseline").pipe( Flag.withDescription( - "Generate a baseline migration from an empty replay (existing-database onboarding).", + "Generate a baseline migration from an empty replay (existing-database onboarding). Refuses if migration files already exist.", ), ), } as const; diff --git a/apps/cli/src/shared/migrations/migration-repair-suggest.ts b/apps/cli/src/shared/migrations/migration-repair-suggest.ts index a6d3f182ed..6de27cd455 100644 --- a/apps/cli/src/shared/migrations/migration-repair-suggest.ts +++ b/apps/cli/src/shared/migrations/migration-repair-suggest.ts @@ -21,7 +21,8 @@ export function repairFlagsForTarget( } return { dbUrlSame: true }; } - return opts.projectRef !== undefined ? { projectRef: opts.projectRef } : {}; + const projectRef = opts.projectRef ?? target.projectRef; + return projectRef !== undefined ? { projectRef } : {}; } export function formatMigrationRepairCommand(input: { @@ -84,7 +85,15 @@ export function suggestRemoteDriftRepair(input: { }), ); } else { - lines.push("supabase migrations pull"); + 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 index 4f549df348..a87e2c616b 100644 --- a/apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts +++ b/apps/cli/src/shared/migrations/migration-repair-suggest.unit.test.ts @@ -86,6 +86,12 @@ describe("repairFlagsForTarget", () => { }); }); + 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 }); }); @@ -127,10 +133,40 @@ describe("formatHistoryConflict", () => { describe("suggestRemoteDriftRepair", () => { test("suggests pull when nothing matches", () => { expect(suggestRemoteDriftRepair({ remoteOnly: [], matchingPrefix: [] })).toBe( - "supabase migrations pull", + "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({ diff --git a/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts index a399d60433..a61af2fbac 100644 --- a/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts @@ -145,7 +145,7 @@ describe("pullMigrations", () => { const result = yield* pullMigrations({}).pipe(Effect.provide(layer)); expect(result.mutatedFiles).toBe(true); expect(result.nextActions.join("\n")).toContain( - "supabase migration repair --status applied 20260819120000", + "supabase migration repair --project-ref abcdefghijklmnop --status applied 20260819120000", ); }); }); diff --git a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts index 4383e8fecd..b4cec8d6d3 100644 --- a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -233,7 +233,7 @@ describe("pushMigrations", () => { 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"); + expect(JSON.stringify(exit)).toContain("supabase migrations pull --from linked"); expect(ctx.shadowProvisions).toBe(1); }); }); @@ -266,7 +266,7 @@ describe("pushMigrations", () => { expect(JSON.stringify(exit)).toContain( "supabase migration repair --project-ref abcdefghijklmnop --status reverted 19990101000000", ); - expect(JSON.stringify(exit)).toContain("supabase migrations pull"); + expect(JSON.stringify(exit)).toContain("supabase migrations pull --from linked"); }); }); diff --git a/apps/cli/src/shared/schema/generate-schema.integration.test.ts b/apps/cli/src/shared/schema/generate-schema.integration.test.ts index 43a75117c8..52ddbc4588 100644 --- a/apps/cli/src/shared/schema/generate-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/generate-schema.integration.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; import { classifyPlanHazards, type Plan } from "@supabase/pg-delta/plan"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { MigrationRepository } from "../migrations/migration-repository.service.ts"; import { MigrationRunner } from "../migrations/migration-runner.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"; @@ -80,10 +81,19 @@ const draftJournal: SchemaDraftJournal = { plans: [], }; -function setup(opts: { changes?: boolean; journal?: SchemaDraftJournal; write?: boolean } = {}) { +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( @@ -118,30 +128,40 @@ function setup(opts: { changes?: boolean; journal?: SchemaDraftJournal; write?: Layer.succeed( MigrationRepository, MigrationRepository.of({ - listLocal: Effect.succeed([ - { - version: "20260101000000", - name: "init", - fileName: "20260101000000_init.sql", - absolutePath: "/tmp/migrations/20260101000000_init.sql", - content: "select 1;", - transactional: true, - }, - ]), - createEmpty: () => Effect.die("unused"), - writeGenerated: () => - opts.write === true - ? Effect.succeed([ + listLocal: Effect.succeed( + opts.localMigrations === "empty" + ? [] + : [ { - version: "20260101000001", - name: "schema", - fileName: "20260101000001_schema.sql", - absolutePath: "/tmp/migrations/20260101000001_schema.sql", - content: "create table t (id int);", + version: "20260101000000", + name: "init", + fileName: "20260101000000_init.sql", + absolutePath: "/tmp/migrations/20260101000000_init.sql", + content: "select 1;", transactional: true, }, - ]) - : Effect.die("unused"), + ], + ), + 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"), }), ), @@ -167,8 +187,11 @@ function setup(opts: { changes?: boolean; journal?: SchemaDraftJournal; write?: }), diffPools: () => Effect.die("unused"), applyPlan: () => Effect.die("unused"), - provisionShadow: Effect.succeed({ - url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + provisionShadow: Effect.sync(() => { + shadowProvisions += 1; + return { + url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + }; }), }), ), @@ -178,6 +201,12 @@ function setup(opts: { changes?: boolean; journal?: SchemaDraftJournal; write?: get cleared() { return cleared; }, + get wrote() { + return wrote; + }, + get shadowProvisions() { + return shadowProvisions; + }, }; } @@ -237,7 +266,7 @@ describe("generateSchema", () => { }); it.live("suggests migration repair after writing a baseline", () => { - const ctx = setup({ write: true }); + const ctx = setup({ write: true, localMigrations: "empty" }); return Effect.gen(function* () { const result = yield* generateSchema({ dryRun: false, baseline: true }).pipe( Effect.provide(ctx.layer), @@ -247,4 +276,43 @@ describe("generateSchema", () => { ); }); }); + + 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 index 2e402b2b9a..687c051f3c 100644 --- a/apps/cli/src/shared/schema/generate-schema.ts +++ b/apps/cli/src/shared/schema/generate-schema.ts @@ -4,7 +4,11 @@ import { MigrationRepository } from "../migrations/migration-repository.service. import { MigrationRunner } from "../migrations/migration-runner.service.ts"; import { formatMigrationRepairCommand } from "../migrations/migration-repair-suggest.ts"; import { digestVersions } from "./schema-digest.ts"; -import { SchemaDraftConflictError, SchemaEngineError } from "./schema-errors.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"; @@ -29,6 +33,14 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen 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* () { diff --git a/apps/cli/src/shared/schema/schema-errors.ts b/apps/cli/src/shared/schema/schema-errors.ts index 27b96eea6e..585be5c53d 100644 --- a/apps/cli/src/shared/schema/schema-errors.ts +++ b/apps/cli/src/shared/schema/schema-errors.ts @@ -135,6 +135,14 @@ export class SchemaMigrationNameError extends SchemaCliError("SchemaMigrationNam } } +export class SchemaBaselineMigrationsExistError extends SchemaCliError( + "SchemaBaselineMigrationsExistError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + export class SchemaHistoryConflictError extends SchemaCliError("SchemaHistoryConflictError") { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.migrationDrift; From 5365d6b22911df784d4ab0f98a8bb961b7134cac Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 20 Aug 2026 11:43:39 +0200 Subject: [PATCH 6/6] refactor(cli): run schema-first shadows on Docker baselines Drop native Postgres and next/ command copies so generate, apply, and push reuse the existing shadow-baseline tar pool and legacy migration helpers. --- ...elta-engine.next.layer.integration.test.ts | 2 + .../legacy-pgdelta-next-shadow.layer.ts | 7 + .../legacy-pgdelta-next-shadow.service.ts | 11 + .../config/legacy-next-cli-config.layer.ts | 53 --- .../legacy/config/legacy-next-cli-config.ts | 44 -- .../legacy-next-cli-config.unit.test.ts | 130 ------ .../legacy-docker-isolated-shadow.layer.ts | 71 +++ .../legacy-migration-repository.layer.ts} | 92 ++-- ...migration-runner.layer.integration.test.ts | 66 +++ .../schema/legacy-migration-runner.layer.ts | 215 +++++++++ .../legacy-schema-database-target.layer.ts | 104 +++++ .../legacy-schema-project-link-state.layer.ts | 187 -------- ...hema-project-link-state.layer.unit.test.ts | 168 ------- .../schema/legacy-schema-runtime.layer.ts | 90 ++-- apps/cli/src/next/cli/root.ts | 8 - apps/cli/src/next/commands/db/db.command.ts | 161 ------- .../commands/migration/migration.command.ts | 65 --- .../migrations/apply/apply.command.ts | 14 - .../migrations/apply/apply.handler.ts | 8 - .../commands/migrations/diff/diff.command.ts | 39 -- .../commands/migrations/diff/diff.handler.ts | 12 - .../commands/migrations/list/list.command.ts | 27 -- .../commands/migrations/list/list.handler.ts | 9 - .../commands/migrations/migrations.command.ts | 26 -- .../commands/migrations/new/new.command.ts | 30 -- .../commands/migrations/new/new.handler.ts | 9 - .../migrations/new/new.integration.test.ts | 125 ------ .../commands/migrations/pull/pull.command.ts | 34 -- .../commands/migrations/pull/pull.handler.ts | 12 - .../commands/migrations/push/push.command.ts | 45 -- .../commands/migrations/push/push.handler.ts | 15 - .../commands/schema/apply/apply.command.ts | 42 -- .../commands/schema/apply/apply.handler.ts | 13 - .../schema/apply/apply.integration.test.ts | 101 ----- .../schema/generate/generate.command.ts | 50 --- .../schema/generate/generate.handler.ts | 13 - .../next/commands/schema/pull/pull.command.ts | 63 --- .../next/commands/schema/pull/pull.handler.ts | 14 - .../next/commands/schema/schema.command.ts | 16 - .../src/next/config/project-runtime.layer.ts | 66 +-- .../shared/cli/code-structure.unit.test.ts | 10 - .../linked-remote-connector.service.ts | 13 +- .../local-database-fallback.service.ts | 12 +- .../apply-migrations.integration.test.ts | 1 + .../src/shared/migrations/diff-migrations.ts | 8 +- ...atching-pending-prefix.integration.test.ts | 94 ++-- .../src/shared/migrations/migration-file.ts | 36 -- .../migrations/migration-runner.layer.ts | 155 ------- .../migration-runner.layer.unit.test.ts | 148 ------- ...w-list-diff-migrations.integration.test.ts | 214 +++++++++ .../pull-migrations.integration.test.ts | 12 +- .../src/shared/migrations/pull-migrations.ts | 6 +- .../push-migrations.integration.test.ts | 4 + .../src/shared/migrations/push-migrations.ts | 3 +- .../schema/apply-schema.integration.test.ts | 1 + .../database-target.layer.integration.test.ts | 144 ------ .../shared/schema/database-target.layer.ts | 133 ------ .../generate-schema.integration.test.ts | 15 +- apps/cli/src/shared/schema/generate-schema.ts | 14 +- .../shared/schema/isolated-shadow.service.ts | 3 + .../schema/native-isolated-shadow-key.ts | 52 --- .../native-isolated-shadow-key.unit.test.ts | 34 -- .../schema/native-isolated-shadow.layer.ts | 416 ------------------ .../shared/schema/pg-delta-engine.layer.ts | 1 + .../shared/schema/pg-delta-engine.service.ts | 1 + .../schema/prepare-declarative-shadow.ts | 3 +- .../schema/pull-schema.integration.test.ts | 1 + apps/cli/src/shared/schema/schema-paths.ts | 1 - .../src/shared/schema/schema-runtime.layer.ts | 88 ---- docs/cli/dev-alpha-command-structure.md | 2 +- docs/cli/schema-first-v1-plan.md | 34 +- packages/stack/src/effect.ts | 4 - packages/stack/src/resolve-native-binary.ts | 21 - packages/stack/src/services/postgres-init.ts | 14 +- 74 files changed, 917 insertions(+), 3038 deletions(-) delete mode 100644 apps/cli/src/legacy/config/legacy-next-cli-config.layer.ts delete mode 100644 apps/cli/src/legacy/config/legacy-next-cli-config.ts delete mode 100644 apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts create mode 100644 apps/cli/src/legacy/schema/legacy-docker-isolated-shadow.layer.ts rename apps/cli/src/{shared/migrations/migration-repository.layer.ts => legacy/schema/legacy-migration-repository.layer.ts} (65%) create mode 100644 apps/cli/src/legacy/schema/legacy-migration-runner.layer.integration.test.ts create mode 100644 apps/cli/src/legacy/schema/legacy-migration-runner.layer.ts create mode 100644 apps/cli/src/legacy/schema/legacy-schema-database-target.layer.ts delete mode 100644 apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts delete mode 100644 apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts delete mode 100644 apps/cli/src/next/commands/db/db.command.ts delete mode 100644 apps/cli/src/next/commands/migration/migration.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/apply/apply.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/apply/apply.handler.ts delete mode 100644 apps/cli/src/next/commands/migrations/diff/diff.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/diff/diff.handler.ts delete mode 100644 apps/cli/src/next/commands/migrations/list/list.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/list/list.handler.ts delete mode 100644 apps/cli/src/next/commands/migrations/migrations.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/new/new.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/new/new.handler.ts delete mode 100644 apps/cli/src/next/commands/migrations/new/new.integration.test.ts delete mode 100644 apps/cli/src/next/commands/migrations/pull/pull.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/pull/pull.handler.ts delete mode 100644 apps/cli/src/next/commands/migrations/push/push.command.ts delete mode 100644 apps/cli/src/next/commands/migrations/push/push.handler.ts delete mode 100644 apps/cli/src/next/commands/schema/apply/apply.command.ts delete mode 100644 apps/cli/src/next/commands/schema/apply/apply.handler.ts delete mode 100644 apps/cli/src/next/commands/schema/apply/apply.integration.test.ts delete mode 100644 apps/cli/src/next/commands/schema/generate/generate.command.ts delete mode 100644 apps/cli/src/next/commands/schema/generate/generate.handler.ts delete mode 100644 apps/cli/src/next/commands/schema/pull/pull.command.ts delete mode 100644 apps/cli/src/next/commands/schema/pull/pull.handler.ts delete mode 100644 apps/cli/src/next/commands/schema/schema.command.ts delete mode 100644 apps/cli/src/shared/migrations/migration-runner.layer.ts delete mode 100644 apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts create mode 100644 apps/cli/src/shared/migrations/new-list-diff-migrations.integration.test.ts delete mode 100644 apps/cli/src/shared/schema/database-target.layer.integration.test.ts delete mode 100644 apps/cli/src/shared/schema/database-target.layer.ts delete mode 100644 apps/cli/src/shared/schema/native-isolated-shadow-key.ts delete mode 100644 apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts delete mode 100644 apps/cli/src/shared/schema/native-isolated-shadow.layer.ts delete mode 100644 apps/cli/src/shared/schema/schema-runtime.layer.ts delete mode 100644 packages/stack/src/resolve-native-binary.ts 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/config/legacy-next-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-next-cli-config.layer.ts deleted file mode 100644 index 34ef5c7313..0000000000 --- a/apps/cli/src/legacy/config/legacy-next-cli-config.layer.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Effect, Layer, Option } from "effect"; -import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { ProjectContext } from "../../next/config/project-context.service.ts"; -import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -import { - LegacyDebugLogger, - type LegacyDebugLoggerShape, -} from "../shared/legacy-debug-logger.service.ts"; -import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; -import { cliConfigFromLegacyProfile } from "./legacy-next-cli-config.ts"; - -const silentDebugLogger: LegacyDebugLoggerShape = { - debug: () => Effect.void, - http: () => Effect.void, -}; - -/** - * Next `CliConfig` for any legacy-hosted command that reuses next services. - * - * `--profile` is a stable global and is already resolved into `LegacyCliConfig`. - * Commands must not rebuild hosts from env-only next config. - */ -export const legacyNextCliConfigLayer = Layer.effect( - CliConfig, - Effect.gen(function* () { - const legacy = yield* LegacyCliConfig; - const runtimeInfo = yield* RuntimeInfo; - const projectContext = yield* Effect.serviceOption(ProjectContext); - const debugLogger = Option.getOrElse( - yield* Effect.serviceOption(LegacyDebugLogger), - () => silentDebugLogger, - ); - const env = Option.match(projectContext, { - onNone: () => process.env, - onSome: (context) => - Option.match(context.projectEnv, { - onNone: () => process.env, - onSome: (projectEnv) => projectEnv.values, - }), - }); - yield* debugLogger.debug(`Using profile: ${legacy.profile} (${legacy.projectHost})`); - return CliConfig.of( - cliConfigFromLegacyProfile({ - apiUrl: legacy.apiUrl, - dashboardUrl: legacy.dashboardUrl, - projectHost: legacy.projectHost, - accessToken: legacy.accessToken, - homeDir: runtimeInfo.homeDir, - env, - }), - ); - }), -); diff --git a/apps/cli/src/legacy/config/legacy-next-cli-config.ts b/apps/cli/src/legacy/config/legacy-next-cli-config.ts deleted file mode 100644 index 89eb3f1926..0000000000 --- a/apps/cli/src/legacy/config/legacy-next-cli-config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Option, Redacted } from "effect"; -import { resolvePosthogConfig } from "../../shared/telemetry/posthog-config.ts"; -import { resolveSupabaseHome } from "../../shared/config/supabase-home.ts"; - -function readEnv( - env: Readonly>, - key: string, -): Option.Option { - const value = env[key]; - return value === undefined ? Option.none() : Option.some(value); -} - -/** - * Next `CliConfig` fields sourced from a resolved legacy profile. - * - * `--profile` is a stable global. Commands that reuse next services must not - * ignore it: API host, dashboard, project host, and the env access token come - * from `LegacyCliConfig`, while telemetry / `SUPABASE_HOME` stay env-based. - */ -export const cliConfigFromLegacyProfile = (input: { - readonly apiUrl: string; - readonly dashboardUrl: string; - readonly projectHost: string; - readonly accessToken: Option.Option>; - readonly homeDir: string; - readonly env?: Readonly>; -}) => { - const env = input.env ?? process.env; - const posthogConfig = resolvePosthogConfig(env); - return { - apiUrl: input.apiUrl, - dashboardUrl: input.dashboardUrl, - projectHost: input.projectHost, - telemetryPosthogHost: posthogConfig.host, - telemetryPosthogKey: posthogConfig.key, - accessToken: input.accessToken, - noKeyring: readEnv(env, "SUPABASE_NO_KEYRING"), - supabaseHome: resolveSupabaseHome(env, input.homeDir), - debug: readEnv(env, "SUPABASE_DEBUG"), - telemetryDebug: readEnv(env, "SUPABASE_TELEMETRY_DEBUG"), - telemetryDisabled: readEnv(env, "SUPABASE_TELEMETRY_DISABLED"), - doNotTrack: readEnv(env, "DO_NOT_TRACK"), - }; -}; diff --git a/apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts b/apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts deleted file mode 100644 index 97e207e4ce..0000000000 --- a/apps/cli/src/legacy/config/legacy-next-cli-config.unit.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Redacted } from "effect"; -import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { ProjectContext } from "../../next/config/project-context.service.ts"; -import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; -import { legacyNextCliConfigLayer } from "./legacy-next-cli-config.layer.ts"; -import { cliConfigFromLegacyProfile } from "./legacy-next-cli-config.ts"; - -describe("cliConfigFromLegacyProfile", () => { - it("copies the resolved legacy profile endpoints onto next CliConfig", () => { - const config = cliConfigFromLegacyProfile({ - apiUrl: "https://api.supabase.green", - dashboardUrl: "https://supabase.green/dashboard", - projectHost: "supabase.red", - accessToken: Option.some(Redacted.make("sbp_staging", { label: "token" })), - homeDir: "/Users/me", - env: {}, - }); - expect(config.apiUrl).toBe("https://api.supabase.green"); - expect(config.dashboardUrl).toBe("https://supabase.green/dashboard"); - expect(config.projectHost).toBe("supabase.red"); - expect(config.supabaseHome).toBe("/Users/me/.supabase"); - expect(Option.isSome(config.accessToken)).toBe(true); - if (Option.isSome(config.accessToken)) { - expect(Redacted.value(config.accessToken.value)).toBe("sbp_staging"); - } - }); - - it("honors SUPABASE_HOME from the environment", () => { - const config = cliConfigFromLegacyProfile({ - apiUrl: "https://api.supabase.com", - dashboardUrl: "https://supabase.com/dashboard", - projectHost: "supabase.co", - accessToken: Option.none(), - homeDir: "/Users/me", - env: { SUPABASE_HOME: "/tmp/supabase-home" }, - }); - expect(config.supabaseHome).toBe("/tmp/supabase-home"); - }); - - it.live("provides next CliConfig from a resolved staging LegacyCliConfig", () => - Effect.gen(function* () { - const config = yield* CliConfig; - expect(config.apiUrl).toBe("https://api.supabase.green"); - expect(config.dashboardUrl).toBe("https://supabase.green/dashboard"); - expect(config.projectHost).toBe("supabase.red"); - }).pipe( - Effect.provide(legacyNextCliConfigLayer), - Effect.provide( - Layer.succeed(LegacyCliConfig, { - profile: "supabase-staging", - apiUrl: "https://api.supabase.green", - projectHost: "supabase.red", - poolerHost: "supabase.green", - dashboardUrl: "https://supabase.green/dashboard", - accessToken: Option.none(), - projectId: Option.none(), - workdir: "/tmp", - userAgent: "SupabaseCLI/test", - }), - ), - Effect.provide( - Layer.succeed( - RuntimeInfo, - RuntimeInfo.of({ - cwd: "/tmp", - platform: process.platform, - arch: process.arch, - homeDir: "/tmp/home", - execPath: process.execPath, - pid: process.pid, - }), - ), - ), - ), - ); - - it.live("resolves supabaseHome from ProjectContext.projectEnv", () => - Effect.gen(function* () { - const config = yield* CliConfig; - expect(config.supabaseHome).toBe("/tmp/from-project-env"); - }).pipe( - Effect.provide(legacyNextCliConfigLayer), - Effect.provide( - Layer.succeed(LegacyCliConfig, { - profile: "supabase", - apiUrl: "https://api.supabase.com", - projectHost: "supabase.co", - poolerHost: "supabase.com", - dashboardUrl: "https://supabase.com/dashboard", - accessToken: Option.none(), - projectId: Option.none(), - workdir: "/tmp", - userAgent: "SupabaseCLI/test", - }), - ), - Effect.provide( - Layer.succeed( - RuntimeInfo, - RuntimeInfo.of({ - cwd: "/tmp", - platform: process.platform, - arch: process.arch, - homeDir: "/tmp/home", - execPath: process.execPath, - pid: process.pid, - }), - ), - ), - Effect.provide( - Layer.succeed(ProjectContext, { - paths: Option.none(), - projectEnv: Option.some({ - paths: { - projectRoot: "/tmp/proj", - supabaseDir: "/tmp/proj/supabase", - configPath: "/tmp/proj/supabase/config.toml", - envPath: "/tmp/proj/supabase/.env", - envLocalPath: "/tmp/proj/supabase/.env.local", - }, - values: { SUPABASE_HOME: "/tmp/from-project-env" }, - loadedPaths: [], - sources: {}, - }), - }), - ), - ), - ); -}); 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/shared/migrations/migration-repository.layer.ts b/apps/cli/src/legacy/schema/legacy-migration-repository.layer.ts similarity index 65% rename from apps/cli/src/shared/migrations/migration-repository.layer.ts rename to apps/cli/src/legacy/schema/legacy-migration-repository.layer.ts index 35a62203bd..dde992a090 100644 --- a/apps/cli/src/shared/migrations/migration-repository.layer.ts +++ b/apps/cli/src/legacy/schema/legacy-migration-repository.layer.ts @@ -1,15 +1,22 @@ import { Clock, Effect, FileSystem, Layer, Path } from "effect"; -import { SchemaMigrationNameError, SchemaWorkspaceIoError } from "../schema/schema-errors.ts"; -import { MIGRATION_NO_TRANSACTION_DIRECTIVE } from "../schema/schema-paths.ts"; -import { SchemaWorkspace } from "../schema/schema-workspace.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; import { - formatMigrationTimestamp, - migrationFileName, - parseMigrationContent, - parseMigrationFileName, - type MigrationFile, -} from "./migration-file.ts"; -import { MigrationRepository } from "./migration-repository.service.ts"; + 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; @@ -20,38 +27,39 @@ const ioError = (detail: string) => suggestion: "Check permissions on supabase/migrations and retry.", }); -export const migrationRepositoryLayer = Layer.effect( +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 exists = yield* fs - .exists(workspace.migrationsDir) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) return []; - const names = yield* fs - .readDirectory(workspace.migrationsDir) - .pipe(Effect.mapError((error) => ioError(`Failed to read migrations: ${error.message}`))); + const paths = yield* legacyListLocalMigrationPaths(fs, path, workspace.migrationsDir).pipe( + Effect.provideService(Output, output), + Effect.mapError((error) => ioError(error.message)), + ); const files: Array = []; - for (const fileName of names) { - const parsed = parseMigrationFileName(fileName); - if (parsed === undefined) continue; - const absolutePath = path.join(workspace.migrationsDir, fileName); + 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: parsed.version, - name: parsed.name, + version, + name, fileName, absolutePath, content, - transactional: parseMigrationContent(content).transactional, + transactional: legacyParseMigrationContent(content).transactionMode === "transactional", }); } return files.sort((left, right) => left.version.localeCompare(right.version)); @@ -69,20 +77,27 @@ export const migrationRepositoryLayer = Layer.effect( 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 = formatMigrationTimestamp(yield* Clock.currentTimeMillis); - const fileName = migrationFileName(version, name); - const absolutePath = path.join(workspace.migrationsDir, fileName); - if (!absolutePath.startsWith(workspace.migrationsDir + path.sep)) { - return yield* new SchemaMigrationNameError({ - detail: `Migration name "${name}" escapes supabase/migrations.`, - suggestion: "Use a simple identifier without path separators.", - }); - } + 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))); @@ -111,14 +126,14 @@ export const migrationRepositoryLayer = Layer.effect( const build = (baseMillis: number) => input.files.map((file, index) => { - const version = formatMigrationTimestamp(baseMillis + index * 1000); + const version = legacyFormatMigrationTimestamp(baseMillis + index * 1000); const name = unitName(file.suffix); - const fileName = migrationFileName(version, name); + const absolutePath = legacyGetMigrationPath(path, workdir, version, name); return { version, name, - fileName, - absolutePath: path.join(workspace.migrationsDir, fileName), + fileName: path.basename(absolutePath), + absolutePath, body: file.transactional ? file.sql : `${MIGRATION_NO_TRANSACTION_DIRECTIVE}\n${file.sql}`, @@ -140,6 +155,7 @@ export const migrationRepositoryLayer = Layer.effect( 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}`), 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-project-link-state.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts deleted file mode 100644 index 5ceb347151..0000000000 --- a/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; -import { ProjectHome } from "../../next/config/project-home.service.ts"; -import { - InvalidProjectLinkStateError, - ProjectLinkState, - ProjectLinkStateValueSchema, - ProjectNotLinkedError, - type ActiveBranch, - type LinkedServiceVersions, - type ProjectLinkStateValue, -} from "../../next/config/project-link-state.service.ts"; -import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; -import { PROJECT_REF_PATTERN } from "../config/legacy-project-ref.service.ts"; -import { legacyParseCachedLinkedProject } from "../shared/legacy-parent-project-ref.ts"; -import { legacyReadProjectRefFile, legacyTempPaths } from "../shared/legacy-temp-paths.ts"; - -const decodeProjectLinkStateValue = Schema.decodeUnknownEffect( - Schema.fromJsonString(ProjectLinkStateValueSchema), -); -const encodeProjectLinkStateValue = Schema.encodeUnknownSync(ProjectLinkStateValueSchema); - -function invalidProjectLinkStateError(filePath: string): InvalidProjectLinkStateError { - return new InvalidProjectLinkStateError({ - detail: `The linked project state file at ${filePath} is invalid or unreadable.`, - suggestion: "Fix or remove the file, then retry the command.", - }); -} - -function encodePrettyJson(value: unknown): string { - return `${JSON.stringify(value, null, 2)}\n`; -} - -const readOptionalVersion = (fs: FileSystem.FileSystem, filePath: string) => - fs.readFileString(filePath).pipe( - Effect.map((content) => content.trim()), - Effect.orElseSucceed(() => ""), - Effect.map((value) => (value.length > 0 ? value : undefined)), - ); - -/** - * Stable-shell `ProjectLinkState` for schema/migrations commands. - * - * Prefers next's `.supabase/project.json` when present. Otherwise reconstructs - * identity from the files `supabase link` actually writes: - * `supabase/.temp/project-ref`, `linked-project.json`, and version pins. - */ -export const legacySchemaProjectLinkStateLayer = Layer.effect( - ProjectLinkState, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectHome = yield* ProjectHome; - const cliConfig = yield* LegacyCliConfig; - - const loadFromPath = (filePath: string) => - Effect.gen(function* () { - const exists = yield* fs - .exists(filePath) - .pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); - if (!exists) { - return Option.none(); - } - - const content = yield* fs - .readFileString(filePath) - .pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); - const decoded = yield* decodeProjectLinkStateValue(content).pipe( - Effect.mapError(() => invalidProjectLinkStateError(filePath)), - ); - if (!PROJECT_REF_PATTERN.test(decoded.project.ref)) { - return yield* new InvalidProjectLinkStateError({ - detail: `The linked project state file at ${filePath} has an invalid project ref.`, - suggestion: "Fix or remove the file, then retry the command.", - }); - } - return Option.some(decoded); - }); - - const loadFromLegacyTemp = Effect.gen(function* () { - const workdir = cliConfig.workdir; - const paths = legacyTempPaths(path, workdir); - let ref: string | undefined; - if (Option.isSome(cliConfig.projectId)) { - if (!PROJECT_REF_PATTERN.test(cliConfig.projectId.value)) { - return yield* new InvalidProjectLinkStateError({ - detail: "SUPABASE_PROJECT_ID is not a valid project ref.", - suggestion: - "Set a 20-letter project ref, or unset SUPABASE_PROJECT_ID to use supabase/.temp/project-ref.", - }); - } - ref = cliConfig.projectId.value; - } else { - const fileRef = yield* legacyReadProjectRefFile(fs, path, workdir).pipe( - Effect.mapError( - (error) => - new InvalidProjectLinkStateError({ - detail: error.message, - suggestion: "Fix or remove supabase/.temp/project-ref, then retry.", - }), - ), - ); - if (Option.isSome(fileRef) && !PROJECT_REF_PATTERN.test(fileRef.value)) { - return yield* new InvalidProjectLinkStateError({ - detail: "supabase/.temp/project-ref is not a valid project ref.", - suggestion: "Run `supabase link` again, or remove the invalid project-ref file.", - }); - } - ref = Option.isSome(fileRef) ? fileRef.value : undefined; - } - if (ref === undefined) { - return Option.none(); - } - - const cacheContent = yield* fs - .readFileString(paths.linkedProjectCache) - .pipe(Effect.orElseSucceed(() => "")); - const cached = legacyParseCachedLinkedProject(cacheContent); - const postgres = yield* readOptionalVersion(fs, paths.postgresVersion); - const postgrest = yield* readOptionalVersion(fs, paths.restVersion); - const auth = yield* readOptionalVersion(fs, paths.gotrueVersion); - const storage = yield* readOptionalVersion(fs, paths.storageVersion); - const versions: LinkedServiceVersions = { - ...(postgres === undefined ? {} : { postgres }), - ...(postgrest === undefined ? {} : { postgrest }), - ...(auth === undefined ? {} : { auth }), - ...(storage === undefined ? {} : { storage }), - }; - - return Option.some({ - project: { - ref, - name: Option.isSome(cached) ? (cached.value.name ?? ref) : ref, - organization_id: Option.isSome(cached) ? (cached.value.organizationId ?? "") : "", - organization_slug: Option.isSome(cached) ? (cached.value.organizationSlug ?? "") : "", - }, - active_branch: { - ref, - name: "main", - is_default: true, - }, - fetchedAt: "", - versions, - } satisfies ProjectLinkStateValue); - }); - - const load = Effect.gen(function* () { - const next = yield* loadFromPath(projectHome.projectLinkPath); - if (Option.isSome(next)) return next; - return yield* loadFromLegacyTemp; - }); - - const save = (state: ProjectLinkStateValue) => - Effect.gen(function* () { - yield* projectHome.ensureProjectHomeDir; - const encoded = encodeProjectLinkStateValue(state); - yield* fs.writeFileString(projectHome.projectLinkPath, encodePrettyJson(encoded), { - mode: 0o600, - }); - }).pipe(Effect.orDie); - - const clear = fs.remove(projectHome.projectLinkPath).pipe(Effect.ignore, Effect.orDie); - - const getActiveBranch = load.pipe(Effect.map(Option.map((state) => state.active_branch))); - - const setActiveBranch = (branch: ActiveBranch) => - Effect.gen(function* () { - const current = yield* load; - if (Option.isNone(current)) { - return yield* Effect.fail( - new ProjectNotLinkedError({ - detail: "Cannot set active branch: no linked project found.", - suggestion: "Run `supabase link` to link this checkout to a Supabase project first.", - }), - ); - } - yield* save({ ...current.value, active_branch: branch }); - }); - - return ProjectLinkState.of({ - load, - save, - clear, - getActiveBranch, - setActiveBranch, - }); - }), -); diff --git a/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts b/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts deleted file mode 100644 index 92b7f6aa1b..0000000000 --- a/apps/cli/src/legacy/schema/legacy-schema-project-link-state.layer.unit.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Layer, Option } from "effect"; -import { ProjectHome } from "../../next/config/project-home.service.ts"; -import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; -import { LEGACY_VALID_REF, mockLegacyCliConfig } from "../../../tests/helpers/legacy-mocks.ts"; -import { legacySchemaProjectLinkStateLayer } from "./legacy-schema-project-link-state.layer.ts"; - -function tempWorkdir() { - const workdir = mkdtempSync(join(tmpdir(), "legacy-schema-link-")); - const supabaseDir = join(workdir, "supabase"); - const projectHomeDir = join(workdir, ".supabase"); - mkdirSync(join(supabaseDir, ".temp"), { recursive: true }); - mkdirSync(projectHomeDir, { recursive: true }); - return { workdir, supabaseDir, projectHomeDir }; -} - -function projectHomeLayer(workdir: string, projectHomeDir: string, supabaseDir: string) { - return Layer.succeed( - ProjectHome, - ProjectHome.of({ - projectRoot: workdir, - supabaseDir, - projectHomeDir, - projectLinkPath: join(projectHomeDir, "project.json"), - projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), - ensureProjectHomeDir: Effect.void, - }), - ); -} - -function setup(workdir: string, projectHomeDir: string, supabaseDir: string) { - return legacySchemaProjectLinkStateLayer.pipe( - Layer.provide(BunServices.layer), - Layer.provide(mockLegacyCliConfig({ workdir, projectId: Option.none() })), - Layer.provide(projectHomeLayer(workdir, projectHomeDir, supabaseDir)), - ); -} - -describe("legacySchemaProjectLinkStateLayer", () => { - it.live("loads a stable link from supabase/.temp/project-ref", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); - writeFileSync( - join(supabaseDir, ".temp", "linked-project.json"), - JSON.stringify({ - ref: LEGACY_VALID_REF, - name: "Demo", - organization_id: "org-1", - organization_slug: "demo-org", - }), - ); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const state = yield* link.load; - expect(Option.isSome(state)).toBe(true); - if (Option.isNone(state)) return; - expect(state.value.project.ref).toBe(LEGACY_VALID_REF); - expect(state.value.project.name).toBe("Demo"); - expect(state.value.project.organization_slug).toBe("demo-org"); - }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); - }); - - it.live("prefers next project.json when both link files exist", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); - writeFileSync( - join(projectHomeDir, "project.json"), - `${JSON.stringify({ - project: { - ref: "zzzzzzzzzzzzzzzzzzzz", - name: "Next", - organization_id: "org-next", - organization_slug: "next-org", - }, - active_branch: { ref: "zzzzzzzzzzzzzzzzzzzz", name: "main", is_default: true }, - fetchedAt: "2026-01-01T00:00:00.000Z", - versions: {}, - })}\n`, - ); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const state = yield* link.load; - expect(Option.isSome(state)).toBe(true); - if (Option.isNone(state)) return; - expect(state.value.project.ref).toBe("zzzzzzzzzzzzzzzzzzzz"); - expect(state.value.project.name).toBe("Next"); - }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); - }); - - it.live("fails closed when project.json has an invalid project ref", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); - writeFileSync( - join(projectHomeDir, "project.json"), - `${JSON.stringify({ - project: { - ref: "not-a-ref", - name: "Broken", - organization_id: "org-1", - organization_slug: "broken", - }, - active_branch: { ref: "not-a-ref", name: "main", is_default: true }, - fetchedAt: "2026-01-01T00:00:00.000Z", - versions: {}, - })}\n`, - ); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const exit = yield* link.load.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); - }); - - it.live("fails closed when SUPABASE_PROJECT_ID is present but invalid", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - writeFileSync(join(supabaseDir, ".temp", "project-ref"), `${LEGACY_VALID_REF}\n`); - const layer = legacySchemaProjectLinkStateLayer.pipe( - Layer.provide(BunServices.layer), - Layer.provide(mockLegacyCliConfig({ workdir, projectId: Option.some("not-a-ref") })), - Layer.provide(projectHomeLayer(workdir, projectHomeDir, supabaseDir)), - ); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const exit = yield* link.load.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.live("uses SUPABASE_PROJECT_ID even when supabase/.temp/project-ref is invalid", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - writeFileSync(join(supabaseDir, ".temp", "project-ref"), "not-a-ref\n"); - const layer = legacySchemaProjectLinkStateLayer.pipe( - Layer.provide(BunServices.layer), - Layer.provide(mockLegacyCliConfig({ workdir, projectId: Option.some(LEGACY_VALID_REF) })), - Layer.provide(projectHomeLayer(workdir, projectHomeDir, supabaseDir)), - ); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const state = yield* link.load; - expect(Option.isSome(state)).toBe(true); - if (Option.isNone(state)) return; - expect(state.value.project.ref).toBe(LEGACY_VALID_REF); - }).pipe(Effect.provide(layer)); - }); - - it.live("fails closed when supabase/.temp/project-ref is present but invalid", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - writeFileSync(join(supabaseDir, ".temp", "project-ref"), "not-a-ref\n"); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const exit = yield* link.load.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); - }); - - it.live("returns none when the workdir is not linked", () => { - const { workdir, supabaseDir, projectHomeDir } = tempWorkdir(); - return Effect.gen(function* () { - const link = yield* ProjectLinkState; - const state = yield* link.load; - expect(Option.isNone(state)).toBe(true); - }).pipe(Effect.provide(setup(workdir, projectHomeDir, supabaseDir))); - }); -}); diff --git a/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts index 0b43c258b6..c590455b26 100644 --- a/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts +++ b/apps/cli/src/legacy/schema/legacy-schema-runtime.layer.ts @@ -1,17 +1,23 @@ -import { Effect, Layer, Option } from "effect"; -import { schemaRuntimeLayer } from "../../shared/schema/schema-runtime.layer.ts"; -import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +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 { legacyNextCliConfigLayer } from "../config/legacy-next-cli-config.layer.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 { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.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 { legacySchemaProjectLinkStateLayer } from "./legacy-schema-project-link-state.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)); @@ -22,40 +28,52 @@ const legacySchemaDbConfig = legacyDbConfigLayer.pipe( 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 runtimeInfo = yield* RuntimeInfo; - const debugLogger = yield* Effect.serviceOption(LegacyDebugLogger); - const runtime = Layer.succeed( - RuntimeInfo, - RuntimeInfo.of({ - ...runtimeInfo, - cwd: config.workdir, - }), - ); - return schemaRuntimeLayer(commandPath, { - runtimeInfo: runtime, - cliConfig: legacyNextCliConfigLayer.pipe( - Layer.provide(Layer.succeed(LegacyCliConfig, config)), - Layer.provide(runtime), - Layer.provide( - Option.match(debugLogger, { - onNone: () => Layer.empty, - onSome: (logger) => Layer.succeed(LegacyDebugLogger, logger), - }), - ), - ), - localDatabaseFallback: legacyDockerLocalDatabaseFallbackLayer.pipe( - Layer.provide(Layer.succeed(LegacyCliConfig, config)), - ), - projectLinkState: legacySchemaProjectLinkStateLayer.pipe( - Layer.provide(Layer.succeed(LegacyCliConfig, config)), - ), - linkedRemoteConnector: legacyLinkedRemoteConnectorLayer.pipe( - Layer.provide(legacySchemaDbConfig), - ), + 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/next/cli/root.ts b/apps/cli/src/next/cli/root.ts index 01f844c272..8166d1a70f 100644 --- a/apps/cli/src/next/cli/root.ts +++ b/apps/cli/src/next/cli/root.ts @@ -22,10 +22,6 @@ import { stopCommand } from "../commands/stop/stop.command.ts"; import { telemetryCommand } from "../commands/telemetry/telemetry.command.ts"; import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { updateCommand } from "../commands/update/update.command.ts"; -import { schemaCommand } from "../commands/schema/schema.command.ts"; -import { migrationsCommand } from "../commands/migrations/migrations.command.ts"; -import { dbCommand } from "../commands/db/db.command.ts"; -import { migrationCommand } from "../commands/migration/migration.command.ts"; import { outputLayerFor } from "../../shared/output/output.layer.ts"; import { jsonCliOutputFormatter } from "../../shared/output/json-formatter.ts"; @@ -53,10 +49,6 @@ export const nextRoot = Command.make("supabase").pipe( statusCommand, logsCommand, apiCommand, - schemaCommand, - migrationsCommand, - dbCommand, - migrationCommand, ]), Command.provide( Layer.unwrap( diff --git a/apps/cli/src/next/commands/db/db.command.ts b/apps/cli/src/next/commands/db/db.command.ts deleted file mode 100644 index d1d06d526f..0000000000 --- a/apps/cli/src/next/commands/db/db.command.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Effect, Option } from "effect"; -import { Command, Flag } from "effect/unstable/cli"; -import { Output } from "../../../shared/output/output.service.ts"; -import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../shared/schema/schema-runtime.layer.ts"; -import { diffMigrations } from "../../../shared/migrations/diff-migrations.ts"; -import { pullMigrations } from "../../../shared/migrations/pull-migrations.ts"; -import { pushMigrations } from "../../../shared/migrations/push-migrations.ts"; -import { pullSchema } from "../../../shared/schema/pull-schema.ts"; -import { generateSchema } from "../../../shared/schema/generate-schema.ts"; -import { applySchema } from "../../../shared/schema/apply-schema.ts"; -import { renderSchemaResult } from "../../../shared/schema/schema-render.ts"; - -const notice = (alias: string, target: string) => - Effect.gen(function* () { - const output = yield* Output; - yield* output.raw(`Command "${alias}" is deprecated, use "${target}" instead.\n`, "stderr"); - }); - -const dbDiffFlags = { - against: Flag.string("against").pipe(Flag.optional), - file: Flag.string("file").pipe(Flag.withAlias("f"), Flag.optional), -} as const; - -const dbDiffCommand = Command.make("diff", dbDiffFlags).pipe( - Command.withDescription("Deprecated alias for migrations diff."), - Command.withShortDescription("Deprecated: use migrations diff"), - Command.withHandler((flags) => - Effect.gen(function* () { - yield* notice("db diff", "migrations diff"); - const result = yield* diffMigrations({ - against: Option.getOrUndefined(flags.against) ?? "local", - file: Option.getOrUndefined(flags.file), - }); - yield* renderSchemaResult("Diff migrations", result); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["db", "diff"])), -); - -const dbPushFlags = { - yes: Flag.boolean("yes").pipe(Flag.withAlias("y")), - projectRef: Flag.string("project-ref").pipe(Flag.optional), - allowRemote: Flag.boolean("allow-remote"), - dbUrl: Flag.string("db-url").pipe(Flag.optional), - skipVerify: Flag.boolean("skip-verify"), -} as const; - -const dbPushCommand = Command.make("push", dbPushFlags).pipe( - Command.withDescription("Deprecated alias for migrations push."), - Command.withShortDescription("Deprecated: use migrations push"), - Command.withHandler((flags) => - Effect.gen(function* () { - yield* notice("db push", "migrations push"); - 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); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["db", "push"])), -); - -const dbPullFlags = { - from: Flag.string("from").pipe(Flag.optional), - declarative: Flag.boolean("declarative"), - output: Flag.string("output").pipe(Flag.optional), - force: Flag.boolean("force"), - pruneUnmanaged: Flag.boolean("prune-unmanaged"), -} as const; - -const dbPullCommand = Command.make("pull", dbPullFlags).pipe( - Command.withDescription( - "Deprecated alias for migrations pull, or schema pull when --declarative is set.", - ), - Command.withShortDescription("Deprecated: use schema pull or migrations pull"), - Command.withHandler((flags) => - Effect.gen(function* () { - if (flags.declarative) { - yield* notice("db pull --declarative", "schema pull --from linked"); - const result = yield* pullSchema({ - from: Option.getOrUndefined(flags.from) ?? "linked", - output: Option.getOrUndefined(flags.output), - force: flags.force, - pruneUnmanaged: flags.pruneUnmanaged, - }); - yield* renderSchemaResult("Pull declarative schema", result); - return; - } - yield* notice("db pull", "migrations pull"); - const result = yield* pullMigrations({ - from: Option.getOrUndefined(flags.from), - }); - yield* renderSchemaResult("Pull remote migrations", result); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["db", "pull"])), -); - -const declarativeGenerateCommand = Command.make("generate").pipe( - Command.withDescription("Deprecated alias for schema pull."), - Command.withShortDescription("Deprecated: use schema pull"), - Command.withHandler(() => - Effect.gen(function* () { - yield* notice("db schema declarative generate", "schema pull"); - const result = yield* pullSchema({ from: "local", force: false, pruneUnmanaged: false }); - yield* renderSchemaResult("Pull declarative schema", result); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["db", "schema", "declarative", "generate"])), -); - -const declarativeSyncFlags = { - apply: Flag.boolean("apply"), - name: Flag.string("name").pipe(Flag.optional), -} as const; - -const declarativeSyncCommand = Command.make("sync", declarativeSyncFlags).pipe( - Command.withDescription("Deprecated alias for schema generate."), - Command.withShortDescription("Deprecated: use schema generate"), - Command.withHandler((flags) => - Effect.gen(function* () { - yield* notice("db schema declarative sync", "schema generate"); - const generated = yield* generateSchema({ - name: Option.getOrUndefined(flags.name), - dryRun: false, - baseline: false, - }); - yield* renderSchemaResult("Generate schema migrations", generated); - if (flags.apply) { - const applied = yield* applySchema({ - yes: true, - allowRemote: false, - }); - yield* renderSchemaResult("Apply declarative schema", applied); - } - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["db", "schema", "declarative", "sync"])), -); - -const declarativeCommand = Command.make("declarative").pipe( - Command.withDescription("Deprecated declarative schema aliases."), - Command.withSubcommands([declarativeGenerateCommand, declarativeSyncCommand]), -); - -const dbSchemaCommand = Command.make("schema").pipe( - Command.withDescription("Deprecated schema aliases. Use top-level schema."), - Command.withSubcommands([declarativeCommand]), -); - -export const dbCommand = Command.make("db").pipe( - Command.withDescription("Deprecated database aliases. Prefer schema and migrations."), - Command.withShortDescription("Deprecated: use schema and migrations"), - Command.withSubcommands([dbDiffCommand, dbPushCommand, dbPullCommand, dbSchemaCommand]), -); diff --git a/apps/cli/src/next/commands/migration/migration.command.ts b/apps/cli/src/next/commands/migration/migration.command.ts deleted file mode 100644 index 322b118e88..0000000000 --- a/apps/cli/src/next/commands/migration/migration.command.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Effect, Option } from "effect"; -import { Argument, Command } from "effect/unstable/cli"; -import { Output } from "../../../shared/output/output.service.ts"; -import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../shared/schema/schema-runtime.layer.ts"; -import { applyMigrations } from "../../../shared/migrations/apply-migrations.ts"; -import { listMigrations } from "../../../shared/migrations/list-migrations.ts"; -import { newMigration } from "../../../shared/migrations/new-migration.ts"; -import { renderSchemaResult } from "../../../shared/schema/schema-render.ts"; - -const notice = (alias: string, target: string) => - Effect.gen(function* () { - const output = yield* Output; - yield* output.raw(`Command "${alias}" is deprecated, use "${target}" instead.\n`, "stderr"); - }); - -const newArgs = { - name: Argument.string("name").pipe(Argument.optional), -} as const; - -const migrationNewCommand = Command.make("new", newArgs).pipe( - Command.withDescription("Deprecated alias for migrations new."), - Command.withShortDescription("Deprecated: use migrations new"), - Command.withHandler((flags) => - Effect.gen(function* () { - yield* notice("migration new", "migrations new"); - const result = yield* newMigration(Option.getOrUndefined(flags.name)); - yield* renderSchemaResult("Create migration", result); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["migration", "new"])), -); - -const migrationListCommand = Command.make("list").pipe( - Command.withDescription("Deprecated alias for migrations list."), - Command.withShortDescription("Deprecated: use migrations list"), - Command.withHandler(() => - Effect.gen(function* () { - yield* notice("migration list", "migrations list"); - const result = yield* listMigrations({}); - yield* renderSchemaResult("List migrations", result); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["migration", "list"])), -); - -const migrationUpCommand = Command.make("up").pipe( - Command.withDescription("Deprecated alias for migrations apply."), - Command.withShortDescription("Deprecated: use migrations apply"), - Command.withHandler(() => - Effect.gen(function* () { - yield* notice("migration up", "migrations apply"); - const result = yield* applyMigrations(); - yield* renderSchemaResult("Apply migrations", result); - }).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["migration", "up"])), -); - -export const migrationCommand = Command.make("migration").pipe( - Command.withDescription("Deprecated singular alias for migrations."), - Command.withShortDescription("Deprecated: use migrations"), - Command.withSubcommands([migrationNewCommand, migrationListCommand, migrationUpCommand]), -); diff --git a/apps/cli/src/next/commands/migrations/apply/apply.command.ts b/apps/cli/src/next/commands/migrations/apply/apply.command.ts deleted file mode 100644 index 4b5b96f764..0000000000 --- a/apps/cli/src/next/commands/migrations/apply/apply.command.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; -import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { migrationsApply } from "./apply.handler.ts"; - -export const migrationsApplyCommand = Command.make("apply").pipe( - Command.withDescription("Apply exact pending migration files to the local database."), - Command.withShortDescription("Apply pending migrations locally"), - Command.withHandler(() => - migrationsApply().pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["migrations", "apply"])), -); diff --git a/apps/cli/src/next/commands/migrations/apply/apply.handler.ts b/apps/cli/src/next/commands/migrations/apply/apply.handler.ts deleted file mode 100644 index c2963c6786..0000000000 --- a/apps/cli/src/next/commands/migrations/apply/apply.handler.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Effect } from "effect"; -import { applyMigrations } from "../../../../shared/migrations/apply-migrations.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; - -export const migrationsApply = Effect.fn("migrations.apply")(function* () { - const result = yield* applyMigrations(); - yield* renderSchemaResult("Apply migrations", result); -}); diff --git a/apps/cli/src/next/commands/migrations/diff/diff.command.ts b/apps/cli/src/next/commands/migrations/diff/diff.command.ts deleted file mode 100644 index b84660a96b..0000000000 --- a/apps/cli/src/next/commands/migrations/diff/diff.command.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { migrationsDiff } from "./diff.handler.ts"; - -const flags = { - 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 MigrationsDiffFlags = CliCommand.Command.Config.Infer; - -export const migrationsDiffCommand = Command.make("diff", flags).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((commandFlags) => - migrationsDiff(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["migrations", "diff"])), -); diff --git a/apps/cli/src/next/commands/migrations/diff/diff.handler.ts b/apps/cli/src/next/commands/migrations/diff/diff.handler.ts deleted file mode 100644 index d7ba218cb6..0000000000 --- a/apps/cli/src/next/commands/migrations/diff/diff.handler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Effect, Option } from "effect"; -import { diffMigrations } from "../../../../shared/migrations/diff-migrations.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { MigrationsDiffFlags } from "./diff.command.ts"; - -export const migrationsDiff = Effect.fn("migrations.diff")(function* (flags: MigrationsDiffFlags) { - 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/next/commands/migrations/list/list.command.ts b/apps/cli/src/next/commands/migrations/list/list.command.ts deleted file mode 100644 index 1f7b00103c..0000000000 --- a/apps/cli/src/next/commands/migrations/list/list.command.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { migrationsList } from "./list.handler.ts"; - -const flags = { - against: Flag.string("against").pipe( - Flag.withDescription("Target to compare: local, linked, or a connection string."), - Flag.optional, - ), -} as const; - -export type MigrationsListFlags = CliCommand.Command.Config.Infer; - -export const migrationsListCommand = Command.make("list", flags).pipe( - Command.withDescription("Compare local migration files with target migration history."), - Command.withShortDescription("List local and remote migrations"), - Command.withHandler((commandFlags) => - migrationsList(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["migrations", "list"])), -); diff --git a/apps/cli/src/next/commands/migrations/list/list.handler.ts b/apps/cli/src/next/commands/migrations/list/list.handler.ts deleted file mode 100644 index 9a484ba01f..0000000000 --- a/apps/cli/src/next/commands/migrations/list/list.handler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Effect, Option } from "effect"; -import { listMigrations } from "../../../../shared/migrations/list-migrations.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { MigrationsListFlags } from "./list.command.ts"; - -export const migrationsList = Effect.fn("migrations.list")(function* (flags: MigrationsListFlags) { - const result = yield* listMigrations({ against: Option.getOrUndefined(flags.against) }); - yield* renderSchemaResult("List migrations", result); -}); diff --git a/apps/cli/src/next/commands/migrations/migrations.command.ts b/apps/cli/src/next/commands/migrations/migrations.command.ts deleted file mode 100644 index 73c55bf76b..0000000000 --- a/apps/cli/src/next/commands/migrations/migrations.command.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { SCHEMA_ECOSYSTEM_MAPPING_HELP } from "../../../shared/schema/schema-ecosystem.ts"; -import { migrationsApplyCommand } from "./apply/apply.command.ts"; -import { migrationsDiffCommand } from "./diff/diff.command.ts"; -import { migrationsListCommand } from "./list/list.command.ts"; -import { migrationsNewCommand } from "./new/new.command.ts"; -import { migrationsPullCommand } from "./pull/pull.command.ts"; -import { migrationsPushCommand } from "./push/push.command.ts"; - -export const migrationsCommand = 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([ - migrationsNewCommand, - migrationsListCommand, - migrationsDiffCommand, - migrationsApplyCommand, - migrationsPushCommand, - migrationsPullCommand, - ]), -); diff --git a/apps/cli/src/next/commands/migrations/new/new.command.ts b/apps/cli/src/next/commands/migrations/new/new.command.ts deleted file mode 100644 index 119ec9d0f9..0000000000 --- a/apps/cli/src/next/commands/migrations/new/new.command.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { migrationsNew } from "./new.handler.ts"; - -const args = { - name: Argument.string("name").pipe( - Argument.withDescription("Migration name."), - Argument.optional, - ), -} as const; - -export type MigrationsNewFlags = CliCommand.Command.Config.Infer; - -export const migrationsNewCommand = Command.make("new", args).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) => - migrationsNew(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), - ), - Command.provide(schemaRuntimeLayer(["migrations", "new"])), -); diff --git a/apps/cli/src/next/commands/migrations/new/new.handler.ts b/apps/cli/src/next/commands/migrations/new/new.handler.ts deleted file mode 100644 index 270a60d3fe..0000000000 --- a/apps/cli/src/next/commands/migrations/new/new.handler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Effect, Option } from "effect"; -import { newMigration } from "../../../../shared/migrations/new-migration.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { MigrationsNewFlags } from "./new.command.ts"; - -export const migrationsNew = Effect.fn("migrations.new")(function* (flags: MigrationsNewFlags) { - const result = yield* newMigration(Option.getOrUndefined(flags.name)); - yield* renderSchemaResult("Create migration", result); -}); diff --git a/apps/cli/src/next/commands/migrations/new/new.integration.test.ts b/apps/cli/src/next/commands/migrations/new/new.integration.test.ts deleted file mode 100644 index f9a40b46f2..0000000000 --- a/apps/cli/src/next/commands/migrations/new/new.integration.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Exit, Layer } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { newMigration } from "../../../../shared/migrations/new-migration.ts"; -import { MigrationRepository } from "../../../../shared/migrations/migration-repository.service.ts"; -import { migrationRepositoryLayer } from "../../../../shared/migrations/migration-repository.layer.ts"; -import { schemaStateLayer } from "../../../../shared/schema/schema-state.layer.ts"; -import { schemaWorkspaceLayer } from "../../../../shared/schema/schema-workspace.layer.ts"; - -describe("newMigration", () => { - it.live("creates a timestamped migration file", () => { - const root = mkdtempSync(join(tmpdir(), "migrations-new-")); - const supabaseDir = join(root, "supabase"); - const projectHomeDir = join(root, ".supabase"); - mkdirSync(supabaseDir, { recursive: true }); - mkdirSync(projectHomeDir, { recursive: true }); - const workspace = schemaWorkspaceLayer({ - projectRoot: root, - supabaseDir, - projectHomeDir, - }).pipe(Layer.provide(BunServices.layer)); - const out = mockOutput({ format: "json" }); - const layer = Layer.mergeAll( - out.layer, - BunServices.layer, - workspace, - schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), - migrationRepositoryLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), - ); - - return Effect.gen(function* () { - const result = yield* newMigration("add_billing").pipe(Effect.provide(layer)); - expect(result.mutatedFiles).toBe(true); - const files = readdirSync(join(supabaseDir, "migrations")); - expect(files.some((name) => name.endsWith("_add_billing.sql"))).toBe(true); - expect(existsSync(join(supabaseDir, "migrations", files[0]!))).toBe(true); - }); - }); - - it.live("assigns a unique version to each generated plan unit", () => { - const root = mkdtempSync(join(tmpdir(), "migrations-units-")); - const supabaseDir = join(root, "supabase"); - const projectHomeDir = join(root, ".supabase"); - mkdirSync(supabaseDir, { recursive: true }); - mkdirSync(projectHomeDir, { recursive: true }); - const workspace = schemaWorkspaceLayer({ - projectRoot: root, - supabaseDir, - projectHomeDir, - }).pipe(Layer.provide(BunServices.layer)); - const layer = Layer.mergeAll( - mockOutput({ format: "json" }).layer, - BunServices.layer, - workspace, - schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), - migrationRepositoryLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), - ); - - return Effect.gen(function* () { - const written = yield* Effect.gen(function* () { - const repository = yield* MigrationRepository; - return yield* repository.writeGenerated({ - name: "split_plan", - baseMillis: Date.parse("2026-01-01T00:00:00.000Z"), - files: [ - { suffix: null, sql: "create table a (id int);\n", transactional: true }, - { suffix: "nt", sql: "grant select on a to anon;\n", transactional: false }, - ], - }); - }).pipe(Effect.provide(layer)); - expect(written.map((file) => file.version)).toEqual(["20260101000000", "20260101000001"]); - expect(written.map((file) => file.fileName)).toEqual([ - "20260101000000_split_plan.sql", - "20260101000001_split_plan_nt.sql", - ]); - }); - }); - - it.live("refuses to create a migration while a declarative draft is active", () => { - const root = mkdtempSync(join(tmpdir(), "migrations-draft-")); - const supabaseDir = join(root, "supabase"); - const projectHomeDir = join(root, ".supabase"); - mkdirSync(supabaseDir, { recursive: true }); - mkdirSync(projectHomeDir, { recursive: true }); - writeFileSync( - join(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`, - ); - const workspace = schemaWorkspaceLayer({ - projectRoot: root, - supabaseDir, - projectHomeDir, - }).pipe(Layer.provide(BunServices.layer)); - const layer = Layer.mergeAll( - mockOutput({ format: "json" }).layer, - BunServices.layer, - workspace, - schemaStateLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), - migrationRepositoryLayer.pipe(Layer.provide(workspace), Layer.provide(BunServices.layer)), - ); - - return Effect.gen(function* () { - const exit = yield* newMigration("should_fail").pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }); - }); -}); diff --git a/apps/cli/src/next/commands/migrations/pull/pull.command.ts b/apps/cli/src/next/commands/migrations/pull/pull.command.ts deleted file mode 100644 index b6dd80c916..0000000000 --- a/apps/cli/src/next/commands/migrations/pull/pull.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { migrationsPull } from "./pull.handler.ts"; - -const flags = { - 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 MigrationsPullFlags = CliCommand.Command.Config.Infer; - -export const migrationsPullCommand = Command.make("pull", flags).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((commandFlags) => - migrationsPull(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["migrations", "pull"])), -); diff --git a/apps/cli/src/next/commands/migrations/pull/pull.handler.ts b/apps/cli/src/next/commands/migrations/pull/pull.handler.ts deleted file mode 100644 index 45f897006b..0000000000 --- a/apps/cli/src/next/commands/migrations/pull/pull.handler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Effect, Option } from "effect"; -import { pullMigrations } from "../../../../shared/migrations/pull-migrations.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { MigrationsPullFlags } from "./pull.command.ts"; - -export const migrationsPull = Effect.fn("migrations.pull")(function* (flags: MigrationsPullFlags) { - 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/next/commands/migrations/push/push.command.ts b/apps/cli/src/next/commands/migrations/push/push.command.ts deleted file mode 100644 index 5c3c36cc08..0000000000 --- a/apps/cli/src/next/commands/migrations/push/push.command.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { migrationsPush } from "./push.handler.ts"; - -const flags = { - 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 MigrationsPushFlags = CliCommand.Command.Config.Infer; - -export const migrationsPushCommand = Command.make("push", flags).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((commandFlags) => - migrationsPush(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["migrations", "push"])), -); diff --git a/apps/cli/src/next/commands/migrations/push/push.handler.ts b/apps/cli/src/next/commands/migrations/push/push.handler.ts deleted file mode 100644 index 4356ddeb8f..0000000000 --- a/apps/cli/src/next/commands/migrations/push/push.handler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Effect, Option } from "effect"; -import { pushMigrations } from "../../../../shared/migrations/push-migrations.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { MigrationsPushFlags } from "./push.command.ts"; - -export const migrationsPush = Effect.fn("migrations.push")(function* (flags: MigrationsPushFlags) { - 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/next/commands/schema/apply/apply.command.ts b/apps/cli/src/next/commands/schema/apply/apply.command.ts deleted file mode 100644 index e5bb637f9a..0000000000 --- a/apps/cli/src/next/commands/schema/apply/apply.command.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { schemaApply } from "./apply.handler.ts"; - -const flags = { - 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 SchemaApplyFlags = CliCommand.Command.Config.Infer; - -export const schemaApplyCommand = Command.make("apply", flags).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((commandFlags) => - schemaApply(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["schema", "apply"])), -); diff --git a/apps/cli/src/next/commands/schema/apply/apply.handler.ts b/apps/cli/src/next/commands/schema/apply/apply.handler.ts deleted file mode 100644 index 49764d01a1..0000000000 --- a/apps/cli/src/next/commands/schema/apply/apply.handler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Effect, Option } from "effect"; -import { applySchema } from "../../../../shared/schema/apply-schema.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { SchemaApplyFlags } from "./apply.command.ts"; - -export const schemaApply = Effect.fn("schema.apply")(function* (flags: SchemaApplyFlags) { - 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/next/commands/schema/apply/apply.integration.test.ts b/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts deleted file mode 100644 index 4f498f9ceb..0000000000 --- a/apps/cli/src/next/commands/schema/apply/apply.integration.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { applySchema } from "../../../../shared/schema/apply-schema.ts"; -import { DatabaseTargetResolver } from "../../../../shared/database/database-target.service.ts"; -import { SchemaStateStore } from "../../../../shared/schema/schema-state.service.ts"; -import { SchemaWorkspace } from "../../../../shared/schema/schema-workspace.service.ts"; -import { PgDeltaSchemaEngine } from "../../../../shared/schema/pg-delta-engine.service.ts"; -import { MigrationRepository } from "../../../../shared/migrations/migration-repository.service.ts"; -import { MigrationRunner } from "../../../../shared/migrations/migration-runner.service.ts"; -import { Option } from "effect"; - -function setup(disposable: boolean) { - const out = mockOutput({ interactive: false }); - return { - layer: Layer.mergeAll( - out.layer, - Layer.succeed( - DatabaseTargetResolver, - DatabaseTargetResolver.of({ - resolve: () => - Effect.succeed({ - kind: disposable ? "local" : "linked", - identity: disposable ? "local:default" : "abcdefghijklmnop", - connectionString: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - disposable, - durable: !disposable, - connectionVerified: disposable, - ...(disposable ? {} : { projectRef: "abcdefghijklmnop" }), - }), - }), - ), - 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(Option.none()), - writeJournal: () => Effect.void, - clearJournal: Effect.void, - withLock: (effect) => effect, - }), - ), - Layer.succeed( - MigrationRepository, - MigrationRepository.of({ - listLocal: Effect.succeed([]), - createEmpty: () => Effect.die("unused"), - writeGenerated: () => Effect.die("unused"), - remove: () => Effect.die("unused"), - }), - ), - Layer.succeed( - MigrationRunner, - MigrationRunner.of({ - listRemote: () => Effect.succeed([]), - applyPending: () => Effect.succeed({ applied: [], skipped: [] }), - markApplied: () => Effect.void, - }), - ), - Layer.succeed( - PgDeltaSchemaEngine, - PgDeltaSchemaEngine.of({ - exportSchema: () => Effect.die("unused"), - planFiles: () => Effect.die("unused"), - diffPools: () => Effect.die("unused"), - applyPlan: () => Effect.die("unused"), - provisionShadow: Effect.die("unused"), - }), - ), - ), - }; -} - -describe("applySchema", () => { - it.live("refuses durable remote targets", () => { - const { layer } = setup(false); - return Effect.gen(function* () { - const exit = yield* applySchema({ - yes: true, - allowRemote: true, - projectRef: "abcdefghijklmnop", - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }); - }); -}); diff --git a/apps/cli/src/next/commands/schema/generate/generate.command.ts b/apps/cli/src/next/commands/schema/generate/generate.command.ts deleted file mode 100644 index 64dce70cd5..0000000000 --- a/apps/cli/src/next/commands/schema/generate/generate.command.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { schemaGenerate } from "./generate.handler.ts"; - -const flags = { - 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 SchemaGenerateFlags = CliCommand.Command.Config.Infer; - -export const schemaGenerateCommand = Command.make("generate", flags).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((commandFlags) => - schemaGenerate(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["schema", "generate"])), -); diff --git a/apps/cli/src/next/commands/schema/generate/generate.handler.ts b/apps/cli/src/next/commands/schema/generate/generate.handler.ts deleted file mode 100644 index 4f498b8b22..0000000000 --- a/apps/cli/src/next/commands/schema/generate/generate.handler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Effect, Option } from "effect"; -import { generateSchema } from "../../../../shared/schema/generate-schema.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { SchemaGenerateFlags } from "./generate.command.ts"; - -export const schemaGenerate = Effect.fn("schema.generate")(function* (flags: SchemaGenerateFlags) { - 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/next/commands/schema/pull/pull.command.ts b/apps/cli/src/next/commands/schema/pull/pull.command.ts deleted file mode 100644 index 613220c322..0000000000 --- a/apps/cli/src/next/commands/schema/pull/pull.command.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { - SCHEMA_ECOSYSTEM_MAPPING_HELP, - SCHEMA_PULL_NO_MERGE_HELP, -} from "../../../../shared/schema/schema-ecosystem.ts"; -import { schemaRuntimeLayer } from "../../../../shared/schema/schema-runtime.layer.ts"; -import { schemaPull } from "./pull.handler.ts"; - -const flags = { - 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 SchemaPullFlags = CliCommand.Command.Config.Infer; - -export const schemaPullCommand = Command.make("pull", flags).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((commandFlags) => - schemaPull(commandFlags).pipe( - withCommandInstrumentation({ flags: commandFlags }), - withJsonErrorHandling, - ), - ), - Command.provide(schemaRuntimeLayer(["schema", "pull"])), -); diff --git a/apps/cli/src/next/commands/schema/pull/pull.handler.ts b/apps/cli/src/next/commands/schema/pull/pull.handler.ts deleted file mode 100644 index 70e89f42bd..0000000000 --- a/apps/cli/src/next/commands/schema/pull/pull.handler.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Effect, Option } from "effect"; -import { pullSchema } from "../../../../shared/schema/pull-schema.ts"; -import { renderSchemaResult } from "../../../../shared/schema/schema-render.ts"; -import type { SchemaPullFlags } from "./pull.command.ts"; - -export const schemaPull = Effect.fn("schema.pull")(function* (flags: SchemaPullFlags) { - 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/next/commands/schema/schema.command.ts b/apps/cli/src/next/commands/schema/schema.command.ts deleted file mode 100644 index 9faa27445e..0000000000 --- a/apps/cli/src/next/commands/schema/schema.command.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { SCHEMA_ECOSYSTEM_MAPPING_HELP } from "../../../shared/schema/schema-ecosystem.ts"; -import { schemaApplyCommand } from "./apply/apply.command.ts"; -import { schemaGenerateCommand } from "./generate/generate.command.ts"; -import { schemaPullCommand } from "./pull/pull.command.ts"; - -export const schemaCommand = 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([schemaPullCommand, schemaGenerateCommand, schemaApplyCommand]), -); diff --git a/apps/cli/src/next/config/project-runtime.layer.ts b/apps/cli/src/next/config/project-runtime.layer.ts index 35d52e35c9..beefea3671 100644 --- a/apps/cli/src/next/config/project-runtime.layer.ts +++ b/apps/cli/src/next/config/project-runtime.layer.ts @@ -1,38 +1,25 @@ import { BunServices } from "@effect/platform-bun"; import { Layer } from "effect"; import { runtimeInfoLayer } from "../../shared/runtime/runtime-info.layer.ts"; -import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -import { CliConfig } from "./cli-config.service.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { projectHomeLayer } from "./project-home.layer.ts"; -export type ProjectCommandRuntimeOptions = { - readonly runtimeInfo?: Layer.Layer; - readonly cliConfig?: Layer.Layer; -}; - -const makeDiscoveredProjectContextLayer = (runtimeInfo: Layer.Layer) => - projectContextLayer.pipe(Layer.provide(runtimeInfo), Layer.provide(BunServices.layer)); - -const makeDiscoveredCliConfigLayer = (runtimeInfo: Layer.Layer) => - cliConfigLayer.pipe( - Layer.provide(makeDiscoveredProjectContextLayer(runtimeInfo)), - Layer.provide(runtimeInfo), - ); - -const makeDiscoveredProjectHomeLayer = (runtimeInfo: Layer.Layer) => - projectHomeLayer.pipe( - Layer.provide(makeDiscoveredProjectContextLayer(runtimeInfo)), - Layer.provide(runtimeInfo), - Layer.provide(BunServices.layer), - ); - -const discoveredProjectContextLayer = makeDiscoveredProjectContextLayer(runtimeInfoLayer); +const discoveredProjectContextLayer = projectContextLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), +); -export const discoveredCliConfigLayer = makeDiscoveredCliConfigLayer(runtimeInfoLayer); +export const discoveredCliConfigLayer = cliConfigLayer.pipe( + Layer.provide(discoveredProjectContextLayer), + Layer.provide(runtimeInfoLayer), +); -const discoveredProjectHomeLayer = makeDiscoveredProjectHomeLayer(runtimeInfoLayer); +const discoveredProjectHomeLayer = projectHomeLayer.pipe( + Layer.provide(discoveredProjectContextLayer), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), +); export const projectCommandBaseLayer = Layer.mergeAll( discoveredProjectHomeLayer, @@ -43,26 +30,11 @@ export const projectCommandBaseLayer = Layer.mergeAll( Layer.provide(BunServices.layer), ); -export const provideProjectCommandRuntime = ( - layer: Layer.Layer, - options?: ProjectCommandRuntimeOptions, -) => { - const runtimeInfo = options?.runtimeInfo ?? runtimeInfoLayer; - const projectContext = makeDiscoveredProjectContextLayer(runtimeInfo); - const cliConfig = - options?.cliConfig ?? - cliConfigLayer.pipe(Layer.provide(projectContext), Layer.provide(runtimeInfo)); - return layer.pipe( - Layer.provide( - projectHomeLayer.pipe( - Layer.provide(projectContext), - Layer.provide(runtimeInfo), - Layer.provide(BunServices.layer), - ), - ), - Layer.provide(cliConfig), - Layer.provide(projectContext), - Layer.provide(runtimeInfo), +export const provideProjectCommandRuntime = (layer: Layer.Layer) => + layer.pipe( + Layer.provide(discoveredProjectHomeLayer), + Layer.provide(discoveredCliConfigLayer), + Layer.provide(discoveredProjectContextLayer), + Layer.provide(runtimeInfoLayer), Layer.provide(BunServices.layer), ); -}; diff --git a/apps/cli/src/shared/cli/code-structure.unit.test.ts b/apps/cli/src/shared/cli/code-structure.unit.test.ts index 16cdca0b89..6fb2c68da1 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -163,13 +163,6 @@ describe("code structure", () => { }); it("prevents next and legacy from importing each other", () => { - // The schema-first commands run in BOTH shells against next's project config services - // (`ProjectHome`, `ProjectLinkState`, `CliConfig`, `ProjectContext`); these legacy-side - // adapter layers are the deliberate, named bridges for that. Nothing else may cross. - const allowedBridges = new Set([ - "legacy/config/legacy-next-cli-config.layer.ts", - "legacy/schema/legacy-schema-project-link-state.layer.ts", - ]); const violations: Array = []; for (const [shellDir, otherShellDir] of [ @@ -177,9 +170,6 @@ describe("code structure", () => { [legacyDir, nextDir], ] as const) { for (const filePath of walk(shellDir).filter(isSourceFile)) { - if (allowedBridges.has(path.relative(srcDir, filePath))) { - continue; - } for (const specifier of extractRelativeImports(filePath)) { const resolved = resolveImport(filePath, specifier); if (resolved.startsWith(otherShellDir)) { diff --git a/apps/cli/src/shared/database/linked-remote-connector.service.ts b/apps/cli/src/shared/database/linked-remote-connector.service.ts index f9fe473702..caba6f7232 100644 --- a/apps/cli/src/shared/database/linked-remote-connector.service.ts +++ b/apps/cli/src/shared/database/linked-remote-connector.service.ts @@ -1,6 +1,6 @@ import type { Effect } from "effect"; -import { Context, Layer } from "effect"; -import { SchemaLinkedConnectionError } from "../schema/schema-errors.ts"; +import { Context } from "effect"; +import type { SchemaLinkedConnectionError } from "../schema/schema-errors.ts"; interface LinkedRemoteConnectorShape { readonly connect: (projectRef: string) => Effect.Effect; @@ -10,12 +10,3 @@ export class LinkedRemoteConnector extends Context.Service< LinkedRemoteConnector, LinkedRemoteConnectorShape >()("supabase/database/LinkedRemoteConnector") {} - -export const failClosedLinkedRemoteConnectorLayer = Layer.succeed(LinkedRemoteConnector, { - connect: (projectRef) => - new SchemaLinkedConnectionError({ - detail: `Linked project ${projectRef} has no connection string in this environment.`, - suggestion: - "Set DATABASE_URL / SUPABASE_DB_URL and pass --allow-remote, or use the stable CLI to connect via the linked project.", - }), -}); diff --git a/apps/cli/src/shared/database/local-database-fallback.service.ts b/apps/cli/src/shared/database/local-database-fallback.service.ts index 572d9966a6..fdc0ca50ee 100644 --- a/apps/cli/src/shared/database/local-database-fallback.service.ts +++ b/apps/cli/src/shared/database/local-database-fallback.service.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, Option } from "effect"; +import { Context, type Effect, Option } from "effect"; import type { SchemaLocalStackNotRunningError } from "../schema/schema-errors.ts"; import type { DatabaseTarget } from "./database-target.ts"; @@ -6,16 +6,8 @@ interface LocalDatabaseFallbackShape { readonly resolve: Effect.Effect, SchemaLocalStackNotRunningError>; } -/** - * Optional project-owned local DB when the native stack is not recorded. - * Used by the stable shell to see a Docker `supabase start` for *this* project. - * Absent on next, which only has native stack state. - */ +/** Optional project-owned local DB from this project's Docker `supabase start`. */ export class LocalDatabaseFallback extends Context.Service< LocalDatabaseFallback, LocalDatabaseFallbackShape >()("supabase/database/LocalDatabaseFallback") {} - -export const noLocalDatabaseFallbackLayer = Layer.succeed(LocalDatabaseFallback, { - resolve: Effect.succeed(Option.none()), -}); diff --git a/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts index 49372972a0..89b3546147 100644 --- a/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/apply-migrations.integration.test.ts @@ -182,6 +182,7 @@ function setup( provisionShadow: Effect.succeed({ url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", }), + provisionMigrations: Effect.die("unused"), }), ), ), diff --git a/apps/cli/src/shared/migrations/diff-migrations.ts b/apps/cli/src/shared/migrations/diff-migrations.ts index 82a6e6fdbb..32e6f826af 100644 --- a/apps/cli/src/shared/migrations/diff-migrations.ts +++ b/apps/cli/src/shared/migrations/diff-migrations.ts @@ -6,8 +6,6 @@ import { SchemaTargetRequiredError, SchemaWorkspaceIoError } from "../schema/sch 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 { MigrationRepository } from "./migration-repository.service.ts"; -import { MigrationRunner } from "./migration-runner.service.ts"; export type DiffMigrationsInput = { readonly against?: string; @@ -24,17 +22,13 @@ export const diffMigrations = Effect.fn("migrations.diff")(function* (input: Dif const targets = yield* DatabaseTargetResolver; const engine = yield* PgDeltaSchemaEngine; - const repository = yield* MigrationRepository; - const runner = yield* MigrationRunner; const live = yield* targets.resolve(parseTargetSelector(input.against)); - const migrations = yield* repository.listLocal; return yield* Effect.scoped( Effect.gen(function* () { const livePool = yield* acquireDatabasePool(live.connectionString); - const shadow = yield* engine.provisionShadow; + const shadow = yield* engine.provisionMigrations; const sourcePool = yield* acquireDatabasePool(shadow.url); - yield* runner.applyPending(sourcePool, migrations); const plan = yield* engine.diffPools({ sourcePool, desiredPool: livePool, 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 index 4362d0798d..a2e0249365 100644 --- a/apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts +++ b/apps/cli/src/shared/migrations/matching-pending-prefix.integration.test.ts @@ -2,10 +2,12 @@ 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 { migrationRunnerLayer } from "./migration-runner.layer.ts"; +import type { MigrationFile } from "./migration-file.ts"; +import { MigrationRunner } from "./migration-runner.service.ts"; function emptyPlan(): Plan { return { @@ -72,67 +74,73 @@ const second = { transactional: true, }; -function historyPool(seed: ReadonlyArray, opts: { readonly failSql?: string } = {}): Pool { - const versions = [...seed]; +function historyPool(opts: { readonly failSql?: string } = {}): Pool { return { - query: async (sql: string, params?: ReadonlyArray) => { + query: async (sql: string) => { if (opts.failSql !== undefined && sql === opts.failSql) { throw new Error("relation does not exist"); } - if (sql.includes("INSERT INTO") && params !== undefined) { - versions.push(String(params[0])); - return { rows: [] }; - } - if (sql.includes("SELECT version")) { - return { rows: versions.map((version) => ({ version, name: "" })) }; - } return { rows: [] }; }, } as Pool; } -describe("findMatchingPendingPrefix", () => { - it.live("applies a later pending file without treating known history as remote-only", () => { - 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"), +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: [] }; }), - ); - return Effect.gen(function* () { + 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([first.version]), - historyPool([]), + historyPool(), + historyPool(), [first], [second], ); expect(prefix.map((file) => file.version)).toEqual([second.version]); - }).pipe(Effect.provide(Layer.mergeAll(migrationRunnerLayer, engine))); - }); + }).pipe(Effect.provide(Layer.mergeAll(runner, engine))), + ); - it.live("stops the prefix scan when a later file cannot replay", () => { - 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"), - }), - ); - return Effect.gen(function* () { + it.live("stops the prefix scan when a later file cannot replay", () => + Effect.gen(function* () { const prefix = yield* findMatchingPendingPrefix( - historyPool([], { failSql: second.content }), - historyPool([]), + historyPool({ failSql: second.content }), + historyPool(), [], [first, second], ); expect(prefix.map((file) => file.version)).toEqual([first.version]); - }).pipe(Effect.provide(Layer.mergeAll(migrationRunnerLayer, engine))); - }); + }).pipe(Effect.provide(Layer.mergeAll(runner, engine))), + ); }); diff --git a/apps/cli/src/shared/migrations/migration-file.ts b/apps/cli/src/shared/migrations/migration-file.ts index afdcd6c62a..8b3ac0cd78 100644 --- a/apps/cli/src/shared/migrations/migration-file.ts +++ b/apps/cli/src/shared/migrations/migration-file.ts @@ -1,8 +1,3 @@ -import { - MIGRATE_FILE_PATTERN, - MIGRATION_NO_TRANSACTION_DIRECTIVE, -} from "../schema/schema-paths.ts"; - export type MigrationFile = { readonly version: string; readonly name: string; @@ -11,34 +6,3 @@ export type MigrationFile = { readonly content: string; readonly transactional: boolean; }; - -export function formatMigrationTimestamp(millis: number): string { - return new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); -} - -export function parseMigrationFileName( - fileName: string, -): { version: string; name: string } | undefined { - const match = MIGRATE_FILE_PATTERN.exec(fileName); - if (match === null) return undefined; - const version = match[1]; - const name = match[2]; - if (version === undefined || name === undefined) return undefined; - return { version, name }; -} - -export function migrationFileName(version: string, name: string, suffix?: string | null): string { - return suffix === undefined || suffix === null || suffix === "" - ? `${version}_${name}.sql` - : `${version}_${name}_${suffix}.sql`; -} - -export function parseMigrationContent(content: string): { - readonly transactional: boolean; -} { - const withoutBom = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; - const firstNewline = withoutBom.indexOf("\n"); - const rawFirstLine = firstNewline < 0 ? withoutBom : withoutBom.slice(0, firstNewline); - const firstLine = rawFirstLine.endsWith("\r") ? rawFirstLine.slice(0, -1) : rawFirstLine; - return { transactional: firstLine !== MIGRATION_NO_TRANSACTION_DIRECTIVE }; -} diff --git a/apps/cli/src/shared/migrations/migration-runner.layer.ts b/apps/cli/src/shared/migrations/migration-runner.layer.ts deleted file mode 100644 index ead4d26d05..0000000000 --- a/apps/cli/src/shared/migrations/migration-runner.layer.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Effect, Layer } from "effect"; -import type { Pool } from "pg"; -import { SchemaEngineError, SchemaHistoryConflictError } from "../schema/schema-errors.ts"; -import type { MigrationFile } from "./migration-file.ts"; -import { formatHistoryConflict } from "./migration-repair-suggest.ts"; -import { - MigrationRunner, - type MigrationApplyResult, - type MigrationHistoryRow, -} from "./migration-runner.service.ts"; - -const ENSURE_HISTORY = ` -BEGIN; -SET LOCAL lock_timeout = '4s'; -CREATE SCHEMA IF NOT EXISTS supabase_migrations; -CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY); -ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]; -ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text; -COMMIT; -`; - -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 INSERT_HISTORY = - "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)"; - -const MISSING_HISTORY_CODES = new Set(["3F000", "42P01"]); - -const engineError = (detail: string) => - new SchemaEngineError({ - detail, - suggestion: "Check the database connection and migration SQL, then retry.", - }); - -function 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; -} - -function postgresErrorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - -function isMissingMigrationHistory(cause: unknown): boolean { - const code = postgresErrorCode(cause); - if (code !== undefined) return MISSING_HISTORY_CODES.has(code); - const message = postgresErrorMessage(cause); - return ( - /relation .* does not exist/iu.test(message) && !/column .* does not exist/iu.test(message) - ); -} - -function isMissingNameColumn(cause: unknown): boolean { - return /column ["']?name["']? does not exist/iu.test(postgresErrorMessage(cause)); -} - -export const migrationRunnerLayer = Layer.succeed( - MigrationRunner, - MigrationRunner.of({ - listRemote: (pool: Pool) => - Effect.tryPromise({ - try: async () => { - try { - const result = await pool.query(LIST_HISTORY); - return result.rows; - } catch (cause) { - if (isMissingMigrationHistory(cause)) return []; - if (isMissingNameColumn(cause)) { - const result = await pool.query<{ version: string }>(LIST_HISTORY_VERSION_ONLY); - return result.rows.map((row) => ({ version: row.version, name: "" })); - } - throw cause; - } - }, - catch: (cause) => engineError(postgresErrorMessage(cause)), - }), - applyPending: (pool: Pool, local: ReadonlyArray) => - Effect.gen(function* () { - const remote = yield* Effect.tryPromise({ - try: async () => { - await pool.query(ENSURE_HISTORY); - const result = await pool.query(LIST_HISTORY); - return result.rows; - }, - catch: (cause) => engineError(cause instanceof Error ? cause.message : String(cause)), - }); - const remoteVersions = new Set(remote.map((row) => row.version)); - const pending = local.filter((file) => !remoteVersions.has(file.version)); - const remoteOnly = remote.filter( - (row) => !local.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), - }), - ); - } - const applied: Array = []; - for (const file of pending) { - yield* Effect.tryPromise({ - try: async () => { - if (file.transactional) { - await pool.query("BEGIN"); - try { - await pool.query(file.content); - await pool.query(INSERT_HISTORY, [file.version, file.name, [file.content]]); - await pool.query("COMMIT"); - } catch (error) { - await pool.query("ROLLBACK").catch(() => undefined); - throw error; - } - } else { - await pool.query(file.content); - await pool.query(INSERT_HISTORY, [file.version, file.name, [file.content]]); - } - }, - catch: (cause) => - engineError( - `Failed applying ${file.fileName}: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }); - applied.push(file.version); - } - return { - applied, - skipped: local - .filter((file) => remoteVersions.has(file.version)) - .map((file) => file.version), - } satisfies MigrationApplyResult; - }), - markApplied: (pool: Pool, files: ReadonlyArray) => - Effect.tryPromise({ - try: async () => { - await pool.query(ENSURE_HISTORY); - const existing = await pool.query(LIST_HISTORY); - const present = new Set(existing.rows.map((row) => row.version)); - for (const file of files) { - if (present.has(file.version)) continue; - await pool.query(INSERT_HISTORY, [file.version, file.name, [file.content]]); - } - }, - catch: (cause) => - engineError( - `Failed recording migration history: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }), - }), -); diff --git a/apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts b/apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts deleted file mode 100644 index cca7f0ef63..0000000000 --- a/apps/cli/src/shared/migrations/migration-runner.layer.unit.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; -import type { Pool } from "pg"; -import { migrationRunnerLayer } from "./migration-runner.layer.ts"; -import { MigrationRunner } from "./migration-runner.service.ts"; - -class PostgresQueryError extends Error { - readonly code: string; - constructor(code: string, message: string) { - super(message); - this.code = code; - } -} - -function fakePool( - query: ( - sql: string, - params?: ReadonlyArray, - ) => Promise<{ rows: Array<{ version: string; name: string }> }>, -): Pool { - return { query } as Pool; -} - -const sampleFile = { - version: "20260101000000", - name: "init", - fileName: "20260101000000_init.sql", - absolutePath: "/tmp/migrations/20260101000000_init.sql", - content: "select 1;", - transactional: true, -}; - -describe("migrationRunnerLayer.listRemote", () => { - it.live("does not create migration history", () => { - const seen: Array = []; - return Effect.gen(function* () { - const runner = yield* MigrationRunner; - const rows = yield* runner.listRemote( - fakePool(async (sql) => { - seen.push(sql); - return { rows: [{ version: "20260101000000", name: "init" }] }; - }), - ); - expect(rows).toEqual([{ version: "20260101000000", name: "init" }]); - expect(seen.some((sql) => sql.includes("CREATE SCHEMA"))).toBe(false); - expect(seen.some((sql) => sql.includes("CREATE TABLE"))).toBe(false); - }).pipe(Effect.provide(migrationRunnerLayer)); - }); - - it.live("returns empty history when the table is missing", () => - Effect.gen(function* () { - const runner = yield* MigrationRunner; - const rows = yield* runner.listRemote( - fakePool(async () => { - throw new PostgresQueryError( - "42P01", - `relation "supabase_migrations.schema_migrations" does not exist`, - ); - }), - ); - expect(rows).toEqual([]); - }).pipe(Effect.provide(migrationRunnerLayer)), - ); - - it.live("returns empty history when the schema is missing", () => - Effect.gen(function* () { - const runner = yield* MigrationRunner; - const rows = yield* runner.listRemote( - fakePool(async () => { - throw new PostgresQueryError("3F000", `schema "supabase_migrations" does not exist`); - }), - ); - expect(rows).toEqual([]); - }).pipe(Effect.provide(migrationRunnerLayer)), - ); - - it.live("reads version-only history when the name column is missing", () => { - const seen: Array = []; - return Effect.gen(function* () { - const runner = yield* MigrationRunner; - const rows = yield* runner.listRemote( - fakePool(async (sql) => { - seen.push(sql); - if (sql.includes("coalesce(name")) { - throw new PostgresQueryError("42703", `column "name" does not exist`); - } - return { rows: [{ version: "20260101000000", name: "" }] }; - }), - ); - expect(rows).toEqual([{ version: "20260101000000", name: "" }]); - expect(seen.some((sql) => sql.includes("CREATE"))).toBe(false); - }).pipe(Effect.provide(migrationRunnerLayer)); - }); - - it.live("propagates other query failures", () => - Effect.gen(function* () { - const runner = yield* MigrationRunner; - const exit = yield* runner - .listRemote( - fakePool(async () => { - throw new PostgresQueryError("42703", `column "version" does not exist`); - }), - ) - .pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(migrationRunnerLayer)), - ); -}); - -describe("migrationRunnerLayer.markApplied", () => { - it.live("inserts missing history rows", () => { - const statements: Array = []; - return Effect.gen(function* () { - const runner = yield* MigrationRunner; - yield* runner.markApplied( - fakePool(async (sql) => { - statements.push(sql); - if (sql.includes("SELECT version")) { - return { rows: [] }; - } - return { rows: [] }; - }), - [sampleFile], - ); - expect(statements.some((sql) => sql.includes("INSERT INTO"))).toBe(true); - }).pipe(Effect.provide(migrationRunnerLayer)); - }); - - it.live("skips versions already present", () => { - const inserts: Array | undefined> = []; - return Effect.gen(function* () { - const runner = yield* MigrationRunner; - yield* runner.markApplied( - fakePool(async (sql, params) => { - if (sql.includes("INSERT INTO")) { - inserts.push(params); - } - if (sql.includes("SELECT version")) { - return { rows: [{ version: sampleFile.version, name: sampleFile.name }] }; - } - return { rows: [] }; - }), - [sampleFile], - ); - expect(inserts).toEqual([]); - }).pipe(Effect.provide(migrationRunnerLayer)); - }); -}); 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/pull-migrations.integration.test.ts b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts index a61af2fbac..2e891e52b1 100644 --- a/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/pull-migrations.integration.test.ts @@ -7,7 +7,6 @@ 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"; -import { MigrationRunner } from "./migration-runner.service.ts"; function emptyPlan(): Plan { return { @@ -105,14 +104,6 @@ function setup(opts: { changes?: boolean } = {}) { remove: () => Effect.die("unused"), }), ), - Layer.succeed( - MigrationRunner, - MigrationRunner.of({ - listRemote: () => Effect.succeed([]), - applyPending: () => Effect.succeed({ applied: [], skipped: [] }), - markApplied: () => Effect.die("unused"), - }), - ), Layer.succeed( PgDeltaSchemaEngine, PgDeltaSchemaEngine.of({ @@ -123,6 +114,9 @@ function setup(opts: { changes?: boolean } = {}) { 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", + }), }), ), ), diff --git a/apps/cli/src/shared/migrations/pull-migrations.ts b/apps/cli/src/shared/migrations/pull-migrations.ts index 79303c4bec..1223caaf39 100644 --- a/apps/cli/src/shared/migrations/pull-migrations.ts +++ b/apps/cli/src/shared/migrations/pull-migrations.ts @@ -7,7 +7,6 @@ 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"; -import { MigrationRunner } from "./migration-runner.service.ts"; export type PullMigrationsInput = { readonly from?: string; @@ -18,17 +17,14 @@ export const pullMigrations = Effect.fn("migrations.pull")(function* (input: Pul const targets = yield* DatabaseTargetResolver; const engine = yield* PgDeltaSchemaEngine; const repository = yield* MigrationRepository; - const runner = yield* MigrationRunner; const remote = yield* targets.resolve(parseTargetSelector(input.from ?? "linked")); - const migrations = yield* repository.listLocal; const name = input.name ?? "remote_schema"; return yield* Effect.scoped( Effect.gen(function* () { const remotePool = yield* acquireDatabasePool(remote.connectionString); - const shadow = yield* engine.provisionShadow; + const shadow = yield* engine.provisionMigrations; const sourcePool = yield* acquireDatabasePool(shadow.url); - yield* runner.applyPending(sourcePool, migrations); const plan = yield* engine.diffPools({ sourcePool, desiredPool: remotePool, diff --git a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts index b4cec8d6d3..e464f20159 100644 --- a/apps/cli/src/shared/migrations/push-migrations.integration.test.ts +++ b/apps/cli/src/shared/migrations/push-migrations.integration.test.ts @@ -189,6 +189,10 @@ function setup( 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" }; + }), }), ), ); diff --git a/apps/cli/src/shared/migrations/push-migrations.ts b/apps/cli/src/shared/migrations/push-migrations.ts index f5155681a7..be9c019b82 100644 --- a/apps/cli/src/shared/migrations/push-migrations.ts +++ b/apps/cli/src/shared/migrations/push-migrations.ts @@ -61,11 +61,10 @@ export const pushMigrations = Effect.fn("migrations.push")(function* (input: Pus if (!input.skipVerify) { if (declarations.length > 0) { - const sourceShadow = yield* engine.provisionShadow; + const sourceShadow = yield* engine.provisionMigrations; const desiredShadow = yield* engine.provisionShadow; const sourcePool = yield* acquireDatabasePool(sourceShadow.url); const desiredPool = yield* acquireDatabasePool(desiredShadow.url); - yield* runner.applyPending(sourcePool, localFiles); const ahead = yield* engine.planFiles({ targetPool: sourcePool, shadowPool: desiredPool, diff --git a/apps/cli/src/shared/schema/apply-schema.integration.test.ts b/apps/cli/src/shared/schema/apply-schema.integration.test.ts index c0d865ad4c..d5bac96059 100644 --- a/apps/cli/src/shared/schema/apply-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/apply-schema.integration.test.ts @@ -190,6 +190,7 @@ function setup( provisionShadow: Effect.succeed({ url: "postgresql://postgres:postgres@127.0.0.1:1/postgres", }), + provisionMigrations: Effect.die("unused"), }), ), ), diff --git a/apps/cli/src/shared/schema/database-target.layer.integration.test.ts b/apps/cli/src/shared/schema/database-target.layer.integration.test.ts deleted file mode 100644 index 8d576012d3..0000000000 --- a/apps/cli/src/shared/schema/database-target.layer.integration.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { ProjectHome } from "../../next/config/project-home.service.ts"; -import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; -import { LinkedRemoteConnector } from "../database/linked-remote-connector.service.ts"; -import { noLocalDatabaseFallbackLayer } from "../database/local-database-fallback.service.ts"; -import { DatabaseTargetResolver } from "../database/database-target.service.ts"; -import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; -import { databaseTargetLayer } from "./database-target.layer.ts"; -import { SchemaLinkedConnectionError } from "./schema-errors.ts"; - -const linkedState = { - project: { - ref: "abcdefghijklmnop", - name: "demo", - organization_id: "org", - organization_slug: "org", - }, - active_branch: { ref: "abcdefghijklmnop", name: "main", is_default: true }, - fetchedAt: "2026-01-01T00:00:00.000Z", - versions: {}, -}; - -function setup(opts: { readonly connectorUrl?: string; readonly linked?: boolean } = {}) { - const deps = Layer.mergeAll( - BunServices.layer, - noLocalDatabaseFallbackLayer, - Layer.succeed( - ProjectHome, - ProjectHome.of({ - projectRoot: "/tmp/project", - supabaseDir: "/tmp/project/supabase", - projectHomeDir: "/tmp/project/.supabase", - projectLinkPath: "/tmp/project/.supabase/project.json", - projectLocalVersionsPath: "/tmp/project/.supabase/local-versions.json", - ensureProjectHomeDir: Effect.void, - }), - ), - Layer.succeed( - RuntimeInfo, - RuntimeInfo.of({ - cwd: "/tmp/project", - platform: process.platform, - arch: process.arch, - homeDir: "/tmp/home", - execPath: "/tmp/supabase", - pid: 1, - }), - ), - Layer.succeed( - CliConfig, - CliConfig.of({ - apiUrl: "https://api.supabase.com", - dashboardUrl: "https://supabase.com/dashboard", - projectHost: "supabase.co", - telemetryPosthogHost: "https://example.com", - telemetryPosthogKey: Option.none(), - accessToken: Option.none(), - noKeyring: Option.none(), - supabaseHome: "/tmp/home/.supabase", - debug: Option.none(), - telemetryDebug: Option.none(), - telemetryDisabled: Option.none(), - doNotTrack: Option.none(), - }), - ), - Layer.succeed( - ProjectLinkState, - ProjectLinkState.of({ - load: Effect.succeed(opts.linked === false ? Option.none() : Option.some(linkedState)), - save: () => Effect.void, - clear: Effect.void, - getActiveBranch: Effect.succeed(Option.some(linkedState.active_branch)), - setActiveBranch: () => Effect.void, - }), - ), - Layer.succeed( - LinkedRemoteConnector, - LinkedRemoteConnector.of({ - connect: (projectRef) => - opts.connectorUrl !== undefined - ? Effect.succeed(opts.connectorUrl) - : new SchemaLinkedConnectionError({ - detail: `Linked project ${projectRef} has no connection string in this environment.`, - suggestion: "Use the stable CLI.", - }), - }), - ), - ); - return databaseTargetLayer.pipe(Layer.provide(deps)); -} - -function restoreEnv(name: "DATABASE_URL" | "SUPABASE_DB_URL", previous: string | undefined) { - if (previous === undefined) { - delete process.env[name]; - } else { - process.env[name] = previous; - } -} - -describe("databaseTargetLayer linked resolve", () => { - it.live("treats DATABASE_URL as an unverifiable URL target even when unlinked", () => { - const previous = process.env["DATABASE_URL"]; - process.env["DATABASE_URL"] = "postgresql://postgres:secret@other.example/postgres"; - return Effect.gen(function* () { - const targets = yield* DatabaseTargetResolver; - const target = yield* targets.resolve({ kind: "linked" }); - expect(target.kind).toBe("url"); - expect(target.projectRef).toBeUndefined(); - expect(target.connectionSource).toBe("env"); - expect(target.identity).toBe("connection-string"); - }).pipe( - Effect.provide(setup({ linked: false })), - Effect.ensuring(Effect.sync(() => restoreEnv("DATABASE_URL", previous))), - ); - }); - - it.live("builds the socket from the linked connector when no env URL is set", () => { - const previousDb = process.env["DATABASE_URL"]; - const previousSupa = process.env["SUPABASE_DB_URL"]; - delete process.env["DATABASE_URL"]; - delete process.env["SUPABASE_DB_URL"]; - return Effect.gen(function* () { - const targets = yield* DatabaseTargetResolver; - const target = yield* targets.resolve({ kind: "linked" }); - expect(target.kind).toBe("linked"); - expect(target.projectRef).toBe("abcdefghijklmnop"); - expect(target.connectionString).toBe("postgresql://postgres:from-link@db.example/postgres"); - expect(target.connectionVerified).toBe(true); - }).pipe( - Effect.provide( - setup({ connectorUrl: "postgresql://postgres:from-link@db.example/postgres" }), - ), - Effect.ensuring( - Effect.sync(() => { - restoreEnv("DATABASE_URL", previousDb); - restoreEnv("SUPABASE_DB_URL", previousSupa); - }), - ), - ); - }); -}); diff --git a/apps/cli/src/shared/schema/database-target.layer.ts b/apps/cli/src/shared/schema/database-target.layer.ts deleted file mode 100644 index 002482696b..0000000000 --- a/apps/cli/src/shared/schema/database-target.layer.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Effect, FileSystem, Layer, Option, Path } from "effect"; -import { - connectLayer, - DEFAULT_MANAGED_STACK_NAME, - httpTransportClientLayer, - Stack, -} from "@supabase/stack/effect"; -import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { ProjectHome } from "../../next/config/project-home.service.ts"; -import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; -import { LocalDatabaseFallback } from "../database/local-database-fallback.service.ts"; -import { LinkedRemoteConnector } from "../database/linked-remote-connector.service.ts"; -import { DatabaseTargetResolver } from "../database/database-target.service.ts"; -import { envDatabaseUrl, type DatabaseTarget } from "../database/database-target.ts"; -import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; -import { SchemaLinkedConnectionError, SchemaLocalStackNotRunningError } from "./schema-errors.ts"; - -export const databaseTargetLayer = Layer.effect( - DatabaseTargetResolver, - Effect.gen(function* () { - const projectHome = yield* ProjectHome; - const runtimeInfo = yield* RuntimeInfo; - const cliConfig = yield* CliConfig; - const linkState = yield* ProjectLinkState; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const fallback = yield* LocalDatabaseFallback; - const linkedRemote = yield* LinkedRemoteConnector; - - const resolveLocal = Effect.gen(function* () { - const layer = yield* connectLayer({ - cwd: runtimeInfo.cwd, - cacheRoot: cliConfig.supabaseHome, - projectDir: projectHome.projectRoot, - name: DEFAULT_MANAGED_STACK_NAME, - }).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - // Anything else the managed-stack manager reports (unreadable state, control-plane - // conflicts) means the local stack cannot be used as a target right now — surface it - // in this resolver's own vocabulary instead of leaking the stack package's union. - Effect.mapError( - (cause) => - new SchemaLocalStackNotRunningError({ - detail: `Could not resolve the local Supabase stack: ${cause.message}`, - suggestion: "Run `supabase start`, then retry.", - }), - ), - ); - if (layer._tag === "None") { - const owned = yield* fallback.resolve; - if (Option.isSome(owned)) { - return owned.value; - } - return yield* new SchemaLocalStackNotRunningError({ - detail: "No local Supabase stack is running for this project.", - suggestion: "Run `supabase start`, then retry.", - }); - } - const stack = yield* Effect.provide(Stack, layer.value); - const info = yield* stack.getInfo(); - return { - kind: "local", - identity: `local:${DEFAULT_MANAGED_STACK_NAME}`, - connectionString: info.dbUrl, - disposable: true, - durable: false, - connectionVerified: true, - } satisfies DatabaseTarget; - }).pipe( - Effect.provide(httpTransportClientLayer), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - - 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 linked = yield* linkState.load.pipe( - Effect.mapError( - (error) => - new SchemaLinkedConnectionError({ - detail: error.detail, - suggestion: error.suggestion, - }), - ), - ); - if (linked._tag === "None") { - 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.", - }); - } - const connectionString = yield* linkedRemote.connect(linked.value.project.ref); - return { - kind: "linked", - identity: linked.value.project.ref, - connectionString, - disposable: false, - durable: true, - connectionVerified: true, - projectRef: linked.value.project.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/shared/schema/generate-schema.integration.test.ts b/apps/cli/src/shared/schema/generate-schema.integration.test.ts index 52ddbc4588..4f9ce1a44a 100644 --- a/apps/cli/src/shared/schema/generate-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/generate-schema.integration.test.ts @@ -3,7 +3,6 @@ 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 { MigrationRunner } from "../migrations/migration-runner.service.ts"; import { digestVersions } from "./schema-digest.ts"; import { generateSchema } from "./generate-schema.ts"; import { PgDeltaSchemaEngine } from "./pg-delta-engine.service.ts"; @@ -165,14 +164,6 @@ function setup( remove: () => Effect.die("unused"), }), ), - Layer.succeed( - MigrationRunner, - MigrationRunner.of({ - listRemote: () => Effect.succeed([]), - applyPending: () => Effect.succeed({ applied: [], skipped: [] }), - markApplied: () => Effect.die("unused"), - }), - ), Layer.succeed( PgDeltaSchemaEngine, PgDeltaSchemaEngine.of({ @@ -193,6 +184,12 @@ function setup( 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", + }; + }), }), ), ); diff --git a/apps/cli/src/shared/schema/generate-schema.ts b/apps/cli/src/shared/schema/generate-schema.ts index 687c051f3c..dbde59b6eb 100644 --- a/apps/cli/src/shared/schema/generate-schema.ts +++ b/apps/cli/src/shared/schema/generate-schema.ts @@ -1,7 +1,6 @@ import { Clock, Effect } from "effect"; import { acquireDatabasePool } from "../database/database-pool.ts"; import { MigrationRepository } from "../migrations/migration-repository.service.ts"; -import { MigrationRunner } from "../migrations/migration-runner.service.ts"; import { formatMigrationRepairCommand } from "../migrations/migration-repair-suggest.ts"; import { digestVersions } from "./schema-digest.ts"; import { @@ -27,7 +26,6 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen const state = yield* SchemaStateStore; const engine = yield* PgDeltaSchemaEngine; const migrations = yield* MigrationRepository; - const runner = yield* MigrationRunner; const declarations = yield* workspace.readDeclarationFiles; const localMigrations = yield* migrations.listLocal; @@ -60,16 +58,14 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen } } - const sourceShadow = yield* engine.provisionShadow; + 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); - if (!input.baseline) { - yield* runner.applyPending(sourcePool, localMigrations); - } - const plan = yield* engine.planFiles({ targetPool: sourcePool, shadowPool: desiredPool, @@ -126,12 +122,10 @@ export const generateSchema = Effect.fn("schema.generate")(function* (input: Gen }); const persistGenerated = Effect.gen(function* () { - const verifyShadow = yield* engine.provisionShadow; + const verifyShadow = yield* engine.provisionMigrations; const verifySource = yield* acquireDatabasePool(verifyShadow.url); const verifyDesired = yield* engine.provisionShadow; const verifyDesiredPool = yield* acquireDatabasePool(verifyDesired.url); - const allMigrations = yield* migrations.listLocal; - yield* runner.applyPending(verifySource, allMigrations); const verify = yield* engine.planFiles({ targetPool: verifySource, shadowPool: verifyDesiredPool, diff --git a/apps/cli/src/shared/schema/isolated-shadow.service.ts b/apps/cli/src/shared/schema/isolated-shadow.service.ts index 999c1c0d08..363989f8a4 100644 --- a/apps/cli/src/shared/schema/isolated-shadow.service.ts +++ b/apps/cli/src/shared/schema/isolated-shadow.service.ts @@ -4,7 +4,10 @@ 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< diff --git a/apps/cli/src/shared/schema/native-isolated-shadow-key.ts b/apps/cli/src/shared/schema/native-isolated-shadow-key.ts deleted file mode 100644 index d915af877c..0000000000 --- a/apps/cli/src/shared/schema/native-isolated-shadow-key.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createHash } from "node:crypto"; -import { readdirSync, readFileSync, type Dirent } from "node:fs"; -import { join } from "node:path"; - -const NATIVE_SHADOW_CACHE_FORMAT = "native-isolated-shadow-v2"; - -export const computeBaselineCacheKey = (input: { - readonly postgresVersion: string; - readonly autoExposeNewTables: boolean; - readonly artifactDigest: string; - readonly initScriptDigest: string; -}): string => - createHash("sha256") - .update(NATIVE_SHADOW_CACHE_FORMAT) - .update("\0") - .update(input.postgresVersion) - .update("\0") - .update(input.autoExposeNewTables ? "1" : "0") - .update("\0") - .update(input.artifactDigest) - .update("\0") - .update(input.initScriptDigest) - .digest("hex") - .slice(0, 16); - -export const digestArtifactTree = (root: string): string => { - const hash = createHash("sha256"); - const walk = (dir: string, prefix: string) => { - let entries: ReadonlyArray; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - const sorted = [...entries].sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of sorted) { - const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`; - const abs = join(dir, entry.name); - if (entry.isDirectory()) { - walk(abs, rel); - continue; - } - if (!entry.isFile()) continue; - hash.update(rel); - hash.update("\0"); - hash.update(readFileSync(abs)); - hash.update("\0"); - } - }; - walk(root, ""); - return hash.digest("hex"); -}; diff --git a/apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts b/apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts deleted file mode 100644 index ea672544f0..0000000000 --- a/apps/cli/src/shared/schema/native-isolated-shadow-key.unit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { computeBaselineCacheKey, digestArtifactTree } from "./native-isolated-shadow-key.ts"; - -describe("computeBaselineCacheKey", () => { - it("changes when the postgres version or expose flag changes", () => { - const base = { - postgresVersion: "17.6.1.158", - autoExposeNewTables: false, - artifactDigest: "abc", - initScriptDigest: "init-a", - }; - const key = computeBaselineCacheKey(base); - expect(key).toHaveLength(16); - expect(computeBaselineCacheKey({ ...base, postgresVersion: "17.6.1.200" })).not.toBe(key); - expect(computeBaselineCacheKey({ ...base, autoExposeNewTables: true })).not.toBe(key); - expect(computeBaselineCacheKey({ ...base, artifactDigest: "def" })).not.toBe(key); - expect(computeBaselineCacheKey({ ...base, initScriptDigest: "init-b" })).not.toBe(key); - }); -}); - -describe("digestArtifactTree", () => { - it("is stable for the same files and changes when contents change", () => { - const root = mkdtempSync(join(tmpdir(), "shadow-digest-")); - mkdirSync(join(root, "migrations"), { recursive: true }); - writeFileSync(join(root, "migrations", "01.sql"), "select 1;\n"); - const first = digestArtifactTree(root); - expect(digestArtifactTree(root)).toBe(first); - writeFileSync(join(root, "migrations", "01.sql"), "select 2;\n"); - expect(digestArtifactTree(root)).not.toBe(first); - }); -}); diff --git a/apps/cli/src/shared/schema/native-isolated-shadow.layer.ts b/apps/cli/src/shared/schema/native-isolated-shadow.layer.ts deleted file mode 100644 index 76dd6d3416..0000000000 --- a/apps/cli/src/shared/schema/native-isolated-shadow.layer.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { Effect, FileSystem, Layer, Option, Path } from "effect"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { loadProjectConfig } from "@supabase/config"; -import { - DEFAULT_VERSIONS, - planStackVersions, - renderNativePostgresInitScript, - resolveNativeBinary, -} from "@supabase/stack/effect"; -import { createHash } from "node:crypto"; -import Net from "node:net"; -import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { ProjectHome } from "../../next/config/project-home.service.ts"; -import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; -import { ProjectLocalServiceVersions } from "../../next/config/project-local-service-versions.service.ts"; -import { IsolatedShadowProvisioner } from "./isolated-shadow.service.ts"; -import { SchemaEngineError } from "./schema-errors.ts"; -import { computeBaselineCacheKey, digestArtifactTree } from "./native-isolated-shadow-key.ts"; - -const NATIVE_POSTGRES_RUNTIME_ARGS = [ - "-c", - "wal_level=logical", - "-c", - "max_wal_senders=5", - "-c", - "max_replication_slots=5", - "-c", - "listen_addresses=127.0.0.1", -] as const; - -const READY_ATTEMPTS = 150; -const READY_DELAY = "200 millis" as const; -const LOCK_ATTEMPTS = 900; -const LOCK_DELAY = "100 millis" as const; - -const isPidAlive = (pid: number) => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -const shadowError = (detail: string, suggestion: string) => - new SchemaEngineError({ detail, suggestion }); - -const toShadowError = (error: unknown) => { - if (error instanceof SchemaEngineError) return error; - if (typeof error === "object" && error !== null && "_tag" in error) { - const tag = String(Reflect.get(error, "_tag")); - const detailValue = Reflect.get(error, "detail"); - const detail = typeof detailValue === "string" ? detailValue : tag; - return shadowError(detail, "Retry the command."); - } - return shadowError(error instanceof Error ? error.message : String(error), "Retry the command."); -}; - -const postgresEnv = ( - postgresDir: string, - extra: Record = {}, -): Record => ({ - ...process.env, - DYLD_LIBRARY_PATH: `${postgresDir}/lib`, - LD_LIBRARY_PATH: `${postgresDir}/lib`, - PGPASSWORD: "postgres", - POSTGRES_PASSWORD: "postgres", - ...extra, -}); - -const cacheEnabled = () => { - const value = process.env["SUPABASE_SHADOW_CACHE"]; - return value !== "0" && value !== "false"; -}; - -const allocatePort = Effect.callback((resume) => { - const server = Net.createServer(); - server.once("error", (error) => - resume( - Effect.fail( - shadowError(`Failed to allocate a shadow port: ${error.message}`, "Retry the command."), - ), - ), - ); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => { - if (port > 0) { - resume(Effect.succeed(port)); - return; - } - resume(Effect.fail(shadowError("Failed to allocate a shadow port.", "Retry the command."))); - }); - }); -}); - -const startCommand = (postgresDir: string, dataDir: string, port: number) => - ChildProcess.make( - "bash", - [ - `${postgresDir}/share/supabase-cli/bin/supabase-postgres-init.sh`, - "-p", - String(port), - ...NATIVE_POSTGRES_RUNTIME_ARGS, - ], - { env: postgresEnv(postgresDir, { PGDATA: dataDir }) }, - ); - -export const nativeIsolatedShadowLayer = Layer.effect( - IsolatedShadowProvisioner, - Effect.gen(function* () { - const cliConfig = yield* CliConfig; - const projectHome = yield* ProjectHome; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const httpClient = yield* HttpClient.HttpClient; - const projectLink = yield* ProjectLinkState; - const localVersions = yield* ProjectLocalServiceVersions; - - const requireExitZero = (command: ChildProcess.Command, detail: string) => - Effect.gen(function* () { - const code = yield* spawner - .exitCode(command) - .pipe( - Effect.mapError((error) => - shadowError(`${detail}: ${error.message}`, "Retry the command."), - ), - ); - if (code !== 0) { - return yield* shadowError(`${detail} (exit ${code}).`, "Retry the command."); - } - }); - - const retry = ( - effect: Effect.Effect, - attempts: number, - delay: typeof READY_DELAY | typeof LOCK_DELAY, - ) => - Effect.gen(function* () { - for (let attempt = 0; attempt < attempts; attempt++) { - const result = yield* Effect.result(effect); - if (result._tag === "Success") return result.success; - if (attempt === attempts - 1) { - return yield* Effect.fail(result.failure); - } - yield* Effect.sleep(delay); - } - return yield* shadowError("Retry budget exhausted.", "Retry the command."); - }); - - const waitReady = (postgresDir: string, port: number) => - retry( - requireExitZero( - ChildProcess.make( - `${postgresDir}/bin/pg_isready`, - ["-h", "127.0.0.1", "-p", String(port), "-U", "postgres"], - { env: postgresEnv(postgresDir) }, - ), - "Isolated shadow Postgres did not become ready", - ), - READY_ATTEMPTS, - READY_DELAY, - ); - - const applyPlatformBaseline = ( - postgresDir: string, - port: number, - autoExposeNewTables: boolean, - ) => - requireExitZero( - ChildProcess.make( - "bash", - [ - "-c", - renderNativePostgresInitScript({ - postgresDir, - dbPort: port, - autoExposeNewTables, - }), - ], - { env: postgresEnv(postgresDir) }, - ), - "Failed to apply the platform baseline to the isolated shadow", - ); - - const startOwnedPostgres = (postgresDir: string, dataDir: string, port: number) => - Effect.gen(function* () { - const handle = yield* spawner - .spawn(startCommand(postgresDir, dataDir, port)) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to start isolated shadow Postgres: ${error.message}`, - "Retry the command.", - ), - ), - ); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - yield* waitReady(postgresDir, port); - }); - - const provision = Effect.gen(function* () { - const localState = yield* localVersions.load.pipe(Effect.mapError(toShadowError)); - const linkedState = yield* projectLink.load.pipe(Effect.mapError(toShadowError)); - const versions = planStackVersions({ - candidateBaseline: Option.match(linkedState, { - onNone: () => undefined, - onSome: (state) => state.versions, - }), - localOverrides: Option.match(localState, { - onNone: () => undefined, - onSome: (state) => state.versions, - }), - }); - const postgresVersion = versions.runtimeVersions.postgres ?? DEFAULT_VERSIONS.postgres; - const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.mapError(toShadowError), - ); - const autoExposeNewTables = loadedConfig?.config.api.auto_expose_new_tables ?? false; - const postgresDir = yield* resolveNativeBinary(cliConfig.supabaseHome, { - service: "postgres", - version: postgresVersion, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.mapError((error) => - shadowError( - `Failed to resolve native Postgres ${postgresVersion}: ${error._tag}`, - "Run `supabase start` once so the native Postgres binary is cached, then retry.", - ), - ), - ); - - const artifactDigest = digestArtifactTree(`${postgresDir}/share/supabase-cli`); - const key = computeBaselineCacheKey({ - postgresVersion, - autoExposeNewTables, - artifactDigest, - initScriptDigest: createHash("sha256") - .update(renderNativePostgresInitScript.toString()) - .digest("hex"), - }); - const cacheRoot = path.join(cliConfig.supabaseHome, "cache", "native-shadow-baseline"); - const archivePath = path.join(cacheRoot, `shadow-baseline-${key}.tar`); - const dataDir = yield* fs - .makeTempDirectory({ prefix: "pgdelta-isolated-" }) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to create an isolated shadow directory: ${error.message}`, - "Check disk space and retry.", - ), - ), - ); - yield* Effect.addFinalizer(() => - fs.remove(dataDir, { recursive: true, force: true }).pipe(Effect.ignore), - ); - - const publishBaseline = Effect.scoped( - Effect.gen(function* () { - const buildDir = yield* fs - .makeTempDirectory({ prefix: "pgdelta-baseline-" }) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to create a baseline build directory: ${error.message}`, - "Check disk space and retry.", - ), - ), - ); - yield* Effect.addFinalizer(() => - fs.remove(buildDir, { recursive: true, force: true }).pipe(Effect.ignore), - ); - const buildData = path.join(buildDir, "data"); - yield* fs - .makeDirectory(buildData, { recursive: true }) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to create baseline PGDATA: ${error.message}`, - "Check disk space and retry.", - ), - ), - ); - const port = yield* allocatePort; - yield* startOwnedPostgres(postgresDir, buildData, port); - yield* applyPlatformBaseline(postgresDir, port, autoExposeNewTables); - yield* requireExitZero( - ChildProcess.make( - `${postgresDir}/bin/pg_ctl`, - ["stop", "-D", buildData, "-m", "fast", "-w"], - { env: postgresEnv(postgresDir) }, - ), - "Failed to stop the baseline Postgres after snapshotting", - ); - yield* fs - .makeDirectory(cacheRoot, { recursive: true }) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to create the shadow baseline cache: ${error.message}`, - "Check permissions on SUPABASE_HOME and retry.", - ), - ), - ); - const staging = path.join(cacheRoot, `shadow-baseline-${key}.${process.pid}.partial`); - yield* Effect.addFinalizer(() => fs.remove(staging, { force: true }).pipe(Effect.ignore)); - yield* requireExitZero( - ChildProcess.make("tar", ["-cf", staging, "-C", buildData, "."]), - "Failed to snapshot the isolated shadow baseline", - ); - yield* fs - .rename(staging, archivePath) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to publish the shadow baseline cache: ${error.message}`, - "Retry the command.", - ), - ), - ); - }), - ); - - const withCacheLock = (effect: Effect.Effect) => - Effect.gen(function* () { - yield* fs - .makeDirectory(cacheRoot, { recursive: true }) - .pipe( - Effect.mapError((error) => - shadowError( - `Failed to create the shadow baseline cache: ${error.message}`, - "Check permissions on SUPABASE_HOME and retry.", - ), - ), - ); - const lockDir = path.join(cacheRoot, `.lock-${key}`); - const pidPath = path.join(lockDir, "pid"); - const acquireLock = Effect.gen(function* () { - const created = yield* Effect.result( - fs - .makeDirectory(lockDir) - .pipe( - Effect.mapError((error) => - shadowError( - `Waiting for the isolated shadow baseline cache lock: ${error.message}`, - "Retry the command.", - ), - ), - ), - ); - if (created._tag === "Success") { - yield* fs.writeFileString(pidPath, String(process.pid)).pipe( - Effect.tapError(() => - fs.remove(lockDir, { recursive: true, force: true }).pipe(Effect.ignore), - ), - Effect.mapError((error) => - shadowError( - `Failed to write the isolated shadow baseline cache lock: ${error.message}`, - "Retry the command.", - ), - ), - ); - return; - } - const holder = Number.parseInt( - yield* fs.readFileString(pidPath).pipe(Effect.orElseSucceed(() => "")), - 10, - ); - if (Number.isInteger(holder) && holder > 0 && !isPidAlive(holder)) { - yield* fs.remove(lockDir, { recursive: true, force: true }).pipe(Effect.ignore); - } - return yield* Effect.fail(created.failure); - }); - yield* retry(acquireLock, LOCK_ATTEMPTS, LOCK_DELAY); - return yield* effect.pipe( - Effect.ensuring( - fs.remove(lockDir, { recursive: true, force: true }).pipe(Effect.ignore), - ), - ); - }); - - if (cacheEnabled()) { - yield* withCacheLock( - Effect.gen(function* () { - const exists = yield* fs.exists(archivePath).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - yield* publishBaseline; - } - }), - ); - yield* requireExitZero( - ChildProcess.make("tar", ["-xf", archivePath, "-C", dataDir]), - "Failed to restore the isolated shadow baseline", - ).pipe(Effect.tapError(() => fs.remove(archivePath, { force: true }).pipe(Effect.ignore))); - const port = yield* allocatePort; - yield* startOwnedPostgres(postgresDir, dataDir, port); - return { url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres` }; - } - - const port = yield* allocatePort; - yield* startOwnedPostgres(postgresDir, dataDir, port); - yield* applyPlatformBaseline(postgresDir, port, autoExposeNewTables); - return { url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres` }; - }).pipe(Effect.mapError(toShadowError)); - - return IsolatedShadowProvisioner.of({ provision }); - }), -); diff --git a/apps/cli/src/shared/schema/pg-delta-engine.layer.ts b/apps/cli/src/shared/schema/pg-delta-engine.layer.ts index d1a06b4438..9dccccf79e 100644 --- a/apps/cli/src/shared/schema/pg-delta-engine.layer.ts +++ b/apps/cli/src/shared/schema/pg-delta-engine.layer.ts @@ -190,6 +190,7 @@ export const pgDeltaSchemaEngineLayer = Layer.effect( ), }), 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 index 617b9e956a..d1d5f658f0 100644 --- a/apps/cli/src/shared/schema/pg-delta-engine.service.ts +++ b/apps/cli/src/shared/schema/pg-delta-engine.service.ts @@ -46,6 +46,7 @@ interface PgDeltaSchemaEngineShape { input: SchemaApplyPlanInput, ) => Effect.Effect; readonly provisionShadow: Effect.Effect; + readonly provisionMigrations: Effect.Effect; } export class PgDeltaSchemaEngine extends Context.Service< diff --git a/apps/cli/src/shared/schema/prepare-declarative-shadow.ts b/apps/cli/src/shared/schema/prepare-declarative-shadow.ts index e501f39714..fd600d14e7 100644 --- a/apps/cli/src/shared/schema/prepare-declarative-shadow.ts +++ b/apps/cli/src/shared/schema/prepare-declarative-shadow.ts @@ -30,7 +30,8 @@ const queryError = (sql: string, cause: unknown) => 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 native shadow baseline cache.", + suggestion: + "Retry the command. If it persists, delete the Docker shadow baseline cache under ~/.supabase/cache/shadow-baseline.", }); const readServerVersion = (rows: ReadonlyArray): string => { diff --git a/apps/cli/src/shared/schema/pull-schema.integration.test.ts b/apps/cli/src/shared/schema/pull-schema.integration.test.ts index ca42ef2f2d..0bfd17b1c5 100644 --- a/apps/cli/src/shared/schema/pull-schema.integration.test.ts +++ b/apps/cli/src/shared/schema/pull-schema.integration.test.ts @@ -42,6 +42,7 @@ function mockEngine(files: Array<{ name: string; sql: string }>) { diffPools: () => Effect.die("unused"), applyPlan: () => Effect.die("unused"), provisionShadow: Effect.die("unused"), + provisionMigrations: Effect.die("unused"), }), ); } diff --git a/apps/cli/src/shared/schema/schema-paths.ts b/apps/cli/src/shared/schema/schema-paths.ts index 359827024a..e0c54c478a 100644 --- a/apps/cli/src/shared/schema/schema-paths.ts +++ b/apps/cli/src/shared/schema/schema-paths.ts @@ -4,4 +4,3 @@ 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"; -export const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; diff --git a/apps/cli/src/shared/schema/schema-runtime.layer.ts b/apps/cli/src/shared/schema/schema-runtime.layer.ts deleted file mode 100644 index 21fceff005..0000000000 --- a/apps/cli/src/shared/schema/schema-runtime.layer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { httpTransportClientLayer } from "@supabase/stack/effect"; -import { projectLinkStateLayer } from "../../next/config/project-link-state.layer.ts"; -import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; -import { projectLocalServiceVersionsLayer } from "../../next/config/project-local-service-versions.layer.ts"; -import { - provideProjectCommandRuntime, - type ProjectCommandRuntimeOptions, -} from "../../next/config/project-runtime.layer.ts"; -import { ProjectHome } from "../../next/config/project-home.service.ts"; -import { - noLocalDatabaseFallbackLayer, - type LocalDatabaseFallback, -} from "../database/local-database-fallback.service.ts"; -import { - failClosedLinkedRemoteConnectorLayer, - LinkedRemoteConnector, -} from "../database/linked-remote-connector.service.ts"; -import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; -import { databaseTargetLayer } from "./database-target.layer.ts"; -import { nativeIsolatedShadowLayer } from "./native-isolated-shadow.layer.ts"; -import { schemaWorkspaceLayer } from "./schema-workspace.layer.ts"; -import { schemaStateLayer } from "./schema-state.layer.ts"; -import { pgDeltaSchemaEngineLayer } from "./pg-delta-engine.layer.ts"; -import { migrationRepositoryLayer } from "../migrations/migration-repository.layer.ts"; -import { migrationRunnerLayer } from "../migrations/migration-runner.layer.ts"; - -const workspaceFromProjectHome = Layer.unwrap( - Effect.gen(function* () { - const projectHome = yield* ProjectHome; - return schemaWorkspaceLayer({ - projectRoot: projectHome.projectRoot, - supabaseDir: projectHome.supabaseDir, - projectHomeDir: projectHome.projectHomeDir, - }); - }), -); - -export type SchemaRuntimeOptions = ProjectCommandRuntimeOptions & { - readonly localDatabaseFallback?: Layer.Layer< - LocalDatabaseFallback, - never, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner - >; - readonly projectLinkState?: Layer.Layer< - ProjectLinkState, - never, - FileSystem.FileSystem | Path.Path | ProjectHome - >; - readonly linkedRemoteConnector?: Layer.Layer; -}; - -export const schemaRuntimeLayer = ( - commandPath: ReadonlyArray, - options?: SchemaRuntimeOptions, -) => { - const linkState = options?.projectLinkState ?? projectLinkStateLayer; - const linkedRemote = options?.linkedRemoteConnector ?? failClosedLinkedRemoteConnectorLayer; - const schemaEngineLive = pgDeltaSchemaEngineLayer.pipe( - Layer.provide(nativeIsolatedShadowLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(projectLocalServiceVersionsLayer), - Layer.provide(linkState), - ); - return provideProjectCommandRuntime( - Layer.mergeAll( - workspaceFromProjectHome, - schemaStateLayer.pipe(Layer.provide(workspaceFromProjectHome)), - migrationRepositoryLayer.pipe(Layer.provide(workspaceFromProjectHome)), - migrationRunnerLayer, - schemaEngineLive, - databaseTargetLayer.pipe( - Layer.provide(options?.localDatabaseFallback ?? noLocalDatabaseFallbackLayer), - Layer.provide(linkState), - Layer.provide(httpTransportClientLayer), - Layer.provide(linkedRemote), - ), - linkedRemote, - linkState, - projectLocalServiceVersionsLayer, - httpTransportClientLayer, - commandRuntimeLayer(commandPath), - ), - options, - ); -}; diff --git a/docs/cli/dev-alpha-command-structure.md b/docs/cli/dev-alpha-command-structure.md index b0fac46f9e..d6af0cba00 100644 --- a/docs/cli/dev-alpha-command-structure.md +++ b/docs/cli/dev-alpha-command-structure.md @@ -4,7 +4,7 @@ This document defines the command structure for the new Supabase CLI. -The `schema` and `migrations` verbs now ship on the stable (legacy) shell as well as next (alpha). Singular `migration` on stable remains the Go-parity group. +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. diff --git a/docs/cli/schema-first-v1-plan.md b/docs/cli/schema-first-v1-plan.md index 4f8a3274d5..a0b9f7b54c 100644 --- a/docs/cli/schema-first-v1-plan.md +++ b/docs/cli/schema-first-v1-plan.md @@ -13,8 +13,8 @@ It is the working spec for the `feat/implement-rfc-schema-first-development` bra 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. The same verbs also stay on next (alpha). 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 native Postgres clusters** with a cached platform baseline — not co-located `CREATE DATABASE` shadows. `planSchemaFiles` always uses `isolatedShadow: true`. +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) @@ -28,25 +28,22 @@ It is the working spec for the `feat/implement-rfc-schema-first-development` bra | 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 native Postgres (same binaries as `start`), snapshotted under `$SUPABASE_HOME/cache/native-shadow-baseline/`. Co-located shadows are not used on this path. | +| 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, runtime -apps/cli/src/shared/migrations/ repository, history, runner, use cases +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/ -apps/cli/src/next/commands/schema/ same verbs on alpha -apps/cli/src/next/commands/migrations/ -apps/cli/src/next/commands/db/ compatibility aliases only -apps/cli/src/next/commands/migration/ compatibility aliases only ``` - `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 and schema runtime live in `shared/` so both shells can call them. +- 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) @@ -64,19 +61,7 @@ apps/cli/src/next/commands/migration/ compatibility aliases only | `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 | -### Aliases (next only, deprecation notice on stderr) - -| Alias | Target | -| ----- | ------ | -| `db diff` | `migrations diff` | -| `db push` | `migrations push` | -| `db pull` (default) | `migrations pull` | -| `db pull --declarative` | `schema pull --from linked` | -| `db schema declarative generate` | `schema pull` | -| `db schema declarative sync` | `schema generate` (`--apply` also runs `schema apply`) | -| `migration new` / `migrations` already | `migrations new` | -| `migration list` | `migrations list` | -| `migration up` | `migrations apply` | +Go-parity `db` / singular `migration` commands are unchanged on stable. This prototype does not add `next/` aliases or deprecations. ## Safety @@ -84,7 +69,7 @@ apps/cli/src/next/commands/migration/ compatibility aliases only - `--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. Next/alpha stays fail-closed unless an env URL + `--allow-remote` is supplied. +- 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. @@ -97,4 +82,5 @@ apps/cli/src/next/commands/migration/ compatibility aliases only - 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" diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 6b8e7a4177..56ca2752cf 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -146,10 +146,6 @@ export type { PartialVersionManifest } from "./versions.ts"; export { PartialVersionManifestSchema } from "./versions.ts"; export { resolveConfig } from "./StackConfigResolver.ts"; -export type { BinarySpec } from "./BinaryResolver.ts"; -export { resolveNativeBinary } from "./resolve-native-binary.ts"; -export { renderNativePostgresInitScript } from "./services/postgres-init.ts"; - export { DaemonStartError } from "./layers.ts"; export type { ManagedDaemonConfigInput } from "./layers.ts"; export type { StackSummary } from "./discovery.ts"; diff --git a/packages/stack/src/resolve-native-binary.ts b/packages/stack/src/resolve-native-binary.ts deleted file mode 100644 index 74c53b6cc4..0000000000 --- a/packages/stack/src/resolve-native-binary.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Effect, FileSystem, Path } from "effect"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { BinaryResolver, type BinarySpec } from "./BinaryResolver.ts"; -import type { BinaryNotFoundError, ChecksumMismatchError, DownloadError } from "./errors.ts"; - -export const resolveNativeBinary = ( - cacheRoot: string, - spec: BinarySpec, -): Effect.Effect< - string, - BinaryNotFoundError | DownloadError | ChecksumMismatchError, - | FileSystem.FileSystem - | Path.Path - | HttpClient.HttpClient - | ChildProcessSpawner.ChildProcessSpawner -> => - Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve(spec); - }).pipe(Effect.provide(BinaryResolver.make(cacheRoot))); diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index c2e3bbd768..63917352b1 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -27,12 +27,9 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `.trim(); -export const renderNativePostgresInitScript = (opts: { - readonly postgresDir: string; - readonly dbPort: number; - readonly autoExposeNewTables: boolean; -}): string => { +export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { const pgBinDir = `${opts.postgresDir}/bin`; + const pgLibDir = `${opts.postgresDir}/lib`; const migrationsDir = `${opts.postgresDir}/share/supabase-cli/migrations`; const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; @@ -51,7 +48,7 @@ EOSQL // Replaces calling migrate.sh (which spawns ~57 separate psql processes) with // chained -f flags that run all SQL files in a single psql session, cutting // postgres-init time from ~5s to ~1s. - return ` + const script = ` export PATH="${pgBinDir}:$PATH" export PGPASSWORD=postgres db="${migrationsDir}" @@ -131,14 +128,11 @@ END \\$\\$; " `; -}; -export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { - const pgLibDir = `${opts.postgresDir}/lib`; return { name: "postgres-init", command: "bash", - args: ["-c", renderNativePostgresInitScript(opts)], + args: ["-c", script], env: { DYLD_LIBRARY_PATH: pgLibDir, LD_LIBRARY_PATH: pgLibDir,