From 8e380ab1af5eb67ef31b1e5cb12d08ee3015a451 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 11:09:32 +0100 Subject: [PATCH 1/3] feat(cli)!: remove deprecated config keys, env vars and per-function import maps (CLI-2597) Each removed surface now fails with an error that names its replacement instead of being rewritten, warned about or silently ignored. BREAKING CHANGE: `[inbucket]` and `[remotes..inbucket]` are removed; use `[local_smtp]`. A non-empty `[experimental] orioledb_version` is removed; use `[db] orioledb_version`. `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` is removed; use `SUPABASE_DB_ORIOLEDB_VERSION`. The `linkedin` and `slack` auth providers are removed; use `linkedin_oidc` and `slack_oidc`. `INBUCKET_URL` is removed from `status` and `stack status` output and `--override-name inbucket.url` is rejected; use `MAILPIT_URL` and `mailpit.url`. An `import_map.json` inside a function directory is no longer picked up by `functions deploy`, `functions serve` or `start`; rename it to `deno.json` or set `functions..import_map`. --- apps/cli/docs/stack-commands.md | 7 +- apps/cli/docs/templates/examples.yaml | 3 +- .../db-bootstrap/start-local-database.ts | 3 +- .../command-internal/db-config.toml-read.ts | 5 +- .../db-config.toml-read.unit.test.ts | 173 +++-- .../command-internal/local-config-values.ts | 4 - .../local-config-values.unit.test.ts | 6 - .../resolved-config-context.ts | 4 +- .../cli/src/command-internal/status-values.ts | 7 +- .../status-values.unit.test.ts | 26 +- apps/cli/src/commands/config/config.load.ts | 2 +- .../config/diff/diff.integration.test.ts | 39 +- .../src/commands/config/push/SIDE_EFFECTS.md | 38 +- .../src/commands/config/push/push.handler.ts | 20 +- .../config/push/push.integration.test.ts | 22 +- .../db/start/start.integration.test.ts | 30 +- .../stack/stack-config.integration.test.ts | 38 +- .../experimental/stack/stack-summary.ts | 6 +- .../experimental/stack/status/SIDE_EFFECTS.md | 3 +- .../experimental/stack/status/status.env.ts | 15 +- .../stack/status/status.env.unit.test.ts | 10 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 28 +- .../deploy/deploy.integration.test.ts | 163 +++++ .../commands/functions/serve/SIDE_EFFECTS.md | 2 + .../src/commands/secrets/set/SIDE_EFFECTS.md | 5 +- .../secrets/set/set.integration.test.ts | 29 + apps/cli/src/commands/start/SIDE_EFFECTS.md | 3 +- .../commands/start/services/gotrue.service.ts | 2 +- .../start/services/mailpit.service.ts | 10 +- apps/cli/src/commands/start/start.handler.ts | 79 ++- .../commands/start/start.integration.test.ts | 93 +++ apps/cli/src/commands/status/SIDE_EFFECTS.md | 11 +- .../cli/src/commands/status/status.handler.ts | 13 +- .../status/status.integration.test.ts | 30 +- .../commands/stop/stop.integration.test.ts | 36 -- .../config/cli-config-contract.unit.test.ts | 2 - .../src/config/cli-config-key-annotations.ts | 11 +- apps/cli/src/config/cli-config-key.ts | 11 + apps/cli/src/config/cli-config-keys.ts | 2 - .../src/config/cli-config-keys.unit.test.ts | 13 + .../cli-config-values.integration.test.ts | 144 ++++- .../cli/src/config/cli-config-values.layer.ts | 43 +- .../src/config/cli-config-values.service.ts | 7 +- apps/cli/src/config/cli-config.errors.ts | 15 + apps/cli/src/shared/cli/run.ts | 3 +- .../cli/src/shared/functions/deploy.errors.ts | 5 +- apps/cli/src/shared/functions/deploy.ts | 38 +- apps/cli/src/shared/functions/serve.ts | 12 +- .../src/shared/init/project-init.templates.ts | 2 +- .../init/testdata/templates/config.toml | 2 +- .../telemetry/__fixtures__/error-tags.txt | 2 + .../shared/telemetry/error-actionability.ts | 1 + apps/docs/public/cli/config.schema.json | 8 - packages/config/README.md | 11 +- packages/config/docs/cli-config-loading.md | 24 +- packages/config/src/auth/providers.ts | 7 +- packages/config/src/cli-config.service.ts | 6 +- packages/config/src/config-document.ts | 16 +- .../config/src/entrypoint-purity.unit.test.ts | 3 +- packages/config/src/errors.ts | 19 + packages/config/src/experimental.ts | 7 - packages/config/src/index.ts | 1 + packages/config/src/internal.ts | 1 - packages/config/src/io.stages.unit.test.ts | 41 +- packages/config/src/io.ts | 297 ++------- packages/config/src/io.unit.test.ts | 595 ++++++------------ .../src/project-config/hosted-sections.ts | 5 +- .../project-config.unit.test.ts | 3 - .../src/project-config/registry-auth.ts | 2 +- packages/config/src/promise-facade.ts | 6 +- packages/config/src/removed-config-keys.ts | 92 +++ packages/config/testdata/legacy-config.toml | 8 +- 72 files changed, 1220 insertions(+), 1210 deletions(-) create mode 100644 packages/config/src/removed-config-keys.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index ae47121fb5..48b065dc6e 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -122,10 +122,9 @@ are quoted so that sourcing the file performs no shell expansion. The exported v `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, and `SERVICE_ROLE_KEY`; `REST_URL` and `FUNCTIONS_URL` are `/rest/v1` and `/functions/v1`, `MCP_URL` is `/mcp`, whose port is assigned per project, and `DB_URL` uses the `postgres` role with the saved database -password, URI-encoded, and no query string. `INBUCKET_URL` is also exported alongside -`MAILPIT_URL`, with the same value, as a deprecated alias. `start` and `status` text output shows -only the publishable and secret keys; `ANON_KEY` and `SERVICE_ROLE_KEY` appear only in JSON `env` -and `status --env`. `start` and `status` JSON/stream-JSON results include this same connection map +password, URI-encoded, and no query string. `start` and `status` text output shows only the +publishable and secret keys; `ANON_KEY` and `SERVICE_ROLE_KEY` appear only in JSON `env` and +`status --env`. `start` and `status` JSON/stream-JSON results include this same connection map under `env`; `status --env` remains the dotenv/variable-map export, and `--override-name` only applies there. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. The diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index f49ca5269f..63e7f6a995 100644 --- a/apps/cli/docs/templates/examples.yaml +++ b/apps/cli/docs/templates/examples.yaml @@ -83,7 +83,7 @@ supabase-status: GraphQL URL: http://127.0.0.1:54321/graphql/v1 DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres Studio URL: http://127.0.0.1:54323 - Inbucket URL: http://127.0.0.1:54324 + Mailpit URL: http://127.0.0.1:54324 JWT secret: super-secret-jwt-token-with-at-least-32-characters-long anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU @@ -95,7 +95,6 @@ supabase-status: API_URL="http://127.0.0.1:54321" DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" GRAPHQL_URL="http://127.0.0.1:54321/graphql/v1" - INBUCKET_URL="http://127.0.0.1:54324" JWT_SECRET="super-secret-jwt-token-with-at-least-32-characters-long" SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" STUDIO_URL="http://127.0.0.1:54323" diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index 00237c2051..3e4ac93e16 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -204,8 +204,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // Not running → bring up the container natively. `context` (loaded eagerly above) is threaded // through as `preloadedContext` so this call reuses it instead of calling - // `loadLocalProjectContext` a second time, which would double-print deprecated-config-section - // warnings. + // `loadLocalProjectContext` a second time. const inputs = yield* buildLocalDbContainerInputs( spawner, cliSettings.workdir, diff --git a/apps/cli/src/command-internal/db-config.toml-read.ts b/apps/cli/src/command-internal/db-config.toml-read.ts index 954527b909..f4bf4565a4 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -60,9 +60,8 @@ export interface DbTomlValues { /** `[db] major_version`, default 17. */ readonly majorVersion: number; /** - * `[db] orioledb_version` (env-expanded); the deprecated `[experimental] orioledb_version` is - * already promoted into this path by `normalizeDeprecatedOrioleDBVersion`. Set on a 15/17 - * project to rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. + * `[db] orioledb_version` (env-expanded). Set on a 15/17 project to rewrite the Postgres image + * to the OrioleDB tag; `None` for a vanilla project. */ readonly orioledbVersion: Option.Option; /** diff --git a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts index a120c30cab..8b8010ccca 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts @@ -1654,59 +1654,35 @@ describe("readDbToml", () => { ); }); - it.effect( - "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is absent", - () => { - return withConfig( - [ - "[db]", - "major_version = 17", - "[experimental]", - 'orioledb_version = "15.1.0.150"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); - }), - ), - ); - }, - ); - - it.effect( - "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is empty", - () => { - return withConfig( - [ - "[db]", - "major_version = 17", - 'orioledb_version = ""', - "[experimental]", - 'orioledb_version = "15.1.0.150"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); - }), - ), - ); - }, - ); + it.effect("rejects a non-empty experimental.orioledb_version, naming db.orioledb_version", () => { + return withConfig( + ["[db]", "major_version = 17", "[experimental]", 'orioledb_version = "15.1.0.150"', ""].join( + "\n", + ), + ).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "experimental.orioledb_version in supabase/config.toml is no longer supported. Use db.orioledb_version instead.", + ); + } + }), + ), + ); + }); - it.effect("prefers an explicit db.orioledb_version over a non-empty legacy value", () => { + it.effect("ignores an empty experimental.orioledb_version", () => { return withConfig( [ "[db]", "major_version = 17", 'orioledb_version = "17.0.0.1"', "[experimental]", - 'orioledb_version = "15.1.0.150"', + 'orioledb_version = ""', "", ].join("\n"), ).pipe( @@ -1719,60 +1695,52 @@ describe("readDbToml", () => { ); }); - it.effect( - "a matched remote's legacy experimental.orioledb_version overrides the base db.orioledb_version", - () => { - // Matches `@supabase/config`'s loader precedence: each `[remotes.*]` block's own legacy - // value is promoted before the remote merge, so it can override the base canonical value. - const ref = "abcdefghijklmnopqrst"; - return withConfig( - [ - "[db]", - "major_version = 17", - 'orioledb_version = "A"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental]", - 'orioledb_version = "B"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); - }), - ), - ); - }, - ); + it.effect("a matched remote's db.orioledb_version overrides the base db.orioledb_version", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.db]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + }), + ), + ); + }); - it.effect( - "SUPABASE_DB_ORIOLEDB_VERSION beats a matched remote's legacy experimental.orioledb_version", - () => { - const ref = "abcdefghijklmnopqrst"; - return withConfig( - [ - "[db]", - "major_version = 17", - 'orioledb_version = "A"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental]", - 'orioledb_version = "B"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("env-value"); - }), - ), - withEnv({ SUPABASE_DB_ORIOLEDB_VERSION: "env-value" }), - ); - }, - ); + it.effect("SUPABASE_DB_ORIOLEDB_VERSION beats a matched remote's db.orioledb_version", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.db]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("env-value"); + }), + ), + withEnv({ SUPABASE_DB_ORIOLEDB_VERSION: "env-value" }), + ); + }); it.effect("warns (does not fail) for an unset S3 env on an OrioleDB project", () => { const writes: Array = []; @@ -2546,8 +2514,11 @@ describe("readDbToml auth.Enabled validation", () => { it.effect("exempts apple/google from the external secret requirement", () => succeeds(["[auth.external.apple]", "enabled = true", 'client_id = "a"']), ); - it.effect("never validates the deprecated linkedin/slack providers", () => - succeeds(["[auth.external.linkedin]", "enabled = true"]), + it.effect("rejects the removed linkedin provider, naming linkedin_oidc", () => + failsWith( + ["[auth.external.linkedin]", "enabled = true"], + "[auth.external.linkedin] in supabase/config.toml is no longer supported. Use [auth.external.linkedin_oidc] instead.", + ), ); it.effect("rejects an enabled firebase third_party without project_id", () => diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index 1ff763b3f3..5e881627ec 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -616,9 +616,6 @@ export function resolveAuthSms(sms: CliConfig["auth"]["sms"]): CliConfig["auth"] }; } -/** Deleted external providers, warned on if still enabled, never validated. */ -const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); - /** Matches `GotrueExternalProviderInput`'s fields, kept separate to avoid a command-specific import. */ export interface ResolvedAuthExternalProvider { readonly enabled: boolean; @@ -674,7 +671,6 @@ export function resolveAuthExternalProviders( const decodedProviders = new Map(Object.entries(external)); const providerNames = new Set([...Object.keys(externalDoc ?? {}), "apple"]); for (const name of providerNames) { - if (DEPRECATED_EXTERNAL_PROVIDERS.has(name)) continue; const provider = decodedProviders.get(name); const rawProvider = provider === undefined ? asRecord(externalDoc?.[name]) : undefined; if (provider === undefined && rawProvider === undefined) continue; diff --git a/apps/cli/src/command-internal/local-config-values.unit.test.ts b/apps/cli/src/command-internal/local-config-values.unit.test.ts index 1acfcba923..11b4087904 100644 --- a/apps/cli/src/command-internal/local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/local-config-values.unit.test.ts @@ -700,12 +700,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).not.toThrow(); }); - it("skips deprecated linkedin/slack providers", () => { - const config = baseConfig(); - const document = { auth: { external: { slack: { enabled: true } } } }; - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).not.toThrow(); - }); - it("does not validate a disabled unmodeled external provider", () => { const config = baseConfig(); const document = { auth: { external: { custom: { enabled: false } } } }; diff --git a/apps/cli/src/command-internal/resolved-config-context.ts b/apps/cli/src/command-internal/resolved-config-context.ts index a54d933595..96adcedabc 100644 --- a/apps/cli/src/command-internal/resolved-config-context.ts +++ b/apps/cli/src/command-internal/resolved-config-context.ts @@ -74,7 +74,9 @@ export const describeConfigLoadFailure = (cause: unknown): string => "_tag" in cause && (cause._tag === "CliConfigValueError" || cause._tag === "CliConfigFlagConflictError" || - cause._tag === "CliConfigLoadError") && + cause._tag === "CliConfigLoadError" || + cause._tag === "CliConfigRemovedKeyError" || + cause._tag === "CliConfigRemovedEnvError") && "message" in cause && typeof cause.message === "string" ? cause.message diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index 074ee335ef..e63c79fe31 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -7,7 +7,7 @@ import { resolveLocalConfigValues, type LocalConfigValues } from "./local-config /** * A status output field: the dotted key `--override-name =` matches against * (`fieldKey`), and its default output env-var name (`defaultName`). Deprecated fields - * (`inbucket`, `jwt_secret`, `anon_key`, `service_role_key`) are still emitted. + * (`jwt_secret`, `anon_key`, `service_role_key`) are still emitted. */ export interface StatusField { readonly fieldKey: string; @@ -28,7 +28,6 @@ const FUNCTIONS_URL: StatusField = { }; const DB_URL: StatusField = { fieldKey: "db.url", defaultName: "DB_URL" }; const STUDIO_URL: StatusField = { fieldKey: "studio.url", defaultName: "STUDIO_URL" }; -const INBUCKET_URL: StatusField = { fieldKey: "inbucket.url", defaultName: "INBUCKET_URL" }; const MAILPIT_URL: StatusField = { fieldKey: "mailpit.url", defaultName: "MAILPIT_URL" }; const PUBLISHABLE_KEY: StatusField = { fieldKey: "auth.publishable_key", @@ -54,7 +53,7 @@ const STORAGE_S3_REGION: StatusField = { defaultName: "S3_PROTOCOL_REGION", }; -/** All 18 fields, in declaration order. */ +/** All 17 fields, in declaration order. */ export const STATUS_FIELDS: ReadonlyArray = [ API_URL, REST_URL, @@ -64,7 +63,6 @@ export const STATUS_FIELDS: ReadonlyArray = [ FUNCTIONS_URL, DB_URL, STUDIO_URL, - INBUCKET_URL, MAILPIT_URL, PUBLISHABLE_KEY, SECRET_KEY, @@ -365,7 +363,6 @@ export function statusValuesFromState( } if (inbucketEnabled) { values[names.mailpitUrl] = local.mailpitUrl; - values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; } if (storageEnabled && storageS3ProtocolEnabled) { values[names.storageS3Url] = local.storageS3Url; diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index ef4d7007d1..64201955ed 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -339,7 +339,7 @@ describe("statusValues", () => { }); describe("inbucket/mailpit gating", () => { - it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { + it("emits MAILPIT_URL and no INBUCKET_URL when local_smtp.enabled", () => { const { values } = statusValues( baseConfig(), CONTAINER_IDS, @@ -349,17 +349,16 @@ describe("statusValues", () => { WORKDIR, ); expect(values.MAILPIT_URL).toBeDefined(); - expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); + expect(values.INBUCKET_URL).toBeUndefined(); }); - it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { + it("omits MAILPIT_URL when local_smtp.enabled is false", () => { const config = baseConfig({ local_smtp: { enabled: false } }); const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); expect(values.MAILPIT_URL).toBeUndefined(); - expect(values.INBUCKET_URL).toBeUndefined(); }); - it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { + it("omits MAILPIT_URL when the inbucket container id is excluded", () => { const { values } = statusValues( baseConfig(), CONTAINER_IDS, @@ -371,7 +370,7 @@ describe("statusValues", () => { expect(values.MAILPIT_URL).toBeUndefined(); }); - it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { + it("omits MAILPIT_URL when the mailpit image short name is excluded", () => { const { values } = statusValues( baseConfig(), CONTAINER_IDS, @@ -508,21 +507,6 @@ describe("statusValues", () => { expect(values.ANON_KEY).toBeUndefined(); expect(values.SERVICE_ROLE_KEY).toBeUndefined(); }); - - it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { - const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, - ); - expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); - expect(values.MAILPIT_URL).toBeDefined(); - expect(values.INBUCKET_URL).toBeUndefined(); - }); }); it("combines stopped-service exclusions with --exclude flag exclusions", () => { diff --git a/apps/cli/src/commands/config/config.load.ts b/apps/cli/src/commands/config/config.load.ts index d0e81a4d72..9272f60121 100644 --- a/apps/cli/src/commands/config/config.load.ts +++ b/apps/cli/src/commands/config/config.load.ts @@ -70,7 +70,7 @@ export const loadResolvedConfig = Effect.fnUntraced(function* ( /** * The resolved config for the resolved target. A command loads once before it knows the target, to fail * on a missing or invalid config before any network call; that resolved config stands unless a - * `[remotes.*]` block selects `ref`, since loading again prints its warnings twice. + * `[remotes.*]` block selects `ref`. */ export const loadTargetResolvedConfig = Effect.fnUntraced(function* ( cliSettings: ConfigWorkdir, diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 3809c0e47a..542adc9052 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -1,7 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; -import { vi } from "vitest"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { @@ -522,34 +521,16 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "an env()-resolving [remotes.*] project_id does not match the target and does not double-warn (CLI-2287)", - () => { - const { layer, out } = setup({ - toml: [ - 'project_id = "test"', - "[inbucket]", - "enabled = true", - "port = 12345", - "[remotes.x]", - 'project_id = "env(REMOTE_REF)"', - "", - ].join("\n"), - dotenv: `REMOTE_REF=${VALID_REF}\n`, - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - return Effect.gen(function* () { - yield* configDiff(noFlags); - expect(out.stderrText).toContain( - `Comparing against project ${VALID_REF} using base config`, - ); - const inbucketWarnings = errorSpy.mock.calls.filter((call) => - String(call[0]).includes("[inbucket] is deprecated"), - ); - expect(inbucketWarnings).toHaveLength(1); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); - }, - ); + it.effect("an env()-resolving [remotes.*] project_id does not match the target", () => { + const { layer, out } = setup({ + toml: ['project_id = "test"', "[remotes.x]", 'project_id = "env(REMOTE_REF)"', ""].join("\n"), + dotenv: `REMOTE_REF=${VALID_REF}\n`, + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stderrText).toContain(`Comparing against project ${VALID_REF} using base config`); + }).pipe(Effect.provide(layer)); + }); it.effect("a branch-named --project-ref resolves via the parent project", () => { const { layer, out, api } = setup({ diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 6b1265ba30..2bc89794d6 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -132,23 +132,24 @@ cannot be resolved from either the read or the file — reported in their own ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success, **including** declining one of the per-service `keep()` confirmation prompts (`api`/`db`/`auth`/`storage`/`webhooks`/MFA addon prompts) | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPushWorkdirError`) — beats target resolution, the config load, and every network call | -| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`ConfigPushLoadConfigError`) — checked again, right before target resolution, so a typo'd workdir fails without a branch-lookup round trip; a DEFAULTED workdir with no linked project instead fails with the not-linked error below | -| `1` | user declined the branch confirmation gate (cancellation, `ConfigPushCancelledError`) — see Output below | -| `1` | `--project-ref` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | -| `1` | malformed or missing `config.toml`/`config.json` | -| `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in `ConfigPushLoadConfigError` | -| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (`CliConfigValueError`, see below) | -| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file, or a path that resolves outside the project root) | -| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | -| `1` | list-addons failure (network or non-200) | -| `1` | effective-project-config read failure (network, decode, or unexpected status) — one failure mode now covers what used to be six independent per-service read failures | -| `1` | the API's effective project configuration fails to parse | -| `1` | the effective-project-config response carried no block at all (`ConfigPushConfigEmptyError`) — nothing was pushed | -| `1` | any per-service update failure or webhook-enable failure (network or unexpected status) | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success, **including** declining one of the per-service `keep()` confirmation prompts (`api`/`db`/`auth`/`storage`/`webhooks`/MFA addon prompts) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPushWorkdirError`) — beats target resolution, the config load, and every network call | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`ConfigPushLoadConfigError`) — checked again, right before target resolution, so a typo'd workdir fails without a branch-lookup round trip; a DEFAULTED workdir with no linked project instead fails with the not-linked error below | +| `1` | user declined the branch confirmation gate (cancellation, `ConfigPushCancelledError`) — see Output below | +| `1` | `--project-ref` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | +| `1` | malformed or missing `config.toml`/`config.json` | +| `1` | `config.toml`/`config.json` uses a removed key (`[inbucket]`, `experimental.orioledb_version` unless empty, `[auth.external.linkedin]`, `[auth.external.slack]`, at the top level or in a `[remotes.*]` block), or `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` is set to a non-empty value — the message names every removed key with its replacement; nothing is pushed | +| `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in `ConfigPushLoadConfigError` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (`CliConfigValueError`, see below) | +| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file, or a path that resolves outside the project root) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | list-addons failure (network or non-200) | +| `1` | effective-project-config read failure (network, decode, or unexpected status) — one failure mode now covers what used to be six independent per-service read failures | +| `1` | the API's effective project configuration fails to parse | +| `1` | the effective-project-config response carried no block at all (`ConfigPushConfigEmptyError`) — nothing was pushed | +| `1` | any per-service update failure or webhook-enable failure (network or unexpected status) | ## Output @@ -367,7 +368,7 @@ may itself contain a `.`. - **`--project-ref` accepts a project ref, or the name (or UUID) of a branch of the linked project** (CLI-2289, the same vocabulary `link`/`config diff` already accept). A value that is exactly 20 lowercase letters is always treated as a ref. A name is resolved against the currently linked project (fails if none is linked, or if the linked ref is itself invalid); a UUID resolves directly and needs no linked project at all. - **Every invocation detects whether the resolved ref is the linked project, one of its branches, or genuinely undeterminable** (CLI-2168) and always echoes which one before doing anything else — see Output above. When `--project-ref` already named a branch by name/UUID, this is known for free (certain, never re-derived from a live probe); otherwise it's a live `GET /v1/projects/{ref}` probe: 200 is a plain project, 404 confirms a branch, and EVERYTHING else (a TIMEOUT, a transport failure, or any other status) degrades to "unknown" — never "project" (that would skip the confirmation gate for what might genuinely be a branch) and never a hard failure (this probe is diagnostic-only and must never block a push that would otherwise succeed). A confirmed branch's own name/parent are recovered best-effort from `.temp/linked-project.json`/`.temp/project-ref` and a branch-list lookup. A CONFIRMED branch target resolved IMPLICITLY (not via an explicit `--project-ref ` this invocation) is gated behind a confirmation before any further network call; a target resolved from an EXPLICIT `--project-ref ` this invocation skips that confirmation (same-invocation intent already expressed once); an "unknown" target is never gated at all. The target-echo line always prints regardless of which shape it is. -- **Resolution runs BEFORE the config load**, not after: a `[remotes.]` overlay is merged inside the resolved config load before its one full schema decode, and only one decode may ever run per invocation — reloading with a different `projectRef` a second time would either double the load-time deprecation warnings or wrongly reject a base document that's only valid once its matching remote's overlay applies. The accepted tradeoff: a branch name/UUID resolution's network call can fire even when the local `config.toml` turns out to be malformed (this only affects `--project-ref `; a ref-shaped or absent target never needs a network call to resolve, so a malformed config there still aborts with zero requests made, matching this command's behavior before CLI-2168/CLI-2289). +- **Resolution runs BEFORE the config load**, not after: a `[remotes.]` overlay is merged inside the resolved config load before its one full schema decode, and only one decode may ever run per invocation — reloading with a different `projectRef` a second time would wrongly reject a base document that's only valid once its matching remote's overlay applies. The accepted tradeoff: a branch name/UUID resolution's network call can fire even when the local `config.toml` turns out to be malformed (this only affects `--project-ref `; a ref-shaped or absent target never needs a network call to resolve, so a malformed config there still aborts with zero requests made, matching this command's behavior before CLI-2168/CLI-2289). - **A non-TTY script piping multiple `y`/`n` answers needs one extra leading answer for an IMPLICIT branch target.** The branch confirmation gate reads one piped stdin line just like any other prompt in this command; it runs before the per-service `keep()` prompts, so a script written for the pre-CLI-2168 prompt sequence (`api`, `db`, `auth`, ...) has every answer shifted by one when its target happens to be an inferred branch. A plain-project target, or a target named explicitly via `--project-ref`, is unaffected (no new prompt fires). - The post-run linked-project telemetry cache fill (`Effect.ensuring`, unconditional) may issue its own `GET /v1/projects/{ref}` independent of the target-detection probe above — both are best-effort/non-fatal for that fill, so a branch ref 404ing there is expected and harmless. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. @@ -384,6 +385,5 @@ may itself contain a `.`. - **`encrypted:` (dotenvx) secrets are decrypted, digested, and compared before being sent.** `DOTENV_PRIVATE_KEY`(`_*`) values from the shell + `supabase/.env` decrypt the ciphertext; the decrypted plaintext is hashed the same way the platform hashes its own stored value and compared against the digest the effective-configuration read returns for that field. A matching digest is left alone — never resent; a differing, `null`, or absent digest gets the decrypted plaintext sent in the update body. An empty value or an unresolved `env(VAR)` reference is never sent, and is reported by the credential `Note:` line instead. The ciphertext itself is never pushed, and no plaintext or digest value ever appears in CLI output. - **A secret is only compared/sent while its parent container is present and not explicitly disabled** (e.g. a disabled `[auth.hook.custom_access_token]` never sends its `secrets` value, an absent `[auth.captcha]` never sends `security_captcha_secret`) — one uniform gate covering every secret family, in place of the previous version's five hand-coded per-secret gates. - **An undecryptable secret aborts before any network call.** Before the cost-matrix list-addons request or any other service call, every `config.Secret`-typed value in the document is asserted decryptable — not just `auth.*` (the only fields `config push` actually sends), via a document-wide decode hook that runs the same check regardless of which fields a given command reads. This covers `[db.vault]` (a `map[string]Secret`, not just an `auth.*` field). An undecryptable value aborts with a `failed to parse config: ` message, exit code `1`. -- **Deprecated `[auth.external.{linkedin,slack}]` secrets are checked before they're stripped.** `@supabase/config` strips these deprecated blocks from the decoded document before returning it, but the decrypt hook runs at decode time — before validation later deletes them — so `config push` re-checks the stripped-out sub-objects separately, following the decode-before-delete order rather than missing a secret hiding in one of them. - KNOWN GAPS: - The document-wide decrypt-or-abort pre-check scans the config document `@supabase/config` hands back, which has the _matched_ `[remotes.]` block already merged in and its `remotes` key removed. An `encrypted:` secret that's undecryptable inside a **different, non-matching** `[remotes.*]` block is therefore not caught here. Narrow edge case: it only matters when a project declares multiple remotes and the _unused_ one has a broken secret. diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 68d0620c78..00142c8811 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -155,7 +155,7 @@ function toSecretReport(decision: PushSecretDecision) { return report; } -/** Loads the resolved config once per push, so the load-time deprecation warnings print once. */ +/** Loads the resolved config once per push. */ const loadPushConfig = Effect.fn("config.push.loadConfig")(function* ( cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, ref: string, @@ -178,9 +178,7 @@ const loadPushConfig = Effect.fn("config.push.loadConfig")(function* ( const projectYes = resolvedConfig.projectEnvValues["SUPABASE_YES"]; const projectEnv: Record = projectYes === undefined ? {} : { SUPABASE_YES: projectYes }; - const referenced = yield* resolvedConfig.envValues( - envReferenceNames(loaded.document, loaded.removedDeprecatedExternalProviders), - ); + const referenced = yield* resolvedConfig.envValues(envReferenceNames(loaded.document)); return { loaded, lookup: (name: string) => referenced[name], @@ -280,18 +278,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // 3. Assert every `encrypted:` value in the document can be decrypted, even fields `config // push` never itself pushes — this must run before the cost matrix or any service is touched. - // - // Deprecated `auth.external.{linkedin,slack}` blocks are stripped from `loaded.document` - // before this decode, so scan `removedDeprecatedExternalProviders` too, or a secret hiding in - // one of them would skip the check. - const secretError = yield* Effect.sync( - () => - assertDecryptableSecrets(loaded.document, lookup, dotenvPrivateKeys) ?? - assertDecryptableSecrets( - { auth: { external: loaded.removedDeprecatedExternalProviders } }, - lookup, - dotenvPrivateKeys, - ), + const secretError = yield* Effect.sync(() => + assertDecryptableSecrets(loaded.document, lookup, dotenvPrivateKeys), ).pipe(Effect.withSpan("config.push.verifyDecryptable")); if (secretError !== undefined) { return yield* new ConfigPushLoadConfigError({ message: secretError }); diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 18174f8b16..29b65beb1e 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -1908,24 +1908,22 @@ my_secret = "${DOTENVX_ENCRYPTED_VALUE}" }); it.effect( - "aborts on an undecryptable secret in a deprecated [auth.external.slack] block (CLI-1881)", + "aborts before any request when config.toml uses a removed [auth.external.slack]", () => { const toml = `project_id = "test" [auth.external.slack] secret = "${DOTENVX_ENCRYPTED_VALUE}" `; const { layer, api } = setup({ toml, yes: true }); - return withEnvVar( - "DOTENV_PRIVATE_KEY", - undefined, - Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), - ); - expect(message).toBe("failed to parse config: missing private key"); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const message = yield* configPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), + ); + expect(message).toBe( + "[auth.external.slack] in supabase/config.toml is no longer supported. Use [auth.external.slack_oidc] instead.", + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index a60c87747e..2bf11627ed 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1438,28 +1438,24 @@ describe("db start", () => { ); it.effect( - "prints @supabase/config's deprecated-[inbucket]-section WARN only once on a fresh, not-already-running start", + "fails before creating any container when config.toml uses the removed [inbucket] section", () => { - // The deprecation WARN is Console.error-pinned to the real console, not this file's - // Output service, so it must be observed with a raw console.error spy, like - // stop/status's identical tests. - const { layer } = setup({ + const { layer, child } = setup({ configContents: 'project_id = "test"\n[inbucket]\n', route: freshVolumeRoute(defaultRoute()), }); - const warnings: Array = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }); return Effect.gen(function* () { - yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - const inbucketWarnings = warnings.filter((m) => - m.includes( - "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", - ), - ); - expect(inbucketWarnings).toHaveLength(1); - }).pipe(Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = Cause.pretty(exit.cause); + expect(message).toContain("DbConfigLoadError"); + expect(message).toContain( + "[inbucket] in supabase/config.toml is no longer supported. Use [local_smtp] instead.", + ); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); }, ); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index b5a8d29a08..8277af7edf 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -15,6 +15,7 @@ import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, } from "../../../telemetry/command-telemetry-attributes.ts"; +import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; const load = (projectRoot: string) => @@ -351,16 +352,6 @@ s3_secret_key = "env(S3_SECRET_KEY)" Effect.gen(function* () { const unpublished = "db.orioledb_version (or SUPABASE_DB_ORIOLEDB_VERSION) = 17.0.0.000 is not an OrioleDB build this CLI ships for the experimental stack; it ships:"; - const orioledb = yield* project(`project_id = "stack-config-orioledb" -[experimental] -orioledb_version = "17.0.0.000" -`); - const orioledbExit = yield* load(orioledb).pipe(Effect.exit); - expect(Exit.isFailure(orioledbExit)).toBe(true); - if (Exit.isFailure(orioledbExit)) expect(String(orioledbExit.cause)).toContain(unpublished); - - // The same check applies to the canonical `[db]` location, not just the deprecated - // `[experimental]` alias. const orioledbCanonical = yield* project(`project_id = "stack-config-orioledb-db" [db] orioledb_version = "17.0.0.000" @@ -393,6 +384,33 @@ s3_host = "s3.example.test" }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("rejects the removed experimental.orioledb_version key and its env var", () => + Effect.gen(function* () { + const removedKey = yield* project(`project_id = "stack-config-orioledb-removed" +[experimental] +orioledb_version = "17.0.0.000" +`); + const keyExit = yield* load(removedKey).pipe(Effect.exit); + expect(Exit.isFailure(keyExit)).toBe(true); + if (Exit.isFailure(keyExit)) + expect(String(keyExit.cause)).toContain( + "experimental.orioledb_version in supabase/config.toml is no longer supported. Use db.orioledb_version instead.", + ); + + const envRoot = yield* project('project_id = "stack-config-orioledb-env"\n'); + const envExit = yield* withEnvVar( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + "17.0.0.000", + load(envRoot).pipe(Effect.exit), + ); + expect(Exit.isFailure(envExit)).toBe(true); + if (Exit.isFailure(envExit)) + expect(String(envExit.cause)).toContain( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION (set in the shell environment) is no longer supported. Use SUPABASE_DB_ORIOLEDB_VERSION instead.", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("records OrioleDB selection on the command event before rejecting it", () => Effect.gen(function* () { const loadRecordingOrioleDb = (projectRoot: string) => diff --git a/apps/cli/src/commands/experimental/stack/stack-summary.ts b/apps/cli/src/commands/experimental/stack/stack-summary.ts index 3354c75b6a..e036a5b401 100644 --- a/apps/cli/src/commands/experimental/stack/stack-summary.ts +++ b/apps/cli/src/commands/experimental/stack/stack-summary.ts @@ -165,11 +165,7 @@ export const connectionEnv = ( if (connections.database !== undefined) values.DB_URL = connections.database; if (connections.studio !== undefined) values.STUDIO_URL = connections.studio; if (connections.mcp !== undefined) values.MCP_URL = connections.mcp; - if (connections.mailpit !== undefined) { - values.MAILPIT_URL = connections.mailpit; - // Deprecated alias of `MAILPIT_URL`, kept for parity with legacy `status --env`. - values.INBUCKET_URL = connections.mailpit; - } + if (connections.mailpit !== undefined) values.MAILPIT_URL = connections.mailpit; if (credentials !== undefined) { values.PUBLISHABLE_KEY = credentials.publishableKey; values.SECRET_KEY = credentials.secretKey; diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index 1c2b7673c6..5ee41d0a2f 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -79,7 +79,8 @@ required saved database JWT secret. `--override-name` renames an exported variable, accepting repeated flags or a comma-separated list of `EXPORTED_VARIABLE=VALID_ENV_NAME` entries. It requires -`--env` and rejects an unknown source variable, duplicate source, invalid target, +`--env` and rejects an unknown source variable (the removed `INBUCKET_URL` names +`MAILPIT_URL` as its replacement), duplicate source, invalid target, malformed entry, or destination collision. Dotenv values that cannot be quoted without shell expansion fail with a pointer to `--output-format json`. diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index e751b24fb8..d570f63a7f 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -11,7 +11,6 @@ const variableNames = [ "STUDIO_URL", "MCP_URL", "MAILPIT_URL", - "INBUCKET_URL", "PUBLISHABLE_KEY", "SECRET_KEY", "ANON_KEY", @@ -22,8 +21,16 @@ const variableNames = [ "S3_PROTOCOL_REGION", ] as const; -/** Names the stack backend used to export, since removed; still worth naming in errors. */ -const removedVariableNames = new Set(["S3_PROTOCOL_URL"]); +/** Names the stack backend used to export, since removed, mapped to their replacement if any. */ +const removedVariableNames = new Map([ + ["S3_PROTOCOL_URL", undefined], + ["INBUCKET_URL", "MAILPIT_URL"], +]); + +const removedReplacement = (name: string) => { + const replacement = removedVariableNames.get(name); + return replacement === undefined ? "" : `; use ${replacement} instead`; +}; export const stackEnvOverrides = (entries: ReadonlyArray) => Effect.gen(function* () { @@ -45,7 +52,7 @@ export const stackEnvOverrides = (entries: ReadonlyArray) => return yield* new StackCommandStatusError({ reason: "flags", message: removedVariableNames.has(source) - ? `--override-name entry "${entry}" refers to ${source}, which is not exported by the stack backend.` + ? `--override-name entry "${entry}" refers to ${source}, which is not exported by the stack backend${removedReplacement(source)}.` : `--override-name entry "${entry}" refers to ${source}, which is not an exported variable; valid variables are ${variableNames.join(", ")}.`, }); if (sources.has(source)) diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index 5b5dcf8ba3..b8427b4a8f 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -64,12 +64,11 @@ describe("stack environment overrides", () => { }), ); - it.effect("emits INBUCKET_URL as a deprecated alias of MAILPIT_URL", () => + it.effect("emits MAILPIT_URL without an INBUCKET_URL alias", () => Effect.gen(function* () { const names = yield* stackEnvOverrides([]); expect(stackEnvValues({ mailpit: "http://127.0.0.1:54324" }, undefined, names)).toEqual({ MAILPIT_URL: "http://127.0.0.1:54324", - INBUCKET_URL: "http://127.0.0.1:54324", }); }), ); @@ -105,7 +104,6 @@ describe("stack environment overrides", () => { "STUDIO_URL", "MCP_URL", "MAILPIT_URL", - "INBUCKET_URL", "PUBLISHABLE_KEY", "SECRET_KEY", "ANON_KEY", @@ -118,7 +116,7 @@ describe("stack environment overrides", () => { const names = yield* stackEnvOverrides([`${name}=RENAMED_${name}`]); expect(names.get(name)).toBe(`RENAMED_${name}`); } - for (const removed of ["S3_PROTOCOL_URL", "JWT_SECRET"]) { + for (const removed of ["S3_PROTOCOL_URL", "INBUCKET_URL", "JWT_SECRET"]) { const error = yield* stackEnvOverrides([`${removed}=RENAMED`]).pipe(Effect.flip); expect(error.reason).toBe("flags"); } @@ -134,6 +132,10 @@ describe("stack environment overrides", () => { expect(removed.message).toContain("S3_PROTOCOL_URL"); expect(removed.message).toContain("not exported by the stack backend"); + const inbucket = yield* stackEnvOverrides(["INBUCKET_URL=RENAMED"]).pipe(Effect.flip); + expect(inbucket.message).toContain("INBUCKET_URL"); + expect(inbucket.message).toContain("use MAILPIT_URL instead"); + const unknown = yield* stackEnvOverrides(["UNKNOWN=RENAMED"]).pipe(Effect.flip); expect(unknown.message).toContain("UNKNOWN"); expect(unknown.message).toContain("API_URL"); diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 44549ccf7b..fb5fb98fbc 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -9,11 +9,12 @@ | `/supabase/config.json` or `config.toml` | JSON / TOML | `config.json` preferred when both exist; to resolve function config, project id, and local Functions — through `CliConfigValues` (same resolver `start`/`stop`/`status` use; flag, shell, project dotenv, then config), so no ancestor directory is searched past `` and the Docker project id is `project_id` or, when unset, the workdir name — this now (CLI-2285) applies to the functions manifest inference as well, so the two loads can never disagree about which project they resolve; also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | | `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | | `/supabase/functions//index.ts` | TypeScript | function source to deploy | -| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | +| `/supabase/functions//deno.json*` | JSON/JSONC | when resolving a function's import map: `deno.json`, then `deno.jsonc`, unless `--import-map` or `functions..import_map` is set | +| `/supabase/functions//import_map.json` | JSON | probed only when the function directory has no `deno.json*` and no explicit import map is set; if present, the deploy fails before any bundling or upload | | imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | | configured static files | any | when `static_files` patterns match local files | | `package.json` next to function entrypoint | JSON | Docker bundling package discovery | -| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | +| `/supabase/functions/import_map.json` | JSON | shared fallback import map, used by functions that resolve no other import map (stderr `WARNING: Functions using fallback import map`) | ## Files Written @@ -63,14 +64,15 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------- | -| `0` | success | -| `1` | authentication / project-ref resolution | -| `1` | API error or unexpected HTTP status | -| `1` | build / bundle failure | -| `1` | invalid function slug or flag conflict | -| `1` | prune confirmation cancelled | +| Code | Condition | +| ---- | ----------------------------------------------------------------- | +| `0` | success | +| `1` | authentication / project-ref resolution | +| `1` | API error or unexpected HTTP status | +| `1` | build / bundle failure | +| `1` | invalid function slug or flag conflict | +| `1` | a function directory has `import_map.json` instead of `deno.json` | +| `1` | prune confirmation cancelled | ## Output @@ -94,6 +96,12 @@ The `--output`/`-o` flag does not change deploy output. (`config.json` over `config.toml`) > default. The whole config is decoded up front, so an invalid value, including an unparsable `SUPABASE_*` override, fails the command. - If no function name is provided, deploys all functions found in `supabase/functions/`. +- **Import map resolution** per function, first match wins: `--import-map`, then + `functions..import_map`, then the function directory's `deno.json`, then `deno.jsonc`. + A function directory with `import_map.json` and none of those fails the whole deploy before any + bundling or upload, naming every affected slug and suggesting renaming the file to `deno.json` or + setting `functions..import_map`; an explicitly configured `import_map.json` is accepted. + A function that resolves nothing else uses `supabase/functions/import_map.json` when it exists. - API-based deploys anchor uploaded file names and the recorded `entrypoint_path` / `import_map_path` / `static_patterns` at the workdir (relative to the working directory, forward slashes). Imports outside the workdir but inside diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 88b81cfb6b..f8c21300cf 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -651,6 +651,169 @@ describe("functions deploy", () => { }, ); + describe("per-function import_map.json", () => { + const functionDir = (slug: string) => `${tempRoot.current}/supabase/functions/${slug}`; + + const harnessRecordingImportMapPaths = () => { + const importMapPaths: Array = []; + const harness = apiDeployHarness((request, recorded) => { + if (Predicate.isTagged(request.body, "FormData")) { + const metadata = request.body.formData.get("metadata"); + if (typeof metadata === "string") + importMapPaths.push(JSON.parse(metadata).import_map_path); + } + return deployedHelloWorld(request, recorded); + }); + return { ...harness, importMapPaths }; + }; + + it.effect( + "fails every function that only has import_map.json before anything is uploaded", + () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeCliConfig(tempRoot.current); + for (const slug of ["hello-world", "other-fn"]) { + yield* writeLocalFunction(tempRoot.current, slug); + yield* fs.remove(`${functionDir(slug)}/deno.json`); + yield* fs.writeFileString(`${functionDir(slug)}/import_map.json`, '{"imports":{}}\n'); + } + + const error = yield* functionsDeploy({ + ...baseFlags, + functionNames: [], + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + message: expect.stringContaining("hello-world, other-fn"), + suggestion: + 'Rename it to deno.json, or set functions.hello-world.import_map = "./functions/hello-world/import_map.json" in config.toml to keep the file.', + }); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.effect("deploys with deno.json when the function directory has both files", () => { + const { importMapPaths, layer } = harnessRecordingImportMapPaths(); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* fs.writeFileString( + `${functionDir("hello-world")}/import_map.json`, + '{"imports":{}}\n', + ); + + yield* functionsDeploy(baseFlags); + + expect(importMapPaths).toEqual(["supabase/functions/hello-world/deno.json"]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("deploys an import_map.json that config.toml names explicitly", () => { + const { importMapPaths, layer } = harnessRecordingImportMapPaths(); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeCliConfig( + tempRoot.current, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'import_map = "./functions/hello-world/import_map.json"', + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* fs.remove(`${functionDir("hello-world")}/deno.json`); + yield* fs.writeFileString( + `${functionDir("hello-world")}/import_map.json`, + '{"imports":{}}\n', + ); + + yield* functionsDeploy(baseFlags); + + expect(importMapPaths).toEqual(["supabase/functions/hello-world/import_map.json"]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect( + "uses the shared fallback import map for an entrypoint directly under functions/", + () => { + const { importMapPaths, out, layer } = harnessRecordingImportMapPaths(); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeCliConfig( + tempRoot.current, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'entrypoint = "./functions/hello-world.ts"', + "", + ].join("\n"), + ); + yield* fs.makeDirectory(`${tempRoot.current}/supabase/functions`, { recursive: true }); + yield* fs.writeFileString( + `${tempRoot.current}/supabase/functions/hello-world.ts`, + "Deno.serve(() => new Response())\n", + ); + yield* fs.writeFileString( + `${tempRoot.current}/supabase/functions/import_map.json`, + '{"imports":{}}\n', + ); + + yield* functionsDeploy(baseFlags); + + expect(importMapPaths).toEqual(["supabase/functions/import_map.json"]); + expect(out.stderrText).toContain( + "WARNING: Functions using fallback import map: hello-world\n", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.effect( + "skips a disabled function that only has import_map.json and deploys the enabled ones", + () => { + const { out, api, layer } = apiDeployHarness(deployedHelloWorld); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeCliConfig( + tempRoot.current, + ['project_id = "test-project"', '[functions."legacy-fn"]', "enabled = false", ""].join( + "\n", + ), + ); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "legacy-fn"); + yield* fs.remove(`${functionDir("legacy-fn")}/deno.json`); + yield* fs.writeFileString( + `${functionDir("legacy-fn")}/import_map.json`, + '{"imports":{}}\n', + ); + + yield* functionsDeploy({ ...baseFlags, functionNames: [] }); + + const deployRequests = api.requests.filter( + (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), + ); + expect(deployRequests).toHaveLength(1); + expect(deployRequests[0]?.urlParams).toContain("slug=hello-world"); + expect(api.requests.some((request) => request.urlWithParams.includes("legacy-fn"))).toBe( + false, + ); + expect(out.stderrText).toContain("Skipping disabled Function: legacy-fn\n"); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + }); + it.effect("keeps collecting upload files after the deploy is interrupted", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: deployedHelloWorld }); diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index dc91a6fcd3..a1818a33f0 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -13,6 +13,7 @@ | `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | | `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | | config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `/supabase/functions//import_map.json` | JSON | probed for each enabled function with no `deno.json*` and no configured import map; if present, startup fails (see Exit Codes) | | `` | JSON | when `auth.signing_keys_path` is configured | | `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | @@ -71,6 +72,7 @@ back to local keys. No scheme/host validation is performed on the discovered URL | `0` | an edge-runtime startup failure or log-stream failure lands within the shutdown grace period (~50ms) of a `SIGINT`/`SIGTERM` | | `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | | `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | +| `1` | an enabled function has `import_map.json` but no `deno.json*` or configured import map — startup fails before the container is created | | `1` | env file, signing key, import map, or function bind resolution failure | | `1` | edge-runtime container startup, log streaming, or restart loop failure — including the edge-runtime container crashing with any exit code other than `0` or `129`/`130`/`131`/`143` | | `1` | the edge-runtime container is killed for exceeding its memory limit — `State.OOMKilled`, typically with exit `137` | diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index 5af84fe312..a7a5de3082 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -9,7 +9,7 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/config.json` or `config.toml` | JSON / TOML | always (for `[edge_runtime.secrets]`, `config.json` preferred) — via the `CliConfigValues` resolved config, with a matching `[remotes.*]` block applied; a parse failure is logged to the debug logger and tolerated, not fatal | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always (for `[edge_runtime.secrets]`, `config.json` preferred) — via the `CliConfigValues` resolved config, with a matching `[remotes.*]` block applied; a parse failure is debug-logged and tolerated, a removed key is fatal. | | `/.env*`, `/supabase/.env*` | dotenv | always — project env files selected by `SUPABASE_ENV` (default `development`), context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values; a variable the shell sets is never taken from a file | | `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | @@ -52,6 +52,8 @@ | `1` | `SecretsEnvFileParseError` — `--env-file` cannot be parsed | | `1` | `SecretsSetUnexpectedStatusError` — non-2xx response from POST | | `1` | `SecretsSetNetworkError` — transport-level network failure | +| `1` | `CliConfigRemovedKeyError` — config uses a removed key, named with its replacement | +| `1` | `CliConfigRemovedEnvError` — non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` set | ## Telemetry Events Fired @@ -80,5 +82,6 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). - `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. - `[edge_runtime.secrets]` from config.toml is read through the `CliConfigValues` resolved config (shell env > project `.env*` > config for `env(VAR)` references) + `resolveCliSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler (secrets whose value never resolved past the literal `env(VAR)` form are dropped). +- A removed config key or a non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` **does** abort the command (exit 1, before any request) instead of being tolerated: silently dropping the config's `[edge_runtime.secrets]` would send a partial secret set. - A malformed `config.toml` does **not** abort the command — the error is logged to the debug logger and the command proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/set/set.integration.test.ts b/apps/cli/src/commands/secrets/set/set.integration.test.ts index 22a1e9d06a..54ceedd8c8 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -919,6 +919,35 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] }, ); + it.effect( + "fails with CliConfigRemovedKeyError naming [local_smtp] and sends no request when config.toml uses [inbucket]", + () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[inbucket] +enabled = true + +[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + const message = yield* secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }).pipe( + Effect.catchTag("CliConfigRemovedKeyError", (error) => Effect.succeed(error.message)), + ); + expect(message).toBe( + "[inbucket] in supabase/config.toml is no longer supported. Use [local_smtp] instead.", + ); + expect(api.requests).toHaveLength(0); + expect(out.stdoutText).not.toContain("Finished supabase secrets set."); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fails with SecretsSetNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 297c81271d..6e4762498e 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -110,7 +110,7 @@ command. | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | | `/supabase/` (files/directories/globs) | SQL | on a fresh volume, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | | `/supabase/.branches/_current_branch` | text | on every start, existence check before writing (see "Files Written") | -| `/supabase/functions/**` | — | when Edge Runtime starts, and independently when Studio starts (function discovery/config resolution + Docker bind mounts, regardless of whether Edge Runtime itself is enabled) | +| `/supabase/functions/**` | — | in the preflight, before any container is created, when Edge Runtime or Studio is enabled (function discovery/config resolution + Docker bind mounts), then again when Edge Runtime starts; probes `/import_map.json` when a function has no `deno.json*` and no configured import map | | `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`), written by `supabase link`; absent/unreadable resolves to no pin | | `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always — linked-project per-service image version pins, written by `supabase link`; absent/unreadable resolves to the embedded default image. Under `SUPABASE_USE_SLIM_IMAGES`, only a pin that matches the current Dockerfile tag is slim-translated; a historical pin stays on docker.io (those slim tags are not published) | | `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | @@ -224,6 +224,7 @@ code is surfaced on failure. | `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | | `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | +| `1` | an enabled function has `import_map.json` but no `deno.json`/`deno.jsonc` and no `functions..import_map` — `import_map.json in a function directory is no longer supported: `; checked in the preflight whenever Edge Runtime or Studio is enabled, so it fails before any container is created | | `1` | an unparsable `SUPABASE_*` config override (e.g. a non-boolean `SUPABASE_AUTH_ENABLED`) | | `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | | `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | diff --git a/apps/cli/src/commands/start/services/gotrue.service.ts b/apps/cli/src/commands/start/services/gotrue.service.ts index 0a8b5eb101..4dfad3353c 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.ts @@ -192,7 +192,7 @@ export interface BuildGotrueEnvInput { /** * `config.local_smtp`, present iff `local_smtp.enabled` AND {@link smtp} * is unset. `adminEmail`/`senderName` default to the literals below when - * unset, since `@supabase/config`'s `inbucket.ts` schema has no default + * unset, since `@supabase/config`'s `local_smtp` schema has no default * for either. */ readonly mailpit?: { diff --git a/apps/cli/src/commands/start/services/mailpit.service.ts b/apps/cli/src/commands/start/services/mailpit.service.ts index 09105dd135..ec80bf4842 100644 --- a/apps/cli/src/commands/start/services/mailpit.service.ts +++ b/apps/cli/src/commands/start/services/mailpit.service.ts @@ -3,22 +3,22 @@ import type { StartContainerSpec } from "../../../command-internal/db-bootstrap/ /** * This service's `containerSuffix` in `SERVICE_CATALOG`. Kept as "inbucket" (the product Mailpit - * replaced) even though the user-facing service and config section are "Mailpit"/`config.inbucket`. + * replaced) even though the user-facing service and config section are "Mailpit"/`config.local_smtp`. */ const MAILPIT_CONTAINER_SUFFIX = "inbucket"; export interface MailpitContainerSpecInput { - /** The already-resolved `config.inbucket.image`; resolution is the caller's responsibility. */ + /** The already-resolved `config.local_smtp.image`; resolution is the caller's responsibility. */ readonly image: string; /** The project id, used to derive this container's own name via {@link serviceContainerName}. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ readonly networkId: string; - /** `config.inbucket.port` — always published as `8025/tcp`. */ + /** `config.local_smtp.port` — always published as `8025/tcp`. */ readonly port: number; - /** `config.inbucket.smtp_port`; published as `1025/tcp` only when set (not `undefined`) and non-zero. */ + /** `config.local_smtp.smtp_port`; published as `1025/tcp` only when set (not `undefined`) and non-zero. */ readonly smtpPort?: number; - /** `config.inbucket.pop3_port`; published as `1110/tcp` only when set (not `undefined`) and non-zero. */ + /** `config.local_smtp.pop3_port`; published as `1110/tcp` only when set (not `undefined`) and non-zero. */ readonly pop3Port?: number; } diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 4d6b723d4b..3fd98b20cb 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -693,31 +693,6 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { (message) => new StartInvalidConfigError({ message }), ).pipe(Effect.withSpan("start.resolveBootstrapConfig")); - // 7. Resolve every image that will actually be pulled before any container is created. - const slim = yield* slimImagesEnabled; - const imagePlan = resolveStartImagePlan(gates, slim, serviceVersionOverrides); - // Edge Runtime doesn't go through `resolveStartImagePlan` (see `start.gates.ts`'s header), - // so its default image is resolved independently, pre-pulled when enabled and not excluded. - const edgeRuntimeDefaultImage = gates.edgeRuntime - ? yield* resolveEdgeRuntimeImage(fs, path, cliSettings.workdir, denoVersion) - : undefined; - // Pre-pull only touches non-excluded services; the one-shot setup-job images are resolved - // lazily, only when the fresh-DB setup job actually runs. - const resolvedImages = yield* ensureImagesCached( - spawner, - [ - postgresImage, - ...imagePlan.map((entry) => entry.image), - ...(edgeRuntimeDefaultImage !== undefined ? [edgeRuntimeDefaultImage] : []), - ], - projectEnvValues, - ); - const resolveImage = (image: string) => resolvedImages.get(image) ?? image; - - // Studio's bind mounts are resolved unconditionally of `config.edge_runtime.enabled`, since - // `buildSpecForService`'s "studio" case needs them regardless of whether Edge Runtime itself - // is enabled. - // // `config.functions..env.` is schema-marked deferred and only gets its literal // interpolated by `resolveConfigSubtree` — without this, a configured `env` entry reaches // Edge Runtime as the literal string `"env(API_KEY)"` instead of the real secret. @@ -740,20 +715,44 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ), ); const rawConfigFunctions = rawFunctionConfigRecord(context.document); - // Resolve once during preflight so a missing function source cannot fail only after stopped - // containers have been removed. Studio consumes the cached binds later during bring-up. - const studioFunctionBinds = gates.studio - ? yield* resolveFunctionBindMounts( - projectId, - cliSettings.workdir, - `${cliSettings.workdir}/supabase`, - { configDeclaredFunctions, configFunctions, rawConfigFunctions }, - Option.none(), - Option.none(), - cliSettings.workdir, - projectEnvValues, - ) - : new Set(); + // Resolve before any image pull or container change so a missing function source or an + // unsupported per-function import map fails fast. Studio consumes the binds during bring-up; + // without Studio, Edge Runtime prints the resolution diagnostics itself. + const preflightFunctionBinds = + gates.studio || gates.edgeRuntime + ? yield* resolveFunctionBindMounts( + projectId, + cliSettings.workdir, + `${cliSettings.workdir}/supabase`, + { configDeclaredFunctions, configFunctions, rawConfigFunctions }, + Option.none(), + Option.none(), + cliSettings.workdir, + projectEnvValues, + !gates.studio, + ) + : new Set(); + + // 7. Resolve every image that will actually be pulled before any container is created. + const slim = yield* slimImagesEnabled; + const imagePlan = resolveStartImagePlan(gates, slim, serviceVersionOverrides); + // Edge Runtime doesn't go through `resolveStartImagePlan` (see `start.gates.ts`'s header), + // so its default image is resolved independently, pre-pulled when enabled and not excluded. + const edgeRuntimeDefaultImage = gates.edgeRuntime + ? yield* resolveEdgeRuntimeImage(fs, path, cliSettings.workdir, denoVersion) + : undefined; + // Pre-pull only touches non-excluded services; the one-shot setup-job images are resolved + // lazily, only when the fresh-DB setup job actually runs. + const resolvedImages = yield* ensureImagesCached( + spawner, + [ + postgresImage, + ...imagePlan.map((entry) => entry.image), + ...(edgeRuntimeDefaultImage !== undefined ? [edgeRuntimeDefaultImage] : []), + ], + projectEnvValues, + ); + const resolveImage = (image: string) => resolvedImages.get(image) ?? image; /* Docker creates a missing bind source, but `podman create -v` rejects it. */ if (gates.studio) yield* fs @@ -1078,7 +1077,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { port: values.studioPort, // Computed whenever Studio is enabled, independently of Edge Runtime. // Resolved during preflight above so recovery teardown remains reversible. - functionBinds: [...studioFunctionBinds], + functionBinds: [...preflightFunctionBinds], env: { dbPassword, workdir: cliSettings.workdir, diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index c1ff9d2dee..6c0dcccf4c 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -2922,6 +2922,42 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); + it.effect( + "logs 'Skipped serving Function' once for a disabled function when Studio is excluded", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = tempRoot.current; + yield* fs.makeDirectory(path.join(workdir, "supabase", "functions", "foo"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "functions", "foo", "index.ts"), + "export {};\n", + ); + const { layer, out } = yield* setup({ + configContents: 'project_id = "demo"\n[functions.foo]\nenabled = false\n', + }); + return yield* Effect.gen(function* () { + yield* start(flags({ exclude: ["studio"] })); + expect(out.stderrText.split("Skipped serving Function: foo")).toHaveLength(2); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(path.join(workdir, "supabase", "functions"), { + recursive: true, + force: true, + }); + }).pipe(Effect.orDie), + ), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect( "does not pick up an unrelated ancestor project's functions for a config-less --workdir subdirectory", () => @@ -2970,6 +3006,38 @@ content_path = "./supabase/templates/custom_notice.html" ); }).pipe(Effect.provide(BunServices.layer)), ); + + it.effect( + "fails before pulling images or creating containers when a function has import_map.json and Studio is excluded", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { layer, child, workdir } = yield* setup(); + const functionDir = path.join(workdir, "supabase", "functions", "hello"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString(path.join(functionDir, "index.ts"), "export {};\n"); + yield* fs.writeFileString(path.join(functionDir, "import_map.json"), '{"imports":{}}\n'); + + const exit = yield* Effect.exit( + start(flags({ exclude: ["studio"] })).pipe(Effect.provide(layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "import_map.json in a function directory is no longer supported: hello", + ); + } + expect(createdContainerNames(child.spawned)).toEqual([]); + expect(edgeRuntimeRunCalls(child.spawned)).toEqual([]); + expect( + child.spawned.some( + (s) => s.args[0] === "pull" || (s.args[0] === "image" && s.args[1] === "inspect"), + ), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); }); describe("image pull", () => { @@ -4536,6 +4604,31 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_DB_ORIOLEDB_VERSION override", () => { + it.effect( + "fails before any container work when the removed SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION is set", + () => + withEnvVar( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + "16.0.0.1", + Effect.gen(function* () { + const { layer, child } = yield* setup(); + + const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = Cause.pretty(exit.cause); + expect(message).toContain( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION (set in the shell environment) is no longer supported. Use SUPABASE_DB_ORIOLEDB_VERSION instead.", + ); + expect(message).not.toContain("invalid config for"); + expect(message).not.toContain("failed to read config"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ).pipe(Effect.provide(BunServices.layer)), + ); + it.effect( "selects the OrioleDB Postgres image and enables the container's S3 env when set only via env", () => diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index 059e811863..deceb29df8 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -86,7 +86,7 @@ convention used elsewhere in config loading, and take precedence over the corres | `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | | `1` | `supabase/config.toml` missing or malformed | | `1` | malformed CSV in an `--override-name`/`--exclude` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "\"api.url=FOO" for "--override-name" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | -| `1` | a malformed `--override-name` entry | +| `1` | a malformed `--override-name` entry, or the removed `inbucket.url` key (the message names `mailpit.url`) | | `1` | listing running containers failed (Docker daemon unreachable, etc.) | | `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | @@ -288,7 +288,7 @@ branch-linked state: A degraded/unresolved branch-linked lookup emits the same object minus `branch` only — every other field the cache knows is still present, matching the machine-format guarantee above. -`--override-name` CAN rename one of the 18 known fields to literally `linked_project` (or, for +`--override-name` CAN rename one of the 17 known fields to literally `linked_project` (or, for `-o env|json|toml|yaml`, to one of the `linked_*` keys below) — the existing/overridden `values` key always wins that collision, never this extension (PR #6168 review; see `valuesWithLinkedState` and the structured-payload `output.success` call in `status.handler.ts`). @@ -355,9 +355,10 @@ print nothing but the red stderr message on failure (no payload at all). The add it is set. `-o pretty` (or `-o` unset) falls through to `--output-format`'s text/json/stream-json handling. - `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` remaps a single field's output KEY; the - value and group layout are unaffected. An unknown key or a malformed (non `KEY=VALUE`) entry - fails with `StatusOverrideParseError`. This only affects the `env`/`json`/`toml`/`yaml` - (`printStatus`) output path — the pretty table (`-o pretty` or unset) always + value and group layout are unaffected. The removed `inbucket.url` key fails with + `StatusOverrideParseError` naming `mailpit.url` as its replacement, as does a malformed (non + `KEY=VALUE`) entry; any other unknown key is ignored. This only + affects the `env`/`json`/`toml`/`yaml` (`printStatus`) output path — the pretty table (`-o pretty` or unset) always renders with un-overridden names. An override that renames a field to collide with one of the additive `linked_*`/`linked_project` keys never loses to this extension — see the `-o json`/`--output-format json` callouts above diff --git a/apps/cli/src/commands/status/status.handler.ts b/apps/cli/src/commands/status/status.handler.ts index 8dc01cfa69..7d90f339b1 100644 --- a/apps/cli/src/commands/status/status.handler.ts +++ b/apps/cli/src/commands/status/status.handler.ts @@ -55,9 +55,14 @@ import { import { validateWorkdirIsDirectory } from "../../command-internal/workdir-validation.ts"; import type { StatusFlags } from "./status.command.ts"; +const REMOVED_OVERRIDE_KEYS: ReadonlyMap = new Map([ + ["inbucket.url", "mailpit.url"], +]); + /** * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a `fieldKey -> outputName` - * map. Each entry must be `KEY=VALUE`; an unknown `KEY` is silently ignored, not an error. + * map. Each entry must be `KEY=VALUE`; a removed `KEY` fails naming its replacement and any other + * unknown `KEY` is silently ignored. */ const parseOverrides = Effect.fnUntraced(function* (entries: ReadonlyArray) { const knownKeys = new Set(STATUS_FIELDS.map((field) => field.fieldKey)); @@ -71,6 +76,12 @@ const parseOverrides = Effect.fnUntraced(function* (entries: ReadonlyArray { - // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits this warning via - // `Console.error` only under the CLI's config-loading semantics. - const { layer } = setup(); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - return Effect.gen(function* () { - yield* writeConfig('project_id = "demo"\n[auth.external.slack]\nenabled = true\n'); - yield* status(flags()); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: disabling deprecated "slack" provider'), - ); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); - }); - it.effect("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => Effect.gen(function* () { // Must fail before falling through to the workdir-basename default. @@ -1165,6 +1150,21 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); + it.effect("rejects an --override-name entry for the removed inbucket.url key", () => { + const { layer, out, child } = setup({ outputFlag: Option.some("json") }); + return Effect.gen(function* () { + yield* writeConfig(); + const message = yield* status(flags({ overrideName: ["inbucket.url=MAIL_URL"] })).pipe( + Effect.catchTag("StatusOverrideParseError", (error) => Effect.succeed(error.message)), + ); + expect(message).toBe( + '--override-name entry "inbucket.url=MAIL_URL" refers to inbucket.url, which was removed; use mailpit.url instead.', + ); + expect(out.stdoutText).toBe(""); + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.effect("silently ignores an --override-name entry with an unknown field key", () => { const { layer, out } = setup({ outputFlag: Option.some("json") }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index 44c2762e7d..367a0f0f65 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -904,42 +904,6 @@ additional_redirect_urls = "http://a,http://b" }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("warns on stderr for a deprecated auth.external provider", () => - Effect.gen(function* () { - // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits this warning via - // `Console.error` only under the CLI's config-loading semantics. - yield* writeSupabaseFile( - tempRoot.current, - "config.toml", - `project_id = "demo" - -[auth.external.slack] -enabled = true -`, - ); - const { layer } = yield* setup({ skipConfig: true, route: defaultRoute() }); - const warnings: Array = []; - yield* Effect.acquireUseRelease( - Effect.sync(() => - vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }), - ), - () => - Effect.gen(function* () { - yield* stop(flags()).pipe(Effect.provide(layer)); - expect( - warnings.some((m) => m.includes('WARN: disabling deprecated "slack" provider')), - ).toBe(true); - }), - (errorSpy) => - Effect.sync(() => { - errorSpy.mockRestore(); - }), - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect( "fails and never spawns docker when config.toml has an unsupported db.major_version", () => diff --git a/apps/cli/src/config/cli-config-contract.unit.test.ts b/apps/cli/src/config/cli-config-contract.unit.test.ts index 75055a3da4..14a73f092d 100644 --- a/apps/cli/src/config/cli-config-contract.unit.test.ts +++ b/apps/cli/src/config/cli-config-contract.unit.test.ts @@ -6,7 +6,6 @@ import { CLI_CONFIG_CODEC_OVERRIDES, CLI_CONFIG_CONTEXT_DEFAULTS, CLI_CONFIG_DOCUMENT_KEYS, - CLI_CONFIG_ENV_EXCLUDED, CLI_CONFIG_FAMILIES, CLI_CONFIG_FLAGS, CLI_CONFIG_LINKED_KEYS, @@ -341,7 +340,6 @@ describe("config key annotations", () => { const annotated: Readonly>> = { codecOverrides: Object.keys(CLI_CONFIG_CODEC_OVERRIDES), schemaExcluded: Object.keys(CLI_CONFIG_SCHEMA_EXCLUDED), - envExcluded: Object.keys(CLI_CONFIG_ENV_EXCLUDED), contextDefaults: Object.keys(CLI_CONFIG_CONTEXT_DEFAULTS), normalizers: Object.keys(CLI_CONFIG_NORMALIZERS), flags: Object.keys(CLI_CONFIG_FLAGS), diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index f4183202bb..05ecb501e0 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -59,9 +59,14 @@ export const CLI_CONFIG_SCHEMA_EXCLUDED: Readonly> = { "experimental.inspect.rules": "a list of tables, not strings; read from the document", }; -/** Schema leaves that are not env-overridable, with the reason. */ -export const CLI_CONFIG_ENV_EXCLUDED: Readonly> = { - "experimental.orioledb_version": "deprecated; promoted to db.orioledb_version before resolution", +/** Removed `SUPABASE_*` config overrides with the key and variable that replace them; a non-empty value fails config resolution. */ +export const CLI_CONFIG_REMOVED_ENV_NAMES: Readonly< + Record +> = { + SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION: { + path: "db.orioledb_version", + replacement: "SUPABASE_DB_ORIOLEDB_VERSION", + }, }; const emptyString = () => ""; diff --git a/apps/cli/src/config/cli-config-key.ts b/apps/cli/src/config/cli-config-key.ts index 9f2fd221e2..7135734cd5 100644 --- a/apps/cli/src/config/cli-config-key.ts +++ b/apps/cli/src/config/cli-config-key.ts @@ -379,6 +379,17 @@ export const describeCliConfigOrigin = ( } }; +/** Where an env name's value was set, for `set in …` messages: the shell, or the project env file relative to the workdir. */ +export const describeCliConfigEnvLocation = ( + sources: Pick, + ctx: Pick, + name: string, +): string => { + if (sources.shell(name) !== undefined) return "the shell environment"; + const file = sources.projectEnv(name)?.file; + return file === undefined ? "the project env file" : displayFile(ctx, file); +}; + interface CliConfigValueFailure { readonly path: string; readonly raw: string; diff --git a/apps/cli/src/config/cli-config-keys.ts b/apps/cli/src/config/cli-config-keys.ts index 4d6314f723..fcd3dd1726 100644 --- a/apps/cli/src/config/cli-config-keys.ts +++ b/apps/cli/src/config/cli-config-keys.ts @@ -5,7 +5,6 @@ import { CLI_CONFIG_CODEC_OVERRIDES, CLI_CONFIG_CONTEXT_DEFAULTS, CLI_CONFIG_DOCUMENT_KEYS, - CLI_CONFIG_ENV_EXCLUDED, CLI_CONFIG_FAMILIES, CLI_CONFIG_FLAGS, CLI_CONFIG_LINKED_KEYS, @@ -174,7 +173,6 @@ export const cliConfigSchemaKeyDefs = (root: SchemaAST.Objects): ReadonlyArray { expect(collisions).toEqual([]); }); + it("points every removed env name at its replacement key and no longer registers it", () => { + const removed = Object.entries(CLI_CONFIG_REMOVED_ENV_NAMES); + + expect(removed.length).toBeGreaterThan(0); + expect(removed.filter(([name]) => registryEnvNames.has(name))).toEqual([]); + expect( + removed.filter( + ([, { path, replacement }]) => !cliConfigRegistry.keyAt(path)?.env.includes(replacement), + ), + ).toEqual([]); + }); + it("accounts for every SUPABASE_* name the CLI source mentions outside the registry files", () => { expect(referencedEnvNames.size).toBeGreaterThan(25); const unaccounted = [...referencedEnvNames] diff --git a/apps/cli/src/config/cli-config-values.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts index 9bab5c64ef..ff08c4c965 100644 --- a/apps/cli/src/config/cli-config-values.integration.test.ts +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import type { CliConfig } from "@supabase/config"; import { loadCliConfig } from "@supabase/config/internal"; import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { vi } from "vitest"; import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { definedEnv } from "../../tests/helpers/config-env-pins.ts"; @@ -1742,43 +1741,140 @@ sql_paths = ["env(SEED_PATH)", 42] expect(resolvedConfig.loaded.config.db.seed.sql_paths).toEqual(["env(CHAIN_W)"]); }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), ); +}); - it.live("prints each deprecation warning once across targets and invalidation", () => { - const warnings: Array = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((arg) => String(arg)).join(" ")); - }); - return Effect.gen(function* () { +describe("CliConfigValues removed config", () => { + const load = (root: string, options: { readonly ignoreConfigFile?: true } = {}) => + CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none(), ...options }), + ); + + it.live("names every removed key and loads once the config is fixed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const root = yield* project( `[inbucket] enabled = true [auth.external.slack] -enabled = true -client_id = "a" -secret = "b" +enabled = false `, ); const layer = configValuesLayer(); - yield* Effect.gen(function* () { - const values = yield* CliConfigValues; - yield* values.load({ workdir: root, projectRef: Option.none() }); - yield* values.load({ workdir: root, projectRef: Option.some(LINKED) }); - yield* values.invalidate; - yield* values.load({ workdir: root, projectRef: Option.none() }); - }).pipe(Effect.provide(layer)); + const failure = yield* load(root).pipe(Effect.flip, Effect.provide(layer)); + yield* fs.writeFileString( + path.join(root, "supabase", "config.toml"), + "[local_smtp]\nenabled = true\n", + ); + const loaded = yield* load(root).pipe(Effect.provide(layer)); + + expect(failure).toMatchObject({ + _tag: "CliConfigRemovedKeyError", + removedKeys: [ + { key: "[inbucket]", replacement: "[local_smtp]" }, + { key: "[auth.external.slack]", replacement: "[auth.external.slack_oidc]" }, + ], + }); + expect(loaded.materialized.config.local_smtp.enabled).toBe(true); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("rejects a removed key in a [remotes.*] block no target selects", () => + Effect.gen(function* () { + const root = yield* project( + `[remotes.staging] +project_id = "${LINKED}" + +[remotes.staging.experimental] +orioledb_version = "15.1.0.150" +`, + ); + + const failure = yield* load(root).pipe(Effect.flip, Effect.provide(configValuesLayer())); - const warned = (needle: string) => warnings.filter((message) => message.includes(needle)); - expect(warned("config section [inbucket] is deprecated")).toHaveLength(1); - expect(warned('disabling deprecated "slack" provider')).toHaveLength(1); + expect(failure).toMatchObject({ + _tag: "CliConfigRemovedKeyError", + removedKeys: [ + { + key: "remotes.staging.experimental.orioledb_version", + replacement: "remotes.staging.db.orioledb_version", + }, + ], + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("drops an empty experimental.orioledb_version from the loaded raw document", () => + Effect.gen(function* () { + const root = yield* project('[experimental]\norioledb_version = ""\n'); + + const resolvedConfig = yield* load(root).pipe(Effect.provide(configValuesLayer())); + + expect(resolvedConfig.loaded.rawDocument?.["experimental"]).toEqual({}); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + const removedEnv = "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"; + + it.live.each([ + { + name: "the shell", + shell: { [removedEnv]: "15.1.0.150" }, + projectOptions: {}, + location: "the shell environment", + }, + { + name: "a project .env file", + shell: {}, + projectOptions: { supabaseEnv: `${removedEnv}=15.1.0.150\n` }, + location: "supabase/.env", + }, + ])("fails when $name sets the removed orioledb variable", ({ shell, projectOptions, location }) => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n', projectOptions); + + const failure = yield* load(root).pipe(Effect.flip, Effect.provide(configValuesLayer())); + + expect(failure).toMatchObject({ + _tag: "CliConfigRemovedEnvError", + envName: removedEnv, + message: `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION (set in ${location}) is no longer supported. Use SUPABASE_DB_ORIOLEDB_VERSION instead.`, + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell(shell, effect), Effect.scoped), + ); + + it.live("fails on the removed orioledb variable even when the config file is ignored", () => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n'); + + const failure = yield* load(root, { ignoreConfigFile: true }).pipe( + Effect.flip, + Effect.provide(configValuesLayer()), + ); + + expect(failure).toMatchObject({ _tag: "CliConfigRemovedEnvError", envName: removedEnv }); }).pipe( Effect.provide(BunServices.layer), - (effect) => withShell({}, effect), + (effect) => withShell({ [removedEnv]: "15.1.0.150" }, effect), Effect.scoped, - Effect.ensuring(Effect.sync(() => errorSpy.mockRestore())), - ); - }); + ), + ); + + it.live("treats an empty removed orioledb variable as unset", () => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n'); + + const resolvedConfig = yield* load(root).pipe(Effect.provide(configValuesLayer())); + + expect(resolvedConfig.hasConfigFile).toBe(true); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ [removedEnv]: "" }, effect), + Effect.scoped, + ), + ); }); describe("CliConfigValues over a lookup-only provider", () => { diff --git a/apps/cli/src/config/cli-config-values.layer.ts b/apps/cli/src/config/cli-config-values.layer.ts index e3809220a5..50021f73a6 100644 --- a/apps/cli/src/config/cli-config-values.layer.ts +++ b/apps/cli/src/config/cli-config-values.layer.ts @@ -8,7 +8,6 @@ import { } from "@supabase/config/internal"; import { Cache, - Console, Data, Duration, Effect, @@ -26,7 +25,7 @@ import { loadCliProjectEnvFiles, readShellEnvironment } from "../shared/config/c import { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { Output } from "../shared/output/output.service.ts"; -import { CLI_CONFIG_FAMILIES } from "./cli-config-key-annotations.ts"; +import { CLI_CONFIG_FAMILIES, CLI_CONFIG_REMOVED_ENV_NAMES } from "./cli-config-key-annotations.ts"; import { cloneDocumentRecord, collectEnvReferences, @@ -40,6 +39,7 @@ import { import { CliConfigFlagInputs, cliConfigFlagConflictError } from "./cli-config-flags.ts"; import { decodingFailedMessage, + describeCliConfigEnvLocation, describeCliConfigOrigin, lookupCliConfigEnv, pickCliConfigKey, @@ -62,7 +62,7 @@ import { type ResolvedCliConfig, type CliConfigWithheldEnv, } from "./cli-config-values.service.ts"; -import { CliConfigValueError } from "./cli-config.errors.ts"; +import { CliConfigRemovedEnvError, CliConfigValueError } from "./cli-config.errors.ts"; class LoadKey extends Data.Class<{ readonly workdir: string; @@ -208,20 +208,8 @@ export const cliConfigValuesLayer = Layer.effect( return output.warn(message); }; - const deprecationsSeen = new Set(); - const warnDeprecationOnce = (message: string) => { - if (deprecationsSeen.has(message)) return Effect.void; - deprecationsSeen.add(message); - return Console.error(message).pipe( - Effect.provideService(Console.Console, globalThis.console), - ); - }; - const parsedDocuments = yield* Cache.makeWith( - (workdir: string) => - withPlatform( - parseCliConfigDocumentFile(workdir, { search: false, onWarning: warnDeprecationOnce }), - ), + (workdir: string) => withPlatform(parseCliConfigDocumentFile(workdir, { search: false })), { capacity: 8, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), @@ -238,7 +226,12 @@ export const cliConfigValuesLayer = Layer.effect( const rawDocument = parsed?.rawDocument ?? {}; const shell = yield* readShellEnvironment({ - names: [...registryEnvNames, "DOTENV_PRIVATE_KEY", ...documentEnvNames(rawDocument)], + names: [ + ...registryEnvNames, + ...Object.keys(CLI_CONFIG_REMOVED_ENV_NAMES), + "DOTENV_PRIVATE_KEY", + ...documentEnvNames(rawDocument), + ], namesWithPrefix: ["DOTENV_PRIVATE_KEY"], }); const projectEnv = yield* withPlatform(loadCliProjectEnvFiles(target.workdir, { shell })); @@ -290,6 +283,19 @@ export const cliConfigValuesLayer = Layer.effect( const lookupEnv = (name: string) => lookupCliConfigEnv({ shell: shellFor, projectEnv: projectEnvFor }, name); + for (const [envName, { replacement }] of Object.entries(CLI_CONFIG_REMOVED_ENV_NAMES)) { + if (lookupEnv(envName) === undefined) continue; + return yield* new CliConfigRemovedEnvError({ + envName, + replacement, + location: describeCliConfigEnvLocation( + { shell: shellFor, projectEnv: projectEnvFor }, + { workdir: target.workdir, path }, + envName, + ), + }); + } + const remotes = parsed?.rawDocument?.["remotes"]; if (isDocumentRecord(remotes)) { const failure = cliConfigRemoteFailure(remotes, lookupEnv); @@ -465,7 +471,6 @@ export const cliConfigValuesLayer = Layer.effect( cliCompat: true, document: declaredDraft, resolvedPaths: declaredResolved, - onWarning: warnDeprecationOnce, }), ), declaredEnvOrigins, @@ -476,7 +481,6 @@ export const cliConfigValuesLayer = Layer.effect( cliCompat: true, document: fileDraft, resolvedPaths: fileResolved, - onWarning: warnDeprecationOnce, }), ).pipe(Effect.map((decoded) => withEnvOrigins(decoded, fileEnvOrigins))); const materializedLoaded = withEnvOrigins( @@ -486,7 +490,6 @@ export const cliConfigValuesLayer = Layer.effect( cliCompat: true, document: materializedDraft, resolvedPaths: materializedResolved, - onWarning: warnDeprecationOnce, }), ), materializedEnvOrigins, diff --git a/apps/cli/src/config/cli-config-values.service.ts b/apps/cli/src/config/cli-config-values.service.ts index bb17a0d189..6c9b63318b 100644 --- a/apps/cli/src/config/cli-config-values.service.ts +++ b/apps/cli/src/config/cli-config-values.service.ts @@ -12,7 +12,11 @@ import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors import type { CliConfigFamilyId } from "./cli-config-key-annotations.ts"; import type { CliConfigFlagDeclaration } from "./cli-config-flags.ts"; import type { CliConfigKey, CliConfigKeyOrigin, CliConfigValue } from "./cli-config-key.ts"; -import type { CliConfigFlagConflictError, CliConfigValueError } from "./cli-config.errors.ts"; +import type { + CliConfigFlagConflictError, + CliConfigRemovedEnvError, + CliConfigValueError, +} from "./cli-config.errors.ts"; interface CliConfigLoadTarget { readonly workdir: string; @@ -52,6 +56,7 @@ type CliConfigLoadFailure = | CliConfigPackageError | CliConfigLoadError | CliConfigValueError + | CliConfigRemovedEnvError | CliConfigFlagConflictError | ProjectRefReadError; diff --git a/apps/cli/src/config/cli-config.errors.ts b/apps/cli/src/config/cli-config.errors.ts index d828a84402..a1c94392ec 100644 --- a/apps/cli/src/config/cli-config.errors.ts +++ b/apps/cli/src/config/cli-config.errors.ts @@ -26,6 +26,21 @@ export class CliConfigValueError extends Data.TaggedError("CliConfigValueError") } } +/** A removed `SUPABASE_*` variable holds a value; `location` names the shell or project env file that set it. */ +export class CliConfigRemovedEnvError extends Data.TaggedError("CliConfigRemovedEnvError")<{ + readonly envName: string; + readonly replacement: string; + readonly location: string; +}> { + override get message(): string { + return `${this.envName} (set in ${this.location}) is no longer supported. Use ${this.replacement} instead.`; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** Two flags assigned different values to the same config key. */ export class CliConfigFlagConflictError extends Data.TaggedError("CliConfigFlagConflictError")<{ readonly path: string; diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 7333c02a82..9e8f1b1f91 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -468,8 +468,7 @@ export function classifyParseErrorConsoleOutput( * `Console.log`/`Console.error` writes are captured instead of reaching the real console, then * disposed of per `classifyParseErrorConsoleOutput`: dropped for a missing-required-flag failure, * replayed to stderr for other parse/validation failures, and replayed unchanged otherwise. Safe - * to wrap the whole call since no handler here writes through `Console` directly except - * `@supabase/config`'s `loadCliConfigFile`, which pins itself to the real console. + * to wrap the whole call since no handler here writes through `Console` directly. * * TODO: remove once https://github.com/Effect-TS/effect/issues/6313 is fixed upstream. */ diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index 2926d86327..ce7e18d4d0 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -51,7 +51,10 @@ export class FunctionImportNotDirectoryError extends Data.TaggedError( } } -export class FunctionDeployError extends Data.Error<{ readonly message: string }> {} +export class FunctionDeployError extends Data.Error<{ + readonly message: string; + readonly suggestion?: string; +}> {} export class FunctionImportMapSyntaxError extends Data.Error<{ readonly message: string }> { override readonly name = "SyntaxError"; diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 64dd94307c..c956981bbd 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2114,11 +2114,13 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs readonly rawConfigFunctions: Readonly>>>; readonly importMapOverride: Option.Option; readonly noVerifyJwtOverride: Option.Option; + /** Skips the fallback-import-map warning, for a resolve whose result is discarded. */ + readonly quiet?: boolean; }) { const output = yield* Output; const path = yield* Path.Path; const functionsDir = path.join(input.projectRoot, SUPABASE_FUNCTIONS_DIR); - const seenDeprecatedImportMap = new Set(); + const unsupportedImportMaps = new Map(); const seenFallbackImportMap = new Set(); const resolved: ResolvedDeployFunctionConfig[] = []; @@ -2176,20 +2178,23 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs const functionDir = path.dirname(entrypoint); const denoJson = path.join(functionDir, "deno.json"); const denoJsonc = path.join(functionDir, "deno.jsonc"); - const deprecatedImportMap = path.join(functionDir, "import_map.json"); + const unsupportedImportMap = path.join(functionDir, "import_map.json"); if (yield* isFile(denoJson)) { importMap = denoJson; } else if (yield* isFile(denoJsonc)) { importMap = denoJsonc; - } else if (yield* isFile(deprecatedImportMap)) { - importMap = deprecatedImportMap; - seenDeprecatedImportMap.add(slug); - } else if (fallbackExists) { - if (fallbackExists) { - importMap = fallbackImportMapPath; - seenFallbackImportMap.add(slug); + } else if ( + // An entrypoint directly under `functions/` shares its directory with the fallback map. + unsupportedImportMap !== path.resolve(fallbackImportMapPath) && + (yield* isFile(unsupportedImportMap)) + ) { + if (enabled) { + unsupportedImportMaps.set(slug, unsupportedImportMap); } + } else if (fallbackExists) { + importMap = fallbackImportMapPath; + seenFallbackImportMap.add(slug); } } } @@ -2209,14 +2214,17 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs }); } - if (seenDeprecatedImportMap.size > 0) { - yield* output.raw( - `WARNING: Functions using deprecated import_map.json (please migrate to deno.json): ${[...seenDeprecatedImportMap].join(", ")}\n`, - "stderr", - ); + const [firstUnsupported] = unsupportedImportMaps; + if (firstUnsupported !== undefined) { + const [slug, importMapPath] = firstUnsupported; + const configPath = path.relative(input.supabaseDir, importMapPath).split(path.sep).join("/"); + return yield* new FunctionDeployError({ + message: `import_map.json in a function directory is no longer supported: ${[...unsupportedImportMaps.keys()].join(", ")}`, + suggestion: `Rename it to deno.json, or set functions.${slug}.import_map = "./${configPath}" in config.toml to keep the file.`, + }); } - if (seenFallbackImportMap.size > 0) { + if (seenFallbackImportMap.size > 0 && input.quiet !== true) { yield* output.raw( `WARNING: Functions using fallback import map: ${[...seenFallbackImportMap].join(", ")}\n`, "stderr", diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2aaa1ae55c..d9046d8c66 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1489,6 +1489,7 @@ const resolveServeFunctionConfigs = Effect.fn("functions.serve.resolveFunctionCo importMapOverride: Option.Option, noVerifyJwtOverride: Option.Option, flagCwd: string, + quiet = false, ) { const slugs = yield* discoverFunctionSlugs(projectRoot, config.configDeclaredFunctions); return yield* resolveFunctionConfigs({ @@ -1501,6 +1502,7 @@ const resolveServeFunctionConfigs = Effect.fn("functions.serve.resolveFunctionCo rawConfigFunctions: config.rawConfigFunctions, importMapOverride, noVerifyJwtOverride, + quiet, }); }); @@ -1512,7 +1514,9 @@ const resolveServeFunctionConfigs = Effect.fn("functions.serve.resolveFunctionCo * * Logs `Skipped serving Function: ` unconditionally for every disabled * function, so the message double-prints when both Edge Runtime and Studio - * are enabled — established behavior, not a bug to dedupe. + * are enabled — established behavior, not a bug to dedupe. `quiet` skips + * that log and the fallback-import-map warning for a call whose binds are + * discarded. * * The returned set is not run through `pruneRedundantDockerBinds`: Studio's * bring-up never `docker cp`s into its container, and pruning is limited to @@ -1531,6 +1535,7 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin noVerifyJwtOverride: Option.Option, flagCwd: string, projectEnvValues?: Readonly>, + quiet = false, ) { const path = yield* Path.Path; const output = yield* Output; @@ -1541,6 +1546,7 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin importMapOverride, noVerifyJwtOverride, flagCwd, + quiet, ); const functionsDir = path.join(projectRoot, "supabase", "functions"); @@ -1549,7 +1555,9 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin for (const fnConfig of functionConfigs) { if (!fnConfig.enabled) { - yield* output.raw(`Skipped serving Function: ${fnConfig.slug}\n`, "stderr"); + if (!quiet) { + yield* output.raw(`Skipped serving Function: ${fnConfig.slug}\n`, "stderr"); + } continue; } diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index b15298f07c..49318c13e1 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -322,7 +322,7 @@ max_frequency = "5s" # Use an external OAuth provider. The full list of providers are: \`apple\`, \`azure\`, \`bitbucket\`, # \`discord\`, \`facebook\`, \`github\`, \`gitlab\`, \`google\`, \`keycloak\`, \`linkedin_oidc\`, \`notion\`, \`twitch\`, -# \`twitter\`, \`x\`, \`slack\`, \`spotify\`, \`workos\`, \`zoom\`. +# \`twitter\`, \`x\`, \`slack_oidc\`, \`spotify\`, \`workos\`, \`zoom\`. [auth.external.apple] enabled = false client_id = "" diff --git a/apps/cli/src/shared/init/testdata/templates/config.toml b/apps/cli/src/shared/init/testdata/templates/config.toml index e13cf910c6..a4a601fefd 100644 --- a/apps/cli/src/shared/init/testdata/templates/config.toml +++ b/apps/cli/src/shared/init/testdata/templates/config.toml @@ -320,7 +320,7 @@ max_frequency = "5s" # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +# `twitter`, `x`, `slack_oidc`, `spotify`, `workos`, `zoom`. [auth.external.apple] enabled = false client_id = "" diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 09374fe90c..e64f9c1eda 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -41,6 +41,8 @@ BranchesUpdateUnexpectedStatusError CliConfigFlagConflictError CliConfigLoadError CliConfigParseError +CliConfigRemovedEnvError +CliConfigRemovedKeyError CliConfigValueError CliConfigWriteError CliProjectEnvParseError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index c865d8c508..f220feaae0 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -908,6 +908,7 @@ const externalActionabilityByTag: Record = { // @supabase/config CliConfigParseError: () => actionability.invalidConfig, + CliConfigRemovedKeyError: () => actionability.invalidConfig, CliProjectEnvParseError: () => actionability.invalidConfig, DuplicateRemoteProjectIdError: () => actionability.invalidConfig, InvalidRemoteProjectIdError: () => actionability.invalidConfig, diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 7d0ccc63a8..daf87ada50 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -2420,10 +2420,6 @@ "type": "boolean", "description": "Use the new local stack backend for start, stop, and status, and for --local targets of the db, migration, test db, gen types, inspect, and pull command families." }, - "orioledb_version": { - "type": "string", - "description": "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB." - }, "s3_host": { "type": "string", "description": "S3 bucket URL.", @@ -4939,10 +4935,6 @@ "type": "boolean", "description": "Use the new local stack backend for start, stop, and status, and for --local targets of the db, migration, test db, gen types, inspect, and pull command families." }, - "orioledb_version": { - "type": "string", - "description": "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB." - }, "s3_host": { "type": "string", "description": "S3 bucket URL.", diff --git a/packages/config/README.md b/packages/config/README.md index e9443bb7dc..a9ca2087bd 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -185,6 +185,7 @@ Every runtime and type export of the pure `.` entrypoint, grouped by category: | Export | What it is | | --------------------------------------------------------------- | ----------------------------------------------------------------------- | | `CliConfigParseError` | A malformed `supabase/config.toml`/`.json`. | +| `CliConfigRemovedKeyError` | A config using removed keys, each named with its replacement. | | `CliProjectEnvParseError` | A malformed `.env`/`.env.local` file. | | `DuplicateRemoteProjectIdError` / `InvalidRemoteProjectIdError` | A `[remotes.*]` block problem. | | `ProjectConfigParseError` | A Management API v2 response, or a caller argument, that failed to map. | @@ -306,17 +307,21 @@ A rejected `loadCliConfig`, `loadCliConfigFile`, or `saveCliConfig` call from `. with any of: - `CliConfigParseError` — a malformed `supabase/config.toml`/`.json` +- `CliConfigRemovedKeyError` — a config using removed keys (`[inbucket]`, + `[auth.external.linkedin]`, `[auth.external.slack]`, a non-empty `experimental.orioledb_version`); + `removedKeys` lists every one with its replacement - `DuplicateRemoteProjectIdError` — two `[remotes.*]` blocks declare the same `project_id` - `InvalidRemoteProjectIdError` — a `[remotes.*]` block's `project_id` isn't a valid project ref - `CliProjectEnvParseError` — a malformed `.env`/`.env.local` file - `PlatformError` (from `effect/PlatformError`) — a host/OS failure surfaced by the underlying `FileSystem` service -The four package-owned classes are plain Effect `Data.TaggedError` classes; `PlatformError` is -Effect's own error class rather than one of this package's — but all five are classes, so a catch +The five package-owned classes are plain Effect `Data.TaggedError` classes; `PlatformError` is +Effect's own error class rather than one of this package's — but all six are classes, so a catch block can distinguish any of them with `instanceof`. What each carries: -- `DuplicateRemoteProjectIdError` and `InvalidRemoteProjectIdError` set a real `error.message`. +- `DuplicateRemoteProjectIdError`, `InvalidRemoteProjectIdError` and `CliConfigRemovedKeyError` set + a real `error.message`. - `PlatformError` sets `error.message` too, describing the failing filesystem operation, alongside `.module`/`.method`/`.description`. - `CliConfigParseError` and `CliProjectEnvParseError` carry structured fields instead of prose — diff --git a/packages/config/docs/cli-config-loading.md b/packages/config/docs/cli-config-loading.md index 403cbf1906..54f94454db 100644 --- a/packages/config/docs/cli-config-loading.md +++ b/packages/config/docs/cli-config-loading.md @@ -439,15 +439,33 @@ Load-time warnings print once per runtime. Outside the foundation files the CLI `[auth.email.smtp]` table that omits `enabled` as on, so a partial table, for example one holding only `pass = "env(SMTP_PASS)"`, still loads. +### Removed keys + +A config that declares a removed key fails to load with `CliConfigRemovedKeyError`, which names +every removed key in the file, `[remotes.*]` blocks included, with its replacement: + +| Removed | Replacement | +| --------------------------------------------------- | ------------------------------------------------------------- | +| `[inbucket]` | `[local_smtp]` | +| `[auth.external.linkedin]`, `[auth.external.slack]` | `[auth.external.linkedin_oidc]`, `[auth.external.slack_oidc]` | +| `experimental.orioledb_version` with a value | `db.orioledb_version` | + +A remote's keys are reported with their `remotes..` prefix. An empty +`experimental.orioledb_version = ""`, which older `supabase init` templates wrote, is ignored: it is +dropped from `rawDocument` before decoding, so it never appears as a value, origin or difference. +The CLI also rejects a non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` environment variable; use +`SUPABASE_DB_ORIOLEDB_VERSION`. + ### Pipeline stages from `./internal` `@supabase/config/internal` exposes the stages the CLI composes in place of `loadCliConfig`: -1. `parseCliConfigDocumentFile` reads `config.json` or `config.toml` without decoding. +1. `parseCliConfigDocumentFile` reads `config.json` or `config.toml` without decoding, and fails + with `CliConfigRemovedKeyError` when the document uses a removed key. 2. `mergeParsedCliConfig` applies the remote chosen by the `selectRemote` callback. 3. The CLI overlays flag, env and default values onto the merged document. -4. `decodeMergedCliConfig` expands `env()` references, strips deprecated external providers, then decodes and - validates the overlaid document. +4. `decodeMergedCliConfig` expands `env()` references, then decodes and validates the overlaid + document. The result is the `materialized` config on the resolved config; `get(key)` also reports the tier a value came from. diff --git a/packages/config/src/auth/providers.ts b/packages/config/src/auth/providers.ts index fd665fc3b5..9006f472f9 100644 --- a/packages/config/src/auth/providers.ts +++ b/packages/config/src/auth/providers.ts @@ -118,12 +118,7 @@ const provider = (providerConfig: { const defaultExternal = {}; -/** - * The deprecated `linkedin`/`slack` provider ids aren't modeled here — only - * their `_oidc` replacements (`linkedin_oidc`, `slack_oidc`) are. `io.ts`'s - * `normalizeDeprecatedExternalProviders` strips a config's `linkedin`/`slack` - * table (warning on stderr if it was enabled) before this schema ever sees it. - */ +/** OAuth providers. The removed `linkedin`/`slack` ids are rejected on load; use `linkedin_oidc`/`slack_oidc`. */ export const external = Schema.Struct({ apple: provider({ id: "apple", diff --git a/packages/config/src/cli-config.service.ts b/packages/config/src/cli-config.service.ts index 2820deadac..0de82502c9 100644 --- a/packages/config/src/cli-config.service.ts +++ b/packages/config/src/cli-config.service.ts @@ -8,6 +8,7 @@ import type { } from "./config-document.ts"; import type { CliConfigParseError, + CliConfigRemovedKeyError, CliProjectEnvParseError, DuplicateRemoteProjectIdError, InvalidRemoteProjectIdError, @@ -15,14 +16,15 @@ import type { /** * Every error a `load`/`loadFile`/`save` rejection can carry: this package's - * own tagged failures (a malformed config document, a duplicate or - * malformed `[remotes.*]` block, a malformed `.env`/`.env.local` file) plus + * own tagged failures (a malformed config document, a config document using removed + * keys, a duplicate or malformed `[remotes.*]` block, a malformed `.env`/`.env.local` file) plus * `PlatformError`, the single tagged wrapper Effect's `FileSystem` service * uses for every host/OS failure (`effect/PlatformError`). A Promise-based * consumer (`@supabase/config/io`) can distinguish these via `instanceof`. */ type CliConfigStoreError = | CliConfigParseError + | CliConfigRemovedKeyError | DuplicateRemoteProjectIdError | InvalidRemoteProjectIdError | CliProjectEnvParseError diff --git a/packages/config/src/config-document.ts b/packages/config/src/config-document.ts index 3ecfa29e1c..86465997c5 100644 --- a/packages/config/src/config-document.ts +++ b/packages/config/src/config-document.ts @@ -34,7 +34,8 @@ export interface LoadedCliConfig { */ readonly document?: Record; /** - * The raw document as parsed from disk: pre-`env()`-interpolation, pre-`[remotes.*]`-merge + * The raw document as parsed from disk, minus an empty `experimental.orioledb_version`: + * pre-`env()`-interpolation, pre-`[remotes.*]`-merge * (unlike {@link document}, whose `remotes` key has already been merged/stripped). Callers * matching a `[remotes.*]` block by its literal `project_id` must use this, not `document`. * Present whenever the file parsed to an object. @@ -59,13 +60,6 @@ export interface LoadedCliConfig { * `undefined` when no `projectRef` was requested or none matched. */ readonly appliedRemote?: string; - /** - * The `auth.external.{linkedin,slack}` sub-objects stripped from {@link document} before it - * was returned, keyed by provider id (empty when neither was present). A caller scanning - * `document` on its own may need to fold this back in, since `document` alone under-reports - * what was originally there. Present whenever {@link document} is. - */ - readonly removedDeprecatedExternalProviders?: Readonly>; /** The source that supplied each explicitly configured effective leaf value. */ readonly valueOrigins?: ReadonlyArray; } @@ -109,9 +103,9 @@ export interface InternalLoadCliConfigOptions extends LoadCliConfigOptions { /** * Opts into the Supabase CLI's decode and validation semantics: duplicate-`project_id` and * project-ref-format checks run across every `[remotes.*]` block even without a `projectRef`, - * deprecated `auth.external.{linkedin,slack}` blocks warn on stderr, `env(...)` matching is - * case-agnostic, and comma-separated strings coerce into array-typed fields regardless of - * origin. Defaults to `false`, the strict behaviour of the public `@supabase/config` API. + * `env(...)` matching is case-agnostic, and comma-separated strings coerce into array-typed + * fields regardless of origin. Defaults to `false`, the strict behaviour of the public + * `@supabase/config` API. */ readonly cliCompat?: boolean; } diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 354fc244ec..daf0365e90 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -370,6 +370,7 @@ describe("src/index.ts export surface", () => { [ "CLI_CONFIG_SCHEMA_URL", "CliConfigParseError", + "CliConfigRemovedKeyError", "CliConfigSchema", "CliProjectEnvParseError", "DuplicateRemoteProjectIdError", @@ -409,6 +410,7 @@ describe("src/effect.ts is a superset of src/index.ts", () => { [ "CLI_CONFIG_SCHEMA_URL", "CliConfigParseError", + "CliConfigRemovedKeyError", "CliConfigSchema", "CliConfigStore", "CliProjectEnvParseError", @@ -498,7 +500,6 @@ describe("src/internal.ts export surface", () => { "dualScopeProjectConfigPaths", "loadCliConfig", "mergeParsedCliConfig", - "normalizeDeprecatedOrioleDBVersion", "parseCliConfigDocumentFile", "projectConfigApiBlockKeys", "projectConfigMappingRows", diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index 95b0903806..252f763a00 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -18,6 +18,25 @@ export class CliConfigParseError extends Data.TaggedError("CliConfigParseError") readonly appliedRemote?: string; }> {} +/** A config document uses keys the config format no longer accepts; `removedKeys` lists every one with its replacement. */ +export class CliConfigRemovedKeyError extends Data.TaggedError("CliConfigRemovedKeyError")<{ + readonly path: string; + readonly format: ConfigFormat; + readonly removedKeys: ReadonlyArray<{ readonly key: string; readonly replacement: string }>; +}> { + override get message(): string { + const file = this.path.split(/[\\/]/).slice(-2).join("/"); + const [only, ...rest] = this.removedKeys; + if (only !== undefined && rest.length === 0) { + return `${only.key} in ${file} is no longer supported. Use ${only.replacement} instead.`; + } + return [ + `${file} uses config keys that are no longer supported:`, + ...this.removedKeys.map(({ key, replacement }) => ` ${key}: use ${replacement} instead.`), + ].join("\n"); + } +} + const PROJECT_CONFIG_PARSE_ERROR_MESSAGE_PREFIX = "Could not read the project config from the Management API response"; diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 406fc35da0..6123d38c9e 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -45,13 +45,6 @@ export const experimental = Schema.Struct({ tags, }), ), - orioledb_version: Schema.optionalKey( - Schema.String.annotate({ - description: - "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB.", - tags, - }), - ), s3_host: Schema.optionalKey( Schema.String.annotate({ description: "S3 bucket URL.", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 142944fb58..8ee4be4002 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -14,6 +14,7 @@ export { } from "./base.ts"; export { CliConfigParseError, + CliConfigRemovedKeyError, CliProjectEnvParseError, DuplicateRemoteProjectIdError, InvalidRemoteProjectIdError, diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 85d9b0b3d3..0a69a07379 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -38,6 +38,5 @@ export { writeCliConfigDocumentText, decodeCliConfigDocumentForValidationEffect, type DecodeCliConfigDocumentForValidationEffectOptions, - normalizeDeprecatedOrioleDBVersion, } from "./io.ts"; export { CliConfigWriteError } from "./errors.ts"; diff --git a/packages/config/src/io.stages.unit.test.ts b/packages/config/src/io.stages.unit.test.ts index 773d2a921d..9068fff84b 100644 --- a/packages/config/src/io.stages.unit.test.ts +++ b/packages/config/src/io.stages.unit.test.ts @@ -1,5 +1,5 @@ import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { Effect, FileSystem, Path } from "effect"; import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; @@ -160,31 +160,24 @@ describe("config pipeline stages", () => { expect(decoded?.config.auth.site_url).toBe("env(SITE)"); }); - test("onWarning receives each deprecation warning instead of the real console", async () => { + test("the parse stage rejects removed keys, including in a remote no stage selects", async () => { const cwd = await makeProject( - '[inbucket]\nenabled = true\n[auth.external.slack]\nenabled = true\nclient_id = "a"\nsecret = "b"\n', + `[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.inbucket] +enabled = true +`, ); - const warnings: Array = []; - const onWarning = (message: string) => Effect.sync(() => void warnings.push(message)); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - await run( - Effect.gen(function* () { - const parsed = yield* parseCliConfigDocumentFile(cwd, { search: false, onWarning }); - if (parsed === null) return; - const merged = yield* mergeParsedCliConfig(parsed, { selectRemote: () => undefined }); - yield* decodeMergedCliConfig(merged, { envValues: {}, cliCompat: true, onWarning }); - }), - ); - - expect(warnings).toEqual([ - "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", - 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', - ]); - expect(errorSpy).not.toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - } + + const failure = await run(Effect.flip(parseCliConfigDocumentFile(cwd, { search: false }))); + + expect(failure).toMatchObject({ + _tag: "CliConfigRemovedKeyError", + removedKeys: [ + { key: "[remotes.staging.inbucket]", replacement: "[remotes.staging.local_smtp]" }, + ], + }); }); }); diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index fefdc66a0f..a601a34410 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { Console, Effect, FileSystem, Path, Predicate, Redacted } from "effect"; +import { Effect, FileSystem, Path, Predicate, Redacted } from "effect"; import * as SmolToml from "smol-toml"; import { CliConfigSchema, type CliConfig } from "./base.ts"; import { @@ -21,6 +21,7 @@ import { } from "./errors.ts"; import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts"; import { findCliProjectPaths } from "./paths.ts"; +import { rejectRemovedConfigKeys } from "./removed-config-keys.ts"; import { setOwnProperty } from "./sparse.ts"; import { loadCliProjectEnvironment } from "./project.ts"; import { validateCliConfig } from "./validate.ts"; @@ -219,206 +220,6 @@ function parseCliConfigDocument(content: string, format: ConfigFormat): unknown return format === "json" ? JSON.parse(content) : SmolToml.parse(content); } -interface NormalizedSMTPDocument { - readonly document: unknown; - /** Section paths that used the deprecated `inbucket` key, e.g. `inbucket`, `remotes.staging.inbucket`. */ - readonly deprecatedSections: ReadonlyArray; -} - -/** - * Rewrites the deprecated `[inbucket]` config section (top-level and per `[remotes.*]`) to its - * preferred `[local_smtp]` name; when both are present, the explicit `local_smtp` wins. - */ -function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocument { - if (!isObject(document)) { - return { document, deprecatedSections: [] }; - } - const deprecatedSections: Array = []; - const normalized = { ...document }; - if ("inbucket" in normalized) { - deprecatedSections.push("inbucket"); - if (!("local_smtp" in normalized)) { - normalized.local_smtp = normalized.inbucket; - } - delete normalized.inbucket; - } - if (isObject(normalized.remotes)) { - normalized.remotes = Object.fromEntries( - Object.entries(normalized.remotes).map(([name, remote]) => { - if (!isObject(remote) || !("inbucket" in remote)) { - return [name, remote]; - } - deprecatedSections.push(`remotes.${name}.inbucket`); - const normalizedRemote = { ...remote }; - if (!("local_smtp" in normalizedRemote)) { - normalizedRemote.local_smtp = normalizedRemote.inbucket; - } - delete normalizedRemote.inbucket; - return [name, normalizedRemote]; - }), - ); - } - return { document: normalized, deprecatedSections }; -} - -export interface NormalizedOrioleDBVersionDocument { - readonly document: unknown; - /** - * Dotted paths whose legacy `experimental.orioledb_version` was non-empty, e.g. - * `experimental.orioledb_version` or `remotes.staging.experimental.orioledb_version`. Emitted - * whether or not the value was actually promoted (an explicit `db.orioledb_version` still - * wins, but the legacy key is deprecated either way). - */ - readonly deprecatedPaths: ReadonlyArray; -} - -/** - * Moves a section's string `experimental.orioledb_version` out of `experimental`, promoting a - * non-empty value to `db.orioledb_version` when that is absent or `""` (the init template always - * writes `""`). Non-string values, a non-empty `db.orioledb_version`, and a non-table `db` are - * left untouched so schema validation still sees them. - */ -function promoteOrioleDBVersion(section: Record): { - readonly section: Record; - readonly warned: boolean; -} { - const experimental = section.experimental; - if (!isObject(experimental) || !("orioledb_version" in experimental)) { - return { section, warned: false }; - } - const legacyValue = experimental.orioledb_version; - if (typeof legacyValue !== "string") { - return { section, warned: false }; - } - const legacyNonEmpty = legacyValue.length > 0; - - const normalizedExperimental = { ...experimental }; - delete normalizedExperimental.orioledb_version; - const normalizedSection: Record = { - ...section, - experimental: normalizedExperimental, - }; - - if (!legacyNonEmpty) { - return { section: normalizedSection, warned: false }; - } - if ("db" in section && !isObject(section.db)) { - return { section: normalizedSection, warned: true }; - } - - const db = isObject(section.db) ? section.db : undefined; - const dbValue = db?.orioledb_version; - const dbCanBePromotedOver = dbValue === undefined || dbValue === ""; - if (dbCanBePromotedOver) { - normalizedSection.db = { ...db, orioledb_version: legacyValue }; - } - - return { section: normalizedSection, warned: true }; -} - -/** - * Rewrites the deprecated `experimental.orioledb_version` (top-level and per `[remotes.*]`) to - * `db.orioledb_version`, following the same shape as {@link normalizeDeprecatedSMTPSections}. - * Exposed via `@supabase/config/internal` so `apps/cli`'s raw TOML reader - * (`db-config.toml-read.ts`) can apply the same precedence before its own `[remotes.*]` merge. - */ -export function normalizeDeprecatedOrioleDBVersion( - document: unknown, -): NormalizedOrioleDBVersionDocument { - if (!isObject(document)) { - return { document, deprecatedPaths: [] }; - } - const deprecatedPaths: Array = []; - let normalized: Record = { ...document }; - - const top = promoteOrioleDBVersion(normalized); - normalized = top.section; - if (top.warned) { - deprecatedPaths.push("experimental.orioledb_version"); - } - - if (isObject(normalized.remotes)) { - normalized = { - ...normalized, - remotes: Object.fromEntries( - Object.entries(normalized.remotes).map(([name, remote]) => { - if (!isObject(remote)) { - return [name, remote]; - } - const result = promoteOrioleDBVersion(remote); - if (result.warned) { - deprecatedPaths.push(`remotes.${name}.experimental.orioledb_version`); - } - return [name, result.section]; - }), - ), - }; - } - - return { document: normalized, deprecatedPaths }; -} - -interface NormalizedExternalProvidersDocument { - readonly document: unknown; - /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled`. */ - readonly deprecatedProviders: ReadonlyArray; - /** - * The removed top-level `auth.external.{linkedin,slack}` sub-objects (provider id → the - * removed object), regardless of `enabled`. A caller checking these for an `encrypted:` - * secret against the already-stripped {@link LoadedCliConfig.document} can fold this back in. - * Only the top-level blocks are captured, not any surviving `remotes.*.auth.external.*`. - */ - readonly removedProviders: Readonly>; -} - -const DEPRECATED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; - -/** - * Strips the deprecated `auth.external.{linkedin,slack}` providers, unconditionally, reporting - * one only when it was `enabled`. Runs on the post-remote-merge document, since only the final - * merged config's `auth.external` matters. Also strips (without reporting) any surviving - * `remotes.*.auth.external.{linkedin,slack}`, purely so an unselected remote's deprecated block - * doesn't get rejected by this package's eager, whole-map schema decode. - */ -function normalizeDeprecatedExternalProviders( - document: unknown, -): NormalizedExternalProvidersDocument { - if (!isObject(document)) { - return { document, deprecatedProviders: [], removedProviders: {} }; - } - const normalized = { ...document }; - const deprecatedProviders: Array = []; - const removedProviders: Record = {}; - if (isObject(normalized.auth) && isObject(normalized.auth.external)) { - const external = { ...normalized.auth.external }; - for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { - const provider = external[ext]; - if (provider === undefined) continue; - removedProviders[ext] = provider; - if (isObject(provider) && provider.enabled === true) { - deprecatedProviders.push(ext); - } - delete external[ext]; - } - normalized.auth = { ...normalized.auth, external }; - } - if (isObject(normalized.remotes)) { - normalized.remotes = Object.fromEntries( - Object.entries(normalized.remotes).map(([name, remote]) => { - if (!isObject(remote) || !isObject(remote.auth) || !isObject(remote.auth.external)) { - return [name, remote]; - } - const external = { ...remote.auth.external }; - for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { - delete external[ext]; - } - return [name, { ...remote, auth: { ...remote.auth, external } }]; - }), - ); - } - return { document: normalized, deprecatedProviders, removedProviders }; -} - /** * Wraps every `edge_runtime.secrets` value in `Redacted` before it's attached to * `CliConfigParseError.document`, so an uncaught parse error can't leak a resolved secret into @@ -544,8 +345,9 @@ export const decodeCliConfigDocumentForValidationEffect = Effect.fn( }); const cliCompat = options.cliCompat ?? false; + const withoutTolerated = yield* rejectRemovedConfigKeys(document, options.path, options.format); const { document: documentForDecode, appliedRemote } = mergeSelectedRemoteForValidation( - document, + withoutTolerated, options.remoteName, ); @@ -555,8 +357,7 @@ export const decodeCliConfigDocumentForValidationEffect = Effect.fn( CliConfigSchema, { cliCompat }, ); - const { document: normalizedForDecode } = normalizeDeprecatedExternalProviders(interpolated); - return yield* parseCliConfig(normalizedForDecode, options.format, options.path, appliedRemote); + return yield* parseCliConfig(interpolated, options.format, options.path, appliedRemote); }); export const configJsonPath = Effect.fnUntraced(function* (cwd: string) { @@ -571,14 +372,7 @@ export const configTomlPath = Effect.fnUntraced(function* (cwd: string) { return configTomlPathWith(path, project?.projectRoot ?? cwd); }); -/** Writes to the real console so a caller's deferred or buffered `Console` cannot delay or swallow it. */ -const warnOnRealConsole = (message: string): Effect.Effect => - Console.error(message).pipe(Effect.provideService(Console.Console, globalThis.console)); - -const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* ( - filePath: string, - onWarning: (message: string) => Effect.Effect = warnOnRealConsole, -) { +const readCliConfigDocumentFile = Effect.fnUntraced(function* (filePath: string) { const fs = yield* FileSystem.FileSystem; const format = filePath.endsWith(".json") ? "json" : "toml"; const content = yield* fs.readFileString(filePath); @@ -586,20 +380,14 @@ const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* ( try: () => parseCliConfigDocument(content, format), catch: (cause) => new CliConfigParseError({ path: filePath, format, cause }), }); - const { document: smtpNormalized, deprecatedSections } = - normalizeDeprecatedSMTPSections(document); - const { document: normalized, deprecatedPaths: deprecatedOrioleDBPaths } = - normalizeDeprecatedOrioleDBVersion(smtpNormalized); - for (const section of deprecatedSections) { - const replacement = section.replace(/inbucket$/, "local_smtp"); - yield* onWarning( - `WARN: config section [${section}] is deprecated. Please use [${replacement}] instead.`, - ); - } - for (const path of deprecatedOrioleDBPaths) { - const replacement = path.replace(/experimental\.orioledb_version$/, "db.orioledb_version"); - yield* onWarning(`WARN: ${path} is deprecated. Please use ${replacement} instead.`); - } + return { format, content, document } as const; +}); + +const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* (filePath: string) { + const { format, content, document } = yield* readCliConfigDocumentFile(filePath); + const normalized = isObject(document) + ? yield* rejectRemovedConfigKeys(document, filePath, format) + : document; return { format, content, document, normalized } as const; }); @@ -614,7 +402,7 @@ export interface MergedCliConfigDocument { readonly rawText: string; readonly schemaRef: string | undefined; readonly ignoredPaths: ReadonlyArray; - /** The document as parsed and deprecation-normalized, before any `[remotes.*]` merge. */ + /** The document as parsed, before any `[remotes.*]` merge. */ readonly rawDocument: Record | undefined; /** The merged document; `remotes` is stripped when a block matched. */ readonly document: unknown; @@ -664,16 +452,14 @@ const mergeRemoteForLoad = ( /** * Not covered by semver — exported from `@supabase/config/internal` only. Stage two of the - * pipeline: interpolates `env()` references against `options.envValues`, strips the deprecated - * external providers, and decodes + validates. `options.document` replaces the merged document, - * so a caller can decode a document it has overlaid with effective values. + * pipeline: interpolates `env()` references against `options.envValues`, then decodes + + * validates. `options.document` replaces the merged document, so a caller can decode a document + * it has overlaid with effective values. */ export interface DecodeMergedCliConfigOptions { readonly envValues: Readonly>; readonly cliCompat?: boolean; readonly document?: Record; - /** Receives each deprecation warning; defaults to writing it to the real console. */ - readonly onWarning?: (message: string) => Effect.Effect; /** Paths whose values are already resolved: the decode keeps them verbatim instead of expanding `env()` again. */ readonly resolvedPaths?: ReadonlyArray>; } @@ -683,7 +469,6 @@ export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(functio options: DecodeMergedCliConfigOptions, ) { const cliCompat = options.cliCompat ?? false; - const onWarning = options.onWarning ?? warnOnRealConsole; const resolvedPathKeys = new Set((options.resolvedPaths ?? []).map(pathKey)); const interpolateDocument = ( document: unknown, @@ -725,23 +510,8 @@ export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(functio ) : documentToDecode; - // Strip the deprecated `auth.external.{linkedin,slack}` provider ids from the post-remote-merge - // document (see `normalizeDeprecatedExternalProviders`). - const { - document: normalizedForDecode, - deprecatedProviders, - removedProviders, - } = normalizeDeprecatedExternalProviders(documentForDecode); - if (cliCompat) { - for (const ext of deprecatedProviders) { - yield* onWarning( - `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, - ); - } - } - const config = yield* parseCliConfig( - normalizedForDecode, + documentForDecode, merged.format, merged.path, merged.appliedRemote, @@ -750,8 +520,8 @@ export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(functio const localPathKeys = new Set(collectLeafPaths(merged.rawDocument).map(pathKey)); const remotePathKeys = new Set(merged.remoteLeafPaths.map(pathKey)); const environmentPathKeys = new Set(resolvedEnvironmentPaths.map(pathKey)); - const valueOrigins = isObject(normalizedForDecode) - ? collectLeafPaths(normalizedForDecode).flatMap((path) => { + const valueOrigins = isObject(documentForDecode) + ? collectLeafPaths(documentForDecode).flatMap((path) => { const key = pathKey(path); const source: CliConfigValueSource | undefined = environmentPathKeys.has(key) ? "environment" @@ -776,11 +546,10 @@ export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(functio schemaRef: merged.schemaRef, ignoredPaths: merged.ignoredPaths, rawText: merged.rawText, - document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, + document: isObject(documentForDecode) ? documentForDecode : undefined, rawDocument: merged.rawDocument, interpolatedRemotes, appliedRemote: merged.appliedRemote, - removedDeprecatedExternalProviders: removedProviders, valueOrigins, } satisfies LoadedCliConfig; }); @@ -894,7 +663,7 @@ export const loadCliConfig = Effect.fn("CliConfig.load")(function* ( /** * Not covered by semver — exported from `@supabase/config/internal` only. The output of the parse - * half of stage one: the config file read and deprecation-normalized, before any `[remotes.*]` + * half of stage one: the config file read and checked for removed keys, before any `[remotes.*]` * merge. */ export interface ParsedCliConfigDocument { @@ -917,7 +686,6 @@ export const parseCliConfigDocumentFile = Effect.fn("CliConfig.parseDocument")(f options?: { readonly search?: boolean; readonly tomlOnly?: boolean; - readonly onWarning?: (message: string) => Effect.Effect; }, ) { const located = yield* locateCliConfigFile(cwd, options); @@ -928,7 +696,6 @@ export const parseCliConfigDocumentFile = Effect.fn("CliConfig.parseDocument")(f const { format, content, document, normalized } = yield* readAndNormalizeCliConfigFile( located.filePath, - options?.onWarning, ); return { @@ -1015,15 +782,27 @@ const writeFileAtomic = Effect.fnUntraced(function* (filePath: string, content: }).pipe(Effect.ensuring(fs.remove(tmpPath).pipe(Effect.ignore))); }); +/** Reads `$schema` from the existing file without decoding it, so a legacy document can be overwritten. */ +const readExistingSchemaRef = Effect.fnUntraced(function* (cwd: string) { + const located = yield* locateCliConfigFile(cwd, undefined); + if (located === null) { + return undefined; + } + const { document } = yield* readCliConfigDocumentFile(located.filePath); + return getSchemaRef(document); +}); + export const saveCliConfig = Effect.fn("CliConfig.save")(function* (options: SaveCliConfigOptions) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const project = yield* findCliProjectPaths(options.cwd); const baseCwd = project?.projectRoot ?? options.cwd; const format = yield* resolveSaveFormat(baseCwd, options.format); - const existingConfig = - options.schemaRef !== undefined || project === null ? null : yield* loadCliConfig(baseCwd); - const schemaRef = options.schemaRef ?? existingConfig?.schemaRef; + const existingSchemaRef = + options.schemaRef !== undefined || project === null + ? undefined + : yield* readExistingSchemaRef(baseCwd); + const schemaRef = options.schemaRef ?? existingSchemaRef; const filePath = format === "json" ? configJsonPathWith(path, baseCwd) : configTomlPathWith(path, baseCwd); const siblingPath = siblingConfigPathWith(path, baseCwd, format); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 981cf95cb1..29810ec4c1 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -27,7 +27,6 @@ import { cliConfigValueSourceAt, isObject, type LoadedCliConfig, - type InternalLoadCliConfigOptions, } from "./config-document.ts"; import { configJsonPath, @@ -1396,33 +1395,6 @@ additional_redirect_urls = "http://a,http://b" } }); - test("does not warn on a deprecated provider (but still strips it) when cliCompat is omitted", async () => { - const cwd = makeTempProject(); - const warnings: Array = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), - `project_id = "abc123" - -[auth.external.slack] -enabled = true -`, - ); - - const loaded = await runConfigEffect(loadCliConfig(cwd)); - expect("slack" in loaded!.config.auth.external).toBe(false); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - } finally { - errorSpy.mockRestore(); - await rm(cwd, { recursive: true, force: true }); - } - }); - test("does not resolve a lowercase-named env() reference when cliCompat is omitted", async () => { const previous = process.env.lowercase_ref_default_off_test; process.env.lowercase_ref_default_off_test = "lowercase-ref-value"; @@ -2278,454 +2250,212 @@ major_version = "not-a-number" }); }); -describe("config io deprecated [inbucket] back-compat", () => { - let warnings: Array = []; - let errorSpy: MockInstance | undefined; - - function captureWarnings() { - warnings = []; - // loadCliConfigFile emits the deprecation warning via Console.error, whose - // default implementation delegates to globalThis.console.error (stderr). - errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }); +describe("config io removed keys", () => { + async function writeToml(cwd: string, contents: string) { + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(path, contents); + return path; } - afterEach(() => { - errorSpy?.mockRestore(); - errorSpy = undefined; - }); - async function loadToml(contents: string) { const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(path, contents); try { - return await runConfigEffect(loadCliConfigFile(path)); + return await runConfigEffect(loadCliConfigFile(await writeToml(cwd, contents))); } finally { await rm(cwd, { recursive: true, force: true }); } } - test("loads a deprecated [inbucket] section as [local_smtp]", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[inbucket] -enabled = true -port = 12345 -`, - ); - - expect(loaded.config.local_smtp.enabled).toBe(true); - expect(loaded.config.local_smtp.port).toBe(12345); - expect("inbucket" in loaded.config).toBe(false); - expect(loaded.document).not.toHaveProperty("inbucket"); - expect(loaded.document).toHaveProperty("local_smtp"); - expect( - warnings.some((m) => - m.includes( - "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", - ), - ), - ).toBe(true); - }); - - test("fills schema defaults when a deprecated [inbucket] section is partial", async () => { - const loaded = await loadToml( - `project_id = "abc123" - -[inbucket] -port = 9999 -`, - ); - - // enabled is omitted by the user; the schema default (true) must survive the - // inbucket -> local_smtp rewrite rather than collapsing to a zero value. - expect(loaded.config.local_smtp.enabled).toBe(true); - expect(loaded.config.local_smtp.port).toBe(9999); - }); - - test("prefers an explicit [local_smtp] when both sections are present", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[inbucket] -enabled = true -port = 11111 - -[local_smtp] -enabled = true -port = 22222 -`, - ); - - expect(loaded.config.local_smtp.port).toBe(22222); - expect(loaded.document).not.toHaveProperty("inbucket"); - // The deprecation warning still fires because the deprecated key was present. - expect(warnings.some((m) => m.includes("[inbucket] is deprecated"))).toBe(true); - }); - - test("normalizes a deprecated [remotes.*.inbucket] section", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[remotes.staging] -project_id = "stagingrefaaaaaaaaaa" - -[remotes.staging.inbucket] -enabled = true -port = 33333 -`, - ); - - const staging = loaded.config.remotes.staging; - expect(staging?.local_smtp?.port).toBe(33333); - expect(staging).not.toHaveProperty("inbucket"); - expect( - warnings.some((m) => - m.includes( - "WARN: config section [remotes.staging.inbucket] is deprecated. Please use [remotes.staging.local_smtp] instead.", - ), - ), - ).toBe(true); - }); - - test("does not warn when only [local_smtp] is used", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[local_smtp] -enabled = true -port = 54324 -`, - ); - - expect(loaded.config.local_smtp.port).toBe(54324); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); -}); - -describe("config io deprecated experimental.orioledb_version back-compat", () => { - let warnings: Array = []; - let errorSpy: MockInstance | undefined; - - function captureWarnings() { - warnings = []; - errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }); - } - - afterEach(() => { - errorSpy?.mockRestore(); - errorSpy = undefined; - }); - - async function loadToml(contents: string) { + async function loadTomlFailure(contents: string) { const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(path, contents); try { - return await runConfigEffect(loadCliConfigFile(path)); + const error = await runConfigEffect( + Effect.flip(loadCliConfigFile(await writeToml(cwd, contents))), + ); + if (error._tag !== "CliConfigRemovedKeyError") { + throw error; + } + return error; } finally { await rm(cwd, { recursive: true, force: true }); } } - test("promotes a non-empty legacy experimental.orioledb_version to db.orioledb_version", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + const stagingRemote = `[remotes.staging] +project_id = "stagingrefaaaaaaaaaa" +`; -[experimental] -orioledb_version = "15.1.0.150" -`, - ); + test.each([ + { + name: "[inbucket] whatever its contents", + toml: "[inbucket]\nenabled = false\n", + key: "[inbucket]", + replacement: "[local_smtp]", + }, + { + name: "[remotes.*.inbucket]", + toml: `${stagingRemote}\n[remotes.staging.inbucket]\nport = 33333\n`, + key: "[remotes.staging.inbucket]", + replacement: "[remotes.staging.local_smtp]", + }, + { + name: "a non-empty experimental.orioledb_version", + toml: '[experimental]\norioledb_version = "15.1.0.150"\n', + key: "experimental.orioledb_version", + replacement: "db.orioledb_version", + }, + { + name: "a non-string experimental.orioledb_version", + toml: "[experimental]\norioledb_version = 1\n", + key: "experimental.orioledb_version", + replacement: "db.orioledb_version", + }, + { + name: "a remote's non-empty experimental.orioledb_version", + toml: `${stagingRemote}\n[remotes.staging.experimental]\norioledb_version = "15.1.0.150"\n`, + key: "remotes.staging.experimental.orioledb_version", + replacement: "remotes.staging.db.orioledb_version", + }, + { + name: "an enabled [auth.external.slack]", + toml: "[auth.external.slack]\nenabled = true\n", + key: "[auth.external.slack]", + replacement: "[auth.external.slack_oidc]", + }, + { + name: "a disabled [auth.external.linkedin]", + toml: "[auth.external.linkedin]\nenabled = false\n", + key: "[auth.external.linkedin]", + replacement: "[auth.external.linkedin_oidc]", + }, + { + name: "a remote's [auth.external.linkedin]", + toml: `${stagingRemote}\n[remotes.staging.auth.external.linkedin]\nenabled = true\n`, + key: "[remotes.staging.auth.external.linkedin]", + replacement: "[remotes.staging.auth.external.linkedin_oidc]", + }, + ])("rejects $name", async ({ toml, key, replacement }) => { + const error = await loadTomlFailure(`project_id = "abc123"\n\n${toml}`); - expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); - expect(loaded.config.experimental.orioledb_version).toBeUndefined(); - expect(loaded.document).not.toHaveProperty("experimental.orioledb_version"); - expect( - warnings.some((m) => - m.includes( - "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", - ), - ), - ).toBe(true); + expect(error.removedKeys).toEqual([{ key, replacement }]); }); - test("does not warn and stays absent when the legacy value is an empty string", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + test("names the key, the file and the replacement", async () => { + const error = await loadTomlFailure('project_id = "abc123"\n\n[inbucket]\nenabled = true\n'); -[experimental] -orioledb_version = "" -`, + expect(error.message).toBe( + "[inbucket] in supabase/config.toml is no longer supported. Use [local_smtp] instead.", ); - - expect(loaded.config.db.orioledb_version).toBeUndefined(); - expect(loaded.config.experimental.orioledb_version).toBeUndefined(); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); }); - test("promotes a non-empty legacy value when db.orioledb_version is present but empty", async () => { - captureWarnings(); - const loaded = await loadToml( + test("reports every removed key in one error, the top level before each remote", async () => { + const error = await loadTomlFailure( `project_id = "abc123" -[db] -orioledb_version = "" +[inbucket] +enabled = true [experimental] orioledb_version = "15.1.0.150" -`, - ); - - expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); - expect( - warnings.some((m) => - m.includes( - "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", - ), - ), - ).toBe(true); - }); - test("an explicit db.orioledb_version wins over a non-empty legacy value, but still warns", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" +[auth.external.slack] +enabled = true -[db] -orioledb_version = "17.0.0.1" +${stagingRemote} +[remotes.staging.inbucket] +enabled = true -[experimental] -orioledb_version = "15.1.0.150" +[remotes.staging.auth.external.linkedin] +enabled = false `, ); - expect(loaded.config.db.orioledb_version).toBe("17.0.0.1"); - expect( - warnings.some((m) => - m.includes( - "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", - ), - ), - ).toBe(true); - }); - - test("rejects a non-string legacy experimental.orioledb_version instead of silently discarding it", async () => { - const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - path, - `project_id = "abc123" - -[experimental] -orioledb_version = 1 -`, + expect(error.message).toBe( + [ + "supabase/config.toml uses config keys that are no longer supported:", + " [inbucket]: use [local_smtp] instead.", + " experimental.orioledb_version: use db.orioledb_version instead.", + " [auth.external.slack]: use [auth.external.slack_oidc] instead.", + " [remotes.staging.inbucket]: use [remotes.staging.local_smtp] instead.", + " [remotes.staging.auth.external.linkedin]: use [remotes.staging.auth.external.linkedin_oidc] instead.", + ].join("\n"), ); - try { - const exit = await Effect.runPromiseExit( - loadCliConfigFile(path).pipe(Effect.provide(BunServices.layer)), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value._tag).toBe("CliConfigParseError"); - } - } - } finally { - await rm(cwd, { recursive: true, force: true }); - } }); - test("rejects a non-string canonical db.orioledb_version beside a valid legacy value", async () => { + test("saves over a config that uses removed keys and keeps its $schema", async () => { const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - path, - `project_id = "abc123" - -[db] -orioledb_version = 1 + const schemaRef = "./node_modules/@supabase/config/schema.json"; -[experimental] -orioledb_version = "15.1.0.150" -`, - ); try { - const exit = await Effect.runPromiseExit( - loadCliConfigFile(path).pipe(Effect.provide(BunServices.layer)), + const filePath = await writeToml( + cwd, + `"$schema" = "${schemaRef}"\nproject_id = "abc123"\n\n[inbucket]\nenabled = true\n`, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value._tag).toBe("CliConfigParseError"); - } - } - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("normalizes a deprecated remotes.*.experimental.orioledb_version", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[remotes.staging] -project_id = "stagingrefaaaaaaaaaa" - -[remotes.staging.experimental] -orioledb_version = "15.1.0.150" -`, - ); - - const staging = loaded.config.remotes.staging; - expect(staging?.db?.orioledb_version).toBe("15.1.0.150"); - expect( - warnings.some((m) => - m.includes( - "WARN: remotes.staging.experimental.orioledb_version is deprecated. Please use remotes.staging.db.orioledb_version instead.", - ), - ), - ).toBe(true); - }); -}); -describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { - let warnings: Array = []; - let errorSpy: MockInstance | undefined; - - function captureWarnings() { - warnings = []; - errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }); - } - - afterEach(() => { - errorSpy?.mockRestore(); - errorSpy = undefined; - }); + const saved = await runConfigEffect( + saveCliConfig({ cwd, config: decodeCliConfig({ project_id: "abc123" }) }), + ); - async function loadToml(contents: string, options?: InternalLoadCliConfigOptions) { - const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(path, contents); - try { - return await runConfigEffect(loadCliConfigFile(path, options)); + expect(saved.schemaRef).toBe(schemaRef); + const content = await readFile(filePath, "utf8"); + expect(content).toContain(`"$schema" = "${schemaRef}"`); + expect(content).not.toContain("inbucket"); } finally { await rm(cwd, { recursive: true, force: true }); } - } - - test("loads a bare [auth.external.slack] block without required fields", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[auth.external.slack] -enabled = true -`, - { cliCompat: true }, - ); - - expect("slack" in loaded.config.auth.external).toBe(false); - expect(loaded.document).not.toHaveProperty("auth.external.slack"); - expect( - warnings.some((m) => - m.includes( - 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', - ), - ), - ).toBe(true); }); - test("loads a bare [auth.external.linkedin] block without required fields", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" - -[auth.external.linkedin] -enabled = true -`, - { cliCompat: true }, - ); - - expect("linkedin" in loaded.config.auth.external).toBe(false); - expect( - warnings.some((m) => - m.includes( - 'WARN: disabling deprecated "linkedin" provider. Please use [auth.external.linkedin_oidc] instead', - ), - ), - ).toBe(true); - }); + describe("an empty experimental.orioledb_version", () => { + let errorSpy: MockInstance | undefined; - test("does not warn when the deprecated section is present but disabled", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + afterEach(() => { + errorSpy?.mockRestore(); + errorSpy = undefined; + }); -[auth.external.slack] -enabled = false -`, - ); + test("loads silently and leaves no trace in the loaded document", async () => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - expect("slack" in loaded.config.auth.external).toBe(false); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); + const loaded = await loadToml( + `project_id = "abc123" - test("does not warn when only [auth.external.slack_oidc] is used", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" +[experimental] +orioledb_version = "" -[auth.external.slack_oidc] -enabled = true -client_id = "abc" -secret = "shh" +${stagingRemote} +[remotes.staging.experimental] +orioledb_version = "" `, - ); + ); - expect(loaded.config.auth.external.slack_oidc.enabled).toBe(true); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); + expect(errorSpy).not.toHaveBeenCalled(); + expect(loaded.rawDocument).toEqual({ + project_id: "abc123", + experimental: {}, + remotes: { staging: { project_id: "stagingrefaaaaaaaaaa", experimental: {} } }, + }); + expect(loaded.document).not.toHaveProperty("experimental.orioledb_version"); + expect(loaded.config.db.orioledb_version).toBeUndefined(); + expect(loaded.valueOrigins?.map((origin) => origin.path.join("."))).not.toContain( + "experimental.orioledb_version", + ); + }); - test("strips a deprecated [remotes.*.auth.external.slack] block without warning for an unselected remote", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + test("leaves db.orioledb_version as written", async () => { + const loaded = await loadToml( + `project_id = "abc123" -[remotes.staging] -project_id = "stagingrefaaaaaaaaaa" +[db] +orioledb_version = "15.1.0.150" -[remotes.staging.auth.external.slack] -enabled = true +[experimental] +orioledb_version = "" `, - ); + ); - // Not requesting `projectRef` means no remote is selected, so `remotes` survives - // decode verbatim (minus the deprecated key) rather than being merged/dropped. - expect(loaded.config.remotes.staging?.auth.external).not.toHaveProperty("slack"); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); + }); }); }); @@ -2909,6 +2639,43 @@ describe("writeCliConfigDocumentText", () => { }); describe("decodeCliConfigDocumentForValidationEffect", () => { + test("fails with CliConfigRemovedKeyError naming every removed key, remotes included", async () => { + const error = await runConfigEffect( + Effect.flip( + decodeCliConfigDocumentForValidationEffect( + { + project_id: "abc123", + inbucket: { enabled: true }, + remotes: { staging: { experimental: { orioledb_version: "15.1.0.150" } } }, + }, + { cliCompat: true, path: "supabase/config.toml", format: "toml" }, + ), + ), + ); + + expect(error).toMatchObject({ + _tag: "CliConfigRemovedKeyError", + removedKeys: [ + { key: "[inbucket]", replacement: "[local_smtp]" }, + { + key: "remotes.staging.experimental.orioledb_version", + replacement: "remotes.staging.db.orioledb_version", + }, + ], + }); + }); + + test("tolerates an empty experimental.orioledb_version", async () => { + const config = await runConfigEffect( + decodeCliConfigDocumentForValidationEffect( + { project_id: "abc123", experimental: { orioledb_version: "" } }, + { cliCompat: true, path: "supabase/config.toml", format: "toml" }, + ), + ); + + expect(config).toEqual(decodeCliConfig({ project_id: "abc123" })); + }); + test("decodes a valid document successfully", async () => { const config = await runConfigEffect( decodeCliConfigDocumentForValidationEffect( diff --git a/packages/config/src/project-config/hosted-sections.ts b/packages/config/src/project-config/hosted-sections.ts index d8e924bcb6..481f7cf0fb 100644 --- a/packages/config/src/project-config/hosted-sections.ts +++ b/packages/config/src/project-config/hosted-sections.ts @@ -21,9 +21,7 @@ export type HostedSectionKey = (typeof HOSTED_SECTION_KEYS)[number]; * `ProjectConfig`'s shape, `ProjectConfigSchema`/`toProjectConfigJsonSchema`, and * `fromConfigDocument`'s output alike: local bind ports/TLS overrides, `db.pooler`'s * `enabled`/`port`, the `db.migrations`/`db.seed` subtrees, every config-side `realtime.*` field, - * `db.orioledb_version` (plus its deprecated `experimental.orioledb_version` alias — a document - * that hasn't gone through the loader's deprecation normalization can still carry it), and - * local-only `experimental.*` engine/backend selection. + * `db.orioledb_version`, and local-only `experimental.*` engine/backend selection. * * `db.major_version` and `db.pooler`'s other three fields (`pool_mode`, `default_pool_size`, * `max_client_conn`) are real hosted facts and excluded from this list, so `config @@ -50,7 +48,6 @@ export const DOCUMENT_ONLY_LOCAL_PATHS = [ ["realtime", "max_header_length"], ["experimental", "stack"], ["experimental", "compute"], - ["experimental", "orioledb_version"], ["experimental", "s3_host"], ["experimental", "s3_region"], ["experimental", "pgdelta"], diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index fb0d4a39ad..ecd033dd6b 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -490,7 +490,6 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { const document = decodeCliConfig({ experimental: { stack: true, - orioledb_version: "1.0", s3_host: "bucket.s3.example.com", s3_region: "us-east-1", pgdelta: { enabled: true }, @@ -501,7 +500,6 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { const projected = fromConfigDocument(document); expect(Object.hasOwn(projected.experimental ?? {}, "stack")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "compute")).toBe(false); - expect(Object.hasOwn(projected.experimental ?? {}, "orioledb_version")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "s3_host")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "s3_region")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "pgdelta")).toBe(false); @@ -589,7 +587,6 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { experimental: { compute: true, stack: true, - orioledb_version: "1.0", s3_host: "host", s3_region: "region", pgdelta: { enabled: true }, diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index 4b74045ac6..e67a7e7484 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -921,7 +921,7 @@ const hookRows: ReadonlyArray = AUTH_HOOK_NAMES.flatMap // API field for workos specifically — both despite the schema declaring those fields for every // provider. `skip_nonce_check` has no API field for any provider except google (handled // separately below). Plain "slack" (unlike `slack_oidc`) has a real API field set but isn't a -// schema member: `../io.ts` strips `[auth.external.slack]` from a config before this schema sees it. +// schema member: `[auth.external.slack]` in a config is rejected on load. interface ExternalProviderSpec { readonly id: string; diff --git a/packages/config/src/promise-facade.ts b/packages/config/src/promise-facade.ts index cd45d00b1f..a2f630d073 100644 --- a/packages/config/src/promise-facade.ts +++ b/packages/config/src/promise-facade.ts @@ -16,9 +16,9 @@ import { CliConfigStore } from "./cli-config.service.ts"; /** * A rejection from `loadCliConfig`, `loadCliConfigFile`, or `saveCliConfig` carries one of - * `CliConfigParseError`, `DuplicateRemoteProjectIdError`, `InvalidRemoteProjectIdError`, - * `CliProjectEnvParseError`, or a `PlatformError` for a host/OS failure — check with - * `instanceof`. + * `CliConfigParseError`, `CliConfigRemovedKeyError`, `DuplicateRemoteProjectIdError`, + * `InvalidRemoteProjectIdError`, `CliProjectEnvParseError`, or a `PlatformError` for a host/OS + * failure — check with `instanceof`. */ export interface CliConfigIo { readonly loadCliConfig: ( diff --git a/packages/config/src/removed-config-keys.ts b/packages/config/src/removed-config-keys.ts new file mode 100644 index 0000000000..65fa008e79 --- /dev/null +++ b/packages/config/src/removed-config-keys.ts @@ -0,0 +1,92 @@ +import { Effect } from "effect"; +import { isObject } from "./config-document.ts"; +import type { ConfigFormat } from "./config-format.ts"; +import { CliConfigRemovedKeyError } from "./errors.ts"; + +type RemovedConfigKey = CliConfigRemovedKeyError["removedKeys"][number]; + +const REMOVED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; + +function scanScope( + scope: Record, + prefix: string, +): { + readonly removedKeys: ReadonlyArray; + readonly scope: Record; +} { + const removedKeys: Array = []; + let stripped = scope; + + if (Object.hasOwn(scope, "inbucket")) { + removedKeys.push({ key: `[${prefix}inbucket]`, replacement: `[${prefix}local_smtp]` }); + } + + const experimental = scope["experimental"]; + if (isObject(experimental) && Object.hasOwn(experimental, "orioledb_version")) { + if (experimental["orioledb_version"] === "") { + const withoutEmptyVersion = { ...experimental }; + delete withoutEmptyVersion["orioledb_version"]; + stripped = { ...scope, experimental: withoutEmptyVersion }; + } else { + removedKeys.push({ + key: `${prefix}experimental.orioledb_version`, + replacement: `${prefix}db.orioledb_version`, + }); + } + } + + const auth = scope["auth"]; + const external = isObject(auth) ? auth["external"] : undefined; + if (isObject(external)) { + for (const provider of REMOVED_EXTERNAL_PROVIDERS) { + if (Object.hasOwn(external, provider)) { + removedKeys.push({ + key: `[${prefix}auth.external.${provider}]`, + replacement: `[${prefix}auth.external.${provider}_oidc]`, + }); + } + } + } + + return { removedKeys, scope: stripped }; +} + +/** + * Finds every removed key in a raw document, top level and each `[remotes.*]` block alike. The + * returned document drops an empty `experimental.orioledb_version`, which older `supabase init` + * templates wrote and which carries no meaning. + */ +function detectRemovedConfigKeys(document: Record): { + readonly removedKeys: ReadonlyArray; + readonly document: Record; +} { + const root = scanScope(document, ""); + const removedKeys = [...root.removedKeys]; + const remotes = document["remotes"]; + if (!isObject(remotes)) { + return { removedKeys, document: root.scope }; + } + const strippedRemotes = Object.fromEntries( + Object.entries(remotes).map(([name, remote]) => { + if (!isObject(remote)) { + return [name, remote]; + } + const scanned = scanScope(remote, `remotes.${name}.`); + removedKeys.push(...scanned.removedKeys); + return [name, scanned.scope]; + }), + ); + return { removedKeys, document: { ...root.scope, remotes: strippedRemotes } }; +} + +/** Fails with every removed key the raw document uses; otherwise yields it without the tolerated empty `experimental.orioledb_version`. */ +export const rejectRemovedConfigKeys = ( + document: Record, + path: string, + format: ConfigFormat, +): Effect.Effect, CliConfigRemovedKeyError> => { + const { removedKeys, document: stripped } = detectRemovedConfigKeys(document); + return removedKeys.length === 0 + ? Effect.succeed(stripped) + : Effect.fail(new CliConfigRemovedKeyError({ path, format, removedKeys })); +}; diff --git a/packages/config/testdata/legacy-config.toml b/packages/config/testdata/legacy-config.toml index b228a9c073..767eedb8ea 100644 --- a/packages/config/testdata/legacy-config.toml +++ b/packages/config/testdata/legacy-config.toml @@ -34,6 +34,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. +orioledb_version = "15.1.0.150" [db.migrations] # If disabled, migrations will be skipped during a db push or reset. @@ -96,7 +98,7 @@ openai_api_key = "env(OPENAI_API_KEY)" # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they # are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] +[local_smtp] enabled = true # Port to use for the email testing server web interface. port = 54324 @@ -310,7 +312,7 @@ verify_enabled = true # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +# `twitter`, `x`, `slack_oidc`, `spotify`, `workos`, `zoom`. [auth.external.azure] enabled = true client_id = "env(AZURE_CLIENT_ID)" @@ -347,8 +349,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "15.1.0.150" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "orioledb.s3-accelerate.amazonaws.com" # Configures S3 bucket region, eg. us-east-1 From 2316b16308ea6b924be7fd5991edc33cb2155960 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 11:19:50 +0100 Subject: [PATCH 2/3] fix(cli): drop the ./ prefix from out-of-tree import_map suggestions --- .../deploy/deploy.integration.test.ts | 32 +++++++++++++++++++ apps/cli/src/shared/functions/deploy.ts | 8 +++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index f8c21300cf..7c29630bca 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -696,6 +696,38 @@ describe("functions deploy", () => { }, ); + it.effect( + "suggests a parent-relative import_map path for an entrypoint outside supabase/", + () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeCliConfig( + tempRoot.current, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'entrypoint = "../src/hello-world/index.ts"', + "", + ].join("\n"), + ); + const sourceDir = `${tempRoot.current}/src/hello-world`; + yield* fs.makeDirectory(sourceDir, { recursive: true }); + yield* fs.writeFileString(`${sourceDir}/index.ts`, "Deno.serve(() => new Response())\n"); + yield* fs.writeFileString(`${sourceDir}/import_map.json`, '{"imports":{}}\n'); + + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toMatchObject({ + suggestion: + 'Rename it to deno.json, or set functions.hello-world.import_map = "../src/hello-world/import_map.json" in config.toml to keep the file.', + }); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + it.effect("deploys with deno.json when the function directory has both files", () => { const { importMapPaths, layer } = harnessRecordingImportMapPaths(); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index c956981bbd..ffa38f4fab 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2217,10 +2217,14 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs const [firstUnsupported] = unsupportedImportMaps; if (firstUnsupported !== undefined) { const [slug, importMapPath] = firstUnsupported; - const configPath = path.relative(input.supabaseDir, importMapPath).split(path.sep).join("/"); + const relativePath = path + .relative(input.supabaseDir, importMapPath) + .split(path.sep) + .join("/"); + const configPath = relativePath.startsWith("../") ? relativePath : `./${relativePath}`; return yield* new FunctionDeployError({ message: `import_map.json in a function directory is no longer supported: ${[...unsupportedImportMaps.keys()].join(", ")}`, - suggestion: `Rename it to deno.json, or set functions.${slug}.import_map = "./${configPath}" in config.toml to keep the file.`, + suggestion: `Rename it to deno.json, or set functions.${slug}.import_map = "${configPath}" in config.toml to keep the file.`, }); } From 307ac4a768b809112353e326e9f9848c4d157d0d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 11:49:21 +0100 Subject: [PATCH 3/3] fix(config): keep an empty experimental.orioledb_version in the published schema Also documents that stack mode never reads a per-function import_map.json and drops the [inbucket] example from ADR 0032. --- .../commands/experimental/stack/start/SIDE_EFFECTS.md | 4 +++- apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md | 2 ++ apps/cli/src/config/cli-config-key-annotations.ts | 2 ++ apps/cli/src/config/cli-config-keys.unit.test.ts | 6 +++++- apps/docs/public/cli/config.schema.json | 10 ++++++++++ docs/adr/0032-config-value-precedence.md | 2 +- packages/config/docs/cli-config-loading.md | 1 + packages/config/src/experimental.ts | 7 +++++++ packages/config/src/io.unit.test.ts | 6 ++++++ packages/config/src/project-config/hosted-sections.ts | 4 +++- .../src/project-config/project-config.unit.test.ts | 1 + 11 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index e7da40e0a0..2760f4f6c1 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -119,7 +119,9 @@ artifact preparation. When Functions is selected, the CLI reads and validates `supabase/functions/.env`, ignoring reserved `SUPABASE_*` entries. `edge_runtime.secrets` overrides that file, while `functions..env` provides per-function values from project environment references. Per-function enabled/JWT policies, -entrypoints, import maps and static files are forwarded to the worker bootstrap. Configured paths +entrypoints, import maps and static files are forwarded to the worker bootstrap. Without a +configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a per-function +`import_map.json` is not read. Configured paths are relative to `supabase/` and must remain within the project; Docker mounts that project read-only. The inspector port is retained as an endpoint intent and does not enable debugging by itself. Running start calls do not refresh Functions from changed project files; stop the stack and start diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index a1818a33f0..39a636a50f 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -136,6 +136,8 @@ per-function `.env` files are not read, and reserved `SUPABASE_*` entries are ig For Stack mode, TOML `edge_runtime.secrets` overrides the shared `.env`; an explicit `--env-file` overrides matching shared secrets while retaining other saved values. Per-function TOML `env` remains the final override for that function. +Without a configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a +per-function `import_map.json` is not read and does not fail the command. - Any legacy Function name positional arguments are accepted and ignored. The command always serves every discovered Function, preserving invocations such as diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index 05ecb501e0..5607a1577e 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -57,6 +57,8 @@ export const CLI_CONFIG_CODEC_OVERRIDES: Readonly> = { "experimental.inspect.rules": "a list of tables, not strings; read from the document", + "experimental.orioledb_version": + "removed; only an empty value is accepted and the loader drops it", }; /** Removed `SUPABASE_*` config overrides with the key and variable that replace them; a non-empty value fails config resolution. */ diff --git a/apps/cli/src/config/cli-config-keys.unit.test.ts b/apps/cli/src/config/cli-config-keys.unit.test.ts index faaf049d14..5fb20903bf 100644 --- a/apps/cli/src/config/cli-config-keys.unit.test.ts +++ b/apps/cli/src/config/cli-config-keys.unit.test.ts @@ -259,8 +259,12 @@ describe("config key registry", () => { ); it("keeps every schema leaf in the registry unless it is explicitly excluded", () => { - expect(Object.keys(CLI_CONFIG_SCHEMA_EXCLUDED)).toEqual(["experimental.inspect.rules"]); + expect(Object.keys(CLI_CONFIG_SCHEMA_EXCLUDED)).toEqual([ + "experimental.inspect.rules", + "experimental.orioledb_version", + ]); expect(cliConfigRegistry.keyAt("experimental.inspect.rules")).toBeUndefined(); + expect(cliConfigRegistry.keyAt("experimental.orioledb_version")).toBeUndefined(); expect(cliConfigRegistry.keyAt("experimental.webhooks.enabled")).toBeDefined(); }); diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index daf87ada50..b0132becc6 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -2420,6 +2420,11 @@ "type": "boolean", "description": "Use the new local stack backend for start, stop, and status, and for --local targets of the db, migration, test db, gen types, inspect, and pull command families." }, + "orioledb_version": { + "type": "string", + "enum": [""], + "description": "Removed: use `db.orioledb_version` instead. Only an empty value is accepted, for older generated config files." + }, "s3_host": { "type": "string", "description": "S3 bucket URL.", @@ -4935,6 +4940,11 @@ "type": "boolean", "description": "Use the new local stack backend for start, stop, and status, and for --local targets of the db, migration, test db, gen types, inspect, and pull command families." }, + "orioledb_version": { + "type": "string", + "enum": [""], + "description": "Removed: use `db.orioledb_version` instead. Only an empty value is accepted, for older generated config files." + }, "s3_host": { "type": "string", "description": "S3 bucket URL.", diff --git a/docs/adr/0032-config-value-precedence.md b/docs/adr/0032-config-value-precedence.md index 1914fac638..2b2b1af817 100644 --- a/docs/adr/0032-config-value-precedence.md +++ b/docs/adr/0032-config-value-precedence.md @@ -21,7 +21,7 @@ variables, `.env` files, `[remotes.*]` blocks and `config.toml` combine. They di returns a resolved config, memoised per workdir, project ref and flag set within a runtime. The resolved config decodes the whole config eagerly, so an invalid value fails every command that loads config unless the caller passes `tolerateInvalid`. Load-time warnings (an env value - overriding a remote, the `[inbucket]` deprecation) print once per runtime, however many + overriding a remote) print once per runtime, however many resolved configs it loads. Code that writes config or `.temp` goes through `writeThrough`, which drops the memo. - The resolved config surface is: diff --git a/packages/config/docs/cli-config-loading.md b/packages/config/docs/cli-config-loading.md index 54f94454db..5c1159054a 100644 --- a/packages/config/docs/cli-config-loading.md +++ b/packages/config/docs/cli-config-loading.md @@ -453,6 +453,7 @@ every removed key in the file, `[remotes.*]` blocks included, with its replaceme A remote's keys are reported with their `remotes..` prefix. An empty `experimental.orioledb_version = ""`, which older `supabase init` templates wrote, is ignored: it is dropped from `rawDocument` before decoding, so it never appears as a value, origin or difference. +The published JSON Schema accepts only that empty value, so editors agree with the loader. The CLI also rejects a non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` environment variable; use `SUPABASE_DB_ORIOLEDB_VERSION`. diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 6123d38c9e..28cddd19a8 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -45,6 +45,13 @@ export const experimental = Schema.Struct({ tags, }), ), + orioledb_version: Schema.optionalKey( + Schema.Literal("").annotate({ + description: + "Removed: use `db.orioledb_version` instead. Only an empty value is accepted, for older generated config files.", + tags, + }), + ), s3_host: Schema.optionalKey( Schema.String.annotate({ description: "S3 bucket URL.", diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 29810ec4c1..010d90fa69 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -2286,6 +2286,12 @@ describe("config io removed keys", () => { project_id = "stagingrefaaaaaaaaaa" `; + test("the config schema accepts only an empty experimental.orioledb_version", () => { + const decode = Schema.decodeUnknownSync(CliConfigSchema); + expect(() => decode({ experimental: { orioledb_version: "" } })).not.toThrow(); + expect(() => decode({ experimental: { orioledb_version: "17.0.1.084-orioledb" } })).toThrow(); + }); + test.each([ { name: "[inbucket] whatever its contents", diff --git a/packages/config/src/project-config/hosted-sections.ts b/packages/config/src/project-config/hosted-sections.ts index 481f7cf0fb..20b5bc6d4c 100644 --- a/packages/config/src/project-config/hosted-sections.ts +++ b/packages/config/src/project-config/hosted-sections.ts @@ -21,7 +21,8 @@ export type HostedSectionKey = (typeof HOSTED_SECTION_KEYS)[number]; * `ProjectConfig`'s shape, `ProjectConfigSchema`/`toProjectConfigJsonSchema`, and * `fromConfigDocument`'s output alike: local bind ports/TLS overrides, `db.pooler`'s * `enabled`/`port`, the `db.migrations`/`db.seed` subtrees, every config-side `realtime.*` field, - * `db.orioledb_version`, and local-only `experimental.*` engine/backend selection. + * `db.orioledb_version` and the removed `experimental.orioledb_version`, and local-only + * `experimental.*` engine/backend selection. * * `db.major_version` and `db.pooler`'s other three fields (`pool_mode`, `default_pool_size`, * `max_client_conn`) are real hosted facts and excluded from this list, so `config @@ -48,6 +49,7 @@ export const DOCUMENT_ONLY_LOCAL_PATHS = [ ["realtime", "max_header_length"], ["experimental", "stack"], ["experimental", "compute"], + ["experimental", "orioledb_version"], ["experimental", "s3_host"], ["experimental", "s3_region"], ["experimental", "pgdelta"], diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index ecd033dd6b..6032b555dd 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -587,6 +587,7 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { experimental: { compute: true, stack: true, + orioledb_version: "", s3_host: "host", s3_region: "region", pgdelta: { enabled: true },