diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 797be14ebd..8013faaeee 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -310,7 +310,7 @@ The legacy shell sends PostHog events to the product analytics pipeline. Drift i - **The canonical catalog is `shared/telemetry/event-catalog.ts`.** Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. The TS catalog is the source of truth for event names and property keys. - **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits the established property shape: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. - **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""`. -- **Use `safeFlags: ["flag-name"]`** to whitelist flags whose values are safe to log verbatim. The established list: `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). Extend it only for flags whose values carry no user data. +- **Use `safeFlags: ["flag-name"]`** to whitelist flags whose values are safe to log verbatim. The established list: `--project-ref` (sso, branches, link, functions, projects/api-keys, config push/diff), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). Extend it only for flags whose values carry no user data. When a `--project-ref` also accepts branch names (link, config diff — CLI-2167 vocabulary), gate the whitelist on `PROJECT_REF_PATTERN.test(...)` so a user-created branch name is never logged verbatim. - **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe — closed enums carry no user data — and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. - **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record. No per-command wiring needed. This gives two flag families their real value automatically, via the boolean-is-safe rule and the choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: - Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`. diff --git a/apps/cli/docs/supabase/config/diff.md b/apps/cli/docs/supabase/config/diff.md new file mode 100644 index 0000000000..c4a45d74ec --- /dev/null +++ b/apps/cli/docs/supabase/config/diff.md @@ -0,0 +1,11 @@ +# supabase-config-diff + +Shows the configuration differences between the local `supabase/config.toml` and the effective configuration of a remote project or branch. Read-only: it never modifies the local file or any remote configuration. + +Pass `--project-ref` to compare against a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. When the target ref matches a `[remotes.*]` block's `project_id`, that block's merged config is the local side of the comparison. + +Each difference is classified as `update` (the file declares a value that differs remotely), `remote-only` (the remote differs while the file is silent — the shown local value is the schema default a `config push` would write), or `local-only` (the file declares a value the remote did not report). `(unset)` means the local side has no value at all; `(not returned)` means the response did not carry the property. Secret values are never compared — the platform only reports digests — and are listed in a masked-credentials note instead, as are declared properties that `config push` cannot communicate. + +Local values are shown as the configuration your file would produce once pushed, not its literal spelling: a duration written as `"1m"` renders as `"1m0s"`, and byte sizes are shown in the units you wrote. + +With `--exit-code`, the command exits `2` when any difference is found, keeping exit `1` for errors — so scripts can distinguish drift from failure. Machine-readable output is available through `--output-format json|stream-json` (a versioned payload with per-change paths as segment arrays) or the global `-o json|yaml|toml|env` flag. diff --git a/apps/cli/src/legacy/commands/branches/branches.resolver.ts b/apps/cli/src/legacy/commands/branches/branches.resolver.ts index ff666f9d47..6f66f5c297 100644 --- a/apps/cli/src/legacy/commands/branches/branches.resolver.ts +++ b/apps/cli/src/legacy/commands/branches/branches.resolver.ts @@ -1,7 +1,5 @@ -import { Effect } from "effect"; - -import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { legacyResolveBranchProjectRef as legacyResolveBranchProjectRefShared } from "../../shared/legacy-branch-ref.resolver.ts"; import { LegacyBranchesFindNetworkError, LegacyBranchesFindUnexpectedStatusError, @@ -9,21 +7,6 @@ import { LegacyBranchesGetUnexpectedStatusError, } from "./branches.errors.ts"; -/** - * Project ref pattern shared by every Management-API endpoint that accepts a - * 20-lowercase-letter project reference. Re-export so siblings (e.g. - * `get.handler.ts`) can classify branch-id inputs without re-declaring it. - */ -export const LEGACY_BRANCH_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -/** - * Permissive UUID pattern (any 8-4-4-4-12 hex sequence) — accepts any RFC 4122 - * variant including v6/v7 and version 0, matching the established liberal - * acceptance rather than the v1–v5 + variant-1 subset. - */ -export const LEGACY_BRANCH_UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const mapFindError = mapLegacyHttpError({ networkError: LegacyBranchesFindNetworkError, statusError: LegacyBranchesFindUnexpectedStatusError, @@ -39,38 +22,10 @@ const mapGetError = mapLegacyHttpError({ }); /** - * Resolves an arbitrary branch identifier to its project ref: - * - * 1. If the input matches `^[a-z]{20}$`, it's already a project ref — return as-is. - * 2. Else if the input is a UUID, call `V1GetABranchConfig` (`GET /v1/branches/{id}`) - * and return `JSON200.ref`. - * 3. Otherwise treat as a branch name under the linked project ref: call - * `V1GetABranch` (`GET /v1/projects/{ref}/branches/{name}`) and return - * `JSON200.project_ref`. - * - * The persistent `--project-ref` is required for path 3 and is passed in by - * the caller (which has already run `LegacyProjectRefResolver` so the linked - * project cache write does not re-fire here). + * The branches family's binding of the shared branch-ref resolver + * (`legacy/shared/legacy-branch-ref.resolver.ts`) to this family's error + * classes. See the shared module for resolution semantics. */ -export const legacyResolveBranchProjectRef = Effect.fnUntraced(function* ( - input: string, - projectRef: string, -) { - if (LEGACY_BRANCH_PROJECT_REF_PATTERN.test(input)) { - return input; - } - - const api = yield* LegacyPlatformApi; - - if (LEGACY_BRANCH_UUID_PATTERN.test(input)) { - const detail = yield* api.v1 - .getABranchConfig({ branch_id_or_ref: input }) - .pipe(Effect.catch(mapGetError)); - return detail.ref; - } - - const branch = yield* api.v1 - .getABranch({ ref: projectRef, name: input }) - .pipe(Effect.catch(mapFindError)); - return branch.project_ref; -}); +export function legacyResolveBranchProjectRef(input: string, projectRef: string) { + return legacyResolveBranchProjectRefShared(input, projectRef, { mapGetError, mapFindError }); +} diff --git a/apps/cli/src/legacy/commands/branches/get/get.handler.ts b/apps/cli/src/legacy/commands/branches/get/get.handler.ts index 0f8df3340b..da7b258ffa 100644 --- a/apps/cli/src/legacy/commands/branches/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/branches/get/get.handler.ts @@ -39,7 +39,7 @@ import { legacyPromptBranchId } from "../branches.prompt.ts"; import { LEGACY_BRANCH_PROJECT_REF_PATTERN, LEGACY_BRANCH_UUID_PATTERN, -} from "../branches.resolver.ts"; +} from "../../../shared/legacy-branch-ref.resolver.ts"; import type { LegacyBranchesGetFlags } from "./get.command.ts"; type BranchDetail = typeof V1GetABranchConfigOutput.Type; diff --git a/apps/cli/src/legacy/commands/config/config.command.ts b/apps/cli/src/legacy/commands/config/config.command.ts index efec9afa22..0c13ba938d 100644 --- a/apps/cli/src/legacy/commands/config/config.command.ts +++ b/apps/cli/src/legacy/commands/config/config.command.ts @@ -1,8 +1,9 @@ import { Command } from "effect/unstable/cli"; +import { legacyConfigDiffCommand } from "./diff/diff.command.ts"; import { legacyConfigPushCommand } from "./push/push.command.ts"; export const legacyConfigCommand = Command.make("config").pipe( Command.withDescription("Manage Supabase project configurations."), Command.withShortDescription("Manage project configurations"), - Command.withSubcommands([legacyConfigPushCommand]), + Command.withSubcommands([legacyConfigDiffCommand, legacyConfigPushCommand]), ); diff --git a/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md new file mode 100644 index 0000000000..e83bd0f40e --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md @@ -0,0 +1,124 @@ +# `supabase config diff` + +Read-only comparison between the local `supabase/config.toml` and the effective +configuration the Management API reports for a target project or branch. +Classifies every remotely-managed property as `update` / `remote_only` / +`local_only` (unmanaged local-only properties are never reported). **Never +writes `config.toml` or any remote configuration.** + +## Files Read + +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, before any network call (missing file or parse error aborts, exit 1); re-read after target resolution when the file declares `[remotes.*]`, to apply the matching overlay | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref for a branch-name `--project-ref` | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, for the telemetry cache write below | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | + +## Files Written + +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | + +**No writes to `supabase/config.toml` or `supabase/config.json`** — covered by +an integration test asserting mtime and contents are unchanged after a run +that finds differences. + +## API Routes + +All Bearer-authenticated, all read-only. + +| # | Purpose | Method | Path | Success | Notes | +| --- | ----------------------- | ------ | ------------------------------------ | ------- | --------------------------------------------------------------------- | +| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--project-ref` is a UUID; needs no linked project | +| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--project-ref` is not a ref/UUID; 404 → "branch not found" | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution) | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | + +## Exit Codes + +Drift has its own exit code (`2`), distinct from every failure (`1`), so +`config diff --exit-code` scripts can tell "config drifted" from "token +expired" without parsing output (`terraform plan -detailed-exitcode`'s +convention; `1` stays the CLI-wide failure code). + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------ | +| `0` | success — including when differences are found, unless `--exit-code` is passed | +| `2` | `--exit-code` passed and at least one difference found | +| `1` | missing or malformed `supabase/config.toml` | +| `1` | unknown branch (branch-name `--project-ref` 404) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | remote config read failure (network or unexpected status) | + +## Output + +Diagnostics on **stderr**: `Comparing against …` (resolved target + local +scope, i.e. `[remotes.]` or `base config`) before the fetch, then +`Comparison scope: ` listing the blocks the response carried (missing +blocks are called out). The payload is on **stdout**. + +### `--output-format text` + +One block per difference (` [update|remote-only|local-only]` with +`local:`/`remote:` lines; unset renders `(unset)` / `(not returned)`, an +undeclared path with a schema default renders ` (schema default — not +declared in config.toml)`, env-resolved values append `(from env VAR, …)`), +then a summary count line — `No config differences found.` when clean — +followed by a `Note: … (masked by the API): …` line when the file sets masked +secrets and a `Note: … cannot be pushed and … not compared: …` line for +declared properties push cannot communicate. Every non-constant string +(path segments, env-var names, remotes/branch names) is sanitized against +control characters before rendering. + +### `--output-format json` / `stream-json` + +`output.success(message, payload)` — the message carries the masked/unmanaged +caveats too, so echoing it never claims "in sync" while masked values may have +drifted. The payload contains `schema_version` (integer version of THIS +payload contract, currently `1`), `config_schema` (the file's `$schema` URL), +`target` (`project_ref`, optional `branch`, `local_scope`), `scope` +(`{present, missing}` block lists — the block set is owned by +`@supabase/config`), `changes[]` (`path` as a SEGMENT ARRAY — a record key may +contain a `.` — plus `class`, `declared`, `local`, `remote`, optional +`env_variables[]`; unset sides are `null`), `masked[]` and `unmanaged[]` +(segment-array paths), and `counts` (per class + `total`). + +### `-o/--output` (legacy machine formats) + +Honored, and takes priority over `--output-format` (Legacy Shell Invariant +#6): `-o json|yaml|toml|env` encodes the same structured payload the +`--output-format json` envelope carries (TOML omits `null`-valued entries — +TOML has no null; env flattens to SCREAMING_SNAKE keys with arrays collapsing +to empty strings, the established `godotenv` shape). stdout is payload-pure in +every machine mode; diagnostics stay on stderr. `-o pretty` (and no `-o`) +falls through to `--output-format` handling. + +## Notes + +- Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. +- **Local operand per target (ADR 0018/0022):** when the resolved target ref matches a + `[remotes.]` block's `project_id`, the local side is that branch's merged + effective config; otherwise the base config. The echoed scope line always says which. +- **Masked credentials:** secret-valued managed properties (the platform returns an HMAC, + never plaintext; the registry's `isSecret` rows) are treated as "present, unknown" — never + reported as differences and never counted for `--exit-code`; they are surfaced via the + masked note / `masked[]`. +- **Values are convergence projections (ADR 0021):** both sides are normalized through + `@supabase/config`'s `fromConfigDocument`/`fromApiProjectConfig`, so a reported "local" + value is what pushing the file would produce hosted (canonicalized durations/byte sizes, + push-gated omissions), not necessarily the file's literal spelling. +- **Partial responses:** a managed property the response does not carry is `local_only` + when the file declares it and silent otherwise; a missing block is called out on the + scope line rather than treated as an error. diff --git a/apps/cli/src/legacy/commands/config/diff/diff.command.ts b/apps/cli/src/legacy/commands/config/diff/diff.command.ts new file mode 100644 index 0000000000..a190abd21e --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.command.ts @@ -0,0 +1,66 @@ +import { Option } from "effect"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command, Flag } from "effect/unstable/cli"; + +import { PROJECT_REF_PATTERN } from "../../../config/legacy-project-ref.service.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyConfigDiff } from "./diff.handler.ts"; + +const config = { + // `link`'s settled vocabulary (CLI-2167): one flag that accepts either a + // project ref or a branch of the linked project — no separate `--target`. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription( + "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", + ), + Flag.optional, + ), + exitCode: Flag.boolean("exit-code").pipe( + Flag.withDescription( + "Exit with status 2 when any difference is found (errors keep exiting 1).", + ), + // Without an explicit default a boolean flag is REQUIRED by the parser, + // making plain `supabase config diff` fail with `required flag(s) + // "exit-code" not set` — pinned by diff.e2e.test.ts, since integration + // tests hand the handler a pre-built flags object and never parse. + Flag.withDefault(false), + ), +} as const; + +export type LegacyConfigDiffFlags = CliCommand.Command.Config.Infer; + +export const legacyConfigDiffCommand = Command.make("diff", config).pipe( + Command.withDescription( + "Shows configuration differences between supabase/config.toml and a remote project or branch. Read-only: never modifies local or remote configuration.", + ), + Command.withShortDescription("Diff local config against a remote project"), + Command.withExamples([ + { + command: "supabase config diff", + description: "Diff against the linked project", + }, + { + command: "supabase config diff --project-ref staging --exit-code", + description: "Diff against the 'staging' branch, exiting 2 on drift", + }, + ]), + Command.withHandler((flags) => + legacyConfigDiff(flags).pipe( + // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so + // its value is only safe to log verbatim when it is actually ref-shaped + // — a user-created branch name must never reach PostHog. Same guard as + // `link`. + withLegacyCommandInstrumentation({ + flags, + safeFlags: + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? ["project-ref"] + : [], + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["config", "diff"])), +); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts new file mode 100644 index 0000000000..5d93a0cef3 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts @@ -0,0 +1,27 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +describe("config diff CLI surface", () => { + test("plain `config diff` parses — no boolean flag is accidentally required", async () => { + // Parser-level regression pin (PR #6295 review): a `Flag.boolean` without + // `Flag.withDefault(false)` is a REQUIRED flag, so the help's own first + // example (`supabase config diff`) failed with `required flag(s) + // "exit-code" not set`. Integration tests hand the handler a pre-built + // flags object and never exercise the parser, so this must be pinned at + // the subprocess boundary. The invocation is expected to fail LATER (no + // linked project in this hermetic cwd/HOME) — the assertion is only that + // it gets past the parser. + const cwd = await mkdtemp(join(tmpdir(), "supabase-config-diff-e2e-")); + const { stdout, stderr } = await runSupabase(["config", "diff"], { + entrypoint: "legacy", + cwd, + }); + const combined = `${stdout}\n${stderr}`; + expect(combined).not.toContain("required flag"); + expect(combined).not.toContain("exit-code"); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.errors.ts b/apps/cli/src/legacy/commands/config/diff/diff.errors.ts new file mode 100644 index 0000000000..07b22b873a --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.errors.ts @@ -0,0 +1,76 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; + +interface NetworkErrorArgs { + readonly message: string; + readonly decode?: boolean; +} + +interface StatusErrorArgs { + readonly status: number; + readonly body: string; + readonly message: string; +} + +/** Local config file missing or unparseable. Aborts before any network call. */ +export class LegacyConfigDiffLoadConfigError extends Data.TaggedError( + "LegacyConfigDiffLoadConfigError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** `--project-ref` named a branch the parent project does not have. */ +export class LegacyConfigDiffBranchNotFoundError extends Data.TaggedError( + "LegacyConfigDiffBranchNotFoundError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class LegacyConfigDiffBranchResolveNetworkError extends Data.TaggedError( + "LegacyConfigDiffBranchResolveNetworkError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigDiffBranchResolveStatusError extends Data.TaggedError( + "LegacyConfigDiffBranchResolveStatusError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} + +export class LegacyConfigDiffReadNetworkError extends Data.TaggedError( + "LegacyConfigDiffReadNetworkError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigDiffReadStatusError extends Data.TaggedError( + "LegacyConfigDiffReadStatusError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // `/v2/projects/{ref}/config` names a user-selected resource, so a 404 + // means "wrong project ref" — user-actionable, not an external-service + // problem (same rule as the branch-resolve error above and the + // ref-addressed push.errors.ts status errors). + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/commands/config/diff/diff.format.ts b/apps/cli/src/legacy/commands/config/diff/diff.format.ts new file mode 100644 index 0000000000..d335aff3cd --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.format.ts @@ -0,0 +1,253 @@ +import { + type ConfigChange, + type ConfigChangeSet, + projectConfigApiBlockKeys, +} from "@supabase/config"; + +import { LEGACY_BRANCH_UUID_PATTERN } from "../../../shared/legacy-branch-ref.resolver.ts"; +import { legacySanitizeInlineName } from "../../../shared/legacy-http-errors.ts"; + +/** + * Pure formatters, payload builders, and input adapters for `config diff` — + * no Effect, no services, unit-testable in isolation. + * + * Every non-constant string interpolated into TEXT output goes through + * `legacySanitizeInlineName`: path segments (`[remotes.*]` names, + * `sms.test_otp` record keys) and env-var names are unconstrained + * user/API-controlled strings, so a hostile value could otherwise emit raw + * ANSI or forge output lines (e.g. a name ending `\nNo config differences + * found.`). JSON output needs no sanitizing — `JSON.stringify` escapes + * control characters. + */ + +/** + * The per-service blocks of the v2 project-config resource — owned by + * `@supabase/config` (derived from its response mirror), never hand-copied + * here, so a block the package learns is never reported "not returned" + * forever. + */ +const REMOTE_CONFIG_BLOCKS: ReadonlyArray = projectConfigApiBlockKeys; + +export interface LegacyConfigDiffScope { + /** Blocks the response's `data.attributes` carried with at least one key. */ + readonly present: ReadonlyArray; + /** Blocks absent from the response — or present but EMPTY, which is how a + * permission-truncated response most plausibly reports a block it could + * not read; claiming an empty block was "compared" would be false. */ + readonly missing: ReadonlyArray; +} + +function isPopulatedBlockRecord(value: unknown): value is Readonly> { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length > 0 + ); +} + +/** + * Which per-service blocks the response's `data.attributes` actually carried + * — echoed to the user so a partially-populated response is never mistaken + * for a clean bill of health. + */ +export function legacyConfigDiffScope( + attributes: Readonly>, +): LegacyConfigDiffScope { + const present = REMOTE_CONFIG_BLOCKS.filter((block) => isPopulatedBlockRecord(attributes[block])); + return { + present, + missing: REMOTE_CONFIG_BLOCKS.filter((block) => !present.includes(block)), + }; +} + +export interface LegacyConfigDiffContext { + /** The resolved comparison target's project ref. */ + readonly projectRef: string; + /** The branch name or UUID `--project-ref` carried, when it named one. */ + readonly branch: string | undefined; + /** Matched `[remotes.]` block, when the local operand was merged. */ + readonly appliedRemote: string | undefined; + /** The local file's `$schema` ref (or the current schema URL). */ + readonly configSchema: string; +} + +/** + * Version of the machine payload's own shape — bump when the payload + * contract changes incompatibly. Distinct from the config document's + * `$schema` URL (`config_schema` in the payload), which is user-controlled + * and per-repo. + */ +export const LEGACY_CONFIG_DIFF_PAYLOAD_VERSION = 1; + +const CLASS_LABELS: Record = { + update: "update", + remote_only: "remote-only", + local_only: "local-only", +}; + +function renderValue(value: unknown, absent: string): string { + if (value === undefined) { + return absent; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + +/** Display-only join — `ConfigChange.path` is segment-array everywhere else. */ +function renderPath(path: ReadonlyArray): string { + return legacySanitizeInlineName(path.join(".")); +} + +function plural(count: number, singular: string, pluralForm: string): string { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +function localScope(context: LegacyConfigDiffContext): string { + return context.appliedRemote === undefined + ? "base config" + : `[remotes.${legacySanitizeInlineName(context.appliedRemote)}]`; +} + +/** The target-echo line, printed to stderr before any comparison output. */ +export function legacyConfigDiffComparisonLine(context: LegacyConfigDiffContext): string { + // A UUID target is an identifier, not a display name — quoting it as + // `'1111…'` would imply the branch is literally named that. + const projectRef = legacySanitizeInlineName(context.projectRef); + const target = + context.branch === undefined + ? `project ${projectRef}` + : LEGACY_BRANCH_UUID_PATTERN.test(context.branch) + ? `branch ${legacySanitizeInlineName(context.branch)} (project ref ${projectRef})` + : `'${legacySanitizeInlineName(context.branch)}' (branch ${projectRef})`; + return `Comparing against ${target} using ${localScope(context)}\n`; +} + +/** The scope-echo line, printed to stderr once the response arrived. */ +export function legacyConfigDiffScopeLine(scope: LegacyConfigDiffScope): string { + const present = scope.present.length === 0 ? "(none)" : scope.present.join(", "); + const suffix = scope.missing.length === 0 ? "" : ` (not returned: ${scope.missing.join(", ")})`; + return `Comparison scope: ${present}${suffix}\n`; +} + +function maskedCaveat(masked: ReadonlyArray>): string { + return `${plural(masked.length, "credential value", "credential values")} not compared (masked by the API): ${masked.map(renderPath).join(", ")}`; +} + +function unmanagedCaveat(unmanaged: ReadonlyArray>): string { + const phrase = + unmanaged.length === 1 + ? "1 declared property cannot be pushed and was not compared" + : `${unmanaged.length} declared properties cannot be pushed and were not compared`; + return `${phrase}: ${unmanaged.map(renderPath).join(", ")}`; +} + +/** + * One-line summary including the masked/unmanaged caveats — the text-mode + * count line's caveats also travel with the machine-mode `message`, so an + * agent echoing `.message` never reports "in sync" on a project whose + * masked SMTP password (or unpushable declared value) may have drifted. + */ +export function legacyConfigDiffSummaryMessage(changeSet: ConfigChangeSet): string { + const total = changeSet.counts.total; + const base = + total === 0 + ? "No config differences found." + : `${plural(total, "config difference", "config differences")} found.`; + const parts = [base]; + if (changeSet.masked.length > 0) { + parts.push(`${maskedCaveat(changeSet.masked)}.`); + } + if (changeSet.unmanaged.length > 0) { + parts.push(`${unmanagedCaveat(changeSet.unmanaged)}.`); + } + return parts.join(" "); +} + +function renderLocal(change: ConfigChange): string { + const value = renderValue(change.local, "(unset)"); + // A populated local value on an undeclared path is the schema default the + // projection materialized — the value a `config push` would write. Say so, + // or "[remote-only]" reads as "this key exists only remotely", which is + // false for anything with a schema default (and the user will grep their + // file for a value that isn't there). + return change.local !== undefined && !change.declared + ? `${value} (schema default — not declared in config.toml)` + : value; +} + +/** Human-readable diff body for text mode (stdout). */ +export function legacyRenderConfigDiffText(changeSet: ConfigChangeSet): string { + const lines: Array = []; + for (const change of changeSet.changes) { + lines.push(`${renderPath(change.path)} [${CLASS_LABELS[change.class]}]`); + const env = + change.envVariables === undefined + ? "" + : ` (from env ${legacySanitizeInlineName(change.envVariables.join(", "))})`; + lines.push(` local: ${renderLocal(change)}${env}`); + lines.push(` remote: ${renderValue(change.remote, "(not returned)")}`); + lines.push(""); + } + + const { update, remote_only, local_only, total } = changeSet.counts; + if (total === 0) { + lines.push("No config differences found."); + } else { + lines.push( + `${plural(total, "difference", "differences")} found (${update} update, ${remote_only} remote-only, ${local_only} local-only).`, + ); + } + if (changeSet.masked.length > 0) { + lines.push(`Note: ${maskedCaveat(changeSet.masked)}`); + } + if (changeSet.unmanaged.length > 0) { + lines.push(`Note: ${unmanagedCaveat(changeSet.unmanaged)}`); + } + return `${lines.join("\n")}\n`; +} + +/** + * The structured result for `--output-format json|stream-json` and the `-o` + * machine formats. Unset sides are explicit `null`s, distinguishable from + * empty values. Paths are segment arrays — a record key (an `sms.test_otp` + * phone number, a `[remotes.*]` name) may itself contain a `.`, so consumers + * must never split a joined string. + */ +export function legacyConfigDiffPayload( + changeSet: ConfigChangeSet, + scope: LegacyConfigDiffScope, + context: LegacyConfigDiffContext, +): Record { + const valueEntry = (key: string, value: unknown): Record => ({ + [key]: value === undefined ? null : value, + }); + + return { + // The payload contract's own version — what a forward-compat consumer + // gates on. The user's `$schema` document reference is `config_schema`: + // user-controlled and per-repo, never a contract signal. + schema_version: LEGACY_CONFIG_DIFF_PAYLOAD_VERSION, + config_schema: context.configSchema, + target: { + project_ref: context.projectRef, + ...valueEntry("branch", context.branch), + local_scope: + context.appliedRemote === undefined ? "base" : `remotes.${context.appliedRemote}`, + }, + scope: { present: scope.present, missing: scope.missing }, + changes: changeSet.changes.map((change) => ({ + path: change.path, + class: change.class, + declared: change.declared, + ...valueEntry("local", change.local), + ...valueEntry("remote", change.remote), + ...(change.envVariables === undefined ? {} : { env_variables: change.envVariables }), + })), + masked: changeSet.masked, + unmanaged: changeSet.unmanaged, + counts: changeSet.counts, + }; +} diff --git a/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts new file mode 100644 index 0000000000..49fd5c66ec --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { legacyConfigDiffScope, legacyConfigDiffScopeLine } from "./diff.format.ts"; + +describe("legacyConfigDiffScope", () => { + test("lists record blocks the response carried, dropping non-records and empty records", () => { + // An EMPTY block record is how a permission-truncated response most + // plausibly reports a block it could not read — claiming it was + // "compared" while all its keys render (not returned) would be false, + // and with --exit-code that is a permanently red CI no file edit fixes. + expect( + legacyConfigDiffScope({ + api: { max_rows: 5 }, + auth: {}, + database: null, + realtime: [1], + storage: "nope", + }), + ).toEqual({ + present: ["api"], + missing: ["auth", "database", "pooler", "realtime", "storage"], + }); + }); +}); + +describe("legacyConfigDiffScopeLine", () => { + test("calls out blocks the response did not return", () => { + expect( + legacyConfigDiffScopeLine({ + present: ["api", "auth"], + missing: ["database", "pooler", "realtime", "storage"], + }), + ).toBe("Comparison scope: api, auth (not returned: database, pooler, realtime, storage)\n"); + }); + + test("an empty response scope renders (none)", () => { + expect( + legacyConfigDiffScopeLine({ + present: [], + missing: ["api", "auth", "database", "pooler", "realtime", "storage"], + }), + ).toBe( + "Comparison scope: (none) (not returned: api, auth, database, pooler, realtime, storage)\n", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.handler.ts b/apps/cli/src/legacy/commands/config/diff/diff.handler.ts new file mode 100644 index 0000000000..62c313443e --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.handler.ts @@ -0,0 +1,281 @@ +import { + CLI_CONFIG_SCHEMA_URL, + diffProjectConfig, + fromApiProjectConfig, + ProjectConfigParseError, +} from "@supabase/config"; +import { loadCliConfig } from "@supabase/config/effect"; +import { Effect, Option } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { + LEGACY_BRANCH_PROJECT_REF_PATTERN, + legacyResolveBranchProjectRef, +} from "../../../shared/legacy-branch-ref.resolver.ts"; +import { + legacySanitizeInlineName, + mapLegacyHttpError, +} from "../../../shared/legacy-http-errors.ts"; +import { + legacyConfigDiffComparisonLine, + legacyConfigDiffPayload, + legacyConfigDiffScope, + legacyConfigDiffScopeLine, + legacyConfigDiffSummaryMessage, + legacyRenderConfigDiffText, + type LegacyConfigDiffContext, +} from "./diff.format.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { + LegacyConfigDiffBranchNotFoundError, + LegacyConfigDiffBranchResolveNetworkError, + LegacyConfigDiffBranchResolveStatusError, + LegacyConfigDiffLoadConfigError, + LegacyConfigDiffReadNetworkError, + LegacyConfigDiffReadStatusError, +} from "./diff.errors.ts"; +import type { LegacyConfigDiffFlags } from "./diff.command.ts"; + +const readStatusMessage = (status: number, body: string) => `unexpected status ${status}: ${body}`; + +const mapBranchResolveError = mapLegacyHttpError({ + networkError: LegacyConfigDiffBranchResolveNetworkError, + statusError: LegacyConfigDiffBranchResolveStatusError, + networkMessage: (cause) => `failed to resolve branch: ${cause}`, + statusMessage: readStatusMessage, +}); + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( + flags: LegacyConfigDiffFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliSettings = yield* LegacyCliSettings; + const processControl = yield* ProcessControl; + const goOutputFlag = yield* LegacyOutputFlag; + + // An empty `--project-ref` value is absent, mirroring the resolver's own rule. + const requested = Option.filter(flags.projectRef, (value) => value.length > 0); + + // Resolved against `cliSettings.workdir` — the same root the project-ref + // resolver and the linked-project cache use — so `--workdir ../other` + // compares `../other`'s config.toml against `../other`'s linked project, + // never the invoking directory's file against another root's project. + const loadLocalConfig = (projectRef: string | undefined) => + loadCliConfig(cliSettings.workdir, { projectRef, goViperCompat: true }).pipe( + Effect.catchTag( + "CliConfigParseError", + (cause) => + new LegacyConfigDiffLoadConfigError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + Effect.catchTag( + "DuplicateRemoteProjectIdError", + (cause) => new LegacyConfigDiffLoadConfigError({ message: cause.message }), + ), + Effect.flatMap((loaded) => + loaded === null + ? Effect.fail( + new LegacyConfigDiffLoadConfigError({ + message: + "failed to read supabase/config.toml: file not found. Run `supabase init` to create one.", + }), + ) + : Effect.succeed(loaded), + ), + ); + + // Written once the comparison target is known, so the linked-project cache + // finalizer below only fires for invocations that got that far — matching + // the family pattern of caching exactly the resolved ref. + let resolvedRef: string | undefined; + + yield* Effect.gen(function* () { + // 1. Load and validate the local config BEFORE any network call or + // target resolution (never writes — this command is read-only by + // contract): a missing file must point at `supabase init` rather than + // the resolver's not-linked error, and a malformed document must not + // burn a branch-resolution round trip. This first load applies no + // `[remotes.*]` overlay — the overlay is keyed by the RESOLVED target + // ref, so a config that declares remotes is reloaded in step 3. + let loaded = yield* loadLocalConfig(undefined); + + // 2. Resolve the comparison target. `--project-ref` accepts a project + // ref, or the name (or UUID) of a branch of the linked project — + // `link`'s settled vocabulary (CLI-2167). A ref-shaped value (exactly 20 + // lowercase letters) is always treated as a project ref; a UUID resolves + // through `GET /v1/branches/{id}` directly, so it works in an unlinked + // directory (the parent ref is passed lazily and only evaluated for a + // branch-NAME lookup). + let ref: string; + let branch: string | undefined; + if (Option.isSome(requested) && !LEGACY_BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { + const target = requested.value; + branch = target; + const resolving = + output.format === "text" ? yield* output.task("Resolving branch...") : undefined; + ref = yield* legacyResolveBranchProjectRef(target, resolver.resolve(Option.none()), { + mapGetError: mapBranchResolveError, + mapFindError: mapBranchResolveError, + }).pipe( + Effect.tapError(() => resolving?.fail() ?? Effect.void), + Effect.catchTag( + "LegacyConfigDiffBranchResolveStatusError", + ( + cause, + ): Effect.Effect< + never, + LegacyConfigDiffBranchNotFoundError | LegacyConfigDiffBranchResolveStatusError + > => + cause.status === 404 + ? Effect.fail( + new LegacyConfigDiffBranchNotFoundError({ + message: `Branch "${legacySanitizeInlineName(target)}" not found. Run \`supabase branches list\` to see available branches.`, + }), + ) + : Effect.fail(cause), + ), + ); + yield* resolving?.clear() ?? Effect.void; + } else { + ref = yield* resolver.resolve(requested); + } + resolvedRef = ref; + + // 3. Apply the matching `[remotes.*]` overlay (ADR 0018) now that the + // target ref is known. Only configs that declare remotes reload — the + // common remotes-free config keeps the step-1 load. A config that both + // declares remotes and triggers a deprecation warning prints that + // warning twice (once per load); the alternative is validating after the + // network call, which is worse. + if (isRecord(loaded.document?.["remotes"])) { + loaded = yield* loadLocalConfig(ref); + } + + const context: LegacyConfigDiffContext = { + projectRef: ref, + branch, + appliedRemote: loaded.appliedRemote, + configSchema: loaded.schemaRef ?? CLI_CONFIG_SCHEMA_URL, + }; + yield* output.raw(legacyConfigDiffComparisonLine(context), "stderr"); + + // 4. Fetch the effective remote config (single read-only call). + const fetching = + output.format === "text" ? yield* output.task("Fetching remote config...") : undefined; + const response = yield* api.v2.getProjectConfig({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch( + mapLegacyHttpError({ + networkError: LegacyConfigDiffReadNetworkError, + statusError: LegacyConfigDiffReadStatusError, + networkMessage: (cause) => `failed to read project config: ${cause}`, + statusMessage: readStatusMessage, + }), + ), + ); + yield* fetching?.clear() ?? Effect.void; + + // 5. Project the response through CLI-2230's convergence normalizer (ADR + // 0021). A response the registry cannot narrow (out-of-domain mapped + // values) is a response problem, not a transport one: + // `ProjectConfigParseError` stays in the typed channel with its own + // `suggestion` and its purpose-built actionability adapter + // (`externalActionabilityByTag` splits caller misuse from genuine + // response problems). Anything else escaping the normalizer would be a + // bug in this package pairing, so it stays a defect. + const remote = yield* Effect.try({ + try: () => fromApiProjectConfig(response), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + cause instanceof ProjectConfigParseError ? Effect.fail(cause) : Effect.die(cause), + ), + ); + + // 6. Classify. The loaded pair carries the raw merged document (declared + // keys) and the env-var origins; `diffProjectConfig` derives the local + // convergence projection from it, so the same `ProjectConfigParseError` + // boundary applies here. + const changeSet = yield* Effect.try({ + try: () => diffProjectConfig({ local: loaded, remote }), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + cause instanceof ProjectConfigParseError ? Effect.fail(cause) : Effect.die(cause), + ), + ); + + const scope = legacyConfigDiffScope(response.data.attributes); + yield* output.raw(legacyConfigDiffScopeLine(scope), "stderr"); + + // 7. Emit. Both output mechanisms are honored, `--output` first (Legacy + // Shell Invariant #6): the machine formats encode the same structured + // payload the `--output-format json` envelope carries; `pretty` (and + // unset) falls through to `--output-format` handling. stdout stays + // payload-pure in every machine mode — diagnostics above went to stderr, + // and root.ts swaps in the quiet-progress layer for `-o` machine formats + // (CLI-1546). + const goFmt = Option.getOrUndefined(goOutputFlag); + if (goFmt !== undefined && goFmt !== "pretty") { + const payload = legacyConfigDiffPayload(changeSet, scope, context); + if (goFmt === "json") { + yield* output.raw(encodeGoJson(payload)); + } else if (goFmt === "yaml") { + yield* output.raw(encodeYaml(payload)); + } else if (goFmt === "toml") { + yield* output.raw(encodeToml(payload)); + } else { + yield* output.raw(encodeEnv(payload) + "\n"); + } + } else if (output.format !== "text") { + yield* output.success( + legacyConfigDiffSummaryMessage(changeSet), + legacyConfigDiffPayload(changeSet, scope, context), + ); + } else { + yield* output.raw(legacyRenderConfigDiffText(changeSet)); + } + + // 8. `--exit-code`: differences flip the exit status to 2 after the + // payload is out, without an error envelope corrupting machine output. + // Drift gets its OWN code — every failure exits 1, and a script's + // `config diff --exit-code || alert` must not fire on an expired token + // (`terraform plan -detailed-exitcode`'s 0/1/2 convention, with 1 kept + // for errors to match the rest of the CLI). + if (flags.exitCode && changeSet.counts.total > 0) { + yield* processControl.setExitCode(2); + } + }).pipe( + // Legacy Shell Invariant #1: telemetry flushes on EVERY invocation — + // including load/parse failures and branch-resolution failures — while + // the linked-project cache write needs a resolved ref, so it fires + // exactly when one exists. + Effect.ensuring( + Effect.suspend(() => + resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), + ), + ), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts new file mode 100644 index 0000000000..d0dc368cd3 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts @@ -0,0 +1,960 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + legacyJsonResponse, + legacyTransportFailure, + mockLegacyCliSettings, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyConfigDiff } from "./diff.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-config-diff-int-"); + +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const BRANCH_REF = "cccccccccccccccccccc"; + +function writeConfig(toml: string): string { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "config.toml"); + writeFileSync(path, toml); + return path; +} + +function writeProjectEnv(dotenv: string): void { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), dotenv); +} + +/** + * Schema-valid v2 project-config body whose managed values all sit at the + * local schema defaults, so an empty config.toml diffs clean against it. + */ +function v2Response( + opts: { + readonly ref?: string; + readonly attributes?: (attributes: Record) => Record; + } = {}, +) { + const attributes: Record = { + database: { + major_version: 17, + ssl_enforced: false, + network_restrictions: { + entitlement: "allowed", + status: "applied", + allowed_cidrs: [ + { address: "0.0.0.0/0", type: "v4" }, + { address: "::/0", type: "v6" }, + ], + }, + postgres_settings: {}, + }, + pooler: { + pool_mode: "transaction", + ignore_startup_parameters: "", + server_idle_timeout: 0, + server_lifetime: 0, + query_wait_timeout: 0, + reserve_pool_size: 0, + default_pool_size: 20, + max_client_conn: 100, + }, + // A realistic fresh-project GoTrue record at platform defaults — the + // largest, most transform-heavy mapping surface (durations, inversions, + // unconfigured sentinels, provisioning-default subjects) must run end to + // end and classify CLEANLY against an empty config.toml. An `auth: {}` + // here previously let two classifier blockers through untested. + auth: { + site_url: "http://127.0.0.1:3000", + uri_allow_list: "https://127.0.0.1:3000", + jwt_exp: 3600, + refresh_token_rotation_enabled: true, + security_refresh_token_reuse_interval: 10, + security_manual_linking_enabled: false, + disable_signup: false, + external_anonymous_users_enabled: false, + password_min_length: 6, + password_required_characters: "", + rate_limit_anonymous_users: 30, + rate_limit_token_refresh: 150, + rate_limit_otp: 30, + rate_limit_verify: 30, + rate_limit_sms_sent: 30, + rate_limit_web3: 30, + // GoTrue reports 0 hours for unconfigured session bounds; the mapping + // canonicalizes them to the STRING "0s" (registry unconfiguredValue). + sessions_timebox: 0, + sessions_inactivity_timeout: 0, + external_email_enabled: true, + mailer_secure_email_change_enabled: true, + mailer_autoconfirm: true, + security_update_password_require_reauthentication: false, + mailer_otp_length: 6, + mailer_otp_exp: 3600, + smtp_max_frequency: 1, + smtp_host: null, + // Provisioning-default subject lines (recorded config_auth fixtures). + mailer_subjects_invite: "You have been invited", + mailer_subjects_confirmation: "Confirm Your Signup", + mailer_subjects_recovery: "Reset Your Password", + mailer_subjects_magic_link: "Your Magic Link", + mailer_subjects_email_change: "Confirm Email Change", + mailer_subjects_reauthentication: "Confirm Reauthentication", + mailer_subjects_password_changed_notification: "Your password has been changed", + mailer_subjects_email_changed_notification: "Your email address has been changed", + mailer_subjects_phone_changed_notification: "Your phone number has been changed", + mailer_subjects_identity_linked_notification: "A new identity has been linked", + mailer_subjects_identity_unlinked_notification: "An identity has been unlinked", + mailer_subjects_mfa_factor_enrolled_notification: "A new MFA factor has been enrolled", + mailer_subjects_mfa_factor_unenrolled_notification: "An MFA factor has been unenrolled", + mailer_notifications_password_changed_enabled: false, + mailer_notifications_email_changed_enabled: false, + mailer_notifications_phone_changed_enabled: false, + mailer_notifications_identity_linked_enabled: false, + mailer_notifications_identity_unlinked_enabled: false, + mailer_notifications_mfa_factor_enrolled_enabled: false, + mailer_notifications_mfa_factor_unenrolled_enabled: false, + external_phone_enabled: false, + sms_autoconfirm: false, + sms_max_frequency: 5, + sms_otp_exp: 600, + sms_otp_length: 6, + external_github_enabled: false, + external_github_client_id: "", + mfa_totp_enroll_enabled: false, + mfa_totp_verify_enabled: false, + mfa_phone_enroll_enabled: false, + mfa_phone_verify_enabled: false, + mfa_phone_otp_length: 6, + mfa_phone_template: "Your code is {{ .Code }}", + mfa_phone_max_frequency: 5, + mfa_web_authn_enroll_enabled: false, + mfa_web_authn_verify_enabled: false, + mfa_max_enrolled_factors: 10, + }, + api: { + db_schema: "public,graphql_public", + db_extra_search_path: "public,extensions", + max_rows: 1000, + db_pool_acquisition_timeout: 10, + db_pool: null, + }, + realtime: { + private_only: false, + max_concurrent_users: 200, + max_events_per_second: 100, + max_bytes_per_second: 100000, + max_channels_per_client: 100, + max_joins_per_second: 100, + max_presence_events_per_second: 100, + max_payload_size_in_kb: 100, + presence_enabled: true, + suspend: false, + connection_pool: 10, + postgres_changes_pool: null, + }, + storage: { + file_size_limit: 52428800, + features: { + image_transformation: { enabled: false }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: false }, + iceberg_catalog: { enabled: false, max_namespaces: 5, max_tables: 10, max_catalogs: 2 }, + vector_buckets: { enabled: true, max_buckets: 10, max_indexes: 5 }, + }, + capabilities: { list_v2: true, iceberg_catalog: false }, + upstream_target: "main", + migration_version: "20240701", + database_pool_mode: "transaction", + }, + }; + return { + data: { + type: "project_config", + id: opts.ref ?? LEGACY_VALID_REF, + attributes: opts.attributes === undefined ? attributes : opts.attributes(attributes), + }, + }; +} + +/** V1GetABranch body for the branch-name `--project-ref` lookup. */ +const BRANCH_BY_NAME = { + id: BRANCH_UUID, + name: "staging", + project_ref: BRANCH_REF, + parent_project_ref: LEGACY_VALID_REF, + is_default: false, + persistent: true, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}; + +/** V1GetABranchConfig body for the UUID `--project-ref` lookup. */ +const BRANCH_CONFIG = { + ref: BRANCH_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "h", + db_port: 5432, +}; + +interface SetupOpts { + readonly toml?: string; + readonly dotenv?: string; + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly v2?: { status: number; body: unknown } | "fail"; + readonly branchByName?: { status: number; body: unknown }; + readonly branchByUuid?: { status: number; body: unknown }; + /** `false` simulates a directory with no linked project. */ + readonly linked?: boolean; + /** Overrides the process cwd (defaults to the temp workdir). */ + readonly cwd?: string; +} + +function setup(opts: SetupOpts = {}) { + if (opts.toml !== undefined) { + writeConfig(opts.toml); + } + if (opts.dotenv !== undefined) { + writeProjectEnv(opts.dotenv); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + const url = request.url; + if (url.includes("/v2/projects/")) { + if (opts.v2 === "fail") { + return Effect.fail(legacyTransportFailure(request)); + } + const v2 = opts.v2 ?? { status: 200, body: v2Response() }; + return Effect.succeed(legacyJsonResponse(request, v2.status, v2.body)); + } + if (url.includes("/v1/branches/")) { + const b = opts.branchByUuid ?? { status: 200, body: BRANCH_CONFIG }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + if (url.includes("/branches/")) { + const b = opts.branchByName ?? { status: 200, body: BRANCH_BY_NAME }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + return Effect.succeed(legacyJsonResponse(request, 200, {})); + }, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ + workdir: tempRoot.current, + ...(opts.linked === false ? { projectId: Option.none() } : {}), + }), + runtimeInfo: mockRuntimeInfo({ cwd: opts.cwd ?? tempRoot.current }), + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + processControl, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + ); + return { layer, out, api, telemetry, linkedProjectCache, processControl }; +} + +const noFlags = { + projectRef: Option.none(), + exitCode: false, +}; + +describe("legacy config diff integration", () => { + it.live("reports drift against the linked project without touching the config file", () => { + const { layer, out, processControl, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + const configPath = join(tempRoot.current, "supabase", "config.toml"); + const before = { + mtimeMs: statSync(configPath).mtimeMs, + contents: readFileSync(configPath, "utf8"), + }; + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + + // Never writes: mtime and contents unchanged after a run with differences. + expect(statSync(configPath).mtimeMs).toBe(before.mtimeMs); + expect(readFileSync(configPath, "utf8")).toBe(before.contents); + + expect(out.stderrText).toContain( + `Comparing against project ${LEGACY_VALID_REF} using base config`, + ); + expect(out.stderrText).toContain( + "Comparison scope: api, auth, database, pooler, realtime, storage", + ); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("local: 500"); + expect(out.stdoutText).toContain("remote: 1000"); + expect(out.stdoutText).toContain( + "1 difference found (1 update, 0 remote-only, 0 local-only).", + ); + // Differences without --exit-code leave the exit status alone. + expect(processControl.exitCode).toBeUndefined(); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("a clean config produces the success message and exit 0 even with --exit-code", () => { + const { layer, out, processControl } = setup({ toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--exit-code sets exit 2 when differences are found", () => { + // Drift gets its own exit code (2) so scripts can tell it from failure + // (1) — `config diff --exit-code || alert` must not fire on an expired + // token. + const { layer, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(processControl.exitCode).toBe(2); + }).pipe(Effect.provide(layer)); + }); + + it.live("declared properties the response does not carry are local_only", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth]\nsite_url = "https://local.example.com"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => { + // Drop site_url from the otherwise-complete auth record so the + // response genuinely does not carry the declared property. + const { site_url: _siteUrl, ...auth } = attributes["auth"] as Record; + return { ...attributes, auth }; + }, + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("auth.site_url [local-only]"); + expect(out.stdoutText).toContain('local: "https://local.example.com"'); + expect(out.stdoutText).toContain("remote: (not returned)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("env()-resolved values compare resolved and name the variable on drift", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = "env(PGRST_MAX_ROWS)"\n', + dotenv: "PGRST_MAX_ROWS=500\n", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("local: 500 (from env PGRST_MAX_ROWS)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("declared secrets are masked, not compared, and never count for --exit-code", () => { + const { layer, out, processControl } = setup({ + toml: [ + 'project_id = "test"', + "[auth.external.github]", + "enabled = true", + 'client_id = "id"', + 'secret = "env(GITHUB_SECRET)"', + "", + ].join("\n"), + dotenv: "GITHUB_SECRET=shh\n", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + external_github_enabled: true, + external_github_client_id: "id", + // The platform reports secret fields as HMAC digests, never + // plaintext — the digest must not surface either. + external_github_secret: "v1,whmac-sha256-digest-of-the-secret", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(out.stdoutText).toContain( + "Note: 1 credential value not compared (masked by the API): auth.external.github.secret", + ); + // The secret STRING never leaks — neither the local plaintext resolved + // from the env var nor the API-reported HMAC digest, on either stream. + // Pins the "secrets never leak" claim against formatter changes. + const everything = out.stdoutText + out.stderrText; + expect(everything).not.toContain("shh"); + expect(everything).not.toContain("whmac-sha256"); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("secret strings never reach the machine payload either", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[auth.external.github]", + "enabled = true", + 'client_id = "id"', + 'secret = "env(GITHUB_SECRET)"', + "", + ].join("\n"), + dotenv: "GITHUB_SECRET=shh\n", + format: "json", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + external_github_enabled: true, + external_github_client_id: "id", + external_github_secret: "v1,whmac-sha256-digest-of-the-secret", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + const serialized = JSON.stringify(success); + expect(serialized).not.toContain("shh"); + expect(serialized).not.toContain("whmac-sha256"); + // The message itself carries the masked caveat, so `.message` echoers + // never claim full sync. + expect(success?.message).toContain("masked by the API"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a matching [remotes.*] block becomes the local operand", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[api]", + "max_rows = 500", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.staging.api]", + "max_rows = 1000", + "", + ].join("\n"), + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain( + `Comparing against project ${LEGACY_VALID_REF} using [remotes.staging]`, + ); + // The merged branch operand (max_rows = 1000) matches the remote, so the + // base config's 500 must NOT surface as drift. + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("a branch-named --project-ref resolves via the parent project", () => { + const { layer, out, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }); + expect(out.stderrText).toContain( + `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, + ); + const urls = api.requests.map((request) => request.url); + expect( + urls.some((url) => url.includes(`/v1/projects/${LEGACY_VALID_REF}/branches/staging`)), + ).toBe(true); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("a UUID --project-ref resolves directly, even in an unlinked directory", () => { + // The UUID endpoint (`GET /v1/branches/{id}`) does not use a parent + // project ref, so the lookup must not demand a linked directory — the + // parent is only resolved (lazily) for branch-NAME lookups. + const { layer, api, out } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + linked: false, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some(BRANCH_UUID) }); + const urls = api.requests.map((request) => request.url); + expect(urls.some((url) => url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + // A UUID is an identifier, not a display name — never quoted as one. + expect(out.stderrText).toContain( + `Comparing against branch ${BRANCH_UUID} (project ref ${BRANCH_REF})`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("a ref-shaped --project-ref never touches the branches API", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some(BRANCH_REF) }); + const urls = api.requests.map((request) => request.url); + expect(urls.some((url) => url.includes("/branches/"))).toBe(false); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("an unknown branch fails with a branches-list suggestion", () => { + const { layer, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 404, body: { message: "not found" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("ghost") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffBranchNotFoundError"); + expect(rendered).toContain('Branch \\"ghost\\" not found'); + expect(rendered).toContain("supabase branches list"); + // Legacy Shell Invariant #1: telemetry flushes on failure too; the + // linked-project cache stays untouched because no target ref resolved. + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("a non-404 branch lookup failure keeps its status error", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 500, body: { message: "boom" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffBranchResolveStatusError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a missing config file points at supabase init before any resolution", () => { + const { layer, telemetry, api } = setup(); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffLoadConfigError"); + expect(rendered).toContain("supabase/config.toml: file not found"); + expect(rendered).toContain("supabase init"); + // The load runs before any network call, and telemetry still flushes. + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("a malformed config aborts before any network call, even with a branch target", () => { + // A broken TOML must not burn a branch-resolution round trip — the local + // document is parsed and validated first. + const { layer, api, telemetry } = setup({ toml: "not [valid toml\n" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("failed to parse supabase/config.toml"); + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("a malformed config file fails as a parse error", () => { + const { layer } = setup({ toml: "not [valid toml\n" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }); + + it.live("duplicate [remotes.*] project_ids abort the load", () => { + const { layer } = setup({ + toml: [ + 'project_id = "test"', + "[remotes.a]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.b]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + ].join("\n"), + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffLoadConfigError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a remote config transport failure maps to the read network error", () => { + const { layer, telemetry } = setup({ toml: 'project_id = "test"\n', v2: "fail" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadNetworkError"); + // Telemetry still flushes on failure via Effect.ensuring. + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("an out-of-domain mapped value in the response keeps its typed parse error", () => { + // Wire-valid but semantically impossible: the registry's typed throw + // (ADR 0021 API-arm family) stays in the typed channel as + // ProjectConfigParseError, keeping its upstream suggestion and its + // purpose-built actionability adapter instead of masquerading as a + // network failure. + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + storage: { + ...(attributes["storage"] as Record), + file_size_limit: -1, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("ProjectConfigParseError"); + expect(rendered).toContain("Could not read the project config"); + // The upstream remedy survives to the renderer instead of being + // stringified away. + expect(rendered).toContain("suggestion"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a remote config error status maps to the read status error", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 403, body: { message: "forbidden" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadStatusError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output-format json emits the structured change set", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success).toBeDefined(); + expect(success?.message).toContain("1 config difference found."); + const data = success?.data as Record; + expect(data["target"]).toMatchObject({ + project_ref: LEGACY_VALID_REF, + local_scope: "base", + }); + // `schema_version` is the PAYLOAD contract's version; the user's + // `$schema` document reference travels separately as `config_schema`. + expect(data["schema_version"]).toBe(1); + expect(typeof data["config_schema"]).toBe("string"); + expect(data["scope"]).toEqual({ + present: ["api", "auth", "database", "pooler", "realtime", "storage"], + missing: [], + }); + expect(data["changes"]).toEqual([ + { path: ["api", "max_rows"], class: "update", declared: true, local: 500, remote: 1000 }, + ]); + expect(data["counts"]).toEqual({ update: 1, remote_only: 0, local_only: 0, total: 1 }); + expect(data["masked"]).toEqual([]); + expect(data["unmanaged"]).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output-format stream-json reports zero differences as a success result", () => { + const { layer, out } = setup({ toml: 'project_id = "test"\n', format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.message).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o json emits the raw payload on a payload-pure stdout", () => { + // Legacy Shell Invariant #6: `--output` is honored and takes priority. + // No envelope — the payload object itself, parseable from stdout. + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + goOutput: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const payload = JSON.parse(out.stdoutText) as Record; + expect(payload["changes"]).toEqual([ + { path: ["api", "max_rows"], class: "update", declared: true, local: 500, remote: 1000 }, + ]); + expect(payload["counts"]).toMatchObject({ total: 1 }); + // The envelope fields of --output-format json must not leak in. + expect(payload["message"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o pretty falls through to the text renderer", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + goOutput: "pretty", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("1 difference found"); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o yaml/toml/env encode the payload through the shared encoders", () => { + const run = (goOutput: "yaml" | "toml" | "env", assert: (stdout: string) => void) => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + goOutput, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + assert(out.stdoutText); + }).pipe(Effect.provide(layer)); + }; + return Effect.gen(function* () { + yield* run("yaml", (stdout) => { + expect(stdout).toContain("class: update"); + }); + yield* run("toml", (stdout) => { + expect(stdout).toContain('class = "update"'); + }); + yield* run("env", (stdout) => { + expect(stdout).toContain("COUNTS_TOTAL=1"); + }); + }); + }); + + it.live("a fetch failure in json mode still maps cleanly without a spinner", () => { + const { layer } = setup({ toml: 'project_id = "test"\n', v2: "fail", format: "json" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadNetworkError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("json payload carries the remotes scope and env variable annotations", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.staging.api]", + 'max_rows = "env(PGRST_MAX_ROWS)"', + "", + ].join("\n"), + dotenv: "PGRST_MAX_ROWS=500\n", + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as Record; + expect(data["target"]).toMatchObject({ local_scope: "remotes.staging" }); + expect(data["changes"]).toEqual([ + { + path: ["api", "max_rows"], + class: "update", + declared: true, + local: 500, + remote: 1000, + env_variables: ["PGRST_MAX_ROWS"], + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("remote-only drift renders (unset) locals distinguishably from empty ones", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + database: { + ...(attributes["database"] as Record), + postgres_settings: { work_mem: "64MB" }, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("db.settings.work_mem [remote-only]"); + expect(out.stdoutText).toContain("local: (unset)"); + expect(out.stdoutText).toContain('remote: "64MB"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("remote-only drift on a defaulted path shows the local schema default", () => { + // The someone-changed-it-in-the-dashboard case: the file never declares + // api.max_rows, the remote reports 250, and a `config push` would write + // the schema default 1000 over it — the output must say so instead of + // implying the key exists only remotely. + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + api: { ...(attributes["api"] as Record), max_rows: 250 }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [remote-only]"); + expect(out.stdoutText).toContain( + "local: 1000 (schema default — not declared in config.toml)", + ); + expect(out.stdoutText).toContain("remote: 250"); + }).pipe(Effect.provide(layer)); + }); + + it.live("the config file is read relative to --workdir, not the invoking directory", () => { + // `--workdir ../other` must compare `../other`'s config.toml against + // `../other`'s linked project — reading the invoking directory's file + // would silently diff the WRONG config (the resolver and linked-project + // cache already use the workdir). The ambient cwd here points somewhere + // with no supabase/ directory at all; only cliSettings.workdir knows + // where the project lives. + const elsewhere = join(tempRoot.current, "unrelated-cwd"); + mkdirSync(elsewhere, { recursive: true }); + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + cwd: elsewhere, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [update]"); + }).pipe(Effect.provide(layer)); + }); + + it.live("hostile names cannot inject ANSI or forge output lines in text mode", () => { + // Path segments are attacker-influenced ([remotes.*] names and + // sms.test_otp keys are unconstrained TOML keys) — a name carrying an + // escape byte or newline must not reach the terminal raw, where it could + // recolor output or append a fake "No config differences found." line. + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + '[remotes."evil\\u001B[31mred"]', + `project_id = "${LEGACY_VALID_REF}"`, + '[remotes."evil\\u001B[31mred".api]', + "max_rows = 500", + "", + ].join("\n"), + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain("[remotes.evil[31mred]"); + expect(out.stderrText).not.toContain("\u001b"); + expect(out.stdoutText).not.toContain("\u001b"); + }).pipe(Effect.provide(layer)); + }); + + it.live("an empty block record is reported not-returned, not silently compared", () => { + // A permission-truncated `auth: {}` is schema-valid; claiming it was + // compared while every auth key silently vanishes would make a red CI + // unfixable by any file edit. + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ attributes: (attributes) => ({ ...attributes, auth: {} }) }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain( + "Comparison scope: api, database, pooler, realtime, storage (not returned: auth)", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("a declared path push cannot communicate surfaces in the unmanaged note", () => { + // auth.oauth_server is dropped from the local projection entirely (push + // has no oauth_server handling), so a declared `enabled = true` + // disagreeing with the remote's default `false` cannot be a change entry + // — but it must not vanish silently either. + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth.oauth_server]\nenabled = true\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { oauth_server_enabled: false }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + expect(out.stdoutText).toContain( + "Note: 1 declared property cannot be pushed and was not compared: auth.oauth_server.enabled", + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts new file mode 100644 index 0000000000..bcec195898 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts @@ -0,0 +1,25 @@ +import { expect } from "vitest"; + +import { requireLiveSuccess, test } from "../../../../../tests/helpers/live.ts"; + +// Golden path only: the one thing mocks cannot prove is the real +// `GET /v2/projects/{ref}/config` response shape (the GoTrue-keyed auth +// record especially) decoding and classifying cleanly. Branch coverage lives +// in diff.integration.test.ts. The `workspace` fixture behind `cli` is a +// fresh `supabase init` project directory. +test("diffs a freshly-initialized config against the project", async ({ cli, project }) => { + const result = await cli(["config", "diff", "--project-ref", project.ref]); + expect(`${result.stdout}${result.stderr}`).not.toContain("Unauthorized"); + expect(result.stderr).toContain(`Comparing against project ${project.ref} using base config`); + expect(result.stderr).toContain("Comparison scope:"); + // The GoTrue-keyed auth record — the one surface mocks cannot prove — must + // classify CLEANLY against a fresh config: the platform's reports of + // unconfigured state (session zeros canonicalized to "0s", the + // provisioning-default mailer subjects, disabled notification toggles) are + // suppressed by the registry's unconfiguredValue baselines, not flagged as + // drift. Asserting only exit 0 here would let that noise through silently. + const authChangeLines = result.stdout.split("\n").filter((line) => line.startsWith("auth.")); + expect(authChangeLines, result.stdout).toEqual([]); + // Read-only success regardless of drift (no --exit-code passed). + requireLiveSuccess(result, "config diff"); +}); diff --git a/apps/cli/src/legacy/commands/config/push/push.command.ts b/apps/cli/src/legacy/commands/config/push/push.command.ts index 05854892e9..22fdc89779 100644 --- a/apps/cli/src/legacy/commands/config/push/push.command.ts +++ b/apps/cli/src/legacy/commands/config/push/push.command.ts @@ -32,7 +32,11 @@ export const legacyConfigPushCommand = Command.make("push", config).pipe( ]), Command.withHandler((flags) => legacyConfigPush(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + // Unlike `config diff`'s branch-accepting flag, push's `--project-ref` + // is ref-only, so its value is always safe to log verbatim — keeping + // the config family's telemetry consistent (documented safe list in + // apps/cli/CLAUDE.md). + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ba0004663c..d038ca645a 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -3,12 +3,12 @@ import { findCliProjectRoot, loadCliConfig } from "@supabase/config/effect"; import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { legacyAssertDecryptableSecrets, legacyLoadProjectEnv, @@ -88,9 +88,9 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const output = yield* Output; const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; + const cliSettings = yield* LegacyCliSettings; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; - const runtimeInfo = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // `--yes` OR `SUPABASE_YES`. `config push` imports `supabase/.env` before @@ -100,7 +100,11 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // (walking up, same as `loadCliConfig` below and the workdir change // before config load), so a push from a subdirectory still reads the // project root's `supabase/.env`. - const projectRoot = (yield* findCliProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; + // Resolved against `cliSettings.workdir` — the same root the project-ref + // resolver and the linked-project cache use — so `--workdir ../other` + // pushes `../other`'s config.toml, never the invoking directory's file to + // another root's linked project. + const projectRoot = (yield* findCliProjectRoot(cliSettings.workdir)) ?? cliSettings.workdir; const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // dotenvx private keys for decrypting `encrypted:` secrets, from the shell @@ -131,7 +135,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // Pass `ref` so a matching `[remotes.*]` block is merged over the base // config before decode. A duplicate `project_id` across remotes surfaces // an established error message. - const loaded = yield* loadCliConfig(runtimeInfo.cwd, { + const loaded = yield* loadCliConfig(cliSettings.workdir, { projectRef: ref, goViperCompat: true, }).pipe( diff --git a/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts b/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts new file mode 100644 index 0000000000..5e6a9d2274 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts @@ -0,0 +1,73 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; + +/** + * Project ref pattern shared by every Management-API endpoint that accepts a + * 20-lowercase-letter project reference. + */ +export const LEGACY_BRANCH_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +/** + * Permissive UUID pattern (any 8-4-4-4-12 hex sequence) — accepts any RFC 4122 + * variant including v6/v7 and version 0, matching the established liberal + * acceptance rather than the v1–v5 + variant-1 subset. + */ +export const LEGACY_BRANCH_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Per-family error mapping for {@link legacyResolveBranchProjectRef}: each + * caller keeps its own tagged error classes (built with `mapLegacyHttpError`) + * so error identities, messages, and actionability stay family-owned. + */ +export interface LegacyBranchRefResolveMappers { + /** Maps a `GET /v1/branches/{branch_id}` (UUID lookup) failure. */ + readonly mapGetError: (cause: SupabaseApiError) => Effect.Effect; + /** Maps a `GET /v1/projects/{ref}/branches/{name}` (name lookup) failure. */ + readonly mapFindError: (cause: SupabaseApiError) => Effect.Effect; +} + +/** + * Resolves an arbitrary branch identifier to its project ref: + * + * 1. If the input matches `^[a-z]{20}$`, it's already a project ref — return as-is. + * 2. Else if the input is a UUID, call `V1GetABranchConfig` (`GET /v1/branches/{id}`) + * and return `JSON200.ref`. + * 3. Otherwise treat as a branch name under the linked project ref: call + * `V1GetABranch` (`GET /v1/projects/{ref}/branches/{name}`) and return + * `JSON200.project_ref`. + * + * The parent project ref is required only for path 3, so it may be passed + * lazily as an Effect — it is evaluated exactly then, never for a ref-shaped + * or UUID input. That keeps `--project-ref ` working in an unlinked + * directory: the UUID endpoint does not use a parent ref, so requiring one + * up front would fail invocations the API itself can serve. + */ +export function legacyResolveBranchProjectRef( + input: string, + projectRef: string | Effect.Effect, + mappers: LegacyBranchRefResolveMappers, +) { + return Effect.gen(function* () { + if (LEGACY_BRANCH_PROJECT_REF_PATTERN.test(input)) { + return input; + } + + const api = yield* LegacyPlatformApi; + + if (LEGACY_BRANCH_UUID_PATTERN.test(input)) { + const detail = yield* api.v1 + .getABranchConfig({ branch_id_or_ref: input }) + .pipe(Effect.catch(mappers.mapGetError)); + return detail.ref; + } + + const parentRef = typeof projectRef === "string" ? projectRef : yield* projectRef; + const branch = yield* api.v1 + .getABranch({ ref: parentRef, name: input }) + .pipe(Effect.catch(mappers.mapFindError)); + return branch.project_ref; + }); +} diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 963e9b295e..dcdd852e20 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -146,6 +146,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "status", "sub", "swift-access-control", + "target", "template", "timestamp", "to", diff --git a/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts b/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts index 4998105326..2338e575cf 100644 --- a/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts +++ b/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts @@ -54,13 +54,13 @@ type _RemovedTopLevelKeys = AssertNever< Exclude >; -type GeneratedDatabase = GeneratedAttrs["database"]; +type GeneratedDatabase = NonNullable; type MirrorDatabase = NonNullable; type _AddedDatabaseKeys = AssertNever>; type _RemovedDatabaseKeys = AssertNever>; -type GeneratedPostgresSettings = GeneratedDatabase["postgres_settings"]; +type GeneratedPostgresSettings = NonNullable; type MirrorPostgresSettings = NonNullable; type _AddedPostgresSettingsKeys = AssertNever< @@ -70,7 +70,7 @@ type _RemovedPostgresSettingsKeys = AssertNever< Exclude >; -type GeneratedNetworkRestrictions = GeneratedDatabase["network_restrictions"]; +type GeneratedNetworkRestrictions = NonNullable; type MirrorNetworkRestrictions = NonNullable; type _AddedNetworkRestrictionsKeys = AssertNever< @@ -97,31 +97,31 @@ type _RemovedAllowedCidrsElementKeys = AssertNever< Exclude >; -type GeneratedPooler = GeneratedAttrs["pooler"]; +type GeneratedPooler = NonNullable; type MirrorPooler = NonNullable; type _AddedPoolerKeys = AssertNever>; type _RemovedPoolerKeys = AssertNever>; -type GeneratedApi = GeneratedAttrs["api"]; +type GeneratedApi = NonNullable; type MirrorApi = NonNullable; type _AddedApiKeys = AssertNever>; type _RemovedApiKeys = AssertNever>; -type GeneratedRealtime = GeneratedAttrs["realtime"]; +type GeneratedRealtime = NonNullable; type MirrorRealtime = NonNullable; type _AddedRealtimeKeys = AssertNever>; type _RemovedRealtimeKeys = AssertNever>; -type GeneratedStorage = GeneratedAttrs["storage"]; +type GeneratedStorage = NonNullable; type MirrorStorage = NonNullable; type _AddedStorageKeys = AssertNever>; type _RemovedStorageKeys = AssertNever>; -type GeneratedStorageFeatures = GeneratedStorage["features"]; +type GeneratedStorageFeatures = NonNullable; type MirrorStorageFeatures = NonNullable; type _AddedStorageFeaturesKeys = AssertNever< @@ -135,7 +135,7 @@ type _RemovedStorageFeaturesKeys = AssertNever< // `registry.ts`), so — unlike sibling `purge_cache`, which the mirror widens // to `Schema.Unknown` since no row maps it — they stay concretely typed // `{enabled}` structs on the mirror side, each worth its own key-set pair. -type GeneratedImageTransformation = GeneratedStorageFeatures["image_transformation"]; +type GeneratedImageTransformation = NonNullable; type MirrorImageTransformation = NonNullable; type _AddedImageTransformationKeys = AssertNever< @@ -145,7 +145,7 @@ type _RemovedImageTransformationKeys = AssertNever< Exclude >; -type GeneratedS3Protocol = GeneratedStorageFeatures["s3_protocol"]; +type GeneratedS3Protocol = NonNullable; type MirrorS3Protocol = NonNullable; type _AddedS3ProtocolKeys = AssertNever>; @@ -153,7 +153,7 @@ type _RemovedS3ProtocolKeys = AssertNever< Exclude >; -type GeneratedIcebergCatalog = GeneratedStorageFeatures["iceberg_catalog"]; +type GeneratedIcebergCatalog = NonNullable; type MirrorIcebergCatalog = NonNullable; type _AddedIcebergCatalogKeys = AssertNever< @@ -163,7 +163,7 @@ type _RemovedIcebergCatalogKeys = AssertNever< Exclude >; -type GeneratedVectorBuckets = GeneratedStorageFeatures["vector_buckets"]; +type GeneratedVectorBuckets = NonNullable; type MirrorVectorBuckets = NonNullable; type _AddedVectorBucketsKeys = AssertNever< diff --git a/docs/adr/0022-config-diff-classification-and-managed-surface.md b/docs/adr/0022-config-diff-classification-and-managed-surface.md new file mode 100644 index 0000000000..ca6f24cd5f --- /dev/null +++ b/docs/adr/0022-config-diff-classification-and-managed-surface.md @@ -0,0 +1,53 @@ +# 0022. Config Diff Classification and Managed Surface + +**Status**: accepted +**Date**: 2026-08-20 (registry consolidation 2026-08-28; review revision 2026-08-31) + +## Problem Statement + +`supabase config diff` (CLI-2156) compares the local `config.toml` against the effective configuration `GET /v2/projects/{ref}/config` reports, and `config pull` (CLI-2064) will delegate to the same engine. Three classification problems make a naive walk wrong: + +1. **Key-set asymmetry.** The earlier POC walked only keys present in the remote response, so a property the file declares and the remote doesn't return was structurally invisible. The inverse walk (local keys only) would hide remote-side drift the file never mentions. +2. **Managed vs. unmanaged.** Most of `config.toml` configures the _local_ stack — `[studio]`, ports, image pins, `[db.migrations]` — and has no platform counterpart. Reporting those as drift is noise; deciding which properties the platform manages needs a source of truth that cannot drift from the code that reads the response. +3. **Incomparable values.** The platform masks secrets (HMAC, never plaintext), reports byte counts where the file writes `"50MiB"`, comma-joins arrays, and types some scalars differently than the schema. Comparing representations instead of meanings misreports drift; silently skipping them misreports cleanliness. + +This ADR was first accepted with a self-contained translation table inside `config-diff.ts` (a `read`-function-per-managed-path port of the Go CLI's `FromRemoteAuthConfig` at `7b469f5b3`). CLI-2230 (PR supabase/cli#6339) then landed the registry-driven `ProjectConfig` convergence normalizers — the same translation, shared with Studio, governed by ADR 0019 (passthrough), ADR 0020 (naming), and ADR 0021 (convergence semantics). Keeping two translations would have been exactly the parallel code path the repo's refactoring policy forbids, so the classifier now consumes the registry; this revision records the consolidated design. + +## Decision + +`@supabase/config` owns the comparison core as pure, synchronous functions (`config-diff.ts`), with no dependency on `@supabase/api`, output formatting, or command flags — layered on CLI-2230's registry rather than a translation of its own: + +- **Both operands are `ProjectConfig` convergence projections (ADR 0021).** The caller builds the local operand with `fromConfigDocument({config, document})` — raw-presence-masked, canonicalized, secret-omitting — and the remote operand with `fromApiProjectConfig(response)`. All wire-shape knowledge (renames, inversions, unit conversions, the GoTrue key table) lives in `projectConfigMappingRows`, once, shared with Studio and the future push mapper. +- **The managed surface is the registry's.** The classifier walks the union of both operands' leaf paths filtered by `isComparableProjectConfigPath` — a path with no registry row is _unmanaged by construction_ and never reported (`[studio]`, ports, image pins, `[realtime]` locals, `workers`). +- **Three-way classification per comparable path**, driven by _declared_ presence (the raw pre-decode document), which a decoded config cannot recover: `update` (declared + reported, values differ), `remote_only` (reported while undeclared — or while push cannot communicate the declared state — and differing from the suppression baseline), `local_only` (a declared local projection value the response did not account for: parsed-but-never-pushed attributes and permission-truncated responses). The local operand follows ADR 0018: the branch's merged effective config when the target ref matches a `[remotes.*]` block's `project_id`, the base config otherwise. +- **`remote_only` suppression baseline**: the default config's own convergence projection, falling back — for push-gated containers the projection is silent on — to the raw default config's value (`db.network_restrictions`' allow-all default IS the platform's unconfigured state), then to the registry row's declared `unconfiguredValue` (the platform's own report of an unconfigured feature — GoTrue's `sessions_timebox: 0` canonicalizes to the STRING `"0s"`, and the provisioning-default mailer subjects are real strings; both are pinned by the recorded `config_auth` fixtures). "Unconfigured" is never inferred from type-level zero values — canonicalization can turn a platform zero into a non-zero shape, and PR #6295's review reproduced 15 noise lines on an untouched staging project from exactly that inference. A path with no baseline at any tier reports rather than guesses (over-report over under-report). An unconfigured project therefore diffs clean instead of flooding with platform-default noise. +- **Equality is meaning-based**: the normalizers canonicalize representations (durations, byte sizes, comma-joins) per ADR 0021, and the classifier's residual equality tolerates string/number and string/boolean scalar skew. Whether an array is a SET or a SEQUENCE is per-field wire knowledge and lives on the registry row (`arrayEquality`), defaulting to sequence — `api.schemas`' first entry is PostgREST's default schema and `api.extra_search_path` is a literal `search_path`, so reordering is drift; `auth.additional_redirect_urls` opts into set semantics. +- **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops (ADR 0021's unmanaged-by-push families — `auth.oauth_server`, disabled `storage.analytics`/`vector`, sentinel-pruned siblings) can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission (the generated API contract keeps every block and block key optional via `openapi-overrides.json` for exactly this reason). +- **Change entries carry what a consumer needs verbatim**: `path` is a SEGMENT ARRAY end to end (an `sms.test_otp` phone-number key may itself contain a `.`; joining is display-only), `declared` distinguishes file-written values from schema-materialized defaults, and a `remote_only` entry keeps the materialized local default so "what would `config push` change?" is answerable from the change alone — the primary dashboard-drift use case, and what `config pull` will need to write back. +- **Command surface (review revision, PR #6295)**: the target flag is `link`'s settled vocabulary — a single `--project-ref` accepting a project ref or a branch name/UUID (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke `--target`. The global `-o/--output` flag is honored per Legacy Shell Invariant #6, encoding the same structured payload as `--output-format json`. `--exit-code` gives drift its own exit code `2`, keeping `1` for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (`schema_version: 1`, an integer) separately from the user-controlled `$schema` document reference (`config_schema`). +- The interpolation pipeline records the resolving env var name on `"environment"` value origins, so a change on an `env()`-fed property can name the variable involved. + +The command layer (`apps/cli/src/legacy/commands/config/diff/`) only resolves the target, fetches, projects, and renders. Per ADR 0021's rendering rule, reported "local" values are the convergence projection — what pushing the file would produce hosted — not the file's literal spelling. + +## Considered Alternatives + +1. **Derive the managed set from the response keys** (the POC's approach): whatever the remote returns is what's compared. Structurally blind to `local_only`, and a permission-truncated response silently shrinks the comparison. +2. **Schema annotations (`x-managed`) on each property**: keeps the knowledge in the schema, but the annotation and the response-reading code can disagree, and the annotation cannot express per-property wire transforms that the registry rows carry. +3. **The original self-contained translation table** (this ADR's first accepted form): correct, but once CLI-2230 landed the registry it became a ~600-line parallel implementation of the same mapping with independently-drifting transforms. Superseded by the consolidation above. +4. **Reuse `config push`'s `config-sync` diffing** (`apps/cli/src/legacy/commands/config/push/config-sync/`): those helpers produce per-service unified-diff _text_ against the v1 per-service endpoints for push previews, not a typed change set, and they live in the CLI app. They remain the Go-parity push path; the registry rows were themselves mined from them (CLI-2230), and a shared push mapper is that ticket's tracked follow-up. + +## Consequences + +- `config pull` gets its comparison engine for free: the change set is typed data, and `fromApiProjectConfig` already produces the local representation of any remote value it needs to write. +- Adding a newly platform-managed property is one registry row (shared with Studio); forgetting it means the property is silently unmanaged (never misreported as drift), which fails safe. +- The wire shape is pinned by `apps/cli`'s `project-config-api-drift.unit.test.ts` type-drift guard plus the registry's lenient decode (ADR 0019); API evolution degrades to "not reported" rather than crashing, and the live test is the tripwire. +- Platform defaults that diverge from schema defaults surface as `remote_only` drift by design — the file's meaning is defined by the schema defaults reference (ADR 0018), not by what the platform would have picked. +- The classifier inherits ADR 0021's limits verbatim: ADR 0021's "honest-but-push-unactionable" residual category (unconditionally-mapped fields with no local-silence signal, tracked on CLI-2266) surfaces here as `remote_only` entries a user cannot fix by editing their file. + +## Related Decisions + +- [ADR 0018](0018-sparse-config-subtraction.md): Sparse Config Subtraction — the defaults baseline and merged-remote-block local operand this classification builds on. +- [ADR 0019](0019-config-api-response-passthrough.md): Raw API-Response Passthrough — the leniency boundary and `_apiResponse` escape hatch of the remote operand. +- [ADR 0020](0020-config-naming-vocabulary.md): Config Naming Vocabulary — `CliConfig` vs `ProjectConfig`. +- [ADR 0021](0021-projectconfig-convergence-semantics.md): ProjectConfig Convergence Semantics — what the two operand-producing normalizers compute, and why comparing them structurally is meaningful. +- [ADR 0006](0006-environment-management.md): Environment Management — remote blocks and branch mapping semantics. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3f38bc15fd..acb10ea67e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -62,6 +62,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0019 | [Raw API-Response Passthrough on API-Sourced Config](0019-config-api-response-passthrough.md) | accepted | | 0020 | [Config Naming Vocabulary](0020-config-naming-vocabulary.md) | accepted | | 0021 | [ProjectConfig Convergence Semantics](0021-projectconfig-convergence-semantics.md) | accepted | +| 0022 | [Config Diff Classification and Managed Surface](0022-config-diff-classification-and-managed-surface.md) | accepted | ## Template diff --git a/packages/api/scripts/openapi-overrides.json b/packages/api/scripts/openapi-overrides.json index b53de50a96..8393c85058 100644 --- a/packages/api/scripts/openapi-overrides.json +++ b/packages/api/scripts/openapi-overrides.json @@ -626,7 +626,7 @@ { "op": "remove", "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints", - "$comment": "CLI-2157: the platform's v2 spec gives all 10 project-webhook operations the shared operationId \"allV2ProjectsByRefWebhooks\" (and all 10 org-webhook operations share \"allV2OrganizationsBySlugWebhooks\") — duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist." + "$comment": "CLI-2157: the platform's v2 spec gives all 10 project-webhook operations the shared operationId \"allV2ProjectsByRefWebhooks\" (and all 10 org-webhook operations share \"allV2OrganizationsBySlugWebhooks\") \u2014 duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist." }, { "op": "remove", @@ -656,7 +656,7 @@ { "op": "remove", "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints", - "$comment": "CLI-2157: the platform's v2 spec gives all 10 org-webhook operations the shared operationId \"allV2OrganizationsBySlugWebhooks\" (and all 10 project-webhook operations share \"allV2ProjectsByRefWebhooks\") — duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist." + "$comment": "CLI-2157: the platform's v2 spec gives all 10 org-webhook operations the shared operationId \"allV2OrganizationsBySlugWebhooks\" (and all 10 project-webhook operations share \"allV2ProjectsByRefWebhooks\") \u2014 duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist." }, { "op": "remove", @@ -723,5 +723,176 @@ "description": "Postgres engine version. If not provided, the latest version will be used." }, "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.postgres_engine in the same spec." + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/required", + "value": ["database", "pooler", "auth", "api", "realtime", "storage"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/database/required", + "value": ["major_version", "ssl_enforced", "network_restrictions", "postgres_settings"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/database/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/database/properties/network_restrictions/required", + "value": ["entitlement", "status", "allowed_cidrs"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/database/properties/network_restrictions/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/pooler/required", + "value": [ + "pool_mode", + "ignore_startup_parameters", + "server_idle_timeout", + "server_lifetime", + "query_wait_timeout", + "reserve_pool_size", + "default_pool_size", + "max_client_conn" + ] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/pooler/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/api/required", + "value": [ + "db_schema", + "db_extra_search_path", + "max_rows", + "db_pool_acquisition_timeout", + "db_pool" + ] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/api/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/realtime/required", + "value": [ + "private_only", + "max_concurrent_users", + "max_events_per_second", + "max_bytes_per_second", + "max_channels_per_client", + "max_joins_per_second", + "max_presence_events_per_second", + "max_payload_size_in_kb", + "presence_enabled", + "suspend", + "connection_pool", + "postgres_changes_pool" + ] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/realtime/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/required", + "value": [ + "file_size_limit", + "features", + "capabilities", + "upstream_target", + "migration_version", + "database_pool_mode" + ] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/required", + "value": [ + "image_transformation", + "s3_protocol", + "purge_cache", + "iceberg_catalog", + "vector_buckets" + ] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/image_transformation/required", + "value": ["enabled"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/image_transformation/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/s3_protocol/required", + "value": ["enabled"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/s3_protocol/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/purge_cache/required", + "value": ["enabled"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/purge_cache/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/iceberg_catalog/required", + "value": ["enabled", "max_namespaces", "max_tables", "max_catalogs"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/iceberg_catalog/required", + "value": [] + }, + { + "op": "test", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/vector_buckets/required", + "value": ["enabled", "max_buckets", "max_indexes"] + }, + { + "op": "replace", + "path": "/components/schemas/V2ProjectConfigResponse/properties/data/properties/attributes/properties/storage/properties/features/properties/vector_buckets/required", + "value": [] } ] diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 659086a4aa..0dfb2af82b 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -10874,148 +10874,367 @@ export const V2GetProjectConfigOutput = Schema.Struct({ type: Schema.Literal("project_config").annotate({ description: "Resource type." }), id: Schema.String.annotate({ description: "Project ref." }), attributes: Schema.Struct({ - database: Schema.Struct({ - major_version: Schema.Number.annotate({ - description: - "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version.", - }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), + database: Schema.optionalKey( + Schema.Struct({ + major_version: Schema.optionalKey( + Schema.Number.annotate({ + description: + "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), ), - ssl_enforced: Schema.Boolean.annotate({ - description: "Whether the database rejects plaintext connections", - }), - network_restrictions: Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), - status: Schema.Literals(["stored", "applied"]).annotate({ - description: "Whether the allowlist below is applied to the project or only stored.", - }), - allowed_cidrs: Schema.Array( - Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + ssl_enforced: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the database rejects plaintext connections", + }), ), - updated_at: Schema.optionalKey(Schema.String), - applied_at: Schema.optionalKey(Schema.String), - }), - postgres_settings: Schema.Struct({ - effective_cache_size: Schema.optionalKey(Schema.String), - logical_decoding_work_mem: Schema.optionalKey(Schema.String), - log_autovacuum_min_duration: Schema.optionalKey( - Schema.String.annotate({ description: "Default unit: ms" }).check( - Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( - { - expected: - "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", - }, + network_restrictions: Schema.optionalKey( + Schema.Struct({ + entitlement: Schema.optionalKey(Schema.Literals(["disallowed", "allowed"])), + status: Schema.optionalKey( + Schema.Literals(["stored", "applied"]).annotate({ + description: + "Whether the allowlist below is applied to the project or only stored.", + }), ), - ), + allowed_cidrs: Schema.optionalKey( + Schema.Array( + Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + ), + ), + updated_at: Schema.optionalKey(Schema.String), + applied_at: Schema.optionalKey(Schema.String), + }), ), - log_checkpoints: Schema.optionalKey(Schema.Boolean), - log_connections: Schema.optionalKey(Schema.Boolean), - log_disconnections: Schema.optionalKey(Schema.Boolean), - log_duration: Schema.optionalKey(Schema.Boolean), - log_lock_waits: Schema.optionalKey(Schema.Boolean), - log_recovery_conflict_waits: Schema.optionalKey(Schema.Boolean), - log_replication_commands: Schema.optionalKey(Schema.Boolean), - log_startup_progress_interval: Schema.optionalKey( - Schema.String.annotate({ description: "Default unit: ms" }).check( - Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( - { - expected: - "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", - }, + postgres_settings: Schema.optionalKey( + Schema.Struct({ + effective_cache_size: Schema.optionalKey(Schema.String), + logical_decoding_work_mem: Schema.optionalKey(Schema.String), + log_autovacuum_min_duration: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern( + new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$"), + ).annotate({ + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }), + ), ), - ), + log_checkpoints: Schema.optionalKey(Schema.Boolean), + log_connections: Schema.optionalKey(Schema.Boolean), + log_disconnections: Schema.optionalKey(Schema.Boolean), + log_duration: Schema.optionalKey(Schema.Boolean), + log_lock_waits: Schema.optionalKey(Schema.Boolean), + log_recovery_conflict_waits: Schema.optionalKey(Schema.Boolean), + log_replication_commands: Schema.optionalKey(Schema.Boolean), + log_startup_progress_interval: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern( + new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$"), + ).annotate({ + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }), + ), + ), + log_temp_files: Schema.optionalKey(Schema.String), + maintenance_work_mem: Schema.optionalKey(Schema.String), + track_activity_query_size: Schema.optionalKey(Schema.String), + max_connections: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(1).annotate({ + expected: "a value greater than or equal to 1", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + max_locks_per_transaction: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(10).annotate({ + expected: "a value greater than or equal to 10", + }), + ) + .check( + Schema.isLessThanOrEqualTo(2147483640).annotate({ + expected: "a value less than or equal to 2147483640", + }), + ), + ), + max_logical_replication_workers: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + max_parallel_maintenance_workers: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(1024).annotate({ + expected: "a value less than or equal to 1024", + }), + ), + ), + max_parallel_workers: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(1024).annotate({ + expected: "a value less than or equal to 1024", + }), + ), + ), + max_parallel_workers_per_gather: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(1024).annotate({ + expected: "a value less than or equal to 1024", + }), + ), + ), + max_replication_slots: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_slot_wal_keep_size: Schema.optionalKey(Schema.String), + max_standby_archive_delay: Schema.optionalKey(Schema.String), + max_standby_streaming_delay: Schema.optionalKey(Schema.String), + max_sync_workers_per_subscription: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + max_wal_size: Schema.optionalKey(Schema.String), + max_wal_senders: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_worker_processes: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + session_replication_role: Schema.optionalKey( + Schema.Literals(["origin", "replica", "local"]), + ), + shared_buffers: Schema.optionalKey(Schema.String), + statement_timeout: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern( + new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$"), + ).annotate({ + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }), + ), + ), + track_commit_timestamp: Schema.optionalKey(Schema.Boolean), + wal_keep_size: Schema.optionalKey(Schema.String), + wal_sender_timeout: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern( + new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$"), + ).annotate({ + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }), + ), + ), + work_mem: Schema.optionalKey(Schema.String), + checkpoint_timeout: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: s" }).check( + Schema.isPattern( + new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$"), + ).annotate({ + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }), + ), + ), + hot_standby_feedback: Schema.optionalKey(Schema.Boolean), + cron_log_statement: Schema.optionalKey(Schema.Boolean), + }).annotate({ + description: + "Postgres parameter overrides. Empty when the project runs entirely on defaults.", + }), ), - log_temp_files: Schema.optionalKey(Schema.String), - maintenance_work_mem: Schema.optionalKey(Schema.String), - track_activity_query_size: Schema.optionalKey(Schema.String), - max_connections: Schema.optionalKey( + }), + ), + pooler: Schema.optionalKey( + Schema.Struct({ + pool_mode: Schema.optionalKey(Schema.Literals(["transaction", "session", "statement"])), + ignore_startup_parameters: Schema.optionalKey(Schema.String), + server_idle_timeout: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(1).annotate({ - expected: "a value greater than or equal to 1", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(262143).annotate({ - expected: "a value less than or equal to 262143", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_locks_per_transaction: Schema.optionalKey( + server_lifetime: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(10).annotate({ - expected: "a value greater than or equal to 10", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(2147483640).annotate({ - expected: "a value less than or equal to 2147483640", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_logical_replication_workers: Schema.optionalKey( + query_wait_timeout: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(262143).annotate({ - expected: "a value less than or equal to 262143", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_parallel_maintenance_workers: Schema.optionalKey( + reserve_pool_size: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(1024).annotate({ - expected: "a value less than or equal to 1024", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_parallel_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + default_pool_size: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Defaults to the pooler's size for the project's compute when not overridden.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(1024).annotate({ - expected: "a value less than or equal to 1024", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_parallel_workers_per_gather: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_client_conn: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Defaults to the pooler's size for the project's compute when not overridden.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(1024).annotate({ - expected: "a value less than or equal to 1024", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_replication_slots: Schema.optionalKey( + }), + ), + auth: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "Effective Auth config, keyed by lowercased GoTrue setting name and resolved through the `gotrue_config` view, so a setting the project has never overridden is reported at its platform default. Secrets are returned as an HMAC of their value, never in plaintext.", + }), + ), + api: Schema.optionalKey( + Schema.Struct({ + db_schema: Schema.optionalKey( + Schema.String.annotate({ description: "Schemas exposed through the Data API" }), + ), + db_extra_search_path: Schema.optionalKey(Schema.String), + max_rows: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -11028,24 +11247,45 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), ), ), - max_slot_wal_keep_size: Schema.optionalKey(Schema.String), - max_standby_archive_delay: Schema.optionalKey(Schema.String), - max_standby_streaming_delay: Schema.optionalKey(Schema.String), - max_sync_workers_per_subscription: Schema.optionalKey( + db_pool_acquisition_timeout: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(262143).annotate({ - expected: "a value less than or equal to 262143", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - max_wal_size: Schema.optionalKey(Schema.String), - max_wal_senders: Schema.optionalKey( + db_pool: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + Schema.Null, + ]), + ), + }), + ), + realtime: Schema.optionalKey( + Schema.Struct({ + private_only: Schema.optionalKey(Schema.Boolean), + max_concurrent_users: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -11058,343 +11298,47 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), ), ), - max_worker_processes: Schema.optionalKey( + max_events_per_second: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", }), ) .check( - Schema.isLessThanOrEqualTo(262143).annotate({ - expected: "a value less than or equal to 262143", + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", }), ), ), - session_replication_role: Schema.optionalKey( - Schema.Literals(["origin", "replica", "local"]), - ), - shared_buffers: Schema.optionalKey(Schema.String), - statement_timeout: Schema.optionalKey( - Schema.String.annotate({ description: "Default unit: ms" }).check( - Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( - { - expected: - "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", - }, - ), - ), - ), - track_commit_timestamp: Schema.optionalKey(Schema.Boolean), - wal_keep_size: Schema.optionalKey(Schema.String), - wal_sender_timeout: Schema.optionalKey( - Schema.String.annotate({ description: "Default unit: ms" }).check( - Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( - { - expected: - "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", - }, + max_bytes_per_second: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), ), - ), ), - work_mem: Schema.optionalKey(Schema.String), - checkpoint_timeout: Schema.optionalKey( - Schema.String.annotate({ description: "Default unit: s" }).check( - Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( - { - expected: - "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", - }, + max_channels_per_client: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), ), - ), - ), - hot_standby_feedback: Schema.optionalKey(Schema.Boolean), - cron_log_statement: Schema.optionalKey(Schema.Boolean), - }).annotate({ - description: - "Postgres parameter overrides. Empty when the project runs entirely on defaults.", - }), - }), - pooler: Schema.Struct({ - pool_mode: Schema.Literals(["transaction", "session", "statement"]), - ignore_startup_parameters: Schema.String, - server_idle_timeout: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - server_lifetime: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - query_wait_timeout: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - reserve_pool_size: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - default_pool_size: Schema.Number.annotate({ - description: - "Defaults to the pooler's size for the project's compute when not overridden.", - }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - max_client_conn: Schema.Number.annotate({ - description: - "Defaults to the pooler's size for the project's compute when not overridden.", - }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - }), - auth: Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate( - { - description: - "Effective Auth config, keyed by lowercased GoTrue setting name and resolved through the `gotrue_config` view, so a setting the project has never overridden is reported at its platform default. Secrets are returned as an HMAC of their value, never in plaintext.", - }, - ), - api: Schema.Struct({ - db_schema: Schema.String.annotate({ description: "Schemas exposed through the Data API" }), - db_extra_search_path: Schema.String, - max_rows: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - db_pool_acquisition_timeout: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - db_pool: Schema.Union([ - Schema.Number.annotate({ - description: - "If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here.", - }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - Schema.Null, - ]), - }), - realtime: Schema.Struct({ - private_only: Schema.Boolean, - max_concurrent_users: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - max_events_per_second: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), ), - max_bytes_per_second: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - max_channels_per_client: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - max_joins_per_second: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - max_presence_events_per_second: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - max_payload_size_in_kb: Schema.Number.check( - Schema.isInt().annotate({ expected: "an integer" }), - ) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - presence_enabled: Schema.Boolean, - suspend: Schema.Boolean, - connection_pool: Schema.Number.annotate({ - description: - "Defaults to Realtime's pool size for the project's compute when not overridden.", - }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - postgres_changes_pool: Schema.Union([ - Schema.Number.annotate({ - description: "If `null`, no override is stored and Realtime applies its own default.", - }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - Schema.Null, - ]), - }), - storage: Schema.Struct({ - file_size_limit: Schema.Number.annotate({ format: "int64" }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ - expected: "a value greater than or equal to -9007199254740991", - }), - ) - .check( - Schema.isLessThanOrEqualTo(9007199254740991).annotate({ - expected: "a value less than or equal to 9007199254740991", - }), - ), - features: Schema.Struct({ - image_transformation: Schema.Struct({ enabled: Schema.Boolean }), - s3_protocol: Schema.Struct({ enabled: Schema.Boolean }), - purge_cache: Schema.Struct({ enabled: Schema.Boolean }), - iceberg_catalog: Schema.Struct({ - enabled: Schema.Boolean, - max_namespaces: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_joins_per_second: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11405,7 +11349,9 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_tables: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ), + max_presence_events_per_second: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11416,7 +11362,9 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_catalogs: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ), + max_payload_size_in_kb: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11427,10 +11375,15 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - }), - vector_buckets: Schema.Struct({ - enabled: Schema.Boolean, - max_buckets: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ), + presence_enabled: Schema.optionalKey(Schema.Boolean), + suspend: Schema.optionalKey(Schema.Boolean), + connection_pool: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Defaults to Realtime's pool size for the project's compute when not overridden.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11441,7 +11394,34 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_indexes: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ), + postgres_changes_pool: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "If `null`, no override is stored and Realtime applies its own default.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + Schema.Null, + ]), + ), + }), + ), + storage: Schema.optionalKey( + Schema.Struct({ + file_size_limit: Schema.optionalKey( + Schema.Number.annotate({ format: "int64" }) + .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11452,16 +11432,106 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - }), + ), + features: Schema.optionalKey( + Schema.Struct({ + image_transformation: Schema.optionalKey( + Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }), + ), + s3_protocol: Schema.optionalKey( + Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }), + ), + purge_cache: Schema.optionalKey( + Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }), + ), + iceberg_catalog: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + max_namespaces: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_tables: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_catalogs: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + }), + ), + vector_buckets: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + max_buckets: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_indexes: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + }), + ), + }), + ), + capabilities: Schema.optionalKey( + Schema.Struct({ list_v2: Schema.Boolean, iceberg_catalog: Schema.Boolean }), + ), + upstream_target: Schema.optionalKey(Schema.Literals(["main", "canary"])), + migration_version: Schema.optionalKey(Schema.String), + database_pool_mode: Schema.optionalKey(Schema.String), + }).annotate({ + description: + "Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config.", }), - capabilities: Schema.Struct({ list_v2: Schema.Boolean, iceberg_catalog: Schema.Boolean }), - upstream_target: Schema.Literals(["main", "canary"]), - migration_version: Schema.String, - database_pool_mode: Schema.String, - }).annotate({ - description: - "Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config.", - }), + ), }), }), }); diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 5c0978df1f..4627356765 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -24426,7 +24426,7 @@ "type": "string" } }, - "required": ["entitlement", "status", "allowed_cidrs"] + "required": [] }, "postgres_settings": { "type": "object", @@ -24580,12 +24580,7 @@ "description": "Postgres parameter overrides. Empty when the project runs entirely on defaults." } }, - "required": [ - "major_version", - "ssl_enforced", - "network_restrictions", - "postgres_settings" - ] + "required": [] }, "pooler": { "type": "object", @@ -24630,16 +24625,7 @@ "description": "Defaults to the pooler's size for the project's compute when not overridden." } }, - "required": [ - "pool_mode", - "ignore_startup_parameters", - "server_idle_timeout", - "server_lifetime", - "query_wait_timeout", - "reserve_pool_size", - "default_pool_size", - "max_client_conn" - ] + "required": [] }, "auth": { "type": "object", @@ -24674,13 +24660,7 @@ "nullable": true } }, - "required": [ - "db_schema", - "db_extra_search_path", - "max_rows", - "db_pool_acquisition_timeout", - "db_pool" - ] + "required": [] }, "realtime": { "type": "object", @@ -24743,20 +24723,7 @@ "nullable": true } }, - "required": [ - "private_only", - "max_concurrent_users", - "max_events_per_second", - "max_bytes_per_second", - "max_channels_per_client", - "max_joins_per_second", - "max_presence_events_per_second", - "max_payload_size_in_kb", - "presence_enabled", - "suspend", - "connection_pool", - "postgres_changes_pool" - ] + "required": [] }, "storage": { "type": "object", @@ -24777,7 +24744,7 @@ "type": "boolean" } }, - "required": ["enabled"] + "required": [] }, "s3_protocol": { "type": "object", @@ -24786,7 +24753,7 @@ "type": "boolean" } }, - "required": ["enabled"] + "required": [] }, "purge_cache": { "type": "object", @@ -24795,7 +24762,7 @@ "type": "boolean" } }, - "required": ["enabled"] + "required": [] }, "iceberg_catalog": { "type": "object", @@ -24819,7 +24786,7 @@ "maximum": 9007199254740991 } }, - "required": ["enabled", "max_namespaces", "max_tables", "max_catalogs"] + "required": [] }, "vector_buckets": { "type": "object", @@ -24838,16 +24805,10 @@ "maximum": 9007199254740991 } }, - "required": ["enabled", "max_buckets", "max_indexes"] + "required": [] } }, - "required": [ - "image_transformation", - "s3_protocol", - "purge_cache", - "iceberg_catalog", - "vector_buckets" - ] + "required": [] }, "capabilities": { "type": "object", @@ -24872,18 +24833,11 @@ "type": "string" } }, - "required": [ - "file_size_limit", - "features", - "capabilities", - "upstream_target", - "migration_version", - "database_pool_mode" - ], + "required": [], "description": "Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config." } }, - "required": ["database", "pooler", "auth", "api", "realtime", "storage"] + "required": [] } }, "required": ["type", "id", "attributes"] diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 893dd619b4..d4fe0df8a1 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1140,9 +1140,69 @@ describe("makeSupabaseApiClient", () => { ), ); - expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); - expect(result.data.attributes.database.major_version).toBe(17); - expect(result.data.attributes.storage.upstream_target).toBe("main"); - expect(result.data.attributes.api.db_pool).toBeNull(); + expect(result.data.attributes.database?.network_restrictions?.entitlement).toBe("disallowed"); + expect(result.data.attributes.database?.major_version).toBe(17); + expect(result.data.attributes.storage?.upstream_target).toBe("main"); + expect(result.data.attributes.api?.db_pool).toBeNull(); + }); + + test("decodes a partial v2GetProjectConfig payload missing blocks and block keys", async () => { + // The platform can report a subset of the config surface — staging + // predates `storage.database_pool_mode`, and a permission-truncated + // response can omit whole blocks. The contract keeps every block and + // block key optional (see the V2ProjectConfigResponse entries in + // scripts/openapi-overrides.json) so a partial response degrades at the + // consumer instead of failing the typed decode. + const result = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + data: { + type: "project_config", + id: "abcdefghijklmnopqrst", + attributes: { + auth: {}, + api: { db_schema: "public" }, + storage: { + file_size_limit: 0, + features: { + image_transformation: { enabled: true }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: true }, + iceberg_catalog: { + enabled: false, + max_namespaces: 0, + max_tables: 0, + max_catalogs: 0, + }, + vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 }, + }, + capabilities: { list_v2: true, iceberg_catalog: true }, + upstream_target: "main", + migration_version: "1", + // no database_pool_mode — the exact staging shape + }, + // no database, pooler, realtime blocks at all + }, + }, + }), + ), + ), + ), + ), + ); + + expect(result.data.attributes.api?.db_schema).toBe("public"); + expect(result.data.attributes.storage?.database_pool_mode).toBeUndefined(); + expect(result.data.attributes.database).toBeUndefined(); + expect(result.data.attributes.pooler).toBeUndefined(); + expect(result.data.attributes.realtime).toBeUndefined(); }); }); diff --git a/packages/config/src/config-diff.ts b/packages/config/src/config-diff.ts new file mode 100644 index 0000000000..33c9f9b35c --- /dev/null +++ b/packages/config/src/config-diff.ts @@ -0,0 +1,433 @@ +import type { CliConfigValueOrigin } from "./config-document.ts"; +import { + type CliConfigWithRawPresence, + comparableProjectConfigPaths, + fromConfigDocument, + isComparableProjectConfigPath, + type ProjectConfig, +} from "./project-config/project-config.ts"; +import { projectConfigMappingRows } from "./project-config/registry.ts"; +import { getDefaultCliConfig } from "./sparse.ts"; + +/** + * Config drift classification between the local project config and the + * effective remote configuration reported by the Management API + * (`GET /v2/projects/{ref}/config`). Pure and synchronous: fetching the + * response, resolving the target, and rendering output are the caller's job + * (`supabase config diff`, and `config pull` after it). See ADR 0022. + * + * Both operands are convergence projections from CLI-2230's normalizers (ADR + * 0021): the local operand is derived here from the loaded `{config, + * document}` pair via `fromConfigDocument` (raw-presence-masked, + * canonicalized, secrets omitted), and the caller builds `remote` with + * `fromApiProjectConfig(response)`. The comparable surface is the mapping + * registry's — a path with no registry row is unmanaged by construction — + * and the raw document's declared-key set drives `update` vs `remote_only`, + * since a decoded config cannot distinguish "the file wrote the default" + * from "the file is silent". + * + * Paths are segment arrays everywhere in this module's API (a record key — + * an `auth.sms.test_otp` phone number, a `[remotes.*]` name — may itself + * contain a `.`, so dotted strings are lossy); joining is display-only and + * belongs to the renderer. + */ + +export type ConfigChangeClass = "update" | "remote_only" | "local_only"; + +export interface ConfigChange { + /** + * Config path segments within the hosted subset, e.g. `["api", + * "max_rows"]`. Join for display only — a segment may contain a `.`. + */ + readonly path: ReadonlyArray; + /** + * `update`: declared locally and reported remotely, values differ. + * `remote_only`: reported remotely while the file does not declare it (or + * push cannot communicate the declared state), and differing from the + * unconfigured baseline. `local_only`: the local projection carries a + * declared value the response did not account for. + */ + readonly class: ConfigChangeClass; + /** + * Local convergence-projected value; `undefined` when the projection is + * silent. For an undeclared `remote_only` path this is the materialized + * schema default — the value a `config push` would write over the remote — + * so consumers can answer "what would push change?" without re-deriving it. + */ + readonly local: unknown; + /** Remote value; `undefined` when the response did not report it. */ + readonly remote: unknown; + /** + * Whether the raw document declares this path — distinguishes "the file + * wrote this value" from "the local side is a schema-materialized default". + */ + readonly declared: boolean; + /** Environment variables local `env()` references resolved from, if any. */ + readonly envVariables?: ReadonlyArray | undefined; +} + +export interface ConfigChangeCounts { + readonly update: number; + readonly remote_only: number; + readonly local_only: number; + readonly total: number; +} + +export interface ConfigChangeSet { + /** Reportable differences, ordered by path. */ + readonly changes: ReadonlyArray; + /** + * Managed secret paths the file sets a value for (the registry's + * `isSecret` rows). These were never compared — the platform reports HMAC + * digests, and both normalizers omit secret leaves — so a clean `changes` + * list is still only a partial claim; callers must surface this. + */ + readonly masked: ReadonlyArray>; + /** + * Comparable non-secret paths the file declares but the local projection + * dropped — declared state a `config push` structurally cannot communicate + * (ADR 0021's unmanaged-by-push families: `auth.oauth_server`, disabled + * `storage.analytics`/`storage.vector`, siblings of a disabled container's + * sentinel, an unselected SMS provider's credentials, …). These were never + * compared on the local side, so — like `masked` — a clean `changes` list + * is only a partial claim; callers must surface this rather than let a + * declared value silently vanish from the comparison. + */ + readonly unmanaged: ReadonlyArray>; + readonly counts: ConfigChangeCounts; +} + +export interface DiffProjectConfigOptions { + /** + * The loaded local config: the `{config, document}` pair + * `fromConfigDocument` accepts (pass the loaded config WITH its raw + * document so raw-presence masking applies — ADR 0021's remedy), plus the + * loader's `valueOrigins` when env-var attribution is wanted. The local + * projection and the declared-key set are both derived from this one value, + * so they can never come from different loads. `LoadedCliConfig` is + * structurally assignable. Note `fromConfigDocument` runs inside + * `diffProjectConfig`, so a document the registry cannot canonicalize + * throws `ProjectConfigParseError` from here. + */ + readonly local: CliConfigWithRawPresence & { + readonly valueOrigins?: ReadonlyArray | undefined; + }; + /** The remote operand: `fromApiProjectConfig(response)`. */ + readonly remote: ProjectConfig; +} + +function isPlainRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pathKey(path: ReadonlyArray): string { + return JSON.stringify(path); +} + +/** Walks a segment path through records with own-key checks only. */ +function valueAtPath(root: unknown, path: ReadonlyArray): unknown { + let current: unknown = root; + for (const segment of path) { + if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function isDeclaredAtPath( + root: Readonly>, + path: ReadonlyArray, +): boolean { + let current: unknown = root; + for (const [index, segment] of path.entries()) { + if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { + return false; + } + if (index < path.length - 1) { + current = current[segment]; + } + } + return true; +} + +/** Collects leaf paths as segment arrays (arrays are leaves; records recurse). */ +function collectLeafPaths(root: ProjectConfig): Array> { + const leaves: Array> = []; + const walk = (value: unknown, prefix: ReadonlyArray): void => { + if (isPlainRecord(value)) { + for (const [key, child] of Object.entries(value)) { + walk(child, [...prefix, key]); + } + return; + } + if (prefix.length > 0) { + leaves.push(prefix); + } + }; + walk(root, []); + return leaves; +} + +/** Segment-wise path order — the display order of the change list. */ +function comparePaths(a: ReadonlyArray, b: ReadonlyArray): number { + const length = Math.min(a.length, b.length); + for (let index = 0; index < length; index++) { + const left = a[index] as string; + const right = b[index] as string; + if (left !== right) { + return left < right ? -1 : 1; + } + } + return a.length - b.length; +} + +function scalarEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + // Type-aware comparison: both operands are already canonicalized by the + // convergence normalizers, but representation skew across schema versions + // ("8080" vs 8080, "true" vs true) is still not drift. + if (typeof a === "string" && typeof b === "number") { + const parsed = Number(a.trim()); + return a.trim() !== "" && Number.isFinite(parsed) && parsed === b; + } + if (typeof a === "number" && typeof b === "string") { + return scalarEqual(b, a); + } + if (typeof a === "string" && typeof b === "boolean") { + return a.trim().toLowerCase() === String(b); + } + if (typeof a === "boolean" && typeof b === "string") { + return scalarEqual(b, a); + } + return false; +} + +function canonicalArrayElement(value: unknown): string { + if (typeof value === "string") { + return `s:${value}`; + } + if (typeof value === "number" || typeof value === "boolean") { + // Scalars fold to their string form so "1" and 1 compare equal, matching + // the scalar type-awareness above. + return `s:${String(value)}`; + } + return `j:${JSON.stringify(value)}`; +} + +export type ConfigArrayEquality = "set" | "sequence"; + +/** + * Type-aware value equality. Scalars tolerate string/number and + * string/boolean representation skew. Arrays default to SEQUENCE semantics — + * element order is meaningful unless the field's registry row opts into + * `"set"` (whether an array is a set or a sequence is per-field wire + * knowledge: `api.schemas`' first entry is PostgREST's default schema and + * `api.extra_search_path` is a literal `search_path`, while + * `auth.additional_redirect_urls` is membership-only). Defaulting to + * sequence over-reports rather than under-reports drift. + */ +export function isEqualConfigValue( + a: unknown, + b: unknown, + arrayEquality: ConfigArrayEquality = "sequence", +): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + const left = a.map(canonicalArrayElement); + const right = b.map(canonicalArrayElement); + if (arrayEquality === "set") { + left.sort(); + right.sort(); + } + return left.every((element, index) => element === right[index]); + } + return scalarEqual(a, b); +} + +/** Deduped secret (`isSecret`) row config paths, in registry order. */ +const secretConfigPaths: ReadonlyArray> = (() => { + const seen = new Set(); + const paths: Array> = []; + for (const row of projectConfigMappingRows) { + if (row.isSecret !== true || seen.has(pathKey(row.configPath))) { + continue; + } + seen.add(pathKey(row.configPath)); + paths.push(row.configPath); + } + return paths; +})(); + +// Per-path row knowledge the classifier consumes, first row wins — matching +// `comparableProjectConfigPaths`'s own dedupe order for paths several rows +// share. +const arrayEqualityByPathKey: ReadonlyMap = (() => { + const map = new Map(); + for (const row of projectConfigMappingRows) { + if (row.arrayEquality !== undefined && !map.has(pathKey(row.configPath))) { + map.set(pathKey(row.configPath), row.arrayEquality); + } + } + return map; +})(); + +const unconfiguredValueByPathKey: ReadonlyMap = (() => { + const map = new Map(); + for (const row of projectConfigMappingRows) { + if (Object.hasOwn(row, "unconfiguredValue") && !map.has(pathKey(row.configPath))) { + map.set(pathKey(row.configPath), row.unconfiguredValue); + } + } + return map; +})(); + +/** + * Equality at a specific path: array semantics come from the path's registry + * row (or the nearest mapped ancestor — a mapped container's descendant + * leaves inherit its row), defaulting to sequence. + */ +function equalsAtPath(path: ReadonlyArray, a: unknown, b: unknown): boolean { + for (let length = path.length; length >= 1; length--) { + const equality = arrayEqualityByPathKey.get(pathKey(path.slice(0, length))); + if (equality !== undefined) { + return isEqualConfigValue(a, b, equality); + } + } + return isEqualConfigValue(a, b); +} + +// The default config's own convergence projection — the first `remote_only` +// suppression baseline tier. Lazy so importing this module never pays for a +// full schema decode + projection up front. +let defaultProjectionMemo: ProjectConfig | undefined; +function defaultProjection(): ProjectConfig { + defaultProjectionMemo ??= fromConfigDocument(getDefaultCliConfig()); + return defaultProjectionMemo; +} + +/** + * Classifies every comparable path into the change set. Pure: no I/O, no + * dependency on command flags or output formatting. Runs `fromConfigDocument` + * over `options.local`, so a document the registry cannot canonicalize throws + * `ProjectConfigParseError` — callers translate at their own boundary. + */ +export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChangeSet { + const local = fromConfigDocument(options.local); + const declaredRoot = options.local.document ?? {}; + const envReferences = new Map>(); + for (const origin of options.local.valueOrigins ?? []) { + if (origin.source === "environment" && origin.envVariables !== undefined) { + envReferences.set(pathKey(origin.path), origin.envVariables); + } + } + + const changes: Array = []; + const paths = new Map>(); + for (const path of [...collectLeafPaths(local), ...collectLeafPaths(options.remote)]) { + paths.set(pathKey(path), path); + } + + for (const path of paths.values()) { + if (!isComparableProjectConfigPath(path)) { + continue; + } + const localValue = valueAtPath(local, path); + const remoteValue = valueAtPath(options.remote, path); + const declared = isDeclaredAtPath(declaredRoot, path); + const envVariables = envReferences.get(pathKey(path)); + + if (localValue !== undefined && remoteValue !== undefined) { + if (equalsAtPath(path, localValue, remoteValue)) { + continue; + } + // A declared value differing from the remote is an update; an + // undeclared one is remote-side drift against the (materialized) + // default the local projection carries — which stays populated on the + // change so consumers can see what a push would write. + changes.push({ + path, + class: declared ? "update" : "remote_only", + local: localValue, + remote: remoteValue, + declared, + ...(envVariables === undefined ? {} : { envVariables }), + }); + continue; + } + + if (remoteValue !== undefined) { + // The local projection is silent: the file doesn't declare it, or push + // cannot communicate the declared state (ADR 0021's unmanaged-by-push + // families — those paths additionally surface in `unmanaged` below). + // Suppress the remote value when it matches the unconfigured baseline: + // the default config's own projection, then the raw default config + // (push-gated containers, e.g. network restrictions' allow-all), then + // the registry row's declared `unconfiguredValue` (the platform's + // report of an unconfigured feature, e.g. `sessions_timebox: 0` + // canonicalized to `"0s"`, or the provisioning-default mailer + // subjects). With no baseline at any tier the value is reported — + // "unconfigured" is never inferred from type-level zero values, since + // canonicalization can turn a platform zero into a non-zero shape. + const baseline = + valueAtPath(defaultProjection(), path) ?? + valueAtPath(getDefaultCliConfig(), path) ?? + unconfiguredValueByPathKey.get(pathKey(path)); + const suppressed = baseline !== undefined && equalsAtPath(path, baseline, remoteValue); + if (!suppressed) { + changes.push({ + path, + class: "remote_only", + local: undefined, + remote: remoteValue, + declared, + ...(envVariables === undefined ? {} : { envVariables }), + }); + } + continue; + } + + if (declared) { + changes.push({ + path, + class: "local_only", + local: localValue, + remote: undefined, + declared, + ...(envVariables === undefined ? {} : { envVariables }), + }); + } + } + + changes.sort((a, b) => comparePaths(a.path, b.path)); + + const masked = secretConfigPaths + .filter((path) => isDeclaredAtPath(declaredRoot, path)) + .toSorted(comparePaths); + + // Declared comparable paths the local projection dropped: push cannot + // communicate them, so they were never compared on the local side. + // `comparableProjectConfigPaths` already excludes secret rows, so this + // never overlaps `masked`. + const unmanaged = comparableProjectConfigPaths + .filter( + (path) => isDeclaredAtPath(declaredRoot, path) && valueAtPath(local, path) === undefined, + ) + .toSorted(comparePaths); + + const update = changes.filter((change) => change.class === "update").length; + const remote_only = changes.filter((change) => change.class === "remote_only").length; + const local_only = changes.filter((change) => change.class === "local_only").length; + + return { + changes, + masked, + unmanaged, + counts: { update, remote_only, local_only, total: update + remote_only + local_only }, + }; +} diff --git a/packages/config/src/config-diff.unit.test.ts b/packages/config/src/config-diff.unit.test.ts new file mode 100644 index 0000000000..6cccc17696 --- /dev/null +++ b/packages/config/src/config-diff.unit.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, test } from "vitest"; +import { Schema } from "effect"; +import { CliConfigSchema } from "./base.ts"; +import { diffProjectConfig, isEqualConfigValue, type ConfigChange } from "./config-diff.ts"; +import type { CliConfigValueOrigin } from "./config-document.ts"; +import { + comparableProjectConfigPaths, + fromApiProjectConfig, + fromConfigDocument, +} from "./project-config/project-config.ts"; +import { projectConfigMappingRows } from "./project-config/registry.ts"; +import { getDefaultCliConfig } from "./sparse.ts"; + +const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); + +/** + * Builds the diff input the way the command layer does: the local operand is + * the loaded `{config, document}` pair (so raw-presence masking applies and + * the declared-key set comes from the same load), the remote operand is + * `fromApiProjectConfig` over bare v2 `data.attributes`. + */ +function diffWith( + declared: Record, + attributes: Record, + valueOrigins?: ReadonlyArray, +) { + return diffProjectConfig({ + local: { config: decodeCliConfig(declared), document: declared, valueOrigins }, + remote: fromApiProjectConfig(attributes), + }); +} + +function changeAt( + changes: ReadonlyArray, + path: ReadonlyArray, +): ConfigChange | undefined { + return changes.find( + (change) => + change.path.length === path.length && + change.path.every((segment, index) => segment === path[index]), + ); +} + +describe("diffProjectConfig classification", () => { + test("an undefined declared document means nothing is declared", () => { + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}) }, + remote: fromApiProjectConfig({ api: { max_rows: 250 } }), + }); + expect(changeAt(result.changes, ["api", "max_rows"])).toMatchObject({ class: "remote_only" }); + }); + + test("declared value differing from remote is an update", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 1000 } }); + const change = changeAt(result.changes, ["api", "max_rows"]); + expect(change).toMatchObject({ class: "update", local: 500, remote: 1000, declared: true }); + expect(result.counts.update).toBe(1); + }); + + test("declared value equal to remote is not a difference", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 500 } }); + expect(result.changes).toEqual([]); + expect(result.counts).toEqual({ update: 0, remote_only: 0, local_only: 0, total: 0 }); + }); + + test("remote value at the schema default is suppressed when undeclared", () => { + const result = diffWith({}, { api: { max_rows: 1000 } }); + expect(changeAt(result.changes, ["api", "max_rows"])).toBeUndefined(); + }); + + test("remote-only drift keeps the materialized local default and declared: false", () => { + // The primary someone-changed-it-in-the-dashboard case: the file is + // silent, the local projection carries the schema default (1000), and a + // push would overwrite the remote 250 with it — the change must say so. + const result = diffWith({}, { api: { max_rows: 250 } }); + const change = changeAt(result.changes, ["api", "max_rows"]); + expect(change).toMatchObject({ + class: "remote_only", + local: 1000, + remote: 250, + declared: false, + }); + }); + + test("raw-presence-masked sections suppress zero-valued remotes", () => { + // db.ssl_enforcement is raw-presence-masked on the document arm (ADR + // 0021), so its local projection is silent when the file never declares + // it; the platform reporting the unconfigured state is not drift. + const clean = diffWith({}, { database: { ssl_enforced: false } }); + expect(changeAt(clean.changes, ["db", "ssl_enforcement", "enabled"])).toBeUndefined(); + + const drifted = diffWith({}, { database: { ssl_enforced: true } }); + expect(changeAt(drifted.changes, ["db", "ssl_enforcement", "enabled"])).toMatchObject({ + class: "remote_only", + remote: true, + }); + }); + + test("push-gated containers fall back to the raw schema default as baseline", () => { + // The registry maps network-restriction CIDRs unconditionally, but push + // gates them on the local `enabled` toggle, so the default projection is + // silent on them. The raw schema default (allow-all) IS the platform's + // unconfigured state — reporting it would flag every untouched project. + const clean = diffWith( + {}, + { + database: { + network_restrictions: { + allowed_cidrs: [ + { address: "0.0.0.0/0", type: "v4" }, + { address: "::/0", type: "v6" }, + ], + }, + }, + }, + ); + expect(clean.changes).toEqual([]); + + const drifted = diffWith( + {}, + { + database: { + network_restrictions: { allowed_cidrs: [{ address: "10.0.0.0/8", type: "v4" }] }, + }, + }, + ); + expect( + changeAt(drifted.changes, ["db", "network_restrictions", "allowed_cidrs"]), + ).toMatchObject({ + class: "remote_only", + remote: ["10.0.0.0/8"], + }); + }); + + test("canonicalized zero durations suppress via the row's unconfiguredValue", () => { + // GoTrue reports 0 hours for unconfigured session bounds; the transform + // canonicalizes that to the STRING "0s", which no type-level zero check + // recognizes — the registry row's `unconfiguredValue` must. An untouched + // project reporting both bounds is clean; a real timebox is drift. + const clean = diffWith({}, { auth: { sessions_timebox: 0, sessions_inactivity_timeout: 0 } }); + expect(clean.changes).toEqual([]); + + const drifted = diffWith({}, { auth: { sessions_timebox: 24 } }); + expect(changeAt(drifted.changes, ["auth", "sessions", "timebox"])).toMatchObject({ + class: "remote_only", + remote: "24h0m0s", + }); + }); + + test("platform-default mailer subjects suppress via the row's unconfiguredValue", () => { + // A fresh project reports the provisioning-default subject lines (pinned + // by the recorded config_auth fixtures); the default config declares no + // subjects, so without the row-level baseline every untouched project + // would flag all 13 of them. + const clean = diffWith( + {}, + { + auth: { + mailer_subjects_confirmation: "Confirm Your Signup", + mailer_subjects_password_changed_notification: "Your password has been changed", + mailer_notifications_password_changed_enabled: false, + }, + }, + ); + expect(clean.changes).toEqual([]); + + const drifted = diffWith( + {}, + { + auth: { + mailer_subjects_confirmation: "Welcome to ACME", + mailer_notifications_password_changed_enabled: true, + }, + }, + ); + expect( + changeAt(drifted.changes, ["auth", "email", "template", "confirmation", "subject"]), + ).toMatchObject({ class: "remote_only", remote: "Welcome to ACME" }); + expect( + changeAt(drifted.changes, ["auth", "email", "notification", "password_changed", "enabled"]), + ).toMatchObject({ class: "remote_only", remote: true }); + }); + + test("every comparable path without a config-side baseline makes a deliberate choice", () => { + // Registry-driven guard for the remote_only suppression baseline: for + // each comparable path the default config's projection AND the raw + // default config are silent on, either its row declares the platform's + // `unconfiguredValue` (and a remote report equal to it classifies clean), + // or the platform's unconfigured report is structural ABSENCE (sentinel- + // pruned SMTP/captcha/SMS/hook siblings, sparse postgres_settings) and a + // zero-form remote — which absence-class paths never receive — must + // REPORT rather than be silently swallowed by type-level zero inference. + const defaults = fromConfigDocument(getDefaultCliConfig()); + const raw = getDefaultCliConfig(); + const valueAt = (root: unknown, path: ReadonlyArray): unknown => { + let current: unknown = root; + for (const segment of path) { + if ( + typeof current !== "object" || + current === null || + Array.isArray(current) || + !Object.hasOwn(current, segment) + ) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; + }; + const rowFor = (path: ReadonlyArray) => + projectConfigMappingRows.find( + (row) => + row.configPath.length === path.length && + row.configPath.every((segment, index) => segment === path[index]), + ); + + const baselineless = comparableProjectConfigPaths.filter( + (path) => (valueAt(defaults, path) ?? valueAt(raw, path)) === undefined, + ); + expect(baselineless.length).toBeGreaterThan(0); + + for (const path of baselineless) { + const row = rowFor(path); + expect(row, path.join(".")).toBeDefined(); + if (row !== undefined && Object.hasOwn(row, "unconfiguredValue")) { + // The declared unconfigured value classifies clean... + const projected: Record = {}; + let cursor = projected; + for (const segment of path.slice(0, -1)) { + cursor[segment] = {}; + cursor = cursor[segment] as Record; + } + cursor[path[path.length - 1] as string] = row.unconfiguredValue; + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}), document: {} }, + remote: projected, + }); + expect(changeAt(result.changes, path), path.join(".")).toBeUndefined(); + } else { + // ...and a path relying on structural absence must not silently + // swallow a zero-form value if the platform ever starts reporting + // one: inject a zero-form leaf directly into the remote projection + // (bypassing the normalizer, which today omits these paths) and + // assert it REPORTS. + const projected: Record = {}; + let cursor = projected; + for (const segment of path.slice(0, -1)) { + cursor[segment] = {}; + cursor = cursor[segment] as Record; + } + cursor[path[path.length - 1] as string] = ""; + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}), document: {} }, + remote: projected, + }); + expect(changeAt(result.changes, path), path.join(".")).toMatchObject({ + class: "remote_only", + }); + } + } + }); + + test("undeclared providers reporting their unconfigured state are not drift", () => { + const result = diffWith( + {}, + { auth: { external_github_enabled: false, external_github_client_id: "" } }, + ); + expect(result.changes.filter((change) => change.path.includes("github"))).toEqual([]); + }); + + test("declared value the response does not carry is local_only", () => { + const result = diffWith( + { auth: { site_url: "https://local.example.com" } }, + // auth block present but without site_url. + { auth: {} }, + ); + expect(changeAt(result.changes, ["auth", "site_url"])).toMatchObject({ + class: "local_only", + local: "https://local.example.com", + remote: undefined, + declared: true, + }); + }); + + test("a wholly absent block turns its declared properties local_only", () => { + const result = diffWith({ db: { settings: { max_connections: 120 } } }, {}); + expect(changeAt(result.changes, ["db", "settings", "max_connections"])).toMatchObject({ + class: "local_only", + local: 120, + }); + }); + + test("unmanaged declared properties are never reported", () => { + const result = diffWith( + { + studio: { port: 55555 }, + api: { port: 4321 }, + realtime: { max_header_length: 8192 }, + local_smtp: { enabled: true }, + }, + { api: {}, realtime: { max_concurrent_users: 5 } }, + ); + expect(result.changes).toEqual([]); + }); + + test("a declared path the projection cannot push surfaces in unmanaged, never as a false clean", () => { + // `auth.oauth_server` is dropped from the document projection entirely — + // push has no oauth_server handling — so a declared `enabled = true` + // disagreeing with the remote's `false` cannot be a change entry. It must + // surface in `unmanaged` so the clean changes list is visibly partial. + const result = diffWith( + { auth: { oauth_server: { enabled: true } } }, + { auth: { oauth_server_enabled: false } }, + ); + expect(result.changes).toEqual([]); + expect(result.unmanaged).toContainEqual(["auth", "oauth_server", "enabled"]); + }); + + test("declared siblings of a disabled container surface in unmanaged", () => { + // Push writes only the disable sentinel for a disabled SMTP block, so a + // declared host is never communicated — the projection prunes it and the + // unmanaged list says so. + const result = diffWith( + { auth: { email: { smtp: { enabled: false, host: "mail.example.com" } } } }, + { auth: {} }, + ); + expect(result.unmanaged).toContainEqual(["auth", "email", "smtp", "host"]); + }); + + test("an undeclared config is fully managed", () => { + const result = diffWith({}, { auth: {} }); + expect(result.unmanaged).toEqual([]); + }); + + test("sequence arrays register reordering as drift", () => { + // api.schemas is order-significant (the first entry is PostgREST's + // default schema), so local ["public","extensions"] vs the wire's + // "extensions,public" is a real difference — in both declared and + // undeclared classifications. + const result = diffWith( + { api: { schemas: ["public", "extensions"] } }, + { api: { db_schema: "extensions,public" } }, + ); + expect(changeAt(result.changes, ["api", "schemas"])).toMatchObject({ class: "update" }); + + const searchPath = diffWith( + { api: { extra_search_path: ["public", "extensions"] } }, + { api: { db_extra_search_path: "extensions,public" } }, + ); + expect(changeAt(searchPath.changes, ["api", "extra_search_path"])).toMatchObject({ + class: "update", + }); + }); + + test("set-semantics arrays ignore element order", () => { + // additional_redirect_urls is membership-only — its registry row opts + // into set equality. + const result = diffWith( + { auth: { additional_redirect_urls: ["https://b.example.com", "https://a.example.com"] } }, + { auth: { uri_allow_list: "https://a.example.com,https://b.example.com" } }, + ); + expect(changeAt(result.changes, ["auth", "additional_redirect_urls"])).toBeUndefined(); + }); + + test("record keys containing dots survive the classification", () => { + // sms.test_otp is keyed by phone numbers — segment-array paths keep the + // key intact where a dotted-string round-trip would silently lose it. + const declared = { + auth: { + sms: { + enable_confirmations: true, + test_otp: { "415.2127777": "111111" }, + }, + }, + }; + const result = diffWith(declared, { + auth: { sms_test_otp: "415.2127777=999999" }, + }); + expect(changeAt(result.changes, ["auth", "sms", "test_otp", "415.2127777"])).toMatchObject({ + class: "update", + local: "111111", + remote: "999999", + }); + }); + + test("byte-size values converge across representations", () => { + // Local "50MiB" and the wire's byte count both canonicalize through the + // convergence normalizers (ADR 0021), so they compare equal. + const equal = diffWith( + { storage: { file_size_limit: "50MiB" } }, + { storage: { file_size_limit: 52428800 } }, + ); + expect(changeAt(equal.changes, ["storage", "file_size_limit"])).toBeUndefined(); + + const differing = diffWith( + { storage: { file_size_limit: "50MiB" } }, + { storage: { file_size_limit: 1048576 } }, + ); + expect(changeAt(differing.changes, ["storage", "file_size_limit"])).toMatchObject({ + class: "update", + }); + }); + + test("declared secret values are masked, never compared, never counted", () => { + const declared = { + auth: { + external: { github: { enabled: true, client_id: "id", secret: "env(GITHUB_SECRET)" } }, + }, + }; + const result = diffWith(declared, { + auth: { external_github_enabled: true, external_github_client_id: "id" }, + }); + expect(result.masked).toContainEqual(["auth", "external", "github", "secret"]); + expect(changeAt(result.changes, ["auth", "external", "github", "secret"])).toBeUndefined(); + expect(result.counts).toEqual({ update: 0, remote_only: 0, local_only: 0, total: 0 }); + }); + + test("undeclared secrets are neither masked nor reported", () => { + const result = diffWith({}, { auth: { smtp_pass: "hmac-of-something" } }); + expect(result.masked).toEqual([]); + expect(result.changes.filter((change) => change.path.includes("pass"))).toEqual([]); + }); + + test("env references annotate the change with every involved variable", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 1000 } }, [ + { path: ["api", "max_rows"], source: "environment", envVariables: ["PGRST_MAX_ROWS"] }, + ]); + expect(changeAt(result.changes, ["api", "max_rows"])).toMatchObject({ + envVariables: ["PGRST_MAX_ROWS"], + }); + }); + + test("changes are ordered by path and counts add up", () => { + const result = diffWith( + { api: { max_rows: 5 }, auth: { site_url: "https://local.example.com" } }, + { api: { max_rows: 6 }, auth: {}, database: { postgres_settings: { work_mem: "64MB" } } }, + ); + const joined = result.changes.map((change) => change.path.join("")); + expect(joined).toEqual([...joined].sort()); + expect(result.counts.update).toBe(1); + expect(result.counts.remote_only).toBe(1); + expect(result.counts.local_only).toBe(1); + expect(result.counts.total).toBe(3); + }); +}); + +describe("isEqualConfigValue", () => { + test("sequence semantics by default", () => { + expect(isEqualConfigValue(["a", "b"], ["a", "b"])).toBe(true); + expect(isEqualConfigValue(["a", "b"], ["b", "a"])).toBe(false); + expect(isEqualConfigValue(["1"], [1])).toBe(true); + expect(isEqualConfigValue(["a"], ["a", "a"])).toBe(false); + }); + + test("set semantics on request", () => { + expect(isEqualConfigValue(["a", "b"], ["b", "a"], "set")).toBe(true); + expect(isEqualConfigValue(["a", "a", "b"], ["a", "b", "b"], "set")).toBe(false); + }); + + test("type-aware scalars", () => { + expect(isEqualConfigValue("8080", 8080)).toBe(true); + expect(isEqualConfigValue(8080, "8080")).toBe(true); + expect(isEqualConfigValue("true", true)).toBe(true); + expect(isEqualConfigValue(false, "false")).toBe(true); + expect(isEqualConfigValue("", 0)).toBe(false); + expect(isEqualConfigValue("8080x", 8080)).toBe(false); + expect(isEqualConfigValue(undefined, "")).toBe(false); + }); +}); diff --git a/packages/config/src/config-document.ts b/packages/config/src/config-document.ts index 2cb90095bb..0f79dd70e6 100644 --- a/packages/config/src/config-document.ts +++ b/packages/config/src/config-document.ts @@ -13,6 +13,12 @@ export type CliConfigValueSource = "environment" | "local" | "remote"; export interface CliConfigValueOrigin { readonly path: ReadonlyArray; readonly source: CliConfigValueSource; + /** + * For `"environment"` origins: the env var names the `env()` reference + * resolved from (one array literal may draw on several, so this is always + * a list — consumers must never have to split a joined string). + */ + readonly envVariables?: ReadonlyArray; } export interface LoadedCliConfig { diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 696c7d0b4e..502e6648cd 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -275,6 +275,7 @@ const { visitedFiles, bareSpecifiers } = collectImportGraph(join(srcDir, "index. const expectedPureGraphFiles = [ "index.ts", "base.ts", + "config-diff.ts", "errors.ts", "config-document.ts", "functions-manifest-model.ts", @@ -352,6 +353,7 @@ describe("src/index.ts export surface", () => { "attachApiResponse", "cliConfigValueSourceAt", "comparableProjectConfigPaths", + "diffProjectConfig", "edgeFunctionDenoConfigFileName", "edgeFunctionEntrypointFileName", "edgeFunctionsDirectoryName", @@ -361,7 +363,9 @@ describe("src/index.ts export surface", () => { "fromConfigDocument", "getDefaultCliConfig", "isComparableProjectConfigPath", + "isEqualConfigValue", "omitDefaultValues", + "projectConfigApiBlockKeys", "projectConfigMappingRows", "subtractCliConfig", "toCliConfigJsonSchema", @@ -395,6 +399,7 @@ describe("src/effect.ts is a superset of src/index.ts", () => { "comparableProjectConfigPaths", "configJsonPath", "configTomlPath", + "diffProjectConfig", "edgeFunctionDenoConfigFileName", "edgeFunctionEntrypointFileName", "edgeFunctionsDirectoryName", @@ -407,11 +412,13 @@ describe("src/effect.ts is a superset of src/index.ts", () => { "getDefaultCliConfig", "inferFunctionsManifest", "isComparableProjectConfigPath", + "isEqualConfigValue", "loadCliConfig", "loadCliConfigFile", "loadCliProjectEnvironment", "loadDotEnvFile", "omitDefaultValues", + "projectConfigApiBlockKeys", "projectConfigMappingRows", "resolveCliConfigSubtree", "resolveCliConfigValue", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index ce27b13fdb..e028d72e56 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -53,6 +53,15 @@ export { omitDefaultValues, subtractCliConfig, } from "./sparse.ts"; +export { + type ConfigChange, + type ConfigChangeClass, + type ConfigChangeCounts, + type ConfigChangeSet, + type DiffProjectConfigOptions, + diffProjectConfig, + isEqualConfigValue, +} from "./config-diff.ts"; export { KONG_LOCAL_CA_CERT } from "./tls.ts"; export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; export { @@ -68,7 +77,10 @@ export { toProjectConfig, unmappedApiFields, } from "./project-config/project-config.ts"; -export { type ProjectConfigApiAttributes } from "./project-config/api-attributes.ts"; +export { + type ProjectConfigApiAttributes, + projectConfigApiBlockKeys, +} from "./project-config/api-attributes.ts"; export { type ProjectConfigMappingRow } from "./project-config/registry-row.ts"; export { projectConfigMappingRows } from "./project-config/registry.ts"; export { AUTH_HOOK_NAMES, unmappedSecretApiPaths } from "./project-config/registry-auth.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index c8fb9a9d8d..89b76d0692 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -526,7 +526,7 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( const goViperCompat = options?.goViperCompat ?? false; const interpolateDocument = ( document: unknown, - onResolvedEnv?: (path: ReadonlyArray) => void, + onResolvedEnv?: (path: ReadonlyArray, envNames: ReadonlyArray) => void, ): unknown => interpolateEnvReferencesAgainstSchema(document, cliProjectEnv?.values ?? {}, CliConfigSchema, { goViperCompat, @@ -574,9 +574,11 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( // that path, but correctness on the match+`env()` path matters more than // avoiding it. const resolvedEnvironmentPaths: Array = []; + const resolvedEnvironmentNames = new Map>(); documentForDecode = isObject(documentForDecode) - ? interpolateDocument(documentForDecode, (path) => { + ? interpolateDocument(documentForDecode, (path, envNames) => { resolvedEnvironmentPaths.push(Array.from(path)); + resolvedEnvironmentNames.set(pathKey(Array.from(path)), envNames); }) : documentForDecode; @@ -621,7 +623,12 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( : localPathKeys.has(key) ? "local" : undefined; - return source === undefined ? [] : [{ path, source }]; + if (source === undefined) { + return []; + } + const envVariables = + source === "environment" ? resolvedEnvironmentNames.get(key) : undefined; + return [{ path, source, ...(envVariables === undefined ? {} : { envVariables }) }]; }) : []; diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 9c43681a96..5232775110 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -218,7 +218,7 @@ function substituteEnvLeaf( value: string, env: Readonly>, goViperCompat: boolean, -): { readonly value: string; readonly resolved: boolean } { +): { readonly value: string; readonly resolved: boolean; readonly envName?: string } { const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); if (match === null) { return { value, resolved: false }; @@ -229,10 +229,10 @@ function substituteEnvLeaf( // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the // `env(KEY)` literal exactly like an unset key, rather than substituting "". - if (resolved === undefined || resolved === "") { + if (envName === undefined || resolved === undefined || resolved === "") { return { value, resolved: false }; } - return { value: resolved, resolved: true }; + return { value: resolved, resolved: true, envName }; } function isDeferredEnvField(ast: SchemaAST.AST): boolean { @@ -258,22 +258,30 @@ function walk( ast: SchemaAST.AST | null, goViperCompat: boolean, path: ReadonlyArray, - onResolvedEnv: ((path: ReadonlyArray) => void) | undefined, + onResolvedEnv: + | ((path: ReadonlyArray, envNames: ReadonlyArray) => void) + | undefined, ): unknown { if (Array.isArray(document)) { - let resolved = false; + // Element-level resolutions are reported once, at the array's own path — + // one array literal may draw on several env vars, so the names collect. + const envNames: Array = []; const onResolvedArrayEnv = onResolvedEnv === undefined ? undefined - : () => { - resolved = true; + : (_: ReadonlyArray, resolvedNames: ReadonlyArray) => { + for (const envName of resolvedNames) { + if (!envNames.includes(envName)) { + envNames.push(envName); + } + } }; const result = document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); return walk(item, env, child, goViperCompat, [...path, String(index)], onResolvedArrayEnv); }); - if (resolved) { - onResolvedEnv?.(path); + if (envNames.length > 0) { + onResolvedEnv?.(path, envNames); } return result; } @@ -297,8 +305,8 @@ function walk( const interpolation = substituteEnvLeaf(document, env, goViperCompat); const substituted = interpolation.value; - if (interpolation.resolved) { - onResolvedEnv?.(path); + if (interpolation.resolved && interpolation.envName !== undefined) { + onResolvedEnv?.(path, [interpolation.envName]); } const expected = ast === null ? "unknown" : leafExpectedType(ast); @@ -357,7 +365,10 @@ export function interpolateEnvReferencesAgainstSchema( schema: { readonly ast: SchemaAST.AST }, options?: { readonly goViperCompat?: boolean; - readonly onResolvedEnv?: (path: ReadonlyArray) => void; + /** Fires per resolved leaf with the substituting env vars' names (array + * leaves report once at the array path, collecting every element's + * variable — one array literal may draw on several). */ + readonly onResolvedEnv?: (path: ReadonlyArray, envNames: ReadonlyArray) => void; }, ): unknown { return walk( diff --git a/packages/config/src/project-config/api-attributes.ts b/packages/config/src/project-config/api-attributes.ts index 2352cccfd5..2f448ce122 100644 --- a/packages/config/src/project-config/api-attributes.ts +++ b/packages/config/src/project-config/api-attributes.ts @@ -261,3 +261,15 @@ export const ProjectConfigApiAttributesSchema = Schema.Struct({ }); export type ProjectConfigApiAttributes = typeof ProjectConfigApiAttributesSchema.Type; + +/** + * The per-service block keys of the v2 project-config resource's + * `data.attributes`, in alphabetical order — derived from the mirror schema's + * own key set so consumers never hand-copy the block list (a hand-copied list + * reports a newly-learned block "not returned" forever, test-green). The + * package owns the response shape; a consumer rendering comparison scope + * (CLI-2156's scope line) reads it from here. + */ +export const projectConfigApiBlockKeys: ReadonlyArray = Object.keys( + ProjectConfigApiAttributesSchema.fields, +).sort(); diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index a6d1be5329..13aa9fcd98 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -735,6 +735,10 @@ const coreRows: ReadonlyArray = [ ? undefined : splitCommaSeparated(expectString(value, ["auth", "uri_allow_list"])), normalizeDocument: canonicalizeCommaJoinedArray, + // GoTrue treats the allow list as membership only — reordering the URLs + // changes nothing at runtime, unlike the sequence-semantics CSV arrays + // (`api.schemas`, `api.extra_search_path`). + arrayEquality: "set", unit: "csv → string[]", }, uintRow(["auth", "jwt_expiry"], "jwt_exp"), @@ -787,8 +791,18 @@ const rateLimitRows: ReadonlyArray = [ // SESSIONS (auth.sync.ts:1400-1408) const sessionsRows: ReadonlyArray = [ - hoursDurationRow(["auth", "sessions", "timebox"], "sessions_timebox"), - hoursDurationRow(["auth", "sessions", "inactivity_timeout"], "sessions_inactivity_timeout"), + // GoTrue reports 0 hours for a session bound that was never configured, and + // the transform canonicalizes that to the string "0s" — declare it here so + // the diff baseline recognizes the canonicalized form (a type-level zero + // check would miss it and flag every untouched project). + { + ...hoursDurationRow(["auth", "sessions", "timebox"], "sessions_timebox"), + unconfiguredValue: "0s", + }, + { + ...hoursDurationRow(["auth", "sessions", "inactivity_timeout"], "sessions_inactivity_timeout"), + unconfiguredValue: "0s", + }, ]; // EMAIL (auth.sync.ts:1548-1562) @@ -904,6 +918,24 @@ function smtpSiblingStringRow( // Email templates ×6 (auth.sync.ts:1439-1461; content_path has no API key) +/** + * The subject lines the platform provisions for a project that never touched + * its email templates — what `mailer_subjects_*` reports on a fresh project. + * The default config declares no subjects (there is no meaningful local + * default for a platform-rendered string), so without these the diff would + * flag every untouched project's subjects as `remote_only` drift. Pinned by + * the recorded real responses in `apps/cli-e2e/fixtures/recorded/ + * GET_v1_projects___PROJECT_REF___config_auth/`. + */ +const PLATFORM_DEFAULT_TEMPLATE_SUBJECTS = { + invite: "You have been invited", + confirmation: "Confirm Your Signup", + recovery: "Reset Your Password", + magic_link: "Your Magic Link", + email_change: "Confirm Email Change", + reauthentication: "Confirm Reauthentication", +} as const; + const EMAIL_TEMPLATE_NAMES = [ "invite", "confirmation", @@ -913,12 +945,24 @@ const EMAIL_TEMPLATE_NAMES = [ "reauthentication", ] as const; -const templateRows: ReadonlyArray = EMAIL_TEMPLATE_NAMES.map((name) => - stringRow(["auth", "email", "template", name, "subject"], `mailer_subjects_${name}`), -); +const templateRows: ReadonlyArray = EMAIL_TEMPLATE_NAMES.map((name) => ({ + ...stringRow(["auth", "email", "template", name, "subject"], `mailer_subjects_${name}`), + unconfiguredValue: PLATFORM_DEFAULT_TEMPLATE_SUBJECTS[name], +})); // Email notifications ×7 (auth.sync.ts:1491-1525) +/** Same provenance as {@link PLATFORM_DEFAULT_TEMPLATE_SUBJECTS}. */ +const PLATFORM_DEFAULT_NOTIFICATION_SUBJECTS = { + password_changed: "Your password has been changed", + email_changed: "Your email address has been changed", + phone_changed: "Your phone number has been changed", + identity_linked: "A new identity has been linked", + identity_unlinked: "An identity has been unlinked", + mfa_factor_enrolled: "A new MFA factor has been enrolled", + mfa_factor_unenrolled: "An MFA factor has been unenrolled", +} as const; + const EMAIL_NOTIFICATION_NAMES = [ "password_changed", "email_changed", @@ -931,14 +975,24 @@ const EMAIL_NOTIFICATION_NAMES = [ const notificationRows: ReadonlyArray = EMAIL_NOTIFICATION_NAMES.flatMap( (name) => [ - boolRow( - ["auth", "email", "notification", name, "enabled"], - `mailer_notifications_${name}_enabled`, - ), - stringRow( - ["auth", "email", "notification", name, "subject"], - `mailer_subjects_${name}_notification`, - ), + { + ...boolRow( + ["auth", "email", "notification", name, "enabled"], + `mailer_notifications_${name}_enabled`, + ), + // Every account-change notification defaults to disabled (supabase/auth + // `NotificationsConfiguration`, `default:"false"` on each field) — the + // config schema declares no default, so the diff baseline needs the + // platform's own unconfigured reading here. + unconfiguredValue: false, + }, + { + ...stringRow( + ["auth", "email", "notification", name, "subject"], + `mailer_subjects_${name}_notification`, + ), + unconfiguredValue: PLATFORM_DEFAULT_NOTIFICATION_SUBJECTS[name], + }, ], ); diff --git a/packages/config/src/project-config/registry-row.ts b/packages/config/src/project-config/registry-row.ts index a568dc5b96..1bb7427cf8 100644 --- a/packages/config/src/project-config/registry-row.ts +++ b/packages/config/src/project-config/registry-row.ts @@ -85,6 +85,36 @@ export interface ProjectConfigMappingRow { * counts as mapped for `unmappedApiFields`. */ readonly isSecret?: boolean; + /** + * Equality semantics for an array-valued row when a diff consumer compares + * the two projections (`../config-diff.ts`). Whether an array is a set or a + * sequence is per-field wire knowledge, so it lives here with the rest of + * the field's semantics. `"sequence"` — the default when absent — treats + * element order as meaningful: `api.schemas`' first entry is PostgREST's + * default schema and `api.extra_search_path` is a literal `search_path` + * whose order is resolution order, so a reordering changes runtime behavior + * and must register as drift. `"set"` opts a row out for arrays whose wire + * semantics are order-free (`auth.additional_redirect_urls`). Defaulting to + * sequence over-reports rather than under-reports when a new array row + * forgets to choose. + */ + readonly arrayEquality?: "set" | "sequence"; + /** + * The value the platform reports at `configPath` for a project that never + * configured this feature, expressed in CONFIG-space (post-`transform`) — + * e.g. the `"0s"` an unset `sessions.timebox` canonicalizes to, or the + * provisioning-default mailer subjects (pinned by the recorded + * `GET /v1/projects/{ref}/config/auth` fixtures under + * `apps/cli-e2e/fixtures/recorded/`). `../config-diff.ts` uses this as the + * last `remote_only`-suppression baseline tier for paths the default config + * (and its convergence projection) is silent on: a remote report equal to + * this value is the platform's spelling of "unconfigured", not drift. A row + * without it (and without any other baseline) over-reports rather than + * guesses — "unconfigured" is never inferred from type-level zero values, + * because canonicalization can turn a platform zero into a non-zero shape + * (`sessions_timebox: 0` arrives as the string `"0s"`). + */ + readonly unconfiguredValue?: unknown; /** * Unit/semantics note, e.g. `"csv → string[]"` or `"seconds → duration * string"`. Documentation-only — never read at runtime.