diff --git a/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts b/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts new file mode 100644 index 0000000000..4004a696db --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { Primitive, type Command } from "effect/unstable/cli"; +import { + legacyCommandInternals, + legacyFlattenSubcommands, + legacyUserGlobalFlagParams, +} from "../docs/legacy-docs-introspection.ts"; +import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; +import { legacyRoot } from "./root.ts"; + +/** + * `Flag.boolean(name)` builds a bare `Single` param, and a bare `Single` is + * *required* — omitting it fails the whole command with a missing-flag error + * before the handler ever runs. Every boolean flag therefore has to be closed + * off with `Flag.withDefault(false)` or `Flag.optional`. + * + * Nothing else catches this: handler integration tests build their flags record + * directly, so they never touch the parser, and the required-ness is invisible + * to the type checker because a required boolean flag still infers as + * `boolean`. The flag only misbehaves when a real invocation omits it, which is + * precisely the invocation no handler test makes — so the guard walks the + * command tree instead of waiting for a command to be exercised end to end. + * + * `experimental workers push --wait` is the flag that prompted it: it first + * shipped with neither closer, which made a plain + * `supabase experimental workers push` fail to parse at all. + */ + +/** + * The published getter for a primitive's kind — `Primitive.getTypeName`, whose + * own doc example pins `Primitive.boolean` to `"boolean"`. Reading + * `primitiveType._tag` instead would couple this guard to effect's runtime + * representation, which this repo forbids in tests as well as in source. + * + * Derived from `Primitive.boolean` rather than written as the literal + * `"boolean"`: were that name to change upstream, a hardcoded literal would + * match nothing and leave the guard silently passing every command, which is + * the one failure mode a regression test must not have. + */ +const BOOLEAN_TYPE_NAME = Primitive.getTypeName(Primitive.boolean); + +function booleanFlagsRequiringAValue(command: Command.Command.Any): ReadonlyArray { + const internals = legacyCommandInternals(command); + // All three parameter sets a command can be parsed with, not just its own: + // `Command.withSharedFlags` puts inherited flags on `contextConfig`, and the + // root's persistent flags arrive as `globalFlags`. A bare boolean introduced + // through either would break every command that inherits it while a guard + // reading only `config.flags` stayed green. + const params = [ + ...internals.config.flags, + ...internals.contextConfig.flags, + ...legacyUserGlobalFlagParams(command), + ]; + + // Throws rather than skipping if effect's internal shape moves, so this + // cannot quietly degrade into a test that inspects nothing. + const own = params.flatMap((flag) => { + const unwrapped = legacyUnwrapParam(flag); + if (unwrapped === undefined) { + throw new Error(`Unrecognizable flag param on "${command.name}".`); + } + const { single, isOptional } = unwrapped; + return Primitive.getTypeName(single.primitiveType) === BOOLEAN_TYPE_NAME && !isOptional + ? [`${command.name} --${single.name}`] + : []; + }); + + return [...own, ...legacyFlattenSubcommands(command).flatMap(booleanFlagsRequiringAValue)]; +} + +describe("legacy boolean flag wiring", () => { + it("gives every boolean flag a default, so omitting it is not a parse error", () => { + expect(booleanFlagsRequiringAValue(legacyRoot)).toEqual([]); + }); +}); diff --git a/apps/cli/src/legacy/commands/experimental/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/delete/SIDE_EFFECTS.md index e9ebcabe0b..4b425f2fdd 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/experimental/workers/delete/SIDE_EFFECTS.md @@ -77,11 +77,17 @@ 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 | +| Mode | stdout | stderr | +| ----------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| text (default) | the confirmation prompt, then what was deleted and kept | that nothing local was kept when nothing was, and the redeploy hint | +| `--output-format json` | one structured result carrying `worker_name`, `project_ref`, `kept_*` | neither — both are text-only | +| `--output-format stream-json` | the same result as a single terminal event | neither — both are text-only | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | neither — both are text-only | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | both, as in text | +| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the worker and then failed | the error | + +A structured emission is the end of the run: the handler returns at +`legacyEmitWorkersMachineOutput` or at `output.success`, so nothing in the text +branch below it — the kept-nothing notice and the redeploy trailer — is reached. +`-o pretty`, `table` and `csv` are the exception, since they encode nothing and +fall through to that same text branch. diff --git a/apps/cli/src/legacy/commands/experimental/workers/delete/delete.command.ts b/apps/cli/src/legacy/commands/experimental/workers/delete/delete.command.ts index 455b8c81a0..47242e5b8e 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/delete/delete.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { legacyWorkersCommand } from "../workers.commands.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; @@ -25,11 +26,11 @@ export const legacyWorkersDeleteCommand = Command.make("delete", config).pipe( Command.withShortDescription("Delete a worker from Supabase"), Command.withExamples([ { - command: "supabase experimental workers delete api", + command: legacyWorkersCommand("delete api"), description: "Delete a worker, confirming by typing its name", }, { - command: "supabase experimental workers delete api --yes", + command: legacyWorkersCommand("delete api --yes"), description: "Skip the confirmation prompt (scripts and CI)", }, ]), diff --git a/apps/cli/src/legacy/commands/experimental/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/delete/delete.handler.ts index e5fc407967..a192f186df 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/delete/delete.handler.ts @@ -1,12 +1,17 @@ import { Effect, Option } from "effect"; import { Output } from "../../../../../shared/output/output.service.ts"; +import { emitSuccessTrailer } from "../../../../../shared/cli/success-trailer.ts"; +import { + legacyWorkerNotDeployed, + legacyWorkersCommand, + legacyWorkersPushCommand, +} from "../workers.commands.ts"; import { legacyAqua } from "../../../../shared/legacy-colors.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { - legacyEmitWorkersMachineOutput, + legacyEmitWorkersPayload, legacyRejectWorkersEnvOutput, - legacyWorkersMachineOutputRequested, - legacyWorkersProjectRefSuffix, + legacyWorkersRendersText, } from "../workers.output.ts"; import { LegacyPlatformApi } from "../../../../auth/legacy-platform-api.service.ts"; import { displayPath } from "../../../../../shared/workers/worker-paths.ts"; @@ -14,19 +19,16 @@ import { deleteWorker, getWorker } from "../../../../../shared/workers/workers-a 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"; 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 { legacyDescribeWorkerForReporting, legacyLoadWorkersProjectForReporting, legacyValidateWorkerName, } from "../workers.shared.ts"; +import { legacyWorkersRun } from "../workers.run.ts"; import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; /** @@ -55,196 +57,177 @@ export const legacyWorkersDelete = Effect.fn("legacy.experimental.workers.delete ) { const output = yield* Output; const api = yield* LegacyPlatformApi; - 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; - // 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); - // 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* legacyLoadWorkersProjectForReporting(); - 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..."); - // 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 = lookup.worker; - 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 (lookup.readable && deployed === undefined && !yes) { - return yield* Effect.fail( - new WorkerNotDeployedError({ - detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, - // `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 experimental workers list${refSuffix}\`.`, - }), + yield* legacyWorkersRun(flags.projectRef, ({ projectRef, refSuffix }) => + Effect.gen(function* () { + const project = yield* legacyLoadWorkersProjectForReporting(); + 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..."); + // 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()), ); - } - - 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. - // - // `output.interactive` only tracks *stdout*, so on its own it still let - // `printf 'api\n' | supabase experimental 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) { + yield* fetching.clear(); + + const deployed = lookup.worker; + const rendersText = yield* legacyWorkersRendersText(); + + // `--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 (lookup.readable && deployed === undefined && !yes) { 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 experimental workers delete ${name} --yes${refSuffix}\` to confirm without a prompt.`, + legacyWorkerNotDeployed({ + name, + projectRef, + suggestion: `See what is deployed with \`${legacyWorkersCommand(`list${refSuffix}`)}\`.`, }), ); } - // 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. - // 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 !== undefined && 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 experimental workers delete ${name}${refSuffix}\` and type the name exactly, or pass --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 + // redirected stdout, whichever flag asked for it. + // + // `output.interactive` only tracks *stdout*, so on its own it still let + // `printf 'api\n' | supabase experimental 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 (!rendersText || !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.`, + suggestion: `Re-run \`${legacyWorkersCommand(`delete ${name} --yes${refSuffix}`)}\` 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. + // 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 !== undefined && 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 \`${legacyWorkersCommand(`delete ${name}${refSuffix}`)}\` and type the name exactly, or pass --yes.`, + }), + ); + } } - } - - // 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(); - } - - // 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; - } + // 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(); + } - { - 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`, - ); + // 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* legacyEmitWorkersPayload(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) { + { + if (deployed === undefined && lookup.readable) { yield* output.raw( - `Redeploy it with supabase experimental workers push ${name}${refSuffix}.\n`, + `Nothing was deployed for ${legacyAqua(name, process.stdout)} in project ${projectRef}, so there was nothing to delete.\n`, ); + return; } - } else { + yield* output.raw( - `Nothing for "${name}" exists in this project on disk, so nothing was kept.\n`, - "stderr", + `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) { + // Trailer, like every other "what to run next" line in this shell. + yield* emitSuccessTrailer( + `Redeploy it with ${legacyAqua(legacyWorkersPushCommand(name, refSuffix))}.\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/experimental/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/delete/delete.integration.test.ts index 1f6e9cf899..3d0c59bc34 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/delete/delete.integration.test.ts @@ -72,13 +72,14 @@ describe("legacy workers delete", () => { // 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 experimental workers push api"); + // The redeploy hint is a success trailer, which lands on stderr. + expect(out.stderrText).toContain("supabase experimental workers push api"); }).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. + // The refusal has to precede the DELETE. At emit time `--yes -o env` would + // remove the worker and then exit 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", () => { @@ -327,8 +328,8 @@ describe("legacy workers delete", () => { }).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. + // `interactive` follows stdout, so a plain `>` redirect reaches this branch even + // from a live terminal — the case where deleting without asking would be worst. it.live("refuses when stdout is redirected and no --yes was given", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ @@ -541,6 +542,63 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + it.live("pluralizes the live instance count in the confirmation", () => { + 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: 2, ready: 2, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("2 running instances will be terminated"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Scaled to zero: there is a tally, and it says nothing is running. Warning + // about terminated instances there would invent a consequence. + it.live("promises no terminations when nothing is running", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["api"], + routes: { + ...routes, + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + instances: 2, + instanceCounts: { declared: 2, live: 0, ready: 0, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("permanently deletes"); + expect(out.stdoutText).not.toContain("will be terminated"); + }).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. @@ -604,7 +662,7 @@ describe("legacy workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Deletion never reads the local source, so a `source` that no longer resolves + // Deletion never reads the local source, so a `source` that does not resolve // 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'); diff --git a/apps/cli/src/legacy/commands/experimental/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/list/SIDE_EFFECTS.md index 322844cd65..b7ae13668c 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/experimental/workers/list/SIDE_EFFECTS.md @@ -67,3 +67,7 @@ wrapper emits for every command. | `-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 | + +The text table omits each worker's URL — it is the same host and prefix on +every row, and carrying it made the table 137 columns wide. Every machine +format still carries `url` per worker, and `workers status` renders it. diff --git a/apps/cli/src/legacy/commands/experimental/workers/list/list.command.ts b/apps/cli/src/legacy/commands/experimental/workers/list/list.command.ts index 0efc9c1008..39325fdb00 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/list/list.command.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/list/list.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { legacyWorkersCommand } from "../workers.commands.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; @@ -21,7 +22,7 @@ export const legacyWorkersListCommand = Command.make("list", config).pipe( Command.withShortDescription("List this project's workers"), Command.withExamples([ { - command: "supabase experimental workers list", + command: legacyWorkersCommand("list"), description: "See every worker in the linked project", }, ]), diff --git a/apps/cli/src/legacy/commands/experimental/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/list/list.handler.ts index c1c5434b58..e103d765d1 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/list/list.handler.ts @@ -1,16 +1,17 @@ import { Effect } from "effect"; import { Output } from "../../../../../shared/output/output.service.ts"; +import { legacyAqua, legacyYellow } from "../../../../shared/legacy-colors.ts"; +import { displayPath } from "../../../../../shared/workers/worker-paths.ts"; import { renderGlamourTable } from "../../../../output/legacy-glamour-table.ts"; -import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput } from "../workers.output.ts"; +import { legacyEmitWorkersPayload, 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"; 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 { legacyWorkersCommand } from "../workers.commands.ts"; +import { legacyWorkersRun } from "../workers.run.ts"; import type { LegacyWorkersListFlags } from "./list.command.ts"; /** @@ -28,7 +29,15 @@ import type { LegacyWorkersListFlags } from "./list.command.ts"; * count from the spec. `status` is where the live tally lives. */ -const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES", "URL"] as const; +/** + * No URL column. Every worker's URL is the same 40-odd characters of host and + * prefix with the name on the end, which pushed the table past 130 columns to + * carry one derivable field — `renderGlamourTable` sizes each column to its + * widest cell and never wraps. `workers status` renders it, vertically, for the + * same reason (see `workers.format.ts`), and every machine format still carries + * `url` per worker. + */ +const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES"] as const; interface WorkerRow { readonly name: string; @@ -68,6 +77,14 @@ function runtimeLabel(row: WorkerRow): string { return runtimeLabelFor(row) ?? "-"; } +/** + * `api is` / `api, box are` — the subject of both advisories below, which only + * ever differ in the verb. + */ +function nameList(names: ReadonlyArray): string { + return `${names.join(", ")} ${names.length === 1 ? "is" : "are"}`; +} + function toCells(row: WorkerRow): ReadonlyArray { return [ row.name, @@ -75,7 +92,6 @@ function toCells(row: WorkerRow): ReadonlyArray { row.deployed === undefined ? "-" : formatApiSize(row.deployed.spec.size), stateLabel(row), row.deployed === undefined ? "-" : String(row.deployed.spec.instances), - row.url ?? "-", ]; } @@ -84,125 +100,115 @@ export const legacyWorkersList = Effect.fn("legacy.experimental.workers.list")(f ) { const output = yield* Output; const api = yield* LegacyPlatformApi; - const resolver = yield* LegacyProjectRefResolver; - const linkedProjectCache = yield* LegacyLinkedProjectCache; - const telemetryState = yield* LegacyTelemetryState; 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`, - // 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(); - - // 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()), - ); - 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, settings.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 experimental workers new .\n", - ); - return; - } - - yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); - - // 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 (unconfigured.length > 0) { - yield* output.raw( - `${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", + yield* legacyWorkersRun(flags.projectRef, ({ projectRef }) => + 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()), ); - } - }).pipe( - Effect.ensuring(linkedProjectCache.cache(projectRef)), - Effect.ensuring(telemetryState.flush), + 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, settings.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* legacyEmitWorkersPayload(payload)) { + return; + } + + if (rows.length === 0) { + yield* output.raw( + `No workers found. Scaffold one with ${legacyAqua(legacyWorkersCommand("new "), process.stdout)}.\n`, + ); + return; + } + + yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); + + // 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. + // + // Both are written the way this shell writes every other heads-up that is + // not a failure: a yellow `WARNING:` prefix, then the consequence on its own + // line (`start`'s Docker-on-Windows notice is the same two-line shape). A + // single long sentence re-flows differently at every terminal width, right + // under a table that lines its columns up. + const unconfigured = rows + .filter((row) => row.deployed !== undefined && !row.configured && row.local) + .map((row) => row.name); + if (unconfigured.length > 0) { + const configDisplay = displayPath(project.projectRoot, project.configPath); + yield* output.raw( + `${legacyYellow("WARNING:")} ${nameList(unconfigured)} deployed but not in ${configDisplay}.\n` + + `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( + `${legacyYellow("WARNING:")} ${nameList(remoteOnly)} deployed with no source in this project.\n` + + `Scaffold or restore before pushing from here.\n`, + "stderr", + ); + } + }), ); }); diff --git a/apps/cli/src/legacy/commands/experimental/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/list/list.integration.test.ts index eb50d0da97..0bfb506676 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/list/list.integration.test.ts @@ -70,7 +70,9 @@ describe("legacy workers list", () => { 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`); + // The URL is deliberately not a column: one derivable field pushed the + // table past 130 columns. The machine payload still carries it. + expect(stdout).not.toContain("https://"); expect(rows[1]).toContain("sandbox"); expect(rows[2]).toContain("not deployed"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); @@ -122,6 +124,63 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // Two of them, so the advisory has to read as a list rather than as one name + // with a stray verb. + it.live("calls out every deployed worker config.toml does not know about", () => { + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/stray/index.js": "export default {};\n", + "supabase/workers/spare/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: { + [listRoute]: { + status: 200, + body: { + data: [ + workerResource({ name: "stray", runtime: "node" }), + workerResource({ name: "spare", runtime: "node" }), + ], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("spare, stray are deployed but not in"); + expect(out.stderrText).toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Deletion is asynchronous, so a worker can be listed while it is being torn + // down. Reporting its build state would show `active` for something on its + // way out. + it.live("shows a worker being torn down as deleting", () => { + const repo = project(`project_id = "demo"\n\n[workers.api]\nruntime = "node"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node", deleting: true })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + }).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. @@ -386,10 +445,33 @@ describe("legacy workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + it.live("encodes YAML when -o yaml asks for it", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "yaml", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("project_ref:"); + expect(out.stdoutText).toContain("name: api"); + // The table would have gone to stdout too, and broken the document. + expect(out.stdoutText).not.toContain("NAME"); + }).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, - // which is the trap the payload allowlist closes. + // them and render text. Falling through to the TOML encoder is the trap the + // payload allowlist closes. it.live.each(["pretty", "table", "csv"] as const)( "renders text rather than TOML for -o %s", (goOutput) => { diff --git a/apps/cli/src/legacy/commands/experimental/workers/logs/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/logs/SIDE_EFFECTS.md new file mode 100644 index 0000000000..357c2f5a96 --- /dev/null +++ b/apps/cli/src/legacy/commands/experimental/workers/logs/SIDE_EFFECTS.md @@ -0,0 +1,144 @@ +# `supabase experimental workers logs ` + +> **No live test yet.** The other `workers` commands skip live coverage because they +> run against the v2 Management API, which the supabase/cli-e2e-ci supabox stack is +> not expected to serve. This one reads the v1 analytics endpoint, which that stack +> may well serve — but a meaningful assertion needs a deployed worker that has +> actually emitted log lines, which the stack cannot provide. Revisit alongside the +> rest of the family. + +## Files Read + +| Path | Format | When | +| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/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 | + +The project config is **not** read. Unlike `status` and `delete`, nothing in this +command's output depends on local state — there is no source path to report — so +`config.toml` is never opened and an unparseable one cannot block a log read. + +## 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 | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | ---------------------- | +| `GET` | `/v1/projects/{ref}/analytics/endpoints/logs` | Bearer token | `sql`, `iso_timestamp_start`, `iso_timestamp_end` as query parameters | `result[]`, `error` | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none — **only when the log query returned no rows**, to tell "not deployed" from "deployed and quiet" | presence only | +| `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | + +Requires the `analytics_logs_read` permission, and the project must be on the +Workers private-alpha allow-list — an unenrolled project answers 404. + +### The query + +SQL in **ClickHouse dialect** against the project's unified `logs` table, filtered +on `log_attributes['worker']` and `log_attributes['source']`. It does **not** filter +the top-level `source` column: worker rows carry an empty string there, because the +Workers Logflare source is not enrolled as a category in the generic logs path. + +### The window + +Both `iso_timestamp_start` and `iso_timestamp_end` are always sent, spanning just +under 24 hours. This is not optional: + +- one bound alone yields a **one-minute** window, server-side and silently; +- neither bound is an outright error; +- a span over 24 hours is **silently clamped** to `start + 24h`, which returns an + older slice than the one requested rather than a truncated one. + +### Rate limits + +The v1 analytics endpoints allow **10 requests per 60 seconds**, and the server +applies a 30-second query timeout. One bounded invocation spends one request, or +two when the result is empty. + +`--follow` polls every **10 seconds** — 6 requests a minute, leaving room for the +history query, the deployed-worker check, and a retry inside the same window. The +interval is set by that limit, not by responsiveness: a 2-second poll would spend +the allowance in ten seconds. A 429 mid-tail is retried on a spaced schedule +rather than ending the tail. + +Each poll re-asks for a window starting 60 seconds behind the newest line already +printed, because guest lines arrive late and out of order. Overlap is therefore +guaranteed and is deduplicated on the Logflare-minted `id`. + +## Exit Codes + +| Code | Condition | +| ----- | ------------------------------------------------------------ | +| `0` | success, including "no logs in the last 24 hours" | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `1` | the log query failed (rejected, or the server's 30s timeout) | +| `1` | log usage exceeded (402), or rate limited (429) | +| `1` | API error, or project not enrolled in the alpha | +| `130` | `--follow` interrupted with SIGINT | + +## 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_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 + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +`--kind` is a choice flag, so its value is logged verbatim (a closed enum carries +no user data). `--project-ref` is not on this command's safe list, so its value is +redacted. No custom events. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| text (default) | one line per entry, oldest first, per-stream layout; `HH:MM:SS` local time; severity as colour | the spinner, and the `status` hint when there are no logs | +| `--output-format json` | one structured result carrying every entry | neither — both are text-only | +| `--output-format stream-json` | the same result as a single terminal event | neither — both are text-only | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | neither — both are text-only | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | both, as in text | +| `-o env` | refused before any request; the payload nests a `logs` array a flat `KEY=value` list cannot express | the error | + +A structured emission is the end of a bounded read: the handler returns at +`legacyEmitWorkersMachineOutput` or at `output.success`, so the no-logs line and +its `status` trailer below them are never reached, and `output.task` is a no-op +in those modes. `-o pretty`, `table` and `csv` are the exception, since they +encode nothing and fall through to the same text branch. + +With `--follow`, `stream-json` emits a `log-entry` event per line rather than one +terminal `result` — a tail has no last element. Its `stream` field is `stderr` when +the derived level is error or warn and `stdout` otherwise, and `source` separates +the initial backlog (`history`) from lines that arrived afterwards (`live`). +`--tail 0 --follow` skips the backlog entirely and makes no history request, since +the endpoint rejects `limit 0`. + +A bounded read echoes `--kind` back as a top-level `kind` key when the flag was +given. That is a different axis from the per-line `source` above — `kind` is which +stream was asked for, `source` is whether the line came from the backlog or the +tail — so the two never mean the same thing. + +Text output prints the time in the reader's own timezone, matching the `--debug` +HTTP logger. Machine payloads carry the unambiguous forms instead — each entry's +`id`, both `timestamp` (ISO-8601 UTC) and `timestamp_ms` (raw epoch), `stream`, +`message`, the derived `level` when one exists, and the raw `attributes` map — whose values are all strings, since the column is a +`Map(String, String)`. + +A `worker_guest_logs` message is bytes the tenant's own code printed. Control and +escape sequences are stripped before it reaches a terminal, so a worker cannot +reposition the cursor or forge CLI output; interior newlines and indentation are +preserved so a stack trace survives intact. diff --git a/apps/cli/src/legacy/commands/experimental/workers/logs/logs.command.ts b/apps/cli/src/legacy/commands/experimental/workers/logs/logs.command.ts new file mode 100644 index 0000000000..485aec3ff0 --- /dev/null +++ b/apps/cli/src/legacy/commands/experimental/workers/logs/logs.command.ts @@ -0,0 +1,98 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { legacyWorkersCommand } from "../workers.commands.ts"; +import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../../shared/legacy-management-api-runtime.layer.ts"; +import { + WORKER_LOG_KINDS, + WORKER_LOG_POLL_SECONDS, +} from "../../../../../shared/workers/worker-logs.sql.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersLogs } from "./logs.handler.ts"; + +/** + * The endpoint's own ceiling is the SQL `LIMIT`, so this bound is the CLI's + * choice. 1000 is high enough to be a non-issue in practice and low enough that a + * typo cannot ask for a payload nobody wants. + * + * 0 is allowed and means "no history", which only becomes useful alongside + * `--follow`; on its own it prints nothing and makes no request. + */ +const MAX_TAIL = 1000; + +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to read logs for.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), + kind: Flag.choice("kind", WORKER_LOG_KINDS).pipe( + Flag.withDescription( + "Limit to one log stream: app (the worker's own output), requests (HTTP access), " + + "builds (deploy lifecycle). Defaults to all three.", + ), + Flag.optional, + ), + follow: Flag.boolean("follow").pipe( + Flag.withAlias("f"), + Flag.withDescription( + `Keep printing new lines until interrupted, polling every ${WORKER_LOG_POLL_SECONDS} seconds.`, + ), + // Required: `Flag.boolean` alone builds a *required* param, which breaks + // invocations that omit the flag. `legacy-boolean-flag-defaults.unit.test.ts` + // walks the command tree and fails any bare boolean. + Flag.withDefault(false), + ), + tail: Flag.integer("tail").pipe( + Flag.filter( + (tail) => tail >= 0 && tail <= MAX_TAIL, + (tail) => `Expected --tail between 0 and ${MAX_TAIL}, got ${tail}`, + ), + Flag.withDescription( + "Number of log lines to print. Use 0 with --follow to skip history and print only new lines.", + ), + Flag.withDefault(100), + ), +} as const; + +export type LegacyWorkersLogsFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersLogsCommand = Command.make("logs", config).pipe( + Command.withDescription( + "Print a worker's recent logs: its own output, the HTTP requests it served, and its " + + "deploy lifecycle events.\n\n" + + "Covers the last 24 hours, which is the longest window the logs API will answer in one " + + "query. Lines are printed oldest first.\n\n" + + `Use --follow to keep printing new lines as they arrive. The logs API is rate limited, so ` + + `following polls every ${WORKER_LOG_POLL_SECONDS} seconds rather than continuously; new ` + + "lines can take that long to appear.", + ), + Command.withShortDescription("Show a worker's logs"), + Command.withExamples([ + { + command: legacyWorkersCommand("logs api"), + description: "Print the last 100 log lines across all streams", + }, + { + command: legacyWorkersCommand("logs api --kind requests --tail 20"), + description: "Print the 20 most recent HTTP requests the worker served", + }, + { + command: legacyWorkersCommand("logs api --follow"), + description: "Print recent logs, then keep printing new lines until interrupted", + }, + { + command: legacyWorkersCommand("logs api --tail 0 --follow"), + description: "Skip the backlog and print only lines that arrive from now on", + }, + ]), + Command.withHandler((flags) => + legacyWorkersLogs(flags).pipe( + // `config` as well as `flags`: `--kind` is a choice flag, and the wrapper + // treats a command's own declared choices as safe to log verbatim. + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["experimental", "workers", "logs"])), +); diff --git a/apps/cli/src/legacy/commands/experimental/workers/logs/logs.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/logs/logs.handler.ts new file mode 100644 index 0000000000..3cf8c3db89 --- /dev/null +++ b/apps/cli/src/legacy/commands/experimental/workers/logs/logs.handler.ts @@ -0,0 +1,422 @@ +import { Effect, Option, Ref, Schedule } from "effect"; +import { Output } from "../../../../../shared/output/output.service.ts"; +import { emitSuccessTrailer } from "../../../../../shared/cli/success-trailer.ts"; +import { + legacyWorkerNotDeployed, + legacyWorkersPushCommand, + legacyWorkersStatusCommand, +} from "../workers.commands.ts"; +import { legacyAqua } from "../../../../shared/legacy-colors.ts"; +import { legacyEmitWorkersPayload, legacyRejectWorkersEnvOutput } from "../workers.output.ts"; +import { + legacyRenderWorkerLogLine, + legacyWorkerLogLevel, + legacyWorkerLogText, +} from "../workers-logs.format.ts"; +import { ProcessControl } from "../../../../../shared/runtime/process-control.service.ts"; +import { LegacyWorkersFollowNotSupportedError } from "../workers.errors.ts"; +import { LegacyPlatformApi } from "../../../../auth/legacy-platform-api.service.ts"; +import { + fetchWorkerLogs, + type WorkerLogEntry, +} from "../../../../../shared/workers/worker-logs-api.ts"; +import { + ALL_WORKER_LOG_STREAMS, + followWindow, + logWindow, + WORKER_LOG_POLL_SECONDS, + WORKER_LOG_STREAMS, +} from "../../../../../shared/workers/worker-logs.sql.ts"; +import { getWorker } from "../../../../../shared/workers/workers-api.ts"; +import { + WorkerLogsQueryFailedError, + WorkerLogsRateLimitedError, + WorkersApiNetworkError, + WorkersApiUnexpectedStatusError, +} from "../../../../../shared/workers/workers.errors.ts"; +import { legacyValidateWorkerName } from "../workers.shared.ts"; +import { + legacyWorkersMachineOutputRequested, + legacyWorkersRenderFormat, +} from "../workers.output.ts"; +import { legacyWorkersRun } from "../workers.run.ts"; +import type { LegacyWorkersLogsFlags } from "./logs.command.ts"; + +/** + * `supabase experimental workers logs ` — what the worker has actually been doing. + * + * `status` reports the deployment; this reports the runtime. Between them they + * cover the two questions a deployed worker raises, and neither answers the + * other's. + * + * Unlike the rest of the family this does not talk to `/v2/.../workers` — there is + * no worker-scoped log route — but to the project's unified logs stream. See + * `worker-logs.sql.ts` for the query and why it filters on `log_attributes` + * rather than the `source` column. + */ + +/** + * How many printed ids the follow loop remembers. + * + * Only lines inside the cursor's grace window can still be re-offered by a later + * poll, so a bound well above one window's worth cannot cause a repeat while + * keeping the set from growing for the lifetime of a long tail. + */ +const SEEN_ID_LIMIT = 5000; + +/** Rows per poll request. Independent of `--tail`, which bounds history only. */ +const FOLLOW_PAGE_SIZE = 1000; + +/** Requests one poll may spend draining a burst, against a 10/minute budget. */ +const FOLLOW_MAX_PAGES = 5; + +/** + * How long one poll may keep failing before the tail gives up. + * + * Bounded by elapsed time rather than attempts, and spaced, so a 429 or a + * momentary blip is ridden out without spending the rate limit on retries. Same + * reasoning as `awaitWorkerBuild`'s read retry. + */ +const FOLLOW_READ_RETRY = Schedule.spaced("5 seconds").pipe( + Schedule.upTo({ duration: "1 minute" }), +); + +/** + * Which poll failures are worth another request. + * + * Server-side statuses, plus 408 and 429 — the server asking for a retry. + * Definitive answers (401, 402, 404) surface immediately rather than burning a + * minute and most of the rate limit first. A decode failure carries the + * response's own status, so a malformed 200 body reads as terminal. + */ +function isRetryableFollowFailure(error: unknown): boolean { + if (error instanceof WorkersApiUnexpectedStatusError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + return ( + error instanceof WorkerLogsRateLimitedError || + error instanceof WorkersApiNetworkError || + // The endpoint reports a rejected or timed-out query this way, and its own + // suggestion is to retry shortly. + error instanceof WorkerLogsQueryFailedError + ); +} + +/** Where the tail has got to: the newest line printed, and the ids printed. */ +interface FollowCursor { + readonly newestMs: number; + readonly seen: ReadonlySet; +} + +/** + * Move the cursor past `fresh`. The timestamp and the id set are only correct + * together: advancing one without the other replays the overlap or loses it. + * + * The id set is bounded — only ids inside the grace window can be re-offered, + * so forgetting the oldest cannot resurrect them. + */ +function advanceCursor(cursor: FollowCursor, fresh: ReadonlyArray): FollowCursor { + const seen = new Set(cursor.seen); + for (const row of fresh) { + seen.add(row.id); + } + return { + newestMs: fresh.reduce((newest, row) => Math.max(newest, row.timestampMs), cursor.newestMs), + seen: seen.size <= SEEN_ID_LIMIT ? seen : new Set([...seen].slice(seen.size - SEEN_ID_LIMIT)), + }; +} + +/** + * Every row since `cursorMs`, across as many requests as it takes. + * + * The query orders newest-first, so one request answers with only the newest + * page. Walk `end` backwards while pages come back full; a short page means the + * window is drained. Bounded by the endpoint's ten-per-minute allowance — rows + * past the bound are not lost, since the cursor only advances over what was + * emitted. + */ +const drainSince = Effect.fnUntraced(function* (input: { + readonly api: LegacyPlatformApi["Service"]; + readonly projectRef: string; + readonly name: string; + readonly streams: ReadonlyArray; + readonly cursorMs: number; +}) { + const collected: Array = []; + let end = new Date(); + for (let page = 0; page < FOLLOW_MAX_PAGES; page += 1) { + const rows = yield* fetchWorkerLogs(input.api, input.projectRef, { + name: input.name, + streams: input.streams, + tail: FOLLOW_PAGE_SIZE, + window: followWindow(end, input.cursorMs), + }); + collected.push(...rows); + if (rows.length < FOLLOW_PAGE_SIZE) { + break; + } + // Rows arrive oldest-first, so the next page ends where this one began. A + // full page sharing one timestamp cannot narrow the window: stop rather than + // re-request it, and let the next poll's grace window cover the remainder. + const nextEnd = new Date(rows[0]!.timestampMs); + if (nextEnd.getTime() >= end.getTime()) { + break; + } + end = nextEnd; + } + return collected; +}); + +/** + * Test seams for the follow loop. + * + * Both schedules are parameters for the same reason `awaitWorkerBuild`'s are: the + * real ones are spaced in seconds, and a test exercising the cursor, the dedupe, + * or the retry path should not wait on a wall clock to do it. + */ +export interface LegacyWorkersLogsOptions { + readonly pollSchedule?: Schedule.Schedule; + readonly retrySchedule?: Schedule.Schedule; +} + +/** The machine-format row for one line. */ +function toPayloadEntry(entry: WorkerLogEntry) { + const level = legacyWorkerLogLevel(entry); + return { + id: entry.id, + // Both forms: the ISO string is what a human or `jq` wants to read, the raw + // epoch value is what a script sorts or diffs on without reparsing. + timestamp: new Date(entry.timestampMs).toISOString(), + timestamp_ms: entry.timestampMs, + stream: entry.stream, + message: entry.message, + ...(level === undefined ? {} : { level }), + attributes: entry.attributes, + }; +} + +export const legacyWorkersLogs = Effect.fn("legacy.experimental.workers.logs")(function* ( + flags: LegacyWorkersLogsFlags, + options: LegacyWorkersLogsOptions = {}, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const processControl = yield* ProcessControl; + + yield* legacyWorkersRun(flags.projectRef, ({ projectRef, refSuffix }) => + Effect.gen(function* () { + const name = yield* legacyValidateWorkerName(flags.name); + + // Up front, like the rest of the family: this payload always carries a `logs` + // array, so `-o env` can never encode it, and finding that out at emit time + // means failing after the query has already been paid for. + yield* legacyRejectWorkersEnvOutput(); + + // Resolved once, before anything branches: `-o` outranks `--output-format`, + // so `output.format` on its own is not what this run renders in. + const renderFormat = yield* legacyWorkersRenderFormat(); + + // Also up front: a tail has no single terminal payload, so the bounded + // machine formats cannot express it. `stream-json` can, and is allowed. + if (flags.follow) { + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + if (machineOutput || renderFormat === "json") { + return yield* new LegacyWorkersFollowNotSupportedError({ + message: + "--follow cannot be combined with a single-payload output format. " + + "Use --output-format stream-json to stream, or drop --follow.", + }); + } + } + + const pollSchedule = + options.pollSchedule ?? Schedule.spaced(`${WORKER_LOG_POLL_SECONDS} seconds`); + const readRetrySchedule = options.retrySchedule ?? FOLLOW_READ_RETRY; + + // The stream tag only earns its width when streams are actually mixed; with + // `--kind` every line would carry the same one. + const showStream = Option.isNone(flags.kind); + + /** + * Write a batch of lines out, in whichever form the format calls for. + * + * `stream-json` emits the existing `log-entry` event per line rather than one + * terminal `result`: a tail has no terminal element, and that variant already + * carries the field set this needs. `stream` is derived from the level so a + * consumer can split diagnostics from ordinary output the way it would for a + * real process; `source` distinguishes the backlog from what arrived after. + */ + const emitLines = ( + batch: ReadonlyArray, + origin: "history" | "live" = "history", + ) => + Effect.gen(function* () { + if (batch.length === 0) { + return; + } + if (renderFormat === "stream-json") { + for (const entry of batch) { + const level = legacyWorkerLogLevel(entry); + yield* output.event({ + type: "log-entry", + timestamp: new Date(entry.timestampMs).toISOString(), + service: name, + stream: level === "error" || level === "warn" ? "stderr" : "stdout", + // The composed sentence, the same one text mode renders: a + // request line's status and duration and a build's reason live + // in `log_attributes`, and `log-entry` has no field to carry + // them separately. + line: legacyWorkerLogText(entry), + source: origin, + }); + } + return; + } + yield* output.raw( + `${batch.map((entry) => legacyRenderWorkerLogLine(entry, { showStream })).join("\n")}\n`, + ); + }); + + const streams = Option.isSome(flags.kind) + ? [WORKER_LOG_STREAMS[flags.kind.value]] + : ALL_WORKER_LOG_STREAMS; + + // Before any request, so a slow one cannot widen `followFloorMs` below. + const startedAtMs = Date.now(); + + // `--tail 0` is "no history". On its own that is a no-op, but it is the shape + // `--follow` will want, and issuing a `limit 0` query would be a 400. + const entries = + flags.tail === 0 + ? [] + : yield* Effect.gen(function* () { + const fetching = yield* output.task("Fetching logs..."); + const rows = yield* fetchWorkerLogs(api, projectRef, { + name, + streams, + tail: flags.tail, + window: logWindow(new Date()), + }).pipe(Effect.tapError(() => fetching.fail())); + yield* fetching.clear(); + return rows; + }); + + // Zero rows is two situations wearing one face: not deployed, or deployed + // and quiet. Worth one extra request to tell them apart. `--tail 0` queried + // nothing, so it only needs the check when it is going on to tail. + if (entries.length === 0 && (flags.tail > 0 || flags.follow)) { + // Its own task: `--tail 0` has no "Fetching logs..." to inherit. + const checking = yield* output.task("Checking worker..."); + const deployed = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => checking.fail()), + ); + yield* checking.clear(); + if (Option.isNone(deployed)) { + return yield* Effect.fail( + legacyWorkerNotDeployed({ + name, + projectRef, + suggestion: `Deploy it with \`${legacyWorkersPushCommand(name, refSuffix)}\`.`, + }), + ); + } + } + + const payload = { + worker_name: name, + project_ref: projectRef, + ...(Option.isSome(flags.kind) ? { kind: flags.kind.value } : {}), + logs: entries.map(toPayloadEntry), + }; + + // Only for a bounded read: a tail has no terminal payload to put here, and + // emits a `log-entry` event per line through `emitLines` instead. + if (!flags.follow && (yield* legacyEmitWorkersPayload(payload))) { + return; + } + + if (entries.length === 0 && !flags.follow) { + // Deployed (the check above would have failed otherwise) and silent. + yield* output.raw(`No logs for "${name}" in the last 24 hours.\n`); + yield* emitSuccessTrailer( + `Check it is running with ${legacyAqua(legacyWorkersStatusCommand(name, refSuffix))}.\n`, + ); + return; + } + + // Oldest first: the query orders newest-first so `limit` means "most recent", + // but a reader scrolls forwards through time, and a stack trace only makes + // sense in the order it was printed. + yield* emitLines(entries); + + // A tail with nothing to show yet would otherwise look like a hang. On stderr, + // so it never lands in piped output. + if (flags.follow && entries.length === 0 && renderFormat === "text") { + yield* output.raw(`Waiting for new logs from "${name}". Press Ctrl+C to stop.\n`, "stderr"); + } + + if (!flags.follow) { + return; + } + + // --- follow --------------------------------------------------------------- + // Inside the generator, not captured while the Effect was built: an Effect + // may run more than once, and shared cursor state would drop lines. + const cursorRef = yield* Ref.make({ + newestMs: entries.at(-1)?.timestampMs ?? Date.now(), + seen: new Set(entries.map((entry) => entry.id)), + }); + + // `followWindow` reaches a grace period behind the cursor so a late relay + // is still caught — which for `--tail 0` would reopen the history it was + // told to skip. Keep the wide window; filter on when the line was written. + const followFloorMs = flags.tail === 0 ? startedAtMs : Number.NEGATIVE_INFINITY; + + const pollOnce = Effect.gen(function* () { + const cursor = yield* Ref.get(cursorRef); + const rows = yield* drainSince({ + api, + projectRef, + name, + streams, + cursorMs: cursor.newestMs, + }); + + // Windows always overlap — the server rounds them to the minute and the + // cursor deliberately lags — so the dedupe is what makes the overlap + // invisible rather than a source of repeats. Pages walk backwards, so + // the concatenation is not in order until this sorts it. + const fresh = rows + .filter((row) => !cursor.seen.has(row.id) && row.timestampMs >= followFloorMs) + .sort((left, right) => left.timestampMs - right.timestampMs); + if (fresh.length === 0) { + return; + } + + yield* emitLines(fresh, "live"); + yield* Ref.set(cursorRef, advanceCursor(cursor, fresh)); + }); + + // A blip should not end a tail someone is watching. See + // `isRetryableFollowFailure` for what does not get a second attempt. + const poll = pollOnce.pipe( + Effect.retry({ schedule: readRetrySchedule, while: isRetryableFollowFailure }), + ); + + // `repeat` runs the body first, so the opening poll is immediate — it + // catches whatever landed while the history query was in flight. ~7 + // requests in the worst 60-second window, against a limit of 10. + yield* Effect.raceFirst( + poll.pipe(Effect.repeat({ schedule: pollSchedule })), + // `setExitCode`, not `exit`: `exit` calls `process.exit` synchronously, + // tearing the runtime down before this command's finalizers run. Record + // the code and let the race complete; `runCli` exits with it. + processControl + .awaitSignal() + .pipe( + Effect.flatMap((signal) => processControl.setExitCode(signal === "SIGINT" ? 130 : 0)), + ), + ); + }), + ); +}); diff --git a/apps/cli/src/legacy/commands/experimental/workers/logs/logs.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/logs/logs.integration.test.ts new file mode 100644 index 0000000000..af296bc2a3 --- /dev/null +++ b/apps/cli/src/legacy/commands/experimental/workers/logs/logs.integration.test.ts @@ -0,0 +1,979 @@ +import { rmSync } from "node:fs"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option, Schedule } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerApiLogRow, + workerIngressLogRow, + workerLogRow, + workerLogsRoute, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyWorkersFollowNotSupportedError } from "../workers.errors.ts"; +import { + InvalidWorkerNameError, + WorkerLogsQueryFailedError, + WorkerLogsRateLimitedError, + WorkerLogsUsageExceededError, + WorkerNotDeployedError, + WorkersApiNetworkError, + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, +} from "../../../../../shared/workers/workers.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { legacyWorkersLogs } from "./logs.handler.ts"; + +const ESCAPE = "\u001b"; +const CONFIG = 'project_id = "demo"\n\n[workers.api]\nruntime = "node"\n'; +const LOGS_ROUTE = `GET ${workerLogsRoute()}`; +const GET_WORKER_ROUTE = `GET ${workersRoute("/api")}`; + +const T1 = 1_788_187_525_212; +const T2 = 1_788_187_531_671; +const T3 = 1_788_187_532_576; + +function project() { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG, + "supabase/workers/api/index.js": "export default {};\n", + }); + return { dir: created.dir, cleanup: () => rmSync(created.dir, { recursive: true, force: true }) }; +} + +/** The default flag set; every test overrides only what it is about. */ +function flags(overrides: Record = {}) { + return { + name: "api", + projectRef: Option.none(), + kind: Option.none(), + tail: 100, + ...overrides, + } as Parameters[0]; +} + +/** + * Follow options that drive the loop instantly and stop after N polls. + * + * The real schedule is spaced in seconds; `recurs` also gives the tail an end, so + * a test does not have to deliver a signal just to finish. + */ +function followFor(polls: number) { + return { + pollSchedule: Schedule.recurs(polls), + retrySchedule: Schedule.recurs(0), + }; +} + +function logsResponse(rows: ReadonlyArray) { + return { status: 200, body: { result: rows, error: null } }; +} + +/** + * The query parameters the handler actually sent. + * + * Read off the recorded request rather than the URL: `HttpClientRequest` keeps + * `urlParams` beside the URL rather than appended to it. + */ +function sentQuery(request: { readonly urlParams: Readonly> }) { + return request.urlParams; +} + +describe("legacy workers logs", () => { + it.live("prints a worker's own output oldest first", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [LOGS_ROUTE]: logsResponse([ + workerLogRow({ id: "c", tsMs: T3, message: "app drained" }), + workerLogRow({ id: "a", tsMs: T1, message: "listening on :8080" }), + workerLogRow({ id: "b", tsMs: T2, message: "terminate hook" }), + ]), + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersLogs(flags()); + + // `