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
4 changes: 4 additions & 0 deletions .oxlintrc.effect.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"!apps/cli/src/commands/encryption/**",
"!apps/cli/src/commands/experimental/**",
"!apps/cli/src/commands/feedback/**",
"!apps/cli/src/commands/functions/functions.command.ts",
"!apps/cli/src/commands/functions/delete/**",
"!apps/cli/src/commands/functions/list/**",
"!apps/cli/src/commands/functions/new/**",
"!apps/cli/src/commands/init/**",
"!apps/cli/src/commands/issue/**",
"!apps/cli/src/commands/link/**",
Expand Down
120 changes: 69 additions & 51 deletions apps/cli/src/commands/functions/delete/delete.live.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,76 @@
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";

import { BunServices } from "@effect/platform-bun";
import { Cause, Data, Effect, Exit, FileSystem, Path } from "effect";
import { expect } from "vitest";

import { test, throwWithCleanup } from "../../../../tests/helpers/live.ts";

async function cleanupFunction(
cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>,
slug: string,
ref: string,
): Promise<void> {
const deleted = await cli(["functions", "delete", slug, "--project-ref", ref]);
if (
deleted.exitCode !== 0 &&
!/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`)
) {
throw new Error(`functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`);
}
import { type LiveFixtures, test, throwWithCleanup } from "../../../../tests/helpers/live.ts";

type LiveCliEffect = LiveFixtures["cliEffect"];

class FunctionsDeleteLiveError extends Data.TaggedError("FunctionsDeleteLiveError")<{
readonly message: string;
}> {}

function cleanupFunction(cliEffect: LiveCliEffect, slug: string, ref: string) {
return Effect.gen(function* () {
const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref]);
if (
deleted.exitCode !== 0 &&
!/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`)
) {
return yield* new FunctionsDeleteLiveError({
message: `functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`,
});
}
});
}

test("deletes a deployed function", async ({ cli, project, workspace }) => {
const slug = `cli-e2e-delete-${randomUUID().slice(0, 8)}`;
const directory = `${workspace.path}/supabase/functions/${slug}`;
await mkdir(directory, { recursive: true });
await writeFile(`${directory}/index.ts`, "Deno.serve(() => Response.json({ ok: true }));\n");
await writeFile(`${directory}/deno.json`, '{\n "imports": {}\n}\n');

let targetError: unknown;
let cleanupError: unknown;
try {
const deployed = await cli([
"functions",
"deploy",
slug,
"--project-ref",
project.ref,
"--use-api",
]);
if (deployed.exitCode !== 0) {
throw new Error(
`functions deploy setup failed (exit ${deployed.exitCode})\nstdout:\n${deployed.stdout}\nstderr:\n${deployed.stderr}`,
test("deletes a deployed function", ({ cliEffect, project, workspace }) =>
Effect.runPromise(
Comment thread
7ttp marked this conversation as resolved.
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const slug = `cli-e2e-delete-${randomUUID().slice(0, 8)}`;
const directory = path.join(workspace.path, "supabase", "functions", slug);
yield* fs.makeDirectory(directory, { recursive: true });
yield* fs.writeFileString(
path.join(directory, "index.ts"),
"Deno.serve(() => Response.json({ ok: true }));\n",
);
}
yield* fs.writeFileString(path.join(directory, "deno.json"), '{\n "imports": {}\n}\n');

const result = await cli(["functions", "delete", slug, "--project-ref", project.ref]);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Deleted Function");
} catch (error) {
targetError = error;
} finally {
try {
await cleanupFunction(cli, slug, project.ref);
} catch (error) {
cleanupError = error;
}
}
throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]);
});
const target = Effect.gen(function* () {
const deployed = yield* cliEffect([
"functions",
"deploy",
slug,
"--project-ref",
project.ref,
"--use-api",
]);
if (deployed.exitCode !== 0) {
return yield* new FunctionsDeleteLiveError({
message: `functions deploy setup failed (exit ${deployed.exitCode})\nstdout:\n${deployed.stdout}\nstderr:\n${deployed.stderr}`,
});
}

const result = yield* cliEffect([
"functions",
"delete",
slug,
"--project-ref",
project.ref,
]);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Deleted Function");
});

const targetExit = yield* Effect.exit(target);
const cleanupExit = yield* Effect.exit(cleanupFunction(cliEffect, slug, project.ref));
return {
targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined,
cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [],
};
}).pipe(Effect.provide(BunServices.layer)),
).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors)));
22 changes: 11 additions & 11 deletions apps/cli/src/commands/functions/list/list.format.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { DateTime, Option } from "effect";

import { renderGlamourTable } from "../../../output/glamour-table.ts";
import type { Functions } from "./list.encoders.ts";

const pad2 = (value: number): string => value.toString().padStart(2, "0");

export function formatUnixMilliTimestamp(value: number): string {
const date = new Date(value);
const parts = [
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
];
const [year, ...rest] = parts.map((part) => part.toString().padStart(2, "0"));
return `${year}-${rest[0]}-${rest[1]} ${rest[2]}:${rest[3]}:${rest[4]}`;
return DateTime.make(value).pipe(
Option.map((dateTime) => {
const utc = DateTime.toPartsUtc(dateTime);
return `${pad2(utc.year)}-${pad2(utc.month)}-${pad2(utc.day)} ${pad2(utc.hour)}:${pad2(utc.minute)}:${pad2(utc.second)}`;
}),
Option.getOrElse(() => "NaN-NaN-NaN NaN:NaN:NaN"),
);
}

export function renderFunctionsTable(functions: Functions): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ describe("formatUnixMilliTimestamp", () => {
it("pads single-digit UTC components", () => {
expect(formatUnixMilliTimestamp(Date.UTC(2024, 0, 2, 3, 4, 5))).toBe("2024-01-02 03:04:05");
});

it("renders invalid millis the way native Date getters do", () => {
expect(formatUnixMilliTimestamp(Number.NaN)).toBe("NaN-NaN-NaN NaN:NaN:NaN");
expect(formatUnixMilliTimestamp(1e20)).toBe("NaN-NaN-NaN NaN:NaN:NaN");
});
});
46 changes: 23 additions & 23 deletions apps/cli/src/commands/functions/list/list.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { V1ListAllFunctionsOutput } from "@supabase/api/effect";
import { describe, expect, it } from "@effect/vitest";
import { Effect, Exit, Layer, Option } from "effect";
import { Cause, Effect, Exit, Layer, Option } from "effect";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";

import {
Expand Down Expand Up @@ -204,9 +204,9 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsEnvNotSupportedError");
expect(json).toContain("--output env flag is not supported");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsEnvNotSupportedError");
expect(causeText).toContain("--output env flag is not supported");
}
}).pipe(Effect.provide(layer));
});
Expand Down Expand Up @@ -270,9 +270,9 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsListUnexpectedStatusError");
expect(json).toContain("unexpected list functions status 503");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsListUnexpectedStatusError");
expect(causeText).toContain("unexpected list functions status 503");
}
}).pipe(Effect.provide(layer));
});
Expand All @@ -283,9 +283,9 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsListNetworkError");
expect(json).toContain("failed to list functions");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsListNetworkError");
expect(causeText).toContain("failed to list functions");
}
}).pipe(Effect.provide(layer));
});
Expand All @@ -310,9 +310,9 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsListNetworkError");
expect(json).toContain("failed to list functions:");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsListNetworkError");
expect(causeText).toContain("failed to list functions:");
}
}).pipe(Effect.provide(layer));
});
Expand All @@ -337,10 +337,10 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsListUnexpectedStatusError");
expect(json).toContain("unexpected list functions status 200");
expect(json).toContain("Hello World");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsListUnexpectedStatusError");
expect(causeText).toContain("unexpected list functions status 200");
expect(causeText).toContain("Hello World");
}
}).pipe(Effect.provide(layer));
});
Expand All @@ -351,9 +351,9 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsListNetworkError");
expect(json).toContain("failed to list functions");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsListNetworkError");
expect(causeText).toContain("failed to list functions");
}
}).pipe(Effect.provide(layer));
});
Expand All @@ -364,9 +364,9 @@ describe("functions list integration", () => {
const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() }));
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
expect(json).toContain("FunctionsListNetworkError");
expect(json).toContain("failed to list functions");
const causeText = Cause.pretty(exit.cause);
expect(causeText).toContain("FunctionsListNetworkError");
expect(causeText).toContain("failed to list functions");
}
}).pipe(Effect.provide(layer));
});
Expand Down
25 changes: 15 additions & 10 deletions apps/cli/src/commands/functions/list/list.live.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Effect } from "effect";
import { describe, expect } from "vitest";

import { test } from "../../../../tests/helpers/live.ts";
Expand All @@ -10,15 +11,19 @@ describe("supabase functions list (live)", () => {
test(
"lists edge functions for the project",
{ timeout: LIVE_TIMEOUT_MS },
async ({ cli, project }) => {
const { exitCode, stdout, stderr } = await cli([
"functions",
"list",
"--project-ref",
project.ref,
]);
expect(exitCode, stderr).toBe(0);
expect(stdout, stderr).toMatch(/ID\s+\|\s+NAME\s+\|\s+SLUG\s+\|\s+STATUS/);
},
({ cliEffect, project, signal }) =>
Effect.runPromise(
Effect.gen(function* () {
const { exitCode, stdout, stderr } = yield* cliEffect([
"functions",
"list",
"--project-ref",
project.ref,
]);
expect(exitCode, stderr).toBe(0);
expect(stdout, stderr).toMatch(/ID\s+\|\s+NAME\s+\|\s+SLUG\s+\|\s+STATUS/);
}),
{ signal },
),
);
});
22 changes: 9 additions & 13 deletions apps/cli/src/commands/functions/new/new.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,10 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio

const invalidSlugMessage = validateFunctionSlugMessage(flags.functionName);
if (invalidSlugMessage !== undefined) {
return yield* Effect.fail(
new FunctionsNewInvalidSlugError({
message: invalidSlugMessage,
detail: invalidFunctionSlugDetail,
}),
);
return yield* new FunctionsNewInvalidSlugError({
message: invalidSlugMessage,
detail: invalidFunctionSlugDetail,
});
}

const existingSlugs = yield* listExistingFunctionSlugs(cliSettings.workdir);
Expand All @@ -205,13 +203,11 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio
.exists(entrypointPath)
.pipe(Effect.orElseSucceed(() => false));
if (entrypointExists) {
return yield* Effect.fail(
new FunctionsNewFileExistsError({
path: relEntrypoint,
message: "failed to create entrypoint: file already exists",
suggestion: `Remove ${relEntrypoint} or use a different Function name.`,
}),
);
return yield* new FunctionsNewFileExistsError({
path: relEntrypoint,
message: "failed to create entrypoint: file already exists",
suggestion: `Remove ${relEntrypoint} or use a different Function name.`,
});
}

const templateInputs = yield* resolveTemplateInputs(cliSettings, flags.functionName);
Expand Down
Loading
Loading