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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/cli/src/command-internal/legacy-db-target-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([
"link",
"issue-type",
"improvement",
// experimental stack start flags
// experimental stack flags
"service",
"stack",
"stack-id",
"preparation",
Expand Down
35 changes: 35 additions & 0 deletions apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# `supabase experimental stack logs`

This command reads retained logs from the managed stack identified by the current project,
an optional `--stack` name, or `--stack-id`. It calls the public `@supabase/stack` logs
and followLogs APIs without loading `supabase/config.toml`, starting the stack, stopping it,
or changing its owner lifecycle.

## Files read and written

The stack package reads its normal durable state under `<SUPABASE_HOME or ~/.supabase>` and
the selected stack's persisted log state. The CLI reads the current workdir from its normal
settings resolution. This command writes no project files, stack state, credentials, or
runtime resources.

## Output

Text mode writes one line per retained or followed entry. JSON mode writes one bounded result;
`--follow` is rejected with `--output-format json`; use the default text mode or
`--output-format stream-json`, which emits one `log-entry` event per line. Each event has
`type: "log-entry"`, `timestamp`, `service`, `stream`, `line`, and `source`; `stream` is
`stdout`, `stderr`, or `internal`, and `source` is `history` or `live`.
Follow prints the retained history first and then resumes from its returned cursor. If the stack
is already stopped, it prints the retained history and exits successfully. With no `--stack` or
`--stack-id`, an absent default stack prints a successful empty result. A missing named stack
fails with status `1`. The legacy `-o`/`--output` flag is rejected; use `--output-format`.

Successful reads, including an absent default stack and a stopped stack, exit with status `0`.
Invalid flags, missing named stacks, and stack read failures exit with status `1`. Interrupting
follow cancels the log reader, exits with status `130`, and leaves the managed stack owner
untouched.

## Telemetry

The command uses the standard command instrumentation wrapper. Stack log contents and messages
are not sent as custom telemetry properties.
60 changes: 60 additions & 0 deletions apps/cli/src/commands/experimental/stack/logs/logs.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Command, Flag } from "effect/unstable/cli";
import { CAPABILITY_NAMES } from "@supabase/stack/effect";
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts";
import { legacyExperimentalStackLogs } from "./logs.handler.ts";

const MAX_TAIL = 10_000;

const config = {
stack: Flag.string("stack").pipe(
Flag.withDescription(
"Select an existing stack by name (defaults to the current project stack).",
),
Flag.optional,
),
stackId: Flag.string("stack-id").pipe(
Flag.withDescription("Read logs from an existing stack by id."),
Flag.optional,
),
service: Flag.choice("service", CAPABILITY_NAMES).pipe(
Flag.withDescription("Limit logs to one stack service."),
Flag.optional,
),
tail: Flag.integer("tail").pipe(
Flag.filter(
(value) => value >= 0 && value <= MAX_TAIL,
(value) => `Expected --tail between 0 and ${MAX_TAIL}, got ${value}`,
),
Flag.withDescription(
"Number of retained log entries to print. Use 0 with --follow to skip retained history.",
),
Flag.withDefault(100),
),
Comment thread
jgoux marked this conversation as resolved.
follow: Flag.boolean("follow").pipe(
Flag.withAlias("f"),
Flag.withDescription("Continue printing new log entries until interrupted."),
Flag.withDefault(false),
),
Comment thread
jgoux marked this conversation as resolved.
} as const;

export const legacyExperimentalStackLogsCommand = Command.make("logs", config).pipe(
Command.withDescription("Read logs from a managed local Supabase stack."),
Command.withShortDescription("Read managed local stack logs"),
Command.withExamples([
{
command: "supabase experimental stack logs --service database --tail 50",
description: "Print the latest database logs",
},
{
command: "supabase experimental stack logs --follow --output-format stream-json",
description: "Stream new stack logs as structured events",
},
]),
Command.withHandler((flags) =>
legacyExperimentalStackLogs(flags).pipe(
withLegacyCommandInstrumentation({ flags, config, aliases: { f: "follow" } }),
withJsonErrorHandling,
),
),
);
30 changes: 30 additions & 0 deletions apps/cli/src/commands/experimental/stack/logs/logs.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Data } from "effect";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
} from "../../../../shared/telemetry/error-actionability.ts";

export class LegacyExperimentalStackLogsError extends Data.TaggedError(
"LegacyExperimentalStackLogsError",
)<{
readonly reason: "flags" | "invalid-config" | "lifecycle" | "impossible-state" | "unknown";
readonly message: string;
readonly suggestion?: string;
readonly cause?: unknown;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
switch (this.reason) {
case "flags":
return actionability.provideFlags;
case "invalid-config":
return actionability.invalidConfig;
case "lifecycle":
return actionability.invalidConfig;
case "impossible-state":
return actionability.impossibleState;
case "unknown":
return actionability.unknown;
}
}
}
166 changes: 166 additions & 0 deletions apps/cli/src/commands/experimental/stack/logs/logs.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Effect, Match, Option, Stream } from "effect";
import {
isStackError,
isStackId,
StackIdSchema,
type CapabilityName,
type StackLogEntry,
} from "@supabase/stack/effect";
import { Output } from "../../../../shared/output/output.service.ts";
import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts";
import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts";
import { LegacyExperimentalStackApi } from "../stack.shared.ts";
import { LegacyExperimentalStackLogsError } from "./logs.errors.ts";

type LegacyExperimentalStackLogsFlags = {
readonly stack: Option.Option<string>;
readonly stackId: Option.Option<string>;
readonly service: Option.Option<CapabilityName>;
readonly tail: number;
readonly follow: boolean;
};

const logsError = (error: unknown): LegacyExperimentalStackLogsError => {
const stackError = isStackError(error) ? error : undefined;
const classification =
stackError === undefined
? ("unknown" as const)
: Match.value(stackError).pipe(
Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({
reason: "flags" as const,
})),
Match.tag("InvalidLogCursorError", () => ({ reason: "impossible-state" as const })),
Match.tag("StackNotRunningError", () => ({
reason: "lifecycle" as const,
suggestion: "Run supabase experimental stack start before reading logs.",
})),
Match.tag("StackOwnershipConflictError", () => ({
reason: "lifecycle" as const,
suggestion:
"Run supabase experimental stack status to inspect ownership; retry if the stack is shutting down.",
})),
Match.tag("StackLifecycleConflictError", () => ({
reason: "lifecycle" as const,
suggestion: "The stack owner is shutting down; retry shortly.",
})),
Match.tag("StackUpgradeRequiredError", () => ({
reason: "lifecycle" as const,
suggestion: "Upgrade the CLI to a compatible stack version before reading logs.",
})),
Match.tag("StackStateInvalidError", "StackStateFormatUnsupportedError", () => ({
reason: "invalid-config" as const,
})),
Match.tag("InvalidProjectRootError", () => ({ reason: "invalid-config" as const })),
Match.orElse(() => ({ reason: "unknown" as const })),
Comment thread
jgoux marked this conversation as resolved.
);
return new LegacyExperimentalStackLogsError({
reason: typeof classification === "string" ? classification : classification.reason,
message: stackError?.message ?? String(error),
...(typeof classification !== "string" && "suggestion" in classification
? { suggestion: classification.suggestion }
: {}),
cause: error,
});
};

const renderEntry = (entry: StackLogEntry) =>
`${entry.timestamp} ${entry.source}/${entry.stream}: ${entry.message}\n`;

const eventForEntry = (entry: StackLogEntry, source: "history" | "live") => ({
type: "log-entry" as const,
timestamp: entry.timestamp,
service: entry.source,
stream: entry.stream,
line: entry.message,
source,
});

export const legacyExperimentalStackLogs = Effect.fn("legacy.experimental.stack.logs")(function* (
flags: LegacyExperimentalStackLogsFlags,
) {
const output = yield* Output;
const settings = yield* LegacyCliSettings;
const stackApi = yield* LegacyExperimentalStackApi;
const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag);
if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value))
return yield* new LegacyExperimentalStackLogsError({
reason: "flags",
message: "The legacy -o/--output flag is not supported here; use --output-format json.",
suggestion: "Use --output-format json, --output-format text, or --output-format stream-json.",
});
if (Option.isSome(flags.stack) && Option.isSome(flags.stackId))
return yield* new LegacyExperimentalStackLogsError({
reason: "flags",
message: "--stack and --stack-id cannot be used together",
});
if (flags.follow && output.format === "json")
return yield* new LegacyExperimentalStackLogsError({
reason: "flags",
message: "--follow cannot be combined with --output-format json.",
suggestion: "Use --output-format stream-json for follow mode, or omit --follow.",
});

const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined;
const targetOption =
id === undefined
? yield* stackApi
.findStack({
projectRoot: settings.workdir,
...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}),
})
.pipe(Effect.mapError(logsError))
: yield* isStackId(id)
? Effect.succeed(Option.some({ id: StackIdSchema.make(id) }))
: Effect.fail(
new LegacyExperimentalStackLogsError({
reason: "flags",
message: "--stack-id must be a lowercase SHA-256 stack id",
}),
);
if (Option.isNone(targetOption)) {
if (Option.isSome(flags.stack))
return yield* new LegacyExperimentalStackLogsError({
reason: "flags",
message: `No managed stack named "${flags.stack.value}" was found for this project.`,
});
yield* output.success("No managed stack found for this context.", {
found: false,
entries: [],
});
return;
}
const stack = yield* stackApi.openStack(targetOption.value.id).pipe(Effect.mapError(logsError));
const query = {
...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}),
tail: flags.tail,
};
const batch = yield* stack.logs(query).pipe(Effect.mapError(logsError));
const emitEntries = (source: "history" | "live", entries: ReadonlyArray<StackLogEntry>) =>
output.format === "stream-json"
? Effect.forEach(entries, (entry) => output.event(eventForEntry(entry, source)), {
discard: true,
})
: Effect.forEach(entries, (entry) => output.raw(renderEntry(entry)), { discard: true });
if (!flags.follow) {
if (output.format === "json") {
yield* output.success("", { found: true, id: stack.id, ...batch });
} else {
yield* emitEntries("history", batch.entries);
}
return;
}
Comment thread
jgoux marked this conversation as resolved.
yield* emitEntries("history", batch.entries);
if (!batch.running) return;
const followQuery = {
...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}),
cursor: batch.cursor,
};
yield* stack.followLogs(followQuery).pipe(
Stream.mapError(logsError),
Stream.runForEach((entry) =>
output.format === "stream-json"
? output.event(eventForEntry(entry, "live"))
: output.raw(renderEntry(entry)),
),
);
});
Loading
Loading