Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
12 changes: 7 additions & 5 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,13 @@ Important areas:
- `src/shared/runtime/` for TTY, stdin, browser, Ink, and process-control services
- `src/next/auth/` for login-related services

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. CLI-managed stacks use lazy service startup:
direct listeners and Realtime start with the stack, while HTTP services activate on first proxied
use. The package API itself keeps eager startup as its default.
The local stack commands use `@supabase/stack` for lifecycle, status, logs, and runtime operations.
Managed ownership uses stable loopback `GET /owner` and session-fenced `POST /stop`; same-build
runtime calls use Effect RPC over framed NDJSON at `POST /rpc`. That stack layer now has an explicit
preparation phase, so foreground and detached `start` flows can surface `Downloading` before normal
runtime states. CLI-managed stacks use lazy service startup: direct listeners and Realtime start
with the stack, while HTTP services activate on first proxied use. The package API itself keeps
eager startup as its default.

Useful companion docs:

Expand Down
21 changes: 20 additions & 1 deletion apps/cli/scripts/build-binary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { $ } from "bun";
import process from "node:process";
import { Effect } from "effect";
import { resolveCliBuildIdentity } from "../src/shared/cli/version.ts";

import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts";

Expand All @@ -17,8 +19,25 @@ if (shell !== "next" && shell !== "legacy") {

const entrypoint = `src/${shell}/main.ts`;
const outfile = `dist/supabase-${shell}`;
const packageJson = JSON.parse(
await Bun.file(new URL("../package.json", import.meta.url)).text(),
) as {
version?: string;
};
if (packageJson.version === undefined || packageJson.version.length === 0) {
throw new Error("CLI package version is required for a compiled build");
}
const buildIdentity = await Effect.runPromise(
resolveCliBuildIdentity({
cliVersion: packageJson.version,
release: process.env.SUPABASE_RELEASE_BUILD === "1",
}),
);
const buildId = buildIdentity.buildId;
const versionDefine = `--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(packageJson.version)}`;
const buildIdDefine = `--define=process.env.SUPABASE_CLI_BUILD_ID=${JSON.stringify(buildId)}`;
const defineArg = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify(
await bundleServeMainTemplate(),
)}`;

await $`bun build ${entrypoint} --compile ${defineArg} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile ${versionDefine} ${buildIdDefine} ${defineArg} --outfile ${outfile}`;
12 changes: 12 additions & 0 deletions apps/cli/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { parseArgs } from "node:util";
import { Effect } from "effect";
import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts";
import { resolveCliBuildIdentity } from "../src/shared/cli/version.ts";
import { darwinBinariesForShell, MACOS_IDENTIFIERS } from "./macos-signing.ts";

const MUSL_TARGETS = [
Expand Down Expand Up @@ -44,6 +46,14 @@ if (!version) {
);
process.exit(1);
}
const buildId = (
await Effect.runPromise(
resolveCliBuildIdentity({
cliVersion: version,
release: values.version !== undefined || process.env.SUPABASE_RELEASE_BUILD === "1",
}),
)
).buildId;
if (values.version === undefined) {
console.warn(
`[build] --version not provided; falling back to package.json version "${version}". Pass --version explicitly in release builds.`,
Expand Down Expand Up @@ -149,6 +159,7 @@ async function buildTarget(target: (typeof TARGETS)[number]) {
"--minify",
`--target=${target.bunTarget}`,
`--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)}`,
`--define=process.env.SUPABASE_CLI_BUILD_ID=${JSON.stringify(buildId)}`,
`--define=SUPABASE_LIBC=${JSON.stringify(libc)}`,
serveMainTemplateDefine,
...posthogBuildDefines,
Expand Down Expand Up @@ -298,6 +309,7 @@ async function buildMuslBinaries() {
"--minify",
`--target=${target.bunTarget}`,
`--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)}`,
`--define=process.env.SUPABASE_CLI_BUILD_ID=${JSON.stringify(buildId)}`,
`--define=SUPABASE_LIBC=${JSON.stringify(libc)}`,
serveMainTemplateDefine,
...posthogBuildDefines,
Expand Down
24 changes: 23 additions & 1 deletion apps/cli/src/next/commands/branches/switch/switch.handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { daemonLayer, resolveManagedStack, stopDaemon } from "@supabase/stack/effect";
import {
connectLayer,
daemonLayer,
resolveManagedStack,
Stack,
stopDaemon,
} from "@supabase/stack/effect";
import { loadProjectConfig } from "@supabase/config";
import { Effect, Option } from "effect";
import { PlatformApi } from "../../../auth/platform-api.service.ts";
Expand All @@ -20,6 +26,7 @@ import { Output } from "../../../../shared/output/output.service.ts";
import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts";
import { printStackConnectionInfo, startStackWithProgress } from "../../../stack/stack.shared.ts";
import { BranchNotFoundError } from "../errors.ts";
import { currentCliBuildIdentity } from "../../../../shared/cli/version.ts";

export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
name: Option.Option<string>;
Expand All @@ -30,6 +37,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
const cliConfig = yield* CliConfig;
const projectHome = yield* ProjectHome;
const runtimeInfo = yield* RuntimeInfo;
const buildIdentity = yield* currentCliBuildIdentity;

yield* output.intro("Switch branch");

Expand Down Expand Up @@ -133,6 +141,18 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
if (Option.isSome(stackCheck) && stackCheck.value.lifecycle === "running") {
const stackName = stackCheck.value.identity.name;

// Branch switching restarts a running stack, but it is not authorized to
// replace an incompatible daemon. Probe the same-build RPC boundary before
// stopping the existing owner so a mismatch leaves the old stack intact.
const existingLayer = yield* connectLayer({
buildIdentity,
cwd: runtimeInfo.cwd,
cacheRoot: cliConfig.supabaseHome,
projectDir: projectHome.projectRoot,
name: stackName,
});
yield* Effect.scoped(Effect.provide(Stack, existingLayer).pipe(Effect.asVoid));
Comment on lines +147 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow branch switching to recover a stale running document

When a supervisor crashes after leaving stack.json with lifecycle: "running", resolveManagedStack returns that stale document and this new connectLayer call fails with NoRunningStackError because no ready control owner can be probed. The command therefore never reaches stopDaemon, which previously acquired ownership, cleaned up the stale runtime, and allowed the stack to restart for the selected branch. Restrict the compatibility preflight to a live owner or let this specific no-owner result continue into stale-owner recovery.

Useful? React with 👍 / 👎.


const stopping = yield* output.task("Stopping local stack...");
yield* stopDaemon({
cwd: runtimeInfo.cwd,
Expand All @@ -158,6 +178,8 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot);

const stackLayer = yield* daemonLayer({
buildIdentity,
incompatibleOwnerPolicy: "fail",
cacheRoot: cliConfig.supabaseHome,
cwd: runtimeInfo.cwd,
projectDir: projectHome.projectRoot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand
import { emptyEnv, mockOutput, mockProjectLinkState } from "../../../../../tests/helpers/mocks.ts";
import { ProjectLinkState } from "../../../config/project-link-state.service.ts";
import { switchBranch } from "./switch.handler.ts";
import { makeRunningStackFixture } from "../../../../../tests/helpers/running-stack.ts";
import { controlTransportLayer } from "@supabase/stack/managed";

// ---------------------------------------------------------------------------
// Fixtures
Expand Down Expand Up @@ -151,7 +153,7 @@ function setup(
const api = mockPlatformApi(opts.branches ?? [MAIN_BRANCH, DEV_BRANCH], {
status: opts.status,
});
const layer = Layer.mergeAll(emptyEnv(), out.layer, state, api.layer);
const layer = Layer.mergeAll(emptyEnv(), out.layer, state, api.layer, controlTransportLayer);
return { out, layer, api };
}

Expand Down Expand Up @@ -335,7 +337,13 @@ describe("branches switch handler", () => {
const out = mockOutput({ format: "json" });
const linkState = mockProjectLinkState(DEFAULT_LINK_STATE);
const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH], { status: 503 });
const layer = Layer.mergeAll(emptyEnv(), out.layer, linkState, api.layer);
const layer = Layer.mergeAll(
emptyEnv(),
out.layer,
linkState,
api.layer,
controlTransportLayer,
);

yield* switchBranch({ name: Option.some("dev") }).pipe(
withJsonErrorHandling,
Expand Down Expand Up @@ -372,4 +380,38 @@ describe("branches switch handler", () => {
);
}),
);

it.live("does not stop an incompatible local stack before branch restart", () =>
Effect.promise(() =>
makeRunningStackFixture({
buildIdentity: { cliVersion: "2.60.0", buildId: "release:2.60.0" },
}),
).pipe(
Effect.flatMap((fixture) => {
const out = mockOutput();
const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH]);
const layer = Layer.mergeAll(
fixture.baseLayer,
out.layer,
mockProjectLinkState(DEFAULT_LINK_STATE),
api.layer,
);
return switchBranch({ name: Option.some("dev") }).pipe(
Effect.provide(layer),
Effect.exit,
Effect.andThen((exit) =>
Effect.promise(async () => {
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain("DaemonUpgradeRequired");
}
expect(api.requests).toHaveLength(1);
expect((await fixture.readDocument())?.lifecycle).toBe("running");
}),
),
Effect.ensuring(Effect.promise(() => fixture.dispose())),
);
}),
),
);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { connectLayer, daemonLayer, Stack, type EdgeRuntimeConfig } from "@supabase/stack/effect";
import { loadProjectConfig } from "@supabase/config";
import { Duration, Effect, FileSystem, Layer, Option, Stream } from "effect";
import { Context, Duration, Effect, FileSystem, Layer, Option, Stream } from "effect";
import { join } from "node:path";
import { currentCliBuildIdentity } from "../../../../shared/cli/version.ts";
import { CliConfig } from "../../../config/cli-config.service.ts";
import { ProjectHome } from "../../../config/project-home.service.ts";
import { projectLocalServiceVersionsLayer } from "../../../config/project-local-service-versions.layer.ts";
Expand Down Expand Up @@ -48,6 +49,7 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
const projectHome = yield* ProjectHome;
const runtimeInfo = yield* RuntimeInfo;
const output = yield* Output;
const buildIdentity = yield* currentCliBuildIdentity;

yield* output.info("No local stack is running. Starting the local Supabase stack...");
yield* ensureProjectStateIgnored(projectHome.projectRoot);
Expand All @@ -62,6 +64,8 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
servicePolicies: { "edge-runtime": "eager" as const },
};
const stackLayer = yield* daemonLayer({
buildIdentity,
incompatibleOwnerPolicy: "fail",
cacheRoot: cliConfig.supabaseHome,
cwd: runtimeInfo.cwd,
projectDir: projectHome.projectRoot,
Expand All @@ -74,8 +78,9 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
...stackConfig,
portIntents: managedPortIntents(stackConfig, loadedProjectConfig ?? undefined),
});
yield* startStackWithProgress().pipe(Effect.provide(stackLayer));
const stack = yield* Stack.pipe(Effect.provide(stackLayer));
const context = yield* Layer.build(stackLayer);
const stack = Context.get(context, Stack);
yield* startStackWithProgress().pipe(Effect.provide(context));

return { stack, startedByCommand: true };
});
Expand All @@ -86,8 +91,10 @@ export const connectOrStartFunctionsDevStack = Effect.fnUntraced(function* (
const cliConfig = yield* CliConfig;
const projectHome = yield* ProjectHome;
const runtimeInfo = yield* RuntimeInfo;
const buildIdentity = yield* currentCliBuildIdentity;

const existingLayer = yield* connectLayer({
buildIdentity,
cwd: runtimeInfo.cwd,
cacheRoot: cliConfig.supabaseHome,
projectDir: projectHome.projectRoot,
Expand All @@ -98,7 +105,8 @@ export const connectOrStartFunctionsDevStack = Effect.fnUntraced(function* (
);

if (Option.isSome(existingLayer)) {
const stack = yield* Stack.pipe(Effect.provide(existingLayer.value));
const context = yield* Layer.build(existingLayer.value);
const stack = Context.get(context, Stack);
return { stack, startedByCommand: false };
}

Expand Down
8 changes: 6 additions & 2 deletions apps/cli/src/next/commands/logs/logs.handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { connectLayer, Stack } from "@supabase/stack/effect";
import { Effect, Stream } from "effect";
import { Context, Effect, Layer, Stream } from "effect";
import { CliConfig } from "../../config/cli-config.service.ts";
import { ProjectHome } from "../../config/project-home.service.ts";
import { Output } from "../../../shared/output/output.service.ts";
import { ProcessControl } from "../../../shared/runtime/process-control.service.ts";
import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts";
import { currentCliBuildIdentity } from "../../../shared/cli/version.ts";
import type { LogsFlags } from "./logs.command.ts";
import { UnsupportedLogsOutputFormatError } from "./logs.errors.ts";

Expand Down Expand Up @@ -54,6 +55,7 @@ export const logs = Effect.fnUntraced(function* (flags: LogsFlags) {
const projectHome = yield* ProjectHome;
const processControl = yield* ProcessControl;
const runtimeInfo = yield* RuntimeInfo;
const buildIdentity = yield* currentCliBuildIdentity;

yield* output.intro("Show local Supabase logs");

Expand All @@ -65,12 +67,14 @@ export const logs = Effect.fnUntraced(function* (flags: LogsFlags) {
}

const layer = yield* connectLayer({
buildIdentity,
cwd: runtimeInfo.cwd,
cacheRoot: cliConfig.supabaseHome,
projectDir: projectHome.projectRoot,
name: flags.stack,
});
const stack = yield* Effect.provide(Stack, layer);
const context = yield* Layer.build(layer);
const stack = Context.get(context, Stack);
const services = flags.service.length === 0 ? undefined : flags.service;
const history = flags.tail > 0 ? yield* stack.logHistoryAll(flags.tail, services) : [];
const historyStream = Stream.fromIterable(history).pipe(
Expand Down
42 changes: 41 additions & 1 deletion apps/cli/src/next/commands/logs/logs.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";
import { BunServices } from "@effect/platform-bun";
import { Effect, Layer } from "effect";
import { Effect, Exit, Layer } from "effect";
import { logs } from "./logs.handler.ts";
import {
mockOutput,
Expand All @@ -10,6 +10,46 @@ import {
import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts";

describe("logs handler", () => {
it.live("fails with an actionable upgrade error without restarting an incompatible owner", () =>
Effect.promise(() =>
makeRunningStackFixture({
buildIdentity: { cliVersion: "2.60.0", buildId: "release:2.60.0" },
}),
).pipe(
Effect.flatMap((fixture) => {
const out = mockOutput();
const processControl = mockProcessControl();
const layer = Layer.mergeAll(
fixture.baseLayer,
out.layer,
processControl.layer,
mockProjectLinkState(),
BunServices.layer,
);
return logs({ stack: fixture.stackName, service: [], tail: 10, noFollow: false }).pipe(
Effect.provide(layer),
Effect.exit,
Effect.ensuring(Effect.promise(() => fixture.dispose())),
Effect.andThen((exit) =>
Effect.sync(() => {
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain("DaemonUpgradeRequired");
}
expect(processControl.exitCalls).toEqual([]);
expect(out.messages).not.toContainEqual(
expect.objectContaining({
type: "info",
message: expect.stringContaining("[postgres]"),
}),
);
}),
),
);
}),
),
);

it.live("attaches to managed control and renders persisted and live history", () =>
Effect.promise(() => makeRunningStackFixture()).pipe(
Effect.flatMap((fixture) => {
Expand Down
Loading
Loading