Skip to content
Merged
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: 2 additions & 2 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ format, or comes from a newer CLI always fails the start. The lock's JSON Schema
`https://supabase.com/schemas/supabase-lock/v1.json`. A database with initialized data keeps
its build: any change other than a new `-rN` revision of the same build (a different upstream build,
release line or major) is refused. The refusal offers to keep the data by pinning the recorded build with
`[local.services.database] version`, setting `[db] major_version` back when the major differs and removing any
`[db] orioledb_version` when an OrioleDB build is requested, or to run `supabase stack destroy` and recreate it. `supabase link`
`local.services.database.version` in your config file, setting `db.major_version` back when the major differs and removing any
`db.orioledb_version` when an OrioleDB build is requested, or to run `supabase stack destroy` and recreate it. `supabase link`
and `supabase stack update` warn when the versions they lock would be refused by an existing stack's
data. A database that never initialized adopts the change. See the [`start` side
effects](../src/commands/experimental/stack/start/SIDE_EFFECTS.md#service-versions-and-the-lock) for
Expand Down
63 changes: 63 additions & 0 deletions apps/cli/src/command-internal/config-advice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ConfigFormat } from "@supabase/config/effect";
import { cliConfigFormatForPath } from "@supabase/config/internal";
import type { Path } from "effect";

/** The project config file a message points at: its format and workdir-relative path. */
export interface ConfigFileRef {
readonly format: ConfigFormat;
readonly path: string;
}

/** Where `supabase init` writes the config, so the file a project without one will get. */
export const DEFAULT_CONFIG_FILE: ConfigFileRef = {
format: "toml",
path: "supabase/config.toml",
};

/** A config file's workdir-relative path as messages show it, with `/` separators on every platform. */
export const configDisplayPath = (path: Path.Path, workdir: string, filePath: string): string =>
path.relative(workdir, filePath).replaceAll("\\", "/");

/** The config file at an absolute `configPath`; `undefined` reads as no file. */
export const configFileRefFromPath = (
path: Path.Path,
workdir: string,
configPath: string | undefined,
): ConfigFileRef =>
configPath === undefined
? DEFAULT_CONFIG_FILE
: {
format: cliConfigFormatForPath(configPath),
path: configDisplayPath(path, workdir, configPath),
};

export const configFileRefOf = (
path: Path.Path,
workdir: string,
resolvedConfig: {
readonly hasConfigFile: boolean;
readonly loaded: { readonly path: string };
},
): ConfigFileRef =>
configFileRefFromPath(
path,
workdir,
resolvedConfig.hasConfigFile ? resolvedConfig.loaded.path : undefined,
);

/** A setting as advice reads it: `[section] key = value` in TOML, `` `section.key` to `value` `` in JSON. */
export const configSetting = (
format: ConfigFormat,
section: string,
key: string,
value: string,
): string =>
format === "json" ? `\`${section}.${key}\` to \`${value}\`` : `[${section}] ${key} = ${value}`;

/** A setting's key as advice names it: `[section].key` in TOML, `section.key` in JSON. */
export const configKey = (format: ConfigFormat, section: string, key: string): string =>
format === "json" ? `${section}.${key}` : `[${section}].${key}`;

/** The config file name without its directory, as "not declared" notes name it. */
export const configFileName = (configPath: string): string =>
configPath.split(/[\\/]/).pop() ?? configPath;
57 changes: 57 additions & 0 deletions apps/cli/src/command-internal/config-advice.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Path } from "effect";
import { BunPath } from "@effect/platform-bun";
import { describe, expect, it } from "vitest";
import { Effect } from "effect";
import {
configFileName,
configFileRefFromPath,
configKey,
configSetting,
DEFAULT_CONFIG_FILE,
} from "./config-advice.ts";
import { schemaPathsTransitionWarning } from "./diff-engine.ts";
import { enableLocalWebhooksSuggestion } from "./pg-net-guidance.ts";

const json = { format: "json", path: "supabase/config.json" } as const;

describe("config advice", () => {
it("renders a setting as TOML section syntax or a JSON dotted key", () => {
expect(configSetting("toml", "storage", "enabled", "true")).toBe("[storage] enabled = true");
expect(configSetting("json", "storage", "enabled", "true")).toBe("`storage.enabled` to `true`");
expect(configKey("toml", "db", "password")).toBe("[db].password");
expect(configKey("json", "db", "password")).toBe("db.password");
});

it("maps a config path to its format and workdir-relative display path", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
expect(configFileRefFromPath(path, "/work", "/work/supabase/config.jsonc")).toEqual({
format: "json",
path: "supabase/config.jsonc",
});
expect(configFileRefFromPath(path, "/work", undefined)).toEqual(DEFAULT_CONFIG_FILE);
expect(configFileName("supabase/config.json")).toBe("config.json");
}).pipe(Effect.provide(BunPath.layer), Effect.runSync));

it("names the config path with forward slashes on Windows", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
expect(configFileRefFromPath(path, "C:\\work", "C:\\work\\supabase\\config.toml")).toEqual({
format: "toml",
path: "supabase/config.toml",
});
}).pipe(Effect.provide(BunPath.layerWin32), Effect.runSync));

it("keeps TOML guidance text and gives JSON projects a JSON snippet", () => {
expect(enableLocalWebhooksSuggestion()).toBe(
"Add the following to supabase/config.toml and retry:\n\n[experimental.webhooks]\nenabled = true",
);
expect(enableLocalWebhooksSuggestion(json)).toContain(
'{\n "experimental": {\n "webhooks": {\n "enabled": true',
);
expect(schemaPathsTransitionWarning()).toContain("[db.migrations].schema_paths");
expect(schemaPathsTransitionWarning(json)).toContain(
"`experimental.pgdelta.enabled` to `false`",
);
});
});
23 changes: 23 additions & 0 deletions apps/cli/src/command-internal/config-file-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { findCliProjectPaths } from "@supabase/config/effect";
import { Effect, Option, Path } from "effect";
import { CliConfigValues } from "../config/cli-config-values.service.ts";
import { configFileRefFromPath, configFileRefOf, DEFAULT_CONFIG_FILE } from "./config-advice.ts";

/** The workdir's config file, from the memoised resolved config; a failed load reads as no file. */
export const loadConfigFileRef = Effect.fnUntraced(function* (workdir: string) {
const path = yield* Path.Path;
const values = yield* CliConfigValues;
return yield* values.load({ workdir, projectRef: Option.none() }).pipe(
Effect.map((resolvedConfig) => configFileRefOf(path, workdir, resolvedConfig)),
Effect.orElseSucceed(() => DEFAULT_CONFIG_FILE),
);
});

/** The workdir's config file found on disk (no resolved-config load); no file reads as the default. */
export const findConfigFileRef = Effect.fnUntraced(function* (workdir: string) {
const path = yield* Path.Path;
const found = yield* findCliProjectPaths(workdir, { search: false }).pipe(
Effect.orElseSucceed(() => null),
);
return configFileRefFromPath(path, workdir, found?.configPath);
});
7 changes: 4 additions & 3 deletions apps/cli/src/command-internal/config-pull-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
configIsRecord,
configPathKey,
} from "../commands/config/config.paths.ts";
import { configFileName } from "./config-advice.ts";
import { loadDeclaredFileConfig, relativeConfigPath } from "../commands/config/config.load.ts";
import {
CONFIG_CLASS_LABELS,
Expand Down Expand Up @@ -833,10 +834,10 @@ function writtenCount(plan: ConfigPullPlan, outcome: ConfigPullOutcome): number
return outcome.dryRun || outcome.declined ? 0 : plan.writes.length;
}

function renderLocal(local: unknown, declared: boolean): string {
function renderLocal(local: unknown, declared: boolean, configPath: string): string {
const value = configRenderValue(local, "(unset)");
return local !== undefined && !declared
? `${value} (schema default — not declared in config.toml)`
? `${value} (schema default — not declared in ${configFileName(configPath)})`
: value;
}

Expand Down Expand Up @@ -942,7 +943,7 @@ export function renderConfigPullText(
change.envVariables === undefined
? ""
: ` (from env ${sanitizeInlineName(change.envVariables.join(", "))})`;
lines.push(` local: ${renderLocal(change.local, change.declared)}${env}`);
lines.push(` local: ${renderLocal(change.local, change.declared, configPath)}${env}`);
lines.push(` remote: ${configRenderValue(change.remote, "(not returned)")}`);
lines.push("");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect";

import { withEnvVar, mockCommandSettings } from "../../tests/helpers/command-mocks.ts";
import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts";
import { readDbToml } from "./db-config.toml-read.ts";
import { readDbConfig } from "./db-config.read.ts";
import { resolveStorageCredentials } from "./storage-credentials.ts";
import { resolveLocalConfigValues } from "./local-config-values.ts";
import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts";
Expand All @@ -17,15 +17,15 @@ const stringifyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));

/**
* Cross-caller parity coverage: for a table of shared misconfigurations, drives both real
* pipelines — D (`readDbToml`, Effect/raw-TOML) and L (`resolveLocalConfigValues`,
* pipelines — D (`readDbConfig`, Effect/raw-TOML) and L (`resolveLocalConfigValues`,
* `@supabase/config`-decoded) — and asserts they fail with the same error-message substring,
* since both route through the single `validateResolvedConfig`. A third caller, the
* storage-credentials resolver (S), shares the `api.port`/`api.tls` presence branches and the
* `auth.jwt_secret`/`auth.service_role_key` resolution; its own describe block below drives
* that pipeline.
*
* D's harness replicates the `withConfig`/`read`/`failsWith` pattern from
* `db-config.toml-read.unit.test.ts` (file-local there, not exported). L's harness replicates
* `db-config.read.unit.test.ts` (file-local there, not exported). L's harness replicates
* the `baseConfig`/`WORKDIR` pattern from `local-config-values.unit.test.ts`.
*/

Expand All @@ -43,7 +43,7 @@ const readD = (workdir: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
return yield* readDbToml(fs, path, workdir);
return yield* readDbConfig(fs, path, workdir);
}).pipe(
Effect.provide(Layer.mergeAll(BunServices.layer, BunCrypto.layer, cliConfigValuesTestLayer)),
);
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/command-internal/config-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { parseUrl } from "./storage-url.ts";
/**
* Single home for config validation, shared by the two config readers:
*
* - **D** = `db-config.toml-read.ts`: raw TOML document, Effect-based, fails with
* - **D** = `db-config.read.ts`: raw TOML document, Effect-based, fails with
* `DbConfigLoadError`.
* - **L** = `local-config-values.ts`: decoded `@supabase/config` `CliConfig`, Effect-based,
* fails with an `Error`.
Expand Down Expand Up @@ -191,6 +191,8 @@ export interface ExperimentalInput {
* `studio`/`localSmtp` entirely; both D and L omit `auth` when auth is disabled).
*/
export interface ConfigValidationInput {
/** The config file's name for messages that name it; defaults to `config.toml`. */
readonly configFileName?: string;
/** L only — D doesn't validate `project_id` here. */
readonly projectId?: string;
/** L only — D has no `[api]` section. */
Expand Down Expand Up @@ -545,7 +547,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void {
}
if (input.analytics.gcpJwtPath.length === 0) {
throw new ConfigValidateError(
"Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path",
`Path to GCP Service Account Key must be provided in config, relative to ${input.configFileName ?? "config.toml"}: analytics.gcp_jwt_path`,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("CLERK_DOMAIN_PATTERN", () => {
});

// Direct coverage for the containment check shared by every caller (config push's
// loadAuthEmailContent, db-config.toml-read.ts, local-config-values.ts, start.handler.ts's
// loadAuthEmailContent, db-config.read.ts, local-config-values.ts, start.handler.ts's
// pre-Docker pass), cheaper to pin here than to re-derive through every caller's own fixtures.
layer(BunServices.layer)("resolveEmailTemplateContentPath", (it) => {
const tempDir = (prefix: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function portConflictSuggestion(hostPort: string, serviceLabel: string): string
return (
`\nTry stopping the project or container already using ${hostPort} ` +
"(`docker ps` lists what's bound to it, or `supabase stop` for another local Supabase project), " +
`or configure a different ${serviceLabel} port in supabase/config.toml.`
`or configure a different ${serviceLabel} port in your config file.`
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ describe("createContainer", () => {
expect(error).toBeInstanceOf(ContainerStartError);
expect(error.message).toContain('failed to start docker container "supabase_db_proj"');
expect(error.message).toContain("0.0.0.0:5432");
expect(error.message).toContain("db port in supabase/config.toml");
expect(error.message).toContain("db port in your config file");
}),
);
},
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/command-internal/db-bootstrap/db-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type {
} from "../../config/cli-config.errors.ts";
import type { ProjectRefReadError } from "../../shared/config/legacy-project-state.service.ts";
import { DbConfigLoadError } from "../db-config.errors.ts";
import { checkDbToml } from "../db-config.toml-read.ts";
import { checkDbConfig } from "../db-config.read.ts";
import { CLI_PROJECT_LABEL, localDbContainerId } from "../docker-ids.ts";
import { DockerRun, type DockerRunOpts } from "../docker-run.service.ts";
import { migrateAndSeed } from "../migrate-and-seed.ts";
Expand Down Expand Up @@ -851,10 +851,10 @@ export const startSetupLocalDatabase = (
Effect.gen(function* () {
const { session, fs, path, workdir } = input;

// `warnOnUnresolvedEnv: false`: the caller's own handler already ran an earlier `checkDbToml`
// `warnOnUnresolvedEnv: false`: the caller's own handler already ran an earlier `checkDbConfig`
// pass before reaching this fresh-volume setup, which already printed any unresolved-env
// warning. Without this, this module's own duplicate config-load pass would print it twice.
const toml = yield* checkDbToml(fs, path, workdir, undefined, {
const toml = yield* checkDbConfig(fs, path, workdir, undefined, {
warnOnUnresolvedEnv: false,
});

Expand All @@ -874,6 +874,7 @@ export const startSetupLocalDatabase = (
pgDeltaEnabled: toml.pgDelta.enabled,
schemaPaths: toml.schemaPaths,
localDatabaseWebhooksEnabled: toml.webhooksEnabled,
configFile: toml.configFile,
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,13 @@ export const buildLocalDbContainerInputs = (
const document = resolvedConfig.loaded.document ?? {};
const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues);

const values = yield* resolveLocalConfigValues(config, hostname, workdir, document).pipe(
Effect.mapError((cause) => mapError(cause.message)),
);
const values = yield* resolveLocalConfigValues(
config,
hostname,
workdir,
document,
resolvedConfig.hasConfigFile ? resolvedConfig.loaded.path : undefined,
).pipe(Effect.mapError((cause) => mapError(cause.message)));

const bootstrapConfig = yield* resolveDbBootstrapConfig(
fs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from "../../shared/telemetry/error-actionability.ts";
import { isSqlState } from "../connect-errors.ts";
import { dockerSupportsHealthStartInterval, isContainerNotFoundMessage } from "../container-cli.ts";
import { checkDbToml } from "../db-config.toml-read.ts";
import { checkDbConfig } from "../db-config.read.ts";
import { DbConnection, type DbSession } from "../db-connection.service.ts";
import { DbExecError, type DbConnectError } from "../db-connection.errors.ts";
import { CLI_PROJECT_LABEL } from "../docker-ids.ts";
Expand Down Expand Up @@ -376,7 +376,7 @@ const recreateLocalDatabase14 = <E>(
Effect.gen(function* () {
const { setup, fs, path, workdir } = input;
const dbPassword = startInternalDbPassword(setup.dbUrl);
const toml = yield* checkDbToml(fs, path, workdir);
const toml = yield* checkDbConfig(fs, path, workdir);
const dbConnection = yield* DbConnection;
const output = yield* Output;

Expand Down Expand Up @@ -450,6 +450,7 @@ const recreateLocalDatabase14 = <E>(
pgDeltaEnabled: toml.pgDelta.enabled,
schemaPaths: toml.schemaPaths,
localDatabaseWebhooksEnabled: toml.webhooksEnabled,
configFile: toml.configFile,
});
}),
);
Expand Down
Loading
Loading