From 2be9a5d253e7b4e2c3a23c0b74ded50288789c37 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 11 Sep 2026 00:48:35 -0300 Subject: [PATCH] feat(cli): add supabase experiments enable and disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `supabase experiments enable …` and `supabase experiments disable …`, which record an experiment opt-in under `[experimental]` in the project's own `supabase/config.{toml,json}`. Until now the only ways to set one were an environment variable, which lasts a single process, and hand-editing the config file. Hand-editing is easy to get wrong: appending a second `[experimental]` header is invalid TOML, and an unparseable config resolves every experiment to off with no diagnostic, so the feature just fails to appear. The write goes through `applyConfigEdits`, which merges into the existing table, preserves comments and formatting byte-for-byte, supports `config.json`, and refuses a layout it cannot edit safely with a message naming the problem. Introduce `command-internal/experiment-registry.ts` as the closed set of opt-in booleans (`compute`, `stack`) and the source of each one's `SUPABASE_EXPERIMENTAL_*` name, and narrow `resolveExperimentalFeature` to it so a new gate cannot skip registration. The registry's descriptions are the argument help, so the valid names are discoverable from `--help`. Enabling is idempotent and writes nothing when the config already says what was asked. Disabling an experiment the config never mentions is likewise a no-op, since an absent key already resolves to off. When a `SUPABASE_EXPERIMENTAL_*` variable would override what was just written, the output says so. Hoist `config pull`'s edit-refusal phrasing to `command-internal/config-edit-refusal.ts` and take the command name as a parameter, so both callers share it. --- apps/cli/docs/compute-commands.md | 16 +- apps/cli/src/cli/root.ts | 2 + .../command-internal/config-edit-refusal.ts | 47 +++ .../src/command-internal/config-pull-run.ts | 49 +-- .../command-internal/experiment-registry.ts | 35 ++ .../command-internal/experimental-feature.ts | 5 +- .../experiments/disable/SIDE_EFFECTS.md | 77 ++++ .../experiments/disable/disable.command.ts | 40 +++ .../experiments/disable/disable.handler.ts | 13 + .../experiments/enable/SIDE_EFFECTS.md | 76 ++++ .../experiments/enable/enable.command.ts | 44 +++ .../experiments/enable/enable.handler.ts | 13 + .../experiments/experiments.command.ts | 11 + .../experiments/experiments.errors.ts | 60 ++++ .../experiments/experiments.format.ts | 80 +++++ .../experiments.format.unit.test.ts | 93 +++++ .../experiments.integration.test.ts | 332 ++++++++++++++++++ .../experiments/experiments.layers.ts | 15 + .../experiments/experiments.shared.ts | 154 ++++++++ apps/cli/src/docs/docs-spec.tables.ts | 1 + .../telemetry/__fixtures__/error-tags.txt | 5 + package.json | 4 +- 22 files changed, 1119 insertions(+), 53 deletions(-) create mode 100644 apps/cli/src/command-internal/config-edit-refusal.ts create mode 100644 apps/cli/src/command-internal/experiment-registry.ts create mode 100644 apps/cli/src/commands/experiments/disable/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experiments/disable/disable.command.ts create mode 100644 apps/cli/src/commands/experiments/disable/disable.handler.ts create mode 100644 apps/cli/src/commands/experiments/enable/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experiments/enable/enable.command.ts create mode 100644 apps/cli/src/commands/experiments/enable/enable.handler.ts create mode 100644 apps/cli/src/commands/experiments/experiments.command.ts create mode 100644 apps/cli/src/commands/experiments/experiments.errors.ts create mode 100644 apps/cli/src/commands/experiments/experiments.format.ts create mode 100644 apps/cli/src/commands/experiments/experiments.format.unit.test.ts create mode 100644 apps/cli/src/commands/experiments/experiments.integration.test.ts create mode 100644 apps/cli/src/commands/experiments/experiments.layers.ts create mode 100644 apps/cli/src/commands/experiments/experiments.shared.ts diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f69581da9b..a56eb3f010 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -3,8 +3,8 @@ The experimental Compute command family runs application containers alongside a project; it is separate from the database instance size. Its commands and local configuration may change incompatibly while experimental. The family is opt in. Enable it with -`SUPABASE_EXPERIMENTAL_COMPUTE=1` or by setting `compute = true` under -`[experimental]` in `supabase/config.toml`. +`supabase experiments enable compute`, which records the setting in the project's config, or +with `SUPABASE_EXPERIMENTAL_COMPUTE=1` for one process. The environment variable accepts `1` to enable and `0` to disable. When it is unset or empty, the config file is used; any other non-empty value reports an @@ -14,11 +14,23 @@ Unrelated commands do not resolve the Compute flag. Unreadable or malformed configuration leaves Compute disabled. An environment opt-in applies only to that process; use the project setting to share the opt-in with teammates and CI. +```sh +supabase experiments enable compute +``` + +which writes into the project's existing `[experimental]` table: + ```toml [experimental] compute = true ``` +Editing the file by hand works too, as long as the key joins the `[experimental]` table the +file already has. A second `[experimental]` header is invalid TOML, and an unparseable config +leaves Compute disabled without reporting why — `experiments enable` merges into the existing +table and refuses a document it cannot edit safely. `supabase experiments disable compute` +reverses it. + For `supabase/config.json`, use the equivalent JSON object: ```json diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 47e94b924c..89d50edd65 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -13,6 +13,7 @@ import { stackStartCommand } from "../commands/experimental/stack/start/start.co import { stackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts"; import type { StackBackend } from "../commands/experimental/stack/stack-backend.ts"; import { computeCommand } from "../commands/experimental/compute/compute.command.ts"; +import { experimentsCommand } from "../commands/experiments/experiments.command.ts"; import { feedbackCommand } from "../commands/feedback/feedback.command.ts"; import { functionsCommand } from "../commands/functions/functions.command.ts"; import { genCommand } from "../commands/gen/gen.command.ts"; @@ -95,6 +96,7 @@ export const rootCommandForFeatures = ( dbCommand, domainsCommand, encryptionCommand, + experimentsCommand, feedbackCommand, functionsCommand, genCommand, diff --git a/apps/cli/src/command-internal/config-edit-refusal.ts b/apps/cli/src/command-internal/config-edit-refusal.ts new file mode 100644 index 0000000000..79bbc0e494 --- /dev/null +++ b/apps/cli/src/command-internal/config-edit-refusal.ts @@ -0,0 +1,47 @@ +import type { ConfigEditRefusalReason } from "@supabase/config/internal"; + +/** + * Human-readable phrase for a `ConfigEditRefusal.reason` — the raw enum token + * (`duplicate_table_header`, …) never appears in a user-facing message, only prose. + */ +export function configEditRefusalPhrase(reason: ConfigEditRefusalReason): string { + switch (reason) { + case "duplicate_table_header": + return "a duplicate table header"; + case "array_of_tables_on_path": + return "an array of tables on this path"; + case "inline_table_on_path": + return "an inline table on this path"; + case "env_reference_target": + return "an existing env() reference at this path"; + case "verification_mismatch": + return "a verification mismatch after editing"; + case "parse_error": + return "a parse error"; + } +} + +/** + * One remediation sentence per `ConfigEditRefusal.reason`, naming `command` where the + * limitation is the command's rather than the document's. `verification_mismatch` and + * `parse_error` both mean the editor misjudged the document, not something the user can fix + * by hand. + */ +export function configEditRefusalRemediation( + reason: ConfigEditRefusalReason, + command: string, +): string { + switch (reason) { + case "duplicate_table_header": + return "Merge the duplicate table headers into one, then rerun."; + case "inline_table_on_path": + return "Rewrite it as a standard [table] section, then rerun."; + case "array_of_tables_on_path": + return `${command} does not support writing through an array of tables ([[...]]); restructure it by hand, then rerun.`; + case "env_reference_target": + return "Replace the env(...) reference with a literal value, then rerun."; + case "verification_mismatch": + case "parse_error": + return "This is a CLI bug; nothing was written. Please report it."; + } +} diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 4ef17d7449..464706f806 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -12,10 +12,10 @@ import { decodeCliConfigDocumentForValidationEffect, writeCliConfigDocumentText, type ConfigEdit, - type ConfigEditRefusalReason, type DecodeCliConfigDocumentForValidationEffectOptions, } from "@supabase/config/internal"; import type { ConfigChange } from "@supabase/config"; +import { configEditRefusalPhrase, configEditRefusalRemediation } from "./config-edit-refusal.ts"; import { operationDefinitions } from "@supabase/api/effect"; import { Effect, FileSystem, Result, Schema, SchemaIssue } from "effect"; @@ -172,51 +172,6 @@ function configPullLabelCollisionMessage( return `--remote-label "${label}" already tracks project ${conflictingProjectId}; pass a different --remote-label, or drop the flag to reuse the block that already tracks this project.`; } -/** - * Human-readable phrase for a `ConfigEditRefusal.reason` — the raw enum - * token (`duplicate_table_header`, ...) never appears in the constructed - * `ConfigPullUnsupportedLayoutError` message, only prose. - */ -function configPullRefusalPhrase(reason: ConfigEditRefusalReason): string { - switch (reason) { - case "duplicate_table_header": - return "a duplicate table header"; - case "array_of_tables_on_path": - return "an array of tables on this path"; - case "inline_table_on_path": - return "an inline table on this path"; - case "env_reference_target": - return "an existing env() reference at this path"; - case "verification_mismatch": - return "a verification mismatch after editing"; - case "parse_error": - return "a parse error"; - } -} - -/** - * One remediation sentence per `ConfigEditRefusal.reason` — `env_reference_target` - * stays generic (the planner already skips every `env()`-declared change - * before it ever reaches `applyConfigEdits`, so this reason should not occur - * in practice); `verification_mismatch`/`parse_error` both mean the editor - * itself misjudged the document, not something the user can fix by hand. - */ -function configPullRefusalRemediation(reason: ConfigEditRefusalReason): string { - switch (reason) { - case "duplicate_table_header": - return "Merge the duplicate table headers into one, then rerun."; - case "inline_table_on_path": - return "Rewrite it as a standard [table] section, then rerun."; - case "array_of_tables_on_path": - return "config pull does not support writing through an array of tables ([[...]]); restructure it by hand, then rerun."; - case "env_reference_target": - return "Replace the env(...) reference with a literal value, then rerun."; - case "verification_mismatch": - case "parse_error": - return "This is a CLI bug; nothing was written. Please report it."; - } -} - /** * Convergence check run after the fixpoint expansion settles, before `--dry-run` returns, * against the fixpoint's own residual (the last round's re-diff). A residual change at a @@ -777,7 +732,7 @@ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { const { reason, path, detail } = editOutcome.refusal; const location = path.length === 0 ? "" : ` at ${configRenderPath(path)}`; return yield* new ConfigPullUnsupportedLayoutError({ - message: `cannot write ${context.configPath}: ${configPullRefusalPhrase(reason)}${location} — ${detail}. ${configPullRefusalRemediation(reason)}`, + message: `cannot write ${context.configPath}: ${configEditRefusalPhrase(reason)}${location} — ${detail}. ${configEditRefusalRemediation(reason, "config pull")}`, }); } yield* writeCliConfigDocumentText(configFilePath, editOutcome.text).pipe( diff --git a/apps/cli/src/command-internal/experiment-registry.ts b/apps/cli/src/command-internal/experiment-registry.ts new file mode 100644 index 0000000000..6f9537ed24 --- /dev/null +++ b/apps/cli/src/command-internal/experiment-registry.ts @@ -0,0 +1,35 @@ +/** + * The closed set of experimental features a project can opt into. An entry here is what makes + * a feature reachable from `supabase experiments enable`; `experimental-feature.ts` resolves + * one at startup and `cli/root.ts` decides what to register from the result. + * + * Only booleans that gate a command path belong here. The other `[experimental]` keys + * (`orioledb_version`, `s3_host`, `pgdelta`, …) are configuration the user fills in, not + * opt-ins a name alone can toggle. + */ + +export const EXPERIMENT_NAMES = ["compute", "stack"] as const; + +export type ExperimentName = (typeof EXPERIMENT_NAMES)[number]; + +/** One line per experiment, for the `experiments` argument help. */ +const EXPERIMENT_DESCRIPTIONS: Record = { + compute: "run containers next to your project", + stack: "new local backend behind start and stop", +}; + +/** The environment override for one experiment, which takes precedence over project config. */ +export function experimentEnvName(feature: ExperimentName): string { + return `SUPABASE_EXPERIMENTAL_${feature.toUpperCase()}`; +} + +/** + * The `FEATURE` argument's help text: what each name in the closed set actually turns on. + * Without it the help lists bare names, which is how an experiment ends up undiscoverable. + */ +export function experimentArgumentDescription(verb: "enable" | "disable"): string { + const catalog = EXPERIMENT_NAMES.map( + (feature) => `${feature}: ${EXPERIMENT_DESCRIPTIONS[feature]}`, + ).join("; "); + return `Experiments to ${verb}. ${catalog}.`; +} diff --git a/apps/cli/src/command-internal/experimental-feature.ts b/apps/cli/src/command-internal/experimental-feature.ts index 312b7cf7e8..f2bbd3ed80 100644 --- a/apps/cli/src/command-internal/experimental-feature.ts +++ b/apps/cli/src/command-internal/experimental-feature.ts @@ -4,6 +4,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { experimentEnvName, type ExperimentName } from "./experiment-registry.ts"; export class ExperimentalFeatureFlagError extends Data.TaggedError("ExperimentalFeatureFlagError")<{ readonly envName: string; @@ -16,11 +17,11 @@ export class ExperimentalFeatureFlagError extends Data.TaggedError("Experimental /** Resolves one experimental boolean from its environment override and config fallback. */ export const resolveExperimentalFeature = (input: { - readonly feature: string; + readonly feature: ExperimentName; readonly configValue: Effect.Effect; readonly env: Readonly>; }): Effect.Effect => { - const envName = `SUPABASE_EXPERIMENTAL_${input.feature.toUpperCase()}`; + const envName = experimentEnvName(input.feature); const override = input.env[envName]; if (override === undefined || override === "") { return input.configValue.pipe(Effect.map((value) => value === true)); diff --git a/apps/cli/src/commands/experiments/disable/SIDE_EFFECTS.md b/apps/cli/src/commands/experiments/disable/SIDE_EFFECTS.md new file mode 100644 index 0000000000..f8fd7ccf5e --- /dev/null +++ b/apps/cli/src/commands/experiments/disable/SIDE_EFFECTS.md @@ -0,0 +1,77 @@ +# `supabase experiments disable …` + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, unless the project is configured by `config.json` | +| `/supabase/config.json` | JSON | always, when it exists (it takes precedence over `config.toml`) | + +The project is located from `CommandSettings.workdir`, climbing ancestor +directories unless `--workdir`/`SUPABASE_WORKDIR` named one explicitly. + +## Files Written + +| Path | Format | When | +| --------------------------------------- | ------------ | ------------------------------------------------------- | +| `/supabase/config.{toml,json}` | same as read | only when at least one named experiment is currently on | + +The write is a surgical, format-preserving edit through `applyConfigEdits`: it +sets `experimental. = false` inside the existing `[experimental]` +table, or creates that table when the document has none. Comments, key order, +spacing, and quoting elsewhere in the file survive byte-for-byte. The file is +replaced atomically (write to a sibling temp file, then rename), preserving its +mode. + +An experiment the document never mentions already resolves to off, so disabling +it writes nothing rather than recording a redundant `false`. + +## API Routes + +None called directly. `cli_command_executed` may be sent to PostHog. + +## Environment Variables + +| Variable | Purpose | Required? | +| ------------------------------- | --------------------------------------------------------- | --------------------------------- | +| `SUPABASE_WORKDIR` | locate the project when `--workdir` is absent | no (defaults to an ancestor walk) | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | read only to warn that it overrides what was just written | no | +| `SUPABASE_EXPERIMENTAL_STACK` | read only to warn that it overrides what was just written | no | + +The `SUPABASE_EXPERIMENTAL_*` variables are never written and never consulted +to decide what to write — the command always records the project setting. They +are read solely so the output can say when the current shell will ignore it. + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success, including a run where every named experiment was already disabled | +| `1` | no project config found; config unreadable or unwritable; unknown feature name; `-o`/`--output` passed; the document's layout refused the edit (e.g. a duplicate `[experimental]` header) | + +## Output + +Text mode writes one line per named experiment: + +```text +Disabled compute in /path/to/supabase/config.toml. +stack is already disabled in /path/to/supabase/config.toml. +``` + +followed by one line per experiment whose environment override shadows the file: + +```text +Note: SUPABASE_EXPERIMENTAL_COMPUTE=1 takes precedence over /path/to/supabase/config.toml for this shell. +``` + +`--output-format json`/`stream-json` emit a result payload instead: +`{config_path, enabled, experiments: [{name, previous, changed, env_override}]}`. + +`-o`/`--output` is rejected outright; this command has no Go-compatible output +contract, so `--output-format` is the only machine-format flag. + +## Notes + +- The feature name is a closed enum (`compute`, `stack`), validated by the + argument parser, so an unknown name fails before any file is read. +- Repeating a name (`disable compute compute`) reports it once. diff --git a/apps/cli/src/commands/experiments/disable/disable.command.ts b/apps/cli/src/commands/experiments/disable/disable.command.ts new file mode 100644 index 0000000000..39f918db8f --- /dev/null +++ b/apps/cli/src/commands/experiments/disable/disable.command.ts @@ -0,0 +1,40 @@ +import { Argument, Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { + EXPERIMENT_NAMES, + experimentArgumentDescription, +} from "../../../command-internal/experiment-registry.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; +import { experimentsRuntimeLayer } from "../experiments.layers.ts"; +import { experimentsDisable } from "./disable.handler.ts"; + +const config = { + features: Argument.choice("FEATURE", EXPERIMENT_NAMES).pipe( + Argument.withDescription(experimentArgumentDescription("disable")), + Argument.variadic({ min: 1 }), + ), +} as const; + +export type ExperimentsDisableFlags = CliCommand.Command.Config.Infer; + +export const experimentsDisableCommand = Command.make("disable", config).pipe( + Command.withDescription( + "Disable one or more experiments by writing them to [experimental] in supabase/config.toml.", + ), + Command.withShortDescription("Disable experiments for this project"), + Command.withExamples([ + { + command: "supabase experiments disable compute", + description: "Turn the compute command family back off for this project", + }, + ]), + Command.withHandler((flags) => + experimentsDisable(flags).pipe( + // The feature names are a closed enum, so logging them verbatim carries no user data. + withCommandTelemetry({ flags, safeFlags: ["features"] }), + withJsonErrorHandling, + ), + ), + Command.provide(experimentsRuntimeLayer(["experiments", "disable"])), +); diff --git a/apps/cli/src/commands/experiments/disable/disable.handler.ts b/apps/cli/src/commands/experiments/disable/disable.handler.ts new file mode 100644 index 0000000000..96ec27f29d --- /dev/null +++ b/apps/cli/src/commands/experiments/disable/disable.handler.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect"; +import { setExperiments } from "../experiments.shared.ts"; +import type { ExperimentsDisableFlags } from "./disable.command.ts"; + +export const experimentsDisable = Effect.fn("experiments.disable")(function* ( + flags: ExperimentsDisableFlags, +) { + yield* setExperiments({ + features: flags.features, + enabled: false, + command: "experiments disable", + }); +}); diff --git a/apps/cli/src/commands/experiments/enable/SIDE_EFFECTS.md b/apps/cli/src/commands/experiments/enable/SIDE_EFFECTS.md new file mode 100644 index 0000000000..521914507e --- /dev/null +++ b/apps/cli/src/commands/experiments/enable/SIDE_EFFECTS.md @@ -0,0 +1,76 @@ +# `supabase experiments enable …` + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, unless the project is configured by `config.json` | +| `/supabase/config.json` | JSON | always, when it exists (it takes precedence over `config.toml`) | + +The project is located from `CommandSettings.workdir`, climbing ancestor +directories unless `--workdir`/`SUPABASE_WORKDIR` named one explicitly. + +## Files Written + +| Path | Format | When | +| --------------------------------------- | ------------ | -------------------------------------------------------------- | +| `/supabase/config.{toml,json}` | same as read | only when at least one named experiment is not already enabled | + +The write is a surgical, format-preserving edit through `applyConfigEdits`: it +sets `experimental. = true` inside the existing `[experimental]` table, +or creates that table when the document has none. Comments, key order, spacing, +and quoting elsewhere in the file survive byte-for-byte. The file is replaced +atomically (write to a sibling temp file, then rename), preserving its mode. + +Nothing is written when every named experiment already holds the requested +value, so a repeat run leaves the file's mtime untouched. + +## API Routes + +None called directly. `cli_command_executed` may be sent to PostHog. + +## Environment Variables + +| Variable | Purpose | Required? | +| ------------------------------- | --------------------------------------------------------- | --------------------------------- | +| `SUPABASE_WORKDIR` | locate the project when `--workdir` is absent | no (defaults to an ancestor walk) | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | read only to warn that it overrides what was just written | no | +| `SUPABASE_EXPERIMENTAL_STACK` | read only to warn that it overrides what was just written | no | + +The `SUPABASE_EXPERIMENTAL_*` variables are never written and never consulted +to decide what to write — the command always records the project setting. They +are read solely so the output can say when the current shell will ignore it. + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success, including a run where every named experiment was already enabled | +| `1` | no project config found; config unreadable or unwritable; unknown feature name; `-o`/`--output` passed; the document's layout refused the edit (e.g. a duplicate `[experimental]` header) | + +## Output + +Text mode writes one line per named experiment: + +```text +Enabled compute in /path/to/supabase/config.toml. +stack is already enabled in /path/to/supabase/config.toml. +``` + +followed by one line per experiment whose environment override shadows the file: + +```text +Note: SUPABASE_EXPERIMENTAL_COMPUTE=0 takes precedence over /path/to/supabase/config.toml for this shell. +``` + +`--output-format json`/`stream-json` emit a result payload instead: +`{config_path, enabled, experiments: [{name, previous, changed, env_override}]}`. + +`-o`/`--output` is rejected outright; this command has no Go-compatible output +contract, so `--output-format` is the only machine-format flag. + +## Notes + +- The feature name is a closed enum (`compute`, `stack`), validated by the + argument parser, so an unknown name fails before any file is read. +- Repeating a name (`enable compute compute`) reports it once. diff --git a/apps/cli/src/commands/experiments/enable/enable.command.ts b/apps/cli/src/commands/experiments/enable/enable.command.ts new file mode 100644 index 0000000000..6a922c7f70 --- /dev/null +++ b/apps/cli/src/commands/experiments/enable/enable.command.ts @@ -0,0 +1,44 @@ +import { Argument, Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { + EXPERIMENT_NAMES, + experimentArgumentDescription, +} from "../../../command-internal/experiment-registry.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; +import { experimentsRuntimeLayer } from "../experiments.layers.ts"; +import { experimentsEnable } from "./enable.handler.ts"; + +const config = { + features: Argument.choice("FEATURE", EXPERIMENT_NAMES).pipe( + Argument.withDescription(experimentArgumentDescription("enable")), + Argument.variadic({ min: 1 }), + ), +} as const; + +export type ExperimentsEnableFlags = CliCommand.Command.Config.Infer; + +export const experimentsEnableCommand = Command.make("enable", config).pipe( + Command.withDescription( + "Enable one or more experiments by writing them to [experimental] in supabase/config.toml.", + ), + Command.withShortDescription("Enable experiments for this project"), + Command.withExamples([ + { + command: "supabase experiments enable compute", + description: "Enable the compute command family for this project", + }, + { + command: "supabase experiments enable compute stack", + description: "Enable several experiments at once", + }, + ]), + Command.withHandler((flags) => + experimentsEnable(flags).pipe( + // The feature names are a closed enum, so logging them verbatim carries no user data. + withCommandTelemetry({ flags, safeFlags: ["features"] }), + withJsonErrorHandling, + ), + ), + Command.provide(experimentsRuntimeLayer(["experiments", "enable"])), +); diff --git a/apps/cli/src/commands/experiments/enable/enable.handler.ts b/apps/cli/src/commands/experiments/enable/enable.handler.ts new file mode 100644 index 0000000000..34fbb41472 --- /dev/null +++ b/apps/cli/src/commands/experiments/enable/enable.handler.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect"; +import { setExperiments } from "../experiments.shared.ts"; +import type { ExperimentsEnableFlags } from "./enable.command.ts"; + +export const experimentsEnable = Effect.fn("experiments.enable")(function* ( + flags: ExperimentsEnableFlags, +) { + yield* setExperiments({ + features: flags.features, + enabled: true, + command: "experiments enable", + }); +}); diff --git a/apps/cli/src/commands/experiments/experiments.command.ts b/apps/cli/src/commands/experiments/experiments.command.ts new file mode 100644 index 0000000000..3c7026660a --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.command.ts @@ -0,0 +1,11 @@ +import { Command } from "effect/unstable/cli"; +import { experimentsDisableCommand } from "./disable/disable.command.ts"; +import { experimentsEnableCommand } from "./enable/enable.command.ts"; + +export const experimentsCommand = Command.make("experiments").pipe( + Command.withDescription( + "Manage this project's experimental feature opt-ins, recorded under [experimental] in supabase/config.toml.", + ), + Command.withShortDescription("Manage experiment opt-ins"), + Command.withSubcommands([experimentsEnableCommand, experimentsDisableCommand]), +); diff --git a/apps/cli/src/commands/experiments/experiments.errors.ts b/apps/cli/src/commands/experiments/experiments.errors.ts new file mode 100644 index 0000000000..0200d203e3 --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.errors.ts @@ -0,0 +1,60 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +/** No `supabase/config.toml` or `supabase/config.json` to record the opt-in in. */ +export class ExperimentsProjectNotFoundError extends Data.TaggedError( + "ExperimentsProjectNotFoundError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** The config file exists but could not be read. */ +export class ExperimentsConfigReadError extends Data.TaggedError("ExperimentsConfigReadError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + } +} + +/** + * The surgical editor refused the document's layout — most often a second `[experimental]` + * table header, which leaves the file unparseable and every experiment silently off. + */ +export class ExperimentsUnsupportedLayoutError extends Data.TaggedError( + "ExperimentsUnsupportedLayoutError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** The edited document could not be written back. */ +export class ExperimentsWriteError extends Data.TaggedError("ExperimentsWriteError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + } +} + +/** `-o`/`--output` carries no meaning here; `--output-format` is the machine-output flag. */ +export class ExperimentsOutputFlagUnsupportedError extends Data.TaggedError( + "ExperimentsOutputFlagUnsupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/commands/experiments/experiments.format.ts b/apps/cli/src/commands/experiments/experiments.format.ts new file mode 100644 index 0000000000..4876e02bfa --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.format.ts @@ -0,0 +1,80 @@ +import * as SmolToml from "smol-toml"; +import type { ConfigFormat } from "@supabase/config"; +import { + EXPERIMENT_NAMES, + experimentEnvName, + type ExperimentName, +} from "../../command-internal/experiment-registry.ts"; + +/** + * One experiment's disposition after `experiments enable`/`disable` decided what to do with + * it. `changed: false` means the project config already said what was asked. + */ +export interface ExperimentOutcome { + readonly feature: ExperimentName; + readonly previous: boolean; + readonly changed: boolean; + /** The `SUPABASE_EXPERIMENTAL_*` value shadowing this setting in the current process. */ + readonly envOverride: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * The `[experimental]` booleans the document currently declares, or `undefined` when it does + * not parse at all. An unreadable document is not an error here: `applyConfigEdits` runs next + * and refuses with a reason naming the actual layout problem, which is more use to the reader + * than "parse failed". + */ +export function readExperimentValues( + format: ConfigFormat, + text: string, +): Partial> | undefined { + let document: unknown; + try { + document = format === "json" ? JSON.parse(text) : SmolToml.parse(text); + } catch { + return undefined; + } + if (!isRecord(document)) return {}; + const experimental = document["experimental"]; + if (!isRecord(experimental)) return {}; + + const values: Partial> = {}; + for (const feature of EXPERIMENT_NAMES) { + const value = experimental[feature]; + if (typeof value === "boolean") { + values[feature] = value; + } + } + return values; +} + +/** + * The text-mode report: one line per requested experiment, then any environment override that + * would make the file's new value a lie for the current shell. + */ +export function renderExperimentOutcomes(input: { + readonly outcomes: ReadonlyArray; + readonly enabled: boolean; + readonly configPath: string; +}): string { + const verb = input.enabled ? "Enabled" : "Disabled"; + const state = input.enabled ? "enabled" : "disabled"; + const lines = input.outcomes.map((outcome) => + outcome.changed + ? `${verb} ${outcome.feature} in ${input.configPath}.` + : `${outcome.feature} is already ${state} in ${input.configPath}.`, + ); + + for (const outcome of input.outcomes) { + if (outcome.envOverride === undefined) continue; + lines.push( + `Note: ${experimentEnvName(outcome.feature)}=${outcome.envOverride} takes precedence over ${input.configPath} for this shell.`, + ); + } + + return `${lines.join("\n")}\n`; +} diff --git a/apps/cli/src/commands/experiments/experiments.format.unit.test.ts b/apps/cli/src/commands/experiments/experiments.format.unit.test.ts new file mode 100644 index 0000000000..cc44c84b37 --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.format.unit.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { readExperimentValues, renderExperimentOutcomes } from "./experiments.format.ts"; + +describe("readExperimentValues", () => { + it("reads the experiment booleans an [experimental] table declares", () => { + expect( + readExperimentValues( + "toml", + 'project_id = "x"\n\n[experimental]\ncompute = true\nstack = false\n', + ), + ).toEqual({ compute: true, stack: false }); + }); + + it("reads the same keys out of a JSON document", () => { + expect(readExperimentValues("json", '{"experimental":{"compute":true}}')).toEqual({ + compute: true, + }); + }); + + it("ignores [experimental] keys that are not experiments and not booleans", () => { + expect( + readExperimentValues("toml", '[experimental]\norioledb_version = ""\ncompute = "yes"\n'), + ).toEqual({}); + }); + + it("reports no experiments for a document with no [experimental] table", () => { + expect(readExperimentValues("toml", 'project_id = "x"\n')).toEqual({}); + }); + + it("reports no experiments when the document's root is not a table", () => { + expect(readExperimentValues("json", "[1, 2]")).toEqual({}); + }); + + it("reports no experiments when [experimental] is not a table", () => { + expect(readExperimentValues("json", '{"experimental": 3}')).toEqual({}); + }); + + // The duplicate-table case the command exists to diagnose: `applyConfigEdits` names it, + // so this reader only has to decline to guess. + it("returns undefined for a document that does not parse", () => { + expect(readExperimentValues("toml", "[experimental]\ncompute = true\n\n[experimental]\n")).toBe( + undefined, + ); + expect(readExperimentValues("json", "{oops")).toBe(undefined); + }); +}); + +describe("renderExperimentOutcomes", () => { + it("reports a write and a no-op differently", () => { + expect( + renderExperimentOutcomes({ + enabled: true, + configPath: "/p/supabase/config.toml", + outcomes: [ + { feature: "compute", previous: false, changed: true, envOverride: undefined }, + { feature: "stack", previous: true, changed: false, envOverride: undefined }, + ], + }), + ).toBe( + "Enabled compute in /p/supabase/config.toml.\n" + + "stack is already enabled in /p/supabase/config.toml.\n", + ); + }); + + it("uses the disable vocabulary when disabling", () => { + expect( + renderExperimentOutcomes({ + enabled: false, + configPath: "/p/supabase/config.toml", + outcomes: [ + { feature: "compute", previous: true, changed: true, envOverride: undefined }, + { feature: "stack", previous: false, changed: false, envOverride: undefined }, + ], + }), + ).toBe( + "Disabled compute in /p/supabase/config.toml.\n" + + "stack is already disabled in /p/supabase/config.toml.\n", + ); + }); + + it("names the environment variable that will ignore what was just written", () => { + expect( + renderExperimentOutcomes({ + enabled: true, + configPath: "/p/supabase/config.toml", + outcomes: [{ feature: "compute", previous: false, changed: true, envOverride: "0" }], + }), + ).toBe( + "Enabled compute in /p/supabase/config.toml.\n" + + "Note: SUPABASE_EXPERIMENTAL_COMPUTE=0 takes precedence over /p/supabase/config.toml for this shell.\n", + ); + }); +}); diff --git a/apps/cli/src/commands/experiments/experiments.integration.test.ts b/apps/cli/src/commands/experiments/experiments.integration.test.ts new file mode 100644 index 0000000000..25b791a74a --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.integration.test.ts @@ -0,0 +1,332 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { OutputFlag } from "../../command-internal/global-flags.ts"; +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; +import { experimentsDisable } from "./disable/disable.handler.ts"; +import { experimentsEnable } from "./enable/enable.handler.ts"; + +const CONFIG_WITH_COMMENTS = `project_id = "demo" + +[api] +enabled = true + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +`; + +/** A temp project whose config file is `relativePath`, plus a reader for it. */ +const project = Effect.fnUntraced(function* ( + contents: string, + relativePath = "supabase/config.toml", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-experiments-" }); + const configPath = path.join(dir, relativePath); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString(configPath, contents); + return { + dir, + configPath, + read: fs.readFileString(configPath), + }; +}); + +function setupExperiments(options: { + readonly workdir: string; + readonly format?: "text" | "json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; + readonly env?: Readonly>; +}) { + const out = mockOutput({ format: options.format ?? "text" }); + return { + out, + layer: Layer.mergeAll( + out.layer, + mockRuntimeInfo({ cwd: options.workdir }), + // Supplied rather than stubbed onto `process.env`: `ConfigProvider`'s default snapshots + // the ambient environment once per runtime, so an ambient stub set by one test leaks into + // every later one in the file. + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { ...options.env } })), + Layer.succeed(CommandSettings, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + poolerHost: "pooler.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + accessToken: Option.some(Redacted.make("sbp_test")), + projectId: Option.none(), + workdir: options.workdir, + explicitWorkdir: true, + userAgent: "supabase", + }), + Layer.succeed( + OutputFlag, + options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), + ), + BunServices.layer, + ), + }; +} + +describe("experiments enable", () => { + it.live("records the opt-in inside the existing [experimental] table, keeping comments", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + + expect(yield* repo.read).toBe(`${CONFIG_WITH_COMMENTS}compute = true\n`); + expect(out.stdoutText).toBe(`Enabled compute in ${repo.configPath}.\n`); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("creates the [experimental] table when the document has none", () => + Effect.gen(function* () { + const repo = yield* project('project_id = "demo"\n'); + const { layer } = setupExperiments({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + expect(yield* repo.read).toContain("[experimental]"); + expect(yield* repo.read).toContain("compute = true"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("enables several experiments in one run and reports each", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute", "stack"] }); + + const written = yield* repo.read; + expect(written).toContain("compute = true"); + expect(written).toContain("stack = true"); + expect(out.stdoutText).toBe( + `Enabled compute in ${repo.configPath}.\nEnabled stack in ${repo.configPath}.\n`, + ); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("reports a repeated name once", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute", "compute"] }); + expect(out.stdoutText).toBe(`Enabled compute in ${repo.configPath}.\n`); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("leaves the file untouched when the experiment is already enabled", () => + Effect.gen(function* () { + const repo = yield* project(`${CONFIG_WITH_COMMENTS}compute = true\n`); + const { layer, out } = setupExperiments({ workdir: repo.dir }); + const before = yield* repo.read; + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + expect(yield* repo.read).toBe(before); + expect(out.stdoutText).toBe(`compute is already enabled in ${repo.configPath}.\n`); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("edits a config.json project in place", () => + Effect.gen(function* () { + const repo = yield* project( + '{\n "project_id": "demo",\n "experimental": {\n "orioledb_version": ""\n }\n}\n', + "supabase/config.json", + ); + const { layer } = setupExperiments({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + // Asserted as text, not as a decoded object: the point is that the edit lands inside + // the existing object with the file's own indentation intact. + expect(yield* repo.read).toBe( + '{\n "project_id": "demo",\n "experimental": {\n "orioledb_version": "",\n "compute": true\n }\n}\n', + ); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("warns when an environment override will ignore what was written", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ + workdir: repo.dir, + env: { SUPABASE_EXPERIMENTAL_COMPUTE: "0" }, + }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + expect(out.stdoutText).toContain( + `Note: SUPABASE_EXPERIMENTAL_COMPUTE=0 takes precedence over ${repo.configPath}`, + ); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("treats an empty environment override as unset", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ + workdir: repo.dir, + env: { SUPABASE_EXPERIMENTAL_COMPUTE: "" }, + }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + expect(out.stdoutText).not.toContain("takes precedence"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("emits a structured payload in json mode", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* experimentsEnable({ features: ["compute"] }); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + config_path: repo.configPath, + enabled: true, + experiments: [ + { name: "compute", previous: false, changed: true, env_override: null }, + ], + }, + }), + ); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("names the duplicate table header rather than silently leaving the flag off", () => + Effect.gen(function* () { + const repo = yield* project( + 'project_id = "demo"\n\n[experimental]\ncompute = true\n\n[api]\nenabled = true\n\n[experimental]\norioledb_version = ""\n', + ); + const { layer } = setupExperiments({ workdir: repo.dir }); + const before = yield* repo.read; + + return yield* Effect.gen(function* () { + const exit = yield* experimentsEnable({ features: ["stack"] }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* repo.read).toBe(before); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("fails when no supabase project config exists", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-experiments-empty-" }); + const { layer } = setupExperiments({ workdir: dir }); + + return yield* Effect.gen(function* () { + const exit = yield* experimentsEnable({ features: ["compute"] }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("fails when the config file cannot be read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-experiments-dir-" }); + // A directory where the config file belongs: `exists` passes, the read does not. + yield* fs.makeDirectory(path.join(dir, "supabase", "config.toml"), { recursive: true }); + const { layer } = setupExperiments({ workdir: dir }); + + return yield* Effect.gen(function* () { + const exit = yield* experimentsEnable({ features: ["compute"] }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("rejects -o before touching the config", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer } = setupExperiments({ workdir: repo.dir, goOutput: "json" }); + const before = yield* repo.read; + + return yield* Effect.gen(function* () { + const exit = yield* experimentsEnable({ features: ["compute"] }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* repo.read).toBe(before); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); +}); + +describe("experiments disable", () => { + it.live("records the opt-out for an enabled experiment", () => + Effect.gen(function* () { + const repo = yield* project(`${CONFIG_WITH_COMMENTS}compute = true\n`); + const { layer, out } = setupExperiments({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* experimentsDisable({ features: ["compute"] }); + expect(yield* repo.read).toContain("compute = false"); + expect(out.stdoutText).toBe(`Disabled compute in ${repo.configPath}.\n`); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // An absent key already resolves to off, so recording `false` would be churn. + it.live("writes nothing for an experiment the config never mentions", () => + Effect.gen(function* () { + const repo = yield* project(CONFIG_WITH_COMMENTS); + const { layer, out } = setupExperiments({ workdir: repo.dir }); + const before = yield* repo.read; + + return yield* Effect.gen(function* () { + yield* experimentsDisable({ features: ["stack"] }); + expect(yield* repo.read).toBe(before); + expect(out.stdoutText).toBe(`stack is already disabled in ${repo.configPath}.\n`); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("emits a structured payload in json mode", () => + Effect.gen(function* () { + const repo = yield* project(`${CONFIG_WITH_COMMENTS}compute = true\n`); + const { layer, out } = setupExperiments({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* experimentsDisable({ features: ["compute"] }); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + config_path: repo.configPath, + enabled: false, + experiments: [{ name: "compute", previous: true, changed: true, env_override: null }], + }, + }), + ); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/commands/experiments/experiments.layers.ts b/apps/cli/src/commands/experiments/experiments.layers.ts new file mode 100644 index 0000000000..b4dd57a3bf --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.layers.ts @@ -0,0 +1,15 @@ +import { Layer } from "effect"; +import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; +import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; + +const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); + +/** + * Local-disk only: these commands edit `supabase/config.{toml,json}` and call no Management + * API, so no platform stack is built. `CommandSettings` supplies the same resolved workdir + * every other command acts on. + */ +export const experimentsRuntimeLayer = (commandPath: ReadonlyArray) => + Layer.mergeAll(cliSettings, telemetryStateLayer, commandRuntimeLayer(commandPath)); diff --git a/apps/cli/src/commands/experiments/experiments.shared.ts b/apps/cli/src/commands/experiments/experiments.shared.ts new file mode 100644 index 0000000000..d0a0abcb2c --- /dev/null +++ b/apps/cli/src/commands/experiments/experiments.shared.ts @@ -0,0 +1,154 @@ +import { findCliProjectPaths } from "@supabase/config/effect"; +import { + applyConfigEdits, + writeCliConfigDocumentText, + type ConfigEdit, +} from "@supabase/config/internal"; +import type { ConfigFormat } from "@supabase/config"; +import { Config, Effect, FileSystem, Option, Path } from "effect"; +import { + configEditRefusalPhrase, + configEditRefusalRemediation, +} from "../../command-internal/config-edit-refusal.ts"; +import { + experimentEnvName, + type ExperimentName, +} from "../../command-internal/experiment-registry.ts"; +import { OutputFlag } from "../../command-internal/global-flags.ts"; +import { unsupportedOutputFlagMessage } from "../../command-internal/go-output-flag.ts"; +import { shouldSearchAncestors } from "../../command-internal/workdir-search.ts"; +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { + ExperimentsConfigReadError, + ExperimentsOutputFlagUnsupportedError, + ExperimentsProjectNotFoundError, + ExperimentsUnsupportedLayoutError, + ExperimentsWriteError, +} from "./experiments.errors.ts"; +import { + readExperimentValues, + renderExperimentOutcomes, + type ExperimentOutcome, +} from "./experiments.format.ts"; + +/** + * `supabase experiments enable|disable …` — record an opt-in in the project's own + * `supabase/config.{toml,json}`, so the whole team and CI inherit it. + * + * The write goes through `applyConfigEdits`, which edits the existing `[experimental]` table + * in place. Appending a second `[experimental]` header instead would leave the file + * unparseable, and an unparseable config resolves every experiment to off without reporting + * anything — the exact failure this command exists to stop people hand-editing their way into. + */ + +/** `features` with duplicates dropped, so `enable compute compute` reports one line. */ +function distinct(features: ReadonlyArray): ReadonlyArray { + return [...new Set(features)]; +} + +/** An env override only counts when it is actually set to something. */ +const envOverrideFor = Effect.fnUntraced(function* (feature: ExperimentName) { + const value = yield* Config.option(Config.string(experimentEnvName(feature))); + return Option.isSome(value) && value.value !== "" ? value.value : undefined; +}); + +export const setExperiments = Effect.fnUntraced(function* (input: { + readonly features: ReadonlyArray; + readonly enabled: boolean; + /** The invoked command path, for the `-o` refusal and edit-refusal remediation text. */ + readonly command: string; +}) { + const output = yield* Output; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settings = yield* CommandSettings; + + // Rejected first, so an invocation that can never produce output does not edit config. + if (Option.isSome(yield* OutputFlag)) { + return yield* new ExperimentsOutputFlagUnsupportedError({ + message: unsupportedOutputFlagMessage(input.command), + }); + } + + const features = distinct(input.features); + const paths = yield* findCliProjectPaths(settings.workdir, { + search: shouldSearchAncestors(settings), + }); + if (paths === null) { + return yield* new ExperimentsProjectNotFoundError({ + message: `No supabase/config.toml or supabase/config.json found from ${settings.workdir}. Run supabase init first, or pass --workdir.`, + }); + } + + const configPath = paths.configPath; + const format: ConfigFormat = path.basename(configPath) === "config.json" ? "json" : "toml"; + const currentText = yield* fs.readFileString(configPath).pipe( + Effect.mapError( + (cause) => + new ExperimentsConfigReadError({ + message: `Unable to read ${configPath}: ${cause.message}`, + }), + ), + ); + + // `undefined` means the document does not parse; every feature becomes an edit so + // `applyConfigEdits` is the one that names what is wrong with it. + const previousValues = readExperimentValues(format, currentText); + const previousFor = (feature: ExperimentName): boolean => previousValues?.[feature] ?? false; + const targets = + previousValues === undefined + ? features + : features.filter((feature) => previousFor(feature) !== input.enabled); + + if (targets.length > 0) { + const edits: ReadonlyArray = targets.map((feature) => ({ + path: ["experimental", feature], + value: input.enabled, + })); + const outcome = applyConfigEdits(currentText, format, edits); + if (outcome.kind === "refused") { + const { reason, path: refusedPath, detail } = outcome.refusal; + const location = refusedPath.length === 0 ? "" : ` at ${refusedPath.join(".")}`; + return yield* new ExperimentsUnsupportedLayoutError({ + message: `cannot write ${configPath}: ${configEditRefusalPhrase(reason)}${location} — ${detail}. ${configEditRefusalRemediation(reason, input.command)}`, + }); + } + yield* writeCliConfigDocumentText(configPath, outcome.text).pipe( + Effect.mapError((cause) => new ExperimentsWriteError({ message: cause.message })), + ); + } + + const targetSet = new Set(targets); + const outcomes = yield* Effect.forEach(features, (feature) => + envOverrideFor(feature).pipe( + Effect.map((envOverride): ExperimentOutcome => ({ + feature, + previous: previousFor(feature), + changed: targetSet.has(feature), + envOverride, + })), + ), + ); + + if (output.format !== "text") { + // States the end state rather than a write count: a run that changed nothing still leaves + // those experiments enabled, and `changed` per entry carries what actually moved. + yield* output.success( + `${features.join(", ")} ${input.enabled ? "enabled" : "disabled"} in ${configPath}.`, + { + config_path: configPath, + enabled: input.enabled, + experiments: outcomes.map((outcome) => ({ + name: outcome.feature, + previous: outcome.previous, + changed: outcome.changed, + env_override: outcome.envOverride ?? null, + })), + }, + ); + return; + } + + yield* output.raw(renderExperimentOutcomes({ outcomes, enabled: input.enabled, configPath })); +}); diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 5c3cce88b5..e1f09cd63f 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -30,6 +30,7 @@ export const DOCS_TAGS: Readonly>> = { "supabase-db": ["local-dev"], "supabase-domains": ["management-api"], "supabase-encryption": ["management-api"], + "supabase-experiments": ["local-dev"], "supabase-feedback": ["other-commands"], "supabase-functions": ["management-api"], "supabase-gen": ["local-dev"], diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9417dee711..1be4b273f6 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -235,6 +235,11 @@ ExperimentalRequiredError ExperimentalStackStartError ExperimentalStackStopError ExperimentalStackTargetError +ExperimentsConfigReadError +ExperimentsOutputFlagUnsupportedError +ExperimentsProjectNotFoundError +ExperimentsUnsupportedLayoutError +ExperimentsWriteError FeedbackBackendError FeedbackDeleteCancelledError FeedbackEmptyMessageError diff --git a/package.json b/package.json index 126702833d..0a3a602dbc 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "fix:all": "pnpm exec turbo run lint:fix fmt:fix knip:fix && pnpm run lint:effect:fix", "lint:check": "oxlint --config .oxlintrc.json", "lint:fix": "oxlint --config .oxlintrc.json --fix", - "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts", - "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts", + "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/experiment-registry.ts apps/cli/src/commands/experiments", + "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/commands/experimental/compute apps/cli/src/shared/compute apps/cli/tests/helpers/compute.ts apps/cli/src/command-internal/experimental-feature.ts apps/cli/src/command-internal/experiment-registry.ts apps/cli/src/commands/experiments", "fmt:check": "oxfmt --config .oxfmtrc.json --check", "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun",