From 97f9b0d17671d4b7ff642b5066df1819ab42ffb7 Mon Sep 17 00:00:00 2001 From: Eduardo Rabelo Date: Thu, 24 Jul 2025 11:15:29 +1200 Subject: [PATCH 1/5] feat(vercel): add Storage resource for managing Vercel storage --- alchemy/src/vercel/index.ts | 1 + alchemy/src/vercel/storage.ts | 184 ++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 alchemy/src/vercel/storage.ts 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/storage.ts b/alchemy/src/vercel/storage.ts new file mode 100644 index 000000000..3c4c2a316 --- /dev/null +++ b/alchemy/src/vercel/storage.ts @@ -0,0 +1,184 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import type { Secret } from "../secret.ts"; +import { createVercelApi, VercelApi } from "./api"; + +type StorageType = "blob"; + +/** + * Properties for creating or updating a Storage + */ +export interface StorageProps { + /** + * The desired name for the storage + */ + name: string; + + /** + * The region where the storage will be deployed + */ + region: string; + + /** + * The team ID that the storage belongs to + */ + teamId: string; + + /** + * The type of storage + */ + type: StorageType; +} + +/** + * Output returned after Storage creation/update + */ +export interface Storage extends Resource<"vercel::Storage">, StorageProps { + /** + * The ID of the storage + */ + id: string; + + /** + * The storage store information + */ + store: { + /** + * The billing state of the storage + */ + billingState: string; + + /** + * The count of items in the storage + */ + count: number; + + /** + * The time at which the storage was created + */ + createdAt: number; + + /** + * The ID of the store + */ + id: string; + + /** + * The name of the store + */ + name: string; + + /** + * The ID of the owner + */ + ownerId: string; + + /** + * The projects metadata + */ + projectsMetadata: {}[]; + + /** + * The region where the storage is deployed + */ + region: string; + + /** + * The size of the storage + */ + size: number; + + /** + * The status of the storage + */ + status: string; + + /** + * The type of storage + */ + type: StorageType; + + /** + * The time at which the storage was last updated + */ + updatedAt: number; + + /** + * Whether the usage quota has been exceeded + */ + usageQuotaExceeded: boolean; + }, +} + +/** + * Create and manage Vercel storage. + * + * @example + * // With accessToken + * const storage = await Storage("my-storage", { + * accessToken: alchemy.secret(process.env.VERCEL_ACCESS_TOKEN), + * name: "my-storage", + * region: "iad1", + * teamId: "team_123", + * type: "blob", + * }); + * + * @example + * // Basic storage creation + * const storage = await Storage("my-storage", { + * name: "my-storage", + * region: "iad1", + * teamId: "team_123", + * 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); + return this({ ...props, ...storage, id: storage.store.id }); + } + + case "update": { + if (props.name !== this.output.name) { + return this.replace() + } + return this({ ...props, ...this.output }); + } + + case "delete": { + await deleteStorage(api, this.output); + return this.destroy(); + } + } + }, +); + +/** + * Create a new storage instance + */ +async function createStorage(api: VercelApi, props: StorageProps) { + const response = await api.post(`/storage/stores/${props.type}?teamId=${props.teamId}`, { + name: props.name, + region: props.region, + }); + return response.json() as Promise<{ store: Storage["store"] }>; +} + +/** + * Delete a storage instance + */ +async function deleteStorage(api: VercelApi, output: Storage) { + await api.delete(`/storage/stores/${output.id}/connections?teamId=${output.teamId}`); + await api.delete(`/storage/stores/${output.type}/${output.id}?teamId=${output.teamId}`); +} \ No newline at end of file From 67ab76d2dfb40647fb925ee4c34592f6405aa902 Mon Sep 17 00:00:00 2001 From: Eduardo Rabelo Date: Thu, 24 Jul 2025 15:33:11 +1200 Subject: [PATCH 2/5] feat(vercel): enhance Storage resource with project management capabilities --- alchemy/src/vercel/storage.ts | 134 ++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 8 deletions(-) diff --git a/alchemy/src/vercel/storage.ts b/alchemy/src/vercel/storage.ts index 3c4c2a316..9d20b40e7 100644 --- a/alchemy/src/vercel/storage.ts +++ b/alchemy/src/vercel/storage.ts @@ -1,3 +1,4 @@ +import { fromPromise } from 'neverthrow'; import type { Context } from "../context.ts"; import { Resource } from "../resource.ts"; import type { Secret } from "../secret.ts"; @@ -5,6 +6,12 @@ import { createVercelApi, VercelApi } from "./api"; type StorageType = "blob"; +interface StorageProject { + projectId: string; + envVarEnvironments: ["production" | "preview" | "development"]; + envVarPrefix?: string; +} + /** * Properties for creating or updating a Storage */ @@ -28,6 +35,11 @@ export interface StorageProps { * The type of storage */ type: StorageType; + + /** + * The projects that the storage belongs to + */ + projects: StorageProject[]; } /** @@ -76,7 +88,15 @@ export interface Storage extends Resource<"vercel::Storage">, StorageProps { /** * The projects metadata */ - projectsMetadata: {}[]; + projectsMetadata: { + environments: StorageProject["envVarEnvironments"]; + environmentVariables: string[]; + envVarPrefix: StorageProject["envVarPrefix"]; + framework: string; + id: string; + name: string; + projectId: string; + }[]; /** * The region where the storage is deployed @@ -146,14 +166,54 @@ export const Storage = Resource( switch (this.phase) { case "create": { const storage = await createStorage(api, props); - return this({ ...props, ...storage, id: storage.store.id }); + + const output = { ...props, ...storage, id: storage.store.id } as Storage; + + if (props.projects.length > 0) { + await createProjectsConnection(api, output, props.projects); + const updatedStorage = await readStorage(api, output); + output.store = updatedStorage.store; + } + + return this(output); } case "update": { if (props.name !== this.output.name) { return this.replace() } - return this({ ...props, ...this.output }); + + const projectsMetadata = this.output.store.projectsMetadata ?? []; + const projects = props.projects ?? []; + + const currentProjectIds = new Set( + projectsMetadata.map((p) => p.projectId) + ); + + const newProjectIds = new Set( + projects.map((p) => p.projectId) + ); + + const toDelete = projectsMetadata + .filter((p) => !newProjectIds.has(p.projectId)) + + if (toDelete.length > 0) { + await deleteProjectsConnection(api, this.output, toDelete); + } + + const toCreate = projects + .filter((project) => !currentProjectIds.has(project.projectId)); + + 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.store = updatedStorage.store; + } + + return this({ ...this.output, ...props }); } case "delete": { @@ -164,21 +224,79 @@ export const Storage = Resource( }, ); +async function readStorage(api: VercelApi, output: Storage) { + const response = await fromPromise(api.get(`/storage/stores/${output.id}?teamId=${output.teamId}`), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } + + return response.value.json() as Promise<{ store: Storage["store"] }>; +} + /** * Create a new storage instance */ async function createStorage(api: VercelApi, props: StorageProps) { - const response = await api.post(`/storage/stores/${props.type}?teamId=${props.teamId}`, { + const response = await fromPromise(api.post(`/storage/stores/${props.type}?teamId=${props.teamId}`, { name: props.name, region: props.region, - }); - return response.json() as Promise<{ store: Storage["store"] }>; + }), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } + + return response.value.json() as Promise<{ store: Storage["store"] }>; } /** * Delete a storage instance */ async function deleteStorage(api: VercelApi, output: Storage) { - await api.delete(`/storage/stores/${output.id}/connections?teamId=${output.teamId}`); - await api.delete(`/storage/stores/${output.type}/${output.id}?teamId=${output.teamId}`); + const connections = await fromPromise(api.delete(`/storage/stores/${output.id}/connections?teamId=${output.teamId}`), (err) => err as Error); + + if (connections.isErr()) { + throw connections.error; + } + + const storage = await fromPromise(api.delete(`/storage/stores/${output.type}/${output.id}?teamId=${output.teamId}`), (err) => err as Error); + + if (storage.isErr()) { + throw storage.error; + } +} + +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); + } +} + +async function deleteProjectsConnection(api: VercelApi, output: Storage, projectsMetadata: Storage["store"]["projectsMetadata"]) { + // 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); + } +} + +async function connectProject(api: VercelApi, output: Storage, project: StorageProject) { + const response = await fromPromise(api.post(`/storage/stores/${output.id}/connections?teamId=${output.teamId}`, { + envVarEnvironments: project.envVarEnvironments, + envVarPrefix: project.envVarPrefix, + projectId: project.projectId, + }), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } +} + +async function disconnectProject(api: VercelApi, output: Storage, metadata: Storage["store"]["projectsMetadata"][number]) { + const response = await fromPromise(api.delete(`/storage/stores/${output.id}/connections/${metadata.id}?teamId=${output.teamId}`), (err) => err as Error); + + if (response.isErr()) { + throw response.error; + } } \ No newline at end of file From e3dcdf65b3edbf349d8e770d3df9ec56bdd3a8ee Mon Sep 17 00:00:00 2001 From: Eduardo Rabelo Date: Thu, 24 Jul 2025 17:16:29 +1200 Subject: [PATCH 3/5] feat(vercel): refactor Storage and Project types for improved structure and functionality --- alchemy/src/vercel/project.ts | 54 +---- alchemy/src/vercel/storage.ts | 338 +++++++++++----------------- alchemy/src/vercel/storage.types.ts | 19 ++ alchemy/src/vercel/storage.utils.ts | 93 ++++++++ alchemy/src/vercel/vercel.types.ts | 77 +++++++ 5 files changed, 327 insertions(+), 254 deletions(-) create mode 100644 alchemy/src/vercel/storage.types.ts create mode 100644 alchemy/src/vercel/storage.utils.ts create mode 100644 alchemy/src/vercel/vercel.types.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 index 9d20b40e7..7501a9684 100644 --- a/alchemy/src/vercel/storage.ts +++ b/alchemy/src/vercel/storage.ts @@ -1,137 +1,109 @@ -import { fromPromise } from 'neverthrow'; import type { Context } from "../context.ts"; import { Resource } from "../resource.ts"; import type { Secret } from "../secret.ts"; -import { createVercelApi, VercelApi } from "./api"; +import { createVercelApi } from "./api"; +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"; -type StorageType = "blob"; -interface StorageProject { - projectId: string; - envVarEnvironments: ["production" | "preview" | "development"]; - envVarPrefix?: string; -} - -/** - * Properties for creating or updating a Storage - */ export interface StorageProps { /** - * The desired name for the storage + * The name of the storage */ - name: string; + name?: string; /** - * The region where the storage will be deployed + * The region of the storage */ - region: string; + region: VercelRegions; /** - * The team ID that the storage belongs to + * The team of the storage */ - teamId: string; + team: string | VercelTeam; /** - * The type of storage + * The type of the storage */ type: StorageType; /** * The projects that the storage belongs to */ - projects: StorageProject[]; + projects?: StorageProject[]; } -/** - * Output returned after Storage creation/update - */ 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 storage store information + * The name of the storage */ - store: { - /** - * The billing state of the storage - */ - billingState: string; - - /** - * The count of items in the storage - */ - count: number; - - /** - * The time at which the storage was created - */ - createdAt: number; - - /** - * The ID of the store - */ - id: string; - - /** - * The name of the store - */ - name: string; - - /** - * The ID of the owner - */ - ownerId: string; - - /** - * The projects metadata - */ - projectsMetadata: { - environments: StorageProject["envVarEnvironments"]; - environmentVariables: string[]; - envVarPrefix: StorageProject["envVarPrefix"]; - framework: string; - id: string; - name: string; - projectId: string; - }[]; - - /** - * The region where the storage is deployed - */ - region: string; - - /** - * The size of the storage - */ - size: number; - - /** - * The status of the storage - */ - status: string; - - /** - * The type of storage - */ - type: StorageType; - - /** - * The time at which the storage was last updated - */ - updatedAt: number; - - /** - * Whether the usage quota has been exceeded - */ - usageQuotaExceeded: boolean; - }, + 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. + * Create and manage Vercel storage resources. + * Blob Storage support only for now. * * @example * // With accessToken @@ -139,17 +111,24 @@ export interface Storage extends Resource<"vercel::Storage">, StorageProps { * accessToken: alchemy.secret(process.env.VERCEL_ACCESS_TOKEN), * name: "my-storage", * region: "iad1", - * teamId: "team_123", - * type: "blob", + * team: "my-team", + * type: "blob" * }); * * @example - * // Basic storage creation + * // Connect Projects to the Storage * const storage = await Storage("my-storage", { * name: "my-storage", - * region: "iad1", - * teamId: "team_123", - * type: "blob", + * projects: [ + * { + * projectId: "project_123", + * envVarEnvironments: ["production"], + * envVarPrefix: "MY_STORAGE_", + * }, + * ], + * region: "cdg1", + * team: "my-team", + * type: "blob" * }); */ export const Storage = Resource( @@ -167,42 +146,72 @@ export const Storage = Resource( case "create": { const storage = await createStorage(api, props); - const output = { ...props, ...storage, id: storage.store.id } as Storage; + let output = { ...props, ...storage.store }; + + if (!output.name) { + output.name = id; + } - if (props.projects.length > 0) { + if (props.projects && props.projects.length > 0) { await createProjectsConnection(api, output, props.projects); const updatedStorage = await readStorage(api, output); - output.store = updatedStorage.store; + output = { ...props, ...updatedStorage.store }; } return this(output); } case "update": { - if (props.name !== this.output.name) { - return this.replace() + 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 projectsMetadata = this.output.store.projectsMetadata ?? []; - const projects = props.projects ?? []; + const newProjects = props.projects ?? []; + const newProjectsMap = new Map(newProjects.map((p) => [p.projectId, p])); - const currentProjectIds = new Set( - projectsMetadata.map((p) => p.projectId) - ); - - const newProjectIds = new Set( - projects.map((p) => p.projectId) - ); + const currentProjects = this.output.projects ?? []; + const currentProjectsMap = new Map(currentProjects.map((p) => [p.projectId, p])); - const toDelete = projectsMetadata - .filter((p) => !newProjectIds.has(p.projectId)) + const projectsMetadata = this.output.projectsMetadata ?? []; - if (toDelete.length > 0) { - await deleteProjectsConnection(api, this.output, toDelete); + // 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 toCreate = projects - .filter((project) => !currentProjectIds.has(project.projectId)); + 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); @@ -210,7 +219,7 @@ export const Storage = Resource( if (toDelete.length > 0 || toCreate.length > 0) { const updatedStorage = await readStorage(api, this.output); - this.output.store = updatedStorage.store; + this.output = updatedStorage.store; } return this({ ...this.output, ...props }); @@ -223,80 +232,3 @@ export const Storage = Resource( } }, ); - -async function readStorage(api: VercelApi, output: Storage) { - const response = await fromPromise(api.get(`/storage/stores/${output.id}?teamId=${output.teamId}`), (err) => err as Error); - - if (response.isErr()) { - throw response.error; - } - - return response.value.json() as Promise<{ store: Storage["store"] }>; -} - -/** - * Create a new storage instance - */ -async function createStorage(api: VercelApi, props: StorageProps) { - const response = await fromPromise(api.post(`/storage/stores/${props.type}?teamId=${props.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["store"] }>; -} - -/** - * Delete a storage instance - */ -async function deleteStorage(api: VercelApi, output: Storage) { - const connections = await fromPromise(api.delete(`/storage/stores/${output.id}/connections?teamId=${output.teamId}`), (err) => err as Error); - - if (connections.isErr()) { - throw connections.error; - } - - const storage = await fromPromise(api.delete(`/storage/stores/${output.type}/${output.id}?teamId=${output.teamId}`), (err) => err as Error); - - if (storage.isErr()) { - throw storage.error; - } -} - -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); - } -} - -async function deleteProjectsConnection(api: VercelApi, output: Storage, projectsMetadata: Storage["store"]["projectsMetadata"]) { - // 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); - } -} - -async function connectProject(api: VercelApi, output: Storage, project: StorageProject) { - const response = await fromPromise(api.post(`/storage/stores/${output.id}/connections?teamId=${output.teamId}`, { - envVarEnvironments: project.envVarEnvironments, - envVarPrefix: project.envVarPrefix, - projectId: project.projectId, - }), (err) => err as Error); - - if (response.isErr()) { - throw response.error; - } -} - -async function disconnectProject(api: VercelApi, output: Storage, metadata: Storage["store"]["projectsMetadata"][number]) { - const response = await fromPromise(api.delete(`/storage/stores/${output.id}/connections/${metadata.id}?teamId=${output.teamId}`), (err) => err as Error); - - if (response.isErr()) { - throw response.error; - } -} \ No newline at end of file diff --git a/alchemy/src/vercel/storage.types.ts b/alchemy/src/vercel/storage.types.ts new file mode 100644 index 000000000..95cca2fb6 --- /dev/null +++ b/alchemy/src/vercel/storage.types.ts @@ -0,0 +1,19 @@ +import type { VercelEnvironments } from "./vercel.types.ts"; + +export type StorageType = "blob"; + +export interface StorageProject { + projectId: string; + envVarEnvironments: VercelEnvironments; + envVarPrefix?: string; +} + +export interface StorageProjectMetadata { + environments: VercelEnvironments; + environmentVariables: string[]; + envVarPrefix?: string; + framework: string; + id: string; + name: string; + projectId: string; +} \ No newline at end of file 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..e68e8012b --- /dev/null +++ b/alchemy/src/vercel/vercel.types.ts @@ -0,0 +1,77 @@ +export type VercelEnvironments = ["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 +} From a250951772f8986551a25cbde2e614bf4d04823a Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Thu, 7 Aug 2025 14:46:23 -0700 Subject: [PATCH 4/5] fix linter --- alchemy/src/vercel/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alchemy/src/vercel/storage.ts b/alchemy/src/vercel/storage.ts index 7501a9684..921316af8 100644 --- a/alchemy/src/vercel/storage.ts +++ b/alchemy/src/vercel/storage.ts @@ -1,7 +1,7 @@ import type { Context } from "../context.ts"; import { Resource } from "../resource.ts"; import type { Secret } from "../secret.ts"; -import { createVercelApi } from "./api"; +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"; From 4c1e95e1709b7daf328bf3245ad7ee4293e57d9a Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Thu, 7 Aug 2025 14:55:20 -0700 Subject: [PATCH 5/5] add docs and tests --- .../content/docs/providers/vercel/storage.md | 93 ++++++++++++++++ alchemy/src/vercel/storage.ts | 38 +++++-- alchemy/src/vercel/storage.types.ts | 8 +- alchemy/src/vercel/vercel.types.ts | 51 ++++----- alchemy/test/vercel/storage.test.ts | 100 ++++++++++++++++++ 5 files changed, 250 insertions(+), 40 deletions(-) create mode 100644 alchemy-web/src/content/docs/providers/vercel/storage.md create mode 100644 alchemy/test/vercel/storage.test.ts 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/storage.ts b/alchemy/src/vercel/storage.ts index 921316af8..3a46a5cf8 100644 --- a/alchemy/src/vercel/storage.ts +++ b/alchemy/src/vercel/storage.ts @@ -2,11 +2,21 @@ 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 { + 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 @@ -163,21 +173,25 @@ export const Storage = Resource( case "update": { if ( - props.name !== this.output.name - || props.region !== this.output.region - || props.team !== this.output.team - || props.type !== this.output.type + 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) + return this.replace(true); } const newProjects = props.projects ?? []; - const newProjectsMap = new Map(newProjects.map((p) => [p.projectId, p])); + 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 currentProjectsMap = new Map( + currentProjects.map((p) => [p.projectId, p]), + ); const projectsMetadata = this.output.projectsMetadata ?? []; @@ -205,7 +219,9 @@ export const Storage = Resource( const toDeleteMetadata: typeof projectsMetadata = []; for (const delProject of toDelete) { - const metas = projectsMetadata.filter(meta => meta.projectId === delProject.projectId); + const metas = projectsMetadata.filter( + (meta) => meta.projectId === delProject.projectId, + ); toDeleteMetadata.push(...metas); } diff --git a/alchemy/src/vercel/storage.types.ts b/alchemy/src/vercel/storage.types.ts index 95cca2fb6..5f9bb80b8 100644 --- a/alchemy/src/vercel/storage.types.ts +++ b/alchemy/src/vercel/storage.types.ts @@ -1,19 +1,19 @@ -import type { VercelEnvironments } from "./vercel.types.ts"; +import type { VercelEnvironment } from "./vercel.types.ts"; export type StorageType = "blob"; export interface StorageProject { projectId: string; - envVarEnvironments: VercelEnvironments; + envVarEnvironments: VercelEnvironment[]; envVarPrefix?: string; } export interface StorageProjectMetadata { - environments: VercelEnvironments; + environments: VercelEnvironment[]; environmentVariables: string[]; envVarPrefix?: string; framework: string; id: string; name: string; projectId: string; -} \ No newline at end of file +} diff --git a/alchemy/src/vercel/vercel.types.ts b/alchemy/src/vercel/vercel.types.ts index e68e8012b..393116e2c 100644 --- a/alchemy/src/vercel/vercel.types.ts +++ b/alchemy/src/vercel/vercel.types.ts @@ -1,28 +1,29 @@ -export type VercelEnvironments = ["development" | "preview" | "production"]; +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 + | "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 + | (string & {}); // special rune to maintain auto-suggestions without closing the type to new regions or ones we missed -export type VercelFrameworks = "blitzjs" +export type VercelFrameworks = + | "blitzjs" | "nextjs" | "gatsby" | "remix" @@ -71,7 +72,7 @@ export type VercelFrameworks = "blitzjs" | (string & {}); export type VercelTeam = { - id: string - slug: string - name: string -} + 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); + } + }); +});