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
4 changes: 3 additions & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ Important areas:

The local stack commands use `@supabase/stack` for lifecycle, daemon transport, status, and logs.
That stack layer now has an explicit preparation phase, so foreground and detached `start` flows
can surface `Downloading` before normal runtime states.
can surface `Downloading` before normal runtime states. CLI-managed stacks use lazy service startup:
direct listeners start with the stack, while HTTP and Realtime services activate on first proxied
use. The package API itself keeps eager startup as its backward-compatible default.

Useful companion docs:

Expand Down
44 changes: 42 additions & 2 deletions apps/cli/src/next/commands/start/start.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,17 @@ function setupInteractive(
function setupNonInteractive(
opts: {
info?: Partial<StackInfo>;
stateChanges?: Array<{ name: string; status: StackServiceStatus }>;
stateChanges?: Array<{ name: string; status: StackServiceStatus; dormant?: boolean }>;
startPending?: boolean;
liveStateChanges?: boolean;
} = {},
) {
const stack = mockStack({ info: opts.info, stateChanges: opts.stateChanges });
const stack = mockStack({
info: opts.info,
stateChanges: opts.stateChanges,
startPending: opts.startPending,
liveStateChanges: opts.liveStateChanges,
});
const analytics = mockAnalytics();
const out = mockOutput({ format: "text", interactive: false });
const ink = mockInk();
Expand Down Expand Up @@ -247,6 +254,39 @@ describe("start", () => {
}).pipe(Effect.provide(layer));
});

it.live("completes startup progress for healthy and dormant services", () => {
const { layer, stack, out } = setupNonInteractive({
stateChanges: [
{ name: "postgres", status: "Pending" },
{ name: "studio", status: "Pending" },
],
startPending: true,
liveStateChanges: true,
});
return Effect.gen(function* () {
const fiber = yield* start(backgroundFlags).pipe(
Effect.forkChild({ startImmediately: true }),
);
yield* waitFor(() => stack.started, "stack startup did not begin");

stack.emitStateChange({ name: "postgres", status: "Healthy" });
stack.emitStateChange({ name: "studio", status: "Pending", dormant: true });
stack.resolveStart();
yield* Fiber.join(fiber);

expect(
out.progressEvents
.filter((event) => event.type === "advance")
.reduce((sum, event) => sum + (event.step ?? 0), 0),
).toBe(2);
expect(out.progressEvents).toContainEqual({
type: "advance",
step: 1,
message: "studio is dormant",
});
}).pipe(Effect.provide(layer));
});

it.live("accepts explicit native mode for detached start", () => {
const { layer, stack } = setupNonInteractive();
return Effect.gen(function* () {
Expand Down
89 changes: 89 additions & 0 deletions apps/cli/src/next/commands/start/start.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, expect, test } from "vitest";
import { makeTempHome, makeTempStackProject } from "../../../../tests/helpers/cli.ts";
import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts";

const START_TIMEOUT_MS = 180_000;
const COMMAND_OPTIONS = { entrypoint: "next" as const };
const LIGHTWEIGHT_DOCKER_ARGS = [
"start",
"--detach",
"--mode",
"docker",
"--exclude",
"realtime",
"--exclude",
"storage",
"--exclude",
"imgproxy",
"--exclude",
"mailpit",
"--exclude",
"pgmeta",
"--exclude",
"studio",
"--exclude",
"analytics",
"--exclude",
"vector",
"--exclude",
"pooler",
] as const;

// Lazy service activation crosses the real proxy, daemon, Docker network, and
// container lifecycle boundaries, so keep one gated golden-path live test.
describeLive("supabase start lazy lifecycle (live)", () => {
let project: Awaited<ReturnType<typeof makeTempStackProject>> | undefined;
let home: ReturnType<typeof makeTempHome> | undefined;

afterEach(async () => {
if (project !== undefined && home !== undefined) {
await runSupabaseLive(["stop", "--no-backup"], {
...COMMAND_OPTIONS,
cwd: project.dir,
home: home.dir,
}).catch(() => undefined);
}
await project?.cleanup();
home?.[Symbol.dispose]();
project = undefined;
home = undefined;
});

test(
"keeps an HTTP service dormant until its first proxied request",
{ timeout: START_TIMEOUT_MS + 120_000 },
async () => {
project = await makeTempStackProject("supabase-lazy-start-live-");
home = makeTempHome();

const started = await runSupabaseLive([...LIGHTWEIGHT_DOCKER_ARGS], {
...COMMAND_OPTIONS,
cwd: project.dir,
home: home.dir,
exitTimeoutMs: START_TIMEOUT_MS,
});
expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0);

const before = await runSupabaseLive(["status"], {
...COMMAND_OPTIONS,
cwd: project.dir,
home: home.dir,
});
expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0);
expect(before.stdout).toContain("auth: Pending");

const response = await fetch(`http://127.0.0.1:${project.ports.apiPort}/auth/v1/health`, {
signal: AbortSignal.timeout(60_000),
});
expect(response.ok).toBe(true);

const after = await runSupabaseLive(["status"], {
...COMMAND_OPTIONS,
cwd: project.dir,
home: home.dir,
});
expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0);
expect(after.stdout).toContain("auth: Healthy");
},
);
});
3 changes: 1 addition & 2 deletions apps/cli/src/next/commands/start/ui/StartDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ export function StartDashboard({ model }: { model: StartDashboardModel }) {
const states = useAtomValue(model.displayStatesAtom);
const info = useAtomValue(model.stackInfoAtom);
const phase = useAtomValue(model.phaseAtom);
const showConnectionInfo =
useAtomValue(model.allHealthyAtom) && info !== null && phase !== "failed";
const showConnectionInfo = useAtomValue(model.showConnectionInfoAtom);
const statusLine = useAtomValue(model.statusLineAtom);

return (
Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/next/commands/start/ui/dashboard.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface StartDashboardModel {
readonly errorAtom: Atom.Writable<string | null>;
readonly displayStatesAtom: Atom.Atom<ReadonlyArray<StackServiceState>>;
readonly allHealthyAtom: Atom.Atom<boolean>;
readonly showConnectionInfoAtom: Atom.Atom<boolean>;
readonly statusLineAtom: Atom.Atom<string>;
}

Expand Down Expand Up @@ -68,6 +69,12 @@ export function createStartDashboardModel(
get(displayStatesAtom).length > 0 &&
get(displayStatesAtom).every((s) => s.status === "Healthy"),
);
// Lazy stacks intentionally leave proxy-backed services Pending. A
// successful start phase, rather than universal health, makes connection
// details safe to display.
const showConnectionInfoAtom = Atom.make(
(get) => get(phaseAtom) === "running" && get(stackInfoAtom) !== null,
);
const statusLineAtom = Atom.make((get) => {
const phase = get(phaseAtom);
const error = get(errorAtom);
Expand Down Expand Up @@ -97,6 +104,7 @@ export function createStartDashboardModel(
errorAtom,
displayStatesAtom,
allHealthyAtom,
showConnectionInfoAtom,
statusLineAtom,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ function state(name: string, status: StackServiceStatus) {
}

describe("createStartDashboardModel", () => {
const stackInfo: StackInfo = {
url: "http://127.0.0.1:54321",
dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres",
publishableKey: "pk",
secretKey: "sk",
anonJwt: "anon",
serviceRoleJwt: "service-role",
serviceEndpoints: {},
};
const dashboardStateLayer = Layer.effect(
StartDashboardState,
Effect.gen(function* () {
Expand Down Expand Up @@ -55,9 +64,12 @@ describe("createStartDashboardModel", () => {
registry.get(model.displayStatesAtom).find((entry) => entry.name === "postgres")?.status,
).toBe("Initializing");
expect(registry.get(model.allHealthyAtom)).toBe(false);
registry.set(model.stackInfoAtom, stackInfo);
expect(registry.get(model.showConnectionInfoAtom)).toBe(false);

registry.set(model.phaseAtom, "running");
expect(registry.get(model.statusLineAtom)).toContain("Interrupt to stop");
expect(registry.get(model.showConnectionInfoAtom)).toBe(true);
});

test("shows the foreground failure message when startup fails", async () => {
Expand Down
9 changes: 6 additions & 3 deletions apps/cli/src/next/commands/status/status.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import { Output } from "../../../shared/output/output.service.ts";
import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts";
import type { StatusFlags } from "./status.command.ts";

const READY_STATUSES = new Set(["Healthy", "Running"]);

function formatServiceStateLine(service: {
readonly name: string;
readonly status: string;
Expand Down Expand Up @@ -148,7 +146,11 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) {
: fillServiceVersionManifest(managedStack.state.services),
);
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
const allReady = sortedServices.every((service) => READY_STATUSES.has(service.status));
const allReady = services.every(
(service) =>
["Running", "Healthy"].includes(service.status) ||
(service.status === "Pending" && service.dormant === true),
);
const message = allReady
? "Local Supabase stack is running."
: "Local Supabase stack is running, but some services are not ready.";
Expand All @@ -175,6 +177,7 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) {
restart_count: service.restartCount,
started_at: service.startedAt,
error: service.error,
dormant: service.dormant === true,
})),
};

Expand Down
Loading
Loading