diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 971b9f047c..c2382fd8fa 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,19 @@ 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. +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 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/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 09336501bb..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,7 +61,8 @@ describe("shadowCacheKey", () => { expect(shadowBaselineTarFileName(first)).toBe(`shadow-baseline-${first}.tar`); }); - it("changes when ANY baked-in input changes", () => { + // 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<{ readonly label: string; 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..e988b8f9c3 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -0,0 +1,23 @@ +# `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 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 +`${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 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.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..a950e0485a --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -0,0 +1,37 @@ +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" + | "cancelled" + | "invalid-config" + | "runtime" + | "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 "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 new file mode 100644 index 0000000000..09972c87b4 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -0,0 +1,138 @@ +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, + ...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }), + cause: error, + }); + +const destroyError = (error: unknown): StackCommandDestroyError => { + const stackError = isStackError(error) ? error : undefined; + const classification = + stackError === undefined + ? { reason: "unknown" as const } + : Match.value(stackError).pipe( + 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", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackRuntimeError", + "StackCleanupError", + "StackDestructionError", + "StackUpgradeRequiredError", + () => ({ reason: "lifecycle" as const }), + ), + Match.tag( + "InvalidStackConfigError", + "StackStateFormatUnsupportedError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => ({ reason: "invalid-config" as const }), + ), + Match.orElse(() => ({ reason: "unknown" as const })), + ); + return new StackCommandDestroyError({ + ...classification, + 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.interactive || 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: "cancelled", + 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..93f15c7015 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -0,0 +1,220 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "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 { OutputFlag, YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + ErrorActionabilityId, + actionability, +} from "../../../../shared/telemetry/error-actionability.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; + 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, opened: 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.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, + }; + 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.sync(() => { + state.opened++; + return 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(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)); + }); + + 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("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* () { + 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..8bfc8ef9d0 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,13 @@ 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. 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.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..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,5 +1,6 @@ import { Effect, Match, Option } from "effect"; import { + excludeStackCapabilities, isStackError, type StackStatus, type StackRuntimePreference, @@ -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,33 @@ 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.succeed( + STACK_START_EXCLUDABLE_CAPABILITIES.filter((name) => exclusions.includes(name)), + ); +}; + const mapTargetError = (error: StackTargetError) => new StackCommandStartError({ reason: error.reason, @@ -72,6 +101,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); 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), @@ -95,42 +125,43 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); + const configuredStart = excludeStackCapabilities(config, exclusions); 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 e523ab6a80..c9eb301203 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, @@ -33,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"; @@ -89,7 +91,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 +106,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 +150,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 +167,181 @@ 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"); + 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( + "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', + ); + 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: { 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(); + 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("does not rewrite listeners for configured-disabled capabilities", () => { + 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: { port: 55421 } }), + }), + }), + ); + }).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 +456,7 @@ describe("stack start targeting", () => { }, owner: "running", }), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }); return Effect.gen(function* () { const resolver = yield* StackTargetResolver; @@ -312,6 +494,7 @@ describe("stack start targeting", () => { runtime: "native", preparation: "on-demand", eager: true, + exclude: ["studio"], }), ); expect(createOptions).toEqual({ @@ -320,9 +503,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( @@ -568,6 +758,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, ); @@ -639,6 +830,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; @@ -655,7 +864,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"); @@ -670,4 +881,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..28c5e7e730 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -0,0 +1,6 @@ +import { CAPABILITY_NAMES } from "@supabase/stack/effect"; + +/** Optional capabilities accepted by `stack start --exclude`. */ +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 f8baa63c19..a1c7dd1a57 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 @@ -21,11 +22,18 @@ 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. 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 +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 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..39ec3ec781 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.optional, + ), 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.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 150df89e9a..dc2ef35ce2 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,82 @@ 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 = Option.getOrElse(flags.all, () => false); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + 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", + }); 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.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 ${failed.length}; skipped ${discovered.errors.length}`; + const detail = [ + ...failed.map( + ({ descriptor, error }) => + `Failed to stop managed stack ${descriptor.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, + message, + detail, + ...(suggestion === undefined ? {} : { suggestion }), + 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 b99c95beb1..f38cb3c40d 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,10 +16,11 @@ import type { EffectStack, OpenStackError, StackDiscoveryError, + StackDescriptor, 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, @@ -34,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), @@ -46,7 +48,10 @@ const status = (id: string): StackStatus => ({ artifacts: [], }); -const flags = (overrides: Partial[0]> = {}) => ({ +const flags = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + all: Option.none(), stack: Option.none(), stackId: Option.none(), ...overrides, @@ -54,12 +59,21 @@ const flags = (overrides: Partial[0]> = {}) => ({ function setup(opts: { root: string; + format?: "text" | "json" | "stream-json"; found?: { id: string; name?: string }; - stop?: () => Effect.Effect; + 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 out = mockOutput({ format: opts.format }); const telemetry = mockTelemetryStateTracked(); const state = { findInputs: [] as Array<{ projectRoot: string; name?: string }>, @@ -74,15 +88,11 @@ function setup(opts: { credentials: Effect.die("unused"), prepare: () => Effect.die("unused"), start: () => Effect.die("unused"), - stop: Effect.suspend(() => - ( - opts.stop ?? - (() => - Effect.sync(() => { - state.stopCalls += 1; - })) - )(), - ), + stop: + opts.stop ?? + Effect.sync(() => { + state.stopCalls += 1; + }), destroy: Effect.sync(() => { state.destroyCalled = true; }), @@ -101,6 +111,7 @@ function setup(opts: { : undefined; const layer = Layer.mergeAll( out.layer, + mockProcessControl().layer, telemetry.layer, mockCommandSettings({ workdir: opts.root }), Layer.succeed(StackApi, { @@ -122,6 +133,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, ); @@ -129,6 +147,251 @@ 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: Option.some(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: 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)); + }); + + 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: `Failed to read managed stack ${corrupt}: corrupt state`, + }), + }, + ], + }, + }); + return Effect.gen(function* () { + 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: 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( + expect.arrayContaining([expect.objectContaining({ type: "warn" })]), + ); + }).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: `Failed to read managed stack ${skipped}: invalid state`, + }), + }, + ], + }, + }); + return Effect.gen(function* () { + 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: Failed to read 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", + discoveryFailure: new StackStateInvalidError({ message: "registry is unreadable" }), + }); + return Effect.gen(function* () { + 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); + }).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: Option.some(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: 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({ @@ -273,7 +536,7 @@ describe("stack stop", () => { const setupResult = setup({ root, found: { id: "b".repeat(64) }, - stop: () => Effect.fail(new StackStateInvalidError({ message: "stop failed" })), + stop: Effect.fail(new StackStateInvalidError({ message: "stop failed" })), }); return Effect.gen(function* () { const failure = yield* stackStop(flags()).pipe(Effect.flip); @@ -289,7 +552,7 @@ describe("stack stop", () => { const setupResult = setup({ root, found: { id: "7".repeat(64) }, - stop: () => Effect.fail(ownershipConflict), + stop: Effect.fail(ownershipConflict), }); return Effect.gen(function* () { const failure = yield* stackStop(flags()).pipe(Effect.flip); @@ -349,7 +612,7 @@ describe("stack stop", () => { const setupResult = setup({ root, found: { id: "a".repeat(64) }, - stop: () => Effect.fail(new StackCleanupError({ message: "cleanup failed" })), + stop: Effect.fail(new StackCleanupError({ message: "cleanup failed" })), }); return Effect.gen(function* () { const failure = yield* stackStop(flags()).pipe(Effect.flip); @@ -390,4 +653,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()))), + ); + }); }); 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/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/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/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/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index a32a7bdabc..252c441936 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, @@ -980,6 +982,7 @@ export const createStack = ( }); const stackId = yield* deriveStackId(identity); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + // Concurrent initial writes may expose temporary files before state.json is published. const persisted = yield* withRegistryLock( env.stateRoot, store @@ -1080,10 +1083,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 > => @@ -1104,25 +1140,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 559b989126..ee00acdeb9 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"; @@ -59,6 +62,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; } @@ -170,6 +174,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)), }; }; @@ -179,6 +184,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 ec6ee96a18..36d89c0e0f 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -59,6 +59,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..392661da79 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,7 +7,16 @@ 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 { excludeStackCapabilities } from "../model/Exclusions.ts"; +export type { ExcludableCapabilityName } from "../model/Exclusions.ts"; +export { + createStack, + openStack, + findStack, + listStacks, + discoverStacks, + inspectStack, +} from "./EffectStack.ts"; export type { EffectStack, StartStackOptions, @@ -15,6 +24,8 @@ export type { CreateStackOptions, FindStackOptions, ListStacksOptions, + StackDiscoveryIssue, + StackDiscoveryResult, PreparedCapability, PrepareStackResult, } from "./EffectStack.ts"; diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 32fdd38aee..b7e4ada27d 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,61 +679,74 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("joins stack creation while the first caller is publishing state", () => + it.live("concurrent creates preserve the published state across an advisory read race", () => withRuntimeRoot((project) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const publicationStarted = yield* Deferred.make(); - const publish = yield* Deferred.make(); - const readerEnteredRegistry = yield* Deferred.make(); - const writerFileSystem: 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) => - Effect.gen(function* () { - if (to.endsWith("/state.json")) { - yield* Deferred.succeed(publicationStarted, undefined); - yield* Deferred.await(publish); - } - yield* 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 readerFileSystem: FileSystem.FileSystem = { + const secondFs: FileSystem.FileSystem = { ...fs, - readFileString: (file, encoding) => - fs - .readFileString(file, encoding) + 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.tap(() => - file.endsWith("/.stack-registry.lock") - ? Deferred.succeed(readerEnteredRegistry, undefined) - : Effect.void, + 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), ), - ), + ); + }, }; - return yield* Effect.gen(function* () { - const first = yield* createStack({ - projectRoot: project, - runtime: { kind: "native" }, - }).pipe( - Effect.provideService(FileSystem.FileSystem, writerFileSystem), - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.await(publicationStarted).pipe(Effect.timeout("10 seconds")); - const second = yield* createStack({ - projectRoot: project, - runtime: { kind: "native" }, - }).pipe( - Effect.provideService(FileSystem.FileSystem, readerFileSystem), - Effect.forkChild({ startImmediately: true }), + const create = (fileSystem: FileSystem.FileSystem) => + createStack({ projectRoot: project }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), ); - yield* Effect.raceFirst(Fiber.await(second), Deferred.await(readerEnteredRegistry)).pipe( - Effect.timeout("10 seconds"), - ); - yield* Deferred.succeed(publish, undefined); - const firstHandle = yield* Fiber.join(first); - const secondHandle = yield* Fiber.join(second); - expect(secondHandle.id).toBe(firstHandle.id); - }).pipe(Effect.ensuring(Deferred.succeed(publish, undefined))); + 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"); }), ), );