diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index c9a41e75f1..66644f0e86 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -161,6 +161,142 @@ const envNestedPortOrConfigured = ( return typeof nested?.[key] === "number" ? configured : undefined; }; +/** A setting's `config.toml` key and the `SUPABASE_*` env var that overrides it. */ +export interface StackEndpointSetting { + readonly configPath: string; + readonly envVar: string; +} + +/** + * A port setting's `config.toml` key and env var, shared by `createCreations` and + * `stackEndpointSetting` so both report the same names; a new endpoint still needs an entry in + * `endpointSettingsByServiceEndpoint`. + */ +interface PortSetting extends StackEndpointSetting { + readonly section: string; + readonly nestedSection?: string; + readonly key: string; +} + +const DB_PORT: PortSetting = { + envVar: "SUPABASE_DB_PORT", + section: "db", + key: "port", + configPath: "db.port", +}; +const API_PORT: PortSetting = { + envVar: "SUPABASE_API_PORT", + section: "api", + key: "port", + configPath: "api.port", +}; +const STUDIO_PORT: PortSetting = { + envVar: "SUPABASE_STUDIO_PORT", + section: "studio", + key: "port", + configPath: "studio.port", +}; +const DB_POOLER_PORT: PortSetting = { + envVar: "SUPABASE_DB_POOLER_PORT", + section: "db", + nestedSection: "pooler", + key: "port", + configPath: "db.pooler.port", +}; +const LOCAL_SMTP_PORT: PortSetting = { + envVar: "SUPABASE_LOCAL_SMTP_PORT", + section: "local_smtp", + key: "port", + configPath: "local_smtp.port", +}; +const LOCAL_SMTP_SMTP_PORT: PortSetting = { + envVar: "SUPABASE_LOCAL_SMTP_SMTP_PORT", + section: "local_smtp", + key: "smtp_port", + configPath: "local_smtp.smtp_port", +}; +const LOCAL_SMTP_POP3_PORT: PortSetting = { + envVar: "SUPABASE_LOCAL_SMTP_POP3_PORT", + section: "local_smtp", + key: "pop3_port", + configPath: "local_smtp.pop3_port", +}; +const ANALYTICS_PORT: PortSetting = { + envVar: "SUPABASE_ANALYTICS_PORT", + section: "analytics", + key: "port", + configPath: "analytics.port", +}; +const ANALYTICS_VECTOR_PORT: PortSetting = { + envVar: "SUPABASE_ANALYTICS_VECTOR_PORT", + section: "analytics", + key: "vector_port", + configPath: "analytics.vector_port", +}; +const EDGE_RUNTIME_INSPECTOR_PORT: PortSetting = { + envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + section: "edge_runtime", + key: "inspector_port", + configPath: "edge_runtime.inspector_port", +}; + +/** Resolves one `PortSetting` against the loaded document and env, picking the nested variant when needed. */ +const resolvePort = ( + setting: PortSetting, + document: Readonly> | undefined, + configured: number, + env: Readonly>, +): number | undefined => + setting.nestedSection === undefined + ? envPortOrConfigured(setting.envVar, document, setting.section, setting.key, configured, env) + : envNestedPortOrConfigured( + setting.envVar, + document, + setting.section, + setting.nestedSection, + setting.key, + configured, + env, + ); + +/** + * Maps a saved stack endpoint (service + endpoint name) to the `PortSetting` that controls it. + * An endpoint missing here (e.g. `pooler.http`, `realtime.rpc`) is always automatic. + */ +const endpointSettingsByServiceEndpoint: Readonly> = { + "database.sql": DB_PORT, + "pooler.sql": DB_POOLER_PORT, + "analytics.http": ANALYTICS_PORT, + "vector.http": ANALYTICS_VECTOR_PORT, + "studio.http": STUDIO_PORT, + "mail.http": LOCAL_SMTP_PORT, + "mail.smtp": LOCAL_SMTP_SMTP_PORT, + "mail.pop3": LOCAL_SMTP_POP3_PORT, + "functions.inspector": EDGE_RUNTIME_INSPECTOR_PORT, + "rest.http": API_PORT, + "auth.http": API_PORT, + "realtime.http": API_PORT, + "storage.http": API_PORT, + "functions.http": API_PORT, +}; + +/** The config.toml key and env var override for a service endpoint, when the CLI exposes one. */ +export const stackEndpointSetting = ( + service: string, + endpoint: string, +): StackEndpointSetting | undefined => endpointSettingsByServiceEndpoint[`${service}.${endpoint}`]; + +/** + * `db.major_version`'s config key and `SUPABASE_DB_MAJOR_VERSION` override. Unlike the ports + * above, `envOverrideMajorVersion` (shared with `db-bootstrap` and the legacy local stack) hardcodes + * its own name/field, so there is no single call site to read this from without widening that + * shared helper's signature; the two literals here are kept in sync by hand. + */ +export const stackMajorVersionSetting: StackEndpointSetting = { + configPath: "db.major_version", + envVar: "SUPABASE_DB_MAJOR_VERSION", +}; + const authProviderNames = [ "apple", "azure", @@ -1064,68 +1200,51 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }, catch: (cause) => new StackConfigError({ message: String(cause) }), }); - const dbPort = envPortOrConfigured( - "SUPABASE_DB_PORT", + const dbPort = resolvePort( + DB_PORT, document, - "db", - "port", validatedConfig.db.port, context.projectEnvValues, ); - const apiPort = envPortOrConfigured( - "SUPABASE_API_PORT", + const apiPort = resolvePort( + API_PORT, document, - "api", - "port", validatedConfig.api.port, context.projectEnvValues, ); - const studioPort = envPortOrConfigured( - "SUPABASE_STUDIO_PORT", + const studioPort = resolvePort( + STUDIO_PORT, document, - "studio", - "port", validatedConfig.studio.port, context.projectEnvValues, ); - const poolerPort = envNestedPortOrConfigured( - "SUPABASE_DB_POOLER_PORT", + const poolerPort = resolvePort( + DB_POOLER_PORT, document, - "db", - "pooler", - "port", validatedConfig.db.pooler.port, context.projectEnvValues, ); - const mailPort = envPortOrConfigured( - "SUPABASE_LOCAL_SMTP_PORT", + const mailPort = resolvePort( + LOCAL_SMTP_PORT, document, - "local_smtp", - "port", validatedConfig.local_smtp.port, context.projectEnvValues, ); - const mailSmtpPort = envPortOrConfigured( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", + const mailSmtpPort = resolvePort( + LOCAL_SMTP_SMTP_PORT, document, - "local_smtp", - "smtp_port", validatedConfig.local_smtp.smtp_port ?? 0, context.projectEnvValues, ); - const mailPop3Port = envPortOrConfigured( - "SUPABASE_LOCAL_SMTP_POP3_PORT", + const mailPop3Port = resolvePort( + LOCAL_SMTP_POP3_PORT, document, - "local_smtp", - "pop3_port", validatedConfig.local_smtp.pop3_port ?? 0, context.projectEnvValues, ); - const analyticsPort = envPortOrConfigured( - "SUPABASE_ANALYTICS_PORT", + const analyticsPort = resolvePort( + ANALYTICS_PORT, document, - "analytics", - "port", validatedConfig.analytics.port, context.projectEnvValues, ); @@ -1257,11 +1376,9 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( config: { apiKey: "api-key" }, endpoints: { http: endpoint( - envPortOrConfigured( - "SUPABASE_ANALYTICS_VECTOR_PORT", + resolvePort( + ANALYTICS_VECTOR_PORT, document, - "analytics", - "vector_port", validatedConfig.analytics.vector_port ?? 0, context.projectEnvValues, ), @@ -1295,11 +1412,9 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( endpoints: { http: endpoint(apiPort), inspector: endpoint( - envPortOrConfigured( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + resolvePort( + EDGE_RUNTIME_INSPECTOR_PORT, document, - "edge_runtime", - "inspector_port", validatedConfig.edge_runtime.inspector_port, context.projectEnvValues, ), 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 59b0bf2b38..4c6e60d465 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -88,8 +88,9 @@ and ports are retained. Changed exclusions reuse existing service identities, da Removed services remain saved and stopped so including them again can reuse them; a saved stopped instance of a newly included service is reused when its endpoints and versions still match. The project configuration file is unchanged. A changed endpoint, artifact version, or PostgreSQL major -version fails before modifying the stopped composition, naming the changed setting and suggesting -`supabase stack destroy` to recreate the stack. +version fails before modifying the stopped composition, naming the `config.toml` key or +`SUPABASE_*` env var behind the change with its saved and requested values, and suggesting either +reverting it or running the stack's exact `supabase stack destroy` command to recreate it. ## First startup and retries @@ -137,3 +138,12 @@ name (protocol, address, port, and URL, matching `stack status`, with no synthet empty message. See [`docs/stack-commands.md`](../../../../../docs/stack-commands.md) for an example. Failures retain typed command errors and package diagnostics. Telemetry state is flushed after success or failure. + +A rejected configuration change additionally carries `stack_changes` on the JSON/stream-json error +envelope: one entry per affected service (a shared setting such as the API port appears once per +API-backed service, unlike the deduplicated text message), each with `service`, `path` (the +composition planner's dotted path, e.g. `endpoints.http.port`, not a `config.toml` key), `key`, +`saved`, `requested`, and `editable`. `recreate_command` is the exact `supabase stack destroy +--stack-id ` invocation, without `--yes`, since destroy deletes local database data; running it +non-interactively or with `--output-format json`/`--output-format stream-json` requires passing +`--yes` explicitly. 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 4f9df6a258..e5e7363d16 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -1,6 +1,8 @@ +import { Layer } from "effect"; 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 { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stringSliceFlag } from "../../../../command-internal/string-slice-flag.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; @@ -66,5 +68,7 @@ export const stackStartCommand = Command.make("start", config).pipe( ), // `stackStart`'s bucket-seeding path satisfies `promptYesNo`'s `Stdin` requirement here even // though it always passes `yes: true`/`interactive: false` and never reaches the prompt. - Command.provide(stdinLayer), + // `machineErrorContextLayer` carries the structured incompatible-change payload onto the + // JSON/stream-json error envelope. + Command.provide(Layer.mergeAll(stdinLayer, machineErrorContextLayer)), ); 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 ad8262f771..4d8deb5567 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,4 +1,4 @@ -import { defaultRuntime } from "@supabase/stack/internal/artifacts"; +import { defaultRuntime, postgresVersion } from "@supabase/stack/internal/artifacts"; import { connectionEnv, renderStackSummary, @@ -27,6 +27,7 @@ import { type StackError, } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; +import { MachineErrorContext } from "../../../../shared/output/machine-error-context.service.ts"; import { OutputFlag, resolveExperimentalWithProjectEnv, @@ -47,7 +48,13 @@ import { seedBucketsRun, } from "../../../../command-internal/seed-buckets.ts"; import { loadLocalProjectContext } from "../../../../command-internal/local-project-context.ts"; -import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; +import { + loadStackConfig, + stackEndpointSetting, + stackMajorVersionSetting, + type StackEndpointSetting, +} from "../../../../command-internal/stack-config.ts"; +import { envOverride } from "../../../../command-internal/local-config-values.ts"; import { StackApi, stackCapabilityForService, @@ -164,23 +171,206 @@ const sameKinds = ( return leftKinds.size === rightKinds.size && [...leftKinds].every((kind) => rightKinds.has(kind)); }; -/** Rejects a saved instance whose endpoints or artifact versions the request would change. */ -const incompatibleChange = (planned: PlannedInstance) => - planned.change !== "incompatible" +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null; + +const endpointPortLabel = (endpoints: unknown, name: string): string => { + const intent = isRecord(endpoints) ? endpoints[name] : undefined; + const port = isRecord(intent) ? intent.port : undefined; + return port === "auto" ? "automatic" : typeof port === "number" ? String(port) : "unset"; +}; + +const databaseVersionOf = ( + creation: ServiceCreation | ServiceCreationInput | undefined, +): string | undefined => (creation?.service === "database" ? creation.config.version : undefined); + +const majorVersionOf = (version: string): string => version.split(".")[0] ?? version; + +/** Renders a dotted config key as its `config.toml` section/key pair, e.g. `[db] major_version`. */ +const formatConfigPath = (path: string): string => { + const segments = path.split("."); + const key = segments.pop(); + return `[${segments.join(".")}] ${key}`; +}; + +/** The display key for a setting: its env var when that's what overrides it, else its config key. */ +const settingKeyLabel = ( + setting: StackEndpointSetting, + projectEnvValues: Readonly>, +): string => + envOverride(setting.envVar, undefined, projectEnvValues) !== undefined + ? setting.envVar + : formatConfigPath(setting.configPath); + +/** + * One incompatible path, reported as the JSON/stream-json error envelope's `stack_changes` + * entries (contract documented in `SIDE_EFFECTS.md`). `editable` marks whether `key` is a + * `config.toml` key or env var the user can revert, or plain wording for a catalog-pinned + * artifact or Postgres build. + */ +interface StructuredSettingChange { + readonly service: string; + readonly path: string; + readonly key: string; + readonly saved: string; + readonly requested: string; + readonly editable: boolean; +} + +const describeSettingChange = ( + service: PlannedInstance["service"], + path: string, + savedCreation: ServiceCreation | undefined, + requestedCreation: ServiceCreationInput | undefined, + projectEnvValues: Readonly>, +): StructuredSettingChange => { + if (service === "database" && path === "config.version") { + // `postgresVersion` resolves a bare major alias (e.g. "17") to the pinned build the + // composition plan actually compared, so the saved/requested pair reflects what changed. + const savedVersion = postgresVersion(databaseVersionOf(savedCreation) ?? "unknown"); + const requestedVersion = postgresVersion(databaseVersionOf(requestedCreation) ?? "unknown"); + const savedMajor = majorVersionOf(savedVersion); + const requestedMajor = majorVersionOf(requestedVersion); + // Same major but different pinned build: `major_version` doesn't control this, so reverting + // it wouldn't fix anything — name the actual (unpinnable) versions instead. + if (savedMajor === requestedMajor) + return { + service, + path, + key: "Postgres build", + saved: savedVersion, + requested: requestedVersion, + editable: false, + }; + return { + service, + path, + key: settingKeyLabel(stackMajorVersionSetting, projectEnvValues), + saved: savedMajor, + requested: requestedMajor, + editable: true, + }; + } + const endpointName = path.startsWith("endpoints.") ? path.split(".")[1] : undefined; + const setting = + endpointName === undefined ? undefined : stackEndpointSetting(service, endpointName); + if (endpointName !== undefined && setting !== undefined) + return { + service, + path, + key: settingKeyLabel(setting, projectEnvValues), + saved: endpointPortLabel(savedCreation?.endpoints, endpointName), + requested: endpointPortLabel(requestedCreation?.endpoints, endpointName), + editable: true, + }; + // No config.toml key or env var covers this path (e.g. the catalog-pinned artifact `version`): + // name it plainly instead of implying a setting the user could edit. + return { + service, + path, + key: path === "version" ? `${service} artifact version` : `${service} ${path}`, + saved: path === "version" ? (savedCreation?.version ?? "unknown") : "changed", + requested: path === "version" ? (requestedCreation?.version ?? "unknown") : "changed", + editable: false, + }; +}; + +/** Every incompatible path across every rejected saved member, as one structured list. */ +const incompatibleSettingChanges = ( + planned: ReadonlyArray, + savedConfigById: ReadonlyMap, + requested: ReadonlyArray, + projectEnvValues: Readonly>, +): ReadonlyArray => + planned + .filter((entry) => entry.member && entry.change === "incompatible") + .flatMap((entry) => + // Narrowed by the filter above; `Extract` isn't inferred through `.filter`. + entry.change === "incompatible" + ? entry.paths.map((path) => + describeSettingChange( + entry.service, + path, + savedConfigById.get(entry.id), + requested.find((creation) => creation.service === entry.service), + projectEnvValues, + ), + ) + : [], + ); + +/** One deduplicated text line per distinct setting change (shared API port lines collapse to one). */ +const settingChangeLines = ( + changes: ReadonlyArray, +): ReadonlyArray => { + const seen = new Set(); + const lines: Array = []; + for (const change of changes) { + const line = `${change.key}: saved ${change.saved}, requested ${change.requested}`; + if (seen.has(line)) continue; + seen.add(line); + lines.push(line); + } + return lines; +}; + +const dedupe = (values: ReadonlyArray): ReadonlyArray => [...new Set(values)]; + +/** + * The exact `supabase stack destroy` invocation that recreates this stack. Always targets + * `--stack-id`: a `--stack ` destroy re-resolves the name against the caller's current + * `--workdir`, which can point at a different project's stack of the same name. Omits `--yes` on + * purpose, since destroying deletes local database data (details in `SIDE_EFFECTS.md`). + */ +const destroyCommandFor = (id: string): string => `supabase stack destroy --stack-id ${id}`; + +/** The revert clause for the editable keys among a rejection's changes, or `undefined` for none. */ +const revertAdvice = (editableKeys: ReadonlyArray): string | undefined => + editableKeys.length === 0 ? undefined - : planned.service === "database" && planned.paths.includes("config.version") - ? new StackCommandStartError({ - reason: "invalid-config", - message: "The requested database version does not match the saved stack binding", - suggestion: - "Keep the saved database version, or run supabase stack destroy to recreate the stack.", - }) - : new StackCommandStartError({ - reason: "invalid-config", - message: `The requested ${planned.service} ${planned.paths.join(", ")} cannot change on the saved stack`, - suggestion: - "Keep the saved endpoint and version settings, or run supabase stack destroy to recreate the stack.", - }); + : editableKeys.length === 1 + ? `Revert ${editableKeys[0]} to its saved value` + : "Revert the settings listed to their saved values"; + +/** Rejects every saved member whose endpoints or artifact versions the request would change. */ +const incompatibleChange = ( + planned: ReadonlyArray, + savedConfigById: ReadonlyMap, + requested: ReadonlyArray, + projectEnvValues: Readonly>, + stackIdentity: { readonly id: string; readonly name?: string }, +): + | { + readonly error: StackCommandStartError; + readonly changes: ReadonlyArray; + readonly command: string; + } + | undefined => { + const changes = incompatibleSettingChanges(planned, savedConfigById, requested, projectEnvValues); + if (changes.length === 0) return undefined; + const lines = settingChangeLines(changes); + const command = destroyCommandFor(stackIdentity.id); + const nameNote = stackIdentity.name === undefined ? "" : ` (stack ${stackIdentity.name})`; + const revert = revertAdvice( + dedupe(changes.filter((change) => change.editable).map(({ key }) => key)), + ); + const nonEditable = dedupe(changes.filter((change) => !change.editable).map(({ key }) => key)); + const destroyClause = `\`${command}\`${nameNote} to recreate the stack — this permanently deletes its local database data.`; + // A non-editable change blocks start whatever else changed, so destroy is the only way out. + const suggestion = + nonEditable.length > 0 || revert === undefined + ? `This CLI release starts a different ${nonEditable.join(" and ")} than the saved stack. Run ${destroyClause}` + : `${revert} to keep the stack and its data, or run ${destroyClause}`; + return { + changes, + command, + error: new StackCommandStartError({ + reason: "invalid-config", + message: `The saved stack cannot adopt these changes: ${lines.join("; ")}`, + suggestion, + }), + }; +}; const selectedCreations = ( creations: ReadonlyArray, @@ -511,9 +701,26 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const initialComposition = composition.members.length === 0; const serviceKindsChanged = !sameKinds(currentInstances, requested); const planned = yield* stack.composition.plan(requested).pipe(Effect.mapError(stackError)); - for (const entry of planned) { - const rejected = entry.member ? incompatibleChange(entry) : undefined; - if (rejected !== undefined) return yield* rejected; + const stackIdentity = { + id: stack.id, + ...(target.name === undefined ? {} : { name: target.name }), + }; + const savedConfigById = new Map(currentStatuses.map(({ id, config: saved }) => [id, saved])); + const rejected = incompatibleChange( + planned, + savedConfigById, + requested, + config.projectEnvValues, + stackIdentity, + ); + if (rejected !== undefined) { + const machineErrorContext = yield* Effect.serviceOption(MachineErrorContext); + if (Option.isSome(machineErrorContext)) + yield* machineErrorContext.value.set({ + stack_changes: rejected.changes, + recreate_command: rejected.command, + }); + return yield* rejected.error; } const reuseIds: Array = planned.filter(({ member }) => member).map(({ id }) => id); for (const creation of requested) { 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 c1267d0a4f..ab3f102e4c 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 @@ -4,12 +4,14 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, - Equal, Fiber, FileSystem, Layer, Option, Redacted, + Schema, + Sink, + Stdio, Stream, } from "effect"; import { @@ -22,18 +24,23 @@ import { StackError, type ServiceCreation, type ServiceCreationInput, - type PlannedInstance, type ServiceInstance, type ServiceInstances, type StackCredentials, type Stack, } from "@supabase/stack/effect"; +import { planSupabaseComposition } from "@supabase/stack/testing"; import { mockCommandSettings, mockTelemetryStateTracked, withEnvVar, } from "../../../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; import { containerEngineSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { DbConnection, @@ -45,6 +52,9 @@ import { CommandPlatformApiFactory } from "../../../../auth/command-platform-api import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; +import { jsonOutputLayer, streamJsonOutputLayer } from "../../../../shared/output/output.layer.ts"; import { StackApi, stackApiLayer, StackTargetResolver } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; import { StackCommandStartError } from "./start.errors.ts"; @@ -144,8 +154,18 @@ const instance = ( resetData: Effect.die("unused"), }; } - case "rest": - return { ...base, service: "rest" }; + case "rest": { + let current = creation; + return { + ...base, + service: "rest", + restart: (input?: Parameters[0]) => + Effect.sync(() => { + if (input !== undefined) current = { ...current, ...input }; + }), + status: Effect.sync(() => status(current)), + }; + } case "auth": return { ...base, service: "auth" }; case "realtime": @@ -305,29 +325,28 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { ); return members; }), - plan: (creations) => + // Delegates to the production planner so paths/shared-API-port normalization match what + // `packages/stack` actually reports, instead of a hand-rolled approximation. + plan: (creations: ReadonlyArray) => Effect.forEach(members, (member) => - member.status.pipe( - Effect.map(({ config }): ReadonlyArray => { - const request = creations.find(({ service }) => service === member.service); - if (request === undefined) return []; - const base = { id: member.id, service: member.service, member: true }; - if (config.service === "database" && request.service === "database") - return [ - postgresVersion(config.config.version) === postgresVersion(request.config.version) - ? { ...base, change: "unchanged" } - : { ...base, change: "incompatible", paths: ["config.version"] }, - ]; - if (!Equal.equals(config.endpoints, request.endpoints)) - return [{ ...base, change: "incompatible", paths: ["endpoints"] }]; - return [ - Equal.equals(config.config, request.config) - ? { ...base, change: "unchanged" } - : { ...base, change: "changed", paths: ["config"] }, - ]; - }), + member.status.pipe(Effect.map(({ config }) => ({ id: member.id, creation: config }))), + ).pipe( + Effect.map((instances) => + planSupabaseComposition( + { + instances, + composition: { + members: members.map(({ id }) => ({ + id, + activation: activations.get(id) ?? "eager", + })), + dependencies: [], + }, + }, + creations, + ), ), - ).pipe(Effect.map((planned) => planned.flat())), + ), configure: ({ members: configured }) => Effect.sync(() => { activations = new Map(configured.map(({ id, activation }) => [id, activation])); @@ -398,7 +417,7 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { const layers = ( root: string, fixture: ReturnType, - output = mockOutput(), + output: Pick, "layer"> = mockOutput(), existing = true, explicitWorkdir = false, // Fixtures request the native runtime, so pin a host that ships native artifacts. @@ -445,6 +464,57 @@ const layers = ( ); }; +const machineEnvelope = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +/** + * A real captured `Stdio` layer, needed only by the JSON/stream-json failure-envelope tests below + * since `machineErrorContextLayer`'s merge into the error envelope lives inside the real + * `jsonOutputLayer`/`streamJsonOutputLayer` `fail` implementations, which `mockOutput()` never + * replicates. + */ +const mockCapturingStdio = () => { + const stdout: Array = []; + const layer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => + Sink.forEach((item: string | Uint8Array) => + Effect.sync(() => { + stdout.push(typeof item === "string" ? item : new TextDecoder().decode(item)); + }), + ), + stderr: () => Sink.forEach(() => Effect.void), + }), + ); + return { layer, stdout }; +}; + +/** + * Wires the real `jsonOutputLayer`/`streamJsonOutputLayer` over a captured `Stdio`, with + * `machineErrorContextLayer` merged alongside it (matching `start.command.ts`'s composition) so + * the handler and the output layer's `fail` share the same live cell, plus a real + * `mockProcessControl()` since `withJsonErrorHandling` sets the exit code on it. + */ +const jsonErrorLayers = ( + root: string, + fixture: ReturnType, + format: "json" | "stream-json", +) => { + const stdio = mockCapturingStdio(); + const processControl = mockProcessControl(); + const outputLayer = format === "json" ? jsonOutputLayer : streamJsonOutputLayer; + const layer = Layer.mergeAll( + layers(root, fixture, { layer: outputLayer.pipe(Layer.provide(stdio.layer)) }), + machineErrorContextLayer, + processControl.layer, + ); + return { layer, stdio, processControl }; +}; + describe("experimental stack start", () => { it.live("rejects incompatible Functions env before changing composition", () => Effect.gen(function* () { @@ -1174,13 +1244,454 @@ describe("experimental stack start", () => { expect(error).toMatchObject({ reason: "invalid-config", - message: expect.stringContaining("cannot change on the saved stack"), - suggestion: expect.stringContaining("supabase stack destroy"), + message: expect.stringContaining("[api] port: saved automatic, requested 54999"), + suggestion: expect.stringContaining( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ), }); expect(fixture.composed).toBe(1); }).pipe(Effect.provide(BunServices.layer)), ); + it.live("names the config key and both values when a saved port changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-port-config-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "port-config"\n[api]\nport = 54321\n', + ); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "port-config"\n[api]\nport = 54999\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("[api] port: saved 54321, requested 54999"), + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("names a dedicated (non-shared) port's own config key", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-dedicated-port-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "dedicated-port"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "dedicated-port"\n[studio]\nport = 12345\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("[studio] port: saved automatic, requested 12345"), + }); + // A dedicated port only affects its own service, unlike the shared API port. + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) + expect(error.message).not.toContain("[api] port"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + // The production planner (`fixedApiPorts`/`withSharedApiPort`) normalizes a requested + // automatic shared-API port to the composition's already-fixed value whenever one exists, so a + // saved fixed port going back to automatic in `config.toml` reuses the saved port rather than + // failing. This locks down that non-obvious compatible case: it is not an incompatible path. + it.live( + "accepts a shared API port going from fixed back to automatic, reusing the saved port", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-api-to-auto-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "api-to-auto"\n[api]\nport = 54321\n', + ); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "api-to-auto"\n'); + yield* fixture.stack.composition.stop; + // Does not throw: the planner treats this as compatible (`change: "unchanged"`), not an + // incompatible path to report. Reusing the already-bound port for the resumed instance is + // `packages/stack`'s own concern, not asserted here. + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "collects simultaneous database-version and port changes into one error with plural revert wording", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-multi-change-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "multi-change"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "multi-change"\n[db]\nmajor_version = 15\n[studio]\nport = 12345\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("[db] major_version: saved 17, requested 15"), + }); + expect(error).toMatchObject({ + message: expect.stringContaining("[studio] port: saved automatic, requested 12345"), + }); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) + expect(error.suggestion).toContain( + "Revert the settings listed to their saved values to keep the stack and its data", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("names the env var override when SUPABASE_*_PORT set the saved port", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-port-env-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "port-env"\n'); + const fixture = fakeStack(); + yield* withEnvVar( + "SUPABASE_API_PORT", + "54321", + stackStart(flags()).pipe(Effect.provide(layers(root, fixture))), + ); + + yield* fixture.stack.composition.stop; + const error = yield* withEnvVar( + "SUPABASE_API_PORT", + "54999", + stackStart(flags()).pipe(Effect.provide(layers(root, fixture)), Effect.flip), + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("SUPABASE_API_PORT: saved 54321, requested 54999"), + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "names [db] major_version and the destroy command when the saved Postgres version changes", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-major-version-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "major-version"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "major-version"\n[db]\nmajor_version = 15\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("[db] major_version: saved 17, requested 15"), + suggestion: expect.stringContaining( + `Revert [db] major_version to its saved value to keep the stack and its data, or run \`supabase stack destroy --stack-id ${fixture.stack.id}\` to recreate the stack`, + ), + }); + expect(error).toMatchObject({ suggestion: expect.stringContaining("database data") }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "names the full Postgres build, not major_version, when only the pinned build differs", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-pg-build-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "pg-build"\n[db]\nmajor_version = 17\n', + ); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const database = fixture.members.find(({ service }) => service === "database"); + if (database?.service !== "database") return yield* Effect.die("Database missing"); + const observed = yield* database.status; + if (observed.config.service !== "database") + return yield* Effect.die("Database config missing"); + const pinnedVersion = postgresVersion("17"); + // A saved build the current catalog no longer pins (`postgresVersion` only normalizes a + // recognized alias): same major as the requested `17`, different full build. + yield* database.restart({ + config: { ...observed.config.config, version: "17.0.0-stale-build" }, + }); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining( + `Postgres build: saved 17.0.0-stale-build, requested ${pinnedVersion}`, + ), + }); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.message).not.toContain("major_version"); + expect(error.suggestion).not.toContain("Revert"); + expect(error.suggestion).toContain( + "This CLI release starts a different Postgres build than the saved stack.", + ); + expect(error.suggestion).toContain( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "drops the revert sentence for an artifact-version-only mismatch and explains the fix in plain language", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-artifact-version-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "artifact-version"\n', + ); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const rest = fixture.members.find(({ service }) => service === "rest"); + if (rest?.service !== "rest") return yield* Effect.die("REST missing"); + const restObserved = yield* rest.status; + if (restObserved.config.service !== "rest") return yield* Effect.die("REST config missing"); + yield* rest.restart({ ...restObserved.config, version: "rest-v1-stale" }); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("rest artifact version"), + }); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.suggestion).not.toContain("Revert"); + expect(error.suggestion).toContain( + "This CLI release starts a different rest artifact version than the saved stack.", + ); + expect(error.suggestion).toContain( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "uses the destroy-only wording when a non-editable change accompanies an editable one", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-mixed-editable-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "mixed-editable"\n', + ); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const rest = fixture.members.find(({ service }) => service === "rest"); + if (rest?.service !== "rest") return yield* Effect.die("REST missing"); + const restObserved = yield* rest.status; + if (restObserved.config.service !== "rest") return yield* Effect.die("REST config missing"); + yield* rest.restart({ ...restObserved.config, version: "rest-v1-stale" }); + yield* fixture.stack.composition.stop; + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "mixed-editable"\n[studio]\nport = 12345\n', + ); + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + // The editable change still appears in the message even though reverting it alone + // can't unblock start: the non-editable artifact-version change still would. + message: expect.stringContaining("[studio] port: saved automatic, requested 12345"), + }); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.suggestion).not.toContain("Revert"); + expect(error.suggestion).toContain( + "This CLI release starts a different rest artifact version than the saved stack.", + ); + expect(error.suggestion).toContain( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("emits structured stack_changes and recreate_command on the JSON error envelope", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-json-envelope-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "json-envelope"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "json-envelope"\n[db]\nmajor_version = 15\n', + ); + yield* fixture.stack.composition.stop; + const { layer, stdio } = jsonErrorLayers(root, fixture, "json"); + yield* stackStart(flags()).pipe(withJsonErrorHandling, Effect.provide(layer)); + + expect(stdio.stdout).toHaveLength(1); + const envelope = yield* machineEnvelope(stdio.stdout[0]!); + expect(envelope._tag).toBe("Error"); + expect(envelope.error).toMatchObject({ + code: "ExperimentalStackStartError", + message: expect.stringContaining("[db] major_version: saved 17, requested 15"), + }); + expect(envelope.stack_changes).toEqual([ + { + service: "database", + path: "config.version", + key: "[db] major_version", + saved: "17", + requested: "15", + editable: true, + }, + ]); + expect(envelope.recreate_command).toBe( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("emits the same structured error fields on the stream-json terminal event", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-stream-json-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "stream-json"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "stream-json"\n[db]\nmajor_version = 15\n', + ); + yield* fixture.stack.composition.stop; + const { layer, stdio } = jsonErrorLayers(root, fixture, "stream-json"); + yield* stackStart(flags()).pipe(withJsonErrorHandling, Effect.provide(layer)); + + const event = yield* machineEnvelope(stdio.stdout.at(-1)!); + expect(event.type).toBe("error"); + expect(event.error).toMatchObject({ + code: "ExperimentalStackStartError", + message: expect.stringContaining("[db] major_version: saved 17, requested 15"), + }); + expect(event.stack_changes).toEqual([ + { + service: "database", + path: "config.version", + key: "[db] major_version", + saved: "17", + requested: "15", + editable: true, + }, + ]); + expect(event.recreate_command).toBe(`supabase stack destroy --stack-id ${fixture.stack.id}`); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("always suggests --stack-id for a named stack, naming the stack as plain text", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-named-destroy-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "named-destroy"\n'); + const fixture = fakeStack(); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.succeed({ + projectRoot: root, + id: fixture.stack.id, + name: "feature-a", + runtime: "native" as const, + hostRunning: false, + }), + }); + yield* stackStart(flags()).pipe( + Effect.provide(Layer.mergeAll(layers(root, fixture), target)), + ); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "named-destroy"\n[db]\nmajor_version = 15\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(Layer.mergeAll(layers(root, fixture), target)), + Effect.flip, + ); + + expect(error).toMatchObject({ + suggestion: expect.stringContaining( + `supabase stack destroy --stack-id ${fixture.stack.id}\` (stack feature-a)`, + ), + }); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) + expect(error.suggestion).not.toContain("--stack feature-a"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("matches a Postgres major alias to the saved pinned database version", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index b92c16d860..531b3c4a25 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -20,6 +20,9 @@ import { ServiceCreationInput as CreationSchema, } from "./services/Catalog.ts"; +/** Exposed for test fakes that need to reproduce the package's own saved-stack comparison. */ +export { planSupabaseComposition } from "./composition/Supabase.ts"; + type Kind = StackEffect.ServiceCreationInput["service"]; type Creation = Extract;