diff --git a/apps/cli/src/command-internal/legacy-db-target-flags.ts b/apps/cli/src/command-internal/legacy-db-target-flags.ts index 24609a253f..dbeafdc527 100644 --- a/apps/cli/src/command-internal/legacy-db-target-flags.ts +++ b/apps/cli/src/command-internal/legacy-db-target-flags.ts @@ -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", diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md new file mode 100644 index 0000000000..10d8a3ad51 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -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 `` 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. diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts new file mode 100644 index 0000000000..2104b99aad --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -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), + ), + follow: Flag.boolean("follow").pipe( + Flag.withAlias("f"), + Flag.withDescription("Continue printing new log entries until interrupted."), + Flag.withDefault(false), + ), +} 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, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts new file mode 100644 index 0000000000..28f191550b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts @@ -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; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts new file mode 100644 index 0000000000..a8e4036873 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -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; + readonly stackId: Option.Option; + readonly service: Option.Option; + 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 })), + ); + 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) => + 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; + } + 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)), + ), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts new file mode 100644 index 0000000000..9d0e5a1045 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -0,0 +1,472 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + InvalidProjectRootError, + StackIdSchema, + StackOwnershipConflictError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; +import type { + EffectStack, + OpenStackError, + StackLogBatch, + StackLogEntry, + StackDiscoveryError, + StackStatus, +} from "@supabase/stack/effect"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + ErrorActionabilityId, + actionability, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyExperimentalStackLogs } from "./logs.handler.ts"; +import { LegacyExperimentalStackLogsError } from "./logs.errors.ts"; +import { legacyExperimentalStackLogsCommand } from "./logs.command.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; + +const id = StackIdSchema.make("a".repeat(64)); +const entries: ReadonlyArray = [ + { + cursor: { opaque: "1" }, + timestamp: "2026-09-08T00:00:00.000Z", + source: "database", + stream: "stdout", + message: "database ready", + }, + { + cursor: { opaque: "2" }, + timestamp: "2026-09-08T00:00:01.000Z", + source: "functions", + stream: "stderr", + message: "function failed", + }, +]; +const internalEntry: StackLogEntry = { + cursor: { opaque: "internal-1" }, + timestamp: "2026-09-08T00:00:02.000Z", + source: "supervisor", + stream: "internal", + message: "stack supervisor ready", +}; + +const status: StackStatus = { + id, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], +}; + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + service: Option.none<"database" | "functions">(), + tail: 100, + follow: false, + ...overrides, +}); + +function setup(opts: { + root: string; + logs?: (query: unknown) => Effect.Effect; + followLogs?: (query: unknown) => Stream.Stream; + openFailure?: OpenStackError; + findFailure?: StackDiscoveryError; + noDefault?: boolean; +}) { + const out = mockOutput(); + const calls: { + readonly queries: unknown[]; + opened: string[]; + stopCalls: number; + destroyCalls: number; + } = { queries: [], opened: [], stopCalls: 0, destroyCalls: 0 }; + const stack = { + id, + status: () => Effect.succeed(status), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("must not start"), + stop: () => Effect.sync(() => void calls.stopCalls++), + destroy: () => Effect.sync(() => void calls.destroyCalls++), + logs: (query?: unknown) => { + calls.queries.push(query); + return ( + opts.logs?.(query) ?? Effect.succeed({ entries, cursor: { opaque: "2" }, running: false }) + ); + }, + followLogs: (query?: unknown) => { + calls.queries.push(query); + return opts.followLogs?.(query) ?? Stream.fromIterable(entries); + }, + } satisfies EffectStack; + const descriptor = { + id, + projectRoot: opts.root, + name: "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const layer = Layer.mergeAll( + out.layer, + mockLegacyCliSettings({ workdir: opts.root }), + Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("must not create"), + listStacks: () => Effect.succeed([]), + findStack: (query) => + opts.findFailure === undefined + ? Effect.succeed( + query.name === "missing" || (query.name === undefined && opts.noDefault) + ? Option.none() + : Option.some(descriptor), + ) + : Effect.fail(opts.findFailure), + openStack: (stackId) => + opts.openFailure === undefined + ? Effect.sync(() => { + calls.opened.push(stackId); + return stack; + }) + : Effect.fail(opts.openFailure), + inspectStack: () => Effect.die("must not inspect"), + }), + BunServices.layer, + ); + return { layer, out, calls }; +} + +describe("experimental stack logs", () => { + it.live("parses --service as a value-consuming flag", () => { + let parsed: { service: Option.Option; tail: number } | undefined; + const command = legacyExperimentalStackLogsCommand.pipe( + Command.withHandler((flags) => + Effect.sync(() => { + parsed = { service: flags.service, tail: flags.tail }; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--service", + "database", + "--tail", + "4", + ]); + expect(parsed?.service).toEqual(Option.some("database")); + expect(parsed?.tail).toBe(4); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.effect( + "reads a finite tail and passes the service filter without starting or stopping", + () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ service: Option.some("database"), tail: 2 })); + expect(setupResult.calls.queries).toEqual([{ capabilities: ["database"], tail: 2 }]); + expect(setupResult.calls.opened).toEqual([id]); + expect(setupResult.out.stdoutText).toContain("database ready"); + expect(setupResult.out.stdoutText).toContain("function failed"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); + + it.effect("rejects conflicting targets before resolving a stack", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-conflict-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs( + flags({ stack: Option.some("feature-a"), stackId: Option.some(id) }), + ).pipe(Effect.flip); + expect(failure.reason).toBe("flags"); + expect(setupResult.calls.queries).toEqual([]); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("distinguishes an absent default stack from a missing named stack", () => { + const defaultRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-default-missing-")); + const namedRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-named-missing-")); + const absent = setup({ root: defaultRoot, noDefault: true }); + const named = setup({ root: namedRoot }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags()).pipe(Effect.provide(absent.layer)); + expect(absent.out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "No managed stack found for this context.", + }), + ); + const failure = yield* legacyExperimentalStackLogs( + flags({ stack: Option.some("missing") }), + ).pipe(Effect.flip, Effect.provide(named.layer)); + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain("No managed stack named"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(defaultRoot, { recursive: true, force: true }); + rmSync(namedRoot, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "streams finite follow output and supports interruption without owner lifecycle calls", + () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-follow-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: true }), + followLogs: (query) => { + expect(query).toEqual({ cursor: { opaque: "1" } }); + return Stream.fromIterable([entries[1]!]); + }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ follow: true })); + expect(setupResult.out.stdoutText).toContain("function failed"); + const followStarted = yield* Deferred.make(); + let finalized = false; + const interrupted = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: true }), + followLogs: (): Stream.Stream => + Stream.fromEffect( + Effect.as(Deferred.succeed(followStarted, undefined), undefined), + ).pipe( + Stream.flatMap(() => Stream.empty), + Stream.concat(Stream.never), + Stream.ensuring(Effect.sync(() => void (finalized = true))), + ), + }); + const fiber = yield* Effect.forkChild( + legacyExperimentalStackLogs(flags({ follow: true })).pipe( + Effect.provide(interrupted.layer), + ), + ); + yield* Deferred.await(followStarted); + expect(interrupted.calls.opened).toEqual([id]); + yield* Fiber.interrupt(fiber); + expect(finalized).toBe(true); + expect(interrupted.calls.stopCalls).toBe(0); + expect(interrupted.calls.destroyCalls).toBe(0); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); + + it.effect("emits a bounded JSON result", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-json-result-")); + const setupResult = setup({ root }); + const output = mockOutput({ format: "json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ tail: 2 })); + expect(output.messages.find((message) => message.type === "success")?.data).toEqual({ + found: true, + id, + entries, + cursor: { opaque: "2" }, + running: false, + }); + }).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("emits bounded stream-json log-entry events", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stream-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ + entries: [internalEntry], + cursor: { opaque: "internal-1" }, + running: false, + }), + }); + const output = mockOutput({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ tail: 2 })); + expect(output.events).toEqual([ + expect.objectContaining({ + type: "log-entry", + line: internalEntry.message, + stream: "internal", + source: "history", + }), + ]); + }).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("emits history and live events while following stream-json", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stream-follow-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: true }), + followLogs: () => Stream.fromIterable([entries[1]!]), + }); + const output = mockOutput({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ follow: true })).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + ); + expect(output.events).toEqual([ + expect.objectContaining({ + type: "log-entry", + source: "history", + line: entries[0]!.message, + }), + expect.objectContaining({ type: "log-entry", source: "live", line: entries[1]!.message }), + ]); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("finishes follow after printing retained history when the stack is stopped", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stopped-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: false }), + followLogs: () => Stream.die("follow must not be opened for a stopped stack"), + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ follow: true })); + expect(setupResult.out.stdoutText).toContain("database ready"); + expect(setupResult.calls.queries).toEqual([{ tail: 100 }]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects legacy output before selecting a stack", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-output-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs(flags()).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll(setupResult.layer, Layer.succeed(LegacyOutputFlag, Option.some("json"))), + ), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects JSON follow mode before selecting a stack", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-json-")); + const setupResult = setup({ root }); + const out = mockOutput({ format: "json" }); + const layer = Layer.mergeAll(setupResult.layer, out.layer); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs(flags({ follow: true })).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackLogsError); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(failure.suggestion).toContain("stream-json"); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects invalid targets without package calls", () => + Effect.gen(function* () { + const result = setup({ root: "/tmp/unused" }); + const failure = yield* legacyExperimentalStackLogs( + flags({ stackId: Option.some("invalid") }), + ).pipe(Effect.flip, Effect.provide(result.layer)); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(result.calls.queries).toEqual([]); + expect(result.calls.opened).toEqual([]); + }), + ); + + it.effect("classifies an invalid project root as invalid config", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-invalid-root-")); + const setupResult = setup({ + root, + findFailure: new InvalidProjectRootError({ message: "Project root is invalid" }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("invalid-config"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("gives retry guidance for busy owners and upgrade guidance for old owners", () => { + const busyRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-busy-")); + const upgradeRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-upgrade-")); + const busy = setup({ + root: busyRoot, + openFailure: new StackOwnershipConflictError({ message: "Stack owner is busy" }), + }); + const upgrade = setup({ + root: upgradeRoot, + openFailure: new StackUpgradeRequiredError({ + expectedRelease: "next", + actualRelease: "current", + message: "Stack upgrade required", + }), + }); + return Effect.gen(function* () { + const busyFailure = yield* legacyExperimentalStackLogs(flags()).pipe( + Effect.flip, + Effect.provide(busy.layer), + ); + const upgradeFailure = yield* legacyExperimentalStackLogs(flags()).pipe( + Effect.flip, + Effect.provide(upgrade.layer), + ); + expect(busyFailure.suggestion).toContain("status to inspect ownership"); + expect(upgradeFailure.suggestion).toContain("compatible stack version"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(busyRoot, { recursive: true, force: true }); + rmSync(upgradeRoot, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 9a4a4183e6..e57be40dcd 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -6,6 +6,7 @@ import { legacyExperimentalStackStartCommand } from "./start/start.command.ts"; import { legacyExperimentalStackStopCommand } from "./stop/stop.command.ts"; import { legacyExperimentalStackStatusCommand } from "./status/status.command.ts"; import { legacyExperimentalStackListCommand } from "./list/list.command.ts"; +import { legacyExperimentalStackLogsCommand } from "./logs/logs.command.ts"; import { legacyExperimentalStackApiLayer, legacyExperimentalStackTargetResolverLayer, @@ -19,6 +20,7 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackStopCommand, legacyExperimentalStackStatusCommand, legacyExperimentalStackListCommand, + legacyExperimentalStackLogsCommand, ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer), diff --git a/apps/cli/src/shared/output/types.ts b/apps/cli/src/shared/output/types.ts index 981bafe186..6c935aa289 100644 --- a/apps/cli/src/shared/output/types.ts +++ b/apps/cli/src/shared/output/types.ts @@ -11,7 +11,7 @@ export type StreamEvent = readonly type: "log-entry"; readonly timestamp: string; readonly service: string; - readonly stream: "stdout" | "stderr"; + readonly stream: "stdout" | "stderr" | "internal"; readonly line: string; readonly source: "history" | "live"; } diff --git a/docs/output.md b/docs/output.md index 6329df448b..cd8dacc40c 100644 --- a/docs/output.md +++ b/docs/output.md @@ -44,6 +44,10 @@ yield* output.fail({ code: "InvalidTokenError", message: "Bad token format" }) ## How It Works +`stream-json` commands may emit `log-entry` events for line-oriented output. A log-entry event +has `type: "log-entry"`, `timestamp`, `service`, `stream`, `line`, and `source`; stack logs use +`stream: "stdout" | "stderr" | "internal"` and `source: "history" | "live"`. + Each format has its own layer implementation. The root command provides the appropriate layer based on the `--output-format` flag: ```ts