Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
7abd16e
feat(stack): prepare slim resources on demand
jgoux Aug 18, 2026
db8411a
fix(stack): harden resource preparation seams
jgoux Aug 18, 2026
044024b
fix(stack): select one execution mode
jgoux Aug 18, 2026
f7501f4
fix(stack): own runtime selection lifecycle
jgoux Aug 18, 2026
2a1e7ee
fix(stack): harden managed lifecycle coordination
jgoux Aug 18, 2026
98ce067
fix(stack): close preparation lifecycle gaps
jgoux Aug 18, 2026
a72437f
fix(stack): preserve runtime and projection state
jgoux Aug 19, 2026
4a4e321
fix(stack): validate runtime sockets and state
jgoux Aug 19, 2026
05b8067
chore(stack): merge develop into CLI-2123
jgoux Aug 19, 2026
e837280
fix(stack): stabilize runtime integration
jgoux Aug 20, 2026
4d31f6c
fix(stack): harden native binary cache reuse
jgoux Aug 20, 2026
f5f1603
fix(stack): stabilize startup in CI
jgoux Aug 20, 2026
066c466
fix(stack): stabilize Linux Docker startup
jgoux Aug 20, 2026
ac4cb18
fix(stack): detect final Docker Postgres process
jgoux Aug 20, 2026
02699de
fix(stack): preserve Linux bind mount ownership
jgoux Aug 20, 2026
ad1ec45
fix(stack): keep managed startup request alive
jgoux Aug 20, 2026
4a9d895
Merge remote-tracking branch 'origin/develop' into juliengoux/cli-212…
jgoux Aug 20, 2026
59a2305
fix(stack): close runtime lifecycle review gaps
jgoux Aug 20, 2026
462b0d1
test(stack): narrow parallel runtime smoke
jgoux Aug 20, 2026
e390340
test(stack): focus runtime concurrency coverage
jgoux Aug 20, 2026
05d26d8
fix(stack): close final runtime review gaps
jgoux Aug 20, 2026
cbc66a7
fix(stack): make native initialization resumable
jgoux Aug 20, 2026
58ac1ee
docs(stack): require Effect-native internals
jgoux Aug 20, 2026
bab2590
fix(stack): normalize versions and validate ports
jgoux Aug 20, 2026
07ba576
refactor(stack): make allocation and config resolution Effect-native
jgoux Aug 20, 2026
c21255b
fix(stack): close interrupted allocator resources safely
jgoux Aug 20, 2026
ebb517e
fix(stack): make claim recovery interruption-safe
jgoux Aug 20, 2026
64f517e
fix(stack): serialize port claims behind sockets
jgoux Aug 20, 2026
4bd2cde
fix(stack): bracket stale port cleanup
jgoux Aug 20, 2026
f489018
fix(stack): skip occupied stale claims
jgoux Aug 20, 2026
8807918
refactor(stack): make managed identities Effect-native
jgoux Aug 20, 2026
b667724
fix(stack): serialize managed claim publication
jgoux Aug 20, 2026
4c7307f
fix(stack): recover managed publication owners
jgoux Aug 20, 2026
49e0636
fix(stack): remove unsafe publication fallback
jgoux Aug 20, 2026
4ee50d7
fix(stack): classify managed identity publication errors
jgoux Aug 20, 2026
de5abe7
fix(stack): make Git and child lifecycle Effect-native
jgoux Aug 20, 2026
d375be9
fix(stack): make transport boundaries interruption-aware
jgoux Aug 20, 2026
fb826de
fix(stack): harden node control response handling
jgoux Aug 20, 2026
b9fe93e
refactor(stack): make foreground creation Effect-native
jgoux Aug 20, 2026
9f77ba5
fix(stack): close foreground resources on interruption
jgoux Aug 20, 2026
424690c
Merge remote-tracking branch 'origin/develop' into juliengoux/cli-212…
jgoux Aug 20, 2026
0f863eb
refactor(stack): deepen port lease ownership
jgoux Aug 21, 2026
fc57472
fix(stack): harden owned port lease handoff
jgoux Aug 21, 2026
da8fa98
test(stack): synchronize supervisor integration journeys
jgoux Aug 21, 2026
b636f23
test(stack): relax supervisor stop guard
jgoux Aug 21, 2026
f09a271
fix(stack): harden native postgres startup
jgoux Aug 21, 2026
fd153f2
test(cli): align service version override expectations
jgoux Aug 21, 2026
cfe2004
docs(stack): align architecture with runtime ownership
jgoux Aug 21, 2026
38fae0f
refactor(stack): make runtime concurrency Effect-native
jgoux Aug 21, 2026
44863c7
test(stack): guard supervisor file waits
jgoux Aug 21, 2026
54099ad
refactor(stack): integrate Effect-native runtime changes
jgoux Aug 21, 2026
3af212d
fix(stack): bound resource preparation concurrency
jgoux Aug 21, 2026
ee50d86
fix(cli): classify atomic claim failures
jgoux Aug 21, 2026
c5588cd
fix(stack): preserve typed runtime failures
jgoux Aug 21, 2026
5b9b7f6
Merge branch 'develop' into juliengoux/cli-2123-stack-prepare-slim-re…
jgoux Aug 21, 2026
213f057
Merge branch 'develop' into juliengoux/cli-2123-stack-prepare-slim-re…
jgoux Aug 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
// `pull` does not exist yet.
const launchConfig =
stackCheck.value.launch === undefined
? toStartStackConfig([], "auto")
? toStartStackConfig([], undefined)
: withServiceVersions(
toStartStackConfig(
stackCheck.value.launch.excludedServices?.filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
const serviceVersionContext = yield* resolveServiceVersionContext([], undefined);
const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot);
const stackConfig = withServiceVersions(
toStartStackConfig([], "auto"),
toStartStackConfig([], undefined),
serviceVersionContext.runtimeVersions,
);
const stackLayer = yield* daemonLayer({
Expand All @@ -65,7 +65,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
name: opts.stack,
edgeRuntime: opts.edgeRuntime,
launch: {
mode: "auto",
versions: serviceVersionContext.pinnedBaseline,
excludedServices: [],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
} from "../../config/service-version-resolution.ts";

describe("service version overrides", () => {
test("parses and normalizes repeated flag overrides", async () => {
test("parses repeated flag overrides as exact image tags", async () => {
await expect(
Effect.runPromise(
parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]),
),
).resolves.toEqual({
postgrest: "14.5",
mailpit: "v1.30.2",
postgrest: "v14.5",
mailpit: "1.30.2",
auth: "2.180.0",
});
});
Expand All @@ -27,8 +27,8 @@ describe("service version overrides", () => {
const candidateBaseline = {
...DEFAULT_VERSIONS,
postgres: "17.6.1.090",
postgrest: "14.5",
auth: "2.187.0",
postgrest: "v14.5",
auth: "v2.187.0",
};

const layer = Layer.mergeAll(
Expand Down Expand Up @@ -68,12 +68,12 @@ describe("service version overrides", () => {
runtimeVersions: {
...candidateBaseline,
postgres: "17.4.1.045",
auth: "2.170.0",
auth: "v2.170.0",
storage: "1.40.0",
},
activeOverrides: [
{ service: "postgres", version: "17.4.1.045", source: "flag" },
{ service: "auth", version: "2.170.0", source: "flag" },
{ service: "auth", version: "v2.170.0", source: "flag" },
{ service: "storage", version: "1.40.0", source: "local" },
],
availableUpdates: [],
Expand Down
16 changes: 10 additions & 6 deletions apps/cli/src/next/commands/start/start.command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, Context } from "effect";
import { Effect, Layer, Context, Option } from "effect";
import { loadProjectConfig } from "@supabase/config";
import {
DEFAULT_MANAGED_STACK_NAME,
Expand Down Expand Up @@ -79,9 +79,10 @@ export const serviceVersionFlag = Flag.string("service-version").pipe(

const modeFlag = Flag.choice("mode", startModes).pipe(
Flag.withDescription(
'Stack startup mode. "auto" prefers native binaries and falls back to Docker, "native" requires native-compatible services, and "docker" forces Docker for all services.',
'Stack startup mode. "native" requires native-compatible services and "docker" requires a usable Docker or Podman runtime.',
),
Flag.withDefault("auto" as StartMode),
Flag.optional,
Flag.map(Option.getOrUndefined),
);

interface StartVersionStateShape {
Expand Down Expand Up @@ -124,7 +125,7 @@ export type StartFlags = CliCommand.Command.Config.Infer<typeof flags>;
export const startCommand = Command.make("start", flags).pipe(
Command.withDescription(
"Start the local Supabase development stack.\n\n" +
"Starts the full local Supabase stack. Use --mode auto (default) to prefer native binaries and fall back to Docker, --mode native to require native-compatible services, or --mode docker to force Docker-backed startup.\n\n" +
"Starts the full local Supabase stack. By default, a usable Docker or Podman runtime selects Docker mode; otherwise the stack uses native mode. Use --mode to require one explicitly.\n\n" +
"Named CLI stacks persist managed runtime state under the Supabase home directory. Use --exclude to skip optional services. Use --detach to run in the background.",
),
Command.withShortDescription("Start local Supabase stack"),
Expand Down Expand Up @@ -219,7 +220,7 @@ export const startCommand = Command.make("start", flags).pipe(
portDocument: portIntents,
});
const launch = {
mode: flags.mode,
...(flags.mode === undefined ? {} : { mode: flags.mode }),
versions: serviceVersionContext.pinnedBaseline,
excludedServices: flags.exclude,
...(existingSummary?.lastNotifiedUpdateFingerprint === undefined
Expand All @@ -242,12 +243,15 @@ export const startCommand = Command.make("start", flags).pipe(
cwd: runtimeInfo.cwd,
name: flags.stack,
});
if (summary.launch === undefined) {
return yield* Effect.die("Managed stack started without persisted launch settings");
}

return {
stackLayer,
startVersionState: StartVersionState.of({
launch: {
mode: flags.mode,
mode: summary.launch.mode,
versions: serviceVersionContext.pinnedBaseline,
excludedServices: flags.exclude,
},
Expand Down
3 changes: 1 addition & 2 deletions apps/cli/src/next/commands/start/start.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) {
yield* updateManagedLaunch({
...lifecycleInput,
launch: {
mode: launch.mode,
versions: launch.versions,
excludedServices: launch.excludedServices,
lastNotifiedUpdateFingerprint: serviceVersionContext.updateFingerprint,
Expand All @@ -68,7 +67,7 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) {
}

yield* analytics.capture("cli_stack_started", {
mode: flags.mode,
mode: launch.mode,
detach: flags.detach,
stack: flags.stack,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe("start handler", () => {
);
return start({
stack: fixture.stackName,
mode: "auto",
mode: "docker",
exclude: [],
serviceVersion: [],
detach: false,
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/status/status.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const resolveConfiguredSummary = Effect.fnUntraced(function* (input: {
return yield* resolveStackSummary({
...input,
portDocument: managedPortIntents(
toStartStackConfig(excluded, current.launch?.mode ?? "auto"),
toStartStackConfig(excluded, current.launch?.mode),
loaded ?? undefined,
),
});
Expand Down
7 changes: 1 addition & 6 deletions apps/cli/src/next/commands/update/update.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,14 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) {
);

if (Option.isSome(existingSummary)) {
const persistedLaunch = existingSummary.value.launch ?? {
mode: "auto" as const,
excludedServices: [] as const,
};
yield* updateManagedLaunch({
cacheRoot: cliConfig.supabaseHome,
cwd: runtimeInfo.cwd,
workspacePath: projectHome.projectRoot,
stackName: flags.stack,
launch: {
mode: persistedLaunch.mode,
versions: serviceVersionContext.candidateBaseline,
excludedServices: persistedLaunch.excludedServices,
excludedServices: existingSummary.value.launch?.excludedServices ?? [],
...(existingSummary.value.lastNotifiedUpdateFingerprint === undefined
? {}
: {
Expand Down
7 changes: 3 additions & 4 deletions apps/cli/src/next/config/stack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,16 @@ export const excludedStackServices = [
export type ExcludedStackService = (typeof excludedStackServices)[number];
export const isExcludedStackService = (value: string): value is ExcludedStackService =>
excludedStackServices.some((candidate) => candidate === value);
export const startModes = ["native", "auto", "docker"] as const;
export const startModes = ["native", "docker"] as const;
export type StartMode = (typeof startModes)[number];

export function toStartStackConfig(
exclude: ReadonlyArray<ExcludedStackService>,
mode: StartMode,
mode?: StartMode,
): StackConfig {
const excluded = new Set(exclude);
return {
mode,
startupMode: "lazy",
...(mode === undefined ? {} : { mode }),
Comment thread
jgoux marked this conversation as resolved.
realtime: excluded.has("realtime") ? false : {},
storage: excluded.has("storage") ? false : {},
imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {},
Expand Down
25 changes: 13 additions & 12 deletions apps/cli/src/next/config/stack-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ import { describe, expect, it } from "vitest";
import { toStartStackConfig, withServiceVersions } from "./stack-config.ts";

describe("toStartStackConfig", () => {
it("uses lazy service startup with the requested runtime mode", () => {
expect(toStartStackConfig([], "auto")).toMatchObject({
mode: "auto",
startupMode: "lazy",
it("leaves mode unset so the stack package can select the usable runtime", () => {
expect(toStartStackConfig([], undefined)).not.toHaveProperty("mode");
});

it("uses the requested runtime mode and catalog service defaults", () => {
expect(toStartStackConfig([], "docker")).toMatchObject({
mode: "docker",
});
expect(toStartStackConfig([], "docker")).toMatchObject({
mode: "docker",
startupMode: "lazy",
});
expect(toStartStackConfig([], "native")).toMatchObject({
mode: "native",
startupMode: "lazy",
});
});

it("dedupes excluded services when building stack config", () => {
expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({
mode: "auto",
expect(toStartStackConfig(["auth", "auth"], "docker")).toMatchObject({
mode: "docker",
auth: false,
});
expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({
mode: "auto",
expect(toStartStackConfig(["auth", "postgrest"], "docker")).toMatchObject({
mode: "docker",
auth: false,
postgrest: false,
});
Expand All @@ -33,7 +34,7 @@ describe("toStartStackConfig", () => {
describe("withServiceVersions", () => {
it("injects linked service versions without re-enabling excluded services", () => {
expect(
withServiceVersions(toStartStackConfig([], "auto"), {
withServiceVersions(toStartStackConfig([], "docker"), {
postgres: "17.6.1.090",
postgrest: "14.5",
auth: "2.187.0",
Expand All @@ -49,7 +50,7 @@ describe("withServiceVersions", () => {
});

expect(
withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), {
withServiceVersions(toStartStackConfig(["auth", "storage"], "docker"), {
postgres: "17.6.1.090",
auth: "2.187.0",
storage: "1.39.2",
Expand Down
7 changes: 6 additions & 1 deletion apps/cli/tests/helpers/running-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { CliConfig } from "../../src/next/config/cli-config.service.ts";
import { ProjectHome } from "../../src/next/config/project-home.service.ts";
import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts";

const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [] };
const launch = {
mode: "docker" as const,
containerRuntime: "docker" as const,
versions: { postgres: "17.6.1" },
excludedServices: [],
};
const portDocument: ManagedPortIntentDocument = {
activeFields: ["apiPort", "dbPort"],
document: {},
Expand Down
4 changes: 2 additions & 2 deletions packages/process-compose/tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ const isOneShotSupervisor = (args: ReadonlyArray<string>): boolean => {
typeof config === "object" &&
config !== null &&
"command" in config &&
config.command === "bash" &&
"args" in config &&
Array.isArray(config.args) &&
config.args[0] === "-c"
((config.command === "bash" && config.args[0] === "-c") ||
((config.command === "docker" || config.command === "podman") && config.args[0] === "exec"))
);
} catch {
return false;
Expand Down
11 changes: 9 additions & 2 deletions packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ console.log((await stack.getInfo()).url);

`createStack` resolves configuration, reserves ports, and builds a scoped
handle. `stack.start()` starts services; disposing the handle stops them and
releases its lease.
releases its lease. When `mode` is omitted, creation uses Docker mode with a
usable Docker or Podman service and otherwise selects native mode. An explicit
mode never falls back to the other one.

## Managed stack

Expand Down Expand Up @@ -47,7 +49,7 @@ const runtime =
projectDir: projectRoot,
name: "default",
portIntents,
launch: { mode: "auto", versions: {}, excludedServices: [] },
launch: { mode: "docker", versions: {}, excludedServices: [] },
});
```

Expand All @@ -56,5 +58,10 @@ const runtime =
`stopDaemon` and the discovery helpers delegate to the managed lifecycle
facade. No CLI metadata file or PID polling is involved.

After a managed supervisor claims a stack, its persisted Docker, Podman, or
native selection remains pinned even if startup later fails. Retry after
restoring or starting that runtime; delete and recreate the stack to choose a
different execution mode. Deletion removes the stack's managed data.

For the end-to-end lifecycle, identity, ports, service execution, transport,
compiled-Bun re-entry, and testing boundary, see [How `@supabase/stack` works](docs/architecture.md).
37 changes: 26 additions & 11 deletions packages/stack/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ prepares/builds a scoped runtime and handle. It does **not** start service
processes. Asset preparation and process-compose graph construction happen
when the handle is first started or a service is activated.

`stack.start()` starts services according to the configured startup mode and
waits for the selected readiness policy. The handle also exposes status, logs,
`stack.start()` prepares and starts Postgres plus the services whose resource
policy is `eager`, then waits for the selected readiness policy. Services whose
policy is `lazy` remain dormant until a proxy request or explicit service
operation activates them; activation prepares the service and its required
dependencies before starting it. The handle also exposes status, logs,
per-service operations, and graceful `stop()`/`dispose()` methods. Its scope
owns service processes and releases the lease when disposed. A direct stack
never reads or writes managed documents and never coordinates with a sibling
Expand Down Expand Up @@ -222,15 +225,27 @@ status, service operations, logs, graceful stop, and launch-update routes;

## Service execution and `ApiProxy`

`StackPreparation` resolves each enabled service to a verified native binary or
a Docker image. `mode: "native"` uses the supported native services and rejects
Docker-only services; `mode: "docker"` resolves every service to an image;
`mode: "auto"` prefers native artifacts and falls back to Docker. The
`StackBuilder` turns those resolutions into one process-compose graph, so a
stack can run native and Docker-backed services together. Docker resources are
namespaced with the managed stack id; when native Postgres is combined with
Docker services, the graph supplies the platform-specific host address so the
containers can reach it.
`StackPreparation` plans resources without materializing them, then prepares
only the requested services and their required dependency closure. Concurrent
requests for the same resource share one installation or pull. Native assets
come from the pinned slim-services release contract and are checksum- and
manifest-verified before atomic publication. Docker assets use one canonical
`ghcr.io/supabase/cli/<service>:<version>` reference; a locally cached image is
reused and a missing image is pulled without registry fallback. Stack creation
selects exactly one execution mode: a usable Docker daemon is preferred, then a
usable Podman service; if neither responds, the stack uses native mode. An
explicit `mode: "native"` rejects Docker-only services, while explicit `mode:
"docker"` requires a usable container runtime. Preparation never falls back to
the other mode after that choice. Once a managed supervisor claims and persists
that selection, it remains pinned even when startup later fails during image
pull, native download, graph build, or readiness. Retries restore or start the
persisted runtime and reuse the same mode; selecting another mode requires
deleting and recreating the stack, which removes its managed data.

The `StackBuilder` turns planned resolutions into one process-compose graph,
without eagerly materializing every graph resource. Container resources are
namespaced with the managed stack id and every pull, launch, health check, and
cleanup uses the selected Docker or Podman executable.

`ApiProxy` listens on the configured public `apiPort` and routes Supabase API
paths (`/auth`, `/rest`, `/functions`, `/realtime`, `/storage`, `/pg`,
Expand Down
Loading
Loading