From 881a886f98d3075dad5468913b5463470ccfbebc Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 1 Sep 2026 23:12:04 -0300 Subject: [PATCH 1/2] feat: add exposure control for worker deployments --- .../experimental/workers/new/SIDE_EFFECTS.md | 20 ++-- .../experimental/workers/new/new.command.ts | 22 ++++- .../experimental/workers/new/new.handler.ts | 48 +++++++++- .../workers/new/new.integration.test.ts | 59 ++++++++++-- .../experimental/workers/push/SIDE_EFFECTS.md | 15 +-- .../experimental/workers/push/push.command.ts | 20 +++- .../experimental/workers/push/push.handler.ts | 58 +++++++++++- .../workers/push/push.integration.test.ts | 92 ++++++++++++++++++- apps/cli/src/shared/workers/worker-config.ts | 5 + .../shared/workers/worker-config.unit.test.ts | 42 ++++++++- .../cli/src/shared/workers/worker-runtimes.ts | 51 ++++++++-- .../workers/worker-runtimes.unit.test.ts | 16 ++++ apps/cli/src/shared/workers/workers.errors.ts | 9 ++ apps/docs/public/cli/config.schema.json | 10 ++ .../public/cli/project-config.schema.json | 5 + packages/config/src/workers.ts | 18 ++++ packages/config/src/workers.unit.test.ts | 26 +++++- 17 files changed, 456 insertions(+), 60 deletions(-) diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md index d331367dbd..5e580fd783 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md @@ -14,12 +14,12 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | -------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on success — appends `[workers.]`, preserving surrounding formatting | -| `/supabase/workers//*` | varies | on success, unless `--source` names another directory | -| `//*` | varies | on success, when `--source` is given | -| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | +| Path | Format | When | +| ----------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on success — appends `[workers.]` with `runtime`, `size`, `exposure` and (when `--source` is given) `source`, preserving surrounding formatting | +| `/supabase/workers//*` | varies | on success, unless `--source` names another directory | +| `//*` | varies | on success, when `--source` is given | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Workers are recorded in `config.toml` only. The project config loader prefers `supabase/config.json` when one exists, but the entry writer is a TOML text @@ -40,13 +40,13 @@ prompt refuses a name that is not a DNS label or that `config.toml` already records — so nothing is asked, and nothing written, for a name the command was going to refuse. With `-o json|yaml|toml|env`, a redirected stdout, or a stdin that is not a terminal, there is nowhere to ask, and the command fails instead -of defaulting: unlike the runtime and size, the name has no default to fall back -on. Every prompt is gated on both streams, so +of defaulting: unlike the runtime, size and exposure, the name has no default to +fall back on. Every prompt is gated on both streams, so `printf 'api\n' | supabase experimental workers new` takes that failure path rather than reading the worker name off the pipe. Writes to `config.toml` are append-only. A worker already recorded under -`[workers.]` is refused outright — before the runtime and size prompts, +`[workers.]` is refused outright — before the dial prompts, and before anything reaches disk — because editing an entry the user owns is not this command's job. @@ -95,7 +95,7 @@ No custom events — only the `cli_command_executed` that the instrumentation wrapper emits for every command. Nothing is emitted for a failure the parser catches, such as a -`--runtime`/`--size` value outside the choice list. The wrapper is installed by +`--runtime`/`--size`/`--exposure` value outside the choice list. The wrapper is installed by `Command.withHandler`, so a command that never reaches its handler never reaches the instrumentation either — and `telemetry.json` is not written. A missing name is _not_ one of those: the argument is optional, so a bare `workers new` reaches diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts b/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts index ce93798dd9..479cff0359 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts @@ -3,7 +3,11 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../../shared/runtime/command-runtime.layer.ts"; -import { WORKER_RUNTIMES, WORKER_SIZES } from "../../../../../shared/workers/worker-runtimes.ts"; +import { + WORKER_EXPOSURES, + WORKER_RUNTIMES, + WORKER_SIZES, +} from "../../../../../shared/workers/worker-runtimes.ts"; import { legacyCliSettingsLayer } from "../../../../config/legacy-cli-settings.layer.ts"; import { legacyDebugLoggerLayer } from "../../../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../../../telemetry/legacy-telemetry-state.layer.ts"; @@ -29,6 +33,12 @@ const config = { ), Flag.optional, ), + exposure: Flag.choice("exposure", WORKER_EXPOSURES).pipe( + Flag.withDescription( + "Whether the worker is reachable from the internet, recorded as `exposure` in supabase/config.toml. Prompted when omitted.", + ), + Flag.optional, + ), source: Flag.string("source").pipe( Flag.withDescription( "Scaffold the worker here instead of the default workers directory, recorded as `source` in supabase/config.toml.", @@ -50,22 +60,26 @@ const legacyWorkersNewRuntimeLayer = Layer.mergeAll( export const legacyWorkersNewCommand = Command.make("new", config).pipe( Command.withDescription( - "Scaffold a worker directory from a runtime's starter files and record the choice in supabase/config.toml. Nothing is deployed.", + "Scaffold a worker directory from a runtime's starter files and record the choices in supabase/config.toml. Nothing is deployed.", ), Command.withShortDescription("Scaffold a worker locally"), Command.withExamples([ { command: "supabase experimental workers new", - description: "Prompt for the name, then for runtime and size", + description: "Prompt for the name, then for runtime, size and exposure", }, { command: "supabase experimental workers new api", - description: "Scaffold supabase/workers/api, prompting for runtime and size", + description: "Scaffold supabase/workers/api, prompting for runtime, size and exposure", }, { command: "supabase experimental workers new api --runtime node", description: "Scaffold supabase/workers/api on the node runtime", }, + { + command: "supabase experimental workers new api --exposure private", + description: "Scaffold a worker with no internet-facing URL", + }, { command: "supabase experimental workers new api --source packages/api", description: "Scaffold the worker outside the workers directory", diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts index 79b195dc54..0d8b8b7b30 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts @@ -22,15 +22,20 @@ import { resolveWorkerSource, } from "../../../../../shared/workers/worker-paths.ts"; import { + DEFAULT_WORKER_EXPOSURE, DEFAULT_WORKER_RUNTIME, DEFAULT_WORKER_SIZE, + parseWorkerExposure, parseWorkerRuntime, parseWorkerSize, validateWorkerNameMessage, vcpuForSize, + WORKER_EXPOSURE_DESCRIPTIONS, + WORKER_EXPOSURES, WORKER_RUNTIME_DESCRIPTIONS, WORKER_RUNTIMES, WORKER_SIZES, + type WorkerExposure, type WorkerRuntime, type WorkerSize, } from "../../../../../shared/workers/worker-runtimes.ts"; @@ -51,8 +56,8 @@ import type { LegacyWorkersNewFlags } from "./new.command.ts"; * chosen runtime's starter files and record the choice in `config.toml`. * Nothing is deployed; this is entirely local-disk work. * - * The name, runtime and size are all resolved *before* anything is written, so a - * cancelled prompt leaves nothing behind for this worker at all. + * The name, runtime, size and exposure are all resolved *before* anything is + * written, so a cancelled prompt leaves nothing behind for this worker at all. */ /** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ @@ -174,6 +179,38 @@ const resolveSize = Effect.fnUntraced(function* (options: { return DEFAULT_WORKER_SIZE; }); +/** + * Recorded on every scaffold, not just when it is asked for: `push` sends a + * complete spec each time, so a worker whose `exposure` is absent from + * `config.toml` is deployed public by the next bare `push`. Writing the value + * down — default included, the way `runtime` and `size` are — is what makes + * `--exposure private` stick past the deploy that chose it. + */ +const resolveExposure = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + /** Whether there is a terminal to ask on — see `canPromptFor`. */ + readonly canPrompt: boolean; +}) { + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + if (options.canPrompt) { + const output = yield* Output; + const selected = yield* output.promptSelect( + "Should this worker be reachable from the internet?", + defaultFirst([...WORKER_EXPOSURES], DEFAULT_WORKER_EXPOSURE).map((exposure) => ({ + value: exposure, + label: exposure, + hint: WORKER_EXPOSURE_DESCRIPTIONS[exposure], + })), + ); + return parseWorkerExposure(selected) ?? DEFAULT_WORKER_EXPOSURE; + } + + return DEFAULT_WORKER_EXPOSURE; +}); + /** * Whether the destination is free for a scaffold: nothing there, or an empty * directory. A plain file counts as occupied, so it is refused by name rather @@ -226,11 +263,12 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun ); } - // Resolved before anything is written, so cancelling either prompt leaves + // Resolved before anything is written, so cancelling any prompt leaves // nothing behind — the name included. With nowhere to ask, the defaults // stand; only the name has nothing to fall back to. const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); const size = yield* resolveSize({ explicit: flags.size, canPrompt }); + const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); // Validated before anything is written: this is the directory the starter // files land in, so a value naming the project root, `supabase/`, or @@ -289,6 +327,7 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun patch: { runtime, size, + exposure, ...(source === undefined ? {} : { source }), }, }); @@ -310,6 +349,7 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun runtime, size, vcpu: vcpuForSize(size), + exposure, source: sourceDisplay, config_path: project.configPath, }; @@ -335,7 +375,7 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun legacyRenderWorkerDetails([ ["Runtime", runtime], ["Size", `${size} (${vcpuForSize(size)} vCPU)`], - ["Access", "public"], + ["Access", exposure], ]), ); // On the success trailer rather than inline, the way `bootstrap` emits its diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts index 07e4285ee2..6fbac9eb5b 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts @@ -31,6 +31,7 @@ function flags(overrides: Partial = {}): LegacyWorkersNew name: Option.some("api"), runtime: Option.none(), size: Option.none(), + exposure: Option.none(), source: Option.none(), ...overrides, }; @@ -60,7 +61,7 @@ describe("legacy workers new", () => { const workerDir = join(repo.dir, "supabase", "workers", "api"); expect(existsSync(join(workerDir, "index.mjs"))).toBe(true); expect(repo.config()).toBe( - `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); // Declarative line first, then the detail rows, then the next step — @@ -94,7 +95,7 @@ describe("legacy workers new", () => { // without ending the run, so it refuses both there rather than after asking. it.live("refuses a bad or already-recorded name at the name prompt", () => { const repo = project({ - "supabase/config.toml": `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/config.toml": `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, }); const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, @@ -150,11 +151,11 @@ describe("legacy workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - it.live("prompts for runtime and size when neither is given", () => { + it.live("prompts for runtime, size and exposure when none is given", () => { const repo = project(); const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, - promptSelectResponses: ["node", "4gb"], + promptSelectResponses: ["node", "4gb", "private"], }); return Effect.gen(function* () { @@ -163,13 +164,43 @@ describe("legacy workers new", () => { expect(out.promptSelectCalls.map((call) => call.message)).toEqual([ "Which runtime should this worker use?", "Which instance size should this worker use?", + "Should this worker be reachable from the internet?", ]); expect(repo.config()).toContain('runtime = "node"'); expect(repo.config()).toContain('size = "4gb"'); + expect(repo.config()).toContain('exposure = "private"'); expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The whole reason `new` records it: `push` sends a complete spec every time, + // so an entry with no `exposure` is deployed public by the next bare `push`. + // Recording the answer is what makes a private worker stay private. + it.live("records the chosen exposure so a later push keeps it", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ exposure: Option.some("private") })); + + expect(repo.config()).toContain('exposure = "private"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Written even when it is the default, the same way `runtime` and `size` are: + // an absent key and `public` mean the same thing to `push` today, but only the + // written one survives a change of default. + it.live("records the default exposure when nothing names one", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, format: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api") })); + + expect(repo.config()).toContain('exposure = "public"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // The runtime and size prompts do have defaults to fall back on, so a piped // stdin must leave them unasked rather than consuming the pipe. it.live("takes the defaults without prompting when stdin is piped", () => { @@ -177,7 +208,7 @@ describe("legacy workers new", () => { const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, stdinIsTty: false, - promptSelectResponses: ["node", "4gb"], + promptSelectResponses: ["node", "4gb", "private"], }); return Effect.gen(function* () { @@ -186,6 +217,7 @@ describe("legacy workers new", () => { expect(out.promptSelectCalls).toEqual([]); expect(repo.config()).toContain('runtime = "deno"'); expect(repo.config()).toContain('size = "2gb"'); + expect(repo.config()).toContain('exposure = "public"'); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -210,7 +242,12 @@ describe("legacy workers new", () => { return Effect.gen(function* () { yield* legacyWorkersNew( - flags({ name: Option.some("api"), runtime: Option.some("deno"), size: Option.some("4gb") }), + flags({ + name: Option.some("api"), + runtime: Option.some("deno"), + size: Option.some("4gb"), + exposure: Option.some("public"), + }), ); const recorded = repo.config(); @@ -298,7 +335,7 @@ describe("legacy workers new", () => { expect(existsSync(join(created.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); expect(readFileSync(join(created.dir, "supabase", "config.toml"), "utf8")).toBe( - `[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + `[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); }).pipe( Effect.provide(layer), @@ -417,7 +454,7 @@ describe("legacy workers new", () => { // The worker is recorded in config.toml, which is the TOML editor's file. expect(repo.config()).toBe( - `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -442,7 +479,7 @@ describe("legacy workers new", () => { // The workdir got both the entry and the scaffold it points at. expect(readFileSync(join(workdir, "supabase", "config.toml"), "utf8")).toBe( - '[workers.api]\nruntime = "node"\nsize = "2gb"\n', + '[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n', ); expect(existsSync(join(workdir, "supabase", "workers", "api", "index.mjs"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); @@ -542,7 +579,7 @@ describe("legacy workers new", () => { const repo = project(); const { layer } = setupLegacyWorkers({ workdir: repo.dir, - promptSelectResponses: ["cobol", "colossal"], + promptSelectResponses: ["cobol", "colossal", "sideways"], }); return Effect.gen(function* () { @@ -550,11 +587,13 @@ describe("legacy workers new", () => { name: Option.some("api"), runtime: Option.none(), size: Option.none(), + exposure: Option.none(), source: Option.none(), }); expect(repo.config()).toContain(`runtime = "deno"`); expect(repo.config()).toContain(`size = "2gb"`); + expect(repo.config()).toContain(`exposure = "public"`); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md index 7d9a9e57b3..2bd563acf2 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md @@ -7,13 +7,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each worker's runtime, size, instances, source | -| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same worker fields | -| `/**` | any | always — packaged into the build context | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each worker's runtime, size, exposure, instances, source | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same worker fields | +| `/**` | any | always — packaged into the build context | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written @@ -43,6 +43,7 @@ returns on the deploy response, which carries the accepted spec and a | ---- | ------------------------------------------------------------------- | | `0` | success | | `1` | no workers named and none found in the project | +| `1` | config records a runtime, size or exposure the CLI does not know | | `1` | a worker's source is missing, not a directory, or empty | | `1` | a worker's source directory cannot be read | | `1` | a worker's source links to a path outside itself | diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts b/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts index 6f9261c2ff..e739badf7b 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts @@ -1,6 +1,7 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; +import { WORKER_EXPOSURES } from "../../../../../shared/workers/worker-runtimes.ts"; import { legacyManagementApiRuntimeLayer } from "../../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; import { legacyWorkersPush } from "./push.handler.ts"; @@ -24,6 +25,17 @@ const config = { ), Flag.optional, ), + exposure: Flag.choice("exposure", WORKER_EXPOSURES).pipe( + // A closed set at the parser, the way `new --runtime` and `new --size` are: + // the accepted values get listed in the refusal, and nothing unrecognized + // reaches the deploy endpoint after a build context has been uploaded. + // `[workers.] exposure` stays a plain string, so a value the API + // grows before this CLI does can still be recorded there. + Flag.withDescription( + "Whether the worker is reachable from the internet, overriding `exposure` in supabase/config.toml for this deploy. Falls back to the recorded value, then public.", + ), + Flag.optional, + ), wait: Flag.boolean("wait").pipe( // Off by default: the deploy POST is answered once the platform has accepted // the spec and the uploaded context, and the server-side container build @@ -46,7 +58,7 @@ export type LegacyWorkersPushFlags = CliCommand.Command.Config.Infer legacyWorkersPush(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts index d3ddf06c50..a28b17933d 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts @@ -21,13 +21,17 @@ import { displayPath } from "../../../../../shared/workers/worker-paths.ts"; import type { WorkerEntry } from "../../../../../shared/workers/worker-config.ts"; import { apiSizeFor, + DEFAULT_WORKER_EXPOSURE, DEFAULT_WORKER_INSTANCES, DEFAULT_WORKER_SIZE, formatApiSize, + parseWorkerExposure, parseWorkerRuntime, parseWorkerSize, + WORKER_EXPOSURES, WORKER_RUNTIMES, WORKER_SIZES, + type WorkerExposure, } from "../../../../../shared/workers/worker-runtimes.ts"; import { workerUrl } from "../../../../../shared/workers/worker-url.ts"; import { @@ -39,6 +43,7 @@ import { } from "../../../../../shared/workers/workers-api.ts"; import { NoWorkersToDeployError, + UnknownWorkerExposureError, UnknownWorkerRuntimeError, UnknownWorkerSizeError, WorkerBuildFailedError, @@ -61,8 +66,8 @@ import type { LegacyWorkersPushFlags } from "./push.command.ts"; * deploy the worker into the linked project. Registered under `deploy` as an * alias, for anyone reaching for the `supabase functions` verb out of habit. * - * The runtime, size and source directory come from `[workers.]` in - * `supabase/config.toml`. A directory pushed without ever running `new` gets + * The runtime, size, exposure and source directory come from `[workers.]` + * in `supabase/config.toml`. A directory pushed without ever running `new` gets * its runtime guessed from marker files instead — reported, with a nudge to pin * it down rather than re-guess on every push. * @@ -142,6 +147,40 @@ function resolveInstances(options: { return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); } +/** + * `--exposure` for one deploy, then the recorded exposure, then + * {@link DEFAULT_WORKER_EXPOSURE}. Never left unset, because every deploy sends a + * complete spec and an omitted exposure would re-expose a worker somebody had + * deliberately made private. + * + * `--exposure` is a `Flag.choice`, so only a recorded value can be unrecognized + * — and that is refused rather than coerced, the same way `resolveSize` treats a + * size it does not know: silently deploying a `private`-typo'd worker as public + * is the one outcome nobody asked for. + */ +const resolveExposure = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; + readonly override: Option.Option; +}) { + if (Option.isSome(options.override)) { + return options.override.value; + } + if (options.recorded === undefined) { + return DEFAULT_WORKER_EXPOSURE; + } + const recorded = parseWorkerExposure(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerExposureError({ + detail: `supabase/config.toml records an unknown exposure "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] exposure to one of: ${WORKER_EXPOSURES.join(", ")}.`, + }), + ); + } + return recorded; +}); + /** * What to do about a worker whose source directory is not there at all. * @@ -192,6 +231,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { */ readonly refSuffix: string; readonly instances: Option.Option; + readonly exposure: Option.Option; /** `--wait`: block on the server-side build instead of returning once it starts. */ readonly wait: boolean; readonly pollSchedule?: Schedule.Schedule; @@ -284,6 +324,15 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { override: input.instances, }); + // Resolved before anything is packaged or uploaded, alongside the runtime and + // size, so a config that records an exposure this CLI does not know is refused + // while the refusal is still free. + const exposure = yield* resolveExposure({ + name, + recorded: worker.entry?.exposure, + override: input.exposure, + }); + let contextUploadId: string; { const packaging = yield* output.task("Packaging worker..."); @@ -326,9 +375,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // context carries its own Dockerfile and is built as-is. ...(runtime === "dockerfile" ? {} : { runtime }), size: apiSizeFor(size), - // Every runtime offered today serves HTTP. A sandbox runtime would need a - // branch here. - exposure: "public", + exposure, instances, }; @@ -545,6 +592,7 @@ export const legacyWorkersPush = Effect.fn("legacy.experimental.workers.push")(f projectRef, refSuffix, instances: flags.instances, + exposure: flags.exposure, wait: flags.wait, machineOutput, ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts index bb9d298f96..7491dd639f 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts @@ -14,6 +14,7 @@ import { LegacyProjectNotLinkedError } from "../../../../config/legacy-project-r import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; import { NoWorkersToDeployError, + UnknownWorkerExposureError, UnknownWorkerRuntimeError, UnknownWorkerSizeError, WorkerBuildFailedError, @@ -45,6 +46,7 @@ function flags(overrides: Partial = {}): LegacyWorkersPu return { names: ["api"], instances: Option.none(), + exposure: Option.none(), // Mirrors the command default: the deploy returns once accepted, and only // the scenarios that are about the build itself opt into waiting. wait: false, @@ -314,6 +316,88 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The whole point of recording it: every deploy sends a complete spec, so a + // worker deliberately made private has to stay private across pushes rather + // than being re-exposed by the next one. + it.live("keeps a worker private when config records it that way", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "private"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.exposure).toBe("private"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Hand-written config, so the casing is the user's own — `PRIVATE` plainly + // means `private`, and the canonical form is what gets sent. + it.live("reads a recorded exposure case-insensitively", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "PRIVATE"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.exposure).toBe("private"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("lets --exposure override the recorded exposure for one deploy", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "private"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ exposure: Option.some("public") }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.exposure).toBe("public"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `[workers.*] exposure` is a plain string in the config schema, so a typo + // reaches the handler. Coercing it to the default would deploy a `privat` + // worker to the whole internet — refused before anything is packaged instead. + it.live("names the exposures on offer when config records one it does not know", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "privat"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(UnknownWorkerExposureError); + expect((error as UnknownWorkerExposureError).detail).toContain("privat"); + expect((error as UnknownWorkerExposureError).suggestion).toContain("public, private"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The flag is the authority for the deploy it runs, so an unrecognized + // recorded value it replaces is moot rather than fatal. + it.live("lets --exposure stand in for an exposure config records badly", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "privat"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ exposure: Option.some("private") }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.exposure).toBe("private"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("polls until the build leaves `building`", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ @@ -397,10 +481,10 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Every deploy this CLI sends asks for public exposure, but the accepted spec - // is the platform's answer, not the request echoed back. A worker it did not - // expose has no URL to print, and inventing one from the ref would name an - // address that does not resolve. + // The accepted spec is the platform's answer, not the request echoed back — so + // a worker the platform did not expose has no URL to print even when the deploy + // asked for `public`, and inventing one from the ref would name an address + // that does not resolve. it.live("omits the URL for a worker the platform did not expose publicly", () => { const repo = project(); const { layer, out } = setupLegacyWorkers({ diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index 6d09c9ccb3..2b34ae8e1c 100644 --- a/apps/cli/src/shared/workers/worker-config.ts +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -21,6 +21,7 @@ import { appendTomlSection, tomlKey } from "./toml-section.ts"; export interface WorkerEntry { readonly runtime?: string; readonly size?: string; + readonly exposure?: string; readonly instances?: number; readonly source?: string; } @@ -107,6 +108,10 @@ export function readWorkersSection(workers: unknown): WorkersSection { entries[key] = { runtime: stringOrUndefined(value["runtime"]), size: stringOrUndefined(value["size"]), + // Left as whatever string was written, like `runtime` and `size`: `push` + // is what names the accepted values, and dropping an unrecognized one here + // would silently deploy a worker at the default exposure instead. + exposure: stringOrUndefined(value["exposure"]), instances: instanceCountOrUndefined(value["instances"]), source: stringOrUndefined(value["source"]), }; diff --git a/apps/cli/src/shared/workers/worker-config.unit.test.ts b/apps/cli/src/shared/workers/worker-config.unit.test.ts index d1439e57ac..e7a9b7da41 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -16,13 +16,31 @@ describe("readWorkersSection", () => { test("reads each worker's recorded dials", () => { expect( readWorkersSection({ - api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, + api: { + runtime: "node", + size: "2gb", + exposure: "private", + instances: 4, + source: "packages/api", + }, box: { runtime: "sandbox" }, }), ).toEqual({ workers: { - api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, - box: { runtime: "sandbox", size: undefined, instances: undefined, source: undefined }, + api: { + runtime: "node", + size: "2gb", + exposure: "private", + instances: 4, + source: "packages/api", + }, + box: { + runtime: "sandbox", + size: undefined, + exposure: undefined, + instances: undefined, + source: undefined, + }, }, }); }); @@ -30,7 +48,13 @@ describe("readWorkersSection", () => { test("drops non-object values so a stray scalar is not read as a worker", () => { expect(readWorkersSection({ stray: "oops", api: {} })).toEqual({ workers: { - api: { runtime: undefined, size: undefined, instances: undefined, source: undefined }, + api: { + runtime: undefined, + size: undefined, + exposure: undefined, + instances: undefined, + source: undefined, + }, }, }); }); @@ -51,6 +75,16 @@ describe("readWorkersSection", () => { expect(readWorkersSection({ api: { instances: 0 } }).workers["api"]?.instances).toBe(0); }); + // Unlike the instance count, an unrecognized exposure is kept and carried to + // `push`, which names the values it accepts. Dropping it here would deploy the + // worker at the default exposure — public — which is the opposite of what a + // misspelled `private` was asking for. + test("keeps an exposure it does not recognize, for push to refuse by name", () => { + expect(readWorkersSection({ api: { exposure: "privat" } }).workers["api"]?.exposure).toBe( + "privat", + ); + }); + test("treats a missing or malformed section as empty", () => { expect(readWorkersSection(undefined)).toEqual({ workers: {} }); expect(readWorkersSection([])).toEqual({ workers: {} }); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index f72fdf0d99..f5f5b96a84 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -1,12 +1,13 @@ /** - * The alpha envelope a worker is described by: which runtime it is built on, - * and how big an instance it runs as. + * The alpha envelope a worker is described by: which runtime it is built on, how + * big an instance it runs as, and whether it is reachable from the internet. * - * Both are deliberately small closed sets. The Workers API takes `spec.size` as - * one opaque string (`2gb-1vcpu`) rather than independent cpu/memory dials, so - * the CLI offers exactly the sizes that string has values for and derives the - * vCPU count from the memory the user picked — one choice, not two that could - * be combined into a shape the platform does not run. + * All three are deliberately small closed sets, and the CLI's own rather than + * the API's: the Workers API takes `spec.size` as one opaque string + * (`2gb-1vcpu`) rather than independent cpu/memory dials, and `spec.exposure` as + * an unconstrained string. So the CLI offers exactly the sizes that string has + * values for and derives the vCPU count from the memory the user picked — one + * choice, not two that could be combined into a shape the platform does not run. */ /** A worker's runtime: its own Dockerfile, or one of the catalog base images. */ @@ -82,6 +83,42 @@ export function parseWorkerSize(value: string): WorkerSize | undefined { return isWorkerSize(canonical) ? canonical : undefined; } +/** + * How a worker is reached: `public` gives it an internet-facing URL, `private` + * keeps it reachable only from inside the project. + * + * `spec.exposure` is an unconstrained string in the Management API's schema, so + * this closed set is the CLI's own — the same arrangement as {@link WORKER_SIZES}, + * and the reason output renders the *accepted* exposure verbatim rather than + * forcing it back into this enum. + */ +export const WORKER_EXPOSURES = ["public", "private"] as const; + +export type WorkerExposure = (typeof WORKER_EXPOSURES)[number]; + +/** + * The exposure a worker gets when neither `--exposure` nor `[workers.] + * exposure` says otherwise. Public, because every runtime offered today serves + * HTTP and a worker nobody has locked down is one you can call. + */ +export const DEFAULT_WORKER_EXPOSURE: WorkerExposure = "public"; + +/** One-line description of each exposure, for `--exposure`'s prompt and help. */ +export const WORKER_EXPOSURE_DESCRIPTIONS: Record = { + public: "Reachable from the internet at the worker's own URL.", + private: "Reachable only from inside the project; no URL is issued.", +}; + +function isWorkerExposure(value: string): value is WorkerExposure { + return WORKER_EXPOSURES.some((exposure) => exposure === value); +} + +/** As {@link parseWorkerRuntime}, for exposures. */ +export function parseWorkerExposure(value: string): WorkerExposure | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerExposure(canonical) ? canonical : undefined; +} + const VCPU_FOR_SIZE: Record = { "2gb": 1, "4gb": 2 }; /** The vCPU count that comes with `size` — not independently choosable. */ diff --git a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts index 1eb1f9bccd..7569ec4992 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { apiSizeFor, formatApiSize, + parseWorkerExposure, parseWorkerRuntime, parseWorkerSize, validateWorkerNameMessage, @@ -46,6 +47,21 @@ describe("sizes", () => { }); }); +describe("parseWorkerExposure", () => { + test("accepts both exposures case-insensitively, and canonicalizes them", () => { + expect(parseWorkerExposure("Public")).toBe("public"); + expect(parseWorkerExposure(" PRIVATE ")).toBe("private"); + }); + + // A typo here would otherwise read as the default and put a worker somebody + // meant to keep private on the internet, so nothing near-miss is accepted. + test("rejects anything outside the pair, including near misses", () => { + expect(parseWorkerExposure("privat")).toBeUndefined(); + expect(parseWorkerExposure("internal")).toBeUndefined(); + expect(parseWorkerExposure("")).toBeUndefined(); + }); +}); + describe("validateWorkerNameMessage", () => { test("accepts DNS labels", () => { expect(validateWorkerNameMessage("api")).toBeUndefined(); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 8d9ba34515..e5edc0897f 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -96,6 +96,15 @@ export class UnknownWorkerSizeError extends Data.TaggedError("UnknownWorkerSizeE } } +export class UnknownWorkerExposureError extends Data.TaggedError("UnknownWorkerExposureError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirectoryExistsError")<{ readonly detail: string; readonly suggestion: string; diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 32f2fe9e42..314356047d 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -2314,6 +2314,11 @@ "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", "examples": ["2gb"] }, + "exposure": { + "type": "string", + "description": "How the worker is reached: `public` gives it an internet-facing URL,\n`private` keeps it reachable only from inside the project. Every deploy\nsends a complete spec, so the value recorded here is what keeps a private\nworker private; `--exposure` overrides it for one deploy. Defaults to\n`public`.", + "examples": ["private"] + }, "instances": { "type": "integer", "minimum": 0, @@ -4753,6 +4758,11 @@ "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", "examples": ["2gb"] }, + "exposure": { + "type": "string", + "description": "How the worker is reached: `public` gives it an internet-facing URL,\n`private` keeps it reachable only from inside the project. Every deploy\nsends a complete spec, so the value recorded here is what keeps a private\nworker private; `--exposure` overrides it for one deploy. Defaults to\n`public`.", + "examples": ["private"] + }, "instances": { "type": "integer", "minimum": 0, diff --git a/apps/docs/public/cli/project-config.schema.json b/apps/docs/public/cli/project-config.schema.json index d05346ae77..6b5ea11714 100644 --- a/apps/docs/public/cli/project-config.schema.json +++ b/apps/docs/public/cli/project-config.schema.json @@ -1862,6 +1862,11 @@ "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", "examples": ["2gb"] }, + "exposure": { + "type": "string", + "description": "How the worker is reached: `public` gives it an internet-facing URL,\n`private` keeps it reachable only from inside the project. Every deploy\nsends a complete spec, so the value recorded here is what keeps a private\nworker private; `--exposure` overrides it for one deploy. Defaults to\n`public`.", + "examples": ["private"] + }, "instances": { "type": "integer", "minimum": 0, diff --git a/packages/config/src/workers.ts b/packages/config/src/workers.ts index 83cbcb360d..c3bc2a4178 100644 --- a/packages/config/src/workers.ts +++ b/packages/config/src/workers.ts @@ -41,6 +41,24 @@ const worker = Schema.Struct({ links, }), ), + exposure: Schema.optionalKey( + // A plain string, like `runtime` and `size`: the Management API takes + // `spec.exposure` as an unconstrained string, and the CLI names the values it + // accepts when it reads one it does not know. Constraining it here would + // report a config that a newer CLI understands as unloadable. + Schema.String.annotate({ + description: dedent` + How the worker is reached: \`public\` gives it an internet-facing URL, + \`private\` keeps it reachable only from inside the project. Every deploy + sends a complete spec, so the value recorded here is what keeps a private + worker private; \`--exposure\` overrides it for one deploy. Defaults to + \`public\`. + `, + examples: ["private"], + tags, + links, + }), + ), instances: Schema.optionalKey( // Bounded to match `spec.instances` in the Management API's input schema. A // value that gets past here is dropped rather than sent, so leaving it diff --git a/packages/config/src/workers.unit.test.ts b/packages/config/src/workers.unit.test.ts index dd9d9dd403..251f106de5 100644 --- a/packages/config/src/workers.unit.test.ts +++ b/packages/config/src/workers.unit.test.ts @@ -8,9 +8,28 @@ const workerNamePattern = "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"; describe("workers schema", () => { test("decodes a worker table with every dial set", () => { - expect( - decode({ api: { runtime: "node", size: "4gb", instances: 3, source: "packages/api" } }), - ).toEqual({ api: { runtime: "node", size: "4gb", instances: 3, source: "packages/api" } }); + const every = { + api: { + runtime: "node", + size: "4gb", + exposure: "private", + instances: 3, + source: "packages/api", + }, + }; + expect(decode(every)).toEqual(every); + }); + + // Unconstrained, like `runtime` and `size`: the Management API takes + // `spec.exposure` as a plain string, and `push` is what names the values it + // accepts. Pinning an enum here would make a config a newer CLI understands + // fail to load at all. + test("accepts an exposure it does not itself recognize", () => { + expect(decode({ api: { exposure: "internal" } })).toEqual({ api: { exposure: "internal" } }); + }); + + test("rejects a non-string exposure", () => { + expect(() => decode({ api: { exposure: true } })).toThrow(); }); test("defaults to an empty section when the key is absent", () => { @@ -66,6 +85,7 @@ describe("workers schema", () => { expect(workerSchema?.properties?.runtime).toBeDefined(); expect(workerSchema?.properties?.size).toBeDefined(); + expect(workerSchema?.properties?.exposure).toBeDefined(); expect(workerSchema?.properties?.instances).toBeDefined(); expect(workerSchema?.properties?.source).toBeDefined(); }); From 2f4f766107cc0d5421aedca799294983f1fd93de Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 1 Sep 2026 23:35:14 -0300 Subject: [PATCH 2/2] feat: add --instances flag to workers new command --- .../experimental/workers/new/SIDE_EFFECTS.md | 24 +++++-- .../experimental/workers/new/new.command.ts | 16 +++++ .../experimental/workers/new/new.handler.ts | 29 ++++++++ .../workers/new/new.integration.test.ts | 72 +++++++++++++++++++ apps/cli/src/shared/workers/toml-section.ts | 16 +++-- .../shared/workers/toml-section.unit.test.ts | 14 ++++ apps/cli/src/shared/workers/worker-config.ts | 3 +- 7 files changed, 162 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md index 5e580fd783..d525ab5e47 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md @@ -14,12 +14,12 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on success — appends `[workers.]` with `runtime`, `size`, `exposure` and (when `--source` is given) `source`, preserving surrounding formatting | -| `/supabase/workers//*` | varies | on success, unless `--source` names another directory | -| `//*` | varies | on success, when `--source` is given | -| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | +| Path | Format | When | +| ----------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on success — appends `[workers.]` with `runtime`, `size`, `exposure`, and `instances`/`source` when those differ from the default, preserving surrounding formatting | +| `/supabase/workers//*` | varies | on success, unless `--source` names another directory | +| `//*` | varies | on success, when `--source` is given | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Workers are recorded in `config.toml` only. The project config loader prefers `supabase/config.json` when one exists, but the entry writer is a TOML text @@ -45,6 +45,15 @@ fall back on. Every prompt is gated on both streams, so `printf 'api\n' | supabase experimental workers new` takes that failure path rather than reading the worker name off the pipe. +`runtime`, `size` and `exposure` are always written, defaults included: they are +closed sets the command prompts for, and pinning the answer is the point of +recording it. `instances` is written only when it differs from the default of 1 — +it has no prompt, because how many instances a worker needs is not something a +scaffold can guess, and an absent `instances` means exactly what `instances = 1` +means to `push`. A `0` is an explicit count that scales the worker to nothing, so +it is written like any other. It is rendered as a bare TOML number rather than a +quoted string, because the config schema types it as a number. + Writes to `config.toml` are append-only. A worker already recorded under `[workers.]` is refused outright — before the dial prompts, and before anything reaches disk — because editing an entry the user owns is @@ -95,7 +104,8 @@ No custom events — only the `cli_command_executed` that the instrumentation wrapper emits for every command. Nothing is emitted for a failure the parser catches, such as a -`--runtime`/`--size`/`--exposure` value outside the choice list. The wrapper is installed by +`--runtime`/`--size`/`--exposure` value outside the choice list, or a negative +`--instances`. The wrapper is installed by `Command.withHandler`, so a command that never reaches its handler never reaches the instrumentation either — and `telemetry.json` is not written. A missing name is _not_ one of those: the argument is optional, so a bare `workers new` reaches diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts b/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts index 479cff0359..a16c4e5a43 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts @@ -39,6 +39,18 @@ const config = { ), Flag.optional, ), + instances: Flag.integer("instances").pipe( + // Bounded at the parser, the same way `push --instances` and the config + // schema's own `instances` are. + Flag.filter( + (instances) => instances >= 0, + (instances) => `--instances ${instances} is negative; pass zero or more.`, + ), + Flag.withDescription( + "Number of instances to record in supabase/config.toml. Not prompted for, and recorded only when it differs from the default of 1.", + ), + Flag.optional, + ), source: Flag.string("source").pipe( Flag.withDescription( "Scaffold the worker here instead of the default workers directory, recorded as `source` in supabase/config.toml.", @@ -80,6 +92,10 @@ export const legacyWorkersNewCommand = Command.make("new", config).pipe( command: "supabase experimental workers new api --exposure private", description: "Scaffold a worker with no internet-facing URL", }, + { + command: "supabase experimental workers new api --instances 3", + description: "Scaffold a worker that deploys at three instances", + }, { command: "supabase experimental workers new api --source packages/api", description: "Scaffold the worker outside the workers directory", diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts index 0d8b8b7b30..af30205547 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts @@ -23,6 +23,7 @@ import { } from "../../../../../shared/workers/worker-paths.ts"; import { DEFAULT_WORKER_EXPOSURE, + DEFAULT_WORKER_INSTANCES, DEFAULT_WORKER_RUNTIME, DEFAULT_WORKER_SIZE, parseWorkerExposure, @@ -58,6 +59,8 @@ import type { LegacyWorkersNewFlags } from "./new.command.ts"; * * The name, runtime, size and exposure are all resolved *before* anything is * written, so a cancelled prompt leaves nothing behind for this worker at all. + * `--instances` is recorded rather than resolved: it has no prompt, and it only + * reaches `config.toml` when it differs from the default. */ /** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ @@ -211,6 +214,23 @@ const resolveExposure = Effect.fnUntraced(function* (options: { return DEFAULT_WORKER_EXPOSURE; }); +/** + * The instance count to record, and whether to record it at all. + * + * Not prompted for, unlike the other dials: how many instances a worker needs is + * an operational answer nobody has while scaffolding it, so the flag records + * one when it is given and the file stays quiet when it is not. + * + * `undefined` — meaning "write no key" — for the default count, because an + * absent `instances` and `instances = 1` mean the same thing to `push`, and a + * scaffold should not commit a line that says nothing. A `0` is not that: it + * scales the worker to nothing, so it is written like any other explicit count. + */ +function recordedInstances(explicit: Option.Option): number | undefined { + const instances = Option.getOrUndefined(explicit); + return instances === undefined || instances === DEFAULT_WORKER_INSTANCES ? undefined : instances; +} + /** * Whether the destination is free for a scaffold: nothing there, or an empty * directory. A plain file counts as occupied, so it is refused by name rather @@ -269,6 +289,7 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); const size = yield* resolveSize({ explicit: flags.size, canPrompt }); const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); + const instances = recordedInstances(flags.instances); // Validated before anything is written: this is the directory the starter // files land in, so a value naming the project root, `supabase/`, or @@ -328,6 +349,7 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun runtime, size, exposure, + ...(instances === undefined ? {} : { instances }), ...(source === undefined ? {} : { source }), }, }); @@ -350,6 +372,10 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun size, vcpu: vcpuForSize(size), exposure, + // The count a deploy will use, whether or not it was written down — a + // payload that omitted it for the default would read as "unknown" rather + // than "one". + instances: instances ?? DEFAULT_WORKER_INSTANCES, source: sourceDisplay, config_path: project.configPath, }; @@ -376,6 +402,9 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun ["Runtime", runtime], ["Size", `${size} (${vcpuForSize(size)} vCPU)`], ["Access", exposure], + // `declared`, the way `workers status` labels the same number: nothing + // is running yet, so a bare count would read as a live tally. + ["Instances", `${instances ?? DEFAULT_WORKER_INSTANCES} declared`], ]), ); // On the success trailer rather than inline, the way `bootstrap` emits its diff --git a/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts index 6fbac9eb5b..fba7a8f3e1 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/new/new.integration.test.ts @@ -32,6 +32,7 @@ function flags(overrides: Partial = {}): LegacyWorkersNew runtime: Option.none(), size: Option.none(), exposure: Option.none(), + instances: Option.none(), source: Option.none(), ...overrides, }; @@ -187,6 +188,76 @@ describe("legacy workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The count a scaffold cannot guess: `--instances` has no prompt, so it is + // recorded when given and left out when not. + it.live("records an instance count that differs from the default", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ instances: Option.some(3) })); + + // Bare, not quoted: the config schema types `instances` as a number, so a + // quoted count would render a config.toml that no longer loads. + expect(repo.config()).toContain("instances = 3"); + expect(repo.config()).not.toContain('instances = "3"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The end-to-end proof that the count is written as a number: the config + // schema types `instances` as one, so a quoted `"3"` renders a config.toml + // that no longer decodes — which only shows up on the *next* load, not on the + // write that caused it. Scaffolding a second worker is that next load. + it.live("writes a count the config loader can read back", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api"), instances: Option.some(3) })); + yield* legacyWorkersNew(flags({ name: Option.some("web") })); + + expect(repo.config()).toContain("instances = 3"); + expect(repo.config()).toContain("[workers.web]"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Zero is an explicit count — it scales the worker to nothing — not an absent + // one, so it has to survive the "only record a non-default" rule. + it.live("records a zero instance count", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ instances: Option.some(0) })); + + expect(repo.config()).toContain("instances = 0"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // An absent `instances` and `instances = 1` mean the same thing to `push`, so + // the scaffold does not commit a line that says nothing. + it.live("writes no instance count when nothing names one", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags()); + + expect(repo.config()).not.toContain("instances"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("writes no instance count when the default is named explicitly", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ instances: Option.some(1) })); + + expect(repo.config()).not.toContain("instances"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // Written even when it is the default, the same way `runtime` and `size` are: // an absent key and `public` mean the same thing to `push` today, but only the // written one survives a change of default. @@ -588,6 +659,7 @@ describe("legacy workers new", () => { runtime: Option.none(), size: Option.none(), exposure: Option.none(), + instances: Option.none(), source: Option.none(), }); diff --git a/apps/cli/src/shared/workers/toml-section.ts b/apps/cli/src/shared/workers/toml-section.ts index 8baab4025d..f8a845ec15 100644 --- a/apps/cli/src/shared/workers/toml-section.ts +++ b/apps/cli/src/shared/workers/toml-section.ts @@ -58,9 +58,17 @@ export function tomlKey(key: string): string { return isBareKey(key) ? key : quote(key); } -/** `key = "value"` — every value the worker commands write is a string. */ -function renderPair(key: string, value: string): string { - return `${tomlKey(key)} = ${quote(value)}`; +/** + * `key = "value"`, or `key = value` for a number. + * + * A number has to be rendered bare: quoting it would write a TOML string, and + * the config schema types `[workers.] instances` as a number — so a quoted + * count produces a `config.toml` that no longer loads at all. Callers pass whole + * numbers; anything else renders as a token TOML does not accept, which + * `planWorkerEntry`'s re-parse catches before the file is written. + */ +function renderPair(key: string, value: string | number): string { + return `${tomlKey(key)} = ${typeof value === "number" ? String(value) : quote(value)}`; } /** @@ -74,7 +82,7 @@ function renderPair(key: string, value: string): string { export function appendTomlSection( text: string, header: string, - values: Readonly>, + values: Readonly>, ): string { const block = [ `[${header}]`, diff --git a/apps/cli/src/shared/workers/toml-section.unit.test.ts b/apps/cli/src/shared/workers/toml-section.unit.test.ts index d00fca6933..8cd27d4214 100644 --- a/apps/cli/src/shared/workers/toml-section.unit.test.ts +++ b/apps/cli/src/shared/workers/toml-section.unit.test.ts @@ -65,6 +65,20 @@ size = "2gb" ); }); + // Quoting a count would write a TOML string, and the config schema types + // `instances` as a number — so the rendered file would stop loading entirely. + test("writes a number bare rather than quoting it", () => { + expect(appendTomlSection("", "workers.api", { size: "2gb", instances: 3 })).toBe( + '[workers.api]\nsize = "2gb"\ninstances = 3\n', + ); + }); + + test("writes a zero count, which is a real value rather than an absent one", () => { + expect(appendTomlSection("", "workers.api", { instances: 0 })).toBe( + "[workers.api]\ninstances = 0\n", + ); + }); + test("writes a header with no keys when there is nothing to set", () => { expect(appendTomlSection("", "workers.api", {})).toBe("[workers.api]\n"); }); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index 2b34ae8e1c..dc5a5c6751 100644 --- a/apps/cli/src/shared/workers/worker-config.ts +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -136,7 +136,8 @@ export interface WorkerEntryWrite { export const planWorkerEntry = Effect.fnUntraced(function* (options: { readonly configPath: string; readonly name: string; - readonly patch: Readonly>; + /** Rendered as written: strings are quoted, numbers are not. */ + readonly patch: Readonly>; /** The already-parsed config — the authority on whether an entry exists. */ readonly existingWorkers: Readonly>; }) {