diff --git a/alchemy-web/src/content/docs/providers/neon/branch.md b/alchemy-web/src/content/docs/providers/neon/branch.md new file mode 100644 index 000000000..e6f9773a6 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/neon/branch.md @@ -0,0 +1,266 @@ +--- +title: NeonBranch +description: Learn how to create and manage Neon database branches for instant development environments and preview deployments using Alchemy. +--- + +The NeonBranch resource lets you create and manage database branches within a [Neon serverless PostgreSQL](https://neon.tech) project. Branches are instant, copy-on-write clones of your database that enable Git-like workflows for your data. + +## Minimal Example + +Create a basic development branch with a read-write endpoint: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const dev = await NeonBranch("dev", { + project, + endpoints: [{ type: "read_write" }], +}); + +console.log("Connection URI:", dev.connectionUris[0].connection_uri); +``` + +## Multiple Endpoints + +Create a branch with both read-write and read-only endpoints: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const prod = await NeonBranch("prod", { + project, + name: "production", + protected: true, // Prevent accidental deletion + endpoints: [ + { type: "read_write" }, + { type: "read_only" }, + ], +}); + +// Read-write connection for application +console.log("Write endpoint:", prod.endpoints[0].host); + +// Read-only connection for analytics +console.log("Read endpoint:", prod.endpoints[1].host); +``` + +## Feature Branch from Parent + +Create a feature branch based on another branch: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const main = await NeonBranch("main", { + project, + endpoints: [{ type: "read_write" }], +}); + +const feature = await NeonBranch("feature", { + project, + name: "feature-new-schema", + parentBranch: main, + endpoints: [{ type: "read_write" }], +}); +``` + +## Point-in-Time Branch + +Create a branch from a specific point in time on the parent: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const restore = await NeonBranch("restore", { + project, + name: "restore-before-incident", + parentTimestamp: "2024-10-17T12:00:00Z", + endpoints: [{ type: "read_write" }], +}); +``` + +## Schema-Only Branch + +Create a branch with only the schema (no data): + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const schema = await NeonBranch("schema", { + project, + name: "schema-only", + initSource: "schema-only", + endpoints: [{ type: "read_write" }], +}); +``` + +## Temporary Preview Branch + +Create a branch that automatically expires: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +// Expires in 7 days +const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + +const preview = await NeonBranch("preview", { + project, + name: "preview-pr-123", + expiresAt: expiresAt.toISOString(), + endpoints: [{ type: "read_write" }], +}); +``` + +## Using Connection Parameters + +Access individual connection parameters for manual connection setup: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("app", { + project, + endpoints: [{ type: "read_write" }], +}); + +const conn = branch.connectionUris[0].connection_parameters; + +console.log(`Host: ${conn.host}`); +console.log(`Port: ${conn.port}`); +console.log(`Database: ${conn.database}`); +console.log(`User: ${conn.user}`); +// Password is encrypted in alchemy state +``` + +## Integration with External Branch + +Reference an existing branch by its ID: + +```ts +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const feature = await NeonBranch("feature", { + project, + parentBranch: "br-aged-wind-12345678", // Existing branch ID + endpoints: [{ type: "read_write" }], +}); +``` + +## Resource Properties + +### Input Properties (NeonBranchProps) + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `project` | `string \| NeonProject` | Yes | The project to create the branch in | +| `endpoints` | `BranchCreateRequestEndpointOptions[]` | Yes | Endpoints to create for the branch | +| `name` | `string` | No | Branch name (default: `${app}-${stage}-${id}`) | +| `protected` | `boolean` | No | Protect against accidental deletion (default: `false`) | +| `parentBranch` | `string \| NeonBranch \| Branch` | No | Parent branch to branch from (default: project's default branch) | +| `parentLsn` | `string` | No | Log Sequence Number on parent branch to branch from | +| `parentTimestamp` | `string` | No | ISO 8601 timestamp on parent branch to branch from | +| `initSource` | `"schema-only" \| "parent-data"` | No | Initialize with schema only or full data (default: `"parent-data"`) | +| `expiresAt` | `string` | No | When the branch should auto-delete (RFC 3339 format) | +| `apiKey` | `Secret` | No | Neon API key (overrides `NEON_API_KEY` env var) | + +### Output Properties (NeonBranch) + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | Branch ID (e.g., `br-small-term-683261`) | +| `name` | `string` | Branch name | +| `projectId` | `string` | Project ID this branch belongs to | +| `parentBranchId` | `string \| undefined` | ID of the parent branch | +| `parentLsn` | `string \| undefined` | LSN the branch was created from | +| `parentTimestamp` | `string \| undefined` | Timestamp the branch was created from | +| `initSource` | `"schema-only" \| "parent-data" \| undefined` | How the branch was initialized | +| `protected` | `boolean` | Whether the branch is protected | +| `default` | `boolean` | Whether this is the project's default branch | +| `createdAt` | `Date` | When the branch was created | +| `updatedAt` | `Date` | When the branch was last updated | +| `expiresAt` | `Date \| undefined` | When the branch will auto-delete | +| `endpoints` | `Endpoint[]` | Compute endpoints for the branch | +| `databases` | `Database[]` | Databases on the branch | +| `roles` | `NeonRole[]` | Database roles (passwords are encrypted) | +| `connectionUris` | `NeonConnectionUri[]` | Connection URIs (passwords are encrypted) | + +## Important Notes + +### Endpoints Required + +You must configure at least one endpoint when creating a branch, otherwise you won't be able to connect to it: + +```ts +// ✅ Correct +const branch = await NeonBranch("app", { + project, + endpoints: [{ type: "read_write" }], +}); + +// ❌ Will fail - no endpoints +const branch = await NeonBranch("app", { + project, + endpoints: [], +}); +``` + +### Secret Encryption + +All sensitive data (passwords, connection strings) is automatically encrypted when stored in Alchemy state files using the `Secret` type. + +### Immutable Properties + +These properties cannot be changed after creation (they trigger replacement): +- `project` / `projectId` +- `parentBranch` / `parentBranchId` +- `parentLsn` +- `parentTimestamp` +- `initSource` + +### Branch Workflows + +Branches are perfect for: +- **Development**: Create a dev branch for local development +- **Feature branches**: Mirror your Git workflow with database branches +- **Preview deployments**: Automatically create and destroy branches for PRs +- **Testing**: Create isolated test environments +- **Migrations**: Test schema changes before applying to production +- **Point-in-time restore**: Recover from accidents + +## Related Resources + +- [NeonProject](./project) - Create a Neon serverless PostgreSQL project +- [Neon Branching Guide](https://neon.tech/docs/guides/branching) - Learn more about database branching diff --git a/alchemy-web/src/content/docs/providers/neon/index.md b/alchemy-web/src/content/docs/providers/neon/index.md new file mode 100644 index 000000000..62c829a72 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/neon/index.md @@ -0,0 +1,314 @@ +--- +title: Neon +description: Learn how to manage Neon serverless PostgreSQL projects and database branches using Alchemy. +--- + +Neon is a serverless PostgreSQL database platform that separates compute and storage, enabling instant scaling, database branching, and point-in-time restore capabilities. Alchemy provides resources to manage Neon projects and branches programmatically. + +[Official Neon Documentation](https://neon.tech/docs) | [Neon API Reference](https://api-docs.neon.tech/) + +## Resources + +- [Project](/providers/neon/project) - Create and manage Neon serverless PostgreSQL projects with multiple regions and PostgreSQL versions +- [Branch](/providers/neon/branch) - Create and manage database branches for development, testing, and preview environments +- [Role](/providers/neon/role) - Create and manage Postgres database roles (users) with encrypted passwords + +## Authentication + +Neon authentication uses API keys that can be generated in the [Neon Console](https://console.neon.tech/app/settings/api-keys). + +### Using Environment Variables + +The recommended approach is to use the `NEON_API_KEY` environment variable: + +```bash +export NEON_API_KEY="your-api-key-here" +``` + +Alchemy will automatically use this environment variable for all Neon resources. + +### Using Encrypted Secrets + +For better security, use Alchemy's secret encryption: + +```ts +import { alchemy } from "alchemy"; +import { NeonProject } from "alchemy/neon"; + +const app = await alchemy("my-app"); + +const project = await NeonProject("db", { + name: "My Database", + apiKey: alchemy.secret.env.NEON_API_KEY, // Encrypted in state +}); + +await app.finalize(); +``` + +### Overriding Per Resource + +You can override authentication for individual resources to support multiple Neon accounts: + +```ts +const project1 = await NeonProject("db1", { + name: "Project 1", + apiKey: alchemy.secret.env.NEON_API_KEY_ACCOUNT_1, +}); + +const project2 = await NeonProject("db2", { + name: "Project 2", + apiKey: alchemy.secret.env.NEON_API_KEY_ACCOUNT_2, +}); +``` + +## Example Usage + +### Basic Project and Branch + +```ts +import { alchemy } from "alchemy"; +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const app = await alchemy("my-app"); + +// Create a Neon project +const project = await NeonProject("db", { + name: "My Database", + region_id: "aws-us-east-1", + pg_version: 16, +}); + +// Create a development branch +const dev = await NeonBranch("dev", { + project, + name: "development", + endpoints: [ + { type: "read_write" } + ], +}); + +console.log("Connection URI:", dev.connectionUris[0].connection_uri); + +await app.finalize(); +``` + +### Git-Style Database Workflow + +```ts +import { alchemy } from "alchemy"; +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const app = await alchemy("my-app"); + +// Create project +const project = await NeonProject("db", { + name: "My App Database", +}); + +// Main production branch (created automatically with project) +const mainBranchId = project.branch.id; + +// Development branch from main +const dev = await NeonBranch("dev", { + project, + name: "development", + parentBranch: mainBranchId, + endpoints: [ + { type: "read_write" } + ], +}); + +// Feature branch from dev +const feature = await NeonBranch("feature", { + project, + name: "feature-new-auth", + parentBranch: dev, + endpoints: [ + { type: "read_write" } + ], +}); + +console.log("Feature branch connection:", feature.connectionUris[0].connection_uri); + +await app.finalize(); +``` + +### Preview Deployments + +```ts +import { alchemy } from "alchemy"; +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const app = await alchemy("my-app"); + +const project = await NeonProject("db", { + name: "My App Database", +}); + +// Get PR number from environment (e.g., GitHub Actions) +const prNumber = process.env.PR_NUMBER; + +// Create temporary preview branch that expires in 7 days +const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + +const preview = await NeonBranch(`preview-${prNumber}`, { + project, + name: `preview-pr-${prNumber}`, + parentBranch: project.branch.id, + expiresAt: expiresAt.toISOString(), + endpoints: [ + { type: "read_write" } + ], +}); + +console.log(`Preview environment for PR #${prNumber}:`, preview.connectionUris[0].connection_uri); + +await app.finalize(); +``` + +### Production with Read Replicas + +```ts +import { alchemy } from "alchemy"; +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const app = await alchemy("my-app"); + +const project = await NeonProject("prod-db", { + name: "Production Database", + region_id: "aws-us-east-1", + pg_version: 16, + history_retention_seconds: 604800, // 7 days +}); + +// Production branch with read-write and read-only endpoints +const prod = await NeonBranch("prod", { + project, + name: "production", + protected: true, // Prevent accidental deletion + endpoints: [ + { type: "read_write" }, // For application writes + { type: "read_only" }, // For analytics/reporting + ], +}); + +console.log("Application (read-write):", prod.connectionUris[0].connection_uri); +console.log("Analytics (read-only):", prod.connectionUris[1].connection_uri); + +await app.finalize(); +``` + +### Point-in-Time Recovery + +```ts +import { alchemy } from "alchemy"; +import { NeonProject, NeonBranch } from "alchemy/neon"; + +const app = await alchemy("my-app"); + +const project = await NeonProject("db", { + name: "My Database", +}); + +// Create a branch from 2 hours ago to recover from an incident +const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + +const recovery = await NeonBranch("recovery", { + project, + name: "recovery-branch", + parentTimestamp: twoHoursAgo.toISOString(), + endpoints: [ + { type: "read_write" } + ], +}); + +console.log("Recovery branch:", recovery.connectionUris[0].connection_uri); + +await app.finalize(); +``` + +## Key Features + +### Instant Database Branching + +Create isolated database copies in seconds without duplicating storage. Perfect for: +- Development and testing environments +- Feature branches that mirror your Git workflow +- Preview deployments for pull requests +- Safe schema migration testing + +### Point-in-Time Restore + +Branch from any point in your database's history (within retention period): +- Recover from accidental data changes +- Test with production data at specific timestamps +- Debug issues by recreating the exact database state + +### Automatic Secret Encryption + +All sensitive data is automatically encrypted in Alchemy state files: +- Database passwords +- Connection strings +- API keys + +### Multiple PostgreSQL Versions + +Support for PostgreSQL 14, 15, 16, 17, and 18 with easy version selection. + +### Global Region Support + +Deploy your database close to your users with regions in: +- AWS (US, EU, APAC, South America) +- Azure (US, Europe) + +## Common Patterns + +### Branch Per Environment + +```ts +const envs = ["dev", "staging", "prod"]; + +for (const env of envs) { + await NeonBranch(env, { + project, + name: env, + protected: env === "prod", + endpoints: [{ type: "read_write" }], + }); +} +``` + +### Schema-Only Test Branch + +```ts +const test = await NeonBranch("test", { + project, + name: "test-schema-only", + initSource: "schema-only", // No data, just schema + endpoints: [{ type: "read_write" }], +}); +``` + +### Temporary CI Branch + +```ts +const ci = await NeonBranch("ci", { + project, + name: "ci-test-branch", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour + endpoints: [{ type: "read_write" }], +}); +``` + +## Best Practices + +1. **Use Secret Encryption**: Always use `alchemy.secret.env.X` for API keys +2. **Protect Production**: Set `protected: true` on production branches +3. **Set Expiration**: Use `expiresAt` for temporary preview/test branches +4. **Name Branches Clearly**: Use descriptive names that match your workflow +5. **Use Read Replicas**: Create read-only endpoints for analytics/reporting +6. **Leverage History Retention**: Configure appropriate retention for point-in-time recovery + +## Migration from Other Platforms + +Neon branches make it easy to adopt a database-per-environment workflow without the cost and complexity of managing multiple database servers. If you're migrating from platforms like Heroku, AWS RDS, or other PostgreSQL providers, Neon branches provide instant environment isolation at a fraction of the cost. diff --git a/alchemy-web/src/content/docs/providers/neon/role.md b/alchemy-web/src/content/docs/providers/neon/role.md new file mode 100644 index 000000000..29a6d756e --- /dev/null +++ b/alchemy-web/src/content/docs/providers/neon/role.md @@ -0,0 +1,285 @@ +--- +title: NeonRole +description: Learn how to create and manage Postgres database roles (users) in Neon branches using Alchemy. +--- + +The NeonRole resource lets you create and manage Postgres roles (database users) within a [Neon serverless PostgreSQL](https://neon.tech) branch. In Neon, the terms "role" and "user" are synonymous - a role is a database user with login credentials. + +## Minimal Example + +Create a basic database role in a branch: + +```ts +import { NeonProject, NeonBranch, NeonRole } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("main", { + project, + endpoints: [{ type: "read_write" }], +}); + +const role = await NeonRole("app-user", { + project, + branch, +}); + +console.log("Username:", role.name); +console.log("Password:", role.password.unencrypted); +``` + +## Using Role with External IDs + +Create a role using project and branch IDs instead of resources: + +```ts +import { NeonRole } from "alchemy/neon"; + +const role = await NeonRole("api-user", { + project: "sunny-meadow-12345678", + branch: "br-aged-wind-87654321", +}); + +console.log("Connection ready for:", role.name); +``` + +## No-Login Role + +Create a role that cannot be used for login (useful for ownership and permissions): + +```ts +import { NeonProject, NeonBranch, NeonRole } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("main", { + project, + endpoints: [{ type: "read_write" }], +}); + +const owner = await NeonRole("owner", { + project, + branch, + noLogin: true, // Cannot be used for login +}); + +console.log("Owner role created:", owner.name); +``` + +## Custom Role Name + +Create a role with a specific name: + +```ts +import { NeonProject, NeonBranch, NeonRole } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("main", { + project, + endpoints: [{ type: "read_write" }], +}); + +const role = await NeonRole("app", { + project, + branch, + name: "myapp_user", +}); + +console.log("Role name:", role.name); // "myapp_user" +``` + +## Multiple Roles for Different Services + +Create different roles for different parts of your application: + +```ts +import { NeonProject, NeonBranch, NeonRole } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("prod", { + project, + endpoints: [{ type: "read_write" }], +}); + +// API service role +const apiRole = await NeonRole("api", { + project, + branch, + name: "api_service", +}); + +// Worker service role +const workerRole = await NeonRole("worker", { + project, + branch, + name: "worker_service", +}); + +// Read-only analytics role +const analyticsRole = await NeonRole("analytics", { + project, + branch, + name: "analytics_readonly", +}); + +console.log("API credentials:", apiRole.password.unencrypted); +console.log("Worker credentials:", workerRole.password.unencrypted); +console.log("Analytics credentials:", analyticsRole.password.unencrypted); +``` + +## Using with Connection String + +Access the role password securely for connection strings: + +```ts +import { NeonProject, NeonBranch, NeonRole } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("main", { + project, + endpoints: [{ type: "read_write" }], +}); + +const role = await NeonRole("app", { + project, + branch, +}); + +// Password is encrypted in state but accessible at runtime +const connectionString = `postgresql://${role.name}:${role.password.unencrypted}@${branch.endpoints[0].host}:5432/neondb`; + +console.log("Connection string:", connectionString); +``` + +## Encrypting Role Password + +Store the password securely using alchemy.secret: + +```ts +import { NeonProject, NeonBranch, NeonRole } from "alchemy/neon"; + +const project = await NeonProject("db", { + name: "My Database", +}); + +const branch = await NeonBranch("main", { + project, + endpoints: [{ type: "read_write" }], +}); + +const role = await NeonRole("app", { + project, + branch, +}); + +// The password is automatically wrapped in a Secret and encrypted in state +console.log("Password (encrypted in state):", role.password); +console.log("Password (decrypted at runtime):", role.password.unencrypted); +``` + +## Resource Properties + +### Input Properties (NeonRoleProps) + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `project` | `string \| NeonProject` | Yes | The project containing the branch | +| `branch` | `string \| NeonBranch` | Yes | The branch to create the role in | +| `name` | `string` | No | Role name (max 63 bytes, default: `${app}-${stage}-${id}`) | +| `noLogin` | `boolean` | No | Whether to create a role that cannot login (default: `false`) | +| `apiKey` | `Secret` | No | Neon API key (overrides `NEON_API_KEY` env var) | + +### Output Properties (NeonRole) + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | The role name | +| `projectId` | `string` | The project ID | +| `branchId` | `string` | The branch ID this role belongs to | +| `branch` | `string \| NeonBranch` | The branch reference from input | +| `password` | `Secret` | The role password (encrypted in state) | +| `noLogin` | `boolean` | Whether the role cannot login | +| `createdAt` | `Date` | When the role was created | +| `updatedAt` | `Date` | When the role was last updated | + +## Important Notes + +### Secret Encryption + +Role passwords are automatically wrapped in Alchemy's `Secret` type and encrypted when stored in state files. To access the password at runtime: + +```ts +const role = await NeonRole("app", { + project: "project-id", + branch: "branch-id", +}); + +// Encrypted in state, decrypted at runtime +const plainPassword = role.password.unencrypted; +``` + +### Immutable Properties + +These properties cannot be changed after creation (they trigger replacement): +- `project` / `projectId` +- `branch` / `branchId` +- `name` +- `noLogin` + +Roles do not have an update endpoint in the Neon API, so attempting to update a role will either keep the existing state or trigger a replacement. + +### Role Names + +Role names: +- Cannot exceed 63 bytes in length +- Must be unique within the branch +- Cannot be changed after creation + +### No-Login Roles + +No-login roles (`noLogin: true`) cannot be used to log into the database. They are useful for: +- Ownership of database objects +- Role hierarchies and permission inheritance +- System roles that shouldn't have direct login access + +### Using Branch References + +You can pass either a Branch resource or a branch ID string: + +```ts +// Using Branch resource (recommended) +const branch = await NeonBranch("main", { project, endpoints: [...] }); +const role = await NeonRole("app", { + project, + branch, // Pass the resource +}); + +// Using branch ID string +const role = await NeonRole("app", { + project: "sunny-meadow-12345678", + branch: "br-aged-wind-87654321", // Pass the ID +}); +``` + +### Default Role + +Every branch automatically gets a default role with the same name as the database. You don't need to create an additional role unless you want multiple users or specific permissions. + +## Related Resources + +- [NeonProject](./index) - Create a Neon serverless PostgreSQL project +- [NeonBranch](./branch) - Create and manage database branches +- [Neon Roles Documentation](https://neon.tech/docs/manage/roles) - Learn more about managing roles diff --git a/alchemy/src/neon/branch.ts b/alchemy/src/neon/branch.ts index e8e145034..1298e180c 100644 --- a/alchemy/src/neon/branch.ts +++ b/alchemy/src/neon/branch.ts @@ -8,7 +8,7 @@ import { formatRole, waitForOperations, type NeonConnectionUri, - type NeonRole, + type NeonRoleData, } from "./utils.ts"; export interface NeonBranchProps extends NeonApiOptions { @@ -139,7 +139,7 @@ export interface NeonBranch { /** * The roles for the branch. */ - roles: NeonRole[]; + roles: NeonRoleData[]; /** * The connection URIs for the branch. */ diff --git a/alchemy/src/neon/index.ts b/alchemy/src/neon/index.ts index 9c18c0682..4aae4bf9d 100644 --- a/alchemy/src/neon/index.ts +++ b/alchemy/src/neon/index.ts @@ -1,3 +1,4 @@ export * from "./api.ts"; export * from "./branch.ts"; export * from "./project.ts"; +export * from "./role.ts"; diff --git a/alchemy/src/neon/project.ts b/alchemy/src/neon/project.ts index 8b03ceb0a..dc4a15868 100644 --- a/alchemy/src/neon/project.ts +++ b/alchemy/src/neon/project.ts @@ -7,7 +7,7 @@ import { formatRole, waitForOperations, type NeonConnectionUri, - type NeonRole, + type NeonRoleData, } from "./utils.ts"; /** @@ -137,7 +137,7 @@ export interface NeonProject { /** * Database roles created with the project */ - roles: [NeonRole, ...NeonRole[]]; + roles: [NeonRoleData, ...NeonRoleData[]]; /** * Databases created with the project @@ -244,7 +244,10 @@ export const NeonProject = Resource( NeonConnectionUri, ...NeonConnectionUri[], ], - roles: data.roles.map(formatRole) as [NeonRole, ...NeonRole[]], + roles: data.roles.map(formatRole) as [ + NeonRoleData, + ...NeonRoleData[], + ], databases: data.databases as [neon.Database, ...neon.Database[]], branch, endpoints: endpoints as [neon.Endpoint, ...neon.Endpoint[]], diff --git a/alchemy/src/neon/role.ts b/alchemy/src/neon/role.ts new file mode 100644 index 000000000..29a345249 --- /dev/null +++ b/alchemy/src/neon/role.ts @@ -0,0 +1,230 @@ +import { Secret } from "../secret.ts"; +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { createNeonApi, type NeonApiOptions } from "./api.ts"; +import type { NeonBranch } from "./branch.ts"; +import type { NeonProject } from "./project.ts"; +import { + createConnectionUri, + waitForOperations, + type NeonConnectionUri, +} from "./utils.ts"; + +export interface NeonRoleProps extends NeonApiOptions { + /** + * The project containing the branch. + * This can be a Project object or an ID string. + */ + project: string | NeonProject; + /** + * The branch to create the role in. + * This can be a Branch object or an ID string beginning with `br-`. + */ + branch: string | NeonBranch; + /** + * The role name. Cannot exceed 63 bytes in length. + * @default `${app}-${stage}-${id}` + */ + name?: string; + /** + * Whether to create a role that cannot login. + * @default false + */ + noLogin?: boolean; +} + +export type NeonRole = Omit & { + /** + * The role name + */ + name: string; + /** + * The ID of the branch to which the role belongs + */ + branchId: string; + /** + * The ID of the project to which the role belongs + */ + projectId: string; + /** + * The role password + */ + password: Secret; + /** + * Whether the role cannot login (no_login flag) + */ + noLogin: boolean; + /** + * A timestamp indicating when the role was created + */ + createdAt: Date; + /** + * A timestamp indicating when the role was last updated + */ + updatedAt: Date; + /** + * The connection URIs for the role. + * Generated from the branch's endpoints and databases. + */ + connectionUris: NeonConnectionUri[]; +}; + +/** + * Creates a Postgres role in a Neon branch. + * + * In Neon, the terms "role" and "user" are synonymous. A role is a database user + * with login credentials that can be used to connect to the database. + * + * @example + * ```ts + * const role = await NeonRole("app-role", { + * project: "project-id", + * branch: "branch-id", + * }); + * + * console.log(`Password: ${role.password.unencrypted}`); + * ``` + * + * @example + * ```ts + * const role = await NeonRole("owner", { + * project: "project-id", + * branch: "branch-id", + * noLogin: true, + * }); + * ``` + * + * @example + * ```ts + * const branch = await NeonBranch("dev", { + * project: "project-id", + * endpoints: [{ type: "read-write" }], + * }); + * + * const role = await NeonRole("app-user", { + * project: branch.projectId, + * branch: branch, + * }); + * ``` + */ +export const NeonRole = Resource( + "neon::Role", + async function ( + this: Context, + id: string, + props: NeonRoleProps, + ): Promise { + const api = createNeonApi(props); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + const projectId = + typeof props.project === "string" ? props.project : props.project.id; + const branchId = + typeof props.branch === "string" ? props.branch : props.branch.id; + + switch (this.phase) { + case "delete": { + if (this.output) { + const res = await api.deleteProjectBranchRole({ + path: { + project_id: this.output.projectId, + branch_id: this.output.branchId, + role_name: this.output.name, + }, + throwOnError: false, + }); + if (res.error && res.response.status !== 404) { + throw new Error(`Failed to delete role: ${res.error.message}`, { + cause: res.error, + }); + } + } + return this.destroy(); + } + case "create": { + const { data } = await api.createProjectBranchRole({ + path: { + project_id: projectId, + branch_id: branchId, + }, + body: { + role: { + name, + no_login: props.noLogin, + }, + }, + }); + + // Wait for operations to complete + await waitForOperations(api, data.operations); + + // Fetch the role password + const passwordRes = await api.getProjectBranchRolePassword({ + path: { + project_id: projectId, + branch_id: branchId, + role_name: data.role.name, + }, + }); + + // Fetch endpoints and databases for the branch to generate connection URIs + const [endpointsRes, databasesRes] = await Promise.all([ + api.listProjectBranchEndpoints({ + path: { + project_id: projectId, + branch_id: branchId, + }, + }), + api.listProjectBranchDatabases({ + path: { + project_id: projectId, + branch_id: branchId, + }, + }), + ]); + + // Generate connection URIs for each endpoint × database combination + const connectionUris: NeonConnectionUri[] = []; + const password = passwordRes.data.password; + for (const endpoint of endpointsRes.data.endpoints) { + if (endpoint.host) { + for (const database of databasesRes.data.databases) { + connectionUris.push( + createConnectionUri( + endpoint.host, + database.name, + data.role.name, + password, + ), + ); + } + } + } + + return { + name: data.role.name, + projectId, + branchId, + password: new Secret(password), + noLogin: props.noLogin ?? false, + createdAt: new Date(data.role.created_at), + updatedAt: new Date(data.role.updated_at), + connectionUris, + }; + } + case "update": { + if ( + this.output.projectId !== projectId || + this.output.branchId !== branchId || + this.output.name !== name + ) { + this.replace(); + } + + // Roles don't have an update endpoint, so return current state + // The connectionUris are already in this.output from creation + return this.output; + } + } + }, +); diff --git a/alchemy/src/neon/utils.ts b/alchemy/src/neon/utils.ts index d9ad26d63..75f705a76 100644 --- a/alchemy/src/neon/utils.ts +++ b/alchemy/src/neon/utils.ts @@ -36,7 +36,7 @@ export function formatConnectionUri( }; } -export interface NeonRole { +export interface NeonRoleData { /** * The ID of the branch to which the role belongs */ @@ -63,13 +63,36 @@ export interface NeonRole { updated_at: string; } -export function formatRole(role: neon.Role): NeonRole { +export function formatRole(role: neon.Role): NeonRoleData { return { ...role, password: role.password ? new Secret(role.password) : undefined, }; } +/** + * Creates a connection URI from individual components. + * Used when constructing connection URIs for a specific role. + */ +export function createConnectionUri( + host: string, + database: string, + roleName: string, + password: string, +): NeonConnectionUri { + const connectionString = `postgresql://${roleName}:${password}@${host}/${database}?sslmode=require`; + return { + connection_uri: new Secret(connectionString), + connection_parameters: { + database, + host, + port: 5432, + user: roleName, + password: new Secret(password), + }, + }; +} + export async function waitForOperations( api: NeonClient, operations: neon.Operation[], diff --git a/alchemy/test/neon/role.test.ts b/alchemy/test/neon/role.test.ts new file mode 100644 index 000000000..20faa5f02 --- /dev/null +++ b/alchemy/test/neon/role.test.ts @@ -0,0 +1,169 @@ +import "../../src/test/vitest.ts"; + +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { createNeonApi } from "../../src/neon/api.ts"; +import { NeonBranch } from "../../src/neon/branch.ts"; +import { NeonProject } from "../../src/neon/project.ts"; +import { NeonRole } from "../../src/neon/role.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("NeonRole Resource", () => { + const api = createNeonApi(); + + test("create and delete neon role", async (scope) => { + let project: NeonProject | undefined; + let branch: NeonBranch | undefined; + let role: NeonRole | undefined; + + try { + // Create project and branch for testing + project = await NeonProject("project", {}); + branch = await NeonBranch("branch", { + project, + endpoints: [{ type: "read_write" }], + }); + + // Create role + role = await NeonRole("role", { + project: project.id, + branch: branch.id, + }); + + expect(role).toMatchObject({ + name: expect.any(String), + projectId: project.id, + branchId: branch.id, + password: expect.any(Object), + noLogin: false, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + connectionUris: expect.any(Array), + }); + expect(role.password.unencrypted).toBeDefined(); + expect(typeof role.password.unencrypted).toBe("string"); + expect(role.connectionUris.length).toBeGreaterThan(0); + expect(role.connectionUris[0].connection_uri.unencrypted).toContain( + "postgresql://", + ); + expect(role.connectionUris[0].connection_parameters.user).toBe(role.name); + expect(role.connectionUris[0].connection_parameters.port).toBe(5432); + + // Update should return the same state + const updatedRole = await NeonRole("role", { + project: project.id, + branch: branch.id, + }); + expect(updatedRole.name).toBe(role.name); + } finally { + await destroy(scope); + + // Verify role was deleted + if (role && project && branch) { + const { response } = await api.getProjectBranchRole({ + path: { + project_id: project.id, + branch_id: branch.id, + role_name: role.name, + }, + throwOnError: false, + }); + expect(response.status).toEqual(404); + } + } + }); + + test("create no-login role", async (scope) => { + let project: NeonProject | undefined; + let branch: NeonBranch | undefined; + let role: NeonRole | undefined; + + try { + // Create project and branch for testing + project = await NeonProject("project", {}); + branch = await NeonBranch("branch", { + project, + endpoints: [{ type: "read_write" }], + }); + + // Create no-login role + role = await NeonRole("no-login-role", { + project: project.id, + branch: branch.id, + noLogin: true, + }); + + expect(role).toMatchObject({ + name: expect.any(String), + projectId: project.id, + branchId: branch.id, + noLogin: true, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + } finally { + await destroy(scope); + } + }); + + test("role with custom name", async (scope) => { + let project: NeonProject | undefined; + let branch: NeonBranch | undefined; + let role: NeonRole | undefined; + + try { + // Create project and branch for testing + project = await NeonProject("project", {}); + branch = await NeonBranch("branch", { + project, + endpoints: [{ type: "read_write" }], + }); + + const customName = `${BRANCH_PREFIX}-custom-role`; + + // Create role with custom name + role = await NeonRole("custom", { + project: project.id, + branch: branch.id, + name: customName, + }); + + expect(role.name).toBe(customName); + } finally { + await destroy(scope); + } + }); + + test("role with branch resource", async (scope) => { + let project: NeonProject | undefined; + let branch: NeonBranch | undefined; + let role: NeonRole | undefined; + + try { + // Create project and branch + project = await NeonProject("project", {}); + branch = await NeonBranch("branch", { + project, + endpoints: [{ type: "read_write" }], + }); + + // Create role using branch resource + role = await NeonRole("role", { + project: project, + branch: branch, + }); + + expect(role).toMatchObject({ + projectId: project.id, + branchId: branch.id, + }); + } finally { + await destroy(scope); + } + }); +});