Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ owner are unreachable. Plain `status` JSON `env` comes from saved bindings and c
stopped or sleeping members, while `--env` requires a reachable owner and a running primary
database.

Starting a stopped stack whose `config.toml` moved an endpoint to a different port, or to and from
automatic, applies that change instead of failing: text output prints one line per changed endpoint,
for example `api: 54321 → 26199`, and JSON/stream-json output add an `endpoint_changes` array (each
entry naming the endpoint and its `from`/`to` port) to the success payload, present only when a
change applied. A changed artifact version or PostgreSQL major version still fails, naming the
changed setting and suggesting `supabase stack destroy`.

## Exporting environment variables

```sh
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/docs/supabase-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ no separate command that rewrites it, and no second project-level pinned-version
Raw `supabase/config.toml` values and their origins are loaded before defaults are applied. Explicit
sticky values are persisted as `exact` intents in each managed document. Omitted values remain
`automatic`; sibling worktrees and branches have independent stack identities and allocations.
Automatic allocation avoids ports saved by any stack, so stopped stacks keep their URLs. An exact
Automatic allocation avoids ports saved by any stack, so stopped stacks keep their URLs. A port
intent is stable: it does not migrate on its own, but starting a stopped stack whose configuration
moved an endpoint to a different exact port, or to and from automatic, re-plans that endpoint and
claims the new one instead of failing; a line in the command's output names the endpoint and its
old and new port. An exact
port is rejected only when a listener already answers on it or the port cannot be bound; another
stack's saved claim alone never blocks it, and a conflict names the stack that saved the port. Runtime-only service ports are
selected by the managed supervisor and are not written to the document.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => {
stop: unused,
restart: unused,
},
startupEndpointChanges: unused,
stop: unused,
destroy: unused,
commands: { run: () => unused },
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/dump/dump.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => {
stop: Effect.succeed([]),
restart: Effect.succeed([]),
},
startupEndpointChanges: Effect.succeed([]),
stop: Effect.void,
destroy: Effect.succeed({ runtimeCleanup: "complete" as const }),
commands: { run: runCommand },
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/reset/reset.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,7 @@ function mockResetStackApi(opts: {
}),
restart: Effect.die("unused"),
},
startupEndpointChanges: Effect.die("unused"),
stop: Effect.die("unused"),
destroy: Effect.die("unused"),
commands: { run: () => Effect.die("unused") },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ function generateStackApi(workdir: string) {
stop: unusedStack,
restart: unusedStack,
},
startupEndpointChanges: unusedStack,
stop: unusedStack,
destroy: unusedStack,
commands: { run: unusedStackFn },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ function syncStackApi(workdir: string, port: number) {
stop: unusedSync,
restart: unusedSync,
},
startupEndpointChanges: unusedSync,
stop: unusedSync,
destroy: unusedSync,
commands: { run: unusedSyncFn },
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/start/start.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,7 @@ describe("db start stack backend", () => {
stop: Effect.succeed([]),
restart: Effect.succeed([]),
},
startupEndpointChanges: Effect.succeed([]),
stop: Effect.void,
destroy: Effect.succeed({ runtimeCleanup: "complete" as const }),
commands: { run: () => Effect.die("unused") },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => {
stop: Effect.succeed([]),
restart: Effect.succeed([]),
},
startupEndpointChanges: Effect.succeed([]),
stop: Effect.void,
destroy: Effect.succeed({ runtimeCleanup: "complete" as const }),
commands: { run: () => Effect.die("unused") },
Expand Down
42 changes: 34 additions & 8 deletions apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,35 @@ verification, replace the saved configuration of the existing instances; their i
and ports are retained. Changed exclusions reuse existing service identities, data, and ports.
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 `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.
project configuration file is unchanged. The requested creations travel into the stack package's own
owner startup: when every incompatible path across the whole composition is a changed endpoint, each
is re-planned there while the new owner alone holds the stack's lease, before it registers endpoint
namespaces from the saved state: as late as practical, just before its own normal endpoint binding
claims the newly requested port, or a freshly chosen automatic one, it saves the updated endpoint
intent with the old port claim dropped, reusing every check a live composition bind already applies.
The rollback covers only this save-and-claim commit, which finishes before the owner serves RPC or
publishes its holder: a failure or interruption there, not only a claim conflict, restores the saved
state and claims as they read before the re-plan, except a claim whose old port another stack took in
the meantime, which is left unclaimed so the next start reports it as a normal port conflict instead
of overlapping that stack's claim; a hard process death in this window is an accepted limitation, and
the next successful start converges the saved state again. A later startup failure, once that commit
succeeds, keeps the committed (consistent) state instead of rolling it back, since an attached client
may already have persisted its own change by then; the next start reuses it. This includes a failure
during the CLI's own database preparation (see First startup and retries below). A concurrent start
attaches to whichever owner wins that race instead of re-planning again. If the
saved stack's owner exits between this command's liveness check and the moment it opens the stack,
the freshly spawned replacement owner boots without the requested creations and this start falls
back to today's rejection; every later start now sees that replacement owner as running and skips
the re-plan too. Recovering means: stop the stack, then start it again. Text
output prints one line per changed endpoint naming its old and new port; JSON and stream-json output
add the same changes to the success payload. Any other incompatible path blocks the re-plan for the
whole composition, even for a member whose own change is purely a changed endpoint: a changed
`config.toml`-backed or env-var-backed setting (such as a changed PostgreSQL major version) still
fails before modifying the stopped composition, naming the key or env var behind the change with its
saved and requested values and suggesting reverting it; a changed catalog-pinned artifact version or
a same-major PostgreSQL build mismatch, which no `config.toml` key or env var controls, instead uses
a plain label with no revert advice. Either way the failure suggests running the stack's exact
`supabase stack destroy` command to recreate it.

## First startup and retries

Expand Down Expand Up @@ -134,10 +159,11 @@ and warnings written while the spinner is shown appear on their own rows.
JSON output returns the stack `id`, its saved `runtime`, `endpoints` keyed by service and endpoint
name (protocol, address, port, and URL, matching `stack status`, with no synthetic entries),
`lazy_services` listing members that start on their first request (empty with `--eager`), `env`
(the same connection map `stack status --env` exports, present on every success path), and an
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.
(the same connection map `stack status --env` exports, present on every success path), an
`endpoint_changes` array naming each re-planned endpoint with its old and new port when the start
applied any, and an 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function makeDatabaseStack(sqlPort: number, credentials: StackCredentials): Stac
stop: Effect.die("unused"),
restart: Effect.die("unused"),
},
startupEndpointChanges: Effect.die("unused"),
stop: Effect.die("unused"),
destroy: Effect.die("unused"),
commands: { run: () => Effect.die("unused") },
Expand Down
115 changes: 80 additions & 35 deletions apps/cli/src/commands/experimental/stack/start/start.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts";
import { Effect, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect";
import {
apiRoute,
resolveNativePostgresUser,
type EndpointPortChange,
type Observation,
type PlannedInstance,
type ServiceCreation,
Expand Down Expand Up @@ -381,6 +383,12 @@ const selectedCreations = (
return !exclusions.includes(capability);
});

/** Names a changed endpoint the way the connection summary does: `api`, or `service.endpoint`. */
const endpointLabel = (change: EndpointPortChange) =>
change.endpoint === "http" && apiRoute(change.service) !== undefined
? "api"
: `${change.service}.${change.endpoint}`;

const isServing = (status: Pick<Observation, "lifecycle" | "health">) =>
status.lifecycle === "running" && status.health === "healthy";

Expand Down Expand Up @@ -456,10 +464,51 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
? output.info(postgresUser.message)
: Effect.void;
const configBeforeCreate =
target.id === undefined ? yield* loadStartConfig(target.projectRoot, fs, path) : undefined;
target.id === undefined || !target.hostRunning
? yield* loadStartConfig(target.projectRoot, fs, path)
: undefined;
if (target.id === undefined) yield* ensurePostgresUser;
const stateRoot = path.join(settings.supabaseHome, "stacks");
const cacheRoot = path.join(settings.supabaseHome, "cache", "stack");
const resolveRequested = (
stackId: string,
config: Effect.Success<ReturnType<typeof loadStartConfig>>["config"],
) =>
Effect.gen(function* () {
const creations = yield* config.creations(stackId).pipe(
Effect.mapError(
(error) =>
new StackCommandStartError({
reason: "invalid-config",
message: error.message,
cause: error,
}),
),
);
return yield* Effect.forEach(
selectedCreations(creations, exclusions),
withProjectFunctionsEnv,
).pipe(
Effect.mapError(
(cause) =>
new StackCommandStartError({
reason: "invalid-config",
message: cause.message,
cause,
}),
),
);
});
// The saved stack's owner is not running, so its requested creations travel into the owner's
// own startup: it re-plans and commits a changed endpoint's port while it alone holds the
// stack's lease, before it registers endpoint namespaces from the saved state. A concurrent
// start attaches to whichever owner wins that race instead of re-planning again. A running
// owner already bound its endpoints at its own startup and keeps today's behavior of applying
// endpoint changes only after stop and start.
const requestedForReplan =
target.id !== undefined && !target.hostRunning && configBeforeCreate !== undefined
? yield* resolveRequested(target.id, configBeforeCreate.config)
: undefined;
Comment thread
jgoux marked this conversation as resolved.
const startupComplete = yield* Ref.make(false);
const stack = yield* Effect.acquireRelease(
target.id === undefined
Expand All @@ -471,7 +520,13 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
startOwner: true,
...(target.name === undefined ? {} : { name: target.name }),
})
: stackApi.open({ id: target.id, stateRoot, cacheRoot, startOwner: true }),
: stackApi.open({
id: target.id,
stateRoot,
cacheRoot,
startOwner: true,
...(requestedForReplan === undefined ? {} : { requestedCreations: requestedForReplan }),
}),
(stack) =>
Ref.get(startupComplete).pipe(
Effect.flatMap((complete) =>
Expand Down Expand Up @@ -501,6 +556,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
},
currentShellPlatform(),
);
// Set once a fully stopped stack's owner reports the endpoint changes it applied at its own
// startup; a stack that was already running never re-plans, so this stays empty for it.
let endpointChanges: ReadonlyArray<EndpointPortChange> = [];
const reportReady = (report: Effect.Success<ReturnType<typeof startReport>>, message: string) =>
Effect.gen(function* () {
const credentials = yield* summaryCredentials(stack.credentials.get, output.warn);
Expand All @@ -517,6 +575,15 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
.filter(({ activation }) => activation === "lazy")
.map(({ service }) => service),
env,
...(endpointChanges.length === 0
? {}
: {
endpoint_changes: endpointChanges.map((change) => ({
endpoint: endpointLabel(change),
from: change.from,
to: change.to,
})),
}),
});
if (message.length > 0) yield* output.success(message);
yield* output.raw(
Expand Down Expand Up @@ -545,7 +612,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
: status.lifecycle !== "starting" && status.wakeEnabled,
);
if (fullyStarted) {
yield* Effect.annotateCurrentSpan({ "stack.path": "already-running" });
yield* Ref.set(startupComplete, true);
yield* reportReady(
yield* startReport(stack, currentInstances),
Expand All @@ -561,10 +627,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
lifecycle === "running" || lifecycle === "starting" || wakeEnabled,
);
if (resumable) {
yield* Effect.annotateCurrentSpan({
"stack.path": "resume",
"stack.service_count": currentInstances.length,
});
yield* output.info(
"Resuming the saved stack services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.",
);
Expand Down Expand Up @@ -608,6 +670,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
message: "The stack is in a partial lifecycle state",
suggestion: "Run supabase stack stop, then supabase stack start to recover the stack.",
});
endpointChanges = yield* stack.startupEndpointChanges.pipe(Effect.mapError(stackError));
for (const change of endpointChanges)
yield* output.info(`${endpointLabel(change)}: ${change.from} → ${change.to}`);
if (target.id !== undefined) yield* ensurePostgresUser;
const shadowDatabase =
composition.members.length === 0
Expand All @@ -621,25 +686,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
});
const { config, keys, toml } =
configBeforeCreate ?? (yield* loadStartConfig(target.projectRoot, fs, path));
const creations = yield* config.creations(stack.id).pipe(
Effect.mapError(
(error) =>
new StackCommandStartError({
reason: "invalid-config",
message: error.message,
cause: error,
}),
),
);
const requested = yield* Effect.forEach(
selectedCreations(creations, exclusions),
withProjectFunctionsEnv,
).pipe(
Effect.mapError(
(cause) =>
new StackCommandStartError({ reason: "invalid-config", message: cause.message, cause }),
),
);
// Reuses the creations already resolved for the re-plan above instead of reading the
// Functions dotenv a second time; only a new or already-running stack has none yet.
const requested = requestedForReplan ?? (yield* resolveRequested(stack.id, config));
if (
requested.some(({ service }) => service === "studio") &&
!requested.some(({ service }) => service === "rest")
Expand Down Expand Up @@ -705,7 +754,11 @@ 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));
// `requested` is the whole desired composition (exclusions already applied), not a partial
// comparison, so an excluded sibling's saved port must not anchor a shared endpoint's port.
const planned = yield* stack.composition
.plan(requested, { requestKind: "complete" })
.pipe(Effect.mapError(stackError));
const stackIdentity = {
id: stack.id,
...(target.name === undefined ? {} : { name: target.name }),
Expand Down Expand Up @@ -759,12 +812,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
const candidate = candidates[0];
if (candidate !== undefined) reuseIds.push(candidate.id);
}
yield* Effect.annotateCurrentSpan({
"stack.path": "start",
"stack.service_count": requested.length,
"stack.initial_composition": initialComposition,
"stack.service_kinds_changed": serviceKindsChanged,
});
const starting = yield* output.task("Starting local Supabase stack...");
const members = yield* stack.composition
.supabase(requested, {
Expand Down Expand Up @@ -838,7 +885,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
message: "The stack has no saved credentials",
});
if (initialComposition || serviceKindsChanged) {
yield* Effect.annotateCurrentSpan({ "stack.migrations_applied": true });
const migrations = initialComposition
? {
workdir: target.projectRoot,
Expand Down Expand Up @@ -868,7 +914,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
(message) => new SeedConfigLoadError({ message }),
);
if (hasConfiguredBuckets(context.config)) {
yield* Effect.annotateCurrentSpan({ "stack.storage_seeded": true });
yield* storage.start.pipe(
Effect.tapError((error) => starting.fail(error.message)),
Effect.mapError(stackError),
Expand Down
Loading
Loading