diff --git a/alchemy-web/src/content/docs/guides/prisma-postgres.md b/alchemy-web/src/content/docs/guides/prisma-postgres.md new file mode 100644 index 000000000..7cd8f2181 --- /dev/null +++ b/alchemy-web/src/content/docs/guides/prisma-postgres.md @@ -0,0 +1,67 @@ +--- +title: Prisma Postgres +headline: Manage Prisma Postgres projects and databases with Alchemy. +description: Learn how to configure a Prisma Postgres service token and manage projects, databases, and connection strings with Alchemy. +--- + +## Prerequisites + +- A Prisma Postgres workspace +- A service token with workspace access +- `PRISMA_SERVICE_TOKEN` set in your environment + +## Install Dependencies + +```bash +bun i alchemy +``` + +## Configure the Service Token + +Create a workspace service token in the Prisma dashboard and export it before running Alchemy commands: + +```bash +export PRISMA_SERVICE_TOKEN="sk_..." +``` + +## Create a Project and Database + +```ts +import alchemy from "alchemy"; +import { + Project, + Database, + DatabaseConnection, +} from "alchemy/prisma/postgres"; + +const app = await alchemy("prisma-postgres-example"); + +const project = await Project("project", { + name: "prisma-app", + region: "us-east-1", + createDatabase: false, +}); + +const database = await Database("database", { + project, + name: "primary", + region: "us-east-1", +}); + +const connection = await DatabaseConnection("connection", { + database, + name: "application", +}); + +console.log("Database URL", connection.connectionString.unencrypted); + +await app.finalize(); +``` + +## Cleanup + +To remove all resources created by the guide, run: + +```bash +alchemy destroy +``` diff --git a/alchemy-web/src/content/docs/providers/prisma-postgres/database-backups.md b/alchemy-web/src/content/docs/providers/prisma-postgres/database-backups.md new file mode 100644 index 000000000..671be3bfb --- /dev/null +++ b/alchemy-web/src/content/docs/providers/prisma-postgres/database-backups.md @@ -0,0 +1,41 @@ +--- +title: DatabaseBackups +description: List Prisma Postgres backups and retention information. +--- + +`DatabaseBackups` fetches the backup catalog for a database along with retention metadata. The resource is read-only and can be combined with `Database` to restore backups. + +## List Backups + +```ts +import { DatabaseBackups } from "alchemy/prisma/postgres"; + +const backups = await DatabaseBackups("backups", { + database, + limit: 10, +}); + +console.log(backups.backups.map((backup) => backup.id)); +``` + +## Use the Most Recent Backup + +```ts +import { Database, DatabaseBackups } from "alchemy/prisma/postgres"; + +const backups = await DatabaseBackups("backups", { database }); + +if (!backups.mostRecent) { + throw new Error("No backups available"); +} + +await Database("restored", { + project, + name: "restored", + region: "us-east-1", + fromDatabase: { + database, + backupId: backups.mostRecent.id, + }, +}); +``` diff --git a/alchemy-web/src/content/docs/providers/prisma-postgres/database-connection.md b/alchemy-web/src/content/docs/providers/prisma-postgres/database-connection.md new file mode 100644 index 000000000..01906b62e --- /dev/null +++ b/alchemy-web/src/content/docs/providers/prisma-postgres/database-connection.md @@ -0,0 +1,41 @@ +--- +title: DatabaseConnection +description: Create and rotate Prisma Postgres database connection strings. +--- + +Use `DatabaseConnection` to issue connection strings for Prisma Postgres databases. Each invocation generates a new API key and connection string. + +## Create a Connection + +```ts +import { DatabaseConnection } from "alchemy/prisma/postgres"; + +const connection = await DatabaseConnection("application", { + database, + name: "application", +}); + +console.log(connection.connectionString.unencrypted); +``` + +## Rotate If Missing + +If the underlying connection is deleted, rerunning the resource automatically creates a replacement with a fresh secret: + +```ts +await DatabaseConnection("application", { + database, + name: "application", +}); +``` + +## Using the Secret in Environment Variables + +```ts +const connection = await DatabaseConnection("application", { + database, + name: "application", +}); + +authEnv.PRISMA_DATABASE_URL = connection.connectionString; +``` diff --git a/alchemy-web/src/content/docs/providers/prisma-postgres/database.md b/alchemy-web/src/content/docs/providers/prisma-postgres/database.md new file mode 100644 index 000000000..4a9d891bd --- /dev/null +++ b/alchemy-web/src/content/docs/providers/prisma-postgres/database.md @@ -0,0 +1,66 @@ +--- +title: Database +description: Provision Prisma Postgres databases, including restoring from backups. +--- + +`Database` creates additional databases within a Prisma Postgres project. Databases inherit workspace access from their parent project and support restoring from backups created by Prisma. + +## Create a Database + +```ts +import { Database } from "alchemy/prisma/postgres"; + +const database = await Database("primary", { + project, + name: "primary", + region: "us-east-1", +}); +``` + +## Disable Adoption + +Force a new database instead of reusing an existing one: + +```ts +import { Database } from "alchemy/prisma/postgres"; + +const database = await Database("isolated", { + project, + name: "isolated", + region: "eu-central-1", + adopt: false, +}); +``` + +## Restore from Backup + +Restore a database from another database and an optional backup id: + +```ts +import { Database, DatabaseBackups } from "alchemy/prisma/postgres"; + +const backups = await DatabaseBackups("backups", { database: sourceDatabase }); + +const restored = await Database("restore", { + project, + name: "restore", + region: "us-east-1", + fromDatabase: { + database: sourceDatabase, + backupId: backups.mostRecent?.id, + }, +}); +``` + +## Promote to Default Database + +Set `isDefault` to true to promote the database when the workspace allows it: + +```ts +const database = await Database("default", { + project, + name: "default", + region: "us-east-1", + isDefault: true, +}); +``` diff --git a/alchemy-web/src/content/docs/providers/prisma-postgres/project.md b/alchemy-web/src/content/docs/providers/prisma-postgres/project.md new file mode 100644 index 000000000..fcc87d287 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/prisma-postgres/project.md @@ -0,0 +1,61 @@ +--- +title: Project +description: Create or adopt Prisma Postgres projects within a workspace. +--- + +`Project` provisions Prisma Postgres projects and optionally creates the default database. Service tokens scoped to the workspace must be supplied via `PRISMA_SERVICE_TOKEN` or the `serviceToken` prop. + +## Minimal Project + +Create a project without provisioning the default database: + +```ts +import { Project } from "alchemy/prisma/postgres"; + +const project = await Project("app-project", { + name: "app-project", + region: "us-east-1", + createDatabase: false, +}); +``` + +## Adopt Existing Project + +Reuse a project if it already exists in the workspace by leaving the default `adopt: true` behaviour: + +```ts +import { Project } from "alchemy/prisma/postgres"; + +const project = await Project("existing", { + name: "existing", + region: "us-east-1", +}); +``` + +## Force New Project Creation + +Disable adoption to ensure a fresh project is created: + +```ts +import { Project } from "alchemy/prisma/postgres"; + +const project = await Project("fresh", { + name: "fresh-project", + region: "eu-central-1", + createDatabase: false, + adopt: false, +}); +``` + +## Custom Service Token + +```ts +import { Project } from "alchemy/prisma/postgres"; + +const project = await Project("project", { + name: "per-region", + region: "ap-southeast-1", + createDatabase: false, + serviceToken: alchemy.secret(process.env.PRISMA_PROJECT_TOKEN), +}); +``` diff --git a/alchemy-web/src/content/docs/providers/prisma-postgres/workspace.md b/alchemy-web/src/content/docs/providers/prisma-postgres/workspace.md new file mode 100644 index 000000000..b808a9d65 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/prisma-postgres/workspace.md @@ -0,0 +1,37 @@ +--- +title: Workspace +description: Resolve Prisma Postgres workspace metadata for use with other resources. +--- + +Use the `Workspace` resource to retrieve workspace details with either the workspace id or name. This is useful when you need to display metadata or verify that a service token has access to the expected workspace. + +## Lookup by ID + +```ts +import { Workspace } from "alchemy/prisma/postgres"; + +const workspace = await Workspace("workspace", { + id: "wksp_cmg94yrap00a9xgfncx1mwt34", +}); +``` + +## Lookup by Name + +```ts +import { Workspace } from "alchemy/prisma/postgres"; + +const workspace = await Workspace("workspace", { + name: "Production", +}); +``` + +## Custom Service Token + +```ts +import { Workspace } from "alchemy/prisma/postgres"; + +const workspace = await Workspace("workspace", { + id: "wksp_cmg94yrap00a9xgfncx1mwt34", + serviceToken: alchemy.secret(process.env.PRISMA_SERVICE_TOKEN), +}); +``` diff --git a/alchemy/package.json b/alchemy/package.json index 2da28283c..cdbb7592e 100644 --- a/alchemy/package.json +++ b/alchemy/package.json @@ -128,6 +128,14 @@ "bun": "./src/planetscale/index.ts", "import": "./lib/planetscale/index.js" }, + "./prisma": { + "bun": "./src/prisma/index.ts", + "import": "./lib/prisma/index.js" + }, + "./prisma/postgres": { + "bun": "./src/prisma/postgres/index.ts", + "import": "./lib/prisma/postgres/index.js" + }, "./random": { "bun": "./src/random/index.ts", "import": "./lib/random/index.js" diff --git a/alchemy/src/prisma/index.ts b/alchemy/src/prisma/index.ts new file mode 100644 index 000000000..72a064172 --- /dev/null +++ b/alchemy/src/prisma/index.ts @@ -0,0 +1 @@ +export * from "./postgres/index.ts"; diff --git a/alchemy/src/prisma/postgres/README.md b/alchemy/src/prisma/postgres/README.md new file mode 100644 index 000000000..6041b769c --- /dev/null +++ b/alchemy/src/prisma/postgres/README.md @@ -0,0 +1,44 @@ +# Prisma Postgres Provider + +The Prisma Postgres provider lets you manage Prisma Postgres projects, databases, connection strings, and backup metadata through Alchemy. It interacts with the [Prisma Postgres Management API](https://www.prisma.io/docs/postgres/introduction/management-api) using a workspace service token. + +## Authentication + +Set the `PRISMA_SERVICE_TOKEN` environment variable or pass `serviceToken` on individual resources. Service tokens are scoped to a Prisma workspace. + +## Resources + +- [Workspace](./workspace.ts) – look up workspace metadata by id or name +- [Project](./project.ts) – create or adopt Prisma Postgres projects +- [Database](./database.ts) – provision databases and restore from backups +- [DatabaseConnection](./database-connection.ts) – create and manage database connection strings +- [DatabaseBackups](./database-backups.ts) – list available backups and retention metadata + +## Usage + +```ts +import { Project, Database, DatabaseConnection } from "alchemy/prisma/postgres"; + +const project = await Project("app-project", { + name: "app-project", + region: "us-east-1", + createDatabase: false, +}); + +const database = await Database("primary", { + project, + name: "primary", + region: "us-east-1", +}); + +const connection = await DatabaseConnection("primary-conn", { + database, + name: "app", +}); +``` + +## Environment Variables + +| Variable | Description | +| ----------------------- | -------------------------------------- | +| `PRISMA_SERVICE_TOKEN` | Prisma Postgres workspace service token | diff --git a/alchemy/src/prisma/postgres/api.ts b/alchemy/src/prisma/postgres/api.ts new file mode 100644 index 000000000..07d6af4b7 --- /dev/null +++ b/alchemy/src/prisma/postgres/api.ts @@ -0,0 +1,298 @@ +import type { + PrismaConnectionListResponse, + PrismaDatabase, + PrismaDatabaseBackupsResponse, + PrismaDatabaseListResponse, + PrismaDatabaseConnection, + PrismaErrorResponse, + PrismaPostgresAuthProps, + PrismaPostgresRegion, + PrismaProject, + PrismaProjectListResponse, + PrismaWorkspace, + PrismaWorkspaceListResponse, +} from "./types.ts"; + +/** + * Error thrown when the Prisma Postgres Management API returns a non-success response + */ +export class PrismaPostgresApiError extends Error { + readonly status: number; + readonly method: string; + readonly url: string; + readonly code?: string; + readonly responseBody?: unknown; + + constructor(props: { + status: number; + method: string; + url: string; + message: string; + code?: string; + responseBody?: unknown; + }) { + super(props.message); + this.status = props.status; + this.method = props.method; + this.url = props.url; + this.code = props.code; + this.responseBody = props.responseBody; + } +} + +/** + * Minimal client for the Prisma Postgres Management API + */ +export class PrismaPostgresApi { + readonly baseUrl: string; + readonly serviceToken: string; + + constructor(options: PrismaPostgresAuthProps = {}) { + const base = options.baseUrl ?? "https://api.prisma.io/v1"; + this.baseUrl = base.endsWith("/") ? base.slice(0, -1) : base; + + const token = options.serviceToken + ? typeof options.serviceToken === "string" + ? options.serviceToken + : options.serviceToken.unencrypted + : process.env.PRISMA_SERVICE_TOKEN; + + if (!token) { + throw new Error( + "Prisma Postgres service token is required. Set PRISMA_SERVICE_TOKEN or provide serviceToken in props.", + ); + } + + this.serviceToken = token; + } + + private async request( + method: string, + path: string, + body?: Record, + ): Promise { + if (!path.startsWith("/")) { + throw new Error(`API path must start with a slash. Received: ${path}`); + } + + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${this.serviceToken}`, + }; + + let payload: string | undefined; + if (body && Object.keys(body).length > 0) { + payload = JSON.stringify(body); + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body: payload, + }); + + const text = await response.text(); + const maybeJson = text ? safeJsonParse(text) : undefined; + + if (!response.ok) { + const errorBody = maybeJson as PrismaErrorResponse | undefined; + const message = + errorBody?.error?.message ?? + `Prisma Postgres API request failed (${response.status} ${response.statusText})`; + throw new PrismaPostgresApiError({ + status: response.status, + method, + url: `${this.baseUrl}${path}`, + message, + code: errorBody?.error?.code, + responseBody: maybeJson ?? text, + }); + } + + if (!text) return undefined as T; + return maybeJson as T; + } + + async listWorkspaces(): Promise { + return this.request("GET", "/workspaces"); + } + + async getWorkspaceById(id: string): Promise { + const response = await this.listWorkspaces(); + return response.data.find((workspace) => workspace.id === id); + } + + async getWorkspaceByName(name: string): Promise { + const response = await this.listWorkspaces(); + return response.data.find((workspace) => workspace.name === name); + } + + async listProjects(cursor?: string): Promise { + const query = cursor ? `?cursor=${cursor}` : ""; + return this.request("GET", `/projects${query}`); + } + + async getProject(id: string): Promise { + try { + const response = await this.request<{ data: PrismaProject }>( + "GET", + `/projects/${id}`, + ); + return response.data; + } catch (error) { + if (error instanceof PrismaPostgresApiError && error.status === 404) { + return undefined; + } + throw error; + } + } + + async createProject(params: { + name: string; + region?: PrismaPostgresRegion; + createDatabase?: boolean; + }): Promise { + const body: Record = { + name: params.name, + }; + if (params.region) { + body.region = params.region; + } + if (params.createDatabase !== undefined) { + body.createDatabase = params.createDatabase; + } + const response = await this.request<{ data: PrismaProject }>( + "POST", + "/projects", + body, + ); + return response.data; + } + + async deleteProject(id: string): Promise { + try { + await this.request("DELETE", `/projects/${id}`); + } catch (error) { + if (error instanceof PrismaPostgresApiError && error.status === 404) { + return; + } + throw error; + } + } + + async createDatabase(params: { + projectId: string; + name: string; + region: PrismaPostgresRegion; + isDefault?: boolean; + fromDatabase?: { + id: string; + backupId?: string; + }; + }): Promise { + const body: Record = { + name: params.name, + region: params.region, + }; + if (params.isDefault !== undefined) { + body.isDefault = params.isDefault; + } + if (params.fromDatabase) { + body.fromDatabase = params.fromDatabase; + } + const response = await this.request<{ data: PrismaDatabase }>( + "POST", + `/projects/${params.projectId}/databases`, + body, + ); + return response.data; + } + + async listProjectDatabases( + projectId: string, + cursor?: string, + ): Promise { + const query = cursor ? `?cursor=${cursor}` : ""; + return this.request("GET", `/projects/${projectId}/databases${query}`); + } + + async getDatabase(databaseId: string): Promise { + try { + const response = await this.request<{ data: PrismaDatabase }>( + "GET", + `/databases/${databaseId}`, + ); + return response.data; + } catch (error) { + if (error instanceof PrismaPostgresApiError && error.status === 404) { + return undefined; + } + throw error; + } + } + + async deleteDatabase(databaseId: string): Promise { + try { + await this.request("DELETE", `/databases/${databaseId}`); + } catch (error) { + if ( + error instanceof PrismaPostgresApiError && + (error.status === 404 || error.status === 403) + ) { + return; + } + throw error; + } + } + + async createConnection(params: { + databaseId: string; + name: string; + }): Promise { + const response = await this.request<{ data: PrismaDatabaseConnection }>( + "POST", + `/databases/${params.databaseId}/connections`, + { name: params.name }, + ); + return response.data; + } + + async deleteConnection(connectionId: string): Promise { + try { + await this.request("DELETE", `/connections/${connectionId}`); + } catch (error) { + if (error instanceof PrismaPostgresApiError && error.status === 404) { + return; + } + throw error; + } + } + + async listConnections( + databaseId: string, + cursor?: string, + ): Promise { + const query = cursor ? `?cursor=${cursor}` : ""; + return this.request("GET", `/databases/${databaseId}/connections${query}`); + } + + async listDatabaseBackups(params: { + databaseId: string; + limit?: number; + }): Promise { + const query = params.limit ? `?limit=${params.limit}` : ""; + return this.request( + "GET", + `/databases/${params.databaseId}/backups${query}`, + ); + } +} + +function safeJsonParse(value: string): any { + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/alchemy/src/prisma/postgres/database-backups.ts b/alchemy/src/prisma/postgres/database-backups.ts new file mode 100644 index 000000000..24536ed03 --- /dev/null +++ b/alchemy/src/prisma/postgres/database-backups.ts @@ -0,0 +1,79 @@ +import type { Context } from "../../context.ts"; +import { Resource } from "../../resource.ts"; +import type { + PrismaDatabaseBackup, + PrismaDatabaseBackupsResponse, + PrismaPostgresAuthProps, +} from "./types.ts"; +import { PrismaPostgresApi } from "./api.ts"; +import type { Database } from "./database.ts"; + +/** + * Properties for retrieving database backups + */ +export interface DatabaseBackupsProps extends PrismaPostgresAuthProps { + /** + * Database (id or resource) to inspect + */ + database: string | Database; + + /** + * Maximum number of backups to fetch (1-100) + * + * @default 25 + */ + limit?: number; +} + +/** + * Database backups list information + */ +export interface DatabaseBackups { + databaseId: string; + backups: PrismaDatabaseBackup[]; + meta: PrismaDatabaseBackupsResponse["meta"]; + pagination: PrismaDatabaseBackupsResponse["pagination"]; + mostRecent?: PrismaDatabaseBackup; +} + +/** + * Retrieve available Prisma Postgres backups for a database + * + * @example + * const backups = await DatabaseBackups("backups", { + * database, + * limit: 10, + * }); + */ +export const DatabaseBackups = Resource( + "prisma-postgres::DatabaseBackups", + async function ( + this: Context, + _id, + props: DatabaseBackupsProps, + ) { + if (this.phase === "delete") { + return this.destroy(); + } + + const api = new PrismaPostgresApi(props); + const databaseId = + typeof props.database === "string" ? props.database : props.database.id; + const limit = props.limit; + + const response = await api.listDatabaseBackups({ + databaseId, + limit, + }); + + const mostRecent = response.data.at(0); + + return { + databaseId, + backups: response.data, + meta: response.meta, + pagination: response.pagination, + mostRecent: mostRecent ?? undefined, + } satisfies DatabaseBackups; + }, +); diff --git a/alchemy/src/prisma/postgres/database-connection.ts b/alchemy/src/prisma/postgres/database-connection.ts new file mode 100644 index 000000000..a29bc0648 --- /dev/null +++ b/alchemy/src/prisma/postgres/database-connection.ts @@ -0,0 +1,142 @@ +import type { Context } from "../../context.ts"; +import { Resource } from "../../resource.ts"; +import { secret, type Secret } from "../../secret.ts"; +import type { + PrismaDatabaseConnection, + PrismaConnectionListItem, + PrismaPostgresAuthProps, +} from "./types.ts"; +import { PrismaPostgresApi } from "./api.ts"; +import type { Database } from "./database.ts"; + +/** + * Properties for managing a Prisma Postgres database connection string + */ +export interface DatabaseConnectionProps extends PrismaPostgresAuthProps { + /** + * Database (id or resource) the connection belongs to + */ + database: string | Database; + + /** + * Human-readable name for the connection string + */ + name: string; +} + +/** + * Prisma Postgres database connection string output + */ +export interface DatabaseConnection { + id: string; + name: string; + createdAt: string; + database: { + id: string; + name: string; + }; + connectionString: Secret; +} + +/** + * Create and rotate Prisma Postgres database connection strings + * + * @example + * const connection = await DatabaseConnection("primary", { + * database: database.id, + * name: "app", + * }); + */ +export const DatabaseConnection = Resource( + "prisma-postgres::DatabaseConnection", + async function ( + this: Context, + _id, + props: DatabaseConnectionProps, + ) { + const api = new PrismaPostgresApi(props); + const databaseId = + typeof props.database === "string" ? props.database : props.database.id; + + if (this.phase === "delete") { + if (this.output?.id) { + await api.deleteConnection(this.output.id); + } + return this.destroy(); + } + + if ( + this.phase === "update" && + this.output && + props.name !== this.output.name + ) { + throw new Error( + "Updating Prisma Postgres connection name is not supported. Create a new connection instead.", + ); + } + + // Attempt to locate existing connection in the API + let connectionMeta = this.output?.id + ? await findConnectionById(api, databaseId, this.output.id) + : undefined; + + if (!connectionMeta) { + const created = await api.createConnection({ + databaseId, + name: props.name, + }); + + return formatConnection(created); + } + + // Reuse existing connection metadata and previously returned secret + if (!this.output?.connectionString) { + throw new Error( + "Existing connection secret missing from state. Remove the resource or recreate the connection.", + ); + } + + return { + id: connectionMeta.id, + name: connectionMeta.name, + createdAt: connectionMeta.createdAt, + database: { + id: connectionMeta.database.id, + name: connectionMeta.database.name, + }, + connectionString: this.output.connectionString, + } satisfies DatabaseConnection; + }, +); + +async function findConnectionById( + api: PrismaPostgresApi, + databaseId: string, + connectionId: string, +): Promise { + let cursor: string | undefined; + do { + const response = await api.listConnections(databaseId, cursor); + const match = response.data.find( + (connection) => connection.id === connectionId, + ); + if (match) return match; + cursor = response.pagination.nextCursor ?? undefined; + } while (cursor); + return undefined; +} + +function formatConnection( + connection: PrismaDatabaseConnection, +): DatabaseConnection { + return { + id: connection.id, + name: connection.name, + createdAt: connection.createdAt, + database: { + id: connection.database.id, + name: connection.database.name, + }, + connectionString: secret(connection.connectionString), + } satisfies DatabaseConnection; +} diff --git a/alchemy/src/prisma/postgres/database.ts b/alchemy/src/prisma/postgres/database.ts new file mode 100644 index 000000000..f1beafadd --- /dev/null +++ b/alchemy/src/prisma/postgres/database.ts @@ -0,0 +1,293 @@ +import type { Context } from "../../context.ts"; +import { Resource } from "../../resource.ts"; +import { secret, type Secret } from "../../secret.ts"; +import type { + PrismaDatabase, + PrismaDatabaseConnection, + PrismaPostgresAuthProps, + PrismaPostgresRegion, +} from "./types.ts"; +import { PrismaPostgresApi } from "./api.ts"; +import type { Project } from "./project.ts"; + +/** + * Reference to another database when restoring from backup + */ +type DatabaseReference = string | { id: string }; + +type BackupReference = string | { id: string }; + +/** + * Properties for managing a Prisma Postgres database + */ +export interface DatabaseProps extends PrismaPostgresAuthProps { + /** + * The parent project (id or Project resource) + */ + project: string | Project; + + /** + * Database name + */ + name: string; + + /** + * Region for the database + */ + region: PrismaPostgresRegion; + + /** + * Whether the database should become the default project database + */ + isDefault?: boolean; + + /** + * Adopt (reuse) an existing database with the same name if one already exists + * + * @default true + */ + adopt?: boolean; + + /** + * Restore the database from another database/backup + */ + fromDatabase?: { + /** Database to clone from */ + database: DatabaseReference; + /** Specific backup id to restore from */ + backupId?: BackupReference; + }; +} + +/** + * Prisma Postgres database representation managed by Alchemy + */ +export interface Database { + id: string; + name: string; + status: PrismaDatabase["status"]; + createdAt: string; + isDefault: boolean; + region: { + id: PrismaPostgresRegion; + name: string; + } | null; + project: { + id: string; + name: string; + }; + connectionString: Secret | null; + directConnection: { + host: string; + user: string; + password: Secret; + } | null; + apiKeys: DatabaseConnectionInfo[]; +} + +/** + * Connection information (API keys) associated with a database + */ +export interface DatabaseConnectionInfo { + id: string; + name: string; + createdAt: string; + connectionString: Secret; + directConnection: { + host: string; + user: string; + password: Secret; + } | null; +} + +/** + * Create, adopt, and delete Prisma Postgres databases + * + * @example + * // Create a database inside an existing project + * const database = await Database("primary", { + * project: project.id, + * name: "primary", + * region: "us-east-1", + * }); + * + * @example + * // Restore from backup + * const database = await Database("restore", { + * project: project, + * name: "restored", + * region: "us-east-1", + * fromDatabase: { + * database: sourceDatabase, + * backupId: backup.id, + * }, + * }); + */ +export const Database = Resource( + "prisma-postgres::Database", + async function (this: Context, _id, props: DatabaseProps) { + const api = new PrismaPostgresApi(props); + const adopt = props.adopt ?? true; + const projectId = + typeof props.project === "string" ? props.project : props.project.id; + + if (this.phase === "delete") { + if (this.output?.id) { + await api.deleteDatabase(this.output.id); + } + return this.destroy(); + } + + if (this.phase === "update" && this.output) { + if (props.name !== this.output.name) { + throw new Error( + "Updating Prisma Postgres database name is not supported. Create a new database instead.", + ); + } + const previousRegionId = this.output.region?.id; + if (previousRegionId && props.region !== previousRegionId) { + throw new Error( + "Updating Prisma Postgres database region is not supported.", + ); + } + if ((props.isDefault ?? false) !== this.output.isDefault) { + throw new Error( + "Changing the isDefault flag after database creation is not supported.", + ); + } + if (props.fromDatabase) { + throw new Error( + "Restoring from a backup is only supported during creation.", + ); + } + } + + let database: PrismaDatabase | undefined; + + if (this.output?.id) { + database = await api.getDatabase(this.output.id); + } + + if (!database && adopt) { + database = await findDatabaseByName(api, projectId, props.name); + } + + if (!database) { + const payload: { + projectId: string; + name: string; + region: PrismaPostgresRegion; + isDefault?: boolean; + fromDatabase?: { + id: string; + backupId?: string; + }; + } = { + projectId, + name: props.name, + region: props.region, + }; + + if (props.isDefault !== undefined) { + payload.isDefault = props.isDefault; + } + + if (props.fromDatabase) { + payload.fromDatabase = { + id: resolveId(props.fromDatabase.database), + ...(props.fromDatabase.backupId + ? { backupId: resolveId(props.fromDatabase.backupId) } + : {}), + }; + } + + database = await api.createDatabase(payload); + } + + if (database.name !== props.name) { + throw new Error( + `Database name mismatch. Expected ${props.name} but API returned ${database.name}.`, + ); + } + + if (database.region && database.region.id !== props.region) { + throw new Error( + `Database region mismatch. Expected ${props.region} but API reports ${database.region.id}.`, + ); + } + + return formatDatabase(database); + }, +); + +async function findDatabaseByName( + api: PrismaPostgresApi, + projectId: string, + name: string, +): Promise { + let cursor: string | undefined; + do { + const response = await api.listProjectDatabases(projectId, cursor); + const match = response.data.find((db) => db.name === name); + if (match) return match; + cursor = response.pagination.nextCursor ?? undefined; + } while (cursor); + return undefined; +} + +function formatDatabase(database: PrismaDatabase): Database { + const region = database.region + ? { + id: database.region.id as PrismaPostgresRegion, + name: database.region.name, + } + : null; + + return { + id: database.id, + name: database.name, + status: database.status, + createdAt: database.createdAt, + isDefault: database.isDefault, + region, + project: { + id: database.project.id, + name: database.project.name, + }, + connectionString: database.connectionString + ? secret(database.connectionString) + : null, + directConnection: database.directConnection + ? { + host: database.directConnection.host, + user: database.directConnection.user, + password: secret(database.directConnection.pass), + } + : null, + apiKeys: database.apiKeys.map(formatConnectionInfo), + } satisfies Database; +} + +function formatConnectionInfo( + connection: PrismaDatabaseConnection, +): DatabaseConnectionInfo { + return { + id: connection.id, + name: connection.name, + createdAt: connection.createdAt, + connectionString: secret(connection.connectionString), + directConnection: connection.directConnection + ? { + host: connection.directConnection.host, + user: connection.directConnection.user, + password: secret(connection.directConnection.pass), + } + : null, + } satisfies DatabaseConnectionInfo; +} + +function resolveId(reference: DatabaseReference | BackupReference): string { + if (typeof reference === "string") return reference; + if (reference?.id) return reference.id; + throw new Error("Unable to resolve id from reference"); +} diff --git a/alchemy/src/prisma/postgres/index.ts b/alchemy/src/prisma/postgres/index.ts new file mode 100644 index 000000000..6aa1f6b85 --- /dev/null +++ b/alchemy/src/prisma/postgres/index.ts @@ -0,0 +1,7 @@ +export * from "./api.ts"; +export * from "./types.ts"; +export * from "./workspace.ts"; +export * from "./project.ts"; +export * from "./database.ts"; +export * from "./database-connection.ts"; +export * from "./database-backups.ts"; diff --git a/alchemy/src/prisma/postgres/project.ts b/alchemy/src/prisma/postgres/project.ts new file mode 100644 index 000000000..0a87b9a5e --- /dev/null +++ b/alchemy/src/prisma/postgres/project.ts @@ -0,0 +1,228 @@ +import type { Context } from "../../context.ts"; +import { Resource } from "../../resource.ts"; +import type { PrismaDatabase, PrismaPostgresRegion } from "./types.ts"; +import { PrismaPostgresApi } from "./api.ts"; +import type { PrismaPostgresAuthProps, PrismaProject } from "./types.ts"; + +/** + * Properties for managing a Prisma Postgres project + */ +type ProjectRegionProps = + | { + /** + * Whether to create the default database when provisioning the project. + * Some workspaces require enabling Prisma Postgres before automatic database + * creation succeeds, so this defaults to false. + * + * @default false + */ + createDatabase?: false | undefined; + + /** + * Region where the initial database will be created (if requested). + */ + region?: PrismaPostgresRegion; + } + | { + /** + * Whether to create the default database when provisioning the project. + * Some workspaces require enabling Prisma Postgres before automatic database + * creation succeeds, so this defaults to false. + * + * @default false + */ + createDatabase: true; + + /** + * Region where the initial database will be created (required when createDatabase is true). + */ + region: PrismaPostgresRegion; + }; + +export type ProjectProps = PrismaPostgresAuthProps & + ProjectRegionProps & { + /** + * Project name. Must be unique per workspace. + */ + name: string; + + /** + * Adopt (reuse) an existing project with the same name instead of creating a new one. + * + * @default true + */ + adopt?: boolean; +}; + +/** + * Prisma Postgres project representation + */ +export interface Project { + /** + * Project identifier + */ + id: string; + + /** + * Project name + */ + name: string; + + /** + * Region used when the project was created (if known) + */ + region?: PrismaPostgresRegion; + + /** + * Timestamp when the project was created + */ + createdAt: string; + + /** + * Workspace metadata + */ + workspace: PrismaProject["workspace"]; + + /** + * Default database created with the project, if any + */ + database: ProjectDatabaseSummary | null; + + /** + * Whether a default database was requested during project creation + */ + createDatabase: boolean; +} + +/** + * Minimal information about a project's default database + */ +export interface ProjectDatabaseSummary { + id: string; + name: string; + status: PrismaDatabase["status"]; + createdAt: string; + isDefault: boolean; + region: PrismaDatabase["region"]; +} + +/** + * Create or adopt Prisma Postgres projects + * + * @example + * // Create a project and provision a database in us-east-1 + * const project = await Project("my-project", { + * name: "my-project", + * region: "us-east-1", + * createDatabase: true, + * }); + * + * @example + * // Reuse an existing project without creating a database + * const project = await Project("existing", { + * name: "existing", + * region: "us-east-1", + * adopt: true, + * createDatabase: false, + * }); + */ +export const Project = Resource( + "prisma-postgres::Project", + async function (this: Context, _id, props: ProjectProps) { + const api = new PrismaPostgresApi(props); + const createDatabase = props.createDatabase ?? false; + const adopt = props.adopt ?? true; + const region = props.region; + + if (createDatabase && !region) { + throw new Error( + "region is required when createDatabase is true for Prisma Postgres projects.", + ); + } + + if (this.phase === "delete") { + if (this.output?.id) { + await api.deleteProject(this.output.id); + } + return this.destroy(); + } + + if (this.phase === "update" && this.output) { + if (region && this.output.region && region !== this.output.region) { + throw new Error( + "Updating Prisma Postgres project region is not supported. Create a new project instead.", + ); + } + if (createDatabase !== this.output.createDatabase) { + throw new Error( + "Changing createDatabase after project creation is not supported.", + ); + } + } + + let project: PrismaProject | undefined; + + if (this.output?.id) { + project = await api.getProject(this.output.id); + } + + if (!project && adopt) { + project = await findProjectByName(api, props.name); + } + + if (!project) { + project = await api.createProject({ + name: props.name, + ...(region ? { region } : {}), + createDatabase, + }); + } + + if (project.name !== props.name) { + throw new Error( + `Project name mismatch. Expected ${props.name} but API returned ${project.name}.`, + ); + } + + return { + id: project.id, + name: project.name, + region: + region ?? + this.output?.region ?? + (project.database?.region?.id as PrismaPostgresRegion | undefined), + createdAt: project.createdAt, + workspace: project.workspace, + database: summarizeDatabase(project.database), + createDatabase, + } satisfies Project; + }, +); + +async function findProjectByName( + api: PrismaPostgresApi, + name: string, +): Promise { + let cursor: string | undefined; + do { + const response = await api.listProjects(cursor); + const found = response.data.find((project) => project.name === name); + if (found) return found; + cursor = response.pagination.nextCursor ?? undefined; + } while (cursor); + return undefined; +} + +function summarizeDatabase( + database: PrismaProject["database"], +): ProjectDatabaseSummary | null { + if (!database) return null; + return { + id: database.id, + name: database.name, + status: database.status, + createdAt: database.createdAt, + isDefault: database.isDefault, + region: database.region, + } satisfies ProjectDatabaseSummary; +} diff --git a/alchemy/src/prisma/postgres/types.ts b/alchemy/src/prisma/postgres/types.ts new file mode 100644 index 000000000..3587d6970 --- /dev/null +++ b/alchemy/src/prisma/postgres/types.ts @@ -0,0 +1,204 @@ +import type { Secret } from "../../secret.ts"; + +/** + * Supported Prisma Postgres regions + */ +export type PrismaPostgresRegion = + | "us-east-1" + | "us-west-1" + | "eu-west-3" + | "eu-central-1" + | "ap-northeast-1" + | "ap-southeast-1"; + +/** + * Authentication options shared across Prisma Postgres resources + */ +export interface PrismaPostgresAuthProps { + /** + * Service token used to authenticate with the Prisma Postgres Management API + * + * @default process.env.PRISMA_SERVICE_TOKEN + */ + serviceToken?: string | Secret; + + /** + * Optional workspace identifier. When provided it will be sent in API requests + * to scope operations explicitly to a workspace. + */ + workspaceId?: string; + + /** + * Override the Management API base URL (useful for testing) + * + * @default https://api.prisma.io/v1 + */ + baseUrl?: string; +} + +/** + * Generic pagination metadata returned by the Management API + */ +export interface PrismaPagination { + nextCursor: string | null; + hasMore: boolean; +} + +/** + * Shape of error responses returned by the Management API + */ +export interface PrismaErrorResponse { + error: { + code: string; + message: string; + }; +} + +/** + * Workspace object returned by the Management API + */ +export interface PrismaWorkspace { + id: string; + type: "workspace"; + name: string; + createdAt: string; +} + +/** + * Workspace list response + */ +export interface PrismaWorkspaceListResponse { + data: PrismaWorkspace[]; + pagination: PrismaPagination; +} + +/** + * Summary information about a workspace embedded inside other responses + */ +export interface PrismaWorkspaceSummary { + id: string; + name: string; +} + +/** + * Connection string information returned alongside databases + */ +export interface PrismaDatabaseConnection { + id: string; + type: "connection"; + name: string; + createdAt: string; + connectionString: string; + directConnection: { + host: string; + user: string; + pass: string; + } | null; + database: PrismaProjectSummary; +} + +/** + * Metadata for a connection as returned by list endpoints (no secrets) + */ +export interface PrismaConnectionListItem { + id: string; + type: "connection"; + name: string; + createdAt: string; + database: PrismaProjectSummary; +} + +/** + * Database object returned by the Management API + */ +export interface PrismaDatabase { + id: string; + type: "database"; + name: string; + status: "failure" | "provisioning" | "ready" | "recovering"; + createdAt: string; + isDefault: boolean; + project: PrismaProjectSummary; + region: { + id: PrismaPostgresRegion; + name: string; + } | null; + apiKeys: PrismaDatabaseConnection[]; + connectionString: string | null; + directConnection: { + host: string; + user: string; + pass: string; + } | null; +} + +/** + * Project summary embedded within other responses + */ +export interface PrismaProjectSummary { + id: string; + name: string; +} + +/** + * Project object returned by the Management API + */ +export interface PrismaProject { + id: string; + type: "project"; + name: string; + createdAt: string; + theme: string | null; + workspace: PrismaWorkspaceSummary; + database: PrismaDatabase | null; +} + +/** + * List response for projects + */ +export interface PrismaProjectListResponse { + data: PrismaProject[]; + pagination: PrismaPagination; +} + +/** + * Database list response + */ +export interface PrismaDatabaseListResponse { + data: PrismaDatabase[]; + pagination: PrismaPagination; +} + +/** + * Database connections list response + */ +export interface PrismaConnectionListResponse { + data: PrismaConnectionListItem[]; + pagination: PrismaPagination; +} + +/** + * Database backups list response + */ +export interface PrismaDatabaseBackupsResponse { + data: PrismaDatabaseBackup[]; + meta: { + backupRetentionDays: number; + }; + pagination: { + hasMore: boolean; + limit: number | null; + }; +} + +/** + * Database backup representation returned by the API + */ +export interface PrismaDatabaseBackup { + id: string; + type: "backup"; + backupType: "full" | "incremental"; + status: "running" | "completed" | "failed" | "unknown"; + createdAt: string; + size?: number; +} diff --git a/alchemy/src/prisma/postgres/workspace.ts b/alchemy/src/prisma/postgres/workspace.ts new file mode 100644 index 000000000..9d5e96955 --- /dev/null +++ b/alchemy/src/prisma/postgres/workspace.ts @@ -0,0 +1,80 @@ +import type { Context } from "../../context.ts"; +import { Resource } from "../../resource.ts"; +import type { PrismaWorkspace, PrismaPostgresAuthProps } from "./types.ts"; +import { PrismaPostgresApi } from "./api.ts"; + +/** + * Properties for looking up a Prisma Postgres workspace + */ +export interface WorkspaceProps extends PrismaPostgresAuthProps { + /** + * Workspace identifier (e.g. `wksp_cmg94yrap00a9xgfncx1mwt34`) + */ + id?: string; + + /** + * Workspace name to resolve + */ + name?: string; +} + +/** + * Prisma Postgres workspace representation + */ +export interface Workspace extends PrismaWorkspace {} + +/** + * Resolve metadata for an existing Prisma Postgres workspace + * + * @example + * // Lookup by workspace id + * const workspace = await Workspace("workspace", { + * id: "wksp_abc123", + * }); + * + * @example + * // Lookup by workspace name + * const workspace = await Workspace("workspace", { + * name: "Production", + * }); + * + * @example + * // Provide a dedicated service token + * const workspace = await Workspace("workspace", { + * id: "wksp_abc123", + * serviceToken: alchemy.secret(process.env.PRISMA_SERVICE_TOKEN), + * }); + */ +export const Workspace = Resource( + "prisma-postgres::Workspace", + async function (this: Context, _id, props: WorkspaceProps) { + if (this.phase === "delete") { + return this.destroy(); + } + + if (!props.id && !props.name) { + throw new Error("Workspace props must include either id or name."); + } + + const api = new PrismaPostgresApi(props); + + let workspace: PrismaWorkspace | undefined; + if (props.id) { + workspace = await api.getWorkspaceById(props.id); + if (props.name && workspace && workspace.name !== props.name) { + throw new Error( + `Workspace id ${props.id} does not match workspace named ${props.name}.`, + ); + } + } else if (props.name) { + workspace = await api.getWorkspaceByName(props.name); + } + + if (!workspace) { + const identifier = props.id ? `id ${props.id}` : `name ${props.name}`; + throw new Error(`Prisma workspace with ${identifier} was not found.`); + } + + return workspace; + }, +); diff --git a/alchemy/test/prisma-postgres/project.test.ts b/alchemy/test/prisma-postgres/project.test.ts new file mode 100644 index 000000000..6b6a826c5 --- /dev/null +++ b/alchemy/test/prisma-postgres/project.test.ts @@ -0,0 +1,144 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { Database } from "../../src/prisma/postgres/database.ts"; +import type { Database as DatabaseOutput } from "../../src/prisma/postgres/database.ts"; +import { DatabaseBackups } from "../../src/prisma/postgres/database-backups.ts"; +import { DatabaseConnection } from "../../src/prisma/postgres/database-connection.ts"; +import type { DatabaseConnection as DatabaseConnectionOutput } from "../../src/prisma/postgres/database-connection.ts"; +import { PrismaPostgresApi } from "../../src/prisma/postgres/api.ts"; +import { Project } from "../../src/prisma/postgres/project.ts"; +import type { Project as ProjectOutput } from "../../src/prisma/postgres/project.ts"; +import { Workspace } from "../../src/prisma/postgres/workspace.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const hasToken = Boolean(process.env.PRISMA_SERVICE_TOKEN); + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe.skipIf(!hasToken)("Prisma Postgres", () => { + test("project, database and connections lifecycle", async (scope) => { + const projectName = `${BRANCH_PREFIX}-prisma-project`; + const databaseName = `${BRANCH_PREFIX}-database`; + const connectionName = `${BRANCH_PREFIX}-connection`; + + const api = new PrismaPostgresApi(); + let project: ProjectOutput | undefined; + let database: DatabaseOutput | undefined; + let connection: DatabaseConnectionOutput | undefined; + + try { + const workspaces = await api.listWorkspaces(); + expect(workspaces.data.length).toBeGreaterThan(0); + + const workspace = await Workspace("workspace", { + id: workspaces.data[0]!.id, + }); + + expect(workspace).toMatchObject({ + id: workspaces.data[0]!.id, + name: expect.any(String), + createdAt: expect.any(String), + }); + + project = await Project("project", { + name: projectName, + region: "us-east-1", + createDatabase: false, + }); + + expect(project).toMatchObject({ + id: expect.any(String), + name: projectName, + region: "us-east-1", + createDatabase: false, + }); + + database = await Database("database", { + project, + name: databaseName, + region: "us-east-1", + adopt: false, + }); + + expect(database.id).toMatch(/^db_/); + expect(database.name).toBe(databaseName); + expect(database.connectionString).toBeDefined(); + expect(database.project.name).toBe(project.name); + expect(database.project.id.endsWith(project.id)).toBe(true); + + connection = await DatabaseConnection("connection", { + database, + name: connectionName, + }); + + expect(connection).toMatchObject({ + id: expect.stringMatching(/^con_/), + name: connectionName, + database: { + id: database.id, + name: database.name, + }, + }); + expect(connection.connectionString.unencrypted).toMatch( + /^prisma\+postgres:\/\//, + ); + + const backups = await DatabaseBackups("backups", { + database, + limit: 5, + }); + + expect(backups).toMatchObject({ + databaseId: database.id, + backups: expect.any(Array), + meta: { + backupRetentionDays: expect.any(Number), + }, + }); + } finally { + await destroy(scope); + + if (connection) { + await assertConnectionDeleted(api, connection.id, database?.id); + } + if (database) { + await assertDatabaseDeleted(api, database.id); + } + if (project) { + await assertProjectDeleted(api, project.id); + } + } + }, 240_000); +}); + +async function assertProjectDeleted(api: PrismaPostgresApi, projectId: string) { + const project = await api.getProject(projectId); + expect(project).toBeUndefined(); +} + +async function assertDatabaseDeleted( + api: PrismaPostgresApi, + databaseId: string, +) { + const database = await api.getDatabase(databaseId); + expect(database).toBeUndefined(); +} + +async function assertConnectionDeleted( + api: PrismaPostgresApi, + connectionId: string, + databaseId?: string, +) { + if (!databaseId) { + return; + } + const existing = await api.listConnections(databaseId); + const found = existing.data.find( + (connection) => connection.id === connectionId, + ); + expect(found).toBeUndefined(); +} diff --git a/bun.lock b/bun.lock index 37b1ebc46..325bb89ea 100644 --- a/bun.lock +++ b/bun.lock @@ -714,6 +714,14 @@ "typescript": "catalog:", }, }, + "examples/prisma-postgres": { + "name": "prisma-postgres-example", + "version": "0.0.0", + "devDependencies": { + "alchemy": "workspace:*", + "typescript": "catalog:", + }, + }, "stacks": { "name": "alchemy-stacks", "version": "0.0.1", @@ -4361,6 +4369,8 @@ "prisma": ["prisma@6.16.3", "", { "dependencies": { "@prisma/config": "6.16.3", "@prisma/engines": "6.16.3" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ=="], + "prisma-postgres-example": ["prisma-postgres-example@workspace:examples/prisma-postgres"], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "proc-log": ["proc-log@3.0.0", "", {}, "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A=="], diff --git a/examples/prisma-postgres/README.md b/examples/prisma-postgres/README.md new file mode 100644 index 000000000..5dee58184 --- /dev/null +++ b/examples/prisma-postgres/README.md @@ -0,0 +1,33 @@ +# Prisma Postgres Example + +This example provisions a Prisma Postgres project, database, and connection string using Alchemy. + +## Prerequisites + +1. Create a Prisma Postgres workspace service token. +2. Export the token before running the example: + + ```bash + export PRISMA_SERVICE_TOKEN="sk_..." + ``` + +3. Choose an Alchemy state password and export it (used to encrypt secrets locally): + + ```bash + export ALCHEMY_PASSWORD="dev-password" + ``` + +## Usage + +```bash +bun i +ALCHEMY_PASSWORD=${ALCHEMY_PASSWORD:-dev-password} bun run alchemy.run.ts +``` + +The script prints the generated database connection string to stdout. + +To tear down the resources: + +```bash +bun run destroy +``` diff --git a/examples/prisma-postgres/alchemy.run.ts b/examples/prisma-postgres/alchemy.run.ts new file mode 100644 index 000000000..d68ebd07a --- /dev/null +++ b/examples/prisma-postgres/alchemy.run.ts @@ -0,0 +1,27 @@ +import alchemy from "alchemy"; +import { Project, Database, DatabaseConnection } from "alchemy/prisma/postgres"; + +const app = await alchemy("prisma-postgres-example", { + password: process.env.ALCHEMY_PASSWORD ?? "dev-password", +}); + +export const project = await Project("project", { + name: "prisma-postgres-example", + region: "us-east-1", + createDatabase: false, +}); + +export const database = await Database("database", { + project, + name: "primary", + region: "us-east-1", +}); + +export const connection = await DatabaseConnection("connection", { + database, + name: "application", +}); + +console.log("Database URL", connection.connectionString.unencrypted); + +await app.finalize(); diff --git a/examples/prisma-postgres/package.json b/examples/prisma-postgres/package.json new file mode 100644 index 000000000..5d4dc38f3 --- /dev/null +++ b/examples/prisma-postgres/package.json @@ -0,0 +1,15 @@ +{ + "name": "prisma-postgres-example", + "version": "0.0.0", + "description": "Alchemy Prisma Postgres example", + "type": "module", + "scripts": { + "build": "tsc -b", + "deploy": "alchemy deploy --env-file ../../.env", + "destroy": "alchemy destroy --env-file ../../.env" + }, + "devDependencies": { + "alchemy": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/examples/prisma-postgres/tsconfig.json b/examples/prisma-postgres/tsconfig.json new file mode 100644 index 000000000..e69b430af --- /dev/null +++ b/examples/prisma-postgres/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["./alchemy.run.ts"] +}