From 640c1032a9c6a70540ec30888e34cc9b477f4356 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:03:16 -0300 Subject: [PATCH 01/50] feat(cli): add supabase workers list, status and delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three read-side verbs, sharing one API seam and one description of what is actually there. `list` is every worker in the project, deployed or not, rendered through `renderGlamourTable` so it sits beside `functions list` and `projects list` looking like them. Its inventory is the union of three sources, because any one alone misleads: the `[workers.*]` entries in `config.toml`, the directories under `supabase/workers/`, and what the API reports as deployed. Leaving the directories out let `list` answer "No workers found" about a worker a bare `push` would discover and deploy. A worker with no deployment shows as `not deployed`; a deployment with no local counterpart is called out on stderr, since pushing it from here would have to guess its runtime — stderr so the note never lands inside a `-o` payload. The runtime column only claims a runtime it can support, and text and payload agree on it: the API omits `spec.runtime` for a context-only build, so on a deployed worker its absence does mean `dockerfile`, while for one never deployed there is nothing to infer from and the column says so rather than falling back to a local entry the deployment may have moved off. The list endpoint makes no per-worker backend call, so the instance column shows the declared count and `status` is where the live one lives. `status` is one worker in detail: the size, access, image and URL a `push` printed once and then scrolled away, plus the live instance tally. The deployed spec is the truth here, not `config.toml` — a worker deployed from its own Dockerfile carries no `spec.runtime`, and letting a stale local entry answer instead would report a runtime that is not what is running. When the instance read-through fails the API says so rather than returning counts, and that failure is reported on stderr instead of printing numbers it does not have. A failed build points at the retry, with the reason the API gave. `delete` removes a worker from the linked project; its instances and image are torn down asynchronously. Whether it exists is asked of the API, never of a local directory, so `status` and `delete` answer that question the same way. Being the irreversible verb, an interactive session has to type the worker's name back before anything happens — the same confirm-by-typing pattern as GitHub's own repository deletion, rather than a bare y/n that is too easy to reflexively accept. `--yes` skips it for scripts, as does a non-interactive session or a machine output format, where there is nowhere to ask. The confirmation counts the live tally when the API reports one and says "declared" when it does not, which for a destructive prompt is the difference that matters. What it does not remove is worth saying out loud, so it says it: the worker's directory and its `config.toml` entry stay on disk — and the redeploy advice waits on that source actually being there, rather than naming a `push` that would fail. None of the three state a local fact it has not checked. `legacyDescribeWorker` can always *compute* a source directory, because with no `[workers.]` entry it falls back to the default path, so a worker deployed from somebody else's checkout would otherwise get a path that looks like fact. It answers separately whether anything local establishes that path, and the reporting variant degrades rather than failing: a configured `source` that no longer resolves inside the project reads the same as having nothing local, which is what the output needs to say, and does not leave a remote worker un-deletable until the user edits `config.toml`. `push` keeps the strict version, since there that directory is what gets packaged and uploaded. Shell conventions across the three: `-o table` and `-o csv` render text like every other resource command rather than falling through to the TOML encoder; a machine format is as non-interactive as a redirected stdout, since `-o` leaves `output.format` as `text` and a prompt would land on the stdout the payload owns; project loading, name validation and worker resolution happen inside the finalizers so those failures still flush telemetry; and `status` and `delete` validate names against the API's DNS-label rule rather than any local naming rule, since neither writes `[workers.]` and a worker visible in `list` should not be impossible to inspect or remove. --- .../commands/workers/delete/SIDE_EFFECTS.md | 69 ++++ .../commands/workers/delete/delete.command.ts | 43 ++ .../commands/workers/delete/delete.handler.ts | 193 +++++++++ .../workers/delete/delete.integration.test.ts | 366 ++++++++++++++++++ .../commands/workers/list/SIDE_EFFECTS.md | 52 +++ .../commands/workers/list/list.command.ts | 35 ++ .../commands/workers/list/list.handler.ts | 185 +++++++++ .../workers/list/list.integration.test.ts | 361 +++++++++++++++++ .../commands/workers/status/SIDE_EFFECTS.md | 54 +++ .../commands/workers/status/status.command.ts | 36 ++ .../commands/workers/status/status.handler.ts | 142 +++++++ .../workers/status/status.integration.test.ts | 366 ++++++++++++++++++ .../commands/workers/workers.command.ts | 11 +- .../legacy/commands/workers/workers.output.ts | 20 +- .../legacy/commands/workers/workers.shared.ts | 62 ++- .../cli/src/shared/workers/worker-runtimes.ts | 7 +- apps/cli/src/shared/workers/workers-api.ts | 67 +++- apps/cli/src/shared/workers/workers.errors.ts | 44 +++ apps/cli/tests/helpers/legacy-workers.ts | 17 +- 19 files changed, 2108 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/delete/delete.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/delete/delete.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/list/list.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/list/list.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/status/status.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/status/status.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/status/status.integration.test.ts diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md new file mode 100644 index 0000000000..f58dbfdf7d --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -0,0 +1,69 @@ +# `supabase workers delete ` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to report the source directory it kept | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +The worker's directory and its `[workers.]` entry are deliberately left +on disk; only the remote worker is deleted. + +## Confirmation + +Interactively, the worker's name has to be typed back before anything is +deleted. `--yes` (the root persistent flag) or `SUPABASE_YES` skips that. With +neither — and no interactive terminal to prompt on, which includes a redirected +stdout and any `--output-format json`/`stream-json` run — the command refuses +rather than deleting unasked. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------- | ------------ | ------------ | --------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec.instances` (for the confirmation) | +| `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (a `404` on DELETE counts — it is already gone) | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `1` | the typed confirmation did not match the worker's name | +| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.command.ts b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts new file mode 100644 index 0000000000..b1d12d1b44 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts @@ -0,0 +1,43 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +// No local `--yes`: it is a root persistent flag every other confirming command +// reads through `legacyResolveYes`, so redeclaring it here would shadow the +// global, list `--yes` twice in `--help`, and quietly ignore `SUPABASE_YES`. +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to delete.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersDeleteFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersDeleteCommand = Command.make("delete", config).pipe( + Command.withDescription( + "Delete a worker from the linked Supabase project. Irreversible; its local directory and supabase/config.toml entry are kept.", + ), + Command.withShortDescription("Delete a worker from Supabase"), + Command.withExamples([ + { + command: "supabase workers delete api", + description: "Delete a worker, confirming by typing its name", + }, + { + command: "supabase workers delete api --yes", + description: "Skip the confirmation prompt (scripts and CI)", + }, + ]), + Command.withHandler((flags) => + legacyWorkersDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "delete"])), +); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts new file mode 100644 index 0000000000..db4242d7d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -0,0 +1,193 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { deleteWorker, getWorker } from "../../../../shared/workers/workers-api.ts"; +import { + WorkerDeleteConfirmationRequiredError, + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorkerForReporting, + legacyLoadWorkersProject, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; + +/** + * `supabase workers delete [name]` — delete the worker; its instances and image + * are torn down asynchronously. Whether it exists is asked of the API, never of + * a local file. + * + * Note what it does *not* remove: the worker's directory and its `config.toml` + * entry stay on disk, so `push ` brings it straight back — which is why + * the command says so. + * + * Being irreversible, an interactive session has to type the worker's name back + * to proceed — the same "confirm by typing it" pattern as GitHub's own repo + * deletion, rather than a bare y/n that is too easy to reflexively confirm. + * `--yes`/`SUPABASE_YES` skips it for scripts, resolved through + * `legacyResolveYes` like every other confirming command rather than through a + * local flag that would shadow the root one. + * + * Without a terminal to prompt on there is no third option: `interactive` tracks + * stdout, so merely redirecting output would otherwise delete unattended. This + * refuses instead, and says which flag would have authorised it. + */ +export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* ( + flags: LegacyWorkersDeleteFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + // `--yes` OR `SUPABASE_YES`, matching `projects delete` and every other + // command that guards a destructive step behind a prompt. + const yes = yield* legacyResolveYes; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = yield* legacyDescribeWorkerForReporting(project, name); + + const fetching = yield* output.task("Fetching worker..."); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + if (Option.isNone(found)) { + return yield* Effect.fail( + new WorkerNotDeployedError({ + detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, + suggestion: `Deploy it with \`supabase workers push ${name}\`.`, + }), + ); + } + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + + if (!yes) { + // `-o json` leaves `output.format` as `text`, so the format check alone + // still let the warning and the prompt run — onto the stdout the user had + // asked to carry a payload. A machine format is as non-interactive as a + // redirected stdout, whichever flag asked for it. + if (output.format !== "text" || machineOutput || !output.interactive) { + return yield* Effect.fail( + new WorkerDeleteConfirmationRequiredError({ + detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, + suggestion: `Re-run \`supabase workers delete ${name} --yes\` to confirm without a prompt.`, + }), + ); + } + + // The live tally when the API reports one, labelled "declared" when it + // does not. `spec.instances` is the target, which for a worker still + // provisioning differs from what is running — and a destructive prompt is + // the wrong place to overstate. + const live = found.value.instances?.live; + const declared = found.value.spec.instances; + const terminating = + live !== undefined + ? live > 0 + ? ` ${live} running instance${live === 1 ? "" : "s"} will be terminated.` + : "" + : declared > 0 + ? ` ${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` + : ""; + yield* output.raw( + `This permanently deletes "${name}" from project ${projectRef}.${terminating}\n`, + ); + const typed = yield* output.promptText(`Type ${name} to confirm`); + // Trimmed: a trailing space from a paste is not a different answer, and + // making someone re-run a destructive command over one is just friction. + if (typed.trim() !== name) { + return yield* Effect.fail( + new WorkerDeleteNotConfirmedError({ + detail: `The confirmation did not match "${name}", so nothing was deleted.`, + suggestion: `Re-run \`supabase workers delete ${name}\` and type the name exactly, or pass --yes.`, + }), + ); + } + } + + const deleting = yield* output.task("Deleting worker..."); + yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.clear(); + + // A worker deployed from another checkout has neither a local entry nor a + // local directory, so there is nothing here that was kept. + const keptSource = worker.sourceExists + ? displayPath(project.projectRoot, worker.sourceDir) + : undefined; + const keptEntry = worker.entry !== undefined; + + const payload = { + worker_name: name, + project_ref: projectRef, + ...(keptSource === undefined ? {} : { kept_source: keptSource }), + kept_config_entry: keptEntry, + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + { + yield* output.raw( + `Deleted Worker ${legacyAqua(name, process.stdout)} from project ${projectRef}\n`, + ); + + // "Deleted" reads more final than it is *when there is something left* — + // so only say so when there is. For an orphan there is nothing local to + // keep, and pointing at `push` would send the user at a command that has + // no source to deploy. + const kept = [ + ...(keptSource === undefined ? [] : [keptSource]), + ...(keptEntry ? ["its supabase/config.toml entry"] : []), + ]; + if (kept.length > 0) { + yield* output.raw(legacyRenderWorkerDetails([["Kept", kept.join(", ")]])); + // Only when the source is still there: a retained `config.toml` entry + // alone is not enough to redeploy from, so `push` would fail on the very + // command this line recommends. + if (keptSource !== undefined) { + yield* output.raw(`Redeploy it with supabase workers push ${name}.\n`); + } + } else { + yield* output.raw( + `Nothing for "${name}" exists in this project on disk, so nothing was kept.\n`, + "stderr", + ); + } + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts new file mode 100644 index 0000000000..8fc7f9900f --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -0,0 +1,366 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + WorkerDeleteConfirmationRequiredError, + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +/** + * A project with `api` configured and on disk by default. Pass a bare config to + * get the orphan case — a worker deployed from somebody else's checkout, with + * nothing local behind it. + */ +function project(config = CONFIG) { + const created = makeWorkersProject({ + "supabase/config.toml": config, + ...(config === CONFIG ? { "supabase/workers/api/index.js": "export default {};\n" } : {}), + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; +const deleteRoute = `DELETE ${workersRoute("/api")}`; + +const routes = { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 3 }) }, + }, + [deleteRoute]: { status: 204 }, +}; + +describe("legacy workers delete", () => { + it.live("deletes after the name is typed back, and keeps the local files", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).toContain("permanently deletes"); + // Labelled "declared" because this response carries no live tally. + // `spec.instances` is the target, not what is running. + expect(out.stdoutText).toContain("3 declared instances"); + expect(out.stdoutText).toContain("Kept"); + + // Nothing local is touched — that is what makes `push` a one-command undo. + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(readFileSync(join(repo.dir, "supabase", "config.toml"), "utf8")).toBe(CONFIG); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes nothing when the confirmation does not match", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["nope"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteNotConfirmedError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("skips the confirmation with --yes", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).not.toContain("permanently deletes"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses to delete unattended rather than skipping the confirmation", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `interactive` follows stdout, so a plain `>` redirect reaches this branch + // even from a live terminal — the case that used to delete without asking. + it.live("refuses when stdout is redirected and no --yes was given", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + interactive: false, + routes, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes unattended when SUPABASE_YES or --yes authorises it", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` before asking anything", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + expect(out.messages.filter((message) => message.type === "warn")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("treats a delete that races another one as done", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 404, body: { message: "already gone" } } }, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected delete status", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 500, body: { message: "boom" } } }, + yes: true, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toEqual({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + kept_source: join("supabase", "workers", "api"), + kept_config_entry: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o json` leaves `output.format` as `text`, so the interactive check alone + // still ran the warning and the prompt — onto the stdout the payload was + // supposed to own. + it.live("refuses rather than prompting when -o json asked for the stdout", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + goOutput: "json", + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkerDeleteConfirmationRequiredError"); + expect(out.stdoutText).not.toContain("permanently deletes"); + expect(http.routeKeys).not.toContain(deleteRoute); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The live tally is what is actually running; `spec.instances` is the target. + // For a worker mid-provision the two differ, and a destructive confirmation is + // the worst place to overstate. + it.live("counts the live instances in the confirmation when the API reports them", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["api"], + routes: { + ...routes, + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + instances: 3, + instanceCounts: { declared: 3, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("1 running instance will be terminated"); + expect(out.stdoutText).not.toContain("3 running"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // An orphan — deployed from another checkout — has no local entry and no local + // directory, so there is nothing that was "kept" and `push` has no source to + // redeploy from. + it.live("does not claim to have kept local files it never had", () => { + const repo = project('project_id = "demo"\n'); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET ${workersRoute("/stray")}`]: { + status: 200, + body: { data: workerResource({ name: "stray" }) }, + }, + [`DELETE ${workersRoute("/stray")}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "stray", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + expect(out.stdoutText).not.toContain("Kept"); + expect(out.stdoutText).not.toContain("workers push stray"); + expect(out.stderrText).toContain("nothing was kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes a deployed worker named root", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET ${workersRoute("/root")}`]: { + status: 200, + body: { data: workerResource({ name: "root" }) }, + }, + [`DELETE ${workersRoute("/root")}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "root", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A `config.toml` entry on its own is not something `push` can deploy from, so + // recommending it would send the user at a command that fails. + it.live("keeps the config entry but does not advise redeploying without a source", () => { + const repo = project(); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("supabase/config.toml entry"); + expect(out.stdoutText).not.toContain("workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Deletion never reads the local source, so a `source` that no longer resolves + // inside the project must not block removing the remote worker. + it.live("deletes the remote worker even when the configured source is unusable", () => { + const repo = project('project_id = "demo"\n\n[workers.api]\nsource = "../../elsewhere"\n'); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toContain(deleteRoute); + expect(out.stdoutText).toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md new file mode 100644 index 0000000000..5538816773 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md @@ -0,0 +1,52 @@ +# `supabase workers list` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for the `[workers.*]` entries | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | ---------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success, including when the project has none | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/list/list.command.ts b/apps/cli/src/legacy/commands/workers/list/list.command.ts new file mode 100644 index 0000000000..ee09a79cac --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.command.ts @@ -0,0 +1,35 @@ +import { 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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const config = { + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersListFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersListCommand = Command.make("list", config).pipe( + Command.withDescription( + "List this project's workers, deployed or not: the union of supabase/config.toml's entries and what the Workers API reports.", + ), + Command.withShortDescription("List this project's workers"), + Command.withExamples([ + { + command: "supabase workers list", + description: "See every worker in the linked project", + }, + ]), + Command.withHandler((flags) => + legacyWorkersList(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "list"])), +); diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts new file mode 100644 index 0000000000..b013449395 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -0,0 +1,185 @@ +import { Effect } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { listWorkers, type WorkerRecord } from "../../../../shared/workers/workers-api.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDiscoverWorkerNames, legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersListFlags } from "./list.command.ts"; + +/** + * `supabase workers list` — every worker in this project, deployed or not. + * + * A union of two sources, because either half alone is misleading: the + * project's `[workers.*]` entries (scaffolded, maybe never deployed) and what + * the API reports as deployed (including anything deployed from elsewhere, or + * from a directory since deleted). A worker in the config with nothing deployed + * shows as `not deployed`; a deployed worker with no local entry is called out, + * since pushing it from here would have to guess its runtime. + * + * The list endpoint deliberately makes no per-worker backend call, so it + * carries no live instance tally — the `INSTANCES` column is the declared + * count from the spec. `status` is where the live tally lives. + */ + +const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES", "URL"] as const; + +interface WorkerRow { + readonly name: string; + /** Has a `[workers.]` entry in `config.toml`. */ + readonly configured: boolean; + /** Exists on this machine at all — a config entry, a directory, or both. */ + readonly local: boolean; + readonly deployed: WorkerRecord | undefined; + readonly localRuntime: string | undefined; + readonly url: string | undefined; +} + +function stateLabel(row: WorkerRow): string { + if (row.deployed === undefined) { + return "not deployed"; + } + if (row.deployed.deleting === true) { + return "deleting"; + } + return row.deployed.buildState; +} + +/** + * The API omits `spec.runtime` only for a context-only build, so for a deployed + * worker its absence *is* "dockerfile". For one that has never been deployed + * there is nothing to infer from — `push` would guess from marker files — so say + * unknown rather than assert a runtime it may not have. + */ +function runtimeLabelFor(row: WorkerRow): string | undefined { + if (row.deployed !== undefined) { + return row.deployed.spec.runtime ?? "dockerfile"; + } + return row.localRuntime; +} + +function runtimeLabel(row: WorkerRow): string { + return runtimeLabelFor(row) ?? "-"; +} + +function toCells(row: WorkerRow): ReadonlyArray { + return [ + row.name, + runtimeLabel(row), + row.deployed === undefined ? "-" : formatApiSize(row.deployed.spec.size), + stateLabel(row), + row.deployed === undefined ? "-" : String(row.deployed.spec.instances), + row.url ?? "-", + ]; +} + +export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( + flags: LegacyWorkersListFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliConfig = yield* LegacyCliConfig; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const fetching = yield* output.task("Fetching workers..."); + const deployed = yield* listWorkers(api, projectRef).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + const byName = new Map(deployed.map((worker) => [worker.name, worker])); + const configuredNames = Object.keys(project.section.workers); + // Three sources: config entries, deployed workers, and directories under the + // workers root. The last are deployable — `legacyDiscoverWorkerNames` is the + // walk a bare `push` does — so the inventory has to show them. + const discoveredNames = yield* legacyDiscoverWorkerNames(project); + const names = [...new Set([...configuredNames, ...discoveredNames, ...byName.keys()])].sort(); + + const rows: Array = names.map((name) => { + const record = byName.get(name); + return { + name, + configured: configuredNames.includes(name), + local: configuredNames.includes(name) || discoveredNames.includes(name), + deployed: record, + localRuntime: project.section.workers[name]?.runtime, + url: + record !== undefined && record.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined, + }; + }); + + const payload = { + project_ref: projectRef, + workers: rows.map((row) => ({ + name: row.name, + configured: row.configured, + local: row.local, + deployed: row.deployed !== undefined, + // Read the same way `runtimeLabel` reads it, so `-o json` and the text + // table cannot disagree: for a deployed worker an absent `spec.runtime` + // *means* dockerfile, and falling back to the local config there + // reported a stale runtime the deployment had moved off. + runtime: runtimeLabelFor(row), + size: row.deployed?.spec.size, + state: stateLabel(row), + instances: row.deployed?.spec.instances, + url: row.url, + })), + }; + + // `-o` is independent of `--output-format`: it leaves `output.format` as + // `text`, so this has to be checked before the text branch below, not + // inside the structured one. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + if (rows.length === 0) { + yield* output.raw("No workers found. Scaffold one with supabase workers new .\n"); + return; + } + + yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); + + // Deployed *and* unconfigured: a bare local directory is also unconfigured, + // and has not been deployed at all. + const orphans = rows + .filter((row) => row.deployed !== undefined && !row.configured) + .map((row) => row.name); + if (orphans.length > 0) { + yield* output.raw( + `${orphans.join(", ")} ${ + orphans.length === 1 ? "is" : "are" + } deployed but absent from supabase/config.toml: pushing from here would have to guess the runtime.\n`, + "stderr", + ); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts new file mode 100644 index 0000000000..65c975f31f --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -0,0 +1,361 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { WorkersUnavailableError } from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const CONFIG = `project_id = "demo" + +[workers.api] +runtime = "node" +size = "2gb" + +[workers.old] +runtime = "deno" +`; + +function project(config = CONFIG) { + const created = makeWorkersProject({ "supabase/config.toml": config }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const listRoute = `GET ${workersRoute()}`; + +describe("legacy workers list", () => { + it.live("shows configured and deployed workers as one inventory", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { + data: [ + workerResource({ name: "api", runtime: "node", imageVersion: "v3" }), + workerResource({ + name: "box", + runtime: "sandbox", + exposure: "private", + instances: 2, + }), + ], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("NAME"); + + const rows = stdout.split("\n").filter((line) => /\|/.test(line) && /api|box|old/.test(line)); + expect(rows).toHaveLength(3); + // Sorted by name, so `api`, `box`, then the scaffolded-but-undeployed `old`. + expect(rows[0]).toContain("2gb (1 vCPU)"); + expect(rows[0]).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(rows[1]).toContain("sandbox"); + expect(rows[2]).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("does not assert a runtime for a worker that has never been deployed", () => { + const repo = project(`project_id = "demo"\n\n[workers.ghost]\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const row = out.stdoutText.split("\n").find((line) => line.includes("ghost")); + expect(row).toBeDefined(); + expect(row).not.toContain("dockerfile"); + expect(row).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("calls out a deployed worker that config.toml does not know about", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "stray", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("stray"); + expect(out.stderrText).toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("says so when the project has no workers at all", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain( + "No workers found. Scaffold one with supabase workers new .", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the inventory as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + name: "api", + configured: true, + local: true, + deployed: true, + runtime: "node", + size: "2gb-1vcpu", + state: "active", + instances: 1, + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + { + name: "old", + configured: true, + local: true, + deployed: false, + runtime: "deno", + size: undefined, + state: "not deployed", + instances: undefined, + url: undefined, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("serialises the inventory for the Go -o flag", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + // `-o` payloads own stdout outright: no clack success line may share it. + const parsed = JSON.parse(out.stdoutText); + expect(parsed.project_ref).toBe(WORKERS_PROJECT_REF); + expect(parsed.workers).toHaveLength(2); + expect(out.messages.filter((m) => m.type === "success")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses -o env, which cannot represent the worker list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "env", + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected status rather than showing an empty list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 500, body: { message: "boom" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("uses an explicit --project-ref without a linked project", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + linked: false, + routes: { + "GET /v2/projects/qrstuvwxyzabcdefghij/workers": { status: 200, body: { data: [] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + + expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/workers"]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project when no ref is given", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A directory under the workers root with no `[workers.]` entry is what + // a bare `push` discovers and deploys, so an inventory that leaves it out can + // say "No workers found" about a worker `push` would happily deploy. + it.live("includes a local worker directory that has no config entry", () => { + const repo = project('project_id = "demo"\n'); + mkdirSync(join(repo.dir, "supabase", "workers", "scaffolded"), { recursive: true }); + writeFileSync(join(repo.dir, "supabase", "workers", "scaffolded", "index.js"), "export {};\n"); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("scaffolded"); + expect(out.stdoutText).not.toContain("No workers found"); + // Never deployed, so it is not announced as a deployed-but-unconfigured + // orphan either. + expect(out.stderrText).not.toContain("scaffolded"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The API omits `spec.runtime` only for a context-only build, so for a + // deployed worker its absence *is* dockerfile. Falling back to the local + // config there made `-o json` report a runtime the text table contradicted. + it.live("reports a deployed dockerfile worker as dockerfile in both renderings", () => { + const repo = project('project_id = "demo"\n\n[workers.api]\nruntime = "node"\n'); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { status: 200, body: { data: [workerResource({ name: "api" })] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data?.["workers"]).toMatchObject([{ name: "api", runtime: "dockerfile" }]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `table` and `csv` are accepted by the global flag for `db query`'s benefit; + // every resource command is meant to ignore them and render text. They used to + // fall through to the TOML encoder. + it.live.each(["table", "csv"] as const)("renders text rather than TOML for -o %s", (goOutput) => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).not.toContain("project_ref = "); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project("project_id = [unclosed\n"); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..cff927b867 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md @@ -0,0 +1,54 @@ +# `supabase workers status ` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to report the worker's source directory | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/status/status.command.ts b/apps/cli/src/legacy/commands/workers/status/status.command.ts new file mode 100644 index 0000000000..15f4e5c23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.command.ts @@ -0,0 +1,36 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to inspect.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersStatusFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersStatusCommand = Command.make("status", config).pipe( + Command.withDescription( + "Show one worker in detail: build state, size, access, image, live instance tally and source directory.", + ), + Command.withShortDescription("Show a worker in detail"), + Command.withExamples([ + { + command: "supabase workers status api", + description: "Inspect a specific worker", + }, + ]), + Command.withHandler((flags) => + legacyWorkersStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "status"])), +); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts new file mode 100644 index 0000000000..56133431ce --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -0,0 +1,142 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { getWorker } from "../../../../shared/workers/workers-api.ts"; +import { WorkerNotDeployedError } from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorkerForReporting, + legacyLoadWorkersProject, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersStatusFlags } from "./status.command.ts"; + +/** + * `supabase workers status [name]` — everything known about one worker. + * + * `list`'s companion: the size, image and URL a `push` printed once and then + * scrolled away, plus the live instance tally, which is the only place it is + * available — the list endpoint stays free of per-worker backend calls. + */ +export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* ( + flags: LegacyWorkersStatusFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliConfig = yield* LegacyCliConfig; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = yield* legacyDescribeWorkerForReporting(project, name); + + const fetching = yield* output.task("Fetching worker..."); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + if (Option.isNone(found)) { + return yield* Effect.fail( + new WorkerNotDeployedError({ + detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, + suggestion: `Deploy it with \`supabase workers push ${name}\`.`, + }), + ); + } + + const record = found.value; + const url = + record.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + // Reported only when an entry or the directory establishes it. With neither, + // the path is an inference about a worker that may have been deployed from + // another checkout. + const sourceDisplay = + worker.entry !== undefined || worker.sourceExists + ? displayPath(project.projectRoot, worker.sourceDir) + : undefined; + + const payload = { + worker_name: name, + project_ref: projectRef, + runtime: record.spec.runtime ?? "dockerfile", + size: record.spec.size, + exposure: record.spec.exposure, + build_state: record.buildState, + state_reason: record.stateReason, + image_version: record.imageVersion, + deleting: record.deleting, + declared_instances: record.spec.instances, + instances: record.instances, + instances_error: record.instancesError, + ...(sourceDisplay === undefined ? {} : { source: sourceDisplay }), + ...(url === undefined ? {} : { url }), + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + // One structured emission, in the structured branch only. Calling + // `output.success` before this check emitted the payload twice: the JSON + // layer appends each success to stdout, so `JSON.parse` failed, and + // `stream-json` saw two terminal result events. + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + const details: Array = [ + ["State", record.deleting === true ? "deleting" : record.buildState], + ["Reason", record.stateReason ?? ""], + ["Runtime", record.spec.runtime ?? "dockerfile"], + ["Size", formatApiSize(record.spec.size)], + ["Image", record.imageVersion ?? ""], + ["Access", record.spec.exposure], + [ + "Instances", + record.instances !== undefined + ? `${record.instances.ready}/${record.spec.instances} ready, ${record.instances.live} live, ${record.instances.stale} stale` + : `${record.spec.instances} declared`, + ], + ["URL", url ?? ""], + ["Project", projectRef], + // `legacyRenderWorkerDetails` drops empty-valued rows, so an unknown + // source omits the row rather than printing a guess. + ["Source", sourceDisplay ?? ""], + ]; + + yield* output.raw(legacyRenderWorkerDetails(details)); + + if (record.instances === undefined && record.instancesError !== undefined) { + yield* output.raw(`Instance counts could not be read: ${record.instancesError}\n`, "stderr"); + } + if (record.buildState === "failed") { + yield* output.raw(`Fix the issue, then re-run supabase workers push ${name}.\n`); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts new file mode 100644 index 0000000000..00800886ec --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -0,0 +1,366 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + InvalidWorkerNameError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG, + "supabase/workers/api/index.js": "export default {};\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; + +describe("legacy workers status", () => { + it.live("reports the deployment facts and the live instance tally", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instances: 3, + instanceCounts: { declared: 3, live: 3, ready: 2, stale: 1 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("State"); + expect(stdout).toContain("active"); + expect(stdout).toContain("node"); + expect(stdout).toContain("2gb (1 vCPU)"); + expect(stdout).toContain("public"); + expect(stdout).toContain(WORKERS_PROJECT_REF); + expect(stdout).toContain("v3"); + expect(stdout).toContain("2/3 ready, 3 live, 1 stale"); + expect(stdout).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(stdout).toContain(join("supabase", "workers", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the deployed runtime, not a stale config.toml entry", () => { + // config.toml says node; the deployment carries no spec.runtime, which the + // API only omits for a context-only (Dockerfile) build. + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const runtimeLine = out.stdoutText + .split("\n") + .find((line) => line.trim().startsWith("Runtime")); + expect(runtimeLine).toContain("dockerfile"); + expect(runtimeLine).not.toContain("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the declared count when no tally came back", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 2 }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("2 declared"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("warns rather than lying when the instance read-through failed", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + instancesError: "backend unreachable", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stderrText).toContain("backend unreachable"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points a failed build at the retry, with the reason", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "failed", + stateReason: "exit status 1", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("failed"); + expect(out.stdoutText).toContain("exit status 1"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("shows a worker being torn down as deleting", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", deleting: true }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` and points at push", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + expect((error as WorkerNotDeployedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a name that could never have been written", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "My_Worker", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the worker's source directory even when it lives outside supabase/", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain(join("packages", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the same facts as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instanceCounts: { declared: 1, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + build_state: "active", + image_version: "v3", + declared_instances: 1, + instances: { declared: 1, live: 1, ready: 1, stale: 0 }, + }); + // The detail lines are text-mode only. + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The JSON layer appends each success to stdout, so emitting the payload twice + // made `JSON.parse(stdout)` fail outright and gave `stream-json` two terminal + // result events. + it.live("emits exactly one structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { status: 200, body: { data: workerResource({ name: "api" }) } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const results = out.messages.filter( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(results).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A worker deployed from another checkout has no entry and no directory here, + // so `supabase/workers/` is pure inference — reporting it as the + // worker's source named a path that was not there. + it.live("omits the source for a worker with nothing local to point at", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET ${workersRoute("/stray")}`]: { + status: 200, + body: { data: workerResource({ name: "stray" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "stray", projectRef: Option.none() }); + + expect(out.stdoutText).not.toContain("workers/stray"); + expect(out.stdoutText).not.toContain("Source"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `root` is only unusable *locally*, because `[workers] root` occupies the key. + // The API accepts it as a DNS label, and `status` writes no config, so it has + // no business refusing a worker `workers list` will happily show. + it.live("inspects a deployed worker named root", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET ${workersRoute("/root")}`]: { + status: 200, + body: { data: workerResource({ name: "root" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "root", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("active"); + expect(http.routeKeys).toEqual([`GET ${workersRoute("/root")}`]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("flushes telemetry when the worker name is invalid", () => { + const repo = project(); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "Not_A_Label", projectRef: Option.none() }).pipe( + Effect.flip, + ); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index d575670118..b5b536fdb7 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,11 +1,20 @@ import { Command } from "effect/unstable/cli"; +import { legacyWorkersDeleteCommand } from "./delete/delete.command.ts"; +import { legacyWorkersListCommand } from "./list/list.command.ts"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; import { legacyWorkersPushCommand } from "./push/push.command.ts"; +import { legacyWorkersStatusCommand } from "./status/status.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), + Command.withSubcommands([ + legacyWorkersNewCommand, + legacyWorkersPushCommand, + legacyWorkersListCommand, + legacyWorkersStatusCommand, + legacyWorkersDeleteCommand, + ]), ); diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts index 840b0a23d2..08962d01f5 100644 --- a/apps/cli/src/legacy/commands/workers/workers.output.ts +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -19,13 +19,28 @@ import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; * rendering — `output.success` writes to stdout in text mode and would corrupt * the payload otherwise. */ +/** + * Which `-o` values these commands answer with a payload. + * + * `pretty` is the human default. `table` and `csv` are accepted by the global + * flag because `db query` reads them, and every resource command is meant to + * ignore them and fall through to its own text rendering — so treating them as + * machine output emitted TOML for `-o table`, and would now suppress the text + * rendering without putting anything in its place. + */ +function emitsPayloadFor(goFormat: string | undefined): boolean { + return ( + goFormat !== undefined && goFormat !== "pretty" && goFormat !== "table" && goFormat !== "csv" + ); +} + export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( payload: Record, ) { const output = yield* Output; const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); - if (goFormat === undefined || goFormat === "pretty") { + if (!emitsPayloadFor(goFormat)) { return false; } @@ -56,8 +71,7 @@ export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( * by which point those lines would already be on stdout. */ export const legacyWorkersMachineOutputRequested = Effect.fnUntraced(function* () { - const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); - return goFormat !== undefined && goFormat !== "pretty"; + return emitsPayloadFor(Option.getOrUndefined(yield* LegacyOutputFlag)); }); /** diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 871c17e23c..f0ef640eee 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -56,8 +56,17 @@ export interface LegacyResolvedWorker { readonly entry: WorkerEntry | undefined; /** The worker's default directory, `supabase/workers//`. */ readonly defaultDir: string; - /** Where its code actually lives, honouring `[workers.] source`. */ + /** Where its code would live, honouring `[workers.] source`. */ readonly sourceDir: string; + /** + * Whether anything local actually establishes {@link sourceDir}. + * + * `sourceDir` is always computable — with no entry it falls back to the default + * directory — so it cannot on its own tell a worker whose code is on this + * machine from one deployed out of another checkout. Commands that print local + * paths need that difference before they state one as fact. + */ + readonly sourceExists: boolean; } /** @@ -65,26 +74,63 @@ export interface LegacyResolvedWorker { * verdict needs the filesystem: `source` comes from a committed `config.toml`, * and a directory inside the project can symlink anywhere outside it. */ +/** + * As {@link legacyDescribeWorker}, but never failing on the source path. + * + * For commands that only *report* on local state — `status` and `delete` — where + * the source is a detail of the output, not a prerequisite. Making confinement + * mandatory there stranded the remote worker: a `source` that resolves outside + * the project (an in-project directory that became a symlink, say) failed the + * describe before either API call, so `delete` could not remove a worker whose + * local files it was never going to touch. + * + * `push` keeps the strict version, because there the source *is* what gets + * packaged and uploaded. + */ +export const legacyDescribeWorkerForReporting = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, + name: string, +) { + const described = yield* legacyDescribeWorker(project, name).pipe(Effect.option); + if (described._tag === "Some") { + return described.value; + } + // The path is unusable, which for reporting purposes reads the same as having + // nothing local at all. + return { + name, + entry: project.section.workers[name], + defaultDir: workerDir(project.projectRoot, name), + sourceDir: workerDir(project.projectRoot, name), + sourceExists: false, + } satisfies LegacyResolvedWorker; +}); + export const legacyDescribeWorker = Effect.fnUntraced(function* ( project: LegacyWorkersProject, name: string, ) { + const fs = yield* FileSystem.FileSystem; const entry = project.section.workers[name]; const defaultDir = workerDir(project.projectRoot, name); + const sourceDir = yield* workerSourceDir({ + projectRoot: project.projectRoot, + defaultDir, + name, + configuredSource: entry?.source, + }); + const info = yield* fs.stat(sourceDir).pipe(Effect.option); + return { name, entry, defaultDir, - sourceDir: yield* workerSourceDir({ - projectRoot: project.projectRoot, - defaultDir, - name, - configuredSource: entry?.source, - }), + sourceDir, + sourceExists: info._tag === "Some" && info.value.type === "Directory", } satisfies LegacyResolvedWorker; }); -/** Reject a name the CLI could never have written, before acting on it. */ +/** Reject a name that could never be a worker, before acting on it. */ export const legacyValidateWorkerName = Effect.fnUntraced(function* (name: string) { const invalid = validateWorkerNameMessage(name); if (invalid !== undefined) { diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 897087b073..f72fdf0d99 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -116,7 +116,12 @@ const WORKER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; const workerNameRequirement = "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; -/** `undefined` when `name` is a valid worker name, else why it is not. */ +/** + * `undefined` when `name` is a name this CLI can *record*, else why it is not. + * + * For commands that write `[workers.]` — which is `new`, and `push` only + * because it deploys what `new` wrote. + */ export function validateWorkerNameMessage(name: string): string | undefined { return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; } diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index 79409d05f6..627a341b12 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -5,6 +5,7 @@ import { V2CreateWorkerUploadOutput, V2DeployAWorkerOutput, V2GetAWorkerOutput, + V2ListAllWorkersOutput, type ApiClient, } from "@supabase/api/effect"; import { Effect, Option, Schedule, Schema } from "effect"; @@ -26,11 +27,11 @@ import { * * The routes are deliberately few — list, get, mint an upload slot, deploy, * delete — so this module is thin, and what it mostly adds is status handling. - * The alpha's allow-list answers 404 for a project that is not enrolled, which - * at the transport level is indistinguishable from "no such worker"; so a 404 - * on a collection endpoint (where no worker name could have been wrong) becomes - * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by - * the caller as "not deployed". + * A 404 is overloaded on these routes: it is the answer for a project outside + * the alpha's allow-list, for a project ref that names nothing, and for a + * worker that is not deployed. A 404 on a named worker is reported by the + * caller as "not deployed"; one on a collection endpoint, where no worker name + * could have been wrong, is split by its body — see {@link projectScoped404}. */ /** The worker shape the API returns, flattened out of its JSON:API envelope. */ @@ -194,12 +195,43 @@ const decodeBody = ( ), ); +export const listWorkers = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { + const operation = "list workers"; + const response = yield* api + .executeRaw(operationDefinitions.v2ListAllWorkers, { ref: projectRef }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2ListAllWorkersOutput, operation, body, response.status); + return decoded.data.map(toWorkerRecord); +}); + /** * One worker, or `None` when the API has no record of it — which is also what a * project outside the alpha's allow-list answers, so callers report it as "not * deployed" and point at `push` rather than guessing which of the two it was. */ -const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { +export const getWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { const operation = `read worker "${name}"`; const response = yield* api .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) @@ -351,6 +383,29 @@ export const deployWorker = Effect.fnUntraced(function* ( return toWorkerRecord(decoded.data); }); +export const deleteWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `delete worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeleteAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + // 404 is the caller's own "not deployed" verdict to report; a delete that + // races another one is still a delete that happened. + if (response.status === 204 || response.status === 200 || response.status === 404) { + return; + } + + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); +}); + /** * The build runs asynchronously — deploy answers 202 and the worker reaches * `active` or `failed` later — so `push` polls `get` until `build_state` leaves diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 7e01fc5bfb..a69f6de65c 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -129,6 +129,19 @@ export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkE } } +/** + * The named worker is not deployed. `status`/`delete` share this verbatim: the + * question "does this exist?" is asked of the API, never of a local directory. + */ +export class WorkerNotDeployedError extends Data.TaggedError("WorkerNotDeployedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** * Workers are in private alpha: the routes answer 404 for a project that is not * enrolled, which is indistinguishable from an unknown worker at the transport @@ -179,3 +192,34 @@ export class WorkersApiUnexpectedStatusError extends Data.TaggedError( return statusCodeActionability(this.status); } } + +/** The user answered the `delete` confirmation with something other than the name. */ +export class WorkerDeleteNotConfirmedError extends Data.TaggedError( + "WorkerDeleteNotConfirmedError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} + +/** + * `delete` could not ask for confirmation and was not told to skip it. + * + * There is nowhere to read a typed answer from without an interactive terminal, + * and the alternative to refusing is deleting on the strength of the command + * line alone — so a redirected stdout or a CI runner has to pass `--yes` + * (or `SUPABASE_YES`) to say that out loud. + */ +export class WorkerDeleteConfirmationRequiredError extends Data.TaggedError( + "WorkerDeleteConfirmationRequiredError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 054add765b..a9d637992b 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -11,7 +11,8 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; -import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { LegacyOutputFlag, LegacyYesFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; @@ -227,8 +228,16 @@ export interface WorkersSetupOptions { readonly promptTextResponses?: ReadonlyArray; readonly promptSelectResponses?: ReadonlyArray; readonly routes?: WorkersHttpRoutes; - /** The Go `-o`/`--output` flag, which every command family here honours. */ - readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + /** + * The `-o`/`--output` flag, with every value the global flag accepts — + * including `table` and `csv`, which these commands are meant to ignore and + * render text for. + */ + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; + /** The root `--yes`, read by `delete` through `legacyResolveYes`. */ + readonly yes?: boolean; + /** Raw argv, which `legacyResolveYes` scans for an explicit `--yes=false`. */ + readonly cliArgs?: ReadonlyArray; } /** @@ -286,6 +295,8 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { LegacyOutputFlag, options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), ), + Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), BunServices.layer, ), }; From 17a9058f3e956f1064eba88fa61e1809e4d79eaa Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:00:07 -0300 Subject: [PATCH 02/50] feat(config): add the [workers] section to the project config schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers record their runtime, instance size, instance count and source directory in `supabase/config.toml`, keyed `[workers.]`, next to the `[functions.]` entries already in the same file. The section is a plain `Schema.Record`: one sub-table per worker and no project-wide scalar sitting beside them, so there is nothing for the index signature to collide with. Worker names are DNS labels, matching what the Management API validates its `:name` path parameter against, since they end up in hostnames. `instances` is bounded as a non-negative integer to match `spec.instances` in the API's own input schema — a value that gets past the schema is dropped rather than sent, so leaving it unbounded silently deploys a different count than the config asked for. The section flows into the published `schema.json`, so editors offer completion for it in `config.toml`. That asset is served at PROJECT_CONFIG_SCHEMA_URL and stamped into every `config.toml` that `saveProjectConfig` writes, so a stale copy makes editors flag valid config as invalid. Most of that asset's diff is not workers. `toJsonSchemaDocument` changed how it emits unions between effect beta.107 and rc.108, and the bump landed on develop without the asset being regenerated, so inline `Infinity`/`NaN` unions collapse into `$defs` refs throughout — regenerating on the parent commit alone produces ~549 of those deletions. Nothing wires the generator into a script or CI job, so the drift is silent. Worth fixing separately. --- apps/docs/public/cli/config.schema.json | 856 ++++++++--------------- packages/config/src/base.ts | 3 + packages/config/src/io.unit.test.ts | 28 + packages/config/src/workers.ts | 89 +++ packages/config/src/workers.unit.test.ts | 83 +++ 5 files changed, 506 insertions(+), 553 deletions(-) create mode 100644 packages/config/src/workers.ts create mode 100644 packages/config/src/workers.unit.test.ts diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 5a7d25463b..07538197f1 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -20,12 +20,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -44,12 +39,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -82,12 +72,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -103,12 +88,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -148,12 +128,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -176,12 +151,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -206,25 +176,12 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, "password_requirements": { - "type": "string", - "enum": [ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols" - ], - "description": "Password character requirements.", - "default": "" + "$ref": "#/$defs/Union_1" }, "publishable_key": { "type": "string", @@ -291,12 +248,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -306,12 +258,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -326,12 +273,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -382,12 +324,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -397,12 +334,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -436,12 +368,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -451,12 +378,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -466,12 +388,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -509,12 +426,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -540,12 +452,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -583,12 +490,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -607,8 +509,64 @@ }, "additionalProperties": false }, + "workers": { + "anyOf": [ + { + "$ref": "#/$defs/Objects_27" + }, + { + "type": "null" + } + ] + }, "experimental": { - "$ref": "#/$defs/Objects_27" + "type": "object", + "properties": { + "orioledb_version": { + "type": "string", + "description": "Postgres storage engine version for OrioleDB." + }, + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [ + ".s3-.amazonaws.com", + "env(S3_HOST)" + ] + }, + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": [ + "us-east-1", + "env(S3_REGION)" + ] + }, + "s3_access_key": { + "type": "string", + "description": "S3 access key.", + "examples": [ + "env(S3_ACCESS_KEY)" + ] + }, + "s3_secret_key": { + "type": "string", + "description": "S3 secret key.", + "examples": [ + "env(S3_SECRET_KEY)" + ] + }, + "webhooks": { + "$ref": "#/$defs/Objects_28" + }, + "pgdelta": { + "$ref": "#/$defs/Objects_29" + }, + "inspect": { + "$ref": "#/$defs/Objects_30" + } + }, + "additionalProperties": false }, "remotes": { "anyOf": [ @@ -638,12 +596,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -662,12 +615,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -700,12 +648,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -721,12 +664,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -766,12 +704,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -794,12 +727,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -824,25 +752,12 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, "password_requirements": { - "type": "string", - "enum": [ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols" - ], - "description": "Password character requirements.", - "default": "" + "$ref": "#/$defs/Union_1" }, "publishable_key": { "type": "string", @@ -909,12 +824,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -924,12 +834,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -944,12 +849,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1000,12 +900,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1015,12 +910,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1054,12 +944,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1069,12 +954,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1084,12 +964,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1127,12 +1002,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1158,12 +1028,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1201,12 +1066,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1225,8 +1085,64 @@ }, "additionalProperties": false }, + "workers": { + "anyOf": [ + { + "$ref": "#/$defs/Objects_27" + }, + { + "type": "null" + } + ] + }, "experimental": { - "$ref": "#/$defs/Objects_27" + "type": "object", + "properties": { + "orioledb_version": { + "type": "string", + "description": "Postgres storage engine version for OrioleDB." + }, + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [ + ".s3-.amazonaws.com", + "env(S3_HOST)" + ] + }, + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": [ + "us-east-1", + "env(S3_REGION)" + ] + }, + "s3_access_key": { + "type": "string", + "description": "S3 access key.", + "examples": [ + "env(S3_ACCESS_KEY)" + ] + }, + "s3_secret_key": { + "type": "string", + "description": "S3 secret key.", + "examples": [ + "env(S3_SECRET_KEY)" + ] + }, + "webhooks": { + "$ref": "#/$defs/Objects_28" + }, + "pgdelta": { + "$ref": "#/$defs/Objects_29" + }, + "inspect": { + "$ref": "#/$defs/Objects_30" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -1247,6 +1163,14 @@ }, "additionalProperties": false, "$defs": { + "Union_": { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, "Arrays_": { "type": "array", "items": { @@ -1299,6 +1223,17 @@ "https://127.0.0.1:3000" ] }, + "Union_1": { + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" + ], + "description": "Password character requirements.", + "default": "" + }, "Objects_1": { "type": "object", "properties": { @@ -1308,12 +1243,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1323,12 +1253,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1338,12 +1263,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1353,12 +1273,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1368,12 +1283,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1383,12 +1293,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1398,12 +1303,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1591,12 +1491,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1635,12 +1530,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1696,12 +1586,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1711,12 +1596,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1738,12 +1618,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -2962,12 +2837,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -2986,12 +2856,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3001,12 +2866,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -3055,6 +2915,16 @@ }, "additionalProperties": false }, + "Union_2": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Union_" + } + ] + }, "Objects_16": { "type": "object", "properties": { @@ -3068,94 +2938,22 @@ "type": "string" }, "max_connections": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_locks_per_transaction": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_parallel_maintenance_workers": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_parallel_workers": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_parallel_workers_per_gather": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_replication_slots": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_slot_wal_keep_size": { "type": "string" @@ -3170,34 +2968,10 @@ "type": "string" }, "max_wal_senders": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_worker_processes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "session_replication_role": { "type": "string", @@ -3389,12 +3163,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -3449,12 +3218,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3464,12 +3228,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3479,12 +3238,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3534,12 +3288,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3549,12 +3298,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3592,109 +3336,115 @@ }, "Objects_27": { "type": "object", - "properties": { - "orioledb_version": { - "type": "string", - "description": "Postgres storage engine version for OrioleDB." - }, - "s3_host": { - "type": "string", - "description": "S3 bucket URL.", - "examples": [ - ".s3-.amazonaws.com", - "env(S3_HOST)" - ] - }, - "s3_region": { - "type": "string", - "description": "S3 bucket region.", - "examples": [ - "us-east-1", - "env(S3_REGION)" - ] - }, - "s3_access_key": { - "type": "string", - "description": "S3 access key.", - "examples": [ - "env(S3_ACCESS_KEY)" - ] - }, - "s3_secret_key": { - "type": "string", - "description": "S3 secret key.", - "examples": [ - "env(S3_SECRET_KEY)" - ] - }, - "webhooks": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable experimental webhooks.", - "default": false - } - }, - "additionalProperties": false - }, - "pgdelta": { + "patternProperties": { + "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", - "default": false + "runtime": { + "type": "string", + "description": "Runtime the worker is built on: `dockerfile` to build the directory's own\nDockerfile, or one of the catalog runtimes (`node`, `deno`). Guessed from\nmarker files when unset.", + "examples": [ + "node" + ] }, - "declarative_schema_path": { + "size": { "type": "string", - "description": "Directory under supabase/ where declarative schema files are written.", + "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", "examples": [ - "./schemas" + "2gb" ] }, - "format_options": { + "instances": { + "type": "integer", + "allOf": [ + { + "minimum": 0, + "description": "Number of instances to run. Every deploy sends a complete spec, so a count\nrecorded here is what keeps a scaled worker scaled; `--instances` overrides\nit for one deploy. Defaults to 1.", + "examples": [ + 3 + ] + } + ] + }, + "source": { "type": "string", - "description": "JSON string passed through to pg-delta SQL formatting.", + "description": "Directory holding the worker's code, relative to the project root, when it\ndoes not live at `supabase/workers//`.", "examples": [ - "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + "packages/api" ] } }, "additionalProperties": false + } + }, + "description": "Worker-specific configuration keyed by worker name.", + "default": {} + }, + "Objects_28": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable experimental webhooks.", + "default": false + } + }, + "additionalProperties": false + }, + "Objects_29": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", + "default": false }, - "inspect": { - "type": "object", - "properties": { - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Inspection query." - }, - "name": { - "type": "string", - "description": "Inspection rule name." - }, - "pass": { - "type": "string", - "description": "Success message." - }, - "fail": { - "type": "string", - "description": "Failure message." - } - }, - "additionalProperties": false + "declarative_schema_path": { + "type": "string", + "description": "Directory under supabase/ where declarative schema files are written.", + "examples": [ + "./schemas" + ] + }, + "format_options": { + "type": "string", + "description": "JSON string passed through to pg-delta SQL formatting.", + "examples": [ + "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + ] + } + }, + "additionalProperties": false + }, + "Objects_30": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Inspection query." }, - "description": "Inspection rules.", - "default": [] - } + "name": { + "type": "string", + "description": "Inspection rule name." + }, + "pass": { + "type": "string", + "description": "Success message." + }, + "fail": { + "type": "string", + "description": "Failure message." + } + }, + "additionalProperties": false }, - "additionalProperties": false + "description": "Inspection rules.", + "default": [] } }, "additionalProperties": false diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index d84ba7c2c4..b4504d92f6 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -10,6 +10,7 @@ import { inbucket } from "./inbucket.ts"; import { realtime } from "./realtime.ts"; import { storage } from "./storage.ts"; import { studio } from "./studio.ts"; +import { workers } from "./workers.ts"; const projectId = Schema.optionalKey( Schema.String.annotate({ @@ -37,6 +38,7 @@ const baseProjectConfigFields = { realtime, storage, studio, + workers, experimental, }; @@ -52,6 +54,7 @@ const remoteProjectConfig = Schema.Struct({ realtime, storage, studio, + workers, experimental, }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 0159b251d4..248c29679f 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1246,6 +1246,34 @@ project_id = "dupref" } }); + test("loads a [remotes.*.workers] section alongside the project's own", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[workers.api] +runtime = "node" + +[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.workers.api] +runtime = "deno" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.workers).toEqual({ api: { runtime: "node" } }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", async () => { const cwd = makeTempProject(); diff --git a/packages/config/src/workers.ts b/packages/config/src/workers.ts new file mode 100644 index 0000000000..5932416494 --- /dev/null +++ b/packages/config/src/workers.ts @@ -0,0 +1,89 @@ +import dedent from "dedent"; +import { Effect, Schema } from "effect"; + +const tags = ["workers"]; + +const links = [ + { + name: "`supabase workers` CLI subcommands", + link: "https://supabase.com/docs/reference/cli/supabase-workers", + }, +]; + +/** + * Worker names end up in hostnames, so they are DNS labels — the same pattern + * the Management API validates `:name` against + * (`v2/projects/{ref}/workers/{name}`). + */ +const workerName = Schema.String.check(Schema.isPattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)); + +const worker = Schema.Struct({ + runtime: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Runtime the worker is built on: \`dockerfile\` to build the directory's own + Dockerfile, or one of the catalog runtimes (\`node\`, \`deno\`). Guessed from + marker files when unset. + `, + examples: ["node"], + tags, + links, + }), + ), + size: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Instance size, denominated by memory. Each size implies its own vCPU count, + so it is the one dial rather than two. + `, + examples: ["2gb"], + 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 + // unbounded deploys a different count than the config asked for. + Schema.Number.check( + Schema.isInt().annotate({ expected: "a whole number of instances" }), + Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "zero or more instances" }), + ).annotate({ + description: dedent` + Number of instances to run. Every deploy sends a complete spec, so a count + recorded here is what keeps a scaled worker scaled; \`--instances\` overrides + it for one deploy. Defaults to 1. + `, + examples: [3], + tags, + links, + }), + ), + source: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Directory holding the worker's code, relative to the project root, when it + does not live at \`supabase/workers//\`. + `, + examples: ["packages/api"], + tags, + links, + }), + ), +}); + +/** + * `[workers]` — one `[workers.]` table per worker, mirroring the + * `[functions.]` convention in the same file. + * + * Workers live at `supabase/workers//`; one whose code lives somewhere + * else entirely uses its own `source`, which is anchored to the project root and + * so can leave `supabase/`. + */ +export const workers = Schema.Record(workerName, worker) + .annotate({ + default: {}, + description: "Worker-specific configuration keyed by worker name.", + tags, + }) + .pipe(Schema.withDecodingDefault(Effect.succeed({}))); diff --git a/packages/config/src/workers.unit.test.ts b/packages/config/src/workers.unit.test.ts new file mode 100644 index 0000000000..2a6e0c7b33 --- /dev/null +++ b/packages/config/src/workers.unit.test.ts @@ -0,0 +1,83 @@ +import { Schema } from "effect"; +import { describe, expect, test } from "vitest"; +import { workers } from "./workers.ts"; + +const decode = Schema.decodeUnknownSync(workers); + +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" } }); + }); + + test("defaults to an empty section when the key is absent", () => { + expect(Schema.decodeUnknownSync(Schema.Struct({ workers }))({})).toEqual({ workers: {} }); + }); + + // Keys outside the DNS-label pattern fall outside the record's index + // signature and are dropped, the same way `[functions.]` treats a slug + // its own pattern does not match. `supabase workers new` validates the name + // up front so the CLI never writes one that would vanish here. + test("drops worker names that are not DNS labels", () => { + expect(decode({ Not_A_Label: {}, api: { runtime: "node" } })).toEqual({ + api: { runtime: "node" }, + }); + }); + + // Every dial is optional: a worker scaffolded by `supabase workers new` records + // only what it prompted for, and `push` resolves the rest from its own defaults. + test("decodes a worker table with no dials set", () => { + expect(decode({ api: {} })).toEqual({ api: {} }); + }); + + test("rejects a non-numeric instance count", () => { + expect(() => decode({ api: { instances: "three" } })).toThrow(); + }); + + // `spec.instances` is an integer in the Management API's input schema, and a + // value that slips through here is dropped downstream and silently rescales + // the worker to 1 rather than failing. Named at load time instead. + test.each([ + ["a fraction", 1.5], + ["a negative count", -1], + ])("rejects %s as an instance count", (_label, instances) => { + expect(() => decode({ api: { instances } })).toThrow(); + }); + + test("accepts zero instances", () => { + expect(decode({ api: { instances: 0 } })).toEqual({ api: { instances: 0 } }); + }); + + test("rejects a bare value where a worker table belongs", () => { + expect(() => decode({ api: "node" })).toThrow(); + }); + + // The published asset at `PROJECT_CONFIG_SCHEMA_URL` is what editors read, so + // the worker dials have to stay described and completable — and a worker value + // has to be a plain table, or an editor would accept a bare scalar the CLI + // refuses to load. + test("includes worker properties in the generated JSON schema", () => { + const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); + const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); + const workerSchema = objectSchema?.patternProperties?.[workerNamePattern]; + + expect(workerSchema?.properties?.runtime).toBeDefined(); + expect(workerSchema?.properties?.size).toBeDefined(); + expect(workerSchema?.properties?.instances).toBeDefined(); + expect(workerSchema?.properties?.source).toBeDefined(); + }); + + // An integer bound the published schema carries, so an editor flags `1.5` + // before the CLI ever reads it. + test("bounds instances as a non-negative integer in the generated JSON schema", () => { + const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); + const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); + const workerSchema = objectSchema?.patternProperties?.[workerNamePattern]; + + expect(workerSchema?.properties?.instances?.type).toBe("integer"); + expect(JSON.stringify(workerSchema?.properties?.instances)).toContain('"minimum":0'); + }); +}); From 0b1b9b3376a22e7334820c53ab6f20d4666a0d40 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:00:45 -0300 Subject: [PATCH 03/50] feat(cli): add supabase workers new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds `supabase/workers//` from a runtime's starter files and records the choice in `config.toml`. Entirely local disk — nothing is deployed and no network is involved, which is why it lands before the API seam. The name is required rather than generated: it is both the hostname and the directory, so a name nobody chose gets renamed immediately. The runtime and instance size are resolved before anything is written, so cancelling either prompt leaves nothing behind. Two closed sets, both narrow on purpose — the runtimes are the ones that have starters, and the sizes are the alpha envelope's two, each implying its own vCPU count. Nothing here deletes. An occupied destination is refused and says how to proceed; a worker already described in `config.toml` is refused rather than overwritten, since changing an existing worker is a `config.toml` edit and the file is the user's to edit. The config entry is planned before any file is written, so an edit already known to fail does not strand a scaffold. Three pieces in `shared/` carry the command, and they are the subtle ones: `worker-paths.ts` resolves the project layout: `supabase/workers//`, mirroring `supabase/functions//`, with `[workers.] source` moving one worker's code anywhere in the project. `confineWorkerPath` answers containment on the filesystem's terms rather than lexically — it canonicalizes the longest existing prefix of the target (`realPath` fails outright on a path that is not there yet) and the project root with it, so a project living under a symlink still compares like for like, and a `source` reaching outside the project through an in-project symlink is refused. `supabase/` itself, the CLI's own files in it, and the reserved subdirectories (`functions`, `migrations` and `.temp` among them, compared case-insensitively because default macOS and Windows filesystems are) are refused too. Both `--source` and the `source` recorded in `config.toml` go through it, because that directory is what `push` packages and uploads. Backslashes are read as separators wherever a persisted value was written and persisted paths are normalized to forward slashes, so a Windows-authored `config.toml` names the same directory elsewhere. `toml-section.ts` appends a `[workers.]` block rather than round-tripping the file. `config.toml` belongs to the whole CLI — users hand-edit, comment and commit it — and reserialising preserves the data while discarding every comment and normalising the formatting they chose. Writes are append-only and whether an entry already exists is answered by the decoded config rather than by matching text, which is the one question a regex over the file cannot answer reliably for a dotted or inline entry. `worker-stacks.ts` holds the starter files as ordinary files under `shared/workers/stacks//`, authored in the language they are written in. A shipped binary has no `stacks/` directory to read, so the directory is expanded through a Bun macro: it runs while the module is transpiled and its return value is inlined as a literal, which means the content is carried with nothing to pass at a build site and no directory to find at runtime. Bun expands macros in the runtime transpiler too, so running from source behaves the same; Vitest does not implement them and degrades to calling the function against the source tree, which is why the path comes from `import.meta.url` rather than Bun's `import.meta.dir`. Discovery stays directory-driven — a new runtime is a new directory plus its `WORKER_RUNTIMES` entry — and a completeness check inside the macro fails the build rather than the binary when the two drift. Nothing imports the starters, which is what keeps them out of the type program: a `deno` starter is not valid under this workspace's Bun types, and `tsconfig.json` excludes the directory. This also brings the command family's shell wiring, which is where the conventions here differ from a command tree's usual shape: - The project directory is `LegacyCliConfig.workdir`, so `--workdir` and `SUPABASE_WORKDIR` select the project exactly as they do for every sibling command, rather than an ancestor walk of the process's own directory. `--source` resolves against the invocation directory instead, matching what a shell prompt implies. - Output goes through `output.raw` as plain text with no `intro`/`outro` framing, and tables through `renderGlamourTable`, so `workers` reads like `functions` and `projects` rather than like a second CLI. - `-o`/`--output` is honoured (`workers.output.ts`), since ignoring a global flag would print human text to a stdout the user asked to be machine-readable. `-o env` is refused up front for the whole family: `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend into slices, and every workers payload has structure a flat `KEY=value` list cannot hold. Prompting is suppressed under a machine format for the same reason — Clack writes its UI to stdout with no stream override and `-o` leaves `output.format` as `text`, so an interactive `workers new api -o json` would otherwise render a selection UI in front of the payload. - Telemetry state is flushed in `Effect.ensuring`. Two shell-wide registries have to move in step with the command appearing, and both are enforced by tests rather than convention: `LEGACY_DOCS_TAGS`, without which the generated CLI reference refuses to build, and `VALUE_CONSUMING_LONG_FLAGS`, without which the telemetry argv scan treats `--runtime`'s value as a flag and can fabricate one that was never passed. --- apps/cli/package.json | 3 +- apps/cli/src/legacy/cli/root.ts | 2 + .../commands/workers/new/SIDE_EFFECTS.md | 64 +++ .../commands/workers/new/new.command.ts | 74 ++++ .../commands/workers/new/new.handler.ts | 275 +++++++++++++ .../workers/new/new.integration.test.ts | 380 ++++++++++++++++++ .../commands/workers/workers.command.ts | 10 + .../legacy/commands/workers/workers.errors.ts | 25 ++ .../legacy/commands/workers/workers.format.ts | 32 ++ .../legacy/commands/workers/workers.output.ts | 78 ++++ .../legacy/commands/workers/workers.shared.ts | 126 ++++++ .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 2 + apps/cli/src/shared/workers/stacks/README.md | 14 + .../src/shared/workers/stacks/deno/main.ts | 10 + .../workers/stacks/dockerfile/Dockerfile | 3 + .../workers/stacks/dockerfile/server.mjs | 15 + .../src/shared/workers/stacks/node/index.mjs | 10 + apps/cli/src/shared/workers/toml-section.ts | 88 ++++ .../shared/workers/toml-section.unit.test.ts | 78 ++++ apps/cli/src/shared/workers/worker-config.ts | 140 +++++++ .../shared/workers/worker-config.unit.test.ts | 177 ++++++++ apps/cli/src/shared/workers/worker-paths.ts | 225 +++++++++++ .../shared/workers/worker-paths.unit.test.ts | 200 +++++++++ .../cli/src/shared/workers/worker-runtimes.ts | 115 ++++++ .../workers/worker-runtimes.unit.test.ts | 62 +++ .../src/shared/workers/worker-stacks.macro.ts | 81 ++++ apps/cli/src/shared/workers/worker-stacks.ts | 16 + apps/cli/src/shared/workers/workers.errors.ts | 45 +++ apps/cli/tests/helpers/legacy-workers.ts | 268 ++++++++++++ apps/cli/tsconfig.json | 2 +- 31 files changed, 2619 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/new/new.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/new/new.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/new/new.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.errors.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.format.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.output.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.shared.ts create mode 100644 apps/cli/src/shared/workers/stacks/README.md create mode 100644 apps/cli/src/shared/workers/stacks/deno/main.ts create mode 100644 apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile create mode 100644 apps/cli/src/shared/workers/stacks/dockerfile/server.mjs create mode 100644 apps/cli/src/shared/workers/stacks/node/index.mjs create mode 100644 apps/cli/src/shared/workers/toml-section.ts create mode 100644 apps/cli/src/shared/workers/toml-section.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-config.ts create mode 100644 apps/cli/src/shared/workers/worker-config.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-paths.ts create mode 100644 apps/cli/src/shared/workers/worker-paths.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-runtimes.ts create mode 100644 apps/cli/src/shared/workers/worker-runtimes.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-stacks.macro.ts create mode 100644 apps/cli/src/shared/workers/worker-stacks.ts create mode 100644 apps/cli/src/shared/workers/workers.errors.ts create mode 100644 apps/cli/tests/helpers/legacy-workers.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 96706fd08e..4395aea916 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,7 +121,8 @@ "ignore": [ "scripts/*.ts", "tests/**/*.ts", - "src/shared/telemetry/event-catalog.ts" + "src/shared/telemetry/event-catalog.ts", + "src/shared/workers/stacks/**" ], "ignoreBinaries": [ "nx", diff --git a/apps/cli/src/legacy/cli/root.ts b/apps/cli/src/legacy/cli/root.ts index db904dca84..6883aee159 100644 --- a/apps/cli/src/legacy/cli/root.ts +++ b/apps/cli/src/legacy/cli/root.ts @@ -35,6 +35,7 @@ import { legacyStorageCommand } from "../commands/storage/storage.command.ts"; import { legacyTestCommand } from "../commands/test/test.command.ts"; import { legacyTelemetryCommand } from "../commands/telemetry/telemetry.command.ts"; import { legacyUnlinkCommand } from "../commands/unlink/unlink.command.ts"; +import { legacyWorkersCommand } from "../commands/workers/workers.command.ts"; import { legacyVanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { OutputFormatFlag } from "../../shared/cli/global-flags.ts"; import { outputLayerFor } from "../../shared/output/output.layer.ts"; @@ -70,6 +71,7 @@ export const legacyRoot = Command.make("supabase").pipe( legacyDomainsCommand, legacyEncryptionCommand, legacyFunctionsCommand, + legacyWorkersCommand, legacyGenCommand, legacyInitCommand, legacyInspectCommand, diff --git a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md new file mode 100644 index 0000000000..53033d7495 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md @@ -0,0 +1,64 @@ +# `supabase workers new [name]` + +> **Local-disk only.** Nothing is deployed and no Management API route is +> called; `workers push` is what talks to the platform. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to refuse a worker that is already recorded | +| `/` | dir | always, to refuse a destination that is not empty | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — appends/updates `[workers.]` in place, preserving comments | +| `/supabase/workers//*` | varies | always, unless `--source` names another directory | +| `//*` | varies | when `--source` is given | +| `/telemetry.json` | JSON | always — flushed on success and on failure | + +Nothing at the destination is ever removed or overwritten: a destination that +exists and is not empty is refused, and clearing it is left to the user. +`--source` is refused when it resolves to the project root, `supabase/`, +`supabase/functions/`, `supabase/migrations/`, or outside the project. Symlinks +are resolved first, so a path inside the project that points outside it is +refused too. A relative `--source` is resolved against the directory the command +was run in; a `source` recorded in `config.toml` is resolved against the project +root. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ---------------------- | +| — | — | — | — | — | + +## Exit Codes + +| Code | Condition | +| ---- | --------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid or reserved worker name, unknown runtime/size, bad `--source` | +| `1` | destination exists and is not empty | +| `1` | `config.toml` records a worker in a form that cannot be edited safely | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/new/new.command.ts b/apps/cli/src/legacy/commands/workers/new/new.command.ts new file mode 100644 index 0000000000..e82bf6550c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.command.ts @@ -0,0 +1,74 @@ +import { Layer } from "effect"; +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 { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersNew } from "./new.handler.ts"; + +const config = { + name: Argument.string("name").pipe( + Argument.withDescription("Worker name. Doubles as its directory, and its hostname."), + ), + runtime: Flag.choice("runtime", WORKER_RUNTIMES).pipe( + Flag.withDescription( + "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted.", + ), + Flag.optional, + ), + size: Flag.choice("size", WORKER_SIZES).pipe( + Flag.withDescription( + "Instance size to record in supabase/config.toml. Each size implies its own vCPU count, so there is no separate --cpu. 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.", + ), + Flag.optional, + ), +} as const; + +export type LegacyWorkersNewFlags = CliCommand.Command.Config.Infer; + +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +/** Local-disk only: no Management API, so no platform stack is built. */ +const legacyWorkersNewRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["workers", "new"]), +); + +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.", + ), + Command.withShortDescription("Scaffold a worker locally"), + Command.withExamples([ + { + command: "supabase workers new", + description: "Scaffold a worker, prompting for runtime and size", + }, + { + command: "supabase workers new api --runtime node", + description: "Scaffold supabase/workers/api on the node runtime", + }, + { + command: "supabase workers new api --source packages/api", + description: "Scaffold the worker outside the workers directory", + }, + ]), + Command.withHandler((flags) => + legacyWorkersNew(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyWorkersNewRuntimeLayer), +); diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts new file mode 100644 index 0000000000..4be159261c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -0,0 +1,275 @@ +import { join, relative, sep } from "node:path"; +import { Effect, FileSystem, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + commitWorkerEntry, + planWorkerEntry, + WorkerAlreadyConfiguredError, +} from "../../../../shared/workers/worker-config.ts"; +import { + confineWorkerPath, + displayPath, + resolveWorkerSource, +} from "../../../../shared/workers/worker-paths.ts"; +import { + DEFAULT_WORKER_RUNTIME, + DEFAULT_WORKER_SIZE, + parseWorkerRuntime, + parseWorkerSize, + validateWorkerNameMessage, + vcpuForSize, + WORKER_RUNTIME_DESCRIPTIONS, + WORKER_RUNTIMES, + WORKER_SIZES, + type WorkerRuntime, + type WorkerSize, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { WORKER_STACKS } from "../../../../shared/workers/worker-stacks.ts"; +import { + InvalidWorkerNameError, + WorkerDirectoryExistsError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersNewFlags } from "./new.command.ts"; + +/** + * `supabase workers new [name]` — scaffold `supabase///` from the + * chosen runtime's starter files and record the choice in `config.toml`. + * Nothing is deployed; this is entirely local-disk work. + * + * The runtime and size are resolved *before* anything is written, so a + * cancelled prompt leaves nothing behind for this worker at all — including the + * name, which is only generated once both questions have been answered. + */ + +/** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ +function defaultFirst(values: ReadonlyArray, defaultValue: T): Array { + return [defaultValue, ...values.filter((value) => value !== defaultValue)]; +} + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + // `--runtime` is a choice flag, so the parser has already rejected anything + // outside the catalog by the time it gets here. + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive && !options.machineOutput) { + const selected = yield* output.promptSelect( + "Which runtime should this worker use?", + defaultFirst([...WORKER_RUNTIMES], DEFAULT_WORKER_RUNTIME).map((runtime) => ({ + value: runtime, + label: runtime, + hint: WORKER_RUNTIME_DESCRIPTIONS[runtime], + })), + ); + return parseWorkerRuntime(selected) ?? DEFAULT_WORKER_RUNTIME; + } + + return DEFAULT_WORKER_RUNTIME; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive && !options.machineOutput) { + const selected = yield* output.promptSelect( + "Which instance size should this worker use?", + defaultFirst([...WORKER_SIZES], DEFAULT_WORKER_SIZE).map((size) => ({ + value: size, + label: `${size} (${vcpuForSize(size)} vCPU)`, + })), + ); + return parseWorkerSize(selected) ?? DEFAULT_WORKER_SIZE; + } + + return DEFAULT_WORKER_SIZE; +}); + +/** + * 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 + * than by a bare `EEXIST` from `makeDirectory`. + */ +const destinationIsFree = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(target).pipe(Effect.option); + if (info._tag === "None") { + return true; + } + if (info.value.type !== "Directory") { + return false; + } + const entries = yield* fs.readDirectory(target).pipe(Effect.orElseSucceed(() => [])); + return entries.length === 0; +}); + +export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( + flags: LegacyWorkersNewFlags, +) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const telemetryState = yield* LegacyTelemetryState; + const runtimeInfo = yield* RuntimeInfo; + + // The telemetry state file is written on every invocation, success or failure. + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const name = flags.name; + const invalid = validateWorkerNameMessage(name); + if (invalid !== undefined) { + return yield* Effect.fail( + new InvalidWorkerNameError({ + detail: `"${name}" is not a valid worker name. ${invalid}`, + suggestion: "Worker names become hostnames, so they must be DNS labels.", + }), + ); + } + + // Refused before anything is asked or written. `new` creates a worker; + // changing one that already exists is a `config.toml` edit, and the file is + // the user's. Checking here rather than only in `planWorkerEntry` means the + // prompts never run for a name that was going to be refused anyway. + if (project.section.workers[name] !== undefined) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${name}" is already configured in ${project.configPath}.`, + suggestion: `Edit [workers.${name}] in ${project.configPath} yourself, or pick a different worker name.`, + }), + ); + } + + // Resolved before anything is written, so cancelling either prompt leaves + // nothing behind — the name included. + // `-o` leaves `output.format` as `text`, and `promptSelect` goes through + // Clack, which writes its terminal UI to stdout with no stream override — so + // a prompt would land in front of the payload just as the notices did. With a + // machine format requested there is nowhere to ask, so the defaults stand. + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const runtime = yield* resolveRuntime({ explicit: flags.runtime, machineOutput }); + const size = yield* resolveSize({ explicit: flags.size, machineOutput }); + + // Validated before anything is written: this is the directory the starter + // files land in, so a value naming the project root, `supabase/`, or + // anywhere outside the project must never get as far as the write below. + // + // `--source` resolves against the directory the user typed it in, the way a + // shell would read it: `--source generated` from `apps/web` means + // `apps/web/generated`. + const destination = Option.isSome(flags.source) + ? yield* resolveWorkerSource({ + projectRoot: project.projectRoot, + cwd: runtimeInfo.cwd, + raw: flags.source.value, + }) + : yield* confineWorkerPath({ + projectRoot: project.projectRoot, + target: join(project.workersDir, name), + subject: `The default directory for "${name}"`, + suggestion: "Point [workers] root at a directory inside supabase/.", + }); + + // Nothing here replaces what is already on disk. Scaffolding over an + // existing directory would have to delete it first, and a command whose job + // is to create a worker has no business removing whatever happens to share + // its name — so it says what is in the way and leaves the choice to the user. + if (!(yield* destinationIsFree(destination))) { + const shown = displayPath(project.projectRoot, destination); + return yield* Effect.fail( + new WorkerDirectoryExistsError({ + detail: `${shown} already exists and is not empty.`, + suggestion: `Remove ${shown} yourself if you meant to replace it, or pick a different worker name.`, + }), + ); + } + + // Recorded as forward slashes whatever platform wrote it. `config.toml` is + // committed and shared, and `path.relative` yields `packages\api` on + // Windows — a backslash the POSIX resolvers on every other machine read as + // a literal character in a filename rather than a separator. + const source = Option.isSome(flags.source) + ? relative(project.projectRoot, destination).split(sep).join("/") + : undefined; + + // Planned before anything is written. Every way this can fail is knowable + // from the current config.toml, so finding out afterwards would leave a + // scaffold on disk that nothing records. + const configWrite = yield* planWorkerEntry({ + configPath: project.configPath, + name, + existingWorkers: project.section.workers, + patch: { + runtime, + size, + ...(source === undefined ? {} : { source }), + }, + }); + + // Everything below this line changes the user's disk, and nothing below it + // can fail for a reason the plan above could have caught. + yield* fs.makeDirectory(destination, { recursive: true }); + + for (const [filename, contents] of Object.entries(WORKER_STACKS[runtime])) { + yield* fs.writeFileString(join(destination, filename), contents); + } + + yield* commitWorkerEntry(configWrite); + + const sourceDisplay = displayPath(project.projectRoot, destination); + + const payload = { + worker_name: name, + runtime, + size, + vcpu: vcpuForSize(size), + source: sourceDisplay, + config_path: project.configPath, + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + // Leads with a declarative line the way every other scaffold does + // (`functions new`: "Created new Function at supabase/functions/hello"), + // then the details. Guidance goes in a closing sentence rather than a + // pseudo-row, since no other command puts a next step inside its output + // table. + yield* output.raw(`Created new Worker at ${sourceDisplay}\n`); + yield* output.raw( + legacyRenderWorkerDetails([ + ["Runtime", runtime], + ["Size", `${size} (${vcpuForSize(size)} vCPU)`], + ["Access", "public"], + ]), + ); + yield* output.raw(`Deploy it with supabase workers push ${name}.\n`); + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts new file mode 100644 index 0000000000..52a74e7e7a --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -0,0 +1,380 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { WorkerAlreadyConfiguredError } from "../../../../shared/workers/worker-config.ts"; +import { + InvalidWorkerNameError, + InvalidWorkerSourceError, + WorkerDirectoryExistsError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersNew } from "./new.handler.ts"; +import type { LegacyWorkersNewFlags } from "./new.command.ts"; + +const CONFIG_WITH_COMMENTS = `# hand-written, and it should stay that way +project_id = "demo" + +[functions.hello] +verify_jwt = false +`; + +function flags(overrides: Partial = {}): LegacyWorkersNewFlags { + return { + name: "api", + runtime: Option.none(), + size: Option.none(), + source: Option.none(), + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG_WITH_COMMENTS, + ...files, + }); + const configPath = join(created.dir, "supabase", "config.toml"); + return { + dir: created.dir, + config: () => readFileSync(configPath, "utf8"), + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +describe("legacy workers new", () => { + it.live("scaffolds the runtime's starter files and records the choice", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + 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`, + ); + + // Declarative line first, then the detail rows, then the next step — + // the shape `functions new` established. + expect(out.stdoutText).toContain("Created new Worker at supabase/workers/api"); + expect(out.stdoutText).toContain("Runtime"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("prompts for runtime and size when neither is given", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptSelectResponses: ["node", "4gb"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + expect(out.promptSelectCalls.map((call) => call.message)).toEqual([ + "Which runtime should this worker use?", + "Which instance size should this worker use?", + ]); + expect(repo.config()).toContain('runtime = "node"'); + expect(repo.config()).toContain('size = "4gb"'); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the defaults without prompting when not interactive", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, format: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toContain('runtime = "deno"'); + expect(repo.config()).toContain('size = "2gb"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A second `new` for the same name is refused rather than re-recorded. Changing + // a worker that exists is a `config.toml` edit, and the file is the user's. + it.live("refuses a name that config.toml already records", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("deno"), size: Option.some("4gb") }), + ); + const recorded = repo.config(); + + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // Refused before anything was asked, and the entry is byte-identical. + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toBe(recorded); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Refused whichever way the entry happens to be written — the decoded config + // is what answers "does this exist", so no TOML shape matters here. + it.live.each(['workers.api.runtime = "node"', "[workers.api]"])( + "refuses an entry recorded as %s", + (entry) => { + const config = `project_id = "demo"\n\n${entry}\n`; + const repo = project({ "supabase/config.toml": config }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(repo.config()).toBe(config); + // Nothing scaffolded either. + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }, + ); + + it.live("records a --source worker relative to the project root", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some("packages/api"), + }), + ); + + expect(existsSync(join(repo.dir, "packages", "api", "index.mjs"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toContain('source = "packages/api"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a --source outside the directories a worker may own", () => { + const repo = project({ "README.md": "keep me", "src/app.ts": "keep me too" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + for (const source of [".", "..", "supabase", "supabase/functions"]) { + const error = yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some(source), + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + } + + // Nothing was written: the resolver refused before any directory was created. + expect(existsSync(join(repo.dir, "README.md"))).toBe(true); + expect(existsSync(join(repo.dir, "src", "app.ts"))).toBe(true); + expect(repo.config()).toContain("project_id"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("scaffolds in a directory that has no Supabase project yet", () => { + const created = makeWorkersProject(); + const { layer } = setupLegacyWorkers({ workdir: created.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + 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`, + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(created.dir, { recursive: true, force: true }))), + ); + }); + + it.live("refuses a destination that already has something in it", () => { + const repo = project({ "supabase/workers/api/leftover.txt": "old" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "leftover.txt"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Scaffolding into an empty directory is fine — it is only a destination with + // contents that is refused. + it.live("scaffolds into a directory that exists but is empty", () => { + const repo = project(); + mkdirSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("tells the user how to proceed when the destination is occupied", () => { + const repo = project({ "supabase/workers/api/leftover.txt": "old" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + // No flag to suggest any more, so the advice has to be actionable on its own. + const suggestion = error instanceof WorkerDirectoryExistsError ? error.suggestion : ""; + expect(suggestion).toContain("Remove"); + expect(suggestion).not.toContain("--force"); + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rejects a name that could not become a hostname", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew(flags({ name: "My_Worker" })).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(existsSync(join(repo.dir, "supabase", "workers"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("keeps stdout parseable under -o json", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, goOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ runtime: Option.some("node") })); + + const payload: unknown = JSON.parse(out.stdoutText); + expect(payload).toMatchObject({ runtime: "node", size: "2gb" }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Why the config edit is planned before the starter files are written: this + // failure is knowable up front, and discovering it afterwards would leave a + // scaffold on disk that nothing records. + it.live("writes no scaffold at all when the config edit cannot be made", () => { + const repo = project({ + "supabase/config.toml": 'project_id = "demo"\n\nworkers.api.runtime = "node"\n', + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("deno") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // No directory, and config.toml exactly as it was. + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toBe('project_id = "demo"\n\nworkers.api.runtime = "node"\n'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A plain file used to read as an empty directory, which then failed with a + // bare EEXIST from `makeDirectory` instead of naming what was in the way. + it.live("refuses a plain file at the destination", () => { + const repo = project({ "supabase/workers/api": "not a directory" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + expect(readFileSync(join(repo.dir, "supabase", "workers", "api"), "utf8")).toBe( + "not a directory", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A relative `--source` is something typed at a shell prompt, so it means + // what it would mean to the shell: relative to where you are. + it.live("resolves a relative --source against the directory it was typed in", () => { + const repo = project({ "apps/web/.keep": "" }); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + cwd: join(repo.dir, "apps", "web"), + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some("generated"), + }), + ); + + expect(existsSync(join(repo.dir, "apps", "web", "generated", "index.mjs"))).toBe(true); + expect(existsSync(join(repo.dir, "generated"))).toBe(false); + // Persisted project-root-relative, with forward slashes on every platform. + expect(repo.config()).toContain('source = "apps/web/generated"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Clack writes its prompt UI to stdout with no stream override, and `-o json` + // leaves `output.format` as `text` — so a prompt lands in front of the payload + // exactly as the notices did. + it.live("does not prompt under -o json, so stdout stays parseable", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + // Answers are available, so a prompt would succeed and corrupt stdout + // rather than fail the test some other way. + promptSelectResponses: ["node", "4gb"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + const payload: unknown = JSON.parse(out.stdoutText); + // The defaults stand, because there was nowhere to ask. + expect(payload).toMatchObject({ runtime: "deno", size: "2gb" }); + expect(out.promptSelectCalls).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses --source pointed at the project config file", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some(join("supabase", "config.toml")), + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + // The config survived, which is the whole point. + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts new file mode 100644 index 0000000000..ac4555f3de --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -0,0 +1,10 @@ +import { Command } from "effect/unstable/cli"; +import { legacyWorkersNewCommand } from "./new/new.command.ts"; + +export const legacyWorkersCommand = Command.make("workers").pipe( + Command.withDescription( + "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", + ), + Command.withShortDescription("Manage Supabase Workers"), + Command.withSubcommands([legacyWorkersNewCommand]), +); diff --git a/apps/cli/src/legacy/commands/workers/workers.errors.ts b/apps/cli/src/legacy/commands/workers/workers.errors.ts new file mode 100644 index 0000000000..9d50b8a447 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.errors.ts @@ -0,0 +1,25 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** + * `--output env` cannot represent a payload containing a list. + * + * `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend + * into slices — a `workers` array would land as a single `WORKERS=""` line + * rather than one entry per worker. Refusing is the same call `functions list` + * makes for the same reason, rather than emitting output that silently omits + * the data. + */ +export class LegacyWorkersEnvNotSupportedError extends Data.TaggedError( + "LegacyWorkersEnvNotSupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/workers/workers.format.ts b/apps/cli/src/legacy/commands/workers/workers.format.ts new file mode 100644 index 0000000000..5b50fc8af4 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.format.ts @@ -0,0 +1,32 @@ +/** + * Text rendering for the workers commands. + * + * Two conventions this shell holds and `supabase workers` follows rather than + * inventing its own: results are written with `output.raw` as plain text, with + * no `intro`/`outro` framing, which no other handler here uses, and tabular + * output goes through `renderGlamourTable`, so `workers list` sits beside + * `functions list` and `projects list` looking like them. + */ + +/** + * `Label value` detail lines for a single worker. + * + * Vertical rather than a one-row `renderGlamourTable` because a worker's values + * include a URL and a source path: `branches get` gets away with laying its + * seven narrow columns out horizontally, and these would not fit. Labels are + * Title Case to match the other vertical key/value view this CLI renders, + * `supabase status` (`legacy-status-pretty.ts`), rather than inventing a third + * casing. + * + * Rows whose value is empty are dropped: several fields are optional strings in + * the API contract (`state_reason`, for one), so an empty one would otherwise + * render as a label, two spaces of padding and nothing else. + */ +export function legacyRenderWorkerDetails(rows: ReadonlyArray): string { + const present = rows.filter(([, value]) => value !== ""); + if (present.length === 0) { + return ""; + } + const width = Math.max(...present.map(([label]) => label.length)); + return `${present.map(([label, value]) => ` ${label.padEnd(width)} ${value}`).join("\n")}\n`; +} diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts new file mode 100644 index 0000000000..840b0a23d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -0,0 +1,78 @@ +import { Effect, Option } from "effect"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts"; +import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; + +/** + * Emits a command's payload in the format `-o`/`--output` asked for. + * + * `-o` is a global flag nearly every command family on this shell honours, so + * ignoring it would print human text to a stdout the user asked to be + * machine-readable. + * + * The struct-shaped encoders elsewhere reproduce a payload shape their command + * already shipped. `workers` has none to match, so it serialises through the + * generic encoders and shapes its payload as the command reads best. + * + * Returns whether it emitted anything, so the caller can skip its text + * rendering — `output.success` writes to stdout in text mode and would corrupt + * the payload otherwise. + */ +export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( + payload: Record, +) { + const output = yield* Output; + const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); + + if (goFormat === undefined || goFormat === "pretty") { + return false; + } + + if (goFormat === "env") { + // Unreachable when the command called `legacyRejectWorkersEnvOutput` first, + // which is where the refusal belongs; here as the backstop that stops a new + // command silently emitting TOML for `-o env`. + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + + if (goFormat === "json") { + yield* output.raw(encodeGoJson(payload)); + return true; + } + if (goFormat === "yaml") { + yield* output.raw(encodeYaml(payload)); + return true; + } + yield* output.raw(encodeToml(payload)); + return true; +}); + +/** + * Whether a machine-readable stdout was requested via `-o`. Callers that emit + * human lines *before* their payload need this: the `-o` branch runs at the end, + * by which point those lines would already be on stdout. + */ +export const legacyWorkersMachineOutputRequested = Effect.fnUntraced(function* () { + const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); + return goFormat !== undefined && goFormat !== "pretty"; +}); + +/** + * Refuse `-o env` before the command does anything. + * + * `env` is a flat `KEY=value` list and every workers payload has structure a + * flat list cannot hold — a collection, or a nested instance tally. So it is + * refused for the whole command family rather than per payload, and refused up + * front: discovering it at emit time means failing after the work is done, which + * for `push` is after the remote project has already changed. + */ +export const legacyRejectWorkersEnvOutput = Effect.fnUntraced(function* () { + if (Option.getOrUndefined(yield* LegacyOutputFlag) === "env") { + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts new file mode 100644 index 0000000000..871c17e23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -0,0 +1,126 @@ +import { join } from "node:path"; +import { loadProjectConfig } from "@supabase/config"; +import { Effect, FileSystem } from "effect"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { + readWorkersSection, + type WorkerEntry, + type WorkersSection, +} from "../../../shared/workers/worker-config.ts"; +import { workerDir, workersDir, workerSourceDir } from "../../../shared/workers/worker-paths.ts"; +import { validateWorkerNameMessage } from "../../../shared/workers/worker-runtimes.ts"; +import { InvalidWorkerNameError } from "../../../shared/workers/workers.errors.ts"; + +/** + * What every `supabase workers` command needs before it does anything: where + * the project is, what `[workers]` says, and which worker is being acted on. + * + * The project directory is `LegacyCliConfig.workdir` rather than an ancestor + * walk from the current directory. That is the resolved workdir every other + * legacy command acts on — `--workdir`/`SUPABASE_WORKDIR` when given, else the + * ancestor walk Go's own `getProjectRoot` performs — so `supabase workers` + * answers to the same flag as its siblings instead of inventing a second notion + * of "which project". + */ + +export interface LegacyWorkersProject { + readonly projectRoot: string; + readonly supabaseDir: string; + readonly configPath: string; + readonly section: WorkersSection; + /** `supabase/workers/`, where every worker lives unless it names a `source`. */ + readonly workersDir: string; +} + +export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { + const cliConfig = yield* LegacyCliConfig; + const projectRoot = cliConfig.workdir; + const supabaseDir = join(projectRoot, "supabase"); + + // `loadProjectConfig` returns null when the directory holds no project yet, + // which is what lets `workers new` scaffold into a bare one. + const loaded = yield* loadProjectConfig(projectRoot); + const section = readWorkersSection(loaded?.config.workers); + + return { + projectRoot, + supabaseDir, + configPath: loaded?.path ?? join(supabaseDir, "config.toml"), + section, + workersDir: workersDir(projectRoot), + } satisfies LegacyWorkersProject; +}); + +export interface LegacyResolvedWorker { + readonly name: string; + readonly entry: WorkerEntry | undefined; + /** The worker's default directory, `supabase/workers//`. */ + readonly defaultDir: string; + /** Where its code actually lives, honouring `[workers.] source`. */ + readonly sourceDir: string; +} + +/** + * Effectful because resolving `sourceDir` confines it to the project, and that + * verdict needs the filesystem: `source` comes from a committed `config.toml`, + * and a directory inside the project can symlink anywhere outside it. + */ +export const legacyDescribeWorker = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, + name: string, +) { + const entry = project.section.workers[name]; + const defaultDir = workerDir(project.projectRoot, name); + return { + name, + entry, + defaultDir, + sourceDir: yield* workerSourceDir({ + projectRoot: project.projectRoot, + defaultDir, + name, + configuredSource: entry?.source, + }), + } satisfies LegacyResolvedWorker; +}); + +/** Reject a name the CLI could never have written, before acting on it. */ +export const legacyValidateWorkerName = Effect.fnUntraced(function* (name: string) { + const invalid = validateWorkerNameMessage(name); + if (invalid !== undefined) { + return yield* Effect.fail( + new InvalidWorkerNameError({ + detail: `"${name}" is not a valid worker name. ${invalid}`, + suggestion: "Worker names become hostnames, so they must be DNS labels.", + }), + ); + } + return name; +}); + +/** + * Every worker in the project, for a command given no names: the directories + * under the workers root, unioned with the `[workers.]` entries, since a + * worker with a `source` lives outside that root and would otherwise be missed. + * + * Sorted, so a bare `push` deploys in a stable order rather than whatever the + * filesystem happened to return. + */ +export const legacyDiscoverWorkerNames = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, +) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory(project.workersDir).pipe(Effect.orElseSucceed(() => [])); + + const scaffolded: Array = []; + for (const entry of entries) { + const info = yield* fs.stat(join(project.workersDir, entry)).pipe(Effect.option); + if (info._tag === "Some" && info.value.type === "Directory") { + scaffolded.push(entry); + } + } + + return [...new Set([...scaffolded, ...Object.keys(project.section.workers)])] + .filter((name) => validateWorkerNameMessage(name) === undefined) + .sort(); +}); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index d3648e8ad7..bd9659d06f 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -64,6 +64,7 @@ export const LEGACY_DOCS_TAGS: Readonly>> = "supabase-secrets": ["management-api"], "supabase-seed": ["local-dev"], "supabase-services": ["local-dev"], + "supabase-workers": ["management-api"], "supabase-snippets": ["management-api"], "supabase-ssl-enforcement": ["management-api"], "supabase-sso": ["management-api"], diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 964434c3a7..d9ad846999 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -139,7 +139,9 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "release-channel", "remove-domains", "role", + "runtime", "size", + "source", "status", "sub", "swift-access-control", diff --git a/apps/cli/src/shared/workers/stacks/README.md b/apps/cli/src/shared/workers/stacks/README.md new file mode 100644 index 0000000000..1098b00c95 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/README.md @@ -0,0 +1,14 @@ +# Examples + +Minimal deployable workers, one per way of packaging code for the lambda +backend. Each runtime directory is discovered by +`worker-stacks.macro.ts` and scaffolded verbatim by `workers new`; adding a +runtime here means adding it to `WORKER_RUNTIMES` too, which the macro checks +at build time. Each returns JSON that includes the `GREETING` secret (null until the +project has one), so the secret-rotation loop is visible in responses. + +| Example | Spec | Notes | +| ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `node` | `{"runtime":"node","size":"2gb-1vcpu","exposure":"public","instances":1}` | catalog runtime; entry `index.mjs` exports `{ fetch }` | +| `deno` | `{"runtime":"deno","size":"2gb-1vcpu","exposure":"public","instances":1}` | catalog runtime; entry `main.ts` exports `{ fetch }` | +| `dockerfile` | `{"size":"2gb-1vcpu","exposure":"public","instances":1}` | no `runtime`: the context carries its own Dockerfile; the app serves plain HTTP on `$PORT` | diff --git a/apps/cli/src/shared/workers/stacks/deno/main.ts b/apps/cli/src/shared/workers/stacks/deno/main.ts new file mode 100644 index 0000000000..66cd89170e --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/deno/main.ts @@ -0,0 +1,10 @@ +export default { + fetch(request: Request): Response { + const { pathname } = new URL(request.url); + return Response.json({ + worker: "hello-deno", + path: pathname, + greeting: Deno.env.get("GREETING") ?? null, + }); + }, +}; diff --git a/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile b/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile new file mode 100644 index 0000000000..74dffeaa95 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile @@ -0,0 +1,3 @@ +FROM public.ecr.aws/docker/library/node:22-alpine +COPY server.mjs /srv/server.mjs +CMD ["node", "/srv/server.mjs"] diff --git a/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs b/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs new file mode 100644 index 0000000000..e005b02f8b --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs @@ -0,0 +1,15 @@ +// A user image serves plain HTTP on $PORT; the injected launcher wraps the +// image's CMD and provides it. +import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? 8080); +createServer((req, res) => { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + worker: "hello-dockerfile", + path: new URL(req.url, "http://localhost").pathname, + greeting: process.env.GREETING ?? null, + }), + ); +}).listen(port); diff --git a/apps/cli/src/shared/workers/stacks/node/index.mjs b/apps/cli/src/shared/workers/stacks/node/index.mjs new file mode 100644 index 0000000000..00b518cae1 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/node/index.mjs @@ -0,0 +1,10 @@ +export default { + fetch(request) { + const { pathname } = new URL(request.url); + return Response.json({ + worker: "hello-node", + path: pathname, + greeting: process.env.GREETING ?? null, + }); + }, +}; diff --git a/apps/cli/src/shared/workers/toml-section.ts b/apps/cli/src/shared/workers/toml-section.ts new file mode 100644 index 0000000000..8baab4025d --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.ts @@ -0,0 +1,88 @@ +/** + * Appending one `[section]` to a TOML file. + * + * `supabase/config.toml` belongs to the whole CLI: users hand-edit it, comment + * it, and commit it. Round-tripping through `saveProjectConfig` preserves the + * data but discards every comment and normalizes the formatting the user chose, + * so the write here is textual — render the table, put it at the end, and leave + * every other byte alone. + * + * Append-only by design: locating an existing table means being right about + * multiline strings, the three ways to quote a key, and where one table ends. + * Callers ask the decoded config whether an entry exists instead, so nothing + * here has to find one. + */ + +/** A TOML bare key needs no quoting; anything else does. */ +function isBareKey(key: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(key); +} + +/** The escapes TOML names, for the control characters that have one. */ +const TOML_NAMED_ESCAPES: Record = { + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +}; + +/** + * Escape a string for a TOML basic (double-quoted) string. + * + * Control characters need the same treatment as quotes and backslashes: TOML + * forbids them raw inside a basic string, and a path is allowed to contain them + * on Unix — a directory name with an embedded newline is legal. Writing one + * through verbatim leaves `config.toml` unparseable after the scaffold is + * already on disk. + */ +function quote(value: string): string { + let escaped = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (char === "\\") { + escaped += "\\\\"; + } else if (char === '"') { + escaped += '\\"'; + } else if (code < 0x20 || code === 0x7f) { + escaped += TOML_NAMED_ESCAPES[char] ?? `\\u${code.toString(16).padStart(4, "0")}`; + } else { + escaped += char; + } + } + return `"${escaped}"`; +} + +/** Render `key` for use in a table header or key position. */ +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)}`; +} + +/** + * `text` with a `[header]` table holding `values` appended to the end. + * + * Cannot fail: the caller has already established that no such table exists, so + * there is nothing to reconcile. A file that is empty (or only whitespace) gets + * no leading blank line; an existing one gets exactly one, however it happened + * to be terminated. + */ +export function appendTomlSection( + text: string, + header: string, + values: Readonly>, +): string { + const block = [ + `[${header}]`, + ...Object.entries(values).map(([key, value]) => renderPair(key, value)), + ].join("\n"); + + if (text.trim() === "") { + return `${block}\n`; + } + return `${text.replace(/\n*$/, "")}\n\n${block}\n`; +} diff --git a/apps/cli/src/shared/workers/toml-section.unit.test.ts b/apps/cli/src/shared/workers/toml-section.unit.test.ts new file mode 100644 index 0000000000..d00fca6933 --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "vitest"; +import { appendTomlSection, tomlKey } from "./toml-section.ts"; + +describe("appendTomlSection", () => { + test("appends a new table to an existing file without disturbing it", () => { + const before = `# my project +project_id = "demo" + +[functions.hello] +verify_jwt = false +`; + + expect(appendTomlSection(before, "workers.api", { runtime: "node", size: "2gb" })) + .toBe(`# my project +project_id = "demo" + +[functions.hello] +verify_jwt = false + +[workers.api] +runtime = "node" +size = "2gb" +`); + }); + + test("writes the table alone into an empty file", () => { + expect(appendTomlSection("", "workers.api", { runtime: "deno" })).toBe( + '[workers.api]\nruntime = "deno"\n', + ); + expect(appendTomlSection("\n \n", "workers.api", { runtime: "deno" })).toBe( + '[workers.api]\nruntime = "deno"\n', + ); + }); + + // However the file happened to be terminated, the new table is separated by + // exactly one blank line. + test.each([ + ['project_id = "demo"', "no trailing newline"], + ['project_id = "demo"\n', "one trailing newline"], + ['project_id = "demo"\n\n\n', "several trailing newlines"], + ])("separates the appended table with one blank line given %s", (before) => { + expect(appendTomlSection(before, "workers.api", { runtime: "node" })).toBe( + 'project_id = "demo"\n\n[workers.api]\nruntime = "node"\n', + ); + }); + + test("escapes quotes and backslashes in values", () => { + expect(appendTomlSection("", "workers.api", { source: 'pack"age\\api' })).toBe( + '[workers.api]\nsource = "pack\\"age\\\\api"\n', + ); + }); + + // A path may legally contain a newline on Unix. Writing it through verbatim + // would leave config.toml unparseable, after the directory is already on disk. + test("escapes control characters in a written value", () => { + const after = appendTomlSection("", "workers.api", { source: "packages/od\nd\tname" }); + + expect(after).toContain('source = "packages/od\\nd\\tname"'); + expect(after).not.toContain("od\nd"); + }); + + test("quotes a worker name that is not a bare key", () => { + expect(appendTomlSection("", `workers.${tomlKey("my worker")}`, { runtime: "node" })).toBe( + '[workers."my worker"]\nruntime = "node"\n', + ); + }); + + test("writes a header with no keys when there is nothing to set", () => { + expect(appendTomlSection("", "workers.api", {})).toBe("[workers.api]\n"); + }); +}); + +describe("tomlKey", () => { + test("quotes only what TOML requires quoting", () => { + expect(tomlKey("my-worker_1")).toBe("my-worker_1"); + expect(tomlKey("my worker")).toBe('"my worker"'); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts new file mode 100644 index 0000000000..b316692178 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -0,0 +1,140 @@ +import { dirname } from "node:path"; +import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; +import { appendTomlSection, tomlKey } from "./toml-section.ts"; + +/** + * The `[workers]` section of `supabase/config.toml`, read through the decoded + * project config and written back surgically. + * + * `[workers]` carries a project-wide `root` plus one `[workers.]` table + * per worker. The schema in `@supabase/config` models exactly that, so reading + * is a matter of splitting the scalar off the record; writing goes through + * `./toml-section.ts` so a user's comments and formatting survive. + */ + +/** One worker's recorded metadata. Every key is optional. */ +export interface WorkerEntry { + readonly runtime?: string; + readonly size?: string; + readonly source?: string; +} + +export interface WorkersSection { + /** `[workers.]` tables, keyed by worker name, in file order. */ + readonly workers: Readonly>; +} + +/** + * The worker is already recorded in `config.toml`. + * + * `workers new` creates a worker; changing one that exists is a different + * operation, and the file is the user's to edit. Refusing is also what keeps + * writes here append-only — amending an entry in place is what required knowing + * enough TOML to find and rewrite it safely. + */ +export class WorkerAlreadyConfiguredError extends Data.TaggedError("WorkerAlreadyConfiguredError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +const stringOrUndefined = (value: unknown): string | undefined => + typeof value === "string" && value !== "" ? value : undefined; + +/** A plain object — a `[workers.]` table rather than a scalar or a list. */ +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The decoded `[workers]` section as per-worker tables. Anything that is not an + * object is dropped rather than read as a worker named after it. + */ +export function readWorkersSection(workers: unknown): WorkersSection { + // Null-prototype, so a worker legitimately named `constructor`, `toString` or + // `hasOwnProperty` reads as absent when it is absent. A plain `{}` answers + // every one of those lookups with something inherited from + // `Object.prototype`, which is enough to make `workers new constructor` write + // its starter files and then refuse to record them. + const entries: Record = Object.create(null); + + if (!isRecord(workers)) { + return { workers: entries }; + } + + for (const [key, value] of Object.entries(workers)) { + if (!isRecord(value)) { + continue; + } + entries[key] = { + runtime: stringOrUndefined(value["runtime"]), + size: stringOrUndefined(value["size"]), + source: stringOrUndefined(value["source"]), + }; + } + + return { workers: entries }; +} + +/** A rendered `config.toml`, not yet written. */ +export interface WorkerEntryWrite { + readonly configPath: string; + readonly text: string; +} + +/** + * Render `config.toml` with `[workers.]` appended, without writing it. + * + * Split from the write so callers can find out an entry already exists before + * they scaffold anything: `new` writes the starter files first, and a failure + * after that would leave a directory nothing records. + */ +export const planWorkerEntry = Effect.fnUntraced(function* (options: { + readonly configPath: string; + readonly name: string; + readonly patch: Readonly>; + /** The already-parsed config — the authority on whether an entry exists. */ + readonly existingWorkers: Readonly>; +}) { + const fs = yield* FileSystem.FileSystem; + + // Append-only, so an entry that is already there cannot be amended. The + // decoded config is the authority on whether one exists — a question the + // parser has answered, and one no amount of regex over the file text answers + // reliably for a dotted or inline entry. + if (options.existingWorkers[options.name] !== undefined) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${options.name}" is already configured in ${options.configPath}.`, + suggestion: `Edit [workers.${options.name}] in ${options.configPath} yourself, or pick a different worker name.`, + }), + ); + } + + const exists = yield* fs.exists(options.configPath); + const text = exists ? yield* fs.readFileString(options.configPath) : ""; + const header = `workers.${tomlKey(options.name)}`; + + return { + configPath: options.configPath, + text: appendTomlSection(text, header, options.patch), + } satisfies WorkerEntryWrite; +}); + +/** + * Commit a {@link planWorkerEntry} result. Creates `supabase/` if it does not + * exist yet, so `new` works in a directory that has never been `supabase + * init`-ed. + */ +export const commitWorkerEntry = Effect.fnUntraced(function* (write: WorkerEntryWrite) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(write.configPath), { recursive: true }); + yield* fs.writeFileString(write.configPath, write.text); +}); diff --git a/apps/cli/src/shared/workers/worker-config.unit.test.ts b/apps/cli/src/shared/workers/worker-config.unit.test.ts new file mode 100644 index 0000000000..668ef584d3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + readWorkersSection, + WorkerAlreadyConfiguredError, + commitWorkerEntry, + planWorkerEntry, +} from "./worker-config.ts"; + +describe("readWorkersSection", () => { + test("reads each worker's recorded dials", () => { + expect( + readWorkersSection({ + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox" }, + }), + ).toEqual({ + workers: { + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, source: undefined }, + }, + }); + }); + + 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, source: undefined } }, + }); + }); + + test("treats a missing or malformed section as empty", () => { + expect(readWorkersSection(undefined)).toEqual({ workers: {} }); + expect(readWorkersSection([])).toEqual({ workers: {} }); + }); +}); + +describe("planWorkerEntry + commitWorkerEntry", () => { + let dir: string; + let configPath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-config-")); + configPath = join(dir, "config.toml"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const run = (effect: Effect.Effect) => Effect.runPromise(effect); + + /** plan + commit — the pairing `new` performs once it has decided to write. */ + const writeWorkerEntry = (options: Parameters[0]) => + planWorkerEntry(options).pipe(Effect.flatMap(commitWorkerEntry)); + + test("creates the file when there is none yet", async () => { + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe('[workers.api]\nruntime = "node"\n'); + }); + + test("appends to an existing file without touching the rest of it", async () => { + writeFileSync(configPath, '# keep me\nproject_id = "demo"\n'); + + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node", size: "4gb" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe( + '# keep me\nproject_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n', + ); + }); + + // `new` creates a worker; changing one that exists is a `config.toml` edit and + // the file is the user's. Refusing is also what keeps writes append-only. + test("refuses a worker that is already configured, leaving the file alone", async () => { + const before = '# hand-written\n[workers.api]\nruntime = "node" # mine\n'; + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "deno" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // How the entry is written — dotted, inline or a table — does not matter. The + // decoded config says it exists, which is the whole question, and answering it + // from the parser rather than the file text is what removed the need to know + // any TOML beyond how to render a value. + test.each([ + ["dotted keys", 'workers.api.runtime = "node"\n'], + ["an inline table", 'workers = { api = { runtime = "node" } }\n'], + ["a value spanning lines", '[workers.api]\nruntime = [\n "node",\n]\n'], + ["a header inside a multiline string", 'notes = """\n[workers.api]\nstill inside"""\n'], + ])("refuses an entry written as %s without reading the file text", async (_label, before) => { + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // Why rendering is separate from writing: `new` writes the starter files before + // it records anything, so a failure that could only surface at the write would + // leave a scaffold on disk that nothing records. + test("renders without writing, and only writes when committed", async () => { + writeFileSync(configPath, 'project_id = "demo"\n'); + + const write = await Effect.runPromise( + planWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(write.text).toContain("[workers.api]"); + expect(readFileSync(configPath, "utf8")).toBe('project_id = "demo"\n'); + + await run(commitWorkerEntry(write).pipe(Effect.provide(BunServices.layer))); + expect(readFileSync(configPath, "utf8")).toContain("[workers.api]"); + }); +}); + +describe("readWorkersSection prototype safety", () => { + // `constructor` is a valid DNS label, so it is a valid worker name. Read into + // a plain `{}`, looking it up would return `Object.prototype.constructor` and + // every caller would believe the worker was already configured. + test.each([["constructor"], ["toString"], ["hasOwnProperty"]])( + "reports %j as absent when it is absent", + (name) => { + const section = readWorkersSection({ api: { runtime: "node" } }); + expect(section.workers[name]).toBeUndefined(); + }, + ); + + test("still reads a worker actually named constructor", () => { + const section = readWorkersSection({ constructor: { runtime: "node" } }); + expect(section.workers["constructor"]).toEqual({ + runtime: "node", + size: undefined, + source: undefined, + }); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-paths.ts b/apps/cli/src/shared/workers/worker-paths.ts new file mode 100644 index 0000000000..95ac98ae8f --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -0,0 +1,225 @@ +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { InvalidWorkerSourceError } from "./workers.errors.ts"; + +/** + * The project layout every worker command resolves against: + * + * supabase/ + * config.toml project config — workers record `[workers.]` here + * workers// one directory per worker; the name IS the directory + * + * This mirrors `supabase/functions//` on purpose: `supabase workers` is a + * sibling of `supabase functions`, not a separate tool with its own + * conventions. A worker's name and its directory are the same fact, so + * `push`/`status`/`delete ` needs no separate lookup, and running from + * inside the directory needs no name at all. + * + * `supabase/workers/` is where they live. One worker whose code belongs + * somewhere else uses `[workers.] source`, relative to the project root, + * which is the only key that moves anything. + */ + +/** The directory workers live in, under `supabase/`. */ +const WORKERS_DIR = "workers"; + +/** + * Directories under `supabase/` the CLI already owns, so no worker's `source` + * may name one: `functions` and `migrations` belong to other parts of the CLI, + * and `.temp` holds CLI state including the linked-project reference. + */ +const RESERVED_SUPABASE_DIRS = ["functions", "migrations", ".temp"]; + +/** + * Files directly under `supabase/` that the CLI owns. Refused separately from the + * directories above, which do not cover them — `supabase/config.toml` sits + * outside every reserved subdirectory. + */ +const RESERVED_SUPABASE_FILES = ["config.toml", "config.json"]; + +/** `supabase/workers/` — where workers live, resolved against the project. */ +export function workersDir(projectRoot: string): string { + return join(projectRoot, "supabase", WORKERS_DIR); +} + +/** Whether `candidate` is `parent` itself or sits underneath it. */ +function isAtOrUnder(parent: string, candidate: string): boolean { + const rel = relative(resolve(parent), resolve(candidate)); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * `target` with every symlink in it resolved, as far as it exists. + * + * `realPath` fails outright on a path that is not there yet, and the whole point + * of canonicalizing here is to vet a destination *before* creating it. So this + * walks up to the deepest ancestor that does exist, resolves that, and re-joins + * the part that doesn't. + */ +const canonicalize = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const absolute = resolve(target); + const pending: Array = []; + let cursor = absolute; + + for (;;) { + const real = yield* fs.realPath(cursor).pipe(Effect.option); + if (real._tag === "Some") { + return pending.length === 0 ? real.value : join(real.value, ...pending); + } + const parent = dirname(cursor); + if (parent === cursor) { + // Walked to the filesystem root without finding anything that exists. + return absolute; + } + pending.unshift(basename(cursor)); + cursor = parent; + } +}); + +/** + * Confine a resolved worker path to the project, on the filesystem's terms + * rather than the string's. + * + * A string comparison cannot see a symlink: `packages/external -> /other-repo` + * makes `--source packages/external/api` write into `/other-repo`. So both the + * target and the project root are canonicalized before comparing — the root too, + * or a project under a symlink (macOS `/tmp` -> `/private/tmp`, most CI + * checkouts) fails containment against itself. + * + * Returns the path as given, not the canonical form, so what gets displayed and + * persisted stays the path the user named. + */ +export const confineWorkerPath = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly target: string; + /** How the path is named in the error, e.g. `--source "packages/api"`. */ + readonly subject: string; + readonly suggestion: string; +}) { + const refuse = (why: string) => + Effect.fail( + new InvalidWorkerSourceError({ + detail: `${options.subject} ${why}.`, + suggestion: options.suggestion, + }), + ); + + const projectRoot = yield* canonicalize(options.projectRoot); + const target = yield* canonicalize(options.target); + const supabaseDir = join(projectRoot, "supabase"); + + if (target === projectRoot) { + return yield* refuse("is the project root itself"); + } + if (!isAtOrUnder(projectRoot, target)) { + return yield* refuse("resolves outside the project"); + } + if (target === supabaseDir) { + return yield* refuse("is the supabase directory itself"); + } + for (const owned of RESERVED_SUPABASE_DIRS) { + if (isAtOrUnder(join(supabaseDir, owned), target)) { + return yield* refuse(`is inside supabase/${owned}/, which the Supabase CLI already owns`); + } + } + for (const owned of RESERVED_SUPABASE_FILES) { + if (target === join(supabaseDir, owned)) { + return yield* refuse(`is supabase/${owned}, which the Supabase CLI already owns`); + } + } + + return options.target; +}); + +/** + * `--source`, resolved against the directory the user typed it in and validated + * before anything is written. + * + * The resolved path is where the starter files land, so a value naming the + * project root, `supabase/`, or anywhere outside the project is refused. + * `source` is the key that may leave the workers directory, but not the project; + * `functions/` and `migrations/` are refused for the same reason `[workers] root` + * refuses them. + */ +export const resolveWorkerSource = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly cwd: string; + readonly raw: string; +}) { + const suggestion = + "Point --source at a directory inside the project, for example --source packages/api."; + + // Whitespace is not trimmed. A directory name may legally begin or end with a + // space on Unix, and the shell only delivers one in a single argv entry if the + // user quoted it — so trimming would silently retarget the scaffold at a + // neighbouring directory. Only the trailing separator, which is syntax rather + // than part of the name, comes off. An argument that is nothing but + // whitespace is refused rather than trimmed into something else. + if (options.raw.trim() === "") { + return yield* Effect.fail( + new InvalidWorkerSourceError({ + detail: `--source "${options.raw}" is empty.`, + suggestion, + }), + ); + } + + return yield* confineWorkerPath({ + projectRoot: options.projectRoot, + target: resolve(options.cwd, options.raw.replace(/[/\\]+$/, "")), + subject: `--source "${options.raw}"`, + suggestion, + }); +}); + +/** A worker's default directory: `supabase/workers//`. */ +export function workerDir(projectRoot: string, name: string): string { + return join(workersDir(projectRoot), name); +} + +/** + * A worker's source directory: `[workers.] source` when one is recorded, + * resolved against the project root, otherwise the default directory. + * + * Confined, not just resolved. `source` arrives from `config.toml`, which is + * committed and shared — so it is as much an input as `--source` is, and a + * checkout carrying `source = "../../.."` or an absolute path would otherwise + * have `push` package and upload a directory that has nothing to do with the + * project. The default directory goes through the same guard so a symlinked + * `[workers] root` cannot escape either. + */ +export const workerSourceDir = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly defaultDir: string; + readonly name: string; + readonly configuredSource: string | undefined; +}) { + const configured = options.configuredSource; + const recorded = configured !== undefined && configured !== ""; + + return yield* confineWorkerPath({ + projectRoot: options.projectRoot, + target: recorded ? resolve(options.projectRoot, configured) : options.defaultDir, + subject: recorded + ? `[workers.${options.name}] source "${configured}"` + : `The default directory for "${options.name}"`, + suggestion: recorded + ? `Set [workers.${options.name}] source to a directory inside the project, relative to the project root.` + : "Point [workers] root at a directory inside supabase/.", + }); +}); + +/** + * A path as it should be shown to the user: relative to the current directory, + * which is how they referred to it in the first place. Falls back to the + * absolute form when the relative one would climb out of the tree, where `../../` + * chains stop being clearer than the truth. + */ +export function displayPath(cwd: string, target: string): string { + const rel = relative(resolve(cwd), resolve(target)); + if (rel === "") { + return "."; + } + return rel.startsWith("..") ? target : rel; +} diff --git a/apps/cli/src/shared/workers/worker-paths.unit.test.ts b/apps/cli/src/shared/workers/worker-paths.unit.test.ts new file mode 100644 index 0000000000..79cc357a58 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.unit.test.ts @@ -0,0 +1,200 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + displayPath, + resolveWorkerSource, + workerDir, + workersDir, + workerSourceDir, +} from "./worker-paths.ts"; +import { InvalidWorkerSourceError } from "./workers.errors.ts"; + +const PROJECT = "/repo"; + +/** + * Confinement is decided on the filesystem's terms, so these need a real one. + * A path that does not exist still resolves — `canonicalize` walks up to the + * deepest existing ancestor — which is what lets the `/repo` cases below stay + * pure string scenarios. + */ +const runFs = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); + +describe("worker directories", () => { + test("resolve under supabase/workers/", () => { + expect(workersDir(PROJECT)).toBe(join(PROJECT, "supabase", "workers")); + expect(workerDir(PROJECT, "api")).toBe(join(PROJECT, "supabase", "workers", "api")); + }); + + test("a recorded source wins and is anchored to the project root", async () => { + const defaultDir = workerDir(PROJECT, "api"); + const sourceDir = (configuredSource: string | undefined) => + runFs(workerSourceDir({ projectRoot: PROJECT, defaultDir, name: "api", configuredSource })); + + expect(await sourceDir(undefined)).toBe(defaultDir); + expect(await sourceDir("")).toBe(defaultDir); + expect(await sourceDir("packages/api")).toBe(join(PROJECT, "packages", "api")); + }); + + // `source` arrives from a committed `config.toml`, so it is as much an input + // as `--source` is — and `push` packages and uploads whatever it resolves to. + test.each([["../../elsewhere"], ["/etc"], ["supabase/functions/hello"]])( + "refuses a recorded source of %j", + async (configuredSource) => { + const error = await runFs( + workerSourceDir({ + projectRoot: PROJECT, + defaultDir: workerDir(PROJECT, "api"), + name: "api", + configuredSource, + }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("[workers.api] source"); + }, + ); +}); + +describe("displayPath", () => { + test("prefers the relative form, and falls back to absolute when it would climb out", () => { + expect(displayPath(PROJECT, join(PROJECT, "supabase", "workers", "api"))).toBe( + join("supabase", "workers", "api"), + ); + expect(displayPath(PROJECT, PROJECT)).toBe("."); + expect(displayPath(join(PROJECT, "deep", "deeper"), "/elsewhere/api")).toBe("/elsewhere/api"); + }); +}); + +describe("resolveWorkerSource", () => { + const cwd = `${PROJECT}/apps/web`; + + test("resolves a directory inside the project against the directory it was typed in", async () => { + expect( + await runFs(resolveWorkerSource({ projectRoot: PROJECT, cwd, raw: "../../packages/api" })), + ).toBe(join(PROJECT, "packages", "api")); + expect( + await runFs( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw: "packages/api/" }), + ), + ).toBe(join(PROJECT, "packages", "api")); + }); + + // The starter files land in whatever this resolves to, so each of these would + // write into work belonging to the project or to the machine. + test.each([ + [".", "the project root itself"], + ["", "empty"], + ["..", "outside the project"], + ["/etc", "outside the project"], + ["../elsewhere", "outside the project"], + ["supabase", "the supabase directory itself"], + ["supabase/functions", "supabase/functions/"], + ["supabase/functions/hello", "supabase/functions/"], + ["supabase/migrations", "supabase/migrations/"], + ["supabase/.temp", "supabase/.temp/"], + ["supabase/.temp/project-ref", "supabase/.temp/"], + // Refusing the reserved directories is not enough on its own: this path is + // inside the project, is not `supabase/` itself, and is in no reserved + // subdirectory — so without this it would be authorized as a scaffold + // destination, and the project's config file is not that. + ["supabase/config.toml", "supabase/config.toml"], + ["supabase/config.json", "supabase/config.json"], + ])("refuses %j", async (raw, reason) => { + const error = await runFs( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain(reason); + }); +}); + +// Containment on a real filesystem, because a string comparison cannot see a +// symlink: a directory inside the project is free to point anywhere outside it, +// and the starter files land wherever the path really resolves. +describe("resolveWorkerSource containment on a real filesystem", () => { + let project = ""; + let outside = ""; + + beforeEach(() => { + const scratch = mkdtempSync(join(tmpdir(), "worker-paths-")); + project = join(scratch, "project"); + outside = join(scratch, "outside"); + mkdirSync(join(project, "packages"), { recursive: true }); + mkdirSync(join(outside, "api"), { recursive: true }); + mkdirSync(join(project, "supabase", "functions", "hello"), { recursive: true }); + }); + + afterEach(() => { + rmSync(join(project, ".."), { recursive: true, force: true }); + }); + + test("resolves a genuine directory inside the project", async () => { + expect( + await runFs(resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages" })), + ).toBe(join(project, "packages")); + }); + + test("refuses a path that reaches outside the project through a symlink", async () => { + symlinkSync(outside, join(project, "packages", "external")); + + const error = await runFs( + resolveWorkerSource({ + projectRoot: project, + cwd: project, + raw: join("packages", "external", "api"), + }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("resolves outside the project"); + }); + + test("refuses a reserved directory reached through a symlink", async () => { + symlinkSync(join(project, "supabase", "functions"), join(project, "fns")); + + const error = await runFs( + resolveWorkerSource({ + projectRoot: project, + cwd: project, + raw: join("fns", "hello"), + }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("supabase/functions/"); + }); + + // A destination that does not exist yet is the normal case for `new`, and the + // project root itself is usually behind a symlink on macOS (`/var` -> + // `/private/var`). Both have to compare equal, not fail containment. + // A name that ends in a space is legal on Unix, and only reaches argv as one + // entry if the user quoted it. Trimming it pointed the scaffold at a different + // directory than the one asked for. + test("keeps whitespace that is part of the directory name", async () => { + expect( + await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages/api " }), + ), + ).toBe(join(project, "packages", "api ")); + }); + + test.each([[""], [" "], ["\t"]])("refuses an all-whitespace --source of %j", async (raw) => { + const error = await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("is empty"); + }); + + test("accepts a destination that does not exist yet", async () => { + expect( + await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages/brand-new" }), + ), + ).toBe(join(project, "packages", "brand-new")); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts new file mode 100644 index 0000000000..7c9f93e8eb --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -0,0 +1,115 @@ +/** + * The alpha envelope a worker is described by: which runtime it is built on, + * and how big an instance it runs as. + * + * 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. + */ + +/** A worker's runtime: its own Dockerfile, or one of the catalog base images. */ +/** + * Kept in step with the directories under `./stacks/` — a runtime offered here + * with no starter files there would scaffold an empty worker, which + * `worker-stacks.macro.ts` refuses at build time. + */ +export const WORKER_RUNTIMES = ["dockerfile", "node", "deno"] as const; + +export type WorkerRuntime = (typeof WORKER_RUNTIMES)[number]; + +/** + * The runtime a worker gets when nobody names one: what `new`'s prompt + * pre-selects, and what the classifier falls back to for a directory it does + * not recognize. Deno, because it is the runtime the rest of the Supabase CLI's + * function tooling assumes. + */ +export const DEFAULT_WORKER_RUNTIME: WorkerRuntime = "deno"; + +function isWorkerRuntime(value: string): value is WorkerRuntime { + return WORKER_RUNTIMES.some((runtime) => runtime === value); +} + +/** + * The runtime a config file named, case-insensitively. The canonical lowercase + * form is what gets recorded. + * + * This is for hand-written `[workers.] runtime` values, where the casing + * is the user's own and `Runtime = "Node"` plainly means `node`. It is not what + * validates `--runtime`: that is a `Flag.choice` over the same catalog, so the + * parser rejects anything outside it — including a case variant — before a + * handler runs, and lists the accepted values when it does. + */ +export function parseWorkerRuntime(value: string): WorkerRuntime | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerRuntime(canonical) ? canonical : undefined; +} + +/** One-line description of each runtime, for `--runtime`'s prompt and help. */ +export const WORKER_RUNTIME_DESCRIPTIONS: Record = { + dockerfile: "Build the directory's own Dockerfile; it serves plain HTTP on $PORT.", + node: "Node.js catalog runtime (Web-standard fetch handler).", + deno: "Deno catalog runtime (Web-standard fetch handler).", +}; + +/** + * The only instance sizes the alpha envelope offers, denominated by memory. + * There is no resize — a different size later means a new worker, not a flag on + * `push`. + */ +export const WORKER_SIZES = ["2gb", "4gb"] as const; + +export type WorkerSize = (typeof WORKER_SIZES)[number]; + +/** The first available option — what `new` records when `--size` is omitted. */ +export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; + +function isWorkerSize(value: string): value is WorkerSize { + return WORKER_SIZES.some((size) => size === value); +} + +/** As {@link parseWorkerRuntime}, for instance sizes. */ +export function parseWorkerSize(value: string): WorkerSize | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerSize(canonical) ? canonical : undefined; +} + +const VCPU_FOR_SIZE: Record = { "2gb": 1, "4gb": 2 }; + +/** The vCPU count that comes with `size` — not independently choosable. */ +export function vcpuForSize(size: WorkerSize): number { + return VCPU_FOR_SIZE[size]; +} + +/** `spec.size` as the Workers API spells it: `2gb-1vcpu`. */ +export function apiSizeFor(size: WorkerSize): string { + return `${size}-${vcpuForSize(size)}vcpu`; +} + +/** + * How a size reads in output: `2gb · 1 vCPU`. Takes the API's own spelling so a + * worker deployed at a size this CLI never offered still renders, verbatim, + * rather than being forced into the local enum. + */ +export function formatApiSize(apiSize: string): string { + const match = /^(\d+gb)-(\d+)vcpu$/.exec(apiSize.trim().toLowerCase()); + if (match === null) { + return apiSize; + } + return `${match[1]} (${match[2]} vCPU)`; +} + +/** + * Worker names end up in hostnames, so they are DNS labels — the same pattern + * the Management API validates the `:name` path parameter against. + */ +const WORKER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +const workerNameRequirement = + "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; + +/** `undefined` when `name` is a valid worker name, else why it is not. */ +export function validateWorkerNameMessage(name: string): string | undefined { + return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; +} diff --git a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts new file mode 100644 index 0000000000..1eb1f9bccd --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { + apiSizeFor, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + validateWorkerNameMessage, + vcpuForSize, +} from "./worker-runtimes.ts"; + +describe("parseWorkerRuntime", () => { + test("accepts the value it displays, case-insensitively, and canonicalizes it", () => { + expect(parseWorkerRuntime("Dockerfile")).toBe("dockerfile"); + expect(parseWorkerRuntime(" NODE ")).toBe("node"); + }); + + test("rejects anything outside the catalog", () => { + expect(parseWorkerRuntime("rust")).toBeUndefined(); + expect(parseWorkerRuntime("sandbox")).toBeUndefined(); + expect(parseWorkerRuntime("")).toBeUndefined(); + }); +}); + +describe("sizes", () => { + test("each size implies its own vCPU count", () => { + expect(vcpuForSize("2gb")).toBe(1); + expect(vcpuForSize("4gb")).toBe(2); + }); + + test("map onto the spelling the Workers API takes", () => { + expect(apiSizeFor("2gb")).toBe("2gb-1vcpu"); + expect(apiSizeFor("4gb")).toBe("4gb-2vcpu"); + }); + + test("render back for display, and pass through anything unrecognized verbatim", () => { + expect(formatApiSize("2gb-1vcpu")).toBe("2gb (1 vCPU)"); + expect(formatApiSize("16gb-8vcpu")).toBe("16gb (8 vCPU)"); + expect(formatApiSize("something-else")).toBe("something-else"); + }); + + test("parse case-insensitively, and reject anything outside the catalog", () => { + expect(parseWorkerSize("4GB")).toBe("4gb"); + expect(parseWorkerSize(" 2gb ")).toBe("2gb"); + expect(parseWorkerSize("64gb")).toBeUndefined(); + expect(parseWorkerSize("")).toBeUndefined(); + }); +}); + +describe("validateWorkerNameMessage", () => { + test("accepts DNS labels", () => { + expect(validateWorkerNameMessage("api")).toBeUndefined(); + expect(validateWorkerNameMessage("my-worker-1")).toBeUndefined(); + expect(validateWorkerNameMessage("a")).toBeUndefined(); + }); + + test.each(["My-Worker", "-leading", "trailing-", "under_score", "", "a".repeat(64)])( + "rejects %j", + (name) => { + expect(validateWorkerNameMessage(name)).toBeDefined(); + }, + ); +}); diff --git a/apps/cli/src/shared/workers/worker-stacks.macro.ts b/apps/cli/src/shared/workers/worker-stacks.macro.ts new file mode 100644 index 0000000000..6c7538dca7 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.macro.ts @@ -0,0 +1,81 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { WORKER_RUNTIMES, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** The files a scaffolded worker is made of, keyed by the name each is written as. */ +export type WorkerStack = Readonly>; + +/** + * Fails unless every offered runtime has a non-empty stack, and every stack + * belongs to an offered runtime. + * + * The two lists are declared separately — `WORKER_RUNTIMES` drives `--runtime` + * and the type union, the directory holds the content — so this is what stops + * them drifting into a runtime users can pick that scaffolds nothing. It runs + * as the macro is expanded, which is to say at build time. + */ +function assertCompleteWorkerStacks( + stacks: Record, +): asserts stacks is Record { + const offered = new Set(WORKER_RUNTIMES); + const present = new Set(Object.keys(stacks)); + + const missing = [...offered].filter((runtime) => !present.has(runtime)); + if (missing.length > 0) { + throw new Error(`no starter files for ${missing.join(", ")}`); + } + const unexpected = [...present].filter((runtime) => !offered.has(runtime)); + if (unexpected.length > 0) { + throw new Error( + `stacks/${unexpected.join(", stacks/")} has no matching entry in WORKER_RUNTIMES`, + ); + } + for (const [runtime, files] of Object.entries(stacks)) { + if (Object.keys(files).length === 0) { + throw new Error(`stacks/${runtime} is empty`); + } + } +} + +/** + * Every runtime's starter files, discovered by reading `./stacks/`. + * + * Expanded as a Bun macro, so this runs while the importing module is + * transpiled and its return value is inlined as a literal — a compiled binary + * carries the content with no `stacks/` directory beside it and no `--define` + * to forget at a build site. Adding a runtime is adding a directory; nothing + * here names the files. + * + * Bun expands macros in the runtime transpiler too, so running from source + * behaves the same. Vitest does not implement them, and degrades to calling + * this as an ordinary function against the source tree — which is why the path + * comes from `import.meta.url` rather than Bun's `import.meta.dir`, undefined + * once the test runner has bundled the module. + * + * Throwing here fails the build. Bun reports it as a macro that could not be + * coerced to AST, so the reason is logged first to make the diagnostic legible. + */ +export function readWorkerStacks(): Record { + const root = fileURLToPath(new URL("stacks", import.meta.url)); + const stacks: Record = {}; + for (const entry of readdirSync(root, { withFileTypes: true })) { + // `README.md` sits beside the runtime directories and documents them. + if (!entry.isDirectory()) { + continue; + } + const files: Record = {}; + for (const name of readdirSync(join(root, entry.name))) { + files[name] = readFileSync(join(root, entry.name, name), "utf8"); + } + stacks[entry.name] = files; + } + + try { + assertCompleteWorkerStacks(stacks); + } catch (cause) { + console.error(`[worker-stacks] ${String(cause)}`); + throw cause; + } + return stacks; +} diff --git a/apps/cli/src/shared/workers/worker-stacks.ts b/apps/cli/src/shared/workers/worker-stacks.ts new file mode 100644 index 0000000000..4ef3a78a1b --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.ts @@ -0,0 +1,16 @@ +import { + readWorkerStacks, + type WorkerStack, +} from "./worker-stacks.macro.ts" with { type: "macro" }; +import type { WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * The starter files `supabase workers new` writes, per runtime — the contents + * of `./stacks//`, keyed by the name each file is scaffolded as. + * + * The content lives there as ordinary files, authored in the language they are + * written in rather than as string literals, and is discovered by reading the + * directory: a new runtime is a new directory, with nothing to wire up here. + * `worker-stacks.macro.ts` explains how that survives compilation. + */ +export const WORKER_STACKS: Record = readWorkerStacks(); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts new file mode 100644 index 0000000000..ecd09ac1fb --- /dev/null +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -0,0 +1,45 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** + * Every worker failure carries a `detail` saying what happened and a + * `suggestion` naming the command that fixes it. The shared output layer renders + * the pair, so no command formats its own recovery line. + */ + +export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameError")<{ + 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; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `--source` names a directory it is not allowed to name. Worth its own error + * because the destination is where the starter files land, so a value that + * resolves to the project root, `supabase/`, or anywhere outside the project has + * to be refused before anything is written. + */ +export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSourceError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts new file mode 100644 index 0000000000..774e41baed --- /dev/null +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -0,0 +1,268 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; +import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; +import { + mockLegacyLinkedProjectCacheLayer, + mockLegacyTelemetryStateLayer, +} from "./legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; + +/** + * Shared scaffolding for the `supabase workers` command integration tests. + * + * Every worker command reads a real `supabase/config.toml` and a real worker + * directory, so these tests run against a per-test temp project rather than a + * mocked filesystem — the config-writing and packaging behaviour is most of + * what is worth asserting. Only the network is faked. + */ + +export const WORKERS_PROJECT_REF = "abcdefghijklmnopqrst"; + +export interface RecordedRequest { + readonly method: string; + readonly url: string; + /** The request body decoded as UTF-8 — meaningful for the JSON requests. */ + readonly body: string; + /** Byte length of the body, which is what matters for the binary upload. */ + readonly byteLength: number; +} + +export interface StubResponse { + readonly status: number; + readonly body?: unknown; +} + +/** How a test answers one request; sequential entries reply to repeated calls. */ +export type RouteHandler = StubResponse | ReadonlyArray; + +export interface WorkersHttpRoutes { + /** Keyed `" "`, e.g. `"GET /v2/projects/abc.../workers"`. */ + readonly [route: string]: RouteHandler; +} + +function respond( + request: HttpClientRequest.HttpClientRequest, + stub: StubResponse, +): HttpClientResponse.HttpClientResponse { + const hasBody = stub.body !== undefined; + return HttpClientResponse.fromWeb( + request, + new Response(hasBody ? JSON.stringify(stub.body) : "", { + status: stub.status, + headers: hasBody ? { "content-type": "application/json" } : { "content-type": "text/plain" }, + }), + ); +} + +/** + * A single HTTP stub shared by the Management API client and the presigned + * build-context upload, so a test can assert the whole request sequence — mint + * the slot, PUT the bytes, deploy, poll — in the order it happened. + */ +export function mockWorkersHttp(routes: WorkersHttpRoutes) { + const requests: Array = []; + const remaining = new Map>( + Object.entries(routes).map(([route, handler]) => [ + route, + Array.isArray(handler) ? [...handler] : [handler as StubResponse], + ]), + ); + + const handle = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + Effect.sync(() => { + const bytes = request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0); + const url = new URL(request.url); + requests.push({ + method: request.method, + url: request.url, + body: new TextDecoder().decode(bytes), + byteLength: bytes.length, + }); + + const key = `${request.method} ${url.pathname}`; + const queue = remaining.get(key); + if (queue === undefined || queue.length === 0) { + return respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }); + } + // The last stub for a route keeps answering, so a poll loop does not have + // to be stubbed a fixed number of times. + const stub = queue.length === 1 ? queue[0]! : queue.shift()!; + return respond(request, stub); + }); + + const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make(handle)); + + const apiLayer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "workers", + "X-Supabase-Command-Run-ID": "run-123", + }, + }), + ).pipe(Layer.provide(httpClientLayer)); + + return { + layer: Layer.mergeAll(apiLayer, httpClientLayer), + requests, + get routeKeys(): Array { + return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); + }, + }; +} + +/** Worker resource JSON, as the Management API's JSON:API envelope wraps it. */ +export function workerResource(options: { + readonly name: string; + readonly runtime?: string; + readonly size?: string; + readonly exposure?: string; + readonly instances?: number; + readonly buildState?: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + readonly instanceCounts?: { + declared: number; + live: number; + ready: number; + stale: number; + }; + readonly instancesError?: string; +}) { + return { + type: "project_worker", + id: options.name, + attributes: { + spec: { + ...(options.runtime === undefined ? {} : { runtime: options.runtime }), + size: options.size ?? "2gb-1vcpu", + exposure: options.exposure ?? "public", + instances: options.instances ?? 1, + }, + build_state: options.buildState ?? "active", + secret_generation: "gen-1", + ...(options.stateReason === undefined ? {} : { state_reason: options.stateReason }), + ...(options.imageVersion === undefined ? {} : { image_version: options.imageVersion }), + ...(options.deleting === undefined ? {} : { deleting: options.deleting }), + ...(options.instanceCounts === undefined ? {} : { instances: options.instanceCounts }), + ...(options.instancesError === undefined ? {} : { instances_error: options.instancesError }), + }, + }; +} + +export const workersRoute = (suffix = "") => `/v2/projects/${WORKERS_PROJECT_REF}/workers${suffix}`; + +/** A per-test temp project, optionally pre-seeded with files. */ +export function makeWorkersProject(files: Readonly> = {}): { + readonly dir: string; +} { + const dir = mkdtempSync(join(tmpdir(), "supabase-workers-")); + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = join(dir, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); + } + return { dir }; +} + +/** + * `LegacyCliConfig`, trimmed to what the worker commands read: the workdir they + * treat as the project, and the host their URLs are built on. + */ +const legacyTestCliConfigLayer = (workdir: string) => + Layer.succeed(LegacyCliConfig, { + 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, + userAgent: "supabase", + } as unknown as LegacyCliConfig["Service"]); + +/** The resolver, stubbed: `--project-ref` wins, else the linked project. */ +const legacyTestProjectRefLayer = (linked: boolean) => + Layer.succeed(LegacyProjectRefResolver, { + resolve: (flagValue: Option.Option) => + Option.isSome(flagValue) + ? Effect.succeed(flagValue.value) + : linked + ? Effect.succeed(WORKERS_PROJECT_REF) + : Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ), + } as unknown as LegacyProjectRefResolver["Service"]); + +export interface WorkersSetupOptions { + readonly workdir: string; + /** + * The directory the command was invoked from, when it differs from the + * project — which is what a relative `--source` resolves against. + */ + readonly cwd?: string; + readonly format?: "text" | "json" | "stream-json"; + readonly interactive?: boolean; + readonly linked?: boolean; + readonly promptTextResponses?: ReadonlyArray; + readonly promptSelectResponses?: ReadonlyArray; + readonly routes?: WorkersHttpRoutes; + /** The Go `-o`/`--output` flag, which every command family here honours. */ + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; +} + +export function setupLegacyWorkers(options: WorkersSetupOptions) { + const out = mockOutput({ + format: options.format ?? "text", + interactive: options.interactive ?? (options.format ?? "text") === "text", + ...(options.promptTextResponses === undefined + ? {} + : { promptTextResponses: options.promptTextResponses }), + ...(options.promptSelectResponses === undefined + ? {} + : { promptSelectResponses: options.promptSelectResponses }), + }); + const http = mockWorkersHttp(options.routes ?? {}); + + return { + out, + http, + layer: Layer.mergeAll( + out.layer, + http.layer, + mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), + legacyTestCliConfigLayer(options.workdir), + legacyTestProjectRefLayer(options.linked !== false), + mockLegacyTelemetryStateLayer, + mockLegacyLinkedProjectCacheLayer, + randomLayer, + Layer.succeed( + LegacyOutputFlag, + options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), + ), + BunServices.layer, + ), + }; +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 362fa4e4dc..50b81a2098 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@tsconfig/bun/tsconfig.json", - "exclude": ["supabase"] + "exclude": ["supabase", "src/shared/workers/stacks"] } From 905115bc4f64b3ce876f9915d451a3182088c1a5 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:02:39 -0300 Subject: [PATCH 04/50] feat(cli): add supabase workers push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds and deploys workers into the linked project, and brings the Management API seam with it. Registered under `deploy` as an alias, for anyone reaching for the `supabase functions` verb out of habit. Given no names it deploys every worker in the project, matching `supabase functions deploy`, whose conventions this command set otherwise mirrors. "Every worker" is the union of the directories under `supabase/workers/` and the `[workers.]` entries, so one with a `source` pointing elsewhere is not missed, and the order is sorted rather than whatever the filesystem returned. Deploys run one at a time: each is a server-side container build, so interleaving them would both compete for the alpha's per-project capacity and shred the progress output; the first failure stops the run. The flow is mint an upload slot, PUT the `.tar.gz` build context straight at the presigned URL, deploy, then poll until `build_state` leaves `building`. The upload carries no Supabase credentials: the signature in the URL is the authorization, and the bytes never pass through the management API. That signature is also a write-capable credential for the archive a deploy is about to build from, so `legacyHttpClientLayer` redacts presigned URLs at the logging boundary — `--debug` scrollback and CI logs are not where it belongs, and redacting there covers every presigned URL the CLI might log rather than only this one. Polling is a `Schedule`, and the read inside it retries on a wall-clock budget so a blip of a second or two does not throw away a deploy that still has minutes of build ahead of it. Which spec is sent depends on the runtime: a `dockerfile` worker sends a context and no `spec.runtime`, a catalog runtime sends both, and a bare `sandbox` sends the runtime alone and skips packaging, so it has no URL. A directory with no `[workers.] runtime` has one guessed from marker files once the source is known to exist, reported on stderr with a nudge to pin it down. Everything that can fail deterministically fails before the remote project changes. `-o env` and a `-o toml` payload carrying an absent optional are settled up front rather than at emit time, where the command would exit non-zero having already deployed and invite a retry that deployed again; `--instances` is bounded at the parser the way the config schema bounds `[workers.] instances`, instead of carrying an impossible scaling request through a packaged upload; and a source of nothing but empty directories is refused before an upload slot is minted, rather than deployed as an image with no handler. The build context is packaged in-process rather than by shelling out to `tar`, whose BSD, GNU and absent-on-Windows variants each produce a different archive from the same tree. `tar.ts` writes USTAR directly: files, directories and symlinks, refusing a value too large for an octal header field instead of letting it spill into the next one and read back as a plausible but wrong size. Symlinks are stored as links rather than followed — anything pnpm installs is symlink-dense, so following them would inline every dependency and walk into a link pointing at an ancestor. Every filesystem error propagates: an unreadable file archived as zero bytes, a dropped subtree or an entry lost between `readDirectory` and its stat all mean a successful `push` reporting an image built from an application with a hole in it. The Workers routes answer 404 both for a project outside the alpha's allow-list and for a ref that names nothing this account can see, so the classification reads `error.code`: `not_found` raises `WorkerProjectNotFoundError` naming the ref, `supabase link` and `supabase login`, and anything unrecognized keeps the enrolment answer, since that is what the allow-list has historically returned and guessing the other way sends someone to check a ref that is fine. This is the first command in this shell to call a v2 Management API route; every other one here is a Go-parity port and uses v1 only. Two findings are deliberate follow-ups rather than defects: streaming the build context instead of buffering it, and an ignore mechanism so `.env` and `.git` can be kept out of the uploaded archive. --- .../legacy/auth/legacy-http-debug.layer.ts | 67 +- .../auth/legacy-http-debug.unit.test.ts | 56 ++ .../commands/workers/push/SIDE_EFFECTS.md | 74 ++ .../commands/workers/push/push.command.ts | 62 ++ .../commands/workers/push/push.handler.ts | 406 +++++++++++ .../workers/push/push.integration.test.ts | 665 ++++++++++++++++++ .../commands/workers/workers.command.ts | 3 +- .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 1 + apps/cli/src/shared/workers/tar.ts | 224 ++++++ apps/cli/src/shared/workers/tar.unit.test.ts | 116 +++ .../cli/src/shared/workers/worker-classify.ts | 48 ++ apps/cli/src/shared/workers/worker-config.ts | 10 + .../shared/workers/worker-config.unit.test.ts | 26 +- apps/cli/src/shared/workers/worker-package.ts | 133 ++++ .../workers/worker-package.unit.test.ts | 216 ++++++ .../cli/src/shared/workers/worker-runtimes.ts | 7 + apps/cli/src/shared/workers/worker-url.ts | 17 + apps/cli/src/shared/workers/workers-api.ts | 429 +++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 136 ++++ apps/cli/tests/helpers/legacy-workers.ts | 34 +- 21 files changed, 2717 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/push/push.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.integration.test.ts create mode 100644 apps/cli/src/shared/workers/tar.ts create mode 100644 apps/cli/src/shared/workers/tar.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-classify.ts create mode 100644 apps/cli/src/shared/workers/worker-package.ts create mode 100644 apps/cli/src/shared/workers/worker-package.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-url.ts create mode 100644 apps/cli/src/shared/workers/workers-api.ts diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts index 9e34b6437d..bf93986607 100644 --- a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -6,9 +6,68 @@ import { legacyDohFetchLayer } from "../shared/legacy-http-dns.ts"; import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; /** - * Wraps `FetchHttpClient.layer` so every HTTP request can go through the - * legacy Go-parity debug side channel. The logger itself owns the `--debug` - * guard and byte-for-byte line formatting. + * Query parameters that mean the URL *is* a credential. + * + * A presigned object-store URL authorizes whoever holds it — for the Workers + * build-context upload, to overwrite the archive a deploy is about to build + * from. Logging one verbatim under `--debug` puts that in terminal scrollback + * and in any CI log or bug report the output is pasted into. + */ +const PRESIGNED_QUERY_KEYS = [ + // AWS SigV4 and SigV2 + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "awsaccesskeyid", + // Google Cloud Storage V4 + "x-goog-signature", + "x-goog-credential", + // Azure SAS, and the generic spellings everything else uses + "sig", + "se", + "signature", + "token", +]; + +/** + * The URL as it should appear in a debug log: unchanged, unless its query string + * carries a signature, in which case the query is replaced wholesale. + * + * Redacting the whole query rather than the matched parameters keeps the + * decision simple and cannot leak a sibling parameter that turns out to matter. + * The path survives, which is what makes the line useful for debugging in the + * first place. + * + * A denylist of known signature parameters, so it is by nature incomplete: a + * provider spelling its signature something new would log verbatim until the + * list learns about it. The alternative — redacting every query string — would + * cost the debug log its usefulness on the Management API calls that are the + * whole reason `--debug` exists. Add spellings here as they turn up. + */ +export function legacyRedactHttpUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Not a URL we can reason about; log it as-is rather than swallow it. + return url; + } + if (parsed.search === "") { + return url; + } + const presigned = [...parsed.searchParams.keys()].some((key) => + PRESIGNED_QUERY_KEYS.includes(key.toLowerCase()), + ); + if (!presigned) { + return url; + } + return `${parsed.origin}${parsed.pathname}?`; +} + +/** + * Wraps `FetchHttpClient.layer` so every HTTP request goes through the legacy + * debug side channel. The logger itself owns the `--debug` guard and the + * line formatting. * * `legacyDohFetchLayer` overrides `FetchHttpClient.Fetch` with a * DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. @@ -19,7 +78,7 @@ export const legacyHttpClientLayer = Layer.effect( const logger = yield* LegacyDebugLogger; const base = yield* HttpClient.HttpClient; return HttpClient.mapRequestEffect(base, (req) => - logger.http(req.method, req.url).pipe(Effect.as(req)), + logger.http(req.method, legacyRedactHttpUrl(req.url)).pipe(Effect.as(req)), ); }), ).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(legacyDohFetchLayer)); diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts new file mode 100644 index 0000000000..c77cd9bace --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { legacyRedactHttpUrl } from "./legacy-http-debug.layer.ts"; + +/** + * `--debug` logs every request URL to stderr. For a presigned object-store URL + * the query string *is* the credential — for the Workers build-context upload, + * one that authorizes overwriting the archive a deploy is about to build from — + * so it must not survive into scrollback or a CI log. + */ +describe("legacyRedactHttpUrl", () => { + test.each([ + [ + "an AWS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a GCS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Goog-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a lowercase signature parameter", + "https://store.example/o/ctx?signature=deadbeef&expires=123", + "https://store.example/o/ctx?", + ], + [ + "a bare token parameter", + "https://store.example/o/ctx?token=deadbeef", + "https://store.example/o/ctx?", + ], + ])("redacts the query string of %s", (_label, url, expected) => { + expect(legacyRedactHttpUrl(url)).toBe(expected); + expect(legacyRedactHttpUrl(url)).not.toContain("deadbeef"); + }); + + // The debug log is only useful if ordinary requests still read normally, so + // redaction has to be the exception rather than the rule. + test.each([ + ["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"], + ["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"], + ["a URL with no query at all", "https://api.supabase.com/v1/projects"], + ])("leaves %s untouched", (_label, url) => { + expect(legacyRedactHttpUrl(url)).toBe(url); + }); + + test("passes through something that is not a parseable URL", () => { + expect(legacyRedactHttpUrl("not a url at all")).toBe("not a url at all"); + }); + + test("keeps the path, which is what makes the log line worth having", () => { + expect(legacyRedactHttpUrl("https://store.example/bucket/deep/ctx.tar.gz?sig=x")).toContain( + "/bucket/deep/ctx.tar.gz", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md new file mode 100644 index 0000000000..b145692970 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -0,0 +1,74 @@ +# `supabase workers push [name...] (alias: deploy)` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/**` | 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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | +| `POST` | `/v2/projects/{ref}/workers/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | +| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only | +| `POST` | `/v2/projects/{ref}/workers/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | + +`GET` is polled until `build_state` leaves `building`. + +## Exit Codes + +| Code | Condition | +| ---- | ---------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source directory is missing or empty | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +`-o env` is refused **before** the first deploy rather than at emit time: the +payload always carries a `workers` array, which a flat `KEY=value` list cannot +express, and discovering that at the end would fail the command with the remote +project already changed. + +The presigned `PUT` above is the one request whose URL is itself a credential. +`--debug` logs every request URL, so `legacyHttpClientLayer` redacts query +strings that carry a signature. diff --git a/apps/cli/src/legacy/commands/workers/push/push.command.ts b/apps/cli/src/legacy/commands/workers/push/push.command.ts new file mode 100644 index 0000000000..9262f028a8 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.command.ts @@ -0,0 +1,62 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; + +const config = { + names: Argument.string("name").pipe( + Argument.withDescription("Workers to deploy. Deploys every worker in the project if omitted."), + Argument.variadic(), + ), + instances: Flag.integer("instances").pipe( + // Bounded at the parser, the same way `[workers.] instances` is bounded + // in the config schema. Left unchecked it reached the deploy endpoint — after + // the build context had been packaged and uploaded — as a scaling request the + // platform cannot honour. + Flag.filter( + (instances) => instances >= 0, + (instances) => `--instances ${instances} is negative; pass zero or more.`, + ), + Flag.withDescription( + "Number of instances to run, overriding `instances` in supabase/config.toml for this deploy. Falls back to the recorded value, then 1.", + ), + Flag.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersPushFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersPushCommand = Command.make("push", config).pipe( + Command.withAlias("deploy"), + Command.withDescription( + "Build and deploy workers into the linked Supabase project. Reads each worker's runtime, size and source directory from supabase/config.toml.", + ), + Command.withShortDescription("Build and deploy workers"), + Command.withExamples([ + { + command: "supabase workers push", + description: "Deploy every worker in the project", + }, + { + command: "supabase workers push api", + description: "Deploy a single worker", + }, + { + command: "supabase workers push api web", + description: "Deploy several workers by name", + }, + ]), + Command.withHandler((flags) => + legacyWorkersPush(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "push"])), +); diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts new file mode 100644 index 0000000000..86b9a068d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -0,0 +1,406 @@ +import { Effect, FileSystem, Option, type Schedule } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; +import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { + apiSizeFor, + DEFAULT_WORKER_INSTANCES, + DEFAULT_WORKER_SIZE, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + WORKER_RUNTIMES, + WORKER_SIZES, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { + awaitWorkerBuild, + createWorkerUpload, + deployWorker, + uploadBuildContext, + type WorkerDeploySpec, +} from "../../../../shared/workers/workers-api.ts"; +import { + NoWorkersToDeployError, + UnknownWorkerRuntimeError, + UnknownWorkerSizeError, + WorkerBuildFailedError, + WorkerSourceMissingError, +} from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorker, + legacyDiscoverWorkerNames, + legacyLoadWorkersProject, + legacyValidateWorkerName, + type LegacyWorkersProject, +} from "../workers.shared.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +/** + * `supabase workers push [name]` — build (when there is code to build) and + * 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 + * its runtime guessed from marker files instead — reported, with a nudge to pin + * it down rather than re-guess on every push. + * + * A `dockerfile` worker is tarred and uploaded, and the build happens + * server-side from that context, never on your machine. A catalog runtime with + * code takes the same path, with the base image and a copy synthesized in place + * of your Dockerfile. Every runtime this CLI offers has code to package, so + * there is no path here that skips the upload. + */ + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; + readonly sourceDir: string; +}) { + if (options.recorded !== undefined) { + const recorded = parseWorkerRuntime(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerRuntimeError({ + detail: `supabase/config.toml records an unknown runtime "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] runtime to one of: ${WORKER_RUNTIMES.join(", ")}.`, + }), + ); + } + return recorded; + } + + const output = yield* Output; + const classified = yield* classifyWorkerDir(options.sourceDir); + // A guess the user should pin down: stderr, so it never lands inside a + // payload stdout is carrying. + yield* output.raw( + `No runtime configured for ${options.name}: guessed ${classified.runtime} (${classified.reason}). ` + + `Pin it down by adding [workers.${options.name}] runtime = "${classified.runtime}" to supabase/config.toml.\n`, + "stderr", + ); + return classified.runtime; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; +}) { + if (options.recorded === undefined) { + return DEFAULT_WORKER_SIZE; + } + const recorded = parseWorkerSize(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerSizeError({ + detail: `supabase/config.toml records an unknown size "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] size to one of: ${WORKER_SIZES.join(", ")}.`, + }), + ); + } + return recorded; +}); + +/** + * `--instances` for one deploy, then the recorded count, then + * {@link DEFAULT_WORKER_INSTANCES}. Never left unset, because every deploy sends + * a complete spec and an omitted count rescales the worker. + * + * No unparseable case to report: the config schema and the flag are both bounded + * to a non-negative integer before the handler runs. + */ +function resolveInstances(options: { + readonly recorded: number | undefined; + readonly override: Option.Option; +}): number { + return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); +} + +const deployOneWorker = Effect.fnUntraced(function* (input: { + readonly project: LegacyWorkersProject; + readonly name: string; + readonly projectRef: string; + readonly instances: Option.Option; + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + /** Suppresses this step's human output when `-o` owns stdout. */ + readonly machineOutput: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + + const { project, name, projectRef } = input; + const worker = yield* legacyDescribeWorker(project, name); + + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + // Checked before the runtime is resolved, not after: with no recorded + // runtime, `resolveRuntime` classifies the directory and announces what it + // guessed. Doing that first meant reporting an inference about a path that + // does not exist, and only then failing on the path. + { + const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); + if (stat._tag === "None" || stat.value.type !== "Directory") { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `There is no worker source at ${sourceDisplay}.`, + suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + }), + ); + } + // An empty directory packages and deploys perfectly happily, producing an + // image with nothing in it — a success message for a worker that cannot + // serve anything. Refuse before uploading rather than after. + const contents = yield* fs.readDirectory(worker.sourceDir).pipe(Effect.orElseSucceed(() => [])); + if (contents.length === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} is empty, so there is nothing to deploy.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + } + + const runtime = yield* resolveRuntime({ + name, + recorded: worker.entry?.runtime, + sourceDir: worker.sourceDir, + }); + + // Size: whatever `new --size` recorded, else the alpha envelope's own + // default. Never left unset, because a worker that is actually running always + // has some concrete size — and never silently coerced, because a size the CLI + // does not recognize is a config mistake worth naming. + const size = yield* resolveSize({ name, recorded: worker.entry?.size }); + + const instances = resolveInstances({ + recorded: worker.entry?.instances, + override: input.instances, + }); + + let contextUploadId: string; + { + const packaging = yield* output.task("Packaging worker..."); + const packaged = yield* packageWorkerDirectory(worker.sourceDir).pipe( + Effect.tapError(() => packaging.fail()), + ); + yield* packaging.clear(); + yield* output.raw( + `Packaged ${sourceDisplay} (${packaged.fileCount} files, ${formatBytes( + packaged.archive.length, + )}).\n`, + "stderr", + ); + + // The guard above counts directory entries, so a tree of nothing but empty + // subdirectories reaches here and packages to zero files. For a catalog + // runtime that deploys an image with no handler in it — the exact "nothing + // to deploy" case that guard exists to refuse. + if (packaged.fileCount === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} holds no files to deploy, only empty directories.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + + const uploading = yield* output.task("Uploading build context..."); + const slot = yield* createWorkerUpload(api, projectRef, name).pipe( + Effect.tapError(() => uploading.fail()), + ); + yield* uploadBuildContext(slot, packaged.archive).pipe(Effect.tapError(() => uploading.fail())); + yield* uploading.clear(); + yield* output.raw("Uploaded build context.\n", "stderr"); + contextUploadId = slot.uploadId; + } + + const spec: WorkerDeploySpec = { + // A plain Dockerfile build has no catalog runtime to name; the uploaded + // 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", + instances, + }; + + const deploying = yield* output.task("Deploying worker..."); + yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( + Effect.tapError(() => deploying.fail()), + ); + + const settled = yield* awaitWorkerBuild(api, projectRef, name, { + schedule: input.pollSchedule, + retrySchedule: input.pollRetrySchedule, + onPoll: (polled) => + polled.buildState === "building" ? deploying.message("Building worker...") : Effect.void, + }).pipe(Effect.tapError(() => deploying.fail())); + + if (settled.buildState === "failed") { + yield* deploying.clear(); + return yield* Effect.fail( + new WorkerBuildFailedError({ + detail: `The build for "${name}" failed${ + settled.stateReason === undefined ? "" : `: ${settled.stateReason}` + }.`, + suggestion: `Fix the issue, then re-run \`supabase workers push ${name}\`.`, + }), + ); + } + + yield* deploying.clear(); + + const url = + settled.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + + // Suppressed when `-o` is in play: the payload owns stdout, and these lines + // would land in the middle of it. + if (output.format === "text" && !input.machineOutput) { + // Declarative line first, then the details — the shape every other command + // that reports a completed remote change uses. `legacyRenderWorkerDetails` drops + // empty-valued rows, so optional fields need no conditional spreads. + yield* output.raw( + `Deployed Worker ${legacyAqua(name, process.stdout)} to project ${projectRef}\n`, + ); + yield* output.raw( + legacyRenderWorkerDetails([ + ["Runtime", runtime], + ["Size", formatApiSize(settled.spec.size)], + ["Image", settled.imageVersion ?? ""], + ["Access", settled.spec.exposure], + ["URL", url ?? ""], + ]), + ); + } + + return { + worker_name: name, + runtime, + size: settled.spec.size, + exposure: settled.spec.exposure, + instances: settled.spec.instances, + // Omitted rather than present-and-undefined: `-o toml` hands the payload to + // smol-toml, which cannot represent undefined and would throw *after* the + // upload and deploy had completed. Same reason `url` is spread below. + ...(settled.imageVersion === undefined ? {} : { image_version: settled.imageVersion }), + build_state: settled.buildState, + ...(url === undefined ? {} : { url }), + }; +}); + +/** + * `supabase workers push [name...]` — deploy the named workers, or every worker + * in the project when none are named, mirroring `supabase functions deploy`. + * + * Deploys run one at a time rather than concurrently: each is a server-side + * container build, and interleaving several would both hammer the alpha's + * per-project capacity and shred the progress output. The first failure stops + * the run, because a build that failed is usually the thing to fix before + * spending minutes on the rest. + */ +export const legacyWorkersPush = Effect.fn("legacy.workers.push")(function* ( + flags: LegacyWorkersPushFlags, + options: { + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + } = {}, +) { + const output = yield* Output; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating names, discovering workers — belongs inside, so a malformed + // config still flushes telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const requested = + flags.names.length > 0 + ? yield* Effect.forEach(flags.names, legacyValidateWorkerName) + : yield* legacyDiscoverWorkerNames(project); + + if (requested.length === 0) { + return yield* Effect.fail( + new NoWorkersToDeployError({ + detail: `No workers were named, and none were found in ${displayPath( + project.projectRoot, + project.workersDir, + )}.`, + suggestion: "Scaffold one with `supabase workers new `.", + }), + ); + } + + const names = [...new Set(requested)]; + + // Before the first deploy, not after the last one: this payload always + // carries a `workers` array, so `-o env` can never encode it, and finding + // that out at the end means failing with the remote project already changed. + yield* legacyRejectWorkersEnvOutput(); + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const deployed: Array> = []; + for (const name of names) { + if (names.length > 1 && !machineOutput) { + // stderr, unblanked and labelled, the way `functions deploy` announces + // each function: a bare name with a leading blank line put a section + // header into whatever was consuming stdout. + yield* output.raw(`Deploying Worker: ${legacyAqua(name)}\n`, "stderr"); + } + deployed.push( + yield* deployOneWorker({ + project, + name, + projectRef, + instances: flags.instances, + machineOutput, + ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), + ...(options.pollRetrySchedule === undefined + ? {} + : { pollRetrySchedule: options.pollRetrySchedule }), + }), + ); + } + + const payload = { project_ref: projectRef, workers: deployed }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts new file mode 100644 index 0000000000..0c16266931 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -0,0 +1,665 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Schedule } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, + type WorkersHttpRoutes, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + NoWorkersToDeployError, + WorkerBuildFailedError, + WorkerProjectNotFoundError, + WorkersUnavailableError, + WorkerSourceMissingError, + WorkerUploadFailedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +const UPLOAD_URL = "https://storage.example/deploy-context/api.tar.gz?signed"; +const UPLOAD_ID = "cafe0000000000000000000000000000"; + +/** Polls run with no delay so a build sequence resolves at test speed. */ +const IMMEDIATE = Schedule.recurs(20); + +const uploadSlot = { + data: { + type: "project_worker_upload", + id: UPLOAD_ID, + attributes: { url: UPLOAD_URL, method: "PUT", expires_at: "2026-08-12T00:15:00Z" }, + }, +}; + +function flags(overrides: Partial = {}): LegacyWorkersPushFlags { + return { + names: ["api"], + instances: Option.none(), + projectRef: Option.none(), + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/workers/api/index.js": "export default { fetch: () => new Response('ok') };\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +function routes(overrides: WorkersHttpRoutes = {}): WorkersHttpRoutes { + return { + [`POST ${workersRoute("/api/uploads")}`]: { status: 201, body: uploadSlot }, + "PUT /deploy-context/api.tar.gz": { status: 200 }, + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "active", + imageVersion: "v1", + }), + }, + }, + ...overrides, + }; +} + +/** + * The `_tag` of a failure, for a channel that also carries plain `Error` + * subclasses — `TarPathTooLongError` has no tag. + */ +function tagOf(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "_tag" in error + ? String((error as { _tag: unknown })._tag) + : undefined; +} + +function push(flagOverrides: Partial = {}) { + // Both schedules are injected: the outer poll and the per-read retry. The + // production retry is spaced in seconds, so leaving it in place made the + // transient-failure test wait on a real clock. + return legacyWorkersPush(flags(flagOverrides), { + pollSchedule: IMMEDIATE, + pollRetrySchedule: IMMEDIATE, + }); +} + +describe("legacy workers push", () => { + it.live("packages, uploads, deploys and waits for the build to settle", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(http.routeKeys).toEqual([ + `POST ${workersRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${workersRoute("/api/deploy")}`, + `GET ${workersRoute("/api")}`, + ]); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}")).toEqual({ + data: { + type: "project_worker", + attributes: { + spec: { + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }, + context_upload_id: UPLOAD_ID, + }, + }, + }); + + const upload = http.requests.find((request) => request.method === "PUT"); + expect(upload?.byteLength).toBeGreaterThan(0); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + expect(out.stdoutText).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("omits the runtime for a Dockerfile worker and builds from the uploaded context", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + "supabase/workers/api/Dockerfile": "FROM node:24-alpine\nEXPOSE 8080\n", + }); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + const attributes = JSON.parse(deploy?.body ?? "{}").data.attributes; + expect(attributes.spec).toEqual({ + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }); + expect(attributes.context_upload_id).toBe(UPLOAD_ID); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("guesses the runtime for a directory with no config entry and says so", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/api/package.json": "{}\n", + }); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stderrText).toContain("guessed node"); + expect(out.stderrText).toContain("found package.json"); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBe("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("sends the recorded size and the requested instance count", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(3) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec).toEqual({ + runtime: "node", + size: "4gb-2vcpu", + exposure: "public", + instances: 3, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps a worker scaled at the count recorded in config", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\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.instances).toBe(4); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("lets --instances override the recorded count for one deploy", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(1) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.instances).toBe(1); + }).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({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { + data: workerResource({ name: "api", buildState: "active", imageVersion: "v2" }), + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const polls = http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`); + expect(polls).toHaveLength(3); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with the build's own reason when the build fails", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + buildState: "failed", + stateReason: "error building image: exit status 1", + }), + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerBuildFailedError); + expect((error as WorkerBuildFailedError).detail).toContain("error building image"); + expect((error as WorkerBuildFailedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("stops waiting on a build that never settles, and says where to look", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersPush(flags(), { pollSchedule: Schedule.recurs(2) }).pipe( + Effect.flip, + ); + + expect(tagOf(error)).toBe("WorkerBuildTimeoutError"); + expect((error as { suggestion: string }).suggestion).toContain("supabase workers status api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails before deploying when the presigned upload is rejected", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ "PUT /deploy-context/api.tar.gz": { status: 403, body: "expired" } }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerUploadFailedError); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Both of the next two arrive as a 404 on the same route; only `error.code` + // separates them, so they are asserted against the bodies the API really + // sends rather than a shape of our own invention. + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + expect((error as WorkersUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points at the project ref when no such project exists", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerProjectNotFoundError); + expect((error as WorkerProjectNotFoundError).suggestion).not.toContain("private alpha"); + expect((error as WorkerProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps the enrolment answer for a 404 body it does not recognize", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { status: 404, body: { unexpected: true } }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when the worker has no source on disk", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses an empty source directory instead of deploying nothing", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js"), { force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).detail).toContain("is empty"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rides out a transient failure while polling the build", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { status: 500, body: { message: "blip" } }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + // The blip was retried rather than aborting a deploy already in flight. + expect( + http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`).length, + ).toBeGreaterThan(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("acts on the workdir's project, not the process's directory", () => { + // `--workdir`/`SUPABASE_WORKDIR` names the project every legacy command acts + // on, so the worker discovered here comes from that tree even though the + // process is somewhere else entirely. + const repo = project(); + const elsewhere = makeWorkersProject(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + repo.cleanup(); + rmSync(elsewhere.dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.live("deploys every worker in the project when none are named", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n\n[workers.web]\nruntime = "node"\n`, + "supabase/workers/web/index.js": "export default {};\n", + }); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + ...routes(), + [`POST ${workersRoute("/web/uploads")}`]: { status: 201, body: uploadSlot }, + [`POST ${workersRoute("/web/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/web")}`]: { + status: 200, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "active" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + // Both deployed, in a stable (sorted) order. + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + expect(http.routeKeys).toContain(`POST ${workersRoute("/web/deploy")}`); + expect(http.routeKeys.indexOf(`POST ${workersRoute("/api/deploy")}`)).toBeLessThan( + http.routeKeys.indexOf(`POST ${workersRoute("/web/deploy")}`), + ); + expect(out.stdoutText).toContain("web"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when there are no workers to deploy at all", () => { + const repo = project({ "supabase/config.toml": `project_id = "demo"\n` }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push({ names: [] }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NoWorkersToDeployError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project or an explicit --project-ref", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("packages a --source worker from where its code actually lives", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: routes(), + }); + + return Effect.gen(function* () { + yield* push(); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + // One entry per worker deployed, since a bare push can deploy several. + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + worker_name: "api", + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + build_state: "active", + image_version: "v1", + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o env` cannot express the `workers` array. Discovering that at emit time + // meant failing with the project already changed, inviting a retry that + // deployed all over again. + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes(), + goOutput: "env", + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(tagOf(error)).toBe("LegacyWorkersEnvNotSupportedError"); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The "nothing to deploy" guard counts directory entries, so a tree of empty + // subdirectories used to package to zero files and deploy an image with no + // handler in it. + it.live("refuses a source holding only empty directories, before minting a slot", () => { + const repo = project({ "supabase/workers/api/nested/.keep": "" }); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js")); + rmSync(join(repo.dir, "supabase", "workers", "api", "nested", ".keep")); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The runtime guess is an inference about the contents of a directory, so it + // has no business being reported for a directory that is not there. + it.live("does not report a guessed runtime when the source is missing", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(out.stderrText).not.toContain("guessed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `image_version` is optional in the response. Present-but-undefined made the + // TOML encoder throw, after the upload and deploy had already completed. + it.live("encodes -o toml when the deployed worker has no image version", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("worker_name"); + expect(out.stdoutText).not.toContain("image_version"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A malformed config.toml used to fail outside the finalizers, so the run + // skipped the telemetry flush every invocation is supposed to perform. + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push().pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index ac4555f3de..d575670118 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,10 +1,11 @@ import { Command } from "effect/unstable/cli"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; +import { legacyWorkersPushCommand } from "./push/push.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand]), + Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), ); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index bd9659d06f..124f2423fa 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -198,6 +198,7 @@ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-storage-rm linked": "true", "supabase-test-db local": "true", "supabase-test-new template": "pgtap", + "supabase-workers-push instances": "1", }; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index d9ad846999..963e9b295e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -120,6 +120,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "git-branch", "import-map", "inspect-mode", + "instances", "lang", "last", "metadata-file", diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts new file mode 100644 index 0000000000..37f28ad05c --- /dev/null +++ b/apps/cli/src/shared/workers/tar.ts @@ -0,0 +1,224 @@ +/** + * A minimal USTAR writer, for the `.tar.gz` build context `supabase workers + * push` uploads. + * + * Shelling out to `tar` would be shorter, but the CLI ships as a single + * compiled binary to machines where `tar` may be BSD tar, GNU tar, or absent + * (Windows), and each writes a different archive for the same directory. The + * server only ever untars what we send, so producing the bytes here keeps the + * upload identical on every platform and keeps packaging out of the process + * table. + */ + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +const BLOCK_SIZE = 512; + +export interface TarEntry { + /** Path inside the archive, always `/`-separated and relative. */ + readonly path: string; + readonly contents: Uint8Array; + /** Unix mode bits. Defaults to `0o644`. */ + readonly mode?: number; + /** Modification time in seconds since the epoch. Defaults to `0`. */ + readonly mtime?: number; + /** + * Target of a symbolic link. When set the entry is stored as a link rather + * than as its contents, which is what keeps a symlink-dense tree (anything + * pnpm installed) from being inlined — and what stops a link to a directory + * from being walked into. + */ + readonly linkTarget?: string; +} + +/** + * The largest value an 11-digit octal field can hold: 8 GiB minus one byte for + * a size, and a little past the year 2242 for an mtime. + */ +const MAX_OCTAL_FIELD = 8 ** 11 - 1; + +/** + * USTAR stores numbers as zero-padded octal followed by a NUL. + * + * A value too large for the field renders one digit too long and spills into the + * next field, producing an archive that reads back with a plausible but wrong + * size — corruption no reader can detect. Real tars switch to base-256 here; + * this writer refuses instead, because a build context carrying an 8 GiB file is + * already a mistake worth naming rather than silently mangling. + */ +function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { + const text = Math.floor(value) + .toString(8) + .padStart(length - 1, "0"); + if (text.length > length - 1) { + throw new TarFieldTooLargeError(value); + } + writeAscii(block, offset, text); +} + +function writeAscii(block: Uint8Array, offset: number, value: string): void { + for (let index = 0; index < value.length; index++) { + block[offset + index] = value.charCodeAt(index) & 0xff; + } +} + +/** + * Split a path into USTAR's `prefix` (155 bytes) and `name` (100 bytes) fields. + * The split has to fall on a `/`, so a single path component longer than 100 + * bytes cannot be represented at all. + */ +function splitPath(path: string): { name: string; prefix: string } | undefined { + if (byteLength(path) <= 100) { + return { name: path, prefix: "" }; + } + + for (let index = path.indexOf("/"); index !== -1; index = path.indexOf("/", index + 1)) { + const prefix = path.slice(0, index); + const name = path.slice(index + 1); + if (byteLength(prefix) <= 155 && byteLength(name) <= 100) { + return { name, prefix }; + } + } + + return undefined; +} + +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +/** + * Thrown for a path USTAR cannot represent. A plain `Error` rather than a + * tagged one because `createTar` is a pure synchronous function with no Effect + * semantics of its own; the caller's own error channel is where this surfaces. + * It is still user-actionable — renaming the offending file fixes it — so it + * carries its own classification, and the static identifier keeps the + * fingerprint stable through minification. + */ +export class TarPathTooLongError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarPathTooLongError"; + + constructor(path: string) { + super( + `"${path}" is too long for a tar archive (over 100 bytes with no directory boundary to split on)`, + ); + this.name = "TarPathTooLongError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * Thrown for a number USTAR's octal fields cannot hold — see {@link writeOctal}. + * Untagged for the same reason as {@link TarPathTooLongError}: `createTar` is a + * pure function, and the caller's error channel is where this surfaces. + */ +export class TarFieldTooLargeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarFieldTooLargeError"; + + constructor(value: number) { + super( + `${value} is too large for a tar header field (the limit is ${MAX_OCTAL_FIELD} bytes per file)`, + ); + this.name = "TarFieldTooLargeError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +function header(entry: TarEntry, typeflag: "0" | "2" | "5", size: number): Uint8Array { + const block = new Uint8Array(BLOCK_SIZE); + const split = splitPath(entry.path); + if (split === undefined) { + throw new TarPathTooLongError(entry.path); + } + + const encodedName = encoder.encode(split.name); + block.set(encodedName, 0); + writeOctal(block, 100, 8, entry.mode ?? 0o644); + writeOctal(block, 108, 8, 0); // uid + writeOctal(block, 116, 8, 0); // gid + writeOctal(block, 124, 12, size); + writeOctal(block, 136, 12, entry.mtime ?? 0); + // The checksum field is treated as spaces while the checksum is computed. + block.fill(0x20, 148, 156); + block[156] = typeflag.charCodeAt(0); + if (entry.linkTarget !== undefined) { + const encodedTarget = encoder.encode(entry.linkTarget); + if (encodedTarget.length > 100) { + throw new TarPathTooLongError(entry.linkTarget); + } + block.set(encodedTarget, 157); + } + writeAscii(block, 257, "ustar"); + writeAscii(block, 263, "00"); + block.set(encoder.encode(split.prefix), 345); + + let checksum = 0; + for (const byte of block) { + checksum += byte; + } + // Six octal digits, a NUL, then a space — the form every tar reads. + writeAscii(block, 148, checksum.toString(8).padStart(6, "0")); + block[154] = 0; + block[155] = 0x20; + + return block; +} + +function padding(size: number): number { + const remainder = size % BLOCK_SIZE; + return remainder === 0 ? 0 : BLOCK_SIZE - remainder; +} + +/** + * Build a USTAR archive from `entries`, in the order given. An entry with a + * `linkTarget` is stored as a symbolic link, a path ending in `/` as a + * directory, and everything else as a regular file. The archive ends with the + * two zero blocks every reader expects. + */ +export function createTar(entries: ReadonlyArray): Uint8Array { + const blocks: Array = []; + let total = 0; + + const push = (block: Uint8Array) => { + blocks.push(block); + total += block.length; + }; + + for (const entry of entries) { + const isSymlink = entry.linkTarget !== undefined; + const isDirectory = !isSymlink && entry.path.endsWith("/"); + // A link's target lives in the header, so it carries no content blocks. + const size = isDirectory || isSymlink ? 0 : entry.contents.length; + push(header(entry, isSymlink ? "2" : isDirectory ? "5" : "0", size)); + if (size > 0) { + push(entry.contents); + const pad = padding(size); + if (pad > 0) { + push(new Uint8Array(pad)); + } + } + } + + push(new Uint8Array(BLOCK_SIZE * 2)); + + const archive = new Uint8Array(total); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.length; + } + return archive; +} diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts new file mode 100644 index 0000000000..c7449efeb6 --- /dev/null +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { createTar, TarFieldTooLargeError, TarPathTooLongError } from "./tar.ts"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function field(archive: Uint8Array, block: number, offset: number, length: number): string { + return decoder.decode(archive.subarray(block * 512 + offset, block * 512 + offset + length)); +} + +/** Trim a NUL-padded USTAR field down to its value. */ +function value(archive: Uint8Array, block: number, offset: number, length: number): string { + return (field(archive, block, offset, length).split("\u0000")[0] ?? "").trim(); +} + +describe("createTar", () => { + test("writes a readable ustar header for a file", () => { + const archive = createTar([ + { path: "index.js", contents: encoder.encode("hello"), mode: 0o644, mtime: 1_700_000_000 }, + ]); + + expect(value(archive, 0, 0, 100)).toBe("index.js"); + expect(value(archive, 0, 100, 8)).toBe("0000644"); + expect(value(archive, 0, 124, 12)).toBe("00000000005"); + expect(value(archive, 0, 136, 12)).toBe("14524770400"); + expect(field(archive, 0, 156, 1)).toBe("0"); + expect(value(archive, 0, 257, 6)).toBe("ustar"); + }); + + test("computes a checksum the standard algorithm reproduces", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("a") }]); + const header = archive.subarray(0, 512); + + const recorded = Number.parseInt(value(archive, 0, 148, 8), 8); + let computed = 0; + for (let index = 0; index < 512; index++) { + // The checksum field itself counts as spaces. + computed += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0); + } + + expect(recorded).toBe(computed); + }); + + test("pads content to a 512-byte boundary and ends with two zero blocks", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("hello") }]); + + // header + one padded content block + two trailing zero blocks + expect(archive.length).toBe(512 * 4); + expect(decoder.decode(archive.subarray(512, 517))).toBe("hello"); + expect(archive.subarray(512 * 2).every((byte) => byte === 0)).toBe(true); + }); + + test("emits directory entries with no content and the directory typeflag", () => { + const archive = createTar([ + { path: "nested/", contents: new Uint8Array(0), mode: 0o755 }, + { path: "nested/a.txt", contents: encoder.encode("a") }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("5"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // The directory has no content block, so the next header follows immediately. + expect(value(archive, 1, 0, 100)).toBe("nested/a.txt"); + }); + + test("stores a symlink as a link entry with no content blocks", () => { + const archive = createTar([ + { path: "link.txt", contents: new Uint8Array(0), linkTarget: "target.txt", mode: 0o777 }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + expect(value(archive, 0, 157, 100)).toBe("target.txt"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // Header plus the two trailing zero blocks — no content block in between. + expect(archive.length).toBe(512 * 3); + }); + + test("a symlink entry wins over the trailing-slash directory rule", () => { + const archive = createTar([{ path: "dir", contents: new Uint8Array(0), linkTarget: ".." }]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + }); + + test("refuses a link target too long for the header field", () => { + expect(() => + createTar([ + { path: "link", contents: new Uint8Array(0), linkTarget: `${"t".repeat(120)}.txt` }, + ]), + ).toThrow(TarPathTooLongError); + }); + + test("splits a long path across the prefix and name fields", () => { + const deep = `${"d".repeat(120)}/${"f".repeat(60)}.txt`; + const archive = createTar([{ path: deep, contents: new Uint8Array(0) }]); + + expect(value(archive, 0, 345, 155)).toBe("d".repeat(120)); + expect(value(archive, 0, 0, 100)).toBe(`${"f".repeat(60)}.txt`); + }); + + test("refuses a value too large for an octal header field rather than truncating it", () => { + // One past the 11-digit octal ceiling. Encoding it would spill a digit into + // the next field and read back as a plausible but wrong number. + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), + ).toThrow(TarFieldTooLargeError); + + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 - 1 }]), + ).not.toThrow(); + }); + + test("refuses a path component too long to represent", () => { + expect(() => + createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), + ).toThrow(TarPathTooLongError); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-classify.ts b/apps/cli/src/shared/workers/worker-classify.ts new file mode 100644 index 0000000000..64e19906ec --- /dev/null +++ b/apps/cli/src/shared/workers/worker-classify.ts @@ -0,0 +1,48 @@ +import { join } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { DEFAULT_WORKER_RUNTIME, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * Best-effort classification of a worker directory into a {@link WorkerRuntime} + * from common marker files, so `supabase workers push` can deploy a directory + * that has no `[workers.] runtime` at all. The guess is always reported, + * with a nudge to pin it down, rather than applied silently. + */ + +interface WorkerClassification { + readonly runtime: WorkerRuntime; + /** Human-readable reason, for the line `push` logs about the guess. */ + readonly reason: string; +} + +const MARKERS: ReadonlyArray<{ + readonly runtime: WorkerRuntime; + readonly files: ReadonlyArray; +}> = [ + // An explicit Dockerfile always wins: it is a deliberate signal, not an + // inference. + { runtime: "dockerfile", files: ["Dockerfile"] }, + // Deno is checked before plain `package.json` because a Deno project can + // still have one (editor tooling, a stray dependency) while a Node project + // has no `deno.json`. + { runtime: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"] }, + { runtime: "node", files: ["package.json"] }, +]; + +export const classifyWorkerDir = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + + for (const marker of MARKERS) { + for (const file of marker.files) { + const found = yield* fs.exists(join(dir, file)).pipe(Effect.orElseSucceed(() => false)); + if (found) { + return { runtime: marker.runtime, reason: `found ${file}` } satisfies WorkerClassification; + } + } + } + + return { + runtime: DEFAULT_WORKER_RUNTIME, + reason: `no recognized marker files, defaulting to ${DEFAULT_WORKER_RUNTIME}`, + } satisfies WorkerClassification; +}); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index b316692178..20f7abaa1c 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 instances?: number; readonly source?: string; } @@ -53,6 +54,14 @@ const stringOrUndefined = (value: unknown): string | undefined => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +/** + * A count only counts if it is a non-negative whole number. Anything else is + * dropped so `push` falls back to its own default; the config schema is what + * tells the user the value was wrong. + */ +const instanceCountOrUndefined = (value: unknown): number | undefined => + typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; + /** * The decoded `[workers]` section as per-worker tables. Anything that is not an * object is dropped rather than read as a worker named after it. @@ -76,6 +85,7 @@ export function readWorkersSection(workers: unknown): WorkersSection { entries[key] = { runtime: stringOrUndefined(value["runtime"]), size: stringOrUndefined(value["size"]), + 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 668ef584d3..15ccdc0afd 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -15,23 +15,41 @@ describe("readWorkersSection", () => { test("reads each worker's recorded dials", () => { expect( readWorkersSection({ - api: { runtime: "node", size: "2gb", source: "packages/api" }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, box: { runtime: "sandbox" }, }), ).toEqual({ workers: { - api: { runtime: "node", size: "2gb", source: "packages/api" }, - box: { runtime: "sandbox", size: undefined, source: undefined }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, instances: undefined, source: undefined }, }, }); }); 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, source: undefined } }, + workers: { + api: { runtime: undefined, size: undefined, instances: undefined, source: undefined }, + }, }); }); + // `push` has to send a count with every deploy, so a value the API would + // reject is dropped here and the default used instead. + test.each([ + ["a float", 1.5], + ["a negative", -1], + ["a string", "3"], + ])("drops %s instance count", (_label, value) => { + expect(readWorkersSection({ api: { instances: value } }).workers["api"]?.instances).toBe( + undefined, + ); + }); + + test("keeps a zero instance count, which scales a worker down rather than being absent", () => { + expect(readWorkersSection({ api: { instances: 0 } }).workers["api"]?.instances).toBe(0); + }); + 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-package.ts b/apps/cli/src/shared/workers/worker-package.ts new file mode 100644 index 0000000000..50078f9363 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -0,0 +1,133 @@ +import { gzipSync } from "node:zlib"; +import { Effect, FileSystem } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; + +/** + * Package a worker's source directory into the `.tar.gz` build context the + * Workers API's upload slot expects. + * + * Nothing is excluded. For a `dockerfile` worker the archive is the build + * context, so it has to be what the user's own `Dockerfile` expects to find; + * for a catalog runtime the server synthesizes `FROM ` + `COPY` with no + * install step of its own, so an installed `node_modules/` is a dependency of + * the deploy rather than noise in it. The packaged size is reported back so a + * directory that has grown past what anyone meant to upload is visible before + * the upload rather than after it. + */ + +interface PackagedWorker { + readonly archive: Uint8Array; + readonly fileCount: number; +} + +/** + * Every entry under `root`, as tar entries. + * + * Filesystem errors propagate rather than being skipped: an entry missing from + * the archive means deploying an application with a hole in it, reported as a + * success. A directory the walk cannot read, a file it cannot open and an entry + * that vanishes mid-walk are all that case. + */ +const collectEntries = ( + root: string, + relativeDir: string, +): Effect.Effect, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; + + const names = yield* fs.readDirectory(absoluteDir); + const entries: Array = []; + + for (const name of [...names].sort()) { + const relativePath = relativeDir === "" ? name : `${relativeDir}/${name}`; + const absolutePath = `${root}/${relativePath}`; + + // `readLink` succeeds only for symlinks, so it stands in for the `lstat` + // this FileSystem service does not expose (the same probe + // `legacy-sql-files-glob.ts` uses). Storing the link rather than following + // it is what keeps a pnpm-installed `node_modules` from being inlined file + // by file, keeps a broken link from vanishing, and stops a link pointing at + // an ancestor from being walked into. + const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); + if (linkTarget._tag === "Some") { + entries.push({ + path: relativePath, + contents: new Uint8Array(0), + mode: 0o777, + mtime: 0, + linkTarget: linkTarget.value, + }); + continue; + } + + const info = yield* fs.stat(absolutePath); + + const modified = info.mtime; + const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + + if (info.type === "Directory") { + entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); + entries.push(...(yield* collectEntries(root, relativePath))); + continue; + } + + if (info.type !== "File") { + // Sockets, FIFOs and devices have nothing meaningful to send. + continue; + } + + const contents = yield* fs.readFile(absolutePath); + // The executable bit is the only permission that changes what the image + // does; everything else is normalized so the same tree packages + // identically on every machine. `mode` is a plain number here, unlike the + // `Option`-wrapped `mtime` above. + const executable = (info.mode & 0o111) !== 0; + entries.push({ + path: relativePath, + contents: new Uint8Array(contents), + mode: executable ? 0o755 : 0o644, + mtime, + }); + } + + return entries; + }); + +export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { + const entries = yield* collectEntries(dir, ""); + + // `createTar` throws for a name USTAR cannot represent, such as a path + // component over 100 bytes. That is user-actionable, so it belongs in the + // failure channel: `withJsonErrorHandling` only catches failures, and a defect + // would exit `--output-format json` with no structured error. + const archive = yield* Effect.try({ + try: () => gzipSync(createTar(entries)), + catch: (cause) => { + if (cause instanceof TarPathTooLongError) { + return cause; + } + // Anything else here really is a bug, so let it stay a defect rather than + // dressing it up as a failure the user could act on. + throw cause; + }, + }); + + return { + archive: new Uint8Array(archive), + fileCount: entries.filter((entry) => !entry.path.endsWith("/")).length, + } satisfies PackagedWorker; +}); + +/** `10 KiB` / `1.4 MiB` — the packaged size, as `push` reports it. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const kib = bytes / 1024; + if (kib < 1024) { + return `${Math.ceil(kib)} KiB`; + } + return `${(kib / 1024).toFixed(1)} MiB`; +} diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts new file mode 100644 index 0000000000..83a1e6545d --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -0,0 +1,216 @@ +import { + accessSync, + chmodSync, + constants, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { gunzipSync } from "node:zlib"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; + +/** + * Whether the current user can still read `path` after it was chmod-ed shut. + * + * Root ignores the permission bits, and CI sometimes runs as root, so the + * permission-denied tests below assert the opposite outcome instead of skipping + * — either way the behaviour under test is pinned. + */ +function readableAsCurrentUser(path: string): boolean { + try { + accessSync(path, constants.R_OK); + return true; + } catch { + return false; + } +} + +function listableAsCurrentUser(path: string): boolean { + try { + readdirSync(path); + return true; + } catch { + return false; + } +} + +/** Entry paths and their USTAR typeflags, read back out of the archive. */ +function readEntries(archive: Uint8Array): Array<{ path: string; type: string; link: string }> { + const raw = new Uint8Array(gunzipSync(archive)); + const decoder = new TextDecoder(); + const trim = (value: string) => value.split("\u0000")[0] ?? ""; + const entries: Array<{ path: string; type: string; link: string }> = []; + + for (let offset = 0; offset + 512 <= raw.length;) { + const name = trim(decoder.decode(raw.subarray(offset, offset + 100))); + if (name === "") { + break; + } + const size = Number.parseInt(trim(decoder.decode(raw.subarray(offset + 124, offset + 136))), 8); + entries.push({ + path: name, + type: decoder.decode(raw.subarray(offset + 156, offset + 157)), + link: trim(decoder.decode(raw.subarray(offset + 157, offset + 257))), + }); + offset += 512 + Math.ceil(size / 512) * 512; + } + return entries; +} + +describe("packageWorkerDirectory", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-package-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const pack = (root: string) => + Effect.runPromise(packageWorkerDirectory(root).pipe(Effect.provide(BunServices.layer))); + + test("packages files and nested directories in a stable order", async () => { + mkdirSync(join(dir, "nested")); + writeFileSync(join(dir, "b.txt"), "b"); + writeFileSync(join(dir, "a.txt"), "a"); + writeFileSync(join(dir, "nested", "c.txt"), "c"); + + const result = await pack(dir); + + expect(readEntries(result.archive).map((entry) => entry.path)).toEqual([ + "a.txt", + "b.txt", + "nested/", + "nested/c.txt", + ]); + expect(result.fileCount).toBe(3); + }); + + // Anything pnpm installs is symlink-dense, so following links would inline + // every dependency's real contents — and a link pointing at an ancestor would + // be walked into until the OS refused. + test("stores symlinks as links rather than following them", async () => { + writeFileSync(join(dir, "target.txt"), "hello"); + symlinkSync("target.txt", join(dir, "link.txt")); + + const entries = readEntries((await pack(dir)).archive); + const link = entries.find((entry) => entry.path === "link.txt"); + + expect(link?.type).toBe("2"); + expect(link?.link).toBe("target.txt"); + }); + + test("keeps a broken symlink instead of dropping it", async () => { + symlinkSync("/nowhere-at-all", join(dir, "broken.txt")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); + }); + + test("does not recurse through a directory symlink that points at an ancestor", async () => { + mkdirSync(join(dir, "sub")); + writeFileSync(join(dir, "keep.txt"), "k"); + symlinkSync("..", join(dir, "sub", "up")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.map((entry) => entry.path).sort()).toEqual(["keep.txt", "sub/", "sub/up"]); + expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); + }); + + test("packages an empty directory to an archive with no entries", async () => { + const result = await pack(dir); + + expect(readEntries(result.archive)).toEqual([]); + expect(result.fileCount).toBe(0); + }); + + // A file that cannot be read used to be archived as zero bytes, so `push` + // reported success for a deploy that shipped an empty file. Failing is the + // only honest answer: the archive is the application. + test("fails rather than archiving a file it cannot read as empty", async () => { + const unreadable = join(dir, "secret.txt"); + writeFileSync(unreadable, "important"); + chmodSync(unreadable, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + // Running as root defeats the permission, so only assert when it took hold. + if (readableAsCurrentUser(unreadable)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(unreadable, 0o600); + }); + + test("fails rather than silently dropping a directory it cannot read", async () => { + const locked = join(dir, "locked"); + mkdirSync(locked); + writeFileSync(join(locked, "inside.txt"), "content"); + chmodSync(locked, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + if (listableAsCurrentUser(locked)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(locked, 0o700); + }); +}); + +// `createTar` throws for a name USTAR cannot represent. Called directly inside +// the generator that became a defect, which `withJsonErrorHandling` does not +// catch — so `--output-format json` would have died with no structured error. +describe("packageWorkerDirectory tar limits", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-tar-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("reports an unrepresentable path as a failure rather than a defect", async () => { + // One component over 100 bytes, with no directory boundary to split on. + writeFileSync(join(dir, "a".repeat(120)), "contents"); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + // A failure, not a defect: the difference is whether the JSON error handler + // ever sees it. + expect(JSON.stringify(exit)).toContain("TarPathTooLong"); + }); +}); + +describe("formatBytes", () => { + test("reports each magnitude in the unit a reader expects", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1023)).toBe("1023 B"); + expect(formatBytes(1024)).toBe("1 KiB"); + expect(formatBytes(1024 * 1024)).toBe("1.0 MiB"); + expect(formatBytes(1024 * 1024 * 1.5)).toBe("1.5 MiB"); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 7c9f93e8eb..897087b073 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -65,6 +65,13 @@ export type WorkerSize = (typeof WORKER_SIZES)[number]; /** The first available option — what `new` records when `--size` is omitted. */ export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; +/** + * Instances a worker runs when neither `--instances` nor `[workers.] + * instances` says otherwise. One, because a deploy has to name a count — the + * API's spec requires it — and a worker nobody has scaled is a single instance. + */ +export const DEFAULT_WORKER_INSTANCES = 1; + function isWorkerSize(value: string): value is WorkerSize { return WORKER_SIZES.some((size) => size === value); } diff --git a/apps/cli/src/shared/workers/worker-url.ts b/apps/cli/src/shared/workers/worker-url.ts new file mode 100644 index 0000000000..d82da066f3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-url.ts @@ -0,0 +1,17 @@ +/** + * Where a worker is served. + * + * Every worker gets a path on the project's own API host, exactly like an Edge + * Function — `.supabase.co/workers/v1/` next to + * `.supabase.co/functions/v1/`. One host per project, one path per + * worker: nothing per-worker is provisioned in DNS, so the URL is derived from + * the name rather than returned by the API. + */ + +/** Path prefix workers are served under, mirroring `functions/v1`. */ +const WORKERS_PATH_PREFIX = "/workers/v1"; + +/** The canonical URL of a worker on its project's API host. */ +export function workerUrl(projectRef: string, projectHost: string, name: string): string { + return `https://${projectRef}.${projectHost}${WORKERS_PATH_PREFIX}/${name}`; +} diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts new file mode 100644 index 0000000000..79409d05f6 --- /dev/null +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -0,0 +1,429 @@ +import { + markSupabaseApiInputErrorAsUserInput, + operationDefinitions, + SupabaseApiInputError, + V2CreateWorkerUploadOutput, + V2DeployAWorkerOutput, + V2GetAWorkerOutput, + type ApiClient, +} from "@supabase/api/effect"; +import { Effect, Option, Schedule, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { + WorkerBuildTimeoutError, + WorkersApiNetworkError, + WorkerProjectNotFoundError, + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, + WorkerUploadFailedError, +} from "./workers.errors.ts"; + +/** + * The seam every worker command talks to: `/v2/projects/{ref}/workers` on the + * Management API. + * + * The routes are deliberately few — list, get, mint an upload slot, deploy, + * delete — so this module is thin, and what it mostly adds is status handling. + * The alpha's allow-list answers 404 for a project that is not enrolled, which + * at the transport level is indistinguishable from "no such worker"; so a 404 + * on a collection endpoint (where no worker name could have been wrong) becomes + * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by + * the caller as "not deployed". + */ + +/** The worker shape the API returns, flattened out of its JSON:API envelope. */ +export interface WorkerRecord { + readonly name: string; + readonly spec: { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; + readonly backend?: string; + }; + readonly buildState: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + /** Present only on single-worker reads; a fresh deploy has nothing to report yet. */ + readonly instances?: { + readonly declared: number; + readonly live: number; + readonly ready: number; + readonly stale: number; + }; + /** Set instead of `instances` when the instance read-through failed. */ + readonly instancesError?: string; +} + +export interface WorkerUploadSlot { + readonly uploadId: string; + readonly url: string; + readonly method: string; + readonly expiresAt: string; +} + +/** The `spec` a deploy sends. Mirrors the API's own field names exactly. */ +export interface WorkerDeploySpec { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; +} + +type WorkerResourceData = typeof V2GetAWorkerOutput.Type extends { data: infer D } ? D : never; + +function toWorkerRecord(data: WorkerResourceData): WorkerRecord { + return { + name: data.id, + spec: data.attributes.spec, + buildState: data.attributes.build_state, + stateReason: data.attributes.state_reason, + imageVersion: data.attributes.image_version, + deleting: data.attributes.deleting, + instances: data.attributes.instances, + instancesError: data.attributes.instances_error, + }; +} + +const workersSuggestion = + "Workers are in private alpha. Ask in the Supabase dashboard to have this project enrolled."; + +/** + * The `error.code` a 404 carries, which is the only thing separating a project + * outside the alpha's allow-list from one that does not exist. Both answer 404 + * on the same routes; the bodies differ: + * + * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Workers are not available for this project"}}` + * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` + */ +const NotFoundBody = Schema.Struct({ + error: Schema.Struct({ code: Schema.String }), +}); + +/** + * Which of the two a project-scoped 404 was. + * + * Only `not_found` is read as a missing project — an unrecognized body keeps + * the enrolment answer, because that is what the alpha's allow-list has + * historically returned and guessing the other way would send someone to check + * a ref that is fine. + */ +const projectScoped404 = Effect.fnUntraced(function* (options: { + readonly projectRef: string; + readonly body: string; +}) { + const parsed = yield* Effect.try(() => JSON.parse(options.body) as unknown).pipe( + Effect.flatMap((json) => Schema.decodeUnknownEffect(NotFoundBody)(json)), + Effect.option, + ); + + if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { + return new WorkerProjectNotFoundError({ + detail: `No project ${options.projectRef} was found for this account.`, + suggestion: + "Check the project ref, or pick the project again with `supabase link`. " + + "If it belongs to another account, log in with `supabase login`.", + }); + } + + return new WorkersUnavailableError({ + detail: `Workers are not available for project ${options.projectRef}.`, + suggestion: workersSuggestion, + }); +}); + +/** + * Everything that can go wrong before a status code exists: the generated input + * schema rejecting the request, or the transport failing outright. + */ +function mapRequestError(operation: string) { + return (error: unknown) => { + if (error instanceof SupabaseApiInputError) { + // The only inputs these operations take are the resolved project ref and + // the prevalidated worker name, so a schema rejection is user-derived. + return markSupabaseApiInputErrorAsUserInput(error); + } + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${description}.`, + suggestion: "Check your network connection and retry.", + }); + } + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${String(error)}.`, + suggestion: "Check your network connection and retry.", + }); + }; +} + +const unexpectedStatus = Effect.fnUntraced(function* (options: { + readonly operation: string; + readonly status: number; + readonly body: string; +}) { + const trimmed = options.body.trim(); + return yield* Effect.fail( + new WorkersApiUnexpectedStatusError({ + status: options.status, + detail: `The Workers API answered ${options.status} while trying to ${options.operation}${ + trimmed === "" ? "" : `: ${trimmed}` + }.`, + suggestion: "Retry shortly; if it persists, report it with `supabase issue`.", + }), + ); +}); + +const decodeBody = ( + schema: Schema.Codec, + operation: string, + body: unknown, + status: number, +) => + Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError( + (error) => + new WorkersApiUnexpectedStatusError({ + status, + detail: `The Workers API returned a response this CLI could not read while trying to ${operation}: ${error.message}.`, + suggestion: "Update the CLI with `supabase update`, then retry.", + }), + ), + ); + +/** + * One worker, or `None` when the API has no record of it — which is also what a + * project outside the alpha's allow-list answers, so callers report it as "not + * deployed" and point at `push` rather than guessing which of the two it was. + */ +const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { + const operation = `read worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return Option.none(); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2GetAWorkerOutput, operation, body, response.status); + return Option.some(toWorkerRecord(decoded.data)); +}); + +export const createWorkerUpload = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `stage a build context for "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2CreateWorkerUpload, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 201 && response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2CreateWorkerUploadOutput, operation, body, response.status); + return { + uploadId: decoded.data.id, + url: decoded.data.attributes.url, + method: decoded.data.attributes.method, + expiresAt: decoded.data.attributes.expires_at, + } satisfies WorkerUploadSlot; +}); + +/** + * PUT the archive straight at the presigned slot. The bytes never pass through + * the Management API, so this goes out with no Supabase credentials attached — + * the signature in the URL is the authorization. + * + * That signature is why `legacyHttpClientLayer` redacts query strings before + * logging them — under `--debug` this URL is a write-capable credential. Done + * there rather than here, so the client stays injectable and every presigned URL + * is covered rather than this one call site. + */ +export const uploadBuildContext = Effect.fnUntraced(function* ( + slot: WorkerUploadSlot, + archive: Uint8Array, +) { + const client = yield* HttpClient.HttpClient; + + // The slot names its own method; the API documents `PUT` and nothing else is + // meaningful for a presigned object-store destination, so anything unexpected + // falls back to it rather than assembling a request we cannot build. + const request = ( + slot.method.toUpperCase() === "POST" + ? HttpClientRequest.post(slot.url) + : HttpClientRequest.put(slot.url) + ).pipe(HttpClientRequest.bodyUint8Array(archive, "application/gzip")); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (error) => + new WorkerUploadFailedError({ + detail: `Uploading the build context failed: ${ + error.reason.description ?? error.reason._tag + }.`, + suggestion: "Check your network connection, then re-run the same command.", + }), + ), + ); + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new WorkerUploadFailedError({ + detail: `Uploading the build context failed with status ${response.status}${ + body.trim() === "" ? "" : `: ${body.trim()}` + }.`, + suggestion: "Re-run the same command; the upload slot is minted fresh each time.", + }), + ); + } +}); + +export const deployWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + attributes: { readonly spec: WorkerDeploySpec; readonly contextUploadId?: string }, +) { + const operation = `deploy worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeployAWorker, { + ref: projectRef, + name, + data: { + type: "project_worker", + attributes: { + spec: attributes.spec, + ...(attributes.contextUploadId === undefined + ? {} + : { context_upload_id: attributes.contextUploadId }), + }, + }, + }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 202 && response.status !== 200 && response.status !== 201) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2DeployAWorkerOutput, operation, body, response.status); + return toWorkerRecord(decoded.data); +}); + +/** + * The build runs asynchronously — deploy answers 202 and the worker reaches + * `active` or `failed` later — so `push` polls `get` until `build_state` leaves + * `building`. + * + * The schedule is a parameter so tests can drive the same loop without waiting + * on wall-clock delays. + */ +const WORKER_BUILD_POLL_SCHEDULE = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "10 minutes" }), +); + +/** + * How long one poll read is allowed to keep failing before the deploy is called + * off. + * + * Bounded by elapsed time, not attempts: unspaced attempts are exhausted by a + * two-second blip, abandoning a build the server is still running. Half a minute + * of spaced retries rides that out, and anything still failing after it is the + * real error. + */ +const WORKER_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "30 seconds" }), +); + +export const awaitWorkerBuild = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + options: { + readonly schedule?: Schedule.Schedule; + /** + * Retry schedule for one poll read. A parameter for the same reason + * `schedule` is: it is spaced in seconds, and a test exercising the + * transient-failure path should not wait on a real clock to do it. + */ + readonly retrySchedule?: Schedule.Schedule; + /** Called with each poll's result, for progress reporting. */ + readonly onPoll?: (worker: WorkerRecord) => Effect.Effect; + } = {}, +) { + const poll = Effect.gen(function* () { + // A build can run for minutes, so a single blip on one read should not throw + // away a deploy that is progressing fine. + const worker = yield* getWorker(api, projectRef, name).pipe( + Effect.retry({ schedule: options.retrySchedule ?? WORKER_POLL_READ_RETRY }), + ); + if (Option.isNone(worker)) { + // The deploy was accepted, so the worker exists; a 404 here is the read + // racing the write. Report it as still building and poll again. + return undefined; + } + if (options.onPoll !== undefined) { + yield* options.onPoll(worker.value); + } + return worker.value.buildState === "building" ? undefined : worker.value; + }); + + const settled = yield* poll.pipe( + Effect.repeat({ + schedule: options.schedule ?? WORKER_BUILD_POLL_SCHEDULE, + until: (result) => result !== undefined, + }), + ); + + if (settled === undefined) { + return yield* Effect.fail( + new WorkerBuildTimeoutError({ + detail: `"${name}" was still building when this command stopped waiting.`, + suggestion: `Check on it with \`supabase workers status ${name}\`.`, + }), + ); + } + + return settled; +}); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index ecd09ac1fb..7e01fc5bfb 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../telemetry/error-actionability.ts"; /** @@ -20,6 +21,41 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** A bare `push` found no workers to deploy — none named, none in the project. */ +export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `config.toml` records a runtime this CLI does not offer. + * + * Raised by `push`, the command that reads a worker's runtime back out of + * config; `new` writes one and never reads it. + */ +export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** As {@link UnknownWorkerRuntimeError}, for a recorded instance size. */ +export class UnknownWorkerSizeError extends Data.TaggedError("UnknownWorkerSizeError")<{ + 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; @@ -29,6 +65,15 @@ export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirector } } +export class WorkerSourceMissingError extends Data.TaggedError("WorkerSourceMissingError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * `--source` names a directory it is not allowed to name. Worth its own error * because the destination is where the starter files land, so a value that @@ -43,3 +88,94 @@ export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSou return actionability.provideFlags; } } + +/** The deploy finished, and the build it started failed. */ +export class WorkerBuildFailedError extends Data.TaggedError("WorkerBuildFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** The build never left `building` inside the CLI's polling budget. */ +export class WorkerBuildTimeoutError extends Data.TaggedError("WorkerBuildTimeoutError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} + +/** PUTting the build context to the presigned slot failed. */ +export class WorkerUploadFailedError extends Data.TaggedError("WorkerUploadFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** Transport failure talking to the Management API. */ +export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** + * Workers are in private alpha: the routes answer 404 for a project that is not + * enrolled, which is indistinguishable from an unknown worker at the transport + * level — so this is only raised for the collection endpoints, where there is + * no worker name that could have been wrong. + */ +export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailableError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * The project ref names no project this account can see. + * + * Separated from {@link WorkersUnavailableError} because both arrive as a 404 + * on the same routes, and telling someone to request alpha enrolment for a + * project that does not exist sends them somewhere that cannot help. + */ +export class WorkerProjectNotFoundError extends Data.TaggedError("WorkerProjectNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * Any other status the Workers routes answered with. + * + * Classified from the status it carries rather than bucketed as a service + * failure: a 401 is the user's to fix by logging in and a 403 by getting access, + * and reporting either as `api_status` both misleads the user and blurs the + * actionability signal for every Workers endpoint at once. + */ +export class WorkersApiUnexpectedStatusError extends Data.TaggedError( + "WorkersApiUnexpectedStatusError", +)<{ + readonly detail: string; + readonly suggestion: string; + readonly status: number; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 774e41baed..054add765b 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -14,10 +14,8 @@ import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; -import { - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, -} from "./legacy-mocks.ts"; +import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; +import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; /** @@ -233,6 +231,30 @@ export interface WorkersSetupOptions { readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; } +/** + * `LegacyTelemetryState`, recording whether it was flushed. + * + * Every worker command is supposed to write the telemetry state file on every + * invocation, success or failure — which is only observable if the mock says so, + * so the shared always-void mock cannot cover it. + */ +function mockWorkersTelemetryState() { + let flushed = false; + return { + layer: Layer.succeed(LegacyTelemetryState, { + flush: Effect.sync(() => { + flushed = true; + }), + stitchLogin: () => Effect.void, + clearDistinctId: Effect.void, + resetIdentity: Effect.void, + } as unknown as LegacyTelemetryState["Service"]), + get flushed() { + return flushed; + }, + }; +} + export function setupLegacyWorkers(options: WorkersSetupOptions) { const out = mockOutput({ format: options.format ?? "text", @@ -245,17 +267,19 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { : { promptSelectResponses: options.promptSelectResponses }), }); const http = mockWorkersHttp(options.routes ?? {}); + const telemetry = mockWorkersTelemetryState(); return { out, http, + telemetry, layer: Layer.mergeAll( out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), legacyTestCliConfigLayer(options.workdir), legacyTestProjectRefLayer(options.linked !== false), - mockLegacyTelemetryStateLayer, + telemetry.layer, mockLegacyLinkedProjectCacheLayer, randomLayer, Layer.succeed( From 69c83d2d55a228d8b920dadc4703d342c07f1e12 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:00:07 -0300 Subject: [PATCH 05/50] feat(config): add the [workers] section to the project config schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers record their runtime, instance size, instance count and source directory in `supabase/config.toml`, keyed `[workers.]`, next to the `[functions.]` entries already in the same file. The section is a plain `Schema.Record`: one sub-table per worker and no project-wide scalar sitting beside them, so there is nothing for the index signature to collide with. Worker names are DNS labels, matching what the Management API validates its `:name` path parameter against, since they end up in hostnames. `instances` is bounded as a non-negative integer to match `spec.instances` in the API's own input schema — a value that gets past the schema is dropped rather than sent, so leaving it unbounded silently deploys a different count than the config asked for. The section flows into the published `schema.json`, so editors offer completion for it in `config.toml`. That asset is served at PROJECT_CONFIG_SCHEMA_URL and stamped into every `config.toml` that `saveProjectConfig` writes, so a stale copy makes editors flag valid config as invalid. Most of that asset's diff is not workers. `toJsonSchemaDocument` changed how it emits unions between effect beta.107 and rc.108, and the bump landed on develop without the asset being regenerated, so inline `Infinity`/`NaN` unions collapse into `$defs` refs throughout — regenerating on the parent commit alone produces ~549 of those deletions. Nothing wires the generator into a script or CI job, so the drift is silent. Worth fixing separately. --- apps/docs/public/cli/config.schema.json | 856 ++++++++--------------- packages/config/src/base.ts | 3 + packages/config/src/io.unit.test.ts | 28 + packages/config/src/workers.ts | 89 +++ packages/config/src/workers.unit.test.ts | 83 +++ 5 files changed, 506 insertions(+), 553 deletions(-) create mode 100644 packages/config/src/workers.ts create mode 100644 packages/config/src/workers.unit.test.ts diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 5a7d25463b..07538197f1 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -20,12 +20,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -44,12 +39,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -82,12 +72,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -103,12 +88,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -148,12 +128,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -176,12 +151,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -206,25 +176,12 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, "password_requirements": { - "type": "string", - "enum": [ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols" - ], - "description": "Password character requirements.", - "default": "" + "$ref": "#/$defs/Union_1" }, "publishable_key": { "type": "string", @@ -291,12 +248,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -306,12 +258,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -326,12 +273,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -382,12 +324,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -397,12 +334,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -436,12 +368,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -451,12 +378,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -466,12 +388,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -509,12 +426,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -540,12 +452,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -583,12 +490,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -607,8 +509,64 @@ }, "additionalProperties": false }, + "workers": { + "anyOf": [ + { + "$ref": "#/$defs/Objects_27" + }, + { + "type": "null" + } + ] + }, "experimental": { - "$ref": "#/$defs/Objects_27" + "type": "object", + "properties": { + "orioledb_version": { + "type": "string", + "description": "Postgres storage engine version for OrioleDB." + }, + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [ + ".s3-.amazonaws.com", + "env(S3_HOST)" + ] + }, + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": [ + "us-east-1", + "env(S3_REGION)" + ] + }, + "s3_access_key": { + "type": "string", + "description": "S3 access key.", + "examples": [ + "env(S3_ACCESS_KEY)" + ] + }, + "s3_secret_key": { + "type": "string", + "description": "S3 secret key.", + "examples": [ + "env(S3_SECRET_KEY)" + ] + }, + "webhooks": { + "$ref": "#/$defs/Objects_28" + }, + "pgdelta": { + "$ref": "#/$defs/Objects_29" + }, + "inspect": { + "$ref": "#/$defs/Objects_30" + } + }, + "additionalProperties": false }, "remotes": { "anyOf": [ @@ -638,12 +596,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -662,12 +615,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -700,12 +648,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -721,12 +664,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -766,12 +704,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -794,12 +727,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -824,25 +752,12 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, "password_requirements": { - "type": "string", - "enum": [ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols" - ], - "description": "Password character requirements.", - "default": "" + "$ref": "#/$defs/Union_1" }, "publishable_key": { "type": "string", @@ -909,12 +824,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -924,12 +834,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -944,12 +849,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1000,12 +900,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1015,12 +910,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1054,12 +944,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1069,12 +954,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1084,12 +964,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1127,12 +1002,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1158,12 +1028,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1201,12 +1066,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1225,8 +1085,64 @@ }, "additionalProperties": false }, + "workers": { + "anyOf": [ + { + "$ref": "#/$defs/Objects_27" + }, + { + "type": "null" + } + ] + }, "experimental": { - "$ref": "#/$defs/Objects_27" + "type": "object", + "properties": { + "orioledb_version": { + "type": "string", + "description": "Postgres storage engine version for OrioleDB." + }, + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [ + ".s3-.amazonaws.com", + "env(S3_HOST)" + ] + }, + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": [ + "us-east-1", + "env(S3_REGION)" + ] + }, + "s3_access_key": { + "type": "string", + "description": "S3 access key.", + "examples": [ + "env(S3_ACCESS_KEY)" + ] + }, + "s3_secret_key": { + "type": "string", + "description": "S3 secret key.", + "examples": [ + "env(S3_SECRET_KEY)" + ] + }, + "webhooks": { + "$ref": "#/$defs/Objects_28" + }, + "pgdelta": { + "$ref": "#/$defs/Objects_29" + }, + "inspect": { + "$ref": "#/$defs/Objects_30" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -1247,6 +1163,14 @@ }, "additionalProperties": false, "$defs": { + "Union_": { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, "Arrays_": { "type": "array", "items": { @@ -1299,6 +1223,17 @@ "https://127.0.0.1:3000" ] }, + "Union_1": { + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" + ], + "description": "Password character requirements.", + "default": "" + }, "Objects_1": { "type": "object", "properties": { @@ -1308,12 +1243,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1323,12 +1253,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1338,12 +1263,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1353,12 +1273,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1368,12 +1283,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1383,12 +1293,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1398,12 +1303,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1591,12 +1491,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1635,12 +1530,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -1696,12 +1586,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1711,12 +1596,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -1738,12 +1618,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -2962,12 +2837,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -2986,12 +2856,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3001,12 +2866,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -3055,6 +2915,16 @@ }, "additionalProperties": false }, + "Union_2": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Union_" + } + ] + }, "Objects_16": { "type": "object", "properties": { @@ -3068,94 +2938,22 @@ "type": "string" }, "max_connections": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_locks_per_transaction": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_parallel_maintenance_workers": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_parallel_workers": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_parallel_workers_per_gather": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_replication_slots": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_slot_wal_keep_size": { "type": "string" @@ -3170,34 +2968,10 @@ "type": "string" }, "max_wal_senders": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "max_worker_processes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] + "$ref": "#/$defs/Union_2" }, "session_replication_role": { "type": "string", @@ -3389,12 +3163,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] } @@ -3449,12 +3218,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3464,12 +3228,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3479,12 +3238,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3534,12 +3288,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3549,12 +3298,7 @@ "type": "number" }, { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] + "$ref": "#/$defs/Union_" } ] }, @@ -3592,109 +3336,115 @@ }, "Objects_27": { "type": "object", - "properties": { - "orioledb_version": { - "type": "string", - "description": "Postgres storage engine version for OrioleDB." - }, - "s3_host": { - "type": "string", - "description": "S3 bucket URL.", - "examples": [ - ".s3-.amazonaws.com", - "env(S3_HOST)" - ] - }, - "s3_region": { - "type": "string", - "description": "S3 bucket region.", - "examples": [ - "us-east-1", - "env(S3_REGION)" - ] - }, - "s3_access_key": { - "type": "string", - "description": "S3 access key.", - "examples": [ - "env(S3_ACCESS_KEY)" - ] - }, - "s3_secret_key": { - "type": "string", - "description": "S3 secret key.", - "examples": [ - "env(S3_SECRET_KEY)" - ] - }, - "webhooks": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable experimental webhooks.", - "default": false - } - }, - "additionalProperties": false - }, - "pgdelta": { + "patternProperties": { + "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", - "default": false + "runtime": { + "type": "string", + "description": "Runtime the worker is built on: `dockerfile` to build the directory's own\nDockerfile, or one of the catalog runtimes (`node`, `deno`). Guessed from\nmarker files when unset.", + "examples": [ + "node" + ] }, - "declarative_schema_path": { + "size": { "type": "string", - "description": "Directory under supabase/ where declarative schema files are written.", + "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", "examples": [ - "./schemas" + "2gb" ] }, - "format_options": { + "instances": { + "type": "integer", + "allOf": [ + { + "minimum": 0, + "description": "Number of instances to run. Every deploy sends a complete spec, so a count\nrecorded here is what keeps a scaled worker scaled; `--instances` overrides\nit for one deploy. Defaults to 1.", + "examples": [ + 3 + ] + } + ] + }, + "source": { "type": "string", - "description": "JSON string passed through to pg-delta SQL formatting.", + "description": "Directory holding the worker's code, relative to the project root, when it\ndoes not live at `supabase/workers//`.", "examples": [ - "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + "packages/api" ] } }, "additionalProperties": false + } + }, + "description": "Worker-specific configuration keyed by worker name.", + "default": {} + }, + "Objects_28": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable experimental webhooks.", + "default": false + } + }, + "additionalProperties": false + }, + "Objects_29": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", + "default": false }, - "inspect": { - "type": "object", - "properties": { - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Inspection query." - }, - "name": { - "type": "string", - "description": "Inspection rule name." - }, - "pass": { - "type": "string", - "description": "Success message." - }, - "fail": { - "type": "string", - "description": "Failure message." - } - }, - "additionalProperties": false + "declarative_schema_path": { + "type": "string", + "description": "Directory under supabase/ where declarative schema files are written.", + "examples": [ + "./schemas" + ] + }, + "format_options": { + "type": "string", + "description": "JSON string passed through to pg-delta SQL formatting.", + "examples": [ + "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + ] + } + }, + "additionalProperties": false + }, + "Objects_30": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Inspection query." }, - "description": "Inspection rules.", - "default": [] - } + "name": { + "type": "string", + "description": "Inspection rule name." + }, + "pass": { + "type": "string", + "description": "Success message." + }, + "fail": { + "type": "string", + "description": "Failure message." + } + }, + "additionalProperties": false }, - "additionalProperties": false + "description": "Inspection rules.", + "default": [] } }, "additionalProperties": false diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index d84ba7c2c4..b4504d92f6 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -10,6 +10,7 @@ import { inbucket } from "./inbucket.ts"; import { realtime } from "./realtime.ts"; import { storage } from "./storage.ts"; import { studio } from "./studio.ts"; +import { workers } from "./workers.ts"; const projectId = Schema.optionalKey( Schema.String.annotate({ @@ -37,6 +38,7 @@ const baseProjectConfigFields = { realtime, storage, studio, + workers, experimental, }; @@ -52,6 +54,7 @@ const remoteProjectConfig = Schema.Struct({ realtime, storage, studio, + workers, experimental, }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 0159b251d4..248c29679f 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1246,6 +1246,34 @@ project_id = "dupref" } }); + test("loads a [remotes.*.workers] section alongside the project's own", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[workers.api] +runtime = "node" + +[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.workers.api] +runtime = "deno" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.workers).toEqual({ api: { runtime: "node" } }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", async () => { const cwd = makeTempProject(); diff --git a/packages/config/src/workers.ts b/packages/config/src/workers.ts new file mode 100644 index 0000000000..5932416494 --- /dev/null +++ b/packages/config/src/workers.ts @@ -0,0 +1,89 @@ +import dedent from "dedent"; +import { Effect, Schema } from "effect"; + +const tags = ["workers"]; + +const links = [ + { + name: "`supabase workers` CLI subcommands", + link: "https://supabase.com/docs/reference/cli/supabase-workers", + }, +]; + +/** + * Worker names end up in hostnames, so they are DNS labels — the same pattern + * the Management API validates `:name` against + * (`v2/projects/{ref}/workers/{name}`). + */ +const workerName = Schema.String.check(Schema.isPattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)); + +const worker = Schema.Struct({ + runtime: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Runtime the worker is built on: \`dockerfile\` to build the directory's own + Dockerfile, or one of the catalog runtimes (\`node\`, \`deno\`). Guessed from + marker files when unset. + `, + examples: ["node"], + tags, + links, + }), + ), + size: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Instance size, denominated by memory. Each size implies its own vCPU count, + so it is the one dial rather than two. + `, + examples: ["2gb"], + 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 + // unbounded deploys a different count than the config asked for. + Schema.Number.check( + Schema.isInt().annotate({ expected: "a whole number of instances" }), + Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "zero or more instances" }), + ).annotate({ + description: dedent` + Number of instances to run. Every deploy sends a complete spec, so a count + recorded here is what keeps a scaled worker scaled; \`--instances\` overrides + it for one deploy. Defaults to 1. + `, + examples: [3], + tags, + links, + }), + ), + source: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Directory holding the worker's code, relative to the project root, when it + does not live at \`supabase/workers//\`. + `, + examples: ["packages/api"], + tags, + links, + }), + ), +}); + +/** + * `[workers]` — one `[workers.]` table per worker, mirroring the + * `[functions.]` convention in the same file. + * + * Workers live at `supabase/workers//`; one whose code lives somewhere + * else entirely uses its own `source`, which is anchored to the project root and + * so can leave `supabase/`. + */ +export const workers = Schema.Record(workerName, worker) + .annotate({ + default: {}, + description: "Worker-specific configuration keyed by worker name.", + tags, + }) + .pipe(Schema.withDecodingDefault(Effect.succeed({}))); diff --git a/packages/config/src/workers.unit.test.ts b/packages/config/src/workers.unit.test.ts new file mode 100644 index 0000000000..2a6e0c7b33 --- /dev/null +++ b/packages/config/src/workers.unit.test.ts @@ -0,0 +1,83 @@ +import { Schema } from "effect"; +import { describe, expect, test } from "vitest"; +import { workers } from "./workers.ts"; + +const decode = Schema.decodeUnknownSync(workers); + +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" } }); + }); + + test("defaults to an empty section when the key is absent", () => { + expect(Schema.decodeUnknownSync(Schema.Struct({ workers }))({})).toEqual({ workers: {} }); + }); + + // Keys outside the DNS-label pattern fall outside the record's index + // signature and are dropped, the same way `[functions.]` treats a slug + // its own pattern does not match. `supabase workers new` validates the name + // up front so the CLI never writes one that would vanish here. + test("drops worker names that are not DNS labels", () => { + expect(decode({ Not_A_Label: {}, api: { runtime: "node" } })).toEqual({ + api: { runtime: "node" }, + }); + }); + + // Every dial is optional: a worker scaffolded by `supabase workers new` records + // only what it prompted for, and `push` resolves the rest from its own defaults. + test("decodes a worker table with no dials set", () => { + expect(decode({ api: {} })).toEqual({ api: {} }); + }); + + test("rejects a non-numeric instance count", () => { + expect(() => decode({ api: { instances: "three" } })).toThrow(); + }); + + // `spec.instances` is an integer in the Management API's input schema, and a + // value that slips through here is dropped downstream and silently rescales + // the worker to 1 rather than failing. Named at load time instead. + test.each([ + ["a fraction", 1.5], + ["a negative count", -1], + ])("rejects %s as an instance count", (_label, instances) => { + expect(() => decode({ api: { instances } })).toThrow(); + }); + + test("accepts zero instances", () => { + expect(decode({ api: { instances: 0 } })).toEqual({ api: { instances: 0 } }); + }); + + test("rejects a bare value where a worker table belongs", () => { + expect(() => decode({ api: "node" })).toThrow(); + }); + + // The published asset at `PROJECT_CONFIG_SCHEMA_URL` is what editors read, so + // the worker dials have to stay described and completable — and a worker value + // has to be a plain table, or an editor would accept a bare scalar the CLI + // refuses to load. + test("includes worker properties in the generated JSON schema", () => { + const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); + const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); + const workerSchema = objectSchema?.patternProperties?.[workerNamePattern]; + + expect(workerSchema?.properties?.runtime).toBeDefined(); + expect(workerSchema?.properties?.size).toBeDefined(); + expect(workerSchema?.properties?.instances).toBeDefined(); + expect(workerSchema?.properties?.source).toBeDefined(); + }); + + // An integer bound the published schema carries, so an editor flags `1.5` + // before the CLI ever reads it. + test("bounds instances as a non-negative integer in the generated JSON schema", () => { + const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); + const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); + const workerSchema = objectSchema?.patternProperties?.[workerNamePattern]; + + expect(workerSchema?.properties?.instances?.type).toBe("integer"); + expect(JSON.stringify(workerSchema?.properties?.instances)).toContain('"minimum":0'); + }); +}); From 7baf0eeac33b271ef5a7dad6c504fecb9dc5a3e0 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:00:45 -0300 Subject: [PATCH 06/50] feat(cli): add supabase workers new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds `supabase/workers//` from a runtime's starter files and records the choice in `config.toml`. Entirely local disk — nothing is deployed and no network is involved, which is why it lands before the API seam. The name is required rather than generated: it is both the hostname and the directory, so a name nobody chose gets renamed immediately. The runtime and instance size are resolved before anything is written, so cancelling either prompt leaves nothing behind. Two closed sets, both narrow on purpose — the runtimes are the ones that have starters, and the sizes are the alpha envelope's two, each implying its own vCPU count. Nothing here deletes. An occupied destination is refused and says how to proceed; a worker already described in `config.toml` is refused rather than overwritten, since changing an existing worker is a `config.toml` edit and the file is the user's to edit. The config entry is planned before any file is written, so an edit already known to fail does not strand a scaffold. Three pieces in `shared/` carry the command, and they are the subtle ones: `worker-paths.ts` resolves the project layout: `supabase/workers//`, mirroring `supabase/functions//`, with `[workers.] source` moving one worker's code anywhere in the project. `confineWorkerPath` answers containment on the filesystem's terms rather than lexically — it canonicalizes the longest existing prefix of the target (`realPath` fails outright on a path that is not there yet) and the project root with it, so a project living under a symlink still compares like for like, and a `source` reaching outside the project through an in-project symlink is refused. `supabase/` itself, the CLI's own files in it, and the reserved subdirectories (`functions`, `migrations` and `.temp` among them, compared case-insensitively because default macOS and Windows filesystems are) are refused too. Both `--source` and the `source` recorded in `config.toml` go through it, because that directory is what `push` packages and uploads. Backslashes are read as separators wherever a persisted value was written and persisted paths are normalized to forward slashes, so a Windows-authored `config.toml` names the same directory elsewhere. `toml-section.ts` appends a `[workers.]` block rather than round-tripping the file. `config.toml` belongs to the whole CLI — users hand-edit, comment and commit it — and reserialising preserves the data while discarding every comment and normalising the formatting they chose. Writes are append-only and whether an entry already exists is answered by the decoded config rather than by matching text, which is the one question a regex over the file cannot answer reliably for a dotted or inline entry. `worker-stacks.ts` holds the starter files as ordinary files under `shared/workers/stacks//`, authored in the language they are written in. A shipped binary has no `stacks/` directory to read, so the directory is expanded through a Bun macro: it runs while the module is transpiled and its return value is inlined as a literal, which means the content is carried with nothing to pass at a build site and no directory to find at runtime. Bun expands macros in the runtime transpiler too, so running from source behaves the same; Vitest does not implement them and degrades to calling the function against the source tree, which is why the path comes from `import.meta.url` rather than Bun's `import.meta.dir`. Discovery stays directory-driven — a new runtime is a new directory plus its `WORKER_RUNTIMES` entry — and a completeness check inside the macro fails the build rather than the binary when the two drift. Nothing imports the starters, which is what keeps them out of the type program: a `deno` starter is not valid under this workspace's Bun types, and `tsconfig.json` excludes the directory. This also brings the command family's shell wiring, which is where the conventions here differ from a command tree's usual shape: - The project directory is `LegacyCliConfig.workdir`, so `--workdir` and `SUPABASE_WORKDIR` select the project exactly as they do for every sibling command, rather than an ancestor walk of the process's own directory. `--source` resolves against the invocation directory instead, matching what a shell prompt implies. - Output goes through `output.raw` as plain text with no `intro`/`outro` framing, and tables through `renderGlamourTable`, so `workers` reads like `functions` and `projects` rather than like a second CLI. - `-o`/`--output` is honoured (`workers.output.ts`), since ignoring a global flag would print human text to a stdout the user asked to be machine-readable. `-o env` is refused up front for the whole family: `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend into slices, and every workers payload has structure a flat `KEY=value` list cannot hold. Prompting is suppressed under a machine format for the same reason — Clack writes its UI to stdout with no stream override and `-o` leaves `output.format` as `text`, so an interactive `workers new api -o json` would otherwise render a selection UI in front of the payload. - Telemetry state is flushed in `Effect.ensuring`. Two shell-wide registries have to move in step with the command appearing, and both are enforced by tests rather than convention: `LEGACY_DOCS_TAGS`, without which the generated CLI reference refuses to build, and `VALUE_CONSUMING_LONG_FLAGS`, without which the telemetry argv scan treats `--runtime`'s value as a flag and can fabricate one that was never passed. --- apps/cli/package.json | 3 +- apps/cli/src/legacy/cli/root.ts | 2 + .../commands/workers/new/SIDE_EFFECTS.md | 64 +++ .../commands/workers/new/new.command.ts | 74 ++++ .../commands/workers/new/new.handler.ts | 275 +++++++++++++ .../workers/new/new.integration.test.ts | 380 ++++++++++++++++++ .../commands/workers/workers.command.ts | 10 + .../legacy/commands/workers/workers.errors.ts | 25 ++ .../legacy/commands/workers/workers.format.ts | 32 ++ .../legacy/commands/workers/workers.output.ts | 78 ++++ .../legacy/commands/workers/workers.shared.ts | 126 ++++++ .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 2 + apps/cli/src/shared/workers/stacks/README.md | 14 + .../src/shared/workers/stacks/deno/main.ts | 10 + .../workers/stacks/dockerfile/Dockerfile | 3 + .../workers/stacks/dockerfile/server.mjs | 15 + .../src/shared/workers/stacks/node/index.mjs | 10 + apps/cli/src/shared/workers/toml-section.ts | 88 ++++ .../shared/workers/toml-section.unit.test.ts | 78 ++++ apps/cli/src/shared/workers/worker-config.ts | 140 +++++++ .../shared/workers/worker-config.unit.test.ts | 177 ++++++++ apps/cli/src/shared/workers/worker-paths.ts | 225 +++++++++++ .../shared/workers/worker-paths.unit.test.ts | 200 +++++++++ .../cli/src/shared/workers/worker-runtimes.ts | 115 ++++++ .../workers/worker-runtimes.unit.test.ts | 62 +++ .../src/shared/workers/worker-stacks.macro.ts | 81 ++++ apps/cli/src/shared/workers/worker-stacks.ts | 16 + apps/cli/src/shared/workers/workers.errors.ts | 45 +++ apps/cli/tests/helpers/legacy-workers.ts | 268 ++++++++++++ apps/cli/tsconfig.json | 2 +- 31 files changed, 2619 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/new/new.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/new/new.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/new/new.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.errors.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.format.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.output.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.shared.ts create mode 100644 apps/cli/src/shared/workers/stacks/README.md create mode 100644 apps/cli/src/shared/workers/stacks/deno/main.ts create mode 100644 apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile create mode 100644 apps/cli/src/shared/workers/stacks/dockerfile/server.mjs create mode 100644 apps/cli/src/shared/workers/stacks/node/index.mjs create mode 100644 apps/cli/src/shared/workers/toml-section.ts create mode 100644 apps/cli/src/shared/workers/toml-section.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-config.ts create mode 100644 apps/cli/src/shared/workers/worker-config.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-paths.ts create mode 100644 apps/cli/src/shared/workers/worker-paths.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-runtimes.ts create mode 100644 apps/cli/src/shared/workers/worker-runtimes.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-stacks.macro.ts create mode 100644 apps/cli/src/shared/workers/worker-stacks.ts create mode 100644 apps/cli/src/shared/workers/workers.errors.ts create mode 100644 apps/cli/tests/helpers/legacy-workers.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 907b71eaf7..bdd2f8a73f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -123,7 +123,8 @@ "ignore": [ "scripts/*.ts", "tests/**/*.ts", - "src/shared/telemetry/event-catalog.ts" + "src/shared/telemetry/event-catalog.ts", + "src/shared/workers/stacks/**" ], "ignoreBinaries": [ "nx", diff --git a/apps/cli/src/legacy/cli/root.ts b/apps/cli/src/legacy/cli/root.ts index db904dca84..6883aee159 100644 --- a/apps/cli/src/legacy/cli/root.ts +++ b/apps/cli/src/legacy/cli/root.ts @@ -35,6 +35,7 @@ import { legacyStorageCommand } from "../commands/storage/storage.command.ts"; import { legacyTestCommand } from "../commands/test/test.command.ts"; import { legacyTelemetryCommand } from "../commands/telemetry/telemetry.command.ts"; import { legacyUnlinkCommand } from "../commands/unlink/unlink.command.ts"; +import { legacyWorkersCommand } from "../commands/workers/workers.command.ts"; import { legacyVanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { OutputFormatFlag } from "../../shared/cli/global-flags.ts"; import { outputLayerFor } from "../../shared/output/output.layer.ts"; @@ -70,6 +71,7 @@ export const legacyRoot = Command.make("supabase").pipe( legacyDomainsCommand, legacyEncryptionCommand, legacyFunctionsCommand, + legacyWorkersCommand, legacyGenCommand, legacyInitCommand, legacyInspectCommand, diff --git a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md new file mode 100644 index 0000000000..53033d7495 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md @@ -0,0 +1,64 @@ +# `supabase workers new [name]` + +> **Local-disk only.** Nothing is deployed and no Management API route is +> called; `workers push` is what talks to the platform. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to refuse a worker that is already recorded | +| `/` | dir | always, to refuse a destination that is not empty | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — appends/updates `[workers.]` in place, preserving comments | +| `/supabase/workers//*` | varies | always, unless `--source` names another directory | +| `//*` | varies | when `--source` is given | +| `/telemetry.json` | JSON | always — flushed on success and on failure | + +Nothing at the destination is ever removed or overwritten: a destination that +exists and is not empty is refused, and clearing it is left to the user. +`--source` is refused when it resolves to the project root, `supabase/`, +`supabase/functions/`, `supabase/migrations/`, or outside the project. Symlinks +are resolved first, so a path inside the project that points outside it is +refused too. A relative `--source` is resolved against the directory the command +was run in; a `source` recorded in `config.toml` is resolved against the project +root. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ---------------------- | +| — | — | — | — | — | + +## Exit Codes + +| Code | Condition | +| ---- | --------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid or reserved worker name, unknown runtime/size, bad `--source` | +| `1` | destination exists and is not empty | +| `1` | `config.toml` records a worker in a form that cannot be edited safely | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/new/new.command.ts b/apps/cli/src/legacy/commands/workers/new/new.command.ts new file mode 100644 index 0000000000..e82bf6550c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.command.ts @@ -0,0 +1,74 @@ +import { Layer } from "effect"; +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 { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersNew } from "./new.handler.ts"; + +const config = { + name: Argument.string("name").pipe( + Argument.withDescription("Worker name. Doubles as its directory, and its hostname."), + ), + runtime: Flag.choice("runtime", WORKER_RUNTIMES).pipe( + Flag.withDescription( + "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted.", + ), + Flag.optional, + ), + size: Flag.choice("size", WORKER_SIZES).pipe( + Flag.withDescription( + "Instance size to record in supabase/config.toml. Each size implies its own vCPU count, so there is no separate --cpu. 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.", + ), + Flag.optional, + ), +} as const; + +export type LegacyWorkersNewFlags = CliCommand.Command.Config.Infer; + +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +/** Local-disk only: no Management API, so no platform stack is built. */ +const legacyWorkersNewRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["workers", "new"]), +); + +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.", + ), + Command.withShortDescription("Scaffold a worker locally"), + Command.withExamples([ + { + command: "supabase workers new", + description: "Scaffold a worker, prompting for runtime and size", + }, + { + command: "supabase workers new api --runtime node", + description: "Scaffold supabase/workers/api on the node runtime", + }, + { + command: "supabase workers new api --source packages/api", + description: "Scaffold the worker outside the workers directory", + }, + ]), + Command.withHandler((flags) => + legacyWorkersNew(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyWorkersNewRuntimeLayer), +); diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts new file mode 100644 index 0000000000..4be159261c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -0,0 +1,275 @@ +import { join, relative, sep } from "node:path"; +import { Effect, FileSystem, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + commitWorkerEntry, + planWorkerEntry, + WorkerAlreadyConfiguredError, +} from "../../../../shared/workers/worker-config.ts"; +import { + confineWorkerPath, + displayPath, + resolveWorkerSource, +} from "../../../../shared/workers/worker-paths.ts"; +import { + DEFAULT_WORKER_RUNTIME, + DEFAULT_WORKER_SIZE, + parseWorkerRuntime, + parseWorkerSize, + validateWorkerNameMessage, + vcpuForSize, + WORKER_RUNTIME_DESCRIPTIONS, + WORKER_RUNTIMES, + WORKER_SIZES, + type WorkerRuntime, + type WorkerSize, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { WORKER_STACKS } from "../../../../shared/workers/worker-stacks.ts"; +import { + InvalidWorkerNameError, + WorkerDirectoryExistsError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersNewFlags } from "./new.command.ts"; + +/** + * `supabase workers new [name]` — scaffold `supabase///` from the + * chosen runtime's starter files and record the choice in `config.toml`. + * Nothing is deployed; this is entirely local-disk work. + * + * The runtime and size are resolved *before* anything is written, so a + * cancelled prompt leaves nothing behind for this worker at all — including the + * name, which is only generated once both questions have been answered. + */ + +/** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ +function defaultFirst(values: ReadonlyArray, defaultValue: T): Array { + return [defaultValue, ...values.filter((value) => value !== defaultValue)]; +} + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + // `--runtime` is a choice flag, so the parser has already rejected anything + // outside the catalog by the time it gets here. + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive && !options.machineOutput) { + const selected = yield* output.promptSelect( + "Which runtime should this worker use?", + defaultFirst([...WORKER_RUNTIMES], DEFAULT_WORKER_RUNTIME).map((runtime) => ({ + value: runtime, + label: runtime, + hint: WORKER_RUNTIME_DESCRIPTIONS[runtime], + })), + ); + return parseWorkerRuntime(selected) ?? DEFAULT_WORKER_RUNTIME; + } + + return DEFAULT_WORKER_RUNTIME; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive && !options.machineOutput) { + const selected = yield* output.promptSelect( + "Which instance size should this worker use?", + defaultFirst([...WORKER_SIZES], DEFAULT_WORKER_SIZE).map((size) => ({ + value: size, + label: `${size} (${vcpuForSize(size)} vCPU)`, + })), + ); + return parseWorkerSize(selected) ?? DEFAULT_WORKER_SIZE; + } + + return DEFAULT_WORKER_SIZE; +}); + +/** + * 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 + * than by a bare `EEXIST` from `makeDirectory`. + */ +const destinationIsFree = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(target).pipe(Effect.option); + if (info._tag === "None") { + return true; + } + if (info.value.type !== "Directory") { + return false; + } + const entries = yield* fs.readDirectory(target).pipe(Effect.orElseSucceed(() => [])); + return entries.length === 0; +}); + +export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( + flags: LegacyWorkersNewFlags, +) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const telemetryState = yield* LegacyTelemetryState; + const runtimeInfo = yield* RuntimeInfo; + + // The telemetry state file is written on every invocation, success or failure. + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const name = flags.name; + const invalid = validateWorkerNameMessage(name); + if (invalid !== undefined) { + return yield* Effect.fail( + new InvalidWorkerNameError({ + detail: `"${name}" is not a valid worker name. ${invalid}`, + suggestion: "Worker names become hostnames, so they must be DNS labels.", + }), + ); + } + + // Refused before anything is asked or written. `new` creates a worker; + // changing one that already exists is a `config.toml` edit, and the file is + // the user's. Checking here rather than only in `planWorkerEntry` means the + // prompts never run for a name that was going to be refused anyway. + if (project.section.workers[name] !== undefined) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${name}" is already configured in ${project.configPath}.`, + suggestion: `Edit [workers.${name}] in ${project.configPath} yourself, or pick a different worker name.`, + }), + ); + } + + // Resolved before anything is written, so cancelling either prompt leaves + // nothing behind — the name included. + // `-o` leaves `output.format` as `text`, and `promptSelect` goes through + // Clack, which writes its terminal UI to stdout with no stream override — so + // a prompt would land in front of the payload just as the notices did. With a + // machine format requested there is nowhere to ask, so the defaults stand. + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const runtime = yield* resolveRuntime({ explicit: flags.runtime, machineOutput }); + const size = yield* resolveSize({ explicit: flags.size, machineOutput }); + + // Validated before anything is written: this is the directory the starter + // files land in, so a value naming the project root, `supabase/`, or + // anywhere outside the project must never get as far as the write below. + // + // `--source` resolves against the directory the user typed it in, the way a + // shell would read it: `--source generated` from `apps/web` means + // `apps/web/generated`. + const destination = Option.isSome(flags.source) + ? yield* resolveWorkerSource({ + projectRoot: project.projectRoot, + cwd: runtimeInfo.cwd, + raw: flags.source.value, + }) + : yield* confineWorkerPath({ + projectRoot: project.projectRoot, + target: join(project.workersDir, name), + subject: `The default directory for "${name}"`, + suggestion: "Point [workers] root at a directory inside supabase/.", + }); + + // Nothing here replaces what is already on disk. Scaffolding over an + // existing directory would have to delete it first, and a command whose job + // is to create a worker has no business removing whatever happens to share + // its name — so it says what is in the way and leaves the choice to the user. + if (!(yield* destinationIsFree(destination))) { + const shown = displayPath(project.projectRoot, destination); + return yield* Effect.fail( + new WorkerDirectoryExistsError({ + detail: `${shown} already exists and is not empty.`, + suggestion: `Remove ${shown} yourself if you meant to replace it, or pick a different worker name.`, + }), + ); + } + + // Recorded as forward slashes whatever platform wrote it. `config.toml` is + // committed and shared, and `path.relative` yields `packages\api` on + // Windows — a backslash the POSIX resolvers on every other machine read as + // a literal character in a filename rather than a separator. + const source = Option.isSome(flags.source) + ? relative(project.projectRoot, destination).split(sep).join("/") + : undefined; + + // Planned before anything is written. Every way this can fail is knowable + // from the current config.toml, so finding out afterwards would leave a + // scaffold on disk that nothing records. + const configWrite = yield* planWorkerEntry({ + configPath: project.configPath, + name, + existingWorkers: project.section.workers, + patch: { + runtime, + size, + ...(source === undefined ? {} : { source }), + }, + }); + + // Everything below this line changes the user's disk, and nothing below it + // can fail for a reason the plan above could have caught. + yield* fs.makeDirectory(destination, { recursive: true }); + + for (const [filename, contents] of Object.entries(WORKER_STACKS[runtime])) { + yield* fs.writeFileString(join(destination, filename), contents); + } + + yield* commitWorkerEntry(configWrite); + + const sourceDisplay = displayPath(project.projectRoot, destination); + + const payload = { + worker_name: name, + runtime, + size, + vcpu: vcpuForSize(size), + source: sourceDisplay, + config_path: project.configPath, + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + // Leads with a declarative line the way every other scaffold does + // (`functions new`: "Created new Function at supabase/functions/hello"), + // then the details. Guidance goes in a closing sentence rather than a + // pseudo-row, since no other command puts a next step inside its output + // table. + yield* output.raw(`Created new Worker at ${sourceDisplay}\n`); + yield* output.raw( + legacyRenderWorkerDetails([ + ["Runtime", runtime], + ["Size", `${size} (${vcpuForSize(size)} vCPU)`], + ["Access", "public"], + ]), + ); + yield* output.raw(`Deploy it with supabase workers push ${name}.\n`); + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts new file mode 100644 index 0000000000..52a74e7e7a --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -0,0 +1,380 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { WorkerAlreadyConfiguredError } from "../../../../shared/workers/worker-config.ts"; +import { + InvalidWorkerNameError, + InvalidWorkerSourceError, + WorkerDirectoryExistsError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersNew } from "./new.handler.ts"; +import type { LegacyWorkersNewFlags } from "./new.command.ts"; + +const CONFIG_WITH_COMMENTS = `# hand-written, and it should stay that way +project_id = "demo" + +[functions.hello] +verify_jwt = false +`; + +function flags(overrides: Partial = {}): LegacyWorkersNewFlags { + return { + name: "api", + runtime: Option.none(), + size: Option.none(), + source: Option.none(), + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG_WITH_COMMENTS, + ...files, + }); + const configPath = join(created.dir, "supabase", "config.toml"); + return { + dir: created.dir, + config: () => readFileSync(configPath, "utf8"), + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +describe("legacy workers new", () => { + it.live("scaffolds the runtime's starter files and records the choice", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + 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`, + ); + + // Declarative line first, then the detail rows, then the next step — + // the shape `functions new` established. + expect(out.stdoutText).toContain("Created new Worker at supabase/workers/api"); + expect(out.stdoutText).toContain("Runtime"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("prompts for runtime and size when neither is given", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptSelectResponses: ["node", "4gb"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + expect(out.promptSelectCalls.map((call) => call.message)).toEqual([ + "Which runtime should this worker use?", + "Which instance size should this worker use?", + ]); + expect(repo.config()).toContain('runtime = "node"'); + expect(repo.config()).toContain('size = "4gb"'); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the defaults without prompting when not interactive", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, format: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toContain('runtime = "deno"'); + expect(repo.config()).toContain('size = "2gb"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A second `new` for the same name is refused rather than re-recorded. Changing + // a worker that exists is a `config.toml` edit, and the file is the user's. + it.live("refuses a name that config.toml already records", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("deno"), size: Option.some("4gb") }), + ); + const recorded = repo.config(); + + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // Refused before anything was asked, and the entry is byte-identical. + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toBe(recorded); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Refused whichever way the entry happens to be written — the decoded config + // is what answers "does this exist", so no TOML shape matters here. + it.live.each(['workers.api.runtime = "node"', "[workers.api]"])( + "refuses an entry recorded as %s", + (entry) => { + const config = `project_id = "demo"\n\n${entry}\n`; + const repo = project({ "supabase/config.toml": config }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(repo.config()).toBe(config); + // Nothing scaffolded either. + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }, + ); + + it.live("records a --source worker relative to the project root", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some("packages/api"), + }), + ); + + expect(existsSync(join(repo.dir, "packages", "api", "index.mjs"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toContain('source = "packages/api"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a --source outside the directories a worker may own", () => { + const repo = project({ "README.md": "keep me", "src/app.ts": "keep me too" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + for (const source of [".", "..", "supabase", "supabase/functions"]) { + const error = yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some(source), + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + } + + // Nothing was written: the resolver refused before any directory was created. + expect(existsSync(join(repo.dir, "README.md"))).toBe(true); + expect(existsSync(join(repo.dir, "src", "app.ts"))).toBe(true); + expect(repo.config()).toContain("project_id"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("scaffolds in a directory that has no Supabase project yet", () => { + const created = makeWorkersProject(); + const { layer } = setupLegacyWorkers({ workdir: created.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + 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`, + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(created.dir, { recursive: true, force: true }))), + ); + }); + + it.live("refuses a destination that already has something in it", () => { + const repo = project({ "supabase/workers/api/leftover.txt": "old" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "leftover.txt"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Scaffolding into an empty directory is fine — it is only a destination with + // contents that is refused. + it.live("scaffolds into a directory that exists but is empty", () => { + const repo = project(); + mkdirSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("tells the user how to proceed when the destination is occupied", () => { + const repo = project({ "supabase/workers/api/leftover.txt": "old" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + // No flag to suggest any more, so the advice has to be actionable on its own. + const suggestion = error instanceof WorkerDirectoryExistsError ? error.suggestion : ""; + expect(suggestion).toContain("Remove"); + expect(suggestion).not.toContain("--force"); + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rejects a name that could not become a hostname", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew(flags({ name: "My_Worker" })).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(existsSync(join(repo.dir, "supabase", "workers"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("keeps stdout parseable under -o json", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, goOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ runtime: Option.some("node") })); + + const payload: unknown = JSON.parse(out.stdoutText); + expect(payload).toMatchObject({ runtime: "node", size: "2gb" }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Why the config edit is planned before the starter files are written: this + // failure is knowable up front, and discovering it afterwards would leave a + // scaffold on disk that nothing records. + it.live("writes no scaffold at all when the config edit cannot be made", () => { + const repo = project({ + "supabase/config.toml": 'project_id = "demo"\n\nworkers.api.runtime = "node"\n', + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("deno") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // No directory, and config.toml exactly as it was. + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toBe('project_id = "demo"\n\nworkers.api.runtime = "node"\n'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A plain file used to read as an empty directory, which then failed with a + // bare EEXIST from `makeDirectory` instead of naming what was in the way. + it.live("refuses a plain file at the destination", () => { + const repo = project({ "supabase/workers/api": "not a directory" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + expect(readFileSync(join(repo.dir, "supabase", "workers", "api"), "utf8")).toBe( + "not a directory", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A relative `--source` is something typed at a shell prompt, so it means + // what it would mean to the shell: relative to where you are. + it.live("resolves a relative --source against the directory it was typed in", () => { + const repo = project({ "apps/web/.keep": "" }); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + cwd: join(repo.dir, "apps", "web"), + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some("generated"), + }), + ); + + expect(existsSync(join(repo.dir, "apps", "web", "generated", "index.mjs"))).toBe(true); + expect(existsSync(join(repo.dir, "generated"))).toBe(false); + // Persisted project-root-relative, with forward slashes on every platform. + expect(repo.config()).toContain('source = "apps/web/generated"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Clack writes its prompt UI to stdout with no stream override, and `-o json` + // leaves `output.format` as `text` — so a prompt lands in front of the payload + // exactly as the notices did. + it.live("does not prompt under -o json, so stdout stays parseable", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + // Answers are available, so a prompt would succeed and corrupt stdout + // rather than fail the test some other way. + promptSelectResponses: ["node", "4gb"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + const payload: unknown = JSON.parse(out.stdoutText); + // The defaults stand, because there was nowhere to ask. + expect(payload).toMatchObject({ runtime: "deno", size: "2gb" }); + expect(out.promptSelectCalls).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses --source pointed at the project config file", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some(join("supabase", "config.toml")), + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + // The config survived, which is the whole point. + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts new file mode 100644 index 0000000000..ac4555f3de --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -0,0 +1,10 @@ +import { Command } from "effect/unstable/cli"; +import { legacyWorkersNewCommand } from "./new/new.command.ts"; + +export const legacyWorkersCommand = Command.make("workers").pipe( + Command.withDescription( + "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", + ), + Command.withShortDescription("Manage Supabase Workers"), + Command.withSubcommands([legacyWorkersNewCommand]), +); diff --git a/apps/cli/src/legacy/commands/workers/workers.errors.ts b/apps/cli/src/legacy/commands/workers/workers.errors.ts new file mode 100644 index 0000000000..9d50b8a447 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.errors.ts @@ -0,0 +1,25 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** + * `--output env` cannot represent a payload containing a list. + * + * `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend + * into slices — a `workers` array would land as a single `WORKERS=""` line + * rather than one entry per worker. Refusing is the same call `functions list` + * makes for the same reason, rather than emitting output that silently omits + * the data. + */ +export class LegacyWorkersEnvNotSupportedError extends Data.TaggedError( + "LegacyWorkersEnvNotSupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/workers/workers.format.ts b/apps/cli/src/legacy/commands/workers/workers.format.ts new file mode 100644 index 0000000000..5b50fc8af4 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.format.ts @@ -0,0 +1,32 @@ +/** + * Text rendering for the workers commands. + * + * Two conventions this shell holds and `supabase workers` follows rather than + * inventing its own: results are written with `output.raw` as plain text, with + * no `intro`/`outro` framing, which no other handler here uses, and tabular + * output goes through `renderGlamourTable`, so `workers list` sits beside + * `functions list` and `projects list` looking like them. + */ + +/** + * `Label value` detail lines for a single worker. + * + * Vertical rather than a one-row `renderGlamourTable` because a worker's values + * include a URL and a source path: `branches get` gets away with laying its + * seven narrow columns out horizontally, and these would not fit. Labels are + * Title Case to match the other vertical key/value view this CLI renders, + * `supabase status` (`legacy-status-pretty.ts`), rather than inventing a third + * casing. + * + * Rows whose value is empty are dropped: several fields are optional strings in + * the API contract (`state_reason`, for one), so an empty one would otherwise + * render as a label, two spaces of padding and nothing else. + */ +export function legacyRenderWorkerDetails(rows: ReadonlyArray): string { + const present = rows.filter(([, value]) => value !== ""); + if (present.length === 0) { + return ""; + } + const width = Math.max(...present.map(([label]) => label.length)); + return `${present.map(([label, value]) => ` ${label.padEnd(width)} ${value}`).join("\n")}\n`; +} diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts new file mode 100644 index 0000000000..840b0a23d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -0,0 +1,78 @@ +import { Effect, Option } from "effect"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts"; +import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; + +/** + * Emits a command's payload in the format `-o`/`--output` asked for. + * + * `-o` is a global flag nearly every command family on this shell honours, so + * ignoring it would print human text to a stdout the user asked to be + * machine-readable. + * + * The struct-shaped encoders elsewhere reproduce a payload shape their command + * already shipped. `workers` has none to match, so it serialises through the + * generic encoders and shapes its payload as the command reads best. + * + * Returns whether it emitted anything, so the caller can skip its text + * rendering — `output.success` writes to stdout in text mode and would corrupt + * the payload otherwise. + */ +export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( + payload: Record, +) { + const output = yield* Output; + const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); + + if (goFormat === undefined || goFormat === "pretty") { + return false; + } + + if (goFormat === "env") { + // Unreachable when the command called `legacyRejectWorkersEnvOutput` first, + // which is where the refusal belongs; here as the backstop that stops a new + // command silently emitting TOML for `-o env`. + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + + if (goFormat === "json") { + yield* output.raw(encodeGoJson(payload)); + return true; + } + if (goFormat === "yaml") { + yield* output.raw(encodeYaml(payload)); + return true; + } + yield* output.raw(encodeToml(payload)); + return true; +}); + +/** + * Whether a machine-readable stdout was requested via `-o`. Callers that emit + * human lines *before* their payload need this: the `-o` branch runs at the end, + * by which point those lines would already be on stdout. + */ +export const legacyWorkersMachineOutputRequested = Effect.fnUntraced(function* () { + const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); + return goFormat !== undefined && goFormat !== "pretty"; +}); + +/** + * Refuse `-o env` before the command does anything. + * + * `env` is a flat `KEY=value` list and every workers payload has structure a + * flat list cannot hold — a collection, or a nested instance tally. So it is + * refused for the whole command family rather than per payload, and refused up + * front: discovering it at emit time means failing after the work is done, which + * for `push` is after the remote project has already changed. + */ +export const legacyRejectWorkersEnvOutput = Effect.fnUntraced(function* () { + if (Option.getOrUndefined(yield* LegacyOutputFlag) === "env") { + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts new file mode 100644 index 0000000000..871c17e23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -0,0 +1,126 @@ +import { join } from "node:path"; +import { loadProjectConfig } from "@supabase/config"; +import { Effect, FileSystem } from "effect"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { + readWorkersSection, + type WorkerEntry, + type WorkersSection, +} from "../../../shared/workers/worker-config.ts"; +import { workerDir, workersDir, workerSourceDir } from "../../../shared/workers/worker-paths.ts"; +import { validateWorkerNameMessage } from "../../../shared/workers/worker-runtimes.ts"; +import { InvalidWorkerNameError } from "../../../shared/workers/workers.errors.ts"; + +/** + * What every `supabase workers` command needs before it does anything: where + * the project is, what `[workers]` says, and which worker is being acted on. + * + * The project directory is `LegacyCliConfig.workdir` rather than an ancestor + * walk from the current directory. That is the resolved workdir every other + * legacy command acts on — `--workdir`/`SUPABASE_WORKDIR` when given, else the + * ancestor walk Go's own `getProjectRoot` performs — so `supabase workers` + * answers to the same flag as its siblings instead of inventing a second notion + * of "which project". + */ + +export interface LegacyWorkersProject { + readonly projectRoot: string; + readonly supabaseDir: string; + readonly configPath: string; + readonly section: WorkersSection; + /** `supabase/workers/`, where every worker lives unless it names a `source`. */ + readonly workersDir: string; +} + +export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { + const cliConfig = yield* LegacyCliConfig; + const projectRoot = cliConfig.workdir; + const supabaseDir = join(projectRoot, "supabase"); + + // `loadProjectConfig` returns null when the directory holds no project yet, + // which is what lets `workers new` scaffold into a bare one. + const loaded = yield* loadProjectConfig(projectRoot); + const section = readWorkersSection(loaded?.config.workers); + + return { + projectRoot, + supabaseDir, + configPath: loaded?.path ?? join(supabaseDir, "config.toml"), + section, + workersDir: workersDir(projectRoot), + } satisfies LegacyWorkersProject; +}); + +export interface LegacyResolvedWorker { + readonly name: string; + readonly entry: WorkerEntry | undefined; + /** The worker's default directory, `supabase/workers//`. */ + readonly defaultDir: string; + /** Where its code actually lives, honouring `[workers.] source`. */ + readonly sourceDir: string; +} + +/** + * Effectful because resolving `sourceDir` confines it to the project, and that + * verdict needs the filesystem: `source` comes from a committed `config.toml`, + * and a directory inside the project can symlink anywhere outside it. + */ +export const legacyDescribeWorker = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, + name: string, +) { + const entry = project.section.workers[name]; + const defaultDir = workerDir(project.projectRoot, name); + return { + name, + entry, + defaultDir, + sourceDir: yield* workerSourceDir({ + projectRoot: project.projectRoot, + defaultDir, + name, + configuredSource: entry?.source, + }), + } satisfies LegacyResolvedWorker; +}); + +/** Reject a name the CLI could never have written, before acting on it. */ +export const legacyValidateWorkerName = Effect.fnUntraced(function* (name: string) { + const invalid = validateWorkerNameMessage(name); + if (invalid !== undefined) { + return yield* Effect.fail( + new InvalidWorkerNameError({ + detail: `"${name}" is not a valid worker name. ${invalid}`, + suggestion: "Worker names become hostnames, so they must be DNS labels.", + }), + ); + } + return name; +}); + +/** + * Every worker in the project, for a command given no names: the directories + * under the workers root, unioned with the `[workers.]` entries, since a + * worker with a `source` lives outside that root and would otherwise be missed. + * + * Sorted, so a bare `push` deploys in a stable order rather than whatever the + * filesystem happened to return. + */ +export const legacyDiscoverWorkerNames = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, +) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory(project.workersDir).pipe(Effect.orElseSucceed(() => [])); + + const scaffolded: Array = []; + for (const entry of entries) { + const info = yield* fs.stat(join(project.workersDir, entry)).pipe(Effect.option); + if (info._tag === "Some" && info.value.type === "Directory") { + scaffolded.push(entry); + } + } + + return [...new Set([...scaffolded, ...Object.keys(project.section.workers)])] + .filter((name) => validateWorkerNameMessage(name) === undefined) + .sort(); +}); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index d3648e8ad7..bd9659d06f 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -64,6 +64,7 @@ export const LEGACY_DOCS_TAGS: Readonly>> = "supabase-secrets": ["management-api"], "supabase-seed": ["local-dev"], "supabase-services": ["local-dev"], + "supabase-workers": ["management-api"], "supabase-snippets": ["management-api"], "supabase-ssl-enforcement": ["management-api"], "supabase-sso": ["management-api"], diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 964434c3a7..d9ad846999 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -139,7 +139,9 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "release-channel", "remove-domains", "role", + "runtime", "size", + "source", "status", "sub", "swift-access-control", diff --git a/apps/cli/src/shared/workers/stacks/README.md b/apps/cli/src/shared/workers/stacks/README.md new file mode 100644 index 0000000000..1098b00c95 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/README.md @@ -0,0 +1,14 @@ +# Examples + +Minimal deployable workers, one per way of packaging code for the lambda +backend. Each runtime directory is discovered by +`worker-stacks.macro.ts` and scaffolded verbatim by `workers new`; adding a +runtime here means adding it to `WORKER_RUNTIMES` too, which the macro checks +at build time. Each returns JSON that includes the `GREETING` secret (null until the +project has one), so the secret-rotation loop is visible in responses. + +| Example | Spec | Notes | +| ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `node` | `{"runtime":"node","size":"2gb-1vcpu","exposure":"public","instances":1}` | catalog runtime; entry `index.mjs` exports `{ fetch }` | +| `deno` | `{"runtime":"deno","size":"2gb-1vcpu","exposure":"public","instances":1}` | catalog runtime; entry `main.ts` exports `{ fetch }` | +| `dockerfile` | `{"size":"2gb-1vcpu","exposure":"public","instances":1}` | no `runtime`: the context carries its own Dockerfile; the app serves plain HTTP on `$PORT` | diff --git a/apps/cli/src/shared/workers/stacks/deno/main.ts b/apps/cli/src/shared/workers/stacks/deno/main.ts new file mode 100644 index 0000000000..66cd89170e --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/deno/main.ts @@ -0,0 +1,10 @@ +export default { + fetch(request: Request): Response { + const { pathname } = new URL(request.url); + return Response.json({ + worker: "hello-deno", + path: pathname, + greeting: Deno.env.get("GREETING") ?? null, + }); + }, +}; diff --git a/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile b/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile new file mode 100644 index 0000000000..74dffeaa95 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile @@ -0,0 +1,3 @@ +FROM public.ecr.aws/docker/library/node:22-alpine +COPY server.mjs /srv/server.mjs +CMD ["node", "/srv/server.mjs"] diff --git a/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs b/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs new file mode 100644 index 0000000000..e005b02f8b --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs @@ -0,0 +1,15 @@ +// A user image serves plain HTTP on $PORT; the injected launcher wraps the +// image's CMD and provides it. +import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? 8080); +createServer((req, res) => { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + worker: "hello-dockerfile", + path: new URL(req.url, "http://localhost").pathname, + greeting: process.env.GREETING ?? null, + }), + ); +}).listen(port); diff --git a/apps/cli/src/shared/workers/stacks/node/index.mjs b/apps/cli/src/shared/workers/stacks/node/index.mjs new file mode 100644 index 0000000000..00b518cae1 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/node/index.mjs @@ -0,0 +1,10 @@ +export default { + fetch(request) { + const { pathname } = new URL(request.url); + return Response.json({ + worker: "hello-node", + path: pathname, + greeting: process.env.GREETING ?? null, + }); + }, +}; diff --git a/apps/cli/src/shared/workers/toml-section.ts b/apps/cli/src/shared/workers/toml-section.ts new file mode 100644 index 0000000000..8baab4025d --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.ts @@ -0,0 +1,88 @@ +/** + * Appending one `[section]` to a TOML file. + * + * `supabase/config.toml` belongs to the whole CLI: users hand-edit it, comment + * it, and commit it. Round-tripping through `saveProjectConfig` preserves the + * data but discards every comment and normalizes the formatting the user chose, + * so the write here is textual — render the table, put it at the end, and leave + * every other byte alone. + * + * Append-only by design: locating an existing table means being right about + * multiline strings, the three ways to quote a key, and where one table ends. + * Callers ask the decoded config whether an entry exists instead, so nothing + * here has to find one. + */ + +/** A TOML bare key needs no quoting; anything else does. */ +function isBareKey(key: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(key); +} + +/** The escapes TOML names, for the control characters that have one. */ +const TOML_NAMED_ESCAPES: Record = { + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +}; + +/** + * Escape a string for a TOML basic (double-quoted) string. + * + * Control characters need the same treatment as quotes and backslashes: TOML + * forbids them raw inside a basic string, and a path is allowed to contain them + * on Unix — a directory name with an embedded newline is legal. Writing one + * through verbatim leaves `config.toml` unparseable after the scaffold is + * already on disk. + */ +function quote(value: string): string { + let escaped = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (char === "\\") { + escaped += "\\\\"; + } else if (char === '"') { + escaped += '\\"'; + } else if (code < 0x20 || code === 0x7f) { + escaped += TOML_NAMED_ESCAPES[char] ?? `\\u${code.toString(16).padStart(4, "0")}`; + } else { + escaped += char; + } + } + return `"${escaped}"`; +} + +/** Render `key` for use in a table header or key position. */ +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)}`; +} + +/** + * `text` with a `[header]` table holding `values` appended to the end. + * + * Cannot fail: the caller has already established that no such table exists, so + * there is nothing to reconcile. A file that is empty (or only whitespace) gets + * no leading blank line; an existing one gets exactly one, however it happened + * to be terminated. + */ +export function appendTomlSection( + text: string, + header: string, + values: Readonly>, +): string { + const block = [ + `[${header}]`, + ...Object.entries(values).map(([key, value]) => renderPair(key, value)), + ].join("\n"); + + if (text.trim() === "") { + return `${block}\n`; + } + return `${text.replace(/\n*$/, "")}\n\n${block}\n`; +} diff --git a/apps/cli/src/shared/workers/toml-section.unit.test.ts b/apps/cli/src/shared/workers/toml-section.unit.test.ts new file mode 100644 index 0000000000..d00fca6933 --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "vitest"; +import { appendTomlSection, tomlKey } from "./toml-section.ts"; + +describe("appendTomlSection", () => { + test("appends a new table to an existing file without disturbing it", () => { + const before = `# my project +project_id = "demo" + +[functions.hello] +verify_jwt = false +`; + + expect(appendTomlSection(before, "workers.api", { runtime: "node", size: "2gb" })) + .toBe(`# my project +project_id = "demo" + +[functions.hello] +verify_jwt = false + +[workers.api] +runtime = "node" +size = "2gb" +`); + }); + + test("writes the table alone into an empty file", () => { + expect(appendTomlSection("", "workers.api", { runtime: "deno" })).toBe( + '[workers.api]\nruntime = "deno"\n', + ); + expect(appendTomlSection("\n \n", "workers.api", { runtime: "deno" })).toBe( + '[workers.api]\nruntime = "deno"\n', + ); + }); + + // However the file happened to be terminated, the new table is separated by + // exactly one blank line. + test.each([ + ['project_id = "demo"', "no trailing newline"], + ['project_id = "demo"\n', "one trailing newline"], + ['project_id = "demo"\n\n\n', "several trailing newlines"], + ])("separates the appended table with one blank line given %s", (before) => { + expect(appendTomlSection(before, "workers.api", { runtime: "node" })).toBe( + 'project_id = "demo"\n\n[workers.api]\nruntime = "node"\n', + ); + }); + + test("escapes quotes and backslashes in values", () => { + expect(appendTomlSection("", "workers.api", { source: 'pack"age\\api' })).toBe( + '[workers.api]\nsource = "pack\\"age\\\\api"\n', + ); + }); + + // A path may legally contain a newline on Unix. Writing it through verbatim + // would leave config.toml unparseable, after the directory is already on disk. + test("escapes control characters in a written value", () => { + const after = appendTomlSection("", "workers.api", { source: "packages/od\nd\tname" }); + + expect(after).toContain('source = "packages/od\\nd\\tname"'); + expect(after).not.toContain("od\nd"); + }); + + test("quotes a worker name that is not a bare key", () => { + expect(appendTomlSection("", `workers.${tomlKey("my worker")}`, { runtime: "node" })).toBe( + '[workers."my worker"]\nruntime = "node"\n', + ); + }); + + test("writes a header with no keys when there is nothing to set", () => { + expect(appendTomlSection("", "workers.api", {})).toBe("[workers.api]\n"); + }); +}); + +describe("tomlKey", () => { + test("quotes only what TOML requires quoting", () => { + expect(tomlKey("my-worker_1")).toBe("my-worker_1"); + expect(tomlKey("my worker")).toBe('"my worker"'); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts new file mode 100644 index 0000000000..b316692178 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -0,0 +1,140 @@ +import { dirname } from "node:path"; +import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; +import { appendTomlSection, tomlKey } from "./toml-section.ts"; + +/** + * The `[workers]` section of `supabase/config.toml`, read through the decoded + * project config and written back surgically. + * + * `[workers]` carries a project-wide `root` plus one `[workers.]` table + * per worker. The schema in `@supabase/config` models exactly that, so reading + * is a matter of splitting the scalar off the record; writing goes through + * `./toml-section.ts` so a user's comments and formatting survive. + */ + +/** One worker's recorded metadata. Every key is optional. */ +export interface WorkerEntry { + readonly runtime?: string; + readonly size?: string; + readonly source?: string; +} + +export interface WorkersSection { + /** `[workers.]` tables, keyed by worker name, in file order. */ + readonly workers: Readonly>; +} + +/** + * The worker is already recorded in `config.toml`. + * + * `workers new` creates a worker; changing one that exists is a different + * operation, and the file is the user's to edit. Refusing is also what keeps + * writes here append-only — amending an entry in place is what required knowing + * enough TOML to find and rewrite it safely. + */ +export class WorkerAlreadyConfiguredError extends Data.TaggedError("WorkerAlreadyConfiguredError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +const stringOrUndefined = (value: unknown): string | undefined => + typeof value === "string" && value !== "" ? value : undefined; + +/** A plain object — a `[workers.]` table rather than a scalar or a list. */ +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The decoded `[workers]` section as per-worker tables. Anything that is not an + * object is dropped rather than read as a worker named after it. + */ +export function readWorkersSection(workers: unknown): WorkersSection { + // Null-prototype, so a worker legitimately named `constructor`, `toString` or + // `hasOwnProperty` reads as absent when it is absent. A plain `{}` answers + // every one of those lookups with something inherited from + // `Object.prototype`, which is enough to make `workers new constructor` write + // its starter files and then refuse to record them. + const entries: Record = Object.create(null); + + if (!isRecord(workers)) { + return { workers: entries }; + } + + for (const [key, value] of Object.entries(workers)) { + if (!isRecord(value)) { + continue; + } + entries[key] = { + runtime: stringOrUndefined(value["runtime"]), + size: stringOrUndefined(value["size"]), + source: stringOrUndefined(value["source"]), + }; + } + + return { workers: entries }; +} + +/** A rendered `config.toml`, not yet written. */ +export interface WorkerEntryWrite { + readonly configPath: string; + readonly text: string; +} + +/** + * Render `config.toml` with `[workers.]` appended, without writing it. + * + * Split from the write so callers can find out an entry already exists before + * they scaffold anything: `new` writes the starter files first, and a failure + * after that would leave a directory nothing records. + */ +export const planWorkerEntry = Effect.fnUntraced(function* (options: { + readonly configPath: string; + readonly name: string; + readonly patch: Readonly>; + /** The already-parsed config — the authority on whether an entry exists. */ + readonly existingWorkers: Readonly>; +}) { + const fs = yield* FileSystem.FileSystem; + + // Append-only, so an entry that is already there cannot be amended. The + // decoded config is the authority on whether one exists — a question the + // parser has answered, and one no amount of regex over the file text answers + // reliably for a dotted or inline entry. + if (options.existingWorkers[options.name] !== undefined) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${options.name}" is already configured in ${options.configPath}.`, + suggestion: `Edit [workers.${options.name}] in ${options.configPath} yourself, or pick a different worker name.`, + }), + ); + } + + const exists = yield* fs.exists(options.configPath); + const text = exists ? yield* fs.readFileString(options.configPath) : ""; + const header = `workers.${tomlKey(options.name)}`; + + return { + configPath: options.configPath, + text: appendTomlSection(text, header, options.patch), + } satisfies WorkerEntryWrite; +}); + +/** + * Commit a {@link planWorkerEntry} result. Creates `supabase/` if it does not + * exist yet, so `new` works in a directory that has never been `supabase + * init`-ed. + */ +export const commitWorkerEntry = Effect.fnUntraced(function* (write: WorkerEntryWrite) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(write.configPath), { recursive: true }); + yield* fs.writeFileString(write.configPath, write.text); +}); diff --git a/apps/cli/src/shared/workers/worker-config.unit.test.ts b/apps/cli/src/shared/workers/worker-config.unit.test.ts new file mode 100644 index 0000000000..668ef584d3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + readWorkersSection, + WorkerAlreadyConfiguredError, + commitWorkerEntry, + planWorkerEntry, +} from "./worker-config.ts"; + +describe("readWorkersSection", () => { + test("reads each worker's recorded dials", () => { + expect( + readWorkersSection({ + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox" }, + }), + ).toEqual({ + workers: { + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, source: undefined }, + }, + }); + }); + + 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, source: undefined } }, + }); + }); + + test("treats a missing or malformed section as empty", () => { + expect(readWorkersSection(undefined)).toEqual({ workers: {} }); + expect(readWorkersSection([])).toEqual({ workers: {} }); + }); +}); + +describe("planWorkerEntry + commitWorkerEntry", () => { + let dir: string; + let configPath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-config-")); + configPath = join(dir, "config.toml"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const run = (effect: Effect.Effect) => Effect.runPromise(effect); + + /** plan + commit — the pairing `new` performs once it has decided to write. */ + const writeWorkerEntry = (options: Parameters[0]) => + planWorkerEntry(options).pipe(Effect.flatMap(commitWorkerEntry)); + + test("creates the file when there is none yet", async () => { + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe('[workers.api]\nruntime = "node"\n'); + }); + + test("appends to an existing file without touching the rest of it", async () => { + writeFileSync(configPath, '# keep me\nproject_id = "demo"\n'); + + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node", size: "4gb" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe( + '# keep me\nproject_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n', + ); + }); + + // `new` creates a worker; changing one that exists is a `config.toml` edit and + // the file is the user's. Refusing is also what keeps writes append-only. + test("refuses a worker that is already configured, leaving the file alone", async () => { + const before = '# hand-written\n[workers.api]\nruntime = "node" # mine\n'; + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "deno" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // How the entry is written — dotted, inline or a table — does not matter. The + // decoded config says it exists, which is the whole question, and answering it + // from the parser rather than the file text is what removed the need to know + // any TOML beyond how to render a value. + test.each([ + ["dotted keys", 'workers.api.runtime = "node"\n'], + ["an inline table", 'workers = { api = { runtime = "node" } }\n'], + ["a value spanning lines", '[workers.api]\nruntime = [\n "node",\n]\n'], + ["a header inside a multiline string", 'notes = """\n[workers.api]\nstill inside"""\n'], + ])("refuses an entry written as %s without reading the file text", async (_label, before) => { + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // Why rendering is separate from writing: `new` writes the starter files before + // it records anything, so a failure that could only surface at the write would + // leave a scaffold on disk that nothing records. + test("renders without writing, and only writes when committed", async () => { + writeFileSync(configPath, 'project_id = "demo"\n'); + + const write = await Effect.runPromise( + planWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(write.text).toContain("[workers.api]"); + expect(readFileSync(configPath, "utf8")).toBe('project_id = "demo"\n'); + + await run(commitWorkerEntry(write).pipe(Effect.provide(BunServices.layer))); + expect(readFileSync(configPath, "utf8")).toContain("[workers.api]"); + }); +}); + +describe("readWorkersSection prototype safety", () => { + // `constructor` is a valid DNS label, so it is a valid worker name. Read into + // a plain `{}`, looking it up would return `Object.prototype.constructor` and + // every caller would believe the worker was already configured. + test.each([["constructor"], ["toString"], ["hasOwnProperty"]])( + "reports %j as absent when it is absent", + (name) => { + const section = readWorkersSection({ api: { runtime: "node" } }); + expect(section.workers[name]).toBeUndefined(); + }, + ); + + test("still reads a worker actually named constructor", () => { + const section = readWorkersSection({ constructor: { runtime: "node" } }); + expect(section.workers["constructor"]).toEqual({ + runtime: "node", + size: undefined, + source: undefined, + }); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-paths.ts b/apps/cli/src/shared/workers/worker-paths.ts new file mode 100644 index 0000000000..95ac98ae8f --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -0,0 +1,225 @@ +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { InvalidWorkerSourceError } from "./workers.errors.ts"; + +/** + * The project layout every worker command resolves against: + * + * supabase/ + * config.toml project config — workers record `[workers.]` here + * workers// one directory per worker; the name IS the directory + * + * This mirrors `supabase/functions//` on purpose: `supabase workers` is a + * sibling of `supabase functions`, not a separate tool with its own + * conventions. A worker's name and its directory are the same fact, so + * `push`/`status`/`delete ` needs no separate lookup, and running from + * inside the directory needs no name at all. + * + * `supabase/workers/` is where they live. One worker whose code belongs + * somewhere else uses `[workers.] source`, relative to the project root, + * which is the only key that moves anything. + */ + +/** The directory workers live in, under `supabase/`. */ +const WORKERS_DIR = "workers"; + +/** + * Directories under `supabase/` the CLI already owns, so no worker's `source` + * may name one: `functions` and `migrations` belong to other parts of the CLI, + * and `.temp` holds CLI state including the linked-project reference. + */ +const RESERVED_SUPABASE_DIRS = ["functions", "migrations", ".temp"]; + +/** + * Files directly under `supabase/` that the CLI owns. Refused separately from the + * directories above, which do not cover them — `supabase/config.toml` sits + * outside every reserved subdirectory. + */ +const RESERVED_SUPABASE_FILES = ["config.toml", "config.json"]; + +/** `supabase/workers/` — where workers live, resolved against the project. */ +export function workersDir(projectRoot: string): string { + return join(projectRoot, "supabase", WORKERS_DIR); +} + +/** Whether `candidate` is `parent` itself or sits underneath it. */ +function isAtOrUnder(parent: string, candidate: string): boolean { + const rel = relative(resolve(parent), resolve(candidate)); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * `target` with every symlink in it resolved, as far as it exists. + * + * `realPath` fails outright on a path that is not there yet, and the whole point + * of canonicalizing here is to vet a destination *before* creating it. So this + * walks up to the deepest ancestor that does exist, resolves that, and re-joins + * the part that doesn't. + */ +const canonicalize = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const absolute = resolve(target); + const pending: Array = []; + let cursor = absolute; + + for (;;) { + const real = yield* fs.realPath(cursor).pipe(Effect.option); + if (real._tag === "Some") { + return pending.length === 0 ? real.value : join(real.value, ...pending); + } + const parent = dirname(cursor); + if (parent === cursor) { + // Walked to the filesystem root without finding anything that exists. + return absolute; + } + pending.unshift(basename(cursor)); + cursor = parent; + } +}); + +/** + * Confine a resolved worker path to the project, on the filesystem's terms + * rather than the string's. + * + * A string comparison cannot see a symlink: `packages/external -> /other-repo` + * makes `--source packages/external/api` write into `/other-repo`. So both the + * target and the project root are canonicalized before comparing — the root too, + * or a project under a symlink (macOS `/tmp` -> `/private/tmp`, most CI + * checkouts) fails containment against itself. + * + * Returns the path as given, not the canonical form, so what gets displayed and + * persisted stays the path the user named. + */ +export const confineWorkerPath = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly target: string; + /** How the path is named in the error, e.g. `--source "packages/api"`. */ + readonly subject: string; + readonly suggestion: string; +}) { + const refuse = (why: string) => + Effect.fail( + new InvalidWorkerSourceError({ + detail: `${options.subject} ${why}.`, + suggestion: options.suggestion, + }), + ); + + const projectRoot = yield* canonicalize(options.projectRoot); + const target = yield* canonicalize(options.target); + const supabaseDir = join(projectRoot, "supabase"); + + if (target === projectRoot) { + return yield* refuse("is the project root itself"); + } + if (!isAtOrUnder(projectRoot, target)) { + return yield* refuse("resolves outside the project"); + } + if (target === supabaseDir) { + return yield* refuse("is the supabase directory itself"); + } + for (const owned of RESERVED_SUPABASE_DIRS) { + if (isAtOrUnder(join(supabaseDir, owned), target)) { + return yield* refuse(`is inside supabase/${owned}/, which the Supabase CLI already owns`); + } + } + for (const owned of RESERVED_SUPABASE_FILES) { + if (target === join(supabaseDir, owned)) { + return yield* refuse(`is supabase/${owned}, which the Supabase CLI already owns`); + } + } + + return options.target; +}); + +/** + * `--source`, resolved against the directory the user typed it in and validated + * before anything is written. + * + * The resolved path is where the starter files land, so a value naming the + * project root, `supabase/`, or anywhere outside the project is refused. + * `source` is the key that may leave the workers directory, but not the project; + * `functions/` and `migrations/` are refused for the same reason `[workers] root` + * refuses them. + */ +export const resolveWorkerSource = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly cwd: string; + readonly raw: string; +}) { + const suggestion = + "Point --source at a directory inside the project, for example --source packages/api."; + + // Whitespace is not trimmed. A directory name may legally begin or end with a + // space on Unix, and the shell only delivers one in a single argv entry if the + // user quoted it — so trimming would silently retarget the scaffold at a + // neighbouring directory. Only the trailing separator, which is syntax rather + // than part of the name, comes off. An argument that is nothing but + // whitespace is refused rather than trimmed into something else. + if (options.raw.trim() === "") { + return yield* Effect.fail( + new InvalidWorkerSourceError({ + detail: `--source "${options.raw}" is empty.`, + suggestion, + }), + ); + } + + return yield* confineWorkerPath({ + projectRoot: options.projectRoot, + target: resolve(options.cwd, options.raw.replace(/[/\\]+$/, "")), + subject: `--source "${options.raw}"`, + suggestion, + }); +}); + +/** A worker's default directory: `supabase/workers//`. */ +export function workerDir(projectRoot: string, name: string): string { + return join(workersDir(projectRoot), name); +} + +/** + * A worker's source directory: `[workers.] source` when one is recorded, + * resolved against the project root, otherwise the default directory. + * + * Confined, not just resolved. `source` arrives from `config.toml`, which is + * committed and shared — so it is as much an input as `--source` is, and a + * checkout carrying `source = "../../.."` or an absolute path would otherwise + * have `push` package and upload a directory that has nothing to do with the + * project. The default directory goes through the same guard so a symlinked + * `[workers] root` cannot escape either. + */ +export const workerSourceDir = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly defaultDir: string; + readonly name: string; + readonly configuredSource: string | undefined; +}) { + const configured = options.configuredSource; + const recorded = configured !== undefined && configured !== ""; + + return yield* confineWorkerPath({ + projectRoot: options.projectRoot, + target: recorded ? resolve(options.projectRoot, configured) : options.defaultDir, + subject: recorded + ? `[workers.${options.name}] source "${configured}"` + : `The default directory for "${options.name}"`, + suggestion: recorded + ? `Set [workers.${options.name}] source to a directory inside the project, relative to the project root.` + : "Point [workers] root at a directory inside supabase/.", + }); +}); + +/** + * A path as it should be shown to the user: relative to the current directory, + * which is how they referred to it in the first place. Falls back to the + * absolute form when the relative one would climb out of the tree, where `../../` + * chains stop being clearer than the truth. + */ +export function displayPath(cwd: string, target: string): string { + const rel = relative(resolve(cwd), resolve(target)); + if (rel === "") { + return "."; + } + return rel.startsWith("..") ? target : rel; +} diff --git a/apps/cli/src/shared/workers/worker-paths.unit.test.ts b/apps/cli/src/shared/workers/worker-paths.unit.test.ts new file mode 100644 index 0000000000..79cc357a58 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.unit.test.ts @@ -0,0 +1,200 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + displayPath, + resolveWorkerSource, + workerDir, + workersDir, + workerSourceDir, +} from "./worker-paths.ts"; +import { InvalidWorkerSourceError } from "./workers.errors.ts"; + +const PROJECT = "/repo"; + +/** + * Confinement is decided on the filesystem's terms, so these need a real one. + * A path that does not exist still resolves — `canonicalize` walks up to the + * deepest existing ancestor — which is what lets the `/repo` cases below stay + * pure string scenarios. + */ +const runFs = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); + +describe("worker directories", () => { + test("resolve under supabase/workers/", () => { + expect(workersDir(PROJECT)).toBe(join(PROJECT, "supabase", "workers")); + expect(workerDir(PROJECT, "api")).toBe(join(PROJECT, "supabase", "workers", "api")); + }); + + test("a recorded source wins and is anchored to the project root", async () => { + const defaultDir = workerDir(PROJECT, "api"); + const sourceDir = (configuredSource: string | undefined) => + runFs(workerSourceDir({ projectRoot: PROJECT, defaultDir, name: "api", configuredSource })); + + expect(await sourceDir(undefined)).toBe(defaultDir); + expect(await sourceDir("")).toBe(defaultDir); + expect(await sourceDir("packages/api")).toBe(join(PROJECT, "packages", "api")); + }); + + // `source` arrives from a committed `config.toml`, so it is as much an input + // as `--source` is — and `push` packages and uploads whatever it resolves to. + test.each([["../../elsewhere"], ["/etc"], ["supabase/functions/hello"]])( + "refuses a recorded source of %j", + async (configuredSource) => { + const error = await runFs( + workerSourceDir({ + projectRoot: PROJECT, + defaultDir: workerDir(PROJECT, "api"), + name: "api", + configuredSource, + }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("[workers.api] source"); + }, + ); +}); + +describe("displayPath", () => { + test("prefers the relative form, and falls back to absolute when it would climb out", () => { + expect(displayPath(PROJECT, join(PROJECT, "supabase", "workers", "api"))).toBe( + join("supabase", "workers", "api"), + ); + expect(displayPath(PROJECT, PROJECT)).toBe("."); + expect(displayPath(join(PROJECT, "deep", "deeper"), "/elsewhere/api")).toBe("/elsewhere/api"); + }); +}); + +describe("resolveWorkerSource", () => { + const cwd = `${PROJECT}/apps/web`; + + test("resolves a directory inside the project against the directory it was typed in", async () => { + expect( + await runFs(resolveWorkerSource({ projectRoot: PROJECT, cwd, raw: "../../packages/api" })), + ).toBe(join(PROJECT, "packages", "api")); + expect( + await runFs( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw: "packages/api/" }), + ), + ).toBe(join(PROJECT, "packages", "api")); + }); + + // The starter files land in whatever this resolves to, so each of these would + // write into work belonging to the project or to the machine. + test.each([ + [".", "the project root itself"], + ["", "empty"], + ["..", "outside the project"], + ["/etc", "outside the project"], + ["../elsewhere", "outside the project"], + ["supabase", "the supabase directory itself"], + ["supabase/functions", "supabase/functions/"], + ["supabase/functions/hello", "supabase/functions/"], + ["supabase/migrations", "supabase/migrations/"], + ["supabase/.temp", "supabase/.temp/"], + ["supabase/.temp/project-ref", "supabase/.temp/"], + // Refusing the reserved directories is not enough on its own: this path is + // inside the project, is not `supabase/` itself, and is in no reserved + // subdirectory — so without this it would be authorized as a scaffold + // destination, and the project's config file is not that. + ["supabase/config.toml", "supabase/config.toml"], + ["supabase/config.json", "supabase/config.json"], + ])("refuses %j", async (raw, reason) => { + const error = await runFs( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain(reason); + }); +}); + +// Containment on a real filesystem, because a string comparison cannot see a +// symlink: a directory inside the project is free to point anywhere outside it, +// and the starter files land wherever the path really resolves. +describe("resolveWorkerSource containment on a real filesystem", () => { + let project = ""; + let outside = ""; + + beforeEach(() => { + const scratch = mkdtempSync(join(tmpdir(), "worker-paths-")); + project = join(scratch, "project"); + outside = join(scratch, "outside"); + mkdirSync(join(project, "packages"), { recursive: true }); + mkdirSync(join(outside, "api"), { recursive: true }); + mkdirSync(join(project, "supabase", "functions", "hello"), { recursive: true }); + }); + + afterEach(() => { + rmSync(join(project, ".."), { recursive: true, force: true }); + }); + + test("resolves a genuine directory inside the project", async () => { + expect( + await runFs(resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages" })), + ).toBe(join(project, "packages")); + }); + + test("refuses a path that reaches outside the project through a symlink", async () => { + symlinkSync(outside, join(project, "packages", "external")); + + const error = await runFs( + resolveWorkerSource({ + projectRoot: project, + cwd: project, + raw: join("packages", "external", "api"), + }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("resolves outside the project"); + }); + + test("refuses a reserved directory reached through a symlink", async () => { + symlinkSync(join(project, "supabase", "functions"), join(project, "fns")); + + const error = await runFs( + resolveWorkerSource({ + projectRoot: project, + cwd: project, + raw: join("fns", "hello"), + }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("supabase/functions/"); + }); + + // A destination that does not exist yet is the normal case for `new`, and the + // project root itself is usually behind a symlink on macOS (`/var` -> + // `/private/var`). Both have to compare equal, not fail containment. + // A name that ends in a space is legal on Unix, and only reaches argv as one + // entry if the user quoted it. Trimming it pointed the scaffold at a different + // directory than the one asked for. + test("keeps whitespace that is part of the directory name", async () => { + expect( + await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages/api " }), + ), + ).toBe(join(project, "packages", "api ")); + }); + + test.each([[""], [" "], ["\t"]])("refuses an all-whitespace --source of %j", async (raw) => { + const error = await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("is empty"); + }); + + test("accepts a destination that does not exist yet", async () => { + expect( + await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages/brand-new" }), + ), + ).toBe(join(project, "packages", "brand-new")); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts new file mode 100644 index 0000000000..7c9f93e8eb --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -0,0 +1,115 @@ +/** + * The alpha envelope a worker is described by: which runtime it is built on, + * and how big an instance it runs as. + * + * 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. + */ + +/** A worker's runtime: its own Dockerfile, or one of the catalog base images. */ +/** + * Kept in step with the directories under `./stacks/` — a runtime offered here + * with no starter files there would scaffold an empty worker, which + * `worker-stacks.macro.ts` refuses at build time. + */ +export const WORKER_RUNTIMES = ["dockerfile", "node", "deno"] as const; + +export type WorkerRuntime = (typeof WORKER_RUNTIMES)[number]; + +/** + * The runtime a worker gets when nobody names one: what `new`'s prompt + * pre-selects, and what the classifier falls back to for a directory it does + * not recognize. Deno, because it is the runtime the rest of the Supabase CLI's + * function tooling assumes. + */ +export const DEFAULT_WORKER_RUNTIME: WorkerRuntime = "deno"; + +function isWorkerRuntime(value: string): value is WorkerRuntime { + return WORKER_RUNTIMES.some((runtime) => runtime === value); +} + +/** + * The runtime a config file named, case-insensitively. The canonical lowercase + * form is what gets recorded. + * + * This is for hand-written `[workers.] runtime` values, where the casing + * is the user's own and `Runtime = "Node"` plainly means `node`. It is not what + * validates `--runtime`: that is a `Flag.choice` over the same catalog, so the + * parser rejects anything outside it — including a case variant — before a + * handler runs, and lists the accepted values when it does. + */ +export function parseWorkerRuntime(value: string): WorkerRuntime | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerRuntime(canonical) ? canonical : undefined; +} + +/** One-line description of each runtime, for `--runtime`'s prompt and help. */ +export const WORKER_RUNTIME_DESCRIPTIONS: Record = { + dockerfile: "Build the directory's own Dockerfile; it serves plain HTTP on $PORT.", + node: "Node.js catalog runtime (Web-standard fetch handler).", + deno: "Deno catalog runtime (Web-standard fetch handler).", +}; + +/** + * The only instance sizes the alpha envelope offers, denominated by memory. + * There is no resize — a different size later means a new worker, not a flag on + * `push`. + */ +export const WORKER_SIZES = ["2gb", "4gb"] as const; + +export type WorkerSize = (typeof WORKER_SIZES)[number]; + +/** The first available option — what `new` records when `--size` is omitted. */ +export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; + +function isWorkerSize(value: string): value is WorkerSize { + return WORKER_SIZES.some((size) => size === value); +} + +/** As {@link parseWorkerRuntime}, for instance sizes. */ +export function parseWorkerSize(value: string): WorkerSize | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerSize(canonical) ? canonical : undefined; +} + +const VCPU_FOR_SIZE: Record = { "2gb": 1, "4gb": 2 }; + +/** The vCPU count that comes with `size` — not independently choosable. */ +export function vcpuForSize(size: WorkerSize): number { + return VCPU_FOR_SIZE[size]; +} + +/** `spec.size` as the Workers API spells it: `2gb-1vcpu`. */ +export function apiSizeFor(size: WorkerSize): string { + return `${size}-${vcpuForSize(size)}vcpu`; +} + +/** + * How a size reads in output: `2gb · 1 vCPU`. Takes the API's own spelling so a + * worker deployed at a size this CLI never offered still renders, verbatim, + * rather than being forced into the local enum. + */ +export function formatApiSize(apiSize: string): string { + const match = /^(\d+gb)-(\d+)vcpu$/.exec(apiSize.trim().toLowerCase()); + if (match === null) { + return apiSize; + } + return `${match[1]} (${match[2]} vCPU)`; +} + +/** + * Worker names end up in hostnames, so they are DNS labels — the same pattern + * the Management API validates the `:name` path parameter against. + */ +const WORKER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +const workerNameRequirement = + "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; + +/** `undefined` when `name` is a valid worker name, else why it is not. */ +export function validateWorkerNameMessage(name: string): string | undefined { + return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; +} diff --git a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts new file mode 100644 index 0000000000..1eb1f9bccd --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { + apiSizeFor, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + validateWorkerNameMessage, + vcpuForSize, +} from "./worker-runtimes.ts"; + +describe("parseWorkerRuntime", () => { + test("accepts the value it displays, case-insensitively, and canonicalizes it", () => { + expect(parseWorkerRuntime("Dockerfile")).toBe("dockerfile"); + expect(parseWorkerRuntime(" NODE ")).toBe("node"); + }); + + test("rejects anything outside the catalog", () => { + expect(parseWorkerRuntime("rust")).toBeUndefined(); + expect(parseWorkerRuntime("sandbox")).toBeUndefined(); + expect(parseWorkerRuntime("")).toBeUndefined(); + }); +}); + +describe("sizes", () => { + test("each size implies its own vCPU count", () => { + expect(vcpuForSize("2gb")).toBe(1); + expect(vcpuForSize("4gb")).toBe(2); + }); + + test("map onto the spelling the Workers API takes", () => { + expect(apiSizeFor("2gb")).toBe("2gb-1vcpu"); + expect(apiSizeFor("4gb")).toBe("4gb-2vcpu"); + }); + + test("render back for display, and pass through anything unrecognized verbatim", () => { + expect(formatApiSize("2gb-1vcpu")).toBe("2gb (1 vCPU)"); + expect(formatApiSize("16gb-8vcpu")).toBe("16gb (8 vCPU)"); + expect(formatApiSize("something-else")).toBe("something-else"); + }); + + test("parse case-insensitively, and reject anything outside the catalog", () => { + expect(parseWorkerSize("4GB")).toBe("4gb"); + expect(parseWorkerSize(" 2gb ")).toBe("2gb"); + expect(parseWorkerSize("64gb")).toBeUndefined(); + expect(parseWorkerSize("")).toBeUndefined(); + }); +}); + +describe("validateWorkerNameMessage", () => { + test("accepts DNS labels", () => { + expect(validateWorkerNameMessage("api")).toBeUndefined(); + expect(validateWorkerNameMessage("my-worker-1")).toBeUndefined(); + expect(validateWorkerNameMessage("a")).toBeUndefined(); + }); + + test.each(["My-Worker", "-leading", "trailing-", "under_score", "", "a".repeat(64)])( + "rejects %j", + (name) => { + expect(validateWorkerNameMessage(name)).toBeDefined(); + }, + ); +}); diff --git a/apps/cli/src/shared/workers/worker-stacks.macro.ts b/apps/cli/src/shared/workers/worker-stacks.macro.ts new file mode 100644 index 0000000000..6c7538dca7 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.macro.ts @@ -0,0 +1,81 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { WORKER_RUNTIMES, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** The files a scaffolded worker is made of, keyed by the name each is written as. */ +export type WorkerStack = Readonly>; + +/** + * Fails unless every offered runtime has a non-empty stack, and every stack + * belongs to an offered runtime. + * + * The two lists are declared separately — `WORKER_RUNTIMES` drives `--runtime` + * and the type union, the directory holds the content — so this is what stops + * them drifting into a runtime users can pick that scaffolds nothing. It runs + * as the macro is expanded, which is to say at build time. + */ +function assertCompleteWorkerStacks( + stacks: Record, +): asserts stacks is Record { + const offered = new Set(WORKER_RUNTIMES); + const present = new Set(Object.keys(stacks)); + + const missing = [...offered].filter((runtime) => !present.has(runtime)); + if (missing.length > 0) { + throw new Error(`no starter files for ${missing.join(", ")}`); + } + const unexpected = [...present].filter((runtime) => !offered.has(runtime)); + if (unexpected.length > 0) { + throw new Error( + `stacks/${unexpected.join(", stacks/")} has no matching entry in WORKER_RUNTIMES`, + ); + } + for (const [runtime, files] of Object.entries(stacks)) { + if (Object.keys(files).length === 0) { + throw new Error(`stacks/${runtime} is empty`); + } + } +} + +/** + * Every runtime's starter files, discovered by reading `./stacks/`. + * + * Expanded as a Bun macro, so this runs while the importing module is + * transpiled and its return value is inlined as a literal — a compiled binary + * carries the content with no `stacks/` directory beside it and no `--define` + * to forget at a build site. Adding a runtime is adding a directory; nothing + * here names the files. + * + * Bun expands macros in the runtime transpiler too, so running from source + * behaves the same. Vitest does not implement them, and degrades to calling + * this as an ordinary function against the source tree — which is why the path + * comes from `import.meta.url` rather than Bun's `import.meta.dir`, undefined + * once the test runner has bundled the module. + * + * Throwing here fails the build. Bun reports it as a macro that could not be + * coerced to AST, so the reason is logged first to make the diagnostic legible. + */ +export function readWorkerStacks(): Record { + const root = fileURLToPath(new URL("stacks", import.meta.url)); + const stacks: Record = {}; + for (const entry of readdirSync(root, { withFileTypes: true })) { + // `README.md` sits beside the runtime directories and documents them. + if (!entry.isDirectory()) { + continue; + } + const files: Record = {}; + for (const name of readdirSync(join(root, entry.name))) { + files[name] = readFileSync(join(root, entry.name, name), "utf8"); + } + stacks[entry.name] = files; + } + + try { + assertCompleteWorkerStacks(stacks); + } catch (cause) { + console.error(`[worker-stacks] ${String(cause)}`); + throw cause; + } + return stacks; +} diff --git a/apps/cli/src/shared/workers/worker-stacks.ts b/apps/cli/src/shared/workers/worker-stacks.ts new file mode 100644 index 0000000000..4ef3a78a1b --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.ts @@ -0,0 +1,16 @@ +import { + readWorkerStacks, + type WorkerStack, +} from "./worker-stacks.macro.ts" with { type: "macro" }; +import type { WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * The starter files `supabase workers new` writes, per runtime — the contents + * of `./stacks//`, keyed by the name each file is scaffolded as. + * + * The content lives there as ordinary files, authored in the language they are + * written in rather than as string literals, and is discovered by reading the + * directory: a new runtime is a new directory, with nothing to wire up here. + * `worker-stacks.macro.ts` explains how that survives compilation. + */ +export const WORKER_STACKS: Record = readWorkerStacks(); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts new file mode 100644 index 0000000000..ecd09ac1fb --- /dev/null +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -0,0 +1,45 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** + * Every worker failure carries a `detail` saying what happened and a + * `suggestion` naming the command that fixes it. The shared output layer renders + * the pair, so no command formats its own recovery line. + */ + +export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameError")<{ + 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; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `--source` names a directory it is not allowed to name. Worth its own error + * because the destination is where the starter files land, so a value that + * resolves to the project root, `supabase/`, or anywhere outside the project has + * to be refused before anything is written. + */ +export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSourceError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts new file mode 100644 index 0000000000..774e41baed --- /dev/null +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -0,0 +1,268 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; +import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; +import { + mockLegacyLinkedProjectCacheLayer, + mockLegacyTelemetryStateLayer, +} from "./legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; + +/** + * Shared scaffolding for the `supabase workers` command integration tests. + * + * Every worker command reads a real `supabase/config.toml` and a real worker + * directory, so these tests run against a per-test temp project rather than a + * mocked filesystem — the config-writing and packaging behaviour is most of + * what is worth asserting. Only the network is faked. + */ + +export const WORKERS_PROJECT_REF = "abcdefghijklmnopqrst"; + +export interface RecordedRequest { + readonly method: string; + readonly url: string; + /** The request body decoded as UTF-8 — meaningful for the JSON requests. */ + readonly body: string; + /** Byte length of the body, which is what matters for the binary upload. */ + readonly byteLength: number; +} + +export interface StubResponse { + readonly status: number; + readonly body?: unknown; +} + +/** How a test answers one request; sequential entries reply to repeated calls. */ +export type RouteHandler = StubResponse | ReadonlyArray; + +export interface WorkersHttpRoutes { + /** Keyed `" "`, e.g. `"GET /v2/projects/abc.../workers"`. */ + readonly [route: string]: RouteHandler; +} + +function respond( + request: HttpClientRequest.HttpClientRequest, + stub: StubResponse, +): HttpClientResponse.HttpClientResponse { + const hasBody = stub.body !== undefined; + return HttpClientResponse.fromWeb( + request, + new Response(hasBody ? JSON.stringify(stub.body) : "", { + status: stub.status, + headers: hasBody ? { "content-type": "application/json" } : { "content-type": "text/plain" }, + }), + ); +} + +/** + * A single HTTP stub shared by the Management API client and the presigned + * build-context upload, so a test can assert the whole request sequence — mint + * the slot, PUT the bytes, deploy, poll — in the order it happened. + */ +export function mockWorkersHttp(routes: WorkersHttpRoutes) { + const requests: Array = []; + const remaining = new Map>( + Object.entries(routes).map(([route, handler]) => [ + route, + Array.isArray(handler) ? [...handler] : [handler as StubResponse], + ]), + ); + + const handle = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + Effect.sync(() => { + const bytes = request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0); + const url = new URL(request.url); + requests.push({ + method: request.method, + url: request.url, + body: new TextDecoder().decode(bytes), + byteLength: bytes.length, + }); + + const key = `${request.method} ${url.pathname}`; + const queue = remaining.get(key); + if (queue === undefined || queue.length === 0) { + return respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }); + } + // The last stub for a route keeps answering, so a poll loop does not have + // to be stubbed a fixed number of times. + const stub = queue.length === 1 ? queue[0]! : queue.shift()!; + return respond(request, stub); + }); + + const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make(handle)); + + const apiLayer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "workers", + "X-Supabase-Command-Run-ID": "run-123", + }, + }), + ).pipe(Layer.provide(httpClientLayer)); + + return { + layer: Layer.mergeAll(apiLayer, httpClientLayer), + requests, + get routeKeys(): Array { + return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); + }, + }; +} + +/** Worker resource JSON, as the Management API's JSON:API envelope wraps it. */ +export function workerResource(options: { + readonly name: string; + readonly runtime?: string; + readonly size?: string; + readonly exposure?: string; + readonly instances?: number; + readonly buildState?: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + readonly instanceCounts?: { + declared: number; + live: number; + ready: number; + stale: number; + }; + readonly instancesError?: string; +}) { + return { + type: "project_worker", + id: options.name, + attributes: { + spec: { + ...(options.runtime === undefined ? {} : { runtime: options.runtime }), + size: options.size ?? "2gb-1vcpu", + exposure: options.exposure ?? "public", + instances: options.instances ?? 1, + }, + build_state: options.buildState ?? "active", + secret_generation: "gen-1", + ...(options.stateReason === undefined ? {} : { state_reason: options.stateReason }), + ...(options.imageVersion === undefined ? {} : { image_version: options.imageVersion }), + ...(options.deleting === undefined ? {} : { deleting: options.deleting }), + ...(options.instanceCounts === undefined ? {} : { instances: options.instanceCounts }), + ...(options.instancesError === undefined ? {} : { instances_error: options.instancesError }), + }, + }; +} + +export const workersRoute = (suffix = "") => `/v2/projects/${WORKERS_PROJECT_REF}/workers${suffix}`; + +/** A per-test temp project, optionally pre-seeded with files. */ +export function makeWorkersProject(files: Readonly> = {}): { + readonly dir: string; +} { + const dir = mkdtempSync(join(tmpdir(), "supabase-workers-")); + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = join(dir, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); + } + return { dir }; +} + +/** + * `LegacyCliConfig`, trimmed to what the worker commands read: the workdir they + * treat as the project, and the host their URLs are built on. + */ +const legacyTestCliConfigLayer = (workdir: string) => + Layer.succeed(LegacyCliConfig, { + 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, + userAgent: "supabase", + } as unknown as LegacyCliConfig["Service"]); + +/** The resolver, stubbed: `--project-ref` wins, else the linked project. */ +const legacyTestProjectRefLayer = (linked: boolean) => + Layer.succeed(LegacyProjectRefResolver, { + resolve: (flagValue: Option.Option) => + Option.isSome(flagValue) + ? Effect.succeed(flagValue.value) + : linked + ? Effect.succeed(WORKERS_PROJECT_REF) + : Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ), + } as unknown as LegacyProjectRefResolver["Service"]); + +export interface WorkersSetupOptions { + readonly workdir: string; + /** + * The directory the command was invoked from, when it differs from the + * project — which is what a relative `--source` resolves against. + */ + readonly cwd?: string; + readonly format?: "text" | "json" | "stream-json"; + readonly interactive?: boolean; + readonly linked?: boolean; + readonly promptTextResponses?: ReadonlyArray; + readonly promptSelectResponses?: ReadonlyArray; + readonly routes?: WorkersHttpRoutes; + /** The Go `-o`/`--output` flag, which every command family here honours. */ + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; +} + +export function setupLegacyWorkers(options: WorkersSetupOptions) { + const out = mockOutput({ + format: options.format ?? "text", + interactive: options.interactive ?? (options.format ?? "text") === "text", + ...(options.promptTextResponses === undefined + ? {} + : { promptTextResponses: options.promptTextResponses }), + ...(options.promptSelectResponses === undefined + ? {} + : { promptSelectResponses: options.promptSelectResponses }), + }); + const http = mockWorkersHttp(options.routes ?? {}); + + return { + out, + http, + layer: Layer.mergeAll( + out.layer, + http.layer, + mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), + legacyTestCliConfigLayer(options.workdir), + legacyTestProjectRefLayer(options.linked !== false), + mockLegacyTelemetryStateLayer, + mockLegacyLinkedProjectCacheLayer, + randomLayer, + Layer.succeed( + LegacyOutputFlag, + options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), + ), + BunServices.layer, + ), + }; +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 362fa4e4dc..50b81a2098 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@tsconfig/bun/tsconfig.json", - "exclude": ["supabase"] + "exclude": ["supabase", "src/shared/workers/stacks"] } From 160a3a5c26092cea258d245aabc5f3894869ea1c Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:02:39 -0300 Subject: [PATCH 07/50] feat(cli): add supabase workers push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds and deploys workers into the linked project, and brings the Management API seam with it. Registered under `deploy` as an alias, for anyone reaching for the `supabase functions` verb out of habit. Given no names it deploys every worker in the project, matching `supabase functions deploy`, whose conventions this command set otherwise mirrors. "Every worker" is the union of the directories under `supabase/workers/` and the `[workers.]` entries, so one with a `source` pointing elsewhere is not missed, and the order is sorted rather than whatever the filesystem returned. Deploys run one at a time: each is a server-side container build, so interleaving them would both compete for the alpha's per-project capacity and shred the progress output; the first failure stops the run. The flow is mint an upload slot, PUT the `.tar.gz` build context straight at the presigned URL, deploy, then poll until `build_state` leaves `building`. The upload carries no Supabase credentials: the signature in the URL is the authorization, and the bytes never pass through the management API. That signature is also a write-capable credential for the archive a deploy is about to build from, so `legacyHttpClientLayer` redacts presigned URLs at the logging boundary — `--debug` scrollback and CI logs are not where it belongs, and redacting there covers every presigned URL the CLI might log rather than only this one. Polling is a `Schedule`, and the read inside it retries on a wall-clock budget so a blip of a second or two does not throw away a deploy that still has minutes of build ahead of it. Which spec is sent depends on the runtime: a `dockerfile` worker sends a context and no `spec.runtime`, a catalog runtime sends both, and a bare `sandbox` sends the runtime alone and skips packaging, so it has no URL. A directory with no `[workers.] runtime` has one guessed from marker files once the source is known to exist, reported on stderr with a nudge to pin it down. Everything that can fail deterministically fails before the remote project changes. `-o env` and a `-o toml` payload carrying an absent optional are settled up front rather than at emit time, where the command would exit non-zero having already deployed and invite a retry that deployed again; `--instances` is bounded at the parser the way the config schema bounds `[workers.] instances`, instead of carrying an impossible scaling request through a packaged upload; and a source of nothing but empty directories is refused before an upload slot is minted, rather than deployed as an image with no handler. The build context is packaged in-process rather than by shelling out to `tar`, whose BSD, GNU and absent-on-Windows variants each produce a different archive from the same tree. `tar.ts` writes USTAR directly: files, directories and symlinks, refusing a value too large for an octal header field instead of letting it spill into the next one and read back as a plausible but wrong size. Symlinks are stored as links rather than followed — anything pnpm installs is symlink-dense, so following them would inline every dependency and walk into a link pointing at an ancestor. Every filesystem error propagates: an unreadable file archived as zero bytes, a dropped subtree or an entry lost between `readDirectory` and its stat all mean a successful `push` reporting an image built from an application with a hole in it. The Workers routes answer 404 both for a project outside the alpha's allow-list and for a ref that names nothing this account can see, so the classification reads `error.code`: `not_found` raises `WorkerProjectNotFoundError` naming the ref, `supabase link` and `supabase login`, and anything unrecognized keeps the enrolment answer, since that is what the allow-list has historically returned and guessing the other way sends someone to check a ref that is fine. This is the first command in this shell to call a v2 Management API route; every other one here is a Go-parity port and uses v1 only. Two findings are deliberate follow-ups rather than defects: streaming the build context instead of buffering it, and an ignore mechanism so `.env` and `.git` can be kept out of the uploaded archive. --- .../legacy/auth/legacy-http-debug.layer.ts | 67 +- .../auth/legacy-http-debug.unit.test.ts | 56 ++ .../commands/workers/push/SIDE_EFFECTS.md | 74 ++ .../commands/workers/push/push.command.ts | 62 ++ .../commands/workers/push/push.handler.ts | 406 +++++++++++ .../workers/push/push.integration.test.ts | 665 ++++++++++++++++++ .../commands/workers/workers.command.ts | 3 +- .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 1 + apps/cli/src/shared/workers/tar.ts | 224 ++++++ apps/cli/src/shared/workers/tar.unit.test.ts | 116 +++ .../cli/src/shared/workers/worker-classify.ts | 48 ++ apps/cli/src/shared/workers/worker-config.ts | 10 + .../shared/workers/worker-config.unit.test.ts | 26 +- apps/cli/src/shared/workers/worker-package.ts | 133 ++++ .../workers/worker-package.unit.test.ts | 216 ++++++ .../cli/src/shared/workers/worker-runtimes.ts | 7 + apps/cli/src/shared/workers/worker-url.ts | 17 + apps/cli/src/shared/workers/workers-api.ts | 429 +++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 136 ++++ apps/cli/tests/helpers/legacy-workers.ts | 34 +- 21 files changed, 2717 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/push/push.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.integration.test.ts create mode 100644 apps/cli/src/shared/workers/tar.ts create mode 100644 apps/cli/src/shared/workers/tar.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-classify.ts create mode 100644 apps/cli/src/shared/workers/worker-package.ts create mode 100644 apps/cli/src/shared/workers/worker-package.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-url.ts create mode 100644 apps/cli/src/shared/workers/workers-api.ts diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts index 9e34b6437d..bf93986607 100644 --- a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -6,9 +6,68 @@ import { legacyDohFetchLayer } from "../shared/legacy-http-dns.ts"; import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; /** - * Wraps `FetchHttpClient.layer` so every HTTP request can go through the - * legacy Go-parity debug side channel. The logger itself owns the `--debug` - * guard and byte-for-byte line formatting. + * Query parameters that mean the URL *is* a credential. + * + * A presigned object-store URL authorizes whoever holds it — for the Workers + * build-context upload, to overwrite the archive a deploy is about to build + * from. Logging one verbatim under `--debug` puts that in terminal scrollback + * and in any CI log or bug report the output is pasted into. + */ +const PRESIGNED_QUERY_KEYS = [ + // AWS SigV4 and SigV2 + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "awsaccesskeyid", + // Google Cloud Storage V4 + "x-goog-signature", + "x-goog-credential", + // Azure SAS, and the generic spellings everything else uses + "sig", + "se", + "signature", + "token", +]; + +/** + * The URL as it should appear in a debug log: unchanged, unless its query string + * carries a signature, in which case the query is replaced wholesale. + * + * Redacting the whole query rather than the matched parameters keeps the + * decision simple and cannot leak a sibling parameter that turns out to matter. + * The path survives, which is what makes the line useful for debugging in the + * first place. + * + * A denylist of known signature parameters, so it is by nature incomplete: a + * provider spelling its signature something new would log verbatim until the + * list learns about it. The alternative — redacting every query string — would + * cost the debug log its usefulness on the Management API calls that are the + * whole reason `--debug` exists. Add spellings here as they turn up. + */ +export function legacyRedactHttpUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Not a URL we can reason about; log it as-is rather than swallow it. + return url; + } + if (parsed.search === "") { + return url; + } + const presigned = [...parsed.searchParams.keys()].some((key) => + PRESIGNED_QUERY_KEYS.includes(key.toLowerCase()), + ); + if (!presigned) { + return url; + } + return `${parsed.origin}${parsed.pathname}?`; +} + +/** + * Wraps `FetchHttpClient.layer` so every HTTP request goes through the legacy + * debug side channel. The logger itself owns the `--debug` guard and the + * line formatting. * * `legacyDohFetchLayer` overrides `FetchHttpClient.Fetch` with a * DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. @@ -19,7 +78,7 @@ export const legacyHttpClientLayer = Layer.effect( const logger = yield* LegacyDebugLogger; const base = yield* HttpClient.HttpClient; return HttpClient.mapRequestEffect(base, (req) => - logger.http(req.method, req.url).pipe(Effect.as(req)), + logger.http(req.method, legacyRedactHttpUrl(req.url)).pipe(Effect.as(req)), ); }), ).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(legacyDohFetchLayer)); diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts new file mode 100644 index 0000000000..c77cd9bace --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { legacyRedactHttpUrl } from "./legacy-http-debug.layer.ts"; + +/** + * `--debug` logs every request URL to stderr. For a presigned object-store URL + * the query string *is* the credential — for the Workers build-context upload, + * one that authorizes overwriting the archive a deploy is about to build from — + * so it must not survive into scrollback or a CI log. + */ +describe("legacyRedactHttpUrl", () => { + test.each([ + [ + "an AWS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a GCS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Goog-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a lowercase signature parameter", + "https://store.example/o/ctx?signature=deadbeef&expires=123", + "https://store.example/o/ctx?", + ], + [ + "a bare token parameter", + "https://store.example/o/ctx?token=deadbeef", + "https://store.example/o/ctx?", + ], + ])("redacts the query string of %s", (_label, url, expected) => { + expect(legacyRedactHttpUrl(url)).toBe(expected); + expect(legacyRedactHttpUrl(url)).not.toContain("deadbeef"); + }); + + // The debug log is only useful if ordinary requests still read normally, so + // redaction has to be the exception rather than the rule. + test.each([ + ["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"], + ["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"], + ["a URL with no query at all", "https://api.supabase.com/v1/projects"], + ])("leaves %s untouched", (_label, url) => { + expect(legacyRedactHttpUrl(url)).toBe(url); + }); + + test("passes through something that is not a parseable URL", () => { + expect(legacyRedactHttpUrl("not a url at all")).toBe("not a url at all"); + }); + + test("keeps the path, which is what makes the log line worth having", () => { + expect(legacyRedactHttpUrl("https://store.example/bucket/deep/ctx.tar.gz?sig=x")).toContain( + "/bucket/deep/ctx.tar.gz", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md new file mode 100644 index 0000000000..b145692970 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -0,0 +1,74 @@ +# `supabase workers push [name...] (alias: deploy)` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/**` | 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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | +| `POST` | `/v2/projects/{ref}/workers/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | +| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only | +| `POST` | `/v2/projects/{ref}/workers/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | + +`GET` is polled until `build_state` leaves `building`. + +## Exit Codes + +| Code | Condition | +| ---- | ---------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source directory is missing or empty | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +`-o env` is refused **before** the first deploy rather than at emit time: the +payload always carries a `workers` array, which a flat `KEY=value` list cannot +express, and discovering that at the end would fail the command with the remote +project already changed. + +The presigned `PUT` above is the one request whose URL is itself a credential. +`--debug` logs every request URL, so `legacyHttpClientLayer` redacts query +strings that carry a signature. diff --git a/apps/cli/src/legacy/commands/workers/push/push.command.ts b/apps/cli/src/legacy/commands/workers/push/push.command.ts new file mode 100644 index 0000000000..9262f028a8 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.command.ts @@ -0,0 +1,62 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; + +const config = { + names: Argument.string("name").pipe( + Argument.withDescription("Workers to deploy. Deploys every worker in the project if omitted."), + Argument.variadic(), + ), + instances: Flag.integer("instances").pipe( + // Bounded at the parser, the same way `[workers.] instances` is bounded + // in the config schema. Left unchecked it reached the deploy endpoint — after + // the build context had been packaged and uploaded — as a scaling request the + // platform cannot honour. + Flag.filter( + (instances) => instances >= 0, + (instances) => `--instances ${instances} is negative; pass zero or more.`, + ), + Flag.withDescription( + "Number of instances to run, overriding `instances` in supabase/config.toml for this deploy. Falls back to the recorded value, then 1.", + ), + Flag.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersPushFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersPushCommand = Command.make("push", config).pipe( + Command.withAlias("deploy"), + Command.withDescription( + "Build and deploy workers into the linked Supabase project. Reads each worker's runtime, size and source directory from supabase/config.toml.", + ), + Command.withShortDescription("Build and deploy workers"), + Command.withExamples([ + { + command: "supabase workers push", + description: "Deploy every worker in the project", + }, + { + command: "supabase workers push api", + description: "Deploy a single worker", + }, + { + command: "supabase workers push api web", + description: "Deploy several workers by name", + }, + ]), + Command.withHandler((flags) => + legacyWorkersPush(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "push"])), +); diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts new file mode 100644 index 0000000000..86b9a068d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -0,0 +1,406 @@ +import { Effect, FileSystem, Option, type Schedule } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; +import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { + apiSizeFor, + DEFAULT_WORKER_INSTANCES, + DEFAULT_WORKER_SIZE, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + WORKER_RUNTIMES, + WORKER_SIZES, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { + awaitWorkerBuild, + createWorkerUpload, + deployWorker, + uploadBuildContext, + type WorkerDeploySpec, +} from "../../../../shared/workers/workers-api.ts"; +import { + NoWorkersToDeployError, + UnknownWorkerRuntimeError, + UnknownWorkerSizeError, + WorkerBuildFailedError, + WorkerSourceMissingError, +} from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorker, + legacyDiscoverWorkerNames, + legacyLoadWorkersProject, + legacyValidateWorkerName, + type LegacyWorkersProject, +} from "../workers.shared.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +/** + * `supabase workers push [name]` — build (when there is code to build) and + * 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 + * its runtime guessed from marker files instead — reported, with a nudge to pin + * it down rather than re-guess on every push. + * + * A `dockerfile` worker is tarred and uploaded, and the build happens + * server-side from that context, never on your machine. A catalog runtime with + * code takes the same path, with the base image and a copy synthesized in place + * of your Dockerfile. Every runtime this CLI offers has code to package, so + * there is no path here that skips the upload. + */ + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; + readonly sourceDir: string; +}) { + if (options.recorded !== undefined) { + const recorded = parseWorkerRuntime(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerRuntimeError({ + detail: `supabase/config.toml records an unknown runtime "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] runtime to one of: ${WORKER_RUNTIMES.join(", ")}.`, + }), + ); + } + return recorded; + } + + const output = yield* Output; + const classified = yield* classifyWorkerDir(options.sourceDir); + // A guess the user should pin down: stderr, so it never lands inside a + // payload stdout is carrying. + yield* output.raw( + `No runtime configured for ${options.name}: guessed ${classified.runtime} (${classified.reason}). ` + + `Pin it down by adding [workers.${options.name}] runtime = "${classified.runtime}" to supabase/config.toml.\n`, + "stderr", + ); + return classified.runtime; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; +}) { + if (options.recorded === undefined) { + return DEFAULT_WORKER_SIZE; + } + const recorded = parseWorkerSize(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerSizeError({ + detail: `supabase/config.toml records an unknown size "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] size to one of: ${WORKER_SIZES.join(", ")}.`, + }), + ); + } + return recorded; +}); + +/** + * `--instances` for one deploy, then the recorded count, then + * {@link DEFAULT_WORKER_INSTANCES}. Never left unset, because every deploy sends + * a complete spec and an omitted count rescales the worker. + * + * No unparseable case to report: the config schema and the flag are both bounded + * to a non-negative integer before the handler runs. + */ +function resolveInstances(options: { + readonly recorded: number | undefined; + readonly override: Option.Option; +}): number { + return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); +} + +const deployOneWorker = Effect.fnUntraced(function* (input: { + readonly project: LegacyWorkersProject; + readonly name: string; + readonly projectRef: string; + readonly instances: Option.Option; + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + /** Suppresses this step's human output when `-o` owns stdout. */ + readonly machineOutput: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + + const { project, name, projectRef } = input; + const worker = yield* legacyDescribeWorker(project, name); + + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + // Checked before the runtime is resolved, not after: with no recorded + // runtime, `resolveRuntime` classifies the directory and announces what it + // guessed. Doing that first meant reporting an inference about a path that + // does not exist, and only then failing on the path. + { + const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); + if (stat._tag === "None" || stat.value.type !== "Directory") { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `There is no worker source at ${sourceDisplay}.`, + suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + }), + ); + } + // An empty directory packages and deploys perfectly happily, producing an + // image with nothing in it — a success message for a worker that cannot + // serve anything. Refuse before uploading rather than after. + const contents = yield* fs.readDirectory(worker.sourceDir).pipe(Effect.orElseSucceed(() => [])); + if (contents.length === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} is empty, so there is nothing to deploy.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + } + + const runtime = yield* resolveRuntime({ + name, + recorded: worker.entry?.runtime, + sourceDir: worker.sourceDir, + }); + + // Size: whatever `new --size` recorded, else the alpha envelope's own + // default. Never left unset, because a worker that is actually running always + // has some concrete size — and never silently coerced, because a size the CLI + // does not recognize is a config mistake worth naming. + const size = yield* resolveSize({ name, recorded: worker.entry?.size }); + + const instances = resolveInstances({ + recorded: worker.entry?.instances, + override: input.instances, + }); + + let contextUploadId: string; + { + const packaging = yield* output.task("Packaging worker..."); + const packaged = yield* packageWorkerDirectory(worker.sourceDir).pipe( + Effect.tapError(() => packaging.fail()), + ); + yield* packaging.clear(); + yield* output.raw( + `Packaged ${sourceDisplay} (${packaged.fileCount} files, ${formatBytes( + packaged.archive.length, + )}).\n`, + "stderr", + ); + + // The guard above counts directory entries, so a tree of nothing but empty + // subdirectories reaches here and packages to zero files. For a catalog + // runtime that deploys an image with no handler in it — the exact "nothing + // to deploy" case that guard exists to refuse. + if (packaged.fileCount === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} holds no files to deploy, only empty directories.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + + const uploading = yield* output.task("Uploading build context..."); + const slot = yield* createWorkerUpload(api, projectRef, name).pipe( + Effect.tapError(() => uploading.fail()), + ); + yield* uploadBuildContext(slot, packaged.archive).pipe(Effect.tapError(() => uploading.fail())); + yield* uploading.clear(); + yield* output.raw("Uploaded build context.\n", "stderr"); + contextUploadId = slot.uploadId; + } + + const spec: WorkerDeploySpec = { + // A plain Dockerfile build has no catalog runtime to name; the uploaded + // 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", + instances, + }; + + const deploying = yield* output.task("Deploying worker..."); + yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( + Effect.tapError(() => deploying.fail()), + ); + + const settled = yield* awaitWorkerBuild(api, projectRef, name, { + schedule: input.pollSchedule, + retrySchedule: input.pollRetrySchedule, + onPoll: (polled) => + polled.buildState === "building" ? deploying.message("Building worker...") : Effect.void, + }).pipe(Effect.tapError(() => deploying.fail())); + + if (settled.buildState === "failed") { + yield* deploying.clear(); + return yield* Effect.fail( + new WorkerBuildFailedError({ + detail: `The build for "${name}" failed${ + settled.stateReason === undefined ? "" : `: ${settled.stateReason}` + }.`, + suggestion: `Fix the issue, then re-run \`supabase workers push ${name}\`.`, + }), + ); + } + + yield* deploying.clear(); + + const url = + settled.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + + // Suppressed when `-o` is in play: the payload owns stdout, and these lines + // would land in the middle of it. + if (output.format === "text" && !input.machineOutput) { + // Declarative line first, then the details — the shape every other command + // that reports a completed remote change uses. `legacyRenderWorkerDetails` drops + // empty-valued rows, so optional fields need no conditional spreads. + yield* output.raw( + `Deployed Worker ${legacyAqua(name, process.stdout)} to project ${projectRef}\n`, + ); + yield* output.raw( + legacyRenderWorkerDetails([ + ["Runtime", runtime], + ["Size", formatApiSize(settled.spec.size)], + ["Image", settled.imageVersion ?? ""], + ["Access", settled.spec.exposure], + ["URL", url ?? ""], + ]), + ); + } + + return { + worker_name: name, + runtime, + size: settled.spec.size, + exposure: settled.spec.exposure, + instances: settled.spec.instances, + // Omitted rather than present-and-undefined: `-o toml` hands the payload to + // smol-toml, which cannot represent undefined and would throw *after* the + // upload and deploy had completed. Same reason `url` is spread below. + ...(settled.imageVersion === undefined ? {} : { image_version: settled.imageVersion }), + build_state: settled.buildState, + ...(url === undefined ? {} : { url }), + }; +}); + +/** + * `supabase workers push [name...]` — deploy the named workers, or every worker + * in the project when none are named, mirroring `supabase functions deploy`. + * + * Deploys run one at a time rather than concurrently: each is a server-side + * container build, and interleaving several would both hammer the alpha's + * per-project capacity and shred the progress output. The first failure stops + * the run, because a build that failed is usually the thing to fix before + * spending minutes on the rest. + */ +export const legacyWorkersPush = Effect.fn("legacy.workers.push")(function* ( + flags: LegacyWorkersPushFlags, + options: { + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + } = {}, +) { + const output = yield* Output; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating names, discovering workers — belongs inside, so a malformed + // config still flushes telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const requested = + flags.names.length > 0 + ? yield* Effect.forEach(flags.names, legacyValidateWorkerName) + : yield* legacyDiscoverWorkerNames(project); + + if (requested.length === 0) { + return yield* Effect.fail( + new NoWorkersToDeployError({ + detail: `No workers were named, and none were found in ${displayPath( + project.projectRoot, + project.workersDir, + )}.`, + suggestion: "Scaffold one with `supabase workers new `.", + }), + ); + } + + const names = [...new Set(requested)]; + + // Before the first deploy, not after the last one: this payload always + // carries a `workers` array, so `-o env` can never encode it, and finding + // that out at the end means failing with the remote project already changed. + yield* legacyRejectWorkersEnvOutput(); + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const deployed: Array> = []; + for (const name of names) { + if (names.length > 1 && !machineOutput) { + // stderr, unblanked and labelled, the way `functions deploy` announces + // each function: a bare name with a leading blank line put a section + // header into whatever was consuming stdout. + yield* output.raw(`Deploying Worker: ${legacyAqua(name)}\n`, "stderr"); + } + deployed.push( + yield* deployOneWorker({ + project, + name, + projectRef, + instances: flags.instances, + machineOutput, + ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), + ...(options.pollRetrySchedule === undefined + ? {} + : { pollRetrySchedule: options.pollRetrySchedule }), + }), + ); + } + + const payload = { project_ref: projectRef, workers: deployed }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts new file mode 100644 index 0000000000..0c16266931 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -0,0 +1,665 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Schedule } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, + type WorkersHttpRoutes, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + NoWorkersToDeployError, + WorkerBuildFailedError, + WorkerProjectNotFoundError, + WorkersUnavailableError, + WorkerSourceMissingError, + WorkerUploadFailedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +const UPLOAD_URL = "https://storage.example/deploy-context/api.tar.gz?signed"; +const UPLOAD_ID = "cafe0000000000000000000000000000"; + +/** Polls run with no delay so a build sequence resolves at test speed. */ +const IMMEDIATE = Schedule.recurs(20); + +const uploadSlot = { + data: { + type: "project_worker_upload", + id: UPLOAD_ID, + attributes: { url: UPLOAD_URL, method: "PUT", expires_at: "2026-08-12T00:15:00Z" }, + }, +}; + +function flags(overrides: Partial = {}): LegacyWorkersPushFlags { + return { + names: ["api"], + instances: Option.none(), + projectRef: Option.none(), + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/workers/api/index.js": "export default { fetch: () => new Response('ok') };\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +function routes(overrides: WorkersHttpRoutes = {}): WorkersHttpRoutes { + return { + [`POST ${workersRoute("/api/uploads")}`]: { status: 201, body: uploadSlot }, + "PUT /deploy-context/api.tar.gz": { status: 200 }, + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "active", + imageVersion: "v1", + }), + }, + }, + ...overrides, + }; +} + +/** + * The `_tag` of a failure, for a channel that also carries plain `Error` + * subclasses — `TarPathTooLongError` has no tag. + */ +function tagOf(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "_tag" in error + ? String((error as { _tag: unknown })._tag) + : undefined; +} + +function push(flagOverrides: Partial = {}) { + // Both schedules are injected: the outer poll and the per-read retry. The + // production retry is spaced in seconds, so leaving it in place made the + // transient-failure test wait on a real clock. + return legacyWorkersPush(flags(flagOverrides), { + pollSchedule: IMMEDIATE, + pollRetrySchedule: IMMEDIATE, + }); +} + +describe("legacy workers push", () => { + it.live("packages, uploads, deploys and waits for the build to settle", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(http.routeKeys).toEqual([ + `POST ${workersRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${workersRoute("/api/deploy")}`, + `GET ${workersRoute("/api")}`, + ]); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}")).toEqual({ + data: { + type: "project_worker", + attributes: { + spec: { + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }, + context_upload_id: UPLOAD_ID, + }, + }, + }); + + const upload = http.requests.find((request) => request.method === "PUT"); + expect(upload?.byteLength).toBeGreaterThan(0); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + expect(out.stdoutText).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("omits the runtime for a Dockerfile worker and builds from the uploaded context", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + "supabase/workers/api/Dockerfile": "FROM node:24-alpine\nEXPOSE 8080\n", + }); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + const attributes = JSON.parse(deploy?.body ?? "{}").data.attributes; + expect(attributes.spec).toEqual({ + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }); + expect(attributes.context_upload_id).toBe(UPLOAD_ID); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("guesses the runtime for a directory with no config entry and says so", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/api/package.json": "{}\n", + }); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stderrText).toContain("guessed node"); + expect(out.stderrText).toContain("found package.json"); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBe("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("sends the recorded size and the requested instance count", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(3) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec).toEqual({ + runtime: "node", + size: "4gb-2vcpu", + exposure: "public", + instances: 3, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps a worker scaled at the count recorded in config", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\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.instances).toBe(4); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("lets --instances override the recorded count for one deploy", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(1) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.instances).toBe(1); + }).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({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { + data: workerResource({ name: "api", buildState: "active", imageVersion: "v2" }), + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const polls = http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`); + expect(polls).toHaveLength(3); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with the build's own reason when the build fails", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + buildState: "failed", + stateReason: "error building image: exit status 1", + }), + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerBuildFailedError); + expect((error as WorkerBuildFailedError).detail).toContain("error building image"); + expect((error as WorkerBuildFailedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("stops waiting on a build that never settles, and says where to look", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersPush(flags(), { pollSchedule: Schedule.recurs(2) }).pipe( + Effect.flip, + ); + + expect(tagOf(error)).toBe("WorkerBuildTimeoutError"); + expect((error as { suggestion: string }).suggestion).toContain("supabase workers status api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails before deploying when the presigned upload is rejected", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ "PUT /deploy-context/api.tar.gz": { status: 403, body: "expired" } }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerUploadFailedError); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Both of the next two arrive as a 404 on the same route; only `error.code` + // separates them, so they are asserted against the bodies the API really + // sends rather than a shape of our own invention. + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + expect((error as WorkersUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points at the project ref when no such project exists", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerProjectNotFoundError); + expect((error as WorkerProjectNotFoundError).suggestion).not.toContain("private alpha"); + expect((error as WorkerProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps the enrolment answer for a 404 body it does not recognize", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { status: 404, body: { unexpected: true } }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when the worker has no source on disk", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses an empty source directory instead of deploying nothing", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js"), { force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).detail).toContain("is empty"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rides out a transient failure while polling the build", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { status: 500, body: { message: "blip" } }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + // The blip was retried rather than aborting a deploy already in flight. + expect( + http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`).length, + ).toBeGreaterThan(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("acts on the workdir's project, not the process's directory", () => { + // `--workdir`/`SUPABASE_WORKDIR` names the project every legacy command acts + // on, so the worker discovered here comes from that tree even though the + // process is somewhere else entirely. + const repo = project(); + const elsewhere = makeWorkersProject(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + repo.cleanup(); + rmSync(elsewhere.dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.live("deploys every worker in the project when none are named", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n\n[workers.web]\nruntime = "node"\n`, + "supabase/workers/web/index.js": "export default {};\n", + }); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + ...routes(), + [`POST ${workersRoute("/web/uploads")}`]: { status: 201, body: uploadSlot }, + [`POST ${workersRoute("/web/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/web")}`]: { + status: 200, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "active" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + // Both deployed, in a stable (sorted) order. + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + expect(http.routeKeys).toContain(`POST ${workersRoute("/web/deploy")}`); + expect(http.routeKeys.indexOf(`POST ${workersRoute("/api/deploy")}`)).toBeLessThan( + http.routeKeys.indexOf(`POST ${workersRoute("/web/deploy")}`), + ); + expect(out.stdoutText).toContain("web"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when there are no workers to deploy at all", () => { + const repo = project({ "supabase/config.toml": `project_id = "demo"\n` }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push({ names: [] }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NoWorkersToDeployError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project or an explicit --project-ref", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("packages a --source worker from where its code actually lives", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: routes(), + }); + + return Effect.gen(function* () { + yield* push(); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + // One entry per worker deployed, since a bare push can deploy several. + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + worker_name: "api", + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + build_state: "active", + image_version: "v1", + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o env` cannot express the `workers` array. Discovering that at emit time + // meant failing with the project already changed, inviting a retry that + // deployed all over again. + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes(), + goOutput: "env", + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(tagOf(error)).toBe("LegacyWorkersEnvNotSupportedError"); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The "nothing to deploy" guard counts directory entries, so a tree of empty + // subdirectories used to package to zero files and deploy an image with no + // handler in it. + it.live("refuses a source holding only empty directories, before minting a slot", () => { + const repo = project({ "supabase/workers/api/nested/.keep": "" }); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js")); + rmSync(join(repo.dir, "supabase", "workers", "api", "nested", ".keep")); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The runtime guess is an inference about the contents of a directory, so it + // has no business being reported for a directory that is not there. + it.live("does not report a guessed runtime when the source is missing", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(out.stderrText).not.toContain("guessed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `image_version` is optional in the response. Present-but-undefined made the + // TOML encoder throw, after the upload and deploy had already completed. + it.live("encodes -o toml when the deployed worker has no image version", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("worker_name"); + expect(out.stdoutText).not.toContain("image_version"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A malformed config.toml used to fail outside the finalizers, so the run + // skipped the telemetry flush every invocation is supposed to perform. + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push().pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index ac4555f3de..d575670118 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,10 +1,11 @@ import { Command } from "effect/unstable/cli"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; +import { legacyWorkersPushCommand } from "./push/push.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand]), + Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), ); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index bd9659d06f..124f2423fa 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -198,6 +198,7 @@ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-storage-rm linked": "true", "supabase-test-db local": "true", "supabase-test-new template": "pgtap", + "supabase-workers-push instances": "1", }; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index d9ad846999..963e9b295e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -120,6 +120,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "git-branch", "import-map", "inspect-mode", + "instances", "lang", "last", "metadata-file", diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts new file mode 100644 index 0000000000..37f28ad05c --- /dev/null +++ b/apps/cli/src/shared/workers/tar.ts @@ -0,0 +1,224 @@ +/** + * A minimal USTAR writer, for the `.tar.gz` build context `supabase workers + * push` uploads. + * + * Shelling out to `tar` would be shorter, but the CLI ships as a single + * compiled binary to machines where `tar` may be BSD tar, GNU tar, or absent + * (Windows), and each writes a different archive for the same directory. The + * server only ever untars what we send, so producing the bytes here keeps the + * upload identical on every platform and keeps packaging out of the process + * table. + */ + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +const BLOCK_SIZE = 512; + +export interface TarEntry { + /** Path inside the archive, always `/`-separated and relative. */ + readonly path: string; + readonly contents: Uint8Array; + /** Unix mode bits. Defaults to `0o644`. */ + readonly mode?: number; + /** Modification time in seconds since the epoch. Defaults to `0`. */ + readonly mtime?: number; + /** + * Target of a symbolic link. When set the entry is stored as a link rather + * than as its contents, which is what keeps a symlink-dense tree (anything + * pnpm installed) from being inlined — and what stops a link to a directory + * from being walked into. + */ + readonly linkTarget?: string; +} + +/** + * The largest value an 11-digit octal field can hold: 8 GiB minus one byte for + * a size, and a little past the year 2242 for an mtime. + */ +const MAX_OCTAL_FIELD = 8 ** 11 - 1; + +/** + * USTAR stores numbers as zero-padded octal followed by a NUL. + * + * A value too large for the field renders one digit too long and spills into the + * next field, producing an archive that reads back with a plausible but wrong + * size — corruption no reader can detect. Real tars switch to base-256 here; + * this writer refuses instead, because a build context carrying an 8 GiB file is + * already a mistake worth naming rather than silently mangling. + */ +function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { + const text = Math.floor(value) + .toString(8) + .padStart(length - 1, "0"); + if (text.length > length - 1) { + throw new TarFieldTooLargeError(value); + } + writeAscii(block, offset, text); +} + +function writeAscii(block: Uint8Array, offset: number, value: string): void { + for (let index = 0; index < value.length; index++) { + block[offset + index] = value.charCodeAt(index) & 0xff; + } +} + +/** + * Split a path into USTAR's `prefix` (155 bytes) and `name` (100 bytes) fields. + * The split has to fall on a `/`, so a single path component longer than 100 + * bytes cannot be represented at all. + */ +function splitPath(path: string): { name: string; prefix: string } | undefined { + if (byteLength(path) <= 100) { + return { name: path, prefix: "" }; + } + + for (let index = path.indexOf("/"); index !== -1; index = path.indexOf("/", index + 1)) { + const prefix = path.slice(0, index); + const name = path.slice(index + 1); + if (byteLength(prefix) <= 155 && byteLength(name) <= 100) { + return { name, prefix }; + } + } + + return undefined; +} + +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +/** + * Thrown for a path USTAR cannot represent. A plain `Error` rather than a + * tagged one because `createTar` is a pure synchronous function with no Effect + * semantics of its own; the caller's own error channel is where this surfaces. + * It is still user-actionable — renaming the offending file fixes it — so it + * carries its own classification, and the static identifier keeps the + * fingerprint stable through minification. + */ +export class TarPathTooLongError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarPathTooLongError"; + + constructor(path: string) { + super( + `"${path}" is too long for a tar archive (over 100 bytes with no directory boundary to split on)`, + ); + this.name = "TarPathTooLongError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * Thrown for a number USTAR's octal fields cannot hold — see {@link writeOctal}. + * Untagged for the same reason as {@link TarPathTooLongError}: `createTar` is a + * pure function, and the caller's error channel is where this surfaces. + */ +export class TarFieldTooLargeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarFieldTooLargeError"; + + constructor(value: number) { + super( + `${value} is too large for a tar header field (the limit is ${MAX_OCTAL_FIELD} bytes per file)`, + ); + this.name = "TarFieldTooLargeError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +function header(entry: TarEntry, typeflag: "0" | "2" | "5", size: number): Uint8Array { + const block = new Uint8Array(BLOCK_SIZE); + const split = splitPath(entry.path); + if (split === undefined) { + throw new TarPathTooLongError(entry.path); + } + + const encodedName = encoder.encode(split.name); + block.set(encodedName, 0); + writeOctal(block, 100, 8, entry.mode ?? 0o644); + writeOctal(block, 108, 8, 0); // uid + writeOctal(block, 116, 8, 0); // gid + writeOctal(block, 124, 12, size); + writeOctal(block, 136, 12, entry.mtime ?? 0); + // The checksum field is treated as spaces while the checksum is computed. + block.fill(0x20, 148, 156); + block[156] = typeflag.charCodeAt(0); + if (entry.linkTarget !== undefined) { + const encodedTarget = encoder.encode(entry.linkTarget); + if (encodedTarget.length > 100) { + throw new TarPathTooLongError(entry.linkTarget); + } + block.set(encodedTarget, 157); + } + writeAscii(block, 257, "ustar"); + writeAscii(block, 263, "00"); + block.set(encoder.encode(split.prefix), 345); + + let checksum = 0; + for (const byte of block) { + checksum += byte; + } + // Six octal digits, a NUL, then a space — the form every tar reads. + writeAscii(block, 148, checksum.toString(8).padStart(6, "0")); + block[154] = 0; + block[155] = 0x20; + + return block; +} + +function padding(size: number): number { + const remainder = size % BLOCK_SIZE; + return remainder === 0 ? 0 : BLOCK_SIZE - remainder; +} + +/** + * Build a USTAR archive from `entries`, in the order given. An entry with a + * `linkTarget` is stored as a symbolic link, a path ending in `/` as a + * directory, and everything else as a regular file. The archive ends with the + * two zero blocks every reader expects. + */ +export function createTar(entries: ReadonlyArray): Uint8Array { + const blocks: Array = []; + let total = 0; + + const push = (block: Uint8Array) => { + blocks.push(block); + total += block.length; + }; + + for (const entry of entries) { + const isSymlink = entry.linkTarget !== undefined; + const isDirectory = !isSymlink && entry.path.endsWith("/"); + // A link's target lives in the header, so it carries no content blocks. + const size = isDirectory || isSymlink ? 0 : entry.contents.length; + push(header(entry, isSymlink ? "2" : isDirectory ? "5" : "0", size)); + if (size > 0) { + push(entry.contents); + const pad = padding(size); + if (pad > 0) { + push(new Uint8Array(pad)); + } + } + } + + push(new Uint8Array(BLOCK_SIZE * 2)); + + const archive = new Uint8Array(total); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.length; + } + return archive; +} diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts new file mode 100644 index 0000000000..c7449efeb6 --- /dev/null +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { createTar, TarFieldTooLargeError, TarPathTooLongError } from "./tar.ts"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function field(archive: Uint8Array, block: number, offset: number, length: number): string { + return decoder.decode(archive.subarray(block * 512 + offset, block * 512 + offset + length)); +} + +/** Trim a NUL-padded USTAR field down to its value. */ +function value(archive: Uint8Array, block: number, offset: number, length: number): string { + return (field(archive, block, offset, length).split("\u0000")[0] ?? "").trim(); +} + +describe("createTar", () => { + test("writes a readable ustar header for a file", () => { + const archive = createTar([ + { path: "index.js", contents: encoder.encode("hello"), mode: 0o644, mtime: 1_700_000_000 }, + ]); + + expect(value(archive, 0, 0, 100)).toBe("index.js"); + expect(value(archive, 0, 100, 8)).toBe("0000644"); + expect(value(archive, 0, 124, 12)).toBe("00000000005"); + expect(value(archive, 0, 136, 12)).toBe("14524770400"); + expect(field(archive, 0, 156, 1)).toBe("0"); + expect(value(archive, 0, 257, 6)).toBe("ustar"); + }); + + test("computes a checksum the standard algorithm reproduces", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("a") }]); + const header = archive.subarray(0, 512); + + const recorded = Number.parseInt(value(archive, 0, 148, 8), 8); + let computed = 0; + for (let index = 0; index < 512; index++) { + // The checksum field itself counts as spaces. + computed += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0); + } + + expect(recorded).toBe(computed); + }); + + test("pads content to a 512-byte boundary and ends with two zero blocks", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("hello") }]); + + // header + one padded content block + two trailing zero blocks + expect(archive.length).toBe(512 * 4); + expect(decoder.decode(archive.subarray(512, 517))).toBe("hello"); + expect(archive.subarray(512 * 2).every((byte) => byte === 0)).toBe(true); + }); + + test("emits directory entries with no content and the directory typeflag", () => { + const archive = createTar([ + { path: "nested/", contents: new Uint8Array(0), mode: 0o755 }, + { path: "nested/a.txt", contents: encoder.encode("a") }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("5"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // The directory has no content block, so the next header follows immediately. + expect(value(archive, 1, 0, 100)).toBe("nested/a.txt"); + }); + + test("stores a symlink as a link entry with no content blocks", () => { + const archive = createTar([ + { path: "link.txt", contents: new Uint8Array(0), linkTarget: "target.txt", mode: 0o777 }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + expect(value(archive, 0, 157, 100)).toBe("target.txt"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // Header plus the two trailing zero blocks — no content block in between. + expect(archive.length).toBe(512 * 3); + }); + + test("a symlink entry wins over the trailing-slash directory rule", () => { + const archive = createTar([{ path: "dir", contents: new Uint8Array(0), linkTarget: ".." }]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + }); + + test("refuses a link target too long for the header field", () => { + expect(() => + createTar([ + { path: "link", contents: new Uint8Array(0), linkTarget: `${"t".repeat(120)}.txt` }, + ]), + ).toThrow(TarPathTooLongError); + }); + + test("splits a long path across the prefix and name fields", () => { + const deep = `${"d".repeat(120)}/${"f".repeat(60)}.txt`; + const archive = createTar([{ path: deep, contents: new Uint8Array(0) }]); + + expect(value(archive, 0, 345, 155)).toBe("d".repeat(120)); + expect(value(archive, 0, 0, 100)).toBe(`${"f".repeat(60)}.txt`); + }); + + test("refuses a value too large for an octal header field rather than truncating it", () => { + // One past the 11-digit octal ceiling. Encoding it would spill a digit into + // the next field and read back as a plausible but wrong number. + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), + ).toThrow(TarFieldTooLargeError); + + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 - 1 }]), + ).not.toThrow(); + }); + + test("refuses a path component too long to represent", () => { + expect(() => + createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), + ).toThrow(TarPathTooLongError); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-classify.ts b/apps/cli/src/shared/workers/worker-classify.ts new file mode 100644 index 0000000000..64e19906ec --- /dev/null +++ b/apps/cli/src/shared/workers/worker-classify.ts @@ -0,0 +1,48 @@ +import { join } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { DEFAULT_WORKER_RUNTIME, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * Best-effort classification of a worker directory into a {@link WorkerRuntime} + * from common marker files, so `supabase workers push` can deploy a directory + * that has no `[workers.] runtime` at all. The guess is always reported, + * with a nudge to pin it down, rather than applied silently. + */ + +interface WorkerClassification { + readonly runtime: WorkerRuntime; + /** Human-readable reason, for the line `push` logs about the guess. */ + readonly reason: string; +} + +const MARKERS: ReadonlyArray<{ + readonly runtime: WorkerRuntime; + readonly files: ReadonlyArray; +}> = [ + // An explicit Dockerfile always wins: it is a deliberate signal, not an + // inference. + { runtime: "dockerfile", files: ["Dockerfile"] }, + // Deno is checked before plain `package.json` because a Deno project can + // still have one (editor tooling, a stray dependency) while a Node project + // has no `deno.json`. + { runtime: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"] }, + { runtime: "node", files: ["package.json"] }, +]; + +export const classifyWorkerDir = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + + for (const marker of MARKERS) { + for (const file of marker.files) { + const found = yield* fs.exists(join(dir, file)).pipe(Effect.orElseSucceed(() => false)); + if (found) { + return { runtime: marker.runtime, reason: `found ${file}` } satisfies WorkerClassification; + } + } + } + + return { + runtime: DEFAULT_WORKER_RUNTIME, + reason: `no recognized marker files, defaulting to ${DEFAULT_WORKER_RUNTIME}`, + } satisfies WorkerClassification; +}); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index b316692178..20f7abaa1c 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 instances?: number; readonly source?: string; } @@ -53,6 +54,14 @@ const stringOrUndefined = (value: unknown): string | undefined => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +/** + * A count only counts if it is a non-negative whole number. Anything else is + * dropped so `push` falls back to its own default; the config schema is what + * tells the user the value was wrong. + */ +const instanceCountOrUndefined = (value: unknown): number | undefined => + typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; + /** * The decoded `[workers]` section as per-worker tables. Anything that is not an * object is dropped rather than read as a worker named after it. @@ -76,6 +85,7 @@ export function readWorkersSection(workers: unknown): WorkersSection { entries[key] = { runtime: stringOrUndefined(value["runtime"]), size: stringOrUndefined(value["size"]), + 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 668ef584d3..15ccdc0afd 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -15,23 +15,41 @@ describe("readWorkersSection", () => { test("reads each worker's recorded dials", () => { expect( readWorkersSection({ - api: { runtime: "node", size: "2gb", source: "packages/api" }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, box: { runtime: "sandbox" }, }), ).toEqual({ workers: { - api: { runtime: "node", size: "2gb", source: "packages/api" }, - box: { runtime: "sandbox", size: undefined, source: undefined }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, instances: undefined, source: undefined }, }, }); }); 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, source: undefined } }, + workers: { + api: { runtime: undefined, size: undefined, instances: undefined, source: undefined }, + }, }); }); + // `push` has to send a count with every deploy, so a value the API would + // reject is dropped here and the default used instead. + test.each([ + ["a float", 1.5], + ["a negative", -1], + ["a string", "3"], + ])("drops %s instance count", (_label, value) => { + expect(readWorkersSection({ api: { instances: value } }).workers["api"]?.instances).toBe( + undefined, + ); + }); + + test("keeps a zero instance count, which scales a worker down rather than being absent", () => { + expect(readWorkersSection({ api: { instances: 0 } }).workers["api"]?.instances).toBe(0); + }); + 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-package.ts b/apps/cli/src/shared/workers/worker-package.ts new file mode 100644 index 0000000000..50078f9363 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -0,0 +1,133 @@ +import { gzipSync } from "node:zlib"; +import { Effect, FileSystem } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; + +/** + * Package a worker's source directory into the `.tar.gz` build context the + * Workers API's upload slot expects. + * + * Nothing is excluded. For a `dockerfile` worker the archive is the build + * context, so it has to be what the user's own `Dockerfile` expects to find; + * for a catalog runtime the server synthesizes `FROM ` + `COPY` with no + * install step of its own, so an installed `node_modules/` is a dependency of + * the deploy rather than noise in it. The packaged size is reported back so a + * directory that has grown past what anyone meant to upload is visible before + * the upload rather than after it. + */ + +interface PackagedWorker { + readonly archive: Uint8Array; + readonly fileCount: number; +} + +/** + * Every entry under `root`, as tar entries. + * + * Filesystem errors propagate rather than being skipped: an entry missing from + * the archive means deploying an application with a hole in it, reported as a + * success. A directory the walk cannot read, a file it cannot open and an entry + * that vanishes mid-walk are all that case. + */ +const collectEntries = ( + root: string, + relativeDir: string, +): Effect.Effect, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; + + const names = yield* fs.readDirectory(absoluteDir); + const entries: Array = []; + + for (const name of [...names].sort()) { + const relativePath = relativeDir === "" ? name : `${relativeDir}/${name}`; + const absolutePath = `${root}/${relativePath}`; + + // `readLink` succeeds only for symlinks, so it stands in for the `lstat` + // this FileSystem service does not expose (the same probe + // `legacy-sql-files-glob.ts` uses). Storing the link rather than following + // it is what keeps a pnpm-installed `node_modules` from being inlined file + // by file, keeps a broken link from vanishing, and stops a link pointing at + // an ancestor from being walked into. + const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); + if (linkTarget._tag === "Some") { + entries.push({ + path: relativePath, + contents: new Uint8Array(0), + mode: 0o777, + mtime: 0, + linkTarget: linkTarget.value, + }); + continue; + } + + const info = yield* fs.stat(absolutePath); + + const modified = info.mtime; + const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + + if (info.type === "Directory") { + entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); + entries.push(...(yield* collectEntries(root, relativePath))); + continue; + } + + if (info.type !== "File") { + // Sockets, FIFOs and devices have nothing meaningful to send. + continue; + } + + const contents = yield* fs.readFile(absolutePath); + // The executable bit is the only permission that changes what the image + // does; everything else is normalized so the same tree packages + // identically on every machine. `mode` is a plain number here, unlike the + // `Option`-wrapped `mtime` above. + const executable = (info.mode & 0o111) !== 0; + entries.push({ + path: relativePath, + contents: new Uint8Array(contents), + mode: executable ? 0o755 : 0o644, + mtime, + }); + } + + return entries; + }); + +export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { + const entries = yield* collectEntries(dir, ""); + + // `createTar` throws for a name USTAR cannot represent, such as a path + // component over 100 bytes. That is user-actionable, so it belongs in the + // failure channel: `withJsonErrorHandling` only catches failures, and a defect + // would exit `--output-format json` with no structured error. + const archive = yield* Effect.try({ + try: () => gzipSync(createTar(entries)), + catch: (cause) => { + if (cause instanceof TarPathTooLongError) { + return cause; + } + // Anything else here really is a bug, so let it stay a defect rather than + // dressing it up as a failure the user could act on. + throw cause; + }, + }); + + return { + archive: new Uint8Array(archive), + fileCount: entries.filter((entry) => !entry.path.endsWith("/")).length, + } satisfies PackagedWorker; +}); + +/** `10 KiB` / `1.4 MiB` — the packaged size, as `push` reports it. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const kib = bytes / 1024; + if (kib < 1024) { + return `${Math.ceil(kib)} KiB`; + } + return `${(kib / 1024).toFixed(1)} MiB`; +} diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts new file mode 100644 index 0000000000..83a1e6545d --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -0,0 +1,216 @@ +import { + accessSync, + chmodSync, + constants, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { gunzipSync } from "node:zlib"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; + +/** + * Whether the current user can still read `path` after it was chmod-ed shut. + * + * Root ignores the permission bits, and CI sometimes runs as root, so the + * permission-denied tests below assert the opposite outcome instead of skipping + * — either way the behaviour under test is pinned. + */ +function readableAsCurrentUser(path: string): boolean { + try { + accessSync(path, constants.R_OK); + return true; + } catch { + return false; + } +} + +function listableAsCurrentUser(path: string): boolean { + try { + readdirSync(path); + return true; + } catch { + return false; + } +} + +/** Entry paths and their USTAR typeflags, read back out of the archive. */ +function readEntries(archive: Uint8Array): Array<{ path: string; type: string; link: string }> { + const raw = new Uint8Array(gunzipSync(archive)); + const decoder = new TextDecoder(); + const trim = (value: string) => value.split("\u0000")[0] ?? ""; + const entries: Array<{ path: string; type: string; link: string }> = []; + + for (let offset = 0; offset + 512 <= raw.length;) { + const name = trim(decoder.decode(raw.subarray(offset, offset + 100))); + if (name === "") { + break; + } + const size = Number.parseInt(trim(decoder.decode(raw.subarray(offset + 124, offset + 136))), 8); + entries.push({ + path: name, + type: decoder.decode(raw.subarray(offset + 156, offset + 157)), + link: trim(decoder.decode(raw.subarray(offset + 157, offset + 257))), + }); + offset += 512 + Math.ceil(size / 512) * 512; + } + return entries; +} + +describe("packageWorkerDirectory", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-package-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const pack = (root: string) => + Effect.runPromise(packageWorkerDirectory(root).pipe(Effect.provide(BunServices.layer))); + + test("packages files and nested directories in a stable order", async () => { + mkdirSync(join(dir, "nested")); + writeFileSync(join(dir, "b.txt"), "b"); + writeFileSync(join(dir, "a.txt"), "a"); + writeFileSync(join(dir, "nested", "c.txt"), "c"); + + const result = await pack(dir); + + expect(readEntries(result.archive).map((entry) => entry.path)).toEqual([ + "a.txt", + "b.txt", + "nested/", + "nested/c.txt", + ]); + expect(result.fileCount).toBe(3); + }); + + // Anything pnpm installs is symlink-dense, so following links would inline + // every dependency's real contents — and a link pointing at an ancestor would + // be walked into until the OS refused. + test("stores symlinks as links rather than following them", async () => { + writeFileSync(join(dir, "target.txt"), "hello"); + symlinkSync("target.txt", join(dir, "link.txt")); + + const entries = readEntries((await pack(dir)).archive); + const link = entries.find((entry) => entry.path === "link.txt"); + + expect(link?.type).toBe("2"); + expect(link?.link).toBe("target.txt"); + }); + + test("keeps a broken symlink instead of dropping it", async () => { + symlinkSync("/nowhere-at-all", join(dir, "broken.txt")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); + }); + + test("does not recurse through a directory symlink that points at an ancestor", async () => { + mkdirSync(join(dir, "sub")); + writeFileSync(join(dir, "keep.txt"), "k"); + symlinkSync("..", join(dir, "sub", "up")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.map((entry) => entry.path).sort()).toEqual(["keep.txt", "sub/", "sub/up"]); + expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); + }); + + test("packages an empty directory to an archive with no entries", async () => { + const result = await pack(dir); + + expect(readEntries(result.archive)).toEqual([]); + expect(result.fileCount).toBe(0); + }); + + // A file that cannot be read used to be archived as zero bytes, so `push` + // reported success for a deploy that shipped an empty file. Failing is the + // only honest answer: the archive is the application. + test("fails rather than archiving a file it cannot read as empty", async () => { + const unreadable = join(dir, "secret.txt"); + writeFileSync(unreadable, "important"); + chmodSync(unreadable, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + // Running as root defeats the permission, so only assert when it took hold. + if (readableAsCurrentUser(unreadable)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(unreadable, 0o600); + }); + + test("fails rather than silently dropping a directory it cannot read", async () => { + const locked = join(dir, "locked"); + mkdirSync(locked); + writeFileSync(join(locked, "inside.txt"), "content"); + chmodSync(locked, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + if (listableAsCurrentUser(locked)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(locked, 0o700); + }); +}); + +// `createTar` throws for a name USTAR cannot represent. Called directly inside +// the generator that became a defect, which `withJsonErrorHandling` does not +// catch — so `--output-format json` would have died with no structured error. +describe("packageWorkerDirectory tar limits", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-tar-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("reports an unrepresentable path as a failure rather than a defect", async () => { + // One component over 100 bytes, with no directory boundary to split on. + writeFileSync(join(dir, "a".repeat(120)), "contents"); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + // A failure, not a defect: the difference is whether the JSON error handler + // ever sees it. + expect(JSON.stringify(exit)).toContain("TarPathTooLong"); + }); +}); + +describe("formatBytes", () => { + test("reports each magnitude in the unit a reader expects", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1023)).toBe("1023 B"); + expect(formatBytes(1024)).toBe("1 KiB"); + expect(formatBytes(1024 * 1024)).toBe("1.0 MiB"); + expect(formatBytes(1024 * 1024 * 1.5)).toBe("1.5 MiB"); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 7c9f93e8eb..897087b073 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -65,6 +65,13 @@ export type WorkerSize = (typeof WORKER_SIZES)[number]; /** The first available option — what `new` records when `--size` is omitted. */ export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; +/** + * Instances a worker runs when neither `--instances` nor `[workers.] + * instances` says otherwise. One, because a deploy has to name a count — the + * API's spec requires it — and a worker nobody has scaled is a single instance. + */ +export const DEFAULT_WORKER_INSTANCES = 1; + function isWorkerSize(value: string): value is WorkerSize { return WORKER_SIZES.some((size) => size === value); } diff --git a/apps/cli/src/shared/workers/worker-url.ts b/apps/cli/src/shared/workers/worker-url.ts new file mode 100644 index 0000000000..d82da066f3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-url.ts @@ -0,0 +1,17 @@ +/** + * Where a worker is served. + * + * Every worker gets a path on the project's own API host, exactly like an Edge + * Function — `.supabase.co/workers/v1/` next to + * `.supabase.co/functions/v1/`. One host per project, one path per + * worker: nothing per-worker is provisioned in DNS, so the URL is derived from + * the name rather than returned by the API. + */ + +/** Path prefix workers are served under, mirroring `functions/v1`. */ +const WORKERS_PATH_PREFIX = "/workers/v1"; + +/** The canonical URL of a worker on its project's API host. */ +export function workerUrl(projectRef: string, projectHost: string, name: string): string { + return `https://${projectRef}.${projectHost}${WORKERS_PATH_PREFIX}/${name}`; +} diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts new file mode 100644 index 0000000000..79409d05f6 --- /dev/null +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -0,0 +1,429 @@ +import { + markSupabaseApiInputErrorAsUserInput, + operationDefinitions, + SupabaseApiInputError, + V2CreateWorkerUploadOutput, + V2DeployAWorkerOutput, + V2GetAWorkerOutput, + type ApiClient, +} from "@supabase/api/effect"; +import { Effect, Option, Schedule, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { + WorkerBuildTimeoutError, + WorkersApiNetworkError, + WorkerProjectNotFoundError, + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, + WorkerUploadFailedError, +} from "./workers.errors.ts"; + +/** + * The seam every worker command talks to: `/v2/projects/{ref}/workers` on the + * Management API. + * + * The routes are deliberately few — list, get, mint an upload slot, deploy, + * delete — so this module is thin, and what it mostly adds is status handling. + * The alpha's allow-list answers 404 for a project that is not enrolled, which + * at the transport level is indistinguishable from "no such worker"; so a 404 + * on a collection endpoint (where no worker name could have been wrong) becomes + * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by + * the caller as "not deployed". + */ + +/** The worker shape the API returns, flattened out of its JSON:API envelope. */ +export interface WorkerRecord { + readonly name: string; + readonly spec: { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; + readonly backend?: string; + }; + readonly buildState: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + /** Present only on single-worker reads; a fresh deploy has nothing to report yet. */ + readonly instances?: { + readonly declared: number; + readonly live: number; + readonly ready: number; + readonly stale: number; + }; + /** Set instead of `instances` when the instance read-through failed. */ + readonly instancesError?: string; +} + +export interface WorkerUploadSlot { + readonly uploadId: string; + readonly url: string; + readonly method: string; + readonly expiresAt: string; +} + +/** The `spec` a deploy sends. Mirrors the API's own field names exactly. */ +export interface WorkerDeploySpec { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; +} + +type WorkerResourceData = typeof V2GetAWorkerOutput.Type extends { data: infer D } ? D : never; + +function toWorkerRecord(data: WorkerResourceData): WorkerRecord { + return { + name: data.id, + spec: data.attributes.spec, + buildState: data.attributes.build_state, + stateReason: data.attributes.state_reason, + imageVersion: data.attributes.image_version, + deleting: data.attributes.deleting, + instances: data.attributes.instances, + instancesError: data.attributes.instances_error, + }; +} + +const workersSuggestion = + "Workers are in private alpha. Ask in the Supabase dashboard to have this project enrolled."; + +/** + * The `error.code` a 404 carries, which is the only thing separating a project + * outside the alpha's allow-list from one that does not exist. Both answer 404 + * on the same routes; the bodies differ: + * + * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Workers are not available for this project"}}` + * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` + */ +const NotFoundBody = Schema.Struct({ + error: Schema.Struct({ code: Schema.String }), +}); + +/** + * Which of the two a project-scoped 404 was. + * + * Only `not_found` is read as a missing project — an unrecognized body keeps + * the enrolment answer, because that is what the alpha's allow-list has + * historically returned and guessing the other way would send someone to check + * a ref that is fine. + */ +const projectScoped404 = Effect.fnUntraced(function* (options: { + readonly projectRef: string; + readonly body: string; +}) { + const parsed = yield* Effect.try(() => JSON.parse(options.body) as unknown).pipe( + Effect.flatMap((json) => Schema.decodeUnknownEffect(NotFoundBody)(json)), + Effect.option, + ); + + if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { + return new WorkerProjectNotFoundError({ + detail: `No project ${options.projectRef} was found for this account.`, + suggestion: + "Check the project ref, or pick the project again with `supabase link`. " + + "If it belongs to another account, log in with `supabase login`.", + }); + } + + return new WorkersUnavailableError({ + detail: `Workers are not available for project ${options.projectRef}.`, + suggestion: workersSuggestion, + }); +}); + +/** + * Everything that can go wrong before a status code exists: the generated input + * schema rejecting the request, or the transport failing outright. + */ +function mapRequestError(operation: string) { + return (error: unknown) => { + if (error instanceof SupabaseApiInputError) { + // The only inputs these operations take are the resolved project ref and + // the prevalidated worker name, so a schema rejection is user-derived. + return markSupabaseApiInputErrorAsUserInput(error); + } + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${description}.`, + suggestion: "Check your network connection and retry.", + }); + } + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${String(error)}.`, + suggestion: "Check your network connection and retry.", + }); + }; +} + +const unexpectedStatus = Effect.fnUntraced(function* (options: { + readonly operation: string; + readonly status: number; + readonly body: string; +}) { + const trimmed = options.body.trim(); + return yield* Effect.fail( + new WorkersApiUnexpectedStatusError({ + status: options.status, + detail: `The Workers API answered ${options.status} while trying to ${options.operation}${ + trimmed === "" ? "" : `: ${trimmed}` + }.`, + suggestion: "Retry shortly; if it persists, report it with `supabase issue`.", + }), + ); +}); + +const decodeBody = ( + schema: Schema.Codec, + operation: string, + body: unknown, + status: number, +) => + Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError( + (error) => + new WorkersApiUnexpectedStatusError({ + status, + detail: `The Workers API returned a response this CLI could not read while trying to ${operation}: ${error.message}.`, + suggestion: "Update the CLI with `supabase update`, then retry.", + }), + ), + ); + +/** + * One worker, or `None` when the API has no record of it — which is also what a + * project outside the alpha's allow-list answers, so callers report it as "not + * deployed" and point at `push` rather than guessing which of the two it was. + */ +const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { + const operation = `read worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return Option.none(); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2GetAWorkerOutput, operation, body, response.status); + return Option.some(toWorkerRecord(decoded.data)); +}); + +export const createWorkerUpload = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `stage a build context for "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2CreateWorkerUpload, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 201 && response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2CreateWorkerUploadOutput, operation, body, response.status); + return { + uploadId: decoded.data.id, + url: decoded.data.attributes.url, + method: decoded.data.attributes.method, + expiresAt: decoded.data.attributes.expires_at, + } satisfies WorkerUploadSlot; +}); + +/** + * PUT the archive straight at the presigned slot. The bytes never pass through + * the Management API, so this goes out with no Supabase credentials attached — + * the signature in the URL is the authorization. + * + * That signature is why `legacyHttpClientLayer` redacts query strings before + * logging them — under `--debug` this URL is a write-capable credential. Done + * there rather than here, so the client stays injectable and every presigned URL + * is covered rather than this one call site. + */ +export const uploadBuildContext = Effect.fnUntraced(function* ( + slot: WorkerUploadSlot, + archive: Uint8Array, +) { + const client = yield* HttpClient.HttpClient; + + // The slot names its own method; the API documents `PUT` and nothing else is + // meaningful for a presigned object-store destination, so anything unexpected + // falls back to it rather than assembling a request we cannot build. + const request = ( + slot.method.toUpperCase() === "POST" + ? HttpClientRequest.post(slot.url) + : HttpClientRequest.put(slot.url) + ).pipe(HttpClientRequest.bodyUint8Array(archive, "application/gzip")); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (error) => + new WorkerUploadFailedError({ + detail: `Uploading the build context failed: ${ + error.reason.description ?? error.reason._tag + }.`, + suggestion: "Check your network connection, then re-run the same command.", + }), + ), + ); + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new WorkerUploadFailedError({ + detail: `Uploading the build context failed with status ${response.status}${ + body.trim() === "" ? "" : `: ${body.trim()}` + }.`, + suggestion: "Re-run the same command; the upload slot is minted fresh each time.", + }), + ); + } +}); + +export const deployWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + attributes: { readonly spec: WorkerDeploySpec; readonly contextUploadId?: string }, +) { + const operation = `deploy worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeployAWorker, { + ref: projectRef, + name, + data: { + type: "project_worker", + attributes: { + spec: attributes.spec, + ...(attributes.contextUploadId === undefined + ? {} + : { context_upload_id: attributes.contextUploadId }), + }, + }, + }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 202 && response.status !== 200 && response.status !== 201) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2DeployAWorkerOutput, operation, body, response.status); + return toWorkerRecord(decoded.data); +}); + +/** + * The build runs asynchronously — deploy answers 202 and the worker reaches + * `active` or `failed` later — so `push` polls `get` until `build_state` leaves + * `building`. + * + * The schedule is a parameter so tests can drive the same loop without waiting + * on wall-clock delays. + */ +const WORKER_BUILD_POLL_SCHEDULE = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "10 minutes" }), +); + +/** + * How long one poll read is allowed to keep failing before the deploy is called + * off. + * + * Bounded by elapsed time, not attempts: unspaced attempts are exhausted by a + * two-second blip, abandoning a build the server is still running. Half a minute + * of spaced retries rides that out, and anything still failing after it is the + * real error. + */ +const WORKER_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "30 seconds" }), +); + +export const awaitWorkerBuild = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + options: { + readonly schedule?: Schedule.Schedule; + /** + * Retry schedule for one poll read. A parameter for the same reason + * `schedule` is: it is spaced in seconds, and a test exercising the + * transient-failure path should not wait on a real clock to do it. + */ + readonly retrySchedule?: Schedule.Schedule; + /** Called with each poll's result, for progress reporting. */ + readonly onPoll?: (worker: WorkerRecord) => Effect.Effect; + } = {}, +) { + const poll = Effect.gen(function* () { + // A build can run for minutes, so a single blip on one read should not throw + // away a deploy that is progressing fine. + const worker = yield* getWorker(api, projectRef, name).pipe( + Effect.retry({ schedule: options.retrySchedule ?? WORKER_POLL_READ_RETRY }), + ); + if (Option.isNone(worker)) { + // The deploy was accepted, so the worker exists; a 404 here is the read + // racing the write. Report it as still building and poll again. + return undefined; + } + if (options.onPoll !== undefined) { + yield* options.onPoll(worker.value); + } + return worker.value.buildState === "building" ? undefined : worker.value; + }); + + const settled = yield* poll.pipe( + Effect.repeat({ + schedule: options.schedule ?? WORKER_BUILD_POLL_SCHEDULE, + until: (result) => result !== undefined, + }), + ); + + if (settled === undefined) { + return yield* Effect.fail( + new WorkerBuildTimeoutError({ + detail: `"${name}" was still building when this command stopped waiting.`, + suggestion: `Check on it with \`supabase workers status ${name}\`.`, + }), + ); + } + + return settled; +}); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index ecd09ac1fb..7e01fc5bfb 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../telemetry/error-actionability.ts"; /** @@ -20,6 +21,41 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** A bare `push` found no workers to deploy — none named, none in the project. */ +export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `config.toml` records a runtime this CLI does not offer. + * + * Raised by `push`, the command that reads a worker's runtime back out of + * config; `new` writes one and never reads it. + */ +export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** As {@link UnknownWorkerRuntimeError}, for a recorded instance size. */ +export class UnknownWorkerSizeError extends Data.TaggedError("UnknownWorkerSizeError")<{ + 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; @@ -29,6 +65,15 @@ export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirector } } +export class WorkerSourceMissingError extends Data.TaggedError("WorkerSourceMissingError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * `--source` names a directory it is not allowed to name. Worth its own error * because the destination is where the starter files land, so a value that @@ -43,3 +88,94 @@ export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSou return actionability.provideFlags; } } + +/** The deploy finished, and the build it started failed. */ +export class WorkerBuildFailedError extends Data.TaggedError("WorkerBuildFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** The build never left `building` inside the CLI's polling budget. */ +export class WorkerBuildTimeoutError extends Data.TaggedError("WorkerBuildTimeoutError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} + +/** PUTting the build context to the presigned slot failed. */ +export class WorkerUploadFailedError extends Data.TaggedError("WorkerUploadFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** Transport failure talking to the Management API. */ +export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** + * Workers are in private alpha: the routes answer 404 for a project that is not + * enrolled, which is indistinguishable from an unknown worker at the transport + * level — so this is only raised for the collection endpoints, where there is + * no worker name that could have been wrong. + */ +export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailableError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * The project ref names no project this account can see. + * + * Separated from {@link WorkersUnavailableError} because both arrive as a 404 + * on the same routes, and telling someone to request alpha enrolment for a + * project that does not exist sends them somewhere that cannot help. + */ +export class WorkerProjectNotFoundError extends Data.TaggedError("WorkerProjectNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * Any other status the Workers routes answered with. + * + * Classified from the status it carries rather than bucketed as a service + * failure: a 401 is the user's to fix by logging in and a 403 by getting access, + * and reporting either as `api_status` both misleads the user and blurs the + * actionability signal for every Workers endpoint at once. + */ +export class WorkersApiUnexpectedStatusError extends Data.TaggedError( + "WorkersApiUnexpectedStatusError", +)<{ + readonly detail: string; + readonly suggestion: string; + readonly status: number; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 774e41baed..054add765b 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -14,10 +14,8 @@ import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; -import { - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, -} from "./legacy-mocks.ts"; +import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; +import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; /** @@ -233,6 +231,30 @@ export interface WorkersSetupOptions { readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; } +/** + * `LegacyTelemetryState`, recording whether it was flushed. + * + * Every worker command is supposed to write the telemetry state file on every + * invocation, success or failure — which is only observable if the mock says so, + * so the shared always-void mock cannot cover it. + */ +function mockWorkersTelemetryState() { + let flushed = false; + return { + layer: Layer.succeed(LegacyTelemetryState, { + flush: Effect.sync(() => { + flushed = true; + }), + stitchLogin: () => Effect.void, + clearDistinctId: Effect.void, + resetIdentity: Effect.void, + } as unknown as LegacyTelemetryState["Service"]), + get flushed() { + return flushed; + }, + }; +} + export function setupLegacyWorkers(options: WorkersSetupOptions) { const out = mockOutput({ format: options.format ?? "text", @@ -245,17 +267,19 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { : { promptSelectResponses: options.promptSelectResponses }), }); const http = mockWorkersHttp(options.routes ?? {}); + const telemetry = mockWorkersTelemetryState(); return { out, http, + telemetry, layer: Layer.mergeAll( out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), legacyTestCliConfigLayer(options.workdir), legacyTestProjectRefLayer(options.linked !== false), - mockLegacyTelemetryStateLayer, + telemetry.layer, mockLegacyLinkedProjectCacheLayer, randomLayer, Layer.succeed( From da11ea0c82b6f17e7ea63d2eea996577c71ee565 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:03:16 -0300 Subject: [PATCH 08/50] feat(cli): add supabase workers list, status and delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three read-side verbs, sharing one API seam and one description of what is actually there. `list` is every worker in the project, deployed or not, rendered through `renderGlamourTable` so it sits beside `functions list` and `projects list` looking like them. Its inventory is the union of three sources, because any one alone misleads: the `[workers.*]` entries in `config.toml`, the directories under `supabase/workers/`, and what the API reports as deployed. Leaving the directories out let `list` answer "No workers found" about a worker a bare `push` would discover and deploy. A worker with no deployment shows as `not deployed`; a deployment with no local counterpart is called out on stderr, since pushing it from here would have to guess its runtime — stderr so the note never lands inside a `-o` payload. The runtime column only claims a runtime it can support, and text and payload agree on it: the API omits `spec.runtime` for a context-only build, so on a deployed worker its absence does mean `dockerfile`, while for one never deployed there is nothing to infer from and the column says so rather than falling back to a local entry the deployment may have moved off. The list endpoint makes no per-worker backend call, so the instance column shows the declared count and `status` is where the live one lives. `status` is one worker in detail: the size, access, image and URL a `push` printed once and then scrolled away, plus the live instance tally. The deployed spec is the truth here, not `config.toml` — a worker deployed from its own Dockerfile carries no `spec.runtime`, and letting a stale local entry answer instead would report a runtime that is not what is running. When the instance read-through fails the API says so rather than returning counts, and that failure is reported on stderr instead of printing numbers it does not have. A failed build points at the retry, with the reason the API gave. `delete` removes a worker from the linked project; its instances and image are torn down asynchronously. Whether it exists is asked of the API, never of a local directory, so `status` and `delete` answer that question the same way. Being the irreversible verb, an interactive session has to type the worker's name back before anything happens — the same confirm-by-typing pattern as GitHub's own repository deletion, rather than a bare y/n that is too easy to reflexively accept. `--yes` skips it for scripts, as does a non-interactive session or a machine output format, where there is nowhere to ask. The confirmation counts the live tally when the API reports one and says "declared" when it does not, which for a destructive prompt is the difference that matters. What it does not remove is worth saying out loud, so it says it: the worker's directory and its `config.toml` entry stay on disk — and the redeploy advice waits on that source actually being there, rather than naming a `push` that would fail. None of the three state a local fact it has not checked. `legacyDescribeWorker` can always *compute* a source directory, because with no `[workers.]` entry it falls back to the default path, so a worker deployed from somebody else's checkout would otherwise get a path that looks like fact. It answers separately whether anything local establishes that path, and the reporting variant degrades rather than failing: a configured `source` that no longer resolves inside the project reads the same as having nothing local, which is what the output needs to say, and does not leave a remote worker un-deletable until the user edits `config.toml`. `push` keeps the strict version, since there that directory is what gets packaged and uploaded. Shell conventions across the three: `-o table` and `-o csv` render text like every other resource command rather than falling through to the TOML encoder; a machine format is as non-interactive as a redirected stdout, since `-o` leaves `output.format` as `text` and a prompt would land on the stdout the payload owns; project loading, name validation and worker resolution happen inside the finalizers so those failures still flush telemetry; and `status` and `delete` validate names against the API's DNS-label rule rather than any local naming rule, since neither writes `[workers.]` and a worker visible in `list` should not be impossible to inspect or remove. --- .../commands/workers/delete/SIDE_EFFECTS.md | 69 ++++ .../commands/workers/delete/delete.command.ts | 43 ++ .../commands/workers/delete/delete.handler.ts | 193 +++++++++ .../workers/delete/delete.integration.test.ts | 366 ++++++++++++++++++ .../commands/workers/list/SIDE_EFFECTS.md | 52 +++ .../commands/workers/list/list.command.ts | 35 ++ .../commands/workers/list/list.handler.ts | 185 +++++++++ .../workers/list/list.integration.test.ts | 361 +++++++++++++++++ .../commands/workers/status/SIDE_EFFECTS.md | 54 +++ .../commands/workers/status/status.command.ts | 36 ++ .../commands/workers/status/status.handler.ts | 142 +++++++ .../workers/status/status.integration.test.ts | 366 ++++++++++++++++++ .../commands/workers/workers.command.ts | 11 +- .../legacy/commands/workers/workers.output.ts | 20 +- .../legacy/commands/workers/workers.shared.ts | 62 ++- .../cli/src/shared/workers/worker-runtimes.ts | 7 +- apps/cli/src/shared/workers/workers-api.ts | 67 +++- apps/cli/src/shared/workers/workers.errors.ts | 44 +++ apps/cli/tests/helpers/legacy-workers.ts | 17 +- 19 files changed, 2108 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/delete/delete.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/delete/delete.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/list/list.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/list/list.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/status/status.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/status/status.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/status/status.integration.test.ts diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md new file mode 100644 index 0000000000..f58dbfdf7d --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -0,0 +1,69 @@ +# `supabase workers delete ` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to report the source directory it kept | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +The worker's directory and its `[workers.]` entry are deliberately left +on disk; only the remote worker is deleted. + +## Confirmation + +Interactively, the worker's name has to be typed back before anything is +deleted. `--yes` (the root persistent flag) or `SUPABASE_YES` skips that. With +neither — and no interactive terminal to prompt on, which includes a redirected +stdout and any `--output-format json`/`stream-json` run — the command refuses +rather than deleting unasked. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------- | ------------ | ------------ | --------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec.instances` (for the confirmation) | +| `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (a `404` on DELETE counts — it is already gone) | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `1` | the typed confirmation did not match the worker's name | +| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.command.ts b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts new file mode 100644 index 0000000000..b1d12d1b44 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts @@ -0,0 +1,43 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +// No local `--yes`: it is a root persistent flag every other confirming command +// reads through `legacyResolveYes`, so redeclaring it here would shadow the +// global, list `--yes` twice in `--help`, and quietly ignore `SUPABASE_YES`. +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to delete.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersDeleteFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersDeleteCommand = Command.make("delete", config).pipe( + Command.withDescription( + "Delete a worker from the linked Supabase project. Irreversible; its local directory and supabase/config.toml entry are kept.", + ), + Command.withShortDescription("Delete a worker from Supabase"), + Command.withExamples([ + { + command: "supabase workers delete api", + description: "Delete a worker, confirming by typing its name", + }, + { + command: "supabase workers delete api --yes", + description: "Skip the confirmation prompt (scripts and CI)", + }, + ]), + Command.withHandler((flags) => + legacyWorkersDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "delete"])), +); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts new file mode 100644 index 0000000000..db4242d7d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -0,0 +1,193 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { deleteWorker, getWorker } from "../../../../shared/workers/workers-api.ts"; +import { + WorkerDeleteConfirmationRequiredError, + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorkerForReporting, + legacyLoadWorkersProject, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; + +/** + * `supabase workers delete [name]` — delete the worker; its instances and image + * are torn down asynchronously. Whether it exists is asked of the API, never of + * a local file. + * + * Note what it does *not* remove: the worker's directory and its `config.toml` + * entry stay on disk, so `push ` brings it straight back — which is why + * the command says so. + * + * Being irreversible, an interactive session has to type the worker's name back + * to proceed — the same "confirm by typing it" pattern as GitHub's own repo + * deletion, rather than a bare y/n that is too easy to reflexively confirm. + * `--yes`/`SUPABASE_YES` skips it for scripts, resolved through + * `legacyResolveYes` like every other confirming command rather than through a + * local flag that would shadow the root one. + * + * Without a terminal to prompt on there is no third option: `interactive` tracks + * stdout, so merely redirecting output would otherwise delete unattended. This + * refuses instead, and says which flag would have authorised it. + */ +export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* ( + flags: LegacyWorkersDeleteFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + // `--yes` OR `SUPABASE_YES`, matching `projects delete` and every other + // command that guards a destructive step behind a prompt. + const yes = yield* legacyResolveYes; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = yield* legacyDescribeWorkerForReporting(project, name); + + const fetching = yield* output.task("Fetching worker..."); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + if (Option.isNone(found)) { + return yield* Effect.fail( + new WorkerNotDeployedError({ + detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, + suggestion: `Deploy it with \`supabase workers push ${name}\`.`, + }), + ); + } + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + + if (!yes) { + // `-o json` leaves `output.format` as `text`, so the format check alone + // still let the warning and the prompt run — onto the stdout the user had + // asked to carry a payload. A machine format is as non-interactive as a + // redirected stdout, whichever flag asked for it. + if (output.format !== "text" || machineOutput || !output.interactive) { + return yield* Effect.fail( + new WorkerDeleteConfirmationRequiredError({ + detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, + suggestion: `Re-run \`supabase workers delete ${name} --yes\` to confirm without a prompt.`, + }), + ); + } + + // The live tally when the API reports one, labelled "declared" when it + // does not. `spec.instances` is the target, which for a worker still + // provisioning differs from what is running — and a destructive prompt is + // the wrong place to overstate. + const live = found.value.instances?.live; + const declared = found.value.spec.instances; + const terminating = + live !== undefined + ? live > 0 + ? ` ${live} running instance${live === 1 ? "" : "s"} will be terminated.` + : "" + : declared > 0 + ? ` ${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` + : ""; + yield* output.raw( + `This permanently deletes "${name}" from project ${projectRef}.${terminating}\n`, + ); + const typed = yield* output.promptText(`Type ${name} to confirm`); + // Trimmed: a trailing space from a paste is not a different answer, and + // making someone re-run a destructive command over one is just friction. + if (typed.trim() !== name) { + return yield* Effect.fail( + new WorkerDeleteNotConfirmedError({ + detail: `The confirmation did not match "${name}", so nothing was deleted.`, + suggestion: `Re-run \`supabase workers delete ${name}\` and type the name exactly, or pass --yes.`, + }), + ); + } + } + + const deleting = yield* output.task("Deleting worker..."); + yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.clear(); + + // A worker deployed from another checkout has neither a local entry nor a + // local directory, so there is nothing here that was kept. + const keptSource = worker.sourceExists + ? displayPath(project.projectRoot, worker.sourceDir) + : undefined; + const keptEntry = worker.entry !== undefined; + + const payload = { + worker_name: name, + project_ref: projectRef, + ...(keptSource === undefined ? {} : { kept_source: keptSource }), + kept_config_entry: keptEntry, + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + { + yield* output.raw( + `Deleted Worker ${legacyAqua(name, process.stdout)} from project ${projectRef}\n`, + ); + + // "Deleted" reads more final than it is *when there is something left* — + // so only say so when there is. For an orphan there is nothing local to + // keep, and pointing at `push` would send the user at a command that has + // no source to deploy. + const kept = [ + ...(keptSource === undefined ? [] : [keptSource]), + ...(keptEntry ? ["its supabase/config.toml entry"] : []), + ]; + if (kept.length > 0) { + yield* output.raw(legacyRenderWorkerDetails([["Kept", kept.join(", ")]])); + // Only when the source is still there: a retained `config.toml` entry + // alone is not enough to redeploy from, so `push` would fail on the very + // command this line recommends. + if (keptSource !== undefined) { + yield* output.raw(`Redeploy it with supabase workers push ${name}.\n`); + } + } else { + yield* output.raw( + `Nothing for "${name}" exists in this project on disk, so nothing was kept.\n`, + "stderr", + ); + } + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts new file mode 100644 index 0000000000..8fc7f9900f --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -0,0 +1,366 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + WorkerDeleteConfirmationRequiredError, + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +/** + * A project with `api` configured and on disk by default. Pass a bare config to + * get the orphan case — a worker deployed from somebody else's checkout, with + * nothing local behind it. + */ +function project(config = CONFIG) { + const created = makeWorkersProject({ + "supabase/config.toml": config, + ...(config === CONFIG ? { "supabase/workers/api/index.js": "export default {};\n" } : {}), + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; +const deleteRoute = `DELETE ${workersRoute("/api")}`; + +const routes = { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 3 }) }, + }, + [deleteRoute]: { status: 204 }, +}; + +describe("legacy workers delete", () => { + it.live("deletes after the name is typed back, and keeps the local files", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).toContain("permanently deletes"); + // Labelled "declared" because this response carries no live tally. + // `spec.instances` is the target, not what is running. + expect(out.stdoutText).toContain("3 declared instances"); + expect(out.stdoutText).toContain("Kept"); + + // Nothing local is touched — that is what makes `push` a one-command undo. + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(readFileSync(join(repo.dir, "supabase", "config.toml"), "utf8")).toBe(CONFIG); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes nothing when the confirmation does not match", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["nope"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteNotConfirmedError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("skips the confirmation with --yes", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).not.toContain("permanently deletes"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses to delete unattended rather than skipping the confirmation", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `interactive` follows stdout, so a plain `>` redirect reaches this branch + // even from a live terminal — the case that used to delete without asking. + it.live("refuses when stdout is redirected and no --yes was given", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + interactive: false, + routes, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes unattended when SUPABASE_YES or --yes authorises it", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` before asking anything", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + expect(out.messages.filter((message) => message.type === "warn")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("treats a delete that races another one as done", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 404, body: { message: "already gone" } } }, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected delete status", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 500, body: { message: "boom" } } }, + yes: true, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toEqual({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + kept_source: join("supabase", "workers", "api"), + kept_config_entry: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o json` leaves `output.format` as `text`, so the interactive check alone + // still ran the warning and the prompt — onto the stdout the payload was + // supposed to own. + it.live("refuses rather than prompting when -o json asked for the stdout", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + goOutput: "json", + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkerDeleteConfirmationRequiredError"); + expect(out.stdoutText).not.toContain("permanently deletes"); + expect(http.routeKeys).not.toContain(deleteRoute); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The live tally is what is actually running; `spec.instances` is the target. + // For a worker mid-provision the two differ, and a destructive confirmation is + // the worst place to overstate. + it.live("counts the live instances in the confirmation when the API reports them", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["api"], + routes: { + ...routes, + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + instances: 3, + instanceCounts: { declared: 3, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("1 running instance will be terminated"); + expect(out.stdoutText).not.toContain("3 running"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // An orphan — deployed from another checkout — has no local entry and no local + // directory, so there is nothing that was "kept" and `push` has no source to + // redeploy from. + it.live("does not claim to have kept local files it never had", () => { + const repo = project('project_id = "demo"\n'); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET ${workersRoute("/stray")}`]: { + status: 200, + body: { data: workerResource({ name: "stray" }) }, + }, + [`DELETE ${workersRoute("/stray")}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "stray", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + expect(out.stdoutText).not.toContain("Kept"); + expect(out.stdoutText).not.toContain("workers push stray"); + expect(out.stderrText).toContain("nothing was kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes a deployed worker named root", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET ${workersRoute("/root")}`]: { + status: 200, + body: { data: workerResource({ name: "root" }) }, + }, + [`DELETE ${workersRoute("/root")}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "root", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A `config.toml` entry on its own is not something `push` can deploy from, so + // recommending it would send the user at a command that fails. + it.live("keeps the config entry but does not advise redeploying without a source", () => { + const repo = project(); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("supabase/config.toml entry"); + expect(out.stdoutText).not.toContain("workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Deletion never reads the local source, so a `source` that no longer resolves + // inside the project must not block removing the remote worker. + it.live("deletes the remote worker even when the configured source is unusable", () => { + const repo = project('project_id = "demo"\n\n[workers.api]\nsource = "../../elsewhere"\n'); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toContain(deleteRoute); + expect(out.stdoutText).toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md new file mode 100644 index 0000000000..5538816773 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md @@ -0,0 +1,52 @@ +# `supabase workers list` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for the `[workers.*]` entries | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | ---------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success, including when the project has none | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/list/list.command.ts b/apps/cli/src/legacy/commands/workers/list/list.command.ts new file mode 100644 index 0000000000..ee09a79cac --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.command.ts @@ -0,0 +1,35 @@ +import { 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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const config = { + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersListFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersListCommand = Command.make("list", config).pipe( + Command.withDescription( + "List this project's workers, deployed or not: the union of supabase/config.toml's entries and what the Workers API reports.", + ), + Command.withShortDescription("List this project's workers"), + Command.withExamples([ + { + command: "supabase workers list", + description: "See every worker in the linked project", + }, + ]), + Command.withHandler((flags) => + legacyWorkersList(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "list"])), +); diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts new file mode 100644 index 0000000000..b013449395 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -0,0 +1,185 @@ +import { Effect } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { listWorkers, type WorkerRecord } from "../../../../shared/workers/workers-api.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDiscoverWorkerNames, legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersListFlags } from "./list.command.ts"; + +/** + * `supabase workers list` — every worker in this project, deployed or not. + * + * A union of two sources, because either half alone is misleading: the + * project's `[workers.*]` entries (scaffolded, maybe never deployed) and what + * the API reports as deployed (including anything deployed from elsewhere, or + * from a directory since deleted). A worker in the config with nothing deployed + * shows as `not deployed`; a deployed worker with no local entry is called out, + * since pushing it from here would have to guess its runtime. + * + * The list endpoint deliberately makes no per-worker backend call, so it + * carries no live instance tally — the `INSTANCES` column is the declared + * count from the spec. `status` is where the live tally lives. + */ + +const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES", "URL"] as const; + +interface WorkerRow { + readonly name: string; + /** Has a `[workers.]` entry in `config.toml`. */ + readonly configured: boolean; + /** Exists on this machine at all — a config entry, a directory, or both. */ + readonly local: boolean; + readonly deployed: WorkerRecord | undefined; + readonly localRuntime: string | undefined; + readonly url: string | undefined; +} + +function stateLabel(row: WorkerRow): string { + if (row.deployed === undefined) { + return "not deployed"; + } + if (row.deployed.deleting === true) { + return "deleting"; + } + return row.deployed.buildState; +} + +/** + * The API omits `spec.runtime` only for a context-only build, so for a deployed + * worker its absence *is* "dockerfile". For one that has never been deployed + * there is nothing to infer from — `push` would guess from marker files — so say + * unknown rather than assert a runtime it may not have. + */ +function runtimeLabelFor(row: WorkerRow): string | undefined { + if (row.deployed !== undefined) { + return row.deployed.spec.runtime ?? "dockerfile"; + } + return row.localRuntime; +} + +function runtimeLabel(row: WorkerRow): string { + return runtimeLabelFor(row) ?? "-"; +} + +function toCells(row: WorkerRow): ReadonlyArray { + return [ + row.name, + runtimeLabel(row), + row.deployed === undefined ? "-" : formatApiSize(row.deployed.spec.size), + stateLabel(row), + row.deployed === undefined ? "-" : String(row.deployed.spec.instances), + row.url ?? "-", + ]; +} + +export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( + flags: LegacyWorkersListFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliConfig = yield* LegacyCliConfig; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const fetching = yield* output.task("Fetching workers..."); + const deployed = yield* listWorkers(api, projectRef).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + const byName = new Map(deployed.map((worker) => [worker.name, worker])); + const configuredNames = Object.keys(project.section.workers); + // Three sources: config entries, deployed workers, and directories under the + // workers root. The last are deployable — `legacyDiscoverWorkerNames` is the + // walk a bare `push` does — so the inventory has to show them. + const discoveredNames = yield* legacyDiscoverWorkerNames(project); + const names = [...new Set([...configuredNames, ...discoveredNames, ...byName.keys()])].sort(); + + const rows: Array = names.map((name) => { + const record = byName.get(name); + return { + name, + configured: configuredNames.includes(name), + local: configuredNames.includes(name) || discoveredNames.includes(name), + deployed: record, + localRuntime: project.section.workers[name]?.runtime, + url: + record !== undefined && record.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined, + }; + }); + + const payload = { + project_ref: projectRef, + workers: rows.map((row) => ({ + name: row.name, + configured: row.configured, + local: row.local, + deployed: row.deployed !== undefined, + // Read the same way `runtimeLabel` reads it, so `-o json` and the text + // table cannot disagree: for a deployed worker an absent `spec.runtime` + // *means* dockerfile, and falling back to the local config there + // reported a stale runtime the deployment had moved off. + runtime: runtimeLabelFor(row), + size: row.deployed?.spec.size, + state: stateLabel(row), + instances: row.deployed?.spec.instances, + url: row.url, + })), + }; + + // `-o` is independent of `--output-format`: it leaves `output.format` as + // `text`, so this has to be checked before the text branch below, not + // inside the structured one. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + if (rows.length === 0) { + yield* output.raw("No workers found. Scaffold one with supabase workers new .\n"); + return; + } + + yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); + + // Deployed *and* unconfigured: a bare local directory is also unconfigured, + // and has not been deployed at all. + const orphans = rows + .filter((row) => row.deployed !== undefined && !row.configured) + .map((row) => row.name); + if (orphans.length > 0) { + yield* output.raw( + `${orphans.join(", ")} ${ + orphans.length === 1 ? "is" : "are" + } deployed but absent from supabase/config.toml: pushing from here would have to guess the runtime.\n`, + "stderr", + ); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts new file mode 100644 index 0000000000..65c975f31f --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -0,0 +1,361 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { WorkersUnavailableError } from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const CONFIG = `project_id = "demo" + +[workers.api] +runtime = "node" +size = "2gb" + +[workers.old] +runtime = "deno" +`; + +function project(config = CONFIG) { + const created = makeWorkersProject({ "supabase/config.toml": config }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const listRoute = `GET ${workersRoute()}`; + +describe("legacy workers list", () => { + it.live("shows configured and deployed workers as one inventory", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { + data: [ + workerResource({ name: "api", runtime: "node", imageVersion: "v3" }), + workerResource({ + name: "box", + runtime: "sandbox", + exposure: "private", + instances: 2, + }), + ], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("NAME"); + + const rows = stdout.split("\n").filter((line) => /\|/.test(line) && /api|box|old/.test(line)); + expect(rows).toHaveLength(3); + // Sorted by name, so `api`, `box`, then the scaffolded-but-undeployed `old`. + expect(rows[0]).toContain("2gb (1 vCPU)"); + expect(rows[0]).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(rows[1]).toContain("sandbox"); + expect(rows[2]).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("does not assert a runtime for a worker that has never been deployed", () => { + const repo = project(`project_id = "demo"\n\n[workers.ghost]\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const row = out.stdoutText.split("\n").find((line) => line.includes("ghost")); + expect(row).toBeDefined(); + expect(row).not.toContain("dockerfile"); + expect(row).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("calls out a deployed worker that config.toml does not know about", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "stray", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("stray"); + expect(out.stderrText).toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("says so when the project has no workers at all", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain( + "No workers found. Scaffold one with supabase workers new .", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the inventory as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + name: "api", + configured: true, + local: true, + deployed: true, + runtime: "node", + size: "2gb-1vcpu", + state: "active", + instances: 1, + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + { + name: "old", + configured: true, + local: true, + deployed: false, + runtime: "deno", + size: undefined, + state: "not deployed", + instances: undefined, + url: undefined, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("serialises the inventory for the Go -o flag", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + // `-o` payloads own stdout outright: no clack success line may share it. + const parsed = JSON.parse(out.stdoutText); + expect(parsed.project_ref).toBe(WORKERS_PROJECT_REF); + expect(parsed.workers).toHaveLength(2); + expect(out.messages.filter((m) => m.type === "success")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses -o env, which cannot represent the worker list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "env", + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected status rather than showing an empty list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 500, body: { message: "boom" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("uses an explicit --project-ref without a linked project", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + linked: false, + routes: { + "GET /v2/projects/qrstuvwxyzabcdefghij/workers": { status: 200, body: { data: [] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + + expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/workers"]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project when no ref is given", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A directory under the workers root with no `[workers.]` entry is what + // a bare `push` discovers and deploys, so an inventory that leaves it out can + // say "No workers found" about a worker `push` would happily deploy. + it.live("includes a local worker directory that has no config entry", () => { + const repo = project('project_id = "demo"\n'); + mkdirSync(join(repo.dir, "supabase", "workers", "scaffolded"), { recursive: true }); + writeFileSync(join(repo.dir, "supabase", "workers", "scaffolded", "index.js"), "export {};\n"); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("scaffolded"); + expect(out.stdoutText).not.toContain("No workers found"); + // Never deployed, so it is not announced as a deployed-but-unconfigured + // orphan either. + expect(out.stderrText).not.toContain("scaffolded"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The API omits `spec.runtime` only for a context-only build, so for a + // deployed worker its absence *is* dockerfile. Falling back to the local + // config there made `-o json` report a runtime the text table contradicted. + it.live("reports a deployed dockerfile worker as dockerfile in both renderings", () => { + const repo = project('project_id = "demo"\n\n[workers.api]\nruntime = "node"\n'); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { status: 200, body: { data: [workerResource({ name: "api" })] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data?.["workers"]).toMatchObject([{ name: "api", runtime: "dockerfile" }]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `table` and `csv` are accepted by the global flag for `db query`'s benefit; + // every resource command is meant to ignore them and render text. They used to + // fall through to the TOML encoder. + it.live.each(["table", "csv"] as const)("renders text rather than TOML for -o %s", (goOutput) => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).not.toContain("project_ref = "); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project("project_id = [unclosed\n"); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..cff927b867 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md @@ -0,0 +1,54 @@ +# `supabase workers status ` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to report the worker's source directory | +| `/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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. diff --git a/apps/cli/src/legacy/commands/workers/status/status.command.ts b/apps/cli/src/legacy/commands/workers/status/status.command.ts new file mode 100644 index 0000000000..15f4e5c23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.command.ts @@ -0,0 +1,36 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to inspect.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersStatusFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersStatusCommand = Command.make("status", config).pipe( + Command.withDescription( + "Show one worker in detail: build state, size, access, image, live instance tally and source directory.", + ), + Command.withShortDescription("Show a worker in detail"), + Command.withExamples([ + { + command: "supabase workers status api", + description: "Inspect a specific worker", + }, + ]), + Command.withHandler((flags) => + legacyWorkersStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "status"])), +); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts new file mode 100644 index 0000000000..56133431ce --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -0,0 +1,142 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { getWorker } from "../../../../shared/workers/workers-api.ts"; +import { WorkerNotDeployedError } from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorkerForReporting, + legacyLoadWorkersProject, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersStatusFlags } from "./status.command.ts"; + +/** + * `supabase workers status [name]` — everything known about one worker. + * + * `list`'s companion: the size, image and URL a `push` printed once and then + * scrolled away, plus the live instance tally, which is the only place it is + * available — the list endpoint stays free of per-worker backend calls. + */ +export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* ( + flags: LegacyWorkersStatusFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliConfig = yield* LegacyCliConfig; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = yield* legacyDescribeWorkerForReporting(project, name); + + const fetching = yield* output.task("Fetching worker..."); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + if (Option.isNone(found)) { + return yield* Effect.fail( + new WorkerNotDeployedError({ + detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, + suggestion: `Deploy it with \`supabase workers push ${name}\`.`, + }), + ); + } + + const record = found.value; + const url = + record.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + // Reported only when an entry or the directory establishes it. With neither, + // the path is an inference about a worker that may have been deployed from + // another checkout. + const sourceDisplay = + worker.entry !== undefined || worker.sourceExists + ? displayPath(project.projectRoot, worker.sourceDir) + : undefined; + + const payload = { + worker_name: name, + project_ref: projectRef, + runtime: record.spec.runtime ?? "dockerfile", + size: record.spec.size, + exposure: record.spec.exposure, + build_state: record.buildState, + state_reason: record.stateReason, + image_version: record.imageVersion, + deleting: record.deleting, + declared_instances: record.spec.instances, + instances: record.instances, + instances_error: record.instancesError, + ...(sourceDisplay === undefined ? {} : { source: sourceDisplay }), + ...(url === undefined ? {} : { url }), + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + // One structured emission, in the structured branch only. Calling + // `output.success` before this check emitted the payload twice: the JSON + // layer appends each success to stdout, so `JSON.parse` failed, and + // `stream-json` saw two terminal result events. + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + const details: Array = [ + ["State", record.deleting === true ? "deleting" : record.buildState], + ["Reason", record.stateReason ?? ""], + ["Runtime", record.spec.runtime ?? "dockerfile"], + ["Size", formatApiSize(record.spec.size)], + ["Image", record.imageVersion ?? ""], + ["Access", record.spec.exposure], + [ + "Instances", + record.instances !== undefined + ? `${record.instances.ready}/${record.spec.instances} ready, ${record.instances.live} live, ${record.instances.stale} stale` + : `${record.spec.instances} declared`, + ], + ["URL", url ?? ""], + ["Project", projectRef], + // `legacyRenderWorkerDetails` drops empty-valued rows, so an unknown + // source omits the row rather than printing a guess. + ["Source", sourceDisplay ?? ""], + ]; + + yield* output.raw(legacyRenderWorkerDetails(details)); + + if (record.instances === undefined && record.instancesError !== undefined) { + yield* output.raw(`Instance counts could not be read: ${record.instancesError}\n`, "stderr"); + } + if (record.buildState === "failed") { + yield* output.raw(`Fix the issue, then re-run supabase workers push ${name}.\n`); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts new file mode 100644 index 0000000000..00800886ec --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -0,0 +1,366 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + InvalidWorkerNameError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG, + "supabase/workers/api/index.js": "export default {};\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; + +describe("legacy workers status", () => { + it.live("reports the deployment facts and the live instance tally", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instances: 3, + instanceCounts: { declared: 3, live: 3, ready: 2, stale: 1 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("State"); + expect(stdout).toContain("active"); + expect(stdout).toContain("node"); + expect(stdout).toContain("2gb (1 vCPU)"); + expect(stdout).toContain("public"); + expect(stdout).toContain(WORKERS_PROJECT_REF); + expect(stdout).toContain("v3"); + expect(stdout).toContain("2/3 ready, 3 live, 1 stale"); + expect(stdout).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(stdout).toContain(join("supabase", "workers", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the deployed runtime, not a stale config.toml entry", () => { + // config.toml says node; the deployment carries no spec.runtime, which the + // API only omits for a context-only (Dockerfile) build. + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const runtimeLine = out.stdoutText + .split("\n") + .find((line) => line.trim().startsWith("Runtime")); + expect(runtimeLine).toContain("dockerfile"); + expect(runtimeLine).not.toContain("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the declared count when no tally came back", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 2 }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("2 declared"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("warns rather than lying when the instance read-through failed", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + instancesError: "backend unreachable", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stderrText).toContain("backend unreachable"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points a failed build at the retry, with the reason", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "failed", + stateReason: "exit status 1", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("failed"); + expect(out.stdoutText).toContain("exit status 1"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("shows a worker being torn down as deleting", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", deleting: true }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` and points at push", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + expect((error as WorkerNotDeployedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a name that could never have been written", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "My_Worker", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the worker's source directory even when it lives outside supabase/", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain(join("packages", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the same facts as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instanceCounts: { declared: 1, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + build_state: "active", + image_version: "v3", + declared_instances: 1, + instances: { declared: 1, live: 1, ready: 1, stale: 0 }, + }); + // The detail lines are text-mode only. + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The JSON layer appends each success to stdout, so emitting the payload twice + // made `JSON.parse(stdout)` fail outright and gave `stream-json` two terminal + // result events. + it.live("emits exactly one structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { status: 200, body: { data: workerResource({ name: "api" }) } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const results = out.messages.filter( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(results).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A worker deployed from another checkout has no entry and no directory here, + // so `supabase/workers/` is pure inference — reporting it as the + // worker's source named a path that was not there. + it.live("omits the source for a worker with nothing local to point at", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET ${workersRoute("/stray")}`]: { + status: 200, + body: { data: workerResource({ name: "stray" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "stray", projectRef: Option.none() }); + + expect(out.stdoutText).not.toContain("workers/stray"); + expect(out.stdoutText).not.toContain("Source"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `root` is only unusable *locally*, because `[workers] root` occupies the key. + // The API accepts it as a DNS label, and `status` writes no config, so it has + // no business refusing a worker `workers list` will happily show. + it.live("inspects a deployed worker named root", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET ${workersRoute("/root")}`]: { + status: 200, + body: { data: workerResource({ name: "root" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "root", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("active"); + expect(http.routeKeys).toEqual([`GET ${workersRoute("/root")}`]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("flushes telemetry when the worker name is invalid", () => { + const repo = project(); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "Not_A_Label", projectRef: Option.none() }).pipe( + Effect.flip, + ); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index d575670118..b5b536fdb7 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,11 +1,20 @@ import { Command } from "effect/unstable/cli"; +import { legacyWorkersDeleteCommand } from "./delete/delete.command.ts"; +import { legacyWorkersListCommand } from "./list/list.command.ts"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; import { legacyWorkersPushCommand } from "./push/push.command.ts"; +import { legacyWorkersStatusCommand } from "./status/status.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), + Command.withSubcommands([ + legacyWorkersNewCommand, + legacyWorkersPushCommand, + legacyWorkersListCommand, + legacyWorkersStatusCommand, + legacyWorkersDeleteCommand, + ]), ); diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts index 840b0a23d2..08962d01f5 100644 --- a/apps/cli/src/legacy/commands/workers/workers.output.ts +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -19,13 +19,28 @@ import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; * rendering — `output.success` writes to stdout in text mode and would corrupt * the payload otherwise. */ +/** + * Which `-o` values these commands answer with a payload. + * + * `pretty` is the human default. `table` and `csv` are accepted by the global + * flag because `db query` reads them, and every resource command is meant to + * ignore them and fall through to its own text rendering — so treating them as + * machine output emitted TOML for `-o table`, and would now suppress the text + * rendering without putting anything in its place. + */ +function emitsPayloadFor(goFormat: string | undefined): boolean { + return ( + goFormat !== undefined && goFormat !== "pretty" && goFormat !== "table" && goFormat !== "csv" + ); +} + export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( payload: Record, ) { const output = yield* Output; const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); - if (goFormat === undefined || goFormat === "pretty") { + if (!emitsPayloadFor(goFormat)) { return false; } @@ -56,8 +71,7 @@ export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( * by which point those lines would already be on stdout. */ export const legacyWorkersMachineOutputRequested = Effect.fnUntraced(function* () { - const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); - return goFormat !== undefined && goFormat !== "pretty"; + return emitsPayloadFor(Option.getOrUndefined(yield* LegacyOutputFlag)); }); /** diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 871c17e23c..f0ef640eee 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -56,8 +56,17 @@ export interface LegacyResolvedWorker { readonly entry: WorkerEntry | undefined; /** The worker's default directory, `supabase/workers//`. */ readonly defaultDir: string; - /** Where its code actually lives, honouring `[workers.] source`. */ + /** Where its code would live, honouring `[workers.] source`. */ readonly sourceDir: string; + /** + * Whether anything local actually establishes {@link sourceDir}. + * + * `sourceDir` is always computable — with no entry it falls back to the default + * directory — so it cannot on its own tell a worker whose code is on this + * machine from one deployed out of another checkout. Commands that print local + * paths need that difference before they state one as fact. + */ + readonly sourceExists: boolean; } /** @@ -65,26 +74,63 @@ export interface LegacyResolvedWorker { * verdict needs the filesystem: `source` comes from a committed `config.toml`, * and a directory inside the project can symlink anywhere outside it. */ +/** + * As {@link legacyDescribeWorker}, but never failing on the source path. + * + * For commands that only *report* on local state — `status` and `delete` — where + * the source is a detail of the output, not a prerequisite. Making confinement + * mandatory there stranded the remote worker: a `source` that resolves outside + * the project (an in-project directory that became a symlink, say) failed the + * describe before either API call, so `delete` could not remove a worker whose + * local files it was never going to touch. + * + * `push` keeps the strict version, because there the source *is* what gets + * packaged and uploaded. + */ +export const legacyDescribeWorkerForReporting = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, + name: string, +) { + const described = yield* legacyDescribeWorker(project, name).pipe(Effect.option); + if (described._tag === "Some") { + return described.value; + } + // The path is unusable, which for reporting purposes reads the same as having + // nothing local at all. + return { + name, + entry: project.section.workers[name], + defaultDir: workerDir(project.projectRoot, name), + sourceDir: workerDir(project.projectRoot, name), + sourceExists: false, + } satisfies LegacyResolvedWorker; +}); + export const legacyDescribeWorker = Effect.fnUntraced(function* ( project: LegacyWorkersProject, name: string, ) { + const fs = yield* FileSystem.FileSystem; const entry = project.section.workers[name]; const defaultDir = workerDir(project.projectRoot, name); + const sourceDir = yield* workerSourceDir({ + projectRoot: project.projectRoot, + defaultDir, + name, + configuredSource: entry?.source, + }); + const info = yield* fs.stat(sourceDir).pipe(Effect.option); + return { name, entry, defaultDir, - sourceDir: yield* workerSourceDir({ - projectRoot: project.projectRoot, - defaultDir, - name, - configuredSource: entry?.source, - }), + sourceDir, + sourceExists: info._tag === "Some" && info.value.type === "Directory", } satisfies LegacyResolvedWorker; }); -/** Reject a name the CLI could never have written, before acting on it. */ +/** Reject a name that could never be a worker, before acting on it. */ export const legacyValidateWorkerName = Effect.fnUntraced(function* (name: string) { const invalid = validateWorkerNameMessage(name); if (invalid !== undefined) { diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 897087b073..f72fdf0d99 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -116,7 +116,12 @@ const WORKER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; const workerNameRequirement = "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; -/** `undefined` when `name` is a valid worker name, else why it is not. */ +/** + * `undefined` when `name` is a name this CLI can *record*, else why it is not. + * + * For commands that write `[workers.]` — which is `new`, and `push` only + * because it deploys what `new` wrote. + */ export function validateWorkerNameMessage(name: string): string | undefined { return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; } diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index 79409d05f6..627a341b12 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -5,6 +5,7 @@ import { V2CreateWorkerUploadOutput, V2DeployAWorkerOutput, V2GetAWorkerOutput, + V2ListAllWorkersOutput, type ApiClient, } from "@supabase/api/effect"; import { Effect, Option, Schedule, Schema } from "effect"; @@ -26,11 +27,11 @@ import { * * The routes are deliberately few — list, get, mint an upload slot, deploy, * delete — so this module is thin, and what it mostly adds is status handling. - * The alpha's allow-list answers 404 for a project that is not enrolled, which - * at the transport level is indistinguishable from "no such worker"; so a 404 - * on a collection endpoint (where no worker name could have been wrong) becomes - * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by - * the caller as "not deployed". + * A 404 is overloaded on these routes: it is the answer for a project outside + * the alpha's allow-list, for a project ref that names nothing, and for a + * worker that is not deployed. A 404 on a named worker is reported by the + * caller as "not deployed"; one on a collection endpoint, where no worker name + * could have been wrong, is split by its body — see {@link projectScoped404}. */ /** The worker shape the API returns, flattened out of its JSON:API envelope. */ @@ -194,12 +195,43 @@ const decodeBody = ( ), ); +export const listWorkers = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { + const operation = "list workers"; + const response = yield* api + .executeRaw(operationDefinitions.v2ListAllWorkers, { ref: projectRef }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2ListAllWorkersOutput, operation, body, response.status); + return decoded.data.map(toWorkerRecord); +}); + /** * One worker, or `None` when the API has no record of it — which is also what a * project outside the alpha's allow-list answers, so callers report it as "not * deployed" and point at `push` rather than guessing which of the two it was. */ -const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { +export const getWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { const operation = `read worker "${name}"`; const response = yield* api .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) @@ -351,6 +383,29 @@ export const deployWorker = Effect.fnUntraced(function* ( return toWorkerRecord(decoded.data); }); +export const deleteWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `delete worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeleteAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + // 404 is the caller's own "not deployed" verdict to report; a delete that + // races another one is still a delete that happened. + if (response.status === 204 || response.status === 200 || response.status === 404) { + return; + } + + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); +}); + /** * The build runs asynchronously — deploy answers 202 and the worker reaches * `active` or `failed` later — so `push` polls `get` until `build_state` leaves diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 7e01fc5bfb..a69f6de65c 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -129,6 +129,19 @@ export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkE } } +/** + * The named worker is not deployed. `status`/`delete` share this verbatim: the + * question "does this exist?" is asked of the API, never of a local directory. + */ +export class WorkerNotDeployedError extends Data.TaggedError("WorkerNotDeployedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** * Workers are in private alpha: the routes answer 404 for a project that is not * enrolled, which is indistinguishable from an unknown worker at the transport @@ -179,3 +192,34 @@ export class WorkersApiUnexpectedStatusError extends Data.TaggedError( return statusCodeActionability(this.status); } } + +/** The user answered the `delete` confirmation with something other than the name. */ +export class WorkerDeleteNotConfirmedError extends Data.TaggedError( + "WorkerDeleteNotConfirmedError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} + +/** + * `delete` could not ask for confirmation and was not told to skip it. + * + * There is nowhere to read a typed answer from without an interactive terminal, + * and the alternative to refusing is deleting on the strength of the command + * line alone — so a redirected stdout or a CI runner has to pass `--yes` + * (or `SUPABASE_YES`) to say that out loud. + */ +export class WorkerDeleteConfirmationRequiredError extends Data.TaggedError( + "WorkerDeleteConfirmationRequiredError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 054add765b..a9d637992b 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -11,7 +11,8 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; -import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { LegacyOutputFlag, LegacyYesFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; @@ -227,8 +228,16 @@ export interface WorkersSetupOptions { readonly promptTextResponses?: ReadonlyArray; readonly promptSelectResponses?: ReadonlyArray; readonly routes?: WorkersHttpRoutes; - /** The Go `-o`/`--output` flag, which every command family here honours. */ - readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + /** + * The `-o`/`--output` flag, with every value the global flag accepts — + * including `table` and `csv`, which these commands are meant to ignore and + * render text for. + */ + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; + /** The root `--yes`, read by `delete` through `legacyResolveYes`. */ + readonly yes?: boolean; + /** Raw argv, which `legacyResolveYes` scans for an explicit `--yes=false`. */ + readonly cliArgs?: ReadonlyArray; } /** @@ -286,6 +295,8 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { LegacyOutputFlag, options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), ), + Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), BunServices.layer, ), }; From 2ea6b94a6ca1271632b61e2d13cbde3f714a8ebe Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 13:48:33 -0300 Subject: [PATCH 09/50] fix(config): let the Go config loader accept the [workers] section The Go baseConfig is decoded with UnmarshalExact, so any top-level key it does not model is a hard parse error. Once the published JSON schema advertises [workers], a hand-written section breaks every Go-delegated path that calls flags.LoadConfig. Add an ignored Workers field so the delegated child accepts what the TS schema accepts. --- apps/cli-go/pkg/config/config.go | 6 ++++++ apps/cli-go/pkg/config/config_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index b55a52b27e..7fb7b684ed 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -239,6 +239,12 @@ type ( Functions FunctionConfig `toml:"functions" json:"functions"` Analytics analytics `toml:"analytics" json:"analytics"` Experimental experimental `toml:"experimental" json:"experimental"` + // Workers is parsed but never read here. The [workers] section is owned by + // the TS CLI; this field exists only so a config the TS schema accepts does + // not trip UnmarshalExact in the delegated Go child (`flags.LoadConfig`). + // The json tag is the one that matters: the decoder runs with + // dc.TagName = "json". Omitted from toml so Go never emits the section. + Workers map[string]any `toml:"-" json:"workers"` } config struct { diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index aa0570ec34..f09803bc4e 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -285,6 +285,30 @@ enabled = false require.NotNil(t, config.Experimental.PgDelta) assert.False(t, config.Experimental.PgDelta.Enabled) }) + + // [workers] is owned by the TS CLI, but the published JSON schema advertises it, + // so a user can hand-write it today. Every Go-delegated path goes through + // config.Load, and UnmarshalExact rejects keys baseConfig does not model — so the + // section has to at least parse here, in both base and remote position. + t.Run("accepts the TS-owned workers section", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +project_id = "test" + +[workers.api] +runtime = "node" + +[remotes.prod] +project_id = "bvikqvbczudanvggcord" + +[remotes.prod.workers.api] +instances = 3 +`)}, + } + + assert.NoError(t, config.Load("", fsys)) + }) } func TestPgDeltaNpmVersionPinning(t *testing.T) { From 3408a66ea87418b918a4b0289055fe2edd88b1ec Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 14:03:43 -0300 Subject: [PATCH 10/50] test(config): add workers.ts to the pure runtime graph allowlist --- packages/config/src/entrypoint-purity.unit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 2910fa5312..25d123776f 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -304,6 +304,7 @@ const expectedPureGraphFiles = [ "realtime.ts", "storage.ts", "studio.ts", + "workers.ts", ] .map((relativePath) => join(srcDir, relativePath)) .sort(); From d50c763ab2e43b547b303e1883d20435ac3452a8 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 14:16:16 -0300 Subject: [PATCH 11/50] fix(cli): import loadProjectConfig from the effect entrypoint The bare @supabase/config entrypoint is the pure, browser-safe surface after CLI-2231; loadProjectConfig now lives on ./effect. Importing it from the bare specifier broke the bun bundle and the docs-spec script. --- apps/cli/src/legacy/commands/workers/workers.shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 871c17e23c..ca36740223 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { loadProjectConfig } from "@supabase/config"; +import { loadProjectConfig } from "@supabase/config/effect"; import { Effect, FileSystem } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { From 58302a4ba71ae4263c85a9d1eb34600cc9337181 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 16:37:52 -0300 Subject: [PATCH 12/50] refactor(cli): read Option through its public helpers in workers `real._tag === "Some"` and `info._tag === "None"` couple production code to Effect's runtime representation, which the repo guidance rules out: "Do not inspect Effect runtime representations through fields such as `._tag` ... use `Option.isSome`, `Option.isNone`". Three call sites, all introduced by this command family rather than inherited: the canonicalize walk in `worker-paths.ts`, the destination-is-free check in `new.handler.ts`, and the scaffolded-directory scan in `workers.shared.ts`. `isSome`/`isNone` are declared as type guards, so the narrowing that followed each check still holds and nothing else moves. --- apps/cli/src/legacy/commands/workers/new/new.handler.ts | 2 +- apps/cli/src/legacy/commands/workers/workers.shared.ts | 4 ++-- apps/cli/src/shared/workers/worker-paths.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts index 4be159261c..fc0ccfbae1 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -113,7 +113,7 @@ const resolveSize = Effect.fnUntraced(function* (options: { const destinationIsFree = Effect.fnUntraced(function* (target: string) { const fs = yield* FileSystem.FileSystem; const info = yield* fs.stat(target).pipe(Effect.option); - if (info._tag === "None") { + if (Option.isNone(info)) { return true; } if (info.value.type !== "Directory") { diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index ca36740223..a250272e8d 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { loadProjectConfig } from "@supabase/config/effect"; -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { readWorkersSection, @@ -115,7 +115,7 @@ export const legacyDiscoverWorkerNames = Effect.fnUntraced(function* ( const scaffolded: Array = []; for (const entry of entries) { const info = yield* fs.stat(join(project.workersDir, entry)).pipe(Effect.option); - if (info._tag === "Some" && info.value.type === "Directory") { + if (Option.isSome(info) && info.value.type === "Directory") { scaffolded.push(entry); } } diff --git a/apps/cli/src/shared/workers/worker-paths.ts b/apps/cli/src/shared/workers/worker-paths.ts index 95ac98ae8f..45fec7563f 100644 --- a/apps/cli/src/shared/workers/worker-paths.ts +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -1,5 +1,5 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import { InvalidWorkerSourceError } from "./workers.errors.ts"; /** @@ -64,7 +64,7 @@ const canonicalize = Effect.fnUntraced(function* (target: string) { for (;;) { const real = yield* fs.realPath(cursor).pipe(Effect.option); - if (real._tag === "Some") { + if (Option.isSome(real)) { return pending.length === 0 ? real.value : join(real.value, ...pending); } const parent = dirname(cursor); From 59707a6b6567d07f4408da92d0d82b6aac73a68d Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 16:38:11 -0300 Subject: [PATCH 13/50] docs(cli): give the first workers new example its required name `supabase workers new` cannot run: `name` is a required `Argument.string`, so the parser rejects the invocation before the runtime and size prompts the description advertises ever fire. Anyone copying the first example gets a missing-argument error. Left over from the generated-name design, which made the argument optional and assigned a name when it was omitted; that was removed in "drop [workers] root, generated names, and three other one-liners" but the example was not. --- apps/cli/src/legacy/commands/workers/new/new.command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/new.command.ts b/apps/cli/src/legacy/commands/workers/new/new.command.ts index e82bf6550c..3e618842d7 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.command.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.command.ts @@ -52,8 +52,8 @@ export const legacyWorkersNewCommand = Command.make("new", config).pipe( Command.withShortDescription("Scaffold a worker locally"), Command.withExamples([ { - command: "supabase workers new", - description: "Scaffold a worker, prompting for runtime and size", + command: "supabase workers new api", + description: "Scaffold supabase/workers/api, prompting for runtime and size", }, { command: "supabase workers new api --runtime node", From 93bcf8e0b555131e4e626d656c30de2e8056b90f Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 16:39:27 -0300 Subject: [PATCH 14/50] docs(cli): stop pointing users at a [workers] root that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[workers] root` was removed in "drop [workers] root, generated names, and three other one-liners" — `[workers.] source` already puts a worker anywhere in the repo — but four references to it survived, two of them in suggestion strings a user actually sees. The two suggestions told users to point a config key at a directory when that key does not exist and is not the problem. The default directory is `supabase/workers/` with an already-validated name, so it cannot be the project root, `supabase/`, or a directory the CLI owns; a symlink escaping the project is the only way it fails confinement. Both now name that, and offer the escape hatch each caller actually has — `--source` when scaffolding, a recorded `source` when resolving. The two docblocks: `resolveWorkerSource` explained the `functions/`/`migrations/` refusal by analogy to a key that is gone, and now gives the reason directly; `workerSourceDir` referred to a symlinked `[workers] root` where it means `supabase/workers`. Also updates the `new.handler.ts` module docblock, which still described scaffolding into `supabase///` and a name "only generated once both questions have been answered" — the removed generated-name design. --- .../src/legacy/commands/workers/new/new.handler.ts | 12 ++++++++---- apps/cli/src/shared/workers/worker-paths.ts | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts index fc0ccfbae1..d6df968137 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -40,13 +40,12 @@ import { legacyLoadWorkersProject } from "../workers.shared.ts"; import type { LegacyWorkersNewFlags } from "./new.command.ts"; /** - * `supabase workers new [name]` — scaffold `supabase///` from the + * `supabase workers new ` — scaffold `supabase/workers//` from the * chosen runtime's starter files and record the choice in `config.toml`. * Nothing is deployed; this is entirely local-disk work. * * The runtime and size are resolved *before* anything is written, so a - * cancelled prompt leaves nothing behind for this worker at all — including the - * name, which is only generated once both questions have been answered. + * cancelled prompt leaves nothing behind for this worker at all. */ /** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ @@ -186,7 +185,12 @@ export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( projectRoot: project.projectRoot, target: join(project.workersDir, name), subject: `The default directory for "${name}"`, - suggestion: "Point [workers] root at a directory inside supabase/.", + // The default directory is `supabase/workers/` with a validated + // name, so it cannot be the project root, `supabase/`, or a directory + // the CLI owns. A symlink escaping the project is the only way it + // reaches this failure, so that is what the suggestion names. + suggestion: + "supabase/workers, or a directory above it, is a symlink leading outside the project. Replace it with a real directory, or pass --source to scaffold somewhere else inside the project.", }); // Nothing here replaces what is already on disk. Scaffolding over an diff --git a/apps/cli/src/shared/workers/worker-paths.ts b/apps/cli/src/shared/workers/worker-paths.ts index 45fec7563f..730ace5242 100644 --- a/apps/cli/src/shared/workers/worker-paths.ts +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -139,8 +139,8 @@ export const confineWorkerPath = Effect.fnUntraced(function* (options: { * The resolved path is where the starter files land, so a value naming the * project root, `supabase/`, or anywhere outside the project is refused. * `source` is the key that may leave the workers directory, but not the project; - * `functions/` and `migrations/` are refused for the same reason `[workers] root` - * refuses them. + * `functions/` and `migrations/` are refused because the CLI already owns them, + * and a worker scaffolded on top would be read as a function or a migration. */ export const resolveWorkerSource = Effect.fnUntraced(function* (options: { readonly projectRoot: string; @@ -187,7 +187,7 @@ export function workerDir(projectRoot: string, name: string): string { * checkout carrying `source = "../../.."` or an absolute path would otherwise * have `push` package and upload a directory that has nothing to do with the * project. The default directory goes through the same guard so a symlinked - * `[workers] root` cannot escape either. + * `supabase/workers` cannot escape either. */ export const workerSourceDir = Effect.fnUntraced(function* (options: { readonly projectRoot: string; @@ -206,7 +206,7 @@ export const workerSourceDir = Effect.fnUntraced(function* (options: { : `The default directory for "${options.name}"`, suggestion: recorded ? `Set [workers.${options.name}] source to a directory inside the project, relative to the project root.` - : "Point [workers] root at a directory inside supabase/.", + : `supabase/workers, or a directory above it, is a symlink leading outside the project. Replace it with a real directory, or record [workers.${options.name}] source as a directory inside the project.`, }); }); From 32d265f25340d8440ad0d9da70c47267df038e2b Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 16:41:27 -0300 Subject: [PATCH 15/50] docs(cli): describe the shipped refusal semantics in workers new SIDE_EFFECTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checklist still described three designs that were removed and one capability the command never had. It is the compatibility contract and the primary input to the e2e suite, so it has to match what ships. - Title: `[name]` was optional-bracket notation from the generated-name design. `name` is a required argument. - Files Written: "appends/updates `[workers.]` in place" — writes are append-only, and a worker already recorded is refused outright, before the prompts and before anything reaches disk. Says so, and drops "always" from the three rows that only happen on success. - Files Read: `config.toml` is decoded to answer the already-recorded question and then re-read as text to append to, which is two reads worth naming. - Exit codes: "unknown runtime/size" cannot happen — `--runtime` and `--size` are `Flag.choice`, so the parser rejects anything outside the catalog and the recorded values are never read back. "Reserved worker name" went with `RESERVED_WORKER_NAMES`; `validateWorkerNameMessage` is one pattern test. "Records a worker in a form that cannot be edited safely" is now simply a worker that is already recorded, in any form. - `SUPABASE_ACCESS_TOKEN`: the row advertised a keyring → `~/.supabase/access-token` fallback. This command's runtime layer is the CLI config, telemetry state and command runtime — no credentials service, no API client, and nothing that reads the token. Row removed rather than reworded. - Telemetry: `cli_command_executed` is emitted by the `Command.withHandler` wrapper, so a failure the parser catches — a missing name, a `--runtime` outside the choice list — never reaches it, and `telemetry.json` is not written either. --- .../commands/workers/new/SIDE_EFFECTS.md | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md index 53033d7495..03fef85b6d 100644 --- a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase workers new [name]` +# `supabase workers new ` > **Local-disk only.** Nothing is deployed and no Management API route is > called; `workers push` is what talks to the platform. @@ -7,19 +7,24 @@ | Path | Format | When | | ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to refuse a worker that is already recorded | +| `/supabase/config.toml` | TOML | always — decoded to refuse a worker that is already recorded, then re-read as text to append the new entry | | `/` | dir | always, to refuse a destination that is not empty | | `/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 -| Path | Format | When | -| ----------------------------------------------- | ------ | ------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — appends/updates `[workers.]` in place, preserving comments | -| `/supabase/workers//*` | varies | always, unless `--source` names another directory | -| `//*` | varies | when `--source` is given | -| `/telemetry.json` | JSON | always — flushed on success and on failure | +| 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 | + +Writes to `config.toml` are append-only. A worker already recorded under +`[workers.]` is refused outright — before the runtime and size prompts, +and before anything reaches disk — because editing an entry the user owns is +not this command's job. Nothing at the destination is ever removed or overwritten: a destination that exists and is not empty is refused, and clearing it is left to the user. @@ -38,27 +43,32 @@ root. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------- | -| `0` | success | -| `1` | invalid or reserved worker name, unknown runtime/size, bad `--source` | -| `1` | destination exists and is not empty | -| `1` | `config.toml` records a worker in a form that cannot be edited safely | +| Code | Condition | +| ---- | ------------------------------------------------------------ | +| `0` | success | +| `1` | invalid worker name — the name must be a DNS label | +| `1` | bad `--source`: outside the project, or a path the CLI owns | +| `1` | destination exists and is not empty | +| `1` | the worker is already recorded in `config.toml`, in any form | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ------------------ | --------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-handler, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | 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 missing worker +name or a `--runtime`/`--size` 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. From fb04deb640e72ed4574f4c0eece946cec5d4f240 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 17:05:09 -0300 Subject: [PATCH 16/50] fix(cli): keep workers new out of config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `legacyLoadWorkersProject` called `loadProjectConfig(projectRoot)` with no options, and the loader prefers `supabase/config.json` when one exists. In a JSON project `configPath` was therefore the JSON file, and `commitWorkerEntry` appended a `[workers.]` TOML table to it — after the scaffold was already written, leaving the project config unparseable. Two layers, per the review: `tomlOnly: true` at the call site. The entry writer is a TOML text editor, so the loader has to resolve the file that editor can actually edit. `functions new` avoids the same trap by joining `supabase/config.toml` directly; this is that, through the loader. `planWorkerEntry` now parses what it rendered before returning it, and checks the new table reads back out. Appending text to a file this code did not write is a syntactic operation, and the only honest check is to read the result. That also closes the sealed-inline-table case: `workers = {}` cannot be extended by appending `[workers.api]`, and the name is absent from the decoded section, so the already-configured check never fired. Both refusals land before the scaffold, like every other refusal in this handler. A JSON project now gets its worker recorded in `config.toml`, which the default loader lists in `ignoredPaths`, and its `config.json` left byte-for-byte alone. That gap is documented rather than fixed here: writing JSON means either losing the comment preservation `appendTomlSection` exists for, or a second surgical editor, and a holistic overhaul of config writing is planned. Also drops the last `[workers] root` reference, in `worker-config.ts`'s module docblock. --- .../commands/workers/new/SIDE_EFFECTS.md | 23 +++++--- .../workers/new/new.integration.test.ts | 49 +++++++++++++++- .../legacy/commands/workers/workers.shared.ts | 13 ++++- apps/cli/src/shared/workers/worker-config.ts | 58 +++++++++++++++++-- .../shared/workers/worker-config.unit.test.ts | 48 +++++++++++++++ 5 files changed, 178 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md index 03fef85b6d..b7d7f33f8f 100644 --- a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md @@ -21,6 +21,14 @@ | `//*` | 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 +editor, so this command pins the loader to `config.toml` (`tomlOnly`). In a +project that has a `config.json`, the worker is therefore written to +`config.toml` — which that loader lists in `ignoredPaths` — and the `config.json` +is left byte-for-byte alone. A rendered edit that would not parse is refused +before anything reaches disk. + Writes to `config.toml` are append-only. A worker already recorded under `[workers.]` is refused outright — before the runtime and size prompts, and before anything reaches disk — because editing an entry the user owns is @@ -43,13 +51,14 @@ root. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------ | -| `0` | success | -| `1` | invalid worker name — the name must be a DNS label | -| `1` | bad `--source`: outside the project, or a path the CLI owns | -| `1` | destination exists and is not empty | -| `1` | the worker is already recorded in `config.toml`, in any form | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid worker name — the name must be a DNS label | +| `1` | bad `--source`: outside the project, or a path the CLI owns | +| `1` | destination exists and is not empty | +| `1` | the worker is already recorded in `config.toml`, in any form | +| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts index 52a74e7e7a..94e8e8dff9 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -6,7 +6,10 @@ import { makeWorkersProject, setupLegacyWorkers, } from "../../../../../tests/helpers/legacy-workers.ts"; -import { WorkerAlreadyConfiguredError } from "../../../../shared/workers/worker-config.ts"; +import { + WorkerAlreadyConfiguredError, + WorkerConfigWriteUnsafeError, +} from "../../../../shared/workers/worker-config.ts"; import { InvalidWorkerNameError, InvalidWorkerSourceError, @@ -293,6 +296,50 @@ describe("legacy workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The project config loader prefers `supabase/config.json` when one exists, + // and the entry writer is a TOML text editor. Without `tomlOnly` the two + // disagree: the plan targets the JSON file and appends a `[workers.api]` + // table to it, leaving the project config unparseable — after the scaffold is + // already on disk. + it.live("leaves config.json alone in a project that has one", () => { + const configJson = `${JSON.stringify({ project_id: "demo" }, null, 2)}\n`; + const repo = project({ "supabase/config.json": configJson }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + const jsonPath = join(repo.dir, "supabase", "config.json"); + expect(readFileSync(jsonPath, "utf8")).toBe(configJson); + expect(() => JSON.parse(readFileSync(jsonPath, "utf8"))).not.toThrow(); + + // 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`, + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A sealed inline `[workers]` cannot be extended by appending a table, and + // the name is absent from the decoded section, so the already-configured + // check does not fire. Parsing the plan is what refuses it — before the + // scaffold is written, like every other refusal here. + it.live("writes no scaffold when [workers] is a sealed inline table", () => { + const before = 'project_id = "demo"\n\nworkers = { web = { runtime = "node" } }\n'; + const repo = project({ "supabase/config.toml": before }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerConfigWriteUnsafeError); + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toBe(before); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // A plain file used to read as an empty directory, which then failed with a // bare EEXIST from `makeDirectory` instead of naming what was in the way. it.live("refuses a plain file at the destination", () => { diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index a250272e8d..4d13eec532 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -37,9 +37,20 @@ export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { const projectRoot = cliConfig.workdir; const supabaseDir = join(projectRoot, "supabase"); + // `tomlOnly`: the entry writer is a TOML text editor. Without this the loader + // prefers `supabase/config.json` when one exists, `configPath` becomes the + // JSON file, and `commitWorkerEntry` appends a `[workers.]` table to it + // — leaving the project config unparseable after the scaffold is on disk. + // `functions new` avoids the same trap by resolving `supabase/config.toml` + // directly; this is that, through the loader. + // + // A JSON project therefore gets a `config.toml` written beside its + // `config.json`, which the default loader lists in `ignoredPaths`. That is a + // known gap: workers are TOML-only until config writing is overhauled. + // // `loadProjectConfig` returns null when the directory holds no project yet, // which is what lets `workers new` scaffold into a bare one. - const loaded = yield* loadProjectConfig(projectRoot); + const loaded = yield* loadProjectConfig(projectRoot, { tomlOnly: true }); const section = readWorkersSection(loaded?.config.workers); return { diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index b316692178..04a46cccaa 100644 --- a/apps/cli/src/shared/workers/worker-config.ts +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -1,5 +1,6 @@ import { dirname } from "node:path"; import { Data, Effect, FileSystem } from "effect"; +import * as SmolToml from "smol-toml"; import { actionability, type CliErrorActionabilityDeclaration, @@ -11,9 +12,8 @@ import { appendTomlSection, tomlKey } from "./toml-section.ts"; * The `[workers]` section of `supabase/config.toml`, read through the decoded * project config and written back surgically. * - * `[workers]` carries a project-wide `root` plus one `[workers.]` table - * per worker. The schema in `@supabase/config` models exactly that, so reading - * is a matter of splitting the scalar off the record; writing goes through + * `[workers]` carries one `[workers.]` table per worker. The schema in + * `@supabase/config` models exactly that; writing goes through * `./toml-section.ts` so a user's comments and formatting survive. */ @@ -46,6 +46,28 @@ export class WorkerAlreadyConfiguredError extends Data.TaggedError("WorkerAlread } } +/** + * Appending the new table would leave `config.toml` unparseable. + * + * `appendTomlSection` renders one table and puts it at the end, which is only + * valid when the existing file is valid TOML that does not already seal the + * `workers` key. A config whose `[workers]` is an inline table (`workers = {}`) + * is the case in point: TOML inline tables cannot be extended, so appending + * `[workers.api]` produces a file nothing can read. + * + * Rather than enumerate the representations that break, the plan is parsed + * before it is returned. Anything that does not round-trip is refused while the + * refusal is still free — `new` calls this before it writes the scaffold. + */ +export class WorkerConfigWriteUnsafeError extends Data.TaggedError("WorkerConfigWriteUnsafeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + const stringOrUndefined = (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined; @@ -121,10 +143,38 @@ export const planWorkerEntry = Effect.fnUntraced(function* (options: { const exists = yield* fs.exists(options.configPath); const text = exists ? yield* fs.readFileString(options.configPath) : ""; const header = `workers.${tomlKey(options.name)}`; + const next = appendTomlSection(text, header, options.patch); + + // The rendered file has to parse, and the new table has to be readable back + // out of it. Appending text is a syntactic operation on a file this code did + // not write, so the only honest check is to read the result. + const parsed = yield* Effect.try({ + try: () => SmolToml.parse(next), + catch: (cause) => + new WorkerConfigWriteUnsafeError({ + detail: `Recording "${options.name}" would make ${options.configPath} unparseable: ${String(cause)}.`, + suggestion: `Add [workers.${options.name}] to ${options.configPath} yourself.`, + }), + }); + + const workers = parsed["workers"]; + if ( + typeof workers !== "object" || + workers === null || + Array.isArray(workers) || + !(options.name in workers) + ) { + return yield* Effect.fail( + new WorkerConfigWriteUnsafeError({ + detail: `Recording "${options.name}" in ${options.configPath} would not take effect, because its [workers] section cannot be extended by appending a table.`, + suggestion: `Add [workers.${options.name}] to ${options.configPath} yourself.`, + }), + ); + } return { configPath: options.configPath, - text: appendTomlSection(text, header, options.patch), + text: next, } satisfies WorkerEntryWrite; }); 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 668ef584d3..fc7fc7411a 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { readWorkersSection, WorkerAlreadyConfiguredError, + WorkerConfigWriteUnsafeError, commitWorkerEntry, planWorkerEntry, } from "./worker-config.ts"; @@ -131,6 +132,53 @@ describe("planWorkerEntry + commitWorkerEntry", () => { expect(readFileSync(configPath, "utf8")).toBe(before); }); + // An inline `[workers]` is sealed: TOML forbids extending it, so appending + // `[workers.api]` renders a file nothing can parse. The name is absent from + // the decoded section, so the already-configured check cannot catch this — + // reading the rendered plan back is what does. + test.each([ + ["an empty inline workers table", "workers = {}\n"], + [ + "an inline workers table holding another worker", + 'workers = { web = { runtime = "node" } }\n', + ], + ])("refuses to append to %s, leaving the file alone", async (_label, before) => { + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerConfigWriteUnsafeError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // The backstop is not limited to the inline case: a config.toml that does not + // parse to begin with cannot be appended to safely either, and finding that + // out after the scaffold is written is exactly what the plan/commit split + // exists to avoid. + test("refuses a config.toml that does not parse, leaving the file alone", async () => { + const before = "this is not = = toml\n"; + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerConfigWriteUnsafeError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + // Why rendering is separate from writing: `new` writes the starter files before // it records anything, so a failure that could only surface at the write would // leave a scaffold on disk that nothing records. From e8141ec32927fa942bd1f8fdbe8c3334404211af Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 17:09:23 -0300 Subject: [PATCH 17/50] refactor(cli): read Option through its public helpers in workers push The same finding as the `workers new` change one commit down the stack, applied to the three occurrences this branch adds: the symlink probe and the mtime fallback in `worker-package.ts`, and the source-directory check in `push.handler.ts`. `Option.isSome`/`isNone` are type guards, so the narrowing after each check is unchanged. `worker-package.unit.test.ts` read `exit._tag` for the same reason; the repo guidance names `Exit.isSuccess`/`Exit.isFailure` and applies to tests too. `push.integration.test.ts`'s `tagOf` keeps its `_tag` access. It classifies values that may be a `Data.TaggedError` or a plain `Error` subclass with no tag at all, which is the dynamic boundary the guidance carves out. Also corrects the `push.handler.ts` module docblock: the argument is variadic, so it is `[name...]`, matching the SIDE_EFFECTS title. --- .../src/legacy/commands/workers/push/push.handler.ts | 4 ++-- apps/cli/src/shared/workers/worker-package.ts | 6 +++--- .../src/shared/workers/worker-package.unit.test.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 86b9a068d3..caf90a67d2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -50,7 +50,7 @@ import { import type { LegacyWorkersPushFlags } from "./push.command.ts"; /** - * `supabase workers push [name]` — build (when there is code to build) and + * `supabase workers push [name...]` — build (when there is code to build) and * deploy the worker into the linked project. Registered under `deploy` as an * alias, for anyone reaching for the `supabase functions` verb out of habit. * @@ -156,7 +156,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // does not exist, and only then failing on the path. { const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); - if (stat._tag === "None" || stat.value.type !== "Directory") { + if (Option.isNone(stat) || stat.value.type !== "Directory") { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `There is no worker source at ${sourceDisplay}.`, diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 50078f9363..4202d152df 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,5 +1,5 @@ import { gzipSync } from "node:zlib"; -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; @@ -51,7 +51,7 @@ const collectEntries = ( // by file, keeps a broken link from vanishing, and stops a link pointing at // an ancestor from being walked into. const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); - if (linkTarget._tag === "Some") { + if (Option.isSome(linkTarget)) { entries.push({ path: relativePath, contents: new Uint8Array(0), @@ -65,7 +65,7 @@ const collectEntries = ( const info = yield* fs.stat(absolutePath); const modified = info.mtime; - const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + const mtime = Option.isSome(modified) ? Math.floor(modified.value.getTime() / 1000) : 0; if (info.type === "Directory") { entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index 83a1e6545d..de6e2f8cb0 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { gunzipSync } from "node:zlib"; -import { Effect } from "effect"; +import { Effect, Exit } from "effect"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; @@ -150,9 +150,9 @@ describe("packageWorkerDirectory", () => { // Running as root defeats the permission, so only assert when it took hold. if (readableAsCurrentUser(unreadable)) { - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); } else { - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); } chmodSync(unreadable, 0o600); }); @@ -168,9 +168,9 @@ describe("packageWorkerDirectory", () => { ); if (listableAsCurrentUser(locked)) { - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); } else { - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); } chmodSync(locked, 0o700); }); @@ -198,7 +198,7 @@ describe("packageWorkerDirectory tar limits", () => { packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), ); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); // A failure, not a defect: the difference is whether the JSON error handler // ever sees it. expect(JSON.stringify(exit)).toContain("TarPathTooLong"); From cb512816fed38c2cda68b2c75ba973f5c918a6ed Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 17:11:12 -0300 Subject: [PATCH 18/50] refactor(cli): read Option through its public helpers in workers read Completes the finding across the stack, on the occurrences this branch adds: the reporting fallback and the source-exists check in `workers.shared.ts`, and the tagged-error assertions in the `list` and `delete` integration tests, which now assert the class rather than the tag string. Two `_tag` reads stay, both under the carve-outs the guidance names: `workers-api.ts` uses `error.reason._tag` as the fallback description when an `HttpClientError` reason carries none. That is a tag rendered as a label, not behaviour branching on a variant. `push.integration.test.ts`'s `tagOf` classifies a channel that carries both `Data.TaggedError`s and plain `Error` subclasses with no tag, which is the dynamic boundary case. --- .../commands/workers/delete/delete.integration.test.ts | 5 +++-- .../legacy/commands/workers/list/list.integration.test.ts | 7 +++++-- apps/cli/src/legacy/commands/workers/workers.shared.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index 8fc7f9900f..f588b47fb1 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -13,6 +13,7 @@ import { WorkerDeleteConfirmationRequiredError, WorkerDeleteNotConfirmedError, WorkerNotDeployedError, + WorkersApiUnexpectedStatusError, } from "../../../../shared/workers/workers.errors.ts"; import { legacyWorkersDelete } from "./delete.handler.ts"; @@ -204,7 +205,7 @@ describe("legacy workers delete", () => { projectRef: Option.none(), }).pipe(Effect.flip); - expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + expect(error).toBeInstanceOf(WorkersApiUnexpectedStatusError); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -250,7 +251,7 @@ describe("legacy workers delete", () => { projectRef: Option.none(), }).pipe(Effect.flip); - expect(error._tag).toBe("WorkerDeleteConfirmationRequiredError"); + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); expect(out.stdoutText).not.toContain("permanently deletes"); expect(http.routeKeys).not.toContain(deleteRoute); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts index 65c975f31f..b63b09ec81 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -11,7 +11,10 @@ import { } from "../../../../../tests/helpers/legacy-workers.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; -import { WorkersUnavailableError } from "../../../../shared/workers/workers.errors.ts"; +import { + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, +} from "../../../../shared/workers/workers.errors.ts"; import { legacyWorkersList } from "./list.handler.ts"; const CONFIG = `project_id = "demo" @@ -246,7 +249,7 @@ describe("legacy workers list", () => { return Effect.gen(function* () { const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); - expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + expect(error).toBeInstanceOf(WorkersApiUnexpectedStatusError); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index df1359ab02..5eaa104e7e 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -103,7 +103,7 @@ export const legacyDescribeWorkerForReporting = Effect.fnUntraced(function* ( name: string, ) { const described = yield* legacyDescribeWorker(project, name).pipe(Effect.option); - if (described._tag === "Some") { + if (Option.isSome(described)) { return described.value; } // The path is unusable, which for reporting purposes reads the same as having @@ -137,7 +137,7 @@ export const legacyDescribeWorker = Effect.fnUntraced(function* ( entry, defaultDir, sourceDir, - sourceExists: info._tag === "Some" && info.value.type === "Directory", + sourceExists: Option.isSome(info) && info.value.type === "Directory", } satisfies LegacyResolvedWorker; }); From c51d62ecbdd01bdc186b942a79d0570f9639a93c Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 18:26:19 -0300 Subject: [PATCH 19/50] fix(cli): follow the LegacyCliSettings rename in workers push develop renamed `LegacyCliConfig` to `LegacyCliSettings` (and its module), which this branch's push handler still imported under the old path. The unresolved import widened the handler's requirements to `unknown`, so the 27 knock-on errors in `push.integration.test.ts` all came from this one line. --- apps/cli/src/legacy/commands/workers/push/push.handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index caf90a67d2..de587c16dd 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -8,7 +8,7 @@ import { } from "../workers.output.ts"; import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; @@ -143,7 +143,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const fs = yield* FileSystem.FileSystem; const output = yield* Output; const api = yield* LegacyPlatformApi; - const cliConfig = yield* LegacyCliConfig; + const settings = yield* LegacyCliSettings; const { project, name, projectRef } = input; const worker = yield* legacyDescribeWorker(project, name); @@ -271,7 +271,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const url = settled.spec.exposure === "public" - ? workerUrl(projectRef, cliConfig.projectHost, name) + ? workerUrl(projectRef, settings.projectHost, name) : undefined; // Suppressed when `-o` is in play: the payload owns stdout, and these lines From baf57a2ccf2c75be2c6e122f55b12842cf52860c Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 18:27:16 -0300 Subject: [PATCH 20/50] fix(cli): follow the LegacyCliSettings rename in workers list and status Same one-line cause as the push handler: develop renamed `LegacyCliConfig` to `LegacyCliSettings`, and the unresolved import widened each handler's requirements to `unknown`, which is where the 30 knock-on errors in `list.integration.test.ts` came from. --- apps/cli/src/legacy/commands/workers/list/list.handler.ts | 6 +++--- .../src/legacy/commands/workers/status/status.handler.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts index b013449395..7eecf5ff15 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -3,7 +3,7 @@ import { Output } from "../../../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; import { workerUrl } from "../../../../shared/workers/worker-url.ts"; import { listWorkers, type WorkerRecord } from "../../../../shared/workers/workers-api.ts"; @@ -87,7 +87,7 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; - const cliConfig = yield* LegacyCliConfig; + const settings = yield* LegacyCliSettings; // The ref is resolved outside the finalizers because caching it is one of // them; everything that can fail on its own — loading `config.toml`, @@ -122,7 +122,7 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( localRuntime: project.section.workers[name]?.runtime, url: record !== undefined && record.spec.exposure === "public" - ? workerUrl(projectRef, cliConfig.projectHost, name) + ? workerUrl(projectRef, settings.projectHost, name) : undefined, }; }); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts index 56133431ce..acbc6758ed 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -3,7 +3,7 @@ import { Output } from "../../../../shared/output/output.service.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; import { workerUrl } from "../../../../shared/workers/worker-url.ts"; @@ -34,7 +34,7 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; - const cliConfig = yield* LegacyCliConfig; + const settings = yield* LegacyCliSettings; // The ref is resolved outside the finalizers because caching it is one of // them; everything that can fail on its own — loading `config.toml`, @@ -65,7 +65,7 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* const record = found.value; const url = record.spec.exposure === "public" - ? workerUrl(projectRef, cliConfig.projectHost, name) + ? workerUrl(projectRef, settings.projectHost, name) : undefined; // Reported only when an entry or the directory establishes it. With neither, // the path is an inference about a worker that may have been deployed from From 3d25f928b3e970619c982ebe65a7713070d6a9a2 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:02:39 -0300 Subject: [PATCH 21/50] feat(cli): add supabase workers push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds and deploys workers into the linked project, and brings the Management API seam with it. Registered under `deploy` as an alias, for anyone reaching for the `supabase functions` verb out of habit. Given no names it deploys every worker in the project, matching `supabase functions deploy`, whose conventions this command set otherwise mirrors. "Every worker" is the union of the directories under `supabase/workers/` and the `[workers.]` entries, so one with a `source` pointing elsewhere is not missed, and the order is sorted rather than whatever the filesystem returned. Deploys run one at a time: each is a server-side container build, so interleaving them would both compete for the alpha's per-project capacity and shred the progress output; the first failure stops the run. The flow is mint an upload slot, PUT the `.tar.gz` build context straight at the presigned URL, deploy, then poll until `build_state` leaves `building`. The upload carries no Supabase credentials: the signature in the URL is the authorization, and the bytes never pass through the management API. That signature is also a write-capable credential for the archive a deploy is about to build from, so `legacyHttpClientLayer` redacts presigned URLs at the logging boundary — `--debug` scrollback and CI logs are not where it belongs, and redacting there covers every presigned URL the CLI might log rather than only this one. Polling is a `Schedule`, and the read inside it retries on a wall-clock budget so a blip of a second or two does not throw away a deploy that still has minutes of build ahead of it. Which spec is sent depends on the runtime: a `dockerfile` worker sends a context and no `spec.runtime`, a catalog runtime sends both, and a bare `sandbox` sends the runtime alone and skips packaging, so it has no URL. A directory with no `[workers.] runtime` has one guessed from marker files once the source is known to exist, reported on stderr with a nudge to pin it down. Everything that can fail deterministically fails before the remote project changes. `-o env` and a `-o toml` payload carrying an absent optional are settled up front rather than at emit time, where the command would exit non-zero having already deployed and invite a retry that deployed again; `--instances` is bounded at the parser the way the config schema bounds `[workers.] instances`, instead of carrying an impossible scaling request through a packaged upload; and a source of nothing but empty directories is refused before an upload slot is minted, rather than deployed as an image with no handler. The build context is packaged in-process rather than by shelling out to `tar`, whose BSD, GNU and absent-on-Windows variants each produce a different archive from the same tree. `tar.ts` writes USTAR directly: files, directories and symlinks, refusing a value too large for an octal header field instead of letting it spill into the next one and read back as a plausible but wrong size. Symlinks are stored as links rather than followed — anything pnpm installs is symlink-dense, so following them would inline every dependency and walk into a link pointing at an ancestor. Every filesystem error propagates: an unreadable file archived as zero bytes, a dropped subtree or an entry lost between `readDirectory` and its stat all mean a successful `push` reporting an image built from an application with a hole in it. The Workers routes answer 404 both for a project outside the alpha's allow-list and for a ref that names nothing this account can see, so the classification reads `error.code`: `not_found` raises `WorkerProjectNotFoundError` naming the ref, `supabase link` and `supabase login`, and anything unrecognized keeps the enrolment answer, since that is what the allow-list has historically returned and guessing the other way sends someone to check a ref that is fine. This is the first command in this shell to call a v2 Management API route; every other one here is a Go-parity port and uses v1 only. Two findings are deliberate follow-ups rather than defects: streaming the build context instead of buffering it, and an ignore mechanism so `.env` and `.git` can be kept out of the uploaded archive. --- .../legacy/auth/legacy-http-debug.layer.ts | 67 +- .../auth/legacy-http-debug.unit.test.ts | 56 ++ .../commands/workers/push/SIDE_EFFECTS.md | 74 ++ .../commands/workers/push/push.command.ts | 62 ++ .../commands/workers/push/push.handler.ts | 406 +++++++++++ .../workers/push/push.integration.test.ts | 665 ++++++++++++++++++ .../commands/workers/workers.command.ts | 3 +- .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 1 + apps/cli/src/shared/workers/tar.ts | 224 ++++++ apps/cli/src/shared/workers/tar.unit.test.ts | 116 +++ .../cli/src/shared/workers/worker-classify.ts | 48 ++ apps/cli/src/shared/workers/worker-config.ts | 10 + .../shared/workers/worker-config.unit.test.ts | 26 +- apps/cli/src/shared/workers/worker-package.ts | 133 ++++ .../workers/worker-package.unit.test.ts | 216 ++++++ .../cli/src/shared/workers/worker-runtimes.ts | 7 + apps/cli/src/shared/workers/worker-url.ts | 17 + apps/cli/src/shared/workers/workers-api.ts | 429 +++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 136 ++++ apps/cli/tests/helpers/legacy-workers.ts | 34 +- 21 files changed, 2717 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/push/push.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.integration.test.ts create mode 100644 apps/cli/src/shared/workers/tar.ts create mode 100644 apps/cli/src/shared/workers/tar.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-classify.ts create mode 100644 apps/cli/src/shared/workers/worker-package.ts create mode 100644 apps/cli/src/shared/workers/worker-package.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-url.ts create mode 100644 apps/cli/src/shared/workers/workers-api.ts diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts index 9e34b6437d..bf93986607 100644 --- a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -6,9 +6,68 @@ import { legacyDohFetchLayer } from "../shared/legacy-http-dns.ts"; import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; /** - * Wraps `FetchHttpClient.layer` so every HTTP request can go through the - * legacy Go-parity debug side channel. The logger itself owns the `--debug` - * guard and byte-for-byte line formatting. + * Query parameters that mean the URL *is* a credential. + * + * A presigned object-store URL authorizes whoever holds it — for the Workers + * build-context upload, to overwrite the archive a deploy is about to build + * from. Logging one verbatim under `--debug` puts that in terminal scrollback + * and in any CI log or bug report the output is pasted into. + */ +const PRESIGNED_QUERY_KEYS = [ + // AWS SigV4 and SigV2 + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "awsaccesskeyid", + // Google Cloud Storage V4 + "x-goog-signature", + "x-goog-credential", + // Azure SAS, and the generic spellings everything else uses + "sig", + "se", + "signature", + "token", +]; + +/** + * The URL as it should appear in a debug log: unchanged, unless its query string + * carries a signature, in which case the query is replaced wholesale. + * + * Redacting the whole query rather than the matched parameters keeps the + * decision simple and cannot leak a sibling parameter that turns out to matter. + * The path survives, which is what makes the line useful for debugging in the + * first place. + * + * A denylist of known signature parameters, so it is by nature incomplete: a + * provider spelling its signature something new would log verbatim until the + * list learns about it. The alternative — redacting every query string — would + * cost the debug log its usefulness on the Management API calls that are the + * whole reason `--debug` exists. Add spellings here as they turn up. + */ +export function legacyRedactHttpUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Not a URL we can reason about; log it as-is rather than swallow it. + return url; + } + if (parsed.search === "") { + return url; + } + const presigned = [...parsed.searchParams.keys()].some((key) => + PRESIGNED_QUERY_KEYS.includes(key.toLowerCase()), + ); + if (!presigned) { + return url; + } + return `${parsed.origin}${parsed.pathname}?`; +} + +/** + * Wraps `FetchHttpClient.layer` so every HTTP request goes through the legacy + * debug side channel. The logger itself owns the `--debug` guard and the + * line formatting. * * `legacyDohFetchLayer` overrides `FetchHttpClient.Fetch` with a * DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. @@ -19,7 +78,7 @@ export const legacyHttpClientLayer = Layer.effect( const logger = yield* LegacyDebugLogger; const base = yield* HttpClient.HttpClient; return HttpClient.mapRequestEffect(base, (req) => - logger.http(req.method, req.url).pipe(Effect.as(req)), + logger.http(req.method, legacyRedactHttpUrl(req.url)).pipe(Effect.as(req)), ); }), ).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(legacyDohFetchLayer)); diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts new file mode 100644 index 0000000000..c77cd9bace --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { legacyRedactHttpUrl } from "./legacy-http-debug.layer.ts"; + +/** + * `--debug` logs every request URL to stderr. For a presigned object-store URL + * the query string *is* the credential — for the Workers build-context upload, + * one that authorizes overwriting the archive a deploy is about to build from — + * so it must not survive into scrollback or a CI log. + */ +describe("legacyRedactHttpUrl", () => { + test.each([ + [ + "an AWS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a GCS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Goog-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a lowercase signature parameter", + "https://store.example/o/ctx?signature=deadbeef&expires=123", + "https://store.example/o/ctx?", + ], + [ + "a bare token parameter", + "https://store.example/o/ctx?token=deadbeef", + "https://store.example/o/ctx?", + ], + ])("redacts the query string of %s", (_label, url, expected) => { + expect(legacyRedactHttpUrl(url)).toBe(expected); + expect(legacyRedactHttpUrl(url)).not.toContain("deadbeef"); + }); + + // The debug log is only useful if ordinary requests still read normally, so + // redaction has to be the exception rather than the rule. + test.each([ + ["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"], + ["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"], + ["a URL with no query at all", "https://api.supabase.com/v1/projects"], + ])("leaves %s untouched", (_label, url) => { + expect(legacyRedactHttpUrl(url)).toBe(url); + }); + + test("passes through something that is not a parseable URL", () => { + expect(legacyRedactHttpUrl("not a url at all")).toBe("not a url at all"); + }); + + test("keeps the path, which is what makes the log line worth having", () => { + expect(legacyRedactHttpUrl("https://store.example/bucket/deep/ctx.tar.gz?sig=x")).toContain( + "/bucket/deep/ctx.tar.gz", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md new file mode 100644 index 0000000000..b145692970 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -0,0 +1,74 @@ +# `supabase workers push [name...] (alias: deploy)` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/**` | 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 + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | +| `POST` | `/v2/projects/{ref}/workers/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | +| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only | +| `POST` | `/v2/projects/{ref}/workers/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | + +`GET` is polled until `build_state` leaves `building`. + +## Exit Codes + +| Code | Condition | +| ---- | ---------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source directory is missing or empty | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +`-o env` is refused **before** the first deploy rather than at emit time: the +payload always carries a `workers` array, which a flat `KEY=value` list cannot +express, and discovering that at the end would fail the command with the remote +project already changed. + +The presigned `PUT` above is the one request whose URL is itself a credential. +`--debug` logs every request URL, so `legacyHttpClientLayer` redacts query +strings that carry a signature. diff --git a/apps/cli/src/legacy/commands/workers/push/push.command.ts b/apps/cli/src/legacy/commands/workers/push/push.command.ts new file mode 100644 index 0000000000..9262f028a8 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.command.ts @@ -0,0 +1,62 @@ +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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; + +const config = { + names: Argument.string("name").pipe( + Argument.withDescription("Workers to deploy. Deploys every worker in the project if omitted."), + Argument.variadic(), + ), + instances: Flag.integer("instances").pipe( + // Bounded at the parser, the same way `[workers.] instances` is bounded + // in the config schema. Left unchecked it reached the deploy endpoint — after + // the build context had been packaged and uploaded — as a scaling request the + // platform cannot honour. + Flag.filter( + (instances) => instances >= 0, + (instances) => `--instances ${instances} is negative; pass zero or more.`, + ), + Flag.withDescription( + "Number of instances to run, overriding `instances` in supabase/config.toml for this deploy. Falls back to the recorded value, then 1.", + ), + Flag.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersPushFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersPushCommand = Command.make("push", config).pipe( + Command.withAlias("deploy"), + Command.withDescription( + "Build and deploy workers into the linked Supabase project. Reads each worker's runtime, size and source directory from supabase/config.toml.", + ), + Command.withShortDescription("Build and deploy workers"), + Command.withExamples([ + { + command: "supabase workers push", + description: "Deploy every worker in the project", + }, + { + command: "supabase workers push api", + description: "Deploy a single worker", + }, + { + command: "supabase workers push api web", + description: "Deploy several workers by name", + }, + ]), + Command.withHandler((flags) => + legacyWorkersPush(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "push"])), +); diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts new file mode 100644 index 0000000000..86b9a068d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -0,0 +1,406 @@ +import { Effect, FileSystem, Option, type Schedule } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; +import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { + apiSizeFor, + DEFAULT_WORKER_INSTANCES, + DEFAULT_WORKER_SIZE, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + WORKER_RUNTIMES, + WORKER_SIZES, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { + awaitWorkerBuild, + createWorkerUpload, + deployWorker, + uploadBuildContext, + type WorkerDeploySpec, +} from "../../../../shared/workers/workers-api.ts"; +import { + NoWorkersToDeployError, + UnknownWorkerRuntimeError, + UnknownWorkerSizeError, + WorkerBuildFailedError, + WorkerSourceMissingError, +} from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorker, + legacyDiscoverWorkerNames, + legacyLoadWorkersProject, + legacyValidateWorkerName, + type LegacyWorkersProject, +} from "../workers.shared.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +/** + * `supabase workers push [name]` — build (when there is code to build) and + * 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 + * its runtime guessed from marker files instead — reported, with a nudge to pin + * it down rather than re-guess on every push. + * + * A `dockerfile` worker is tarred and uploaded, and the build happens + * server-side from that context, never on your machine. A catalog runtime with + * code takes the same path, with the base image and a copy synthesized in place + * of your Dockerfile. Every runtime this CLI offers has code to package, so + * there is no path here that skips the upload. + */ + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; + readonly sourceDir: string; +}) { + if (options.recorded !== undefined) { + const recorded = parseWorkerRuntime(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerRuntimeError({ + detail: `supabase/config.toml records an unknown runtime "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] runtime to one of: ${WORKER_RUNTIMES.join(", ")}.`, + }), + ); + } + return recorded; + } + + const output = yield* Output; + const classified = yield* classifyWorkerDir(options.sourceDir); + // A guess the user should pin down: stderr, so it never lands inside a + // payload stdout is carrying. + yield* output.raw( + `No runtime configured for ${options.name}: guessed ${classified.runtime} (${classified.reason}). ` + + `Pin it down by adding [workers.${options.name}] runtime = "${classified.runtime}" to supabase/config.toml.\n`, + "stderr", + ); + return classified.runtime; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; +}) { + if (options.recorded === undefined) { + return DEFAULT_WORKER_SIZE; + } + const recorded = parseWorkerSize(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerSizeError({ + detail: `supabase/config.toml records an unknown size "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] size to one of: ${WORKER_SIZES.join(", ")}.`, + }), + ); + } + return recorded; +}); + +/** + * `--instances` for one deploy, then the recorded count, then + * {@link DEFAULT_WORKER_INSTANCES}. Never left unset, because every deploy sends + * a complete spec and an omitted count rescales the worker. + * + * No unparseable case to report: the config schema and the flag are both bounded + * to a non-negative integer before the handler runs. + */ +function resolveInstances(options: { + readonly recorded: number | undefined; + readonly override: Option.Option; +}): number { + return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); +} + +const deployOneWorker = Effect.fnUntraced(function* (input: { + readonly project: LegacyWorkersProject; + readonly name: string; + readonly projectRef: string; + readonly instances: Option.Option; + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + /** Suppresses this step's human output when `-o` owns stdout. */ + readonly machineOutput: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + + const { project, name, projectRef } = input; + const worker = yield* legacyDescribeWorker(project, name); + + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + // Checked before the runtime is resolved, not after: with no recorded + // runtime, `resolveRuntime` classifies the directory and announces what it + // guessed. Doing that first meant reporting an inference about a path that + // does not exist, and only then failing on the path. + { + const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); + if (stat._tag === "None" || stat.value.type !== "Directory") { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `There is no worker source at ${sourceDisplay}.`, + suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + }), + ); + } + // An empty directory packages and deploys perfectly happily, producing an + // image with nothing in it — a success message for a worker that cannot + // serve anything. Refuse before uploading rather than after. + const contents = yield* fs.readDirectory(worker.sourceDir).pipe(Effect.orElseSucceed(() => [])); + if (contents.length === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} is empty, so there is nothing to deploy.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + } + + const runtime = yield* resolveRuntime({ + name, + recorded: worker.entry?.runtime, + sourceDir: worker.sourceDir, + }); + + // Size: whatever `new --size` recorded, else the alpha envelope's own + // default. Never left unset, because a worker that is actually running always + // has some concrete size — and never silently coerced, because a size the CLI + // does not recognize is a config mistake worth naming. + const size = yield* resolveSize({ name, recorded: worker.entry?.size }); + + const instances = resolveInstances({ + recorded: worker.entry?.instances, + override: input.instances, + }); + + let contextUploadId: string; + { + const packaging = yield* output.task("Packaging worker..."); + const packaged = yield* packageWorkerDirectory(worker.sourceDir).pipe( + Effect.tapError(() => packaging.fail()), + ); + yield* packaging.clear(); + yield* output.raw( + `Packaged ${sourceDisplay} (${packaged.fileCount} files, ${formatBytes( + packaged.archive.length, + )}).\n`, + "stderr", + ); + + // The guard above counts directory entries, so a tree of nothing but empty + // subdirectories reaches here and packages to zero files. For a catalog + // runtime that deploys an image with no handler in it — the exact "nothing + // to deploy" case that guard exists to refuse. + if (packaged.fileCount === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} holds no files to deploy, only empty directories.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + + const uploading = yield* output.task("Uploading build context..."); + const slot = yield* createWorkerUpload(api, projectRef, name).pipe( + Effect.tapError(() => uploading.fail()), + ); + yield* uploadBuildContext(slot, packaged.archive).pipe(Effect.tapError(() => uploading.fail())); + yield* uploading.clear(); + yield* output.raw("Uploaded build context.\n", "stderr"); + contextUploadId = slot.uploadId; + } + + const spec: WorkerDeploySpec = { + // A plain Dockerfile build has no catalog runtime to name; the uploaded + // 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", + instances, + }; + + const deploying = yield* output.task("Deploying worker..."); + yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( + Effect.tapError(() => deploying.fail()), + ); + + const settled = yield* awaitWorkerBuild(api, projectRef, name, { + schedule: input.pollSchedule, + retrySchedule: input.pollRetrySchedule, + onPoll: (polled) => + polled.buildState === "building" ? deploying.message("Building worker...") : Effect.void, + }).pipe(Effect.tapError(() => deploying.fail())); + + if (settled.buildState === "failed") { + yield* deploying.clear(); + return yield* Effect.fail( + new WorkerBuildFailedError({ + detail: `The build for "${name}" failed${ + settled.stateReason === undefined ? "" : `: ${settled.stateReason}` + }.`, + suggestion: `Fix the issue, then re-run \`supabase workers push ${name}\`.`, + }), + ); + } + + yield* deploying.clear(); + + const url = + settled.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + + // Suppressed when `-o` is in play: the payload owns stdout, and these lines + // would land in the middle of it. + if (output.format === "text" && !input.machineOutput) { + // Declarative line first, then the details — the shape every other command + // that reports a completed remote change uses. `legacyRenderWorkerDetails` drops + // empty-valued rows, so optional fields need no conditional spreads. + yield* output.raw( + `Deployed Worker ${legacyAqua(name, process.stdout)} to project ${projectRef}\n`, + ); + yield* output.raw( + legacyRenderWorkerDetails([ + ["Runtime", runtime], + ["Size", formatApiSize(settled.spec.size)], + ["Image", settled.imageVersion ?? ""], + ["Access", settled.spec.exposure], + ["URL", url ?? ""], + ]), + ); + } + + return { + worker_name: name, + runtime, + size: settled.spec.size, + exposure: settled.spec.exposure, + instances: settled.spec.instances, + // Omitted rather than present-and-undefined: `-o toml` hands the payload to + // smol-toml, which cannot represent undefined and would throw *after* the + // upload and deploy had completed. Same reason `url` is spread below. + ...(settled.imageVersion === undefined ? {} : { image_version: settled.imageVersion }), + build_state: settled.buildState, + ...(url === undefined ? {} : { url }), + }; +}); + +/** + * `supabase workers push [name...]` — deploy the named workers, or every worker + * in the project when none are named, mirroring `supabase functions deploy`. + * + * Deploys run one at a time rather than concurrently: each is a server-side + * container build, and interleaving several would both hammer the alpha's + * per-project capacity and shred the progress output. The first failure stops + * the run, because a build that failed is usually the thing to fix before + * spending minutes on the rest. + */ +export const legacyWorkersPush = Effect.fn("legacy.workers.push")(function* ( + flags: LegacyWorkersPushFlags, + options: { + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + } = {}, +) { + const output = yield* Output; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating names, discovering workers — belongs inside, so a malformed + // config still flushes telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const requested = + flags.names.length > 0 + ? yield* Effect.forEach(flags.names, legacyValidateWorkerName) + : yield* legacyDiscoverWorkerNames(project); + + if (requested.length === 0) { + return yield* Effect.fail( + new NoWorkersToDeployError({ + detail: `No workers were named, and none were found in ${displayPath( + project.projectRoot, + project.workersDir, + )}.`, + suggestion: "Scaffold one with `supabase workers new `.", + }), + ); + } + + const names = [...new Set(requested)]; + + // Before the first deploy, not after the last one: this payload always + // carries a `workers` array, so `-o env` can never encode it, and finding + // that out at the end means failing with the remote project already changed. + yield* legacyRejectWorkersEnvOutput(); + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const deployed: Array> = []; + for (const name of names) { + if (names.length > 1 && !machineOutput) { + // stderr, unblanked and labelled, the way `functions deploy` announces + // each function: a bare name with a leading blank line put a section + // header into whatever was consuming stdout. + yield* output.raw(`Deploying Worker: ${legacyAqua(name)}\n`, "stderr"); + } + deployed.push( + yield* deployOneWorker({ + project, + name, + projectRef, + instances: flags.instances, + machineOutput, + ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), + ...(options.pollRetrySchedule === undefined + ? {} + : { pollRetrySchedule: options.pollRetrySchedule }), + }), + ); + } + + const payload = { project_ref: projectRef, workers: deployed }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts new file mode 100644 index 0000000000..0c16266931 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -0,0 +1,665 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Schedule } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, + type WorkersHttpRoutes, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + NoWorkersToDeployError, + WorkerBuildFailedError, + WorkerProjectNotFoundError, + WorkersUnavailableError, + WorkerSourceMissingError, + WorkerUploadFailedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +const UPLOAD_URL = "https://storage.example/deploy-context/api.tar.gz?signed"; +const UPLOAD_ID = "cafe0000000000000000000000000000"; + +/** Polls run with no delay so a build sequence resolves at test speed. */ +const IMMEDIATE = Schedule.recurs(20); + +const uploadSlot = { + data: { + type: "project_worker_upload", + id: UPLOAD_ID, + attributes: { url: UPLOAD_URL, method: "PUT", expires_at: "2026-08-12T00:15:00Z" }, + }, +}; + +function flags(overrides: Partial = {}): LegacyWorkersPushFlags { + return { + names: ["api"], + instances: Option.none(), + projectRef: Option.none(), + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/workers/api/index.js": "export default { fetch: () => new Response('ok') };\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +function routes(overrides: WorkersHttpRoutes = {}): WorkersHttpRoutes { + return { + [`POST ${workersRoute("/api/uploads")}`]: { status: 201, body: uploadSlot }, + "PUT /deploy-context/api.tar.gz": { status: 200 }, + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "active", + imageVersion: "v1", + }), + }, + }, + ...overrides, + }; +} + +/** + * The `_tag` of a failure, for a channel that also carries plain `Error` + * subclasses — `TarPathTooLongError` has no tag. + */ +function tagOf(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "_tag" in error + ? String((error as { _tag: unknown })._tag) + : undefined; +} + +function push(flagOverrides: Partial = {}) { + // Both schedules are injected: the outer poll and the per-read retry. The + // production retry is spaced in seconds, so leaving it in place made the + // transient-failure test wait on a real clock. + return legacyWorkersPush(flags(flagOverrides), { + pollSchedule: IMMEDIATE, + pollRetrySchedule: IMMEDIATE, + }); +} + +describe("legacy workers push", () => { + it.live("packages, uploads, deploys and waits for the build to settle", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(http.routeKeys).toEqual([ + `POST ${workersRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${workersRoute("/api/deploy")}`, + `GET ${workersRoute("/api")}`, + ]); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}")).toEqual({ + data: { + type: "project_worker", + attributes: { + spec: { + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }, + context_upload_id: UPLOAD_ID, + }, + }, + }); + + const upload = http.requests.find((request) => request.method === "PUT"); + expect(upload?.byteLength).toBeGreaterThan(0); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + expect(out.stdoutText).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("omits the runtime for a Dockerfile worker and builds from the uploaded context", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + "supabase/workers/api/Dockerfile": "FROM node:24-alpine\nEXPOSE 8080\n", + }); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + const attributes = JSON.parse(deploy?.body ?? "{}").data.attributes; + expect(attributes.spec).toEqual({ + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }); + expect(attributes.context_upload_id).toBe(UPLOAD_ID); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("guesses the runtime for a directory with no config entry and says so", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/api/package.json": "{}\n", + }); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stderrText).toContain("guessed node"); + expect(out.stderrText).toContain("found package.json"); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBe("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("sends the recorded size and the requested instance count", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(3) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec).toEqual({ + runtime: "node", + size: "4gb-2vcpu", + exposure: "public", + instances: 3, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps a worker scaled at the count recorded in config", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\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.instances).toBe(4); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("lets --instances override the recorded count for one deploy", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(1) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.instances).toBe(1); + }).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({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { + data: workerResource({ name: "api", buildState: "active", imageVersion: "v2" }), + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const polls = http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`); + expect(polls).toHaveLength(3); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with the build's own reason when the build fails", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + buildState: "failed", + stateReason: "error building image: exit status 1", + }), + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerBuildFailedError); + expect((error as WorkerBuildFailedError).detail).toContain("error building image"); + expect((error as WorkerBuildFailedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("stops waiting on a build that never settles, and says where to look", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersPush(flags(), { pollSchedule: Schedule.recurs(2) }).pipe( + Effect.flip, + ); + + expect(tagOf(error)).toBe("WorkerBuildTimeoutError"); + expect((error as { suggestion: string }).suggestion).toContain("supabase workers status api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails before deploying when the presigned upload is rejected", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ "PUT /deploy-context/api.tar.gz": { status: 403, body: "expired" } }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerUploadFailedError); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Both of the next two arrive as a 404 on the same route; only `error.code` + // separates them, so they are asserted against the bodies the API really + // sends rather than a shape of our own invention. + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + expect((error as WorkersUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points at the project ref when no such project exists", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerProjectNotFoundError); + expect((error as WorkerProjectNotFoundError).suggestion).not.toContain("private alpha"); + expect((error as WorkerProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps the enrolment answer for a 404 body it does not recognize", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { status: 404, body: { unexpected: true } }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when the worker has no source on disk", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses an empty source directory instead of deploying nothing", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js"), { force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).detail).toContain("is empty"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rides out a transient failure while polling the build", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { status: 500, body: { message: "blip" } }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + // The blip was retried rather than aborting a deploy already in flight. + expect( + http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`).length, + ).toBeGreaterThan(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("acts on the workdir's project, not the process's directory", () => { + // `--workdir`/`SUPABASE_WORKDIR` names the project every legacy command acts + // on, so the worker discovered here comes from that tree even though the + // process is somewhere else entirely. + const repo = project(); + const elsewhere = makeWorkersProject(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + repo.cleanup(); + rmSync(elsewhere.dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.live("deploys every worker in the project when none are named", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n\n[workers.web]\nruntime = "node"\n`, + "supabase/workers/web/index.js": "export default {};\n", + }); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + ...routes(), + [`POST ${workersRoute("/web/uploads")}`]: { status: 201, body: uploadSlot }, + [`POST ${workersRoute("/web/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/web")}`]: { + status: 200, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "active" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + // Both deployed, in a stable (sorted) order. + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + expect(http.routeKeys).toContain(`POST ${workersRoute("/web/deploy")}`); + expect(http.routeKeys.indexOf(`POST ${workersRoute("/api/deploy")}`)).toBeLessThan( + http.routeKeys.indexOf(`POST ${workersRoute("/web/deploy")}`), + ); + expect(out.stdoutText).toContain("web"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when there are no workers to deploy at all", () => { + const repo = project({ "supabase/config.toml": `project_id = "demo"\n` }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push({ names: [] }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NoWorkersToDeployError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project or an explicit --project-ref", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("packages a --source worker from where its code actually lives", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: routes(), + }); + + return Effect.gen(function* () { + yield* push(); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + // One entry per worker deployed, since a bare push can deploy several. + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + worker_name: "api", + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + build_state: "active", + image_version: "v1", + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o env` cannot express the `workers` array. Discovering that at emit time + // meant failing with the project already changed, inviting a retry that + // deployed all over again. + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes(), + goOutput: "env", + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(tagOf(error)).toBe("LegacyWorkersEnvNotSupportedError"); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The "nothing to deploy" guard counts directory entries, so a tree of empty + // subdirectories used to package to zero files and deploy an image with no + // handler in it. + it.live("refuses a source holding only empty directories, before minting a slot", () => { + const repo = project({ "supabase/workers/api/nested/.keep": "" }); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js")); + rmSync(join(repo.dir, "supabase", "workers", "api", "nested", ".keep")); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The runtime guess is an inference about the contents of a directory, so it + // has no business being reported for a directory that is not there. + it.live("does not report a guessed runtime when the source is missing", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(out.stderrText).not.toContain("guessed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `image_version` is optional in the response. Present-but-undefined made the + // TOML encoder throw, after the upload and deploy had already completed. + it.live("encodes -o toml when the deployed worker has no image version", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("worker_name"); + expect(out.stdoutText).not.toContain("image_version"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A malformed config.toml used to fail outside the finalizers, so the run + // skipped the telemetry flush every invocation is supposed to perform. + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push().pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index ac4555f3de..d575670118 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,10 +1,11 @@ import { Command } from "effect/unstable/cli"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; +import { legacyWorkersPushCommand } from "./push/push.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand]), + Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), ); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index bd9659d06f..124f2423fa 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -198,6 +198,7 @@ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-storage-rm linked": "true", "supabase-test-db local": "true", "supabase-test-new template": "pgtap", + "supabase-workers-push instances": "1", }; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index d9ad846999..963e9b295e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -120,6 +120,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "git-branch", "import-map", "inspect-mode", + "instances", "lang", "last", "metadata-file", diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts new file mode 100644 index 0000000000..37f28ad05c --- /dev/null +++ b/apps/cli/src/shared/workers/tar.ts @@ -0,0 +1,224 @@ +/** + * A minimal USTAR writer, for the `.tar.gz` build context `supabase workers + * push` uploads. + * + * Shelling out to `tar` would be shorter, but the CLI ships as a single + * compiled binary to machines where `tar` may be BSD tar, GNU tar, or absent + * (Windows), and each writes a different archive for the same directory. The + * server only ever untars what we send, so producing the bytes here keeps the + * upload identical on every platform and keeps packaging out of the process + * table. + */ + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +const BLOCK_SIZE = 512; + +export interface TarEntry { + /** Path inside the archive, always `/`-separated and relative. */ + readonly path: string; + readonly contents: Uint8Array; + /** Unix mode bits. Defaults to `0o644`. */ + readonly mode?: number; + /** Modification time in seconds since the epoch. Defaults to `0`. */ + readonly mtime?: number; + /** + * Target of a symbolic link. When set the entry is stored as a link rather + * than as its contents, which is what keeps a symlink-dense tree (anything + * pnpm installed) from being inlined — and what stops a link to a directory + * from being walked into. + */ + readonly linkTarget?: string; +} + +/** + * The largest value an 11-digit octal field can hold: 8 GiB minus one byte for + * a size, and a little past the year 2242 for an mtime. + */ +const MAX_OCTAL_FIELD = 8 ** 11 - 1; + +/** + * USTAR stores numbers as zero-padded octal followed by a NUL. + * + * A value too large for the field renders one digit too long and spills into the + * next field, producing an archive that reads back with a plausible but wrong + * size — corruption no reader can detect. Real tars switch to base-256 here; + * this writer refuses instead, because a build context carrying an 8 GiB file is + * already a mistake worth naming rather than silently mangling. + */ +function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { + const text = Math.floor(value) + .toString(8) + .padStart(length - 1, "0"); + if (text.length > length - 1) { + throw new TarFieldTooLargeError(value); + } + writeAscii(block, offset, text); +} + +function writeAscii(block: Uint8Array, offset: number, value: string): void { + for (let index = 0; index < value.length; index++) { + block[offset + index] = value.charCodeAt(index) & 0xff; + } +} + +/** + * Split a path into USTAR's `prefix` (155 bytes) and `name` (100 bytes) fields. + * The split has to fall on a `/`, so a single path component longer than 100 + * bytes cannot be represented at all. + */ +function splitPath(path: string): { name: string; prefix: string } | undefined { + if (byteLength(path) <= 100) { + return { name: path, prefix: "" }; + } + + for (let index = path.indexOf("/"); index !== -1; index = path.indexOf("/", index + 1)) { + const prefix = path.slice(0, index); + const name = path.slice(index + 1); + if (byteLength(prefix) <= 155 && byteLength(name) <= 100) { + return { name, prefix }; + } + } + + return undefined; +} + +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +/** + * Thrown for a path USTAR cannot represent. A plain `Error` rather than a + * tagged one because `createTar` is a pure synchronous function with no Effect + * semantics of its own; the caller's own error channel is where this surfaces. + * It is still user-actionable — renaming the offending file fixes it — so it + * carries its own classification, and the static identifier keeps the + * fingerprint stable through minification. + */ +export class TarPathTooLongError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarPathTooLongError"; + + constructor(path: string) { + super( + `"${path}" is too long for a tar archive (over 100 bytes with no directory boundary to split on)`, + ); + this.name = "TarPathTooLongError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * Thrown for a number USTAR's octal fields cannot hold — see {@link writeOctal}. + * Untagged for the same reason as {@link TarPathTooLongError}: `createTar` is a + * pure function, and the caller's error channel is where this surfaces. + */ +export class TarFieldTooLargeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarFieldTooLargeError"; + + constructor(value: number) { + super( + `${value} is too large for a tar header field (the limit is ${MAX_OCTAL_FIELD} bytes per file)`, + ); + this.name = "TarFieldTooLargeError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +function header(entry: TarEntry, typeflag: "0" | "2" | "5", size: number): Uint8Array { + const block = new Uint8Array(BLOCK_SIZE); + const split = splitPath(entry.path); + if (split === undefined) { + throw new TarPathTooLongError(entry.path); + } + + const encodedName = encoder.encode(split.name); + block.set(encodedName, 0); + writeOctal(block, 100, 8, entry.mode ?? 0o644); + writeOctal(block, 108, 8, 0); // uid + writeOctal(block, 116, 8, 0); // gid + writeOctal(block, 124, 12, size); + writeOctal(block, 136, 12, entry.mtime ?? 0); + // The checksum field is treated as spaces while the checksum is computed. + block.fill(0x20, 148, 156); + block[156] = typeflag.charCodeAt(0); + if (entry.linkTarget !== undefined) { + const encodedTarget = encoder.encode(entry.linkTarget); + if (encodedTarget.length > 100) { + throw new TarPathTooLongError(entry.linkTarget); + } + block.set(encodedTarget, 157); + } + writeAscii(block, 257, "ustar"); + writeAscii(block, 263, "00"); + block.set(encoder.encode(split.prefix), 345); + + let checksum = 0; + for (const byte of block) { + checksum += byte; + } + // Six octal digits, a NUL, then a space — the form every tar reads. + writeAscii(block, 148, checksum.toString(8).padStart(6, "0")); + block[154] = 0; + block[155] = 0x20; + + return block; +} + +function padding(size: number): number { + const remainder = size % BLOCK_SIZE; + return remainder === 0 ? 0 : BLOCK_SIZE - remainder; +} + +/** + * Build a USTAR archive from `entries`, in the order given. An entry with a + * `linkTarget` is stored as a symbolic link, a path ending in `/` as a + * directory, and everything else as a regular file. The archive ends with the + * two zero blocks every reader expects. + */ +export function createTar(entries: ReadonlyArray): Uint8Array { + const blocks: Array = []; + let total = 0; + + const push = (block: Uint8Array) => { + blocks.push(block); + total += block.length; + }; + + for (const entry of entries) { + const isSymlink = entry.linkTarget !== undefined; + const isDirectory = !isSymlink && entry.path.endsWith("/"); + // A link's target lives in the header, so it carries no content blocks. + const size = isDirectory || isSymlink ? 0 : entry.contents.length; + push(header(entry, isSymlink ? "2" : isDirectory ? "5" : "0", size)); + if (size > 0) { + push(entry.contents); + const pad = padding(size); + if (pad > 0) { + push(new Uint8Array(pad)); + } + } + } + + push(new Uint8Array(BLOCK_SIZE * 2)); + + const archive = new Uint8Array(total); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.length; + } + return archive; +} diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts new file mode 100644 index 0000000000..c7449efeb6 --- /dev/null +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { createTar, TarFieldTooLargeError, TarPathTooLongError } from "./tar.ts"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function field(archive: Uint8Array, block: number, offset: number, length: number): string { + return decoder.decode(archive.subarray(block * 512 + offset, block * 512 + offset + length)); +} + +/** Trim a NUL-padded USTAR field down to its value. */ +function value(archive: Uint8Array, block: number, offset: number, length: number): string { + return (field(archive, block, offset, length).split("\u0000")[0] ?? "").trim(); +} + +describe("createTar", () => { + test("writes a readable ustar header for a file", () => { + const archive = createTar([ + { path: "index.js", contents: encoder.encode("hello"), mode: 0o644, mtime: 1_700_000_000 }, + ]); + + expect(value(archive, 0, 0, 100)).toBe("index.js"); + expect(value(archive, 0, 100, 8)).toBe("0000644"); + expect(value(archive, 0, 124, 12)).toBe("00000000005"); + expect(value(archive, 0, 136, 12)).toBe("14524770400"); + expect(field(archive, 0, 156, 1)).toBe("0"); + expect(value(archive, 0, 257, 6)).toBe("ustar"); + }); + + test("computes a checksum the standard algorithm reproduces", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("a") }]); + const header = archive.subarray(0, 512); + + const recorded = Number.parseInt(value(archive, 0, 148, 8), 8); + let computed = 0; + for (let index = 0; index < 512; index++) { + // The checksum field itself counts as spaces. + computed += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0); + } + + expect(recorded).toBe(computed); + }); + + test("pads content to a 512-byte boundary and ends with two zero blocks", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("hello") }]); + + // header + one padded content block + two trailing zero blocks + expect(archive.length).toBe(512 * 4); + expect(decoder.decode(archive.subarray(512, 517))).toBe("hello"); + expect(archive.subarray(512 * 2).every((byte) => byte === 0)).toBe(true); + }); + + test("emits directory entries with no content and the directory typeflag", () => { + const archive = createTar([ + { path: "nested/", contents: new Uint8Array(0), mode: 0o755 }, + { path: "nested/a.txt", contents: encoder.encode("a") }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("5"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // The directory has no content block, so the next header follows immediately. + expect(value(archive, 1, 0, 100)).toBe("nested/a.txt"); + }); + + test("stores a symlink as a link entry with no content blocks", () => { + const archive = createTar([ + { path: "link.txt", contents: new Uint8Array(0), linkTarget: "target.txt", mode: 0o777 }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + expect(value(archive, 0, 157, 100)).toBe("target.txt"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // Header plus the two trailing zero blocks — no content block in between. + expect(archive.length).toBe(512 * 3); + }); + + test("a symlink entry wins over the trailing-slash directory rule", () => { + const archive = createTar([{ path: "dir", contents: new Uint8Array(0), linkTarget: ".." }]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + }); + + test("refuses a link target too long for the header field", () => { + expect(() => + createTar([ + { path: "link", contents: new Uint8Array(0), linkTarget: `${"t".repeat(120)}.txt` }, + ]), + ).toThrow(TarPathTooLongError); + }); + + test("splits a long path across the prefix and name fields", () => { + const deep = `${"d".repeat(120)}/${"f".repeat(60)}.txt`; + const archive = createTar([{ path: deep, contents: new Uint8Array(0) }]); + + expect(value(archive, 0, 345, 155)).toBe("d".repeat(120)); + expect(value(archive, 0, 0, 100)).toBe(`${"f".repeat(60)}.txt`); + }); + + test("refuses a value too large for an octal header field rather than truncating it", () => { + // One past the 11-digit octal ceiling. Encoding it would spill a digit into + // the next field and read back as a plausible but wrong number. + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), + ).toThrow(TarFieldTooLargeError); + + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 - 1 }]), + ).not.toThrow(); + }); + + test("refuses a path component too long to represent", () => { + expect(() => + createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), + ).toThrow(TarPathTooLongError); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-classify.ts b/apps/cli/src/shared/workers/worker-classify.ts new file mode 100644 index 0000000000..64e19906ec --- /dev/null +++ b/apps/cli/src/shared/workers/worker-classify.ts @@ -0,0 +1,48 @@ +import { join } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { DEFAULT_WORKER_RUNTIME, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * Best-effort classification of a worker directory into a {@link WorkerRuntime} + * from common marker files, so `supabase workers push` can deploy a directory + * that has no `[workers.] runtime` at all. The guess is always reported, + * with a nudge to pin it down, rather than applied silently. + */ + +interface WorkerClassification { + readonly runtime: WorkerRuntime; + /** Human-readable reason, for the line `push` logs about the guess. */ + readonly reason: string; +} + +const MARKERS: ReadonlyArray<{ + readonly runtime: WorkerRuntime; + readonly files: ReadonlyArray; +}> = [ + // An explicit Dockerfile always wins: it is a deliberate signal, not an + // inference. + { runtime: "dockerfile", files: ["Dockerfile"] }, + // Deno is checked before plain `package.json` because a Deno project can + // still have one (editor tooling, a stray dependency) while a Node project + // has no `deno.json`. + { runtime: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"] }, + { runtime: "node", files: ["package.json"] }, +]; + +export const classifyWorkerDir = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + + for (const marker of MARKERS) { + for (const file of marker.files) { + const found = yield* fs.exists(join(dir, file)).pipe(Effect.orElseSucceed(() => false)); + if (found) { + return { runtime: marker.runtime, reason: `found ${file}` } satisfies WorkerClassification; + } + } + } + + return { + runtime: DEFAULT_WORKER_RUNTIME, + reason: `no recognized marker files, defaulting to ${DEFAULT_WORKER_RUNTIME}`, + } satisfies WorkerClassification; +}); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index 04a46cccaa..6d09c9ccb3 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 instances?: number; readonly source?: string; } @@ -75,6 +76,14 @@ const stringOrUndefined = (value: unknown): string | undefined => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +/** + * A count only counts if it is a non-negative whole number. Anything else is + * dropped so `push` falls back to its own default; the config schema is what + * tells the user the value was wrong. + */ +const instanceCountOrUndefined = (value: unknown): number | undefined => + typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; + /** * The decoded `[workers]` section as per-worker tables. Anything that is not an * object is dropped rather than read as a worker named after it. @@ -98,6 +107,7 @@ export function readWorkersSection(workers: unknown): WorkersSection { entries[key] = { runtime: stringOrUndefined(value["runtime"]), size: stringOrUndefined(value["size"]), + 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 fc7fc7411a..d1439e57ac 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -16,23 +16,41 @@ describe("readWorkersSection", () => { test("reads each worker's recorded dials", () => { expect( readWorkersSection({ - api: { runtime: "node", size: "2gb", source: "packages/api" }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, box: { runtime: "sandbox" }, }), ).toEqual({ workers: { - api: { runtime: "node", size: "2gb", source: "packages/api" }, - box: { runtime: "sandbox", size: undefined, source: undefined }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, instances: undefined, source: undefined }, }, }); }); 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, source: undefined } }, + workers: { + api: { runtime: undefined, size: undefined, instances: undefined, source: undefined }, + }, }); }); + // `push` has to send a count with every deploy, so a value the API would + // reject is dropped here and the default used instead. + test.each([ + ["a float", 1.5], + ["a negative", -1], + ["a string", "3"], + ])("drops %s instance count", (_label, value) => { + expect(readWorkersSection({ api: { instances: value } }).workers["api"]?.instances).toBe( + undefined, + ); + }); + + test("keeps a zero instance count, which scales a worker down rather than being absent", () => { + expect(readWorkersSection({ api: { instances: 0 } }).workers["api"]?.instances).toBe(0); + }); + 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-package.ts b/apps/cli/src/shared/workers/worker-package.ts new file mode 100644 index 0000000000..50078f9363 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -0,0 +1,133 @@ +import { gzipSync } from "node:zlib"; +import { Effect, FileSystem } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; + +/** + * Package a worker's source directory into the `.tar.gz` build context the + * Workers API's upload slot expects. + * + * Nothing is excluded. For a `dockerfile` worker the archive is the build + * context, so it has to be what the user's own `Dockerfile` expects to find; + * for a catalog runtime the server synthesizes `FROM ` + `COPY` with no + * install step of its own, so an installed `node_modules/` is a dependency of + * the deploy rather than noise in it. The packaged size is reported back so a + * directory that has grown past what anyone meant to upload is visible before + * the upload rather than after it. + */ + +interface PackagedWorker { + readonly archive: Uint8Array; + readonly fileCount: number; +} + +/** + * Every entry under `root`, as tar entries. + * + * Filesystem errors propagate rather than being skipped: an entry missing from + * the archive means deploying an application with a hole in it, reported as a + * success. A directory the walk cannot read, a file it cannot open and an entry + * that vanishes mid-walk are all that case. + */ +const collectEntries = ( + root: string, + relativeDir: string, +): Effect.Effect, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; + + const names = yield* fs.readDirectory(absoluteDir); + const entries: Array = []; + + for (const name of [...names].sort()) { + const relativePath = relativeDir === "" ? name : `${relativeDir}/${name}`; + const absolutePath = `${root}/${relativePath}`; + + // `readLink` succeeds only for symlinks, so it stands in for the `lstat` + // this FileSystem service does not expose (the same probe + // `legacy-sql-files-glob.ts` uses). Storing the link rather than following + // it is what keeps a pnpm-installed `node_modules` from being inlined file + // by file, keeps a broken link from vanishing, and stops a link pointing at + // an ancestor from being walked into. + const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); + if (linkTarget._tag === "Some") { + entries.push({ + path: relativePath, + contents: new Uint8Array(0), + mode: 0o777, + mtime: 0, + linkTarget: linkTarget.value, + }); + continue; + } + + const info = yield* fs.stat(absolutePath); + + const modified = info.mtime; + const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + + if (info.type === "Directory") { + entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); + entries.push(...(yield* collectEntries(root, relativePath))); + continue; + } + + if (info.type !== "File") { + // Sockets, FIFOs and devices have nothing meaningful to send. + continue; + } + + const contents = yield* fs.readFile(absolutePath); + // The executable bit is the only permission that changes what the image + // does; everything else is normalized so the same tree packages + // identically on every machine. `mode` is a plain number here, unlike the + // `Option`-wrapped `mtime` above. + const executable = (info.mode & 0o111) !== 0; + entries.push({ + path: relativePath, + contents: new Uint8Array(contents), + mode: executable ? 0o755 : 0o644, + mtime, + }); + } + + return entries; + }); + +export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { + const entries = yield* collectEntries(dir, ""); + + // `createTar` throws for a name USTAR cannot represent, such as a path + // component over 100 bytes. That is user-actionable, so it belongs in the + // failure channel: `withJsonErrorHandling` only catches failures, and a defect + // would exit `--output-format json` with no structured error. + const archive = yield* Effect.try({ + try: () => gzipSync(createTar(entries)), + catch: (cause) => { + if (cause instanceof TarPathTooLongError) { + return cause; + } + // Anything else here really is a bug, so let it stay a defect rather than + // dressing it up as a failure the user could act on. + throw cause; + }, + }); + + return { + archive: new Uint8Array(archive), + fileCount: entries.filter((entry) => !entry.path.endsWith("/")).length, + } satisfies PackagedWorker; +}); + +/** `10 KiB` / `1.4 MiB` — the packaged size, as `push` reports it. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const kib = bytes / 1024; + if (kib < 1024) { + return `${Math.ceil(kib)} KiB`; + } + return `${(kib / 1024).toFixed(1)} MiB`; +} diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts new file mode 100644 index 0000000000..83a1e6545d --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -0,0 +1,216 @@ +import { + accessSync, + chmodSync, + constants, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { gunzipSync } from "node:zlib"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; + +/** + * Whether the current user can still read `path` after it was chmod-ed shut. + * + * Root ignores the permission bits, and CI sometimes runs as root, so the + * permission-denied tests below assert the opposite outcome instead of skipping + * — either way the behaviour under test is pinned. + */ +function readableAsCurrentUser(path: string): boolean { + try { + accessSync(path, constants.R_OK); + return true; + } catch { + return false; + } +} + +function listableAsCurrentUser(path: string): boolean { + try { + readdirSync(path); + return true; + } catch { + return false; + } +} + +/** Entry paths and their USTAR typeflags, read back out of the archive. */ +function readEntries(archive: Uint8Array): Array<{ path: string; type: string; link: string }> { + const raw = new Uint8Array(gunzipSync(archive)); + const decoder = new TextDecoder(); + const trim = (value: string) => value.split("\u0000")[0] ?? ""; + const entries: Array<{ path: string; type: string; link: string }> = []; + + for (let offset = 0; offset + 512 <= raw.length;) { + const name = trim(decoder.decode(raw.subarray(offset, offset + 100))); + if (name === "") { + break; + } + const size = Number.parseInt(trim(decoder.decode(raw.subarray(offset + 124, offset + 136))), 8); + entries.push({ + path: name, + type: decoder.decode(raw.subarray(offset + 156, offset + 157)), + link: trim(decoder.decode(raw.subarray(offset + 157, offset + 257))), + }); + offset += 512 + Math.ceil(size / 512) * 512; + } + return entries; +} + +describe("packageWorkerDirectory", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-package-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const pack = (root: string) => + Effect.runPromise(packageWorkerDirectory(root).pipe(Effect.provide(BunServices.layer))); + + test("packages files and nested directories in a stable order", async () => { + mkdirSync(join(dir, "nested")); + writeFileSync(join(dir, "b.txt"), "b"); + writeFileSync(join(dir, "a.txt"), "a"); + writeFileSync(join(dir, "nested", "c.txt"), "c"); + + const result = await pack(dir); + + expect(readEntries(result.archive).map((entry) => entry.path)).toEqual([ + "a.txt", + "b.txt", + "nested/", + "nested/c.txt", + ]); + expect(result.fileCount).toBe(3); + }); + + // Anything pnpm installs is symlink-dense, so following links would inline + // every dependency's real contents — and a link pointing at an ancestor would + // be walked into until the OS refused. + test("stores symlinks as links rather than following them", async () => { + writeFileSync(join(dir, "target.txt"), "hello"); + symlinkSync("target.txt", join(dir, "link.txt")); + + const entries = readEntries((await pack(dir)).archive); + const link = entries.find((entry) => entry.path === "link.txt"); + + expect(link?.type).toBe("2"); + expect(link?.link).toBe("target.txt"); + }); + + test("keeps a broken symlink instead of dropping it", async () => { + symlinkSync("/nowhere-at-all", join(dir, "broken.txt")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); + }); + + test("does not recurse through a directory symlink that points at an ancestor", async () => { + mkdirSync(join(dir, "sub")); + writeFileSync(join(dir, "keep.txt"), "k"); + symlinkSync("..", join(dir, "sub", "up")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.map((entry) => entry.path).sort()).toEqual(["keep.txt", "sub/", "sub/up"]); + expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); + }); + + test("packages an empty directory to an archive with no entries", async () => { + const result = await pack(dir); + + expect(readEntries(result.archive)).toEqual([]); + expect(result.fileCount).toBe(0); + }); + + // A file that cannot be read used to be archived as zero bytes, so `push` + // reported success for a deploy that shipped an empty file. Failing is the + // only honest answer: the archive is the application. + test("fails rather than archiving a file it cannot read as empty", async () => { + const unreadable = join(dir, "secret.txt"); + writeFileSync(unreadable, "important"); + chmodSync(unreadable, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + // Running as root defeats the permission, so only assert when it took hold. + if (readableAsCurrentUser(unreadable)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(unreadable, 0o600); + }); + + test("fails rather than silently dropping a directory it cannot read", async () => { + const locked = join(dir, "locked"); + mkdirSync(locked); + writeFileSync(join(locked, "inside.txt"), "content"); + chmodSync(locked, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + if (listableAsCurrentUser(locked)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(locked, 0o700); + }); +}); + +// `createTar` throws for a name USTAR cannot represent. Called directly inside +// the generator that became a defect, which `withJsonErrorHandling` does not +// catch — so `--output-format json` would have died with no structured error. +describe("packageWorkerDirectory tar limits", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-tar-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("reports an unrepresentable path as a failure rather than a defect", async () => { + // One component over 100 bytes, with no directory boundary to split on. + writeFileSync(join(dir, "a".repeat(120)), "contents"); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + // A failure, not a defect: the difference is whether the JSON error handler + // ever sees it. + expect(JSON.stringify(exit)).toContain("TarPathTooLong"); + }); +}); + +describe("formatBytes", () => { + test("reports each magnitude in the unit a reader expects", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1023)).toBe("1023 B"); + expect(formatBytes(1024)).toBe("1 KiB"); + expect(formatBytes(1024 * 1024)).toBe("1.0 MiB"); + expect(formatBytes(1024 * 1024 * 1.5)).toBe("1.5 MiB"); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 7c9f93e8eb..897087b073 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -65,6 +65,13 @@ export type WorkerSize = (typeof WORKER_SIZES)[number]; /** The first available option — what `new` records when `--size` is omitted. */ export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; +/** + * Instances a worker runs when neither `--instances` nor `[workers.] + * instances` says otherwise. One, because a deploy has to name a count — the + * API's spec requires it — and a worker nobody has scaled is a single instance. + */ +export const DEFAULT_WORKER_INSTANCES = 1; + function isWorkerSize(value: string): value is WorkerSize { return WORKER_SIZES.some((size) => size === value); } diff --git a/apps/cli/src/shared/workers/worker-url.ts b/apps/cli/src/shared/workers/worker-url.ts new file mode 100644 index 0000000000..d82da066f3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-url.ts @@ -0,0 +1,17 @@ +/** + * Where a worker is served. + * + * Every worker gets a path on the project's own API host, exactly like an Edge + * Function — `.supabase.co/workers/v1/` next to + * `.supabase.co/functions/v1/`. One host per project, one path per + * worker: nothing per-worker is provisioned in DNS, so the URL is derived from + * the name rather than returned by the API. + */ + +/** Path prefix workers are served under, mirroring `functions/v1`. */ +const WORKERS_PATH_PREFIX = "/workers/v1"; + +/** The canonical URL of a worker on its project's API host. */ +export function workerUrl(projectRef: string, projectHost: string, name: string): string { + return `https://${projectRef}.${projectHost}${WORKERS_PATH_PREFIX}/${name}`; +} diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts new file mode 100644 index 0000000000..79409d05f6 --- /dev/null +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -0,0 +1,429 @@ +import { + markSupabaseApiInputErrorAsUserInput, + operationDefinitions, + SupabaseApiInputError, + V2CreateWorkerUploadOutput, + V2DeployAWorkerOutput, + V2GetAWorkerOutput, + type ApiClient, +} from "@supabase/api/effect"; +import { Effect, Option, Schedule, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { + WorkerBuildTimeoutError, + WorkersApiNetworkError, + WorkerProjectNotFoundError, + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, + WorkerUploadFailedError, +} from "./workers.errors.ts"; + +/** + * The seam every worker command talks to: `/v2/projects/{ref}/workers` on the + * Management API. + * + * The routes are deliberately few — list, get, mint an upload slot, deploy, + * delete — so this module is thin, and what it mostly adds is status handling. + * The alpha's allow-list answers 404 for a project that is not enrolled, which + * at the transport level is indistinguishable from "no such worker"; so a 404 + * on a collection endpoint (where no worker name could have been wrong) becomes + * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by + * the caller as "not deployed". + */ + +/** The worker shape the API returns, flattened out of its JSON:API envelope. */ +export interface WorkerRecord { + readonly name: string; + readonly spec: { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; + readonly backend?: string; + }; + readonly buildState: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + /** Present only on single-worker reads; a fresh deploy has nothing to report yet. */ + readonly instances?: { + readonly declared: number; + readonly live: number; + readonly ready: number; + readonly stale: number; + }; + /** Set instead of `instances` when the instance read-through failed. */ + readonly instancesError?: string; +} + +export interface WorkerUploadSlot { + readonly uploadId: string; + readonly url: string; + readonly method: string; + readonly expiresAt: string; +} + +/** The `spec` a deploy sends. Mirrors the API's own field names exactly. */ +export interface WorkerDeploySpec { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; +} + +type WorkerResourceData = typeof V2GetAWorkerOutput.Type extends { data: infer D } ? D : never; + +function toWorkerRecord(data: WorkerResourceData): WorkerRecord { + return { + name: data.id, + spec: data.attributes.spec, + buildState: data.attributes.build_state, + stateReason: data.attributes.state_reason, + imageVersion: data.attributes.image_version, + deleting: data.attributes.deleting, + instances: data.attributes.instances, + instancesError: data.attributes.instances_error, + }; +} + +const workersSuggestion = + "Workers are in private alpha. Ask in the Supabase dashboard to have this project enrolled."; + +/** + * The `error.code` a 404 carries, which is the only thing separating a project + * outside the alpha's allow-list from one that does not exist. Both answer 404 + * on the same routes; the bodies differ: + * + * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Workers are not available for this project"}}` + * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` + */ +const NotFoundBody = Schema.Struct({ + error: Schema.Struct({ code: Schema.String }), +}); + +/** + * Which of the two a project-scoped 404 was. + * + * Only `not_found` is read as a missing project — an unrecognized body keeps + * the enrolment answer, because that is what the alpha's allow-list has + * historically returned and guessing the other way would send someone to check + * a ref that is fine. + */ +const projectScoped404 = Effect.fnUntraced(function* (options: { + readonly projectRef: string; + readonly body: string; +}) { + const parsed = yield* Effect.try(() => JSON.parse(options.body) as unknown).pipe( + Effect.flatMap((json) => Schema.decodeUnknownEffect(NotFoundBody)(json)), + Effect.option, + ); + + if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { + return new WorkerProjectNotFoundError({ + detail: `No project ${options.projectRef} was found for this account.`, + suggestion: + "Check the project ref, or pick the project again with `supabase link`. " + + "If it belongs to another account, log in with `supabase login`.", + }); + } + + return new WorkersUnavailableError({ + detail: `Workers are not available for project ${options.projectRef}.`, + suggestion: workersSuggestion, + }); +}); + +/** + * Everything that can go wrong before a status code exists: the generated input + * schema rejecting the request, or the transport failing outright. + */ +function mapRequestError(operation: string) { + return (error: unknown) => { + if (error instanceof SupabaseApiInputError) { + // The only inputs these operations take are the resolved project ref and + // the prevalidated worker name, so a schema rejection is user-derived. + return markSupabaseApiInputErrorAsUserInput(error); + } + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${description}.`, + suggestion: "Check your network connection and retry.", + }); + } + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${String(error)}.`, + suggestion: "Check your network connection and retry.", + }); + }; +} + +const unexpectedStatus = Effect.fnUntraced(function* (options: { + readonly operation: string; + readonly status: number; + readonly body: string; +}) { + const trimmed = options.body.trim(); + return yield* Effect.fail( + new WorkersApiUnexpectedStatusError({ + status: options.status, + detail: `The Workers API answered ${options.status} while trying to ${options.operation}${ + trimmed === "" ? "" : `: ${trimmed}` + }.`, + suggestion: "Retry shortly; if it persists, report it with `supabase issue`.", + }), + ); +}); + +const decodeBody = ( + schema: Schema.Codec, + operation: string, + body: unknown, + status: number, +) => + Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError( + (error) => + new WorkersApiUnexpectedStatusError({ + status, + detail: `The Workers API returned a response this CLI could not read while trying to ${operation}: ${error.message}.`, + suggestion: "Update the CLI with `supabase update`, then retry.", + }), + ), + ); + +/** + * One worker, or `None` when the API has no record of it — which is also what a + * project outside the alpha's allow-list answers, so callers report it as "not + * deployed" and point at `push` rather than guessing which of the two it was. + */ +const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { + const operation = `read worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return Option.none(); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2GetAWorkerOutput, operation, body, response.status); + return Option.some(toWorkerRecord(decoded.data)); +}); + +export const createWorkerUpload = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `stage a build context for "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2CreateWorkerUpload, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 201 && response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2CreateWorkerUploadOutput, operation, body, response.status); + return { + uploadId: decoded.data.id, + url: decoded.data.attributes.url, + method: decoded.data.attributes.method, + expiresAt: decoded.data.attributes.expires_at, + } satisfies WorkerUploadSlot; +}); + +/** + * PUT the archive straight at the presigned slot. The bytes never pass through + * the Management API, so this goes out with no Supabase credentials attached — + * the signature in the URL is the authorization. + * + * That signature is why `legacyHttpClientLayer` redacts query strings before + * logging them — under `--debug` this URL is a write-capable credential. Done + * there rather than here, so the client stays injectable and every presigned URL + * is covered rather than this one call site. + */ +export const uploadBuildContext = Effect.fnUntraced(function* ( + slot: WorkerUploadSlot, + archive: Uint8Array, +) { + const client = yield* HttpClient.HttpClient; + + // The slot names its own method; the API documents `PUT` and nothing else is + // meaningful for a presigned object-store destination, so anything unexpected + // falls back to it rather than assembling a request we cannot build. + const request = ( + slot.method.toUpperCase() === "POST" + ? HttpClientRequest.post(slot.url) + : HttpClientRequest.put(slot.url) + ).pipe(HttpClientRequest.bodyUint8Array(archive, "application/gzip")); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (error) => + new WorkerUploadFailedError({ + detail: `Uploading the build context failed: ${ + error.reason.description ?? error.reason._tag + }.`, + suggestion: "Check your network connection, then re-run the same command.", + }), + ), + ); + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new WorkerUploadFailedError({ + detail: `Uploading the build context failed with status ${response.status}${ + body.trim() === "" ? "" : `: ${body.trim()}` + }.`, + suggestion: "Re-run the same command; the upload slot is minted fresh each time.", + }), + ); + } +}); + +export const deployWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + attributes: { readonly spec: WorkerDeploySpec; readonly contextUploadId?: string }, +) { + const operation = `deploy worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeployAWorker, { + ref: projectRef, + name, + data: { + type: "project_worker", + attributes: { + spec: attributes.spec, + ...(attributes.contextUploadId === undefined + ? {} + : { context_upload_id: attributes.contextUploadId }), + }, + }, + }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 202 && response.status !== 200 && response.status !== 201) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2DeployAWorkerOutput, operation, body, response.status); + return toWorkerRecord(decoded.data); +}); + +/** + * The build runs asynchronously — deploy answers 202 and the worker reaches + * `active` or `failed` later — so `push` polls `get` until `build_state` leaves + * `building`. + * + * The schedule is a parameter so tests can drive the same loop without waiting + * on wall-clock delays. + */ +const WORKER_BUILD_POLL_SCHEDULE = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "10 minutes" }), +); + +/** + * How long one poll read is allowed to keep failing before the deploy is called + * off. + * + * Bounded by elapsed time, not attempts: unspaced attempts are exhausted by a + * two-second blip, abandoning a build the server is still running. Half a minute + * of spaced retries rides that out, and anything still failing after it is the + * real error. + */ +const WORKER_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "30 seconds" }), +); + +export const awaitWorkerBuild = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + options: { + readonly schedule?: Schedule.Schedule; + /** + * Retry schedule for one poll read. A parameter for the same reason + * `schedule` is: it is spaced in seconds, and a test exercising the + * transient-failure path should not wait on a real clock to do it. + */ + readonly retrySchedule?: Schedule.Schedule; + /** Called with each poll's result, for progress reporting. */ + readonly onPoll?: (worker: WorkerRecord) => Effect.Effect; + } = {}, +) { + const poll = Effect.gen(function* () { + // A build can run for minutes, so a single blip on one read should not throw + // away a deploy that is progressing fine. + const worker = yield* getWorker(api, projectRef, name).pipe( + Effect.retry({ schedule: options.retrySchedule ?? WORKER_POLL_READ_RETRY }), + ); + if (Option.isNone(worker)) { + // The deploy was accepted, so the worker exists; a 404 here is the read + // racing the write. Report it as still building and poll again. + return undefined; + } + if (options.onPoll !== undefined) { + yield* options.onPoll(worker.value); + } + return worker.value.buildState === "building" ? undefined : worker.value; + }); + + const settled = yield* poll.pipe( + Effect.repeat({ + schedule: options.schedule ?? WORKER_BUILD_POLL_SCHEDULE, + until: (result) => result !== undefined, + }), + ); + + if (settled === undefined) { + return yield* Effect.fail( + new WorkerBuildTimeoutError({ + detail: `"${name}" was still building when this command stopped waiting.`, + suggestion: `Check on it with \`supabase workers status ${name}\`.`, + }), + ); + } + + return settled; +}); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index ecd09ac1fb..7e01fc5bfb 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../telemetry/error-actionability.ts"; /** @@ -20,6 +21,41 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** A bare `push` found no workers to deploy — none named, none in the project. */ +export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `config.toml` records a runtime this CLI does not offer. + * + * Raised by `push`, the command that reads a worker's runtime back out of + * config; `new` writes one and never reads it. + */ +export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** As {@link UnknownWorkerRuntimeError}, for a recorded instance size. */ +export class UnknownWorkerSizeError extends Data.TaggedError("UnknownWorkerSizeError")<{ + 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; @@ -29,6 +65,15 @@ export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirector } } +export class WorkerSourceMissingError extends Data.TaggedError("WorkerSourceMissingError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * `--source` names a directory it is not allowed to name. Worth its own error * because the destination is where the starter files land, so a value that @@ -43,3 +88,94 @@ export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSou return actionability.provideFlags; } } + +/** The deploy finished, and the build it started failed. */ +export class WorkerBuildFailedError extends Data.TaggedError("WorkerBuildFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** The build never left `building` inside the CLI's polling budget. */ +export class WorkerBuildTimeoutError extends Data.TaggedError("WorkerBuildTimeoutError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} + +/** PUTting the build context to the presigned slot failed. */ +export class WorkerUploadFailedError extends Data.TaggedError("WorkerUploadFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** Transport failure talking to the Management API. */ +export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** + * Workers are in private alpha: the routes answer 404 for a project that is not + * enrolled, which is indistinguishable from an unknown worker at the transport + * level — so this is only raised for the collection endpoints, where there is + * no worker name that could have been wrong. + */ +export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailableError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * The project ref names no project this account can see. + * + * Separated from {@link WorkersUnavailableError} because both arrive as a 404 + * on the same routes, and telling someone to request alpha enrolment for a + * project that does not exist sends them somewhere that cannot help. + */ +export class WorkerProjectNotFoundError extends Data.TaggedError("WorkerProjectNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * Any other status the Workers routes answered with. + * + * Classified from the status it carries rather than bucketed as a service + * failure: a 401 is the user's to fix by logging in and a 403 by getting access, + * and reporting either as `api_status` both misleads the user and blurs the + * actionability signal for every Workers endpoint at once. + */ +export class WorkersApiUnexpectedStatusError extends Data.TaggedError( + "WorkersApiUnexpectedStatusError", +)<{ + readonly detail: string; + readonly suggestion: string; + readonly status: number; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 459977e351..98a0535feb 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -14,10 +14,8 @@ import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; -import { - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, -} from "./legacy-mocks.ts"; +import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; +import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; /** @@ -233,6 +231,30 @@ export interface WorkersSetupOptions { readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; } +/** + * `LegacyTelemetryState`, recording whether it was flushed. + * + * Every worker command is supposed to write the telemetry state file on every + * invocation, success or failure — which is only observable if the mock says so, + * so the shared always-void mock cannot cover it. + */ +function mockWorkersTelemetryState() { + let flushed = false; + return { + layer: Layer.succeed(LegacyTelemetryState, { + flush: Effect.sync(() => { + flushed = true; + }), + stitchLogin: () => Effect.void, + clearDistinctId: Effect.void, + resetIdentity: Effect.void, + } as unknown as LegacyTelemetryState["Service"]), + get flushed() { + return flushed; + }, + }; +} + export function setupLegacyWorkers(options: WorkersSetupOptions) { const out = mockOutput({ format: options.format ?? "text", @@ -245,17 +267,19 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { : { promptSelectResponses: options.promptSelectResponses }), }); const http = mockWorkersHttp(options.routes ?? {}); + const telemetry = mockWorkersTelemetryState(); return { out, http, + telemetry, layer: Layer.mergeAll( out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), legacyTestCliConfigLayer(options.workdir), legacyTestProjectRefLayer(options.linked !== false), - mockLegacyTelemetryStateLayer, + telemetry.layer, mockLegacyLinkedProjectCacheLayer, randomLayer, Layer.succeed( From d5b804f5ab1cda1c2363953142eb588313fa07f4 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 17:09:23 -0300 Subject: [PATCH 22/50] refactor(cli): read Option through its public helpers in workers push The same finding as the `workers new` change one commit down the stack, applied to the three occurrences this branch adds: the symlink probe and the mtime fallback in `worker-package.ts`, and the source-directory check in `push.handler.ts`. `Option.isSome`/`isNone` are type guards, so the narrowing after each check is unchanged. `worker-package.unit.test.ts` read `exit._tag` for the same reason; the repo guidance names `Exit.isSuccess`/`Exit.isFailure` and applies to tests too. `push.integration.test.ts`'s `tagOf` keeps its `_tag` access. It classifies values that may be a `Data.TaggedError` or a plain `Error` subclass with no tag at all, which is the dynamic boundary the guidance carves out. Also corrects the `push.handler.ts` module docblock: the argument is variadic, so it is `[name...]`, matching the SIDE_EFFECTS title. --- .../src/legacy/commands/workers/push/push.handler.ts | 4 ++-- apps/cli/src/shared/workers/worker-package.ts | 6 +++--- .../src/shared/workers/worker-package.unit.test.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 86b9a068d3..caf90a67d2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -50,7 +50,7 @@ import { import type { LegacyWorkersPushFlags } from "./push.command.ts"; /** - * `supabase workers push [name]` — build (when there is code to build) and + * `supabase workers push [name...]` — build (when there is code to build) and * deploy the worker into the linked project. Registered under `deploy` as an * alias, for anyone reaching for the `supabase functions` verb out of habit. * @@ -156,7 +156,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // does not exist, and only then failing on the path. { const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); - if (stat._tag === "None" || stat.value.type !== "Directory") { + if (Option.isNone(stat) || stat.value.type !== "Directory") { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `There is no worker source at ${sourceDisplay}.`, diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 50078f9363..4202d152df 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,5 +1,5 @@ import { gzipSync } from "node:zlib"; -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; @@ -51,7 +51,7 @@ const collectEntries = ( // by file, keeps a broken link from vanishing, and stops a link pointing at // an ancestor from being walked into. const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); - if (linkTarget._tag === "Some") { + if (Option.isSome(linkTarget)) { entries.push({ path: relativePath, contents: new Uint8Array(0), @@ -65,7 +65,7 @@ const collectEntries = ( const info = yield* fs.stat(absolutePath); const modified = info.mtime; - const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + const mtime = Option.isSome(modified) ? Math.floor(modified.value.getTime() / 1000) : 0; if (info.type === "Directory") { entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index 83a1e6545d..de6e2f8cb0 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { gunzipSync } from "node:zlib"; -import { Effect } from "effect"; +import { Effect, Exit } from "effect"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; @@ -150,9 +150,9 @@ describe("packageWorkerDirectory", () => { // Running as root defeats the permission, so only assert when it took hold. if (readableAsCurrentUser(unreadable)) { - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); } else { - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); } chmodSync(unreadable, 0o600); }); @@ -168,9 +168,9 @@ describe("packageWorkerDirectory", () => { ); if (listableAsCurrentUser(locked)) { - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); } else { - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); } chmodSync(locked, 0o700); }); @@ -198,7 +198,7 @@ describe("packageWorkerDirectory tar limits", () => { packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), ); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); // A failure, not a defect: the difference is whether the JSON error handler // ever sees it. expect(JSON.stringify(exit)).toContain("TarPathTooLong"); From 0fef65eb7377eff591ed73011c236f223658a929 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 18:26:19 -0300 Subject: [PATCH 23/50] fix(cli): follow the LegacyCliSettings rename in workers push develop renamed `LegacyCliConfig` to `LegacyCliSettings` (and its module), which this branch's push handler still imported under the old path. The unresolved import widened the handler's requirements to `unknown`, so the 27 knock-on errors in `push.integration.test.ts` all came from this one line. --- apps/cli/src/legacy/commands/workers/push/push.handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index caf90a67d2..de587c16dd 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -8,7 +8,7 @@ import { } from "../workers.output.ts"; import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; @@ -143,7 +143,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const fs = yield* FileSystem.FileSystem; const output = yield* Output; const api = yield* LegacyPlatformApi; - const cliConfig = yield* LegacyCliConfig; + const settings = yield* LegacyCliSettings; const { project, name, projectRef } = input; const worker = yield* legacyDescribeWorker(project, name); @@ -271,7 +271,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const url = settled.spec.exposure === "public" - ? workerUrl(projectRef, cliConfig.projectHost, name) + ? workerUrl(projectRef, settings.projectHost, name) : undefined; // Suppressed when `-o` is in play: the payload owns stdout, and these lines From a595b6007f8746c6ad1c4255ab97b70818bb2b11 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:29:44 -0300 Subject: [PATCH 24/50] fix(cli): reject tar header values octal fields cannot represent `writeOctal` only checked the rendered width, which a negative or non-finite value passes: `(-1).toString(8)` is `"-1"` and `NaN.toString(8)` is `"NaN"`, and both pad to exactly the field width. The header went out unparseable, so GNU tar rejected the whole archive server-side after the build context had already uploaded. The reachable path is a file mtime: a pre-1970 timestamp is negative, and a corrupt one decodes to an `Invalid Date` whose `getTime()` is `NaN`. Neither is worth failing a deploy over, so `packageWorkerDirectory` now collapses both to the epoch before they reach the writer, and the writer checks the range as well as the width for anything that still gets there. `TarFieldTooLargeError` is renamed `TarFieldOutOfRangeError`, since it no longer only reports values that are too large. --- apps/cli/src/shared/workers/tar.ts | 23 ++++++----- apps/cli/src/shared/workers/tar.unit.test.ts | 24 +++++++++++- apps/cli/src/shared/workers/worker-package.ts | 20 +++++++++- .../workers/worker-package.unit.test.ts | 38 +++++++++++++++++-- 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts index 37f28ad05c..8ec0bfd4ff 100644 --- a/apps/cli/src/shared/workers/tar.ts +++ b/apps/cli/src/shared/workers/tar.ts @@ -50,13 +50,18 @@ const MAX_OCTAL_FIELD = 8 ** 11 - 1; * size — corruption no reader can detect. Real tars switch to base-256 here; * this writer refuses instead, because a build context carrying an 8 GiB file is * already a mistake worth naming rather than silently mangling. + * + * The range is checked, not just the rendered width, because the width check + * alone does not catch a value that is not a whole non-negative number: + * `(-1).toString(8)` is `"-1"` and `NaN.toString(8)` is `"NaN"`, both of which + * pad to exactly `length - 1` characters and slip through while writing a field + * no tar can parse. */ function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { - const text = Math.floor(value) - .toString(8) - .padStart(length - 1, "0"); - if (text.length > length - 1) { - throw new TarFieldTooLargeError(value); + const digits = Math.floor(value); + const text = digits.toString(8).padStart(length - 1, "0"); + if (digits < 0 || !Number.isSafeInteger(digits) || text.length > length - 1) { + throw new TarFieldOutOfRangeError(value); } writeAscii(block, offset, text); } @@ -122,14 +127,14 @@ export class TarPathTooLongError extends Error { * Untagged for the same reason as {@link TarPathTooLongError}: `createTar` is a * pure function, and the caller's error channel is where this surfaces. */ -export class TarFieldTooLargeError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "TarFieldTooLargeError"; +export class TarFieldOutOfRangeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarFieldOutOfRangeError"; constructor(value: number) { super( - `${value} is too large for a tar header field (the limit is ${MAX_OCTAL_FIELD} bytes per file)`, + `${value} cannot be written to a tar header field (values must be whole numbers from 0 to ${MAX_OCTAL_FIELD})`, ); - this.name = "TarFieldTooLargeError"; + this.name = "TarFieldOutOfRangeError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts index c7449efeb6..31cc74fddd 100644 --- a/apps/cli/src/shared/workers/tar.unit.test.ts +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createTar, TarFieldTooLargeError, TarPathTooLongError } from "./tar.ts"; +import { createTar, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -101,13 +101,33 @@ describe("createTar", () => { // the next field and read back as a plausible but wrong number. expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), - ).toThrow(TarFieldTooLargeError); + ).toThrow(TarFieldOutOfRangeError); expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 - 1 }]), ).not.toThrow(); }); + // Each of these renders to exactly the field width once padded, so the width + // check alone waves it through and the header goes out unparseable: GNU tar + // rejects the whole archive, which surfaces server-side after the upload + // rather than here. + test.each([ + ["a pre-epoch mtime", -1], + ["an mtime from an invalid date", Number.NaN], + ["an infinite mtime", Number.POSITIVE_INFINITY], + ])("refuses %s rather than writing a field no tar can parse", (_label, mtime) => { + expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime }])).toThrow( + TarFieldOutOfRangeError, + ); + }); + + test("refuses a negative mode rather than writing a field no tar can parse", () => { + expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mode: -1 }])).toThrow( + TarFieldOutOfRangeError, + ); + }); + test("refuses a path component too long to represent", () => { expect(() => createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 4202d152df..fa8092f8a5 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -21,6 +21,23 @@ interface PackagedWorker { readonly fileCount: number; } +/** + * Seconds since the epoch, as a USTAR octal field can hold them. + * + * A filesystem timestamp is not always a sane one. A pre-1970 mtime is negative + * — a botched `touch` and some archive extractors both produce them — and a + * corrupt one decodes to an `Invalid Date` whose `getTime()` is `NaN`. Neither + * is representable, and neither is worth failing a deploy over, so both collapse + * to the epoch rather than reaching `writeOctal`'s range check. + */ +function tarMtime(modified: Option.Option): number { + if (Option.isNone(modified)) { + return 0; + } + const seconds = Math.floor(modified.value.getTime() / 1000); + return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : 0; +} + /** * Every entry under `root`, as tar entries. * @@ -64,8 +81,7 @@ const collectEntries = ( const info = yield* fs.stat(absolutePath); - const modified = info.mtime; - const mtime = Option.isSome(modified) ? Math.floor(modified.value.getTime() / 1000) : 0; + const mtime = tarMtime(info.mtime); if (info.type === "Directory") { entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index de6e2f8cb0..d00cbfbe13 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -6,7 +6,9 @@ import { mkdtempSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -42,12 +44,14 @@ function listableAsCurrentUser(path: string): boolean { } } -/** Entry paths and their USTAR typeflags, read back out of the archive. */ -function readEntries(archive: Uint8Array): Array<{ path: string; type: string; link: string }> { +/** Entry paths, USTAR typeflags and mtimes, read back out of the archive. */ +function readEntries( + archive: Uint8Array, +): Array<{ path: string; type: string; link: string; mtime: string }> { const raw = new Uint8Array(gunzipSync(archive)); const decoder = new TextDecoder(); const trim = (value: string) => value.split("\u0000")[0] ?? ""; - const entries: Array<{ path: string; type: string; link: string }> = []; + const entries: Array<{ path: string; type: string; link: string; mtime: string }> = []; for (let offset = 0; offset + 512 <= raw.length;) { const name = trim(decoder.decode(raw.subarray(offset, offset + 100))); @@ -59,12 +63,20 @@ function readEntries(archive: Uint8Array): Array<{ path: string; type: string; l path: name, type: decoder.decode(raw.subarray(offset + 156, offset + 157)), link: trim(decoder.decode(raw.subarray(offset + 157, offset + 257))), + mtime: trim(decoder.decode(raw.subarray(offset + 136, offset + 148))), }); offset += 512 + Math.ceil(size / 512) * 512; } return entries; } +/** The 11-digit octal a tar header carries for `mtimeMs`. */ +function expectedOctalMtime(mtimeMs: number): string { + return Math.floor(mtimeMs / 1000) + .toString(8) + .padStart(11, "0"); +} + describe("packageWorkerDirectory", () => { let dir: string; @@ -129,6 +141,26 @@ describe("packageWorkerDirectory", () => { expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); }); + // A pre-1970 mtime is negative, and a negative number is not representable in + // a USTAR octal field: `(-1).toString(8)` renders to exactly the field width, + // so it would sail past the width check and ship a header GNU tar rejects + // after the upload. A botched `touch` is not worth failing a deploy over, so + // the timestamp collapses to the epoch instead. + test("packages a file with a pre-epoch mtime, timestamped at the epoch", async () => { + const file = join(dir, "a.txt"); + writeFileSync(file, "a"); + utimesSync(file, new Date(-86_400_000), new Date(-86_400_000)); + + const result = await pack(dir); + + const entry = readEntries(result.archive).find((candidate) => candidate.path === "a.txt"); + // Some filesystems refuse a pre-epoch timestamp and clamp it on the way in, + // in which case there is nothing to collapse — either way the field has to + // be a plain octal number the archive can carry. + const stored = statSync(file).mtimeMs; + expect(entry?.mtime).toBe(stored < 0 ? "00000000000" : expectedOctalMtime(stored)); + }); + test("packages an empty directory to an archive with no entries", async () => { const result = await pack(dir); From 3a78cd010c5fb899312b100e2c348becc1ae4886 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:31:47 -0300 Subject: [PATCH 25/50] fix(cli): keep out-of-range tar fields in the failure channel `TarFieldOutOfRangeError` carries `actionability.invalidInput` and documents itself as user-actionable, but `packageWorkerDirectory` narrowed its catch to `TarPathTooLongError` and rethrew the other as a defect. The classification could therefore never take effect, and because `withJsonErrorHandling` catches failures and not defects, `-o json` exited with no structured error at all. An 8 GiB file in the source directory is the realistic way in, through the size field. The sibling test on the path error asserted only `Exit.isFailure`, which a defect also satisfies, so it never pinned the distinction it was named for. Both tests now check the cause. --- apps/cli/src/shared/workers/worker-package.ts | 14 ++++---- .../workers/worker-package.unit.test.ts | 33 +++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index fa8092f8a5..5f366ec362 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,7 +1,7 @@ import { gzipSync } from "node:zlib"; import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; -import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; +import { createTar, type TarEntry, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; /** * Package a worker's source directory into the `.tar.gz` build context the @@ -114,14 +114,16 @@ const collectEntries = ( export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { const entries = yield* collectEntries(dir, ""); - // `createTar` throws for a name USTAR cannot represent, such as a path - // component over 100 bytes. That is user-actionable, so it belongs in the - // failure channel: `withJsonErrorHandling` only catches failures, and a defect - // would exit `--output-format json` with no structured error. + // `createTar` throws for anything USTAR cannot represent: a path component + // over 100 bytes, or a size past the 8 GiB an octal field holds. Both are + // user-actionable, and both declare themselves so, which only takes effect if + // they reach the failure channel — `withJsonErrorHandling` catches failures + // and not defects, so a defect exits `--output-format json` with no + // structured error at all. const archive = yield* Effect.try({ try: () => gzipSync(createTar(entries)), catch: (cause) => { - if (cause instanceof TarPathTooLongError) { + if (cause instanceof TarPathTooLongError || cause instanceof TarFieldOutOfRangeError) { return cause; } // Anything else here really is a bug, so let it stay a defect rather than diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index d00cbfbe13..21401ed8c3 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { gunzipSync } from "node:zlib"; -import { Effect, Exit } from "effect"; +import { Cause, Effect, Exit } from "effect"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; @@ -232,9 +232,38 @@ describe("packageWorkerDirectory tar limits", () => { expect(Exit.isFailure(exit)).toBe(true); // A failure, not a defect: the difference is whether the JSON error handler - // ever sees it. + // ever sees it. `Exit.isFailure` alone does not say which, since a defect + // exits that way too — the cause is what tells them apart. + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); expect(JSON.stringify(exit)).toContain("TarPathTooLong"); }); + + // The other half of the same rule. `TarFieldOutOfRangeError` declares itself + // user-actionable too, and that declaration can only take effect if the error + // reaches the failure channel rather than being rethrown as a defect. An 8 GiB + // file trips it through the size field; a far-future mtime is the same check + // for the price of a `utimes` call. + test("reports an out-of-range header field as a failure rather than a defect", async () => { + const file = join(dir, "a.txt"); + writeFileSync(file, "contents"); + // One past the 11-digit octal ceiling, a little past the year 2242. + utimesSync(file, 8 ** 11, 8 ** 11); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + // Filesystems that cannot hold a timestamp that far out clamp it on the way + // in, which leaves nothing out of range to report. + if (Math.floor(statSync(file).mtimeMs / 1000) > 8 ** 11 - 1) { + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); + expect(JSON.stringify(exit)).toContain("TarFieldOutOfRange"); + } else { + expect(Exit.isSuccess(exit)).toBe(true); + } + }); }); describe("formatBytes", () => { From 323852d898a6cc1ada40250d4f5d5c5deeb61889 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:37:45 -0300 Subject: [PATCH 26/50] fix(cli): recover only a missing source, not every filesystem error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Effect.option` on the source-directory stat swallowed every failure, so a permission or I/O error was reported as "There is no worker source at " with a suggestion to scaffold one — a misdiagnosis whose remediation points at a path that is already occupied. The `readDirectory` a few lines down had the same shape through `orElseSucceed(() => [])`, reading an unopenable directory as an empty one. Only a `NotFound` reason now maps to `WorkerSourceMissingError`; every other `PlatformError` propagates as itself. `PlatformError` was already in this handler's error channel via `packageWorkerDirectory`, so nothing downstream changes. --- .../commands/workers/push/push.handler.ts | 35 +++++++--- .../workers/push/push.integration.test.ts | 67 ++++++++++++++++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index de587c16dd..e22359e011 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Option, type Schedule } from "effect"; +import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { @@ -155,19 +156,35 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // guessed. Doing that first meant reporting an inference about a path that // does not exist, and only then failing on the path. { - const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); - if (Option.isNone(stat) || stat.value.type !== "Directory") { - return yield* Effect.fail( - new WorkerSourceMissingError({ - detail: `There is no worker source at ${sourceDisplay}.`, - suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, - }), + const sourceMissing = new WorkerSourceMissingError({ + detail: `There is no worker source at ${sourceDisplay}.`, + suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + }); + // Only "no such path" means the worker was never scaffolded. A permission + // or I/O error on the directory is a different problem with a different + // fix, and answering it with "there is no worker source, run `workers new`" + // both misdiagnoses it and points at a directory that already exists — so + // every other reason propagates as itself. + const info = yield* fs + .stat(worker.sourceDir) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.fail(sourceMissing) + : Effect.fail(error), + ), ); + if (info.type !== "Directory") { + return yield* Effect.fail(sourceMissing); } // An empty directory packages and deploys perfectly happily, producing an // image with nothing in it — a success message for a worker that cannot // serve anything. Refuse before uploading rather than after. - const contents = yield* fs.readDirectory(worker.sourceDir).pipe(Effect.orElseSucceed(() => [])); + // + // Read errors propagate rather than reading as empty: a directory the CLI + // cannot open is not a directory with nothing in it, and the two want + // opposite things from the user. + const contents = yield* fs.readDirectory(worker.sourceDir); if (contents.length === 0) { return yield* Effect.fail( new WorkerSourceMissingError({ diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 0c16266931..c23b74baa2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -1,4 +1,4 @@ -import { rmSync } from "node:fs"; +import { chmodSync, readdirSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, Schedule } from "effect"; @@ -90,6 +90,20 @@ function tagOf(error: unknown): string | undefined { : undefined; } +/** + * Whether the current user can still list `path` after it was chmod-ed shut. + * Root ignores the permission bits, and CI sometimes runs as root, so the + * permission test below asserts the opposite outcome instead of skipping. + */ +function listableAsCurrentUser(path: string): boolean { + try { + readdirSync(path); + return true; + } catch { + return false; + } +} + function push(flagOverrides: Partial = {}) { // Both schedules are injected: the outer poll and the per-read retry. The // production retry is spaced in seconds, so leaving it in place made the @@ -423,6 +437,57 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // "Cannot read it" and "it is not there" want opposite things from the user, + // and `Effect.option` on the stat collapsed them into the second — so an + // unreadable source was reported as an unscaffolded worker, with a suggestion + // to run `workers new` over a path that is already occupied. A symlink loop + // is the cheapest stat failure that is not a missing path, and unlike a + // chmod it behaves the same when the suite runs as root. + it.live("reports an unstattable source rather than calling it missing", () => { + const repo = project({}); + const source = join(repo.dir, "supabase", "workers", "api"); + rmSync(source, { recursive: true, force: true }); + symlinkSync("api", source); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).not.toBeInstanceOf(WorkerSourceMissingError); + expect(tagOf(error)).toBe("PlatformError"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Same rule one line down: `orElseSucceed([])` on the read reported a + // directory the CLI cannot open as a directory with nothing in it. + it.live("reports an unreadable source rather than calling it empty", () => { + const repo = project({}); + const source = join(repo.dir, "supabase", "workers", "api"); + chmodSync(source, 0o000); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + if (listableAsCurrentUser(source)) { + expect(http.requests.length).toBeGreaterThan(0); + } else { + expect(error).not.toBeInstanceOf(WorkerSourceMissingError); + expect(tagOf(error)).toBe("PlatformError"); + expect(http.requests).toHaveLength(0); + } + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + chmodSync(source, 0o700); + repo.cleanup(); + }), + ), + ); + }); + it.live("rides out a transient failure while polling the build", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ From 31305259f447d54d3189331e7ecd47a1f51e6bb5 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:39:07 -0300 Subject: [PATCH 27/50] fix(cli): stop pointing an empty worker source at `workers new` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both "nothing to deploy" guards suggested re-scaffolding with `supabase workers new --force`. `new` defines no `--force` flag, so following the suggestion exits with an unknown-option error — and dropping the flag would not save it: `new` refuses any name already present in `config.toml`, which is where a pushed worker almost always comes from, and refuses a directory that exists and is not empty, which covers the empty-subdirectories guard. The directory is already there and already wired up, so the only honest instruction is to put the code in it. Both call sites now share one suggestion, and the tests pin that neither names `new`. --- .../commands/workers/push/push.handler.ts | 18 ++++++++++++++++-- .../workers/push/push.integration.test.ts | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index e22359e011..a83558505a 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -131,6 +131,20 @@ function resolveInstances(options: { return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); } +/** + * What to do about a source directory that exists but holds nothing to deploy. + * + * Deliberately does not point at `supabase workers new`. That command refuses + * any name already present in `config.toml`, which is where a pushed worker + * almost always comes from, and it refuses a directory that exists and is not + * empty — so for both callers here it would answer with a second error rather + * than a fix. The directory is already in place and already wired up; the only + * thing missing is the code. + */ +function addYourCode(sourceDisplay: string): string { + return `Add your worker's code to ${sourceDisplay}, then run this command again.`; +} + const deployOneWorker = Effect.fnUntraced(function* (input: { readonly project: LegacyWorkersProject; readonly name: string; @@ -189,7 +203,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `${sourceDisplay} is empty, so there is nothing to deploy.`, - suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + suggestion: addYourCode(sourceDisplay), }), ); } @@ -234,7 +248,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `${sourceDisplay} holds no files to deploy, only empty directories.`, - suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + suggestion: addYourCode(sourceDisplay), }), ); } diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index c23b74baa2..4d4303e2f8 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -433,6 +433,11 @@ describe("legacy workers push", () => { expect(error).toBeInstanceOf(WorkerSourceMissingError); expect((error as WorkerSourceMissingError).detail).toContain("is empty"); + // `workers new` defines no `--force`, and refuses both a name already in + // `config.toml` and a directory that is not empty — so recovery advice + // that names it would answer with a second error instead of a fix. + expect((error as WorkerSourceMissingError).suggestion).not.toContain("--force"); + expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -673,6 +678,8 @@ describe("legacy workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).not.toContain("--force"); + expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); expect(http.routeKeys).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); From e831044de1a5c215e659a12b507fa1c0fbb91834 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:39:26 -0300 Subject: [PATCH 28/50] docs(cli): say why the tar writer does not use Bun.Archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The why-not paragraph only covered shelling out to `tar`, leaving the next reader to wonder why this does not reuse `Bun.Archive`, which the repo already builds tar bytes with. Verified against Bun 1.3.14: creation takes path-to-contents pairs and nothing else, and every entry is emitted as a regular file with mode 0644 and the current wall-clock time — no symlinks, no executable bit, no reproducible output. --- apps/cli/src/shared/workers/tar.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts index 8ec0bfd4ff..0dccc1de48 100644 --- a/apps/cli/src/shared/workers/tar.ts +++ b/apps/cli/src/shared/workers/tar.ts @@ -8,6 +8,14 @@ * server only ever untars what we send, so producing the bytes here keeps the * upload identical on every platform and keeps packaging out of the process * table. + * + * `Bun.Archive` — which this repo already uses to build the pgdata baseline + * marker, `legacyPgDataBaselineMarkerTar` — is not the same tool. It builds + * from path-to-contents pairs and exposes no per-entry metadata: every entry + * comes out as a regular file with mode `0644` and the current wall-clock time, + * so a symlink cannot be stored at all, an executable loses its bit, and the + * same tree packages to different bytes on every run. A single-file marker + * needs none of that; a build context needs all of it. */ import { From 1e966613ad12e36cf90564c39ea38a8b9a94276d Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:59:36 -0300 Subject: [PATCH 29/50] fix(cli): report a non-directory worker source as what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file sitting where the source directory should be fell into the same `WorkerSourceMissingError` as a missing path, so `push` said "There is no worker source at " about a path that is occupied, and suggested `supabase workers new ` — which refuses a destination that exists and is not a directory, and refuses any name already in `config.toml`. The user got a false diagnosis followed by a command that errors out. The missing-path branch keeps that message, where both halves are true: a name is only validated as a DNS label before dispatch, so `push ` for a worker that is in neither `config.toml` nor the workers directory does reach it, and `workers new ` is the right answer there. SIDE_EFFECTS.md picks up this condition and the unreadable-source one from the preceding commit. --- .../commands/workers/push/SIDE_EFFECTS.md | 17 +++++++------ .../commands/workers/push/push.handler.ts | 11 +++++++- .../workers/push/push.integration.test.ts | 25 ++++++++++++++++++- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index b145692970..e6b9692640 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -35,14 +35,15 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------- | -| `0` | success | -| `1` | no workers named and none found in the project | -| `1` | a worker's source directory is missing or empty | -| `1` | build context upload failed | -| `1` | the build reached `failed`, or never left `building` | -| `1` | API error, or project not enrolled in the alpha | +| Code | Condition | +| ---- | ------------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source is missing, not a directory, or empty | +| `1` | a worker's source directory cannot be read | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | API error, or project not enrolled in the alpha | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index a83558505a..1d623f2041 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -188,8 +188,17 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { : Effect.fail(error), ), ); + // Something is there, it is just not a directory. Reporting that as "there + // is no worker source" is false twice over: the path is occupied, and + // `workers new` refuses a destination that exists and is not a directory, + // so the scaffold suggestion would answer with a second error. if (info.type !== "Directory") { - return yield* Effect.fail(sourceMissing); + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} is not a directory.`, + suggestion: `Replace it with a directory holding your worker's code, then run this command again.`, + }), + ); } // An empty directory packages and deploys perfectly happily, producing an // image with nothing in it — a success message for a worker that cannot diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 4d4303e2f8..f1dbc3a97e 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, readdirSync, rmSync, symlinkSync } from "node:fs"; +import { chmodSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, Schedule } from "effect"; @@ -442,6 +442,29 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // A file sitting where the source directory should be is not a missing + // worker: the path is occupied, and `workers new` refuses a destination that + // exists and is not a directory, so pointing there would answer with a second + // error. + it.live("reports a file at the source path as not a directory", () => { + const repo = project({}); + const source = join(repo.dir, "supabase", "workers", "api"); + rmSync(source, { recursive: true, force: true }); + writeFileSync(source, "not a directory"); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + const failure = error as WorkerSourceMissingError; + expect(failure.detail).toContain("is not a directory"); + expect(failure.detail).not.toContain("There is no worker source"); + expect(failure.suggestion).not.toContain("workers new"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // "Cannot read it" and "it is not there" want opposite things from the user, // and `Effect.option` on the stat collapsed them into the second — so an // unreadable source was reported as an unscaffolded worker, with a suggestion From f0d3477d64c8d210f9c39287c145a03f1439174e Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:02:45 -0300 Subject: [PATCH 30/50] fix(cli): suggest a scaffold only where `workers new` would work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The missing-source suggestion always said `supabase workers new `, but `new` refuses any name already under `[workers.]` — so for a configured worker whose directory is gone, the one recovery offered exits with "already configured". The suggestion now depends on how the worker got here: - no config entry — the name reached `push` from argv alone, `new` is the answer, message unchanged; - configured, default directory — the entry is fine and the directory is not, so say to create it; - configured with an explicit `source` — the path in config is as likely to be the mistake as the absent directory, so name both. --- .../commands/workers/push/push.handler.ts | 33 ++++++++++++- .../workers/push/push.integration.test.ts | 47 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 1d623f2041..cc2678c66c 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -13,6 +13,7 @@ import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.t import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import type { WorkerEntry } from "../../../../shared/workers/worker-config.ts"; import { apiSizeFor, DEFAULT_WORKER_INSTANCES, @@ -131,6 +132,31 @@ function resolveInstances(options: { return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); } +/** + * What to do about a worker whose source directory is not there at all. + * + * `supabase workers new` is only an answer for a name the config has never + * heard of — `new` refuses any name already under `[workers.]`, so + * offering it to a configured worker would answer with a second error. A + * configured worker is missing a directory, not a config entry, and when the + * entry pins an explicit `source` the path itself is as likely to be the + * mistake as the absent directory. + */ +function missingSourceSuggestion(input: { + readonly name: string; + readonly sourceDisplay: string; + readonly configPath: string; + readonly entry: WorkerEntry | undefined; +}): string { + if (input.entry === undefined) { + return `Scaffold it with \`supabase workers new ${input.name}\`.`; + } + if (input.entry.source !== undefined) { + return `Create ${input.sourceDisplay}, or correct \`source\` under [workers.${input.name}] in ${input.configPath}.`; + } + return `Create ${input.sourceDisplay} and add your worker's code, then run this command again.`; +} + /** * What to do about a source directory that exists but holds nothing to deploy. * @@ -172,7 +198,12 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { { const sourceMissing = new WorkerSourceMissingError({ detail: `There is no worker source at ${sourceDisplay}.`, - suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + suggestion: missingSourceSuggestion({ + name, + sourceDisplay, + configPath: displayPath(project.projectRoot, project.configPath), + entry: worker.entry, + }), }); // Only "no such path" means the worker was never scaffolded. A permission // or I/O error on the directory is a different problem with a different diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index f1dbc3a97e..11989f277f 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -418,7 +418,12 @@ describe("legacy workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerSourceMissingError); - expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + // `api` is under `[workers.api]`, and `new` refuses a name the config + // already carries — so the answer is the absent directory, not a scaffold. + expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); + expect((error as WorkerSourceMissingError).suggestion).toContain( + "supabase/workers/api and add your worker's code", + ); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -442,6 +447,46 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The one case `workers new` really does answer: a name that reached `push` + // from argv alone, with no `[workers.]` entry and nothing on disk. + // Names are only validated as DNS labels before dispatch, so this is + // reachable — a typo, or a worker nobody has scaffolded yet. + it.live("offers to scaffold a worker the config has never heard of", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A worker whose `source` points somewhere that is not there: the path in + // config is as likely to be the mistake as the absent directory, so the + // suggestion names both. + it.live("points at the config entry when a configured source is missing", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "./services/api"\n`, + }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + const failure = error as WorkerSourceMissingError; + expect(failure.suggestion).not.toContain("workers new"); + expect(failure.suggestion).toContain("[workers.api]"); + expect(failure.suggestion).toContain("source"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // A file sitting where the source directory should be is not a missing // worker: the path is occupied, and `workers new` refuses a destination that // exists and is not a directory, so pointing there would answer with a second From e14bedc82e73fed8d346644b49de9cb66bb535fd Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:31:58 -0300 Subject: [PATCH 31/50] test(cli): make the unreadable-source test survive a root runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test chmod-ed the source directory shut, then ran the deploy through `Effect.flip`. Root ignores the permission bits, so the deploy succeeds there — and `Effect.flip` turns a success into a failure, which fails the test before the branch written to handle exactly that case can run. The root-safe branch was unreachable. The permission probe now happens before the run and selects which effect to run, so both permission models are asserted rather than one of them crashing. `tagOf` goes with it: every call site knows the variant it expects, so they use `Predicate.isTagged` or the error class instead of reaching for `_tag`, per the repo rule that tests follow the same narrowing rules as production code. --- .../workers/push/push.integration.test.ts | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 11989f277f..2b84a385d4 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -1,7 +1,7 @@ import { chmodSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Schedule } from "effect"; +import { Effect, Option, Predicate, Schedule } from "effect"; import { makeWorkersProject, setupLegacyWorkers, @@ -11,9 +11,11 @@ import { type WorkersHttpRoutes, } from "../../../../../tests/helpers/legacy-workers.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; import { NoWorkersToDeployError, WorkerBuildFailedError, + WorkerBuildTimeoutError, WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, @@ -80,16 +82,6 @@ function routes(overrides: WorkersHttpRoutes = {}): WorkersHttpRoutes { }; } -/** - * The `_tag` of a failure, for a channel that also carries plain `Error` - * subclasses — `TarPathTooLongError` has no tag. - */ -function tagOf(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "_tag" in error - ? String((error as { _tag: unknown })._tag) - : undefined; -} - /** * Whether the current user can still list `path` after it was chmod-ed shut. * Root ignores the permission bits, and CI sometimes runs as root, so the @@ -324,7 +316,7 @@ describe("legacy workers push", () => { Effect.flip, ); - expect(tagOf(error)).toBe("WorkerBuildTimeoutError"); + expect(error).toBeInstanceOf(WorkerBuildTimeoutError); expect((error as { suggestion: string }).suggestion).toContain("supabase workers status api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -527,7 +519,7 @@ describe("legacy workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).not.toBeInstanceOf(WorkerSourceMissingError); - expect(tagOf(error)).toBe("PlatformError"); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -538,18 +530,24 @@ describe("legacy workers push", () => { const repo = project({}); const source = join(repo.dir, "supabase", "workers", "api"); chmodSync(source, 0o000); + // Probed before the run, not inside it: root ignores the permission bits, so + // the deploy would succeed, and `Effect.flip` turns a success into a failure + // — the branch below would never be reached to handle that case. + const unreadable = !listableAsCurrentUser(source); const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); return Effect.gen(function* () { - const error = yield* push().pipe(Effect.flip); - - if (listableAsCurrentUser(source)) { + if (!unreadable) { + yield* push(); expect(http.requests.length).toBeGreaterThan(0); - } else { - expect(error).not.toBeInstanceOf(WorkerSourceMissingError); - expect(tagOf(error)).toBe("PlatformError"); - expect(http.requests).toHaveLength(0); + return; } + + const error = yield* push().pipe(Effect.flip); + + expect(error).not.toBeInstanceOf(WorkerSourceMissingError); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); + expect(http.requests).toHaveLength(0); }).pipe( Effect.provide(layer), Effect.ensuring( @@ -728,7 +726,7 @@ describe("legacy workers push", () => { return Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); - expect(tagOf(error)).toBe("LegacyWorkersEnvNotSupportedError"); + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); expect(http.routeKeys).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); From e41948be8651594bda212cd1ce8c6e880d1d7cb8 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:35:54 -0300 Subject: [PATCH 32/50] fix(cli): render transport failures without reaching for `_tag` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fallbacks read `error.reason._tag` when a transport error carried no description, which reinvents `HttpClientError.message` and does it worse — the library renders `Transport error (POST https://...)` where this rendered the bare class name. The two sites cannot take the same fix. `mapRequestError` talks to the Management API, so `error.message` is a straight upgrade. `uploadBuildContext` cannot use it: the message appends the URL that failed, and there that URL is the presigned signature — a write-capable credential, and the same leak `legacyRedactHttpUrl` exists to prevent on the debug log. That site keeps the reason's own description with a fixed fallback. The workers HTTP harness gains a transport-failure stub, so the leak has a test rather than only a comment. Swapping `error.message` back in turns it red. --- .../workers/push/push.integration.test.ts | 25 +++++++++++ apps/cli/src/shared/workers/workers-api.ts | 14 ++++-- apps/cli/tests/helpers/legacy-workers.ts | 44 ++++++++++++++++--- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 2b84a385d4..982fd10f84 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -336,6 +336,31 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The presigned URL's query string is a write-capable credential, so it must + // not ride along in the error text — which rules out the library's own + // `HttpClientError.message`, since that appends the method and URL that + // failed. A transport failure is the case that would carry it. + it.live("keeps the presigned signature out of an upload transport failure", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + "PUT /deploy-context/api.tar.gz": { transportError: "connection reset by peer" }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerUploadFailedError); + const failure = error as WorkerUploadFailedError; + expect(failure.detail).toContain("connection reset by peer"); + expect(failure.detail).not.toContain("signed"); + expect(failure.detail).not.toContain(UPLOAD_URL); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // Both of the next two arrive as a 404 on the same route; only `error.code` // separates them, so they are asserted against the bodies the API really // sends rather than a shape of our own invention. diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index 79409d05f6..1d15a12cc9 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -147,9 +147,12 @@ function mapRequestError(operation: string) { return markSupabaseApiInputErrorAsUserInput(error); } if (HttpClientError.isHttpClientError(error)) { - const description = error.reason.description ?? error.reason._tag; + // `message` is the library's own rendering of the reason — its label, the + // description when there is one, and the method and URL that failed. + // These requests all go to the Management API, so that URL is safe to + // show and is the most useful thing in the sentence. return new WorkersApiNetworkError({ - detail: `Could not reach the Workers API while trying to ${operation}: ${description}.`, + detail: `Could not reach the Workers API while trying to ${operation}: ${error.message}.`, suggestion: "Check your network connection and retry.", }); } @@ -286,8 +289,13 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( Effect.mapError( (error) => new WorkerUploadFailedError({ + // Deliberately not `error.message`, which is what the other transport + // failures in this module use: it appends the URL that failed, and + // here that URL is the write-capable signature. The reason's own + // description is the part worth showing, and the destination is + // already named by the step the user is watching. detail: `Uploading the build context failed: ${ - error.reason.description ?? error.reason._tag + error.reason.description ?? "the upload request did not complete" }.`, suggestion: "Check your network connection, then re-run the same command.", }), diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 98a0535feb..2a838b1aba 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -5,7 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { makeApiClient } from "@supabase/api/effect"; import { Effect, Layer, Option, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; @@ -43,8 +43,26 @@ export interface StubResponse { readonly body?: unknown; } +/** + * A request that never reaches a status code — the connection itself failed. + * Distinct from a `StubResponse` with an error status, which is a server that + * answered. + */ +export interface StubTransportFailure { + readonly transportError: string; +} + +function isTransportFailure( + stub: StubResponse | StubTransportFailure, +): stub is StubTransportFailure { + return "transportError" in stub; +} + /** How a test answers one request; sequential entries reply to repeated calls. */ -export type RouteHandler = StubResponse | ReadonlyArray; +export type RouteHandler = + | StubResponse + | StubTransportFailure + | ReadonlyArray; export interface WorkersHttpRoutes { /** Keyed `" "`, e.g. `"GET /v2/projects/abc.../workers"`. */ @@ -72,17 +90,17 @@ function respond( */ export function mockWorkersHttp(routes: WorkersHttpRoutes) { const requests: Array = []; - const remaining = new Map>( + const remaining = new Map>( Object.entries(routes).map(([route, handler]) => [ route, - Array.isArray(handler) ? [...handler] : [handler as StubResponse], + Array.isArray(handler) ? [...handler] : [handler as StubResponse | StubTransportFailure], ]), ); const handle = ( request: HttpClientRequest.HttpClientRequest, ): Effect.Effect => - Effect.sync(() => { + Effect.suspend(() => { const bytes = request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0); const url = new URL(request.url); requests.push({ @@ -95,12 +113,24 @@ export function mockWorkersHttp(routes: WorkersHttpRoutes) { const key = `${request.method} ${url.pathname}`; const queue = remaining.get(key); if (queue === undefined || queue.length === 0) { - return respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }); + return Effect.succeed( + respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }), + ); } // The last stub for a route keeps answering, so a poll loop does not have // to be stubbed a fixed number of times. const stub = queue.length === 1 ? queue[0]! : queue.shift()!; - return respond(request, stub); + if (isTransportFailure(stub)) { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: stub.transportError, + }), + }), + ); + } + return Effect.succeed(respond(request, stub)); }); const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make(handle)); From c2a6eaf6970bc562433e56b86ce6e51da80de069 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:38:49 -0300 Subject: [PATCH 33/50] fix(cli): read JSON project config when deploying workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `push` reused `legacyLoadWorkersProject`, which pins the loader to `tomlOnly`. That constraint belongs to `workers new`, whose entry writer is a TOML text editor and would corrupt a `config.json` by appending a `[workers.]` table to it. `push` only reads, and inherited it by sharing one function. With only a `config.json` on disk the loader returns null, so the workers section came back empty: a bare `push` skipped any worker whose `source` sits outside `supabase/workers/`, and a named one deployed with a guessed runtime and the default size and instance count instead of its configured values. The loader now takes the flag, with two named entry points so the call site says which it wants — `legacyLoadWorkersProject` for readers, `legacyLoadWorkersProjectForEntryWrite` for the scaffolder. The TOML-only gap is now the writer's alone. --- .../commands/workers/new/new.handler.ts | 4 +- .../commands/workers/push/SIDE_EFFECTS.md | 3 +- .../workers/push/push.integration.test.ts | 33 ++++++++++++++ .../legacy/commands/workers/workers.shared.ts | 43 +++++++++++++------ 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts index d6df968137..9b5e73774c 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -36,7 +36,7 @@ import { InvalidWorkerNameError, WorkerDirectoryExistsError, } from "../../../../shared/workers/workers.errors.ts"; -import { legacyLoadWorkersProject } from "../workers.shared.ts"; +import { legacyLoadWorkersProjectForEntryWrite } from "../workers.shared.ts"; import type { LegacyWorkersNewFlags } from "./new.command.ts"; /** @@ -132,7 +132,7 @@ export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( // The telemetry state file is written on every invocation, success or failure. yield* Effect.gen(function* () { - const project = yield* legacyLoadWorkersProject(); + const project = yield* legacyLoadWorkersProjectForEntryWrite(); const name = flags.name; const invalid = validateWorkerNameMessage(name); diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index e6b9692640..693de7002f 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -9,7 +9,8 @@ | Path | Format | When | | ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/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 | diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 982fd10f84..52daf33332 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -336,6 +336,39 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // `config.json` is a supported project format. `push` only reads the workers + // section, so it has to honour one: loading TOML-only left the section empty, + // which meant a guessed runtime and default size and instance count for a + // worker that had configured all three. + it.live("deploys a worker configured in config.json, not just config.toml", () => { + const created = makeWorkersProject({ + "supabase/config.json": JSON.stringify({ + project_id: "demo", + workers: { api: { runtime: "node", size: "2gb", instances: 3 } }, + }), + "supabase/workers/api/index.js": "export default { fetch: () => new Response('ok') };\n", + }); + const repo = { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; + const { layer, http, out } = 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).toEqual({ + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 3, + }); + // Every value came from config, so nothing was inferred from the files. + expect(out.stderrText).not.toContain("guessed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // The presigned URL's query string is a write-capable credential, so it must // not ride along in the error text — which rules out the library's own // `HttpClientError.message`, since that appends the method and URL that diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 2a7a311982..10ab03cff9 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -32,22 +32,11 @@ export interface LegacyWorkersProject { readonly workersDir: string; } -export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { +const loadWorkersProject = Effect.fnUntraced(function* (options: { readonly tomlOnly: boolean }) { const settings = yield* LegacyCliSettings; const projectRoot = settings.workdir; const supabaseDir = join(projectRoot, "supabase"); - // `tomlOnly`: the entry writer is a TOML text editor. Without this the loader - // prefers `supabase/config.json` when one exists, `configPath` becomes the - // JSON file, and `commitWorkerEntry` appends a `[workers.]` table to it - // — leaving the project config unparseable after the scaffold is on disk. - // `functions new` avoids the same trap by resolving `supabase/config.toml` - // directly; this is that, through the loader. - // - // A JSON project therefore gets a `config.toml` written beside its - // `config.json`, which the default loader lists in `ignoredPaths`. That is a - // known gap: workers are TOML-only until config writing is overhauled. - // // `search: false`: `settings.workdir` is already an authoritative project // root — `--workdir`/`SUPABASE_WORKDIR` as given, else the one ancestor walk // Go's `getProjectRoot` performs — so letting the loader climb again resolves @@ -59,7 +48,7 @@ export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { // // `loadCliConfig` returns null when the directory holds no project yet, // which is what lets `workers new` scaffold into a bare one. - const loaded = yield* loadCliConfig(projectRoot, { tomlOnly: true, search: false }); + const loaded = yield* loadCliConfig(projectRoot, { tomlOnly: options.tomlOnly, search: false }); const section = readWorkersSection(loaded?.config.workers); return { @@ -71,6 +60,34 @@ export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { } satisfies LegacyWorkersProject; }); +/** + * The project as a reader sees it, following the loader's normal + * JSON-over-TOML selection. `config.json` is a supported project format, so a + * command that only reads `[workers.*]` has to honour it — otherwise a JSON + * project deploys with a guessed runtime and default size and instance counts + * instead of the ones it configured, and a worker whose `source` sits outside + * `supabase/workers/` is not discovered at all. + */ +export const legacyLoadWorkersProject = () => loadWorkersProject({ tomlOnly: false }); + +/** + * The project as the `[workers.]` entry writer needs to see it: TOML + * only. + * + * `commitWorkerEntry` is a TOML text editor. Without `tomlOnly` the loader + * prefers `supabase/config.json` when one exists, `configPath` becomes the JSON + * file, and the writer appends a `[workers.]` table to it — leaving the + * project config unparseable after the scaffold is already on disk. + * `functions new` avoids the same trap by resolving `supabase/config.toml` + * directly; this is that, through the loader. + * + * A JSON project therefore gets a `config.toml` written beside its + * `config.json`, which the loader lists in `ignoredPaths`. That gap is the + * writer's alone — reads go through {@link legacyLoadWorkersProject} — and it + * closes when config writing is overhauled. + */ +export const legacyLoadWorkersProjectForEntryWrite = () => loadWorkersProject({ tomlOnly: true }); + export interface LegacyResolvedWorker { readonly name: string; readonly entry: WorkerEntry | undefined; From db35bcee7357a78008f1fd1c8acc8b36f0d4c068 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:02:30 -0300 Subject: [PATCH 34/50] fix(cli): refuse a build context that links outside itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collectEntries` stored every symlink as a link entry, which is right for a link inside the packaged tree and wrong for one pointing out of it. The archive is the whole of what the server gets — it runs no install step and has no view of the surrounding repository — so an escaping link arrives dangling: a catalog runtime boots without the dependency, a Dockerfile build fails on the `COPY`, both minutes later with nothing naming the cause. A worker directory that is a pnpm workspace member is the common way in. Its dependencies link to the repository-root store, so every one of them escapes. A worker with `source` pointing at an existing monorepo package is the same case, and that is the use `source` exists for. An escaping link is now refused before the upload. An absolute target that does land back inside the tree is rewritten relative to the link, since a path on this machine resolves to nothing on the other end. Not runtime-specific, so not gated on one: the walk never sees the runtime, and a Dockerfile worker with a symlinked config has the same problem. --- .../commands/workers/push/SIDE_EFFECTS.md | 1 + apps/cli/src/shared/workers/worker-package.ts | 46 ++++++++++++++++++- .../workers/worker-package.unit.test.ts | 37 ++++++++++++++- apps/cli/src/shared/workers/workers.errors.ts | 24 ++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index 693de7002f..52e5ab4366 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -42,6 +42,7 @@ | `1` | no workers named and none found in the project | | `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 | | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 5f366ec362..3e4d5a300f 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,6 +1,8 @@ import { gzipSync } from "node:zlib"; +import { isAbsolute, relative, resolve } from "node:path"; import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; +import { WorkerSourceEscapingLinkError } from "./workers.errors.ts"; import { createTar, type TarEntry, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; /** @@ -21,6 +23,28 @@ interface PackagedWorker { readonly fileCount: number; } +/** + * Where a symlink points, relative to the packaged tree — or `undefined` when it + * points outside it. + * + * A link is stored rather than followed, so the target has to be packaged too + * for the link to mean anything on the other end. Targets are also rewritten + * relative to the link's own directory: an absolute one is a path on this + * machine and would not resolve anywhere else. + */ +function confinedLinkTarget(input: { + readonly root: string; + readonly linkDir: string; + readonly target: string; +}): string | undefined { + const resolved = resolve(input.linkDir, input.target); + const fromRoot = relative(input.root, resolved); + if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) { + return undefined; + } + return isAbsolute(input.target) ? relative(input.linkDir, resolved) : input.target; +} + /** * Seconds since the epoch, as a USTAR octal field can hold them. * @@ -49,7 +73,11 @@ function tarMtime(modified: Option.Option): number { const collectEntries = ( root: string, relativeDir: string, -): Effect.Effect, PlatformError, FileSystem.FileSystem> => +): Effect.Effect< + Array, + PlatformError | WorkerSourceEscapingLinkError, + FileSystem.FileSystem +> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; @@ -69,12 +97,26 @@ const collectEntries = ( // an ancestor from being walked into. const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); if (Option.isSome(linkTarget)) { + const confined = confinedLinkTarget({ + root, + linkDir: absoluteDir, + target: linkTarget.value, + }); + if (confined === undefined) { + return yield* Effect.fail( + new WorkerSourceEscapingLinkError({ + detail: `${relativePath} links to ${linkTarget.value}, which is outside the worker source and cannot be packaged with it.`, + suggestion: + "Install the worker's dependencies inside its own directory, or point `source` at a directory that contains everything the build needs.", + }), + ); + } entries.push({ path: relativePath, contents: new Uint8Array(0), mode: 0o777, mtime: 0, - linkTarget: linkTarget.value, + linkTarget: confined, }); continue; } diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index 21401ed8c3..d95169a2d7 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -122,14 +122,49 @@ describe("packageWorkerDirectory", () => { expect(link?.link).toBe("target.txt"); }); + // Broken, but pointing at a name inside the tree: whether the target exists is + // the server's problem once the archive is extracted, and dropping the link + // would change the tree the build sees. test("keeps a broken symlink instead of dropping it", async () => { - symlinkSync("/nowhere-at-all", join(dir, "broken.txt")); + symlinkSync("nowhere-at-all.txt", join(dir, "broken.txt")); const entries = readEntries((await pack(dir)).archive); expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); }); + // The archive is the whole of what the server gets, so a link out of it + // arrives dangling however valid it is here. Refused while the user is still + // at the terminal, rather than surfacing as a remote build failure. + test.each([ + ["a relative escape", "../../outside.txt"], + ["an absolute escape", "/nowhere-at-all"], + ["a hoisted dependency", "../../node_modules/.pnpm/left-pad@1.3.0/node_modules/left-pad"], + ])("refuses %s out of the build context", async (_label, target) => { + mkdirSync(join(dir, "nested")); + symlinkSync(target, join(dir, "nested", "dep")); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); + expect(JSON.stringify(exit)).toContain("WorkerSourceEscapingLinkError"); + }); + + // An absolute target that lands back inside the tree is a path on this + // machine; stored verbatim it would resolve to nothing on the other end. + test("rewrites an absolute in-tree link target as a relative one", async () => { + writeFileSync(join(dir, "target.txt"), "t"); + mkdirSync(join(dir, "nested")); + symlinkSync(join(dir, "target.txt"), join(dir, "nested", "link.txt")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.find((entry) => entry.path === "nested/link.txt")?.link).toBe("../target.txt"); + }); + test("does not recurse through a directory symlink that points at an ancestor", async () => { mkdirSync(join(dir, "sub")); writeFileSync(join(dir, "keep.txt"), "k"); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 7e01fc5bfb..2cdfc97e35 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,6 +21,30 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** + * A symlink in the worker source points outside the build context. + * + * The archive is everything the server gets — it runs no install step and has + * no view of the surrounding repository — so a link whose target is not also + * packaged arrives dangling. The catalog runtimes then boot without the + * dependency and a Dockerfile build fails on the `COPY`, both of them minutes + * later and with nothing naming the cause. Refused here instead. + * + * The common source is a package manager that hoists: a worker directory that + * is a pnpm workspace member links its dependencies at the repository root + * rather than under its own `node_modules`. + */ +export class WorkerSourceEscapingLinkError extends Data.TaggedError( + "WorkerSourceEscapingLinkError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** A bare `push` found no workers to deploy — none named, none in the project. */ export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployError")<{ readonly detail: string; From 9690274d38849b59b5d4510dff1ae8d81b3f4e05 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:04:32 -0300 Subject: [PATCH 35/50] fix(cli): stop reading an unlistable workers root as an empty one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `legacyDiscoverWorkerNames` mapped every `readDirectory` failure to `[]` and every per-entry `stat` failure to `None`. A bare `push` on a workers root it cannot list therefore reported "no workers were named, and none were found" — or, when config named some, deployed those and exited 0 having silently skipped every directory-only worker. Absence and unreadable again, one level above the source-directory guards. A missing workers root still reads as nothing: a project may never have scaffolded one, and `[workers.]` entries can name workers that live elsewhere. Every other reason propagates. The per-entry stat keeps skipping a name that vanished between the listing and the stat, and nothing else. --- .../workers/push/push.integration.test.ts | 34 +++++++++++++++++++ .../legacy/commands/workers/workers.shared.ts | 27 +++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 52daf33332..38b92c5fa2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -699,6 +699,40 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // A bare `push` promises to deploy every worker in the project, and a worker + // with no config entry is known only by its directory. Reading an unlistable + // workers root as "no workers here" therefore answers a real filesystem + // problem with "nothing to deploy" — the same absence-versus-unreadable + // confusion as the source-directory guards, one level up. + it.live("fails rather than reporting an unlistable workers root as empty", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + const workersRoot = join(repo.dir, "supabase", "workers"); + chmodSync(workersRoot, 0o000); + const listable = listableAsCurrentUser(workersRoot); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push({ names: [] }).pipe(Effect.flip); + + if (listable) { + // Root ignores the permission bits, so the root lists and `api` is found. + expect(error).not.toBeInstanceOf(NoWorkersToDeployError); + } else { + expect(error).not.toBeInstanceOf(NoWorkersToDeployError); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); + expect(http.requests).toHaveLength(0); + } + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + chmodSync(workersRoot, 0o700); + repo.cleanup(); + }), + ), + ); + }); + it.live("fails when there are no workers to deploy at all", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n` }); rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 10ab03cff9..ccc5762b92 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { loadCliConfig } from "@supabase/config/effect"; -import { Effect, FileSystem, Option } from "effect"; +import { Effect, FileSystem, Option, Predicate } from "effect"; import { LegacyCliSettings } from "../../config/legacy-cli-settings.service.ts"; import { readWorkersSection, @@ -147,11 +147,32 @@ export const legacyDiscoverWorkerNames = Effect.fnUntraced(function* ( project: LegacyWorkersProject, ) { const fs = yield* FileSystem.FileSystem; - const entries = yield* fs.readDirectory(project.workersDir).pipe(Effect.orElseSucceed(() => [])); + + // No workers root at all is a project that has never scaffolded one, and the + // config entries below may still name workers living elsewhere — so absence + // reads as nothing here. Any other reason propagates: a root the CLI cannot + // list is not a project with no workers in it, and answering a bare `push` + // with "deployed everything" after silently skipping them is the worst + // possible reading of it. + const entries = yield* fs + .readDirectory(project.workersDir) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed>([]) + : Effect.fail(error), + ), + ); const scaffolded: Array = []; for (const entry of entries) { - const info = yield* fs.stat(join(project.workersDir, entry)).pipe(Effect.option); + // Only a name that vanished between the listing and this stat is skipped. + const info = yield* fs.stat(join(project.workersDir, entry)).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(error), + ), + ); if (Option.isSome(info) && info.value.type === "Directory") { scaffolded.push(entry); } From 8bac2f2772a90c60b3a15f2fdf53e269254ad125 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:06:34 -0300 Subject: [PATCH 36/50] fix(cli): refuse a Dockerfile worker with no Dockerfile A worker recorded as `runtime = "dockerfile"` deploys its uploaded context as-is, so with no top-level `Dockerfile` the server has nothing to build. That only surfaced as a remote build failure, minutes after the archive had uploaded and a deployment had started, when the CLI was already standing in the directory that answers the question. Only reachable from a recorded runtime. A guessed `dockerfile` always passes, because the classifier picks it by finding this exact file. Classified `invalidConfig` rather than the `provideFlags` its neighbours in this file use: `push` has no runtime flag, so the fix is in `config.toml` or the directory, and the suggestion names both. --- .../commands/workers/push/SIDE_EFFECTS.md | 1 + .../commands/workers/push/push.handler.ts | 27 +++++++++++ .../workers/push/push.integration.test.ts | 45 +++++++++++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 18 ++++++++ 4 files changed, 91 insertions(+) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index 52e5ab4366..f76178266a 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -43,6 +43,7 @@ | `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 | +| `1` | a `dockerfile` worker's source holds no `Dockerfile` | | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index cc2678c66c..274acf26e8 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -37,6 +38,7 @@ import { UnknownWorkerRuntimeError, UnknownWorkerSizeError, WorkerBuildFailedError, + WorkerDockerfileMissingError, WorkerSourceMissingError, } from "../../../../shared/workers/workers.errors.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -255,6 +257,31 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { sourceDir: worker.sourceDir, }); + // A `dockerfile` worker ships its own build instructions, and the server has + // nothing to do without them. Checked before the archive is built rather than + // after the remote build fails — the CLI is already standing in the directory + // that either has the file or does not. A guessed `dockerfile` runtime always + // passes, since the classifier chose it by finding this exact file. + if (runtime === "dockerfile") { + const dockerfile = yield* fs.stat(join(worker.sourceDir, "Dockerfile")).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(error), + ), + ); + if (Option.isNone(dockerfile) || dockerfile.value.type !== "File") { + return yield* Effect.fail( + new WorkerDockerfileMissingError({ + detail: `${name} is configured to build its own Dockerfile, but there is no Dockerfile in ${sourceDisplay}.`, + suggestion: `Add a Dockerfile there, or set a catalog runtime under [workers.${name}] in ${displayPath( + project.projectRoot, + project.configPath, + )}.`, + }), + ); + } + } + // Size: whatever `new --size` recorded, else the alpha envelope's own // default. Never left unset, because a worker that is actually running always // has some concrete size — and never silently coerced, because a size the CLI diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 38b92c5fa2..e7b9ad7524 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -16,6 +16,7 @@ import { NoWorkersToDeployError, WorkerBuildFailedError, WorkerBuildTimeoutError, + WorkerDockerfileMissingError, WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, @@ -175,6 +176,50 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // Configured `runtime = "dockerfile"` with nothing to build: the server can + // only report this after the context has uploaded and a build has started, so + // the CLI answers it from the directory it is already looking at. + it.live("refuses a Dockerfile worker with no Dockerfile, before uploading", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDockerfileMissingError); + expect((error as WorkerDockerfileMissingError).suggestion).toContain("config.toml"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The same worker with the file present deploys as a Dockerfile build, which + // is what keeps the guard above from being a blanket refusal. + it.live("deploys a Dockerfile worker that has one", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + "supabase/workers/api/Dockerfile": "FROM scratch\n", + }); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + // No catalog runtime: the uploaded context carries its own Dockerfile. + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBeUndefined(); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("guesses the runtime for a directory with no config entry and says so", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n`, diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 2cdfc97e35..d6be3d60c9 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,6 +21,24 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** + * A worker is configured `runtime = "dockerfile"` but its source holds no + * top-level `Dockerfile`. + * + * Only reachable from a recorded runtime: when the runtime is guessed instead, + * the classifier picked `dockerfile` precisely because it found the file. The + * server has nothing to build without it, so refusing here costs the user a + * message instead of an upload, a deploy and a remote build failure. + */ +export class WorkerDockerfileMissingError extends Data.TaggedError("WorkerDockerfileMissingError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** * A symlink in the worker source points outside the build context. * From 9619d2ec81dcfc91d6f6edbd1607a44376e6103b Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:12:45 -0300 Subject: [PATCH 37/50] Revert "fix(cli): refuse a Dockerfile worker with no Dockerfile" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8bac2f277. The guard bought a clearer message for one misconfiguration and cost a runtime-specific branch in a handler that had none, plus a second copy of the `"Dockerfile"` literal already held by the classifier's marker table. The refactor that would have justified it does not exist: markers are evidence for a guess, not requirements. `deno.json` and `package.json` are both optional — `workers new` scaffolds neither — so there is no shared "required source" contract to hoist the check into, and `dockerfile` would stay the lone special case however it were written. Deploying and letting the build report it is the honest cost of not modelling this yet. --- .../commands/workers/push/SIDE_EFFECTS.md | 1 - .../commands/workers/push/push.handler.ts | 27 ----------- .../workers/push/push.integration.test.ts | 45 ------------------- apps/cli/src/shared/workers/workers.errors.ts | 18 -------- 4 files changed, 91 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index f76178266a..52e5ab4366 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -43,7 +43,6 @@ | `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 | -| `1` | a `dockerfile` worker's source holds no `Dockerfile` | | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 274acf26e8..cc2678c66c 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -1,4 +1,3 @@ -import { join } from "node:path"; import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -38,7 +37,6 @@ import { UnknownWorkerRuntimeError, UnknownWorkerSizeError, WorkerBuildFailedError, - WorkerDockerfileMissingError, WorkerSourceMissingError, } from "../../../../shared/workers/workers.errors.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -257,31 +255,6 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { sourceDir: worker.sourceDir, }); - // A `dockerfile` worker ships its own build instructions, and the server has - // nothing to do without them. Checked before the archive is built rather than - // after the remote build fails — the CLI is already standing in the directory - // that either has the file or does not. A guessed `dockerfile` runtime always - // passes, since the classifier chose it by finding this exact file. - if (runtime === "dockerfile") { - const dockerfile = yield* fs.stat(join(worker.sourceDir, "Dockerfile")).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(error), - ), - ); - if (Option.isNone(dockerfile) || dockerfile.value.type !== "File") { - return yield* Effect.fail( - new WorkerDockerfileMissingError({ - detail: `${name} is configured to build its own Dockerfile, but there is no Dockerfile in ${sourceDisplay}.`, - suggestion: `Add a Dockerfile there, or set a catalog runtime under [workers.${name}] in ${displayPath( - project.projectRoot, - project.configPath, - )}.`, - }), - ); - } - } - // Size: whatever `new --size` recorded, else the alpha envelope's own // default. Never left unset, because a worker that is actually running always // has some concrete size — and never silently coerced, because a size the CLI diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index e7b9ad7524..38b92c5fa2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -16,7 +16,6 @@ import { NoWorkersToDeployError, WorkerBuildFailedError, WorkerBuildTimeoutError, - WorkerDockerfileMissingError, WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, @@ -176,50 +175,6 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Configured `runtime = "dockerfile"` with nothing to build: the server can - // only report this after the context has uploaded and a build has started, so - // the CLI answers it from the directory it is already looking at. - it.live("refuses a Dockerfile worker with no Dockerfile, before uploading", () => { - const repo = project({ - "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, - }); - const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); - - return Effect.gen(function* () { - const error = yield* push().pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkerDockerfileMissingError); - expect((error as WorkerDockerfileMissingError).suggestion).toContain("config.toml"); - expect(http.requests).toHaveLength(0); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); - }); - - // The same worker with the file present deploys as a Dockerfile build, which - // is what keeps the guard above from being a blanket refusal. - it.live("deploys a Dockerfile worker that has one", () => { - const repo = project({ - "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, - "supabase/workers/api/Dockerfile": "FROM scratch\n", - }); - const { layer, http } = setupLegacyWorkers({ - workdir: repo.dir, - routes: routes({ - [`POST ${workersRoute("/api/deploy")}`]: { - status: 202, - body: { data: workerResource({ name: "api", buildState: "active" }) }, - }, - }), - }); - - return Effect.gen(function* () { - yield* push(); - - const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); - // No catalog runtime: the uploaded context carries its own Dockerfile. - expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBeUndefined(); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); - }); - it.live("guesses the runtime for a directory with no config entry and says so", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n`, diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index d6be3d60c9..2cdfc97e35 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,24 +21,6 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } -/** - * A worker is configured `runtime = "dockerfile"` but its source holds no - * top-level `Dockerfile`. - * - * Only reachable from a recorded runtime: when the runtime is guessed instead, - * the classifier picked `dockerfile` precisely because it found the file. The - * server has nothing to build without it, so refusing here costs the user a - * message instead of an upload, a deploy and a remote build failure. - */ -export class WorkerDockerfileMissingError extends Data.TaggedError("WorkerDockerfileMissingError")<{ - readonly detail: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - /** * A symlink in the worker source points outside the build context. * From c99ea48cd44479a1371312bd7dda80e20bcf525a Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:27:46 -0300 Subject: [PATCH 38/50] fix(cli): reject -o env before workers read and delete touch the API The refusal lived in the emitter, which on `delete` runs after the DELETE: `workers delete api --yes -o env` removed the worker and only then exited non-zero with no payload, which a script reads as a failed delete. `legacyRejectWorkersEnvOutput` already exists for this ordering problem and `push` calls it up front; `list`, `status` and `delete` now do the same. --- .../commands/workers/delete/delete.handler.ts | 6 +++++ .../workers/delete/delete.integration.test.ts | 24 +++++++++++++++++++ .../commands/workers/list/list.handler.ts | 7 +++++- .../workers/list/list.integration.test.ts | 5 ++-- .../commands/workers/status/status.handler.ts | 6 ++++- .../workers/status/status.integration.test.ts | 19 +++++++++++++++ 6 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index db4242d7d2..7028b17f70 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -4,6 +4,7 @@ import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, legacyWorkersMachineOutputRequested, } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; @@ -68,6 +69,11 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* const name = yield* legacyValidateWorkerName(flags.name); const worker = yield* legacyDescribeWorkerForReporting(project, name); + // Before the first API call, not at emit time: the emit branch is reached + // *after* the DELETE, so `--yes -o env` deleted the worker and only then + // exited non-zero with no payload — which a script reads as a failed delete. + yield* legacyRejectWorkersEnvOutput(); + const fetching = yield* output.task("Fetching worker..."); const found = yield* getWorker(api, projectRef, name).pipe( Effect.tapError(() => fetching.fail()), diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index f588b47fb1..d93d6c79ed 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -15,6 +15,7 @@ import { WorkerNotDeployedError, WorkersApiUnexpectedStatusError, } from "../../../../shared/workers/workers.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; import { legacyWorkersDelete } from "./delete.handler.ts"; const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; @@ -75,6 +76,29 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The refusal used to live at emit time, which on this command is *after* the + // DELETE: `--yes -o env` removed the worker and then exited non-zero with no + // payload, which a script reads as "the delete failed" and may retry. + it.live("refuses -o env before deleting anything", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + yes: true, + goOutput: "env", + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("deletes nothing when the confirmation does not match", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts index 7eecf5ff15..a3cb1f8410 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; -import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; +import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; @@ -98,6 +98,11 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( yield* Effect.gen(function* () { const project = yield* legacyLoadWorkersProject(); + // Up front, like the rest of the family: this payload always carries a + // `workers` array, so `-o env` can never encode it, and finding that out at + // emit time means failing after the fetch has already been paid for. + yield* legacyRejectWorkersEnvOutput(); + const fetching = yield* output.task("Fetching workers..."); const deployed = yield* listWorkers(api, projectRef).pipe( Effect.tapError(() => fetching.fail()), diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts index b63b09ec81..2c165a41f2 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -200,9 +200,9 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - it.live("refuses -o env, which cannot represent the worker list", () => { + it.live("refuses -o env before making any request at all", () => { const repo = project(); - const { layer } = setupLegacyWorkers({ + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, goOutput: "env", routes: { [listRoute]: { status: 200, body: { data: [] } } }, @@ -212,6 +212,7 @@ describe("legacy workers list", () => { const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + expect(http.routeKeys).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts index acbc6758ed..b7b906e716 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; -import { legacyEmitWorkersMachineOutput } from "../workers.output.ts"; +import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; @@ -47,6 +47,10 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* const name = yield* legacyValidateWorkerName(flags.name); const worker = yield* legacyDescribeWorkerForReporting(project, name); + // Up front, like the rest of the family: discovering an unencodable format + // at emit time means failing after the fetch has already been paid for. + yield* legacyRejectWorkersEnvOutput(); + const fetching = yield* output.task("Fetching worker..."); const found = yield* getWorker(api, projectRef, name).pipe( Effect.tapError(() => fetching.fail()), diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts index 00800886ec..8f3504fa78 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -13,6 +13,7 @@ import { InvalidWorkerNameError, WorkerNotDeployedError, } from "../../../../shared/workers/workers.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; import { legacyWorkersStatus } from "./status.handler.ts"; const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; @@ -351,6 +352,24 @@ describe("legacy workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "env", + routes: { [getRoute]: { status: 200, body: { data: workerResource({ name: "api" }) } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("flushes telemetry when the worker name is invalid", () => { const repo = project(); const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); From 6f904a7a1aa4a59cb7fee3436f20cfc9aab062dc Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:30:04 -0300 Subject: [PATCH 39/50] fix(cli): keep an explicit --project-ref in workers retry suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A suggested retry is copy-pasted verbatim, so one that drops an explicit `--project-ref` re-resolves to whatever the checkout is linked to. On `workers delete` the suggestion carries `--yes`, so the copy-paste deletes a same-named worker in a project the user never named, with no prompt. `legacyWorkersProjectRefSuffix` appends the ref only when the flag supplied it — when the link did, re-stating it is noise. --- .../commands/workers/delete/delete.handler.ts | 10 ++- .../workers/delete/delete.integration.test.ts | 73 +++++++++++++++++++ .../commands/workers/status/status.handler.ts | 11 ++- .../legacy/commands/workers/workers.output.ts | 15 ++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index 7028b17f70..372dccade1 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -6,6 +6,7 @@ import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput, legacyWorkersMachineOutputRequested, + legacyWorkersProjectRefSuffix, } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; @@ -63,6 +64,9 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* // validating the name, resolving the worker — belongs inside, so those // failures still flush telemetry. Same shape as `config/push`. const projectRef = yield* resolver.resolve(flags.projectRef); + // Every retry this command suggests is for a *destructive* re-run, so the ref + // has to survive the copy-paste. + const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef); yield* Effect.gen(function* () { const project = yield* legacyLoadWorkersProject(); @@ -100,7 +104,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* return yield* Effect.fail( new WorkerDeleteConfirmationRequiredError({ detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, - suggestion: `Re-run \`supabase workers delete ${name} --yes\` to confirm without a prompt.`, + suggestion: `Re-run \`supabase workers delete ${name} --yes${refSuffix}\` to confirm without a prompt.`, }), ); } @@ -129,7 +133,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* return yield* Effect.fail( new WorkerDeleteNotConfirmedError({ detail: `The confirmation did not match "${name}", so nothing was deleted.`, - suggestion: `Re-run \`supabase workers delete ${name}\` and type the name exactly, or pass --yes.`, + suggestion: `Re-run \`supabase workers delete ${name}${refSuffix}\` and type the name exactly, or pass --yes.`, }), ); } @@ -183,7 +187,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* // alone is not enough to redeploy from, so `push` would fail on the very // command this line recommends. if (keptSource !== undefined) { - yield* output.raw(`Redeploy it with supabase workers push ${name}.\n`); + yield* output.raw(`Redeploy it with supabase workers push ${name}${refSuffix}.\n`); } } else { yield* output.raw( diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index d93d6c79ed..5ceb7befe1 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -118,6 +118,79 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The suggested retry is copy-pasted verbatim and carries `--yes`, so dropping + // an explicit ref points a no-prompt delete at whatever this checkout is + // linked to — a same-named worker in a project the user never named. + it.live("keeps an explicit --project-ref in the retry it suggests", () => { + const repo = project(); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.some(otherRef), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + const suggestion = + error instanceof WorkerDeleteConfirmationRequiredError ? error.suggestion : ""; + expect(suggestion).toContain(`--project-ref ${otherRef}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("leaves the retry bare when the ref came from the link", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + const suggestion = + error instanceof WorkerDeleteConfirmationRequiredError ? error.suggestion : ""; + expect(suggestion).not.toContain("--project-ref"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps an explicit --project-ref in the confirmation-mismatch retry", () => { + const repo = project(); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["nope"], + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.some(otherRef), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteNotConfirmedError); + const suggestion = error instanceof WorkerDeleteNotConfirmedError ? error.suggestion : ""; + expect(suggestion).toContain(`--project-ref ${otherRef}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("skips the confirmation with --yes", () => { const repo = project(); const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts index b7b906e716..85ad70128c 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -1,7 +1,11 @@ import { Effect, Option } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; -import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput } from "../workers.output.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersProjectRefSuffix, +} from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; @@ -41,6 +45,7 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* // validating the name, resolving the worker — belongs inside, so those // failures still flush telemetry. Same shape as `config/push`. const projectRef = yield* resolver.resolve(flags.projectRef); + const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef); yield* Effect.gen(function* () { const project = yield* legacyLoadWorkersProject(); @@ -61,7 +66,7 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* return yield* Effect.fail( new WorkerNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, - suggestion: `Deploy it with \`supabase workers push ${name}\`.`, + suggestion: `Deploy it with \`supabase workers push ${name}${refSuffix}\`.`, }), ); } @@ -137,7 +142,7 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* yield* output.raw(`Instance counts could not be read: ${record.instancesError}\n`, "stderr"); } if (record.buildState === "failed") { - yield* output.raw(`Fix the issue, then re-run supabase workers push ${name}.\n`); + yield* output.raw(`Fix the issue, then re-run supabase workers push ${name}${refSuffix}.\n`); } }).pipe( Effect.ensuring(linkedProjectCache.cache(projectRef)), diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts index 08962d01f5..a036bb0a13 100644 --- a/apps/cli/src/legacy/commands/workers/workers.output.ts +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -90,3 +90,18 @@ export const legacyRejectWorkersEnvOutput = Effect.fnUntraced(function* () { }); } }); + +/** + * The `--project-ref` a retry suggestion has to carry, or `""` when the ref came + * from the link. + * + * A suggested command is copy-pasted verbatim, so one that drops an explicit + * `--project-ref` re-resolves to whatever *this* checkout is linked to. On + * `delete --yes` that is a same-named worker in a project the user never named, + * removed without a prompt. + * + * Keyed off the flag rather than the resolved ref: when the link supplied it, + * appending it again is noise on a command that already resolves correctly. + */ +export const legacyWorkersProjectRefSuffix = (projectRef: Option.Option): string => + Option.isSome(projectRef) ? ` --project-ref ${projectRef.value}` : ""; From 4e5b97f0c3fede0250c036a0e9af9046264f7d2a Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:32:06 -0300 Subject: [PATCH 40/50] fix(cli): make workers delete --yes idempotent for an absent worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deleteWorker` already treats a DELETE 404 as done — "a delete that races another one is still a delete that happened" — but the pre-flight GET contradicted that, so a teardown script run twice exited non-zero the second time for a worker in exactly the state it asked for. Under `--yes` an already-absent worker is now a success that skips the DELETE and emits the usual payload. Interactive runs keep the error: somebody typed the command and wants to hear the worker was not there. --- .../commands/workers/delete/SIDE_EFFECTS.md | 3 +- .../commands/workers/delete/delete.handler.ts | 42 +++++++++++++----- .../workers/delete/delete.integration.test.ts | 43 +++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md index f58dbfdf7d..bed13f66fa 100644 --- a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -43,8 +43,9 @@ rather than deleting unasked. | Code | Condition | | ---- | ------------------------------------------------------------------------- | | `0` | success (a `404` on DELETE counts — it is already gone) | +| `0` | nothing deployed under that name, with `--yes` (teardown is idempotent) | | `1` | invalid worker name | -| `1` | nothing deployed under that name | +| `1` | nothing deployed under that name, without `--yes` | | `1` | the typed confirmation did not match the worker's name | | `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index 372dccade1..096d9b3b1f 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -41,7 +41,8 @@ import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; * deletion, rather than a bare y/n that is too easy to reflexively confirm. * `--yes`/`SUPABASE_YES` skips it for scripts, resolved through * `legacyResolveYes` like every other confirming command rather than through a - * local flag that would shadow the root one. + * local flag that would shadow the root one. It also makes an already-absent + * worker a success: teardown run twice should not fail the second time. * * Without a terminal to prompt on there is no third option: `interactive` tracks * stdout, so merely redirecting output would otherwise delete unattended. This @@ -84,18 +85,25 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* ); yield* fetching.clear(); - if (Option.isNone(found)) { + const deployed = Option.getOrUndefined(found); + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + + // `--yes` is the scripted path, and `deleteWorker` already treats a DELETE + // 404 as done — "a delete that races another one is still a delete that + // happened". The pre-flight GET contradicted that for teardown: a script run + // twice exited non-zero the second time, for a worker in exactly the state + // it asked for. Interactively the error stays: somebody typed this command + // and wants to hear the worker was not there. + if (deployed === undefined && !yes) { return yield* Effect.fail( new WorkerNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, - suggestion: `Deploy it with \`supabase workers push ${name}\`.`, + suggestion: `Deploy it with \`supabase workers push ${name}${refSuffix}\`.`, }), ); } - const machineOutput = yield* legacyWorkersMachineOutputRequested(); - - if (!yes) { + if (deployed !== undefined && !yes) { // `-o json` leaves `output.format` as `text`, so the format check alone // still let the warning and the prompt run — onto the stdout the user had // asked to carry a payload. A machine format is as non-interactive as a @@ -113,8 +121,8 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* // does not. `spec.instances` is the target, which for a worker still // provisioning differs from what is running — and a destructive prompt is // the wrong place to overstate. - const live = found.value.instances?.live; - const declared = found.value.spec.instances; + const live = deployed.instances?.live; + const declared = deployed.spec.instances; const terminating = live !== undefined ? live > 0 @@ -139,9 +147,14 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* } } - const deleting = yield* output.task("Deleting worker..."); - yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); - yield* deleting.clear(); + // Skipped when the fetch already said there is nothing there: only `--yes` + // reaches this with `deployed` undefined, and a DELETE for a worker we never + // saw is a request with nothing to do. + if (deployed !== undefined) { + const deleting = yield* output.task("Deleting worker..."); + yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.clear(); + } // A worker deployed from another checkout has neither a local entry nor a // local directory, so there is nothing here that was kept. @@ -169,6 +182,13 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* } { + if (deployed === undefined) { + yield* output.raw( + `Nothing was deployed for ${legacyAqua(name, process.stdout)} in project ${projectRef}, so there was nothing to delete.\n`, + ); + return; + } + yield* output.raw( `Deleted Worker ${legacyAqua(name, process.stdout)} from project ${projectRef}\n`, ); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index 5ceb7befe1..d3c713700a 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -273,6 +273,49 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // `deleteWorker` already treats a DELETE 404 as done; the pre-flight GET used + // to contradict that, so a teardown script run twice failed the second time + // for a worker in exactly the state it asked for. + it.live("succeeds under --yes when the worker is already gone", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + // Nothing to delete, so nothing is asked of the API beyond the lookup. + expect(http.routeKeys).toEqual([getRoute]); + expect(out.stdoutText).toContain("nothing to delete"); + expect(out.stdoutText).not.toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the same payload shape for a no-op delete", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + yes: true, + goOutput: "json", + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + const parsed: unknown = JSON.parse(out.stdoutText); + expect(parsed).toMatchObject({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + kept_config_entry: true, + }); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("treats a delete that races another one as done", () => { const repo = project(); const { layer, out } = setupLegacyWorkers({ From 7abf5654810dc005e474a42acee424f96b3a3047 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:32:39 -0300 Subject: [PATCH 41/50] fix(cli): point a failed workers delete at list, not push The `not deployed` error inherited `status`'s suggestion verbatim, so `delete` advised deploying the worker the user was trying to remove. Somebody deleting "api" and hearing "nothing is deployed" wants to see what *is* deployed. --- .../cli/src/legacy/commands/workers/delete/delete.handler.ts | 5 ++++- .../commands/workers/delete/delete.integration.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index 096d9b3b1f..9218bd0497 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -98,7 +98,10 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* return yield* Effect.fail( new WorkerNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, - suggestion: `Deploy it with \`supabase workers push ${name}${refSuffix}\`.`, + // `status`'s wording, inherited, pointed the wrong way here: somebody + // deleting "api" and hearing "nothing is deployed" does not want to + // deploy it — they want to see what *is* deployed. + suggestion: `See what is deployed with \`supabase workers list${refSuffix}\`.`, }), ); } diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index d3c713700a..5fd16dedd5 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -269,6 +269,10 @@ describe("legacy workers delete", () => { }).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerNotDeployedError); + // Not `workers push`: somebody deleting "api" does not want to deploy it. + const suggestion = error instanceof WorkerNotDeployedError ? error.suggestion : ""; + expect(suggestion).toContain("supabase workers list"); + expect(suggestion).not.toContain("workers push"); expect(out.messages.filter((message) => message.type === "warn")).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); From 23a222ff99d7b07dc1789194b5d1752285c26169 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:34:11 -0300 Subject: [PATCH 42/50] refactor(cli): pick workers -o payload formats from an allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emitter's last branch is TOML, so the denylist made every `-o` value it had not heard of serialise as TOML — the next format the global flag learns would silently emit TOML from every workers command until somebody remembered to exclude it. An allowlist keeps today's behaviour identical and makes an unrecognised value fall through to text rendering, which is the direction the `table`/`csv` fix already chose. `env` stays in the set so it reaches the refusal. --- .../workers/list/list.integration.test.ts | 44 ++++++++++--------- .../legacy/commands/workers/workers.output.ts | 22 ++++++---- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts index 2c165a41f2..5390292191 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -328,29 +328,33 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `table` and `csv` are accepted by the global flag for `db query`'s benefit; - // every resource command is meant to ignore them and render text. They used to - // fall through to the TOML encoder. - it.live.each(["table", "csv"] as const)("renders text rather than TOML for -o %s", (goOutput) => { - const repo = project(); - const { layer, out } = setupLegacyWorkers({ - workdir: repo.dir, - goOutput, - routes: { - [listRoute]: { - status: 200, - body: { data: [workerResource({ name: "api", runtime: "node" })] }, + // `pretty` is the human default; `table` and `csv` are accepted by the global + // flag for `db query`'s benefit, and every resource command is meant to ignore + // them and render text. All three used to fall through to the TOML encoder, + // which is the trap the payload allowlist closes. + it.live.each(["pretty", "table", "csv"] as const)( + "renders text rather than TOML for -o %s", + (goOutput) => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, }, - }, - }); + }); - return Effect.gen(function* () { - yield* legacyWorkersList({ projectRef: Option.none() }); + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("NAME"); - expect(out.stdoutText).not.toContain("project_ref = "); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); - }); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).not.toContain("project_ref = "); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }, + ); it.live("flushes telemetry when the project config cannot be loaded", () => { const repo = project("project_id = [unclosed\n"); diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts index a036bb0a13..392fce7069 100644 --- a/apps/cli/src/legacy/commands/workers/workers.output.ts +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -22,16 +22,22 @@ import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; /** * Which `-o` values these commands answer with a payload. * - * `pretty` is the human default. `table` and `csv` are accepted by the global - * flag because `db query` reads them, and every resource command is meant to - * ignore them and fall through to its own text rendering — so treating them as - * machine output emitted TOML for `-o table`, and would now suppress the text - * rendering without putting anything in its place. + * An allowlist, because the emitter's last branch is TOML: a denylist made every + * value it had not heard of serialise as TOML, so the next format the global + * flag learns would silently emit TOML from every workers command until somebody + * remembered to exclude it. `pretty` is the human default, and `table`/`csv` are + * accepted by the global flag only because `db query` reads them — every + * resource command falls through to its own text rendering for those, which is + * what an unrecognised value should do too. + * + * `env` is in the set so it reaches the refusal below rather than falling + * through to text: it is a format these commands *recognise* and cannot encode, + * which is a different answer from one they have never heard of. */ +const PAYLOAD_FORMATS = new Set(["json", "yaml", "toml", "env"]); + function emitsPayloadFor(goFormat: string | undefined): boolean { - return ( - goFormat !== undefined && goFormat !== "pretty" && goFormat !== "table" && goFormat !== "csv" - ); + return goFormat !== undefined && PAYLOAD_FORMATS.has(goFormat); } export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( From 8cc320411aab45ed2232840d6347c6b04242ee09 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:49:13 -0300 Subject: [PATCH 43/50] test(cli): pin sparse workers payloads encoding to TOML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review finding claimed `-o toml` throws on the optional fields these payloads leave undefined. It does not — smol-toml, the YAML encoder and the JSON encoder all omit an undefined-valued key, nested ones included. These two tests record that, so the claim does not have to be re-derived. --- .../workers/list/list.integration.test.ts | 26 +++++++++++++++++++ .../workers/status/status.integration.test.ts | 24 +++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts index 5390292191..c3a2c2c54d 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -328,6 +328,32 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // An undeployed worker has no `size`/`instances` and a private one no `url`, + // so a realistic inventory hands the encoder a payload full of holes. Pins + // that they are omitted rather than rendered or thrown on. + it.live("encodes TOML for an inventory holding undeployed and private workers", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: { + [listRoute]: { + status: 200, + body: { + data: [workerResource({ name: "api", runtime: "node", exposure: "private" })], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("project_ref = "); + expect(out.stdoutText).not.toContain("undefined"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // `pretty` is the human default; `table` and `csv` are accepted by the global // flag for `db query`'s benefit, and every resource command is meant to ignore // them and render text. All three used to fall through to the TOML encoder, diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts index 8f3504fa78..1d0513a6b0 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -352,6 +352,30 @@ describe("legacy workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // `state_reason`, `image_version`, `deleting` and `instances_error` are all + // optional, so a healthy worker's payload is mostly holes. Pins that they are + // omitted rather than rendered. + it.live("encodes TOML for a worker whose optional fields are absent", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", exposure: "private" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("worker_name = "); + expect(out.stdoutText).not.toContain("undefined"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("refuses -o env before making any request at all", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ From 0cdfa42456473e748a374c512c24dbd5607717d5 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:50:39 -0300 Subject: [PATCH 44/50] fix(cli): require a terminal stdin before prompting to delete a worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `output.interactive` only tracks stdout, so with a TTY stdout and a piped stdin `printf 'api\n' | supabase workers delete api` fed the pipe straight into the confirmation prompt and deleted without `--yes` — a confirmation the user never typed. The prompt now also requires `tty.stdinIsTty`, the same pair `projects delete` guards its own prompt with. The workers test helper gained a `stdinIsTty` option, defaulting to `interactive` so existing prompt scenarios are unchanged. --- .../commands/workers/delete/delete.handler.ts | 10 +++++++- .../workers/delete/delete.integration.test.ts | 23 +++++++++++++++++++ apps/cli/tests/helpers/legacy-workers.ts | 12 ++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index 9218bd0497..999b096794 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -18,6 +18,7 @@ import { } from "../../../../shared/workers/workers.errors.ts"; import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -56,6 +57,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; + const tty = yield* Tty; // `--yes` OR `SUPABASE_YES`, matching `projects delete` and every other // command that guards a destructive step behind a prompt. const yes = yield* legacyResolveYes; @@ -111,7 +113,13 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* // still let the warning and the prompt run — onto the stdout the user had // asked to carry a payload. A machine format is as non-interactive as a // redirected stdout, whichever flag asked for it. - if (output.format !== "text" || machineOutput || !output.interactive) { + // + // `output.interactive` only tracks *stdout*, so on its own it still let + // `printf 'api\n' | supabase workers delete api` feed the pipe straight + // into the prompt and delete without `--yes`. The confirmation is only + // meaningful from a keyboard, so stdin has to be a terminal too — the same + // pair `projects delete` guards its prompt with. + if (output.format !== "text" || machineOutput || !output.interactive || !tty.stdinIsTty) { return yield* Effect.fail( new WorkerDeleteConfirmationRequiredError({ detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index 5fd16dedd5..56cc189be4 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -203,6 +203,29 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // `printf 'api\n' | supabase workers delete api`: stdout is still a TTY, so + // `output.interactive` stayed true and the prompt read the worker name off the + // pipe — a confirmation the user never typed. + it.live("refuses to read the confirmation off a piped stdin", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + stdinIsTty: false, + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).not.toContain(deleteRoute); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("refuses to delete unattended rather than skipping the confirmation", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 58b3513c02..4b256041bd 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -17,7 +17,7 @@ import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; -import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "./mocks.ts"; /** * Shared scaffolding for the `supabase workers` command integration tests. @@ -254,6 +254,12 @@ export interface WorkersSetupOptions { readonly cwd?: string; readonly format?: "text" | "json" | "stream-json"; readonly interactive?: boolean; + /** + * Whether stdin is a terminal. Defaults to `interactive`, so a text-mode test + * can prompt; set it false to model a piped stdin with a TTY stdout, which is + * what `printf 'api\n' | supabase workers delete api` looks like. + */ + readonly stdinIsTty?: boolean; readonly linked?: boolean; readonly promptTextResponses?: ReadonlyArray; readonly promptSelectResponses?: ReadonlyArray; @@ -295,9 +301,10 @@ function mockWorkersTelemetryState() { } export function setupLegacyWorkers(options: WorkersSetupOptions) { + const interactive = options.interactive ?? (options.format ?? "text") === "text"; const out = mockOutput({ format: options.format ?? "text", - interactive: options.interactive ?? (options.format ?? "text") === "text", + interactive, ...(options.promptTextResponses === undefined ? {} : { promptTextResponses: options.promptTextResponses }), @@ -316,6 +323,7 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), + mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }), legacyTestCliConfigLayer(options.workdir), legacyTestProjectRefLayer(options.linked !== false), telemetry.layer, From 1579a2092755ebfb5b1f9dc1fe35443d5c4ecc00 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:51:31 -0300 Subject: [PATCH 45/50] fix(cli): report the workers status tally from a single snapshot Two corrections to the details block: - The readiness fraction read its numerator from the instance snapshot and its denominator from `spec.instances`. Mid-scale those disagree, rendering impossible fractions like `3/1 ready`; both now come from the tally. - A worker whose last build failed was told to fix it and push again even while `deleting` was true. Deletion is asynchronous, so that push races the tombstone or resurrects the worker being removed. --- .../commands/workers/status/status.handler.ts | 9 ++- .../workers/status/status.integration.test.ts | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts index 85ad70128c..05c25d80d9 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -124,9 +124,12 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* ["Image", record.imageVersion ?? ""], ["Access", record.spec.exposure], [ + // Every number in the tally line comes from the tally: mixing + // `instances.ready` with `spec.instances` compares a snapshot against + // the desired count, which mid-scale renders fractions like `3/1 ready`. "Instances", record.instances !== undefined - ? `${record.instances.ready}/${record.spec.instances} ready, ${record.instances.live} live, ${record.instances.stale} stale` + ? `${record.instances.ready}/${record.instances.declared} ready, ${record.instances.live} live, ${record.instances.stale} stale` : `${record.spec.instances} declared`, ], ["URL", url ?? ""], @@ -141,7 +144,9 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* if (record.instances === undefined && record.instancesError !== undefined) { yield* output.raw(`Instance counts could not be read: ${record.instancesError}\n`, "stderr"); } - if (record.buildState === "failed") { + // Not while it is being torn down: deletion is asynchronous, so a push here + // races the tombstone or resurrects the very worker the user is removing. + if (record.buildState === "failed" && record.deleting !== true) { yield* output.raw(`Fix the issue, then re-run supabase workers push ${name}${refSuffix}.\n`); } }).pipe( diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts index 1d0513a6b0..ec7b79dc0c 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -139,6 +139,66 @@ describe("legacy workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // Mid-scale the snapshot and the desired spec disagree; reading the numerator + // from one and the denominator from the other rendered fractions like + // `3/1 ready`. + it.live("reads the whole tally from one snapshot while scaling", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + instances: 1, + instanceCounts: { declared: 3, live: 3, ready: 3, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("3/3 ready"); + expect(out.stdoutText).not.toContain("3/1 ready"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Deletion is asynchronous, so pushing here races the tombstone or resurrects + // the worker the user is removing. + it.live("withholds the build retry while the worker is being deleted", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "failed", + stateReason: "exit status 1", + deleting: true, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + expect(out.stdoutText).not.toContain("re-run supabase workers push"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("points a failed build at the retry, with the reason", () => { const repo = project(); const { layer, out } = setupLegacyWorkers({ From b80e41cd88e2de8ef7e01b28379ece5f2bd6f6a9 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:53:44 -0300 Subject: [PATCH 46/50] fix(cli): stop a broken local config blocking workers status and delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands act on the remote worker and consult the project only for the optional source detail, but the load was a hard prerequisite — so an unrelated parse error in `supabase/config.toml` stranded a deployed worker even when `--project-ref` named the project explicitly and nothing local was going to be touched. `legacyLoadWorkersProjectForReporting` degrades an unloadable config to a project with no `[workers.*]` entries, the same shape `legacyDescribeWorkerForReporting` already uses for an unusable source path. `list` and `push` keep the strict load: their output *is* the local inventory. Also drops an `as WorkerNotDeployedError` cast in the status suite for the `instanceof` narrowing the workspace rules ask for. --- .../commands/workers/delete/delete.handler.ts | 4 +-- .../workers/delete/delete.integration.test.ts | 24 ++++++++++++++ .../commands/workers/status/status.handler.ts | 4 +-- .../workers/status/status.integration.test.ts | 27 +++++++++++++++- .../legacy/commands/workers/workers.shared.ts | 32 +++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index 999b096794..b0a390eb89 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -23,7 +23,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyDescribeWorkerForReporting, - legacyLoadWorkersProject, + legacyLoadWorkersProjectForReporting, legacyValidateWorkerName, } from "../workers.shared.ts"; import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; @@ -72,7 +72,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef); yield* Effect.gen(function* () { - const project = yield* legacyLoadWorkersProject(); + const project = yield* legacyLoadWorkersProjectForReporting(); const name = yield* legacyValidateWorkerName(flags.name); const worker = yield* legacyDescribeWorkerForReporting(project, name); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index 56cc189be4..3458cd2a64 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -79,6 +79,30 @@ describe("legacy workers delete", () => { // The refusal used to live at emit time, which on this command is *after* the // DELETE: `--yes -o env` removed the worker and then exited non-zero with no // payload, which a script reads as "the delete failed" and may retry. + // Deletion never touches local files, so a malformed local config has no + // business standing between the user and a worker they named explicitly. + it.live("deletes a remote worker despite an unparseable local config", () => { + const repo = project("project_id = [unclosed\n"); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + [`DELETE /v2/projects/${otherRef}/workers/api`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.some(otherRef) }); + + expect(http.routeKeys).toContain(`DELETE /v2/projects/${otherRef}/workers/api`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("refuses -o env before deleting anything", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts index 05c25d80d9..4e33dfa55b 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -18,7 +18,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyDescribeWorkerForReporting, - legacyLoadWorkersProject, + legacyLoadWorkersProjectForReporting, legacyValidateWorkerName, } from "../workers.shared.ts"; import type { LegacyWorkersStatusFlags } from "./status.command.ts"; @@ -48,7 +48,7 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef); yield* Effect.gen(function* () { - const project = yield* legacyLoadWorkersProject(); + const project = yield* legacyLoadWorkersProjectForReporting(); const name = yield* legacyValidateWorkerName(flags.name); const worker = yield* legacyDescribeWorkerForReporting(project, name); diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts index ec7b79dc0c..b2f118f95b 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -260,7 +260,8 @@ describe("legacy workers status", () => { }).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerNotDeployedError); - expect((error as WorkerNotDeployedError).suggestion).toContain("supabase workers push api"); + const suggestion = error instanceof WorkerNotDeployedError ? error.suggestion : ""; + expect(suggestion).toContain("supabase workers push api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -436,6 +437,30 @@ describe("legacy workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The project is consulted only for the optional Source row, so an unrelated + // local parse error should not stand between the user and a remote worker + // they named explicitly. + it.live("inspects a remote worker despite an unparseable local config", () => { + const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.some(otherRef) }); + + expect(http.routeKeys).toEqual([`GET /v2/projects/${otherRef}/workers/api`]); + expect(out.stdoutText).toContain("active"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("refuses -o env before making any request at all", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index d1430c2b35..da01656433 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -88,6 +88,38 @@ export const legacyLoadWorkersProject = () => loadWorkersProject({ tomlOnly: fal */ export const legacyLoadWorkersProjectForEntryWrite = () => loadWorkersProject({ tomlOnly: true }); +/** + * As {@link legacyLoadWorkersProject}, but never failing on the project config. + * + * For commands that only *report* on local state — `status` and `delete` — + * which act on the remote worker and consult the project purely to add the + * optional source detail. Making it a prerequisite stranded a deployed worker + * behind an unrelated local parse error, even when `--project-ref` named the + * project explicitly and nothing local was going to be touched. + * + * A config that will not load reads the same as a project with no + * `[workers.*]` entries: no entry, no configured source, so no source row. + * Same degrade-rather-than-fail shape as + * {@link legacyDescribeWorkerForReporting}, which does it for the source path. + */ +export const legacyLoadWorkersProjectForReporting = Effect.fnUntraced(function* () { + const loaded = yield* legacyLoadWorkersProject().pipe(Effect.option); + if (Option.isSome(loaded)) { + return loaded.value; + } + + const settings = yield* LegacyCliSettings; + const projectRoot = settings.workdir; + const supabaseDir = join(projectRoot, "supabase"); + return { + projectRoot, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + section: readWorkersSection(undefined), + workersDir: workersDir(projectRoot), + } satisfies LegacyWorkersProject; +}); + export interface LegacyResolvedWorker { readonly name: string; readonly entry: WorkerEntry | undefined; From 2f05f94ded6d4f393b8e5355add93c4a5ab6599f Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:56:31 -0300 Subject: [PATCH 47/50] fix(cli): only report local worker state the project establishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places stated something about local files that was not true: - `status` printed a Source row whenever a `[workers.]` entry existed, including when the configured `source` could not be resolved and the default directory had stood in for it — naming a path the entry does not. `LegacyResolvedWorker` gained `sourceResolved` to tell the stand-in apart. - `list` told every unconfigured deployed worker that a push "would have to guess the runtime". For one with nothing local at all that is the wrong prerequisite: `deployOneWorker` checks the source directory first and fails with `WorkerSourceMissingError`. Those are now split, and the remote-only case is told to scaffold or restore the source. --- .../commands/workers/list/list.handler.ts | 30 ++++++++++++---- .../workers/list/list.integration.test.ts | 34 ++++++++++++++++++- .../commands/workers/status/status.handler.ts | 6 +++- .../workers/status/status.integration.test.ts | 25 ++++++++++++++ .../legacy/commands/workers/workers.shared.ts | 14 +++++++- 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts index a3cb1f8410..d6e5c36cca 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -170,19 +170,35 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); - // Deployed *and* unconfigured: a bare local directory is also unconfigured, - // and has not been deployed at all. - const orphans = rows - .filter((row) => row.deployed !== undefined && !row.configured) + // Two different problems, and they need different advice. A worker with a + // local directory but no entry can be pushed — the runtime is the only + // unknown. One with nothing local at all cannot: `deployOneWorker` checks + // the source directory *before* inferring a runtime and fails with + // `WorkerSourceMissingError`, so telling that user about runtime guessing + // points them at the wrong prerequisite. + const unconfigured = rows + .filter((row) => row.deployed !== undefined && !row.configured && row.local) .map((row) => row.name); - if (orphans.length > 0) { + if (unconfigured.length > 0) { yield* output.raw( - `${orphans.join(", ")} ${ - orphans.length === 1 ? "is" : "are" + `${unconfigured.join(", ")} ${ + unconfigured.length === 1 ? "is" : "are" } deployed but absent from supabase/config.toml: pushing from here would have to guess the runtime.\n`, "stderr", ); } + + const remoteOnly = rows + .filter((row) => row.deployed !== undefined && !row.local) + .map((row) => row.name); + if (remoteOnly.length > 0) { + yield* output.raw( + `${remoteOnly.join(", ")} ${ + remoteOnly.length === 1 ? "is" : "are" + } deployed but ${remoteOnly.length === 1 ? "has" : "have"} no source in this project: scaffold or restore it before pushing from here.\n`, + "stderr", + ); + } }).pipe( Effect.ensuring(linkedProjectCache.cache(projectRef)), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts index c3a2c2c54d..c9279f5cbc 100644 --- a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -93,8 +93,17 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // A local directory with no `[workers.]` entry: pushable, and the + // runtime is the only thing a push would have to work out for itself. it.live("calls out a deployed worker that config.toml does not know about", () => { - const repo = project(`project_id = "demo"\n`); + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/stray/index.js": "export default {};\n", + }); + const repo = { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: { @@ -113,6 +122,29 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // Nothing local at all: `deployOneWorker` checks the source directory before + // it ever infers a runtime, so "would have to guess the runtime" named the + // wrong prerequisite for this one. + it.live("tells a worker with no local source to restore it, not to expect a guess", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "stray", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("no source in this project"); + expect(out.stderrText).not.toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("says so when the project has no workers at all", () => { const repo = project(`project_id = "demo"\n`); const { layer, out } = setupLegacyWorkers({ diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts index 4e33dfa55b..b29606b7af 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -79,8 +79,12 @@ export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* // Reported only when an entry or the directory establishes it. With neither, // the path is an inference about a worker that may have been deployed from // another checkout. + // + // `sourceResolved` matters for the entry half: when the configured `source` + // could not be resolved, `sourceDir` is the *default* directory standing in + // for it, and printing that would name a path the entry does not. const sourceDisplay = - worker.entry !== undefined || worker.sourceExists + (worker.entry !== undefined && worker.sourceResolved) || worker.sourceExists ? displayPath(project.projectRoot, worker.sourceDir) : undefined; diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts index b2f118f95b..2f5f6392e5 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -302,6 +302,31 @@ describe("legacy workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // A `source` that escapes the project cannot be resolved, so the describe + // falls back to the default directory. Printing that named a path the entry + // does not, presenting a guess as established local state. + it.live("omits the source when the configured one cannot be resolved", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "../../elsewhere"\n`, + }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("active"); + expect(out.stdoutText).not.toContain("Source"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("emits the same facts as structured data in json mode", () => { const repo = project(); const { layer, out } = setupLegacyWorkers({ diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index da01656433..3b84e14404 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -136,6 +136,14 @@ export interface LegacyResolvedWorker { * paths need that difference before they state one as fact. */ readonly sourceExists: boolean; + /** + * Whether {@link sourceDir} is the path the project actually names. + * + * False only when resolution failed and the default directory stood in for a + * `source` the entry does name — reporting that fallback as the worker's + * source states a path the project never mentioned. + */ + readonly sourceResolved: boolean; } /** @@ -165,13 +173,16 @@ export const legacyDescribeWorkerForReporting = Effect.fnUntraced(function* ( return described.value; } // The path is unusable, which for reporting purposes reads the same as having - // nothing local at all. + // nothing local at all. `sourceResolved: false` keeps callers from printing + // this stand-in as the source the entry names — it is the default directory, + // not the path that failed. return { name, entry: project.section.workers[name], defaultDir: workerDir(project.projectRoot, name), sourceDir: workerDir(project.projectRoot, name), sourceExists: false, + sourceResolved: false, } satisfies LegacyResolvedWorker; }); @@ -196,6 +207,7 @@ export const legacyDescribeWorker = Effect.fnUntraced(function* ( defaultDir, sourceDir, sourceExists: Option.isSome(info) && info.value.type === "Directory", + sourceResolved: true, } satisfies LegacyResolvedWorker; }); From 986b0d14ea0f918bc721e29276c79e9beb3abbbb Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 20:58:18 -0300 Subject: [PATCH 48/50] fix(cli): do not require read scope to delete a worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API grants the two endpoints separately — `edge_functions:read` for the `GET`, `edge_functions:write` for the `DELETE` — so a credential holding only write got a 403 on the pre-flight lookup and never reached the delete it was entitled to perform. That lookup is a courtesy: it supplies the instance tally the confirmation quotes and the already-gone verdict. A refused read now leaves the worker unknown rather than absent — the prompt still asks for the name but quotes no count, and the DELETE goes ahead. Only a real 404 still means there was nothing to delete. Also records `instances.live` in the delete side-effects response column, which the confirmation has always preferred over `spec.instances`. --- .../commands/workers/delete/SIDE_EFFECTS.md | 8 +-- .../commands/workers/delete/delete.handler.ts | 38 ++++++++---- .../workers/delete/delete.integration.test.ts | 62 +++++++++++++++++++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md index bed13f66fa..4f81bac00c 100644 --- a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -33,10 +33,10 @@ rather than deleting unasked. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------------- | ------------ | ------------ | --------------------------------------- | -| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec.instances` (for the confirmation) | -| `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the worker is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`edge_functions:read` vs `edge_functions:write`) | +| `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts index b0a390eb89..f742e81467 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -15,6 +15,7 @@ import { WorkerDeleteConfirmationRequiredError, WorkerDeleteNotConfirmedError, WorkerNotDeployedError, + WorkersApiUnexpectedStatusError, } from "../../../../shared/workers/workers.errors.ts"; import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -82,12 +83,23 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* yield* legacyRejectWorkersEnvOutput(); const fetching = yield* output.task("Fetching worker..."); - const found = yield* getWorker(api, projectRef, name).pipe( + // The lookup is a courtesy, not a prerequisite: it supplies the instance + // tally the confirmation quotes and the "already gone" verdict. The API + // grants the read and the delete separately — `edge_functions:read` for + // `GET`, `edge_functions:write` for `DELETE` — so a credential holding only + // the latter could not delete a worker it is entitled to delete. A refused + // read now leaves the worker *unknown* and the delete goes ahead. + const lookup = yield* getWorker(api, projectRef, name).pipe( + Effect.map((found) => ({ readable: true, worker: Option.getOrUndefined(found) })), + Effect.catchIf( + (error) => error instanceof WorkersApiUnexpectedStatusError && error.status === 403, + () => Effect.succeed({ readable: false, worker: undefined }), + ), Effect.tapError(() => fetching.fail()), ); yield* fetching.clear(); - const deployed = Option.getOrUndefined(found); + const deployed = lookup.worker; const machineOutput = yield* legacyWorkersMachineOutputRequested(); // `--yes` is the scripted path, and `deleteWorker` already treats a DELETE @@ -96,7 +108,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* // twice exited non-zero the second time, for a worker in exactly the state // it asked for. Interactively the error stays: somebody typed this command // and wants to hear the worker was not there. - if (deployed === undefined && !yes) { + if (lookup.readable && deployed === undefined && !yes) { return yield* Effect.fail( new WorkerNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, @@ -108,7 +120,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* ); } - if (deployed !== undefined && !yes) { + if (!yes) { // `-o json` leaves `output.format` as `text`, so the format check alone // still let the warning and the prompt run — onto the stdout the user had // asked to carry a payload. A machine format is as non-interactive as a @@ -132,14 +144,16 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* // does not. `spec.instances` is the target, which for a worker still // provisioning differs from what is running — and a destructive prompt is // the wrong place to overstate. - const live = deployed.instances?.live; - const declared = deployed.spec.instances; + // Absent when the read was refused: the prompt still asks for the name, + // it just cannot quote a count it was not allowed to see. + const live = deployed?.instances?.live; + const declared = deployed?.spec.instances; const terminating = live !== undefined ? live > 0 ? ` ${live} running instance${live === 1 ? "" : "s"} will be terminated.` : "" - : declared > 0 + : declared !== undefined && declared > 0 ? ` ${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` : ""; yield* output.raw( @@ -158,10 +172,10 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* } } - // Skipped when the fetch already said there is nothing there: only `--yes` - // reaches this with `deployed` undefined, and a DELETE for a worker we never - // saw is a request with nothing to do. - if (deployed !== undefined) { + // Skipped only when the fetch actually said there is nothing there. An + // unreadable worker still gets the DELETE — that request is the one the + // credential is entitled to make, and the API treats a 404 on it as done. + if (deployed !== undefined || !lookup.readable) { const deleting = yield* output.task("Deleting worker..."); yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); yield* deleting.clear(); @@ -193,7 +207,7 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* } { - if (deployed === undefined) { + if (deployed === undefined && lookup.readable) { yield* output.raw( `Nothing was deployed for ${legacyAqua(name, process.stdout)} in project ${projectRef}, so there was nothing to delete.\n`, ); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts index 3458cd2a64..39ac309be9 100644 --- a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -103,6 +103,68 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The API grants `edge_functions:read` for the GET and `edge_functions:write` + // for the DELETE separately, so a credential holding only write could not + // delete a worker it is entitled to delete. + it.live("deletes with --yes when the credential may not read the worker", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [getRoute]: { status: 403, body: { message: "insufficient scope" } }, + [deleteRoute]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("still confirms interactively when the worker cannot be read", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["api"], + routes: { + [getRoute]: { status: 403, body: { message: "insufficient scope" } }, + [deleteRoute]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("permanently deletes"); + // No count is quoted: the read that would have supplied one was refused. + expect(out.stdoutText).not.toContain("will be terminated"); + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A refusal is not an absence: only a real 404 means there was nothing there. + it.live("reports an unreadable worker as deleted, not as nothing to delete", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [getRoute]: { status: 403, body: { message: "insufficient scope" } }, + [deleteRoute]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + expect(out.stdoutText).not.toContain("nothing to delete"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("refuses -o env before deleting anything", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ From eb9445702883f2cb95ad4c584b62250cd87c25df Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 21:03:48 -0300 Subject: [PATCH 49/50] docs(cli): complete the workers read and delete side-effect checklists The checklists drive E2E coverage, so the gaps understated what the commands actually touch: - `config.json` is preferred over `config.toml` by the loader; only `push` recorded that. - The workers root is enumerated and each child stat'd by `list`; the source path is canonicalised and stat'd by `status` and `delete`. - Project-ref resolution reads `SUPABASE_PROJECT_ID` and `supabase/.temp/project-ref`, and can call `GET /v1/projects` for the interactive picker. - The credential file is read when the env var is unset and the keyring is empty. - None of the three documented their stdout/stderr contract; each now has the template's Output Formats section. --- .../commands/workers/delete/SIDE_EFFECTS.md | 41 ++++++++++++----- .../commands/workers/list/SIDE_EFFECTS.md | 45 +++++++++++++------ .../commands/workers/status/SIDE_EFFECTS.md | 45 +++++++++++++------ 3 files changed, 91 insertions(+), 40 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md index 4f81bac00c..c0d579c2da 100644 --- a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -7,11 +7,15 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to report the source directory it kept | -| `/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 | when present — preferred over `config.toml`; the source directory it kept. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command | +| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms | +| `/` | directory | canonicalised and stat'd, to decide whether the kept-source line is stated at all | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/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 @@ -37,6 +41,7 @@ rather than deleting unasked. | -------- | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the worker is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`edge_functions:read` vs `edge_functions:write`) | | `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | ## Exit Codes @@ -52,13 +57,14 @@ rather than deleting unasked. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | -| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | ## Telemetry Events Fired @@ -68,3 +74,14 @@ rather than deleting unasked. No custom events — only the `cli_command_executed` that the instrumentation wrapper emits for every command. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------- | +| text (default) | the confirmation prompt, then what was deleted and kept | that nothing local was kept, when nothing was | +| `--output-format json` | one structured result carrying `worker_name`, `project_ref`, `kept_*` | as above | +| `--output-format stream-json` | the same result as a single terminal event | as above | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above | +| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the worker and then failed | the error | diff --git a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md index 5538816773..cd294029e5 100644 --- a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md @@ -7,11 +7,15 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, for the `[workers.*]` entries | -| `/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`; the `[workers.*]` entries | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries | +| `/` | directory | always — enumerated and each child stat'd, so a bare directory still appears in the inventory; `supabase/workers/` unless `[workers] root` names another | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/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 @@ -22,9 +26,10 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | ------------ | ---------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/workers` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | ## Exit Codes @@ -35,12 +40,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired @@ -50,3 +56,14 @@ No custom events — only the `cli_command_executed` that the instrumentation wrapper emits for every command. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| text (default) | the inventory table | notes about deployed workers with no entry or source | +| `--output-format json` | one structured result carrying `project_ref`, `workers` | as above | +| `--output-format stream-json` | the same result as a single terminal event | as above | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above | +| `-o env` | refused before any request; the payload carries a `workers` array a flat `KEY=value` list cannot express | the error | diff --git a/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md index cff927b867..84ad0e7c46 100644 --- a/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md @@ -7,11 +7,15 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to report the worker's source directory | -| `/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 | when present — preferred over `config.toml`; the worker's source directory. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command | +| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms | +| `/` | directory | canonicalised and stat'd, to decide whether the source row is stated at all | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/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 @@ -22,9 +26,10 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | ## Exit Codes @@ -37,12 +42,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired @@ -52,3 +58,14 @@ No custom events — only the `cli_command_executed` that the instrumentation wrapper emits for every command. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------- | +| text (default) | the details block, plus the build-retry line on a failure | an unreadable instance tally | +| `--output-format json` | one structured result carrying every reported field | as above | +| `--output-format stream-json` | the same result as a single terminal event | as above | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above | +| `-o env` | refused before any request; the payload nests an instance tally a flat `KEY=value` list cannot express | the error | From 5ce85986988d914fdb2feadb2306dfed005bd3e1 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 21:29:58 -0300 Subject: [PATCH 50/50] docs(cli): record the real workers root, which is not configurable The list checklist claimed `[workers] root` could redirect the enumerated directory. It cannot: `WorkersSection` carries only `workers`, `readWorkersSection` reads every table under `[workers]` as a worker name, and `workersDir` is hard-coded to `/supabase/workers`. Recording a configurable root would have pointed E2E coverage at behaviour the command does not have. The same false premise sat in a `status` test comment, which claimed `root` is unusable as a worker name locally because `[workers] root` occupies the key. There is no such key and no reserved name. --- .../commands/workers/list/SIDE_EFFECTS.md | 18 +++++++++--------- .../workers/status/status.integration.test.ts | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md index cd294029e5..d019f17f1e 100644 --- a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md @@ -7,15 +7,15 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; the `[workers.*]` entries | -| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries | -| `/` | directory | always — enumerated and each child stat'd, so a bare directory still appears in the inventory; `supabase/workers/` unless `[workers] root` names another | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | -| `/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`; the `[workers.*]` entries | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries | +| `/supabase/workers/` | directory | always — enumerated and each child stat'd, so a directory with no `[workers.]` entry still appears in the inventory. Absent reads as no workers; any other read failure fails the command | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/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 diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts index 2f5f6392e5..ab83eab319 100644 --- a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -415,9 +415,9 @@ describe("legacy workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `root` is only unusable *locally*, because `[workers] root` occupies the key. - // The API accepts it as a DNS label, and `status` writes no config, so it has - // no business refusing a worker `workers list` will happily show. + // `root` is an ordinary worker name: a valid DNS label, and `[workers]` has no + // reserved keys — `readWorkersSection` reads every table under it as a worker. + // Here as a guard against the name picking up a special case it never had. it.live("inspects a deployed worker named root", () => { const repo = project(); const { layer, out, http } = setupLegacyWorkers({