Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions apps/cli/docs/compute-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,6 +96,7 @@ export const rootCommandForFeatures = (
dbCommand,
domainsCommand,
encryptionCommand,
experimentsCommand,
feedbackCommand,
functionsCommand,
genCommand,
Expand Down
47 changes: 47 additions & 0 deletions apps/cli/src/command-internal/config-edit-refusal.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
}
49 changes: 2 additions & 47 deletions apps/cli/src/command-internal/config-pull-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
35 changes: 35 additions & 0 deletions apps/cli/src/command-internal/experiment-registry.ts
Original file line number Diff line number Diff line change
@@ -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<ExperimentName, string> = {
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}.`;
}
5 changes: 3 additions & 2 deletions apps/cli/src/command-internal/experimental-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = <E, R>(input: {
readonly feature: string;
readonly feature: ExperimentName;
readonly configValue: Effect.Effect<boolean | undefined, E, R>;
readonly env: Readonly<Record<string, string | undefined>>;
}): Effect.Effect<boolean, E | ExperimentalFeatureFlagError, R> => {
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));
Expand Down
77 changes: 77 additions & 0 deletions apps/cli/src/commands/experiments/disable/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# `supabase experiments disable <FEATURE>…`

## Files Read

| Path | Format | When |
| -------------------------------- | ------ | --------------------------------------------------------------- |
| `<workdir>/supabase/config.toml` | TOML | always, unless the project is configured by `config.json` |
| `<workdir>/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 |
| --------------------------------------- | ------------ | ------------------------------------------------------- |
| `<workdir>/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.<feature> = 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.
40 changes: 40 additions & 0 deletions apps/cli/src/commands/experiments/disable/disable.command.ts
Original file line number Diff line number Diff line change
@@ -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<typeof config>;

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"])),
);
13 changes: 13 additions & 0 deletions apps/cli/src/commands/experiments/disable/disable.handler.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
Loading
Loading