diff --git a/alchemy-web/src/content/docs/providers/vercel/storage.md b/alchemy-web/src/content/docs/providers/vercel/storage.md new file mode 100644 index 000000000..4571ea7fd --- /dev/null +++ b/alchemy-web/src/content/docs/providers/vercel/storage.md @@ -0,0 +1,93 @@ +--- +title: Storage +description: Create and manage Vercel Storage (Blob) with Alchemy +--- + +Create and manage Vercel Storage. Blob Storage is currently supported. + +## Authentication + +You can authenticate with Vercel in one of two ways: + +1. Set `VERCEL_ACCESS_TOKEN` in your `.env` file +2. Pass `accessToken` directly in your resource configuration + +## Examples + +### With `accessToken` + +```ts +const storage = await Storage("my-storage", { + accessToken: alchemy.secret(process.env.VERCEL_ACCESS_TOKEN), + name: "my-storage", + region: "iad1", + team: "team_abc123", + type: "blob", +}); +``` + +### Minimal + +```ts +const storage = await Storage("my-storage", { + name: "my-storage", + region: "cdg1", + team: "team_abc123", + type: "blob", +}); +``` + +### Connect projects + +Automatically connect projects and create environment variable bindings. + +```ts +const storage = await Storage("my-storage", { + name: "my-storage", + projects: [ + { + projectId: "prj_123", + envVarEnvironments: ["production", "preview"], + envVarPrefix: "MY_STORAGE_", + }, + ], + region: "iad1", + team: "team_abc123", + type: "blob", +}); +``` + +### Update connections + +Updating `projects` will add, remove, or re-create connections as needed. Changing `name`, `region`, `team`, or `type` will replace the Storage resource. + +```ts +const storage = await Storage("my-storage", { + name: "my-storage", + projects: [ + { + projectId: "prj_456", + envVarEnvironments: ["production"], + envVarPrefix: "BLOB_", + }, + ], + region: "iad1", + team: "team_abc123", + type: "blob", +}); +``` + +## Props + +- **name**: Optional display name for the storage. Defaults to the resource id +- **region**: One of Vercel regions (e.g. `"iad1"`, `"cdg1"`) +- **team**: Team id or a `VercelTeam` object +- **type**: Storage type. Currently only `"blob"` +- **projects**: Optional list of project connections with `projectId`, `envVarEnvironments`, and optional `envVarPrefix` + +## Notes + +- When replacing storage (changing `name`, `region`, `team`, or `type`), the old storage is deleted and a new one created +- Project connections are reconciled during updates: new ones are created, removed ones are disconnected, and changed ones are re-created + + diff --git a/alchemy/src/vercel/index.ts b/alchemy/src/vercel/index.ts index fafccab56..6cac759e1 100644 --- a/alchemy/src/vercel/index.ts +++ b/alchemy/src/vercel/index.ts @@ -1,3 +1,4 @@ export * from "./api.ts"; export * from "./project-domain.ts"; export * from "./project.ts"; +export * from "./storage.ts"; diff --git a/alchemy/src/vercel/project.ts b/alchemy/src/vercel/project.ts index 6bc1196cf..f039a4129 100644 --- a/alchemy/src/vercel/project.ts +++ b/alchemy/src/vercel/project.ts @@ -3,8 +3,7 @@ import { Resource } from "../resource.ts"; import { isSecret, type Secret } from "../secret.ts"; import { logger } from "../util/logger.ts"; import { createVercelApi, type VercelApi } from "./api.ts"; - -type TargetEnvironment = "production" | "preview" | "development"; +import type { VercelEnvironments, VercelFrameworks } from "./vercel.types.ts"; export type EnvironmentVariable = { /** @@ -15,7 +14,7 @@ export type EnvironmentVariable = { /** * The target environment */ - target: TargetEnvironment[]; + target: VercelEnvironments; /** * The Git branch @@ -80,54 +79,7 @@ export interface ProjectProps { /** * The framework that is being used for this project. When `null` is used no framework is selected */ - framework?: - | "blitzjs" - | "nextjs" - | "gatsby" - | "remix" - | "react-router" - | "astro" - | "hexo" - | "eleventy" - | "docusaurus-2" - | "docusaurus" - | "preact" - | "solidstart-1" - | "solidstart" - | "dojo" - | "ember" - | "vue" - | "scully" - | "ionic-angular" - | "angular" - | "polymer" - | "svelte" - | "sveltekit" - | "sveltekit-1" - | "ionic-react" - | "create-react-app" - | "gridsome" - | "umijs" - | "sapper" - | "saber" - | "stencil" - | "nuxtjs" - | "redwoodjs" - | "hugo" - | "jekyll" - | "brunch" - | "middleman" - | "zola" - | "hydrogen" - | "vite" - | "vitepress" - | "vuepress" - | "parcel" - | "fasthtml" - | "sanity-v3" - | "sanity" - | "storybook" - | (string & {}); + framework?: VercelFrameworks; /** * The Git Repository that will be connected to the project. When this is defined, any pushes to the specified connected Git Repository will be automatically deployed diff --git a/alchemy/src/vercel/storage.ts b/alchemy/src/vercel/storage.ts new file mode 100644 index 000000000..3a46a5cf8 --- /dev/null +++ b/alchemy/src/vercel/storage.ts @@ -0,0 +1,250 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import type { Secret } from "../secret.ts"; +import { createVercelApi } from "./api.ts"; +import type { + StorageProject, + StorageProjectMetadata, + StorageType, +} from "./storage.types.ts"; +import { + createProjectsConnection, + createStorage, + deleteProjectsConnection, + deleteStorage, + projectPropsChanged, + readStorage, +} from "./storage.utils.ts"; +import type { VercelRegions, VercelTeam } from "./vercel.types.ts"; + +export interface StorageProps { + /** + * The name of the storage + */ + name?: string; + + /** + * The region of the storage + */ + region: VercelRegions; + + /** + * The team of the storage + */ + team: string | VercelTeam; + + /** + * The type of the storage + */ + type: StorageType; + + /** + * The projects that the storage belongs to + */ + projects?: StorageProject[]; +} + +export interface Storage extends Resource<"vercel::Storage">, StorageProps { + /** + * The billing state of the storage + */ + billingState: string; + + /** + * The number of connections to the storage + */ + count: number; + + /** + * The creation time of the storage + */ + createdAt: number; + + /** + * The ID of the storage + */ + id: string; + + /** + * The name of the storage + */ + name: string; + + /** + * The owner ID of the storage + */ + ownerId: string; + + /** + * The projects metadata of the storage + */ + projectsMetadata: StorageProjectMetadata[]; + + /** + * The region of the storage + */ + region: VercelRegions; + + /** + * The size of the storage + */ + size: number; + + /** + * The status of the storage + */ + status: string; + + /** + * The type of the storage + */ + type: StorageType; + + /** + * The update time of the storage + */ + updatedAt: number; + + /** + * Whether the usage quota has been exceeded + */ + usageQuotaExceeded: boolean; +} + +/** + * Create and manage Vercel storage resources. + * Blob Storage support only for now. + * + * @example + * // With accessToken + * const storage = await Storage("my-storage", { + * accessToken: alchemy.secret(process.env.VERCEL_ACCESS_TOKEN), + * name: "my-storage", + * region: "iad1", + * team: "my-team", + * type: "blob" + * }); + * + * @example + * // Connect Projects to the Storage + * const storage = await Storage("my-storage", { + * name: "my-storage", + * projects: [ + * { + * projectId: "project_123", + * envVarEnvironments: ["production"], + * envVarPrefix: "MY_STORAGE_", + * }, + * ], + * region: "cdg1", + * team: "my-team", + * type: "blob" + * }); + */ +export const Storage = Resource( + "vercel::Storage", + async function ( + this: Context, + id: string, + { accessToken, ...props }: StorageProps & { accessToken?: Secret }, + ): Promise { + const api = await createVercelApi({ + baseUrl: "https://api.vercel.com/v1", + accessToken, + }); + switch (this.phase) { + case "create": { + const storage = await createStorage(api, props); + + let output = { ...props, ...storage.store }; + + if (!output.name) { + output.name = id; + } + + if (props.projects && props.projects.length > 0) { + await createProjectsConnection(api, output, props.projects); + const updatedStorage = await readStorage(api, output); + output = { ...props, ...updatedStorage.store }; + } + + return this(output); + } + + case "update": { + if ( + props.name !== this.output.name || + props.region !== this.output.region || + props.team !== this.output.team || + props.type !== this.output.type + ) { + // if the storage is being replaced, we need to delete the old storage + // name can be a conflict and we can't change the remaining properties + return this.replace(true); + } + + const newProjects = props.projects ?? []; + const newProjectsMap = new Map( + newProjects.map((p) => [p.projectId, p]), + ); + + const currentProjects = this.output.projects ?? []; + const currentProjectsMap = new Map( + currentProjects.map((p) => [p.projectId, p]), + ); + + const projectsMetadata = this.output.projectsMetadata ?? []; + + // determine which projects to create and which to delete + const toCreate: typeof newProjects = []; + const toDelete: typeof currentProjects = []; + + // find new or changed projects to create or re-create + for (const newProject of newProjects) { + const existing = currentProjectsMap.get(newProject.projectId); + if (!existing) { + toCreate.push(newProject); + } else if (projectPropsChanged(newProject, existing)) { + toDelete.push(existing); + toCreate.push(newProject); + } + } + + // find removed projects to delete + for (const currentProject of currentProjects) { + if (!newProjectsMap.has(currentProject.projectId)) { + toDelete.push(currentProject); + } + } + + const toDeleteMetadata: typeof projectsMetadata = []; + for (const delProject of toDelete) { + const metas = projectsMetadata.filter( + (meta) => meta.projectId === delProject.projectId, + ); + toDeleteMetadata.push(...metas); + } + + if (toDelete.length > 0) { + await deleteProjectsConnection(api, this.output, toDeleteMetadata); + } + + if (toCreate.length > 0) { + await createProjectsConnection(api, this.output, toCreate); + } + + if (toDelete.length > 0 || toCreate.length > 0) { + const updatedStorage = await readStorage(api, this.output); + this.output = updatedStorage.store; + } + + return this({ ...this.output, ...props }); + } + + case "delete": { + await deleteStorage(api, this.output); + return this.destroy(); + } + } + }, +); diff --git a/alchemy/src/vercel/storage.types.ts b/alchemy/src/vercel/storage.types.ts new file mode 100644 index 000000000..5f9bb80b8 --- /dev/null +++ b/alchemy/src/vercel/storage.types.ts @@ -0,0 +1,19 @@ +import type { VercelEnvironment } from "./vercel.types.ts"; + +export type StorageType = "blob"; + +export interface StorageProject { + projectId: string; + envVarEnvironments: VercelEnvironment[]; + envVarPrefix?: string; +} + +export interface StorageProjectMetadata { + environments: VercelEnvironment[]; + environmentVariables: string[]; + envVarPrefix?: string; + framework: string; + id: string; + name: string; + projectId: string; +} diff --git a/alchemy/src/vercel/storage.utils.ts b/alchemy/src/vercel/storage.utils.ts new file mode 100644 index 000000000..74dd58b40 --- /dev/null +++ b/alchemy/src/vercel/storage.utils.ts @@ -0,0 +1,93 @@ +import { fromPromise } from 'neverthrow'; +import type { VercelApi } from './api.ts'; +import type { Storage, StorageProps } from './storage.ts'; +import type { StorageProject, StorageProjectMetadata } from './storage.types.ts'; +import type { VercelTeam } from './vercel.types.ts'; + +function getTeamId(team: string | VercelTeam) { + return typeof team === 'string' ? team : team.id; +} + +export function projectPropsChanged(a: StorageProject, b: StorageProject) { + return ( + a.projectId !== b.projectId || + a.envVarPrefix !== b.envVarPrefix || + JSON.stringify(a.envVarEnvironments) !== JSON.stringify(b.envVarEnvironments) + ); +} + +export async function readStorage(api: VercelApi, output: Storage) { + const teamId = getTeamId(output.team); + const response = await fromPromise(api.get(`/storage/stores/${output.id}?teamId=${teamId}`), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } + + return response.value.json() as Promise<{ store: Storage }>; +} + +export async function createStorage(api: VercelApi, props: StorageProps) { + const teamId = getTeamId(props.team); + const response = await fromPromise(api.post(`/storage/stores/${props.type}?teamId=${teamId}`, { + name: props.name, + region: props.region, + }), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } + + return response.value.json() as Promise<{ store: Storage }>; +} + +export async function deleteStorage(api: VercelApi, output: Storage) { + const teamId = getTeamId(output.team); + const connections = await fromPromise(api.delete(`/storage/stores/${output.id}/connections?teamId=${teamId}`), (err) => err as Error); + + if (connections.isErr()) { + throw connections.error; + } + + const storage = await fromPromise(api.delete(`/storage/stores/${output.type}/${output.id}?teamId=${teamId}`), (err) => err as Error); + + if (storage.isErr()) { + throw storage.error; + } +} + +export async function createProjectsConnection(api: VercelApi, output: Storage, projects: StorageProject[]) { + // Promise.all didn't worked well with the API, so we're using a for loop instead + for (const project of projects) { + await connectProject(api, output, project); + } +} + +export async function deleteProjectsConnection(api: VercelApi, output: Storage, projectsMetadata: StorageProjectMetadata[]) { + // Promise.all didn't worked well with the API, so we're using a for loop instead + for (const metadata of projectsMetadata) { + await disconnectProject(api, output, metadata); + } +} + +export async function connectProject(api: VercelApi, output: Storage, project: StorageProject) { + const teamId = getTeamId(output.team); + const response = await fromPromise(api.post(`/storage/stores/${output.id}/connections?teamId=${teamId}`, { + envVarEnvironments: project.envVarEnvironments, + envVarPrefix: project.envVarPrefix, + projectId: project.projectId, + }), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } +} + +export async function disconnectProject(api: VercelApi, output: Storage, metadata: StorageProjectMetadata) { + const teamId = getTeamId(output.team); + const response = await fromPromise(api.delete(`/storage/stores/${output.id}/connections/${metadata.id}?teamId=${teamId}`), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } +} \ No newline at end of file diff --git a/alchemy/src/vercel/vercel.types.ts b/alchemy/src/vercel/vercel.types.ts new file mode 100644 index 000000000..393116e2c --- /dev/null +++ b/alchemy/src/vercel/vercel.types.ts @@ -0,0 +1,78 @@ +export type VercelEnvironment = "development" | "preview" | "production"; + +export type VercelRegions = + | "cpt1" // Cape Town, South Africa + | "cle1" // Cleveland, USA + | "dxb1" // Dubai, UAE + | "dub1" // Dublin, Ireland + | "fra1" // Frankfurt, Germany + | "hkg1" // Hong Kong + | "lhr1" // London, UK + | "bom1" // Mumbai, India + | "kix1" // Osaka, Japan + | "cdg1" // Paris, France + | "pdx1" // Portland, USA + | "sfo1" // San Francisco, USA + | "gru1" // São Paulo, Brazil + | "icn1" // Seoul, South Korea + | "sin1" // Singapore + | "arn1" // Stockholm, Sweden + | "syd1" // Sydney, Australia + | "hnd1" // Tokyo, Japan + | "iad1" // Washington, D.C., USA + | (string & {}); // special rune to maintain auto-suggestions without closing the type to new regions or ones we missed + +export type VercelFrameworks = + | "blitzjs" + | "nextjs" + | "gatsby" + | "remix" + | "react-router" + | "astro" + | "hexo" + | "eleventy" + | "docusaurus-2" + | "docusaurus" + | "preact" + | "solidstart-1" + | "solidstart" + | "dojo" + | "ember" + | "vue" + | "scully" + | "ionic-angular" + | "angular" + | "polymer" + | "svelte" + | "sveltekit" + | "sveltekit-1" + | "ionic-react" + | "create-react-app" + | "gridsome" + | "umijs" + | "sapper" + | "saber" + | "stencil" + | "nuxtjs" + | "redwoodjs" + | "hugo" + | "jekyll" + | "brunch" + | "middleman" + | "zola" + | "hydrogen" + | "vite" + | "vitepress" + | "vuepress" + | "parcel" + | "fasthtml" + | "sanity-v3" + | "sanity" + | "storybook" + | (string & {}); + +export type VercelTeam = { + id: string; + slug: string; + name: string; +}; diff --git a/alchemy/test/vercel/storage.test.ts b/alchemy/test/vercel/storage.test.ts new file mode 100644 index 000000000..d9369dbf4 --- /dev/null +++ b/alchemy/test/vercel/storage.test.ts @@ -0,0 +1,100 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { createVercelApi } from "../../src/vercel/api.ts"; +import { Project } from "../../src/vercel/project.ts"; +import { Storage } from "../../src/vercel/storage.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +// must import this or else alchemy.test won't exist +import "../../src/test/vitest.ts"; + +const api = await createVercelApi({ + baseUrl: "https://api.vercel.com/v1", +}); + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Vercel Storage Resource", () => { + const testId = `${BRANCH_PREFIX}-test-storage`; + + test("create, connect projects, update connections, and delete storage", async (scope) => { + let storage: Storage | undefined; + let project: Project | undefined; + + try { + // Create a project to connect + project = await Project(`${BRANCH_PREFIX}-test-project`, { + name: `${BRANCH_PREFIX}-test-project`, + framework: "nextjs", + }); + + // Create storage with a connection to the project + storage = await Storage(testId, { + name: testId, + region: "iad1", + team: `team_${BRANCH_PREFIX}`, + type: "blob", + projects: [ + { + projectId: project.id, + envVarEnvironments: ["production", "preview"], + envVarPrefix: "MY_STORAGE_", + }, + ], + }); + + expect(storage.id).toBeTruthy(); + expect(storage.name).toEqual(testId); + expect(storage.type).toEqual("blob"); + + // Verify created via API + const getResponse = await api.get( + `/storage/stores/${storage.id}?teamId=${typeof storage.team === "string" ? storage.team : storage.team.id}`, + ); + expect(getResponse.status).toEqual(200); + const responseData: any = await getResponse.json(); + expect(responseData.store?.id).toEqual(storage.id); + + // Update connections: remove previous, add a new prefix + storage = await Storage(testId, { + name: testId, + region: storage.region, + team: storage.team, + type: storage.type, + projects: [ + { + projectId: project.id, + envVarEnvironments: ["production"], + envVarPrefix: "BLOB_", + }, + ], + }); + + expect(storage.id).toBeTruthy(); + expect(storage.projectsMetadata).toBeInstanceOf(Array); + + // Verify still exists + const getUpdatedResponse = await api.get( + `/storage/stores/${storage.id}?teamId=${typeof storage.team === "string" ? storage.team : storage.team.id}`, + ); + expect(getUpdatedResponse.status).toEqual(200); + } catch (err) { + console.log(err); + throw err; + } finally { + await destroy(scope); + + // Verify storage deleted + const error = await api + .get( + `/storage/stores/${storage?.id}?teamId=${storage && (typeof storage.team === "string" ? storage.team : storage.team.id)}`, + ) + .catch((error) => error); + + expect(error.cause?.status).toEqual(404); + } + }); +});