From 21ac3aa20d0fdec26a5189a4081ba93770996395 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 15:16:12 +0200 Subject: [PATCH 01/14] test(cli): allow cache-key checks time under load --- .../src/command-internal/db-bootstrap/shadow-cache.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 09336501bb..5ca9dfad05 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -61,7 +61,7 @@ describe("shadowCacheKey", () => { expect(shadowBaselineTarFileName(first)).toBe(`shadow-baseline-${first}.tar`); }); - it("changes when ANY baked-in input changes", () => { + it("changes when ANY baked-in input changes", { timeout: 30_000 }, () => { const base = baseKeyInputs(); const mutations: ReadonlyArray<{ readonly label: string; From 030eff7e194cec8affcd956cbb2303c7a57c5f80 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 15:16:14 +0200 Subject: [PATCH 02/14] feat(cli): add stack command options and destruction --- apps/cli/docs/stack-commands.md | 24 +- .../stack/destroy/SIDE_EFFECTS.md | 21 ++ .../stack/destroy/destroy.command.ts | 34 +++ .../stack/destroy/destroy.errors.ts | 26 +++ .../stack/destroy/destroy.handler.ts | 131 +++++++++++ .../stack/destroy/destroy.integration.test.ts | 164 +++++++++++++ .../stack/stack-backend.integration.test.ts | 2 +- ...tack-command-telemetry.integration.test.ts | 31 ++- .../experimental/stack/stack.command.ts | 6 +- .../experimental/stack/stack.shared.ts | 7 + .../experimental/stack/start/SIDE_EFFECTS.md | 6 + .../experimental/stack/start/start.command.ts | 14 +- .../experimental/stack/start/start.handler.ts | 111 +++++++-- .../stack/start/start.integration.test.ts | 219 +++++++++++++++++- .../experimental/stack/start/start.options.ts | 12 + .../experimental/stack/stop/SIDE_EFFECTS.md | 13 +- .../experimental/stack/stop/stop.command.ts | 4 + .../experimental/stack/stop/stop.handler.ts | 47 +++- .../stack/stop/stop.integration.test.ts | 167 ++++++++++++- .../telemetry/__fixtures__/error-tags.txt | 1 + packages/stack/src/index.ts | 3 + packages/stack/src/public/EffectStack.ts | 76 +++++- packages/stack/src/public/PromiseStack.ts | 15 +- .../public/effect-stack.integration.test.ts | 24 ++ packages/stack/src/public/index.ts | 11 +- 25 files changed, 1115 insertions(+), 54 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start.options.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 971b9f047c..30c4cfc991 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -4,10 +4,11 @@ command interface may change, and it is excluded from the CLI compatibility promise. It is available regardless of the project's backend setting and supports both Docker and native runtimes. -| Command | Purpose | -| ---------------------- | -------------------------------------- | -| `supabase stack start` | Create or resume the project's stack. | -| `supabase stack stop` | Stop a stack while retaining its data. | +| Command | Purpose | +| ------------------------ | ------------------------------------------ | +| `supabase stack start` | Create or resume the project's stack. | +| `supabase stack destroy` | Permanently delete one stack and its data. | +| `supabase stack stop` | Stop a stack while retaining its data. | Use each command's `--help` for its available targeting and runtime options. @@ -50,3 +51,18 @@ and seed configuration are separate from importing legacy database data. The flag is local CLI configuration in `supabase/config.toml` and is excluded from hosted project configuration. Routing reads that exact file after applying the CLI's working-directory rules, including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enable the flag. + +## Service selection and shutdown + +`supabase stack start --exclude studio,analytics -x mail` disables those services in the effective +start configuration without changing the project file. Valid names are `rest`, `auth`, `realtime`, +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; the database is required. The +effective configuration is retained in stack state, so starting without `--exclude` restores the +project's configured services. + +`supabase stack stop --all` stops every readable managed stack while preserving data. It continues +after unreadable entries or individual stop failures, reports warnings and counts, and exits +nonzero when anything was skipped or failed. Registry-root enumeration errors remain fatal. + +`supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after +confirmation. Use `--yes` for unattended execution. There is no bulk destroy option. diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md new file mode 100644 index 0000000000..80f59e0ceb --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -0,0 +1,21 @@ +# `supabase stack destroy` + +Permanently stops and removes one managed stack, including its persisted data. + +The command targets the current project stack by default, or an explicit `--stack` name or +`--stack-id`. It requires interactive confirmation; `--yes` is required for non-interactive and +machine-readable invocations. `SUPABASE_YES` participates in the existing confirmation setting; +an explicit `--yes=false` overrides it. It never accepts `--all`. + +The stack package reads the selected descriptor and removes resources and state under +`${SUPABASE_HOME:-~/.supabase}/managed/stacks/`. It owns stopping the Supervisor, removing +native processes or containers, and deleting persistent stack data. The CLI does not delete paths +or Docker resources itself and makes no Management API calls. Project files are retained. + +The confirmation prompt identifies the stack name, project directory, and immutable stack ID. +Rejection or missing noninteractive confirmation performs no destructive operation. Text output +reports the destroyed stack ID. JSON and stream-JSON return +`{ "destroyed": true, "id": "..." }`. Success exits `0`; invalid targets, confirmation refusal, +and destruction failures exit `1`; interruption follows the command runtime's interruption exit. +Standard command instrumentation records command metadata without exporting credentials, and +telemetry state flushes after both successful and failed runs. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts new file mode 100644 index 0000000000..5b6c44198d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.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 { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; +import { stackDestroy } from "./destroy.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe( + Flag.withDescription( + "Destroy the stack with this name (defaults to the current project stack).", + ), + Flag.optional, + ), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Destroy an existing stack by id."), + Flag.optional, + ), +} as const; + +export type StackDestroyFlags = CliCommand.Command.Config.Infer; + +export const stackDestroyCommand = Command.make("destroy", config).pipe( + Command.withDescription("Permanently destroy a managed local Supabase stack and its data."), + Command.withShortDescription("Destroy a managed local stack"), + Command.withExamples([ + { + command: "supabase stack destroy --stack feature-a --yes", + description: "Permanently destroy the feature-a stack", + }, + ]), + Command.withHandler((flags) => + stackDestroy(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts new file mode 100644 index 0000000000..c627b89a33 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -0,0 +1,26 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStackDestroyError")<{ + readonly reason: "flags" | "confirmation" | "invalid-config" | "lifecycle" | "unknown"; + readonly message: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "flags": + case "confirmation": + return actionability.provideFlags; + case "invalid-config": + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts new file mode 100644 index 0000000000..42f680c5b5 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -0,0 +1,131 @@ +import { Effect, Match, Option } from "effect"; +import { isStackError, StackIdSchema } from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { OutputFlag, resolveYes } from "../../../../command-internal/global-flags.ts"; +import { promptYesNo } from "../../../../command-internal/prompt-yes-no.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; +import { + StackApi, + StackTargetError, + rejectStackOutput, + validateStackId, + validateStackTarget, +} from "../stack.shared.ts"; +import type { StackDestroyFlags } from "./destroy.command.ts"; +import { StackCommandDestroyError } from "./destroy.errors.ts"; + +const mapTargetError = (error: StackTargetError) => + new StackCommandDestroyError({ + reason: error.reason, + message: error.message, + suggestion: error.suggestion, + cause: error, + }); + +const destroyError = (error: unknown): StackCommandDestroyError => { + const stackError = isStackError(error) ? error : undefined; + const reason = + stackError === undefined + ? "unknown" + : Match.value(stackError).pipe( + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => "flags" as const), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackRuntimeError", + "StackCleanupError", + "StackDestructionError", + "StackUpgradeRequiredError", + () => "lifecycle" as const, + ), + Match.tag( + "InvalidStackConfigError", + "StackStateFormatUnsupportedError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => "invalid-config" as const, + ), + Match.orElse(() => "unknown" as const), + ); + return new StackCommandDestroyError({ + reason, + message: stackError?.message ?? String(error), + cause: error, + }); +}; + +export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( + flags: StackDestroyFlags, +) { + const telemetryState = yield* TelemetryState; + const body = Effect.gen(function* () { + const output = yield* Output; + const settings = yield* CommandSettings; + const api = yield* StackApi; + const outputFlag = yield* Effect.serviceOption(OutputFlag); + yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + yield* validateStackTarget({ + stack: Option.getOrUndefined(flags.stack), + stackId: Option.getOrUndefined(flags.stackId), + }).pipe(Effect.mapError(mapTargetError)); + + const target = yield* Effect.gen(function* () { + if (Option.isSome(flags.stackId)) { + const id = yield* validateStackId(flags.stackId.value).pipe( + Effect.mapError(mapTargetError), + ); + return yield* api.inspectStack(id).pipe( + Effect.map(({ descriptor }) => descriptor), + Effect.mapError(destroyError), + ); + } + const found = yield* api + .findStack({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + }) + .pipe(Effect.mapError(destroyError)); + if (Option.isSome(found)) return found.value; + return yield* new StackCommandDestroyError({ + reason: "flags", + message: `No managed stack${Option.isSome(flags.stack) ? ` named "${flags.stack.value}"` : ""} was found for this project.`, + suggestion: "Choose an existing --stack name or omit --stack for the current project.", + }); + }); + const yes = yield* resolveYes; + const tty = yield* Tty; + if (!yes && (!tty.stdinIsTty || output.format !== "text")) + return yield* new StackCommandDestroyError({ + reason: "confirmation", + message: "Destroying a stack requires confirmation; rerun with --yes.", + suggestion: "Pass --yes when running non-interactively or in a machine-readable format.", + }); + const confirmed = yield* promptYesNo( + output, + yes, + `Permanently destroy stack "${target.name}" at ${target.projectRoot} (${target.id}) and all of its data?`, + false, + ); + if (!confirmed) + return yield* new StackCommandDestroyError({ + reason: "confirmation", + message: "Stack destruction was not confirmed.", + }); + const stack = yield* api + .openStack(StackIdSchema.make(target.id)) + .pipe(Effect.mapError(destroyError)); + const destroying = yield* output.task(`Destroying stack ${target.id}...`); + yield* stack.destroy().pipe( + Effect.tapError((error) => destroying.fail(error.message)), + Effect.tap(() => destroying.clear()), + Effect.mapError(destroyError), + ); + if (output.format === "text") yield* output.raw(`Stack ${target.id} destroyed.\n`); + else yield* output.success("", { destroyed: true, id: target.id }); + }); + return yield* body.pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts new file mode 100644 index 0000000000..680ef80411 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -0,0 +1,164 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; +import type { EffectStack } from "@supabase/stack/effect"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { + mockOutput, + mockStdin, + mockTty, + processEnvLayer, +} from "../../../../../tests/helpers/mocks.ts"; +import { StackApi } from "../stack.shared.ts"; +import { stackDestroy } from "./destroy.handler.ts"; +import { StackCommandDestroyError } from "./destroy.errors.ts"; + +const id = StackIdSchema.make("a".repeat(64)); +const descriptor = { + id, + projectRoot: "/project", + name: "feature-a", + branchContext: "ordinary-workspace" as const, + runtime: { kind: "native" as const }, + desiredLifecycle: "stopped" as const, +}; + +const flags = (stackId = Option.none(), stack = Option.none()) => ({ + stack, + stackId, +}); + +function setup(options: { + yes: boolean; + destroyFailure?: boolean; + interactive?: boolean; + promptConfirmResponses?: ReadonlyArray; + outputFormat?: "text" | "json"; + found?: boolean; +}) { + const output = mockOutput({ + format: options.outputFormat, + promptConfirmResponses: options.promptConfirmResponses, + }); + const telemetry = mockTelemetryStateTracked(); + const state = { destroyed: 0 }; + const stack: EffectStack = { + id, + status: () => Effect.die("unused"), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + destroy: () => + options.destroyFailure + ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) + : Effect.sync(() => void state.destroyed++), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + }; + return { + output, + state, + telemetry, + layer: Layer.mergeAll( + output.layer, + telemetry.layer, + mockCommandSettings({ workdir: descriptor.projectRoot }), + mockTty({ + stdinIsTty: options.interactive ?? false, + stdoutIsTty: options.interactive ?? false, + }), + mockStdin(options.interactive ?? false), + processEnvLayer({}), + Layer.succeed(YesFlag, options.yes), + Layer.succeed(CliArgs, { args: options.yes ? ["--yes"] : [] }), + Layer.succeed(StackApi, { + findStack: () => + Effect.succeed(options.found === false ? Option.none() : Option.some(descriptor)), + createStack: () => Effect.die("unused"), + inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), + openStack: () => Effect.succeed(stack), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + }), + BunServices.layer, + ), + }; +} + +describe("stack destroy", () => { + it.live("requires confirmation before mutating a noninteractive invocation", () => { + const fixture = setup({ yes: false }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(StackCommandDestroyError); + expect(failure.message).toContain("requires confirmation"); + expect(fixture.state.destroyed).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("destroys the selected stack after --yes", () => { + const fixture = setup({ yes: true }); + return Effect.gen(function* () { + yield* stackDestroy(flags(Option.some(id))); + expect(fixture.state.destroyed).toBe(1); + expect(fixture.output.stdoutText).toContain("destroyed"); + expect(fixture.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("emits a machine-readable result after --yes", () => { + const fixture = setup({ yes: true, outputFormat: "json" }); + return Effect.gen(function* () { + yield* stackDestroy(flags(Option.some(id))); + expect(fixture.output.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "success", data: { destroyed: true, id } }), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("does not destroy a stack when interactive confirmation is declined", () => { + const fixture = setup({ yes: false, interactive: true, promptConfirmResponses: [false] }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure.message).toContain("not confirmed"); + expect(fixture.state.destroyed).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("flushes telemetry when destruction fails", () => { + const fixture = setup({ yes: true, destroyFailure: true }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags(Option.some(id))).pipe(Effect.flip); + expect(failure.message).toContain("destroy failed"); + expect(fixture.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("rejects malformed and conflicting targets without opening a stack", () => { + const fixture = setup({ yes: true }); + return Effect.gen(function* () { + const malformed = yield* stackDestroy(flags(Option.some("invalid"))).pipe(Effect.flip); + expect(malformed.message).toContain("lowercase SHA-256"); + const conflicting = yield* stackDestroy( + flags(Option.some(id), Option.some("feature-a")), + ).pipe(Effect.flip); + expect(conflicting.message).toContain("cannot be used together"); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("reports an absent named target", () => { + const fixture = setup({ yes: true, found: false }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure.message).toContain("No managed stack"); + }).pipe(Effect.provide(fixture.layer)); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 8c4b33e295..18f20afe78 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -222,7 +222,7 @@ stack = true "stack", "", ])?.candidates.map(({ name }) => name); - expect(stackCommands).toEqual(["start", "stop"]); + expect(stackCommands).toEqual(["destroy", "start", "stop"]); expect(completionFlags(backend, "status")).toContain("--override-name"); } diff --git a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts index 0cf1acf955..ebceffb713 100644 --- a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts @@ -13,10 +13,17 @@ import { mockProcessControl, mockRuntimeInfo, mockTelemetryRuntime, + mockStdin, + mockTty, processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DebugFlag, ProfileFlag, WorkdirFlag } from "../../../command-internal/global-flags.ts"; +import { + DebugFlag, + ProfileFlag, + WorkdirFlag, + YesFlag, +} from "../../../command-internal/global-flags.ts"; import { EventCommandExecuted, PropCommand, @@ -45,6 +52,9 @@ function setup() { Layer.succeed(DebugFlag, false), Layer.succeed(ProfileFlag, "supabase"), Layer.succeed(WorkdirFlag, Option.none()), + Layer.succeed(YesFlag, false), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockStdin(false), mockRuntimeInfo({ cwd: root, homeDir: root }), mockTelemetryRuntime({ configDir: join(root, ".supabase"), @@ -81,4 +91,23 @@ describe("stack command telemetry", () => { Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), ); }); + + it.live("records the destroy command identity on invalid target input", () => { + const fixture = setup(); + const command = stackCommand.pipe(Command.provide(fixture.layer)); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "destroy", + "--stack-id", + "invalid", + ]).pipe(Effect.flip); + const event = fixture.analytics.captured.find( + (candidate) => candidate.event === EventCommandExecuted, + ); + expect(event?.properties[PropCommand]).toBe("stack destroy"); + }).pipe( + Effect.provide(fixture.layer), + Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index d85294f588..ddea323de6 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -6,6 +6,7 @@ import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.t import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { stackStartCommand as stackStartCommandBase } from "./start/start.command.ts"; import { stackStopCommand as stackStopCommandBase } from "./stop/stop.command.ts"; +import { stackDestroyCommand as stackDestroyCommandBase } from "./destroy/destroy.command.ts"; import { stackApiLayer, stackTargetResolverLayer } from "./stack.shared.ts"; export const stackRuntimeLayer = Layer.mergeAll( @@ -21,12 +22,15 @@ const stackStartCommand = stackStartCommandBase.pipe( const stackStopCommand = stackStopCommandBase.pipe( Command.provide(commandRuntimeLayer(["stack", "stop"])), ); +const stackDestroyCommand = stackDestroyCommandBase.pipe( + Command.provide(commandRuntimeLayer(["stack", "destroy"])), +); export const stackCommand = Command.make("stack").pipe( Command.withDescription( "Manage an experimental, unstable local Supabase stack with the new backend. This command is excluded from the CLI compatibility promise.", ), Command.withShortDescription("Manage experimental local stacks"), - Command.withSubcommands([stackStartCommand, stackStopCommand]), + Command.withSubcommands([stackStartCommand, stackStopCommand, stackDestroyCommand]), Command.provide(stackRuntimeLayer), ); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 7e1e65211f..d098356e40 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,11 +1,13 @@ import { Context, Data, Effect, FileSystem, Layer, Option, Path, Crypto } from "effect"; import { createStack, + discoverStacks, findStack, inspectStack, isStackId, openStack, type StackRuntimePreference, + type StackDiscoveryResult, } from "@supabase/stack/effect"; import type { StackId } from "@supabase/stack"; import { StackNotFoundError } from "@supabase/stack/effect"; @@ -80,6 +82,9 @@ export class StackApi extends Context.Service< Effect.Success>, Effect.Error> >; + readonly discoverStacks: ( + ...args: Parameters + ) => Effect.Effect>>; } >()("supabase/experimental-stack/StackApi") {} @@ -141,6 +146,8 @@ export const stackApiLayer = Layer.effect( openStack: (...args: Parameters) => provideServices(openStack(...args)), inspectStack: (...args: Parameters) => provideServices(inspectStack(...args)), + discoverStacks: (...args: Parameters) => + provideServices(discoverStacks(...args)), }; }), ); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 2cd090451d..c9eafe946f 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -55,6 +55,12 @@ runtime. `docker` and `native` select the requested runtime without fallback. `--preparation` controls background versus on-demand artifact preparation, and `--eager` requests enabled capabilities be activated before the command returns. +`--exclude` accepts repeated or comma-separated capability names (`rest`, `auth`, `realtime`, +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`) and disables those services +in the effective start configuration. The database cannot be excluded. Exclusions are applied in +memory and persisted with the stack state; the project configuration file is unchanged. If all +API gateway capabilities are disabled, the API listener is disabled as well. Eager activation never +re-enables an excluded capability. The command owns only the start request. Once the package reports readiness, the detached stack owner remains alive after the CLI process exits. If the CLI diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index 08ad53ebbf..42e7267d48 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -2,9 +2,18 @@ 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 { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; +import { stringSliceFlag } from "../../../../command-internal/string-slice-flag.ts"; import { stackStart } from "./start.handler.ts"; +import { STACK_START_EXCLUDABLE_CAPABILITIES } from "./start.options.ts"; + +const excludeFlag = stringSliceFlag( + "exclude", + `Capabilities to leave disabled. [${STACK_START_EXCLUDABLE_CAPABILITIES.join(", ")}]`, + { alias: "x" }, +); const config = { + exclude: excludeFlag, stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), stackId: Flag.string("stack-id").pipe( Flag.withDescription("Open an existing stack by id."), @@ -45,6 +54,9 @@ export const stackStartCommand = Command.make("start", config).pipe( }, ]), Command.withHandler((flags) => - stackStart(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), + stackStart(flags).pipe( + withCommandTelemetry({ flags, config, aliases: { x: "exclude" } }), + withJsonErrorHandling, + ), ), ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index fd65b223f4..6e81f61e19 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,6 +1,7 @@ import { Effect, Match, Option } from "effect"; import { isStackError, + type StackConfig, type StackStatus, type StackRuntimePreference, } from "@supabase/stack/effect"; @@ -18,6 +19,7 @@ import { import { loadStackConfig } from "../stack-config.ts"; import type { StackStartFlags } from "./start.command.ts"; import { StackCommandStartError } from "./start.errors.ts"; +import { STACK_START_EXCLUDABLE_CAPABILITIES } from "./start.options.ts"; const statusPayload = (status: StackStatus) => ({ id: status.id, @@ -55,6 +57,71 @@ const eagerlyActivate = < value: T, ): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" })); +const validateExclusions = (exclusions: ReadonlyArray) => { + const unknown = exclusions.filter( + (name) => + name !== "database" && + !STACK_START_EXCLUDABLE_CAPABILITIES.some((capability) => capability === name), + ); + if (unknown.length > 0) + return Effect.fail( + new StackCommandStartError({ + reason: "flags", + message: `Unknown stack capabilities in --exclude: ${unknown.map((name) => JSON.stringify(name)).join(", ")}`, + suggestion: `Choose from ${STACK_START_EXCLUDABLE_CAPABILITIES.join(", ")}.`, + }), + ); + if (exclusions.includes("database")) + return Effect.fail( + new StackCommandStartError({ + reason: "flags", + message: "The database capability cannot be excluded from a stack.", + suggestion: "Remove database from --exclude.", + }), + ); + return Effect.void; +}; + +const applyExclusions = (config: StackConfig, exclusions: ReadonlyArray): StackConfig => { + if (exclusions.length === 0) return config; + const excluded = new Set(exclusions); + const capabilities = config.capabilities; + const gatewayCapabilities = [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "analytics", + ] as const; + const apiGatewayDisabled = gatewayCapabilities.every( + (name) => excluded.has(name) || capabilities?.[name]?.enabled === false, + ); + return { + ...config, + capabilities: { + ...capabilities, + ...(excluded.has("rest") ? { rest: { enabled: false as const } } : {}), + ...(excluded.has("auth") ? { auth: { enabled: false as const } } : {}), + ...(excluded.has("realtime") ? { realtime: { enabled: false as const } } : {}), + ...(excluded.has("storage") ? { storage: { enabled: false as const } } : {}), + ...(excluded.has("functions") ? { functions: { enabled: false as const } } : {}), + ...(excluded.has("studio") ? { studio: { enabled: false as const } } : {}), + ...(excluded.has("mail") ? { mail: { enabled: false as const } } : {}), + ...(excluded.has("analytics") ? { analytics: { enabled: false as const } } : {}), + ...(excluded.has("pooler") ? { pooler: { enabled: false as const } } : {}), + }, + ...(apiGatewayDisabled + ? { + listeners: { + ...config.listeners, + api: { enabled: false as const }, + }, + } + : {}), + }; +}; + const mapTargetError = (error: StackTargetError) => new StackCommandStartError({ reason: error.reason, @@ -71,6 +138,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const resolver = yield* StackTargetResolver; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); + yield* validateExclusions(flags.exclude); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), @@ -95,42 +163,43 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); + const configuredStart = applyExclusions(config, flags.exclude); const startConfig = flags.eager ? { - ...config, + ...configuredStart, capabilities: { - ...config.capabilities, - ...(config.capabilities?.rest === undefined + ...configuredStart.capabilities, + ...(configuredStart.capabilities?.rest === undefined ? {} - : { rest: eagerlyActivate(config.capabilities.rest) }), - ...(config.capabilities?.auth === undefined + : { rest: eagerlyActivate(configuredStart.capabilities.rest) }), + ...(configuredStart.capabilities?.auth === undefined ? {} - : { auth: eagerlyActivate(config.capabilities.auth) }), - ...(config.capabilities?.realtime === undefined + : { auth: eagerlyActivate(configuredStart.capabilities.auth) }), + ...(configuredStart.capabilities?.realtime === undefined ? {} - : { realtime: eagerlyActivate(config.capabilities.realtime) }), - ...(config.capabilities?.storage === undefined + : { realtime: eagerlyActivate(configuredStart.capabilities.realtime) }), + ...(configuredStart.capabilities?.storage === undefined ? {} - : { storage: eagerlyActivate(config.capabilities.storage) }), - ...(config.capabilities?.functions === undefined + : { storage: eagerlyActivate(configuredStart.capabilities.storage) }), + ...(configuredStart.capabilities?.functions === undefined ? {} - : { functions: eagerlyActivate(config.capabilities.functions) }), - ...(config.capabilities?.studio === undefined + : { functions: eagerlyActivate(configuredStart.capabilities.functions) }), + ...(configuredStart.capabilities?.studio === undefined ? {} - : { studio: eagerlyActivate(config.capabilities.studio) }), - ...(config.capabilities?.mail === undefined + : { studio: eagerlyActivate(configuredStart.capabilities.studio) }), + ...(configuredStart.capabilities?.mail === undefined ? {} - : { mail: eagerlyActivate(config.capabilities.mail) }), - ...(config.capabilities?.analytics === undefined + : { mail: eagerlyActivate(configuredStart.capabilities.mail) }), + ...(configuredStart.capabilities?.analytics === undefined ? {} - : { analytics: eagerlyActivate(config.capabilities.analytics) }), - ...(config.capabilities?.pooler === undefined + : { analytics: eagerlyActivate(configuredStart.capabilities.analytics) }), + ...(configuredStart.capabilities?.pooler === undefined ? {} - : { pooler: eagerlyActivate(config.capabilities.pooler) }), + : { pooler: eagerlyActivate(configuredStart.capabilities.pooler) }), }, preparation: flags.preparation, } - : { ...config, preparation: flags.preparation }; + : { ...configuredStart, preparation: flags.preparation }; const runtime: StackRuntimePreference | undefined = target.runtime; // The package's public Effect API reads SUPABASE_HOME only at its runtime // composition boundary and launches the detached owner through the compiled diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 70401c2f65..706f241d7d 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1,15 +1,16 @@ // oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, Option, Schema, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { ContainerEngineError, ContainerPullError, + StackConfigSchema, StackIdSchema, StackRuntimeError, StackStateInvalidError, @@ -89,7 +90,7 @@ const status = (id: string, runtime: "native" | "container" = "native") => function fakeStack( id: string, - start: (config: unknown) => Effect.Effect, + start: (config?: { readonly config?: unknown }) => Effect.Effect, ) { return { id: StackIdSchema.make(id), @@ -104,7 +105,10 @@ function fakeStack( } satisfies EffectStack; } -const flags = (overrides: Partial[0]> = {}) => ({ +const flags = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + exclude: [], stack: Option.none(), stackId: Option.none(), runtime: "auto" as const, @@ -145,6 +149,7 @@ function handlerLayer(opts: { return Effect.succeed(opts.stack); }, inspectStack: () => Effect.die("inspect not used in handler test"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }); return { out, @@ -161,6 +166,141 @@ function handlerLayer(opts: { } describe("stack start targeting", () => { + it.live("applies exclusions to the effective config without changing the project file", () => { + const root = project(); + const configBefore = readFileSync(join(root, "supabase", "config.toml"), "utf8"); + let startedConfig: unknown; + const stack = fakeStack("e".repeat(64), (config) => + Effect.gen(function* () { + startedConfig = config; + yield* Schema.decodeUnknownEffect(StackConfigSchema)(config?.config, { + onExcessProperty: "error", + }).pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + return status("e".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart(flags({ exclude: ["studio", "analytics"] })); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + capabilities: expect.objectContaining({ + studio: { enabled: false }, + analytics: { enabled: false }, + }), + }), + }), + ); + expect(readFileSync(join(root, "supabase", "config.toml"), "utf8")).toBe(configBefore); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("disables the API listener when all gateway capabilities are excluded", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55421\n', + ); + let startedConfig: unknown; + const stack = fakeStack("7".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("7".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart( + flags({ exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"] }), + ); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { enabled: false } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("preserves the configured API listener on partial gateway exclusion", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55421\n', + ); + let startedConfig: unknown; + const stack = fakeStack("8".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("8".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart(flags({ exclude: ["rest"] })); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { port: 55421 } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("counts configured-disabled gateway capabilities toward API listener disablement", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + `project_id = "start-test" +[api] +port = 55421 +[auth] +enabled = false +[realtime] +enabled = false +[storage] +enabled = false +[edge_runtime] +enabled = false +[analytics] +enabled = false +`, + ); + let startedConfig: unknown; + const stack = fakeStack("9".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("9".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart(flags({ exclude: ["rest"] })); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { enabled: false } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.live("leaves auto runtime selection to the package for a new stack", () => { const root = project(); let createOptions: unknown; @@ -275,6 +415,7 @@ describe("stack start targeting", () => { }, owner: "running", }), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }); return Effect.gen(function* () { const resolver = yield* StackTargetResolver; @@ -312,6 +453,7 @@ describe("stack start targeting", () => { runtime: "native", preparation: "on-demand", eager: true, + exclude: ["studio"], }), ); expect(createOptions).toEqual({ @@ -320,9 +462,16 @@ describe("stack start targeting", () => { runtime: { kind: "native" }, }); expect(startConfig).toMatchObject({ config: { preparation: "on-demand" } }); - expect(startConfig).toMatchObject({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); + expect(startConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + capabilities: expect.objectContaining({ + rest: expect.objectContaining({ activation: "eager" }), + studio: { enabled: false }, + }), + }), + }), + ); expect(setup.out.stdoutText).toContain("Stack"); expect(setup.telemetry.flushed).toBe(true); }).pipe( @@ -574,6 +723,7 @@ describe("stack start targeting", () => { }, openStack: () => Effect.die("open should not run"), inspectStack: () => Effect.die("inspect should not run"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }), BunServices.layer, ); @@ -645,6 +795,24 @@ describe("stack start parser", () => { ); }); + it.live("parses repeated, comma-separated, and short exclusion flags", () => { + let parsed: ReadonlyArray | undefined; + const command = stackStartCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => void (parsed = flags.exclude))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--exclude", + "studio,analytics", + "-x", + "mail", + ]); + expect(parsed).toEqual(["studio", "analytics", "mail"]); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + it.live("rejects a root legacy output value before resolving the stack target", () => { const root = project(); let resolved = false; @@ -676,4 +844,41 @@ describe("stack start parser", () => { Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), ); }); + + it.live("rejects unknown and database exclusions before resolving the target", () => { + const root = project(); + let resolved = false; + const setup = handlerLayer({ + root, + target: { projectRoot: root }, + stack: fakeStack("b".repeat(64), () => Effect.succeed(status("b".repeat(64)))), + }); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.sync(() => { + resolved = true; + return { projectRoot: root }; + }), + }); + return Effect.gen(function* () { + for (const { exclusion, message } of [ + { exclusion: "", message: 'Unknown stack capabilities in --exclude: ""' }, + { + exclusion: " analytics", + message: 'Unknown stack capabilities in --exclude: " analytics"', + }, + { exclusion: "unknown", message: 'Unknown stack capabilities in --exclude: "unknown"' }, + { exclusion: "database", message: "database capability cannot be excluded" }, + ]) { + const failure = yield* stackStart(flags({ exclude: [exclusion] })).pipe(Effect.flip); + expect(failure).toBeInstanceOf(StackCommandStartError); + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain(message); + } + expect(resolved).toBe(false); + }).pipe( + Effect.provide(Layer.mergeAll(setup.layer, target)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts new file mode 100644 index 0000000000..cd470693bc --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -0,0 +1,12 @@ +/** Optional capabilities accepted by `stack start --exclude`. */ +export const STACK_START_EXCLUDABLE_CAPABILITIES = [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index f8baa63c19..e846c5d20a 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -1,8 +1,9 @@ # `supabase stack stop` This command stops the managed stack identified by the current project, an optional `--stack` -name, or `--stack-id`. It uses the public `@supabase/stack` API to stop the owner while -preserving the stack's persistent state and data volumes. It never destroys the stack. +name, or `--stack-id`. With `--all`, it discovers every readable managed stack and attempts each +stop while preserving persistent state and data volumes. `--all` cannot be combined with a named +stack or `--stack-id`; the command never destroys stacks. ## Files read and written @@ -23,9 +24,11 @@ returning. That process is package-owned and is not managed directly by the CLI. Text mode reports the selected stack and stopped outcome. Structured modes include the selected stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit -no-stack result. Exit status is `0` for a successful stop or no current stack, `1` for a -missing named stack or any typed stop failure, and `130` if the command is interrupted before -the stop completes. Standard command instrumentation records command +no-stack result. Bulk mode attempts all readable stacks, warns for unreadable entries, and reports +stopped, failed, and skipped counts. It exits nonzero when an entry is skipped or a stop fails. +Registry-root enumeration failures remain fatal. Exit status is `0` for a successful stop or no +current stack, `1` for a missing named stack or any typed stop failure, and `130` if the command is +interrupted before the stop completes. Standard command instrumentation records command metadata; stack data and credentials are not emitted as telemetry properties. Telemetry state is flushed to `/telemetry.json` diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index 2d61b99cd6..f53e8068e1 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -5,6 +5,10 @@ import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts import { stackStop } from "./stop.handler.ts"; const config = { + all: Flag.boolean("all").pipe( + Flag.withDescription("Stop every readable managed stack."), + Flag.withDefault(false), + ), stack: Flag.string("stack").pipe( Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), Flag.optional, diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index ee09c15a4b..84e67f8dab 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Match, Option } from "effect"; +import { Effect, Match, Option, Result } from "effect"; import { type StackDescriptor, type OpenStackError, @@ -69,12 +69,57 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: const settings = yield* CommandSettings; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); + const stopAll = flags.all; yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + if (stopAll && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) + return yield* new StackCommandStopError({ + reason: "flags", + message: "--all cannot be combined with --stack or --stack-id", + }); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), stackId: Option.getOrUndefined(flags.stackId), }).pipe(Effect.mapError(mapTargetError)); + if (stopAll) { + const discovered = yield* stackApi.discoverStacks().pipe(Effect.mapError(stopError)); + for (const issue of discovered.errors) + yield* output.warn(`Skipping managed stack ${issue.id}: ${issue.error.message}`); + const stopping = yield* output.task( + `Stopping ${discovered.stacks.length} managed stack(s)...`, + ); + const results = yield* Effect.forEach( + discovered.stacks, + (descriptor) => + stackApi.openStack(descriptor.id).pipe( + Effect.flatMap((stack) => stack.stop()), + Effect.result, + Effect.map((result) => ({ descriptor, result })), + ), + { concurrency: 1 }, + ); + const failed = results.flatMap(({ descriptor, result }) => + Result.isFailure(result) ? [{ descriptor, error: result.failure }] : [], + ); + if (failed.length > 0 || discovered.errors.length > 0) { + const message = `Stopped ${discovered.stacks.length - failed.length} managed stack(s); failed to stop ${failed.length} and skipped ${discovered.errors.length}: ${[ + ...failed.map(({ descriptor, error }) => `${descriptor.id}: ${error.message}`), + ...discovered.errors.map(({ id, error }) => `${id}: ${error.message}`), + ].join("; ")}`; + yield* stopping.fail(message); + return yield* new StackCommandStopError({ + reason: "lifecycle", + message, + cause: { failures: failed, discovery: discovered.errors }, + }); + } + yield* stopping.clear(); + if (output.format === "text") + yield* output.raw(`Stopped ${discovered.stacks.length} managed stack(s).\n`); + else yield* output.success("", { stopped: discovered.stacks.map(({ id }) => id) }); + return; + } + const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined; const targetOption = id === undefined diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 042363948e..74321ad0b2 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -16,6 +16,7 @@ import type { EffectStack, OpenStackError, StackDiscoveryError, + StackDescriptor, StackStatus, StackStopError as ApiStackStopError, } from "@supabase/stack/effect"; @@ -46,7 +47,10 @@ const status = (id: string): StackStatus => ({ artifacts: [], }); -const flags = (overrides: Partial[0]> = {}) => ({ +const flags = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + all: false, stack: Option.none(), stackId: Option.none(), ...overrides, @@ -58,6 +62,14 @@ function setup(opts: { stop?: () => Effect.Effect; openFailure?: OpenStackError; findFailure?: StackDiscoveryError; + discoveryFailure?: StackDiscoveryError; + discovered?: { + readonly stacks: ReadonlyArray; + readonly errors: ReadonlyArray<{ + readonly id: StackDescriptor["id"]; + readonly error: StackDiscoveryError; + }>; + }; }) { const out = mockOutput(); const telemetry = mockTelemetryStateTracked(); @@ -120,6 +132,13 @@ function setup(opts: { }); }, inspectStack: () => Effect.die("must not inspect"), + discoverStacks: () => + opts.discoveryFailure === undefined + ? Effect.succeed({ + stacks: opts.discovered?.stacks ?? [], + errors: opts.discovered?.errors ?? [], + }) + : Effect.fail(opts.discoveryFailure), }), BunServices.layer, ); @@ -127,6 +146,152 @@ function setup(opts: { } describe("stack stop", () => { + it.effect("stops every discovered stack when --all is selected", () => { + const root = "/tmp/supabase-stack-stop-all"; + const id = "b".repeat(64); + const setupResult = setup({ + root, + found: { id, name: "feature-a" }, + discovered: { + stacks: [ + { + id: StackIdSchema.make(id), + projectRoot: root, + name: "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + { + id: StackIdSchema.make("c".repeat(64)), + projectRoot: root, + name: "feature-b", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [], + }, + }); + return Effect.gen(function* () { + yield* stackStop(flags({ all: true })); + expect(setupResult.state.openedIds).toEqual([id, "c".repeat(64)]); + expect(setupResult.state.stopCalls).toBe(2); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.stdoutText).toContain("Stopped 2"); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("continues attempting every stack after a stop failure", () => { + const root = "/tmp/supabase-stack-stop-all-failure"; + const first = "d".repeat(64); + const second = "e".repeat(64); + const setupResult = setup({ + root, + found: { id: first }, + stop: () => Effect.fail(new StackCleanupError({ message: "stop failed" })), + discovered: { + stacks: [ + { + id: StackIdSchema.make(first), + projectRoot: root, + name: "first", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + { + id: StackIdSchema.make(second), + projectRoot: root, + name: "second", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [], + }, + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("failed to stop 2"); + expect(setupResult.state.openedIds).toEqual([first, second]); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("reports corrupt discovery entries while stopping healthy stacks", () => { + const root = "/tmp/supabase-stack-stop-all-corrupt"; + const healthy = "f".repeat(64); + const corrupt = "1".repeat(64); + const setupResult = setup({ + root, + found: { id: healthy }, + discovered: { + stacks: [ + { + id: StackIdSchema.make(healthy), + projectRoot: root, + name: "healthy", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [ + { + id: StackIdSchema.make(corrupt), + error: new StackStateInvalidError({ message: "corrupt state" }), + }, + ], + }, + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("skipped 1"); + expect(setupResult.state.stopCalls).toBe(1); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.messages).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "warn" })]), + ); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("fails before opening any stack when registry discovery fails", () => { + const setupResult = setup({ + root: "/tmp/supabase-stack-stop-all-discovery-failure", + discoveryFailure: new StackStateInvalidError({ message: "registry is unreadable" }), + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("registry is unreadable"); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("treats an empty registry as a successful bulk no-op", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-all-empty" }); + return Effect.gen(function* () { + yield* stackStop(flags({ all: true })); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.stdoutText).toContain("Stopped 0 managed stack(s)."); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("rejects bulk stop target combinations", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-conflict" }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true, stack: Option.some("feature-a") })).pipe( + Effect.flip, + ); + expect(failure.message).toContain("cannot be combined"); + }).pipe(Effect.provide(setupResult.layer)); + }); + it.effect("stops a named stack without calling destroy", () => { const root = "/tmp/supabase-stack-stop"; const setupResult = setup({ diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9417dee711..9f750f3cfb 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -232,6 +232,7 @@ EncryptionNetworkError EncryptionUnexpectedStatusError ExperimentalFeatureFlagError ExperimentalRequiredError +ExperimentalStackDestroyError ExperimentalStackStartError ExperimentalStackStopError ExperimentalStackTargetError diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 9f9ca11994..4d863bb877 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -3,6 +3,7 @@ export { openStack, findStack, listStacks, + discoverStacks, inspectStack, } from "./public/PromiseStack.ts"; export type { @@ -13,6 +14,8 @@ export type { CreateStackOptions, FindStackOptions, ListStacksOptions, + StackDiscoveryIssue, + StackDiscoveryResult, PreparedCapability, } from "./public/PromiseStack.ts"; export type { diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 71f93e9c98..81acc13bad 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -7,9 +7,11 @@ import { Exit, FileSystem, Fiber, + Match, Option, Path, Predicate, + Result, Schedule, Schema, Stream, @@ -1077,10 +1079,43 @@ export const findStack = ( return state === undefined ? Option.none() : Option.some(descriptor(state, id)); }); -export const listStacks = ( +export interface StackDiscoveryIssue { + readonly id: StackId; + readonly error: StackDiscoveryError; +} + +/** The managed stack registry with entry-level read errors retained for bulk operations. */ +export interface StackDiscoveryResult { + readonly stacks: ReadonlyArray; + readonly errors: ReadonlyArray; +} + +const enrichStackDiscoveryError = ( + entry: StackId, + error: Effect.Error>, +): StackDiscoveryError => { + const message = `Failed to read managed stack ${entry}: ${error.message}`; + return Match.value(error).pipe( + Match.tag( + "InvalidProjectRootError", + (value) => new InvalidProjectRootError({ ...value, message, cause: error }), + ), + Match.tag( + "StackStateInvalidError", + (value) => new StackStateInvalidError({ ...value, stackId: entry, message, cause: error }), + ), + Match.tag( + "StackStateFormatUnsupportedError", + (value) => new StackStateFormatUnsupportedError({ ...value, message, cause: error }), + ), + Match.exhaustive, + ); +}; + +export const discoverStacks = ( options: ListStacksOptions = {}, ): Effect.Effect< - ReadonlyArray, + StackDiscoveryResult, StackDiscoveryError, FileSystem.FileSystem | Path.Path | Crypto.Crypto > => @@ -1101,25 +1136,48 @@ export const listStacks = ( .exists(env.stateRoot) .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message })))) ) - return []; + return { stacks: [], errors: [] }; const entries = yield* fs .readDirectory(env.stateRoot) .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); - const result: StackDescriptor[] = []; + const stacks: StackDescriptor[] = []; + const errors: StackDiscoveryIssue[] = []; for (const entry of entries) { if (!Schema.is(StackIdSchema)(entry)) continue; - const state = yield* store - .read(entry) - .pipe(Effect.catchIf(isMissingStateRemnantError, () => Effect.void)); + const result = yield* store.read(entry).pipe( + Effect.catchTag("StackStateInvalidError", (error) => + isMissingStateRemnantError(error) ? Effect.void : Effect.fail(error), + ), + Effect.result, + ); + if (Result.isFailure(result)) { + errors.push({ id: entry, error: enrichStackDiscoveryError(entry, result.failure) }); + continue; + } + const state = result.success; if ( state !== undefined && (projectRoot === undefined || state.identity.projectRoot === projectRoot) ) - result.push(descriptor(state, entry)); + stacks.push(descriptor(state, entry)); } - return result; + return { stacks, errors }; }); +export const listStacks = ( + options: ListStacksOptions = {}, +): Effect.Effect< + ReadonlyArray, + StackDiscoveryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + discoverStacks(options).pipe( + Effect.flatMap(({ stacks, errors }) => { + const firstError = errors[0]; + return firstError === undefined ? Effect.succeed(stacks) : Effect.fail(firstError.error); + }), + ); + export const inspectStack = ( id: StackId, ): Effect.Effect< diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index a3b9855017..ccddeddd95 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -3,6 +3,7 @@ import { Crypto, Effect, FileSystem, Layer, Option, Path, Redacted, Schema, Stre import { ChildProcessSpawner } from "effect/unstable/process"; import { createStack as createEffectStack, + discoverStacks as discoverEffectStacks, findStack as findEffectStack, inspectStack as inspectEffectStack, listStacks as listEffectStacks, @@ -11,6 +12,8 @@ import { type CreateStackOptions, type FindStackOptions, type ListStacksOptions, + type StackDiscoveryResult, + type StackDiscoveryIssue, type PrepareStackOptions, type StartStackOptions, } from "./EffectStack.ts"; @@ -62,6 +65,7 @@ interface PromiseStackApi { readonly openStack: (id: StackId) => Promise; readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; + readonly discoverStacks: (options?: ListStacksOptions) => Promise; readonly inspectStack: (id: StackId) => Promise; } @@ -174,6 +178,7 @@ export const makePromiseApi = ( findStack: (options) => run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), + discoverStacks: (options) => run(discoverEffectStacks(options)), inspectStack: (id) => run(inspectEffectStack(id)), }; }; @@ -183,6 +188,14 @@ export const createStack = defaultApi.createStack; export const openStack = defaultApi.openStack; export const findStack = defaultApi.findStack; export const listStacks = defaultApi.listStacks; +export const discoverStacks = defaultApi.discoverStacks; export const inspectStack = defaultApi.inspectStack; -export type { CreateStackOptions, FindStackOptions, ListStacksOptions, PreparedCapability }; +export type { + CreateStackOptions, + FindStackOptions, + ListStacksOptions, + PreparedCapability, + StackDiscoveryIssue, + StackDiscoveryResult, +}; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 71dce6dcaf..f736f9e610 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -60,6 +60,7 @@ import { import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import { createStack, + discoverStacks, findStack, inspectStack, listStacks, @@ -1130,6 +1131,29 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("retains healthy stacks while reporting unreadable registry entries", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = yield* StackRuntimeEnvironment; + const healthy = yield* createStack({ projectRoot: project }); + const corruptProject = path.join(project, "corrupt"); + yield* fs.makeDirectory(corruptProject); + const corrupt = yield* createStack({ projectRoot: corruptProject }); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: corrupt.id }); + yield* fs.writeFileString(paths.stateDocument, "{ malformed"); + + const discovered = yield* discoverStacks(); + + expect(discovered.stacks.map(({ id }) => id)).toEqual([healthy.id]); + expect(discovered.errors).toHaveLength(1); + expect(discovered.errors[0]?.id).toBe(corrupt.id); + expect(discovered.errors[0]?.error).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + it.live("recovers a same-identity runtime-only missing-state remnant during create", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index bd911e2d46..80b763ddf0 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,7 +7,14 @@ export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; -export { createStack, openStack, findStack, listStacks, inspectStack } from "./EffectStack.ts"; +export { + createStack, + openStack, + findStack, + listStacks, + discoverStacks, + inspectStack, +} from "./EffectStack.ts"; export type { EffectStack, StartStackOptions, @@ -15,6 +22,8 @@ export type { CreateStackOptions, FindStackOptions, ListStacksOptions, + StackDiscoveryIssue, + StackDiscoveryResult, PreparedCapability, PrepareStackResult, } from "./EffectStack.ts"; From 8a45f141196241d17cdc66c33348fbfaf9802479 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 16:18:11 +0200 Subject: [PATCH 03/14] fix(cli): respect stack exclusion dependencies --- apps/cli/docs/stack-commands.md | 11 +- .../stack/destroy/destroy.handler.ts | 2 +- .../experimental/stack/start/SIDE_EFFECTS.md | 7 +- .../experimental/stack/start/start.handler.ts | 48 +------- .../stack/start/start.integration.test.ts | 103 +++++++++++++----- .../experimental/stack/start/start.options.ts | 16 +-- .../experimental/stack/stop/SIDE_EFFECTS.md | 3 +- .../experimental/stack/stop/stop.errors.ts | 1 + .../experimental/stack/stop/stop.handler.ts | 15 ++- .../stack/stop/stop.integration.test.ts | 55 +++++++++- apps/cli/tests/helpers/mocks.ts | 3 + packages/stack/README.md | 4 + packages/stack/src/model/Exclusions.ts | 33 ++++++ .../src/model/compiler.integration.test.ts | 36 ++++++ packages/stack/src/public/index.ts | 2 + 15 files changed, 237 insertions(+), 102 deletions(-) create mode 100644 packages/stack/src/model/Exclusions.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 30c4cfc991..c2382fd8fa 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -56,13 +56,14 @@ including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enabl `supabase stack start --exclude studio,analytics -x mail` disables those services in the effective start configuration without changing the project file. Valid names are `rest`, `auth`, `realtime`, -`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; the database is required. The -effective configuration is retained in stack state, so starting without `--exclude` restores the -project's configured services. +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; the database is required. +Excluding `rest` or `analytics` also disables Studio. The effective configuration is +retained in stack state, so starting without `--exclude` restores the project's configured services. `supabase stack stop --all` stops every readable managed stack while preserving data. It continues -after unreadable entries or individual stop failures, reports warnings and counts, and exits -nonzero when anything was skipped or failed. Registry-root enumeration errors remain fatal. +after unreadable entries or individual stop failures, reports a bounded stopped/failed/skipped +summary with per-stack details, and exits nonzero when anything was skipped or failed. Registry-root +enumeration errors remain fatal. `supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after confirmation. Use `--yes` for unattended execution. There is no bulk destroy option. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 42f680c5b5..c6f573fb84 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -20,7 +20,7 @@ const mapTargetError = (error: StackTargetError) => new StackCommandDestroyError({ reason: error.reason, message: error.message, - suggestion: error.suggestion, + ...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }), cause: error, }); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index c9eafe946f..8bfc8ef9d0 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -58,9 +58,10 @@ activated before the command returns. `--exclude` accepts repeated or comma-separated capability names (`rest`, `auth`, `realtime`, `storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`) and disables those services in the effective start configuration. The database cannot be excluded. Exclusions are applied in -memory and persisted with the stack state; the project configuration file is unchanged. If all -API gateway capabilities are disabled, the API listener is disabled as well. Eager activation never -re-enables an excluded capability. +memory and persisted with the stack state; the project configuration file is unchanged. A capability +and its dependents are disabled together, so excluding `rest` or `analytics` also disables `studio`. +Listeners are derived by the runtime from enabled capability routes; route-less listeners are therefore omitted. +Eager activation never re-enables an excluded capability. The command owns only the start request. Once the package reports readiness, the detached stack owner remains alive after the CLI process exits. If the CLI diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 6e81f61e19..316c25048b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,7 +1,7 @@ import { Effect, Match, Option } from "effect"; import { + excludeStackCapabilities, isStackError, - type StackConfig, type StackStatus, type StackRuntimePreference, } from "@supabase/stack/effect"; @@ -79,47 +79,9 @@ const validateExclusions = (exclusions: ReadonlyArray) => { suggestion: "Remove database from --exclude.", }), ); - return Effect.void; -}; - -const applyExclusions = (config: StackConfig, exclusions: ReadonlyArray): StackConfig => { - if (exclusions.length === 0) return config; - const excluded = new Set(exclusions); - const capabilities = config.capabilities; - const gatewayCapabilities = [ - "rest", - "auth", - "realtime", - "storage", - "functions", - "analytics", - ] as const; - const apiGatewayDisabled = gatewayCapabilities.every( - (name) => excluded.has(name) || capabilities?.[name]?.enabled === false, + return Effect.succeed( + STACK_START_EXCLUDABLE_CAPABILITIES.filter((name) => exclusions.includes(name)), ); - return { - ...config, - capabilities: { - ...capabilities, - ...(excluded.has("rest") ? { rest: { enabled: false as const } } : {}), - ...(excluded.has("auth") ? { auth: { enabled: false as const } } : {}), - ...(excluded.has("realtime") ? { realtime: { enabled: false as const } } : {}), - ...(excluded.has("storage") ? { storage: { enabled: false as const } } : {}), - ...(excluded.has("functions") ? { functions: { enabled: false as const } } : {}), - ...(excluded.has("studio") ? { studio: { enabled: false as const } } : {}), - ...(excluded.has("mail") ? { mail: { enabled: false as const } } : {}), - ...(excluded.has("analytics") ? { analytics: { enabled: false as const } } : {}), - ...(excluded.has("pooler") ? { pooler: { enabled: false as const } } : {}), - }, - ...(apiGatewayDisabled - ? { - listeners: { - ...config.listeners, - api: { enabled: false as const }, - }, - } - : {}), - }; }; const mapTargetError = (error: StackTargetError) => @@ -138,8 +100,8 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const resolver = yield* StackTargetResolver; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); - yield* validateExclusions(flags.exclude); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + const exclusions = yield* validateExclusions(flags.exclude); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), stackId: Option.getOrUndefined(flags.stackId), @@ -163,7 +125,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); - const configuredStart = applyExclusions(config, flags.exclude); + const configuredStart = excludeStackCapabilities(config, exclusions); const startConfig = flags.eager ? { ...configuredStart, diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 706f241d7d..c605198f87 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -34,6 +34,7 @@ import { StackApi, } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; +import { compileStack } from "../../../../../../../packages/stack/src/model/Compiler.ts"; import { StackCommandStartError } from "./start.errors.ts"; import { stackStartCommand } from "./start.command.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; @@ -166,6 +167,43 @@ function handlerLayer(opts: { } describe("stack start targeting", () => { + for (const exclusion of ["rest", "analytics"] as const) { + it.live(`compiles ${exclusion} exclusion and dependent Studio`, () => { + const root = project(); + const configBefore = readFileSync(join(root, "supabase", "config.toml"), "utf8"); + const stack = fakeStack("c".repeat(64), (input) => + Effect.gen(function* () { + const stackConfig = yield* Schema.decodeUnknownEffect(StackConfigSchema)( + input?.config, + ).pipe( + Effect.mapError((error) => new StackStateInvalidError({ message: error.message })), + ); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: stackConfig, + }).pipe( + Effect.mapError((error) => new StackStateInvalidError({ message: error.message })), + Effect.provide(BunServices.layer), + ); + expect(compiled.definition.capabilities[exclusion].enabled).toBe(false); + expect(compiled.definition.capabilities.studio.enabled).toBe(false); + expect(compiled.definition.capabilities.auth.enabled).toBe(true); + return status("c".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + for (const eager of [false, true]) + yield* stackStart(flags({ exclude: [exclusion], eager })); + expect(readFileSync(join(root, "supabase", "config.toml"), "utf8")).toBe(configBefore); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + } + it.live("applies exclusions to the effective config without changing the project file", () => { const root = project(); const configBefore = readFileSync(join(root, "supabase", "config.toml"), "utf8"); @@ -199,36 +237,39 @@ describe("stack start targeting", () => { ); }); - it.live("disables the API listener when all gateway capabilities are excluded", () => { - const root = project(); - writeFileSync( - join(root, "supabase", "config.toml"), - 'project_id = "start-test"\n[api]\nport = 55421\n', - ); - let startedConfig: unknown; - const stack = fakeStack("7".repeat(64), (config) => - Effect.sync(() => { - startedConfig = config; - return status("7".repeat(64)); - }), - ); - const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); - return Effect.gen(function* () { - yield* stackStart( - flags({ exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"] }), + it.live( + "leaves listener configuration to the compiled runtime when capabilities are excluded", + () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55421\n', ); - expect(startedConfig).toEqual( - expect.objectContaining({ - config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { enabled: false } }), - }), + let startedConfig: unknown; + const stack = fakeStack("7".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("7".repeat(64)); }), ); - }).pipe( - Effect.provide(setup.layer), - Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), - ); - }); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart( + flags({ exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"] }), + ); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { port: 55421 } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); it.live("preserves the configured API listener on partial gateway exclusion", () => { const root = project(); @@ -259,7 +300,7 @@ describe("stack start targeting", () => { ); }); - it.live("counts configured-disabled gateway capabilities toward API listener disablement", () => { + it.live("does not rewrite listeners for configured-disabled capabilities", () => { const root = project(); writeFileSync( join(root, "supabase", "config.toml"), @@ -291,7 +332,7 @@ enabled = false expect(startedConfig).toEqual( expect.objectContaining({ config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { enabled: false } }), + listeners: expect.objectContaining({ api: { port: 55421 } }), }), }), ); @@ -829,7 +870,9 @@ describe("stack start parser", () => { }), }); return Effect.gen(function* () { - const failure = yield* stackStart(flags()).pipe(Effect.flip); + const failure = yield* stackStart(flags({ exclude: ["bogus"] })).pipe(Effect.flip); + expect(failure.message).toContain("--output"); + expect(failure.message).not.toContain("Unknown stack capabilities"); expect(failure).toBeInstanceOf(StackCommandStartError); if (failure instanceof StackCommandStartError) { expect(failure.reason).toBe("flags"); diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts index cd470693bc..28c5e7e730 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.options.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -1,12 +1,6 @@ +import { CAPABILITY_NAMES } from "@supabase/stack/effect"; + /** Optional capabilities accepted by `stack start --exclude`. */ -export const STACK_START_EXCLUDABLE_CAPABILITIES = [ - "rest", - "auth", - "realtime", - "storage", - "functions", - "studio", - "mail", - "analytics", - "pooler", -] as const; +export const STACK_START_EXCLUDABLE_CAPABILITIES = CAPABILITY_NAMES.filter( + (name) => name !== "database", +); diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index e846c5d20a..929ba6cfbd 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -25,7 +25,8 @@ returning. That process is package-owned and is not managed directly by the CLI. Text mode reports the selected stack and stopped outcome. Structured modes include the selected stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit no-stack result. Bulk mode attempts all readable stacks, warns for unreadable entries, and reports -stopped, failed, and skipped counts. It exits nonzero when an entry is skipped or a stop fails. +stopped, failed, and skipped counts with per-stack details. It exits nonzero when an entry is skipped +or a stop fails. Registry-root enumeration failures remain fatal. Exit status is `0` for a successful stop or no current stack, `1` for a missing named stack or any typed stop failure, and `130` if the command is interrupted before the stop completes. Standard command instrumentation records command diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts index 12a5068ce8..460e5efff3 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts @@ -8,6 +8,7 @@ import { export class StackCommandStopError extends Data.TaggedError("ExperimentalStackStopError")<{ readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; readonly message: string; + readonly detail?: string; readonly suggestion?: string; readonly cause?: unknown; }> { diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 84e67f8dab..c7313ce375 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -102,14 +102,21 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: Result.isFailure(result) ? [{ descriptor, error: result.failure }] : [], ); if (failed.length > 0 || discovered.errors.length > 0) { - const message = `Stopped ${discovered.stacks.length - failed.length} managed stack(s); failed to stop ${failed.length} and skipped ${discovered.errors.length}: ${[ - ...failed.map(({ descriptor, error }) => `${descriptor.id}: ${error.message}`), - ...discovered.errors.map(({ id, error }) => `${id}: ${error.message}`), - ].join("; ")}`; + const message = `Stopped ${discovered.stacks.length - failed.length} managed stack(s); failed ${failed.length}; skipped ${discovered.errors.length}`; + const detail = [ + ...failed.map( + ({ descriptor, error }) => + `Failed to stop managed stack ${descriptor.id}: ${error.message}`, + ), + ...discovered.errors.map( + ({ id, error }) => `Skipped managed stack ${id}: ${error.message}`, + ), + ].join("\n"); yield* stopping.fail(message); return yield* new StackCommandStopError({ reason: "lifecycle", message, + detail, cause: { failures: failed, discovery: discovered.errors }, }); } diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 74321ad0b2..9c7ab6313a 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -20,7 +20,7 @@ import type { StackStatus, StackStopError as ApiStackStopError, } from "@supabase/stack/effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import { mockCommandSettings, mockTelemetryStateTracked, @@ -35,6 +35,7 @@ import { textCliOutputFormatter } from "../../../../shared/output/text-formatter import { stackStop } from "./stop.handler.ts"; import { StackCommandStopError } from "./stop.errors.ts"; import { stackStopCommand } from "./stop.command.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; const status = (id: string): StackStatus => ({ id: StackIdSchema.make(id), @@ -58,6 +59,7 @@ const flags = ( function setup(opts: { root: string; + format?: "text" | "json" | "stream-json"; found?: { id: string; name?: string }; stop?: () => Effect.Effect; openFailure?: OpenStackError; @@ -71,7 +73,7 @@ function setup(opts: { }>; }; }) { - const out = mockOutput(); + const out = mockOutput({ format: opts.format }); const telemetry = mockTelemetryStateTracked(); const state = { findInputs: [] as Array<{ projectRoot: string; name?: string }>, @@ -111,6 +113,7 @@ function setup(opts: { : undefined; const layer = Layer.mergeAll( out.layer, + mockProcessControl().layer, telemetry.layer, mockCommandSettings({ workdir: opts.root }), Layer.succeed(StackApi, { @@ -215,9 +218,12 @@ describe("stack stop", () => { }); return Effect.gen(function* () { const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); - expect(failure.message).toContain("failed to stop 2"); + expect(failure.message).toContain("failed 2"); expect(setupResult.state.openedIds).toEqual([first, second]); expect(setupResult.state.destroyCalled).toBe(false); + expect(failure.detail).toBe( + `Failed to stop managed stack ${first}: stop failed\nFailed to stop managed stack ${second}: stop failed`, + ); }).pipe(Effect.provide(setupResult.layer)); }); @@ -249,7 +255,8 @@ describe("stack stop", () => { }); return Effect.gen(function* () { const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); - expect(failure.message).toContain("skipped 1"); + expect(failure.message).toBe("Stopped 1 managed stack(s); failed 0; skipped 1"); + expect(failure.detail).toBe(`Skipped managed stack ${corrupt}: corrupt state`); expect(setupResult.state.stopCalls).toBe(1); expect(setupResult.state.destroyCalled).toBe(false); expect(setupResult.out.messages).toEqual( @@ -258,6 +265,46 @@ describe("stack stop", () => { }).pipe(Effect.provide(setupResult.layer)); }); + it.effect("preserves bulk failure details through JSON error handling", () => { + const failed = "2".repeat(64); + const skipped = "3".repeat(64); + const setupResult = setup({ + root: "/tmp/supabase-stack-stop-json", + format: "json", + found: { id: failed }, + stop: () => Effect.fail(new StackCleanupError({ message: "cleanup failed" })), + discovered: { + stacks: [ + { + id: StackIdSchema.make(failed), + projectRoot: "/tmp/supabase-stack-stop-json", + name: "failed", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [ + { + id: StackIdSchema.make(skipped), + error: new StackStateInvalidError({ message: "invalid state" }), + }, + ], + }, + }); + return Effect.gen(function* () { + yield* stackStop(flags({ all: true })).pipe(withJsonErrorHandling); + expect(setupResult.out.failures).toEqual([ + expect.objectContaining({ + message: "Stopped 0 managed stack(s); failed 1; skipped 1", + detail: `Failed to stop managed stack ${failed}: cleanup failed\nSkipped managed stack ${skipped}: invalid state`, + }), + ]); + expect(setupResult.state.openedIds).toEqual([failed]); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + it.effect("fails before opening any stack when registry discovery fails", () => { const setupResult = setup({ root: "/tmp/supabase-stack-stop-all-discovery-failure", diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 00ea574274..d1176dde2a 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -242,6 +242,7 @@ export function mockOutput( } = {}, ) { const messages: OutputMessage[] = []; + const failures: Array["fail"]>[0]> = []; const progressEvents: ProgressEvent[] = []; const events: OutputEvent[] = []; const rawChunks: Array<{ text: string; stream: "stdout" | "stderr" }> = []; @@ -362,6 +363,7 @@ export function mockOutput( fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.sync(() => { messages.push({ type: "fail", message: err.message }); + failures.push(err); }), progress: (opts: { max: number }) => Effect.sync(() => ({ @@ -446,6 +448,7 @@ export function mockOutput( }), }), messages, + failures, progressEvents, events, promptConfirmCalls, diff --git a/packages/stack/README.md b/packages/stack/README.md index e4e0f98345..2690732e2a 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -24,6 +24,10 @@ capability is lazy. Starting the stack therefore launches only PostgreSQL by default; capabilities configured as eager join its startup dependency closure. The remaining lazy capabilities activate through the stack's listeners on demand for the current running session. +The Effect API's `excludeStackCapabilities` helper disables requested optional capabilities and +their dependents in an in-memory config. Excluding `rest` or `analytics` also disables `studio`, +while the database remains required. The project config is unchanged, and runtime listeners are +created only for enabled capability routes. Native workloads have a two-minute readiness budget to allow cold starts to load shared libraries; container workloads retain a 30-second budget, and PostgreSQL uses its configured `health_timeout`. Each readiness probe returns immediately when its endpoint becomes healthy. diff --git a/packages/stack/src/model/Exclusions.ts b/packages/stack/src/model/Exclusions.ts new file mode 100644 index 0000000000..f357d9524e --- /dev/null +++ b/packages/stack/src/model/Exclusions.ts @@ -0,0 +1,33 @@ +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { StackConfig } from "../public/Config.ts"; +import { CAPABILITY_MODULES } from "./ExecutionPlan.ts"; + +export type ExcludableCapabilityName = Exclude; + +/** Disable requested optional capabilities and their transitive dependents. */ +export const excludeStackCapabilities = ( + config: StackConfig, + exclusions: readonly ExcludableCapabilityName[], +): StackConfig => { + if (exclusions.length === 0) return config; + + const disabled = new Set(exclusions); + let changed = true; + while (changed) { + changed = false; + for (const name of CAPABILITY_NAMES) { + if (name === "database" || disabled.has(name)) continue; + if (CAPABILITY_MODULES[name].dependencies.some((dependency) => disabled.has(dependency))) { + disabled.add(name); + changed = true; + } + } + } + + const capabilities = { ...config.capabilities }; + for (const name of CAPABILITY_NAMES) { + if (name === "database" || !disabled.has(name)) continue; + capabilities[name] = { enabled: false }; + } + return { ...config, capabilities }; +}; diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 58e0489c66..ff6dc6d62e 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -6,6 +6,7 @@ import { canonicalize, compileStack, rebuildExecutionPlan, sameDefinition } from import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { DEFAULT_DATABASE_HEALTH_TIMEOUT } from "./capabilities/database.ts"; import { catalogEntryFor } from "./WorkloadCatalog.ts"; +import { excludeStackCapabilities } from "./Exclusions.ts"; const layer = NodeServices.layer; const compile = ( @@ -21,6 +22,41 @@ const failureOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("closed capability compiler", () => { + it.live("compiles every optional exclusion and closes Studio dependents", () => + Effect.gen(function* () { + for (const name of [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ] as const) { + const config = { capabilities: { rest: { settings: { max_rows: 42 } } } }; + const excluded = excludeStackCapabilities(config, [name]); + expect(config.capabilities?.rest).toEqual({ settings: { max_rows: 42 } }); + const result = yield* compile(excluded); + expect(result.definition.capabilities[name].enabled).toBe(false); + if (name !== "rest") expect(result.definition.capabilities.rest.settings.max_rows).toBe(42); + if (name === "rest" || name === "analytics") + expect(result.definition.capabilities.studio.enabled).toBe(false); + } + const combined = excludeStackCapabilities({}, ["rest", "analytics"]); + const result = yield* compile(combined); + expect(result.definition.capabilities.studio.enabled).toBe(false); + expect(result.definition.capabilities.rest.enabled).toBe(false); + expect(result.definition.capabilities.analytics.enabled).toBe(false); + expect(result.definition.capabilities.auth.enabled).toBe(true); + expect(excludeStackCapabilities({}, [])).toEqual({}); + const studioExcluded = yield* compile(excludeStackCapabilities({}, ["studio"])); + expect(studioExcluded.definition.capabilities.rest.enabled).toBe(true); + expect(studioExcluded.definition.capabilities.analytics.enabled).toBe(true); + }), + ); + it.live("accepts named storage byte limit formats during compilation", () => Effect.gen(function* () { for (const input of ["50MiB", "1.5KB", "2 GiB"]) { diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 80b763ddf0..392661da79 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,6 +7,8 @@ export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; +export { excludeStackCapabilities } from "../model/Exclusions.ts"; +export type { ExcludableCapabilityName } from "../model/Exclusions.ts"; export { createStack, openStack, From d3af8152883ac8361f619430764101263a210cda Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 16:46:12 +0200 Subject: [PATCH 04/14] fix(cli): serialize concurrent stack creation --- packages/stack/src/public/EffectStack.ts | 21 +++-- .../supervisor/handles.integration.test.ts | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 81acc13bad..f03ecee34b 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -29,6 +29,7 @@ import { toPersistedIdentity } from "../state/StackState.ts"; import { isMissingStateRemnantError, makeStackStateStore, + withRegistryLock, type StackStateStore, } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; @@ -982,15 +983,19 @@ export const createStack = ( }); const stackId = yield* deriveStackId(identity); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const persisted = yield* store - .read(stackId) - .pipe( - Effect.catch((error) => - isMissingStateRemnantError(error) - ? Effect.map(Effect.void, () => undefined) - : Effect.fail(error), + // Concurrent initial writes may expose temporary files before state.json is published. + const persisted = yield* withRegistryLock( + env.stateRoot, + store + .read(stackId) + .pipe( + Effect.catch((error) => + isMissingStateRemnantError(error) + ? Effect.map(Effect.void, () => undefined) + : Effect.fail(error), + ), ), - ); + ); const resolverOption = yield* Effect.serviceOption(ContainerEngineResolver).pipe( Effect.map(Option.getOrUndefined), ); diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index e69fd04a4c..e642023e69 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -10,6 +10,7 @@ import { Fiber, Option, Path, + Predicate, Redacted, Result, Schema, @@ -27,6 +28,7 @@ import { listStacks, } from "../public/EffectStack.ts"; import type { StackStatus } from "../public/Status.ts"; +import type { CreateStackError } from "../public/Errors.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; import { defaultRuntimeEnvironment, @@ -327,6 +329,26 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("reuses an existing runtime without probing the container resolver", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const created = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }); + const resolver = { + isInstalled: () => Effect.die("resolver must not be called"), + resolve: () => Effect.die("resolver must not be called"), + }; + const reopened = yield* createStack({ projectRoot: project }).pipe( + Effect.provideService(ContainerEngineResolver, resolver), + ); + expect(reopened.id).toBe(created.id); + expect((yield* reopened.status()).runtime).toEqual({ kind: "container", engine: "podman" }); + }), + ), + ); + it.live("prepares through only the explicitly selected Podman engine", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -657,6 +679,78 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("concurrent creates preserve the published state across an advisory read race", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const stackId = yield* deriveStackId(identity); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId }); + const path = yield* Path.Path; + const registryLock = path.join(path.resolve(env.stateRoot), ".stack-registry.lock"); + const writerReady = yield* Deferred.make(); + const releaseWriter = yield* Deferred.make(); + const writerPublished = yield* Deferred.make(); + const firstFs: FileSystem.FileSystem = { + ...fs, + rename: (from, to) => + to === paths.stateDocument + ? Deferred.succeed(writerReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseWriter)), + Effect.andThen(fs.rename(from, to)), + Effect.tap(() => Deferred.succeed(writerPublished, undefined)), + ) + : fs.rename(from, to), + }; + const secondFs: FileSystem.FileSystem = { + ...fs, + readFileString: (candidate, encoding) => { + if (candidate === registryLock) + return Deferred.succeed(releaseWriter, undefined).pipe( + Effect.andThen(fs.readFileString(candidate, encoding)), + ); + if (candidate !== paths.stateDocument) return fs.readFileString(candidate, encoding); + return fs + .readFileString(candidate, encoding) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Deferred.succeed(releaseWriter, undefined).pipe( + Effect.andThen(Deferred.await(writerPublished)), + Effect.andThen(Effect.fail(error)), + ) + : Effect.fail(error), + ), + ); + }, + }; + const create = (fileSystem: FileSystem.FileSystem) => + createStack({ projectRoot: project }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + const first = yield* Effect.forkChild( + create(firstFs).pipe( + Effect.catchCause((cause) => + Deferred.failCause(writerReady, cause).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.await(writerReady); + const second = yield* Effect.forkChild(create(secondFs), { startImmediately: true }); + const [firstHandle, secondHandle] = yield* Effect.all( + [Fiber.join(first), Fiber.join(second)], + { + concurrency: 2, + }, + ); + expect(secondHandle.id).toBe(firstHandle.id); + expect((yield* secondHandle.status()).lifecycle).toBe("unconfigured"); + }), + ), + ); + it.live("concurrent caller processes share one stack identity after exit", () => withRuntimeRoot((project) => Effect.gen(function* () { From 1797aac3d2220bbe97e19b7ece76174671dd9208 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 17:46:25 +0200 Subject: [PATCH 05/14] fix(cli): refine stack command errors and confirmation --- .../db-bootstrap/shadow-cache.unit.test.ts | 1 + .../stack/destroy/SIDE_EFFECTS.md | 12 +- .../stack/destroy/destroy.errors.ts | 13 +- .../stack/destroy/destroy.handler.ts | 25 ++-- .../stack/destroy/destroy.integration.test.ts | 71 +++++++++-- .../experimental/stack/stop/SIDE_EFFECTS.md | 14 ++- .../experimental/stack/stop/stop.command.ts | 2 +- .../experimental/stack/stop/stop.handler.ts | 32 +++-- .../stack/stop/stop.integration.test.ts | 114 +++++++++++++++--- 9 files changed, 235 insertions(+), 49 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 5ca9dfad05..313c8504df 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -61,6 +61,7 @@ describe("shadowCacheKey", () => { expect(shadowBaselineTarFileName(first)).toBe(`shadow-baseline-${first}.tar`); }); + // Each variant performs an intentionally expensive scrypt derivation; parallel suite load needs headroom. it("changes when ANY baked-in input changes", { timeout: 30_000 }, () => { const base = baseKeyInputs(); const mutations: ReadonlyArray<{ diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 80f59e0ceb..e988b8f9c3 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -3,8 +3,9 @@ Permanently stops and removes one managed stack, including its persisted data. The command targets the current project stack by default, or an explicit `--stack` name or -`--stack-id`. It requires interactive confirmation; `--yes` is required for non-interactive and -machine-readable invocations. `SUPABASE_YES` participates in the existing confirmation setting; +`--stack-id`. It requires an interactive text terminal on both stdout and stdin for confirmation; +`--yes` is required for redirected, non-interactive, and machine-readable invocations. +`SUPABASE_YES` participates in the existing confirmation setting; an explicit `--yes=false` overrides it. It never accepts `--all`. The stack package reads the selected descriptor and removes resources and state under @@ -14,8 +15,9 @@ or Docker resources itself and makes no Management API calls. Project files are The confirmation prompt identifies the stack name, project directory, and immutable stack ID. Rejection or missing noninteractive confirmation performs no destructive operation. Text output -reports the destroyed stack ID. JSON and stream-JSON return -`{ "destroyed": true, "id": "..." }`. Success exits `0`; invalid targets, confirmation refusal, -and destruction failures exit `1`; interruption follows the command runtime's interruption exit. +reports the destroyed stack ID. JSON returns +`{ "destroyed": true, "id": "...", "message": "" }`; stream-JSON wraps the same payload in +the standard result event. Success exits `0`; invalid targets, confirmation refusal, and +destruction failures exit `1`; interruption follows the command runtime's interruption exit. Standard command instrumentation records command metadata without exporting credentials, and telemetry state flushes after both successful and failed runs. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts index c627b89a33..a950e0485a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -6,7 +6,14 @@ import { } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStackDestroyError")<{ - readonly reason: "flags" | "confirmation" | "invalid-config" | "lifecycle" | "unknown"; + readonly reason: + | "flags" + | "confirmation" + | "cancelled" + | "invalid-config" + | "runtime" + | "lifecycle" + | "unknown"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -16,9 +23,13 @@ export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStac case "flags": case "confirmation": return actionability.provideFlags; + case "cancelled": + return actionability.cancelled; case "invalid-config": case "lifecycle": return actionability.invalidConfig; + case "runtime": + return actionability.dockerNotRunning; case "unknown": return actionability.unknown; } diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index c6f573fb84..eea67d4587 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -26,11 +26,18 @@ const mapTargetError = (error: StackTargetError) => const destroyError = (error: unknown): StackCommandDestroyError => { const stackError = isStackError(error) ? error : undefined; - const reason = + const classification = stackError === undefined - ? "unknown" + ? { reason: "unknown" as const } : Match.value(stackError).pipe( - Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => "flags" as const), + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ + reason: "flags" as const, + })), + Match.tag("ContainerEngineError", () => ({ + reason: "runtime" as const, + suggestion: + "Check that the selected container engine is installed and its daemon is running, then retry the command.", + })), Match.tag( "StackOwnershipConflictError", "StackNotRunningError", @@ -40,19 +47,19 @@ const destroyError = (error: unknown): StackCommandDestroyError => { "StackCleanupError", "StackDestructionError", "StackUpgradeRequiredError", - () => "lifecycle" as const, + () => ({ reason: "lifecycle" as const }), ), Match.tag( "InvalidStackConfigError", "StackStateFormatUnsupportedError", "InvalidProjectRootError", "StackStateInvalidError", - () => "invalid-config" as const, + () => ({ reason: "invalid-config" as const }), ), - Match.orElse(() => "unknown" as const), + Match.orElse(() => ({ reason: "unknown" as const })), ); return new StackCommandDestroyError({ - reason, + ...classification, message: stackError?.message ?? String(error), cause: error, }); @@ -98,7 +105,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( }); const yes = yield* resolveYes; const tty = yield* Tty; - if (!yes && (!tty.stdinIsTty || output.format !== "text")) + if (!yes && (!tty.stdinIsTty || !output.interactive || output.format !== "text")) return yield* new StackCommandDestroyError({ reason: "confirmation", message: "Destroying a stack requires confirmation; rerun with --yes.", @@ -112,7 +119,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( ); if (!confirmed) return yield* new StackCommandDestroyError({ - reason: "confirmation", + reason: "cancelled", message: "Stack destruction was not confirmed.", }); const stack = yield* api diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 680ef80411..aa5a124059 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -1,10 +1,14 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Stream } from "effect"; -import { StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; +import { ContainerEngineError, StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; import type { EffectStack } from "@supabase/stack/effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { YesFlag } from "../../../../command-internal/global-flags.ts"; +import { OutputFlag, YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + ErrorActionabilityId, + actionability, +} from "../../../../shared/telemetry/error-actionability.ts"; import { mockCommandSettings, mockTelemetryStateTracked, @@ -37,17 +41,20 @@ const flags = (stackId = Option.none(), stack = Option.none()) = function setup(options: { yes: boolean; destroyFailure?: boolean; + destroyContainerFailure?: boolean; interactive?: boolean; + outputInteractive?: boolean; promptConfirmResponses?: ReadonlyArray; outputFormat?: "text" | "json"; found?: boolean; }) { const output = mockOutput({ format: options.outputFormat, + interactive: options.outputInteractive, promptConfirmResponses: options.promptConfirmResponses, }); const telemetry = mockTelemetryStateTracked(); - const state = { destroyed: 0 }; + const state = { destroyed: 0, opened: 0 }; const stack: EffectStack = { id, status: () => Effect.die("unused"), @@ -56,9 +63,11 @@ function setup(options: { start: () => Effect.die("unused"), stop: () => Effect.die("unused"), destroy: () => - options.destroyFailure - ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) - : Effect.sync(() => void state.destroyed++), + options.destroyContainerFailure + ? Effect.fail(new ContainerEngineError({ message: "container engine unavailable" })) + : options.destroyFailure + ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) + : Effect.sync(() => void state.destroyed++), logs: () => Effect.die("unused"), followLogs: () => Stream.empty, }; @@ -83,7 +92,11 @@ function setup(options: { Effect.succeed(options.found === false ? Option.none() : Option.some(descriptor)), createStack: () => Effect.die("unused"), inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), - openStack: () => Effect.succeed(stack), + openStack: () => + Effect.sync(() => { + state.opened++; + return stack; + }), discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }), BunServices.layer, @@ -129,6 +142,30 @@ describe("stack destroy", () => { return Effect.gen(function* () { const failure = yield* stackDestroy(flags()).pipe(Effect.flip); expect(failure.message).toContain("not confirmed"); + expect(failure.reason).toBe("cancelled"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.cancelled); + expect(fixture.state.destroyed).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("destroys a stack after interactive confirmation is accepted", () => { + const fixture = setup({ yes: false, interactive: true, promptConfirmResponses: [true] }); + return Effect.gen(function* () { + yield* stackDestroy(flags()); + expect(fixture.state.destroyed).toBe(1); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("requires --yes when stdout is redirected", () => { + const fixture = setup({ + yes: false, + interactive: true, + outputInteractive: false, + promptConfirmResponses: [true], + }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("confirmation"); expect(fixture.state.destroyed).toBe(0); }).pipe(Effect.provide(fixture.layer)); }); @@ -142,6 +179,26 @@ describe("stack destroy", () => { }).pipe(Effect.provide(fixture.layer)); }); + it.live("classifies container engine failures with runtime guidance", () => { + const fixture = setup({ yes: true, destroyContainerFailure: true }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags(Option.some(id))).pipe(Effect.flip); + expect(failure.reason).toBe("runtime"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.dockerNotRunning); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("rejects the legacy output flag before opening a stack", () => { + const fixture = setup({ yes: true }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags(Option.some(id))).pipe(Effect.flip); + expect(failure.reason).toBe("flags"); + expect(fixture.state.opened).toBe(0); + }).pipe( + Effect.provide(Layer.mergeAll(fixture.layer, Layer.succeed(OutputFlag, Option.some("json")))), + ); + }); + it.live("rejects malformed and conflicting targets without opening a stack", () => { const fixture = setup({ yes: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index 929ba6cfbd..a1c7dd1a57 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -22,11 +22,15 @@ returning. That process is package-owned and is not managed directly by the CLI. ## Output and telemetry -Text mode reports the selected stack and stopped outcome. Structured modes include the selected -stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit -no-stack result. Bulk mode attempts all readable stacks, warns for unreadable entries, and reports -stopped, failed, and skipped counts with per-stack details. It exits nonzero when an entry is skipped -or a stop fails. +Text mode reports the selected stack and stopped outcome. A successful single-stack JSON response is +`{ "found": true, "id": "", "lifecycle": "stopped", "message": "" }`; when no current +stack exists it is `{ "found": false, "message": "No managed stack found for this context." }`. +A successful bulk JSON response is `{ "stopped": ["", ...], "message": "" }`. +Stream-json wraps the same payload in its standard result event. Bulk mode attempts all readable +stacks, warns for unreadable entries, and reports stopped, failed, and skipped counts with per-stack +details. It exits nonzero when an entry is skipped or a stop fails. +The `--all` flag is presence-sensitive for target validation, so `--all=false` still conflicts with +`--stack` and `--stack-id`; `--all=false` alone uses single-stack mode. Registry-root enumeration failures remain fatal. Exit status is `0` for a successful stop or no current stack, `1` for a missing named stack or any typed stop failure, and `130` if the command is interrupted before the stop completes. Standard command instrumentation records command diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index f53e8068e1..39ec3ec781 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -7,7 +7,7 @@ import { stackStop } from "./stop.handler.ts"; const config = { all: Flag.boolean("all").pipe( Flag.withDescription("Stop every readable managed stack."), - Flag.withDefault(false), + Flag.optional, ), stack: Flag.string("stack").pipe( Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index c7313ce375..0e5f523180 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -69,9 +69,9 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: const settings = yield* CommandSettings; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); - const stopAll = flags.all; + const stopAll = Option.getOrElse(flags.all, () => false); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); - if (stopAll && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) + if (Option.isSome(flags.all) && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) return yield* new StackCommandStopError({ reason: "flags", message: "--all cannot be combined with --stack or --stack-id", @@ -84,7 +84,7 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: if (stopAll) { const discovered = yield* stackApi.discoverStacks().pipe(Effect.mapError(stopError)); for (const issue of discovered.errors) - yield* output.warn(`Skipping managed stack ${issue.id}: ${issue.error.message}`); + yield* output.warn(`Skipping managed stack: ${issue.error.message}`); const stopping = yield* output.task( `Stopping ${discovered.stacks.length} managed stack(s)...`, ); @@ -108,15 +108,33 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: ({ descriptor, error }) => `Failed to stop managed stack ${descriptor.id}: ${error.message}`, ), - ...discovered.errors.map( - ({ id, error }) => `Skipped managed stack ${id}: ${error.message}`, - ), + ...discovered.errors.map(({ error }) => `Skipped managed stack: ${error.message}`), ].join("\n"); yield* stopping.fail(message); + const classifications = [ + ...failed.map(({ error }) => stopError(error)), + ...discovered.errors.map(({ error }) => stopError(error)), + ]; + const firstClassification = classifications[0]; + const reason = + firstClassification !== undefined && + classifications.every( + (classification) => classification.reason === firstClassification.reason, + ) + ? firstClassification.reason + : "unknown"; + const suggestion = + firstClassification?.suggestion !== undefined && + classifications.every( + (classification) => classification.suggestion === firstClassification.suggestion, + ) + ? firstClassification.suggestion + : undefined; return yield* new StackCommandStopError({ - reason: "lifecycle", + reason, message, detail, + ...(suggestion === undefined ? {} : { suggestion }), cause: { failures: failed, discovery: discovered.errors }, }); } diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 9c7ab6313a..23b6866825 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -51,7 +51,7 @@ const status = (id: string): StackStatus => ({ const flags = ( overrides: Partial[0]> = {}, ): Parameters[0] => ({ - all: false, + all: Option.none(), stack: Option.none(), stackId: Option.none(), ...overrides, @@ -178,7 +178,7 @@ describe("stack stop", () => { }, }); return Effect.gen(function* () { - yield* stackStop(flags({ all: true })); + yield* stackStop(flags({ all: Option.some(true) })); expect(setupResult.state.openedIds).toEqual([id, "c".repeat(64)]); expect(setupResult.state.stopCalls).toBe(2); expect(setupResult.state.destroyCalled).toBe(false); @@ -217,13 +217,15 @@ describe("stack stop", () => { }, }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); expect(failure.message).toContain("failed 2"); expect(setupResult.state.openedIds).toEqual([first, second]); expect(setupResult.state.destroyCalled).toBe(false); expect(failure.detail).toBe( `Failed to stop managed stack ${first}: stop failed\nFailed to stop managed stack ${second}: stop failed`, ); + expect(failure.reason).toBe("unknown"); + expect(failure.suggestion).toContain("--debug"); }).pipe(Effect.provide(setupResult.layer)); }); @@ -248,15 +250,19 @@ describe("stack stop", () => { errors: [ { id: StackIdSchema.make(corrupt), - error: new StackStateInvalidError({ message: "corrupt state" }), + error: new StackStateInvalidError({ + message: `Failed to read managed stack ${corrupt}: corrupt state`, + }), }, ], }, }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); expect(failure.message).toBe("Stopped 1 managed stack(s); failed 0; skipped 1"); - expect(failure.detail).toBe(`Skipped managed stack ${corrupt}: corrupt state`); + expect(failure.detail).toBe( + `Skipped managed stack: Failed to read managed stack ${corrupt}: corrupt state`, + ); expect(setupResult.state.stopCalls).toBe(1); expect(setupResult.state.destroyCalled).toBe(false); expect(setupResult.out.messages).toEqual( @@ -287,17 +293,19 @@ describe("stack stop", () => { errors: [ { id: StackIdSchema.make(skipped), - error: new StackStateInvalidError({ message: "invalid state" }), + error: new StackStateInvalidError({ + message: `Failed to read managed stack ${skipped}: invalid state`, + }), }, ], }, }); return Effect.gen(function* () { - yield* stackStop(flags({ all: true })).pipe(withJsonErrorHandling); + yield* stackStop(flags({ all: Option.some(true) })).pipe(withJsonErrorHandling); expect(setupResult.out.failures).toEqual([ expect.objectContaining({ message: "Stopped 0 managed stack(s); failed 1; skipped 1", - detail: `Failed to stop managed stack ${failed}: cleanup failed\nSkipped managed stack ${skipped}: invalid state`, + detail: `Failed to stop managed stack ${failed}: cleanup failed\nSkipped managed stack: Failed to read managed stack ${skipped}: invalid state`, }), ]); expect(setupResult.state.openedIds).toEqual([failed]); @@ -311,7 +319,7 @@ describe("stack stop", () => { discoveryFailure: new StackStateInvalidError({ message: "registry is unreadable" }), }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); expect(failure.message).toContain("registry is unreadable"); expect(setupResult.state.openedIds).toEqual([]); expect(setupResult.state.destroyCalled).toBe(false); @@ -321,7 +329,7 @@ describe("stack stop", () => { it.effect("treats an empty registry as a successful bulk no-op", () => { const setupResult = setup({ root: "/tmp/supabase-stack-stop-all-empty" }); return Effect.gen(function* () { - yield* stackStop(flags({ all: true })); + yield* stackStop(flags({ all: Option.some(true) })); expect(setupResult.state.openedIds).toEqual([]); expect(setupResult.state.stopCalls).toBe(0); expect(setupResult.state.destroyCalled).toBe(false); @@ -332,13 +340,60 @@ describe("stack stop", () => { it.effect("rejects bulk stop target combinations", () => { const setupResult = setup({ root: "/tmp/supabase-stack-stop-conflict" }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true, stack: Option.some("feature-a") })).pipe( - Effect.flip, - ); + const failure = yield* stackStop( + flags({ all: Option.some(true), stack: Option.some("feature-a") }), + ).pipe(Effect.flip); expect(failure.message).toContain("cannot be combined"); }).pipe(Effect.provide(setupResult.layer)); }); + it.effect("rejects an explicit false --all with a stack target", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-explicit-false" }); + return Effect.gen(function* () { + const failure = yield* stackStop( + flags({ all: Option.some(false), stack: Option.some("feature-a") }), + ).pipe(Effect.flip); + expect(failure.message).toContain("cannot be combined"); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([]); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("uses unknown classification when bulk failures disagree", () => { + const failed = "6".repeat(64); + const skipped = "5".repeat(64); + const setupResult = setup({ + root: "/tmp/supabase-stack-stop-mixed", + found: { id: failed }, + stop: () => Effect.fail(new StackCleanupError({ message: "cleanup failed" })), + discovered: { + stacks: [ + { + id: StackIdSchema.make(failed), + projectRoot: "/tmp/supabase-stack-stop-mixed", + name: "failed", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [ + { + id: StackIdSchema.make(skipped), + error: new StackStateInvalidError({ + message: `Failed to read managed stack ${skipped}: corrupt state`, + }), + }, + ], + }, + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); + expect(failure.reason).toBe("unknown"); + expect(failure.suggestion).toBeUndefined(); + }).pipe(Effect.provide(setupResult.layer)); + }); + it.effect("stops a named stack without calling destroy", () => { const root = "/tmp/supabase-stack-stop"; const setupResult = setup({ @@ -600,4 +655,35 @@ describe("stack stop parser", () => { Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), ); }); + + it.live("preserves explicit false for --all alongside a target", () => { + let parsed: Parameters[0] | undefined; + const command = stackStopCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--all=false", + "--stack", + "feature-a", + ]); + expect(parsed?.all).toEqual(Option.some(false)); + expect(parsed?.stack).toEqual(Option.some("feature-a")); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.live("parses bare --all as true", () => { + let parsed: Parameters[0] | undefined; + const command = stackStopCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })(["--all"]); + expect(parsed?.all).toEqual(Option.some(true)); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); }); From bf2dba8aac0b4e9daa8ca34dafaac7ed99759b9e Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 9 Sep 2026 11:25:24 +0200 Subject: [PATCH 06/14] feat(cli): use stack EphemeralPostgres for schema-tooling shadows When [experimental].stack is on, db diff/pull, declarative generate/sync, and migration squash provision throwaway shadows through @supabase/stack instead of legacy Docker SQL-template containers. --- apps/cli/docs/stack-commands.md | 13 +- apps/cli/src/cli/root.ts | 2 + .../db-bootstrap/shadow-cache.ts | 7 +- .../db-bootstrap/shadow-cache.unit.test.ts | 18 + .../src/command-internal/db-config.layer.ts | 9 + .../src/command-internal/db-config.service.ts | 2 + apps/cli/src/command-internal/db-pull-run.ts | 147 ++- .../pgdelta-engine-runtime.layer.ts | 5 + apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 9 + apps/cli/src/commands/db/diff/diff.handler.ts | 164 +-- .../commands/db/diff/diff.integration.test.ts | 26 +- .../db/shared/pgdelta-next-shadow.layer.ts | 74 +- .../commands/db/shared/pgdelta.seam.layer.ts | 10 + .../stack/stack-backend.integration.test.ts | 2 + .../experimental/stack/stack-backend.ts | 22 +- .../stack-local-database.integration.test.ts | 85 ++ .../stack/stack-local-database.ts | 192 +++ .../stack/stack-shadow.integration.test.ts | 286 +++++ .../experimental/stack/stack-shadow.ts | 587 ++++++++++ .../stack/stack-shadow.unit.test.ts | 61 + .../commands/migration/migration.layers.ts | 4 + .../migration/squash/squash.handler.ts | 99 +- ...5-ephemeral-postgres-for-schema-tooling.md | 96 ++ docs/adr/README.md | 1 + packages/config/src/experimental.ts | 3 +- packages/stack/README.md | 7 + packages/stack/src/index.ts | 3 + packages/stack/src/public/EffectStack.ts | 2 + .../stack/src/public/EphemeralPostgres.ts | 84 ++ packages/stack/src/public/Errors.ts | 30 +- packages/stack/src/public/PromiseStack.ts | 79 +- .../ephemeral-postgres.integration.test.ts | 176 +++ .../public/ephemeral-postgres.unit.test.ts | 32 + packages/stack/src/public/index.ts | 8 + .../stack/src/public/whole-stack.e2e.test.ts | 7 +- packages/stack/src/runtime/ContainerEngine.ts | 5 +- .../stack/src/runtime/EphemeralPostgres.ts | 1030 +++++++++++++++++ .../src/runtime/PostgresDatabaseSession.ts | 28 + 38 files changed, 3269 insertions(+), 146 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-local-database.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-shadow.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-shadow.unit.test.ts create mode 100644 docs/adr/0025-ephemeral-postgres-for-schema-tooling.md create mode 100644 packages/stack/src/public/EphemeralPostgres.ts create mode 100644 packages/stack/src/public/ephemeral-postgres.integration.test.ts create mode 100644 packages/stack/src/public/ephemeral-postgres.unit.test.ts create mode 100644 packages/stack/src/runtime/EphemeralPostgres.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index c2382fd8fa..2e424183dd 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -39,8 +39,17 @@ command. For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes precedence over `experimental.stack`; an unset or empty value falls back to the file setting. -Other values are rejected. The override affects only the top-level lifecycle aliases and is -applied before reading the project configuration. +Other values are rejected. The override is applied before reading the project configuration. + +When the flag is on, `db` and `migration` commands use the project stack for `--local` and +provision throwaway shadow Postgres through `@supabase/stack` (`EphemeralPostgres`). Linked +and `--db-url` targets stay on the Management API. The stack backend requires the in-process +pg-delta engine; `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, and `--diff-engine migra` +are rejected. The flag does not switch functions or storage command families, and does not +change top-level `status`. + +`db reset` and declarative `--apply`/`--reset` still use the legacy Docker volume recreate +path; stack data-dir wipe is a later follow-up. ## Data and configuration diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 47e94b924c..0b0e555a54 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -12,6 +12,7 @@ import { stackRuntimeLayer, stackCommand } from "../commands/experimental/stack/ import { stackStartCommand } from "../commands/experimental/stack/start/start.command.ts"; import { stackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts"; import type { StackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; import { computeCommand } from "../commands/experimental/compute/compute.command.ts"; import { feedbackCommand } from "../commands/feedback/feedback.command.ts"; import { functionsCommand } from "../commands/functions/functions.command.ts"; @@ -181,6 +182,7 @@ export const rootCommandForFeatures = ( : outputLayerFor(outputFormat); return Layer.mergeAll( + stackBackendLayer(options.stackBackend ?? "legacy"), outputLayer, makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 8ae52abc9c..d599ffbb2d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -363,6 +363,8 @@ export interface ShadowBaselineRetentionOpts { readonly maxAgeMs?: number; /** Never evict this published tar, even if it is older than the TTL or over the cap. */ readonly retainFileName?: string; + /** Defaults to {@link isShadowBaselineTar}. */ + readonly isPublishedTar?: (fileName: string) => boolean; } /** @@ -377,8 +379,9 @@ export function shadowBaselineTarsToEvict( const keep = opts.keep ?? SHADOW_BASELINE_KEEP; const maxAgeMs = opts.maxAgeMs ?? SHADOW_BASELINE_MAX_AGE_MS; const retain = opts.retainFileName; + const isPublishedTar = opts.isPublishedTar ?? isShadowBaselineTar; const candidates = entries.filter( - (entry) => isShadowBaselineTar(entry.fileName) && entry.fileName !== retain, + (entry) => isPublishedTar(entry.fileName) && entry.fileName !== retain, ); const aged = new Set( candidates.filter((entry) => now - entry.mtimeMs > maxAgeMs).map((entry) => entry.fileName), @@ -469,7 +472,7 @@ const sweepShadowBaselineRetention = ( }); /** Refresh mtime on a warm hit so frequently used keys survive LRU/TTL. Best-effort. */ -const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Effect.Effect => +export const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Effect.Effect => Effect.gen(function* () { const now = new Date(yield* Clock.currentTimeMillis); yield* fs.utimes(tarPath, now, now); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 313c8504df..518b971881 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -412,4 +412,22 @@ describe("shadow baseline tar retention", () => { ), ).toEqual([]); }); + + it("never evicts retainFileName when using a custom published-tar matcher", () => { + const current = "stack-shadow-baseline-dddddddddddddddd.tar"; + const aged = now - LEGACY_SHADOW_BASELINE_MAX_AGE_MS - 1; + const isStack = (fileName: string) => + /^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u.test(fileName); + const evicted = legacyShadowBaselineTarsToEvict( + [ + { fileName: current, mtimeMs: aged }, + { fileName: "stack-shadow-baseline-aaaaaaaaaaaaaaaa.tar", mtimeMs: now - 1_000 }, + { fileName: "not-a-tar.json", mtimeMs: aged }, + ], + now, + { retainFileName: current, isPublishedTar: isStack }, + ); + expect(evicted).not.toContain(current); + expect(evicted).not.toContain("not-a-tar.json"); + }); }); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 14f6b9ec45..19bdb66451 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -38,6 +38,8 @@ import type { DbConfigFlags } from "./db-config.types.ts"; import { DebugLogger } from "./debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; import { mapHttpError } from "./http-errors.ts"; +import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { stackLocalDatabaseConn } from "../commands/experimental/stack/stack-local-database.ts"; const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); @@ -561,6 +563,13 @@ export const dbConfigLayer = Layer.effect( const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { resolveVaultSecrets, }); + const backend = yield* currentStackBackend; + if (backend.kind === "stack") { + const conn = yield* stackLocalDatabaseConn.pipe( + Effect.provideService(CommandSettings, cliSettings), + ); + return { conn, isLocal: true }; + } return { conn: { host: localHost, diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index 18de77b180..040d98d280 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -10,6 +10,7 @@ import type { import type { ProfileLoadError } from "./profile-load.ts"; import type { ProjectRefReadError } from "./temp-paths.ts"; import type { DbConnectError } from "./db-connection.errors.ts"; +import type { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import type { DbConfigConnectTempRoleError, DbConfigIpv6Error, @@ -29,6 +30,7 @@ import type { DbConfigFlags, ResolvedDbConfig } from "./db-config.types.ts"; export type DbConfigError = | DbConfigParseUrlError | DbConfigLoadError + | LocalDbRunningError | ProjectRefNotLinkedError | InvalidProjectRefError // A hard linked-ref load surfaces a real `.temp/project-ref` read error instead of masking it diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 89dcc90435..517c0c280d 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -66,6 +66,12 @@ import { } from "../commands/db/shared/pgdelta-engine.service.ts"; import { type PgDeltaContext, isPgDeltaDebugEnabled, resolvePgDeltaProjectId } from "./pgdelta.ts"; import { prepareShadowSource } from "../commands/db/shared/shadow-source.ts"; +import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { stackRejectNativeDockerDiffEngine } from "../commands/experimental/stack/stack-local-database.ts"; +import { + stackPrepareShadowSource, + stackWithShadowDatabase, +} from "../commands/experimental/stack/stack-shadow.ts"; import type { DbPullFlags } from "../commands/db/pull/pull.command.ts"; import { DbPullDumpError, @@ -366,12 +372,17 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const usePgDeltaDiff = resolvePullDiffEngine({ engineFlagChanged: Option.isSome(flags.diffEngine), engine: Option.getOrElse(flags.diffEngine, () => "migra"), - pgDeltaDefault: shouldUsePgDelta({ - configEnabled: toml.pgDelta.enabled, - usePgDeltaFlag: false, - envEnabled: parseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), - }), + pgDeltaDefault: + (yield* currentStackBackend).kind === "stack" || + shouldUsePgDelta({ + configEnabled: toml.pgDelta.enabled, + usePgDeltaFlag: false, + envEnabled: parseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), + }), }); + if (Option.getOrElse(flags.diffEngine, () => "pg-delta") === "migra") { + yield* stackRejectNativeDockerDiffEngine; + } // Connectivity check, run before dialing. return yield* Effect.scoped( @@ -576,7 +587,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const runShadowDiff = (targetEndpoint: PgDeltaDatabaseEndpoint) => Effect.gen(function* () { yield* output.raw("Creating shadow database...\n", "stderr"); - const resolvedPullShadowImage = yield* pullLocalInputs.resolvePostgresImage; + const stackBackend = (yield* currentStackBackend).kind === "stack"; + const resolvedPullShadowImage = stackBackend + ? "stack-ephemeral" + : yield* pullLocalInputs.resolvePostgresImage; const migrationMode: "legacy" | "pgdelta-next" = usePgDeltaDiff ? "pgdelta-next" : "legacy"; @@ -596,65 +610,72 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( schemaPaths: toml.schemaPathPatterns, pgDelta: toml.pgDelta, }; - // `withShadowDatabase` owns the interrupt-safe lifecycle and the cache seam. Each - // pooler-retry attempt still acquires and releases its own shadow; on the warm path - // every attempt restores a fresh container from the same cached snapshot. The key's - // webhooks policy must mirror what {@link prepareShadowSource} selects for this mode, - // or the two engines could restore each other's tars. - return yield* withShadowDatabase( - spawner, - shadowInput, - (handle) => - Effect.gen(function* () { - const shadow = yield* prepareShadowSource(spawner, handle, shadowInput); - const target = shadow.targetUrlOverride ?? targetEndpoint.ref; - yield* output.raw( - diffSchema.length > 0 - ? `Diffing schemas: ${diffSchema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (usePgDeltaDiff) { - return yield* pgDeltaEngine.diffDatabase({ - context: ctx, - source: { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - target: { - kind: "database", - ref: target, - ...(shadow.targetUrlOverride === undefined - ? { - ...(targetEndpoint.connection !== undefined - ? { connection: targetEndpoint.connection } - : {}), - connectOptions: targetEndpoint.connectOptions, - } - : { - connectOptions: { isLocal: true, dnsResolver }, - }), - }, - schema: diffSchema, - formatOptions, - debug: isPgDeltaDebugEnabled(), - strictCoverage: flags.strictCoverage, - }); - } - const sql = yield* diffMigra(ctx, { - source: shadow.sourceUrl, - target, + const runDiff = (shadow: { + readonly sourceUrl: string; + readonly targetUrlOverride: string | undefined; + }) => + Effect.gen(function* () { + const target = shadow.targetUrlOverride ?? targetEndpoint.ref; + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (usePgDeltaDiff) { + return yield* pgDeltaEngine.diffDatabase({ + context: ctx, + source: { + kind: "database", + ref: shadow.sourceUrl, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: target, + ...(shadow.targetUrlOverride === undefined + ? { + ...(targetEndpoint.connection !== undefined + ? { connection: targetEndpoint.connection } + : {}), + connectOptions: targetEndpoint.connectOptions, + } + : { + connectOptions: { isLocal: true, dnsResolver }, + }), + }, schema: diffSchema, - connectOptions: - shadow.targetUrlOverride === undefined - ? targetEndpoint.connectOptions - : { isLocal: true, dnsResolver }, + formatOptions, + debug: isPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); - return { sql, files: undefined, debug: undefined }; - }), - { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, - ); + } + const sql = yield* diffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: diffSchema, + connectOptions: + shadow.targetUrlOverride === undefined + ? targetEndpoint.connectOptions + : { isLocal: true, dnsResolver }, + }); + return { sql, files: undefined, debug: undefined }; + }); + return stackBackend + ? yield* stackWithShadowDatabase(shadowInput, (handle) => + stackPrepareShadowSource(handle, shadowInput).pipe(Effect.flatMap(runDiff)), + ) + : // `withShadowDatabase` owns the interrupt-safe lifecycle and the cache seam. + // Webhooks policy must mirror {@link prepareShadowSource} for this mode. + yield* withShadowDatabase( + spawner, + shadowInput, + (handle) => + prepareShadowSource(spawner, handle, shadowInput).pipe( + Effect.flatMap(runDiff), + ), + { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, + ); }); const diffOutcome = yield* withPoolerFallback(targetEndpoint, runShadowDiff); diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts index 04fc9f26ad..be2e17e36d 100644 --- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts @@ -14,6 +14,8 @@ import { pgDeltaNextAdapterLayer } from "../commands/db/shared/pgdelta-next-adap import { pgDeltaNextShadowLayer } from "../commands/db/shared/pgdelta-next-shadow.layer.ts"; import { declarativeSeamLayer } from "../commands/db/shared/pgdelta.seam.layer.ts"; import { localDockerEngineLayer } from "./db-bootstrap/local-db-running.ts"; +import { stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; +import { ephemeralPostgresLayer } from "../commands/experimental/stack/stack-shadow.ts"; /** The in-process pg-delta engine — the only implementation. */ const pgDeltaEngineLayer = pgDeltaNextEngineLayer; @@ -57,6 +59,7 @@ const nextShadow = pgDeltaNextShadowLayer.pipe( Layer.provide(dockerRunLayer), Layer.provide(dbConnectionLayer), Layer.provide(httpClient), + Layer.provide(pgDeltaCommandSettingsRuntimeLayer), ); const engine = pgDeltaEngineLayer.pipe( Layer.provide(pgDeltaCommandSettingsRuntimeLayer), @@ -77,4 +80,6 @@ export const pgDeltaCommandRuntimeLayer = Layer.mergeAll( pgDeltaCommandSettingsRuntimeLayer, // Exposed for handlers' own direct `isLocalDbRunning` calls (`db diff --use-pgadmin`). localDockerEngine, + stackApiLayer, + ephemeralPostgresLayer, ); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 569e59e07f..80cd8bea6e 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -26,7 +26,9 @@ it, and JSON `null` disables formatting without disabling safe compaction. | `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `migrateShadowDatabase` | | `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` only — slim-init + stack bootstrap baseline; key includes artifact identity and runtime kind (native vs container). Never mixed with `shadow-baseline-*.tar` | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` abandoned-partial sweep — same 5-minute TTL, stack prefix only (legacy `shadow-baseline-*.partial` names are not candidates) | | `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | | `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | @@ -39,7 +41,9 @@ it, and JSON `null` disables formatting without disabling safe compaction. | `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | | `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` COLD export of an `EphemeralPostgres` cluster; same LRU keep-3 + 2-day TTL, separate glob so keys cannot collide with legacy SQL-template baselines | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` cold export temp file — pid-scoped, `chmod` 0600, `rename`d into the stack tar above; abandoned leftovers older than 5 minutes are swept on later acquires | | `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | @@ -56,6 +60,11 @@ it, and JSON `null` disables formatting without disabling safe compaction. narrower composition — `createShadowDatabase` -> health-wait -> `migrateShadowDatabase` directly (`diff.handler.ts`'s pgadmin branch) — with no declarative-schema-override branch and no `targetUrlOverride`. +- When `[experimental].stack` / `SUPABASE_EXPERIMENTAL_STACK=1` is on, `--local` inspects the + project stack (`findStack` + `status`, database ready) instead of `supabase_db_`, + and shadows are `@supabase/stack` `EphemeralPostgres` clusters (slim-init baseline, cache + prefix `stack-shadow-baseline-`). Every stack runtime rejects `--use-migra` / `--use-pgadmin` / + `--use-pg-schema`. - `supabase/migra` container — the migra OOM bash fallback only. - **Differ container** (`--use-pgadmin`, CLI-1968) — `supabase/pgadmin-schema-diff:cli-0.0.5` (`dockerfileServiceImage("differ")`). One `docker run --rm` when no `--schema` is given; one diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 4ca78558f0..d3e9c23376 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -28,6 +28,15 @@ import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; import { schemaToCsvField } from "../../../command-internal/schema-flags.ts"; import { findDropStatements } from "../../../command-internal/sql-split.ts"; import { buildLocalDbContainerInputs } from "../../../command-internal/db-bootstrap/local-container-inputs.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { + stackLocalDatabaseConn, + stackRejectNativeDockerDiffEngine, +} from "../../experimental/stack/stack-local-database.ts"; +import { + stackPrepareShadowSource, + stackWithShadowDatabase, +} from "../../experimental/stack/stack-shadow.ts"; import { isLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { waitForHealthyServices } from "../../../command-internal/db-bootstrap/health-check.ts"; import { withShadowDatabase } from "../../../command-internal/db-bootstrap/shadow-cache.ts"; @@ -168,6 +177,13 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }), ); } + if ( + Option.isSome(flags.useMigra) || + Option.isSome(flags.usePgAdmin) || + Option.isSome(flags.usePgSchema) + ) { + yield* stackRejectNativeDockerDiffEngine; + } // Config is read lazily per path, not unconditionally up front: reading the base config // before the ref is known would validate fields a `[remotes.]` block overrides, which @@ -253,13 +269,19 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { Effect.gen(function* () { switch (classifyExplicitRef(ref)) { case "local": { - const connection = { - host: getHostname(), - port: cfg.port, - user: "postgres", - password: cfg.password, - database: "postgres", - }; + const backend = yield* currentStackBackend; + const connection = + backend.kind === "stack" + ? yield* stackLocalDatabaseConn.pipe( + Effect.provideService(CommandSettings, cliSettings), + ) + : { + host: getHostname(), + port: cfg.port, + user: "postgres", + password: cfg.password, + database: "postgres", + }; return { kind: "database", ref: toPostgresURL(connection), @@ -495,11 +517,13 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Engine resolution: the pg-delta env/config/flag gate, read from the // (possibly remote-merged) config. - const pgDeltaDefault = shouldUsePgDelta({ - configEnabled: cfg.pgDelta.enabled, - usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), - envEnabled: parseBoolEnv(cfg.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), - }); + const pgDeltaDefault = + (yield* currentStackBackend).kind === "stack" || + shouldUsePgDelta({ + configEnabled: cfg.pgDelta.enabled, + usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), + envEnabled: parseBoolEnv(cfg.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), + }); const useDelta = resolveDiffEngine({ useMigraChanged: Option.isSome(flags.useMigra), usePgAdmin, @@ -521,7 +545,10 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // branch's own "Creating shadow database..." banner announces, so every call site emits its // banner first and only then invokes this. const resolveShadowRunInput = Effect.fnUntraced(function* () { - const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + const stackBackend = (yield* currentStackBackend).kind === "stack"; + const resolvedShadowImage = stackBackend + ? "stack-ephemeral" + : yield* localInputs.resolvePostgresImage; return shadowRunInputFromLocalContainerInputs( localInputs, resolvedShadowImage, @@ -622,63 +649,70 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { schemaPaths: cfg.schemaPathPatterns, pgDelta: cfg.pgDelta, }; + const runDiff = (shadow: Pick & { + readonly sourceUrl: string; + readonly targetUrlOverride?: string; + }) => + Effect.gen(function* () { + const target = shadow.targetUrlOverride ?? targetUrl; + yield* output.raw( + flags.schema.length > 0 + ? `Diffing schemas: ${flags.schema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (useDelta) { + const result = yield* pgDelta.diffDatabase({ + context: ctx, + source: { + kind: "database", + ref: shadow.sourceUrl, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: target, + ...(shadow.targetUrlOverride === undefined ? { connection: resolved.conn } : {}), + connectOptions: { + isLocal: shadow.targetUrlOverride !== undefined || resolved.isLocal, + dnsResolver, + }, + }, + schema: flags.schema, + formatOptions, + debug: isPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + }); + return { sql: result.sql, files: result.files, hazards: result.hazards }; + } + const sql = yield* diffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: flags.schema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + return { sql, files: undefined }; + }); // `withShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle and the // cache seam — a plain create/remove pair when `SUPABASE_SHADOW_CACHE` is explicitly // disabled (the cache is on by default). The key's webhooks policy must mirror what // `prepareShadowSource` selects for this mode (legacy migrate forces `pg_net` on, // next follows config), or the two engines could restore each other's tars. - diffResult = yield* withShadowDatabase( - spawner, - shadowInput, - (handle) => - Effect.gen(function* () { - const shadow = yield* prepareShadowSource(spawner, handle, shadowInput); - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - flags.schema.length > 0 - ? `Diffing schemas: ${flags.schema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (useDelta) { - const result = yield* pgDelta.diffDatabase({ - context: ctx, - source: { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - target: { - kind: "database", - ref: target, - ...(shadow.targetUrlOverride === undefined ? { connection: resolved.conn } : {}), - connectOptions: { - isLocal: shadow.targetUrlOverride !== undefined || resolved.isLocal, - dnsResolver, - }, - }, - schema: flags.schema, - formatOptions, - debug: isPgDeltaDebugEnabled(), - strictCoverage: flags.strictCoverage, - }); - // Keep the per-unit plan files so a multi-unit plan can be written as one - // migration file each; `sql` stays the flattened join for stdout review + - // machine payloads. - return { sql: result.sql, files: result.files, hazards: result.hazards }; - } - const sql = yield* diffMigra(ctx, { - source: shadow.sourceUrl, - target, - schema: flags.schema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - // The migra engine has no execution-aware plan units, so it always writes a - // single migration file. - return { sql, files: undefined }; - }), - { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, - ); + const stackBackend = (yield* currentStackBackend).kind === "stack"; + diffResult = stackBackend + ? yield* stackWithShadowDatabase(shadowInput, (handle) => + stackPrepareShadowSource(handle, shadowInput).pipe(Effect.flatMap(runDiff)), + ) + : yield* withShadowDatabase( + spawner, + shadowInput, + (handle) => + Effect.gen(function* () { + const shadow = yield* prepareShadowSource(spawner, handle, shadowInput); + return yield* runDiff(shadow); + }), + { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, + ); } const out = diffResult.sql; diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 363299b6a4..26bc20ec30 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Fiber, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -63,6 +63,8 @@ import { } from "../shared/pgdelta-engine.service.ts"; import type { DbDiffFlags } from "./diff.command.ts"; import { dbDiff } from "./diff.handler.ts"; +import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; +import { StackNativeEngineError } from "../../experimental/stack/stack-local-database.ts"; import { PGADMIN_DESKTOP_NOTE_PREFIX, PGADMIN_DIFF_HEADER } from "./pgadmin-diff.ts"; interface SetupOpts { @@ -1719,6 +1721,28 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("rejects --use-migra on the stack backend", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* dbDiff(flags({ useMigra: Option.some(true) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(StackNativeEngineError); + }).pipe(Effect.provide(Layer.mergeAll(s.layer, stackBackendLayer("stack")))); + }); + + it.effect("rejects --use-migra=false on the stack backend", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* dbDiff(flags({ useMigra: Option.some(false) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(StackNativeEngineError); + }).pipe(Effect.provide(Layer.mergeAll(s.layer, stackBackendLayer("stack")))); + }); + it.effect("fails on target mutex (--linked with --local)", () => { const s = setup(tmp.current); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index ca49141a92..8d2d284dcb 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Crypto, Effect, FileSystem, Layer, Option, Path } from "effect"; import * as Net from "node:net"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -10,6 +10,7 @@ import { import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; import { @@ -48,6 +49,12 @@ import { type PgDeltaNextShadowInput, } from "./pgdelta-next-shadow.service.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { + stackAcquireShadowDatabase, + stackMigrateShadow, + stackReleaseShadowDatabase, +} from "../../experimental/stack/stack-shadow.ts"; const allocateFreeHostPort = Effect.callback>((resume) => { const server = Net.createServer(); @@ -136,6 +143,8 @@ export const pgDeltaNextShadowLayer = Layer.effect( const docker = yield* DockerRun; const dbConnection = yield* DbConnection; const httpClient = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + const cliSettings = yield* CommandSettings; const runtimeWith = (outputService: typeof Output.Service) => Layer.mergeAll( @@ -150,6 +159,9 @@ export const pgDeltaNextShadowLayer = Layer.effect( Layer.succeed(DockerRun, docker), Layer.succeed(DbConnection, dbConnection), Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(CommandSettings, cliSettings), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const runtime = runtimeWith(output); @@ -181,7 +193,10 @@ export const pgDeltaNextShadowLayer = Layer.effect( request.projectRef, request.toml.remoteOverrideKeys, ); - const image = yield* localInputs.resolvePostgresImage; + const image = + (yield* currentStackBackend).kind === "stack" + ? "stack-ephemeral" + : yield* localInputs.resolvePostgresImage; // One JWKS memo shared by every input built from this base: `provisionPlan`'s two // shadows must hash identical JWKS bytes or their snapshot keys can never match. return { @@ -271,6 +286,35 @@ export const pgDeltaNextShadowLayer = Layer.effect( } satisfies ProvisionedDeclarativeShadow; }).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError)); + const stackAcquire = (input: NativeShadowInput, opts: ShadowCacheOpts) => + Effect.acquireRelease( + stackAcquireShadowDatabase(input.base, { + ...(opts.bypassCache === true ? { bypassCache: true } : {}), + port: input.base.shadowPort, + }), + (handle) => stackReleaseShadowDatabase(handle), + ); + + const stackProvisionMigrations = (input: NativeShadowInput, opts: ShadowCacheOpts) => + Effect.gen(function* () { + const handle = yield* stackAcquire(input, opts); + yield* stackMigrateShadow(handle, input.base); + return { + migrationsUrl: handle.url, + snapshotKey: handle.snapshotKey, + } satisfies ProvisionedMigrationsShadow; + }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); + + const stackProvisionDeclarative = (input: NativeShadowInput, opts: ShadowCacheOpts) => + Effect.gen(function* () { + const handle = yield* stackAcquire(input, opts); + return { + declarativeUrl: handle.url, + restoredFromPgDataSnapshot: handle.baselinePresent, + snapshotKey: handle.snapshotKey, + } satisfies ProvisionedDeclarativeShadow; + }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); + const cacheOpts = ( opts: PgDeltaNextShadowInput, webhooks: NonNullable, @@ -285,7 +329,10 @@ export const pgDeltaNextShadowLayer = Layer.effect( const port = yield* nextPort(); const built = yield* buildNativeBase(opts); const input = buildNativeInput(opts, built, port); - return yield* provisionMigrations(input, cacheOpts(opts, "config")); + const backend = yield* currentStackBackend; + return backend.kind === "stack" + ? yield* stackProvisionMigrations(input, cacheOpts(opts, "config")) + : yield* provisionMigrations(input, cacheOpts(opts, "config")); }).pipe(Effect.mapError(nextShadowError)), provisionPlan: (opts) => Effect.gen(function* () { @@ -294,6 +341,27 @@ export const pgDeltaNextShadowLayer = Layer.effect( const built = yield* buildNativeBase(opts); const migrationsInput = buildNativeInput(opts, built, migrationsPort); const declarativeInput = buildNativeInput(opts, built, declarativePort); + const backend = yield* currentStackBackend; + if (backend.kind === "stack") { + const migrations = yield* stackProvisionMigrations( + migrationsInput, + cacheOpts(opts, "config"), + ); + const declarative = yield* stackProvisionDeclarative( + declarativeInput, + cacheOpts(opts, "disabled"), + ); + return { + migrationsUrl: migrations.migrationsUrl, + declarativeUrl: declarative.declarativeUrl, + allowSameDatabaseIdentity: allowSameDatabaseIdentityForPlanShadows({ + declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot, + sameSnapshotKey: + migrations.snapshotKey !== undefined && + migrations.snapshotKey === declarative.snapshotKey, + }), + } satisfies PgDeltaNextPlanShadows; + } const [migrationsPeek, declarativePeek] = yield* Effect.all([ peekShadowBaseline(migrationsInput.base, cacheOpts(opts, "config")), peekShadowBaseline(declarativeInput.base, cacheOpts(opts, "disabled")), diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 336fff70dd..c9e544dff6 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -13,6 +13,8 @@ import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start import { resolveLocalProjectId, localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { stackEnsureLocalDatabaseStarted } from "../../experimental/stack/stack-local-database.ts"; const shadowDockerCause = (stderr: string): { readonly docker: "daemon" } | Record => isDockerDaemonUnreachable(stderr) ? { docker: "daemon" } : {}; @@ -74,6 +76,14 @@ export const declarativeSeamLayer = Layer.effect( return DeclarativeSeam.of({ ensureLocalDatabaseStarted: () => Effect.gen(function* () { + const backend = yield* currentStackBackend; + if (backend.kind === "stack") { + return yield* stackEnsureLocalDatabaseStarted.pipe( + Effect.provideService(CommandSettings, cliSettings), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + } const running = yield* isLocalDbRunning( spawner, fs, diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 18f20afe78..2dbee02bae 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -51,6 +51,8 @@ stack = true expect(yield* resolve({ args: ["start"], cwd: join(root, "nested"), env: {} })).toBe("stack"); expect(yield* resolve({ args: ["stop"], cwd: root, env: {} })).toBe("stack"); expect(yield* resolve({ args: ["status"], cwd: root, env: {} })).toBe("legacy"); + expect(yield* resolve({ args: ["db", "diff"], cwd: root, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["migration", "squash"], cwd: root, env: {} })).toBe("stack"); }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index 82a7dd2beb..2f2e68e95f 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -1,5 +1,5 @@ import { CliConfigSchema } from "@supabase/config/effect"; -import { Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Context, Data, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { resolveExperimentalFeature } from "../../../command-internal/experimental-feature.ts"; @@ -12,6 +12,9 @@ import { export type StackBackend = "legacy" | "stack"; +/** Commands that consult experimental.stack for local database and shadow routing. */ +const STACK_BACKEND_COMMANDS = new Set(["start", "stop", "db", "migration"]); + export class StackRoutingError extends Data.TaggedError("StackRoutingError")<{ readonly message: string; readonly cause?: unknown; @@ -25,6 +28,21 @@ export class StackRoutingError extends Data.TaggedError("StackRoutingError")<{ } } +/** In-process backend selected before parse; handlers must not re-read argv. */ +export class StackBackendContext extends Context.Service< + StackBackendContext, + { readonly kind: StackBackend } +>()("supabase/stack/Backend") {} + +export const stackBackendLayer = (kind: StackBackend) => + Layer.succeed(StackBackendContext, { kind }); + +/** Handlers default to legacy when tests omit the root-provided backend service. */ +export const currentStackBackend: Effect.Effect<{ readonly kind: StackBackend }, never, never> = + Effect.serviceOption(StackBackendContext).pipe( + Effect.map((value) => Option.getOrElse(value, () => ({ kind: "legacy" as const }))), + ); + const stackRoutingSchema = Schema.Struct({ experimental: Schema.optionalKey( Schema.Struct({ stack: CliConfigSchema.fields.experimental.to.fields.stack }), @@ -92,7 +110,7 @@ export const resolveStackBackend = (input: { // The explicit namespace is always backed by the stack runtime and does // not need a project config or environment lookup to select it. if (command === "stack") return "stack"; - if (command !== "start" && command !== "stop") return "legacy"; + if (command === undefined || !STACK_BACKEND_COMMANDS.has(command)) return "legacy"; const configValue = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts new file mode 100644 index 0000000000..8c36c4b7b5 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Redacted, Stream } from "effect"; +import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; +import { mockCommandSettings, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; +import { stackBackendLayer } from "./stack-backend.ts"; +import { stackLocalDatabaseUrl } from "./stack-local-database.ts"; +import { StackApi } from "./stack.shared.ts"; + +const tmp = useTempWorkdir("stack-local-db-"); +const STACK_ID = StackIdSchema.make("a".repeat(64)); + +const unused = () => Effect.die("unused"); + +const stack: EffectStack = { + id: STACK_ID, + status: () => + Effect.succeed({ + id: STACK_ID, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? "eager" : "lazy", + state: name === "database" ? "ready" : "dormant", + })), + artifacts: [], + }), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make("postgresql://postgres:secret@127.0.0.1:54329/postgres"), + password: Redacted.make("secret"), + }, + api: { + publishableKey: "anon", + secretKey: Redacted.make("service"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: unused, + start: unused, + stop: unused, + destroy: unused, + logs: unused, + followLogs: () => Stream.empty, +}; + +describe("stackLocalDatabaseUrl", () => { + it.effect("returns the project stack database URL when the database is ready", () => { + const api = Layer.succeed(StackApi, { + createStack: unused, + findStack: () => + Effect.succeed( + Option.some({ + id: STACK_ID, + projectRoot: tmp.current, + name: "default", + branchContext: "main", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }), + ), + discoverStacks: unused, + openStack: () => Effect.succeed(stack), + inspectStack: unused, + }); + return Effect.gen(function* () { + expect(yield* stackLocalDatabaseUrl).toBe( + "postgresql://postgres:secret@127.0.0.1:54329/postgres", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + mockCommandSettings({ workdir: tmp.current }), + stackBackendLayer("stack"), + api, + ), + ), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-local-database.ts b/apps/cli/src/commands/experimental/stack/stack-local-database.ts new file mode 100644 index 0000000000..88023cbef8 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-local-database.ts @@ -0,0 +1,192 @@ +import { Data, Effect, FileSystem, Option, Path, Redacted } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; +import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; +import type { EffectStack } from "@supabase/stack/effect"; +import type { StackRuntime } from "@supabase/stack/effect"; +import { parseConnectionString } from "../../../command-internal/db-config.parse.ts"; +import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { + LocalDbRunningError, + isLocalDbRunning, + type LocalDockerEngine, +} from "../../../command-internal/db-bootstrap/local-db-running.ts"; +import { DeclarativeShadowDbError } from "../../db/shared/pgdelta.errors.ts"; +import { currentStackBackend } from "./stack-backend.ts"; +import { StackApi } from "./stack.shared.ts"; +import { loadStackConfig } from "./stack-config.ts"; + +const notRunning = (message = "supabase start is not running.") => + new LocalDbRunningError({ message }); + +const databaseReady = (stack: EffectStack) => + Effect.gen(function* () { + const status = yield* stack + .status() + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + const database = status.capabilities.find((capability) => capability.name === "database"); + if (status.lifecycle !== "running" || database?.state !== "ready") return Option.none(); + return Option.some({ stack, runtime: status.runtime }); + }); + +const openProjectStack = () => + Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return Option.none(); + const cliSettings = yield* CommandSettings; + const descriptor = yield* api.value + .findStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + if (Option.isNone(descriptor)) return Option.none(); + const stack = yield* api.value + .openStack(descriptor.value.id) + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + return yield* databaseReady(stack); + }); + +export const stackProjectRuntime: Effect.Effect< + StackRuntime | undefined, + never, + CommandSettings +> = Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return undefined; + const cliSettings = yield* CommandSettings; + const descriptor = yield* api.value + .findStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.orElseSucceed(() => Option.none())); + return Option.match(descriptor, { + onNone: () => undefined, + onSome: (value) => value.runtime, + }); +}); + +export const STACK_NATIVE_ENGINE_MESSAGE = + "The stack backend only supports the pg-delta engine. Do not pass --use-migra, --use-pgadmin, --use-pg-schema, or --diff-engine migra."; + +export class StackNativeEngineError extends Data.TaggedError("StackNativeEngineError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export const stackRejectNativeDockerDiffEngine: Effect.Effect = + Effect.gen(function* () { + const backend = yield* currentStackBackend; + if (backend.kind !== "stack") return; + return yield* new StackNativeEngineError({ message: STACK_NATIVE_ENGINE_MESSAGE }); + }); + +export const stackLocalDatabaseUrl: Effect.Effect = + Effect.gen(function* () { + const opened = yield* openProjectStack(); + if (Option.isNone(opened)) return yield* notRunning(); + const credentials = yield* opened.value.stack + .credentials() + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + return Redacted.value(credentials.database.url); + }); + +export const stackLocalDatabaseConn: Effect.Effect< + PgConnInput, + LocalDbRunningError, + CommandSettings +> = Effect.gen(function* () { + const url = yield* stackLocalDatabaseUrl; + const conn = parseConnectionString(url); + if (conn === undefined) { + return yield* notRunning(`failed to parse stack database URL`); + } + return conn; +}); + +export const stackLocalDatabaseIsRunning: Effect.Effect< + boolean, + LocalDbRunningError, + CommandSettings +> = openProjectStack().pipe(Effect.map(Option.isSome)); + +export const resolveLocalDatabaseIsRunning = ( + spawner: ChildProcessSpawnerType["Service"], + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + configuredProjectId: string | undefined, +): Effect.Effect => + Effect.gen(function* () { + const backend = yield* currentStackBackend; + if (backend.kind === "legacy") + return yield* isLocalDbRunning(spawner, fs, path, workdir, configuredProjectId); + return yield* stackLocalDatabaseIsRunning; + }); + +export const stackEnsureLocalDatabaseStarted: Effect.Effect< + void, + DeclarativeShadowDbError, + CommandSettings | FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) { + return yield* new DeclarativeShadowDbError({ + message: "failed to start local database: supabase start is not running.", + }); + } + const cliSettings = yield* CommandSettings; + const existing = yield* api.value.findStack({ projectRoot: cliSettings.workdir }).pipe( + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + }), + ), + ); + const config = yield* loadStackConfig(cliSettings.workdir).pipe( + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + }), + ), + ); + const stack = Option.isSome(existing) + ? yield* api.value.openStack(existing.value.id).pipe( + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + }), + ), + ) + : yield* api.value.createStack({ projectRoot: cliSettings.workdir }).pipe( + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + }), + ), + ); + const status = yield* stack.status().pipe( + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + }), + ), + ); + const database = status.capabilities.find((capability) => capability.name === "database"); + if (status.lifecycle === "running" && database?.state === "ready") return; + yield* stack.start({ config }).pipe( + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + }), + ), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts new file mode 100644 index 0000000000..bfdc4d57b4 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts @@ -0,0 +1,286 @@ +import { CliConfigSchema, type CliConfig } from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; +import { + EphemeralPostgresError, + type CreateEphemeralPostgresOptions, + type EffectEphemeralPostgres, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockCommandSettings, + useTempWorkdir, + withEnvVar, +} from "../../../../tests/helpers/command-mocks.ts"; +import { SHADOW_CACHE_ENV } from "../../../command-internal/db-bootstrap/shadow-cache.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { stackBackendLayer } from "./stack-backend.ts"; +import { + StackEphemeralPostgres, + stackAcquireShadowDatabase, + stackShadowBaselineTarFileName, + stackShadowCacheKey, +} from "./stack-shadow.ts"; +import type { ShadowSetupInput } from "../../../command-internal/db-bootstrap/shadow-database.ts"; + +const tmp = useTempWorkdir("stack-shadow-"); +const defaultConfig: CliConfig = Schema.decodeSync(CliConfigSchema)({}); + +const mockEphemeral = () => { + const restores: Array = []; + const exports: Array = []; + const create = ( + options: CreateEphemeralPostgresOptions, + ): Effect.Effect => + Effect.sync(() => { + restores.push(options.restoreFrom); + return { + host: "127.0.0.1", + port: 59999, + version: "17.6.1", + runtime: { kind: "native" as const }, + artifactIdentity: "native:17.6.1", + url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), + start: () => Effect.void, + stop: () => Effect.void, + exportPgData: (tarPath: string) => + Effect.gen(function* () { + exports.push(tarPath); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(tarPath, "pgdata").pipe(Effect.ignore); + }), + }; + }); + return { + restores, + exports, + layer: Layer.succeed(StackEphemeralPostgres, { + create, + resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), + }), + }; +}; + +const db = Layer.succeed(DbConnection, { + connect: () => + Effect.succeed({ + exec: () => Effect.void, + query: () => Effect.succeed([]), + execBatch: () => Effect.void, + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), +}); + +const input = (fs: FileSystem.FileSystem, path: Path.Path): ShadowSetupInput => ({ + db: { major_version: 17, settings: {} }, + experimental: defaultConfig.experimental, + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + networkId: "n", + image: "stack-ephemeral", + configImage: "stack-ephemeral", + shadowPort: 54320, + password: "postgres", + projectId: "proj", + isBitbucketPipeline: false, + workdir: tmp.current, + extraHosts: [], + fs, + path, + hostname: "127.0.0.1", + healthTimeoutSeconds: 2, + setup: { + majorVersion: 17, + config: defaultConfig, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54320/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: Effect.succeed("{}"), + apiUrl: "http://127.0.0.1:54321", + authExternalUrl: undefined, + siteUrl: "http://127.0.0.1:3000", + anonKey: "anon", + serviceRoleKey: "service", + storageTargetMigration: "", + realtimeEnabledForSetup: false, + storageEnabledForSetup: false, + authEnabledForSetup: false, + serviceVersionOverrides: {}, + projectEnvValues: undefined, + debug: false, + webhooksEnabled: false, + apiAutoExposeNewTables: Option.none(), + vault: [], + }, +}); + +const withShadowCacheHome = ( + home: string, + value: string, + body: Effect.Effect, +): Effect.Effect => + withEnvVar("SUPABASE_HOME", home, withEnvVar(SHADOW_CACHE_ENV, value, body)); + +describe("stackAcquireShadowDatabase", () => { + it.live( + "exports a stack-shadow-baseline tar on a cold miss and restores it on a warm hit", + () => { + const ephemeral = mockEphemeral(); + const out = mockOutput(); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + return yield* withShadowCacheHome( + home, + "1", + Effect.gen(function* () { + const first = yield* stackAcquireShadowDatabase(input(fs, path)); + expect(first.baselinePresent).toBe(false); + expect(first.artifactIdentity).toBe("native:17.6.1"); + expect(ephemeral.restores).toEqual([undefined]); + expect(ephemeral.exports).toHaveLength(1); + expect(ephemeral.exports[0]?.endsWith(`.${String(process.pid)}.partial`)).toBe(true); + const names = (yield* fs.readDirectory( + path.join(home, "cache", "shadow-baseline"), + )).filter((name) => name.endsWith(".tar") && !name.includes(".partial")); + expect(names).toHaveLength(1); + const info = yield* fs.stat(path.join(home, "cache", "shadow-baseline", names[0]!)); + expect((Number(info.mode) & 0o777).toString(8)).toBe("600"); + expect(names[0]?.startsWith("stack-shadow-baseline-")).toBe(true); + expect(names[0]).toBe( + stackShadowBaselineTarFileName( + stackShadowCacheKey({ + artifactIdentity: "native:17.6.1", + majorVersion: 17, + runtimeKind: "native", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + dbPassword: "postgres", + dbSettings: {}, + rolesSql: "", + }), + ), + ); + + const warm = yield* stackAcquireShadowDatabase(input(fs, path)); + expect(warm.baselinePresent).toBe(true); + expect(ephemeral.restores[1]?.endsWith(names[0] ?? "")).toBe(true); + }), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + db, + mockCommandSettings({ workdir: tmp.current }), + stackBackendLayer("stack"), + ephemeral.layer, + ), + ), + ); + }, + ); + + it.live("skips the cache when SUPABASE_SHADOW_CACHE is 0", () => { + const ephemeral = mockEphemeral(); + const out = mockOutput(); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + return yield* withShadowCacheHome( + home, + "0", + Effect.gen(function* () { + const handle = yield* stackAcquireShadowDatabase(input(fs, path)); + expect(handle.baselinePresent).toBe(false); + expect(ephemeral.exports).toHaveLength(0); + const names = yield* fs + .readDirectory(path.join(home, "cache", "shadow-baseline")) + .pipe(Effect.orElseSucceed(() => [])); + expect(names.filter((name) => name.endsWith(".tar"))).toEqual([]); + }), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + db, + mockCommandSettings({ workdir: tmp.current }), + stackBackendLayer("stack"), + ephemeral.layer, + ), + ), + ); + }); + + it.live("keeps the cluster uncached when the baseline export fails", () => { + const restores: Array = []; + const out = mockOutput(); + const layer = Layer.succeed(StackEphemeralPostgres, { + create: (options) => + Effect.sync(() => { + restores.push(options.restoreFrom); + return { + host: "127.0.0.1", + port: 59999, + version: "17.6.1", + runtime: { kind: "native" as const }, + artifactIdentity: "native:17.6.1", + url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), + start: () => Effect.void, + stop: () => Effect.void, + exportPgData: () => + Effect.fail( + new EphemeralPostgresError({ message: "export failed", reason: "snapshot" }), + ), + }; + }), + resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), + }); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + return yield* withShadowCacheHome( + home, + "1", + Effect.gen(function* () { + const handle = yield* stackAcquireShadowDatabase(input(fs, path)); + expect(handle.baselinePresent).toBe(false); + expect(handle.snapshotKey).toBeUndefined(); + expect(restores).toEqual([undefined]); + expect(out.stderrText).toContain("Warning: shadow baseline not cached:"); + const names = yield* fs + .readDirectory(path.join(home, "cache", "shadow-baseline")) + .pipe(Effect.orElseSucceed(() => [])); + expect( + names.filter((name) => name.endsWith(".tar") && !name.includes(".partial")), + ).toEqual([]); + }), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + db, + mockCommandSettings({ workdir: tmp.current }), + stackBackendLayer("stack"), + layer, + ), + ), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-shadow.ts b/apps/cli/src/commands/experimental/stack/stack-shadow.ts new file mode 100644 index 0000000000..a65fedaab1 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-shadow.ts @@ -0,0 +1,587 @@ +import { scryptSync } from "node:crypto"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- pid scopes the exclusive temp name across processes. +import process from "node:process"; +import { + Clock, + Context, + Crypto, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Redacted, + Result, + Scope, + Semaphore, +} from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + createEphemeralPostgres, + resolveEphemeralPostgresRelease, + type CreateEphemeralPostgresOptions, + type EffectEphemeralPostgres, + type EphemeralPostgresRelease, + type EphemeralPostgresSettings, + type StackRuntime, + type StackRuntimePreference, + type StackVersionUnsupportedError, +} from "@supabase/stack/effect"; +import { Output } from "../../../shared/output/output.service.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { shadowBaselineCacheDir } from "../../../command-internal/pgdelta.paths.ts"; +import { + SHADOW_BASELINE_KEEP, + SHADOW_BASELINE_MAX_AGE_MS, + SHADOW_CACHE_ENV, + shadowBaselineTarsToEvict, + touchShadowBaselineTar, +} from "../../../command-internal/db-bootstrap/shadow-cache.ts"; +import { viperEnvBoolWithProjectFallback } from "../../../command-internal/viper-env.ts"; +import { + connectShadowDatabase, + ShadowDbError, + type ShadowSetupInput, + type ShadowSourceResult, +} from "../../../command-internal/db-bootstrap/shadow-database.ts"; +import { listLocalMigrationPaths } from "../../../command-internal/migration-history.ts"; +import { applyMigrations, seedGlobals } from "../../../command-internal/migration-apply.ts"; +import { stackProjectRuntime } from "./stack-local-database.ts"; + +/** Optional factory so CLI tests can `Layer.succeed` a fake cluster. */ +export class StackEphemeralPostgres extends Context.Service< + StackEphemeralPostgres, + { + readonly create: typeof createEphemeralPostgres; + readonly resolveRelease: typeof resolveEphemeralPostgresRelease; + } +>()("supabase/experimental-stack/EphemeralPostgres") {} + +export const ephemeralPostgresLayer = Layer.succeed(StackEphemeralPostgres, { + create: createEphemeralPostgres, + resolveRelease: resolveEphemeralPostgresRelease, +}); + +const TAR_PREFIX = "stack-shadow-baseline-"; + +/** A partial older than 5 minutes is abandoned; a live export finishes in seconds. */ +const STACK_SHADOW_PARTIAL_ABANDON_MS = 5 * 60 * 1000; + +export const stackShadowBaselineTarFileName = (key: string): string => `${TAR_PREFIX}${key}.tar`; + +const isStackShadowBaselineTar = (fileName: string): boolean => + /^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u.test(fileName); + +export function isStackShadowBaselinePartial(fileName: string): boolean { + return /^stack-shadow-baseline-[0-9a-f]{16}\.tar\.\d+\.partial$/u.test(fileName); +} + +const stackShadowExportMutex = Semaphore.makeUnsafe(1); + +export interface StackShadowCacheKeyInputs { + readonly artifactIdentity: string; + readonly majorVersion: number; + readonly runtimeKind: string; + readonly jwtSecret: string; + readonly jwtExpiry: number; + readonly dbPassword: string; + readonly dbSettings: unknown; + readonly rolesSql: string; +} + +export const stackShadowCacheKey = (inputs: StackShadowCacheKeyInputs): string => { + const quoted = (value: string) => JSON.stringify(value); + const payload = [ + `artifact=${quoted(inputs.artifactIdentity)}`, + `major_version=${inputs.majorVersion}`, + `runtime=${quoted(inputs.runtimeKind)}`, + `jwt_secret=${quoted(inputs.jwtSecret)}`, + `jwt_expiry=${inputs.jwtExpiry}`, + `db_password=${quoted(inputs.dbPassword)}`, + `db_settings=${JSON.stringify(inputs.dbSettings ?? {})}`, + ].join("\n"); + return scryptSync( + `${payload}\nroles_sql=\n${inputs.rolesSql}`, + "supabase-stack-shadow-cache-key", + 32, + ) + .toString("hex") + .slice(0, 16); +}; + +export interface StackShadowAcquiredHandle { + readonly url: string; + readonly host: string; + readonly port: number; + readonly artifactIdentity: string; + readonly runtime: StackRuntime; + readonly baselinePresent: boolean; + readonly snapshotKey?: string; + readonly ephemeral: EffectEphemeralPostgres; +} + +export interface StackShadowAcquireOpts { + readonly bypassCache?: boolean; + readonly port?: number; + readonly runtime?: StackRuntimePreference; +} + +const cacheEnabled = (projectEnv: Record | undefined, bypass: boolean): boolean => + !bypass && + viperEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, projectEnv ?? {}, { + whenUnset: true, + }); + +const canonicalSettings = (value: unknown): unknown => { + if (value === null || typeof value !== "object") return value ?? {}; + if (Array.isArray(value)) return value.map(canonicalSettings); + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalSettings(entry)]), + ); +}; + +const readRolesSql = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): Effect.Effect => + fs.readFileString(path.join(workdir, "supabase", "roles.sql")).pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed("") + : Effect.fail( + new ShadowDbError({ + message: `failed to read supabase/roles.sql: ${error.message}`, + reason: "filesystem", + }), + ), + ), + ); + +const runtimePreference = ( + runtime: StackRuntime | undefined, + override?: StackRuntimePreference, +): StackRuntimePreference | undefined => { + if (override !== undefined) return override; + if (runtime === undefined) return undefined; + return runtime.kind === "native" + ? { kind: "native" } + : { kind: "container", engine: runtime.engine }; +}; + +const postgresSettings = (value: unknown): EphemeralPostgresSettings | undefined => { + if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) + return undefined; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string | number | boolean] => + typeof entry[1] === "string" || + typeof entry[1] === "number" || + typeof entry[1] === "boolean", + ), + ); +}; + +const createOptions = ( + input: ShadowSetupInput, + runtime: StackRuntimePreference | undefined, + restoreFrom: string | undefined, + port: number | undefined, +): CreateEphemeralPostgresOptions => ({ + databasePassword: Redacted.make(input.password), + jwtSecret: Redacted.make(input.jwtSecret), + jwtExpiry: input.jwtExpiry, + postgresSettings: postgresSettings(input.db.settings), + healthTimeout: `${String(input.healthTimeoutSeconds)}s`, + version: String(input.setup.majorVersion), + ...(runtime === undefined ? {} : { runtime }), + ...(port === undefined ? {} : { port }), + ...(restoreFrom === undefined ? {} : { restoreFrom }), +}); + +const connFrom = (handle: EffectEphemeralPostgres, password: string) => ({ + host: handle.host, + port: handle.port, + user: "postgres", + password, + database: "postgres", +}); + +const applyRoles = ( + handle: EffectEphemeralPostgres, + input: ShadowSetupInput, + rolesSql: string, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + if (rolesSql.length === 0) return; + const session = yield* connectShadowDatabase(connFrom(handle, input.password)); + yield* seedGlobals( + session, + input.fs, + input.path, + [input.path.join(input.workdir, "supabase", "roles.sql")], + (message) => new ShadowDbError({ message, reason: "database" }), + ).pipe( + Effect.catchTag("DbConnectError"), (cause) => + Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); + }), + ); + +const artifactIdentityFor = ( + runtime: StackRuntimePreference | undefined, + version: string, + image: string, +): string => + runtime?.kind === "container" + ? `container:${runtime.engine ?? "docker"}:${image}` + : `native:${version}`; + +const sweepAbandonedPartials = ( + fs: FileSystem.FileSystem, + path: Path.Path, + cacheDir: string, +): Effect.Effect => + Effect.gen(function* () { + const names = yield* fs.readDirectory(cacheDir).pipe(Effect.orElseSucceed(() => [])); + const now = yield* Clock.currentTimeMillis; + yield* Effect.forEach( + names.filter(isStackShadowBaselinePartial), + (fileName) => + Effect.gen(function* () { + const filePath = path.join(cacheDir, fileName); + const info = yield* fs.stat(filePath); + const mtime = Option.getOrUndefined(info.mtime); + if (mtime !== undefined && now - mtime.getTime() > STACK_SHADOW_PARTIAL_ABANDON_MS) { + yield* fs.remove(filePath).pipe(Effect.ignore); + } + }).pipe(Effect.ignore), + { discard: true }, + ); + }); + +const sweepCache = ( + fs: FileSystem.FileSystem, + path: Path.Path, + cacheDir: string, + keepName: string | undefined, +): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const names = yield* fs.readDirectory(cacheDir).pipe(Effect.orElseSucceed(() => [])); + const entries: Array<{ readonly fileName: string; readonly mtimeMs: number }> = []; + for (const fileName of names) { + if (!isStackShadowBaselineTar(fileName)) continue; + const info = yield* fs.stat(path.join(cacheDir, fileName)).pipe(Effect.option); + if (Option.isNone(info) || Option.isNone(info.value.mtime)) continue; + entries.push({ fileName, mtimeMs: info.value.mtime.value.getTime() }); + } + yield* Effect.forEach( + shadowBaselineTarsToEvict(entries, now, { + keep: SHADOW_BASELINE_KEEP, + maxAgeMs: SHADOW_BASELINE_MAX_AGE_MS, + retainFileName: keepName, + isPublishedTar: isStackShadowBaselineTar, + }), + (fileName) => fs.remove(path.join(cacheDir, fileName)).pipe(Effect.ignore), + { discard: true }, + ); + }); + +const writeStackShadowBaselineTar = ( + fs: FileSystem.FileSystem, + path: Path.Path, + cacheDir: string, + tarPath: string, + exportPgData: (tempPath: string) => Effect.Effect, + skipIfPublished: boolean, +): Effect.Effect => + stackShadowExportMutex.withPermit( + Effect.gen(function* () { + if (skipIfPublished) { + const published = yield* fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); + if (published) return; + } + yield* fs.makeDirectory(cacheDir, { recursive: true, mode: 0o700 }).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to create ${cacheDir}: ${cause.message}`, + reason: "filesystem", + }), + ), + ); + yield* sweepAbandonedPartials(fs, path, cacheDir); + const tempPath = `${tarPath}.${String(process.pid)}.partial`; + yield* fs.remove(tempPath).pipe(Effect.ignore); + yield* Effect.gen(function* () { + yield* Effect.scoped( + fs.open(tempPath, { flag: "wx", mode: 0o600 }).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to create ${tempPath}: ${cause.message}`, + reason: "filesystem", + }), + ), + Effect.asVoid, + ), + ); + yield* exportPgData(tempPath); + yield* fs.chmod(tempPath, 0o600).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to restrict ${tempPath}: ${cause.message}`, + reason: "filesystem", + }), + ), + ); + yield* fs.rename(tempPath, tarPath).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to publish ${tarPath}: ${cause.message}`, + reason: "filesystem", + }), + ), + ); + }).pipe(Effect.onError(() => fs.remove(tempPath).pipe(Effect.ignore))); + yield* sweepCache(fs, path, cacheDir, path.basename(tarPath)); + }), + ); + +const mapCreateError = (cause: unknown): ShadowDbError => + new ShadowDbError({ + message: + typeof cause === "object" && cause !== null && "message" in cause + ? String(Reflect.get(cause, "message")) + : String(cause), + reason: "database", + }); + +const runtimeKindFor = (runtime: StackRuntimePreference | undefined): string => + runtime?.kind === "container" ? `container:${runtime.engine ?? "docker"}` : "native"; + +const ephemeralApis = (): Effect.Effect<{ + readonly create: typeof createEphemeralPostgres; + readonly resolveRelease: ( + version?: string, + ) => Effect.Effect; +}> => + Effect.serviceOption(StackEphemeralPostgres).pipe( + Effect.map((value) => + Option.getOrElse(value, () => ({ + create: createEphemeralPostgres, + resolveRelease: resolveEphemeralPostgresRelease, + })), + ), + ); + +export const stackAcquireShadowDatabase = ( + input: ShadowSetupInput, + opts: StackShadowAcquireOpts = {}, +): Effect.Effect< + StackShadowAcquiredHandle, + ShadowDbError | E, + | Output + | DbConnection + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner + | Scope.Scope + | CommandSettings +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const apis = yield* ephemeralApis(); + const projectRuntime = yield* stackProjectRuntime; + const runtime = runtimePreference(projectRuntime, opts.runtime); + const rolesSql = yield* readRolesSql(input.fs, input.path, input.workdir); + const cacheOn = cacheEnabled(input.setup.projectEnvValues, opts.bypassCache === true); + const cacheDir = shadowBaselineCacheDir(path); + yield* fs.makeDirectory(cacheDir, { recursive: true, mode: 0o700 }).pipe(Effect.ignore); + + const startEmpty = () => + apis + .create(createOptions(input, runtime, undefined, opts.port)) + .pipe(Effect.mapError(mapCreateError)); + + if (!cacheOn) { + const ephemeral = yield* startEmpty(); + yield* applyRoles(ephemeral, input, rolesSql); + return { + url: Redacted.value(ephemeral.url), + host: ephemeral.host, + port: ephemeral.port, + artifactIdentity: ephemeral.artifactIdentity, + runtime: ephemeral.runtime, + baselinePresent: false, + ephemeral, + }; + } + + const release = yield* apis + .resolveRelease(String(input.setup.majorVersion)) + .pipe(Effect.mapError(mapCreateError)); + const identity = artifactIdentityFor(runtime, release.version, release.image); + const key = stackShadowCacheKey({ + artifactIdentity: identity, + majorVersion: input.setup.majorVersion, + runtimeKind: runtimeKindFor(runtime), + jwtSecret: input.jwtSecret, + jwtExpiry: input.jwtExpiry, + dbPassword: input.password, + dbSettings: canonicalSettings(input.db.settings), + rolesSql, + }); + const tarName = stackShadowBaselineTarFileName(key); + const tarPath = path.join(cacheDir, tarName); + const cached = yield* fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); + yield* sweepAbandonedPartials(fs, path, cacheDir); + yield* sweepCache(fs, path, cacheDir, tarName); + + if (cached) { + const restored = yield* apis + .create(createOptions(input, runtime, tarPath, opts.port)) + .pipe(Effect.exit); + if (Exit.isSuccess(restored)) { + yield* touchShadowBaselineTar(fs, tarPath); + return { + url: Redacted.value(restored.value.url), + host: restored.value.host, + port: restored.value.port, + artifactIdentity: restored.value.artifactIdentity, + runtime: restored.value.runtime, + baselinePresent: true, + snapshotKey: key, + ephemeral: restored.value, + }; + } + } + + const probe = yield* startEmpty(); + yield* applyRoles(probe, input, rolesSql); + const exported = yield* Effect.result( + Effect.gen(function* () { + const rolesSqlNow = yield* readRolesSql(input.fs, input.path, input.workdir); + if (rolesSqlNow !== rolesSql) { + return yield* new ShadowDbError({ + message: "supabase/roles.sql changed during provisioning", + reason: "filesystem", + }); + } + yield* probe.stop().pipe(Effect.mapError(mapCreateError)); + yield* writeStackShadowBaselineTar( + fs, + path, + cacheDir, + tarPath, + (tempPath) => probe.exportPgData(tempPath).pipe(Effect.mapError(mapCreateError)), + !cached, + ); + }), + ); + yield* probe.start().pipe(Effect.mapError(mapCreateError)); + if (Result.isFailure(exported)) { + const output = yield* Output; + yield* output.raw( + `Warning: shadow baseline not cached: ${exported.failure.message}\n`, + "stderr", + ); + } + return { + url: Redacted.value(probe.url), + host: probe.host, + port: probe.port, + artifactIdentity: probe.artifactIdentity, + runtime: probe.runtime, + baselinePresent: false, + snapshotKey: Result.isSuccess(exported) ? key : undefined, + ephemeral: probe, + }; + }); + +export const stackReleaseShadowDatabase = ( + handle: StackShadowAcquiredHandle, +): Effect.Effect => handle.ephemeral.stop().pipe(Effect.ignore); + +export const stackWithShadowDatabase = ( + input: ShadowSetupInput, + use: (handle: StackShadowAcquiredHandle) => Effect.Effect, + opts: StackShadowAcquireOpts = {}, +): Effect.Effect< + A, + E2 | ShadowDbError | E, + | R2 + | Output + | DbConnection + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner + | Scope.Scope + | CommandSettings +> => + Effect.acquireUseRelease(stackAcquireShadowDatabase(input, opts), use, (handle) => + stackReleaseShadowDatabase(handle), + ); + +export const stackPrepareShadowSource = ( + handle: StackShadowAcquiredHandle, + input: ShadowSetupInput, +): Effect.Effect< + Pick, + ShadowDbError, + DbConnection | Output | Scope.Scope | FileSystem.FileSystem | Path.Path +> => + stackMigrateShadow(handle, input).pipe( + Effect.as({ sourceUrl: handle.url, targetUrlOverride: undefined }), + ); + +export const stackMigrateShadow = ( + handle: StackShadowAcquiredHandle, + input: ShadowSetupInput, +): Effect.Effect< + void, + ShadowDbError, + DbConnection | Output | Scope.Scope | FileSystem.FileSystem | Path.Path +> => + Effect.scoped( + Effect.gen(function* () { + const migrationsDir = input.path.join(input.workdir, "supabase", "migrations"); + const pending = yield* listLocalMigrationPaths( + input.fs, + input.path, + migrationsDir, + ).pipe( + Effect.mapError( + (cause) => new ShadowDbError({ message: cause.message, reason: "filesystem" }), + ), + ); + const session = yield* connectShadowDatabase( + connFrom(handle.ephemeral, input.password), + ); + yield* applyMigrations( + session, + input.fs, + input.path, + pending, + (message) => new ShadowDbError({ message, reason: "database" }), + ).pipe( + Effect.catchTag("DbConnectError"), (cause) => + Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); + }), + ); diff --git a/apps/cli/src/commands/experimental/stack/stack-shadow.unit.test.ts b/apps/cli/src/commands/experimental/stack/stack-shadow.unit.test.ts new file mode 100644 index 0000000000..9ca87390d7 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-shadow.unit.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + stackShadowBaselineTarFileName, + stackShadowCacheKey, + isStackShadowBaselinePartial, +} from "./stack-shadow.ts"; + +const base = { + artifactIdentity: "native:17.6.1", + majorVersion: 17, + runtimeKind: "native", + jwtSecret: "jwt", + jwtExpiry: 3600, + dbPassword: "postgres", + dbSettings: {}, + rolesSql: "", +}; + +describe("stackShadowCacheKey", () => { + it("changes when the artifact identity or runtime kind changes", () => { + const native = stackShadowCacheKey(base); + const otherArtifact = stackShadowCacheKey({ + ...base, + artifactIdentity: "native:17.6.2", + }); + const container = stackShadowCacheKey({ + ...base, + artifactIdentity: "container:docker:example", + runtimeKind: "container:docker", + }); + expect(native).toMatch(/^[0-9a-f]{16}$/u); + expect(native).not.toBe(otherArtifact); + expect(native).not.toBe(container); + expect(stackShadowBaselineTarFileName(native)).toBe(`stack-shadow-baseline-${native}.tar`); + expect(stackShadowBaselineTarFileName(native)).not.toContain("shadow-baseline-shadow"); + }); + + it("recognizes only this module's own partial temp files as abandoned-sweep candidates", () => { + const key = "0123456789abcdef"; + expect(isStackShadowBaselinePartial(`stack-shadow-baseline-${key}.tar.4242.partial`)).toBe( + true, + ); + for (const other of [ + stackShadowBaselineTarFileName(key), + `shadow-baseline-${key}.tar.4242.partial`, + `stack-shadow-baseline-${key}.tar.partial`, + `stack-shadow-baseline-${key}.tar.4242.partial.bak`, + "catalog-local-migrations-abc-123.json", + ]) { + expect(isStackShadowBaselinePartial(other), other).toBe(false); + } + }); + + it("changes when roles.sql or db settings change", () => { + const withRoles = stackShadowCacheKey({ ...base, rolesSql: "create role x;" }); + expect(stackShadowCacheKey(base)).not.toBe(withRoles); + expect(stackShadowCacheKey({ ...base, dbSettings: { max_connections: 20 } })).not.toBe( + stackShadowCacheKey(base), + ); + }); +}); diff --git a/apps/cli/src/commands/migration/migration.layers.ts b/apps/cli/src/commands/migration/migration.layers.ts index e9366c12cc..ac0ec351e0 100644 --- a/apps/cli/src/commands/migration/migration.layers.ts +++ b/apps/cli/src/commands/migration/migration.layers.ts @@ -11,6 +11,8 @@ import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { linkedDbResolverRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; +import { stackApiLayer } from "../experimental/stack/stack.shared.ts"; +import { ephemeralPostgresLayer } from "../experimental/stack/stack-shadow.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -64,4 +66,6 @@ export const migrationSquashRuntimeLayer = Layer.mergeAll( dockerRunLayer, httpClient, debugLoggerLayer, + stackApiLayer, + ephemeralPostgresLayer, ); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index e15fd9a6e9..1993fa27d2 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -43,6 +43,9 @@ import { DbConnection, type PgConnInput } from "../../../command-internal/db-con import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { errorMessage, relativizeErrorMessage } from "../../../command-internal/error-message.ts"; +import { viperEnvStringWithProjectFallback } from "../../../command-internal/viper-env.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { stackWithShadowDatabase } from "../../experimental/stack/stack-shadow.ts"; import { applyMigrations, MigrationApplyError } from "../../../command-internal/migration-apply.ts"; import { INSERT_MIGRATION_VERSION, @@ -89,7 +92,10 @@ const squashMigrations = Effect.fnUntraced(function* ( localInputs: LocalDbContainerInputs, toml: DbTomlValues, ) { - const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + const stackBackend = (yield* currentStackBackend).kind === "stack"; + const resolvedShadowImage = stackBackend + ? "stack-ephemeral" + : yield* localInputs.resolvePostgresImage; const shadowInput = shadowRunInputFromLocalContainerInputs( localInputs, resolvedShadowImage, @@ -108,6 +114,97 @@ const squashMigrations = Effect.fnUntraced(function* ( // `pg_dump` container below uses; `squashDumpSchema` applies the registry mirror itself. const image = localInputs.bootstrapConfig.postgresImage; + if (stackBackend) { + return yield* stackWithShadowDatabase(shadowInput, (handle) => + Effect.scoped( + Effect.gen(function* () { + const stackConn: PgConnInput = { + host: handle.host, + port: handle.port, + user: "postgres", + password: toml.password, + database: "postgres", + }; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* NetworkIdFlag; + const networkId = Option.getOrUndefined(networkIdFlag); + const envNetworkId = viperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + localInputs.context.projectEnvValues ?? {}, + ); + const dumpUsesHostNetwork = + (networkId === undefined || networkId.length === 0) && envNetworkId.length === 0; + const dumpConn: PgConnInput = { + ...stackConn, + host: + (handle.host === "127.0.0.1" || handle.host === "localhost") && + (runtimeInfo.platform !== "linux" || !dumpUsesHostNetwork) + ? "host.docker.internal" + : handle.host, + }; + const session = yield* connectShadowDatabase(stackConn); + const before = yield* squashDumpSchemaToString({ + image, + conn: dumpConn, + schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, + }); + yield* applyMigrations( + session, + fs, + path, + migrations, + (message) => new MigrationApplyError({ message }), + ); + const after = yield* squashDumpSchemaToString({ + image, + conn: dumpConn, + schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, + }); + const targetPath = migrations[migrations.length - 1]!; + const targetRel = path.relative(workdir, targetPath); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(targetPath, { flag: "w", mode: 0o644 }).pipe( + Effect.mapError( + (cause) => + new MigrationSquashWriteError({ + message: `failed to open migration file: ${relativizeErrorMessage(errorMessage(cause), targetPath, targetRel)}`, + }), + ), + ); + yield* squashDumpSchema({ + image, + conn: dumpConn, + schema: [], + projectEnvValues: localInputs.context.projectEnvValues, + onStdout: (chunk) => + file.writeAll(chunk).pipe( + Effect.mapError( + (cause) => + new MigrationSquashWriteError({ + message: `failed to copy docker logs: ${errorMessage(cause)}`, + }), + ), + ), + }); + const tail = SQUASH_SEPARATOR_COMMENT + squashLineByLineDiff(before, after); + yield* file.writeAll(new TextEncoder().encode(tail)).pipe( + Effect.mapError( + (cause) => + new MigrationSquashWriteError({ + message: `failed to write line: ${relativizeErrorMessage(errorMessage(cause), targetPath, targetRel)}`, + }), + ), + ); + }), + ); + }), + ), + ); + } + yield* Effect.acquireUseRelease( createShadowDatabase(spawner, shadowInput), (handle) => diff --git a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md new file mode 100644 index 0000000000..9741065bd8 --- /dev/null +++ b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md @@ -0,0 +1,96 @@ +# 0025. Ephemeral Postgres for schema tooling + +**Status**: proposed +**Date**: 2026-09-09 + +## Problem Statement + +`db diff`, `db pull`, `db schema declarative`, and `migration squash` provision a throwaway +shadow Postgres, snapshot its platform baseline as a PGDATA tar, and compare it to a target. +That path today always uses the legacy Docker local database: compose container IDs, platform +SQL templates, and `db.shadow_port`. + +The managed stack runtime (`@supabase/stack`) is a different Postgres: slim-artifact init plus +a fixed role/JWT/`_supabase` bootstrap, native host `PGDATA` or a named volume, and no extra +database API. `[experimental].stack` currently switches top-level `start`/`stop`. With the flag +on, schema commands still inspect `supabase_db_` and shadow against the legacy +baseline, so `--local` diffs are wrong or impossible. + +A second Postgres **instance** is required. `CREATE DATABASE` on the live cluster is not +equivalent: declarative sync needs two independent servers, and the cache is a full PGDATA +snapshot. + +## Decision + +### (a) Public `EphemeralPostgres` on `@supabase/stack` + +The package exposes a scoped, Supervisor-free Postgres cluster API (`createEphemeralPostgres`) +on both the Effect and Promise facades. It is not a stack identity: it does not appear in +`listStacks` / `discoverStacks`, and it does not persist `state.json` under the managed stacks +root. + +The cluster uses the same catalog artifact/image and the same bootstrap as a real stack +database. Callers own migrations, `roles.sql`, declarative SQL, and cache keys. + +Handle operations: loopback URL; `stop` (process/container down, data retained); `start` (from +existing data); `exportPgData` only while stopped; destroy on scope close. + +### (b) Snapshots are runtime-kind specific + +Native Postgres runs as the host user. Container snapshots preserve image uids. A Docker tar must +not restore onto native, and the reverse is also refused. The cache key includes `runtime.kind` +(and engine). Native export is a host-tree tar of `PGDATA`; container export tars the volume +through the catalog Postgres image. + +### (c) `[experimental].stack` covers the db/migration family + +`SUPABASE_EXPERIMENTAL_STACK` / `[experimental].stack` select the stack backend for `db` and +`migration` as well as `start`/`stop`. Flag off keeps the legacy Docker shadow and +`supabase_db_*` local target. Linked / `--db-url` targets are unchanged. Top-level `status` is +not switched. + +Shadow baseline for the stack backend is slim-init plus stack bootstrap, not the legacy SQL +templates. Cache files use a distinct `stack-shadow-baseline-*` namespace. + +The stack backend requires the in-process pg-delta engine. Migra, pgAdmin, and +`--use-pg-schema` assume Docker networks or differ containers and are rejected for every +stack runtime. + +## Rationale + +Throwaway full stacks would pollute discovery, pull in a Supervisor, and still need a +pre-start PGDATA inject. Duplicating native spawn in the CLI would fork artifact and bootstrap +logic. A package-level cluster keeps one Postgres lifecycle for native and container while +leaving schema policy in the CLI. + +## Consequences + +### Positive + +- Native and Docker/Podman shadows share one API and the same slim baseline as `stack start`. +- Schema commands can target a running project stack through `credentials()` when the flag is on. +- Legacy Docker behavior is unchanged when the flag is off. + +### Negative + +- `db reset` / declarative `--apply`/`--reset` still need a later stack data-wipe API. +- Cache tars cannot be shared across native and container runtimes. +- Migra/pgAdmin remain unavailable on stack backends. + +## Alternatives Considered + +1. **Database-only throwaway stacks** via `createStack`/`destroy`: extra Supervisor and + registry identity for a tooling cluster; cache restore still needs a data inject. +2. **CLI-owned spawn**: Docker shadows with the slim image, CLI-spawned native binary. Forks + catalog/bootstrap from the runtime package. +3. **`CREATE DATABASE` on the live cluster**: cannot snapshot independently or run two + declarative plan servers. + +## Related Decisions + +- ADR 0017: Simplified managed stack architecture + +## See Also + +- [`packages/stack/README.md`](../../packages/stack/README.md) +- [`apps/cli/docs/stack-commands.md`](../../apps/cli/docs/stack-commands.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 4a99680ad5..083f7fb36e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -65,6 +65,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0022 | [Config Diff Classification and Managed Surface](0022-config-diff-classification-and-managed-surface.md) | accepted | | 0023 | [Config Pull Write Strategy and Scope Resolution](0023-config-pull-write-strategy-and-scope-resolution.md) | accepted | | 0024 | [Top-Level `pull` Orchestration](0024-top-level-pull-orchestration.md) | accepted | +| 0025 | [Ephemeral Postgres for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | proposed | ## Template diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 36c2e5dbb2..3bb116813d 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -40,7 +40,8 @@ export const experimental = Schema.Struct({ ), stack: Schema.optionalKey( Schema.Boolean.annotate({ - description: "Use the new local stack backend for top-level start and stop commands.", + description: + "Use the new local stack backend for top-level start and stop commands, and for the db and migration command families.", tags, }), ), diff --git a/packages/stack/README.md b/packages/stack/README.md index 2690732e2a..863aca2cf1 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -116,3 +116,10 @@ Database reset is intentionally outside the current API. Applying migrations, de and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the `_realtime` schema owner, closed database role passwords, and JWT settings in one transaction; the slim database artifact owns its initialization and migrations. + +`createEphemeralPostgres` is a scoped, Supervisor-free Postgres cluster for schema tooling. It uses +the same catalog artifact and bootstrap as a stack database, is not registered in `listStacks` / +`discoverStacks`, and destroys its data directory or volume when the Effect scope closes. The +Promise facade returns a handle with explicit `destroy()`. Callers own migrations and PGDATA +cache keys. `exportPgData` is valid only while the cluster is stopped; native and container snapshots +are not interchangeable. diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 4d863bb877..4d46519f33 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -5,12 +5,15 @@ export { listStacks, discoverStacks, inspectStack, + createEphemeralPostgres, } from "./public/PromiseStack.ts"; export type { PromiseStack, PromiseStackConfig, PromiseStartStackOptions, PromisePrepareStackOptions, + PromiseCreateEphemeralPostgresOptions, + PromiseEphemeralPostgres, CreateStackOptions, FindStackOptions, ListStacksOptions, diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index f03ecee34b..4e8d09f899 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -72,6 +72,7 @@ import { PortUnavailableError, GatewayActivationError, InvalidLogCursorError, + EphemeralPostgresError, type CreateStackError, type OpenStackError, type StackDiscoveryError, @@ -252,6 +253,7 @@ const stackErrorFactories = { StackCleanupError: (message: string) => new StackCleanupError({ message }), ContainerEngineError: (message: string) => new ContainerEngineError({ message }), StackDestructionError: (message: string) => new StackDestructionError({ message }), + EphemeralPostgresError: (message: string) => new EphemeralPostgresError({ message }), } satisfies Record StackError>; const isOwnerUnreachable = (error: unknown): boolean => diff --git a/packages/stack/src/public/EphemeralPostgres.ts b/packages/stack/src/public/EphemeralPostgres.ts new file mode 100644 index 0000000000..94ccd57933 --- /dev/null +++ b/packages/stack/src/public/EphemeralPostgres.ts @@ -0,0 +1,84 @@ +import { Crypto, Effect, FileSystem, Path, Redacted, Scope } from "effect"; +import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; +import { DatabaseModule } from "../model/capabilities/database.ts"; +import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; +import { createEphemeralPostgresCluster } from "../runtime/EphemeralPostgres.ts"; +import type { EphemeralPostgresCreateError, EphemeralPostgresError } from "./Errors.ts"; +import { StackVersionUnsupportedError } from "./Errors.ts"; +import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; + +export interface EphemeralPostgresSettings { + readonly [key: string]: string | number | boolean | undefined; +} + +export interface CreateEphemeralPostgresOptions { + readonly runtime?: StackRuntimePreference; + /** Exact catalog release or major selector such as `"17"`. */ + readonly version?: string; + readonly port?: number; + readonly databasePassword: Redacted.Redacted; + readonly jwtSecret: Redacted.Redacted; + readonly jwtExpiry?: number; + readonly postgresSettings?: EphemeralPostgresSettings; + readonly healthTimeout?: string; + /** Stopped-cluster PGDATA tar to restore before the first start. */ + readonly restoreFrom?: string; +} + +export interface EphemeralPostgresRelease { + readonly version: string; + readonly image: string; +} + +export type EphemeralPostgresServices = + | ChildProcessSpawnerService + | Scope.Scope + | FileSystem.FileSystem + | Path.Path; + +export interface EffectEphemeralPostgres { + readonly host: string; + readonly port: number; + readonly version: string; + readonly runtime: StackRuntime; + /** Catalog identity hashed into CLI shadow-cache keys. */ + readonly artifactIdentity: string; + readonly url: Redacted.Redacted; + // Fresh invocation each call so the closure observes the cluster's current lifecycle. + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly start: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly stop: () => Effect.Effect; + readonly exportPgData: ( + tarPath: string, + ) => Effect.Effect; +} + +/** Resolves a Postgres catalog release the same way stack compilation does. */ +export const resolveEphemeralPostgresRelease = ( + version?: string, +): Effect.Effect => { + const requested = version ?? DatabaseModule.defaultVersion; + const selected = DatabaseModule.releases[requested]; + const release = + selected === undefined + ? catalogReleaseFor("database:database", requested) + : catalogReleaseFor("database:database", selected.version); + if (release === undefined) + return Effect.fail( + new StackVersionUnsupportedError({ + message: `Unsupported PostgreSQL version ${requested}`, + version: requested, + capability: "database", + }), + ); + return Effect.succeed({ version: release.version, image: release.containerImage }); +}; + +export const createEphemeralPostgres = ( + options: CreateEphemeralPostgresOptions, +): Effect.Effect< + EffectEphemeralPostgres, + EphemeralPostgresCreateError, + Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService +> => createEphemeralPostgresCluster(options); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index cb56aabc90..11f781f10b 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -136,6 +136,19 @@ export class ContainerEngineError extends Data.TaggedError("ContainerEngineError ErrorFields & { readonly engine?: ContainerEngineKind } > {} export class StackDestructionError extends Data.TaggedError("StackDestructionError") {} +export class EphemeralPostgresError extends Data.TaggedError("EphemeralPostgresError")< + ErrorFields & { + readonly reason?: + | "not-stopped" + | "not-running" + | "snapshot" + | "restore-mismatch" + | "bootstrap" + | "destroy"; + readonly path?: string; + readonly version?: string; + } +> {} /** Stable wire tags for errors produced by the managed stack runtime. */ export const STACK_ERROR_TAGS = [ @@ -165,6 +178,7 @@ export const STACK_ERROR_TAGS = [ "StackCleanupError", "ContainerEngineError", "StackDestructionError", + "EphemeralPostgresError", ] as const; export type StackErrorTag = (typeof STACK_ERROR_TAGS)[number]; @@ -198,7 +212,8 @@ export type StackError = | StackRuntimeError | StackCleanupError | ContainerEngineError - | StackDestructionError; + | StackDestructionError + | EphemeralPostgresError; export const isStackError = (value: unknown): value is StackError => Predicate.hasProperty(value, "_tag") && @@ -325,3 +340,16 @@ export const DESTROY_STACK_ERROR_TAGS = [ "StackUpgradeRequiredError", ] as const satisfies ReadonlyArray; export type DestroyStackError = ErrorByTag<(typeof DESTROY_STACK_ERROR_TAGS)[number]>; + +export const EPHEMERAL_POSTGRES_ERROR_TAGS = [ + "EphemeralPostgresError", + "StackVersionUnsupportedError", + "PortUnavailableError", + "StackPreparationError", + "ArtifactIntegrityError", + "ContainerPullError", + "ContainerEngineError", +] as const satisfies ReadonlyArray; +export type EphemeralPostgresCreateError = ErrorByTag< + (typeof EPHEMERAL_POSTGRES_ERROR_TAGS)[number] +>; diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index ccddeddd95..3a2f6baebb 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -1,5 +1,17 @@ import { NodeServices } from "@effect/platform-node"; -import { Crypto, Effect, FileSystem, Layer, Option, Path, Redacted, Schema, Stream } from "effect"; +import { + Crypto, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schema, + Scope, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createStack as createEffectStack, @@ -26,6 +38,11 @@ import type { StackId } from "./StackId.ts"; import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; import { InvalidStackConfigError } from "./Errors.ts"; import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; +import { + createEphemeralPostgres as createEffectEphemeralPostgres, + type CreateEphemeralPostgresOptions, +} from "./EphemeralPostgres.ts"; +import type { StackRuntime } from "./Runtime.ts"; // oxlint-disable effecttsgo/async-function -- Promise facade methods must expose Promise/AsyncIterable APIs. // oxlint-disable effecttsgo/any-unknown-in-error-context -- Promise callers receive native rejection values. @@ -60,6 +77,27 @@ export interface PromiseStack { readonly followLogs: (query?: LogQuery) => AsyncIterable; } +export type PromiseCreateEphemeralPostgresOptions = Omit< + CreateEphemeralPostgresOptions, + "databasePassword" | "jwtSecret" +> & { + readonly databasePassword: string; + readonly jwtSecret: string; +}; + +export interface PromiseEphemeralPostgres { + readonly host: string; + readonly port: number; + readonly version: string; + readonly runtime: StackRuntime; + readonly artifactIdentity: string; + readonly url: string; + readonly start: () => Promise; + readonly stop: () => Promise; + readonly exportPgData: (tarPath: string) => Promise; + readonly destroy: () => Promise; +} + interface PromiseStackApi { readonly createStack: (options: CreateStackOptions) => Promise; readonly openStack: (id: StackId) => Promise; @@ -67,6 +105,9 @@ interface PromiseStackApi { readonly listStacks: (options?: ListStacksOptions) => Promise>; readonly discoverStacks: (options?: ListStacksOptions) => Promise; readonly inspectStack: (id: StackId) => Promise; + readonly createEphemeralPostgres: ( + options: PromiseCreateEphemeralPostgresOptions, + ) => Promise; } type PlatformLayer = typeof NodeServices.layer; @@ -180,6 +221,41 @@ export const makePromiseApi = ( listStacks: (options) => run(listEffectStacks(options)), discoverStacks: (options) => run(discoverEffectStacks(options)), inspectStack: (id) => run(inspectEffectStack(id)), + createEphemeralPostgres: async (options) => { + const scope = await Effect.runPromise(Scope.make()); + const close = () => + Effect.runPromise(Scope.close(scope, Exit.void).pipe(Effect.provide(providedLayer))); + const invoke = ( + effect: Effect.Effect, + ): Promise => + Effect.runPromise( + effect.pipe(Effect.provideService(Scope.Scope, scope), Effect.provide(providedLayer)), + ); + try { + const handle = await invoke( + createEffectEphemeralPostgres({ + ...options, + databasePassword: Redacted.make(options.databasePassword), + jwtSecret: Redacted.make(options.jwtSecret), + }), + ); + return { + host: handle.host, + port: handle.port, + version: handle.version, + runtime: handle.runtime, + artifactIdentity: handle.artifactIdentity, + url: Redacted.value(handle.url), + start: () => invoke(handle.start()), + stop: () => invoke(handle.stop()), + exportPgData: (tarPath) => invoke(handle.exportPgData(tarPath)), + destroy: close, + }; + } catch (cause) { + await close().catch(() => undefined); + throw cause; + } + }, }; }; @@ -190,6 +266,7 @@ export const findStack = defaultApi.findStack; export const listStacks = defaultApi.listStacks; export const discoverStacks = defaultApi.discoverStacks; export const inspectStack = defaultApi.inspectStack; +export const createEphemeralPostgres = defaultApi.createEphemeralPostgres; export type { CreateStackOptions, diff --git a/packages/stack/src/public/ephemeral-postgres.integration.test.ts b/packages/stack/src/public/ephemeral-postgres.integration.test.ts new file mode 100644 index 0000000000..56dffd38c4 --- /dev/null +++ b/packages/stack/src/public/ephemeral-postgres.integration.test.ts @@ -0,0 +1,176 @@ +import { NodeServices } from "@effect/platform-node"; +import { PgClient } from "@effect/sql-pg"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { ChildProcess } from "effect/unstable/process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- isolated artifact cache path. +import { join } from "node:path"; +import { EphemeralPostgresError } from "./Errors.ts"; +import { createEphemeralPostgres } from "./EphemeralPostgres.ts"; +import { listStacks } from "./EffectStack.ts"; +import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; +import type { StackRuntimePreference } from "./Runtime.ts"; + +const NATIVE_TIMEOUT_MS = 180_000; +const PASSWORD = "ephemeral-test-password"; +const JWT_SECRET = "ephemeral-test-jwt-secret-value"; + +const dockerAvailable = (): boolean => + spawnSync("docker", ["info"], { encoding: "utf8" }).status === 0; + +const artifactCacheRoot = join(tmpdir(), "supabase-stack-test-artifacts"); + +const testEnvironment = (stateRoot: string) => + Layer.succeed(StackRuntimeEnvironment, { + ...defaultRuntimeEnvironment(), + stateRoot, + artifactCacheRoot, + }); + +const secrets = { + databasePassword: Redacted.make(PASSWORD), + jwtSecret: Redacted.make(JWT_SECRET), +}; + +const query = (url: Redacted.Redacted, statement: string) => + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + return yield* client.unsafe(statement); + }).pipe(Effect.provide(PgClient.layer({ url, connectTimeout: "10 seconds" }))), + ); + +const withIsolatedRoot = (effect: Effect.Effect) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-eph-" }); + yield* Effect.addFinalizer(() => fs.remove(root, { recursive: true }).pipe(Effect.ignore)); + const stateRoot = path.join(root, "managed", "stacks"); + yield* fs.makeDirectory(stateRoot, { recursive: true }); + return yield* effect.pipe(Effect.provide(testEnvironment(stateRoot))); + }); + +const writeForeignMarkerTar = (tarPath: string, marker: unknown) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staging = yield* fs.makeTempDirectoryScoped(); + const data = path.join(staging, "data"); + yield* fs.makeDirectory(data); + yield* fs.writeFileString( + path.join(data, ".supabase-ephemeral-runtime"), + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- fixture marker bytes packed into a tar. + JSON.stringify(marker), + ); + const handle = yield* ChildProcess.make("tar", ["-C", staging, "-cf", tarPath, "data"], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const code = yield* handle.exitCode; + expect(Number(code)).toBe(0); + }), + ); + +describe("ephemeral Postgres", () => { + it.live("refuses a snapshot produced by a different runtime before starting Postgres", () => + withIsolatedRoot( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tarPath = path.join(yield* fs.makeTempDirectoryScoped(), "foreign.tar"); + yield* writeForeignMarkerTar(tarPath, { kind: "container", engine: "docker" }); + const exit = yield* createEphemeralPostgres({ + runtime: { kind: "native" }, + restoreFrom: tarPath, + ...secrets, + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(EphemeralPostgresError); + if (!(error instanceof EphemeralPostgresError)) return; + expect(error.reason).toBe("restore-mismatch"); + }), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live( + "starts a native cluster, snapshots, restores, and destroys without a stack identity", + () => + withIsolatedRoot( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exportDir = yield* fs.makeTempDirectoryScoped(); + const tarPath = path.join(exportDir, "baseline.tar"); + const first = yield* createEphemeralPostgres({ runtime: { kind: "native" }, ...secrets }); + const rows = yield* query( + first.url, + "SELECT rolname FROM pg_roles WHERE rolname = 'supabase_admin'", + ); + expect(rows.length).toBeGreaterThan(0); + expect(first.runtime.kind).toBe("native"); + expect(first.artifactIdentity.startsWith("native:")).toBe(true); + const listedWhileRunning = yield* listStacks({}); + expect(listedWhileRunning.some((stack) => stack.id === first.artifactIdentity)).toBe( + false, + ); + yield* first.stop(); + yield* first.exportPgData(tarPath); + const exists = yield* fs.exists(tarPath); + expect(exists).toBe(true); + + const restored = yield* createEphemeralPostgres({ + runtime: { kind: "native" }, + restoreFrom: tarPath, + ...secrets, + }); + const restoredRows = yield* query(restored.url, "SELECT current_database() AS name"); + expect(restoredRows).toEqual([{ name: "postgres" }]); + expect(restored.port).not.toBe(first.port); + + const second = yield* createEphemeralPostgres({ + runtime: { kind: "native" }, + ...secrets, + }); + expect(second.port).not.toBe(first.port); + expect(second.port).not.toBe(restored.port); + yield* query(second.url, "SELECT 1"); + }), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + NATIVE_TIMEOUT_MS, + ); + + it.live.skipIf(!dockerAvailable())( + "starts a container cluster, snapshots, and restores", + () => + withIsolatedRoot( + Effect.gen(function* () { + const runtime: StackRuntimePreference = { kind: "container", engine: "docker" }; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tarPath = path.join(yield* fs.makeTempDirectoryScoped(), "baseline.tar"); + const first = yield* createEphemeralPostgres({ runtime, ...secrets }); + yield* query(first.url, "SELECT 1"); + expect(first.runtime.kind).toBe("container"); + expect(first.artifactIdentity.startsWith("container:docker:")).toBe(true); + yield* first.stop(); + yield* first.exportPgData(tarPath); + const restored = yield* createEphemeralPostgres({ + runtime, + restoreFrom: tarPath, + ...secrets, + }); + yield* query(restored.url, "SELECT 1"); + expect(restored.port).not.toBe(first.port); + }), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + NATIVE_TIMEOUT_MS, + ); +}); diff --git a/packages/stack/src/public/ephemeral-postgres.unit.test.ts b/packages/stack/src/public/ephemeral-postgres.unit.test.ts new file mode 100644 index 0000000000..f8801135ef --- /dev/null +++ b/packages/stack/src/public/ephemeral-postgres.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { DatabaseModule } from "../model/capabilities/database.ts"; +import { StackVersionUnsupportedError } from "./Errors.ts"; +import { resolveEphemeralPostgresRelease } from "./EphemeralPostgres.ts"; + +describe("resolveEphemeralPostgresRelease", () => { + it.effect("resolves the catalog default and a major selector", () => + Effect.gen(function* () { + const fallback = yield* resolveEphemeralPostgresRelease(); + expect(fallback.version).toBe(DatabaseModule.defaultVersion); + expect(fallback.image.length).toBeGreaterThan(0); + + const major = DatabaseModule.defaultVersion.split(".")[0]; + expect(major).toBeDefined(); + if (major === undefined) return; + const selected = yield* resolveEphemeralPostgresRelease(major); + expect(selected.version).toBe(fallback.version); + expect(selected.image).toBe(fallback.image); + }), + ); + + it.effect("fails for an unknown PostgreSQL version", () => + Effect.gen(function* () { + const exit = yield* resolveEphemeralPostgresRelease("99").pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(StackVersionUnsupportedError); + }), + ); +}); diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 392661da79..78aa56be46 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -29,3 +29,11 @@ export type { PreparedCapability, PrepareStackResult, } from "./EffectStack.ts"; +export { createEphemeralPostgres, resolveEphemeralPostgresRelease } from "./EphemeralPostgres.ts"; +export type { + CreateEphemeralPostgresOptions, + EffectEphemeralPostgres, + EphemeralPostgresRelease, + EphemeralPostgresServices, + EphemeralPostgresSettings, +} from "./EphemeralPostgres.ts"; diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 2e35a88892..384c536952 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -1346,13 +1346,14 @@ describe("managed Supabase stack whole-stack E2E", () => { name: `stack-cli-consumer-${identity}`, runtime: mode.runtime, }); - await ordinary.start(); + const ordinaryStack = ordinary; + await ordinaryStack.start(); helper = await createTestStack({ name: `stack-helper-consumer-${identity}`, runtime: mode.runtime, }); - const ordinaryStatus = await ordinary.status(); + const ordinaryStatus = await ordinaryStack.status(); const helperStatus = await helper.status(); expect(ordinaryStatus.lifecycle).toBe("running"); expect(helperStatus.lifecycle).toBe("running"); @@ -1360,7 +1361,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(endpoint(ordinaryStatus, "api").port).not.toBe(endpoint(helperStatus, "api").port); const scoped = await listStacks({ projectRoot: ordinaryRoot }); - expect(scoped.map(({ id }) => id)).toContain(ordinary.id); + expect(scoped.map(({ id }) => id)).toContain(ordinaryStack.id); expect(scoped.map(({ id }) => id)).not.toContain(helper.id); const all = await listStacks(); expect(all.map(({ id }) => id)).toEqual(expect.arrayContaining([ordinary.id, helper.id])); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 9162e706af..8b6ac1a879 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -732,7 +732,10 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai : Effect.fail( new ContainerCommandError({ operation, - message: `Container engine command failed (${result.exitCode})`, + message: + result.stderr.trim().length > 0 + ? `Container engine command failed (${result.exitCode}): ${result.stderr.trim()}` + : `Container engine command failed (${result.exitCode})`, }), ), ), diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts new file mode 100644 index 0000000000..3eb7a0737e --- /dev/null +++ b/packages/stack/src/runtime/EphemeralPostgres.ts @@ -0,0 +1,1030 @@ +import { PgClient } from "@effect/sql-pg"; +import { + Crypto, + Duration, + Effect, + Exit, + FileSystem, + Option, + Path, + Redacted, + Schedule, + Schema, + Scope, + Semaphore, +} from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- loopback bind is the port allocator. +import { createServer } from "node:net"; +import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; +import { + DEFAULT_DATABASE_HEALTH_TIMEOUT, + parseGoDuration, +} from "../model/capabilities/database.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { + ContainerEngineError, + EphemeralPostgresError, + PortUnavailableError, + StackPreparationError, + type EphemeralPostgresCreateError, +} from "../public/Errors.ts"; +import { + resolveEphemeralPostgresRelease, + type CreateEphemeralPostgresOptions, + type EffectEphemeralPostgres, +} from "../public/EphemeralPostgres.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; +import { checkHostPort } from "../supervisor/HostListener.ts"; +import { probeReadiness } from "./ReadinessProbe.ts"; +import { + defaultNativeProcessLauncher, + spawnNativeProcess, + type NativeProcess, +} from "./NativeProcess.ts"; +import { bootstrapManagedPostgres } from "./PostgresDatabaseSession.ts"; +import { makeProductionRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import { resolveContainerEngine, ContainerEngineResolver } from "./ContainerEngineResolver.ts"; +import type { ContainerEngine } from "./ContainerEngine.ts"; + +const DATABASE_WORKLOAD_ID = "database:database"; +const PGDATA_DIR_NAME = "data"; +const CONTAINER_PGDATA_PARENT = "/var/lib/postgresql"; +const SNAPSHOT_MOUNT = "/snapshot"; +const BUSYBOX = "/usr/bin/busybox"; +const RUNTIME_MARKER = ".supabase-ephemeral-runtime"; +const DEFAULT_JWT_EXPIRY = 3600; + +const RuntimeMarkerSchema = Schema.Struct({ + kind: Schema.Literals(["native", "container"] as const), + engine: Schema.optionalKey(Schema.Literals(["docker", "podman"] as const)), +}); +type RuntimeMarker = Schema.Schema.Type; + +const ephemeralError = ( + message: string, + fields: Omit[0], "message"> = {}, +) => new EphemeralPostgresError({ message, ...fields }); + +const resolvedRuntime = (preference?: CreateEphemeralPostgresOptions["runtime"]): StackRuntime => + preference?.kind === "container" + ? { kind: "container", engine: preference.engine ?? "docker" } + : { kind: "native" }; + +const plannedWorkload = ( + version: string, + image: string, + runtime: StackRuntime, +): PlannedWorkload => ({ + id: DATABASE_WORKLOAD_ID, + capability: "database", + bootstrap: "database", + dependencies: [], + readiness: { portField: "database" }, + artifacts: { + native: { kind: "native", release: version }, + container: { kind: "container", image }, + }, + selected: + runtime.kind === "native" ? { kind: "native", release: version } : { kind: "container", image }, +}); + +const postgresArgs = ( + port: number, + runtime: StackRuntime, + settings: CreateEphemeralPostgresOptions["postgresSettings"], +): ReadonlyArray => { + const tuned = Object.entries(settings ?? {}).flatMap(([key, value]) => { + if (value === undefined) return []; + const rendered = String(value); + return rendered.length === 0 ? [] : ["-c", `${key}=${rendered}`]; + }); + return [ + "-p", + String(port), + "-c", + runtime.kind === "container" ? "listen_addresses=*" : "listen_addresses=127.0.0.1", + ...tuned, + ]; +}; + +const postgresEnv = (input: { + readonly port: number; + readonly dataPath: string; + readonly password: string; +}): Record => ({ + SUPABASE_STACK_WORKLOAD: DATABASE_WORKLOAD_ID, + SUPABASE_STACK_PRIVATE_PORT: String(input.port), + PGDATA: input.dataPath, + POSTGRES_USER: "supabase_admin", + POSTGRES_DB: "postgres", + POSTGRES_PASSWORD: input.password, + TZDIR: "/var/db/timezone/zoneinfo", +}); + +const databaseUrl = (port: number, password: string): string => + `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent(password)}@127.0.0.1:${port}/postgres`; + +const markerFor = (runtime: StackRuntime): RuntimeMarker => + runtime.kind === "native" ? { kind: "native" } : { kind: "container", engine: runtime.engine }; + +const encodeMarker = (marker: RuntimeMarker): string => JSON.stringify(marker); + +const decodeMarker = (text: string): Effect.Effect => + Schema.decodeEffect(Schema.fromJsonString(RuntimeMarkerSchema))(text).pipe( + Effect.mapError(() => + ephemeralError("Ephemeral Postgres snapshot marker is invalid", { reason: "snapshot" }), + ), + ); + +const sameRuntime = (left: RuntimeMarker, right: StackRuntime): boolean => + left.kind === right.kind && (right.kind === "native" || left.engine === right.engine); + +const allocateLoopbackPort = ( + requested: number | undefined, +): Effect.Effect => { + if (requested !== undefined) + return checkHostPort("127.0.0.1", requested, "database").pipe(Effect.as(requested)); + return Effect.callback((resume) => { + const server = createServer(); + let settled = false; + const finish = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + server.once("error", (cause) => + finish(Effect.fail(ephemeralError("Unable to allocate a loopback port", { cause }))), + ); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + server.close(() => + finish( + port > 0 + ? Effect.succeed(port) + : Effect.fail(ephemeralError("Unable to allocate a loopback port")), + ), + ); + }); + return Effect.sync(() => { + if (settled) return; + settled = true; + try { + server.close(); + } catch { + // The listener never obtained a handle. + } + }); + }); +}; + +const runTar = ( + args: ReadonlyArray, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("tar", args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe(Effect.mapError((cause) => ephemeralError("Unable to start tar", { cause }))); + const code = yield* handle.exitCode.pipe( + Effect.mapError((cause) => ephemeralError("tar failed", { cause })), + ); + if (Number(code) !== 0) + return yield* ephemeralError(`tar failed (${String(code)})`, { reason: "snapshot" }); + }), + ); + +const writeEnvFile = ( + filePath: string, + values: Readonly>, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = Object.entries(values) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => `${name}=${value}\n`) + .join(""); + yield* fs + .writeFileString(filePath, text) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to write Postgres environment file", { cause, path: filePath }), + ), + ); + yield* fs.chmod(filePath, 0o600).pipe(Effect.ignore); + return filePath; + }); + +const waitForPostgres = ( + port: number, + healthTimeout: string, +): Effect.Effect => + Effect.try({ + try: () => parseGoDuration(healthTimeout), + catch: (cause) => ephemeralError("Invalid database health timeout", { cause }), + }).pipe( + Effect.flatMap((deadline) => + probeReadiness( + { mode: "tcp", host: "127.0.0.1", port }, + { deadline: Duration.isZero(deadline) ? Duration.seconds(1) : deadline }, + ).pipe( + Effect.mapError((cause) => + ephemeralError("Ephemeral Postgres did not become ready", { cause }), + ), + ), + ), + ); + +const pingAdvertised = ( + port: number, + password: string, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + yield* client.unsafe("SELECT 1"); + }).pipe( + Effect.provide( + PgClient.layer({ + url: Redacted.make(databaseUrl(port, password)), + connectTimeout: "2 seconds", + }), + ), + ), + ).pipe( + Effect.mapError((cause) => + ephemeralError("Ephemeral Postgres did not accept a connection", { cause }), + ), + ); + +const waitForAdvertised = ( + port: number, + password: string, + healthTimeout: string, +): Effect.Effect => + Effect.try({ + try: () => parseGoDuration(healthTimeout), + catch: (cause) => ephemeralError("Invalid database health timeout", { cause }), + }).pipe( + Effect.flatMap((deadline) => + Effect.timeout( + Effect.retry(pingAdvertised(port, password), { + schedule: Schedule.spaced("100 millis"), + }), + Duration.isZero(deadline) ? Duration.seconds(1) : deadline, + ).pipe( + Effect.mapError((cause) => + ephemeralError("Ephemeral Postgres did not become ready", { cause }), + ), + ), + ), + ); + +const bootstrap = ( + port: number, + options: CreateEphemeralPostgresOptions, + healthTimeout: string, +): Effect.Effect => + Effect.try({ + try: () => parseGoDuration(healthTimeout), + catch: (cause) => ephemeralError("Invalid database health timeout", { cause }), + }).pipe( + Effect.flatMap((deadline) => + Effect.timeout( + Effect.retry( + bootstrapManagedPostgres({ + host: "127.0.0.1", + port, + databasePassword: options.databasePassword, + jwtSecret: options.jwtSecret, + jwtExpiry: options.jwtExpiry ?? DEFAULT_JWT_EXPIRY, + }), + { + schedule: Schedule.spaced("100 millis"), + while: (error) => error instanceof DatabaseBootstrapError && error.retryable === true, + }, + ), + Duration.isZero(deadline) ? Duration.seconds(1) : deadline, + ).pipe( + Effect.mapError((cause) => + ephemeralError("Ephemeral Postgres bootstrap failed", { reason: "bootstrap", cause }), + ), + ), + ), + ); + +interface NativeResources { + readonly kind: "native"; + process?: NativeProcess; + processScope?: Scope.Closeable; +} + +interface ContainerResources { + readonly kind: "container"; + readonly engine: ContainerEngine; + networkId?: string; + volumeId?: string; + containerId?: string; +} + +type RuntimeResources = NativeResources | ContainerResources; + +interface Cluster { + readonly identity: StackId; + readonly root: string; + readonly dataPath: string; + readonly host: "127.0.0.1"; + readonly port: number; + readonly version: string; + readonly runtime: StackRuntime; + readonly artifactIdentity: string; + readonly executable?: string; + readonly image?: string; + readonly lifecycle: Semaphore.Semaphore; + running: boolean; + bootstrapped: boolean; + resources: RuntimeResources; +} + +const resourceName = (identity: StackId, role: string): string => + `supabase-eph-${identity.slice(0, 16)}-${role}`; + +const createIdentity = (crypto: Crypto.Crypto): Effect.Effect => + Effect.gen(function* () { + const first = yield* crypto.randomUUIDv4; + const second = yield* crypto.randomUUIDv4; + return yield* Schema.decodeEffect(StackIdSchema)(`${first}${second}`.replaceAll("-", "")); + }).pipe( + Effect.mapError((cause) => ephemeralError("Unable to allocate ephemeral identity", { cause })), + ); + +const writeRuntimeMarker = ( + cluster: Cluster, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const markerPath = `${cluster.dataPath}/${RUNTIME_MARKER}`; + const encoded = encodeMarker(markerFor(cluster.runtime)); + if (cluster.resources.kind === "native") { + yield* fs.writeFileString(markerPath, encoded).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to write snapshot runtime marker", { + cause, + path: markerPath, + reason: "snapshot", + }), + ), + ); + return; + } + const containerId = cluster.resources.containerId; + if (containerId === undefined) + return yield* ephemeralError("Ephemeral Postgres container is missing", { + reason: "snapshot", + }); + const tempPath = `${cluster.root}/${RUNTIME_MARKER}`; + yield* fs.writeFileString(tempPath, encoded).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to write snapshot runtime marker", { + cause, + path: tempPath, + reason: "snapshot", + }), + ), + ); + yield* cluster.resources.engine + .copyToContainer( + containerId, + tempPath, + `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}/${RUNTIME_MARKER}`, + ) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to copy snapshot runtime marker", { cause, reason: "snapshot" }), + ), + ); + }); + +const snapshotFileName = ( + tarPath: string, + path: Path.Path, +): Effect.Effect => { + const name = path.basename(tarPath); + if (name.length === 0 || name === "." || name === "..") + return Effect.fail( + ephemeralError("Ephemeral Postgres snapshot path is invalid", { + reason: "snapshot", + path: tarPath, + }), + ); + return Effect.succeed(name); +}; + +/** Catalog image has no tar on PATH; busybox tar archives the volume in place. */ +const runVolumeTar = ( + cluster: Cluster, + tarPath: string, + mode: "create" | "extract", +): Effect.Effect => + Effect.gen(function* () { + if (cluster.resources.kind !== "container") return; + const { engine, networkId, volumeId } = cluster.resources; + if (networkId === undefined || volumeId === undefined) + return yield* ephemeralError("Ephemeral Postgres volume is unavailable", { + reason: "snapshot", + }); + const image = cluster.image; + if (image === undefined) + return yield* ephemeralError("Ephemeral Postgres image is unavailable", { + reason: "snapshot", + }); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const fileName = yield* snapshotFileName(tarPath, path); + const parent = path.dirname(tarPath); + yield* fs.makeDirectory(parent, { recursive: true }).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to create snapshot directory", { + cause, + path: parent, + reason: "snapshot", + }), + ), + ); + const snapshotPath = `${SNAPSHOT_MOUNT}/${fileName}`; + const command = + mode === "create" + ? ["tar", "-C", CONTAINER_PGDATA_PARENT, "-cf", snapshotPath, PGDATA_DIR_NAME] + : ["tar", "-C", CONTAINER_PGDATA_PARENT, "-xf", snapshotPath]; + yield* Effect.acquireUseRelease( + engine + .createContainer({ + name: resourceName(cluster.identity, "snapshot"), + image, + labels: { + stackId: cluster.identity, + ownerSessionId: cluster.identity.slice(0, 32), + workloadId: `${DATABASE_WORKLOAD_ID}:snapshot`, + role: "workload", + }, + network: networkId, + mounts: [{ source: parent, target: SNAPSHOT_MOUNT, readOnly: mode === "extract" }], + volumeMounts: [ + { + volume: volumeId, + target: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, + readOnly: false, + }, + ], + publications: [], + role: "workload", + entrypoint: BUSYBOX, + command, + }) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to create snapshot helper", { cause, reason: "snapshot" }), + ), + ), + (created) => + Effect.gen(function* () { + yield* engine.startContainer(created.id).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to start snapshot helper", { + cause, + reason: "snapshot", + }), + ), + ); + const code = yield* engine.waitContainer(created.id).pipe( + Effect.mapError((cause) => + ephemeralError("Snapshot helper did not finish", { + cause, + reason: "snapshot", + }), + ), + ); + if (code !== 0) + return yield* ephemeralError(`Snapshot helper failed (${String(code)})`, { + reason: "snapshot", + }); + }), + (created) => engine.removeContainer(created.id).pipe(Effect.ignore), + ); + }); + +const verifyRestoredMarker = ( + cluster: Cluster, + restoreFrom: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const markerPath = `${cluster.dataPath}/${RUNTIME_MARKER}`; + const exists = yield* fs.exists(markerPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) + return yield* ephemeralError("Ephemeral Postgres snapshot is missing a runtime marker", { + reason: "restore-mismatch", + path: restoreFrom, + }); + const marker = yield* fs.readFileString(markerPath).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to read snapshot runtime marker", { + cause, + path: markerPath, + reason: "snapshot", + }), + ), + Effect.flatMap(decodeMarker), + ); + if (!sameRuntime(marker, cluster.runtime)) + return yield* ephemeralError( + "Ephemeral Postgres snapshot was produced by a different runtime", + { + reason: "restore-mismatch", + path: restoreFrom, + }, + ); + }); + +const stopNative = (cluster: Cluster): Effect.Effect => + Effect.gen(function* () { + if (cluster.resources.kind !== "native") return; + const process = cluster.resources.process; + if (process !== undefined) { + const running = yield* process.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (running) + yield* process.kill.pipe( + Effect.mapError((cause) => + ephemeralError("Unable to stop ephemeral Postgres", { cause }), + ), + ); + } + const scope = cluster.resources.processScope; + if (scope !== undefined) yield* Scope.close(scope, Exit.void); + cluster.resources.process = undefined; + cluster.resources.processScope = undefined; + cluster.running = false; + }); + +const stopContainer = (cluster: Cluster): Effect.Effect => + Effect.gen(function* () { + if (cluster.resources.kind !== "container") return; + const containerId = cluster.resources.containerId; + if (containerId !== undefined) + yield* cluster.resources.engine + .stopContainer(containerId) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to stop ephemeral Postgres", { cause }), + ), + ); + cluster.running = false; + }); + +const startNative = ( + cluster: Cluster, + options: CreateEphemeralPostgresOptions, + healthTimeout: string, + password: string, +): Effect.Effect => + Effect.gen(function* () { + if (cluster.executable === undefined) + return yield* ephemeralError("Native Postgres executable is unavailable"); + const processScope = yield* Scope.make("sequential"); + const process = yield* spawnNativeProcess( + { + executable: cluster.executable, + args: postgresArgs(cluster.port, cluster.runtime, options.postgresSettings), + env: postgresEnv({ + port: cluster.port, + dataPath: cluster.dataPath, + password, + }), + cwd: cluster.root, + gracefulStopSignal: "SIGINT", + gracefulStopTimeout: "15 seconds", + }, + defaultNativeProcessLauncher(), + { stackId: cluster.identity, workloadId: DATABASE_WORKLOAD_ID }, + ).pipe( + Effect.provideService(Scope.Scope, processScope), + Effect.mapError((cause) => ephemeralError("Unable to start native Postgres", { cause })), + ); + if (cluster.resources.kind === "native") { + cluster.resources.process = process; + cluster.resources.processScope = processScope; + } + yield* Effect.gen(function* () { + yield* waitForPostgres(cluster.port, healthTimeout); + if (!cluster.bootstrapped) { + yield* bootstrap(cluster.port, options, healthTimeout); + cluster.bootstrapped = true; + } + yield* waitForAdvertised(cluster.port, password, healthTimeout); + cluster.running = true; + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : stopNative(cluster).pipe(Effect.ignore), + ), + ); + }); + +const startContainer = ( + cluster: Cluster, + options: CreateEphemeralPostgresOptions, + healthTimeout: string, + password: string, +): Effect.Effect => + Effect.gen(function* () { + if (cluster.resources.kind !== "container") return; + const image = cluster.image; + if (image === undefined) + return yield* ephemeralError("Ephemeral Postgres image is unavailable"); + const networkId = cluster.resources.networkId; + const volumeId = cluster.resources.volumeId; + if (networkId === undefined || volumeId === undefined) + return yield* ephemeralError("Ephemeral Postgres volume is unavailable"); + yield* Effect.gen(function* () { + if (cluster.resources.kind !== "container") return; + if (cluster.resources.containerId !== undefined) { + yield* cluster.resources.engine + .startContainer(cluster.resources.containerId) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to start ephemeral Postgres", { cause }), + ), + ); + } else { + const path = yield* Path.Path; + const envFile = yield* writeEnvFile( + path.join(cluster.root, "postgres.env"), + postgresEnv({ + port: 5432, + dataPath: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, + password, + }), + ); + const created = yield* cluster.resources.engine + .createContainer({ + name: resourceName(cluster.identity, "database"), + image, + labels: { + stackId: cluster.identity, + ownerSessionId: cluster.identity.slice(0, 32), + workloadId: DATABASE_WORKLOAD_ID, + role: "workload", + }, + network: networkId, + mounts: [], + volumeMounts: [ + { + volume: volumeId, + target: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, + readOnly: false, + }, + ], + publications: [{ address: "127.0.0.1", hostPort: cluster.port, containerPort: 5432 }], + role: "workload", + command: postgresArgs(5432, cluster.runtime, options.postgresSettings), + envFile, + }) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to create ephemeral Postgres", { cause }), + ), + ); + cluster.resources.containerId = created.id; + yield* cluster.resources.engine + .startContainer(created.id) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to start ephemeral Postgres", { cause }), + ), + ); + } + yield* waitForPostgres(cluster.port, healthTimeout); + if (!cluster.bootstrapped) { + yield* bootstrap(cluster.port, options, healthTimeout); + cluster.bootstrapped = true; + } + yield* waitForAdvertised(cluster.port, password, healthTimeout); + cluster.running = true; + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : stopContainer(cluster).pipe(Effect.ignore), + ), + ); + }); + +const exportNative = ( + cluster: Cluster, + tarPath: string, +): Effect.Effect< + void, + EphemeralPostgresError, + ChildProcessSpawnerService | Scope.Scope | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* writeRuntimeMarker(cluster); + yield* runTar(["-C", cluster.root, "-cf", tarPath, PGDATA_DIR_NAME]); + }); + +const exportContainer = ( + cluster: Cluster, + tarPath: string, +): Effect.Effect => + Effect.gen(function* () { + yield* writeRuntimeMarker(cluster); + yield* runVolumeTar(cluster, tarPath, "create"); + }); + +const restoreNative = ( + cluster: Cluster, + restoreFrom: string, +): Effect.Effect< + void, + EphemeralPostgresError, + ChildProcessSpawnerService | Scope.Scope | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* runTar(["-C", cluster.root, "-xf", restoreFrom]); + yield* verifyRestoredMarker(cluster, restoreFrom); + }); + +const restoreContainer = ( + cluster: Cluster, + restoreFrom: string, +): Effect.Effect => + runVolumeTar(cluster, restoreFrom, "extract"); + +const peekSnapshotRuntime = ( + restoreFrom: string, + runtime: StackRuntime, + peekRoot: string, +): Effect.Effect< + void, + EphemeralPostgresError, + FileSystem.FileSystem | ChildProcessSpawnerService | Scope.Scope +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(peekRoot, { recursive: true }).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to inspect snapshot", { + cause, + path: peekRoot, + reason: "snapshot", + }), + ), + ); + yield* runTar([ + "-xf", + restoreFrom, + "-C", + peekRoot, + `${PGDATA_DIR_NAME}/${RUNTIME_MARKER}`, + ]).pipe( + Effect.mapError(() => + ephemeralError("Ephemeral Postgres snapshot is missing a runtime marker", { + reason: "restore-mismatch", + path: restoreFrom, + }), + ), + ); + const marker = yield* fs + .readFileString(`${peekRoot}/${PGDATA_DIR_NAME}/${RUNTIME_MARKER}`) + .pipe( + Effect.mapError((cause) => + ephemeralError("Unable to read snapshot runtime marker", { cause, reason: "snapshot" }), + ), + Effect.flatMap(decodeMarker), + ); + if (!sameRuntime(marker, runtime)) + return yield* ephemeralError( + "Ephemeral Postgres snapshot was produced by a different runtime", + { + reason: "restore-mismatch", + path: restoreFrom, + }, + ); + yield* fs.remove(peekRoot, { recursive: true }).pipe(Effect.ignore); + }); + +const destroyCluster = (cluster: Cluster): Effect.Effect => + Effect.gen(function* () { + if (cluster.resources.kind === "native") yield* stopNative(cluster).pipe(Effect.ignore); + else { + yield* stopContainer(cluster).pipe(Effect.ignore); + if (cluster.resources.containerId !== undefined) + yield* cluster.resources.engine + .removeContainer(cluster.resources.containerId) + .pipe(Effect.ignore); + if (cluster.resources.volumeId !== undefined) + yield* cluster.resources.engine + .removeVolume(cluster.resources.volumeId) + .pipe(Effect.ignore); + if (cluster.resources.networkId !== undefined) + yield* cluster.resources.engine + .removeNetwork(cluster.resources.networkId) + .pipe(Effect.ignore); + } + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(cluster.root, { recursive: true }).pipe(Effect.ignore); + }); + +const clusterHandle = ( + cluster: Cluster, + options: CreateEphemeralPostgresOptions, + healthTimeout: string, + password: string, +): EffectEphemeralPostgres => { + const requireStopped = (): Effect.Effect => + cluster.running + ? Effect.fail( + ephemeralError("Ephemeral Postgres must be stopped before exporting PGDATA", { + reason: "not-stopped", + }), + ) + : Effect.void; + return { + host: cluster.host, + port: cluster.port, + version: cluster.version, + runtime: cluster.runtime, + artifactIdentity: cluster.artifactIdentity, + url: Redacted.make(databaseUrl(cluster.port, password)), + start: () => + cluster.lifecycle.withPermit( + Effect.gen(function* () { + if (cluster.running) { + if (cluster.runtime.kind === "native" && cluster.resources.kind === "native") { + const process = cluster.resources.process; + const stillRunning = + process === undefined + ? false + : yield* process.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (stillRunning) return; + yield* stopNative(cluster).pipe(Effect.ignore); + } else { + const probe = yield* waitForPostgres(cluster.port, "1s").pipe(Effect.exit); + if (Exit.isSuccess(probe)) return; + cluster.running = false; + } + } + if (cluster.runtime.kind === "native") + yield* startNative(cluster, options, healthTimeout, password); + else yield* startContainer(cluster, options, healthTimeout, password); + }), + ), + stop: () => + cluster.lifecycle.withPermit( + cluster.runtime.kind === "native" ? stopNative(cluster) : stopContainer(cluster), + ), + exportPgData: (tarPath) => + cluster.lifecycle.withPermit( + Effect.gen(function* () { + yield* requireStopped(); + if (cluster.runtime.kind === "native") yield* exportNative(cluster, tarPath); + else yield* exportContainer(cluster, tarPath); + }), + ), + }; +}; + +export const createEphemeralPostgresCluster = ( + options: CreateEphemeralPostgresOptions, +): Effect.Effect< + EffectEphemeralPostgres, + EphemeralPostgresCreateError, + Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService +> => + Effect.gen(function* () { + const runtime = resolvedRuntime(options.runtime); + const release = yield* resolveEphemeralPostgresRelease(options.version); + const env = yield* Effect.serviceOption(StackRuntimeEnvironment).pipe( + Effect.map(Option.getOrElse(defaultRuntimeEnvironment)), + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const identity = yield* createIdentity(crypto); + const root = path.join(path.dirname(env.stateRoot), "ephemeral-postgres", identity); + const dataPath = path.join(root, PGDATA_DIR_NAME); + yield* fs.makeDirectory(dataPath, { recursive: true, mode: 0o700 }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to create ephemeral Postgres data directory", + path: dataPath, + cause, + }), + ), + ); + yield* Effect.addFinalizer(() => fs.remove(root, { recursive: true }).pipe(Effect.ignore)); + if (options.restoreFrom !== undefined) + yield* peekSnapshotRuntime(options.restoreFrom, runtime, path.join(root, "peek")); + const port = yield* allocateLoopbackPort(options.port); + const healthTimeout = options.healthTimeout ?? DEFAULT_DATABASE_HEALTH_TIMEOUT; + const password = Redacted.value(options.databasePassword); + const workload = plannedWorkload(release.version, release.image, runtime); + const preparer = yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: env.stateRoot, + ...(env.artifactCacheRoot === undefined ? {} : { artifactCacheRoot: env.artifactCacheRoot }), + runtime, + }); + const prepared = yield* preparer.prepare(runtime, workload); + let resources: RuntimeResources; + if (runtime.kind === "native") { + resources = { kind: "native" }; + } else { + const resolver = yield* Effect.serviceOption(ContainerEngineResolver).pipe( + Effect.map(Option.getOrUndefined), + ); + const engine = yield* resolveContainerEngine(runtime.engine, resolver).pipe( + Effect.mapError( + (cause) => + new ContainerEngineError({ + message: `Unable to configure ${runtime.engine} for ephemeral Postgres`, + engine: runtime.engine, + cause, + }), + ), + ); + resources = { kind: "container", engine }; + } + const cluster: Cluster = { + identity, + root, + dataPath, + host: "127.0.0.1", + port, + version: release.version, + runtime, + artifactIdentity: + runtime.kind === "native" + ? `native:${release.version}` + : `container:${runtime.engine}:${release.image}`, + ...(prepared.executablePath === undefined || prepared.artifactRoot === undefined + ? {} + : { + executable: prepared.artifactRoot.endsWith("/") + ? `${prepared.artifactRoot}${prepared.executablePath}` + : `${prepared.artifactRoot}/${prepared.executablePath}`, + }), + ...(prepared.image === undefined ? {} : { image: prepared.image }), + lifecycle: Semaphore.makeUnsafe(1), + running: false, + bootstrapped: options.restoreFrom !== undefined, + resources, + }; + yield* Effect.addFinalizer(() => destroyCluster(cluster)); + if (cluster.resources.kind === "container") { + const engine = cluster.resources.engine; + const engineKind = cluster.runtime.kind === "container" ? cluster.runtime.engine : "docker"; + const network = yield* engine + .createNetwork({ + name: resourceName(identity, "network"), + labels: { stackId: identity, ownerSessionId: identity.slice(0, 32), role: "network" }, + }) + .pipe( + Effect.mapError( + (cause) => + new ContainerEngineError({ + message: "Unable to create ephemeral Postgres network", + engine: engineKind, + cause, + }), + ), + ); + cluster.resources.networkId = network.id; + const volume = yield* engine + .createVolume({ + name: resourceName(identity, "database-volume"), + labels: { stackId: identity, workloadId: DATABASE_WORKLOAD_ID, role: "volume" }, + }) + .pipe( + Effect.mapError( + (cause) => + new ContainerEngineError({ + message: "Unable to create ephemeral Postgres volume", + engine: engineKind, + cause, + }), + ), + ); + cluster.resources.volumeId = volume.id; + } + if (options.restoreFrom !== undefined) { + if (runtime.kind === "native") yield* restoreNative(cluster, options.restoreFrom); + else yield* restoreContainer(cluster, options.restoreFrom); + } + if (runtime.kind === "native") yield* startNative(cluster, options, healthTimeout, password); + else yield* startContainer(cluster, options, healthTimeout, password); + return clusterHandle(cluster, options, healthTimeout, password); + }); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 0a313b1515..18e5a83a72 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -3,6 +3,7 @@ import { Context, Duration, Effect, Layer, Predicate, Redacted, Schema, Scope } import { isSqlError, type SqlError } from "effect/unstable/sql/SqlError"; import { DatabaseBootstrapError, + type DatabaseBootstrapOptions, type DatabaseSession, type DatabaseSqlValue, type DatabaseTransaction, @@ -214,3 +215,30 @@ export const bootstrapDatabaseAt = ( }), ); }); + +/** Reconciles roles, JWT settings, and `_supabase` against an already-ready Postgres. */ +export const bootstrapManagedPostgres = ( + options: DatabaseBootstrapOptions & { + readonly host: string; + readonly port: number; + }, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const session = yield* makePostgresDatabaseSession({ + host: options.host, + port: options.port, + password: options.databasePassword, + }); + yield* ensureInternalDatabase( + session, + makePostgresDatabaseSession({ + host: options.host, + port: options.port, + database: INTERNAL_DATABASE, + password: options.databasePassword, + }), + ); + yield* runDatabaseBootstrap(session, options); + }), + ); From 0d13ecce770a13272eec860e80c268fa541f2271 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 10 Sep 2026 12:50:25 +0200 Subject: [PATCH 07/14] fix(cli): wire StackApi into all --local db and migration commands The stack backend resolved --local through stack credentials, but only pg-delta and squash runtimes provided the API, so migration up failed as not running. Move shared stack helpers out of the experimental command family, fail closed on local reset, skip Docker image inspect, and use stack credentials for declarative --local. --- .../db-bootstrap/reset-local-database.ts | 24 +++++ .../db-config.integration.test.ts | 89 ++++++++++++++++++- .../src/command-internal/db-config.layer.ts | 10 ++- apps/cli/src/command-internal/db-pull-run.ts | 4 +- .../pgdelta-engine-runtime.layer.ts | 2 +- .../stack-local-database.integration.test.ts | 6 +- .../stack-local-database.ts | 86 +++++++++--------- .../stack-shadow.integration.test.ts | 12 +-- .../stack-shadow.ts | 18 ++-- .../stack-shadow.unit.test.ts | 0 apps/cli/src/commands/db/diff/diff.handler.ts | 52 ++++++++--- .../commands/db/diff/diff.integration.test.ts | 2 +- .../src/commands/db/reset/reset.handler.ts | 13 ++- .../db/reset/reset.integration.test.ts | 25 ++++++ .../declarative/declarative.smart-target.ts | 28 +++++- .../declarative/generate/generate.handler.ts | 4 +- .../generate/generate.integration.test.ts | 29 ++++++ .../schema/declarative/sync/sync.handler.ts | 13 +-- .../db/shared/pgdelta-next-shadow.layer.ts | 2 +- .../shared/pgdelta.seam.integration.test.ts | 18 ++++ .../commands/db/shared/pgdelta.seam.layer.ts | 25 ++++-- .../commands/migration/migration.layers.ts | 2 +- .../migration/squash/squash.handler.ts | 2 +- 23 files changed, 362 insertions(+), 104 deletions(-) rename apps/cli/src/{commands/experimental/stack => command-internal}/stack-local-database.integration.test.ts (90%) rename apps/cli/src/{commands/experimental/stack => command-internal}/stack-local-database.ts (70%) rename apps/cli/src/{commands/experimental/stack => command-internal}/stack-shadow.integration.test.ts (95%) rename apps/cli/src/{commands/experimental/stack => command-internal}/stack-shadow.ts (96%) rename apps/cli/src/{commands/experimental/stack => command-internal}/stack-shadow.unit.test.ts (100%) diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 7196372509..7f85a4ce02 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -34,6 +34,7 @@ import { awaitStorageReady } from "./await-storage-ready.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; +import { currentStackBackend } from "../../commands/experimental/stack/stack-backend.ts"; /** The local database container is not running. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ @@ -44,6 +45,25 @@ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunni } } +/** Docker recreate would wipe leftover volumes while schema commands still target the stack. */ +class ResetLocalDbStackUnsupportedError extends Data.TaggedError( + "ResetLocalDbStackUnsupportedError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export const stackLocalResetUnsupportedError = () => + new ResetLocalDbStackUnsupportedError({ + message: "db reset --local is not supported when the stack backend is enabled.", + suggestion: + "Stack data-dir reset is not implemented yet. Use --linked or --db-url, or disable [experimental].stack.", + }); + /** ` to version: X`, or `...` when resetting to the latest migration. */ const toLogMessage = (version: string): string => version.length > 0 ? ` to version: ${version}` : "..."; @@ -64,6 +84,10 @@ const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { export const resetLocalDatabase = Effect.fnUntraced(function* ( input: ResetLocalDatabaseInput = PLAIN_FULL_RESET, ) { + const backend = yield* currentStackBackend; + if (backend.kind === "stack") { + return yield* Effect.fail(stackLocalResetUnsupportedError()); + } const output = yield* Output; const cliSettings = yield* CommandSettings; const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 767f04a909..456f55355f 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; +import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { mockAnalytics, @@ -22,10 +23,12 @@ import { } from "./global-flags.ts"; import { DebugLogger } from "./debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; -import { dbConfigLayer } from "./db-config.layer.ts"; +import { dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConfigFlags } from "./db-config.types.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; +import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; +import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver // builds with simple ambient stubs. The `--linked` sub-flow (login-role, @@ -46,6 +49,8 @@ function buildResolver( readonly projectHost?: string; readonly poolerHost?: string; readonly dbConnection?: Layer.Layer; + readonly stackApi?: Layer.Layer; + readonly stackBackend?: "legacy" | "stack"; } = {}, ) { const deps = Layer.mergeAll( @@ -76,7 +81,14 @@ function buildResolver( ), BunServices.layer, ); - return dbConfigLayer.pipe(Layer.provide(deps)); + const resolver = + opts.stackApi !== undefined + ? dbConfigResolverLayer.pipe(Layer.provide(opts.stackApi), Layer.provide(deps)) + : dbConfigLayer.pipe(Layer.provide(deps)); + return Layer.mergeAll( + resolver, + opts.stackBackend !== undefined ? stackBackendLayer(opts.stackBackend) : Layer.empty, + ); } function withWorkdir(toml?: string) { @@ -168,6 +180,77 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); + it.effect("local mode: uses the stack credentials URL when the stack backend is on", () => { + const dir = withWorkdir(["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n")); + const unused = () => Effect.die("unused"); + const stackId = StackIdSchema.make("a".repeat(64)); + const stack: EffectStack = { + id: stackId, + status: () => + Effect.succeed({ + id: stackId, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? "eager" : "lazy", + state: name === "database" ? "ready" : "dormant", + })), + artifacts: [], + }), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make("postgresql://postgres:stack-secret@127.0.0.1:54329/postgres"), + password: Redacted.make("stack-secret"), + }, + api: { + publishableKey: "anon", + secretKey: Redacted.make("service"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: unused, + start: unused, + stop: unused, + destroy: unused, + logs: unused, + followLogs: () => Stream.empty, + }; + const stackApi = Layer.succeed(StackApi, { + createStack: unused, + findStack: () => + Effect.succeed( + Option.some({ + id: stackId, + projectRoot: dir, + name: "default", + branchContext: "main", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }), + ), + discoverStacks: unused, + openStack: () => Effect.succeed(stack), + inspectStack: unused, + }); + return resolve(dir, localFlags, { stackBackend: "stack", stackApi }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn.host).toBe("127.0.0.1"); + expect(r.conn.port).toBe(54329); + expect(r.conn.password).toBe("stack-secret"); + expect(r.isLocal).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("local mode: honors SUPABASE_SERVICES_HOSTNAME for the connection host", () => { process.env["SUPABASE_SERVICES_HOSTNAME"] = "host.docker.internal"; const dir = withWorkdir(); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 19bdb66451..f2cce25f37 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -39,7 +39,8 @@ import { DebugLogger } from "./debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; -import { stackLocalDatabaseConn } from "../commands/experimental/stack/stack-local-database.ts"; +import { StackApi, stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; +import { stackLocalDatabaseConn } from "./stack-local-database.ts"; const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); @@ -377,10 +378,11 @@ export const resolveLinkedConn = Effect.fnUntraced(function* ( return poolerConn.value; }); -export const dbConfigLayer = Layer.effect( +export const dbConfigResolverLayer = Layer.effect( DbConfigResolver, Effect.gen(function* () { const cliSettings = yield* CommandSettings; + const stackApi = yield* StackApi; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const debug = yield* DebugLogger; @@ -565,8 +567,10 @@ export const dbConfigLayer = Layer.effect( }); const backend = yield* currentStackBackend; if (backend.kind === "stack") { + // `resolve`'s R is `never`, so capture StackApi at layer build. const conn = yield* stackLocalDatabaseConn.pipe( Effect.provideService(CommandSettings, cliSettings), + Effect.provideService(StackApi, stackApi), ); return { conn, isLocal: true }; } @@ -637,3 +641,5 @@ export const dbConfigLayer = Layer.effect( }); }), ); + +export const dbConfigLayer = dbConfigResolverLayer.pipe(Layer.provide(stackApiLayer)); diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 517c0c280d..bad27ed4aa 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -67,11 +67,11 @@ import { import { type PgDeltaContext, isPgDeltaDebugEnabled, resolvePgDeltaProjectId } from "./pgdelta.ts"; import { prepareShadowSource } from "../commands/db/shared/shadow-source.ts"; import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; -import { stackRejectNativeDockerDiffEngine } from "../commands/experimental/stack/stack-local-database.ts"; +import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; import { stackPrepareShadowSource, stackWithShadowDatabase, -} from "../commands/experimental/stack/stack-shadow.ts"; +} from "./stack-shadow.ts"; import type { DbPullFlags } from "../commands/db/pull/pull.command.ts"; import { DbPullDumpError, diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts index be2e17e36d..dcb3ef681c 100644 --- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts @@ -15,7 +15,7 @@ import { pgDeltaNextShadowLayer } from "../commands/db/shared/pgdelta-next-shado import { declarativeSeamLayer } from "../commands/db/shared/pgdelta.seam.layer.ts"; import { localDockerEngineLayer } from "./db-bootstrap/local-db-running.ts"; import { stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; -import { ephemeralPostgresLayer } from "../commands/experimental/stack/stack-shadow.ts"; +import { ephemeralPostgresLayer } from "./stack-shadow.ts"; /** The in-process pg-delta engine — the only implementation. */ const pgDeltaEngineLayer = pgDeltaNextEngineLayer; diff --git a/apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts b/apps/cli/src/command-internal/stack-local-database.integration.test.ts similarity index 90% rename from apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts rename to apps/cli/src/command-internal/stack-local-database.integration.test.ts index 8c36c4b7b5..1d7db27b45 100644 --- a/apps/cli/src/commands/experimental/stack/stack-local-database.integration.test.ts +++ b/apps/cli/src/command-internal/stack-local-database.integration.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Redacted, Stream } from "effect"; import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; -import { mockCommandSettings, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; -import { stackBackendLayer } from "./stack-backend.ts"; +import { mockCommandSettings, useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; import { stackLocalDatabaseUrl } from "./stack-local-database.ts"; -import { StackApi } from "./stack.shared.ts"; +import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; const tmp = useTempWorkdir("stack-local-db-"); const STACK_ID = StackIdSchema.make("a".repeat(64)); diff --git a/apps/cli/src/commands/experimental/stack/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts similarity index 70% rename from apps/cli/src/commands/experimental/stack/stack-local-database.ts rename to apps/cli/src/command-internal/stack-local-database.ts index 88023cbef8..9d9a1d827e 100644 --- a/apps/cli/src/commands/experimental/stack/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -3,22 +3,21 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, -} from "../../../shared/telemetry/error-actionability.ts"; +} from "../shared/telemetry/error-actionability.ts"; import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; import type { EffectStack } from "@supabase/stack/effect"; import type { StackRuntime } from "@supabase/stack/effect"; -import { parseConnectionString } from "../../../command-internal/db-config.parse.ts"; -import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { parseConnectionString } from "./db-config.parse.ts"; +import type { PgConnInput } from "./db-connection.service.ts"; +import { CommandSettings } from "../config/command-settings.service.ts"; import { LocalDbRunningError, isLocalDbRunning, type LocalDockerEngine, -} from "../../../command-internal/db-bootstrap/local-db-running.ts"; -import { DeclarativeShadowDbError } from "../../db/shared/pgdelta.errors.ts"; -import { currentStackBackend } from "./stack-backend.ts"; -import { StackApi } from "./stack.shared.ts"; -import { loadStackConfig } from "./stack-config.ts"; +} from "./db-bootstrap/local-db-running.ts"; +import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; +import { loadStackConfig } from "../commands/experimental/stack/stack-config.ts"; const notRunning = (message = "supabase start is not running.") => new LocalDbRunningError({ message }); @@ -35,14 +34,13 @@ const databaseReady = (stack: EffectStack) => const openProjectStack = () => Effect.gen(function* () { - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) return Option.none(); + const api = yield* StackApi; const cliSettings = yield* CommandSettings; - const descriptor = yield* api.value + const descriptor = yield* api .findStack({ projectRoot: cliSettings.workdir }) .pipe(Effect.mapError((cause) => notRunning(cause.message))); if (Option.isNone(descriptor)) return Option.none(); - const stack = yield* api.value + const stack = yield* api .openStack(descriptor.value.id) .pipe(Effect.mapError((cause) => notRunning(cause.message))); return yield* databaseReady(stack); @@ -65,7 +63,7 @@ export const stackProjectRuntime: Effect.Effect< }); }); -export const STACK_NATIVE_ENGINE_MESSAGE = +const STACK_NATIVE_ENGINE_MESSAGE = "The stack backend only supports the pg-delta engine. Do not pass --use-migra, --use-pgadmin, --use-pg-schema, or --diff-engine migra."; export class StackNativeEngineError extends Data.TaggedError("StackNativeEngineError")<{ @@ -83,20 +81,23 @@ export const stackRejectNativeDockerDiffEngine: Effect.Effect = - Effect.gen(function* () { - const opened = yield* openProjectStack(); - if (Option.isNone(opened)) return yield* notRunning(); - const credentials = yield* opened.value.stack - .credentials() - .pipe(Effect.mapError((cause) => notRunning(cause.message))); - return Redacted.value(credentials.database.url); - }); +export const stackLocalDatabaseUrl: Effect.Effect< + string, + LocalDbRunningError, + CommandSettings | StackApi +> = Effect.gen(function* () { + const opened = yield* openProjectStack(); + if (Option.isNone(opened)) return yield* notRunning(); + const credentials = yield* opened.value.stack + .credentials() + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + return Redacted.value(credentials.database.url); +}); export const stackLocalDatabaseConn: Effect.Effect< PgConnInput, LocalDbRunningError, - CommandSettings + CommandSettings | StackApi > = Effect.gen(function* () { const url = yield* stackLocalDatabaseUrl; const conn = parseConnectionString(url); @@ -106,10 +107,10 @@ export const stackLocalDatabaseConn: Effect.Effect< return conn; }); -export const stackLocalDatabaseIsRunning: Effect.Effect< +const stackLocalDatabaseIsRunning: Effect.Effect< boolean, LocalDbRunningError, - CommandSettings + CommandSettings | StackApi > = openProjectStack().pipe(Effect.map(Option.isSome)); export const resolveLocalDatabaseIsRunning = ( @@ -123,25 +124,22 @@ export const resolveLocalDatabaseIsRunning = ( const backend = yield* currentStackBackend; if (backend.kind === "legacy") return yield* isLocalDbRunning(spawner, fs, path, workdir, configuredProjectId); - return yield* stackLocalDatabaseIsRunning; + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return false; + return yield* stackLocalDatabaseIsRunning.pipe(Effect.provideService(StackApi, api.value)); }); export const stackEnsureLocalDatabaseStarted: Effect.Effect< void, - DeclarativeShadowDbError, - CommandSettings | FileSystem.FileSystem | Path.Path + LocalDbRunningError, + CommandSettings | FileSystem.FileSystem | Path.Path | StackApi > = Effect.gen(function* () { - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) { - return yield* new DeclarativeShadowDbError({ - message: "failed to start local database: supabase start is not running.", - }); - } + const api = yield* StackApi; const cliSettings = yield* CommandSettings; - const existing = yield* api.value.findStack({ projectRoot: cliSettings.workdir }).pipe( + const existing = yield* api.findStack({ projectRoot: cliSettings.workdir }).pipe( Effect.mapError( (cause) => - new DeclarativeShadowDbError({ + new LocalDbRunningError({ message: `failed to start local database: ${cause.message}`, }), ), @@ -149,24 +147,24 @@ export const stackEnsureLocalDatabaseStarted: Effect.Effect< const config = yield* loadStackConfig(cliSettings.workdir).pipe( Effect.mapError( (cause) => - new DeclarativeShadowDbError({ + new LocalDbRunningError({ message: `failed to start local database: ${cause.message}`, }), ), ); const stack = Option.isSome(existing) - ? yield* api.value.openStack(existing.value.id).pipe( + ? yield* api.openStack(existing.value.id).pipe( Effect.mapError( (cause) => - new DeclarativeShadowDbError({ + new LocalDbRunningError({ message: `failed to start local database: ${cause.message}`, }), ), ) - : yield* api.value.createStack({ projectRoot: cliSettings.workdir }).pipe( + : yield* api.createStack({ projectRoot: cliSettings.workdir }).pipe( Effect.mapError( (cause) => - new DeclarativeShadowDbError({ + new LocalDbRunningError({ message: `failed to start local database: ${cause.message}`, }), ), @@ -174,7 +172,7 @@ export const stackEnsureLocalDatabaseStarted: Effect.Effect< const status = yield* stack.status().pipe( Effect.mapError( (cause) => - new DeclarativeShadowDbError({ + new LocalDbRunningError({ message: `failed to start local database: ${cause.message}`, }), ), @@ -184,7 +182,7 @@ export const stackEnsureLocalDatabaseStarted: Effect.Effect< yield* stack.start({ config }).pipe( Effect.mapError( (cause) => - new DeclarativeShadowDbError({ + new LocalDbRunningError({ message: `failed to start local database: ${cause.message}`, }), ), diff --git a/apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts similarity index 95% rename from apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts rename to apps/cli/src/command-internal/stack-shadow.integration.test.ts index bfdc4d57b4..3e3741b525 100644 --- a/apps/cli/src/commands/experimental/stack/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -7,22 +7,22 @@ import { type CreateEphemeralPostgresOptions, type EffectEphemeralPostgres, } from "@supabase/stack/effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput } from "../../tests/helpers/mocks.ts"; import { mockCommandSettings, useTempWorkdir, withEnvVar, -} from "../../../../tests/helpers/command-mocks.ts"; -import { SHADOW_CACHE_ENV } from "../../../command-internal/db-bootstrap/shadow-cache.ts"; -import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { stackBackendLayer } from "./stack-backend.ts"; +} from "../../tests/helpers/command-mocks.ts"; +import { SHADOW_CACHE_ENV } from "./db-bootstrap/shadow-cache.ts"; +import { DbConnection } from "./db-connection.service.ts"; +import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; import { StackEphemeralPostgres, stackAcquireShadowDatabase, stackShadowBaselineTarFileName, stackShadowCacheKey, } from "./stack-shadow.ts"; -import type { ShadowSetupInput } from "../../../command-internal/db-bootstrap/shadow-database.ts"; +import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; const tmp = useTempWorkdir("stack-shadow-"); const defaultConfig: CliConfig = Schema.decodeSync(CliConfigSchema)({}); diff --git a/apps/cli/src/commands/experimental/stack/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts similarity index 96% rename from apps/cli/src/commands/experimental/stack/stack-shadow.ts rename to apps/cli/src/command-internal/stack-shadow.ts index a65fedaab1..cc69d54e7e 100644 --- a/apps/cli/src/commands/experimental/stack/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -29,26 +29,26 @@ import { type StackRuntimePreference, type StackVersionUnsupportedError, } from "@supabase/stack/effect"; -import { Output } from "../../../shared/output/output.service.ts"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { shadowBaselineCacheDir } from "../../../command-internal/pgdelta.paths.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { CommandSettings } from "../config/command-settings.service.ts"; +import { DbConnection } from "./db-connection.service.ts"; +import { shadowBaselineCacheDir } from "./pgdelta.paths.ts"; import { SHADOW_BASELINE_KEEP, SHADOW_BASELINE_MAX_AGE_MS, SHADOW_CACHE_ENV, shadowBaselineTarsToEvict, touchShadowBaselineTar, -} from "../../../command-internal/db-bootstrap/shadow-cache.ts"; -import { viperEnvBoolWithProjectFallback } from "../../../command-internal/viper-env.ts"; +} from "./db-bootstrap/shadow-cache.ts"; +import { viperEnvBoolWithProjectFallback } from "./viper-env.ts"; import { connectShadowDatabase, ShadowDbError, type ShadowSetupInput, type ShadowSourceResult, -} from "../../../command-internal/db-bootstrap/shadow-database.ts"; -import { listLocalMigrationPaths } from "../../../command-internal/migration-history.ts"; -import { applyMigrations, seedGlobals } from "../../../command-internal/migration-apply.ts"; +} from "./db-bootstrap/shadow-database.ts"; +import { listLocalMigrationPaths } from "./migration-history.ts"; +import { applyMigrations, seedGlobals } from "./migration-apply.ts"; import { stackProjectRuntime } from "./stack-local-database.ts"; /** Optional factory so CLI tests can `Layer.succeed` a fake cluster. */ diff --git a/apps/cli/src/commands/experimental/stack/stack-shadow.unit.test.ts b/apps/cli/src/command-internal/stack-shadow.unit.test.ts similarity index 100% rename from apps/cli/src/commands/experimental/stack/stack-shadow.unit.test.ts rename to apps/cli/src/command-internal/stack-shadow.unit.test.ts diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index d3e9c23376..17264af3e3 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -29,14 +29,15 @@ import { schemaToCsvField } from "../../../command-internal/schema-flags.ts"; import { findDropStatements } from "../../../command-internal/sql-split.ts"; import { buildLocalDbContainerInputs } from "../../../command-internal/db-bootstrap/local-container-inputs.ts"; import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { StackApi } from "../../experimental/stack/stack.shared.ts"; import { stackLocalDatabaseConn, stackRejectNativeDockerDiffEngine, -} from "../../experimental/stack/stack-local-database.ts"; +} from "../../../command-internal/stack-local-database.ts"; import { stackPrepareShadowSource, stackWithShadowDatabase, -} from "../../experimental/stack/stack-shadow.ts"; +} from "../../../command-internal/stack-shadow.ts"; import { isLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { waitForHealthyServices } from "../../../command-internal/db-bootstrap/health-check.ts"; import { withShadowDatabase } from "../../../command-internal/db-bootstrap/shadow-cache.ts"; @@ -145,6 +146,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; const debug = yield* DebugFlag; + const stackApi = yield* Effect.serviceOption(StackApi); // Resolved linked ref, captured so the post-run finalizer caches the project // (GET /v1/projects/{ref}). @@ -270,18 +272,40 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { switch (classifyExplicitRef(ref)) { case "local": { const backend = yield* currentStackBackend; - const connection = - backend.kind === "stack" - ? yield* stackLocalDatabaseConn.pipe( - Effect.provideService(CommandSettings, cliSettings), - ) - : { - host: getHostname(), - port: cfg.port, - user: "postgres", - password: cfg.password, - database: "postgres", - }; + if (backend.kind !== "stack") { + const connection = { + host: getHostname(), + port: cfg.port, + user: "postgres", + password: cfg.password, + database: "postgres", + }; + return { + kind: "database", + ref: toPostgresURL(connection), + connection, + connectOptions: { isLocal: true, dnsResolver }, + } satisfies PgDeltaDatabaseEndpoint; + } + if (Option.isNone(stackApi)) { + return yield* Effect.fail( + new DbDiffDbNotRunningError({ + message: "supabase start is not running.", + }), + ); + } + const connection = yield* stackLocalDatabaseConn.pipe( + Effect.provideService(CommandSettings, cliSettings), + Effect.provideService(StackApi, stackApi.value), + Effect.mapError( + (cause) => + new DbDiffDbNotRunningError({ + message: cause.message, + daemonDown: cause.daemonDown, + suggestion: cause.suggestion, + }), + ), + ); return { kind: "database", ref: toPostgresURL(connection), diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 26bc20ec30..c7d249a97f 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -64,7 +64,7 @@ import { import type { DbDiffFlags } from "./diff.command.ts"; import { dbDiff } from "./diff.handler.ts"; import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; -import { StackNativeEngineError } from "../../experimental/stack/stack-local-database.ts"; +import { StackNativeEngineError } from "../../../command-internal/stack-local-database.ts"; import { PGADMIN_DESKTOP_NOTE_PREFIX, PGADMIN_DIFF_HEADER } from "./pgadmin-diff.ts"; interface SetupOpts { diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 9b53b41c18..f20add9ac7 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -13,7 +13,11 @@ import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua, yellow } from "../../../command-internal/colors.ts"; import { resolveResetSeedConfig } from "../../../command-internal/db-bootstrap/db-setup.ts"; -import { resetLocalDatabase } from "../../../command-internal/db-bootstrap/reset-local-database.ts"; +import { + resetLocalDatabase, + stackLocalResetUnsupportedError, +} from "../../../command-internal/db-bootstrap/reset-local-database.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { applyProjectEnv, @@ -187,6 +191,13 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const connType = target.connType ?? "local"; + if (connType === "local") { + const backend = yield* currentStackBackend; + if (backend.kind === "stack") { + return yield* Effect.fail(stackLocalResetUnsupportedError()); + } + } + // `--project-ref` only applies to the linked target; it must not be silently ignored when // targeting `--local`/`--db-url`. if (Option.isSome(flags.projectRef) && connType !== "linked") { diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index d19a7e9eef..e4a7a62961 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -42,6 +42,7 @@ import { } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; +import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; @@ -417,6 +418,7 @@ function setup( // Simulates an unlinked workdir: `loadProjectRef` fails with `ProjectRefNotLinkedError` // absent an explicit `--project-ref` flag, instead of falling back to `opts.ref`. linkedFails?: boolean; + stackBackend?: boolean; }, ) { if (opts.toml !== undefined) { @@ -485,6 +487,7 @@ function setup( Layer.succeed(DebugFlag, opts.debug ?? false), telemetry.layer, linkedCache.layer, + ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), ); return { layer, @@ -666,6 +669,28 @@ describe("db reset", () => { }, ); + it.live("refuses --local reset when the stack backend is enabled, before any recreate", () => { + const { layer, child, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "db reset --local is not supported when the stack backend is enabled.", + ); + } + expect(resolver.calls).toBe(0); + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( + false, + ); + }); + }); + it.live( "fails a local reset before the destructive recreate on a malformed config.toml", () => { diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts index 034a172138..52208ab134 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts @@ -8,6 +8,7 @@ import { promptYesNo } from "../../../../command-internal/prompt-yes-no.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { resetLocalDatabase } from "../../../../command-internal/db-bootstrap/reset-local-database.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/project-ref.service.ts"; +import { currentStackBackend } from "../../../experimental/stack/stack-backend.ts"; import { DbConfigResolver } from "../../../../command-internal/db-config.service.ts"; import { loadProjectEnv } from "../../../../command-internal/db-config.toml-read.ts"; import { @@ -54,7 +55,7 @@ const localConnection = (local: LocalConn) => ({ database: "postgres", }); -export const localEndpoint = ( +const localEndpoint = ( local: LocalConn, dnsResolver: "native" | "https", ): PgDeltaDatabaseEndpoint => { @@ -67,6 +68,27 @@ export const localEndpoint = ( }; }; +/** Local target URL: stack credentials when the stack backend is on, else config.toml `[db]`. */ +export const resolveLocalTargetEndpoint = Effect.fnUntraced(function* ( + local: LocalConn, + dnsResolver: "native" | "https", +) { + const backend = yield* currentStackBackend; + if (backend.kind !== "stack") return localEndpoint(local, dnsResolver); + const resolver = yield* DbConfigResolver; + const resolved = yield* resolver.resolve({ + dbUrl: Option.none(), + connType: "local", + dnsResolver, + }); + return { + kind: "database", + ref: toPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: true, dnsResolver }, + } satisfies PgDeltaDatabaseEndpoint; +}); + /** Resolves a remote target without discarding TLS and connection options. */ export const resolveRemoteEndpoint = Effect.fnUntraced(function* (flags: SmartTargetFlags) { const resolver = yield* DbConfigResolver; @@ -104,7 +126,7 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( // No migrations: generate from local, starting a stopped stack first. yield* beforeLocalTarget; yield* (yield* DeclarativeSeam).ensureLocalDatabaseStarted(); - return localEndpoint(local, yield* DnsResolverFlag); + return yield* resolveLocalTargetEndpoint(local, yield* DnsResolverFlag); } const output = yield* Output; @@ -189,5 +211,5 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( ), ); } - return localEndpoint(local, yield* DnsResolverFlag); + return yield* resolveLocalTargetEndpoint(local, yield* DnsResolverFlag); }); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index 249cd9df18..e3f7436891 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -44,7 +44,7 @@ import { import type { DbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { type LocalConn, - localEndpoint, + resolveLocalTargetEndpoint, resolveRemoteEndpoint, resolveSmartTargetEndpoint, } from "../declarative.smart-target.ts"; @@ -178,7 +178,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene if (Option.getOrElse(flags.local, () => false)) { yield* seam.ensureLocalDatabaseStarted(); } - target = localEndpoint(local, dnsResolver); + target = yield* resolveLocalTargetEndpoint(local, dnsResolver); } else { target = yield* resolveRemoteEndpoint(flags); } diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 862855f61e..b3ae60696f 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -41,6 +41,7 @@ import { GoProxy } from "../../../../../command-internal/go-proxy.service.ts"; import { CommandPlatformApi } from "../../../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../../../auth/command-platform-api-factory.service.ts"; import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; +import { stackBackendLayer } from "../../../../experimental/stack/stack-backend.ts"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; import { type DbSession, @@ -71,6 +72,7 @@ interface SetupOpts { /** Makes the engine's `exportDeclarativeSchema` fail after recording the call. */ exportFails?: boolean; staleLocalImage?: boolean; + stackBackend?: boolean; } /** What the handler handed the engine for one `exportDeclarativeSchema` call. */ @@ -171,6 +173,18 @@ function setup(workdir: string, opts: SetupOpts = {}) { const resolver = Layer.succeed(DbConfigResolver, { resolve: (flags) => { resolverCalls.push(flags); + if (flags.connType === "local") { + return Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54329, + user: "postgres", + password: "stack-secret", + database: "postgres", + }, + isLocal: true, + }); + } return Effect.succeed({ conn: { host: "db.remote", @@ -234,6 +248,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { processControl.layer, alwaysReadyHttpClientLayer, dockerRun, + ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), ); return { layer, @@ -435,6 +450,20 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "explicit --local on the stack backend exports from stack credentials, not toml [db].port", + () => { + const s = setup(tmp.current, { experimental: true, stackBackend: true }); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + expect(s.engineExportCalls[0]!.targetRef).toContain( + "postgresql://postgres:stack-secret@127.0.0.1:54329", + ); + expect(s.engineExportCalls[0]!.targetRef).not.toContain(":54322"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect( "--output-dir writes a complete export relative to the project without activating it", () => { diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index f061a8e813..253d54f813 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -31,7 +31,7 @@ import { resolvePgDeltaProjectId, } from "../../../../../command-internal/pgdelta.ts"; import { writePgDeltaMigrations } from "../../../shared/pgdelta-migrations.write.ts"; -import { localEndpoint, resolveSmartTargetEndpoint } from "../declarative.smart-target.ts"; +import { resolveLocalTargetEndpoint, resolveSmartTargetEndpoint } from "../declarative.smart-target.ts"; import { type DebugBundle, collectMigrationsList, @@ -304,10 +304,13 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f ), ); } - const generated = yield* generateDeclarativeOutput( - { ...run, declarativeDir: stagedDir }, - localEndpoint({ port: toml.port, password: toml.password }, dnsResolver), - ); + const generated = const generated = yield* generateDeclarativeOutput( + { ...run, declarativeDir: stagedDir }, + yield* resolveLocalTargetEndpoint( + { port: toml.port, password: toml.password }, + dnsResolver, + ), + ); const written = yield* writeDeclarativeSchemas(fs, path, stagedDir, generated); yield* warnPreservedUnmanagedDeclarativeFiles(stagedDirRel, written); yield* output.raw(declarativeSchemaWrittenLine(stagedDirRel), "stderr"); diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index 8d2d284dcb..e12b339e4d 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -54,7 +54,7 @@ import { stackAcquireShadowDatabase, stackMigrateShadow, stackReleaseShadowDatabase, -} from "../../experimental/stack/stack-shadow.ts"; +} from "../../../command-internal/stack-shadow.ts"; const allocateFreeHostPort = Effect.callback>((resume) => { const server = Net.createServer(); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index 9f40101ac5..d873cde47c 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -29,6 +29,7 @@ import { import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { SUGGEST_DOCKER_INSTALL } from "../../../command-internal/docker-suggest.ts"; +import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { declarativeSeamLayer } from "./pgdelta.seam.layer.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; @@ -82,6 +83,7 @@ function setup( readonly failCreate?: boolean; readonly dbInspectFailsWith?: string; readonly dbInspectImage?: string; + readonly stackBackend?: boolean; } = {}, ) { const out = mockOutput(); @@ -134,6 +136,7 @@ function setup( Layer.succeed(DebugFlag, false), Layer.succeed(CliArgs, { args: [] }), seam, + ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), ); return { layer, out, shadowSpawned: shadowSpawner.spawned }; @@ -226,4 +229,19 @@ describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { rmSync(dir, { recursive: true, force: true }); }).pipe(Effect.provide(layer)); }); + + it.effect("skips docker container inspect when the stack backend is on", () => { + const dir = mkdtempSync(join(tmpdir(), "pgdelta-seam-")); + const { layer, shadowSpawned } = setup(dir, { + dbInspectImage: dockerfileServiceImageRaw("pg"), + stackBackend: true, + }); + return Effect.gen(function* () { + const seam = yield* DeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(shadowSpawned.some((s) => s.args.includes("inspect"))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index c9e544dff6..809c32b36a 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -14,7 +14,8 @@ import { resolveLocalProjectId, localDbContainerId } from "../../../command-inte import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; -import { stackEnsureLocalDatabaseStarted } from "../../experimental/stack/stack-local-database.ts"; +import { StackApi, stackApiLayer } from "../../experimental/stack/stack.shared.ts"; +import { stackEnsureLocalDatabaseStarted } from "../../../command-internal/stack-local-database.ts"; const shadowDockerCause = (stderr: string): { readonly docker: "daemon" } | Record => isDockerDaemonUnreachable(stderr) ? { docker: "daemon" } : {}; @@ -65,6 +66,7 @@ export const declarativeSeamLayer = Layer.effect( DeclarativeSeam, Effect.gen(function* () { const cliSettings = yield* CommandSettings; + const stackApi = yield* StackApi; const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -82,6 +84,15 @@ export const declarativeSeamLayer = Layer.effect( Effect.provideService(CommandSettings, cliSettings), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(StackApi, stackApi), + Effect.mapError( + (cause) => + new DeclarativeShadowDbError({ + message: cause.message, + ...(cause.daemonDown === true ? { docker: "daemon" as const } : {}), + ...(cause.suggestion !== undefined ? { suggestion: cause.suggestion } : {}), + }), + ), ); } const running = yield* isLocalDbRunning( @@ -122,8 +133,11 @@ export const declarativeSeamLayer = Layer.effect( ); }), ensureLocalPostgresImageCurrent: () => - Effect.scoped( - Effect.gen(function* () { + Effect.gen(function* () { + const backend = yield* currentStackBackend; + if (backend.kind === "stack") return; + return yield* Effect.scoped( + Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir).pipe( Effect.mapError( (error) => @@ -246,10 +260,11 @@ export const declarativeSeamLayer = Layer.effect( }), ); }), - ), + ); + }), }); }), -); +).pipe(Layer.provide(stackApiLayer)); type StartLocalDatabaseDeps = ReturnType extends Effect.Effect diff --git a/apps/cli/src/commands/migration/migration.layers.ts b/apps/cli/src/commands/migration/migration.layers.ts index ac0ec351e0..bc74ea4a7c 100644 --- a/apps/cli/src/commands/migration/migration.layers.ts +++ b/apps/cli/src/commands/migration/migration.layers.ts @@ -12,7 +12,7 @@ import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { linkedDbResolverRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; import { stackApiLayer } from "../experimental/stack/stack.shared.ts"; -import { ephemeralPostgresLayer } from "../experimental/stack/stack-shadow.ts"; +import { ephemeralPostgresLayer } from "../../command-internal/stack-shadow.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index 1993fa27d2..d307da77b6 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -45,7 +45,7 @@ import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { errorMessage, relativizeErrorMessage } from "../../../command-internal/error-message.ts"; import { viperEnvStringWithProjectFallback } from "../../../command-internal/viper-env.ts"; import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; -import { stackWithShadowDatabase } from "../../experimental/stack/stack-shadow.ts"; +import { stackWithShadowDatabase } from "../../../command-internal/stack-shadow.ts"; import { applyMigrations, MigrationApplyError } from "../../../command-internal/migration-apply.ts"; import { INSERT_MIGRATION_VERSION, From 96c1ff795bb5919eb4f2a7b8fffeafed39543ba5 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 10:35:39 +0200 Subject: [PATCH 08/14] feat(cli): route local db commands through the project stack When [experimental].stack is on, db start/reset/dump/test and squash use stack credentials and resetDatabase instead of Compose. Native engines use PATH pg_dump/pg_prove; docker engines keep the tool container. --- apps/cli/docs/stack-commands.md | 19 +- .../db-bootstrap/reset-local-database.ts | 112 ++++++++--- .../db-bootstrap/shadow-cache.unit.test.ts | 4 +- .../db-config.integration.test.ts | 1 + apps/cli/src/command-internal/pg-dump.run.ts | 49 ++++- .../command-internal/postgres-client.run.ts | 183 ++++++++++++++++++ .../postgres-client.run.unit.test.ts | 16 ++ .../stack-local-database.integration.test.ts | 1 + .../command-internal/stack-local-database.ts | 154 +++++++++------ .../stack-shadow.integration.test.ts | 74 +++++++ apps/cli/src/command-internal/stack-shadow.ts | 30 +-- .../src/command-internal/test-db.handler.ts | 89 ++++++--- .../src/command-internal/test-db.layers.ts | 3 + apps/cli/src/commands/db/dump/dump.handler.ts | 49 ++++- .../commands/db/dump/dump.integration.test.ts | 146 +++++++++++++- apps/cli/src/commands/db/dump/dump.layers.ts | 3 + .../src/commands/db/reset/reset.handler.ts | 13 +- .../db/reset/reset.integration.test.ts | 126 ++++++++++-- .../cli/src/commands/db/reset/reset.layers.ts | 3 + .../schema/declarative/sync/sync.handler.ts | 30 ++- .../declarative/sync/sync.integration.test.ts | 95 ++++++++- .../cli/src/commands/db/start/start.errors.ts | 18 ++ .../src/commands/db/start/start.handler.ts | 37 +++- .../db/start/start.integration.test.ts | 149 +++++++++++++- .../cli/src/commands/db/start/start.layers.ts | 2 + .../stack/destroy/destroy.integration.test.ts | 1 + .../stack/start/start.integration.test.ts | 1 + .../experimental/stack/start/start.options.ts | 6 +- .../stack/stop/stop.integration.test.ts | 1 + .../commands/migration/squash/squash.dump.ts | 9 +- .../migration/squash/squash.handler.ts | 40 ++-- ...5-ephemeral-postgres-for-schema-tooling.md | 3 +- packages/stack/README.md | 9 +- packages/stack/src/control/StackRpc.ts | 7 +- .../control-transport.integration.test.ts | 37 +++- packages/stack/src/public/EffectStack.ts | 29 +++ packages/stack/src/public/Errors.ts | 6 + packages/stack/src/public/PromiseStack.ts | 2 + .../public/effect-stack.integration.test.ts | 15 ++ .../ephemeral-postgres.integration.test.ts | 88 ++++++++- .../src/public/promise.integration.test.ts | 1 + .../public/reset-database.integration.test.ts | 116 +++++++++++ .../src/public/testing.integration.test.ts | 7 + .../stack/src/runtime/ContainerRuntime.ts | 18 ++ .../stack/src/runtime/EphemeralPostgres.ts | 62 +++--- packages/stack/src/runtime/NativeRuntime.ts | 8 + .../stack/src/runtime/ProductionRuntime.ts | 16 ++ packages/stack/src/runtime/RuntimeDriver.ts | 5 + .../production-runtime.integration.test.ts | 2 + .../stack/src/supervisor/SessionLauncher.ts | 7 + packages/stack/src/supervisor/Supervisor.ts | 69 ++++++- .../supervisor/handles.integration.test.ts | 2 + .../session-launcher.integration.test.ts | 3 + .../startup-ingress.integration.test.ts | 1 + .../supervisor/supervisor.integration.test.ts | 80 ++++++++ 55 files changed, 1823 insertions(+), 234 deletions(-) create mode 100644 apps/cli/src/command-internal/postgres-client.run.ts create mode 100644 apps/cli/src/command-internal/postgres-client.run.unit.test.ts create mode 100644 apps/cli/src/commands/db/start/start.errors.ts create mode 100644 packages/stack/src/public/reset-database.integration.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 2e424183dd..302b3d5242 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -41,15 +41,24 @@ For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new b precedence over `experimental.stack`; an unset or empty value falls back to the file setting. Other values are rejected. The override is applied before reading the project configuration. -When the flag is on, `db` and `migration` commands use the project stack for `--local` and -provision throwaway shadow Postgres through `@supabase/stack` (`EphemeralPostgres`). Linked -and `--db-url` targets stay on the Management API. The stack backend requires the in-process +When the flag is on, the `db` and `migration` family uses the project stack for `--local` and +provisions throwaway shadow Postgres through `@supabase/stack` (`EphemeralPostgres`). Linked +and `--db-url` targets stay on the Management API. Compose names (`supabase_db_*`, +`supabase_network_*`, `db:5432`) are not used. The stack backend requires the in-process pg-delta engine; `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, and `--diff-engine migra` are rejected. The flag does not switch functions or storage command families, and does not change top-level `status`. -`db reset` and declarative `--apply`/`--reset` still use the legacy Docker volume recreate -path; stack data-dir wipe is a later follow-up. +`db start` brings up a postgres-only project stack. If a full stack already exists, it starts +the database without persisting `--exclude`. `--from-backup` is not supported on the stack +path. `db reset --local` and declarative `--apply` wipe Postgres through `resetDatabase` and +then migrate or seed on stack credentials. + +`db dump --local`, `db test` / `test db`, and `migration squash` use host `pg_dump` / `pg_prove` +only when the stack engine is native. Those PATH clients must match the stack Postgres major; +otherwise install matching client tools or start with `--runtime docker`. The Docker/Podman +engine keeps the one-shot tool container and targets published stack credentials, never +`PGHOST=db`. ## Data and configuration diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 7f85a4ce02..49d2da305d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -28,13 +28,18 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { aqua, yellow } from "../colors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; -import { checkDbToml, loadProjectEnv } from "../db-config.toml-read.ts"; +import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts"; +import { DbConnection } from "../db-connection.service.ts"; +import { loadLocalProjectContext } from "../local-project-context.ts"; +import { migrateAndSeed } from "../migrate-and-seed.ts"; import { seedBucketsRun } from "../seed-buckets.ts"; import { awaitStorageReady } from "./await-storage-ready.ts"; +import { resolveResetSeedConfig } from "./db-setup.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; import { currentStackBackend } from "../../commands/experimental/stack/stack-backend.ts"; +import { stackLocalDatabaseConn, stackOpenReadyProject } from "../stack-local-database.ts"; /** The local database container is not running. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ @@ -45,25 +50,15 @@ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunni } } -/** Docker recreate would wipe leftover volumes while schema commands still target the stack. */ -class ResetLocalDbStackUnsupportedError extends Data.TaggedError( - "ResetLocalDbStackUnsupportedError", -)<{ +class ResetLocalDbFailedError extends Data.TaggedError("ResetLocalDbFailedError")<{ readonly message: string; readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return actionability.dbConnection; } } -export const stackLocalResetUnsupportedError = () => - new ResetLocalDbStackUnsupportedError({ - message: "db reset --local is not supported when the stack backend is enabled.", - suggestion: - "Stack data-dir reset is not implemented yet. Use --linked or --db-url, or disable [experimental].stack.", - }); - /** ` to version: X`, or `...` when resetting to the latest migration. */ const toLogMessage = (version: string): string => version.length > 0 ? ` to version: ${version}` : "..."; @@ -80,24 +75,22 @@ const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { seedFlags: { noSeed: false, sqlPaths: [] }, }; +const notRunning = () => + new ResetLocalDbNotRunningError({ + message: `${aqua("supabase start")} is not running.`, + }); + +const resetFailed = (message: string) => new ResetLocalDbFailedError({ message }); + /** Resets the local database in-process. See this module's own header for the full design rationale. */ export const resetLocalDatabase = Effect.fnUntraced(function* ( input: ResetLocalDatabaseInput = PLAIN_FULL_RESET, ) { const backend = yield* currentStackBackend; - if (backend.kind === "stack") { - return yield* Effect.fail(stackLocalResetUnsupportedError()); - } const output = yield* Output; const cliSettings = yield* CommandSettings; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const networkIdFlag = yield* NetworkIdFlag; - // Threaded into `buildLocalDbContainerInputs`'s `setup.debug`, so a failed fresh-volume - // Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own stderr. - const debug = yield* DebugFlag; const workdir = cliSettings.workdir; // Load the project env first so a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is @@ -106,10 +99,77 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); - // Validate config before checking whether the container is running, so a malformed config + // Validate config before checking whether the database is running, so a malformed config // aborts before the local database is recreated — the same pattern `db start`/`db push` use. yield* checkDbToml(fs, path, workdir); + if (backend.kind === "stack") { + const opened = yield* stackOpenReadyProject(); + if (Option.isNone(opened)) return yield* Effect.fail(notRunning()); + yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); + yield* opened.value.stack.resetDatabase().pipe( + Effect.catchTag("StackNotRunningError", () => Effect.fail(notRunning())), + Effect.mapError((cause) => + resetFailed(`failed to reset local database: ${cause.message}`), + ), + ); + const dbConn = yield* DbConnection; + const toml = yield* readDbToml(fs, path, workdir); + const conn = yield* stackLocalDatabaseConn.pipe( + Effect.mapError((cause) => new ResetLocalDbNotRunningError({ message: cause.message })), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(conn, { isLocal: true, dnsResolver: "native" }).pipe( + Effect.mapError((cause) => resetFailed(`failed to connect after reset: ${cause.message}`)), + ); + yield* migrateAndSeed(session, fs, path, workdir, input.version, { + migrationsEnabled: toml.migrationsEnabled, + seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), + experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + schemaPaths: toml.schemaPaths, + localDatabaseWebhooksEnabled: toml.webhooksEnabled, + }).pipe(Effect.mapError((cause) => resetFailed(cause.message))); + }), + ); + const after = yield* opened.value.stack.status().pipe( + Effect.mapError((cause) => resetFailed(`failed to inspect stack after reset: ${cause.message}`)), + ); + const storage = after.capabilities.find((capability) => capability.name === "storage"); + if (storage?.state === "ready") { + const context = yield* loadLocalProjectContext(workdir, (message) => resetFailed(message)); + yield* seedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + yes, + resolvedConfig: { config: context.config, document: context.loaded?.document }, + projectEnvValues: projectEnv, + }).pipe( + Effect.catchTag("SeedConfigLoadError", (error) => + output.raw( + `${yellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, + "stderr", + ), + ), + ); + } + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`, + "stderr", + ); + return; + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* NetworkIdFlag; + // Threaded into `buildLocalDbContainerInputs`'s `setup.debug`, so a failed fresh-volume + // Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own stderr. + const debug = yield* DebugFlag; + // Error if the local db container is down. const running = yield* isLocalDbRunning( spawner, @@ -119,11 +179,7 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( Option.getOrUndefined(cliSettings.projectId), ); if (!running) { - return yield* Effect.fail( - new ResetLocalDbNotRunningError({ - message: `${aqua("supabase start")} is not running.`, - }), - ); + return yield* Effect.fail(notRunning()); } // "Resetting local database…" then recreate + migrate + seed. yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 518b971881..27fc8f0a6f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -415,10 +415,10 @@ describe("shadow baseline tar retention", () => { it("never evicts retainFileName when using a custom published-tar matcher", () => { const current = "stack-shadow-baseline-dddddddddddddddd.tar"; - const aged = now - LEGACY_SHADOW_BASELINE_MAX_AGE_MS - 1; + const aged = now - SHADOW_BASELINE_MAX_AGE_MS - 1; const isStack = (fileName: string) => /^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u.test(fileName); - const evicted = legacyShadowBaselineTarsToEvict( + const evicted = shadowBaselineTarsToEvict( [ { fileName: current, mtimeMs: aged }, { fileName: "stack-shadow-baseline-aaaaaaaaaaaaaaaa.tar", mtimeMs: now - 1_000 }, diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 456f55355f..9a973c4bee 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -218,6 +218,7 @@ describe("dbConfigResolver (local + db-url)", () => { start: unused, stop: unused, destroy: unused, + resetDatabase: unused, logs: unused, followLogs: () => Stream.empty, }; diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index f8fbf9f5de..5613d530eb 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -5,6 +5,8 @@ import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { getRegistryImageUrl } from "./docker-registry.ts"; import { DockerRun } from "./docker-run.service.ts"; +import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { requireHostPostgresClient, streamHostCommand } from "./postgres-client.run.ts"; /** * Runs a pg_dump/pg_dumpall bash script in a one-shot container, streaming stdout @@ -31,6 +33,12 @@ export const streamPgDump = Effect.fnUntraced(function* (params: { * (or `{}`) by callers that haven't loaded a project env map. */ readonly projectEnvValues?: Readonly>; + /** + * Stack dumps always talk to published credentials. Ignore compose + * `SUPABASE_NETWORK_ID` so the tool container never joins `supabase_network_*`. + * An explicit `--network-id` still wins. + */ + readonly forceHostNetwork?: boolean; }) { const docker = yield* DockerRun; const runtimeInfo = yield* RuntimeInfo; @@ -40,10 +48,9 @@ export const streamPgDump = Effect.fnUntraced(function* (params: { // precedence order. The generated `supabase_network_*` fallback used elsewhere never // applies here, since this path always sets a NetworkMode. const networkId = Option.getOrUndefined(networkIdFlag); - const envNetworkId = viperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - params.projectEnvValues ?? {}, - ); + const envNetworkId = params.forceHostNetwork + ? "" + : viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", params.projectEnvValues ?? {}); const network = networkId !== undefined && networkId.length > 0 ? { _tag: "named" as const, name: networkId } @@ -66,3 +73,37 @@ export const streamPgDump = Effect.fnUntraced(function* (params: { { onStdout: params.onStdout, teeStderr: true }, ); }); + +export type PgDumpClient = + | { readonly kind: "container" } + | { + readonly kind: "host"; + readonly command: "pg_dump" | "pg_dumpall"; + readonly expectedMajor: number; + }; + +/** Container dump, or PATH `pg_dump`/`pg_dumpall` when the stack engine is native. */ +export const streamPgDumpWithClient = Effect.fnUntraced(function* (params: { + readonly image: string; + readonly script: string; + readonly env: Readonly>; + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; + readonly projectEnvValues?: Readonly>; + readonly client: PgDumpClient; +}) { + if (params.client.kind === "host") { + yield* requireHostPostgresClient(params.client.command, params.client.expectedMajor); + return yield* streamHostCommand({ + command: "bash", + args: ["-c", params.script, "--"], + env: params.env, + onStdout: params.onStdout, + teeStderr: true, + }); + } + const backend = yield* currentStackBackend; + return yield* streamPgDump({ + ...params, + forceHostNetwork: backend.kind === "stack", + }); +}); diff --git a/apps/cli/src/command-internal/postgres-client.run.ts b/apps/cli/src/command-internal/postgres-client.run.ts new file mode 100644 index 0000000000..d57e7d9e8b --- /dev/null +++ b/apps/cli/src/command-internal/postgres-client.run.ts @@ -0,0 +1,183 @@ +import { Data, Effect, Option, Result, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { ProcessControl } from "../shared/runtime/process-control.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; +import { collectText } from "./container-cli.ts"; +import type { PgConnInput } from "./db-connection.service.ts"; + +const POSTGRES_CLIENT_MAJOR = /\(PostgreSQL\)\s+(\d+)/; +const HOST_CLIENT_SUGGESTION = + "Install matching PostgreSQL client tools on PATH, or start the stack with --runtime docker."; + +export const parsePostgresClientMajor = (text: string): number | undefined => { + const match = POSTGRES_CLIENT_MAJOR.exec(text); + if (match?.[1] === undefined) return undefined; + const major = Number(match[1]); + return Number.isInteger(major) ? major : undefined; +}; + +export class HostPostgresClientError extends Data.TaggedError("HostPostgresClientError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +const missingClient = (command: string) => + new HostPostgresClientError({ + message: `${command} was not found on PATH.`, + suggestion: HOST_CLIENT_SUGGESTION, + }); + +const majorMismatch = (command: string, actual: number | undefined, expected: number) => + new HostPostgresClientError({ + message: + actual === undefined + ? `${command} did not report a PostgreSQL major version.` + : `${command} major version ${actual} does not match stack Postgres ${expected}.`, + suggestion: HOST_CLIENT_SUGGESTION, + }); + +const hostClientVersion = (command: string) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(command, ["--version"], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + handle.exitCode.pipe(Effect.map(Number)), + collectText(handle.stdout), + collectText(handle.stderr), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, output: `${stdout}\n${stderr}` }; + }), + ).pipe( + Effect.mapError(() => missingClient(command)), + Effect.flatMap((result) => + result.exitCode === 0 ? Effect.succeed(result.output) : Effect.fail(missingClient(command)), + ), + ); + +/** Require a PATH client whose `--version` major matches the stack Postgres. */ +export const requireHostPostgresClient = ( + command: string, + expectedMajor: number, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* hostClientVersion(command); + const major = parsePostgresClientMajor(output); + if (major !== expectedMajor) return yield* majorMismatch(command, major, expectedMajor); + }); + +/** + * `pg_prove --version` has no Postgres major. Require `pg_prove` on PATH and a + * matching `pg_dump` or `psql` major. + */ +export const requireHostPgProve = ( + expectedMajor: number, +): Effect.Effect => + Effect.gen(function* () { + yield* hostClientVersion("pg_prove"); + const dump = yield* hostClientVersion("pg_dump").pipe(Effect.result); + const psql = yield* hostClientVersion("psql").pipe(Effect.result); + const dumpMajor = Result.isSuccess(dump) ? parsePostgresClientMajor(dump.success) : undefined; + const psqlMajor = Result.isSuccess(psql) ? parsePostgresClientMajor(psql.success) : undefined; + if (dumpMajor !== undefined && dumpMajor !== expectedMajor) + return yield* majorMismatch("pg_dump", dumpMajor, expectedMajor); + if (psqlMajor !== undefined && psqlMajor !== expectedMajor) + return yield* majorMismatch("psql", psqlMajor, expectedMajor); + const major = psqlMajor ?? dumpMajor; + if (major !== expectedMajor) return yield* majorMismatch("psql", major, expectedMajor); + }); + +const concatChunks = (chunks: ReadonlyArray): Uint8Array => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +}; + +/** Stream a host process stdout like `streamPgDump`, teeing stderr when requested. */ +export const streamHostCommand = Effect.fnUntraced(function* (params: { + readonly command: string; + readonly args: ReadonlyArray; + readonly env: Readonly>; + readonly cwd?: string; + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; + readonly teeStderr?: boolean; + readonly captureStderr?: boolean; +}) { + const spawner = yield* ChildProcessSpawner; + const processControl = yield* Effect.serviceOption(ProcessControl); + const teeStderr = params.teeStderr ?? false; + const captureStderr = params.captureStderr ?? true; + return yield* Effect.scoped( + Effect.gen(function* () { + if (Option.isSome(processControl)) { + yield* processControl.value.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); + } + const handle = yield* spawner + .spawn( + ChildProcess.make(params.command, [...params.args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + cwd: params.cwd, + env: params.env, + extendEnv: true, + }), + ) + .pipe(Effect.mapError(() => missingClient(params.command))); + const stderrChunks: Array = []; + yield* Effect.all( + [ + Stream.runForEach(handle.stdout, params.onStdout), + Stream.runForEach(handle.stderr, (chunk) => + Effect.sync(() => { + if (captureStderr) stderrChunks.push(chunk); + if (teeStderr) globalThis.process.stderr.write(chunk); + }), + ), + ], + { concurrency: "unbounded" }, + ); + const exitCode = yield* handle.exitCode.pipe(Effect.map(Number)); + return { exitCode, stderr: new TextDecoder().decode(concatChunks(stderrChunks)) }; + }), + ); +}); + +/** Native-engine dumps talk to loopback; container tools may need Docker Desktop's host alias. */ +export const rewriteDumpHostForToolContainer = ( + host: string, + opts: { readonly platform: string; readonly usesHostNetwork: boolean }, +): string => { + if (host !== "127.0.0.1" && host !== "localhost") return host; + if (opts.platform !== "linux" || !opts.usesHostNetwork) return "host.docker.internal"; + return host; +}; + +export const dumpConnForHostClient = (conn: PgConnInput): PgConnInput => ({ + ...conn, + host: "127.0.0.1", +}); diff --git a/apps/cli/src/command-internal/postgres-client.run.unit.test.ts b/apps/cli/src/command-internal/postgres-client.run.unit.test.ts new file mode 100644 index 0000000000..1ec864fb20 --- /dev/null +++ b/apps/cli/src/command-internal/postgres-client.run.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parsePostgresClientMajor } from "./postgres-client.run.ts"; + +describe("parsePostgresClientMajor", () => { + it("reads the PostgreSQL major from client --version output", () => { + expect(parsePostgresClientMajor("pg_dump (PostgreSQL) 17.4")).toBe(17); + expect(parsePostgresClientMajor("psql (PostgreSQL) 15.12")).toBe(15); + expect(parsePostgresClientMajor("pg_dumpall (PostgreSQL) 16.1")).toBe(16); + }); + + it("returns undefined when the version line has no PostgreSQL major", () => { + expect(parsePostgresClientMajor("pg_prove version 3.36")).toBeUndefined(); + expect(parsePostgresClientMajor("")).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/command-internal/stack-local-database.integration.test.ts b/apps/cli/src/command-internal/stack-local-database.integration.test.ts index 1d7db27b45..70d499822d 100644 --- a/apps/cli/src/command-internal/stack-local-database.integration.test.ts +++ b/apps/cli/src/command-internal/stack-local-database.integration.test.ts @@ -45,6 +45,7 @@ const stack: EffectStack = { start: unused, stop: unused, destroy: unused, + resetDatabase: unused, logs: unused, followLogs: () => Stream.empty, }; diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 9d9a1d827e..274e07b7e7 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -18,10 +18,16 @@ import { import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; import { loadStackConfig } from "../commands/experimental/stack/stack-config.ts"; +import { postgresOnlyStackStartConfig } from "../commands/experimental/stack/start/start.options.ts"; const notRunning = (message = "supabase start is not running.") => new LocalDbRunningError({ message }); +const startFailed = (cause: { readonly message: string }) => + new LocalDbRunningError({ + message: `failed to start local database: ${cause.message}`, + }); + const databaseReady = (stack: EffectStack) => Effect.gen(function* () { const status = yield* stack @@ -34,18 +40,22 @@ const databaseReady = (stack: EffectStack) => const openProjectStack = () => Effect.gen(function* () { - const api = yield* StackApi; + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return Option.none(); const cliSettings = yield* CommandSettings; - const descriptor = yield* api + const descriptor = yield* api.value .findStack({ projectRoot: cliSettings.workdir }) .pipe(Effect.mapError((cause) => notRunning(cause.message))); if (Option.isNone(descriptor)) return Option.none(); - const stack = yield* api + const stack = yield* api.value .openStack(descriptor.value.id) .pipe(Effect.mapError((cause) => notRunning(cause.message))); return yield* databaseReady(stack); }); +/** Ready project stack, or none when the stack is missing or the database is not ready. */ +export const stackOpenReadyProject = openProjectStack; + export const stackProjectRuntime: Effect.Effect< StackRuntime | undefined, never, @@ -63,6 +73,42 @@ export const stackProjectRuntime: Effect.Effect< }); }); +export class StackRuntimeUnavailableError extends Data.TaggedError("StackRuntimeUnavailableError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +const RUNTIME_UNAVAILABLE = new StackRuntimeUnavailableError({ + message: "Could not determine the stack runtime.", + suggestion: "Start the stack, or start with --runtime docker.", +}); + +/** Fail instead of treating an unknown engine as Docker. */ +export const stackRequireProjectRuntime: Effect.Effect< + StackRuntime, + StackRuntimeUnavailableError, + CommandSettings +> = Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return yield* RUNTIME_UNAVAILABLE; + const cliSettings = yield* CommandSettings; + const descriptor = yield* api.value.findStack({ projectRoot: cliSettings.workdir }).pipe( + Effect.mapError( + (cause) => + new StackRuntimeUnavailableError({ + message: cause.message, + suggestion: RUNTIME_UNAVAILABLE.suggestion, + }), + ), + ); + if (Option.isNone(descriptor)) return yield* RUNTIME_UNAVAILABLE; + return descriptor.value.runtime; +}); + const STACK_NATIVE_ENGINE_MESSAGE = "The stack backend only supports the pg-delta engine. Do not pass --use-migra, --use-pgadmin, --use-pg-schema, or --diff-engine migra."; @@ -84,7 +130,7 @@ export const stackRejectNativeDockerDiffEngine: Effect.Effect = Effect.gen(function* () { const opened = yield* openProjectStack(); if (Option.isNone(opened)) return yield* notRunning(); @@ -97,7 +143,7 @@ export const stackLocalDatabaseUrl: Effect.Effect< export const stackLocalDatabaseConn: Effect.Effect< PgConnInput, LocalDbRunningError, - CommandSettings | StackApi + CommandSettings > = Effect.gen(function* () { const url = yield* stackLocalDatabaseUrl; const conn = parseConnectionString(url); @@ -110,7 +156,7 @@ export const stackLocalDatabaseConn: Effect.Effect< const stackLocalDatabaseIsRunning: Effect.Effect< boolean, LocalDbRunningError, - CommandSettings | StackApi + CommandSettings > = openProjectStack().pipe(Effect.map(Option.isSome)); export const resolveLocalDatabaseIsRunning = ( @@ -132,59 +178,55 @@ export const resolveLocalDatabaseIsRunning = ( export const stackEnsureLocalDatabaseStarted: Effect.Effect< void, LocalDbRunningError, - CommandSettings | FileSystem.FileSystem | Path.Path | StackApi + CommandSettings | FileSystem.FileSystem | Path.Path > = Effect.gen(function* () { - const api = yield* StackApi; + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return yield* startFailed({ message: "stack API is unavailable" }); const cliSettings = yield* CommandSettings; - const existing = yield* api.findStack({ projectRoot: cliSettings.workdir }).pipe( - Effect.mapError( - (cause) => - new LocalDbRunningError({ - message: `failed to start local database: ${cause.message}`, - }), - ), - ); - const config = yield* loadStackConfig(cliSettings.workdir).pipe( - Effect.mapError( - (cause) => - new LocalDbRunningError({ - message: `failed to start local database: ${cause.message}`, - }), - ), - ); + const existing = yield* api.value + .findStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError(startFailed)); + const config = yield* loadStackConfig(cliSettings.workdir).pipe(Effect.mapError(startFailed)); const stack = Option.isSome(existing) - ? yield* api.openStack(existing.value.id).pipe( - Effect.mapError( - (cause) => - new LocalDbRunningError({ - message: `failed to start local database: ${cause.message}`, - }), - ), - ) - : yield* api.createStack({ projectRoot: cliSettings.workdir }).pipe( - Effect.mapError( - (cause) => - new LocalDbRunningError({ - message: `failed to start local database: ${cause.message}`, - }), - ), - ); - const status = yield* stack.status().pipe( - Effect.mapError( - (cause) => - new LocalDbRunningError({ - message: `failed to start local database: ${cause.message}`, - }), - ), - ); + ? yield* api.value.openStack(existing.value.id).pipe(Effect.mapError(startFailed)) + : yield* api.value + .createStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError(startFailed)); + const status = yield* stack.status().pipe(Effect.mapError(startFailed)); const database = status.capabilities.find((capability) => capability.name === "database"); if (status.lifecycle === "running" && database?.state === "ready") return; - yield* stack.start({ config }).pipe( - Effect.mapError( - (cause) => - new LocalDbRunningError({ - message: `failed to start local database: ${cause.message}`, - }), - ), - ); + yield* stack.start({ config }).pipe(Effect.mapError(startFailed)); +}); + +/** + * Start a postgres-only stack for `db start`. Fresh stacks persist the overlay; an existing + * full project stack is started without rewriting `--exclude`. + */ +export const stackEnsurePostgresOnlyStarted: Effect.Effect< + "already-running" | "started", + LocalDbRunningError, + CommandSettings | FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return yield* startFailed({ message: "stack API is unavailable" }); + const cliSettings = yield* CommandSettings; + const existing = yield* api.value + .findStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError(startFailed)); + if (Option.isNone(existing)) { + const config = yield* loadStackConfig(cliSettings.workdir).pipe(Effect.mapError(startFailed)); + const stack = yield* api.value + .createStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError(startFailed)); + yield* stack + .start({ config: postgresOnlyStackStartConfig(config) }) + .pipe(Effect.mapError(startFailed)); + return "started"; + } + const stack = yield* api.value.openStack(existing.value.id).pipe(Effect.mapError(startFailed)); + const status = yield* stack.status().pipe(Effect.mapError(startFailed)); + const database = status.capabilities.find((capability) => capability.name === "database"); + if (status.lifecycle === "running" && database?.state === "ready") return "already-running"; + yield* stack.start().pipe(Effect.mapError(startFailed)); + return "started"; }); diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index 3e3741b525..96ded25039 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -283,4 +283,78 @@ describe("stackAcquireShadowDatabase", () => { ), ); }); + + it.live("warns and cold-provisions when a cached baseline restore fails", () => { + const restores: Array = []; + const out = mockOutput(); + const layer = Layer.succeed(StackEphemeralPostgres, { + create: (options) => { + restores.push(options.restoreFrom); + if (options.restoreFrom !== undefined) + return Effect.fail( + new EphemeralPostgresError({ + message: "restore failed", + reason: "restore-mismatch", + }), + ); + return Effect.succeed({ + host: "127.0.0.1", + port: 59999, + version: "17.6.1", + runtime: { kind: "native" as const }, + artifactIdentity: "native:17.6.1", + url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), + start: () => Effect.void, + stop: () => Effect.void, + exportPgData: () => Effect.void, + }); + }, + resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), + }); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const cacheDir = path.join(home, "cache", "shadow-baseline"); + yield* fs.makeDirectory(cacheDir, { recursive: true }); + const tarName = stackShadowBaselineTarFileName( + stackShadowCacheKey({ + artifactIdentity: "native:17.6.1", + majorVersion: 17, + runtimeKind: "native", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + dbPassword: "postgres", + dbSettings: {}, + rolesSql: "", + }), + ); + yield* fs.writeFileString(path.join(cacheDir, tarName), "corrupt"); + return yield* withShadowCacheHome( + home, + "1", + Effect.gen(function* () { + const handle = yield* stackAcquireShadowDatabase(input(fs, path)); + expect(handle.baselinePresent).toBe(false); + expect(restores).toHaveLength(2); + expect(restores[0]?.endsWith(tarName)).toBe(true); + expect(restores[1]).toBeUndefined(); + expect(out.stderrText).toContain("Warning: shadow baseline not cached: restore failed"); + }), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + db, + mockCommandSettings({ workdir: tmp.current }), + stackBackendLayer("stack"), + layer, + ), + ), + ); + }); }); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index cc69d54e7e..a4dd4a4684 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -6,7 +6,6 @@ import { Context, Crypto, Effect, - Exit, FileSystem, Layer, Option, @@ -229,7 +228,7 @@ const applyRoles = ( [input.path.join(input.workdir, "supabase", "roles.sql")], (message) => new ShadowDbError({ message, reason: "database" }), ).pipe( - Effect.catchTag("DbConnectError"), (cause) => + Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), ); @@ -452,22 +451,27 @@ export const stackAcquireShadowDatabase = ( yield* sweepCache(fs, path, cacheDir, tarName); if (cached) { - const restored = yield* apis - .create(createOptions(input, runtime, tarPath, opts.port)) - .pipe(Effect.exit); - if (Exit.isSuccess(restored)) { + const restored = yield* Effect.result( + apis.create(createOptions(input, runtime, tarPath, opts.port)), + ); + if (Result.isSuccess(restored)) { yield* touchShadowBaselineTar(fs, tarPath); return { - url: Redacted.value(restored.value.url), - host: restored.value.host, - port: restored.value.port, - artifactIdentity: restored.value.artifactIdentity, - runtime: restored.value.runtime, + url: Redacted.value(restored.success.url), + host: restored.success.host, + port: restored.success.port, + artifactIdentity: restored.success.artifactIdentity, + runtime: restored.success.runtime, baselinePresent: true, snapshotKey: key, - ephemeral: restored.value, + ephemeral: restored.success, }; } + const output = yield* Output; + yield* output.raw( + `Warning: shadow baseline not cached: ${restored.failure.message}\n`, + "stderr", + ); } const probe = yield* startEmpty(); @@ -579,7 +583,7 @@ export const stackMigrateShadow = ( pending, (message) => new ShadowDbError({ message, reason: "database" }), ).pipe( - Effect.catchTag("DbConnectError"), (cause) => + Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), ); diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index e1c2ac8e22..e2f1b7c28d 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -21,6 +21,13 @@ import { TestDbRunError, } from "./test-db.errors.ts"; import { buildPgProveArgs } from "./test-db.pg-prove-args.ts"; +import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { stackRequireProjectRuntime } from "./stack-local-database.ts"; +import { + rewriteDumpHostForToolContainer, + requireHostPgProve, + streamHostCommand, +} from "./postgres-client.run.ts"; const ENABLE_PGTAP = "create extension if not exists pgtap with schema extensions"; const DISABLE_PGTAP = "drop extension if exists pgtap"; @@ -101,25 +108,37 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { debug, }); - // For a local database the pg_prove container joins the supabase docker - // network and reaches postgres via the internal `db:5432` alias; otherwise - // it uses host networking. + const backend = yield* currentStackBackend; + const stackRuntime = + backend.kind === "stack" && isLocal ? yield* stackRequireProjectRuntime : undefined; + const useHostProve = stackRuntime?.kind === "native"; + const stackContainerProve = backend.kind === "stack" && isLocal && !useHostProve; + + const networkId = Option.getOrUndefined(networkIdFlag); + const dumpUsesHostNetwork = networkId === undefined || networkId.length === 0; const runEnv = { - PGHOST: isLocal ? "db" : conn.host, - PGPORT: isLocal ? "5432" : String(conn.port), + PGHOST: useHostProve + ? "127.0.0.1" + : stackContainerProve + ? rewriteDumpHostForToolContainer(conn.host, { + platform: runtimeInfo.platform, + usesHostNetwork: dumpUsesHostNetwork, + }) + : isLocal + ? "db" + : conn.host, + PGPORT: isLocal && backend.kind !== "stack" ? "5432" : String(conn.port), PGUSER: conn.user, PGPASSWORD: conn.password, PGDATABASE: conn.database, }; // A non-empty `--network-id` overrides everything (even host mode); - // otherwise local uses the generated `supabase_network_` - // network and remote uses host networking. - const networkId = Option.getOrUndefined(networkIdFlag); + // otherwise local Compose uses `supabase_network_` and remote / stack uses host networking. const network = networkId !== undefined && networkId.length > 0 ? { _tag: "named" as const, name: networkId } - : isLocal + : isLocal && backend.kind !== "stack" ? yield* Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir); // The project id is sanitized unconditionally before deriving the @@ -179,9 +198,40 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { // Docker Desktop provide the mapping natively. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - // Stream (rather than inherit) stdout so the verdict can be read on the - // way past; every chunk is forwarded byte-exact and unframed. stderr is - // teed live, as inheriting it did. + const onStdout = (chunk: Uint8Array) => + Effect.suspend(() => { + // Split on newlines, carrying the incomplete trailing line into the + // next chunk so a verdict straddling a chunk boundary is still seen. + const lines = (pendingLine + decoder.decode(chunk, { stream: true })).split("\n"); + pendingLine = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith(VERDICT_PREFIX)) lastVerdict = line; + else if (FILES_SUMMARY.test(line)) lastSummary = line; + } + return output.rawBytes(chunk, "stdout"); + }); + if (useHostProve) { + const toml = yield* readDbToml(fs, path, cliSettings.workdir); + yield* requireHostPgProve(toml.majorVersion); + const hostPath = args.hostPaths[0]; + const hostWorkingDir = + hostPath === undefined + ? undefined + : nodePath.extname(hostPath) !== "" + ? nodePath.dirname(hostPath) + : hostPath; + const hostArgs = ["--ext", ".pg", "--ext", ".sql", "-r", ...args.hostPaths]; + if (debug) hostArgs.push("--verbose"); + return yield* streamHostCommand({ + command: "pg_prove", + args: hostArgs, + env: runEnv, + cwd: hostWorkingDir, + onStdout, + teeStderr: true, + captureStderr: false, + }); + } return yield* docker.runStream( { image: getRegistryImageUrl(PG_PROVE_IMAGE), @@ -194,20 +244,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { network, }, { - onStdout: (chunk) => - Effect.suspend(() => { - // Split on newlines, carrying the incomplete trailing line into the - // next chunk so a verdict straddling a chunk boundary is still seen. - const lines = (pendingLine + decoder.decode(chunk, { stream: true })).split("\n"); - pendingLine = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith(VERDICT_PREFIX)) lastVerdict = line; - else if (FILES_SUMMARY.test(line)) lastSummary = line; - } - return output.rawBytes(chunk, "stdout"); - }), - // Teed straight to the terminal as inheriting it did; nothing here reads - // the buffered copy, and a pgTAP suite's psql notices are unbounded. + onStdout, teeStderr: true, captureStderr: false, }, diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 52d9b90447..c885c7bd1d 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -8,6 +8,7 @@ import { identityStitchLayer } from "./identity-stitch.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { telemetryStateLayer } from "../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; +import { stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; /** * Runtime layer shared by `supabase test db` and its hidden alias `supabase @@ -45,5 +46,7 @@ export const testDbRuntimeLayer = (commandPath: ReadonlyArray) => // above, so the lazy linked stack shares a single stitch attempt. identityStitchLayer, telemetryStateLayer, + // Exposed so native-engine prove can read `runtime.kind` and pick PATH pg_prove. + stackApiLayer, commandRuntimeLayer(commandPath), ); diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index f0ef718103..d03061008b 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -17,7 +17,7 @@ import { isIPv6ConnectivityError, } from "../../../command-internal/connect-errors.ts"; import { bold, yellow } from "../../../command-internal/colors.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, NetworkIdFlag } from "../../../command-internal/global-flags.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; import { cobraMutuallyExclusiveErrorMessage } from "../../../shared/cli/cobra-flag-groups.ts"; @@ -35,7 +35,14 @@ import { buildSchemaDumpEnv, expandScript, } from "../../../command-internal/pg-dump.env.ts"; -import { streamPgDump } from "../../../command-internal/pg-dump.run.ts"; +import { streamPgDumpWithClient } from "../../../command-internal/pg-dump.run.ts"; +import { + dumpConnForHostClient, + rewriteDumpHostForToolContainer, +} from "../../../command-internal/postgres-client.run.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { stackRequireProjectRuntime } from "../../../command-internal/stack-local-database.ts"; +import { viperEnvStringWithProjectFallback } from "../../../command-internal/viper-env.ts"; import { runWithPoolerFallback } from "../shared/pooler-fallback.ts"; import { dumpDataScript, @@ -71,6 +78,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; + const networkIdFlag = yield* NetworkIdFlag; const tty = yield* Tty; const runtimeInfo = yield* RuntimeInfo; @@ -183,6 +191,35 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // silently printing a script. const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); + const backend = yield* currentStackBackend; + const stackRuntime = + backend.kind === "stack" && isLocal ? yield* stackRequireProjectRuntime : undefined; + const useHostClient = stackRuntime?.kind === "native"; + const networkId = Option.getOrUndefined(networkIdFlag); + const envNetworkId = viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnv); + const dumpUsesHostNetwork = + backend.kind === "stack" + ? networkId === undefined || networkId.length === 0 + : (networkId === undefined || networkId.length === 0) && envNetworkId.length === 0; + const dumpConn = useHostClient + ? dumpConnForHostClient(conn) + : backend.kind === "stack" && isLocal + ? { + ...conn, + host: rewriteDumpHostForToolContainer(conn.host, { + platform: runtimeInfo.platform, + usesHostNetwork: dumpUsesHostNetwork, + }), + } + : conn; + const dumpClient = useHostClient + ? { + kind: "host" as const, + command: roleOnly ? ("pg_dumpall" as const) : ("pg_dump" as const), + expectedMajor: tomlValues.majorVersion, + } + : { kind: "container" as const }; + // 4. Pick the mode-specific script + env. --schema/-s and --exclude/-x arrive here // already CSV-parsed by `parseSchemaFlags`. const opt = { @@ -206,7 +243,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { script: dumpSchemaScript, buildEnv: buildSchemaDumpEnv, } as const); - const modeEnv = mode.buildEnv(conn, opt); + const modeEnv = mode.buildEnv(dumpConn, opt); // Keys off `path.length > 0`, not flag presence: `--file ""` means stdout, no // file opened. @@ -278,13 +315,14 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const file = yield* fs .open(resolvedFile.value, { flag: "a" }) .pipe(Effect.mapError(toOpenFileError)); - return yield* streamPgDump({ + return yield* streamPgDumpWithClient({ image, script: mode.script, env, onStdout: (chunk) => file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), projectEnvValues: projectEnv, + client: dumpClient, }); }), ), @@ -293,7 +331,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { : // stdout: write each chunk straight to stdout (binary-safe, no decode). // On a pooler retry the partial first-attempt bytes are left on // stdout (a pipe can't be rewound); streaming matches that. - streamPgDump({ + streamPgDumpWithClient({ image, script: mode.script, env, @@ -307,6 +345,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { }) : (chunk) => output.rawBytes(chunk), projectEnvValues: projectEnv, + client: dumpClient, }); // 7b. IPv6 → IPv4-pooler retry, shared with `db pull`: a linked dump can reach the diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 85cbd4ed99..17760e6d5a 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -4,7 +4,8 @@ import process from "node:process"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { @@ -34,6 +35,9 @@ import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; import type { DbDumpFlags } from "./dump.command.ts"; import { dbDump } from "./dump.handler.ts"; +import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; +import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { StackIdSchema, type EffectStack } from "@supabase/stack/effect"; const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -972,4 +976,144 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); } + + const DUMP_STACK_ID = StackIdSchema.make("d".repeat(64)); + const unusedDump = () => Effect.die("unused"); + const dumpStackApi = (runtime: { kind: "native" } | { kind: "container"; engine: "docker" }) => { + const stack: EffectStack = { + id: DUMP_STACK_ID, + status: unusedDump, + credentials: unusedDump, + prepare: unusedDump, + start: unusedDump, + stop: unusedDump, + destroy: unusedDump, + resetDatabase: unusedDump, + logs: unusedDump, + followLogs: () => Stream.empty, + }; + return Layer.succeed(StackApi, { + createStack: unusedDump, + findStack: () => + Effect.succeed( + Option.some({ + id: DUMP_STACK_ID, + projectRoot: "/work/project", + name: "default", + branchContext: "main", + runtime, + desiredLifecycle: "running", + }), + ), + discoverStacks: unusedDump, + openStack: () => Effect.succeed(stack), + inspectStack: unusedDump, + }); + }; + + it.live("dump --local on the stack backend fails instead of using Docker when no stack exists", () => { + const { layer, docker } = setup({ isLocal: true, stdout: "-- schema\n" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbDump(flags({ local: Option.some(true) }))); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toContain("Could not determine the stack runtime"); + expect(docker.lastOpts).toBeUndefined(); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + stackBackendLayer("stack"), + Layer.succeed(StackApi, { + createStack: unusedDump, + findStack: () => Effect.succeed(Option.none()), + discoverStacks: unusedDump, + openStack: unusedDump, + inspectStack: unusedDump, + }), + ), + ), + ); + }); + + it.live("dump --local on a docker stack never uses PGHOST=db", () => { + const { layer, docker } = setup({ + isLocal: true, + stdout: "-- schema\n", + platform: "darwin", + }); + return Effect.gen(function* () { + yield* dbDump(flags({ local: Option.some(true) })); + expect(docker.lastOpts?.env["PGHOST"]).toBe("host.docker.internal"); + expect(docker.lastOpts?.env["PGHOST"]).not.toBe("db"); + }).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), dumpStackApi({ kind: "container", engine: "docker" }))), + ); + }); + + it.live("dump --local on a docker stack ignores compose SUPABASE_NETWORK_ID", () => { + const { layer, docker } = setup({ + isLocal: true, + stdout: "-- schema\n", + platform: "darwin", + env: { SUPABASE_NETWORK_ID: "supabase_network_test" }, + }); + return Effect.gen(function* () { + yield* dbDump(flags({ local: Option.some(true) })); + expect(docker.lastOpts?.network).toEqual({ _tag: "host" }); + expect(docker.lastOpts?.env["PGHOST"]).toBe("host.docker.internal"); + }).pipe( + Effect.provide( + Layer.mergeAll(layer, stackBackendLayer("stack"), dumpStackApi({ kind: "container", engine: "docker" })), + ), + ); + }); + + it.live("dump --local on a native stack uses PATH pg_dump, not a tool container", () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n[db]\nmajor_version = 17\n', + ); + const spawned: Array = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const name = command._tag === "StandardCommand" ? command.command : ""; + spawned.push(name); + const stdoutText = name === "pg_dump" ? "pg_dump (PostgreSQL) 17.4\n" : "-- schema\n"; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable([new TextEncoder().encode(stdoutText)]), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const { layer, docker, out } = setup({ + isLocal: true, + workdir: tmp.current, + }); + return Effect.gen(function* () { + yield* dbDump(flags({ local: Option.some(true) })); + expect(docker.lastOpts).toBeUndefined(); + expect(spawned).toContain("pg_dump"); + expect(spawned).toContain("bash"); + expect(out.stdoutText).toContain("-- schema"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + stackBackendLayer("stack"), + dumpStackApi({ kind: "native" }), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ); + }); }); diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index d97f74d197..2414cc2a3b 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -13,6 +13,7 @@ import { identityStitchLayer } from "../../../command-internal/identity-stitch.t import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { stackApiLayer } from "../../experimental/stack/stack.shared.ts"; /** * Runtime layer for `supabase db dump`. @@ -74,5 +75,7 @@ export const dbDumpRuntimeLayer = Layer.mergeAll( linkedProjectCache, identityStitchLayer, telemetryStateLayer, + // Exposed so native-engine dump can read `runtime.kind` and pick PATH pg_dump. + stackApiLayer, commandRuntimeLayer(["db", "dump"]), ); diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index f20add9ac7..9b53b41c18 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -13,11 +13,7 @@ import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua, yellow } from "../../../command-internal/colors.ts"; import { resolveResetSeedConfig } from "../../../command-internal/db-bootstrap/db-setup.ts"; -import { - resetLocalDatabase, - stackLocalResetUnsupportedError, -} from "../../../command-internal/db-bootstrap/reset-local-database.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { resetLocalDatabase } from "../../../command-internal/db-bootstrap/reset-local-database.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { applyProjectEnv, @@ -191,13 +187,6 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const connType = target.connType ?? "local"; - if (connType === "local") { - const backend = yield* currentStackBackend; - if (backend.kind === "stack") { - return yield* Effect.fail(stackLocalResetUnsupportedError()); - } - } - // `--project-ref` only applies to the linked target; it must not be silently ignored when // targeting `--local`/`--db-url`. if (Option.isSome(flags.projectRef) && connType !== "linked") { diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index e4a7a62961..86d70ba9a7 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream, Redacted } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -43,6 +43,8 @@ import { import type { OutputFormat } from "../../../shared/output/types.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; +import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; @@ -390,6 +392,90 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); +const RESET_STACK_ID = StackIdSchema.make("c".repeat(64)); + +function mockResetStackApi(opts: { readonly workdir: string; readonly ready: boolean }) { + let resetCalls = 0; + const unused = () => Effect.die("unused"); + const stack: EffectStack = { + id: RESET_STACK_ID, + status: () => + Effect.succeed({ + id: RESET_STACK_ID, + lifecycle: opts.ready ? "running" : "stopped", + desiredLifecycle: opts.ready ? "running" : "stopped", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? "eager" : "lazy", + state: name === "database" && opts.ready ? "ready" : "stopped", + })), + artifacts: [], + }), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:54329/postgres"), + password: Redacted.make("postgres"), + }, + api: { + publishableKey: "anon", + secretKey: Redacted.make("service"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: unused, + start: unused, + stop: unused, + destroy: unused, + resetDatabase: () => + Effect.sync(() => { + resetCalls++; + return { + id: RESET_STACK_ID, + lifecycle: "running" as const, + desiredLifecycle: "running" as const, + runtime: { kind: "native" as const }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? ("eager" as const) : ("lazy" as const), + state: name === "database" ? ("ready" as const) : ("dormant" as const), + })), + artifacts: [], + }; + }), + logs: unused, + followLogs: () => Stream.empty, + }; + return { + layer: Layer.succeed(StackApi, { + createStack: unused, + findStack: () => + Effect.succeed( + Option.some({ + id: RESET_STACK_ID, + projectRoot: opts.workdir, + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }), + ), + discoverStacks: unused, + openStack: () => Effect.succeed(stack), + inspectStack: unused, + }), + get resetCalls() { + return resetCalls; + }, + }; +} + function setup( workdir: string, opts: { @@ -419,6 +505,7 @@ function setup( // absent an explicit `--project-ref` flag, instead of falling back to `opts.ref`. linkedFails?: boolean; stackBackend?: boolean; + stackDatabaseReady?: boolean; }, ) { if (opts.toml !== undefined) { @@ -446,6 +533,10 @@ function setup( }); const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); const child = mockContainerCliSpawner(route); + const stackApi = mockResetStackApi({ + workdir, + ready: opts.stackDatabaseReady !== false, + }); const layer = Layer.mergeAll( out.layer, conn.layer, @@ -487,7 +578,7 @@ function setup( Layer.succeed(DebugFlag, opts.debug ?? false), telemetry.layer, linkedCache.layer, - ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), + ...(opts.stackBackend === true ? [stackBackendLayer("stack"), stackApi.layer] : []), ); return { layer, @@ -497,6 +588,7 @@ function setup( linkedCache, resolver, child, + stackApi, }; } @@ -669,28 +761,38 @@ describe("db reset", () => { }, ); - it.live("refuses --local reset when the stack backend is enabled, before any recreate", () => { - const { layer, child, resolver } = setup(tmp.current, { + it.live("resets the stack database without Compose volume recreate", () => { + const { layer, child, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], isLocal: true, stackBackend: true, }); return Effect.gen(function* () { - const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "db reset --local is not supported when the stack backend is enabled.", - ); - } - expect(resolver.calls).toBe(0); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(stackApi.resetCalls).toBe(1); expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( false, ); }); }); + it.live("fails --local reset when the stack database is not running", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackDatabaseReady: false, + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect(stackApi.resetCalls).toBe(0); + }); + }); + it.live( "fails a local reset before the destructive recreate on a malformed config.toml", () => { diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index 384615d627..cfc1875484 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -15,6 +15,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; +import { stackApiLayer } from "../../experimental/stack/stack.shared.ts"; /** * Runtime layer for `supabase db reset`: the Postgres connection, the db-config resolver, @@ -78,5 +79,7 @@ export const dbResetRuntimeLayer = Layer.mergeAll( dockerRunLayer, // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace). localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)), + // Exposed so `db reset --local` can open the project stack and call `resetDatabase`. + stackApiLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 253d54f813..19501b346e 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -10,6 +10,8 @@ import { Output } from "../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../shared/runtime/tty.service.ts"; import { CommandSettings } from "../../../../../config/command-settings.service.ts"; import { resetLocalDatabase } from "../../../../../command-internal/db-bootstrap/reset-local-database.ts"; +import { stackLocalDatabaseConn } from "../../../../../command-internal/stack-local-database.ts"; +import { currentStackBackend } from "../../../../experimental/stack/stack-backend.ts"; import { bold, red, yellow } from "../../../../../command-internal/colors.ts"; import { DbConnection } from "../../../../../command-internal/db-connection.service.ts"; import { getHostname } from "../../../../../command-internal/hostname.ts"; @@ -304,7 +306,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f ), ); } - const generated = const generated = yield* generateDeclarativeOutput( + const generated = yield* generateDeclarativeOutput( { ...run, declarativeDir: stagedDir }, yield* resolveLocalTargetEndpoint( { port: toml.port, password: toml.password }, @@ -576,8 +578,26 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // Step 8: apply the migration to the local database (native). yield* ensureLocalPostgresImageCurrent; + const backend = yield* currentStackBackend; + const applyTarget = + backend.kind === "stack" + ? yield* stackLocalDatabaseConn.pipe( + Effect.mapError( + (error) => new DeclarativeApplyError({ message: error.message, connect: true }), + ), + ) + : { + host: getHostname(), + port: toml.port, + password: toml.password, + }; const applyExit = yield* applyMigrationToLocal( - { port: toml.port, password: toml.password, dnsResolver }, + { + host: applyTarget.host, + port: applyTarget.port, + password: applyTarget.password, + dnsResolver, + }, migrationPaths, ).pipe(Effect.exit); @@ -687,7 +707,7 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( /** Connects once and applies the ordered migration files. */ const applyMigrationToLocal = ( - local: { port: number; password: string; dnsResolver: "native" | "https" }, + local: { host: string; port: number; password: string; dnsResolver: "native" | "https" }, migrationPaths: ReadonlyArray, ) => Effect.gen(function* () { @@ -697,9 +717,7 @@ const applyMigrationToLocal = ( const session = yield* dbConnection .connect( { - // Host resolution order: SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1, not - // a hardcoded loopback. - host: getHostname(), + host: local.host, port: local.port, user: "postgres", password: local.password, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index f8110f21c1..08d3ed5e32 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Stream, Redacted } from "effect"; import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; import { @@ -39,6 +39,9 @@ import { import { CommandPlatformApi } from "../../../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../../../auth/command-platform-api-factory.service.ts"; import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; +import { stackBackendLayer } from "../../../../experimental/stack/stack-backend.ts"; +import { StackApi } from "../../../../experimental/stack/stack.shared.ts"; +import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; import { type DbBatchStatement, @@ -79,6 +82,69 @@ interface SetupOpts { renderedFiles?: ReadonlyArray; removals?: PgDeltaRemovalSummary; planErrors?: ReadonlyArray; + stackBackend?: boolean; +} + +const SYNC_STACK_ID = StackIdSchema.make("e".repeat(64)); +const unusedSync = () => Effect.die("unused"); +const STACK_APPLY_PORT = 54329; + +function syncStackApi(workdir: string, port: number) { + const stack: EffectStack = { + id: SYNC_STACK_ID, + status: () => + Effect.succeed({ + id: SYNC_STACK_ID, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? "eager" : "lazy", + state: name === "database" ? "ready" : "dormant", + })), + artifacts: [], + }), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + password: Redacted.make("postgres"), + }, + api: { + publishableKey: "anon", + secretKey: Redacted.make("service"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: unusedSync, + start: unusedSync, + stop: unusedSync, + destroy: unusedSync, + resetDatabase: unusedSync, + logs: unusedSync, + followLogs: () => Stream.empty, + }; + return Layer.succeed(StackApi, { + createStack: unusedSync, + findStack: () => + Effect.succeed( + Option.some({ + id: SYNC_STACK_ID, + projectRoot: workdir, + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running", + }), + ), + discoverStacks: unusedSync, + openStack: () => Effect.succeed(stack), + inspectStack: unusedSync, + }); } function setup(workdir: string, opts: SetupOpts = {}) { @@ -119,9 +185,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { // shadow also connects through this fake `DbConnection`, so its SQL must be excluded from // `dbExec`, which every "not yet applied" assertion expects to stay empty until real apply. const SHADOW_PORT = 54320; + const dbConnectPorts: number[] = []; const dbConn = Layer.succeed(DbConnection, { - connect: (cfg: PgConnInput) => - Effect.succeed({ + connect: (cfg: PgConnInput) => { + if (cfg.port !== SHADOW_PORT) dbConnectPorts.push(cfg.port); + return Effect.succeed({ exec: (sql: string) => opts.applyFails === true && sql.startsWith("ALTER") ? Effect.fail({ _tag: "DbExecError", message: "boom" } as never) @@ -155,7 +223,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), + }); + }, }); // The no-files bootstrap delegates to the shared smart-target resolver; its // local path never calls `resolve`, but the linked/custom branches would. @@ -265,6 +334,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { processControl.layer, alwaysReadyHttpClientLayer, dockerRun, + ...(opts.stackBackend === true + ? [stackBackendLayer("stack"), syncStackApi(workdir, STACK_APPLY_PORT)] + : []), ); return { layer, @@ -272,6 +344,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { child, dbExec, dbBatches, + dbConnectPorts, cache, telemetry, localPostgresImageChecks, @@ -827,6 +900,20 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("--apply on the stack backend uses stack credentials, not toml.port", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + stackBackend: true, + }); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); + expect(s.dbConnectPorts).toContain(STACK_APPLY_PORT); + expect(s.dbConnectPorts).not.toContain(54322); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("refuses a known implicit-extension load failure under --yes", () => { seedUuidDeclarative(tmp.current); const s = setup(tmp.current, { diff --git a/apps/cli/src/commands/db/start/start.errors.ts b/apps/cli/src/commands/db/start/start.errors.ts new file mode 100644 index 0000000000..1410f17c24 --- /dev/null +++ b/apps/cli/src/commands/db/start/start.errors.ts @@ -0,0 +1,18 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** `--from-backup` restore is Compose-only; stack `db start` has no restore path. */ +export class DbStartFromBackupUnsupportedError extends Data.TaggedError( + "DbStartFromBackupUnsupportedError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/db/start/start.handler.ts b/apps/cli/src/commands/db/start/start.handler.ts index 3e6f9b9b35..39d08aab28 100644 --- a/apps/cli/src/commands/db/start/start.handler.ts +++ b/apps/cli/src/commands/db/start/start.handler.ts @@ -3,6 +3,9 @@ import { Effect, Option } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; +import { stackEnsurePostgresOnlyStarted } from "../../../command-internal/stack-local-database.ts"; +import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { DbStartFromBackupUnsupportedError } from "./start.errors.ts"; import type { DbStartFlags } from "./start.command.ts"; /** @@ -11,13 +14,45 @@ import type { DbStartFlags } from "./start.command.ts"; * `ensureLocalDatabaseStarted`. This handler only adds the output-format-aware terminal * message and telemetry flush. Unlike `supabase start`, it has no status table, no * `cli_stack_started` event, and no `--exclude`/`--ignore-health-check` flags. + * + * When `[experimental].stack` is on, this command starts a postgres-only project stack + * instead of Compose. `--from-backup` is refused on the stack path. */ export const dbStart = Effect.fn("db.start")(function* (flags: DbStartFlags) { const output = yield* Output; const telemetryState = yield* TelemetryState; const body = Effect.gen(function* () { - const result = yield* startLocalDatabase(Option.getOrUndefined(flags.fromBackup)); + const backend = yield* currentStackBackend; + const fromBackup = Option.getOrUndefined(flags.fromBackup); + if (backend.kind === "stack") { + if (fromBackup !== undefined && fromBackup.length > 0) { + return yield* Effect.fail( + new DbStartFromBackupUnsupportedError({ + message: "db start --from-backup is not supported when the stack backend is enabled.", + suggestion: + "Omit --from-backup, or disable [experimental].stack to restore a Compose backup.", + }), + ); + } + const result = yield* stackEnsurePostgresOnlyStarted; + if (result === "already-running") { + if (output.format === "text") { + yield* output.raw("Postgres database is already running.\n", "stderr"); + } else { + yield* output.success("Postgres database is already running.", { + status: "already-running", + }); + } + return; + } + if (output.format !== "text") { + yield* output.success("Started local database.", { status: "started" }); + } + return; + } + + const result = yield* startLocalDatabase(fromBackup); if (result.status === "already-running") { if (output.format === "text") { diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 5261bf8e5c..2e40c11528 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream, Redacted } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -34,6 +34,9 @@ import { DbConnection, type DbSession } from "../../../command-internal/db-conne import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { dbStart } from "./start.handler.ts"; import type { DbStartFlags } from "./start.command.ts"; +import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; +import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; const DEFAULT_FLAGS: DbStartFlags = { fromBackup: Option.none() }; const PG_NET_CREATE_FINGERPRINT = "create extension if not exists pg_net schema extensions"; @@ -1518,3 +1521,147 @@ describe("db start", () => { }); }); }); + +describe("db start stack backend", () => { + const STACK_ID = StackIdSchema.make("b".repeat(64)); + const unused = () => Effect.die("unused"); + + function mockStackApi(opts: { + readonly existing?: boolean; + readonly databaseReady?: boolean; + }) { + const startConfigs: Array = []; + const stack: EffectStack = { + id: STACK_ID, + status: () => + Effect.succeed({ + id: STACK_ID, + lifecycle: opts.databaseReady === true ? "running" : "stopped", + desiredLifecycle: opts.databaseReady === true ? "running" : "stopped", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? "eager" : "lazy", + state: name === "database" && opts.databaseReady === true ? "ready" : "stopped", + })), + artifacts: [], + }), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make("postgresql://postgres:secret@127.0.0.1:54329/postgres"), + password: Redacted.make("secret"), + }, + api: { + publishableKey: "anon", + secretKey: Redacted.make("service"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: unused, + start: (startOpts) => + Effect.sync(() => { + startConfigs.push(startOpts?.config); + return { + id: STACK_ID, + lifecycle: "running" as const, + desiredLifecycle: "running" as const, + runtime: { kind: "native" as const }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "database" ? ("eager" as const) : ("lazy" as const), + state: name === "database" ? ("ready" as const) : ("dormant" as const), + })), + artifacts: [], + }; + }), + stop: unused, + destroy: unused, + resetDatabase: unused, + logs: unused, + followLogs: () => Stream.empty, + }; + const api = Layer.succeed(StackApi, { + createStack: () => Effect.succeed(stack), + findStack: () => + Effect.succeed( + opts.existing === true + ? Option.some({ + id: STACK_ID, + projectRoot: tempRoot.current, + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "stopped" as const, + }) + : Option.none(), + ), + discoverStacks: unused, + openStack: () => Effect.succeed(stack), + inspectStack: unused, + }); + return { api, startConfigs }; + } + + it.live("starts a postgres-only stack when none exists", () => { + const { layer } = setup(); + const stack = mockStackApi({}); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), + ); + expect(stack.startConfigs).toHaveLength(1); + expect(stack.startConfigs[0]).toMatchObject({ + capabilities: { + rest: { enabled: false }, + }, + }); + }); + }); + + it.live("does not persist exclusions when a stack already exists", () => { + const { layer } = setup(); + const stack = mockStackApi({ existing: true }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), + ); + expect(stack.startConfigs).toEqual([undefined]); + }); + }); + + it.live("reports an already-running stack database without starting", () => { + const { layer, out } = setup(); + const stack = mockStackApi({ existing: true, databaseReady: true }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), + ); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(stack.startConfigs).toEqual([]); + }); + }); + + it.live("refuses --from-backup", () => { + const { layer } = setup(); + const stack = mockStackApi({}); + return Effect.gen(function* () { + const exit = yield* dbStart(flags("backup.sql")).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "db start --from-backup is not supported when the stack backend is enabled.", + ); + } + expect(stack.startConfigs).toEqual([]); + }); + }); +}); diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index 7725681631..ddf2ebb597 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -8,6 +8,7 @@ import { dbConnectionLayer } from "../../../command-internal/db-connection.layer import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; +import { stackApiLayer } from "../../experimental/stack/stack.shared.ts"; /** * Runtime layer for `supabase db start`, matching `supabase start`'s own composition. @@ -28,4 +29,5 @@ export const dbStartRuntimeLayer = Layer.mergeAll( dockerRunLayer, dbConnectionLayer, httpClient, + stackApiLayer, ); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index aa5a124059..491238a30a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -68,6 +68,7 @@ function setup(options: { : options.destroyFailure ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) : Effect.sync(() => void state.destroyed++), + resetDatabase: () => Effect.die("unused"), logs: () => Effect.die("unused"), followLogs: () => Stream.empty, }; diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index c605198f87..a6596226b3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -101,6 +101,7 @@ function fakeStack( start, stop: () => Effect.void, destroy: () => Effect.die("destroy not used in start test"), + resetDatabase: () => Effect.die("resetDatabase not used in start test"), logs: () => Effect.die("logs not used in start test"), followLogs: () => Stream.empty, } satisfies EffectStack; diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts index 28c5e7e730..fc2a878794 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.options.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -1,6 +1,10 @@ -import { CAPABILITY_NAMES } from "@supabase/stack/effect"; +import { CAPABILITY_NAMES, excludeStackCapabilities, type StackConfig } from "@supabase/stack/effect"; /** Optional capabilities accepted by `stack start --exclude`. */ export const STACK_START_EXCLUDABLE_CAPABILITIES = CAPABILITY_NAMES.filter( (name) => name !== "database", ); + +/** Database-only overlay for `db start`. Do not persist this on an existing full stack. */ +export const postgresOnlyStackStartConfig = (config: StackConfig): StackConfig => + excludeStackCapabilities(config, STACK_START_EXCLUDABLE_CAPABILITIES); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 23b6866825..a32b371139 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -98,6 +98,7 @@ function setup(opts: { Effect.sync(() => { state.destroyCalled = true; }), + resetDatabase: () => Effect.die("unused"), logs: () => Effect.die("unused"), followLogs: () => Stream.empty, } satisfies EffectStack; diff --git a/apps/cli/src/commands/migration/squash/squash.dump.ts b/apps/cli/src/commands/migration/squash/squash.dump.ts index 8b7c897a15..351efdadd3 100644 --- a/apps/cli/src/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/commands/migration/squash/squash.dump.ts @@ -3,7 +3,7 @@ import { Effect } from "effect"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { buildSchemaDumpEnv, type DumpOptions } from "../../../command-internal/pg-dump.env.ts"; import { dumpSchemaScript } from "../../../command-internal/pg-dump.scripts.ts"; -import { streamPgDump } from "../../../command-internal/pg-dump.run.ts"; +import { streamPgDumpWithClient, type PgDumpClient } from "../../../command-internal/pg-dump.run.ts"; import { MigrationSquashDumpError } from "./squash.errors.ts"; /** @@ -25,6 +25,8 @@ export interface SquashDumpParams { readonly onStdout: (chunk: Uint8Array) => Effect.Effect; /** Loaded project `supabase/.env` map — forwarded to {@link streamPgDump}'s own `SUPABASE_NETWORK_ID` fallback. */ readonly projectEnvValues?: Readonly>; + /** Native-engine shadows dump with PATH `pg_dump`; container shadows keep the tool container. */ + readonly client?: PgDumpClient; } /** @@ -40,12 +42,13 @@ export const squashDumpSchema = Effect.fnUntraced(function* (params: SquashDu excludeTable: [], columnInsert: false, }; - const result = yield* streamPgDump({ + const result = yield* streamPgDumpWithClient({ image: params.image, script: dumpSchemaScript, env: buildSchemaDumpEnv(params.conn, opt), onStdout: params.onStdout, projectEnvValues: params.projectEnvValues, + client: params.client ?? { kind: "container" }, }); if (result.exitCode !== 0) { return yield* Effect.fail( @@ -80,6 +83,7 @@ export const squashDumpSchemaToString = Effect.fnUntraced(function* (params: { readonly conn: PgConnInput; readonly schema: ReadonlyArray; readonly projectEnvValues?: Readonly>; + readonly client?: PgDumpClient; }) { const chunks: Array = []; yield* squashDumpSchema({ @@ -88,6 +92,7 @@ export const squashDumpSchemaToString = Effect.fnUntraced(function* (params: { schema: params.schema, onStdout: (chunk) => Effect.sync(() => chunks.push(chunk)), projectEnvValues: params.projectEnvValues, + client: params.client, }); return new TextDecoder().decode(concatChunks(chunks)); }); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index d307da77b6..67a52d55c2 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -43,9 +43,12 @@ import { DbConnection, type PgConnInput } from "../../../command-internal/db-con import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { errorMessage, relativizeErrorMessage } from "../../../command-internal/error-message.ts"; -import { viperEnvStringWithProjectFallback } from "../../../command-internal/viper-env.ts"; import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; import { stackWithShadowDatabase } from "../../../command-internal/stack-shadow.ts"; +import { + dumpConnForHostClient, + rewriteDumpHostForToolContainer, +} from "../../../command-internal/postgres-client.run.ts"; import { applyMigrations, MigrationApplyError } from "../../../command-internal/migration-apply.ts"; import { INSERT_MIGRATION_VERSION, @@ -128,26 +131,31 @@ const squashMigrations = Effect.fnUntraced(function* ( const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* NetworkIdFlag; const networkId = Option.getOrUndefined(networkIdFlag); - const envNetworkId = viperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - localInputs.context.projectEnvValues ?? {}, - ); - const dumpUsesHostNetwork = - (networkId === undefined || networkId.length === 0) && envNetworkId.length === 0; - const dumpConn: PgConnInput = { - ...stackConn, - host: - (handle.host === "127.0.0.1" || handle.host === "localhost") && - (runtimeInfo.platform !== "linux" || !dumpUsesHostNetwork) - ? "host.docker.internal" - : handle.host, - }; + const dumpUsesHostNetwork = networkId === undefined || networkId.length === 0; + const nativeShadow = handle.runtime.kind === "native"; + const dumpClient = nativeShadow + ? { + kind: "host" as const, + command: "pg_dump" as const, + expectedMajor: toml.majorVersion, + } + : { kind: "container" as const }; + const dumpConn: PgConnInput = nativeShadow + ? dumpConnForHostClient(stackConn) + : { + ...stackConn, + host: rewriteDumpHostForToolContainer(handle.host, { + platform: runtimeInfo.platform, + usesHostNetwork: dumpUsesHostNetwork, + }), + }; const session = yield* connectShadowDatabase(stackConn); const before = yield* squashDumpSchemaToString({ image, conn: dumpConn, schema: ["auth", "storage"], projectEnvValues: localInputs.context.projectEnvValues, + client: dumpClient, }); yield* applyMigrations( session, @@ -161,6 +169,7 @@ const squashMigrations = Effect.fnUntraced(function* ( conn: dumpConn, schema: ["auth", "storage"], projectEnvValues: localInputs.context.projectEnvValues, + client: dumpClient, }); const targetPath = migrations[migrations.length - 1]!; const targetRel = path.relative(workdir, targetPath); @@ -179,6 +188,7 @@ const squashMigrations = Effect.fnUntraced(function* ( conn: dumpConn, schema: [], projectEnvValues: localInputs.context.projectEnvValues, + client: dumpClient, onStdout: (chunk) => file.writeAll(chunk).pipe( Effect.mapError( diff --git a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md index 9741065bd8..4d1c04b4de 100644 --- a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md +++ b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md @@ -69,13 +69,14 @@ leaving schema policy in the CLI. - Native and Docker/Podman shadows share one API and the same slim baseline as `stack start`. - Schema commands can target a running project stack through `credentials()` when the flag is on. +- `resetDatabase` wipes Postgres without destroying the stack identity, so `db reset --local` and declarative `--apply` stay on the stack backend. - Legacy Docker behavior is unchanged when the flag is off. ### Negative -- `db reset` / declarative `--apply`/`--reset` still need a later stack data-wipe API. - Cache tars cannot be shared across native and container runtimes. - Migra/pgAdmin remain unavailable on stack backends. +- Native-engine dump, test, and squash require matching PostgreSQL client tools on PATH. ## Alternatives Considered diff --git a/packages/stack/README.md b/packages/stack/README.md index 863aca2cf1..27240b159c 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -112,10 +112,11 @@ Each capability may opt into eager activation in `StackConfig`; omitted settings non-PostgreSQL capability lazy. Prepared artifacts are not automatically pruned. `followLogs(...)` provides filterable live entries through a stateless client-polled cursor. -Database reset is intentionally outside the current API. Applying migrations, declarative schemas, -and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the `_realtime` -schema owner, closed database role passwords, and JWT settings in one transaction; the slim database -artifact owns its initialization and migrations. +`resetDatabase()` wipes Postgres data only: identity, ports, secrets, logs, and storage volumes +stay. The database is started and bootstrapped before return. Applying migrations, declarative +schemas, and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the +`_realtime` schema owner, closed database role passwords, and JWT settings in one transaction; the +slim database artifact owns its initialization and migrations. `createEphemeralPostgres` is a scoped, Supervisor-free Postgres cluster for schema tooling. It uses the same catalog artifact and bootstrap as a stack database, is not registered in `listStacks` / diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index 1fa472d938..97ea96a9d0 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -9,7 +9,7 @@ import { StackStatusSchema } from "../public/Status.ts"; import { STACK_ERROR_TAGS } from "../public/Errors.ts"; /** Pinned release identifier used to detect incompatible live owners. */ -export const STACK_RPC_RELEASE = "stack-rpc-v1@0.1.0" as const; +export const STACK_RPC_RELEASE = "stack-rpc-v1@0.2.0" as const; const StackRpcErrorTagSchema = Schema.Literals([...STACK_ERROR_TAGS] as const); @@ -31,6 +31,10 @@ const StackRpc = { error: StackRpcErrorSchema, }), destroy: Rpc.make("destroy", { success: Schema.Void, error: StackRpcErrorSchema }), + resetDatabase: Rpc.make("resetDatabase", { + success: StackStatusSchema, + error: StackRpcErrorSchema, + }), logs: Rpc.make("logs", { payload: LogQuerySchema, success: StackLogBatchSchema, @@ -43,6 +47,7 @@ export const StackRpcGroup = RpcGroup.make( StackRpc.credentials, StackRpc.start, StackRpc.destroy, + StackRpc.resetDatabase, StackRpc.logs, ); type StackRpcDefinitions = RpcGroup.Rpcs; diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 006656c4ad..516284ca38 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -42,7 +42,7 @@ import { encodePreface, MaintenanceProtocolError, } from "./MaintenanceProtocol.ts"; -import type { StackRpcError, StackRpcHandlers } from "./StackRpc.ts"; +import { STACK_RPC_RELEASE, type StackRpcError, type StackRpcHandlers } from "./StackRpc.ts"; interface ServerOverrides { readonly rpcHandlers?: Partial; @@ -147,6 +147,7 @@ const withServer = ( }), start: () => Effect.succeed(status), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(status), logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; const defaultMaintenanceHandlers: MaintenanceHandlers = { @@ -155,7 +156,7 @@ const withServer = ( op: "probe", stackId, ownerSessionId, - rpcRelease: "stack-rpc-v1@0.1.0", + rpcRelease: STACK_RPC_RELEASE, }), stop: Effect.succeed({ ok: true, op: "stop" }), }; @@ -456,7 +457,7 @@ describe("control transport", () => { Effect.gen(function* () { const preface = encodePreface({ kind: "rpc", - release: "stack-rpc-v1@0.1.0", + release: STACK_RPC_RELEASE, stackId, ownerSessionId, }); @@ -503,7 +504,7 @@ describe("control transport", () => { Effect.gen(function* () { const preface = encodePreface({ kind: "rpc", - release: "stack-rpc-v1@0.1.0", + release: STACK_RPC_RELEASE, stackId, ownerSessionId, }); @@ -613,7 +614,7 @@ describe("control transport", () => { const ownerSessionId = "session"; const preface = encodePreface({ kind: "rpc", - release: "stack-rpc-v1@0.1.0", + release: STACK_RPC_RELEASE, stackId, ownerSessionId, }); @@ -630,7 +631,7 @@ describe("control transport", () => { Effect.gen(function* () { const preface = encodePreface({ kind: "rpc", - release: "stack-rpc-v1@0.1.0", + release: STACK_RPC_RELEASE, stackId: "b".repeat(64), ownerSessionId, }); @@ -647,7 +648,7 @@ describe("control transport", () => { Effect.gen(function* () { const preface = encodePreface({ kind: "rpc", - release: "stack-rpc-v1@0.1.0", + release: STACK_RPC_RELEASE, stackId, ownerSessionId: "stale-session", }); @@ -674,6 +675,28 @@ describe("control transport", () => { ), ); + it.live("rejects an older stack RPC release with an upgrade error", () => + withServer(({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const invalid = concatBytes(new Uint8Array([0, 0, 0, 1]), new Uint8Array([0xff])); + const response = yield* sendRawAndReadFrame(endpoint, concatBytes(preface, invalid)); + expect(response).toMatchObject({ + ok: false, + error: { + tag: "unsupported-release", + message: `Incompatible Stack RPC release; expected ${STACK_RPC_RELEASE}, received stack-rpc-v1@0.1.0`, + }, + }); + }), + ), + ); + it.live("dispatches exactly one maintenance request on a connection", () => { return withServer( ({ endpoint, stackId, ownerSessionId, stopCalls }) => diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 4e8d09f899..836a3e9ea2 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -83,6 +83,7 @@ import { type StackStopError, type StackLogsError, type DestroyStackError, + type ResetDatabaseError, type StackError, type StackErrorTag, isStackError, @@ -94,6 +95,7 @@ import { STACK_STOP_ERROR_TAGS, STACK_LOGS_ERROR_TAGS, DESTROY_STACK_ERROR_TAGS, + RESET_DATABASE_ERROR_TAGS, } from "./Errors.ts"; import { ownerLockExists, @@ -192,6 +194,8 @@ export interface EffectStack { // oxlint-disable-next-line effecttsgo/lazy-effect readonly destroy: () => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect + readonly resetDatabase: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect readonly logs: (query?: LogQuery) => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect readonly followLogs: (query?: LogQuery) => Stream.Stream; @@ -315,6 +319,8 @@ const logsError = (error: ControlError): StackLogsError => narrowError(error, STACK_LOGS_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); const destroyError = (error: ControlError): DestroyStackError => narrowError(error, DESTROY_STACK_ERROR_TAGS, (message) => new StackDestructionError({ message })); +const resetDatabaseError = (error: ControlError): ResetDatabaseError => + narrowError(error, RESET_DATABASE_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); /** Internal control-transport seam used by public lifecycle integration tests. */ export interface HandleDependencies { @@ -604,6 +610,28 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff ), ); }; + const resetDatabase = (): Effect.Effect => + invoke((rpc) => rpc.resetDatabase(undefined), resetDatabaseError).pipe( + Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { + const offline: Effect.Effect = options.readOfflineState.pipe( + Effect.mapError(resetDatabaseError), + Effect.flatMap((state): Effect.Effect => + Option.isNone(state) + ? Effect.fail(stackNotFound()) + : isStoppedState(state.value) + ? Effect.fail( + new StackNotRunningError({ + stackId: id, + message: "Stack is not running", + }), + ) + : Effect.fail(ownershipError), + ), + Effect.catchTag("StackOwnershipConflictError", () => Effect.fail(ownershipError)), + ); + return offline; + }), + ); const logsStateError = (error: StackError): StackLogsError => isNarrowError(error, STACK_LOGS_ERROR_TAGS) ? error @@ -690,6 +718,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff start, stop, destroy, + resetDatabase, logs, followLogs: (query) => Stream.paginate({ cursor: query?.cursor, first: true }, ({ cursor, first }) => { diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 11f781f10b..bd2e2182c1 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -341,6 +341,12 @@ export const DESTROY_STACK_ERROR_TAGS = [ ] as const satisfies ReadonlyArray; export type DestroyStackError = ErrorByTag<(typeof DESTROY_STACK_ERROR_TAGS)[number]>; +export const RESET_DATABASE_ERROR_TAGS = [ + "StackNotFoundError", + ...STACK_START_ERROR_TAGS, +] as const satisfies ReadonlyArray; +export type ResetDatabaseError = ErrorByTag<(typeof RESET_DATABASE_ERROR_TAGS)[number]>; + export const EPHEMERAL_POSTGRES_ERROR_TAGS = [ "EphemeralPostgresError", "StackVersionUnsupportedError", diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 3a2f6baebb..98fcc77a80 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -73,6 +73,7 @@ export interface PromiseStack { readonly start: (options?: PromiseStartStackOptions) => Promise; readonly stop: () => Promise; readonly destroy: () => Promise; + readonly resetDatabase: () => Promise; readonly logs: (query?: LogQuery) => Promise; readonly followLogs: (query?: LogQuery) => AsyncIterable; } @@ -193,6 +194,7 @@ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { ), stop: () => invoke(effectStack.stop()), destroy: () => invoke(effectStack.destroy()), + resetDatabase: () => invoke(effectStack.resetDatabase()), logs: (query) => invoke(effectStack.logs(query)), followLogs: (query) => adaptStream(effectStack.followLogs(query)), }; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index f736f9e610..342b5ae756 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -245,6 +245,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -306,6 +307,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -423,6 +425,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: (query) => readLogs(query), }, maintenanceHandlers: { @@ -502,6 +505,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => Effect.fail({ tag: "InvalidLogCursorError", message: "Log cursor is invalid" }), }, @@ -1370,6 +1374,7 @@ describe("Effect stack lifecycle handoff", () => { message: "Container engine command failed while starting database", }), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -1445,6 +1450,7 @@ describe("Effect stack lifecycle handoff", () => { } as const); }), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -1637,6 +1643,7 @@ describe("Effect stack lifecycle handoff", () => { return Effect.succeed(runningStatus); }, destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: emptyLogs, }; yield* startControlServer({ @@ -1698,6 +1705,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: emptyLogs, }, maintenanceHandlers: { @@ -1740,6 +1748,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: emptyLogs, }; const maintenanceHandlers = { @@ -2156,6 +2165,7 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => invoked(credentials), start: () => invoked(runningStatus), destroy: () => invoked(undefined), + resetDatabase: () => invoked(runningStatus), logs: () => invoked({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }, maintenanceHandlers: { @@ -2230,6 +2240,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -2289,6 +2300,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Effect.succeed(runningStatus), destroy: () => Deferred.succeed(destroyEntered, undefined).pipe(Effect.andThen(Effect.never)), + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -2352,6 +2364,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -2436,6 +2449,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { @@ -2515,6 +2529,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(runningStatus), logs: () => emptyLogs(), }, maintenanceHandlers: { diff --git a/packages/stack/src/public/ephemeral-postgres.integration.test.ts b/packages/stack/src/public/ephemeral-postgres.integration.test.ts index 56dffd38c4..e128ea7a7d 100644 --- a/packages/stack/src/public/ephemeral-postgres.integration.test.ts +++ b/packages/stack/src/public/ephemeral-postgres.integration.test.ts @@ -1,7 +1,20 @@ import { NodeServices } from "@effect/platform-node"; import { PgClient } from "@effect/sql-pg"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Path, + Redacted, + Ref, + Stream, +} from "effect"; import { ChildProcess } from "effect/unstable/process"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. import { spawnSync } from "node:child_process"; @@ -12,6 +25,7 @@ import { EphemeralPostgresError } from "./Errors.ts"; import { createEphemeralPostgres } from "./EphemeralPostgres.ts"; import { listStacks } from "./EffectStack.ts"; import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; +import { checkHostPort } from "../supervisor/HostListener.ts"; import type { StackRuntimePreference } from "./Runtime.ts"; const NATIVE_TIMEOUT_MS = 180_000; @@ -77,7 +91,79 @@ const writeForeignMarkerTar = (tarPath: string, marker: unknown) => }), ); +const postmasterPort = (contents: string): number | undefined => { + const port = Number(contents.split("\n")[3]?.trim()); + return Number.isFinite(port) && port > 0 ? port : undefined; +}; + describe("ephemeral Postgres", () => { + it.live( + "does not leave postgres listening after an interrupted native start", + () => + withIsolatedRoot( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = yield* StackRuntimeEnvironment; + const ephemeralRoot = path.join(path.dirname(env.stateRoot), "ephemeral-postgres"); + yield* fs.makeDirectory(ephemeralRoot, { recursive: true }); + const spawned = yield* Deferred.make(); + const scan = (): Effect.Effect => + Effect.gen(function* () { + const identities = yield* fs + .readDirectory(ephemeralRoot) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + for (const identity of identities) { + const pidPath = path.join(ephemeralRoot, identity, "data", "postmaster.pid"); + if (!(yield* fs.exists(pidPath).pipe(Effect.orElseSucceed(() => false)))) continue; + const contents = yield* fs.readFileString(pidPath).pipe(Effect.orElseSucceed(() => "")); + const port = postmasterPort(contents); + if (port !== undefined) { + yield* Deferred.succeed(spawned, port).pipe(Effect.asVoid); + return; + } + } + }).pipe(Effect.asVoid); + const watchers = yield* Ref.make(new Set()); + const watchDir = ( + dir: string, + onEvent: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const known = yield* Ref.get(watchers); + if (known.has(dir)) return; + yield* Ref.update(watchers, (current) => new Set([...current, dir])); + yield* Effect.forkChild( + Stream.runForEach(fs.watch(dir), () => onEvent).pipe(Effect.ignore), + ); + }).pipe(Effect.asVoid); + const onEvent: Effect.Effect = Effect.suspend(() => + Effect.gen(function* () { + yield* scan(); + const identities = yield* fs + .readDirectory(ephemeralRoot) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + for (const identity of identities) { + const identityDir = path.join(ephemeralRoot, identity); + yield* watchDir(identityDir, onEvent); + const dataDir = path.join(identityDir, "data"); + if (yield* fs.exists(dataDir).pipe(Effect.orElseSucceed(() => false))) + yield* watchDir(dataDir, onEvent); + } + }).pipe(Effect.asVoid), + ); + yield* watchDir(ephemeralRoot, onEvent); + const fiber = yield* Effect.forkChild( + createEphemeralPostgres({ runtime: { kind: "native" }, ...secrets }), + ); + const port = yield* Deferred.await(spawned); + yield* Fiber.interrupt(fiber); + yield* checkHostPort("127.0.0.1", port, "database"); + }), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + NATIVE_TIMEOUT_MS, + ); + it.live("refuses a snapshot produced by a different runtime before starting Postgres", () => withIsolatedRoot( Effect.gen(function* () { diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index ec3cca1069..6b6de1335a 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -69,6 +69,7 @@ const effectStack = (): EffectStack => start: () => Effect.succeed(status), stop: () => Effect.void, destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(status), logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), followLogs: () => Stream.empty, }) satisfies EffectStack; diff --git a/packages/stack/src/public/reset-database.integration.test.ts b/packages/stack/src/public/reset-database.integration.test.ts new file mode 100644 index 0000000000..cca2489221 --- /dev/null +++ b/packages/stack/src/public/reset-database.integration.test.ts @@ -0,0 +1,116 @@ +// oxlint-disable effecttsgo/async-function -- Promise-facade live reset uses createTestStack. +// oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. +import { execFile as execFileCallback, spawnSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- native storage marker path. +import { join } from "node:path"; +import { promisify } from "node:util"; +import { PgClient } from "@effect/sql-pg"; +import { Effect, Redacted } from "effect"; +import { describe, expect, it } from "vitest"; +import { createTestStack, type TestStack } from "../testing.ts"; +import type { StackRuntimePreference } from "./Runtime.ts"; + +const RESET_TIMEOUT_MS = 180_000; +const execFile = promisify(execFileCallback); +const MARKER_TABLE = "public.stack_reset_marker"; + +const dockerAvailable = (): boolean => + spawnSync("docker", ["info"], { encoding: "utf8" }).status === 0; + +const query = async (url: string, statement: string): Promise> => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + return yield* client.unsafe(statement); + }).pipe(Effect.provide(PgClient.layer({ url: Redacted.make(url), connectTimeout: "10 seconds" }))), + ), + ); + +const volumeWorkloadIds = async (stackId: string): Promise> => { + const listed = await execFile("docker", [ + "volume", + "ls", + "-q", + "--filter", + `label=com.supabase.stack.stackId=${stackId}`, + ]); + const ids = listed.stdout + .trim() + .split("\n") + .filter((value) => value.length > 0); + if (ids.length === 0) return []; + const inspected = await execFile("docker", [ + "inspect", + "--format", + '{{index .Labels "com.supabase.stack.workloadId"}}', + ...ids, + ]); + return inspected.stdout + .trim() + .split("\n") + .filter((value) => value.length > 0); +}; + +const resetAndAssert = async ( + stack: TestStack, + runtime: StackRuntimePreference, +): Promise => { + const before = await stack.status(); + const credentials = await stack.credentials(); + await query(credentials.database.url, `CREATE TABLE ${MARKER_TABLE} (id integer PRIMARY KEY)`); + const storageMarker = join(stack.stateRoot, stack.id, "data", "storage", "keep.txt"); + if (runtime.kind === "native") { + await mkdir(join(stack.stateRoot, stack.id, "data", "storage"), { recursive: true }); + await writeFile(storageMarker, "keep"); + } + const volumesBefore = + runtime.kind === "container" ? await volumeWorkloadIds(stack.id) : []; + + const after = await stack.resetDatabase(); + expect(after.id).toBe(stack.id); + expect(after.endpoints).toEqual(before.endpoints); + expect(after.lifecycle).toBe("running"); + const database = after.capabilities.find((capability) => capability.name === "database"); + expect(database?.state).toBe("ready"); + + const leftover = await query( + (await stack.credentials()).database.url, + `SELECT to_regclass('${MARKER_TABLE}') AS name`, + ); + expect(leftover).toEqual([{ name: null }]); + + if (runtime.kind === "native") { + expect(await readFile(storageMarker, "utf8")).toBe("keep"); + } else { + const volumesAfter = await volumeWorkloadIds(stack.id); + expect(volumesAfter.filter((id) => id !== "database:database")).toEqual( + volumesBefore.filter((id) => id !== "database:database"), + ); + } +}; + +describe("resetDatabase", () => { + it( + "wipes native Postgres while keeping identity, ports, and storage data", + async () => { + await using stack = await createTestStack({ + runtime: { kind: "native" }, + }); + await resetAndAssert(stack, { kind: "native" }); + }, + RESET_TIMEOUT_MS, + ); + + it.skipIf(!dockerAvailable())( + "wipes container Postgres while keeping identity, ports, and non-database volumes", + async () => { + await using stack = await createTestStack({ + runtime: { kind: "container", engine: "docker" }, + }); + await resetAndAssert(stack, { kind: "container", engine: "docker" }); + }, + RESET_TIMEOUT_MS, + ); +}); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index cbd8a4eb48..f76168a08c 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -115,6 +115,13 @@ const fakeStack = (events: Array, options: FakeStackOptions = {}): Promi events.push("destroy"); if (failStart) throw new Error("destroy failed"); }, + resetDatabase: async () => + status( + reachesReadiness ? "running" : "stopped", + includeApi, + functionsState, + failedCapability, + ), logs: async () => ({ entries: [], cursor: { opaque: "v1_0" }, running: false }), followLogs: () => stream([]), }; diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index d8c506c19a..474f6cb9d6 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -1038,11 +1038,29 @@ export const makeContainerRuntime = ( }), ); + const wipePersistentData = ( + key: RuntimeWorkloadKey, + ): Effect.Effect => + registration.withPermit( + Effect.gen(function* () { + const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); + const volumes = entries.filter( + (entry) => + entry.kind === "volume" && + entry.labels.role === "volume" && + entry.labels.workloadId === key.workloadId, + ); + for (const volume of volumes) + yield* withEngine(key, options.engine.removeVolume(volume.id)); + }), + ); + return { observe, start, stop, remove, cleanup, + wipePersistentData, } satisfies RuntimeDriver; }); diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts index 3eb7a0737e..8cf11adf3d 100644 --- a/packages/stack/src/runtime/EphemeralPostgres.ts +++ b/packages/stack/src/runtime/EphemeralPostgres.ts @@ -594,32 +594,46 @@ const startNative = ( password: string, ): Effect.Effect => Effect.gen(function* () { - if (cluster.executable === undefined) + const executable = cluster.executable; + if (executable === undefined) return yield* ephemeralError("Native Postgres executable is unavailable"); - const processScope = yield* Scope.make("sequential"); - const process = yield* spawnNativeProcess( - { - executable: cluster.executable, - args: postgresArgs(cluster.port, cluster.runtime, options.postgresSettings), - env: postgresEnv({ - port: cluster.port, - dataPath: cluster.dataPath, - password, - }), - cwd: cluster.root, - gracefulStopSignal: "SIGINT", - gracefulStopTimeout: "15 seconds", - }, - defaultNativeProcessLauncher(), - { stackId: cluster.identity, workloadId: DATABASE_WORKLOAD_ID }, - ).pipe( - Effect.provideService(Scope.Scope, processScope), - Effect.mapError((cause) => ephemeralError("Unable to start native Postgres", { cause })), + const parentScope = yield* Scope.Scope; + const processScope = yield* Scope.fork(parentScope, "parallel"); + yield* Effect.uninterruptibleMask((restore) => + restore( + spawnNativeProcess( + { + executable, + args: postgresArgs(cluster.port, cluster.runtime, options.postgresSettings), + env: postgresEnv({ + port: cluster.port, + dataPath: cluster.dataPath, + password, + }), + cwd: cluster.root, + gracefulStopSignal: "SIGINT", + gracefulStopTimeout: "15 seconds", + }, + defaultNativeProcessLauncher(), + { stackId: cluster.identity, workloadId: DATABASE_WORKLOAD_ID }, + ).pipe(Scope.provide(processScope)), + ).pipe( + Effect.mapError((cause) => ephemeralError("Unable to start native Postgres", { cause })), + Effect.tap((process) => + Effect.sync(() => { + if (cluster.resources.kind === "native") { + cluster.resources.process = process; + cluster.resources.processScope = processScope; + } + }), + ), + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : Scope.close(processScope, Exit.void).pipe(Effect.asVoid), + ), + ), ); - if (cluster.resources.kind === "native") { - cluster.resources.process = process; - cluster.resources.processScope = processScope; - } yield* Effect.gen(function* () { yield* waitForPostgres(cluster.port, healthTimeout); if (!cluster.bootstrapped) { diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index a06bf2a3fd..abaa6d2e6b 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -48,6 +48,8 @@ export interface NativeRuntimeOptions { process?: NativeProcess, ) => Effect.Effect; readonly logStore?: LogStore; + /** Wipes native PGDATA after the database workload has been stopped and removed. */ + readonly wipeDatabaseData?: Effect.Effect; } /** One-shot startup processes followed by the long-lived workload process. */ @@ -591,11 +593,17 @@ export const makeNativeRuntime = ( }), ); + const wipePersistentData = (key: RuntimeWorkloadKey): Effect.Effect => + key.workloadId === "database:database" && options.wipeDatabaseData !== undefined + ? options.wipeDatabaseData + : Effect.void; + return { observe, start, stop, remove, cleanup: cleanupRuntime, + wipePersistentData, } satisfies RuntimeDriver; }); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 96b4b3d62a..790d04f701 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -962,6 +962,22 @@ export const makeProductionRuntime = ( waitForReadiness, bootstrapDatabase: bootstrapWorkloadDatabase, logStore: logs, + wipeDatabaseData: Effect.gen(function* () { + const dataPath = pathService.join(paths.data, "database"); + const key = { stackId: options.stackId, workloadId: "database:database" }; + const exists = yield* fileSystem.exists(dataPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) + yield* fileSystem.remove(dataPath, { recursive: true }).pipe( + Effect.mapError((error) => + driverError(key, "Unable to wipe native database data", error), + ), + ); + yield* fileSystem.makeDirectory(dataPath, { recursive: true, mode: 0o700 }).pipe( + Effect.mapError((error) => + driverError(key, "Unable to recreate native database data directory", error), + ), + ); + }), }).pipe( Effect.mapError((error) => preparationError("Unable to initialize native runtime", error)), ); diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 55fef48c5c..30f4285303 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -46,6 +46,11 @@ export interface RuntimeDriver { * cleanup removes them after containers and networks have been removed. */ readonly cleanup: (request: RuntimeCleanupRequest) => Effect.Effect; + /** + * Wipes persistent data for one stopped and removed workload. Native database data directories + * and container volumes owned by that workload are removed; other stack volumes stay. + */ + readonly wipePersistentData: (key: RuntimeWorkloadKey) => Effect.Effect; } export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 12a422b65e..386285ecf3 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -2795,6 +2795,7 @@ describe("production runtime", () => { stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.fail(runtimeFailure), + wipePersistentData: () => Effect.void, }; const envOwner: RuntimeEnvFileOwner = { write: () => Effect.die("unused"), @@ -2822,6 +2823,7 @@ describe("production runtime", () => { stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, + wipePersistentData: () => Effect.void, }; const envOwner: RuntimeEnvFileOwner = { write: () => Effect.die("unused"), diff --git a/packages/stack/src/supervisor/SessionLauncher.ts b/packages/stack/src/supervisor/SessionLauncher.ts index 8f996d9309..450daf7de2 100644 --- a/packages/stack/src/supervisor/SessionLauncher.ts +++ b/packages/stack/src/supervisor/SessionLauncher.ts @@ -17,6 +17,8 @@ export interface SessionLauncher { readonly launch: (plan: ExecutionPlan) => Effect.Effect; /** Stops and removes every workload started in this session in reverse order. */ readonly stop: Effect.Effect; + /** Drops session ownership after the caller already stopped those workloads. */ + readonly forget: (workloadIds: ReadonlyArray) => Effect.Effect; /** Whether the most recent launch/rollback cleanup completed exactly. */ readonly cleanupProven: Effect.Effect; /** Clears the session after stack-wide runtime cleanup has completed. */ @@ -190,9 +192,14 @@ export const makeSessionLauncher = (options: { }); const stop = Effect.suspend(() => Ref.get(session).pipe(Effect.flatMap(cleanup))); + const forget = (workloadIds: ReadonlyArray) => + Ref.update(session, (current) => + current.filter((entry) => !workloadIds.includes(entry.key.workloadId)), + ); return { launch, stop, + forget, cleanupProven: Ref.get(cleanupProven), clear: Ref.set(session, []), } satisfies SessionLauncher; diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index fc653786af..6a63438cb4 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -105,6 +105,8 @@ export interface Supervisor { readonly config?: StackConfig; }) => Effect.Effect; readonly destroy: Effect.Effect; + /** Wipes Postgres data for the running stack and bootstraps a fresh cluster. */ + readonly resetDatabase: Effect.Effect; /** Completes after a successful stop or destroy shutdown signal. */ readonly shutdown: Effect.Effect; /** Shuts down only when durable state is absent or cleanly non-running. */ @@ -274,7 +276,7 @@ export const makeSupervisor = ( }); const joinExit = (result: Exit.Exit): Effect.Effect => Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - type LifecycleKind = "start" | "stop" | "destroy"; + type LifecycleKind = "start" | "stop" | "destroy" | "reset"; type LifecycleResult = Deferred.Deferred, never>; type ActiveLifecycle = Readonly<{ kind: LifecycleKind; @@ -289,7 +291,7 @@ export const makeSupervisor = ( // installing its workloads. Wait for that shared lifecycle result before attempting lazy // activation; otherwise the phase check below would turn a valid cold request into 503. const lifecycle = yield* Ref.get(lifecycleActive); - if (lifecycle?.kind === "start") { + if (lifecycle?.kind === "start" || lifecycle?.kind === "reset") { const started = yield* Deferred.await(lifecycle.result); yield* joinExit(started); } @@ -676,6 +678,67 @@ export const makeSupervisor = ( yield* submitLifecycle("start", startOperation(startOptions)); return yield* snapshot(); }); + const resetDatabaseOperation = () => + Effect.gen(function* () { + const previous = yield* Ref.get(phase); + if (previous !== "running") + return yield* new StackNotRunningError({ + stackId: options.stackId, + message: "Stack is not running", + }); + const state = yield* read(); + if (state === undefined || state.definition === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const status = yield* snapshot(); + const database = status.capabilities.find((capability) => capability.name === "database"); + if (database?.state !== "ready") + return yield* new StackNotRunningError({ + stackId: options.stackId, + message: "Database is not running", + }); + const plan = yield* rebuildExecutionPlan(state.runtime, state.definition).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + const bounceNames = new Set( + status.capabilities.flatMap((capability) => + capability.state === "ready" && + (capability.name === "auth" || + capability.name === "storage" || + capability.name === "realtime" || + capability.name === "pooler") + ? [capability.name] + : [], + ), + ); + const bounce = plan.workloads.filter((workload) => bounceNames.has(workload.capability)); + const databaseWorkload = plan.workloads.find( + (workload) => workload.id === "database:database", + ); + if (databaseWorkload === undefined) + return yield* new StackStateInvalidError({ message: "Database workload is missing" }); + const stopOne = (workloadId: string) => + Effect.gen(function* () { + const key = { stackId: options.stackId, workloadId: workloadId }; + yield* runtime.driver.stop(key).pipe(Effect.mapError(mapRuntimeError)); + yield* runtime.driver.remove(key).pipe(Effect.mapError(mapRuntimeError)); + yield* launcher.forget([workloadId]); + }); + for (const workload of [...bounce].reverse()) yield* stopOne(workload.id); + yield* stopOne(databaseWorkload.id); + yield* runtime.driver + .wipePersistentData({ stackId: options.stackId, workloadId: databaseWorkload.id }) + .pipe(Effect.mapError(mapRuntimeError)); + const resetWorkloads = [databaseWorkload, ...bounce]; + yield* launcher + .launch({ ...plan, workloads: resetWorkloads }) + .pipe(Effect.mapError(mapRuntimeError)); + }); + const resetDatabase = submitLifecycle("reset", resetDatabaseOperation()).pipe( + Effect.andThen(snapshot()), + ); const stopOperation = () => Effect.gen(function* () { const previous = yield* Ref.get(phase); @@ -902,11 +965,13 @@ export const makeSupervisor = ( credentials: () => credentials, start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), destroy: () => operation(destroy), + resetDatabase: () => operation(resetDatabase), logs: (query: LogQuery) => operation(logs(query)), }); return { status, start, + resetDatabase, destroy, shutdown: Deferred.await(shutdownSignal), shutdownIfIdle, diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index e642023e69..0e2cca40ed 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -500,6 +500,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { start: () => Effect.fail({ tag: "StackPreparationError", message: "artifact is incomplete" }), destroy: () => Effect.void, + resetDatabase: () => Effect.succeed(status), logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; yield* startControlServer({ @@ -604,6 +605,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { Effect.andThen(Deferred.await(responseRelease)), Effect.asVoid, ), + resetDatabase: () => Effect.succeed(status), logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }, onShutdownReady: Deferred.succeed(callbackStarted, undefined).pipe( diff --git a/packages/stack/src/supervisor/session-launcher.integration.test.ts b/packages/stack/src/supervisor/session-launcher.integration.test.ts index 45b6e68a59..0a79852e84 100644 --- a/packages/stack/src/supervisor/session-launcher.integration.test.ts +++ b/packages/stack/src/supervisor/session-launcher.integration.test.ts @@ -92,6 +92,7 @@ describe("session launcher", () => { stop: (key) => Effect.sync(() => calls.push(`stop:${key.workloadId}`)), remove: (key) => Effect.sync(() => calls.push(`remove:${key.workloadId}`)), cleanup: () => Effect.void, + wipePersistentData: () => Effect.void, }; const launcher = yield* makeSessionLauncher({ stackId, driver }); const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail, rest])), { @@ -134,6 +135,7 @@ describe("session launcher", () => { stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, + wipePersistentData: () => Effect.void, }; const launcher = yield* makeSessionLauncher({ stackId, driver }); const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail, rest])), { @@ -197,6 +199,7 @@ describe("session launcher", () => { stop: () => Effect.die("unreachable"), remove: () => Effect.die("unreachable"), cleanup: () => Effect.void, + wipePersistentData: () => Effect.void, }; const launcher = yield* makeSessionLauncher({ stackId, driver }); const result = yield* launcher diff --git a/packages/stack/src/supervisor/startup-ingress.integration.test.ts b/packages/stack/src/supervisor/startup-ingress.integration.test.ts index ae62d6d9fb..baa6c71eb1 100644 --- a/packages/stack/src/supervisor/startup-ingress.integration.test.ts +++ b/packages/stack/src/supervisor/startup-ingress.integration.test.ts @@ -146,6 +146,7 @@ const makeStartupFixture = () => stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, + wipePersistentData: () => Effect.void, }; const entry: StackLogEntry = { cursor: { opaque: "v1_1" }, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 49921c7d8e..24c74eb74c 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -96,6 +96,7 @@ const makeFixture = ( readonly stopStarted?: Deferred.Deferred; readonly workloadStopFailFirst?: Ref.Ref; readonly workloadRemoveFailFirst?: Ref.Ref; + readonly wipeFailFirst?: Ref.Ref; readonly stopFailFirst?: Ref.Ref; readonly destroyGate?: Deferred.Deferred; readonly destroyStarted?: Deferred.Deferred; @@ -328,6 +329,26 @@ const makeFixture = ( if (!destroy && gateStopCleanup) yield* Ref.update(logEntries, (current) => [...current, finalEntry]); }), + wipePersistentData: (key) => + Effect.gen(function* () { + if (fixtureOptions.wipeFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.wipeFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.wipeFailFirst, false); + return yield* new RuntimeDriverError({ + message: "injected wipe failure", + stackId: key.stackId, + workloadId: key.workloadId, + }); + } + } + if (fixtureOptions.timeline !== undefined) + yield* Ref.update(fixtureOptions.timeline, (current) => [ + ...current, + `wipe:${key.workloadId}`, + ]); + yield* Ref.update(calls, (current) => [...current, `wipe:${key.workloadId}`]); + }), }; const runtime: SupervisorRuntime = { driver, @@ -1492,6 +1513,65 @@ describe("Supervisor composition", () => { ), ); + it.live("wipes only the database workload and bounces ready dependents", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ timeline }); + yield* fixture.supervisor.start({ + config: { capabilities: { auth: { activation: "eager" } } }, + }); + yield* Ref.set(timeline, []); + yield* Ref.set(fixture.calls, []); + + const status = yield* fixture.supervisor.resetDatabase; + expect(status.lifecycle).toBe("running"); + expect(status.capabilities.find((capability) => capability.name === "database")?.state).toBe( + "ready", + ); + expect(yield* Ref.get(timeline)).toEqual([ + "stop:auth:auth", + "stop:database:database", + "wipe:database:database", + "start:database:database", + "start:auth:auth", + ]); + expect(yield* Ref.get(fixture.calls)).toContain("wipe:database:database"); + }), + ), + ); + + it.live("relaunches the database after a wipe failure because stopped workloads were forgotten", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const wipeFailFirst = yield* Ref.make(true); + const fixture = yield* makeFixture({ timeline, wipeFailFirst }); + yield* fixture.supervisor.start(); + yield* Ref.set(timeline, []); + + const resetExit = yield* fixture.supervisor.resetDatabase.pipe(Effect.exit); + expect(Exit.isFailure(resetExit)).toBe(true); + + yield* fixture.supervisor.start(); + expect(yield* Ref.get(timeline)).toEqual([ + "stop:database:database", + "start:database:database", + ]); + }), + ), + ); + + it.live("refuses reset when the database is not running", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const exit = yield* fixture.supervisor.resetDatabase.pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackNotRunningError); + }), + ), + ); + it.live("stops the launched session in reverse dependency order", () => run( Effect.gen(function* () { From 02c652819412a4a63487cbc9da9cf52c62852931 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 17:31:52 +0200 Subject: [PATCH 09/14] fix(cli): derive shadow bootstrap identity and tear down interrupted Postgres Restored snapshots skip bootstrap, so the cache key now uses the same internal database constants. Interrupt owns the cluster on the start fiber so Postgres cannot stay bound, and remaining oxfmt drift is formatted. --- apps/cli/docs/stack-commands.md | 4 + .../command-internal/postgres-client.run.ts | 26 +++- .../postgres-client.run.unit.test.ts | 27 +++- .../stack-shadow.integration.test.ts | 3 + apps/cli/src/command-internal/stack-shadow.ts | 4 + .../stack-shadow.unit.test.ts | 6 +- packages/stack/src/model/DatabaseBootstrap.ts | 27 +++- packages/stack/src/public/EffectStack.ts | 6 +- .../ephemeral-postgres.integration.test.ts | 141 ++++++++---------- packages/stack/src/public/index.ts | 1 + .../public/reset-database.integration.test.ts | 13 +- .../stack/src/runtime/ContainerRuntime.ts | 4 +- .../stack/src/runtime/EphemeralPostgres.ts | 10 +- packages/stack/src/runtime/NativeRuntime.ts | 4 +- .../src/runtime/PostgresDatabaseSession.ts | 8 +- .../stack/src/runtime/ProductionRuntime.ts | 18 ++- packages/stack/src/runtime/RuntimeEnvFile.ts | 4 +- .../src/runtime/RuntimeEnvFile.unit.test.ts | 37 +++++ packages/stack/src/supervisor/Supervisor.ts | 16 +- .../supervisor/supervisor.integration.test.ts | 46 +++--- 20 files changed, 261 insertions(+), 144 deletions(-) create mode 100644 packages/stack/src/runtime/RuntimeEnvFile.unit.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 302b3d5242..e73d84314e 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -70,6 +70,10 @@ The flag is local CLI configuration in `supabase/config.toml` and is excluded fr configuration. Routing reads that exact file after applying the CLI's working-directory rules, including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enable the flag. +## Port intents + +Host listener assignment for `supabase stack` is documented in [Port intents](./supabase-home.md#port-intents). + ## Service selection and shutdown `supabase stack start --exclude studio,analytics -x mail` disables those services in the effective diff --git a/apps/cli/src/command-internal/postgres-client.run.ts b/apps/cli/src/command-internal/postgres-client.run.ts index d57e7d9e8b..3b77e234fd 100644 --- a/apps/cli/src/command-internal/postgres-client.run.ts +++ b/apps/cli/src/command-internal/postgres-client.run.ts @@ -22,6 +22,23 @@ export const parsePostgresClientMajor = (text: string): number | undefined => { return Number.isInteger(major) ? major : undefined; }; +/** `pg_prove` has no Postgres major; any matching `psql` or `pg_dump` on PATH is enough. */ +export const matchingHostPostgresClient = ( + dumpMajor: number | undefined, + psqlMajor: number | undefined, + expected: number, +): + | { readonly kind: "match" } + | { + readonly kind: "mismatch"; + readonly command: "pg_dump" | "psql"; + readonly actual: number | undefined; + } => { + if (dumpMajor === expected || psqlMajor === expected) return { kind: "match" }; + if (psqlMajor !== undefined) return { kind: "mismatch", command: "psql", actual: psqlMajor }; + return { kind: "mismatch", command: "pg_dump", actual: dumpMajor }; +}; + export class HostPostgresClientError extends Data.TaggedError("HostPostgresClientError")<{ readonly message: string; readonly suggestion?: string; @@ -98,12 +115,9 @@ export const requireHostPgProve = ( const psql = yield* hostClientVersion("psql").pipe(Effect.result); const dumpMajor = Result.isSuccess(dump) ? parsePostgresClientMajor(dump.success) : undefined; const psqlMajor = Result.isSuccess(psql) ? parsePostgresClientMajor(psql.success) : undefined; - if (dumpMajor !== undefined && dumpMajor !== expectedMajor) - return yield* majorMismatch("pg_dump", dumpMajor, expectedMajor); - if (psqlMajor !== undefined && psqlMajor !== expectedMajor) - return yield* majorMismatch("psql", psqlMajor, expectedMajor); - const major = psqlMajor ?? dumpMajor; - if (major !== expectedMajor) return yield* majorMismatch("psql", major, expectedMajor); + const matched = matchingHostPostgresClient(dumpMajor, psqlMajor, expectedMajor); + if (matched.kind === "match") return; + return yield* majorMismatch(matched.command, matched.actual, expectedMajor); }); const concatChunks = (chunks: ReadonlyArray): Uint8Array => { diff --git a/apps/cli/src/command-internal/postgres-client.run.unit.test.ts b/apps/cli/src/command-internal/postgres-client.run.unit.test.ts index 1ec864fb20..b786ae51b9 100644 --- a/apps/cli/src/command-internal/postgres-client.run.unit.test.ts +++ b/apps/cli/src/command-internal/postgres-client.run.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { parsePostgresClientMajor } from "./postgres-client.run.ts"; +import { matchingHostPostgresClient, parsePostgresClientMajor } from "./postgres-client.run.ts"; describe("parsePostgresClientMajor", () => { it("reads the PostgreSQL major from client --version output", () => { @@ -14,3 +14,28 @@ describe("parsePostgresClientMajor", () => { expect(parsePostgresClientMajor("")).toBeUndefined(); }); }); + +describe("matchingHostPostgresClient", () => { + it("accepts a matching psql when pg_dump reports another major", () => { + expect(matchingHostPostgresClient(16, 17, 17)).toEqual({ kind: "match" }); + expect(matchingHostPostgresClient(17, 16, 17)).toEqual({ kind: "match" }); + }); + + it("fails only when neither client matches", () => { + expect(matchingHostPostgresClient(16, undefined, 17)).toEqual({ + kind: "mismatch", + command: "pg_dump", + actual: 16, + }); + expect(matchingHostPostgresClient(undefined, 15, 17)).toEqual({ + kind: "mismatch", + command: "psql", + actual: 15, + }); + expect(matchingHostPostgresClient(undefined, undefined, 17)).toEqual({ + kind: "mismatch", + command: "pg_dump", + actual: undefined, + }); + }); +}); diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index 96ded25039..6993737df2 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; import { EphemeralPostgresError, + databaseBootstrapIdentity, type CreateEphemeralPostgresOptions, type EffectEphemeralPostgres, } from "@supabase/stack/effect"; @@ -162,6 +163,7 @@ describe("stackAcquireShadowDatabase", () => { dbPassword: "postgres", dbSettings: {}, rolesSql: "", + bootstrapIdentity: databaseBootstrapIdentity, }), ), ); @@ -328,6 +330,7 @@ describe("stackAcquireShadowDatabase", () => { dbPassword: "postgres", dbSettings: {}, rolesSql: "", + bootstrapIdentity: databaseBootstrapIdentity, }), ); yield* fs.writeFileString(path.join(cacheDir, tarName), "corrupt"); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index a4dd4a4684..d653a95586 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -19,6 +19,7 @@ import { import { ChildProcessSpawner } from "effect/unstable/process"; import { createEphemeralPostgres, + databaseBootstrapIdentity, resolveEphemeralPostgresRelease, type CreateEphemeralPostgresOptions, type EffectEphemeralPostgres, @@ -89,6 +90,7 @@ export interface StackShadowCacheKeyInputs { readonly dbPassword: string; readonly dbSettings: unknown; readonly rolesSql: string; + readonly bootstrapIdentity: string; } export const stackShadowCacheKey = (inputs: StackShadowCacheKeyInputs): string => { @@ -101,6 +103,7 @@ export const stackShadowCacheKey = (inputs: StackShadowCacheKeyInputs): string = `jwt_expiry=${inputs.jwtExpiry}`, `db_password=${quoted(inputs.dbPassword)}`, `db_settings=${JSON.stringify(inputs.dbSettings ?? {})}`, + `bootstrap=${quoted(inputs.bootstrapIdentity)}`, ].join("\n"); return scryptSync( `${payload}\nroles_sql=\n${inputs.rolesSql}`, @@ -443,6 +446,7 @@ export const stackAcquireShadowDatabase = ( dbPassword: input.password, dbSettings: canonicalSettings(input.db.settings), rolesSql, + bootstrapIdentity: databaseBootstrapIdentity, }); const tarName = stackShadowBaselineTarFileName(key); const tarPath = path.join(cacheDir, tarName); diff --git a/apps/cli/src/command-internal/stack-shadow.unit.test.ts b/apps/cli/src/command-internal/stack-shadow.unit.test.ts index 9ca87390d7..1a8b78e507 100644 --- a/apps/cli/src/command-internal/stack-shadow.unit.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.unit.test.ts @@ -14,6 +14,7 @@ const base = { dbPassword: "postgres", dbSettings: {}, rolesSql: "", + bootstrapIdentity: "bootstrap-v1", }; describe("stackShadowCacheKey", () => { @@ -51,11 +52,14 @@ describe("stackShadowCacheKey", () => { } }); - it("changes when roles.sql or db settings change", () => { + it("changes when roles.sql, db settings, or bootstrap identity change", () => { const withRoles = stackShadowCacheKey({ ...base, rolesSql: "create role x;" }); expect(stackShadowCacheKey(base)).not.toBe(withRoles); expect(stackShadowCacheKey({ ...base, dbSettings: { max_connections: 20 } })).not.toBe( stackShadowCacheKey(base), ); + expect(stackShadowCacheKey({ ...base, bootstrapIdentity: "bootstrap-v2" })).not.toBe( + stackShadowCacheKey(base), + ); }); }); diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index 8262d5a958..0a32a89ddf 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -15,13 +15,16 @@ const DATABASE_BOOTSTRAP_ROLES = [ ] as const; type DatabaseBootstrapRole = (typeof DATABASE_BOOTSTRAP_ROLES)[number]; +export const JWT_SECRET_SETTING = "app.settings.jwt_secret" as const; +const JWT_EXP_SETTING = "app.settings.jwt_exp" as const; + type DatabaseBootstrapSetting = | { - readonly name: "app.settings.jwt_secret"; + readonly name: typeof JWT_SECRET_SETTING; readonly value: Redacted.Redacted; } | { - readonly name: "app.settings.jwt_exp"; + readonly name: typeof JWT_EXP_SETTING; readonly value: number; }; @@ -74,6 +77,22 @@ const REALTIME_SCHEMA_STATEMENT = "CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;"; const ADVISORY_LOCK_STATEMENT = `SELECT pg_advisory_xact_lock(hashtext('supabase_internal.bootstrap'));`; +/** Private database created outside the bootstrap transaction. */ +export const INTERNAL_DATABASE = "_supabase"; +/** Service-owned schemas created in the private database. */ +export const INTERNAL_SCHEMAS = ["_analytics", "_supavisor"] as const; + +/** Cache/key material for the managed bootstrap. */ +export const databaseBootstrapIdentity = [ + ...DATABASE_BOOTSTRAP_ROLES, + ADVISORY_LOCK_STATEMENT, + REALTIME_SCHEMA_STATEMENT, + JWT_SECRET_SETTING, + JWT_EXP_SETTING, + INTERNAL_DATABASE, + ...INTERNAL_SCHEMAS, +].join("\n"); + const statementError = (error: DatabaseBootstrapError, statement: string) => new DatabaseBootstrapError({ message: error.message, @@ -106,8 +125,8 @@ export const runDatabaseBootstrap = ( ); yield* transaction .setDatabaseSettings([ - { name: "app.settings.jwt_secret", value: options.jwtSecret }, - { name: "app.settings.jwt_exp", value: options.jwtExpiry }, + { name: JWT_SECRET_SETTING, value: options.jwtSecret }, + { name: JWT_EXP_SETTING, value: options.jwtExpiry }, ]) .pipe( Effect.mapError( diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 836a3e9ea2..06b4b17ee1 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -320,7 +320,11 @@ const logsError = (error: ControlError): StackLogsError => const destroyError = (error: ControlError): DestroyStackError => narrowError(error, DESTROY_STACK_ERROR_TAGS, (message) => new StackDestructionError({ message })); const resetDatabaseError = (error: ControlError): ResetDatabaseError => - narrowError(error, RESET_DATABASE_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); + narrowError( + error, + RESET_DATABASE_ERROR_TAGS, + (message) => new StackStateInvalidError({ message }), + ); /** Internal control-transport seam used by public lifecycle integration tests. */ export interface HandleDependencies { diff --git a/packages/stack/src/public/ephemeral-postgres.integration.test.ts b/packages/stack/src/public/ephemeral-postgres.integration.test.ts index e128ea7a7d..d5cf2bac22 100644 --- a/packages/stack/src/public/ephemeral-postgres.integration.test.ts +++ b/packages/stack/src/public/ephemeral-postgres.integration.test.ts @@ -3,7 +3,7 @@ import { PgClient } from "@effect/sql-pg"; import { describe, expect, it } from "@effect/vitest"; import { Cause, - Deferred, + Duration, Effect, Exit, Fiber, @@ -12,12 +12,13 @@ import { Option, Path, Redacted, - Ref, - Stream, + Schedule, } from "effect"; import { ChildProcess } from "effect/unstable/process"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. import { spawnSync } from "node:child_process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- test reserves a loopback port before fork. +import { createServer } from "node:net"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- isolated artifact cache path. import { join } from "node:path"; @@ -91,79 +92,39 @@ const writeForeignMarkerTar = (tarPath: string, marker: unknown) => }), ); -const postmasterPort = (contents: string): number | undefined => { - const port = Number(contents.split("\n")[3]?.trim()); - return Number.isFinite(port) && port > 0 ? port : undefined; -}; - -describe("ephemeral Postgres", () => { - it.live( - "does not leave postgres listening after an interrupted native start", - () => - withIsolatedRoot( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* StackRuntimeEnvironment; - const ephemeralRoot = path.join(path.dirname(env.stateRoot), "ephemeral-postgres"); - yield* fs.makeDirectory(ephemeralRoot, { recursive: true }); - const spawned = yield* Deferred.make(); - const scan = (): Effect.Effect => - Effect.gen(function* () { - const identities = yield* fs - .readDirectory(ephemeralRoot) - .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - for (const identity of identities) { - const pidPath = path.join(ephemeralRoot, identity, "data", "postmaster.pid"); - if (!(yield* fs.exists(pidPath).pipe(Effect.orElseSucceed(() => false)))) continue; - const contents = yield* fs.readFileString(pidPath).pipe(Effect.orElseSucceed(() => "")); - const port = postmasterPort(contents); - if (port !== undefined) { - yield* Deferred.succeed(spawned, port).pipe(Effect.asVoid); - return; - } - } - }).pipe(Effect.asVoid); - const watchers = yield* Ref.make(new Set()); - const watchDir = ( - dir: string, - onEvent: Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const known = yield* Ref.get(watchers); - if (known.has(dir)) return; - yield* Ref.update(watchers, (current) => new Set([...current, dir])); - yield* Effect.forkChild( - Stream.runForEach(fs.watch(dir), () => onEvent).pipe(Effect.ignore), - ); - }).pipe(Effect.asVoid); - const onEvent: Effect.Effect = Effect.suspend(() => - Effect.gen(function* () { - yield* scan(); - const identities = yield* fs - .readDirectory(ephemeralRoot) - .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - for (const identity of identities) { - const identityDir = path.join(ephemeralRoot, identity); - yield* watchDir(identityDir, onEvent); - const dataDir = path.join(identityDir, "data"); - if (yield* fs.exists(dataDir).pipe(Effect.orElseSucceed(() => false))) - yield* watchDir(dataDir, onEvent); - } - }).pipe(Effect.asVoid), - ); - yield* watchDir(ephemeralRoot, onEvent); - const fiber = yield* Effect.forkChild( - createEphemeralPostgres({ runtime: { kind: "native" }, ...secrets }), - ); - const port = yield* Deferred.await(spawned); - yield* Fiber.interrupt(fiber); - yield* checkHostPort("127.0.0.1", port, "database"); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - NATIVE_TIMEOUT_MS, - ); +const reserveLoopbackPort = (): Effect.Effect => + Effect.callback((resume) => { + const server = createServer(); + let settled = false; + const finish = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + server.once("error", (cause) => finish(Effect.die(cause))); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + server.close((error) => { + if (error !== undefined) { + finish(Effect.die(error)); + return; + } + finish(port > 0 ? Effect.succeed(port) : Effect.die("Unable to allocate a loopback port")); + }); + }); + return Effect.sync(() => { + if (settled) return; + settled = true; + try { + server.close(); + } catch { + // The listener never obtained a handle. + } + }); + }); +describe.sequential("ephemeral Postgres", () => { it.live("refuses a snapshot produced by a different runtime before starting Postgres", () => withIsolatedRoot( Effect.gen(function* () { @@ -233,6 +194,36 @@ describe("ephemeral Postgres", () => { NATIVE_TIMEOUT_MS, ); + it.live( + "does not leave postgres listening after an interrupted native start", + () => + withIsolatedRoot( + Effect.gen(function* () { + const port = yield* reserveLoopbackPort(); + // Fiber-owned scope so interrupt always tears the cluster down, even after start returns. + const fiber = yield* Effect.forkChild( + Effect.scoped( + createEphemeralPostgres({ runtime: { kind: "native" }, port, ...secrets }).pipe( + Effect.andThen(Effect.never), + ), + ), + ); + const url = Redacted.make( + `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent(PASSWORD)}@127.0.0.1:${port}/postgres`, + ); + yield* Effect.raceFirst( + Effect.retry(query(url, "SELECT 1"), { + schedule: Schedule.spaced("100 millis"), + }).pipe(Effect.timeout(Duration.seconds(120))), + Fiber.join(fiber), + ); + yield* Fiber.interrupt(fiber); + yield* checkHostPort("127.0.0.1", port, "database"); + }), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + NATIVE_TIMEOUT_MS, + ); + it.live.skipIf(!dockerAvailable())( "starts a container cluster, snapshots, and restores", () => diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 78aa56be46..3d3ab587cb 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -29,6 +29,7 @@ export type { PreparedCapability, PrepareStackResult, } from "./EffectStack.ts"; +export { databaseBootstrapIdentity } from "../model/DatabaseBootstrap.ts"; export { createEphemeralPostgres, resolveEphemeralPostgresRelease } from "./EphemeralPostgres.ts"; export type { CreateEphemeralPostgresOptions, diff --git a/packages/stack/src/public/reset-database.integration.test.ts b/packages/stack/src/public/reset-database.integration.test.ts index cca2489221..ae08a2b165 100644 --- a/packages/stack/src/public/reset-database.integration.test.ts +++ b/packages/stack/src/public/reset-database.integration.test.ts @@ -1,6 +1,7 @@ // oxlint-disable effecttsgo/async-function -- Promise-facade live reset uses createTestStack. // oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. import { execFile as execFileCallback, spawnSync } from "node:child_process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- native storage marker path. import { mkdir, readFile, writeFile } from "node:fs/promises"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- native storage marker path. import { join } from "node:path"; @@ -24,7 +25,9 @@ const query = async (url: string, statement: string): Promise .filter((value) => value.length > 0); }; -const resetAndAssert = async ( - stack: TestStack, - runtime: StackRuntimePreference, -): Promise => { +const resetAndAssert = async (stack: TestStack, runtime: StackRuntimePreference): Promise => { const before = await stack.status(); const credentials = await stack.credentials(); await query(credentials.database.url, `CREATE TABLE ${MARKER_TABLE} (id integer PRIMARY KEY)`); @@ -65,8 +65,7 @@ const resetAndAssert = async ( await mkdir(join(stack.stateRoot, stack.id, "data", "storage"), { recursive: true }); await writeFile(storageMarker, "keep"); } - const volumesBefore = - runtime.kind === "container" ? await volumeWorkloadIds(stack.id) : []; + const volumesBefore = runtime.kind === "container" ? await volumeWorkloadIds(stack.id) : []; const after = await stack.resetDatabase(); expect(after.id).toBe(stack.id); diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 474f6cb9d6..50dc30427b 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -1038,9 +1038,7 @@ export const makeContainerRuntime = ( }), ); - const wipePersistentData = ( - key: RuntimeWorkloadKey, - ): Effect.Effect => + const wipePersistentData = (key: RuntimeWorkloadKey): Effect.Effect => registration.withPermit( Effect.gen(function* () { const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts index 8cf11adf3d..9400cdc90d 100644 --- a/packages/stack/src/runtime/EphemeralPostgres.ts +++ b/packages/stack/src/runtime/EphemeralPostgres.ts @@ -49,6 +49,7 @@ import { bootstrapManagedPostgres } from "./PostgresDatabaseSession.ts"; import { makeProductionRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import { resolveContainerEngine, ContainerEngineResolver } from "./ContainerEngineResolver.ts"; import type { ContainerEngine } from "./ContainerEngine.ts"; +import { encodeRuntimeEnvFile } from "./RuntimeEnvFile.ts"; const DATABASE_WORKLOAD_ID = "database:database"; const PGDATA_DIR_NAME = "data"; @@ -206,10 +207,11 @@ const writeEnvFile = ( ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const text = Object.entries(values) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, value]) => `${name}=${value}\n`) - .join(""); + const text = yield* encodeRuntimeEnvFile(values).pipe( + Effect.mapError((cause) => + ephemeralError("Unable to write Postgres environment file", { cause, path: filePath }), + ), + ); yield* fs .writeFileString(filePath, text) .pipe( diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index abaa6d2e6b..bce3e8955a 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -593,7 +593,9 @@ export const makeNativeRuntime = ( }), ); - const wipePersistentData = (key: RuntimeWorkloadKey): Effect.Effect => + const wipePersistentData = ( + key: RuntimeWorkloadKey, + ): Effect.Effect => key.workloadId === "database:database" && options.wipeDatabaseData !== undefined ? options.wipeDatabaseData : Effect.void; diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 18e5a83a72..c5cf9a4a68 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -3,6 +3,9 @@ import { Context, Duration, Effect, Layer, Predicate, Redacted, Schema, Scope } import { isSqlError, type SqlError } from "effect/unstable/sql/SqlError"; import { DatabaseBootstrapError, + INTERNAL_DATABASE, + INTERNAL_SCHEMAS, + JWT_SECRET_SETTING, type DatabaseBootstrapOptions, type DatabaseSession, type DatabaseSqlValue, @@ -106,7 +109,7 @@ export const makeDatabaseSessionFromSqlClient = ( .join(", "); const parameters = settings.flatMap((setting) => [ setting.name, - setting.name === "app.settings.jwt_secret" ? Redacted.value(setting.value) : setting.value, + setting.name === JWT_SECRET_SETTING ? Redacted.value(setting.value) : setting.value, ]); return generated( `SELECT string_agg(format('ALTER DATABASE postgres SET %I TO %L', name, value), E';\\n') AS statement FROM (VALUES ${values}) AS settings(name, value)`, @@ -155,9 +158,6 @@ const makePostgresDatabaseSession = ( }), ); -const INTERNAL_DATABASE = "_supabase"; -const INTERNAL_SCHEMAS = ["_analytics", "_supavisor"] as const; - /** * Ensures the private database and service-owned schemas exist before any * dependent workload is started. Database creation happens outside a transaction because diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 790d04f701..6f1f705740 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -967,16 +967,20 @@ export const makeProductionRuntime = ( const key = { stackId: options.stackId, workloadId: "database:database" }; const exists = yield* fileSystem.exists(dataPath).pipe(Effect.orElseSucceed(() => false)); if (exists) - yield* fileSystem.remove(dataPath, { recursive: true }).pipe( + yield* fileSystem + .remove(dataPath, { recursive: true }) + .pipe( + Effect.mapError((error) => + driverError(key, "Unable to wipe native database data", error), + ), + ); + yield* fileSystem + .makeDirectory(dataPath, { recursive: true, mode: 0o700 }) + .pipe( Effect.mapError((error) => - driverError(key, "Unable to wipe native database data", error), + driverError(key, "Unable to recreate native database data directory", error), ), ); - yield* fileSystem.makeDirectory(dataPath, { recursive: true, mode: 0o700 }).pipe( - Effect.mapError((error) => - driverError(key, "Unable to recreate native database data directory", error), - ), - ); }), }).pipe( Effect.mapError((error) => preparationError("Unable to initialize native runtime", error)), diff --git a/packages/stack/src/runtime/RuntimeEnvFile.ts b/packages/stack/src/runtime/RuntimeEnvFile.ts index b91a12059d..a89f25fffd 100644 --- a/packages/stack/src/runtime/RuntimeEnvFile.ts +++ b/packages/stack/src/runtime/RuntimeEnvFile.ts @@ -41,7 +41,7 @@ const mapFile = ( ), ); -const contentFor = ( +export const encodeRuntimeEnvFile = ( values: Readonly>, ): Effect.Effect => { const entries = Object.entries(values).sort(([left], [right]) => left.localeCompare(right)); @@ -81,7 +81,7 @@ export const makeRuntimeEnvFileOwner = ( if (!validWorkloadId(input.workloadId)) return Effect.fail(error("Invalid runtime environment workload identity")); return Effect.gen(function* () { - const text = yield* contentFor(input.values); + const text = yield* encodeRuntimeEnvFile(input.values); const target = path.join(envRoot, `${encodeWorkloadId(input.workloadId)}.env`); const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError(() => error("Unable to allocate runtime environment file name")), diff --git a/packages/stack/src/runtime/RuntimeEnvFile.unit.test.ts b/packages/stack/src/runtime/RuntimeEnvFile.unit.test.ts new file mode 100644 index 0000000000..edc85189f4 --- /dev/null +++ b/packages/stack/src/runtime/RuntimeEnvFile.unit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { StackPreparationError } from "../public/Errors.ts"; +import { encodeRuntimeEnvFile } from "./RuntimeEnvFile.ts"; + +describe("encodeRuntimeEnvFile", () => { + it.effect("encodes sorted NAME=value lines", () => + Effect.gen(function* () { + const text = yield* encodeRuntimeEnvFile({ ZETA: "2", ALPHA: "1" }); + expect(text).toBe("ALPHA=1\nZETA=2\n"); + }), + ); + + it.effect("rejects CR/LF in a value", () => + Effect.gen(function* () { + const exit = yield* encodeRuntimeEnvFile({ POSTGRES_PASSWORD: "x\ny" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(StackPreparationError); + if (!(error instanceof StackPreparationError)) return; + expect(error.message).toBe("Invalid runtime environment variable value"); + }), + ); + + it.effect("rejects CR/LF in a name", () => + Effect.gen(function* () { + const exit = yield* encodeRuntimeEnvFile({ "FOO\nBAR": "1" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(StackPreparationError); + if (!(error instanceof StackPreparationError)) return; + expect(error.message).toBe("Invalid runtime environment variable name"); + }), + ); +}); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 6a63438cb4..7d3e85e613 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -72,6 +72,14 @@ import { import type { ActivationResult } from "../gateway/Gateway.ts"; +const RESET_DATABASE_BOUNCE_CAPABILITIES: ReadonlySet = new Set([ + "auth", + "storage", + "realtime", + "pooler", + "analytics", +]); + interface SupervisorLaunchAttempt { /** Rolls back only workloads and ingress acquired by this launch. */ readonly rollback: Effect.Effect; @@ -702,13 +710,9 @@ export const makeSupervisor = ( (error) => new StackStateInvalidError({ message: error.message, cause: error }), ), ); - const bounceNames = new Set( + const bounceNames = new Set( status.capabilities.flatMap((capability) => - capability.state === "ready" && - (capability.name === "auth" || - capability.name === "storage" || - capability.name === "realtime" || - capability.name === "pooler") + capability.state === "ready" && RESET_DATABASE_BOUNCE_CAPABILITIES.has(capability.name) ? [capability.name] : [], ), diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 24c74eb74c..9d8faf5702 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1526,9 +1526,9 @@ describe("Supervisor composition", () => { const status = yield* fixture.supervisor.resetDatabase; expect(status.lifecycle).toBe("running"); - expect(status.capabilities.find((capability) => capability.name === "database")?.state).toBe( - "ready", - ); + expect( + status.capabilities.find((capability) => capability.name === "database")?.state, + ).toBe("ready"); expect(yield* Ref.get(timeline)).toEqual([ "stop:auth:auth", "stop:database:database", @@ -1541,25 +1541,27 @@ describe("Supervisor composition", () => { ), ); - it.live("relaunches the database after a wipe failure because stopped workloads were forgotten", () => - run( - Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const wipeFailFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ timeline, wipeFailFirst }); - yield* fixture.supervisor.start(); - yield* Ref.set(timeline, []); - - const resetExit = yield* fixture.supervisor.resetDatabase.pipe(Effect.exit); - expect(Exit.isFailure(resetExit)).toBe(true); - - yield* fixture.supervisor.start(); - expect(yield* Ref.get(timeline)).toEqual([ - "stop:database:database", - "start:database:database", - ]); - }), - ), + it.live( + "relaunches the database after a wipe failure because stopped workloads were forgotten", + () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const wipeFailFirst = yield* Ref.make(true); + const fixture = yield* makeFixture({ timeline, wipeFailFirst }); + yield* fixture.supervisor.start(); + yield* Ref.set(timeline, []); + + const resetExit = yield* fixture.supervisor.resetDatabase.pipe(Effect.exit); + expect(Exit.isFailure(resetExit)).toBe(true); + + yield* fixture.supervisor.start(); + expect(yield* Ref.get(timeline)).toEqual([ + "stop:database:database", + "start:database:database", + ]); + }), + ), ); it.live("refuses reset when the database is not running", () => From d34d08985f6ce915d63278e9d633ae2eb2981585 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 19:15:08 +0200 Subject: [PATCH 10/14] fix(cli): route test db through the stack and register ephemeral resources Reuse the shadow-cache JSON helper, snapshot new error tags, and mask container create-to-ID so Ctrl-C cannot orphan Docker/Podman objects. --- .../db-bootstrap/reset-local-database.ts | 24 +- .../db-bootstrap/shadow-cache.ts | 7 +- apps/cli/src/command-internal/db-pull-run.ts | 9 +- .../command-internal/stack-local-database.ts | 55 ++-- apps/cli/src/command-internal/stack-shadow.ts | 26 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 26 +- apps/cli/src/commands/db/diff/diff.handler.ts | 10 +- .../commands/db/dump/dump.integration.test.ts | 61 +++-- .../schema/declarative/sync/sync.handler.ts | 17 +- .../commands/db/shared/pgdelta.seam.layer.ts | 234 +++++++++--------- .../db/start/start.integration.test.ts | 5 +- .../stack/stack-backend.integration.test.ts | 2 + .../experimental/stack/stack-backend.ts | 2 +- .../experimental/stack/start/start.options.ts | 6 +- .../commands/migration/squash/squash.dump.ts | 5 +- .../telemetry/__fixtures__/error-tags.txt | 5 + .../stack/src/runtime/EphemeralPostgres.ts | 119 +++++---- 17 files changed, 322 insertions(+), 291 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 49d2da305d..91a82f518f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -109,9 +109,7 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); yield* opened.value.stack.resetDatabase().pipe( Effect.catchTag("StackNotRunningError", () => Effect.fail(notRunning())), - Effect.mapError((cause) => - resetFailed(`failed to reset local database: ${cause.message}`), - ), + Effect.mapError((cause) => resetFailed(`failed to reset local database: ${cause.message}`)), ); const dbConn = yield* DbConnection; const toml = yield* readDbToml(fs, path, workdir); @@ -120,9 +118,13 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( ); yield* Effect.scoped( Effect.gen(function* () { - const session = yield* dbConn.connect(conn, { isLocal: true, dnsResolver: "native" }).pipe( - Effect.mapError((cause) => resetFailed(`failed to connect after reset: ${cause.message}`)), - ); + const session = yield* dbConn + .connect(conn, { isLocal: true, dnsResolver: "native" }) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to connect after reset: ${cause.message}`), + ), + ); yield* migrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), @@ -133,9 +135,13 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( }).pipe(Effect.mapError((cause) => resetFailed(cause.message))); }), ); - const after = yield* opened.value.stack.status().pipe( - Effect.mapError((cause) => resetFailed(`failed to inspect stack after reset: ${cause.message}`)), - ); + const after = yield* opened.value.stack + .status() + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to inspect stack after reset: ${cause.message}`), + ), + ); const storage = after.capabilities.find((capability) => capability.name === "storage"); if (storage?.state === "ready") { const context = yield* loadLocalProjectContext(workdir, (message) => resetFailed(message)); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index d599ffbb2d..195406ed72 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -171,7 +171,7 @@ const shadowBaselineEmbeddedDigest = (): string => .digest("hex")); /** JSON with recursively key-sorted objects, so `db.settings`' own property order cannot change the key. */ -function canonicalJson(value: unknown): string { +export function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; const entries = Object.entries(value) @@ -472,7 +472,10 @@ const sweepShadowBaselineRetention = ( }); /** Refresh mtime on a warm hit so frequently used keys survive LRU/TTL. Best-effort. */ -export const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Effect.Effect => +export const touchShadowBaselineTar = ( + fs: FileSystem.FileSystem, + tarPath: string, +): Effect.Effect => Effect.gen(function* () { const now = new Date(yield* Clock.currentTimeMillis); yield* fs.utimes(tarPath, now, now); diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index bad27ed4aa..04125b7c1c 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -68,10 +68,7 @@ import { type PgDeltaContext, isPgDeltaDebugEnabled, resolvePgDeltaProjectId } f import { prepareShadowSource } from "../commands/db/shared/shadow-source.ts"; import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; -import { - stackPrepareShadowSource, - stackWithShadowDatabase, -} from "./stack-shadow.ts"; +import { stackPrepareShadowSource, stackWithShadowDatabase } from "./stack-shadow.ts"; import type { DbPullFlags } from "../commands/db/pull/pull.command.ts"; import { DbPullDumpError, @@ -671,9 +668,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( spawner, shadowInput, (handle) => - prepareShadowSource(spawner, handle, shadowInput).pipe( - Effect.flatMap(runDiff), - ), + prepareShadowSource(spawner, handle, shadowInput).pipe(Effect.flatMap(runDiff)), { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, ); }); diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 274e07b7e7..e8f6f14647 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -56,22 +56,19 @@ const openProjectStack = () => /** Ready project stack, or none when the stack is missing or the database is not ready. */ export const stackOpenReadyProject = openProjectStack; -export const stackProjectRuntime: Effect.Effect< - StackRuntime | undefined, - never, - CommandSettings -> = Effect.gen(function* () { - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) return undefined; - const cliSettings = yield* CommandSettings; - const descriptor = yield* api.value - .findStack({ projectRoot: cliSettings.workdir }) - .pipe(Effect.orElseSucceed(() => Option.none())); - return Option.match(descriptor, { - onNone: () => undefined, - onSome: (value) => value.runtime, +export const stackProjectRuntime: Effect.Effect = + Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return undefined; + const cliSettings = yield* CommandSettings; + const descriptor = yield* api.value + .findStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.orElseSucceed(() => Option.none())); + return Option.match(descriptor, { + onNone: () => undefined, + onSome: (value) => value.runtime, + }); }); -}); export class StackRuntimeUnavailableError extends Data.TaggedError("StackRuntimeUnavailableError")<{ readonly message: string; @@ -127,18 +124,15 @@ export const stackRejectNativeDockerDiffEngine: Effect.Effect = Effect.gen(function* () { - const opened = yield* openProjectStack(); - if (Option.isNone(opened)) return yield* notRunning(); - const credentials = yield* opened.value.stack - .credentials() - .pipe(Effect.mapError((cause) => notRunning(cause.message))); - return Redacted.value(credentials.database.url); -}); +export const stackLocalDatabaseUrl: Effect.Effect = + Effect.gen(function* () { + const opened = yield* openProjectStack(); + if (Option.isNone(opened)) return yield* notRunning(); + const credentials = yield* opened.value.stack + .credentials() + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + return Redacted.value(credentials.database.url); + }); export const stackLocalDatabaseConn: Effect.Effect< PgConnInput, @@ -153,11 +147,8 @@ export const stackLocalDatabaseConn: Effect.Effect< return conn; }); -const stackLocalDatabaseIsRunning: Effect.Effect< - boolean, - LocalDbRunningError, - CommandSettings -> = openProjectStack().pipe(Effect.map(Option.isSome)); +const stackLocalDatabaseIsRunning: Effect.Effect = + openProjectStack().pipe(Effect.map(Option.isSome)); export const resolveLocalDatabaseIsRunning = ( spawner: ChildProcessSpawnerType["Service"], diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index d653a95586..07a1a605a7 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -37,6 +37,7 @@ import { SHADOW_BASELINE_KEEP, SHADOW_BASELINE_MAX_AGE_MS, SHADOW_CACHE_ENV, + canonicalJson, shadowBaselineTarsToEvict, touchShadowBaselineTar, } from "./db-bootstrap/shadow-cache.ts"; @@ -102,7 +103,7 @@ export const stackShadowCacheKey = (inputs: StackShadowCacheKeyInputs): string = `jwt_secret=${quoted(inputs.jwtSecret)}`, `jwt_expiry=${inputs.jwtExpiry}`, `db_password=${quoted(inputs.dbPassword)}`, - `db_settings=${JSON.stringify(inputs.dbSettings ?? {})}`, + `db_settings=${canonicalJson(inputs.dbSettings ?? {})}`, `bootstrap=${quoted(inputs.bootstrapIdentity)}`, ].join("\n"); return scryptSync( @@ -137,17 +138,6 @@ const cacheEnabled = (projectEnv: Record | undefined, bypass: bo whenUnset: true, }); -const canonicalSettings = (value: unknown): unknown => { - if (value === null || typeof value !== "object") return value ?? {}; - if (Array.isArray(value)) return value.map(canonicalSettings); - return Object.fromEntries( - Object.entries(value) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, canonicalSettings(entry)]), - ); -}; - const readRolesSql = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -444,7 +434,7 @@ export const stackAcquireShadowDatabase = ( jwtSecret: input.jwtSecret, jwtExpiry: input.jwtExpiry, dbPassword: input.password, - dbSettings: canonicalSettings(input.db.settings), + dbSettings: input.db.settings, rolesSql, bootstrapIdentity: databaseBootstrapIdentity, }); @@ -568,18 +558,12 @@ export const stackMigrateShadow = ( Effect.scoped( Effect.gen(function* () { const migrationsDir = input.path.join(input.workdir, "supabase", "migrations"); - const pending = yield* listLocalMigrationPaths( - input.fs, - input.path, - migrationsDir, - ).pipe( + const pending = yield* listLocalMigrationPaths(input.fs, input.path, migrationsDir).pipe( Effect.mapError( (cause) => new ShadowDbError({ message: cause.message, reason: "filesystem" }), ), ); - const session = yield* connectShadowDatabase( - connFrom(handle.ephemeral, input.password), - ); + const session = yield* connectShadowDatabase(connFrom(handle.ephemeral, input.password)); yield* applyMigrations( session, input.fs, diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 80cd8bea6e..de7605e327 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -26,26 +26,26 @@ it, and JSON `null` disables formatting without disabling safe compaction. | `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `migrateShadowDatabase` | | `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` only — slim-init + stack bootstrap baseline; key includes artifact identity and runtime kind (native vs container). Never mixed with `shadow-baseline-*.tar` | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` only — slim-init + stack bootstrap baseline; key includes artifact identity and runtime kind (native vs container). Never mixed with `shadow-baseline-*.tar` | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` abandoned-partial sweep — same 5-minute TTL, stack prefix only (legacy `shadow-baseline-*.partial` names are not candidates) | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` abandoned-partial sweep — same 5-minute TTL, stack prefix only (legacy `shadow-baseline-*.partial` names are not candidates) | | `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | | `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` COLD export of an `EphemeralPostgres` cluster; same LRU keep-3 + 2-day TTL, separate glob so keys cannot collide with legacy SQL-template baselines | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` cold export temp file — pid-scoped, `chmod` 0600, `rename`d into the stack tar above; abandoned leftovers older than 5 minutes are swept on later acquires | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| --------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` COLD export of an `EphemeralPostgres` cluster; same LRU keep-3 + 2-day TTL, separate glob so keys cannot collide with legacy SQL-template baselines | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` cold export temp file — pid-scoped, `chmod` 0600, `rename`d into the stack tar above; abandoned leftovers older than 5 minutes are swept on later acquires | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 17264af3e3..c1c140a2d5 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -673,10 +673,12 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { schemaPaths: cfg.schemaPathPatterns, pgDelta: cfg.pgDelta, }; - const runDiff = (shadow: Pick & { - readonly sourceUrl: string; - readonly targetUrlOverride?: string; - }) => + const runDiff = ( + shadow: Pick & { + readonly sourceUrl: string; + readonly targetUrlOverride?: string; + }, + ) => Effect.gen(function* () { const target = shadow.targetUrlOverride ?? targetUrl; yield* output.raw( diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 17760e6d5a..6f0b753220 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -1011,29 +1011,32 @@ describe("db dump integration", () => { }); }; - it.live("dump --local on the stack backend fails instead of using Docker when no stack exists", () => { - const { layer, docker } = setup({ isLocal: true, stdout: "-- schema\n" }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbDump(flags({ local: Option.some(true) }))); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toContain("Could not determine the stack runtime"); - expect(docker.lastOpts).toBeUndefined(); - }).pipe( - Effect.provide( - Layer.mergeAll( - layer, - stackBackendLayer("stack"), - Layer.succeed(StackApi, { - createStack: unusedDump, - findStack: () => Effect.succeed(Option.none()), - discoverStacks: unusedDump, - openStack: unusedDump, - inspectStack: unusedDump, - }), + it.live( + "dump --local on the stack backend fails instead of using Docker when no stack exists", + () => { + const { layer, docker } = setup({ isLocal: true, stdout: "-- schema\n" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbDump(flags({ local: Option.some(true) }))); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toContain("Could not determine the stack runtime"); + expect(docker.lastOpts).toBeUndefined(); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + stackBackendLayer("stack"), + Layer.succeed(StackApi, { + createStack: unusedDump, + findStack: () => Effect.succeed(Option.none()), + discoverStacks: unusedDump, + openStack: unusedDump, + inspectStack: unusedDump, + }), + ), ), - ), - ); - }); + ); + }, + ); it.live("dump --local on a docker stack never uses PGHOST=db", () => { const { layer, docker } = setup({ @@ -1046,7 +1049,13 @@ describe("db dump integration", () => { expect(docker.lastOpts?.env["PGHOST"]).toBe("host.docker.internal"); expect(docker.lastOpts?.env["PGHOST"]).not.toBe("db"); }).pipe( - Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), dumpStackApi({ kind: "container", engine: "docker" }))), + Effect.provide( + Layer.mergeAll( + layer, + stackBackendLayer("stack"), + dumpStackApi({ kind: "container", engine: "docker" }), + ), + ), ); }); @@ -1063,7 +1072,11 @@ describe("db dump integration", () => { expect(docker.lastOpts?.env["PGHOST"]).toBe("host.docker.internal"); }).pipe( Effect.provide( - Layer.mergeAll(layer, stackBackendLayer("stack"), dumpStackApi({ kind: "container", engine: "docker" })), + Layer.mergeAll( + layer, + stackBackendLayer("stack"), + dumpStackApi({ kind: "container", engine: "docker" }), + ), ), ); }); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 19501b346e..b2421ff19a 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -33,7 +33,10 @@ import { resolvePgDeltaProjectId, } from "../../../../../command-internal/pgdelta.ts"; import { writePgDeltaMigrations } from "../../../shared/pgdelta-migrations.write.ts"; -import { resolveLocalTargetEndpoint, resolveSmartTargetEndpoint } from "../declarative.smart-target.ts"; +import { + resolveLocalTargetEndpoint, + resolveSmartTargetEndpoint, +} from "../declarative.smart-target.ts"; import { type DebugBundle, collectMigrationsList, @@ -307,12 +310,12 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f ); } const generated = yield* generateDeclarativeOutput( - { ...run, declarativeDir: stagedDir }, - yield* resolveLocalTargetEndpoint( - { port: toml.port, password: toml.password }, - dnsResolver, - ), - ); + { ...run, declarativeDir: stagedDir }, + yield* resolveLocalTargetEndpoint( + { port: toml.port, password: toml.password }, + dnsResolver, + ), + ); const written = yield* writeDeclarativeSchemas(fs, path, stagedDir, generated); yield* warnPreservedUnmanagedDeclarativeFiles(stagedDirRel, written); yield* output.raw(declarativeSchemaWrittenLine(stagedDirRel), "stderr"); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 809c32b36a..68229beff6 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -138,128 +138,132 @@ export const declarativeSeamLayer = Layer.effect( if (backend.kind === "stack") return; return yield* Effect.scoped( Effect.gen(function* () { - const toml = yield* readDbToml(fs, path, cliSettings.workdir).pipe( - Effect.mapError( - (error) => - new DeclarativeShadowDbError({ - message: `failed to read config for local Postgres image check: ${error.message}`, - }), - ), - ); - const { image } = yield* resolveDbImage( - fs, - path, - cliSettings.workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - ); - const tomlProjectId = toml.projectId; - const projectId = resolveLocalProjectId( - Option.getOrUndefined(cliSettings.projectId), - Option.getOrUndefined(tomlProjectId), - cliSettings.workdir, - ); - const containerId = localDbContainerId(projectId); - const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - extendEnv: true, - }).pipe( - Effect.mapError( - () => - new DeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - const stdoutChunks: Array = []; - const stderrChunks: Array = []; - yield* Stream.runForEach(child.stdout, (chunk) => - Effect.sync(() => { - stdoutChunks.push(chunk); - }), - ).pipe( - Effect.mapError( - () => - new DeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - yield* Stream.runForEach(child.stderr, (chunk) => - Effect.sync(() => { - stderrChunks.push(chunk); - }), - ).pipe( - Effect.mapError( - () => - new DeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - const inspectExit = yield* child.exitCode.pipe( - Effect.map(Number), - Effect.mapError( - () => + const toml = yield* readDbToml(fs, path, cliSettings.workdir).pipe( + Effect.mapError( + (error) => + new DeclarativeShadowDbError({ + message: `failed to read config for local Postgres image check: ${error.message}`, + }), + ), + ); + const { image } = yield* resolveDbImage( + fs, + path, + cliSettings.workdir, + toml.majorVersion, + Option.getOrUndefined(toml.orioledbVersion), + ); + const tomlProjectId = toml.projectId; + const projectId = resolveLocalProjectId( + Option.getOrUndefined(cliSettings.projectId), + Option.getOrUndefined(tomlProjectId), + cliSettings.workdir, + ); + const containerId = localDbContainerId(projectId); + const child = yield* spawnContainerCli( + spawner, + ["container", "inspect", containerId], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }, + ).pipe( + Effect.mapError( + () => + new DeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + docker: "daemon", + }), + ), + ); + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stdout, (chunk) => + Effect.sync(() => { + stdoutChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => + new DeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + docker: "daemon", + }), + ), + ); + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => + new DeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + docker: "daemon", + }), + ), + ); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + () => + new DeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + docker: "daemon", + }), + ), + ); + const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes).trim(); + }; + const stderr = decodeChunks(stderrChunks); + const stdout = decodeChunks(stdoutChunks); + if (inspectExit !== 0) { + if (isMissingContainerInspectError(stderr)) return; + return yield* Effect.fail( new DeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", + message: + stderr.length > 0 + ? `failed to inspect local Postgres container: ${stderr}` + : "failed to inspect local Postgres container.", + ...shadowDockerCause(stderr), }), - ), - ); - const decodeChunks = (chunks: ReadonlyArray): string => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; + ); + } + const actual = resolveContainerInspectImageName(stdout); + const expected = getRegistryImageUrl(image).trim(); + const actualTag = dockerImageTag(actual); + const expectedTag = dockerImageTag(expected); + if (actual.length === 0 || actualTag.length === 0 || expectedTag.length === 0) { + return; } - return new TextDecoder().decode(bytes).trim(); - }; - const stderr = decodeChunks(stderrChunks); - const stdout = decodeChunks(stdoutChunks); - if (inspectExit !== 0) { - if (isMissingContainerInspectError(stderr)) return; + // Slim refs never go through a registry mirror, so a family mismatch + // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli + // expectation) is stale even when the tags happen to match. + const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); + if (!familyMismatch && actualTag === expectedTag) { + return; + } + const remediation = + familyMismatch && actualTag === expectedTag + ? "The tags match but the image family does not (slim vs docker.io). Run supabase stop, then supabase start with the same SUPABASE_USE_SLIM_IMAGES setting before syncing declarative schemas." + : "Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas."; return yield* Effect.fail( new DeclarativeShadowDbError({ - message: - stderr.length > 0 - ? `failed to inspect local Postgres container: ${stderr}` - : "failed to inspect local Postgres container.", - ...shadowDockerCause(stderr), + message: `local Postgres container image is stale: running ${actual} but expected ${expected}. ${remediation}`, }), ); - } - const actual = resolveContainerInspectImageName(stdout); - const expected = getRegistryImageUrl(image).trim(); - const actualTag = dockerImageTag(actual); - const expectedTag = dockerImageTag(expected); - if (actual.length === 0 || actualTag.length === 0 || expectedTag.length === 0) { - return; - } - // Slim refs never go through a registry mirror, so a family mismatch - // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli - // expectation) is stale even when the tags happen to match. - const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); - if (!familyMismatch && actualTag === expectedTag) { - return; - } - const remediation = - familyMismatch && actualTag === expectedTag - ? "The tags match but the image family does not (slim vs docker.io). Run supabase stop, then supabase start with the same SUPABASE_USE_SLIM_IMAGES setting before syncing declarative schemas." - : "Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas."; - return yield* Effect.fail( - new DeclarativeShadowDbError({ - message: `local Postgres container image is stale: running ${actual} but expected ${expected}. ${remediation}`, - }), - ); - }), + }), ); }), }); diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 2e40c11528..11824bec65 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1526,10 +1526,7 @@ describe("db start stack backend", () => { const STACK_ID = StackIdSchema.make("b".repeat(64)); const unused = () => Effect.die("unused"); - function mockStackApi(opts: { - readonly existing?: boolean; - readonly databaseReady?: boolean; - }) { + function mockStackApi(opts: { readonly existing?: boolean; readonly databaseReady?: boolean }) { const startConfigs: Array = []; const stack: EffectStack = { id: STACK_ID, diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 2dbee02bae..0cd954abd6 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -52,6 +52,8 @@ stack = true expect(yield* resolve({ args: ["stop"], cwd: root, env: {} })).toBe("stack"); expect(yield* resolve({ args: ["status"], cwd: root, env: {} })).toBe("legacy"); expect(yield* resolve({ args: ["db", "diff"], cwd: root, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["db", "test"], cwd: root, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["test", "db"], cwd: root, env: {} })).toBe("stack"); expect(yield* resolve({ args: ["migration", "squash"], cwd: root, env: {} })).toBe("stack"); }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index 2f2e68e95f..558c28f2f1 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -13,7 +13,7 @@ import { export type StackBackend = "legacy" | "stack"; /** Commands that consult experimental.stack for local database and shadow routing. */ -const STACK_BACKEND_COMMANDS = new Set(["start", "stop", "db", "migration"]); +const STACK_BACKEND_COMMANDS = new Set(["start", "stop", "db", "migration", "test"]); export class StackRoutingError extends Data.TaggedError("StackRoutingError")<{ readonly message: string; diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts index fc2a878794..4d164cab12 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.options.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -1,4 +1,8 @@ -import { CAPABILITY_NAMES, excludeStackCapabilities, type StackConfig } from "@supabase/stack/effect"; +import { + CAPABILITY_NAMES, + excludeStackCapabilities, + type StackConfig, +} from "@supabase/stack/effect"; /** Optional capabilities accepted by `stack start --exclude`. */ export const STACK_START_EXCLUDABLE_CAPABILITIES = CAPABILITY_NAMES.filter( diff --git a/apps/cli/src/commands/migration/squash/squash.dump.ts b/apps/cli/src/commands/migration/squash/squash.dump.ts index 351efdadd3..be8321a2bf 100644 --- a/apps/cli/src/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/commands/migration/squash/squash.dump.ts @@ -3,7 +3,10 @@ import { Effect } from "effect"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { buildSchemaDumpEnv, type DumpOptions } from "../../../command-internal/pg-dump.env.ts"; import { dumpSchemaScript } from "../../../command-internal/pg-dump.scripts.ts"; -import { streamPgDumpWithClient, type PgDumpClient } from "../../../command-internal/pg-dump.run.ts"; +import { + streamPgDumpWithClient, + type PgDumpClient, +} from "../../../command-internal/pg-dump.run.ts"; import { MigrationSquashDumpError } from "./squash.errors.ts"; /** diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9f750f3cfb..76a09960d9 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -201,6 +201,7 @@ DbResetSeedFlagsError DbResetTargetFlagsError DbResetVersionFlagsError DbSetupError +DbStartFromBackupUnsupportedError DeclarativeApplyError DeclarativeCompatibilityError DeclarativeDiffError @@ -279,6 +280,7 @@ GenTypesWorkdirError GoChildExitError HealthCheckProbeError HealthCheckTimeoutError +HostPostgresClientError ImagePrepullError InitConfigExistsError InitExperimentalRequiredError @@ -421,6 +423,7 @@ PullOutputFlagUnsupportedError PullParentRefInvalidError PullUncommittedChangesError PullWorkdirError +ResetLocalDbFailedError ResetLocalDbNotRunningError ResetReplicationSlotsError RestartServicesError @@ -488,7 +491,9 @@ SsoUpdateNetworkError SsoUpdateNotFoundError SsoUpdateUnexpectedStatusError StackConfigError +StackNativeEngineError StackRoutingError +StackRuntimeUnavailableError StartBackupVolumeExistsError StartConfigLoadError StartInvalidConfigError diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts index 9400cdc90d..710d03b0ba 100644 --- a/packages/stack/src/runtime/EphemeralPostgres.ts +++ b/packages/stack/src/runtime/EphemeralPostgres.ts @@ -659,18 +659,18 @@ const startContainer = ( ): Effect.Effect => Effect.gen(function* () { if (cluster.resources.kind !== "container") return; + const resources = cluster.resources; const image = cluster.image; if (image === undefined) return yield* ephemeralError("Ephemeral Postgres image is unavailable"); - const networkId = cluster.resources.networkId; - const volumeId = cluster.resources.volumeId; + const networkId = resources.networkId; + const volumeId = resources.volumeId; if (networkId === undefined || volumeId === undefined) return yield* ephemeralError("Ephemeral Postgres volume is unavailable"); yield* Effect.gen(function* () { - if (cluster.resources.kind !== "container") return; - if (cluster.resources.containerId !== undefined) { - yield* cluster.resources.engine - .startContainer(cluster.resources.containerId) + if (resources.containerId !== undefined) { + yield* resources.engine + .startContainer(resources.containerId) .pipe( Effect.mapError((cause) => ephemeralError("Unable to start ephemeral Postgres", { cause }), @@ -686,37 +686,43 @@ const startContainer = ( password, }), ); - const created = yield* cluster.resources.engine - .createContainer({ - name: resourceName(cluster.identity, "database"), - image, - labels: { - stackId: cluster.identity, - ownerSessionId: cluster.identity.slice(0, 32), - workloadId: DATABASE_WORKLOAD_ID, - role: "workload", - }, - network: networkId, - mounts: [], - volumeMounts: [ - { - volume: volumeId, - target: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, - readOnly: false, + const created = yield* Effect.uninterruptibleMask((restore) => + restore( + resources.engine.createContainer({ + name: resourceName(cluster.identity, "database"), + image, + labels: { + stackId: cluster.identity, + ownerSessionId: cluster.identity.slice(0, 32), + workloadId: DATABASE_WORKLOAD_ID, + role: "workload", }, - ], - publications: [{ address: "127.0.0.1", hostPort: cluster.port, containerPort: 5432 }], - role: "workload", - command: postgresArgs(5432, cluster.runtime, options.postgresSettings), - envFile, - }) - .pipe( + network: networkId, + mounts: [], + volumeMounts: [ + { + volume: volumeId, + target: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, + readOnly: false, + }, + ], + publications: [{ address: "127.0.0.1", hostPort: cluster.port, containerPort: 5432 }], + role: "workload", + command: postgresArgs(5432, cluster.runtime, options.postgresSettings), + envFile, + }), + ).pipe( Effect.mapError((cause) => ephemeralError("Unable to create ephemeral Postgres", { cause }), ), - ); - cluster.resources.containerId = created.id; - yield* cluster.resources.engine + Effect.tap((created) => + Effect.sync(() => { + resources.containerId = created.id; + }), + ), + ), + ); + yield* resources.engine .startContainer(created.id) .pipe( Effect.mapError((cause) => @@ -1001,14 +1007,16 @@ export const createEphemeralPostgresCluster = ( }; yield* Effect.addFinalizer(() => destroyCluster(cluster)); if (cluster.resources.kind === "container") { - const engine = cluster.resources.engine; + const resources = cluster.resources; + const engine = resources.engine; const engineKind = cluster.runtime.kind === "container" ? cluster.runtime.engine : "docker"; - const network = yield* engine - .createNetwork({ - name: resourceName(identity, "network"), - labels: { stackId: identity, ownerSessionId: identity.slice(0, 32), role: "network" }, - }) - .pipe( + yield* Effect.uninterruptibleMask((restore) => + restore( + engine.createNetwork({ + name: resourceName(identity, "network"), + labels: { stackId: identity, ownerSessionId: identity.slice(0, 32), role: "network" }, + }), + ).pipe( Effect.mapError( (cause) => new ContainerEngineError({ @@ -1017,14 +1025,20 @@ export const createEphemeralPostgresCluster = ( cause, }), ), - ); - cluster.resources.networkId = network.id; - const volume = yield* engine - .createVolume({ - name: resourceName(identity, "database-volume"), - labels: { stackId: identity, workloadId: DATABASE_WORKLOAD_ID, role: "volume" }, - }) - .pipe( + Effect.tap((created) => + Effect.sync(() => { + resources.networkId = created.id; + }), + ), + ), + ); + yield* Effect.uninterruptibleMask((restore) => + restore( + engine.createVolume({ + name: resourceName(identity, "database-volume"), + labels: { stackId: identity, workloadId: DATABASE_WORKLOAD_ID, role: "volume" }, + }), + ).pipe( Effect.mapError( (cause) => new ContainerEngineError({ @@ -1033,8 +1047,13 @@ export const createEphemeralPostgresCluster = ( cause, }), ), - ); - cluster.resources.volumeId = volume.id; + Effect.tap((created) => + Effect.sync(() => { + resources.volumeId = created.id; + }), + ), + ), + ); } if (options.restoreFrom !== undefined) { if (runtime.kind === "native") yield* restoreNative(cluster, options.restoreFrom); From dfe3e5bbdb68f857d32ac43b8b3f396f590068e4 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 20:44:06 +0200 Subject: [PATCH 11/14] fix(cli): move stack routing and StackApi into command-internal db/migration and db-bootstrap cannot import experimental command internals, and knip flagged an unused local-running probe. --- AGENTS.md | 4 +- apps/cli/src/cli/complete.unit.test.ts | 2 +- apps/cli/src/cli/main.ts | 2 +- apps/cli/src/cli/root.ts | 4 +- .../db-bootstrap/reset-local-database.ts | 2 +- .../db-config.integration.test.ts | 5 +- .../src/command-internal/db-config.layer.ts | 4 +- apps/cli/src/command-internal/db-pull-run.ts | 2 +- apps/cli/src/command-internal/pg-dump.run.ts | 2 +- .../pgdelta-engine-runtime.layer.ts | 2 +- apps/cli/src/command-internal/stack-api.ts | 70 +++++++++++++++++ .../stack-backend.ts | 8 +- .../stack-local-database.integration.test.ts | 5 +- .../command-internal/stack-local-database.ts | 30 +------ .../stack-shadow.integration.test.ts | 2 +- .../src/command-internal/test-db.handler.ts | 2 +- .../src/command-internal/test-db.layers.ts | 3 +- apps/cli/src/commands/db/diff/diff.handler.ts | 4 +- .../commands/db/diff/diff.integration.test.ts | 2 +- apps/cli/src/commands/db/dump/dump.handler.ts | 2 +- .../commands/db/dump/dump.integration.test.ts | 4 +- apps/cli/src/commands/db/dump/dump.layers.ts | 3 +- .../db/reset/reset.integration.test.ts | 4 +- .../cli/src/commands/db/reset/reset.layers.ts | 3 +- .../declarative/declarative.smart-target.ts | 2 +- .../generate/generate.integration.test.ts | 2 +- .../schema/declarative/sync/sync.handler.ts | 2 +- .../declarative/sync/sync.integration.test.ts | 4 +- .../db/shared/pgdelta-next-shadow.layer.ts | 2 +- .../shared/pgdelta.seam.integration.test.ts | 2 +- .../commands/db/shared/pgdelta.seam.layer.ts | 4 +- .../src/commands/db/start/start.handler.ts | 2 +- .../db/start/start.integration.test.ts | 4 +- .../cli/src/commands/db/start/start.layers.ts | 3 +- .../stack/stack-backend.integration.test.ts | 2 +- .../experimental/stack/stack.shared.ts | 78 ++----------------- .../commands/migration/migration.layers.ts | 2 +- .../migration/squash/squash.handler.ts | 2 +- package.json | 4 +- 39 files changed, 129 insertions(+), 157 deletions(-) create mode 100644 apps/cli/src/command-internal/stack-api.ts rename apps/cli/src/{commands/experimental/stack => command-internal}/stack-backend.ts (95%) diff --git a/AGENTS.md b/AGENTS.md index fd890afc1e..f9c184a1d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,8 @@ reference. Published `apps/cli` and `packages/config` are not private; `apps/doc root-owned. Effect lint covers `packages/stack`, all files under `apps/cli/src/commands/experimental/stack` and `apps/cli/src/commands/experimental/compute`, the shared `apps/cli/src/shared/compute` runtime helpers (excluding embedded starter templates), the -Compute test fixture helper, and `apps/cli/src/command-internal/experimental-feature.ts`; use the -root scripts for it. +Compute test fixture helper, and `apps/cli/src/command-internal/experimental-feature.ts`, +`stack-backend.ts`, and `stack-api.ts`; use the root scripts for it. ### Config Naming Vocabulary diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 92dd1bf716..9bee1c46ca 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { Cause } from "effect"; import { rootCommand } from "./root.ts"; -import { StackRoutingError } from "../commands/experimental/stack/stack-backend.ts"; +import { StackRoutingError } from "../command-internal/stack-backend.ts"; import { CompletionDirective, type ClassifyCompletionInput, diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 01271f4bb2..972d154e36 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -5,7 +5,7 @@ import { runCli } from "../shared/cli/run.ts"; import { upgradeNoticeHook } from "../command-internal/upgrade-notice.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { defaultCompleteDeps, tryComplete } from "./complete.ts"; -import { resolveStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { resolveStackBackend } from "../command-internal/stack-backend.ts"; import { resolveComputeEnabled } from "../commands/experimental/compute/compute-backend.ts"; import { rootCommandForFeatures } from "./root.ts"; diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 0b0e555a54..1313deabf5 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -11,8 +11,8 @@ import { encryptionCommand } from "../commands/encryption/encryption.command.ts" import { stackRuntimeLayer, stackCommand } from "../commands/experimental/stack/stack.command.ts"; import { stackStartCommand } from "../commands/experimental/stack/start/start.command.ts"; import { stackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts"; -import type { StackBackend } from "../commands/experimental/stack/stack-backend.ts"; -import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; +import type { StackBackend } from "../command-internal/stack-backend.ts"; +import { stackBackendLayer } from "../command-internal/stack-backend.ts"; import { computeCommand } from "../commands/experimental/compute/compute.command.ts"; import { feedbackCommand } from "../commands/feedback/feedback.command.ts"; import { functionsCommand } from "../commands/functions/functions.command.ts"; diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 48bbc36cef..256f5de270 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -38,7 +38,7 @@ import { resolveResetSeedConfig } from "./db-setup.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; -import { currentStackBackend } from "../../commands/experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../stack-backend.ts"; import { stackLocalDatabaseConn, stackOpenReadyProject } from "../stack-local-database.ts"; /** The local database container is not running. */ diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index a0172c3987..1705ae097d 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -27,9 +27,8 @@ import { dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConfigFlags } from "./db-config.types.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; -import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; -import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; - +import { stackBackendLayer } from "./stack-backend.ts"; +import { StackApi } from "./stack-api.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver // builds with simple ambient stubs. The `--linked` sub-flow (login-role, // pooler, unban, backoff) requires the real management runtime with a mocked diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f2cce25f37..8697a4641b 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -38,8 +38,8 @@ import type { DbConfigFlags } from "./db-config.types.ts"; import { DebugLogger } from "./debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; import { mapHttpError } from "./http-errors.ts"; -import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; -import { StackApi, stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; +import { currentStackBackend } from "./stack-backend.ts"; +import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; const DIRECT_PORT = 5432; diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 04125b7c1c..62f606c3ac 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -66,7 +66,7 @@ import { } from "../commands/db/shared/pgdelta-engine.service.ts"; import { type PgDeltaContext, isPgDeltaDebugEnabled, resolvePgDeltaProjectId } from "./pgdelta.ts"; import { prepareShadowSource } from "../commands/db/shared/shadow-source.ts"; -import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "./stack-backend.ts"; import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; import { stackPrepareShadowSource, stackWithShadowDatabase } from "./stack-shadow.ts"; import type { DbPullFlags } from "../commands/db/pull/pull.command.ts"; diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index 5613d530eb..79ca1d5004 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -5,7 +5,7 @@ import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { getRegistryImageUrl } from "./docker-registry.ts"; import { DockerRun } from "./docker-run.service.ts"; -import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "./stack-backend.ts"; import { requireHostPostgresClient, streamHostCommand } from "./postgres-client.run.ts"; /** diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts index dcb3ef681c..e145965b10 100644 --- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts @@ -14,7 +14,7 @@ import { pgDeltaNextAdapterLayer } from "../commands/db/shared/pgdelta-next-adap import { pgDeltaNextShadowLayer } from "../commands/db/shared/pgdelta-next-shadow.layer.ts"; import { declarativeSeamLayer } from "../commands/db/shared/pgdelta.seam.layer.ts"; import { localDockerEngineLayer } from "./db-bootstrap/local-db-running.ts"; -import { stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; +import { stackApiLayer } from "./stack-api.ts"; import { ephemeralPostgresLayer } from "./stack-shadow.ts"; /** The in-process pg-delta engine — the only implementation. */ diff --git a/apps/cli/src/command-internal/stack-api.ts b/apps/cli/src/command-internal/stack-api.ts new file mode 100644 index 0000000000..09249ac4a3 --- /dev/null +++ b/apps/cli/src/command-internal/stack-api.ts @@ -0,0 +1,70 @@ +import { Context, Crypto, Effect, FileSystem, Layer, Path } from "effect"; +import { + createStack, + discoverStacks, + findStack, + inspectStack, + openStack, + type StackDiscoveryResult, +} from "@supabase/stack/effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +export class StackApi extends Context.Service< + StackApi, + { + readonly findStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly createStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly openStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly inspectStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly discoverStacks: ( + ...args: Parameters + ) => Effect.Effect>>; + } +>()("supabase/experimental-stack/StackApi") {} + +export const stackApiLayer = Layer.effect( + StackApi, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const childProcess = yield* ChildProcessSpawner.ChildProcessSpawner; + const provideServices = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcess), + ); + return { + findStack: (...args: Parameters) => provideServices(findStack(...args)), + createStack: (...args: Parameters) => + provideServices(createStack(...args)), + openStack: (...args: Parameters) => provideServices(openStack(...args)), + inspectStack: (...args: Parameters) => + provideServices(inspectStack(...args)), + discoverStacks: (...args: Parameters) => + provideServices(discoverStacks(...args)), + }; + }), +); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/command-internal/stack-backend.ts similarity index 95% rename from apps/cli/src/commands/experimental/stack/stack-backend.ts rename to apps/cli/src/command-internal/stack-backend.ts index 558c28f2f1..d7b789721b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/command-internal/stack-backend.ts @@ -1,14 +1,14 @@ import { CliConfigSchema } from "@supabase/config/effect"; import { Context, Data, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; -import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; -import { resolveExperimentalFeature } from "../../../command-internal/experimental-feature.ts"; -import { extractCommandPath, hasRootVersionFlag, rootFlagTokens } from "../../../shared/cli/run.ts"; +import { resolveWorkdir } from "../config/command-settings.layer.ts"; +import { resolveExperimentalFeature } from "./experimental-feature.ts"; +import { extractCommandPath, hasRootVersionFlag, rootFlagTokens } from "../shared/cli/run.ts"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, -} from "../../../shared/telemetry/error-actionability.ts"; +} from "../shared/telemetry/error-actionability.ts"; export type StackBackend = "legacy" | "stack"; diff --git a/apps/cli/src/command-internal/stack-local-database.integration.test.ts b/apps/cli/src/command-internal/stack-local-database.integration.test.ts index 470ec8e459..d829be0591 100644 --- a/apps/cli/src/command-internal/stack-local-database.integration.test.ts +++ b/apps/cli/src/command-internal/stack-local-database.integration.test.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Redacted, Stream } from "effect"; import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { mockCommandSettings, useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; -import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; +import { stackBackendLayer } from "./stack-backend.ts"; import { stackLocalDatabaseUrl } from "./stack-local-database.ts"; -import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; - +import { StackApi } from "./stack-api.ts"; const tmp = useTempWorkdir("stack-local-db-"); const STACK_ID = StackIdSchema.make("a".repeat(64)); diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 6048ae2fa4..96ea661b13 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -4,19 +4,14 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; import type { EffectStack } from "@supabase/stack/effect"; import type { StackRuntime } from "@supabase/stack/effect"; import { parseConnectionString } from "./db-config.parse.ts"; import type { PgConnInput } from "./db-connection.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { - LocalDbRunningError, - isLocalDbRunning, - type LocalDockerEngine, -} from "./db-bootstrap/local-db-running.ts"; -import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; -import { StackApi } from "../commands/experimental/stack/stack.shared.ts"; +import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; +import { currentStackBackend } from "./stack-backend.ts"; +import { StackApi } from "./stack-api.ts"; import { loadStackConfig } from "../commands/experimental/stack/stack-config.ts"; import { postgresOnlyStackStartConfig } from "../commands/experimental/stack/start/start.options.ts"; @@ -145,25 +140,6 @@ export const stackLocalDatabaseConn: Effect.Effect< return conn; }); -const stackLocalDatabaseIsRunning: Effect.Effect = - openProjectStack().pipe(Effect.map(Option.isSome)); - -export const resolveLocalDatabaseIsRunning = ( - spawner: ChildProcessSpawnerType["Service"], - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - configuredProjectId: string | undefined, -): Effect.Effect => - Effect.gen(function* () { - const backend = yield* currentStackBackend; - if (backend.kind === "legacy") - return yield* isLocalDbRunning(spawner, fs, path, workdir, configuredProjectId); - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) return false; - return yield* stackLocalDatabaseIsRunning.pipe(Effect.provideService(StackApi, api.value)); - }); - export const stackEnsureLocalDatabaseStarted: Effect.Effect< void, LocalDbRunningError, diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index 6993737df2..a53f641bd7 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -16,7 +16,7 @@ import { } from "../../tests/helpers/command-mocks.ts"; import { SHADOW_CACHE_ENV } from "./db-bootstrap/shadow-cache.ts"; import { DbConnection } from "./db-connection.service.ts"; -import { stackBackendLayer } from "../commands/experimental/stack/stack-backend.ts"; +import { stackBackendLayer } from "./stack-backend.ts"; import { StackEphemeralPostgres, stackAcquireShadowDatabase, diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index e2f1b7c28d..a80c761251 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -21,7 +21,7 @@ import { TestDbRunError, } from "./test-db.errors.ts"; import { buildPgProveArgs } from "./test-db.pg-prove-args.ts"; -import { currentStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "./stack-backend.ts"; import { stackRequireProjectRuntime } from "./stack-local-database.ts"; import { rewriteDumpHostForToolContainer, diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index c885c7bd1d..dfe8a84031 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -8,8 +8,7 @@ import { identityStitchLayer } from "./identity-stitch.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { telemetryStateLayer } from "../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; -import { stackApiLayer } from "../commands/experimental/stack/stack.shared.ts"; - +import { stackApiLayer } from "./stack-api.ts"; /** * Runtime layer shared by `supabase test db` and its hidden alias `supabase * db test`, both calling this same factory and `runTestDbCommand`. diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index c1c140a2d5..5175a73e61 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -28,8 +28,8 @@ import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; import { schemaToCsvField } from "../../../command-internal/schema-flags.ts"; import { findDropStatements } from "../../../command-internal/sql-split.ts"; import { buildLocalDbContainerInputs } from "../../../command-internal/db-bootstrap/local-container-inputs.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; -import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; +import { StackApi } from "../../../command-internal/stack-api.ts"; import { stackLocalDatabaseConn, stackRejectNativeDockerDiffEngine, diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index c7d249a97f..3bdbee47bd 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -63,7 +63,7 @@ import { } from "../shared/pgdelta-engine.service.ts"; import type { DbDiffFlags } from "./diff.command.ts"; import { dbDiff } from "./diff.handler.ts"; -import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; +import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { StackNativeEngineError } from "../../../command-internal/stack-local-database.ts"; import { PGADMIN_DESKTOP_NOTE_PREFIX, PGADMIN_DIFF_HEADER } from "./pgadmin-diff.ts"; diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index d03061008b..421bc89438 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -40,7 +40,7 @@ import { dumpConnForHostClient, rewriteDumpHostForToolContainer, } from "../../../command-internal/postgres-client.run.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { stackRequireProjectRuntime } from "../../../command-internal/stack-local-database.ts"; import { viperEnvStringWithProjectFallback } from "../../../command-internal/viper-env.ts"; import { runWithPoolerFallback } from "../shared/pooler-fallback.ts"; diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 2c8320ba5b..29f08d6261 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -35,8 +35,8 @@ import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; import type { DbDumpFlags } from "./dump.command.ts"; import { dbDump } from "./dump.handler.ts"; -import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; -import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { StackApi } from "../../../command-internal/stack-api.ts"; import { StackIdSchema, type EffectStack } from "@supabase/stack/effect"; const LOCAL_CONN: PgConnInput = { diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index 2414cc2a3b..6181526246 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -13,8 +13,7 @@ import { identityStitchLayer } from "../../../command-internal/identity-stitch.t import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { stackApiLayer } from "../../experimental/stack/stack.shared.ts"; - +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; /** * Runtime layer for `supabase db dump`. * diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index a758eb3efa..d1f64666b0 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -42,8 +42,8 @@ import { } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; -import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; -import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { StackApi } from "../../../command-internal/stack-api.ts"; import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index cfc1875484..e6667599f3 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -15,8 +15,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; -import { stackApiLayer } from "../../experimental/stack/stack.shared.ts"; - +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; /** * Runtime layer for `supabase db reset`: the Postgres connection, the db-config resolver, * project-ref resolution, and the linked-project cache, all over the lazy management-API factory diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts index 52208ab134..ddcac98275 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts @@ -8,7 +8,7 @@ import { promptYesNo } from "../../../../command-internal/prompt-yes-no.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { resetLocalDatabase } from "../../../../command-internal/db-bootstrap/reset-local-database.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/project-ref.service.ts"; -import { currentStackBackend } from "../../../experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../../../../command-internal/stack-backend.ts"; import { DbConfigResolver } from "../../../../command-internal/db-config.service.ts"; import { loadProjectEnv } from "../../../../command-internal/db-config.toml-read.ts"; import { diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index b3ae60696f..bf68e2a5fb 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -41,7 +41,7 @@ import { GoProxy } from "../../../../../command-internal/go-proxy.service.ts"; import { CommandPlatformApi } from "../../../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../../../auth/command-platform-api-factory.service.ts"; import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; -import { stackBackendLayer } from "../../../../experimental/stack/stack-backend.ts"; +import { stackBackendLayer } from "../../../../../command-internal/stack-backend.ts"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; import { type DbSession, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index b2421ff19a..4e574318c4 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -11,7 +11,7 @@ import { Tty } from "../../../../../shared/runtime/tty.service.ts"; import { CommandSettings } from "../../../../../config/command-settings.service.ts"; import { resetLocalDatabase } from "../../../../../command-internal/db-bootstrap/reset-local-database.ts"; import { stackLocalDatabaseConn } from "../../../../../command-internal/stack-local-database.ts"; -import { currentStackBackend } from "../../../../experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../../../../../command-internal/stack-backend.ts"; import { bold, red, yellow } from "../../../../../command-internal/colors.ts"; import { DbConnection } from "../../../../../command-internal/db-connection.service.ts"; import { getHostname } from "../../../../../command-internal/hostname.ts"; diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 5b0eb4d5aa..1dda37faa0 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -39,8 +39,8 @@ import { import { CommandPlatformApi } from "../../../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../../../auth/command-platform-api-factory.service.ts"; import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; -import { stackBackendLayer } from "../../../../experimental/stack/stack-backend.ts"; -import { StackApi } from "../../../../experimental/stack/stack.shared.ts"; +import { stackBackendLayer } from "../../../../../command-internal/stack-backend.ts"; +import { StackApi } from "../../../../../command-internal/stack-api.ts"; import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; import { diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index e12b339e4d..cda95c334f 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -49,7 +49,7 @@ import { type PgDeltaNextShadowInput, } from "./pgdelta-next-shadow.service.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { stackAcquireShadowDatabase, stackMigrateShadow, diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index d873cde47c..63a11c21fc 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -29,7 +29,7 @@ import { import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { SUGGEST_DOCKER_INSTALL } from "../../../command-internal/docker-suggest.ts"; -import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; +import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { declarativeSeamLayer } from "./pgdelta.seam.layer.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 68229beff6..1a343794c1 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -13,8 +13,8 @@ import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start import { resolveLocalProjectId, localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; -import { StackApi, stackApiLayer } from "../../experimental/stack/stack.shared.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; +import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; import { stackEnsureLocalDatabaseStarted } from "../../../command-internal/stack-local-database.ts"; const shadowDockerCause = (stderr: string): { readonly docker: "daemon" } | Record => diff --git a/apps/cli/src/commands/db/start/start.handler.ts b/apps/cli/src/commands/db/start/start.handler.ts index 39d08aab28..c3185eedbb 100644 --- a/apps/cli/src/commands/db/start/start.handler.ts +++ b/apps/cli/src/commands/db/start/start.handler.ts @@ -4,7 +4,7 @@ import { Output } from "../../../shared/output/output.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; import { stackEnsurePostgresOnlyStarted } from "../../../command-internal/stack-local-database.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { DbStartFromBackupUnsupportedError } from "./start.errors.ts"; import type { DbStartFlags } from "./start.command.ts"; diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 3dfa27d60a..971e597f18 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -34,8 +34,8 @@ import { DbConnection, type DbSession } from "../../../command-internal/db-conne import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { dbStart } from "./start.handler.ts"; import type { DbStartFlags } from "./start.command.ts"; -import { stackBackendLayer } from "../../experimental/stack/stack-backend.ts"; -import { StackApi } from "../../experimental/stack/stack.shared.ts"; +import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { StackApi } from "../../../command-internal/stack-api.ts"; import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; const DEFAULT_FLAGS: DbStartFlags = { fromBackup: Option.none() }; diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index ddf2ebb597..a8a545a013 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -8,8 +8,7 @@ import { dbConnectionLayer } from "../../../command-internal/db-connection.layer import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; -import { stackApiLayer } from "../../experimental/stack/stack.shared.ts"; - +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; /** * Runtime layer for `supabase db start`, matching `supabase start`'s own composition. * `dockerRunLayer`/`dbConnectionLayer`/`httpClientLayer` back the native container diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 0cd954abd6..46ed9b84a8 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option } from "effect"; import { respondToComplete } from "../../../cli/complete.ts"; import { rootCommandForFeatures } from "../../../cli/root.ts"; -import { StackRoutingError, resolveStackBackend } from "./stack-backend.ts"; +import { StackRoutingError, resolveStackBackend } from "../../../command-internal/stack-backend.ts"; const resolve = (input: Parameters[0]) => resolveStackBackend(input).pipe(Effect.provide(BunServices.layer)); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index d098356e40..f3b2afeb75 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,22 +1,14 @@ -import { Context, Data, Effect, FileSystem, Layer, Option, Path, Crypto } from "effect"; -import { - createStack, - discoverStacks, - findStack, - inspectStack, - isStackId, - openStack, - type StackRuntimePreference, - type StackDiscoveryResult, -} from "@supabase/stack/effect"; +import { Context, Data, Effect, Layer, Option } from "effect"; +import { isStackId, StackNotFoundError, type StackRuntimePreference } from "@supabase/stack/effect"; import type { StackId } from "@supabase/stack"; -import { StackNotFoundError } from "@supabase/stack/effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; +import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; + +export { StackApi, stackApiLayer }; /** The target selected by the CLI adapter for one stack command. */ interface StackTarget { @@ -55,39 +47,6 @@ export class StackTargetResolver extends Context.Service< StackTargetResolverShape >()("supabase/experimental-stack/TargetResolver") {} -export class StackApi extends Context.Service< - StackApi, - { - readonly findStack: ( - ...args: Parameters - ) => Effect.Effect< - Effect.Success>, - Effect.Error> - >; - readonly createStack: ( - ...args: Parameters - ) => Effect.Effect< - Effect.Success>, - Effect.Error> - >; - readonly openStack: ( - ...args: Parameters - ) => Effect.Effect< - Effect.Success>, - Effect.Error> - >; - readonly inspectStack: ( - ...args: Parameters - ) => Effect.Effect< - Effect.Success>, - Effect.Error> - >; - readonly discoverStacks: ( - ...args: Parameters - ) => Effect.Effect>>; - } ->()("supabase/experimental-stack/StackApi") {} - export const validateStackTarget = (input: { readonly stack?: string; readonly stackId?: string; @@ -125,33 +84,6 @@ export const rejectStackOutput = ( ) : Effect.void; -export const stackApiLayer = Layer.effect( - StackApi, - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const childProcess = yield* ChildProcessSpawner.ChildProcessSpawner; - const provideServices = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcess), - ); - return { - findStack: (...args: Parameters) => provideServices(findStack(...args)), - createStack: (...args: Parameters) => - provideServices(createStack(...args)), - openStack: (...args: Parameters) => provideServices(openStack(...args)), - inspectStack: (...args: Parameters) => - provideServices(inspectStack(...args)), - discoverStacks: (...args: Parameters) => - provideServices(discoverStacks(...args)), - }; - }), -); - /** Runtime configuration for the first stack command. Later commands reuse this layer. */ export const stackTargetResolverLayer = Layer.succeed(StackTargetResolver, { resolve: (input) => diff --git a/apps/cli/src/commands/migration/migration.layers.ts b/apps/cli/src/commands/migration/migration.layers.ts index bc74ea4a7c..8c4ebe5a9b 100644 --- a/apps/cli/src/commands/migration/migration.layers.ts +++ b/apps/cli/src/commands/migration/migration.layers.ts @@ -11,7 +11,7 @@ import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { linkedDbResolverRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; -import { stackApiLayer } from "../experimental/stack/stack.shared.ts"; +import { stackApiLayer } from "../../command-internal/stack-api.ts"; import { ephemeralPostgresLayer } from "../../command-internal/stack-shadow.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index 67a52d55c2..cd9a5dc8ef 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -43,7 +43,7 @@ import { DbConnection, type PgConnInput } from "../../../command-internal/db-con import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { errorMessage, relativizeErrorMessage } from "../../../command-internal/error-message.ts"; -import { currentStackBackend } from "../../experimental/stack/stack-backend.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { stackWithShadowDatabase } from "../../../command-internal/stack-shadow.ts"; import { dumpConnForHostClient, diff --git a/package.json b/package.json index 126702833d..2a5ea26142 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "fix:all": "pnpm exec turbo run lint:fix fmt:fix knip:fix && pnpm run lint:effect:fix", "lint:check": "oxlint --config .oxlintrc.json", "lint:fix": "oxlint --config .oxlintrc.json --fix", - "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts", - "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts", + "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/stack-backend.ts apps/cli/src/command-internal/stack-api.ts", + "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/stack-backend.ts apps/cli/src/command-internal/stack-api.ts", "fmt:check": "oxfmt --config .oxfmtrc.json --check", "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun", From 919795b774617d347f75373c333fdac6f814f97e Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 20:51:39 +0200 Subject: [PATCH 12/14] fix(stack): default ephemeral runtime like createStack Omitted runtime now probes Docker. Declarative local ensure uses the postgres-only start helper so it cannot undo db start's overlay. --- .../command-internal/postgres-client.run.ts | 13 +------- .../command-internal/stack-local-database.ts | 27 ++-------------- .../commands/db/shared/pgdelta.seam.layer.ts | 5 +-- .../commands/migration/squash/squash.dump.ts | 14 +------- packages/stack/src/public/EffectStack.ts | 21 +----------- .../stack/src/public/EphemeralPostgres.ts | 1 + .../src/runtime/ContainerEngineResolver.ts | 20 ++++++++++++ .../ContainerEngineResolver.unit.test.ts | 31 ++++++++++++++++++ .../stack/src/runtime/EphemeralPostgres.ts | 32 +++++++++++++------ 9 files changed, 83 insertions(+), 81 deletions(-) create mode 100644 packages/stack/src/runtime/ContainerEngineResolver.unit.test.ts diff --git a/apps/cli/src/command-internal/postgres-client.run.ts b/apps/cli/src/command-internal/postgres-client.run.ts index 3b77e234fd..835bde99aa 100644 --- a/apps/cli/src/command-internal/postgres-client.run.ts +++ b/apps/cli/src/command-internal/postgres-client.run.ts @@ -120,17 +120,6 @@ export const requireHostPgProve = ( return yield* majorMismatch(matched.command, matched.actual, expectedMajor); }); -const concatChunks = (chunks: ReadonlyArray): Uint8Array => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return bytes; -}; - /** Stream a host process stdout like `streamPgDump`, teeing stderr when requested. */ export const streamHostCommand = Effect.fnUntraced(function* (params: { readonly command: string; @@ -176,7 +165,7 @@ export const streamHostCommand = Effect.fnUntraced(function* (params: { { concurrency: "unbounded" }, ); const exitCode = yield* handle.exitCode.pipe(Effect.map(Number)); - return { exitCode, stderr: new TextDecoder().decode(concatChunks(stderrChunks)) }; + return { exitCode, stderr: new TextDecoder().decode(Buffer.concat(stderrChunks)) }; }), ); }); diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 96ea661b13..c522e35cc1 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -140,32 +140,9 @@ export const stackLocalDatabaseConn: Effect.Effect< return conn; }); -export const stackEnsureLocalDatabaseStarted: Effect.Effect< - void, - LocalDbRunningError, - CommandSettings | FileSystem.FileSystem | Path.Path -> = Effect.gen(function* () { - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) return yield* startFailed({ message: "stack API is unavailable" }); - const cliSettings = yield* CommandSettings; - const existing = yield* api.value - .findStack({ projectRoot: cliSettings.workdir }) - .pipe(Effect.mapError(startFailed)); - const config = yield* loadStackConfig(cliSettings.workdir).pipe(Effect.mapError(startFailed)); - const stack = Option.isSome(existing) - ? yield* api.value.openStack(existing.value.id).pipe(Effect.mapError(startFailed)) - : yield* api.value - .createStack({ projectRoot: cliSettings.workdir }) - .pipe(Effect.mapError(startFailed)); - const status = yield* stack.status.pipe(Effect.mapError(startFailed)); - const database = status.capabilities.find((capability) => capability.name === "database"); - if (status.lifecycle === "running" && database?.state === "ready") return; - yield* stack.start({ config }).pipe(Effect.mapError(startFailed)); -}); - /** - * Start a postgres-only stack for `db start`. Fresh stacks persist the overlay; an existing - * full project stack is started without rewriting `--exclude`. + * Start a postgres-only stack for `db start` and declarative local ensure. Fresh stacks persist + * the overlay; an existing full project stack is started without rewriting `--exclude`. */ export const stackEnsurePostgresOnlyStarted: Effect.Effect< "already-running" | "started", diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 1a343794c1..76801dc26b 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -15,7 +15,7 @@ import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; -import { stackEnsureLocalDatabaseStarted } from "../../../command-internal/stack-local-database.ts"; +import { stackEnsurePostgresOnlyStarted } from "../../../command-internal/stack-local-database.ts"; const shadowDockerCause = (stderr: string): { readonly docker: "daemon" } | Record => isDockerDaemonUnreachable(stderr) ? { docker: "daemon" } : {}; @@ -80,7 +80,8 @@ export const declarativeSeamLayer = Layer.effect( Effect.gen(function* () { const backend = yield* currentStackBackend; if (backend.kind === "stack") { - return yield* stackEnsureLocalDatabaseStarted.pipe( + return yield* stackEnsurePostgresOnlyStarted.pipe( + Effect.asVoid, Effect.provideService(CommandSettings, cliSettings), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), diff --git a/apps/cli/src/commands/migration/squash/squash.dump.ts b/apps/cli/src/commands/migration/squash/squash.dump.ts index be8321a2bf..f272be7671 100644 --- a/apps/cli/src/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/commands/migration/squash/squash.dump.ts @@ -62,18 +62,6 @@ export const squashDumpSchema = Effect.fnUntraced(function* (params: SquashDu } }); -/** Concatenates stdout chunks into one buffer. */ -const concatChunks = (chunks: ReadonlyArray): Uint8Array => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return bytes; -}; - /** * Buffered convenience over {@link squashDumpSchema} for the before/after * diff dumps — an `auth`/`storage` schema-only dump is tens of KB, not @@ -97,5 +85,5 @@ export const squashDumpSchemaToString = Effect.fnUntraced(function* (params: { projectEnvValues: params.projectEnvValues, client: params.client, }); - return new TextDecoder().decode(concatChunks(chunks)); + return new TextDecoder().decode(Buffer.concat(chunks)); }); diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 9d085b2a6c..0b94de9895 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -116,10 +116,9 @@ import { } from "../supervisor/Launcher.ts"; import { ContainerEngineResolver, - defaultContainerEngineResolver, + selectDefaultRuntime, type ContainerEngineResolverShape, } from "../runtime/ContainerEngineResolver.ts"; -import type { ContainerEngineFailure } from "../runtime/ContainerEngine.ts"; import { statusFor } from "../supervisor/StatusProjection.ts"; import { EMPTY_LOG_CURSOR, readRetainedLogs, selectLogBatch } from "../supervisor/LogStore.ts"; import { @@ -154,24 +153,6 @@ export interface PreparedCapability { readonly outcome: "cached" | "downloaded" | "pulled"; } -const selectDefaultRuntime = ( - resolver: ContainerEngineResolverShape | undefined, -): Effect.Effect => { - return (resolver ?? defaultContainerEngineResolver).isInstalled("docker").pipe( - Effect.map((installed): StackRuntime => - installed ? { kind: "container", engine: "docker" } : { kind: "native" }, - ), - Effect.mapError( - (error: ContainerEngineFailure) => - new ContainerEngineError({ - engine: "docker", - message: `Unable to determine whether Docker is installed: ${error.message}`, - cause: error, - }), - ), - ); -}; - export interface PrepareStackResult { readonly capabilities: ReadonlyArray; } diff --git a/packages/stack/src/public/EphemeralPostgres.ts b/packages/stack/src/public/EphemeralPostgres.ts index 94ccd57933..efa2484469 100644 --- a/packages/stack/src/public/EphemeralPostgres.ts +++ b/packages/stack/src/public/EphemeralPostgres.ts @@ -12,6 +12,7 @@ export interface EphemeralPostgresSettings { } export interface CreateEphemeralPostgresOptions { + /** Omitted preference uses Docker when installed, otherwise native. */ readonly runtime?: StackRuntimePreference; /** Exact catalog release or major selector such as `"17"`. */ readonly version?: string; diff --git a/packages/stack/src/runtime/ContainerEngineResolver.ts b/packages/stack/src/runtime/ContainerEngineResolver.ts index 8c0f3d8812..d42570351a 100644 --- a/packages/stack/src/runtime/ContainerEngineResolver.ts +++ b/packages/stack/src/runtime/ContainerEngineResolver.ts @@ -1,5 +1,7 @@ import { Context, Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { ContainerEngineError } from "../public/Errors.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; import { makeProcessCommandRunner, type ContainerEngine, @@ -65,3 +67,21 @@ export const resolveContainerEngine = ( resolver?: ContainerEngineResolverShape, ): Effect.Effect => (resolver ?? defaultContainerEngineResolver).resolve(kind); + +/** Docker when the client is installed, otherwise native. */ +export const selectDefaultRuntime = ( + resolver?: ContainerEngineResolverShape, +): Effect.Effect => + (resolver ?? defaultContainerEngineResolver).isInstalled("docker").pipe( + Effect.map((installed): StackRuntime => + installed ? { kind: "container", engine: "docker" } : { kind: "native" }, + ), + Effect.mapError( + (error) => + new ContainerEngineError({ + engine: "docker", + message: `Unable to determine whether Docker is installed: ${error.message}`, + cause: error, + }), + ), + ); diff --git a/packages/stack/src/runtime/ContainerEngineResolver.unit.test.ts b/packages/stack/src/runtime/ContainerEngineResolver.unit.test.ts new file mode 100644 index 0000000000..dc0208eabe --- /dev/null +++ b/packages/stack/src/runtime/ContainerEngineResolver.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + selectDefaultRuntime, + type ContainerEngineResolverShape, +} from "./ContainerEngineResolver.ts"; + +const unusedSpawner = ChildProcessSpawner.make(() => Effect.die("unused")); + +const resolver = (installed: boolean): ContainerEngineResolverShape => ({ + isInstalled: () => Effect.succeed(installed), + resolve: () => Effect.die("unused"), +}); + +describe("selectDefaultRuntime", () => { + it.effect("selects Docker when the client is installed", () => + Effect.gen(function* () { + expect(yield* selectDefaultRuntime(resolver(true))).toEqual({ + kind: "container", + engine: "docker", + }); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, unusedSpawner)), + ); + + it.effect("selects native when Docker is not installed", () => + Effect.gen(function* () { + expect(yield* selectDefaultRuntime(resolver(false))).toEqual({ kind: "native" }); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, unusedSpawner)), + ); +}); diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts index 46f9583056..71792517a0 100644 --- a/packages/stack/src/runtime/EphemeralPostgres.ts +++ b/packages/stack/src/runtime/EphemeralPostgres.ts @@ -47,7 +47,12 @@ import { } from "./NativeProcess.ts"; import { bootstrapManagedPostgres } from "./PostgresDatabaseSession.ts"; import { makeProductionRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import { resolveContainerEngine, ContainerEngineResolver } from "./ContainerEngineResolver.ts"; +import { + resolveContainerEngine, + ContainerEngineResolver, + selectDefaultRuntime, + type ContainerEngineResolverShape, +} from "./ContainerEngineResolver.ts"; import type { ContainerEngine } from "./ContainerEngine.ts"; import { encodeRuntimeEnvFile } from "./RuntimeEnvFile.ts"; @@ -70,10 +75,19 @@ const ephemeralError = ( fields: Omit[0], "message"> = {}, ) => new EphemeralPostgresError({ message, ...fields }); -const resolvedRuntime = (preference?: CreateEphemeralPostgresOptions["runtime"]): StackRuntime => - preference?.kind === "container" - ? { kind: "container", engine: preference.engine ?? "docker" } - : { kind: "native" }; +const resolvedRuntime = ( + preference: CreateEphemeralPostgresOptions["runtime"] | undefined, + resolver: ContainerEngineResolverShape | undefined, +): Effect.Effect => { + if (preference !== undefined) { + return Effect.succeed( + preference.kind === "container" + ? { kind: "container", engine: preference.engine ?? "docker" } + : { kind: "native" }, + ); + } + return selectDefaultRuntime(resolver); +}; const plannedWorkload = ( version: string, @@ -927,7 +941,10 @@ export const createEphemeralPostgresCluster = ( Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService > => Effect.gen(function* () { - const runtime = resolvedRuntime(options.runtime); + const resolver = yield* Effect.serviceOption(ContainerEngineResolver).pipe( + Effect.map(Option.getOrUndefined), + ); + const runtime = yield* resolvedRuntime(options.runtime, resolver); const release = yield* resolveEphemeralPostgresRelease(options.version); const env = yield* Effect.serviceOption(StackRuntimeEnvironment).pipe( Effect.flatMap((configured) => @@ -967,9 +984,6 @@ export const createEphemeralPostgresCluster = ( if (runtime.kind === "native") { resources = { kind: "native" }; } else { - const resolver = yield* Effect.serviceOption(ContainerEngineResolver).pipe( - Effect.map(Option.getOrUndefined), - ); const engine = yield* resolveContainerEngine(runtime.engine, resolver).pipe( Effect.mapError( (cause) => From 19af6a7e02d7be19e0f4c78e85648ce75ed66a21 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 20:53:29 +0200 Subject: [PATCH 13/14] fix(cli): keep postgres-only overlay on unconfigured stack identities A failed first db start leaves an unconfigured identity; the retry must not compile the full default stack. --- .../command-internal/stack-local-database.ts | 10 +++++--- .../db/start/start.integration.test.ts | 25 +++++++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index c522e35cc1..afe9911601 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -155,11 +155,13 @@ export const stackEnsurePostgresOnlyStarted: Effect.Effect< const existing = yield* api.value .findStack({ projectRoot: cliSettings.workdir }) .pipe(Effect.mapError(startFailed)); - if (Option.isNone(existing)) { + if (Option.isNone(existing) || existing.value.desiredLifecycle === "unconfigured") { const config = yield* loadStackConfig(cliSettings.workdir).pipe(Effect.mapError(startFailed)); - const stack = yield* api.value - .createStack({ projectRoot: cliSettings.workdir }) - .pipe(Effect.mapError(startFailed)); + const stack = Option.isNone(existing) + ? yield* api.value + .createStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError(startFailed)) + : yield* api.value.openStack(existing.value.id).pipe(Effect.mapError(startFailed)); yield* stack .start({ config: postgresOnlyStackStartConfig(config) }) .pipe(Effect.mapError(startFailed)); diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 971e597f18..51e1228ca3 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1527,7 +1527,11 @@ describe("db start stack backend", () => { const unused = () => Effect.die("unused"); const unusedEffect = Effect.die("unused"); - function mockStackApi(opts: { readonly existing?: boolean; readonly databaseReady?: boolean }) { + function mockStackApi(opts: { + readonly existing?: boolean; + readonly unconfigured?: boolean; + readonly databaseReady?: boolean; + }) { const startConfigs: Array = []; const stack: EffectStack = { id: STACK_ID, @@ -1593,7 +1597,8 @@ describe("db start stack backend", () => { name: "default", branchContext: "main", runtime: { kind: "native" as const }, - desiredLifecycle: "stopped" as const, + desiredLifecycle: + opts.unconfigured === true ? ("unconfigured" as const) : ("stopped" as const), }) : Option.none(), ), @@ -1631,6 +1636,22 @@ describe("db start stack backend", () => { }); }); + it.live("applies the postgres-only overlay when an unconfigured identity already exists", () => { + const { layer } = setup(); + const stack = mockStackApi({ existing: true, unconfigured: true }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), + ); + expect(stack.startConfigs).toHaveLength(1); + expect(stack.startConfigs[0]).toMatchObject({ + capabilities: { + rest: { enabled: false }, + }, + }); + }); + }); + it.live("reports an already-running stack database without starting", () => { const { layer, out } = setup(); const stack = mockStackApi({ existing: true, databaseReady: true }); From ce91bdb3f46f8a5e8caab414891883c4b907c999 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 21:01:42 +0200 Subject: [PATCH 14/14] fix(cli): lint stack routing and native host-client errors Include the new command-internal stack modules in Effect lint, make ephemeral Postgres start/stop Effect properties, and report PATH pg_dump/pg_prove failures instead of a container exit. --- AGENTS.md | 3 +- .../cli/src/command-internal/container-cli.ts | 2 +- .../db-bootstrap/reset-local-database.ts | 2 +- apps/cli/src/command-internal/pg-dump.run.ts | 5 ++ .../command-internal/postgres-client.run.ts | 9 ++-- .../command-internal/stack-local-database.ts | 47 ++++++++++++------- .../stack-shadow.integration.test.ts | 12 ++--- apps/cli/src/command-internal/stack-shadow.ts | 6 +-- .../src/command-internal/test-db.handler.ts | 4 +- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 13 ++--- apps/cli/src/commands/db/dump/dump.errors.ts | 5 +- apps/cli/src/commands/db/dump/dump.handler.ts | 7 ++- .../commands/db/dump/dump.integration.test.ts | 46 ++++++++++++++++++ .../experimental/stack/start/start.options.ts | 15 +----- .../commands/migration/squash/squash.dump.ts | 6 ++- .../migration/squash/squash.errors.ts | 5 +- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 16 +++---- package.json | 4 +- .../stack/src/public/EphemeralPostgres.ts | 7 +-- packages/stack/src/public/PromiseStack.ts | 4 +- .../ephemeral-postgres.integration.test.ts | 4 +- .../stack/src/runtime/EphemeralPostgres.ts | 6 ++- 22 files changed, 145 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9c184a1d0..ec9f7ea525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ root-owned. Effect lint covers `packages/stack`, all files under `apps/cli/src/commands/experimental/stack` and `apps/cli/src/commands/experimental/compute`, the shared `apps/cli/src/shared/compute` runtime helpers (excluding embedded starter templates), the Compute test fixture helper, and `apps/cli/src/command-internal/experimental-feature.ts`, -`stack-backend.ts`, and `stack-api.ts`; use the root scripts for it. +`stack-backend.ts`, `stack-api.ts`, `stack-local-database.ts`, `stack-shadow.ts`, and +`postgres-client.run.ts`; use the root scripts for it. ### Config Naming Vocabulary diff --git a/apps/cli/src/command-internal/container-cli.ts b/apps/cli/src/command-internal/container-cli.ts index 9318b3dcd8..71d8cabe65 100644 --- a/apps/cli/src/command-internal/container-cli.ts +++ b/apps/cli/src/command-internal/container-cli.ts @@ -110,7 +110,7 @@ export const containerCliExitCode = ( ); /** Folds a byte stream into a decoded string. */ -export function collectText(stream: Stream.Stream) { +export function collectText(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( stream, diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 256f5de270..922af15254 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -104,7 +104,7 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( yield* checkDbToml(fs, path, workdir); if (backend.kind === "stack") { - const opened = yield* stackOpenReadyProject(); + const opened = yield* stackOpenReadyProject; if (Option.isNone(opened)) return yield* Effect.fail(notRunning()); yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); yield* opened.value.stack.resetDatabase.pipe( diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index 79ca1d5004..9518d6655e 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -82,6 +82,11 @@ export type PgDumpClient = readonly expectedMajor: number; }; +export const pgDumpClientExitMessage = (client: PgDumpClient, exitCode: number): string => + client.kind === "host" + ? `error running ${client.command}: exit ${exitCode}` + : `error running container: exit ${exitCode}`; + /** Container dump, or PATH `pg_dump`/`pg_dumpall` when the stack engine is native. */ export const streamPgDumpWithClient = Effect.fnUntraced(function* (params: { readonly image: string; diff --git a/apps/cli/src/command-internal/postgres-client.run.ts b/apps/cli/src/command-internal/postgres-client.run.ts index 835bde99aa..81394b4eef 100644 --- a/apps/cli/src/command-internal/postgres-client.run.ts +++ b/apps/cli/src/command-internal/postgres-client.run.ts @@ -76,9 +76,12 @@ const hostClientVersion = (command: string) => ); const [exitCode, stdout, stderr] = yield* Effect.all( [ - handle.exitCode.pipe(Effect.map(Number)), - collectText(handle.stdout), - collectText(handle.stderr), + handle.exitCode.pipe( + Effect.map(Number), + Effect.mapError(() => missingClient(command)), + ), + collectText(handle.stdout.pipe(Stream.mapError(() => missingClient(command)))), + collectText(handle.stderr.pipe(Stream.mapError(() => missingClient(command)))), ], { concurrency: "unbounded" }, ); diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index afe9911601..5ad4f4dacd 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -4,8 +4,13 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -import type { EffectStack } from "@supabase/stack/effect"; -import type { StackRuntime } from "@supabase/stack/effect"; +import { + CAPABILITY_NAMES, + excludeStackCapabilities, + type EffectStack, + type StackConfig, + type StackRuntime, +} from "@supabase/stack/effect"; import { parseConnectionString } from "./db-config.parse.ts"; import type { PgConnInput } from "./db-connection.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; @@ -13,7 +18,6 @@ import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi } from "./stack-api.ts"; import { loadStackConfig } from "../commands/experimental/stack/stack-config.ts"; -import { postgresOnlyStackStartConfig } from "../commands/experimental/stack/start/start.options.ts"; const notRunning = (message = "supabase start is not running.") => new LocalDbRunningError({ message }); @@ -23,6 +27,14 @@ const startFailed = (cause: { readonly message: string }) => message: `failed to start local database: ${cause.message}`, }); +/** Capabilities `db start` and `stack start --exclude` can leave disabled. */ +export const STACK_START_EXCLUDABLE_CAPABILITIES = CAPABILITY_NAMES.filter( + (name) => name !== "database", +); + +const postgresOnlyStackStartConfig = (config: StackConfig): StackConfig => + excludeStackCapabilities(config, STACK_START_EXCLUDABLE_CAPABILITIES); + const databaseReady = (stack: EffectStack) => Effect.gen(function* () { const status = yield* stack.status.pipe(Effect.mapError((cause) => notRunning(cause.message))); @@ -31,20 +43,19 @@ const databaseReady = (stack: EffectStack) => return Option.some({ stack, runtime: status.runtime }); }); -const openProjectStack = () => - Effect.gen(function* () { - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) return Option.none(); - const cliSettings = yield* CommandSettings; - const descriptor = yield* api.value - .findStack({ projectRoot: cliSettings.workdir }) - .pipe(Effect.mapError((cause) => notRunning(cause.message))); - if (Option.isNone(descriptor)) return Option.none(); - const stack = yield* api.value - .openStack(descriptor.value.id) - .pipe(Effect.mapError((cause) => notRunning(cause.message))); - return yield* databaseReady(stack); - }); +const openProjectStack = Effect.gen(function* () { + const api = yield* Effect.serviceOption(StackApi); + if (Option.isNone(api)) return Option.none(); + const cliSettings = yield* CommandSettings; + const descriptor = yield* api.value + .findStack({ projectRoot: cliSettings.workdir }) + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + if (Option.isNone(descriptor)) return Option.none(); + const stack = yield* api.value + .openStack(descriptor.value.id) + .pipe(Effect.mapError((cause) => notRunning(cause.message))); + return yield* databaseReady(stack); +}); /** Ready project stack, or none when the stack is missing or the database is not ready. */ export const stackOpenReadyProject = openProjectStack; @@ -119,7 +130,7 @@ export const stackRejectNativeDockerDiffEngine: Effect.Effect = Effect.gen(function* () { - const opened = yield* openProjectStack(); + const opened = yield* openProjectStack; if (Option.isNone(opened)) return yield* notRunning(); const credentials = yield* opened.value.stack.credentials.pipe( Effect.mapError((cause) => notRunning(cause.message)), diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index a53f641bd7..43b70341ab 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -43,8 +43,8 @@ const mockEphemeral = () => { runtime: { kind: "native" as const }, artifactIdentity: "native:17.6.1", url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: () => Effect.void, - stop: () => Effect.void, + start: Effect.void, + stop: Effect.void, exportPgData: (tarPath: string) => Effect.gen(function* () { exports.push(tarPath); @@ -239,8 +239,8 @@ describe("stackAcquireShadowDatabase", () => { runtime: { kind: "native" as const }, artifactIdentity: "native:17.6.1", url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: () => Effect.void, - stop: () => Effect.void, + start: Effect.void, + stop: Effect.void, exportPgData: () => Effect.fail( new EphemeralPostgresError({ message: "export failed", reason: "snapshot" }), @@ -306,8 +306,8 @@ describe("stackAcquireShadowDatabase", () => { runtime: { kind: "native" as const }, artifactIdentity: "native:17.6.1", url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: () => Effect.void, - stop: () => Effect.void, + start: Effect.void, + stop: Effect.void, exportPgData: () => Effect.void, }); }, diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index 07a1a605a7..e5b9395c6f 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -479,7 +479,7 @@ export const stackAcquireShadowDatabase = ( reason: "filesystem", }); } - yield* probe.stop().pipe(Effect.mapError(mapCreateError)); + yield* probe.stop.pipe(Effect.mapError(mapCreateError)); yield* writeStackShadowBaselineTar( fs, path, @@ -490,7 +490,7 @@ export const stackAcquireShadowDatabase = ( ); }), ); - yield* probe.start().pipe(Effect.mapError(mapCreateError)); + yield* probe.start.pipe(Effect.mapError(mapCreateError)); if (Result.isFailure(exported)) { const output = yield* Output; yield* output.raw( @@ -512,7 +512,7 @@ export const stackAcquireShadowDatabase = ( export const stackReleaseShadowDatabase = ( handle: StackShadowAcquiredHandle, -): Effect.Effect => handle.ephemeral.stop().pipe(Effect.ignore); +): Effect.Effect => handle.ephemeral.stop.pipe(Effect.ignore); export const stackWithShadowDatabase = ( input: ShadowSetupInput, diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index a80c761251..f9f0342541 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -260,7 +260,9 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { // already streamed to stdout. if (exitCode !== 0) { return yield* Effect.fail( - new TestDbRunError({ message: `error running container: exit ${exitCode}` }), + new TestDbRunError({ + message: `error running ${useHostProve ? "pg_prove" : "container"}: exit ${exitCode}`, + }), ); } diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 1f36cac1c4..7df2ed6768 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -1,7 +1,8 @@ # `supabase db dump` Native TypeScript port (`dump.handler.ts`). Streams a `pg_dump`/`pg_dumpall` -script run inside the local Postgres image to stdout or `--file`. +script run inside the local Postgres image (or PATH `pg_dump`/`pg_dumpall` on +a native stack) to stdout or `--file`. ## Files Read @@ -43,11 +44,11 @@ script run inside the local Postgres image to stdout or `--file`. ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--use-copy`/`--exclude` without `--data-only`; mutually-exclusive flags; bad `--file` path; connection failure; container exit ≠ 0 | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes / Divergences) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | `--use-copy`/`--exclude` without `--data-only`; mutually-exclusive flags; bad `--file` path; connection failure; container or PATH `pg_dump`/`pg_dumpall` exit ≠ 0 | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes / Divergences) | ## Output diff --git a/apps/cli/src/commands/db/dump/dump.errors.ts b/apps/cli/src/commands/db/dump/dump.errors.ts index f912696e25..387939abda 100644 --- a/apps/cli/src/commands/db/dump/dump.errors.ts +++ b/apps/cli/src/commands/db/dump/dump.errors.ts @@ -44,8 +44,9 @@ export class DbDumpOpenFileError extends Data.TaggedError("DbDumpOpenFileError") } /** - * The pg_dump container exited non-zero; message text - * (`"error running container: exit " + code`) is an established output contract. + * pg_dump exited non-zero. Container dumps keep + * `"error running container: exit " + code`; native PATH dumps use + * `"error running pg_dump: exit " + code` (or `pg_dumpall`). */ export class DbDumpRunError extends Data.TaggedError("DbDumpRunError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 421bc89438..8b7fbd581f 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -35,7 +35,10 @@ import { buildSchemaDumpEnv, expandScript, } from "../../../command-internal/pg-dump.env.ts"; -import { streamPgDumpWithClient } from "../../../command-internal/pg-dump.run.ts"; +import { + pgDumpClientExitMessage, + streamPgDumpWithClient, +} from "../../../command-internal/pg-dump.run.ts"; import { dumpConnForHostClient, rewriteDumpHostForToolContainer, @@ -381,7 +384,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { if (result.exitCode !== 0) { return yield* Effect.fail( new DbDumpRunError({ - message: `error running container: exit ${result.exitCode}`, + message: pgDumpClientExitMessage(dumpClient, result.exitCode), ...(isIPv6ConnectivityError(result.stderr) ? { suggestion: ipv6Suggestion() } : {}), }), ); diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 29f08d6261..dc6aa73126 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -1130,4 +1130,50 @@ describe("db dump integration", () => { ), ); }); + + it.live("dump --local on a native stack reports PATH pg_dump exit, not a container", () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n[db]\nmajor_version = 17\n', + ); + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const name = command._tag === "StandardCommand" ? command.command : ""; + const stdoutText = name === "pg_dump" ? "pg_dump (PostgreSQL) 17.4\n" : "partial\n"; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable([new TextEncoder().encode(stdoutText)]), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(name === "bash" ? 1 : 0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const { layer, docker } = setup({ + isLocal: true, + workdir: tmp.current, + }); + return Effect.gen(function* () { + const exit = yield* dbDump(flags({ local: Option.some(true) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe("error running pg_dump: exit 1"); + expect(docker.lastOpts).toBeUndefined(); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + stackBackendLayer("stack"), + dumpStackApi({ kind: "native" }), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ); + }); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts index 4d164cab12..55147662bb 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.options.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -1,14 +1 @@ -import { - CAPABILITY_NAMES, - excludeStackCapabilities, - type StackConfig, -} from "@supabase/stack/effect"; - -/** Optional capabilities accepted by `stack start --exclude`. */ -export const STACK_START_EXCLUDABLE_CAPABILITIES = CAPABILITY_NAMES.filter( - (name) => name !== "database", -); - -/** Database-only overlay for `db start`. Do not persist this on an existing full stack. */ -export const postgresOnlyStackStartConfig = (config: StackConfig): StackConfig => - excludeStackCapabilities(config, STACK_START_EXCLUDABLE_CAPABILITIES); +export { STACK_START_EXCLUDABLE_CAPABILITIES } from "../../../../command-internal/stack-local-database.ts"; diff --git a/apps/cli/src/commands/migration/squash/squash.dump.ts b/apps/cli/src/commands/migration/squash/squash.dump.ts index f272be7671..2809158709 100644 --- a/apps/cli/src/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/commands/migration/squash/squash.dump.ts @@ -4,6 +4,7 @@ import type { PgConnInput } from "../../../command-internal/db-connection.servic import { buildSchemaDumpEnv, type DumpOptions } from "../../../command-internal/pg-dump.env.ts"; import { dumpSchemaScript } from "../../../command-internal/pg-dump.scripts.ts"; import { + pgDumpClientExitMessage, streamPgDumpWithClient, type PgDumpClient, } from "../../../command-internal/pg-dump.run.ts"; @@ -45,18 +46,19 @@ export const squashDumpSchema = Effect.fnUntraced(function* (params: SquashDu excludeTable: [], columnInsert: false, }; + const client = params.client ?? { kind: "container" as const }; const result = yield* streamPgDumpWithClient({ image: params.image, script: dumpSchemaScript, env: buildSchemaDumpEnv(params.conn, opt), onStdout: params.onStdout, projectEnvValues: params.projectEnvValues, - client: params.client ?? { kind: "container" }, + client, }); if (result.exitCode !== 0) { return yield* Effect.fail( new MigrationSquashDumpError({ - message: `error running container: exit ${result.exitCode}`, + message: pgDumpClientExitMessage(client, result.exitCode), }), ); } diff --git a/apps/cli/src/commands/migration/squash/squash.errors.ts b/apps/cli/src/commands/migration/squash/squash.errors.ts index 258bd60e7c..6d213647b2 100644 --- a/apps/cli/src/commands/migration/squash/squash.errors.ts +++ b/apps/cli/src/commands/migration/squash/squash.errors.ts @@ -22,8 +22,9 @@ export class MigrationSquashMissingVersionError extends Data.TaggedError( } /** - * One of squash's three `pg_dump` containers exited non-zero. Matches the - * established `"error running container: exit " + code` text. + * One of squash's three `pg_dump` runs exited non-zero. Container dumps keep + * `"error running container: exit " + code`; native PATH dumps use + * `"error running pg_dump: exit " + code`. */ export class MigrationSquashDumpError extends Data.TaggedError("MigrationSquashDumpError")<{ readonly message: string; diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 674b0c256b..e90076c77a 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -57,14 +57,14 @@ One-shot `docker run --rm `, where the image is `supabase/pg_pro ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------- | -| `0` | all pgTAP tests pass | -| `1` | `pg_prove` exits non-zero (test failures) — `error running container: exit N` | -| `1` | `pg_prove` ran no tests (`Result: NOTESTS`) — `no pgTAP tests found in `; Go exits `0` here | -| `1` | `--db-url` / `--linked` / `--local` set together (mutually exclusive) | -| `1` | database connection failure / pgTAP enable failure / docker failure / `--linked` auth or IPv6 errors | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | all pgTAP tests pass | +| `1` | `pg_prove` exits non-zero (test failures) — `error running container: exit N`, or `error running pg_prove: exit N` on a native stack | +| `1` | `pg_prove` ran no tests (`Result: NOTESTS`) — `no pgTAP tests found in `; Go exits `0` here | +| `1` | `--db-url` / `--linked` / `--local` set together (mutually exclusive) | +| `1` | database connection failure / pgTAP enable failure / docker failure / `--linked` auth or IPv6 errors | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Telemetry Events Fired diff --git a/package.json b/package.json index 2a5ea26142..6d2360d875 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "fix:all": "pnpm exec turbo run lint:fix fmt:fix knip:fix && pnpm run lint:effect:fix", "lint:check": "oxlint --config .oxlintrc.json", "lint:fix": "oxlint --config .oxlintrc.json --fix", - "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/stack-backend.ts apps/cli/src/command-internal/stack-api.ts", - "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/stack-backend.ts apps/cli/src/command-internal/stack-api.ts", + "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/stack-backend.ts apps/cli/src/command-internal/stack-api.ts apps/cli/src/command-internal/stack-local-database.ts apps/cli/src/command-internal/stack-shadow.ts apps/cli/src/command-internal/postgres-client.run.ts", + "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/stack-backend.ts apps/cli/src/command-internal/stack-api.ts apps/cli/src/command-internal/stack-local-database.ts apps/cli/src/command-internal/stack-shadow.ts apps/cli/src/command-internal/postgres-client.run.ts", "fmt:check": "oxfmt --config .oxfmtrc.json --check", "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun", diff --git a/packages/stack/src/public/EphemeralPostgres.ts b/packages/stack/src/public/EphemeralPostgres.ts index efa2484469..52bc0282a5 100644 --- a/packages/stack/src/public/EphemeralPostgres.ts +++ b/packages/stack/src/public/EphemeralPostgres.ts @@ -45,11 +45,8 @@ export interface EffectEphemeralPostgres { /** Catalog identity hashed into CLI shadow-cache keys. */ readonly artifactIdentity: string; readonly url: Redacted.Redacted; - // Fresh invocation each call so the closure observes the cluster's current lifecycle. - // oxlint-disable-next-line effecttsgo/lazy-effect - readonly start: () => Effect.Effect; - // oxlint-disable-next-line effecttsgo/lazy-effect - readonly stop: () => Effect.Effect; + readonly start: Effect.Effect; + readonly stop: Effect.Effect; readonly exportPgData: ( tarPath: string, ) => Effect.Effect; diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index d8d3ae7668..24b7b65816 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -244,8 +244,8 @@ export const makePromiseApi = ( runtime: handle.runtime, artifactIdentity: handle.artifactIdentity, url: Redacted.value(handle.url), - start: () => runInScope(handle.start()), - stop: () => runInScope(handle.stop()), + start: () => runInScope(handle.start), + stop: () => runInScope(handle.stop), exportPgData: (tarPath: string) => runInScope(handle.exportPgData(tarPath)), destroy: () => run(Scope.close(scope, Exit.void)), }; diff --git a/packages/stack/src/public/ephemeral-postgres.integration.test.ts b/packages/stack/src/public/ephemeral-postgres.integration.test.ts index 3b19d377f6..ee84c9bd0b 100644 --- a/packages/stack/src/public/ephemeral-postgres.integration.test.ts +++ b/packages/stack/src/public/ephemeral-postgres.integration.test.ts @@ -170,7 +170,7 @@ describe.sequential("ephemeral Postgres", () => { expect(listedWhileRunning.some((stack) => stack.id === first.artifactIdentity)).toBe( false, ); - yield* first.stop(); + yield* first.stop; yield* first.exportPgData(tarPath); const exists = yield* fs.exists(tarPath); expect(exists).toBe(true); @@ -239,7 +239,7 @@ describe.sequential("ephemeral Postgres", () => { yield* query(first.url, "SELECT 1"); expect(first.runtime.kind).toBe("container"); expect(first.artifactIdentity.startsWith("container:docker:")).toBe(true); - yield* first.stop(); + yield* first.stop; yield* first.exportPgData(tarPath); const restored = yield* createEphemeralPostgres({ runtime, diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts index 71792517a0..5c3b4f584f 100644 --- a/packages/stack/src/runtime/EphemeralPostgres.ts +++ b/packages/stack/src/runtime/EphemeralPostgres.ts @@ -895,7 +895,7 @@ const clusterHandle = ( runtime: cluster.runtime, artifactIdentity: cluster.artifactIdentity, url: Redacted.make(databaseUrl(cluster.port, password)), - start: () => + start: Effect.suspend(() => cluster.lifecycle.withPermit( Effect.gen(function* () { if (cluster.running) { @@ -918,10 +918,12 @@ const clusterHandle = ( else yield* startContainer(cluster, options, healthTimeout, password); }), ), - stop: () => + ), + stop: Effect.suspend(() => cluster.lifecycle.withPermit( cluster.runtime.kind === "native" ? stopNative(cluster) : stopContainer(cluster), ), + ), exportPgData: (tarPath) => cluster.lifecycle.withPermit( Effect.gen(function* () {