Skip to content
Draft
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ reference. Published `apps/cli` and `packages/config` are not private; `apps/doc
root-owned. Effect lint covers `packages/stack`, all files under
`apps/cli/src/commands/experimental/stack` and `apps/cli/src/commands/experimental/compute`, the
shared `apps/cli/src/shared/compute` runtime helpers (excluding embedded starter templates), the
Compute test fixture helper, and `apps/cli/src/command-internal/experimental-feature.ts`; use the
root scripts for it.
Compute test fixture helper, and `apps/cli/src/command-internal/experimental-feature.ts`,
`stack-backend.ts`, `stack-api.ts`, `stack-local-database.ts`, `stack-shadow.ts`, and
`postgres-client.run.ts`; use the root scripts for it.

### Config Naming Vocabulary

Expand Down
26 changes: 24 additions & 2 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,26 @@ command.
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`; 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.
Other values are rejected. The override is applied before reading the project configuration.

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, and does not
change top-level `status`.

`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 All @@ -52,6 +70,10 @@ The flag is local CLI configuration in `supabase/config.toml` and is excluded fr
configuration. Routing reads that exact file after applying the CLI's working-directory rules,
including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enable the flag.

## Port intents

Host listener assignment for `supabase stack` is documented in [Port intents](./supabase-home.md#port-intents).

## Service selection and shutdown

`supabase stack start --exclude studio,analytics -x mail` disables those services in the effective
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/cli/complete.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { Cause } from "effect";

import { rootCommand } from "./root.ts";
import { StackRoutingError } from "../commands/experimental/stack/stack-backend.ts";
import { StackRoutingError } from "../command-internal/stack-backend.ts";
import {
CompletionDirective,
type ClassifyCompletionInput,
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { runCli } from "../shared/cli/run.ts";
import { upgradeNoticeHook } from "../command-internal/upgrade-notice.ts";
import { analyticsLayer } from "../telemetry/analytics.layer.ts";
import { defaultCompleteDeps, tryComplete } from "./complete.ts";
import { resolveStackBackend } from "../commands/experimental/stack/stack-backend.ts";
import { resolveStackBackend } from "../command-internal/stack-backend.ts";
import { resolveComputeEnabled } from "../commands/experimental/compute/compute-backend.ts";
import { rootCommandForFeatures } from "./root.ts";

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 @@ -11,7 +11,8 @@ import { encryptionCommand } from "../commands/encryption/encryption.command.ts"
import { stackRuntimeLayer, stackCommand } from "../commands/experimental/stack/stack.command.ts";
import { stackStartCommand } from "../commands/experimental/stack/start/start.command.ts";
import { stackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts";
import type { StackBackend } from "../commands/experimental/stack/stack-backend.ts";
import type { StackBackend } from "../command-internal/stack-backend.ts";
import { stackBackendLayer } from "../command-internal/stack-backend.ts";
import { computeCommand } from "../commands/experimental/compute/compute.command.ts";
import { feedbackCommand } from "../commands/feedback/feedback.command.ts";
import { functionsCommand } from "../commands/functions/functions.command.ts";
Expand Down Expand Up @@ -181,6 +182,7 @@ export const rootCommandForFeatures = (
: outputLayerFor(outputFormat);

return Layer.mergeAll(
stackBackendLayer(options.stackBackend ?? "legacy"),
outputLayer,
makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }),
);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/command-internal/container-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const containerCliExitCode = (
);

/** Folds a byte stream into a decoded string. */
export function collectText(stream: Stream.Stream<Uint8Array, unknown>) {
export function collectText<E>(stream: Stream.Stream<Uint8Array, E>) {
const decoder = new TextDecoder();
return Stream.runFold(
stream,
Expand Down
110 changes: 97 additions & 13 deletions apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ import {
} from "../../shared/telemetry/error-actionability.ts";
import { aqua, yellow } from "../colors.ts";
import { CommandSettings } from "../../config/command-settings.service.ts";
import { checkDbToml, loadProjectEnv } from "../db-config.toml-read.ts";
import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts";
import { DbConnection } from "../db-connection.service.ts";
import { loadLocalProjectContext } from "../local-project-context.ts";
import { migrateAndSeed } from "../migrate-and-seed.ts";
import { seedBucketsRun } from "../seed-buckets.ts";
import { awaitStorageReady } from "./await-storage-ready.ts";
import { resolveResetSeedConfig } from "./db-setup.ts";
import { buildLocalDbContainerInputs } from "./local-container-inputs.ts";
import { isLocalDbRunning } from "./local-db-running.ts";
import { recreateLocalDatabase } from "./recreate-local-database.ts";
import { currentStackBackend } from "../stack-backend.ts";
import { stackLocalDatabaseConn, stackOpenReadyProject } from "../stack-local-database.ts";

/** The local database container is not running. */
class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{
Expand All @@ -44,6 +50,15 @@ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunni
}
}

class ResetLocalDbFailedError extends Data.TaggedError("ResetLocalDbFailedError")<{
readonly message: string;
readonly suggestion?: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.dbConnection;
}
}

/** ` to version: X`, or `...` when resetting to the latest migration. */
const toLogMessage = (version: string): string =>
version.length > 0 ? ` to version: ${version}` : "...";
Expand All @@ -60,20 +75,22 @@ const PLAIN_FULL_RESET: ResetLocalDatabaseInput = {
seedFlags: { noSeed: false, sqlPaths: [] },
};

const notRunning = () =>
new ResetLocalDbNotRunningError({
message: `${aqua("supabase start")} is not running.`,
});

const resetFailed = (message: string) => new ResetLocalDbFailedError({ message });

/** Resets the local database in-process. See this module's own header for the full design rationale. */
export const resetLocalDatabase = Effect.fnUntraced(function* (
input: ResetLocalDatabaseInput = PLAIN_FULL_RESET,
) {
const backend = yield* currentStackBackend;
const output = yield* Output;
const cliSettings = yield* CommandSettings;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const runtimeInfo = yield* RuntimeInfo;
const networkIdFlag = yield* NetworkIdFlag;
// Threaded into `buildLocalDbContainerInputs`'s `setup.debug`, so a failed fresh-volume
// Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own stderr.
const debug = yield* DebugFlag;

const workdir = cliSettings.workdir;
// Load the project env first so a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is
Expand All @@ -82,10 +99,81 @@ export const resetLocalDatabase = Effect.fnUntraced(function* (
const yes = yield* resolveYesWithProjectEnv(projectEnv);
const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv);

// Validate config before checking whether the container is running, so a malformed config
// Validate config before checking whether the database is running, so a malformed config
// aborts before the local database is recreated — the same pattern `db start`/`db push` use.
yield* checkDbToml(fs, path, workdir);

if (backend.kind === "stack") {
const opened = yield* stackOpenReadyProject;
if (Option.isNone(opened)) return yield* Effect.fail(notRunning());
yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr");
yield* opened.value.stack.resetDatabase.pipe(
Effect.catchTag("StackNotRunningError", () => Effect.fail(notRunning())),
Effect.mapError((cause) => resetFailed(`failed to reset local database: ${cause.message}`)),
);
const dbConn = yield* DbConnection;
const toml = yield* readDbToml(fs, path, workdir);
const conn = yield* stackLocalDatabaseConn.pipe(
Effect.mapError((cause) => new ResetLocalDbNotRunningError({ message: cause.message })),
);
yield* Effect.scoped(
Effect.gen(function* () {
const session = yield* dbConn
.connect(conn, { isLocal: true, dnsResolver: "native" })
.pipe(
Effect.mapError((cause) =>
resetFailed(`failed to connect after reset: ${cause.message}`),
),
);
yield* migrateAndSeed(session, fs, path, workdir, input.version, {
migrationsEnabled: toml.migrationsEnabled,
seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path),
experimental,
pgDeltaEnabled: toml.pgDelta.enabled,
schemaPaths: toml.schemaPaths,
localDatabaseWebhooksEnabled: toml.webhooksEnabled,
}).pipe(Effect.mapError((cause) => resetFailed(cause.message)));
}),
);
const after = yield* opened.value.stack.status.pipe(
Effect.mapError((cause) =>
resetFailed(`failed to inspect stack after reset: ${cause.message}`),
),
);
const storage = after.capabilities.find((capability) => capability.name === "storage");
if (storage?.state === "ready") {
const context = yield* loadLocalProjectContext(workdir, (message) => resetFailed(message));
yield* seedBucketsRun({
projectRef: "",
emitSummary: false,
interactive: false,
yes,
resolvedConfig: { config: context.config, document: context.loaded?.document },
projectEnvValues: projectEnv,
}).pipe(
Effect.catchTag("SeedConfigLoadError", (error) =>
output.raw(
`${yellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`,
"stderr",
),
),
);
}
const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main");
yield* output.raw(
`Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`,
"stderr",
);
return;
}

const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const runtimeInfo = yield* RuntimeInfo;
const networkIdFlag = yield* NetworkIdFlag;
// Threaded into `buildLocalDbContainerInputs`'s `setup.debug`, so a failed fresh-volume
// Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own stderr.
const debug = yield* DebugFlag;

// Error if the local db container is down.
const running = yield* isLocalDbRunning(
spawner,
Expand All @@ -95,11 +183,7 @@ export const resetLocalDatabase = Effect.fnUntraced(function* (
Option.getOrUndefined(cliSettings.projectId),
);
if (!running) {
return yield* Effect.fail(
new ResetLocalDbNotRunningError({
message: `${aqua("supabase start")} is not running.`,
}),
);
return yield* Effect.fail(notRunning());
}
// "Resetting local database…" then recreate + migrate + seed.
yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr");
Expand Down
12 changes: 9 additions & 3 deletions apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const shadowBaselineEmbeddedDigest = (): string =>
.digest("hex"));

/** JSON with recursively key-sorted objects, so `db.settings`' own property order cannot change the key. */
function canonicalJson(value: unknown): string {
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const entries = Object.entries(value)
Expand Down Expand Up @@ -363,6 +363,8 @@ export interface ShadowBaselineRetentionOpts {
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 isShadowBaselineTar}. */
readonly isPublishedTar?: (fileName: string) => boolean;
}

/**
Expand All @@ -377,8 +379,9 @@ export function shadowBaselineTarsToEvict(
const keep = opts.keep ?? SHADOW_BASELINE_KEEP;
const maxAgeMs = opts.maxAgeMs ?? SHADOW_BASELINE_MAX_AGE_MS;
const retain = opts.retainFileName;
const isPublishedTar = opts.isPublishedTar ?? isShadowBaselineTar;
const candidates = entries.filter(
(entry) => isShadowBaselineTar(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 @@ -469,7 +472,10 @@ const sweepShadowBaselineRetention = <E>(
});

/** Refresh mtime on a warm hit so frequently used keys survive LRU/TTL. Best-effort. */
const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Effect.Effect<void> =>
export const touchShadowBaselineTar = (
fs: FileSystem.FileSystem,
tarPath: string,
): Effect.Effect<void> =>
Effect.gen(function* () {
const now = new Date(yield* Clock.currentTimeMillis);
yield* fs.utimes(tarPath, now, now);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,4 +412,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 - SHADOW_BASELINE_MAX_AGE_MS - 1;
const isStack = (fileName: string) =>
/^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u.test(fileName);
const evicted = shadowBaselineTarsToEvict(
[
{ 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");
});
});
Loading