From c2b1c4dfcc8bc7b292b4ea19573aaad7b8d484c2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 10:17:46 +0200 Subject: [PATCH 1/5] fix(cli): name the changed setting and the fix when a saved stack rejects a config change Maps each incompatible plan path (endpoint port, db major version) to the config.toml key or SUPABASE_*_PORT env var that set it, shows the saved and requested values, and suggests reverting it or running the stack's exact destroy command with a data-loss warning. --- apps/cli/src/command-internal/stack-config.ts | 43 +++++ .../experimental/stack/start/SIDE_EFFECTS.md | 5 +- .../experimental/stack/start/start.handler.ts | 157 ++++++++++++++++-- .../stack/start/start.integration.test.ts | 150 ++++++++++++++++- 4 files changed, 332 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index c9a41e75f1..cbbe60b8eb 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -161,6 +161,49 @@ 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; +} + +/** + * Maps a saved stack endpoint (service + endpoint name) to the setting that controls it below, + * mirroring the `envPortOrConfigured`/`envNestedPortOrConfigured` calls that build `createCreations`. + * An endpoint missing here (e.g. `pooler.http`, `realtime.rpc`) is always automatic. + */ +const endpointSettingsByServiceEndpoint: Readonly> = { + "database.sql": { configPath: "db.port", envVar: "SUPABASE_DB_PORT" }, + "pooler.sql": { configPath: "db.pooler.port", envVar: "SUPABASE_DB_POOLER_PORT" }, + "analytics.http": { configPath: "analytics.port", envVar: "SUPABASE_ANALYTICS_PORT" }, + "vector.http": { configPath: "analytics.vector_port", envVar: "SUPABASE_ANALYTICS_VECTOR_PORT" }, + "studio.http": { configPath: "studio.port", envVar: "SUPABASE_STUDIO_PORT" }, + "mail.http": { configPath: "local_smtp.port", envVar: "SUPABASE_LOCAL_SMTP_PORT" }, + "mail.smtp": { configPath: "local_smtp.smtp_port", envVar: "SUPABASE_LOCAL_SMTP_SMTP_PORT" }, + "mail.pop3": { configPath: "local_smtp.pop3_port", envVar: "SUPABASE_LOCAL_SMTP_POP3_PORT" }, + "functions.inspector": { + configPath: "edge_runtime.inspector_port", + envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + }, + "rest.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, + "auth.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, + "realtime.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, + "storage.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, + "functions.http": { configPath: "api.port", envVar: "SUPABASE_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, see `envOverrideMajorVersion`. */ +export const stackMajorVersionSetting: StackEndpointSetting = { + configPath: "db.major_version", + envVar: "SUPABASE_DB_MAJOR_VERSION", +}; + const authProviderNames = [ "apple", "azure", 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..d5d410cfd0 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 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..d5d7fe5e9b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -47,7 +47,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 +170,126 @@ const sameKinds = ( return leftKinds.size === rightKinds.size && [...leftKinds].every((kind) => rightKinds.has(kind)); }; +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null; + +/** The declared port for an endpoint intent (`number | "auto"`), read without an `as` cast. */ +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"; +}; + +/** The saved stack's declared database version, discriminating the `ServiceCreation` union. */ +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); + +interface SettingChange { + readonly key: string; + readonly saved: string; + readonly requested: string; +} + +/** Describes one incompatible path as the config key/env var it maps to and its saved/requested values. */ +const describeSettingChange = ( + service: PlannedInstance["service"], + path: string, + savedCreation: ServiceCreation | undefined, + requestedCreation: ServiceCreationInput | undefined, + projectEnvValues: Readonly>, +): SettingChange | undefined => { + if (service === "database" && path === "config.version") + return { + key: settingKeyLabel(stackMajorVersionSetting, projectEnvValues), + saved: majorVersionOf(databaseVersionOf(savedCreation) ?? "unknown"), + requested: majorVersionOf(databaseVersionOf(requestedCreation) ?? "unknown"), + }; + const endpointName = path.startsWith("endpoints.") ? path.split(".")[1] : undefined; + const setting = + endpointName === undefined ? undefined : stackEndpointSetting(service, endpointName); + if (endpointName === undefined || setting === undefined) return undefined; + return { + key: settingKeyLabel(setting, projectEnvValues), + saved: endpointPortLabel(savedCreation?.endpoints, endpointName), + requested: endpointPortLabel(requestedCreation?.endpoints, endpointName), + }; +}; + +/** One line per incompatible path: the mapped setting with its saved/requested values, or plain wording. */ +const settingChangeLines = ( + planned: Extract, + savedCreation: ServiceCreation | undefined, + requestedCreation: ServiceCreationInput | undefined, + projectEnvValues: Readonly>, +): ReadonlyArray<{ readonly line: string; readonly key?: string }> => + planned.paths.map((path) => { + const change = describeSettingChange( + planned.service, + path, + savedCreation, + requestedCreation, + projectEnvValues, + ); + if (change !== undefined) + return { + line: `${change.key}: saved ${change.saved}, requested ${change.requested}`, + key: change.key, + }; + return { + line: + path === "version" + ? `The saved stack's ${planned.service} artifact version no longer matches what this CLI would start` + : `The requested ${planned.service} ${path} cannot change on the saved stack`, + }; + }); + +/** The exact `supabase stack destroy` invocation that recreates this stack. */ +const destroyCommandFor = (stackIdentity: { + readonly id: string; + readonly name?: string; +}): string => + stackIdentity.name === undefined + ? `supabase stack destroy --stack-id ${stackIdentity.id}` + : `supabase stack destroy --stack ${stackIdentity.name}`; + /** Rejects a saved instance whose endpoints or artifact versions the request would change. */ -const incompatibleChange = (planned: PlannedInstance) => - planned.change !== "incompatible" - ? 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.", - }); +const incompatibleChange = ( + planned: PlannedInstance, + savedCreation: ServiceCreation | undefined, + requestedCreation: ServiceCreationInput | undefined, + projectEnvValues: Readonly>, + stackIdentity: { readonly id: string; readonly name?: string }, +) => { + if (planned.change !== "incompatible") return undefined; + const changes = settingChangeLines(planned, savedCreation, requestedCreation, projectEnvValues); + const revertSubject = + changes.length === 1 && changes[0]?.key !== undefined ? changes[0].key : "the settings above"; + const destroy = destroyCommandFor(stackIdentity); + return new StackCommandStartError({ + reason: "invalid-config", + message: changes.map(({ line }) => line).join("; "), + suggestion: `Revert ${revertSubject} to its saved value to keep the stack and its data, or run \`${destroy}\` to recreate the stack — this permanently deletes its local database data.`, + }); +}; const selectedCreations = ( creations: ReadonlyArray, @@ -511,8 +620,20 @@ 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)); + const stackIdentity = { + id: stack.id, + ...(target.name === undefined ? {} : { name: target.name }), + }; for (const entry of planned) { - const rejected = entry.member ? incompatibleChange(entry) : undefined; + const rejected = entry.member + ? incompatibleChange( + entry, + currentStatuses.find(({ id }) => id === entry.id)?.config, + requested.find((creation) => creation.service === entry.service), + config.projectEnvValues, + stackIdentity, + ) + : undefined; if (rejected !== undefined) return yield* rejected; } const reuseIds: Array = planned.filter(({ member }) => member).map(({ id }) => id); 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..481e2a1392 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 @@ -319,7 +319,7 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { : { ...base, change: "incompatible", paths: ["config.version"] }, ]; if (!Equal.equals(config.endpoints, request.endpoints)) - return [{ ...base, change: "incompatible", paths: ["endpoints"] }]; + return [{ ...base, change: "incompatible", paths: ["endpoints.http.port"] }]; return [ Equal.equals(config.config, request.config) ? { ...base, change: "unchanged" } @@ -1174,13 +1174,157 @@ 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 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") }); + + const json = mockOutput({ format: "json" }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "major-version"\n[db]\nmajor_version = 15\n', + ); + const jsonError = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture, json)), + Effect.flip, + ); + expect(jsonError).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("[db] major_version: saved 17, requested 15"), + suggestion: expect.stringContaining( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ), + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("names the --stack flag in the destroy command for a named stack", () => + 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 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; From a912c5012ab1e821c2784183fd586e80ebdab5ea Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 10:48:54 +0200 Subject: [PATCH 2/5] fix(cli): make the incompatible-stack error exact, structured, and complete Always suggest the exact --stack-id destroy command instead of an unquoted --stack that re-resolves against the caller's current workdir, carry structured per-change records (service, path, key, saved, requested) plus the recreate command on the JSON/stream-json error envelope via MachineErrorContext, and collect every incompatible member into one error instead of reporting only the first, deduplicating shared API port lines. Use the production planSupabaseComposition planner in the start handler's integration tests instead of a hand-rolled approximation, and derive the port-setting table and createCreations' env-override calls from one shared definition per port to prevent drift. --- apps/cli/src/command-internal/stack-config.ts | 195 +++++++---- .../experimental/stack/start/start.command.ts | 6 +- .../experimental/stack/start/start.handler.ts | 168 +++++---- .../stack/start/start.integration.test.ts | 321 +++++++++++++++--- packages/stack/src/effect.ts | 1 + 5 files changed, 523 insertions(+), 168 deletions(-) diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index cbbe60b8eb..97e23ca461 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -168,28 +168,117 @@ export interface StackEndpointSetting { } /** - * Maps a saved stack endpoint (service + endpoint name) to the setting that controls it below, - * mirroring the `envPortOrConfigured`/`envNestedPortOrConfigured` calls that build `createCreations`. + * A port setting's full identity: everything `envPortOrConfigured`/`envNestedPortOrConfigured` + * need to resolve it below, plus the `configPath` `stackEndpointSetting` reports for it. Each port + * is declared once as a `PortSetting` constant and read from both places, so a new or renamed port + * can't be added to `createCreations` without also reaching `stackEndpointSetting` (or vice versa). + */ +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": { configPath: "db.port", envVar: "SUPABASE_DB_PORT" }, - "pooler.sql": { configPath: "db.pooler.port", envVar: "SUPABASE_DB_POOLER_PORT" }, - "analytics.http": { configPath: "analytics.port", envVar: "SUPABASE_ANALYTICS_PORT" }, - "vector.http": { configPath: "analytics.vector_port", envVar: "SUPABASE_ANALYTICS_VECTOR_PORT" }, - "studio.http": { configPath: "studio.port", envVar: "SUPABASE_STUDIO_PORT" }, - "mail.http": { configPath: "local_smtp.port", envVar: "SUPABASE_LOCAL_SMTP_PORT" }, - "mail.smtp": { configPath: "local_smtp.smtp_port", envVar: "SUPABASE_LOCAL_SMTP_SMTP_PORT" }, - "mail.pop3": { configPath: "local_smtp.pop3_port", envVar: "SUPABASE_LOCAL_SMTP_POP3_PORT" }, - "functions.inspector": { - configPath: "edge_runtime.inspector_port", - envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - }, - "rest.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, - "auth.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, - "realtime.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, - "storage.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, - "functions.http": { configPath: "api.port", envVar: "SUPABASE_API_PORT" }, + "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. */ @@ -198,7 +287,12 @@ export const stackEndpointSetting = ( endpoint: string, ): StackEndpointSetting | undefined => endpointSettingsByServiceEndpoint[`${service}.${endpoint}`]; -/** `db.major_version`'s config key and `SUPABASE_DB_MAJOR_VERSION` override, see `envOverrideMajorVersion`. */ +/** + * `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", @@ -1107,68 +1201,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, ); @@ -1300,11 +1377,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, ), @@ -1338,11 +1413,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/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 d5d7fe5e9b..5c0f8bec5a 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -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, @@ -203,22 +204,27 @@ const settingKeyLabel = ( ? setting.envVar : formatConfigPath(setting.configPath); -interface SettingChange { +/** One incompatible path, named as its setting (or plain wording) with its saved/requested values. */ +interface StructuredSettingChange { + readonly service: string; + readonly path: string; readonly key: string; readonly saved: string; readonly requested: string; } -/** Describes one incompatible path as the config key/env var it maps to and its saved/requested values. */ +/** Describes one incompatible path: the config key/env var it maps to and its saved/requested values. */ const describeSettingChange = ( service: PlannedInstance["service"], path: string, savedCreation: ServiceCreation | undefined, requestedCreation: ServiceCreationInput | undefined, projectEnvValues: Readonly>, -): SettingChange | undefined => { +): StructuredSettingChange => { if (service === "database" && path === "config.version") return { + service, + path, key: settingKeyLabel(stackMajorVersionSetting, projectEnvValues), saved: majorVersionOf(databaseVersionOf(savedCreation) ?? "unknown"), requested: majorVersionOf(databaseVersionOf(requestedCreation) ?? "unknown"), @@ -226,69 +232,102 @@ const describeSettingChange = ( const endpointName = path.startsWith("endpoints.") ? path.split(".")[1] : undefined; const setting = endpointName === undefined ? undefined : stackEndpointSetting(service, endpointName); - if (endpointName === undefined || setting === undefined) return undefined; + if (endpointName !== undefined && setting !== undefined) + return { + service, + path, + key: settingKeyLabel(setting, projectEnvValues), + saved: endpointPortLabel(savedCreation?.endpoints, endpointName), + requested: endpointPortLabel(requestedCreation?.endpoints, endpointName), + }; + // 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 { - key: settingKeyLabel(setting, projectEnvValues), - saved: endpointPortLabel(savedCreation?.endpoints, endpointName), - requested: endpointPortLabel(requestedCreation?.endpoints, endpointName), + service, + path, + key: path === "version" ? `${service} artifact version` : `${service} ${path}`, + saved: path === "version" ? (savedCreation?.version ?? "unknown") : "changed", + requested: path === "version" ? (requestedCreation?.version ?? "unknown") : "changed", }; }; -/** One line per incompatible path: the mapped setting with its saved/requested values, or plain wording. */ -const settingChangeLines = ( - planned: Extract, - savedCreation: ServiceCreation | undefined, - requestedCreation: ServiceCreationInput | undefined, +/** Every incompatible path across every rejected saved member, as one structured list. */ +const incompatibleSettingChanges = ( + planned: ReadonlyArray, + savedConfigById: ReadonlyMap, + requested: ReadonlyArray, projectEnvValues: Readonly>, -): ReadonlyArray<{ readonly line: string; readonly key?: string }> => - planned.paths.map((path) => { - const change = describeSettingChange( - planned.service, - path, - savedCreation, - requestedCreation, - projectEnvValues, +): 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, + ), + ) + : [], ); - if (change !== undefined) - return { - line: `${change.key}: saved ${change.saved}, requested ${change.requested}`, - key: change.key, - }; - return { - line: - path === "version" - ? `The saved stack's ${planned.service} artifact version no longer matches what this CLI would start` - : `The requested ${planned.service} ${path} cannot change on the saved stack`, - }; - }); -/** The exact `supabase stack destroy` invocation that recreates this stack. */ +/** 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; +}; + +/** + * 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. A saved name is + * shown as plain text, not as a shell-quoted command argument. + */ const destroyCommandFor = (stackIdentity: { readonly id: string; readonly name?: string; -}): string => - stackIdentity.name === undefined - ? `supabase stack destroy --stack-id ${stackIdentity.id}` - : `supabase stack destroy --stack ${stackIdentity.name}`; +}): string => `supabase stack destroy --stack-id ${stackIdentity.id}`; -/** Rejects a saved instance whose endpoints or artifact versions the request would change. */ +/** Rejects every saved member whose endpoints or artifact versions the request would change. */ const incompatibleChange = ( - planned: PlannedInstance, - savedCreation: ServiceCreation | undefined, - requestedCreation: ServiceCreationInput | undefined, + planned: ReadonlyArray, + savedConfigById: ReadonlyMap, + requested: ReadonlyArray, projectEnvValues: Readonly>, stackIdentity: { readonly id: string; readonly name?: string }, -) => { - if (planned.change !== "incompatible") return undefined; - const changes = settingChangeLines(planned, savedCreation, requestedCreation, projectEnvValues); - const revertSubject = - changes.length === 1 && changes[0]?.key !== undefined ? changes[0].key : "the settings above"; +): + | { + readonly error: StackCommandStartError; + readonly changes: ReadonlyArray; + } + | undefined => { + const changes = incompatibleSettingChanges(planned, savedConfigById, requested, projectEnvValues); + if (changes.length === 0) return undefined; + const lines = settingChangeLines(changes); + const revertSubject = lines.length === 1 ? changes[0]?.key : undefined; const destroy = destroyCommandFor(stackIdentity); - return new StackCommandStartError({ - reason: "invalid-config", - message: changes.map(({ line }) => line).join("; "), - suggestion: `Revert ${revertSubject} to its saved value to keep the stack and its data, or run \`${destroy}\` to recreate the stack — this permanently deletes its local database data.`, - }); + const nameNote = stackIdentity.name === undefined ? "" : ` (stack ${stackIdentity.name})`; + return { + changes, + error: new StackCommandStartError({ + reason: "invalid-config", + message: lines.join("; "), + suggestion: `Revert ${revertSubject ?? "the settings above"} to its saved value to keep the stack and its data, or run \`${destroy}\`${nameNote} to recreate the stack — this permanently deletes its local database data.`, + }), + }; }; const selectedCreations = ( @@ -624,17 +663,22 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags id: stack.id, ...(target.name === undefined ? {} : { name: target.name }), }; - for (const entry of planned) { - const rejected = entry.member - ? incompatibleChange( - entry, - currentStatuses.find(({ id }) => id === entry.id)?.config, - requested.find((creation) => creation.service === entry.service), - config.projectEnvValues, - stackIdentity, - ) - : undefined; - if (rejected !== undefined) return yield* rejected; + 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: destroyCommandFor(stackIdentity), + }); + 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 481e2a1392..afaa8ac9de 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 { @@ -19,10 +21,10 @@ import { } from "@supabase/stack/defaults"; import { postgresVersion } from "@supabase/stack/internal/artifacts"; import { + planSupabaseComposition, StackError, type ServiceCreation, type ServiceCreationInput, - type PlannedInstance, type ServiceInstance, type ServiceInstances, type StackCredentials, @@ -33,7 +35,12 @@ import { 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"; @@ -305,29 +315,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.http.port"] }]; - 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 +407,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 +454,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* () { @@ -1212,6 +1272,117 @@ describe("experimental stack start", () => { }).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. + if (error instanceof StackCommandStartError) + expect(error.message).not.toContain("[api] port"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("reports a shared API port transitioning from automatic to fixed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-api-to-fixed-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "api-to-fixed"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "api-to-fixed"\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 automatic, requested 54999"), + }); + }).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", () => + 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"), + }); + }).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; @@ -1268,27 +1439,85 @@ describe("experimental stack start", () => { ), }); expect(error).toMatchObject({ suggestion: expect.stringContaining("database data") }); - - const json = mockOutput({ format: "json" }); - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - 'project_id = "major-version"\n[db]\nmajor_version = 15\n', - ); - const jsonError = yield* stackStart(flags()).pipe( - Effect.provide(layers(root, fixture, json)), - Effect.flip, - ); - expect(jsonError).toMatchObject({ - reason: "invalid-config", - message: expect.stringContaining("[db] major_version: saved 17, requested 15"), - suggestion: expect.stringContaining( - `supabase stack destroy --stack-id ${fixture.stack.id}`, - ), - }); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("names the --stack flag in the destroy command for a named stack", () => + 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", + }, + ]); + 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", + }, + ]); + 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-" }); @@ -1320,8 +1549,12 @@ describe("experimental stack start", () => { ); expect(error).toMatchObject({ - suggestion: expect.stringContaining("supabase stack destroy --stack feature-a"), + suggestion: expect.stringContaining( + `supabase stack destroy --stack-id ${fixture.stack.id}\` (stack feature-a)`, + ), }); + if (error instanceof StackCommandStartError) + expect(error.suggestion).not.toContain("--stack feature-a"); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 40e09273f6..a3c65aca4c 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -68,6 +68,7 @@ export type { ServiceCreation } from "./services/Catalog.ts"; /** A service creation as `services.create` accepts it, before stack credentials fill its inputs. */ export type ServiceCreationInput = CatalogServiceCreationInput; export type { CompositionConfig } from "./Orchestrator.ts"; +export { planSupabaseComposition }; export type { CreationChange, PlannedInstance, From 6796f6b9bba0d5ea732ecaba9b2caa7ec8c451cf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 15:35:56 +0200 Subject: [PATCH 3/5] fix(cli): distinguish editable settings from pinned-build mismatches in the stack error Same-major Postgres version differences and catalog-pinned artifact versions have no config.toml key or env var, so the incompatible-stack error no longer suggests reverting major_version or an editable setting for them: it names the full saved and requested builds, drops the revert sentence when nothing is editable, and explains the fix in plain language. Mixed rejections keep the revert advice for the editable keys only, with singular/plural grammar matching their count. destroyCommandFor now takes only the stack id (it never read the name) and is computed once per rejection, reused for both the error suggestion and the JSON/stream-json envelope's recreate_command. Move planSupabaseComposition's export from the main @supabase/stack/effect entrypoint to @supabase/stack/testing, since the CLI only needs it to build a realistic test double for its own composition plan. --- .../experimental/stack/start/start.handler.ts | 75 ++++++-- .../stack/start/start.integration.test.ts | 171 +++++++++++++++++- packages/stack/src/effect.ts | 1 - packages/stack/src/testing.ts | 3 + 4 files changed, 228 insertions(+), 22 deletions(-) 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 5c0f8bec5a..9bf18332cf 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, @@ -174,14 +174,12 @@ const sameKinds = ( const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null; -/** The declared port for an endpoint intent (`number | "auto"`), read without an `as` cast. */ 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"; }; -/** The saved stack's declared database version, discriminating the `ServiceCreation` union. */ const databaseVersionOf = ( creation: ServiceCreation | ServiceCreationInput | undefined, ): string | undefined => (creation?.service === "database" ? creation.config.version : undefined); @@ -204,16 +202,20 @@ const settingKeyLabel = ( ? setting.envVar : formatConfigPath(setting.configPath); -/** One incompatible path, named as its setting (or plain wording) with its saved/requested values. */ +/** + * One incompatible path. `editable` marks whether `key` is a `config.toml` key or env var the + * user can revert; when it isn't (a catalog-pinned artifact or Postgres build), `key` is plain + * wording instead and the suggestion drops the revert advice for it. + */ interface StructuredSettingChange { readonly service: string; readonly path: string; readonly key: string; readonly saved: string; readonly requested: string; + readonly editable: boolean; } -/** Describes one incompatible path: the config key/env var it maps to and its saved/requested values. */ const describeSettingChange = ( service: PlannedInstance["service"], path: string, @@ -221,14 +223,33 @@ const describeSettingChange = ( requestedCreation: ServiceCreationInput | undefined, projectEnvValues: Readonly>, ): StructuredSettingChange => { - if (service === "database" && path === "config.version") + 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: majorVersionOf(databaseVersionOf(savedCreation) ?? "unknown"), - requested: majorVersionOf(databaseVersionOf(requestedCreation) ?? "unknown"), + saved: savedMajor, + requested: requestedMajor, + editable: true, }; + } const endpointName = path.startsWith("endpoints.") ? path.split(".")[1] : undefined; const setting = endpointName === undefined ? undefined : stackEndpointSetting(service, endpointName); @@ -239,6 +260,7 @@ const describeSettingChange = ( 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. @@ -248,6 +270,7 @@ const describeSettingChange = ( key: path === "version" ? `${service} artifact version` : `${service} ${path}`, saved: path === "version" ? (savedCreation?.version ?? "unknown") : "changed", requested: path === "version" ? (requestedCreation?.version ?? "unknown") : "changed", + editable: false, }; }; @@ -290,16 +313,22 @@ const settingChangeLines = ( 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. A saved name is - * shown as plain text, not as a shell-quoted command argument. + * `--workdir`, which can point at a different project's stack of the same name. */ -const destroyCommandFor = (stackIdentity: { - readonly id: string; - readonly name?: string; -}): string => `supabase stack destroy --stack-id ${stackIdentity.id}`; +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 + : 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 = ( @@ -312,20 +341,30 @@ const incompatibleChange = ( | { 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 revertSubject = lines.length === 1 ? changes[0]?.key : undefined; - const destroy = destroyCommandFor(stackIdentity); + 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.`; + const suggestion = + 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: lines.join("; "), - suggestion: `Revert ${revertSubject ?? "the settings above"} to its saved value to keep the stack and its data, or run \`${destroy}\`${nameNote} to recreate the stack — this permanently deletes its local database data.`, + suggestion, }), }; }; @@ -676,7 +715,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags if (Option.isSome(machineErrorContext)) yield* machineErrorContext.value.set({ stack_changes: rejected.changes, - recreate_command: destroyCommandFor(stackIdentity), + recreate_command: rejected.command, }); return yield* rejected.error; } 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 afaa8ac9de..0ea4479de4 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 @@ -21,7 +21,6 @@ import { } from "@supabase/stack/defaults"; import { postgresVersion } from "@supabase/stack/internal/artifacts"; import { - planSupabaseComposition, StackError, type ServiceCreation, type ServiceCreationInput, @@ -30,6 +29,7 @@ import { type StackCredentials, type Stack, } from "@supabase/stack/effect"; +import { planSupabaseComposition } from "@supabase/stack/testing"; import { mockCommandSettings, mockTelemetryStateTracked, @@ -154,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": @@ -1442,6 +1452,159 @@ describe("experimental stack start", () => { }).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}`, + ), + }); + 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"), + }); + 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( + "keeps the revert advice only for editable keys in a mixed editable/non-editable rejection", + () => + 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, + ); + + if (error instanceof StackCommandStartError) { + expect(error.suggestion).toContain( + "Revert [studio] port to its saved value to keep the stack and its data", + ); + expect(error.suggestion).not.toContain("rest artifact version"); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("uses plural grammar to revert several editable settings at once", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-plural-revert-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "plural-revert"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "plural-revert"\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, + ); + + 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("emits structured stack_changes and recreate_command on the JSON error envelope", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1473,6 +1636,7 @@ describe("experimental stack start", () => { key: "[db] major_version", saved: "17", requested: "15", + editable: true, }, ]); expect(envelope.recreate_command).toBe( @@ -1511,6 +1675,7 @@ describe("experimental stack start", () => { key: "[db] major_version", saved: "17", requested: "15", + editable: true, }, ]); expect(event.recreate_command).toBe(`supabase stack destroy --stack-id ${fixture.stack.id}`); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index a3c65aca4c..40e09273f6 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -68,7 +68,6 @@ export type { ServiceCreation } from "./services/Catalog.ts"; /** A service creation as `services.create` accepts it, before stack credentials fill its inputs. */ export type ServiceCreationInput = CatalogServiceCreationInput; export type { CompositionConfig } from "./Orchestrator.ts"; -export { planSupabaseComposition }; export type { CreationChange, PlannedInstance, 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; From 0247b903b68320d5f446e1876515c09eed9ac1cd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 15:37:43 +0200 Subject: [PATCH 4/5] docs(cli): state what the shared port settings guarantee --- apps/cli/src/command-internal/stack-config.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 97e23ca461..66644f0e86 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -168,10 +168,9 @@ export interface StackEndpointSetting { } /** - * A port setting's full identity: everything `envPortOrConfigured`/`envNestedPortOrConfigured` - * need to resolve it below, plus the `configPath` `stackEndpointSetting` reports for it. Each port - * is declared once as a `PortSetting` constant and read from both places, so a new or renamed port - * can't be added to `createCreations` without also reaching `stackEndpointSetting` (or vice versa). + * 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; From cdaef1698b555ea7669688f6a773d03fb55d9afa Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 2 Oct 2026 19:43:18 +0200 Subject: [PATCH 5/5] fix(cli): use destroy-only advice when a non-editable change blocks start A rejected config change that mixed an editable key (e.g. a port) with a non-editable one (a catalog-pinned artifact or Postgres build) suggested reverting the editable key and dropped the non-editable change, but reverting it alone can't unblock start. Use the destroy-only wording whenever any non-editable change is present, and lead the change-list message with a sentence so it reads standalone in text output. Also document the `stack_changes`/`recreate_command` JSON contract and the intentional `--yes` omission, and make several integration-test assertions that lived inside `if (error instanceof StackCommandStartError)` fail loudly instead of passing vacuously for a different error type. --- .../experimental/stack/start/SIDE_EFFECTS.md | 9 ++ .../experimental/stack/start/start.handler.ts | 15 +- .../stack/start/start.integration.test.ts | 129 +++++++----------- 3 files changed, 67 insertions(+), 86 deletions(-) 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 d5d410cfd0..4c6e60d465 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -138,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.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 9bf18332cf..4d8deb5567 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -203,9 +203,10 @@ const settingKeyLabel = ( : formatConfigPath(setting.configPath); /** - * One incompatible path. `editable` marks whether `key` is a `config.toml` key or env var the - * user can revert; when it isn't (a catalog-pinned artifact or Postgres build), `key` is plain - * wording instead and the suggestion drops the revert advice for it. + * 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; @@ -318,7 +319,8 @@ const dedupe = (values: ReadonlyArray): ReadonlyArray => [...new /** * 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. + * `--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}`; @@ -354,8 +356,9 @@ const incompatibleChange = ( ); 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 = - revert === undefined + 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 { @@ -363,7 +366,7 @@ const incompatibleChange = ( command, error: new StackCommandStartError({ reason: "invalid-config", - message: lines.join("; "), + message: `The saved stack cannot adopt these changes: ${lines.join("; ")}`, suggestion, }), }; 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 0ea4479de4..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 @@ -1306,37 +1306,12 @@ describe("experimental stack start", () => { 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)), ); - it.live("reports a shared API port transitioning from automatic to fixed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-api-to-fixed-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "api-to-fixed"\n'); - const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - 'project_id = "api-to-fixed"\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 automatic, requested 54999"), - }); - }).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 @@ -1364,33 +1339,40 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("collects simultaneous database-version and port changes into one error", () => - 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))); + 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, - ); + 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"), - }); - }).pipe(Effect.provide(BunServices.layer)), + 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", () => @@ -1488,6 +1470,7 @@ describe("experimental stack start", () => { `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"); @@ -1529,6 +1512,7 @@ describe("experimental stack start", () => { 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( @@ -1542,7 +1526,7 @@ describe("experimental stack start", () => { ); it.live( - "keeps the revert advice only for editable keys in a mixed editable/non-editable rejection", + "uses the destroy-only wording when a non-editable change accompanies an editable one", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1570,41 +1554,25 @@ describe("experimental stack start", () => { 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( - "Revert [studio] port to its saved value to keep the stack and its data", + "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}`, ); - expect(error.suggestion).not.toContain("rest artifact version"); } }).pipe(Effect.provide(BunServices.layer)), ); - it.live("uses plural grammar to revert several editable settings at once", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-plural-revert-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "plural-revert"\n'); - const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - 'project_id = "plural-revert"\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, - ); - - 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("emits structured stack_changes and recreate_command on the JSON error envelope", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1718,6 +1686,7 @@ describe("experimental stack start", () => { `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)),