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
8 changes: 6 additions & 2 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ stack = true

The selected backend determines accepted flags, help, and completion before the command is parsed. Set the flag to `false`, or remove it, to restore the legacy top-level commands. Explicit `supabase stack` commands continue to use the new backend.

For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes precedence over `experimental.stack` in `config.toml`; an unset or empty value falls back to the file setting. Other values are rejected. The override affects only the top-level lifecycle aliases and is applied before reading the project configuration.
For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes precedence over `experimental.stack` in `config.toml`; an unset or empty value falls back to the file setting. Other values are rejected. The override is applied before reading the project configuration.

This flag currently selects only the `start`, `stop`, and `status` aliases. It does not switch the database, migration, functions, or storage command families to the new backend.
When the flag is on, the `db` and `migration` family uses the project stack for `--local` and provisions throwaway shadow Postgres through `@supabase/stack` (`EphemeralPostgres`). Linked and `--db-url` targets stay on the Management API. Compose names (`supabase_db_*`, `supabase_network_*`, `db:5432`) are not used. The stack backend requires the in-process pg-delta engine; `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, and `--diff-engine migra` are rejected. The flag does not switch functions or storage command families.

`db start` brings up a postgres-only project stack. If a full stack already exists, it starts the database without persisting `--exclude`. `--from-backup` is not supported on the stack path. `db reset --local` and declarative `--apply` wipe Postgres through `resetDatabase` and then migrate or seed on stack credentials.

`db dump --local`, `db test` / `test db`, and `migration squash` use host `pg_dump` / `pg_prove` only when the stack engine is native. Those PATH clients must match the stack Postgres major; otherwise install matching client tools or start with `--runtime docker`. The Docker/Podman engine keeps the one-shot tool container and targets published stack credentials, never `PGHOST=db`.

## Data and configuration

Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
import { legacyExperimentalStackStartCommand } from "../commands/experimental/stack/start/start.command.ts";
import { legacyExperimentalStackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts";
import { legacyExperimentalStackStatusCommand } from "../commands/experimental/stack/status/status.command.ts";
import type { LegacyExperimentalStackBackend } from "../commands/experimental/stack/stack-backend.ts";
import type { LegacyExperimentalStackBackend } from "../command-internal/experimental-stack-backend.ts";
import { experimentalStackBackendLayer } from "../command-internal/experimental-stack-backend.ts";

import { legacyFunctionsCommand } from "../commands/functions/functions.command.ts";
import { legacyGenCommand } from "../commands/gen/gen.command.ts";
Expand Down Expand Up @@ -194,6 +195,7 @@ export const legacyRootForBackend = (backend: LegacyExperimentalStackBackend = "
: outputLayerFor(outputFormat);

return Layer.mergeAll(
experimentalStackBackendLayer(backend),
outputLayer,
makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }),
);
Expand Down
180 changes: 161 additions & 19 deletions apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,16 @@ import {
} from "../../shared/telemetry/error-actionability.ts";
import { legacyAqua, legacyYellow } from "../legacy-colors.ts";
import { LegacyCliSettings } from "../../config/legacy-cli-settings.service.ts";
import { legacyCheckDbToml, legacyLoadProjectEnv } from "../legacy-db-config.toml-read.ts";
import { currentExperimentalStackBackend } from "../experimental-stack-backend.ts";
import { LegacyExperimentalStackApi } from "../experimental-stack-api.ts";
import { legacyCheckDbToml, legacyLoadProjectEnv, legacyReadDbToml } from "../legacy-db-config.toml-read.ts";
import { LegacyDbConnection } from "../legacy-db-connection.service.ts";
import { legacyLoadLocalProjectContext } from "../legacy-local-project-context.ts";
import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts";
import { stackLocalDatabaseConn } from "../stack-local-database.ts";
import { legacySeedBucketsRun } from "../legacy-seed-buckets.ts";
import { legacyAwaitStorageReady } from "./await-storage-ready.ts";
import { legacyResolveResetSeedConfig } from "./db-setup.ts";
import { legacyBuildLocalDbContainerInputs } from "./local-container-inputs.ts";
import { legacyIsLocalDbRunning } from "./local-db-running.ts";
import { legacyRecreateLocalDatabase } from "./recreate-local-database.ts";
Expand All @@ -80,6 +87,16 @@ class LegacyResetLocalDbNotRunningError extends Data.TaggedError(
}
}

/** Compose recreate would wipe leftover Docker volumes while schema commands target the stack. */
class LegacyResetLocalDbFailedError extends Data.TaggedError("LegacyResetLocalDbFailedError")<{
readonly message: string;
readonly suggestion?: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.dbConnection;
}
}

/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */
const toLogMessage = (version: string): string =>
version.length > 0 ? ` to version: ${version}` : "...";
Expand All @@ -103,37 +120,162 @@ const PLAIN_FULL_RESET: LegacyResetLocalDatabaseInput = {
export const legacyResetLocalDatabase = Effect.fnUntraced(function* (
input: LegacyResetLocalDatabaseInput = PLAIN_FULL_RESET,
) {
const backend = yield* currentExperimentalStackBackend;
const output = yield* Output;
const cliSettings = yield* LegacyCliSettings;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const runtimeInfo = yield* RuntimeInfo;
const networkIdFlag = yield* LegacyNetworkIdFlag;
// Threaded into `legacyBuildLocalDbContainerInputs`'s own `setup.debug`, so a failed
// fresh-volume Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own
// stderr, matching Go's `initSchema15` passing `utils.GetDebugLogger()` as that job's
// stderr writer (`start.go:349-353`) — reached by BOTH real Go callers of
// `SetupLocalDatabase` (`db start` and `db reset`'s PG15 recreate).
const debug = yield* LegacyDebugFlag;

const workdir = cliSettings.workdir;
// Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key)
// before `reset.Run` reads `viper.GetBool("EXPERIMENTAL")`, so a `SUPABASE_EXPERIMENTAL` set
// only in `supabase/.env` is honored. Load the project env first and resolve against it, as
// `legacyDbReset` does for its own experimental gate.
const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir);
const yes = yield* legacyResolveYesWithProjectEnv(projectEnv);
const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv);

// Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's per-connType
// `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full config validation before
// `reset.Run` ever reaches `AssertSupabaseDbIsRunning` / the destructive `resetDatabase`
// (`internal/db/reset/reset.go:57-61`). Re-validate here as an explicit, independent gate
// (the same pattern `db start`/`db push` use), so "a malformed config aborts before the
// local database is recreated" is enforced by this function directly.
yield* legacyCheckDbToml(fs, path, workdir);

if (backend.kind === "stack") {
const api = yield* Effect.serviceOption(LegacyExperimentalStackApi);
if (Option.isNone(api)) {
return yield* Effect.fail(
new LegacyResetLocalDbNotRunningError({
message: `${legacyAqua("supabase start")} is not running.`,
}),
);
}
const dbConn = yield* LegacyDbConnection;
const descriptor = yield* api.value.findStack({ projectRoot: workdir }).pipe(
Effect.mapError(
() =>
new LegacyResetLocalDbNotRunningError({
message: `${legacyAqua("supabase start")} is not running.`,
}),
),
);
if (Option.isNone(descriptor)) {
return yield* Effect.fail(
new LegacyResetLocalDbNotRunningError({
message: `${legacyAqua("supabase start")} is not running.`,
}),
);
}
const stack = yield* api.value.openStack(descriptor.value.id).pipe(
Effect.mapError(
() =>
new LegacyResetLocalDbNotRunningError({
message: `${legacyAqua("supabase start")} is not running.`,
}),
),
);
const status = yield* stack.status().pipe(
Effect.mapError(
(cause) =>
new LegacyResetLocalDbFailedError({
message: `failed to reset local database: ${cause.message}`,
}),
),
);
const database = status.capabilities.find((capability) => capability.name === "database");
if (status.lifecycle !== "running" || database?.state !== "ready") {
return yield* Effect.fail(
new LegacyResetLocalDbNotRunningError({
message: `${legacyAqua("supabase start")} is not running.`,
}),
);
}
yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr");
yield* stack.resetDatabase().pipe(
Effect.catchTag("StackNotRunningError", () =>
Effect.fail(
new LegacyResetLocalDbNotRunningError({
message: `${legacyAqua("supabase start")} is not running.`,
}),
),
),
Effect.mapError(
(cause) =>
new LegacyResetLocalDbFailedError({
message: `failed to reset local database: ${cause.message}`,
}),
),
);
const toml = yield* legacyReadDbToml(fs, path, workdir);
const conn = yield* stackLocalDatabaseConn.pipe(
Effect.mapError(
(cause) =>
new LegacyResetLocalDbNotRunningError({
message: cause.message,
}),
),
);
yield* Effect.scoped(
Effect.gen(function* () {
const session = yield* dbConn.connect(conn, { isLocal: true, dnsResolver: "native" }).pipe(
Effect.mapError(
(cause) =>
new LegacyResetLocalDbFailedError({
message: `failed to connect after reset: ${cause.message}`,
}),
),
);
yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, {
migrationsEnabled: toml.migrationsEnabled,
seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path),
experimental,
pgDeltaEnabled: toml.pgDelta.enabled,
schemaPaths: toml.schemaPaths,
localDatabaseWebhooksEnabled: toml.webhooksEnabled,
}).pipe(
Effect.mapError(
(cause) =>
new LegacyResetLocalDbFailedError({
message: cause.message,
}),
),
);
}),
);
const after = yield* stack.status().pipe(
Effect.mapError(
(cause) =>
new LegacyResetLocalDbFailedError({
message: `failed to inspect stack after reset: ${cause.message}`,
}),
),
);
const storage = after.capabilities.find((capability) => capability.name === "storage");
if (storage?.state === "ready") {
const context = yield* legacyLoadLocalProjectContext(workdir, (message) =>
new LegacyResetLocalDbFailedError({ message }),
);
yield* legacySeedBucketsRun({
projectRef: "",
emitSummary: false,
interactive: false,
yes,
resolvedConfig: { config: context.config, document: context.loaded?.document },
projectEnvValues: projectEnv,
}).pipe(
Effect.catchTag("LegacySeedConfigLoadError", (error) =>
output.raw(
`${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`,
"stderr",
),
),
);
}
const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main");
yield* output.raw(
`Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`,
"stderr",
);
return;
}

const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const runtimeInfo = yield* RuntimeInfo;
const networkIdFlag = yield* LegacyNetworkIdFlag;

// AssertSupabaseDbIsRunning — error if the local db container is down.
const running = yield* legacyIsLocalDbRunning(
spawner,
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ export interface LegacyShadowBaselineRetentionOpts {
readonly maxAgeMs?: number;
/** Never evict this published tar, even if it is older than the TTL or over the cap. */
readonly retainFileName?: string;
/** Defaults to {@link legacyIsShadowBaselineTar}. */
readonly isPublishedTar?: (fileName: string) => boolean;
}

/**
Expand All @@ -460,8 +462,9 @@ export function legacyShadowBaselineTarsToEvict(
const keep = opts.keep ?? LEGACY_SHADOW_BASELINE_KEEP;
const maxAgeMs = opts.maxAgeMs ?? LEGACY_SHADOW_BASELINE_MAX_AGE_MS;
const retain = opts.retainFileName;
const isPublishedTar = opts.isPublishedTar ?? legacyIsShadowBaselineTar;
const candidates = entries.filter(
(entry) => legacyIsShadowBaselineTar(entry.fileName) && entry.fileName !== retain,
(entry) => isPublishedTar(entry.fileName) && entry.fileName !== retain,
);
const aged = new Set(
candidates.filter((entry) => now - entry.mtimeMs > maxAgeMs).map((entry) => entry.fileName),
Expand Down Expand Up @@ -557,7 +560,7 @@ const legacySweepShadowBaselineRetention = <E>(
});

/** Refresh mtime on a warm hit so frequently used keys survive LRU/TTL. Best-effort. */
const legacyTouchShadowBaselineTar = (
export const legacyTouchShadowBaselineTar = (
fs: FileSystem.FileSystem,
tarPath: string,
): Effect.Effect<void> =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,22 @@ describe("shadow baseline tar retention", () => {
),
).toEqual([]);
});

it("never evicts retainFileName when using a custom published-tar matcher", () => {
const current = "stack-shadow-baseline-dddddddddddddddd.tar";
const aged = now - LEGACY_SHADOW_BASELINE_MAX_AGE_MS - 1;
const isStack = (fileName: string) =>
/^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u.test(fileName);
const evicted = legacyShadowBaselineTarsToEvict(
[
{ fileName: current, mtimeMs: aged },
{ fileName: "stack-shadow-baseline-aaaaaaaaaaaaaaaa.tar", mtimeMs: now - 1_000 },
{ fileName: "not-a-tar.json", mtimeMs: aged },
],
now,
{ retainFileName: current, isPublishedTar: isStack },
);
expect(evicted).not.toContain(current);
expect(evicted).not.toContain("not-a-tar.json");
});
});
80 changes: 80 additions & 0 deletions apps/cli/src/command-internal/experimental-stack-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Context, Crypto, Effect, FileSystem, Layer, Path } from "effect";
import {
createStack,
discoverStacks,
findStack,
inspectStack,
listStacks,
openStack,
} from "@supabase/stack/effect";
import { ChildProcessSpawner } from "effect/unstable/process";

export class LegacyExperimentalStackApi extends Context.Service<
LegacyExperimentalStackApi,
{
readonly createStack: (
...args: Parameters<typeof createStack>
) => Effect.Effect<
Effect.Success<ReturnType<typeof createStack>>,
Effect.Error<ReturnType<typeof createStack>>
>;
readonly findStack: (
...args: Parameters<typeof findStack>
) => Effect.Effect<
Effect.Success<ReturnType<typeof findStack>>,
Effect.Error<ReturnType<typeof findStack>>
>;
readonly listStacks: (
...args: Parameters<typeof listStacks>
) => Effect.Effect<
Effect.Success<ReturnType<typeof listStacks>>,
Effect.Error<ReturnType<typeof listStacks>>
>;
readonly discoverStacks: (
...args: Parameters<typeof discoverStacks>
) => Effect.Effect<
Effect.Success<ReturnType<typeof discoverStacks>>,
Effect.Error<ReturnType<typeof discoverStacks>>
>;
readonly openStack: (
...args: Parameters<typeof openStack>
) => Effect.Effect<
Effect.Success<ReturnType<typeof openStack>>,
Effect.Error<ReturnType<typeof openStack>>
>;
readonly inspectStack: (
...args: Parameters<typeof inspectStack>
) => Effect.Effect<
Effect.Success<ReturnType<typeof inspectStack>>,
Effect.Error<ReturnType<typeof inspectStack>>
>;
}
>()("supabase/experimental-stack/StackApi") {}

export const legacyExperimentalStackApiLayer = Layer.effect(
LegacyExperimentalStackApi,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
const childProcess = yield* ChildProcessSpawner.ChildProcessSpawner;
const provideServices = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcess),
);
return {
createStack: (...args: Parameters<typeof createStack>) =>
provideServices(createStack(...args)),
findStack: (...args: Parameters<typeof findStack>) => provideServices(findStack(...args)),
listStacks: (...args: Parameters<typeof listStacks>) => provideServices(listStacks(...args)),
discoverStacks: (...args: Parameters<typeof discoverStacks>) =>
provideServices(discoverStacks(...args)),
openStack: (...args: Parameters<typeof openStack>) => provideServices(openStack(...args)),
inspectStack: (...args: Parameters<typeof inspectStack>) =>
provideServices(inspectStack(...args)),
};
}),
);
Loading
Loading