From 05e0f835ab0a9d88eca2f5771eaf955db299183e Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 2 Jan 2026 14:01:54 +0000 Subject: [PATCH 1/2] railway --- .../src/content/docs/guides/railway.mdx | 215 +++++ .../docs/providers/railway/database.md | 172 ++++ .../content/docs/providers/railway/domain.md | 95 +++ .../docs/providers/railway/environment.md | 118 +++ .../content/docs/providers/railway/index.md | 97 +++ .../content/docs/providers/railway/project.md | 89 ++ .../content/docs/providers/railway/service.md | 135 +++ .../docs/providers/railway/tcp-proxy.md | 126 +++ .../docs/providers/railway/variable.md | 119 +++ .../content/docs/providers/railway/volume.md | 95 +++ alchemy/package.json | 4 + alchemy/src/railway/api.ts | 141 ++++ alchemy/src/railway/database.ts | 766 ++++++++++++++++++ alchemy/src/railway/domain.ts | 270 ++++++ alchemy/src/railway/environment.ts | 237 ++++++ alchemy/src/railway/index.ts | 82 ++ alchemy/src/railway/project.ts | 431 ++++++++++ alchemy/src/railway/service.ts | 618 ++++++++++++++ alchemy/src/railway/tcp-proxy.ts | 204 +++++ alchemy/src/railway/variable.ts | 262 ++++++ alchemy/src/railway/volume.ts | 312 +++++++ alchemy/test/railway/database.test.ts | 91 +++ alchemy/test/railway/project.test.ts | 214 +++++ alchemy/test/railway/service.test.ts | 132 +++ bun.lock | 15 + examples/railway/README.md | 76 ++ examples/railway/alchemy.run.ts | 62 ++ examples/railway/package.json | 16 + examples/railway/tsconfig.json | 19 + 29 files changed, 5213 insertions(+) create mode 100644 alchemy-web/src/content/docs/guides/railway.mdx create mode 100644 alchemy-web/src/content/docs/providers/railway/database.md create mode 100644 alchemy-web/src/content/docs/providers/railway/domain.md create mode 100644 alchemy-web/src/content/docs/providers/railway/environment.md create mode 100644 alchemy-web/src/content/docs/providers/railway/index.md create mode 100644 alchemy-web/src/content/docs/providers/railway/project.md create mode 100644 alchemy-web/src/content/docs/providers/railway/service.md create mode 100644 alchemy-web/src/content/docs/providers/railway/tcp-proxy.md create mode 100644 alchemy-web/src/content/docs/providers/railway/variable.md create mode 100644 alchemy-web/src/content/docs/providers/railway/volume.md create mode 100644 alchemy/src/railway/api.ts create mode 100644 alchemy/src/railway/database.ts create mode 100644 alchemy/src/railway/domain.ts create mode 100644 alchemy/src/railway/environment.ts create mode 100644 alchemy/src/railway/index.ts create mode 100644 alchemy/src/railway/project.ts create mode 100644 alchemy/src/railway/service.ts create mode 100644 alchemy/src/railway/tcp-proxy.ts create mode 100644 alchemy/src/railway/variable.ts create mode 100644 alchemy/src/railway/volume.ts create mode 100644 alchemy/test/railway/database.test.ts create mode 100644 alchemy/test/railway/project.test.ts create mode 100644 alchemy/test/railway/service.test.ts create mode 100644 examples/railway/README.md create mode 100644 examples/railway/alchemy.run.ts create mode 100644 examples/railway/package.json create mode 100644 examples/railway/tsconfig.json diff --git a/alchemy-web/src/content/docs/guides/railway.mdx b/alchemy-web/src/content/docs/guides/railway.mdx new file mode 100644 index 0000000000..dd2c6e13dd --- /dev/null +++ b/alchemy-web/src/content/docs/guides/railway.mdx @@ -0,0 +1,215 @@ +--- +title: Railway +description: Deploy applications and databases to Railway using Alchemy. +sidebar: + order: 22 +--- + +import { Steps, Tabs, TabItem } from "@astrojs/starlight/components"; + +This guide walks you through deploying an application with a PostgreSQL database to Railway using Alchemy. + + + +0. Install and setup Alchemy + + + + + ```bash + bun add alchemy + ``` + + + + + ```bash + npm install alchemy + ``` + + + + + ```bash + pnpm add alchemy + ``` + + + + + ```bash + yarn add alchemy + ``` + + + + + :::tip + If you're new to Alchemy, see the [Getting Started](/getting-started/) guide. + ::: + +1. **Get Railway API Token** + + Create an API token from your [Railway account settings](https://railway.com/account/tokens). + +2. **Configure environment variables** + + Add the token to your `.env` file: + + ```bash + RAILWAY_API_TOKEN=your_railway_api_token + ``` + +3. **Create your infrastructure** + + Create `alchemy.run.ts` with a Railway project, service, and database: + + ```ts title="alchemy.run.ts" + import alchemy from "alchemy"; + import { + Project, + Service, + PostgresDatabase, + Domain, + Variable, + } from "alchemy/railway"; + + const app = await alchemy("my-railway-app"); + + // Create a Railway project + const project = await Project("my-project", { + name: "My App", + description: "My application on Railway", + }); + + // Deploy a PostgreSQL database + const db = await PostgresDatabase("db", { + project, + name: "postgres", + }); + + // Deploy the API service from Docker image + const api = await Service("api", { + project, + name: "api", + source: { + image: "nginx:alpine", // Replace with your image + }, + }); + + // Get the production environment + const productionEnv = project.environments[0]; + + // Add database URL to the service + if (productionEnv) { + await Variable("db-url", { + project, + environment: productionEnv.id, + service: api, + name: "DATABASE_URL", + value: db.connectionUrl, + }); + + // Create a public domain + const domain = await Domain("api-domain", { + service: api, + environment: productionEnv.id, + }); + + console.log(`API will be available at: https://${domain.domain}`); + } + + await app.finalize(); + ``` + +4. **Deploy from GitHub (Alternative)** + + To deploy from a GitHub repository instead of a Docker image: + + ```diff lang="ts" title="alchemy.run.ts" + const api = await Service("api", { + project, + name: "api", + - source: { + - image: "nginx:alpine", + - }, + + source: { + + repo: "your-username/your-repo", + + branch: "main", + + }, + + buildCommand: "npm run build", + + startCommand: "npm start", + }); + ``` + +5. **Deploy your application** + + Use the Alchemy CLI to deploy: + + + + ```sh + bun alchemy deploy + ``` + + + ```sh + npx alchemy deploy + ``` + + + ```sh + pnpm alchemy deploy + ``` + + + ```sh + yarn alchemy deploy + ``` + + + + This will create your Railway project, database, and service. The console will output your service URL. + +6. **Verify deployment** + + Visit the [Railway dashboard](https://railway.com) to see your deployed resources. You can also test your service by visiting the domain URL output in the previous step. + +7. **(Optional) Tear down** + + Use the Alchemy CLI to delete all resources: + + + + ```sh + bun alchemy destroy + ``` + + + ```sh + npx alchemy destroy + ``` + + + ```sh + pnpm alchemy destroy + ``` + + + ```sh + yarn alchemy destroy + ``` + + + + :::caution + This will delete your database and all its data. Use `delete: false` on your database resource to preserve data. + ::: + + + +## Next Steps + +- [Railway Provider Documentation](/providers/railway/) - Full API reference +- [Railway Official Docs](https://docs.railway.com) - Learn more about Railway features +- [Railway Templates](https://railway.app/templates) - Pre-built templates for common stacks diff --git a/alchemy-web/src/content/docs/providers/railway/database.md b/alchemy-web/src/content/docs/providers/railway/database.md new file mode 100644 index 0000000000..ad19046e2b --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/database.md @@ -0,0 +1,172 @@ +--- +title: Databases +description: Learn how to deploy databases on Railway with Alchemy +--- + +Railway provides managed database services for PostgreSQL, MySQL, Redis, and MongoDB. + +## Authentication + +To use these resources, set `RAILWAY_API_TOKEN` in your `.env` file. + +## PostgreSQL + +### Basic Usage + +```ts +import { Project, PostgresDatabase } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const db = await PostgresDatabase("db", { + project, + name: "postgres", +}); + +// Use the connection URL +console.log(db.connectionUrl); // Secret +console.log(db.host); // hostname +console.log(db.port); // 5432 +console.log(db.database); // database name +console.log(db.user); // username +console.log(db.password); // Secret +``` + +### With Service + +```ts +import { Project, Service, PostgresDatabase, Variable } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const db = await PostgresDatabase("db", { project }); + +const api = await Service("api", { + project, + source: { repo: "myorg/api" }, +}); + +// Connect the database to the service +const productionEnv = project.environments[0]; +await Variable("db-url", { + project, + environment: productionEnv.id, + service: api, + name: "DATABASE_URL", + value: db.connectionUrl, +}); +``` + +## MySQL + +```ts +import { Project, MysqlDatabase } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const db = await MysqlDatabase("mysql", { + project, + name: "mysql-db", +}); + +console.log(db.connectionUrl); // mysql://... +console.log(db.host); +console.log(db.port); // 3306 +console.log(db.database); +console.log(db.user); +console.log(db.password); +``` + +## Redis + +```ts +import { Project, RedisDatabase } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const redis = await RedisDatabase("cache", { + project, + name: "redis-cache", +}); + +console.log(redis.connectionUrl); // redis://... +console.log(redis.host); +console.log(redis.port); // 6379 +console.log(redis.password); +``` + +## MongoDB + +```ts +import { Project, MongoDatabase } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const mongo = await MongoDatabase("mongo", { + project, + name: "mongodb", +}); + +console.log(mongo.connectionUrl); // mongodb://... +``` + +## Properties (All Databases) + +| Property | Type | Description | +|----------|------|-------------| +| `project` | `string \| Project` | The project to create the database in | +| `environment` | `string \| Environment` | Target environment (optional) | +| `name` | `string` | Name of the database service | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | + +## Outputs + +### PostgreSQL / MySQL + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The service ID | +| `connectionUrl` | `Secret` | Full connection URL | +| `host` | `string` | Database host | +| `port` | `number` | Database port | +| `database` | `string` | Database name | +| `user` | `string` | Database user | +| `password` | `Secret` | Database password | + +### Redis + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The service ID | +| `connectionUrl` | `Secret` | Full connection URL | +| `host` | `string` | Redis host | +| `port` | `number` | Redis port (6379) | +| `password` | `Secret` | Redis password | + +### MongoDB + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The service ID | +| `connectionUrl` | `Secret` | Full connection URL | + +## Data Persistence + +By default, databases are deleted when removed from Alchemy. To preserve data: + +```ts +const db = await PostgresDatabase("db", { + project, + delete: false, // Database will not be deleted +}); +``` diff --git a/alchemy-web/src/content/docs/providers/railway/domain.md b/alchemy-web/src/content/docs/providers/railway/domain.md new file mode 100644 index 0000000000..d8c9d52583 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/domain.md @@ -0,0 +1,95 @@ +--- +title: Domain +description: Learn how to configure domains on Railway with Alchemy +--- + +Configure custom domains or Railway-generated domains for your services. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file. + +## Examples + +### Railway-Generated Domain + +```ts +import { Project, Service, Domain } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const api = await Service("api", { + project, + source: { image: "nginx:alpine" }, +}); + +const productionEnv = project.environments[0]; + +const domain = await Domain("api-domain", { + service: api, + environment: productionEnv.id, +}); + +console.log(`Service available at: https://${domain.domain}`); +// e.g., https://api-production-abc123.up.railway.app +``` + +### Custom Domain + +```ts +import { Project, Service, Domain } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const api = await Service("api", { + project, + source: { repo: "myorg/api" }, +}); + +const productionEnv = project.environments[0]; + +const domain = await Domain("custom-domain", { + service: api, + environment: productionEnv.id, + customDomain: "api.example.com", +}); + +// Don't forget to configure DNS! +// Add a CNAME record pointing api.example.com to your Railway domain +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `service` | `string \| Service` | The service to attach the domain to | +| `environment` | `string \| Environment` | The target environment | +| `customDomain` | `string` | Custom domain (e.g., "api.example.com") | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The domain ID | +| `domain` | `string` | The domain name | +| `serviceId` | `string` | Associated service ID | +| `environmentId` | `string` | Associated environment ID | +| `isCustom` | `boolean` | Whether this is a custom domain | +| `targetPort` | `number` | Target port (if configured) | + +## DNS Configuration + +For custom domains, you need to configure DNS: + +1. **CNAME Record**: Point your custom domain to your Railway-generated domain +2. **SSL**: Railway automatically provisions SSL certificates for custom domains + +Example DNS configuration: +``` +api.example.com. CNAME api-production-abc123.up.railway.app. +``` diff --git a/alchemy-web/src/content/docs/providers/railway/environment.md b/alchemy-web/src/content/docs/providers/railway/environment.md new file mode 100644 index 0000000000..f5decc3b5b --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/environment.md @@ -0,0 +1,118 @@ +--- +title: Environment +description: Learn how to create deployment environments on Railway with Alchemy +--- + +Create and manage deployment environments (staging, production, etc.) within a Railway project. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file. + +## Examples + +### Create Environment + +```ts +import { Project, Environment } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const staging = await Environment("staging", { + project, + name: "staging", +}); + +const production = await Environment("production", { + project, + name: "production", +}); +``` + +### Copy from Existing Environment + +```ts +const staging = await Environment("staging", { + project, + name: "staging", +}); + +// Create preview environment with staging's configuration +const preview = await Environment("preview", { + project, + name: "preview", + sourceEnvironmentId: staging.id, +}); +``` + +### Reference Existing Environment + +```ts +import { EnvironmentRef } from "alchemy/railway"; + +const existingEnv = await EnvironmentRef({ + environmentId: "your-environment-id", +}); +``` + +### Deploy to Specific Environment + +```ts +import { Project, Environment, Service } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const staging = await Environment("staging", { + project, + name: "staging", +}); + +const api = await Service("api", { + project, + environment: staging, + source: { repo: "myorg/api" }, +}); +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `project` | `string \| Project` | The project containing the environment | +| `name` | `string` | Name of the environment | +| `sourceEnvironmentId` | `string` | Environment to copy configuration from | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The environment ID | +| `name` | `string` | Environment name | +| `projectId` | `string` | Parent project ID | +| `createdAt` | `string` | Creation timestamp | +| `updatedAt` | `string` | Last update timestamp | + +## Default Environment + +Every Railway project comes with a default "production" environment. You can access it via the project: + +```ts +const project = await Project("my-project", { + name: "My App", +}); + +const productionEnv = project.environments.find( + (env) => env.name === "production" +); +``` + +## Best Practices + +1. **Consistent Naming**: Use consistent names like "staging", "production", "preview" +2. **Environment Isolation**: Keep production and non-production environments separate +3. **Copy Configuration**: Use `sourceEnvironmentId` to copy variables and settings diff --git a/alchemy-web/src/content/docs/providers/railway/index.md b/alchemy-web/src/content/docs/providers/railway/index.md new file mode 100644 index 0000000000..cc8d88971e --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/index.md @@ -0,0 +1,97 @@ +--- +title: Railway Provider +description: Deploy and manage Railway resources using Alchemy +--- + +The Railway provider allows you to deploy applications, databases, and infrastructure to [Railway](https://railway.app), a modern platform-as-a-service. With this provider, you can create projects, deploy services from GitHub or Docker, provision databases, and configure networking. + +## Authentication + +Set `RAILWAY_API_TOKEN` in your `.env` file or pass `apiToken` directly in your resource configuration. You can create an API token from your [Railway account settings](https://railway.com/account/tokens). + +## Resources + +The Railway provider includes the following resources: + +- [Project](/providers/railway/project/) - Create and manage Railway projects +- [Environment](/providers/railway/environment/) - Create deployment environments (staging, production, etc.) +- [Service](/providers/railway/service/) - Deploy applications from GitHub repos or Docker images +- [Domain](/providers/railway/domain/) - Configure custom or Railway-generated domains +- [Variable](/providers/railway/variable/) - Manage environment variables +- [Volume](/providers/railway/volume/) - Create persistent storage +- [TcpProxy](/providers/railway/tcp-proxy/) - Enable TCP access for databases and other services +- [PostgresDatabase](/providers/railway/database/) - Deploy PostgreSQL databases +- [MysqlDatabase](/providers/railway/database/) - Deploy MySQL databases +- [RedisDatabase](/providers/railway/database/) - Deploy Redis databases +- [MongoDatabase](/providers/railway/database/) - Deploy MongoDB databases + +## Example + +Here's a complete example deploying a web application with a PostgreSQL database: + +```typescript +import { alchemy } from "alchemy"; +import { + Project, + Service, + PostgresDatabase, + Domain, + Variable, +} from "alchemy/railway"; + +const app = await alchemy("my-app", { + stage: "production", +}); + +// Create a Railway project +const project = await Project("my-project", { + name: "My Application", + description: "Production application", +}); + +// Deploy a PostgreSQL database +const db = await PostgresDatabase("db", { + project, + name: "postgres", +}); + +// Deploy the API service from GitHub +const api = await Service("api", { + project, + name: "api", + source: { + repo: "myorg/api", + branch: "main", + }, + startCommand: "npm start", +}); + +// Get the production environment +const productionEnv = project.environments.find((e) => e.name === "production"); + +// Set the database URL as an environment variable +if (productionEnv) { + await Variable("db-url", { + project, + environment: productionEnv.id, + service: api, + name: "DATABASE_URL", + value: db.connectionUrl, + }); +} + +// Add a domain +const domain = await Domain("api-domain", { + service: api, + environment: productionEnv!.id, +}); + +console.log(`API deployed at: https://${domain.domain}`); + +await app.finalize(); +``` + +## Additional Resources + +- [Railway Documentation](https://docs.railway.com) +- [Railway API Reference](https://docs.railway.com/reference/public-api) diff --git a/alchemy-web/src/content/docs/providers/railway/project.md b/alchemy-web/src/content/docs/providers/railway/project.md new file mode 100644 index 0000000000..cbc16460b2 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/project.md @@ -0,0 +1,89 @@ +--- +title: Project +description: Learn how to create and manage Railway Projects with Alchemy +--- + +Create and manage Railway projects. A project is the top-level container for your services, databases, and environments. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file or pass `apiToken` directly in your resource configuration. + +## Examples + +### Basic Project + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My Application", + description: "Production application", +}); +``` + +### With API Token + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("my-project", { + apiToken: alchemy.secret(process.env.RAILWAY_API_TOKEN), + name: "My Application", +}); +``` + +### Adopt Existing Project + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("existing", { + adopt: true, + name: "existing-project-name", +}); +``` + +### Reference Existing Project + +```ts +import { ProjectRef } from "alchemy/railway"; + +const project = await ProjectRef({ + projectId: "your-project-id", +}); +``` + +### Prevent Deletion + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("important", { + name: "Critical Production", + delete: false, // Won't be deleted when removed from Alchemy +}); +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Name of the project | +| `description` | `string` | Description of the project | +| `isPublic` | `boolean` | Whether the project is public (default: false) | +| `teamId` | `string` | Team ID for team projects | +| `adopt` | `boolean` | Adopt an existing project by name | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | +| `apiToken` | `Secret` | Railway API token | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The project ID | +| `name` | `string` | Name of the project | +| `description` | `string` | Description of the project | +| `createdAt` | `string` | Creation timestamp | +| `updatedAt` | `string` | Last update timestamp | +| `environments` | `Array<{id, name}>` | Environments in the project | diff --git a/alchemy-web/src/content/docs/providers/railway/service.md b/alchemy-web/src/content/docs/providers/railway/service.md new file mode 100644 index 0000000000..4aac5c10c8 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/service.md @@ -0,0 +1,135 @@ +--- +title: Service +description: Learn how to deploy services on Railway with Alchemy +--- + +Deploy applications from GitHub repositories or Docker images to Railway. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file or pass `apiToken` directly in your resource configuration. + +## Examples + +### Deploy from GitHub + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const api = await Service("api", { + project, + name: "api-service", + source: { + repo: "myorg/api", + branch: "main", + }, +}); +``` + +### Deploy from Docker Image + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const nginx = await Service("nginx", { + project, + name: "nginx", + source: { + image: "nginx:alpine", + }, +}); +``` + +### With Build Configuration + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const app = await Service("app", { + project, + source: { + repo: "myorg/monorepo", + branch: "main", + }, + buildCommand: "npm run build", + startCommand: "npm start", + rootDirectory: "packages/api", +}); +``` + +### With Environment + +```ts +import { Project, Environment, Service } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const staging = await Environment("staging", { + project, + name: "staging", +}); + +const api = await Service("api", { + project, + environment: staging, + source: { + repo: "myorg/api", + }, +}); +``` + +### With Health Checks + +```ts +const api = await Service("api", { + project, + source: { image: "myorg/api:latest" }, + healthcheckPath: "/health", + healthcheckTimeout: 30, + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 3, +}); +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `project` | `string \| Project` | The project to deploy to | +| `environment` | `string \| Environment` | Target environment | +| `name` | `string` | Name of the service | +| `source.repo` | `string` | GitHub repository (e.g., "owner/repo") | +| `source.branch` | `string` | Git branch to deploy | +| `source.image` | `string` | Docker image to deploy | +| `buildCommand` | `string` | Build command | +| `startCommand` | `string` | Start command | +| `rootDirectory` | `string` | Root directory for builds | +| `numReplicas` | `number` | Number of replicas | +| `healthcheckPath` | `string` | Health check endpoint | +| `healthcheckTimeout` | `number` | Health check timeout (seconds) | +| `restartPolicyType` | `string` | Restart policy: ON_FAILURE, ALWAYS, NEVER | +| `restartPolicyMaxRetries` | `number` | Max restart retries | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The service ID | +| `name` | `string` | Name of the service | +| `projectId` | `string` | Parent project ID | +| `createdAt` | `string` | Creation timestamp | +| `domains` | `Array<{id, domain}>` | Associated domains | diff --git a/alchemy-web/src/content/docs/providers/railway/tcp-proxy.md b/alchemy-web/src/content/docs/providers/railway/tcp-proxy.md new file mode 100644 index 0000000000..41da327fb1 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/tcp-proxy.md @@ -0,0 +1,126 @@ +--- +title: TCP Proxy +description: Learn how to configure TCP proxies on Railway with Alchemy +--- + +Enable external TCP access to your Railway services for databases and other non-HTTP protocols. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file. + +## Examples + +### Database Access + +```ts +import { Project, PostgresDatabase, TcpProxy } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const db = await PostgresDatabase("db", { project }); + +const productionEnv = project.environments[0]; + +const proxy = await TcpProxy("db-proxy", { + service: db, + environment: productionEnv.id, + applicationPort: 5432, +}); + +console.log(`Connect to: ${proxy.domain}:${proxy.proxyPort}`); +// e.g., monorail.proxy.rlwy.net:12345 +``` + +### Redis Access + +```ts +import { Project, RedisDatabase, TcpProxy } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const redis = await RedisDatabase("cache", { project }); + +const productionEnv = project.environments[0]; + +const proxy = await TcpProxy("redis-proxy", { + service: redis, + environment: productionEnv.id, + applicationPort: 6379, +}); + +// Connect from external applications +// redis-cli -h monorail.proxy.rlwy.net -p 12345 -a +``` + +### Custom Service + +```ts +import { Project, Service, TcpProxy } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const mqService = await Service("rabbitmq", { + project, + source: { image: "rabbitmq:3-management" }, +}); + +const productionEnv = project.environments[0]; + +// Expose AMQP port +const amqpProxy = await TcpProxy("amqp-proxy", { + service: mqService, + environment: productionEnv.id, + applicationPort: 5672, +}); +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `service` | `string \| Service` | The service to proxy | +| `environment` | `string \| Environment` | The target environment | +| `applicationPort` | `number` | The port on the service to forward to | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The TCP proxy ID | +| `domain` | `string` | The proxy domain (e.g., monorail.proxy.rlwy.net) | +| `proxyPort` | `number` | The external port to connect to | +| `applicationPort` | `number` | The internal application port | +| `serviceId` | `string` | Associated service ID | +| `environmentId` | `string` | Associated environment ID | + +## Connection String + +To connect to your proxied service: + +``` +://: + +# PostgreSQL +postgresql://user:pass@monorail.proxy.rlwy.net:12345/dbname + +# Redis +redis://default:pass@monorail.proxy.rlwy.net:12346 + +# MongoDB +mongodb://user:pass@monorail.proxy.rlwy.net:12347/dbname +``` + +## Use Cases + +- **Database Access**: Connect to databases from local development +- **Message Queues**: Expose RabbitMQ, Redis Pub/Sub, etc. +- **Game Servers**: TCP-based game server connections +- **Custom Protocols**: Any non-HTTP TCP service diff --git a/alchemy-web/src/content/docs/providers/railway/variable.md b/alchemy-web/src/content/docs/providers/railway/variable.md new file mode 100644 index 0000000000..99a715f326 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/variable.md @@ -0,0 +1,119 @@ +--- +title: Variable +description: Learn how to manage environment variables on Railway with Alchemy +--- + +Manage environment variables for your Railway services. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file. + +## Examples + +### Service-Specific Variable + +```ts +import { Project, Service, Variable } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const api = await Service("api", { + project, + source: { repo: "myorg/api" }, +}); + +const productionEnv = project.environments[0]; + +await Variable("api-key", { + project, + environment: productionEnv.id, + service: api, + name: "API_KEY", + value: alchemy.secret(process.env.API_KEY), +}); +``` + +### Shared Variable + +Variables without a service are shared across all services in the environment: + +```ts +await Variable("shared-secret", { + project, + environment: productionEnv.id, + name: "SHARED_SECRET", + value: alchemy.secret(process.env.SHARED_SECRET), +}); +``` + +### Reference Variables + +Use Railway's reference syntax to link variables between services: + +```ts +// Reference a variable from another service +await Variable("redis-url", { + project, + environment: productionEnv.id, + service: api, + name: "REDIS_URL", + value: "${{Redis.REDIS_URL}}", +}); +``` + +### Database Connection + +```ts +import { Project, Service, PostgresDatabase, Variable } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const db = await PostgresDatabase("db", { project }); + +const api = await Service("api", { + project, + source: { repo: "myorg/api" }, +}); + +const productionEnv = project.environments[0]; + +await Variable("database-url", { + project, + environment: productionEnv.id, + service: api, + name: "DATABASE_URL", + value: db.connectionUrl, +}); +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `project` | `string \| Project` | The project containing the variable | +| `environment` | `string \| Environment` | The target environment | +| `service` | `string \| Service` | Service to attach to (optional for shared) | +| `name` | `string` | Variable name (e.g., "DATABASE_URL") | +| `value` | `string \| Secret` | Variable value | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Variable name | +| `value` | `Secret` | Variable value (wrapped) | +| `projectId` | `string` | Project ID | +| `environmentId` | `string` | Environment ID | +| `serviceId` | `string` | Service ID (if service-specific) | + +## Best Practices + +1. **Use Secrets**: Always wrap sensitive values with `alchemy.secret()` +2. **Service-Specific**: Prefer service-specific variables over shared when possible +3. **Reference Variables**: Use Railway's reference syntax for cross-service dependencies diff --git a/alchemy-web/src/content/docs/providers/railway/volume.md b/alchemy-web/src/content/docs/providers/railway/volume.md new file mode 100644 index 0000000000..20866c51b2 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/volume.md @@ -0,0 +1,95 @@ +--- +title: Volume +description: Learn how to create persistent storage on Railway with Alchemy +--- + +Create persistent storage volumes for your Railway services. + +## Authentication + +To use this resource, set `RAILWAY_API_TOKEN` in your `.env` file. + +## Examples + +### Basic Volume + +```ts +import { Project, Service, Volume } from "alchemy/railway"; + +const project = await Project("my-project", { + name: "My App", +}); + +const api = await Service("api", { + project, + source: { image: "node:20" }, +}); + +const productionEnv = project.environments[0]; + +const uploads = await Volume("uploads", { + project, + service: api, + environment: productionEnv.id, + mountPath: "/app/uploads", +}); +``` + +### Database Storage + +```ts +const dbService = await Service("postgres", { + project, + source: { image: "postgres:16" }, +}); + +const dataVolume = await Volume("postgres-data", { + project, + service: dbService, + environment: productionEnv.id, + name: "postgres-data", + mountPath: "/var/lib/postgresql/data", +}); +``` + +### Preserve Data on Destroy + +```ts +const volume = await Volume("important-data", { + project, + service: api, + environment: productionEnv.id, + mountPath: "/data", + delete: false, // Volume won't be deleted when removed from Alchemy +}); +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `project` | `string \| Project` | The project containing the volume | +| `service` | `string \| Service` | The service to attach the volume to | +| `environment` | `string \| Environment` | The target environment | +| `name` | `string` | Name of the volume | +| `mountPath` | `string` | Path where the volume is mounted in the container | +| `delete` | `boolean` | Whether to delete on destroy (default: true) | + +## Outputs + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | The volume ID | +| `name` | `string` | Volume name | +| `mountPath` | `string` | Mount path | +| `projectId` | `string` | Project ID | +| `serviceId` | `string` | Service ID | +| `environmentId` | `string` | Environment ID | +| `createdAt` | `string` | Creation timestamp | + +## Important Notes + +- **Data Persistence**: Volumes persist data across deployments +- **Single Service**: Each volume can only be attached to one service +- **Mount Path Changes**: Changing the mount path requires volume replacement +- **Data Loss Warning**: Deleting a volume permanently deletes all data diff --git a/alchemy/package.json b/alchemy/package.json index c26678aa7d..e89b552d14 100644 --- a/alchemy/package.json +++ b/alchemy/package.json @@ -139,6 +139,10 @@ "bun": "./src/prisma-postgres/index.ts", "import": "./lib/prisma-postgres/index.js" }, + "./railway": { + "bun": "./src/railway/index.ts", + "import": "./lib/railway/index.js" + }, "./random": { "bun": "./src/random/index.ts", "import": "./lib/random/index.js" diff --git a/alchemy/src/railway/api.ts b/alchemy/src/railway/api.ts new file mode 100644 index 0000000000..3e300ac14d --- /dev/null +++ b/alchemy/src/railway/api.ts @@ -0,0 +1,141 @@ +import type { Secret } from "../secret.ts"; + +/** + * Options for Railway API requests + */ +export interface RailwayApiOptions { + /** + * Base URL for Railway GraphQL API + * @default https://backboard.railway.com/graphql/v2 + */ + baseUrl?: string; + + /** + * API token to use (overrides RAILWAY_API_TOKEN env var) + */ + apiToken?: Secret; +} + +/** + * Error from Railway API + */ +export class RailwayError extends Error { + code: string; + status: number; + + constructor(props: { message: string; code?: string; status: number }) { + super(`Railway API error (${props.status}): ${props.message}`); + this.code = props.code ?? "UNKNOWN"; + this.status = props.status; + } +} + +/** + * GraphQL error shape from Railway API + */ +interface GraphQLError { + message: string; + extensions?: { + code?: string; + }; +} + +/** + * GraphQL response shape + */ +interface GraphQLResponse { + data?: T; + errors?: GraphQLError[]; +} + +/** + * Railway API client for GraphQL operations + */ +export interface RailwayClient { + /** + * Execute a GraphQL query or mutation + */ + request(query: string, variables?: Record): Promise; +} + +/** + * Create a Railway API client with environment variable fallback + * @param options API options + * @returns Railway API client instance + */ +export function createRailwayApi( + options: Partial = {}, +): RailwayClient { + const apiToken = + options.apiToken?.unencrypted ?? process.env.RAILWAY_API_TOKEN; + if (!apiToken) { + throw new Error( + "Railway API token is required. Set RAILWAY_API_TOKEN environment variable or provide apiToken option.", + ); + } + + const baseUrl = options.baseUrl ?? "https://backboard.railway.com/graphql/v2"; + + return { + async request( + query: string, + variables?: Record, + ): Promise { + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ + query, + variables, + }), + }); + + if (!response.ok) { + throw new RailwayError({ + message: `HTTP error: ${response.statusText}`, + status: response.status, + }); + } + + const json = (await response.json()) as GraphQLResponse; + + if (json.errors && json.errors.length > 0) { + const firstError = json.errors[0]; + throw new RailwayError({ + message: firstError.message, + code: firstError.extensions?.code, + status: 400, + }); + } + + if (!json.data) { + throw new RailwayError({ + message: "No data returned from Railway API", + status: 500, + }); + } + + return json.data; + }, + }; +} + +/** + * Helper to extract a specific field from a GraphQL response + */ +export function extractResult( + data: Record, + field: string, +): T { + const result = data[field]; + if (result === undefined) { + throw new RailwayError({ + message: `Expected field '${field}' not found in response`, + status: 500, + }); + } + return result as T; +} diff --git a/alchemy/src/railway/database.ts b/alchemy/src/railway/database.ts new file mode 100644 index 0000000000..4aaeefe9da --- /dev/null +++ b/alchemy/src/railway/database.ts @@ -0,0 +1,766 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { Secret } from "../secret.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isEnvironment, type Environment } from "./environment.ts"; +import { isProject, type Project } from "./project.ts"; + +/** + * Railway database template IDs + * @internal + */ +const DATABASE_TEMPLATES = { + postgres: "postgres", + mysql: "mysql", + redis: "redis", + mongodb: "mongo", +} as const; + +/** + * Base properties for creating a Railway database + */ +interface BaseDatabaseProps extends RailwayApiOptions { + /** + * The project to create the database in + */ + project: string | Project; + + /** + * The environment to create the database in (optional) + */ + environment?: string | Environment; + + /** + * Name of the database service + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Whether to delete the database when the resource is destroyed. + * Note: Deleting a database will permanently delete all data. + * @default true + */ + delete?: boolean; +} + +/** + * Properties for creating a Railway PostgreSQL database + */ +export interface PostgresDatabaseProps extends BaseDatabaseProps {} + +/** + * Properties for creating a Railway MySQL database + */ +export interface MysqlDatabaseProps extends BaseDatabaseProps {} + +/** + * Properties for creating a Railway Redis database + */ +export interface RedisDatabaseProps extends BaseDatabaseProps {} + +/** + * Properties for creating a Railway MongoDB database + */ +export interface MongoDatabaseProps extends BaseDatabaseProps {} + +/** + * Base output for Railway database + */ +interface BaseDatabase { + /** + * The ID of the database service + */ + id: string; + + /** + * Name of the database service + */ + name: string; + + /** + * The project ID this database belongs to + */ + projectId: string; + + /** + * Time at which the database was created + */ + createdAt: string; +} + +/** + * Output returned after Railway PostgreSQL database creation + */ +export interface PostgresDatabase extends BaseDatabase { + /** + * PostgreSQL connection URL + */ + connectionUrl: Secret; + + /** + * Database host + */ + host: string; + + /** + * Database port + */ + port: number; + + /** + * Database name + */ + database: string; + + /** + * Database user + */ + user: string; + + /** + * Database password + */ + password: Secret; + + /** + * Database type identifier + */ + type: "postgres"; +} + +/** + * Output returned after Railway MySQL database creation + */ +export interface MysqlDatabase extends BaseDatabase { + /** + * MySQL connection URL + */ + connectionUrl: Secret; + + /** + * Database host + */ + host: string; + + /** + * Database port + */ + port: number; + + /** + * Database name + */ + database: string; + + /** + * Database user + */ + user: string; + + /** + * Database password + */ + password: Secret; + + /** + * Database type identifier + */ + type: "mysql"; +} + +/** + * Output returned after Railway Redis database creation + */ +export interface RedisDatabase extends BaseDatabase { + /** + * Redis connection URL + */ + connectionUrl: Secret; + + /** + * Redis host + */ + host: string; + + /** + * Redis port + */ + port: number; + + /** + * Redis password + */ + password: Secret; + + /** + * Database type identifier + */ + type: "redis"; +} + +/** + * Output returned after Railway MongoDB database creation + */ +export interface MongoDatabase extends BaseDatabase { + /** + * MongoDB connection URL + */ + connectionUrl: Secret; + + /** + * Database type identifier + */ + type: "mongodb"; +} + +/** + * GraphQL response types + * @internal + */ +interface TemplateDeployResponse { + templateDeploy: { + projectId: string; + workflowId: string; + }; +} + +interface ServiceCreateResponse { + serviceCreate: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + }; +} + +interface VariablesQueryResponse { + variables: Record; +} + +interface ServicesQueryResponse { + project: { + services: { + edges: Array<{ + node: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + }; + }>; + }; + }; +} + +/** + * Type guards for Railway databases + */ +export function isPostgresDatabase( + resource: any, +): resource is PostgresDatabase { + return resource?.[ResourceKind] === "railway::PostgresDatabase"; +} + +export function isMysqlDatabase(resource: any): resource is MysqlDatabase { + return resource?.[ResourceKind] === "railway::MysqlDatabase"; +} + +export function isRedisDatabase(resource: any): resource is RedisDatabase { + return resource?.[ResourceKind] === "railway::RedisDatabase"; +} + +export function isMongoDatabase(resource: any): resource is MongoDatabase { + return resource?.[ResourceKind] === "railway::MongoDatabase"; +} + +/** + * Get the project ID from a project reference + */ +function getProjectId(project: string | Project): string { + if (isProject(project)) { + return project.id; + } + return project; +} + +/** + * Get the environment ID from an environment reference + */ +function getEnvironmentId( + environment: string | Environment | undefined, +): string | undefined { + if (environment === undefined) { + return undefined; + } + if (isEnvironment(environment)) { + return environment.id; + } + return environment; +} + +/** + * Helper to fetch variables for a service + */ +async function fetchServiceVariables( + api: ReturnType, + projectId: string, + serviceId: string, + environmentId?: string, +): Promise> { + // Get the first environment if not specified + let envId = environmentId; + if (!envId) { + const projectData = await api.request<{ + project: { + environments: { + edges: Array<{ node: { id: string; name: string } }>; + }; + }; + }>( + ` + query project($id: String!) { + project(id: $id) { + environments { + edges { + node { + id + name + } + } + } + } + } + `, + { id: projectId }, + ); + + const firstEnv = projectData.project.environments.edges[0]; + if (firstEnv) { + envId = firstEnv.node.id; + } + } + + if (!envId) { + return {}; + } + + const data = await api.request( + ` + query variables($projectId: String!, $environmentId: String!, $serviceId: String) { + variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) + } + `, + { + projectId, + environmentId: envId, + serviceId, + }, + ); + + return data.variables; +} + +/** + * Helper to delete a database service + */ +async function deleteDatabase( + api: ReturnType, + serviceId: string, +): Promise { + try { + await api.request( + ` + mutation serviceDelete($id: String!) { + serviceDelete(id: $id) + } + `, + { id: serviceId }, + ); + } catch (error) { + // Ignore 404 errors (service already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } +} + +/** + * Creates a Railway PostgreSQL database. + * + * @example + * // Create a PostgreSQL database: + * const db = await PostgresDatabase("db", { + * project: project, + * name: "my-postgres" + * }); + * + * // Use the connection URL in another service: + * await Variable("db-url", { + * project, + * environment: production, + * service: apiService, + * name: "DATABASE_URL", + * value: db.connectionUrl + * }); + */ +export const PostgresDatabase = Resource( + "railway::PostgresDatabase", + async function ( + this: Context, + id: string, + props: PostgresDatabaseProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const environmentId = getEnvironmentId(props.environment); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + await deleteDatabase(api, this.output.id); + } + return this.destroy(); + } + + // For updates, we can only update the name - other properties require replacement + if (this.phase === "update" && this.output?.id) { + // Just return the current state - name updates would require service update + return this.output; + } + + // Create database service using Docker image + const data = await api.request( + ` + mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + projectId + createdAt + updatedAt + } + } + `, + { + input: { + name, + projectId, + source: { + image: "postgres:16", + }, + }, + }, + ); + + // Wait a moment for variables to be populated + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Fetch variables to get connection info + const variables = await fetchServiceVariables( + api, + projectId, + data.serviceCreate.id, + environmentId, + ); + + const connectionUrl = + variables.DATABASE_URL || variables.POSTGRES_URL || ""; + const host = variables.PGHOST || variables.POSTGRES_HOST || "localhost"; + const port = parseInt( + variables.PGPORT || variables.POSTGRES_PORT || "5432", + 10, + ); + const database = + variables.PGDATABASE || variables.POSTGRES_DB || "postgres"; + const user = variables.PGUSER || variables.POSTGRES_USER || "postgres"; + const password = variables.PGPASSWORD || variables.POSTGRES_PASSWORD || ""; + + return { + id: data.serviceCreate.id, + name: data.serviceCreate.name, + projectId: data.serviceCreate.projectId, + createdAt: data.serviceCreate.createdAt, + connectionUrl: Secret.wrap(connectionUrl), + host, + port, + database, + user, + password: Secret.wrap(password), + type: "postgres", + }; + }, +); + +/** + * Creates a Railway MySQL database. + * + * @example + * // Create a MySQL database: + * const db = await MysqlDatabase("db", { + * project: project, + * name: "my-mysql" + * }); + */ +export const MysqlDatabase = Resource( + "railway::MysqlDatabase", + async function ( + this: Context, + id: string, + props: MysqlDatabaseProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const environmentId = getEnvironmentId(props.environment); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + await deleteDatabase(api, this.output.id); + } + return this.destroy(); + } + + if (this.phase === "update" && this.output?.id) { + return this.output; + } + + // Create database service using Docker image + const data = await api.request( + ` + mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + projectId + createdAt + updatedAt + } + } + `, + { + input: { + name, + projectId, + source: { + image: "mysql:8", + }, + }, + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const variables = await fetchServiceVariables( + api, + projectId, + data.serviceCreate.id, + environmentId, + ); + + const connectionUrl = variables.DATABASE_URL || variables.MYSQL_URL || ""; + const host = variables.MYSQL_HOST || variables.MYSQLHOST || "localhost"; + const port = parseInt( + variables.MYSQL_PORT || variables.MYSQLPORT || "3306", + 10, + ); + const database = + variables.MYSQL_DATABASE || variables.MYSQLDATABASE || "mysql"; + const user = variables.MYSQL_USER || variables.MYSQLUSER || "root"; + const password = variables.MYSQL_PASSWORD || variables.MYSQLPASSWORD || ""; + + return { + id: data.serviceCreate.id, + name: data.serviceCreate.name, + projectId: data.serviceCreate.projectId, + createdAt: data.serviceCreate.createdAt, + connectionUrl: Secret.wrap(connectionUrl), + host, + port, + database, + user, + password: Secret.wrap(password), + type: "mysql", + }; + }, +); + +/** + * Creates a Railway Redis database. + * + * @example + * // Create a Redis database: + * const redis = await RedisDatabase("cache", { + * project: project, + * name: "my-redis" + * }); + */ +export const RedisDatabase = Resource( + "railway::RedisDatabase", + async function ( + this: Context, + id: string, + props: RedisDatabaseProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const environmentId = getEnvironmentId(props.environment); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + await deleteDatabase(api, this.output.id); + } + return this.destroy(); + } + + if (this.phase === "update" && this.output?.id) { + return this.output; + } + + // Create database service using Docker image + const data = await api.request( + ` + mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + projectId + createdAt + updatedAt + } + } + `, + { + input: { + name, + projectId, + source: { + image: "redis:7", + }, + }, + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const variables = await fetchServiceVariables( + api, + projectId, + data.serviceCreate.id, + environmentId, + ); + + const connectionUrl = variables.REDIS_URL || ""; + const host = variables.REDIS_HOST || variables.REDISHOST || "localhost"; + const port = parseInt( + variables.REDIS_PORT || variables.REDISPORT || "6379", + 10, + ); + const password = variables.REDIS_PASSWORD || variables.REDISPASSWORD || ""; + + return { + id: data.serviceCreate.id, + name: data.serviceCreate.name, + projectId: data.serviceCreate.projectId, + createdAt: data.serviceCreate.createdAt, + connectionUrl: Secret.wrap(connectionUrl), + host, + port, + password: Secret.wrap(password), + type: "redis", + }; + }, +); + +/** + * Creates a Railway MongoDB database. + * + * @example + * // Create a MongoDB database: + * const mongo = await MongoDatabase("db", { + * project: project, + * name: "my-mongo" + * }); + */ +export const MongoDatabase = Resource( + "railway::MongoDatabase", + async function ( + this: Context, + id: string, + props: MongoDatabaseProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const environmentId = getEnvironmentId(props.environment); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + await deleteDatabase(api, this.output.id); + } + return this.destroy(); + } + + if (this.phase === "update" && this.output?.id) { + return this.output; + } + + // Create database service using Docker image + const data = await api.request( + ` + mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + projectId + createdAt + updatedAt + } + } + `, + { + input: { + name, + projectId, + source: { + image: "mongo:7", + }, + }, + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const variables = await fetchServiceVariables( + api, + projectId, + data.serviceCreate.id, + environmentId, + ); + + const connectionUrl = variables.MONGO_URL || variables.MONGODB_URL || ""; + + return { + id: data.serviceCreate.id, + name: data.serviceCreate.name, + projectId: data.serviceCreate.projectId, + createdAt: data.serviceCreate.createdAt, + connectionUrl: Secret.wrap(connectionUrl), + type: "mongodb", + }; + }, +); diff --git a/alchemy/src/railway/domain.ts b/alchemy/src/railway/domain.ts new file mode 100644 index 0000000000..a66d8932fd --- /dev/null +++ b/alchemy/src/railway/domain.ts @@ -0,0 +1,270 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isEnvironment, type Environment } from "./environment.ts"; +import { isService, type Service } from "./service.ts"; + +/** + * Properties for creating a Railway domain + */ +export interface DomainProps extends RailwayApiOptions { + /** + * The service to attach the domain to + */ + service: string | Service; + + /** + * The environment to attach the domain to + */ + environment: string | Environment; + + /** + * Custom domain to use (e.g., "api.example.com") + * If not provided, a Railway-generated domain will be created + */ + customDomain?: string; + + /** + * Whether to delete the domain when the resource is destroyed. + * @default true + */ + delete?: boolean; +} + +/** + * Output returned after Railway domain creation + */ +export interface Domain { + /** + * The ID of the domain + */ + id: string; + + /** + * The domain name + */ + domain: string; + + /** + * The service ID this domain is attached to + */ + serviceId: string; + + /** + * The environment ID this domain is attached to + */ + environmentId: string; + + /** + * Whether this is a custom domain + */ + isCustom: boolean; + + /** + * Target port for the domain (for custom domains) + */ + targetPort?: number; +} + +/** + * GraphQL response types + * @internal + */ +interface ServiceDomainCreateResponse { + serviceDomainCreate: { + id: string; + domain: string; + serviceId: string; + environmentId: string; + targetPort?: number; + }; +} + +interface CustomDomainCreateResponse { + customDomainCreate: { + id: string; + domain: string; + serviceId: string; + environmentId: string; + targetPort?: number; + }; +} + +/** + * Type guard for Railway Domain + */ +export function isDomain(resource: any): resource is Domain { + return resource?.[ResourceKind] === "railway::Domain"; +} + +/** + * Get the service ID from a service reference + */ +function getServiceId(service: string | Service): string { + if (isService(service)) { + return service.id; + } + return service; +} + +/** + * Get the environment ID from an environment reference + */ +function getEnvironmentId(environment: string | Environment): string { + if (isEnvironment(environment)) { + return environment.id; + } + return environment; +} + +/** + * Creates a Railway domain for accessing a service. + * + * @example + * // Create a Railway-generated domain: + * const domain = await Domain("api-domain", { + * service: apiService, + * environment: production + * }); + * console.log(`API available at: https://${domain.domain}`); + * + * @example + * // Create a custom domain: + * const domain = await Domain("custom-domain", { + * service: apiService, + * environment: production, + * customDomain: "api.example.com" + * }); + */ +export const Domain = Resource( + "railway::Domain", + async function ( + this: Context, + id: string, + props: DomainProps, + ): Promise { + const api = createRailwayApi(props); + const serviceId = getServiceId(props.service); + const environmentId = getEnvironmentId(props.environment); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + if (this.output.isCustom) { + await api.request( + ` + mutation customDomainDelete($id: String!) { + customDomainDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } else { + await api.request( + ` + mutation serviceDomainDelete($id: String!) { + serviceDomainDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } + } catch (error) { + // Ignore 404 errors (domain already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // Domains are immutable - changing domain type requires replacement + if (this.phase === "update" && this.output?.id) { + const currentIsCustom = this.output.isCustom; + const newIsCustom = !!props.customDomain; + + // If switching between custom and generated, replace + if (currentIsCustom !== newIsCustom) { + return this.replace(); + } + + // If custom domain name changed, replace + if (newIsCustom && this.output.domain !== props.customDomain) { + return this.replace(); + } + + // No changes needed + return this.output; + } + + // Create custom domain + if (props.customDomain) { + const data = await api.request( + ` + mutation customDomainCreate($input: CustomDomainCreateInput!) { + customDomainCreate(input: $input) { + id + domain + serviceId + environmentId + targetPort + } + } + `, + { + input: { + domain: props.customDomain, + serviceId, + environmentId, + }, + }, + ); + + return { + id: data.customDomainCreate.id, + domain: data.customDomainCreate.domain, + serviceId: data.customDomainCreate.serviceId, + environmentId: data.customDomainCreate.environmentId, + isCustom: true, + targetPort: data.customDomainCreate.targetPort, + }; + } + + // Create Railway-generated domain + const data = await api.request( + ` + mutation serviceDomainCreate($input: ServiceDomainCreateInput!) { + serviceDomainCreate(input: $input) { + id + domain + serviceId + environmentId + targetPort + } + } + `, + { + input: { + serviceId, + environmentId, + }, + }, + ); + + return { + id: data.serviceDomainCreate.id, + domain: data.serviceDomainCreate.domain, + serviceId: data.serviceDomainCreate.serviceId, + environmentId: data.serviceDomainCreate.environmentId, + isCustom: false, + targetPort: data.serviceDomainCreate.targetPort, + }; + }, +); diff --git a/alchemy/src/railway/environment.ts b/alchemy/src/railway/environment.ts new file mode 100644 index 0000000000..1edde45b5a --- /dev/null +++ b/alchemy/src/railway/environment.ts @@ -0,0 +1,237 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isProject, type Project } from "./project.ts"; + +/** + * Properties for creating or updating a Railway environment + */ +export interface EnvironmentProps extends RailwayApiOptions { + /** + * The project to create the environment in + */ + project: string | Project; + + /** + * Name of the environment + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Source environment ID to copy configuration from + * If not provided, creates an empty environment + */ + sourceEnvironmentId?: string; + + /** + * Whether to delete the environment when the resource is destroyed. + * @default true + */ + delete?: boolean; +} + +/** + * Output returned after Railway environment creation/update + */ +export interface Environment { + /** + * The ID of the environment + */ + id: string; + + /** + * Name of the environment + */ + name: string; + + /** + * The project ID this environment belongs to + */ + projectId: string; + + /** + * Time at which the environment was created + */ + createdAt: string; + + /** + * Time at which the environment was last updated + */ + updatedAt: string; +} + +/** + * GraphQL response types + * @internal + */ +interface EnvironmentCreateResponse { + environmentCreate: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + }; +} + +interface EnvironmentQueryResponse { + environment: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + }; +} + +const ENVIRONMENT_FIELDS = ` + id + name + projectId + createdAt + updatedAt +`; + +/** + * Type guard for Railway Environment + */ +export function isEnvironment(resource: any): resource is Environment { + return resource?.[ResourceKind] === "railway::Environment"; +} + +/** + * Get the project ID from a project reference + */ +function getProjectId(project: string | Project): string { + if (isProject(project)) { + return project.id; + } + return project; +} + +/** + * Creates a Railway environment for deploying services to different stages. + * + * @example + * // Create a staging environment: + * const staging = await Environment("staging", { + * project: project, + * name: "staging" + * }); + * + * @example + * // Create an environment from an existing one: + * const preview = await Environment("preview", { + * project: project, + * name: "preview", + * sourceEnvironmentId: production.id + * }); + */ +export const Environment = Resource( + "railway::Environment", + async function ( + this: Context, + id: string, + props: EnvironmentProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + await api.request( + ` + mutation environmentDelete($id: String!) { + environmentDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } catch (error) { + // Ignore 404 errors (environment already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // Updates to environments are limited - name changes require delete/create + if (this.phase === "update" && this.output?.id) { + // Check if name changed (requires replacement) + if (this.output.name !== name) { + return this.replace(); + } + // No other updatable fields, return current state + return this.output; + } + + // Create new environment + const data = await api.request( + ` + mutation environmentCreate($input: EnvironmentCreateInput!) { + environmentCreate(input: $input) { + ${ENVIRONMENT_FIELDS} + } + } + `, + { + input: { + name, + projectId, + sourceEnvironmentId: props.sourceEnvironmentId, + }, + }, + ); + + const env = data.environmentCreate; + return { + id: env.id, + name: env.name, + projectId: env.projectId, + createdAt: env.createdAt, + updatedAt: env.updatedAt, + }; + }, +); + +/** + * Reference an existing Railway environment by ID + */ +export async function EnvironmentRef( + props: RailwayApiOptions & { environmentId: string }, +): Promise { + const api = createRailwayApi(props); + + const data = await api.request( + ` + query environment($id: String!) { + environment(id: $id) { + ${ENVIRONMENT_FIELDS} + } + } + `, + { id: props.environmentId }, + ); + + const env = data.environment; + return { + id: env.id, + name: env.name, + projectId: env.projectId, + createdAt: env.createdAt, + updatedAt: env.updatedAt, + }; +} diff --git a/alchemy/src/railway/index.ts b/alchemy/src/railway/index.ts new file mode 100644 index 0000000000..4b904bc576 --- /dev/null +++ b/alchemy/src/railway/index.ts @@ -0,0 +1,82 @@ +export { + createRailwayApi, + RailwayError, + type RailwayApiOptions, + type RailwayClient, +} from "./api.ts"; + +export { + Project, + ProjectRef, + isProject, + type ProjectProps, + type Project as ProjectOutput, + type ProjectEnvironment, +} from "./project.ts"; + +export { + Environment, + EnvironmentRef, + isEnvironment, + type EnvironmentProps, + type Environment as EnvironmentOutput, +} from "./environment.ts"; + +export { + Service, + ServiceRef, + isService, + type ServiceProps, + type Service as ServiceOutput, + type ServiceDomain, + type GitHubSource, + type DockerSource, +} from "./service.ts"; + +export { + Domain, + isDomain, + type DomainProps, + type Domain as DomainOutput, +} from "./domain.ts"; + +export { + Variable, + getVariables, + isVariable, + type VariableProps, + type Variable as VariableOutput, +} from "./variable.ts"; + +export { + Volume, + isVolume, + type VolumeProps, + type Volume as VolumeOutput, +} from "./volume.ts"; + +export { + TcpProxy, + isTcpProxy, + type TcpProxyProps, + type TcpProxy as TcpProxyOutput, +} from "./tcp-proxy.ts"; + +export { + PostgresDatabase, + MysqlDatabase, + RedisDatabase, + MongoDatabase, + isPostgresDatabase, + isMysqlDatabase, + isRedisDatabase, + isMongoDatabase, + type PostgresDatabaseProps, + type MysqlDatabaseProps, + type RedisDatabaseProps, + type MongoDatabaseProps, + type PostgresDatabase as PostgresDatabaseOutput, + type MysqlDatabase as MysqlDatabaseOutput, + type RedisDatabase as RedisDatabaseOutput, + type MongoDatabase as MongoDatabaseOutput, +} from "./database.ts"; diff --git a/alchemy/src/railway/project.ts b/alchemy/src/railway/project.ts new file mode 100644 index 0000000000..a84a67aab3 --- /dev/null +++ b/alchemy/src/railway/project.ts @@ -0,0 +1,431 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { + createRailwayApi, + extractResult, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; + +/** + * Properties for creating or updating a Railway project + */ +export interface ProjectProps extends RailwayApiOptions { + /** + * When `true`, will adopt an existing project by `name` + */ + adopt?: true; + + /** + * Whether to delete the project when the resource is destroyed. + * @default true, unless the resource was adopted + */ + delete?: boolean; + + /** + * Name of the project + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Description of the project + */ + description?: string; + + /** + * Whether the project is public + * @default false + */ + isPublic?: boolean; + + /** + * Team ID to create the project in (for team tokens) + */ + teamId?: string; +} + +/** + * A Railway environment within a project + */ +export interface ProjectEnvironment { + id: string; + name: string; +} + +/** + * Output returned after Railway project creation/update + */ +export interface Project { + /** + * The ID of the project + */ + id: string; + + /** + * Name of the project + */ + name: string; + + /** + * Description of the project + */ + description: string; + + /** + * Whether the project is public + */ + isPublic: boolean; + + /** + * Time at which the project was created + */ + createdAt: string; + + /** + * Time at which the project was last updated + */ + updatedAt: string; + + /** + * Environments in the project + */ + environments: ProjectEnvironment[]; + + /** + * Team ID if the project belongs to a team + */ + teamId?: string; +} + +/** + * GraphQL response types + * @internal + */ +interface ProjectCreateResponse { + projectCreate: { + id: string; + name: string; + description: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; + teamId?: string; + environments: { + edges: Array<{ + node: { + id: string; + name: string; + }; + }>; + }; + }; +} + +interface ProjectUpdateResponse { + projectUpdate: { + id: string; + name: string; + description: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; + teamId?: string; + environments: { + edges: Array<{ + node: { + id: string; + name: string; + }; + }>; + }; + }; +} + +interface ProjectQueryResponse { + project: { + id: string; + name: string; + description: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; + teamId?: string; + environments: { + edges: Array<{ + node: { + id: string; + name: string; + }; + }>; + }; + }; +} + +interface ProjectsQueryResponse { + projects: { + edges: Array<{ + node: { + id: string; + name: string; + description: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; + teamId?: string; + environments: { + edges: Array<{ + node: { + id: string; + name: string; + }; + }>; + }; + }; + }>; + }; +} + +const PROJECT_FIELDS = ` + id + name + description + isPublic + createdAt + updatedAt + teamId + environments { + edges { + node { + id + name + } + } + } +`; + +/** + * Type guard for Railway Project + */ +export function isProject(resource: any): resource is Project { + return resource?.[ResourceKind] === "railway::Project"; +} + +/** + * Creates a Railway project for deploying services. + * + * @example + * // Create a basic Railway project: + * const project = await Project("my-project", { + * name: "My App" + * }); + * + * @example + * // Adopt an existing Railway project by name: + * const project = await Project("my-project", { + * adopt: true, + * name: "existing-project-name", + * }); + * + * @example + * // Create a project with description: + * const project = await Project("my-project", { + * name: "My App", + * description: "Production application", + * apiToken: alchemy.secret(process.env.RAILWAY_API_TOKEN) + * }); + */ +export const Project = Resource( + "railway::Project", + async function ( + this: Context, + id: string, + props: ProjectProps, + ): Promise { + const api = createRailwayApi(props); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + await api.request( + ` + mutation projectDelete($id: String!) { + projectDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } catch (error) { + // Ignore 404 errors (project already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // Handle adoption + if (this.phase === "create" && props.adopt) { + const existingProject = await findProjectByName(api, name, props.teamId); + if (existingProject) { + return existingProject; + } + // Project not found, continue with creation + } + + // Update existing project + if (this.phase === "update" && this.output?.id) { + const data = await api.request( + ` + mutation projectUpdate($id: String!, $input: ProjectUpdateInput!) { + projectUpdate(id: $id, input: $input) { + ${PROJECT_FIELDS} + } + } + `, + { + id: this.output.id, + input: { + name, + description: props.description ?? "", + isPublic: props.isPublic ?? false, + }, + }, + ); + + const project = data.projectUpdate; + return { + id: project.id, + name: project.name, + description: project.description, + isPublic: project.isPublic, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + teamId: project.teamId, + environments: project.environments.edges.map((e) => ({ + id: e.node.id, + name: e.node.name, + })), + }; + } + + // Create new project + const data = await api.request( + ` + mutation projectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + ${PROJECT_FIELDS} + } + } + `, + { + input: { + name, + description: props.description ?? "", + isPublic: props.isPublic ?? false, + teamId: props.teamId, + }, + }, + ); + + const project = data.projectCreate; + return { + id: project.id, + name: project.name, + description: project.description, + isPublic: project.isPublic, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + teamId: project.teamId, + environments: project.environments.edges.map((e) => ({ + id: e.node.id, + name: e.node.name, + })), + }; + }, +); + +/** + * Reference an existing Railway project by ID + */ +export async function ProjectRef( + props: RailwayApiOptions & { projectId: string }, +): Promise { + const api = createRailwayApi(props); + + const data = await api.request( + ` + query project($id: String!) { + project(id: $id) { + ${PROJECT_FIELDS} + } + } + `, + { id: props.projectId }, + ); + + const project = data.project; + return { + id: project.id, + name: project.name, + description: project.description, + isPublic: project.isPublic, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + teamId: project.teamId, + environments: project.environments.edges.map((e) => ({ + id: e.node.id, + name: e.node.name, + })), + }; +} + +async function findProjectByName( + api: ReturnType, + name: string, + teamId?: string, +): Promise { + const data = await api.request( + ` + query projects($teamId: String) { + projects(teamId: $teamId) { + edges { + node { + ${PROJECT_FIELDS} + } + } + } + } + `, + { teamId }, + ); + + const matchingProject = data.projects.edges.find( + (edge) => edge.node.name === name, + ); + + if (!matchingProject) { + return null; + } + + const project = matchingProject.node; + return { + id: project.id, + name: project.name, + description: project.description, + isPublic: project.isPublic, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + teamId: project.teamId, + environments: project.environments.edges.map((e) => ({ + id: e.node.id, + name: e.node.name, + })), + }; +} diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts new file mode 100644 index 0000000000..6602005ab6 --- /dev/null +++ b/alchemy/src/railway/service.ts @@ -0,0 +1,618 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isEnvironment, type Environment } from "./environment.ts"; +import { isProject, type Project } from "./project.ts"; + +/** + * Source configuration for deploying from a GitHub repository + */ +export interface GitHubSource { + /** + * GitHub repository in format "owner/repo" + */ + repo: string; + + /** + * Branch to deploy from + * @default main + */ + branch?: string; +} + +/** + * Source configuration for deploying from a Docker image + */ +export interface DockerSource { + /** + * Docker image to deploy (e.g., "nginx:latest") + */ + image: string; +} + +/** + * Properties for creating or updating a Railway service + */ +export interface ServiceProps extends RailwayApiOptions { + /** + * The project to create the service in + */ + project: string | Project; + + /** + * The environment to deploy to (if not specified, deploys to all environments) + */ + environment?: string | Environment; + + /** + * Name of the service + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Source to deploy from - either a GitHub repo or Docker image + */ + source?: GitHubSource | DockerSource; + + /** + * Build command to run before starting the service + */ + buildCommand?: string; + + /** + * Command to start the service + */ + startCommand?: string; + + /** + * Root directory containing the application code (relative to repo root) + */ + rootDirectory?: string; + + /** + * Number of replicas to run + * @default 1 + */ + numReplicas?: number; + + /** + * Health check path for HTTP services + */ + healthcheckPath?: string; + + /** + * Health check timeout in seconds + */ + healthcheckTimeout?: number; + + /** + * Whether to enable the restart policy + * @default true + */ + restartPolicyType?: "ON_FAILURE" | "ALWAYS" | "NEVER"; + + /** + * Maximum number of restart retries + */ + restartPolicyMaxRetries?: number; + + /** + * When `true`, will adopt an existing service by `name` + */ + adopt?: true; + + /** + * Whether to delete the service when the resource is destroyed. + * @default true + */ + delete?: boolean; +} + +/** + * Domain associated with a service + */ +export interface ServiceDomain { + id: string; + domain: string; +} + +/** + * Output returned after Railway service creation/update + */ +export interface Service { + /** + * The ID of the service + */ + id: string; + + /** + * Name of the service + */ + name: string; + + /** + * The project ID this service belongs to + */ + projectId: string; + + /** + * Time at which the service was created + */ + createdAt: string; + + /** + * Time at which the service was last updated + */ + updatedAt: string; + + /** + * Domains associated with the service + */ + domains: ServiceDomain[]; + + /** + * Icon for the service (if set) + */ + icon?: string; +} + +/** + * GraphQL response types + * @internal + */ +interface ServiceCreateResponse { + serviceCreate: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + icon?: string; + }; +} + +interface ServiceUpdateResponse { + serviceUpdate: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + icon?: string; + }; +} + +interface ServiceQueryResponse { + service: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + icon?: string; + serviceDomains: { + id: string; + domain: string; + }[]; + }; +} + +interface ServicesQueryResponse { + project: { + services: { + edges: Array<{ + node: { + id: string; + name: string; + projectId: string; + createdAt: string; + updatedAt: string; + icon?: string; + serviceDomains: { + id: string; + domain: string; + }[]; + }; + }>; + }; + }; +} + +const SERVICE_FIELDS = ` + id + name + projectId + createdAt + updatedAt + icon +`; + +/** + * Type guard for Railway Service + */ +export function isService(resource: any): resource is Service { + return resource?.[ResourceKind] === "railway::Service"; +} + +/** + * Get the project ID from a project reference + */ +function getProjectId(project: string | Project): string { + if (isProject(project)) { + return project.id; + } + return project; +} + +/** + * Get the environment ID from an environment reference + */ +function getEnvironmentId( + environment: string | Environment | undefined, +): string | undefined { + if (environment === undefined) { + return undefined; + } + if (isEnvironment(environment)) { + return environment.id; + } + return environment; +} + +/** + * Check if source is a GitHub source + */ +function isGitHubSource( + source: GitHubSource | DockerSource | undefined, +): source is GitHubSource { + return source !== undefined && "repo" in source; +} + +/** + * Check if source is a Docker source + */ +function isDockerSource( + source: GitHubSource | DockerSource | undefined, +): source is DockerSource { + return source !== undefined && "image" in source; +} + +/** + * Creates a Railway service for deploying applications. + * + * @example + * // Deploy from a GitHub repository: + * const api = await Service("api", { + * project: project, + * source: { + * repo: "myorg/api", + * branch: "main" + * }, + * startCommand: "npm start" + * }); + * + * @example + * // Deploy from a Docker image: + * const nginx = await Service("nginx", { + * project: project, + * source: { + * image: "nginx:latest" + * } + * }); + * + * @example + * // Deploy with build configuration: + * const app = await Service("app", { + * project: project, + * source: { repo: "myorg/app" }, + * buildCommand: "npm run build", + * startCommand: "npm start", + * rootDirectory: "packages/api" + * }); + */ +export const Service = Resource( + "railway::Service", + async function ( + this: Context, + id: string, + props: ServiceProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const environmentId = getEnvironmentId(props.environment); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + await api.request( + ` + mutation serviceDelete($id: String!) { + serviceDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } catch (error) { + // Ignore 404 errors (service already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // Handle adoption + if (this.phase === "create" && props.adopt) { + const existingService = await findServiceByName(api, projectId, name); + if (existingService) { + return existingService; + } + // Service not found, continue with creation + } + + // Update existing service + if (this.phase === "update" && this.output?.id) { + // Update the service + const data = await api.request( + ` + mutation serviceUpdate($id: String!, $input: ServiceUpdateInput!) { + serviceUpdate(id: $id, input: $input) { + ${SERVICE_FIELDS} + } + } + `, + { + id: this.output.id, + input: { + name, + }, + }, + ); + + // Update service instance if environment is specified + if (environmentId) { + await updateServiceInstance(api, this.output.id, environmentId, props); + } + + // Fetch domains + const serviceData = await fetchService(api, this.output.id); + + return { + id: data.serviceUpdate.id, + name: data.serviceUpdate.name, + projectId: data.serviceUpdate.projectId, + createdAt: data.serviceUpdate.createdAt, + updatedAt: data.serviceUpdate.updatedAt, + icon: data.serviceUpdate.icon, + domains: serviceData.domains, + }; + } + + // Build source input + let sourceInput: Record | undefined; + if (isGitHubSource(props.source)) { + sourceInput = { + repo: props.source.repo, + }; + } else if (isDockerSource(props.source)) { + sourceInput = { + image: props.source.image, + }; + } + + // Create new service + const createInput: Record = { + name, + projectId, + }; + + if (sourceInput) { + createInput.source = sourceInput; + } + + if (environmentId) { + createInput.environmentId = environmentId; + } + + if (props.source && isGitHubSource(props.source) && props.source.branch) { + createInput.branch = props.source.branch; + } + + const data = await api.request( + ` + mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + ${SERVICE_FIELDS} + } + } + `, + { input: createInput }, + ); + + // Update service instance with build/start commands if provided + if ( + environmentId && + (props.buildCommand || props.startCommand || props.rootDirectory) + ) { + await updateServiceInstance( + api, + data.serviceCreate.id, + environmentId, + props, + ); + } + + return { + id: data.serviceCreate.id, + name: data.serviceCreate.name, + projectId: data.serviceCreate.projectId, + createdAt: data.serviceCreate.createdAt, + updatedAt: data.serviceCreate.updatedAt, + icon: data.serviceCreate.icon, + domains: [], + }; + }, +); + +/** + * Update service instance configuration + */ +async function updateServiceInstance( + api: ReturnType, + serviceId: string, + environmentId: string, + props: ServiceProps, +): Promise { + const input: Record = {}; + + if (props.buildCommand !== undefined) { + input.buildCommand = props.buildCommand; + } + if (props.startCommand !== undefined) { + input.startCommand = props.startCommand; + } + if (props.rootDirectory !== undefined) { + input.rootDirectory = props.rootDirectory; + } + if (props.numReplicas !== undefined) { + input.numReplicas = props.numReplicas; + } + if (props.healthcheckPath !== undefined) { + input.healthcheckPath = props.healthcheckPath; + } + if (props.healthcheckTimeout !== undefined) { + input.healthcheckTimeout = props.healthcheckTimeout; + } + if (props.restartPolicyType !== undefined) { + input.restartPolicyType = props.restartPolicyType; + } + if (props.restartPolicyMaxRetries !== undefined) { + input.restartPolicyMaxRetries = props.restartPolicyMaxRetries; + } + + if (Object.keys(input).length === 0) { + return; + } + + await api.request( + ` + mutation serviceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + } + `, + { + serviceId, + environmentId, + input, + }, + ); +} + +/** + * Fetch service with domains + */ +async function fetchService( + api: ReturnType, + serviceId: string, +): Promise { + const data = await api.request( + ` + query service($id: String!) { + service(id: $id) { + ${SERVICE_FIELDS} + serviceDomains { + id + domain + } + } + } + `, + { id: serviceId }, + ); + + return { + id: data.service.id, + name: data.service.name, + projectId: data.service.projectId, + createdAt: data.service.createdAt, + updatedAt: data.service.updatedAt, + icon: data.service.icon, + domains: data.service.serviceDomains.map((d) => ({ + id: d.id, + domain: d.domain, + })), + }; +} + +/** + * Find service by name in a project + */ +async function findServiceByName( + api: ReturnType, + projectId: string, + name: string, +): Promise { + const data = await api.request( + ` + query services($projectId: String!) { + project(id: $projectId) { + services { + edges { + node { + ${SERVICE_FIELDS} + serviceDomains { + id + domain + } + } + } + } + } + } + `, + { projectId }, + ); + + const matchingService = data.project.services.edges.find( + (edge) => edge.node.name === name, + ); + + if (!matchingService) { + return null; + } + + const service = matchingService.node; + return { + id: service.id, + name: service.name, + projectId: service.projectId, + createdAt: service.createdAt, + updatedAt: service.updatedAt, + icon: service.icon, + domains: service.serviceDomains.map((d) => ({ + id: d.id, + domain: d.domain, + })), + }; +} + +/** + * Reference an existing Railway service by ID + */ +export async function ServiceRef( + props: RailwayApiOptions & { serviceId: string }, +): Promise { + const api = createRailwayApi(props); + return fetchService(api, props.serviceId); +} diff --git a/alchemy/src/railway/tcp-proxy.ts b/alchemy/src/railway/tcp-proxy.ts new file mode 100644 index 0000000000..fdbafbd2c7 --- /dev/null +++ b/alchemy/src/railway/tcp-proxy.ts @@ -0,0 +1,204 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isEnvironment, type Environment } from "./environment.ts"; +import { isService, type Service } from "./service.ts"; + +/** + * Properties for creating a Railway TCP proxy + */ +export interface TcpProxyProps extends RailwayApiOptions { + /** + * The service to attach the TCP proxy to + */ + service: string | Service; + + /** + * The environment to create the TCP proxy in + */ + environment: string | Environment; + + /** + * The application port to proxy traffic to + */ + applicationPort: number; + + /** + * Whether to delete the TCP proxy when the resource is destroyed. + * @default true + */ + delete?: boolean; +} + +/** + * Output returned after Railway TCP proxy creation + */ +export interface TcpProxy { + /** + * The ID of the TCP proxy + */ + id: string; + + /** + * The domain to connect to + */ + domain: string; + + /** + * The proxy port to connect to + */ + proxyPort: number; + + /** + * The application port traffic is forwarded to + */ + applicationPort: number; + + /** + * The service ID this TCP proxy is attached to + */ + serviceId: string; + + /** + * The environment ID this TCP proxy belongs to + */ + environmentId: string; +} + +/** + * GraphQL response types + * @internal + */ +interface TcpProxyCreateResponse { + tcpProxyCreate: { + id: string; + domain: string; + proxyPort: number; + applicationPort: number; + }; +} + +/** + * Type guard for Railway TcpProxy + */ +export function isTcpProxy(resource: any): resource is TcpProxy { + return resource?.[ResourceKind] === "railway::TcpProxy"; +} + +/** + * Get the service ID from a service reference + */ +function getServiceId(service: string | Service): string { + if (isService(service)) { + return service.id; + } + return service; +} + +/** + * Get the environment ID from an environment reference + */ +function getEnvironmentId(environment: string | Environment): string { + if (isEnvironment(environment)) { + return environment.id; + } + return environment; +} + +/** + * Creates a Railway TCP proxy for non-HTTP traffic. + * + * @example + * // Create a TCP proxy for a database: + * const proxy = await TcpProxy("db-proxy", { + * service: postgresService, + * environment: production, + * applicationPort: 5432 + * }); + * console.log(`Connect to: ${proxy.domain}:${proxy.proxyPort}`); + * + * @example + * // Create a TCP proxy for Redis: + * const redisProxy = await TcpProxy("redis-proxy", { + * service: redisService, + * environment: production, + * applicationPort: 6379 + * }); + */ +export const TcpProxy = Resource( + "railway::TcpProxy", + async function ( + this: Context, + id: string, + props: TcpProxyProps, + ): Promise { + const api = createRailwayApi(props); + const serviceId = getServiceId(props.service); + const environmentId = getEnvironmentId(props.environment); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + await api.request( + ` + mutation tcpProxyDelete($id: String!) { + tcpProxyDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } catch (error) { + // Ignore 404 errors (TCP proxy already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // TCP proxies are immutable - port changes require replacement + if (this.phase === "update" && this.output?.id) { + if (this.output.applicationPort !== props.applicationPort) { + return this.replace(); + } + return this.output; + } + + // Create new TCP proxy + const data = await api.request( + ` + mutation tcpProxyCreate($input: TCPProxyCreateInput!) { + tcpProxyCreate(input: $input) { + id + domain + proxyPort + applicationPort + } + } + `, + { + input: { + serviceId, + environmentId, + applicationPort: props.applicationPort, + }, + }, + ); + + return { + id: data.tcpProxyCreate.id, + domain: data.tcpProxyCreate.domain, + proxyPort: data.tcpProxyCreate.proxyPort, + applicationPort: data.tcpProxyCreate.applicationPort, + serviceId, + environmentId, + }; + }, +); diff --git a/alchemy/src/railway/variable.ts b/alchemy/src/railway/variable.ts new file mode 100644 index 0000000000..1ff9fc671b --- /dev/null +++ b/alchemy/src/railway/variable.ts @@ -0,0 +1,262 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { Secret } from "../secret.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isEnvironment, type Environment } from "./environment.ts"; +import { isProject, type Project } from "./project.ts"; +import { isService, type Service } from "./service.ts"; + +/** + * Properties for creating or updating a Railway variable + */ +export interface VariableProps extends RailwayApiOptions { + /** + * The project to create the variable in + */ + project: string | Project; + + /** + * The environment to create the variable in + */ + environment: string | Environment; + + /** + * The service to attach the variable to (optional for shared variables) + */ + service?: string | Service; + + /** + * Name of the variable + */ + name: string; + + /** + * Value of the variable + */ + value: string | Secret; + + /** + * Whether to delete the variable when the resource is destroyed. + * @default true + */ + delete?: boolean; +} + +/** + * Output returned after Railway variable creation/update + */ +export interface Variable { + /** + * The name of the variable + */ + name: string; + + /** + * The value of the variable (wrapped as Secret) + */ + value: Secret; + + /** + * The project ID this variable belongs to + */ + projectId: string; + + /** + * The environment ID this variable belongs to + */ + environmentId: string; + + /** + * The service ID this variable is attached to (if service-specific) + */ + serviceId?: string; +} + +/** + * GraphQL response types + * @internal + */ +interface VariableUpsertResponse { + variableUpsert: boolean; +} + +interface VariablesQueryResponse { + variables: Record; +} + +/** + * Type guard for Railway Variable + */ +export function isVariable(resource: any): resource is Variable { + return resource?.[ResourceKind] === "railway::Variable"; +} + +/** + * Get the project ID from a project reference + */ +function getProjectId(project: string | Project): string { + if (isProject(project)) { + return project.id; + } + return project; +} + +/** + * Get the environment ID from an environment reference + */ +function getEnvironmentId(environment: string | Environment): string { + if (isEnvironment(environment)) { + return environment.id; + } + return environment; +} + +/** + * Get the service ID from a service reference + */ +function getServiceId( + service: string | Service | undefined, +): string | undefined { + if (service === undefined) { + return undefined; + } + if (isService(service)) { + return service.id; + } + return service; +} + +/** + * Creates a Railway environment variable. + * + * @example + * // Create a service-specific variable: + * const dbUrl = await Variable("db-url", { + * project: project, + * environment: production, + * service: apiService, + * name: "DATABASE_URL", + * value: database.connectionUrl + * }); + * + * @example + * // Create a shared variable (available to all services): + * const apiKey = await Variable("api-key", { + * project: project, + * environment: production, + * name: "API_KEY", + * value: alchemy.secret(process.env.API_KEY) + * }); + * + * @example + * // Use Railway reference syntax: + * const redisUrl = await Variable("redis-url", { + * project: project, + * environment: production, + * service: apiService, + * name: "REDIS_URL", + * value: "${{Redis.REDIS_URL}}" + * }); + */ +export const Variable = Resource( + "railway::Variable", + async function ( + this: Context, + id: string, + props: VariableProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const environmentId = getEnvironmentId(props.environment); + const serviceId = getServiceId(props.service); + const value = Secret.unwrap(props.value); + + if (this.phase === "delete") { + if (props.delete !== false) { + try { + await api.request( + ` + mutation variableDelete($input: VariableDeleteInput!) { + variableDelete(input: $input) + } + `, + { + input: { + projectId, + environmentId, + serviceId, + name: props.name, + }, + }, + ); + } catch (error) { + // Ignore 404 errors (variable already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // Upsert the variable (works for both create and update) + await api.request( + ` + mutation variableUpsert($input: VariableUpsertInput!) { + variableUpsert(input: $input) + } + `, + { + input: { + projectId, + environmentId, + serviceId, + name: props.name, + value, + }, + }, + ); + + return { + name: props.name, + value: Secret.wrap(props.value), + projectId, + environmentId, + serviceId, + }; + }, +); + +/** + * Fetch all variables for a project/environment/service + */ +export async function getVariables( + props: RailwayApiOptions & { + projectId: string; + environmentId: string; + serviceId?: string; + }, +): Promise> { + const api = createRailwayApi(props); + + const data = await api.request( + ` + query variables($projectId: String!, $environmentId: String!, $serviceId: String) { + variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) + } + `, + { + projectId: props.projectId, + environmentId: props.environmentId, + serviceId: props.serviceId, + }, + ); + + return data.variables; +} diff --git a/alchemy/src/railway/volume.ts b/alchemy/src/railway/volume.ts new file mode 100644 index 0000000000..51f2b56d90 --- /dev/null +++ b/alchemy/src/railway/volume.ts @@ -0,0 +1,312 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { + createRailwayApi, + type RailwayApiOptions, + RailwayError, +} from "./api.ts"; +import { isEnvironment, type Environment } from "./environment.ts"; +import { isProject, type Project } from "./project.ts"; +import { isService, type Service } from "./service.ts"; + +/** + * Properties for creating a Railway volume + */ +export interface VolumeProps extends RailwayApiOptions { + /** + * The project to create the volume in + */ + project: string | Project; + + /** + * The service to attach the volume to + */ + service: string | Service; + + /** + * The environment to create the volume in + */ + environment: string | Environment; + + /** + * Name of the volume + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Mount path inside the container + */ + mountPath: string; + + /** + * Whether to delete the volume when the resource is destroyed. + * Note: Deleting a volume will permanently delete all data. + * @default true + */ + delete?: boolean; +} + +/** + * Output returned after Railway volume creation + */ +export interface Volume { + /** + * The ID of the volume + */ + id: string; + + /** + * Name of the volume + */ + name: string; + + /** + * Mount path inside the container + */ + mountPath: string; + + /** + * The project ID this volume belongs to + */ + projectId: string; + + /** + * The service ID this volume is attached to + */ + serviceId: string; + + /** + * The environment ID this volume belongs to + */ + environmentId: string; + + /** + * Time at which the volume was created + */ + createdAt: string; +} + +/** + * GraphQL response types + * @internal + */ +interface VolumeCreateResponse { + volumeCreate: { + id: string; + name: string; + mountPath: string; + projectId: string; + createdAt: string; + }; +} + +interface VolumeUpdateResponse { + volumeUpdate: { + id: string; + name: string; + mountPath: string; + projectId: string; + createdAt: string; + }; +} + +const VOLUME_FIELDS = ` + id + name + mountPath + projectId + createdAt +`; + +/** + * Type guard for Railway Volume + */ +export function isVolume(resource: any): resource is Volume { + return resource?.[ResourceKind] === "railway::Volume"; +} + +/** + * Get the project ID from a project reference + */ +function getProjectId(project: string | Project): string { + if (isProject(project)) { + return project.id; + } + return project; +} + +/** + * Get the service ID from a service reference + */ +function getServiceId(service: string | Service): string { + if (isService(service)) { + return service.id; + } + return service; +} + +/** + * Get the environment ID from an environment reference + */ +function getEnvironmentId(environment: string | Environment): string { + if (isEnvironment(environment)) { + return environment.id; + } + return environment; +} + +/** + * Creates a Railway volume for persistent storage. + * + * @example + * // Create a volume for database storage: + * const dataVolume = await Volume("data", { + * project: project, + * service: dbService, + * environment: production, + * name: "postgres-data", + * mountPath: "/var/lib/postgresql/data" + * }); + * + * @example + * // Create a volume for file uploads: + * const uploadsVolume = await Volume("uploads", { + * project: project, + * service: apiService, + * environment: production, + * mountPath: "/app/uploads" + * }); + */ +export const Volume = Resource( + "railway::Volume", + async function ( + this: Context, + id: string, + props: VolumeProps, + ): Promise { + const api = createRailwayApi(props); + const projectId = getProjectId(props.project); + const serviceId = getServiceId(props.service); + const environmentId = getEnvironmentId(props.environment); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + await api.request( + ` + mutation volumeDelete($id: String!) { + volumeDelete(id: $id) + } + `, + { id: this.output.id }, + ); + } catch (error) { + // Ignore 404 errors (volume already deleted) + if (error instanceof RailwayError && error.code === "NOT_FOUND") { + // Already deleted, that's fine + } else { + throw error; + } + } + } + return this.destroy(); + } + + // Update existing volume + if (this.phase === "update" && this.output?.id) { + // Mount path change requires replacement + if (this.output.mountPath !== props.mountPath) { + return this.replace(); + } + + // Update name if changed + if (this.output.name !== name) { + const data = await api.request( + ` + mutation volumeUpdate($id: String!, $input: VolumeUpdateInput!) { + volumeUpdate(id: $id, input: $input) { + ${VOLUME_FIELDS} + } + } + `, + { + id: this.output.id, + input: { name }, + }, + ); + + return { + id: data.volumeUpdate.id, + name: data.volumeUpdate.name, + mountPath: data.volumeUpdate.mountPath, + projectId: data.volumeUpdate.projectId, + serviceId, + environmentId, + createdAt: data.volumeUpdate.createdAt, + }; + } + + return this.output; + } + + // Create new volume + const data = await api.request( + ` + mutation volumeCreate($input: VolumeCreateInput!) { + volumeCreate(input: $input) { + ${VOLUME_FIELDS} + } + } + `, + { + input: { + projectId, + serviceId, + environmentId, + mountPath: props.mountPath, + }, + }, + ); + + // Update volume name if different from default + if (name !== data.volumeCreate.name) { + const updateData = await api.request( + ` + mutation volumeUpdate($id: String!, $input: VolumeUpdateInput!) { + volumeUpdate(id: $id, input: $input) { + ${VOLUME_FIELDS} + } + } + `, + { + id: data.volumeCreate.id, + input: { name }, + }, + ); + + return { + id: updateData.volumeUpdate.id, + name: updateData.volumeUpdate.name, + mountPath: updateData.volumeUpdate.mountPath, + projectId: updateData.volumeUpdate.projectId, + serviceId, + environmentId, + createdAt: updateData.volumeUpdate.createdAt, + }; + } + + return { + id: data.volumeCreate.id, + name: data.volumeCreate.name, + mountPath: data.volumeCreate.mountPath, + projectId: data.volumeCreate.projectId, + serviceId, + environmentId, + createdAt: data.volumeCreate.createdAt, + }; + }, +); diff --git a/alchemy/test/railway/database.test.ts b/alchemy/test/railway/database.test.ts new file mode 100644 index 0000000000..d68a11fe67 --- /dev/null +++ b/alchemy/test/railway/database.test.ts @@ -0,0 +1,91 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { createRailwayApi } from "../../src/railway/api.ts"; +import { + Project, + PostgresDatabase, + RedisDatabase, +} from "../../src/railway/index.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const api = createRailwayApi(); + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Railway Database Resources", () => { + const testId = `${BRANCH_PREFIX}-test-railway-db`; + + test("create PostgreSQL database", async (scope) => { + let project: typeof Project.prototype | undefined; + let db: typeof PostgresDatabase.prototype | undefined; + + try { + project = await Project(`${testId}-pg-project`, { + name: `${testId}-postgres-project`, + }); + + db = await PostgresDatabase(`${testId}-postgres`, { + project, + name: `${testId}-postgres-db`, + }); + + expect(db.id).toBeTruthy(); + expect(db.name).toEqual(`${testId}-postgres-db`); + expect(db.projectId).toEqual(project.id); + expect(db.type).toEqual("postgres"); + expect(db.createdAt).toBeTruthy(); + + // Verify the service was created via API + const data = await api.request<{ + service: { + id: string; + name: string; + projectId: string; + }; + }>( + ` + query service($id: String!) { + service(id: $id) { + id + name + projectId + } + } + `, + { id: db.id }, + ); + + expect(data.service.projectId).toEqual(project.id); + } finally { + await destroy(scope); + } + }, 60000); + + test("create Redis database", async (scope) => { + let project: typeof Project.prototype | undefined; + let redis: typeof RedisDatabase.prototype | undefined; + + try { + project = await Project(`${testId}-redis-project`, { + name: `${testId}-redis-project`, + }); + + redis = await RedisDatabase(`${testId}-redis`, { + project, + name: `${testId}-redis-cache`, + }); + + expect(redis.id).toBeTruthy(); + expect(redis.name).toEqual(`${testId}-redis-cache`); + expect(redis.projectId).toEqual(project.id); + expect(redis.type).toEqual("redis"); + expect(redis.createdAt).toBeTruthy(); + } finally { + await destroy(scope); + } + }, 60000); +}); diff --git a/alchemy/test/railway/project.test.ts b/alchemy/test/railway/project.test.ts new file mode 100644 index 0000000000..43a22da0c8 --- /dev/null +++ b/alchemy/test/railway/project.test.ts @@ -0,0 +1,214 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { createRailwayApi } from "../../src/railway/api.ts"; +import { Project, ProjectRef } from "../../src/railway/index.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +// must import this or else alchemy.test won't exist +import "../../src/test/vitest.ts"; + +// Create API client for verification +const api = createRailwayApi(); + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Railway Project Resource", () => { + const testId = `${BRANCH_PREFIX}-test-railway-project`; + + test("create, update, and delete project", async (scope) => { + let project: typeof Project.prototype | undefined; + try { + // Create a test Railway project + project = await Project(testId, { + name: `${testId}-test`, + description: "Test project for Alchemy", + }); + + expect(project.id).toBeTruthy(); + expect(project.name).toEqual(`${testId}-test`); + expect(project.description).toEqual("Test project for Alchemy"); + expect(project.createdAt).toBeTruthy(); + expect(project.updatedAt).toBeTruthy(); + expect(project.environments).toBeDefined(); + + // Verify project was created by querying the API directly + const data = await api.request<{ + project: { + id: string; + name: string; + description: string; + }; + }>( + ` + query project($id: String!) { + project(id: $id) { + id + name + description + } + } + `, + { id: project.id }, + ); + + expect(data.project.name).toEqual(`${testId}-test`); + + // Update the project + const updatedDescription = "Updated test project"; + project = await Project(testId, { + name: `${testId}-test`, + description: updatedDescription, + }); + + expect(project.description).toEqual(updatedDescription); + + // Verify project was updated + const updatedData = await api.request<{ + project: { + id: string; + name: string; + description: string; + }; + }>( + ` + query project($id: String!) { + project(id: $id) { + id + name + description + } + } + `, + { id: project.id }, + ); + expect(updatedData.project.description).toEqual(updatedDescription); + } finally { + // Always clean up + await destroy(scope); + + // Verify project was deleted + if (project?.id) { + try { + await api.request( + ` + query project($id: String!) { + project(id: $id) { + id + } + } + `, + { id: project.id }, + ); + // If we get here, project still exists - fail + expect(true).toBe(false); + } catch (error) { + // Project should not exist + expect(error).toBeDefined(); + } + } + } + }); + + test("adopt existing project", async (scope) => { + let existingProjectId: string | undefined; + let adoptedProject: typeof Project.prototype | undefined; + + try { + // Create a project via API first + const createData = await api.request<{ + projectCreate: { + id: string; + name: string; + }; + }>( + ` + mutation projectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + id + name + } + } + `, + { + input: { + name: `${testId}-existing`, + description: "Existing project to adopt", + }, + }, + ); + + existingProjectId = createData.projectCreate.id; + + // Adopt the project + adoptedProject = await Project(`${testId}-adopted`, { + adopt: true, + name: `${testId}-existing`, + }); + + expect(adoptedProject.id).toEqual(existingProjectId); + expect(adoptedProject.name).toEqual(`${testId}-existing`); + } finally { + await destroy(scope); + + // Clean up the adopted project if it still exists + if (existingProjectId) { + try { + await api.request( + ` + mutation projectDelete($id: String!) { + projectDelete(id: $id) + } + `, + { id: existingProjectId }, + ); + } catch { + // Ignore errors - project may have been deleted + } + } + } + }); + + test("does not delete project when delete is false", async (scope) => { + let project: typeof Project.prototype | undefined; + try { + project = await Project(`${testId}-no-delete`, { + name: `${testId}-no-delete`, + delete: false, + }); + expect(project.id).toBeTruthy(); + } finally { + await destroy(scope); + + // Verify project still exists + if (project?.id) { + const data = await api.request<{ + project: { + id: string; + }; + }>( + ` + query project($id: String!) { + project(id: $id) { + id + } + } + `, + { id: project.id }, + ); + expect(data.project.id).toEqual(project.id); + + // Clean up manually + await api.request( + ` + mutation projectDelete($id: String!) { + projectDelete(id: $id) + } + `, + { id: project.id }, + ); + } + } + }); +}); diff --git a/alchemy/test/railway/service.test.ts b/alchemy/test/railway/service.test.ts new file mode 100644 index 0000000000..4943214e32 --- /dev/null +++ b/alchemy/test/railway/service.test.ts @@ -0,0 +1,132 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { createRailwayApi } from "../../src/railway/api.ts"; +import { Project, Service } from "../../src/railway/index.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const api = createRailwayApi(); + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Railway Service Resource", () => { + const testId = `${BRANCH_PREFIX}-test-railway-service`; + + test("create service from Docker image", async (scope) => { + let project: typeof Project.prototype | undefined; + let service: typeof Service.prototype | undefined; + + try { + // Create a project first + project = await Project(`${testId}-project`, { + name: `${testId}-docker-project`, + }); + + // Create a service from Docker image + service = await Service(`${testId}-service`, { + project, + name: `${testId}-nginx`, + source: { + image: "nginx:alpine", + }, + }); + + expect(service.id).toBeTruthy(); + expect(service.name).toEqual(`${testId}-nginx`); + expect(service.projectId).toEqual(project.id); + expect(service.createdAt).toBeTruthy(); + + // Verify service was created via API + const data = await api.request<{ + service: { + id: string; + name: string; + projectId: string; + }; + }>( + ` + query service($id: String!) { + service(id: $id) { + id + name + projectId + } + } + `, + { id: service.id }, + ); + + expect(data.service.name).toEqual(`${testId}-nginx`); + expect(data.service.projectId).toEqual(project.id); + } finally { + await destroy(scope); + } + }); + + test("create service from GitHub repo", async (scope) => { + let project: typeof Project.prototype | undefined; + let service: typeof Service.prototype | undefined; + + try { + project = await Project(`${testId}-gh-project`, { + name: `${testId}-github-project`, + }); + + // Create a service from a public GitHub repo + service = await Service(`${testId}-gh-service`, { + project, + name: `${testId}-from-github`, + source: { + repo: "railwayapp-templates/nextjs", + branch: "main", + }, + }); + + expect(service.id).toBeTruthy(); + expect(service.name).toEqual(`${testId}-from-github`); + expect(service.projectId).toEqual(project.id); + } finally { + await destroy(scope); + } + }); + + test("update service", async (scope) => { + let project: typeof Project.prototype | undefined; + let service: typeof Service.prototype | undefined; + + try { + project = await Project(`${testId}-update-project`, { + name: `${testId}-update-test-project`, + }); + + // Create service + service = await Service(`${testId}-update-service`, { + project, + name: `${testId}-to-update`, + source: { + image: "nginx:alpine", + }, + }); + + const originalId = service.id; + + // Update service name + service = await Service(`${testId}-update-service`, { + project, + name: `${testId}-updated`, + source: { + image: "nginx:alpine", + }, + }); + + // Service ID should remain the same + expect(service.id).toEqual(originalId); + expect(service.name).toEqual(`${testId}-updated`); + } finally { + await destroy(scope); + } + }); +}); diff --git a/bun.lock b/bun.lock index 0f9343291a..707b677d05 100644 --- a/bun.lock +++ b/bun.lock @@ -796,6 +796,15 @@ "typescript": "catalog:", }, }, + "examples/railway": { + "name": "railway-example", + "version": "0.0.0", + "devDependencies": { + "@types/node": "^24.0.1", + "alchemy": "workspace:*", + "typescript": "catalog:", + }, + }, "stacks": { "name": "alchemy-stacks", "version": "0.0.1", @@ -4619,6 +4628,8 @@ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "railway-example": ["railway-example@workspace:examples/railway"], + "randomatic": ["randomatic@3.1.1", "", { "dependencies": { "is-number": "^4.0.0", "kind-of": "^6.0.0", "math-random": "^1.0.1" } }, "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -6423,6 +6434,8 @@ "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1312386", "", {}, "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA=="], + "railway-example/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "randomatic/is-number": ["is-number@4.0.0", "", {}, "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ=="], "randomatic/kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -7427,6 +7440,8 @@ "puppeteer-core/@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "railway-example/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "react-server-dom-webpack/react-dom/scheduler": ["scheduler@0.27.0-canary-39cad7af-20250411", "", {}, "sha512-u+r6WyKk4zmirtBMZP8QZx0sTptiRxMMbGjf/3nOunE0mNnmkGFDcsG2UVDuOb5CyxvfbxgWE6W5JIyzTN4FXQ=="], "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/examples/railway/README.md b/examples/railway/README.md new file mode 100644 index 0000000000..0631fe7be1 --- /dev/null +++ b/examples/railway/README.md @@ -0,0 +1,76 @@ +# Railway Example + +This example demonstrates deploying a web service with a PostgreSQL database to Railway using Alchemy. + +## Prerequisites + +1. Create a Railway account at [railway.app](https://railway.app) +2. Get an API token from [Railway account settings](https://railway.com/account/tokens) + +## Setup + +1. Add your Railway API token to `.env`: + +```bash +RAILWAY_API_TOKEN=your_token_here +``` + +2. Install dependencies: + +```bash +bun install +``` + +## Deploy + +```bash +bun run deploy +``` + +This will: +- Create a Railway project +- Deploy a PostgreSQL database +- Deploy an nginx web server +- Configure the database connection as an environment variable +- Create a public domain for the web service + +## Resources Created + +- **Project**: Container for all services +- **PostgresDatabase**: Managed PostgreSQL database +- **Service**: nginx web server (can be replaced with your app) +- **Variable**: DATABASE_URL environment variable +- **Domain**: Public URL for the web service + +## Tear Down + +```bash +bun run destroy +``` + +⚠️ This will delete the database and all its data. + +## Customization + +Replace the nginx image with your own Docker image or GitHub repository: + +```typescript +// From Docker image +const api = await Service("api", { + project, + source: { + image: "your-registry/your-image:tag", + }, +}); + +// From GitHub +const api = await Service("api", { + project, + source: { + repo: "your-username/your-repo", + branch: "main", + }, + buildCommand: "npm run build", + startCommand: "npm start", +}); +``` diff --git a/examples/railway/alchemy.run.ts b/examples/railway/alchemy.run.ts new file mode 100644 index 0000000000..66d7d40e8e --- /dev/null +++ b/examples/railway/alchemy.run.ts @@ -0,0 +1,62 @@ +import alchemy from "alchemy"; +import { + Project, + Service, + PostgresDatabase, + Domain, + Variable, +} from "alchemy/railway"; + +const app = await alchemy("railway-example"); + +// Create a Railway project +const project = await Project("my-project", { + name: `${app.name}-${app.stage}`, + description: "Railway example with Alchemy", +}); + +console.log(`Created project: ${project.name} (${project.id})`); + +// Deploy a PostgreSQL database +const db = await PostgresDatabase("db", { + project, + name: `${app.name}-postgres`, +}); + +console.log(`Created database: ${db.name} (${db.id})`); +console.log(`Database host: ${db.host}`); + +// Deploy an nginx service as a simple web server +const web = await Service("web", { + project, + name: `${app.name}-web`, + source: { + image: "nginx:alpine", + }, +}); + +console.log(`Created service: ${web.name} (${web.id})`); + +// Get the production environment +const productionEnv = project.environments[0]; + +if (productionEnv) { + // Add database URL as environment variable + await Variable("db-url", { + project, + environment: productionEnv.id, + service: web, + name: "DATABASE_URL", + value: db.connectionUrl, + }); + + // Create a public domain for the web service + const domain = await Domain("web-domain", { + service: web, + environment: productionEnv.id, + }); + + console.log(`Web service available at: https://${domain.domain}`); +} + +await app.finalize(); diff --git a/examples/railway/package.json b/examples/railway/package.json new file mode 100644 index 0000000000..0fdf720f82 --- /dev/null +++ b/examples/railway/package.json @@ -0,0 +1,16 @@ +{ + "name": "railway-example", + "version": "0.0.0", + "description": "Deploy a service with PostgreSQL to Railway using Alchemy", + "type": "module", + "scripts": { + "build": "tsc -b", + "deploy": "alchemy deploy --env-file ../../.env", + "destroy": "alchemy destroy --env-file ../../.env" + }, + "devDependencies": { + "@types/node": "^24.0.1", + "alchemy": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/examples/railway/tsconfig.json b/examples/railway/tsconfig.json new file mode 100644 index 0000000000..5f755f38f8 --- /dev/null +++ b/examples/railway/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noEmit": true + }, + "include": ["alchemy.run.ts"], + "references": [ + { + "path": "../../alchemy/tsconfig.json" + } + ] +} From 53facf9344ddb2d7fabd6659f879847e571fa11a Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 2 Jan 2026 15:42:41 +0000 Subject: [PATCH 2/2] remove database template concept for now --- alchemy/src/railway/database.ts | 766 -------------------------- alchemy/src/railway/index.ts | 19 - alchemy/test/railway/database.test.ts | 91 --- 3 files changed, 876 deletions(-) delete mode 100644 alchemy/src/railway/database.ts delete mode 100644 alchemy/test/railway/database.test.ts diff --git a/alchemy/src/railway/database.ts b/alchemy/src/railway/database.ts deleted file mode 100644 index 4aaeefe9da..0000000000 --- a/alchemy/src/railway/database.ts +++ /dev/null @@ -1,766 +0,0 @@ -import type { Context } from "../context.ts"; -import { Resource, ResourceKind } from "../resource.ts"; -import { Secret } from "../secret.ts"; -import { - createRailwayApi, - type RailwayApiOptions, - RailwayError, -} from "./api.ts"; -import { isEnvironment, type Environment } from "./environment.ts"; -import { isProject, type Project } from "./project.ts"; - -/** - * Railway database template IDs - * @internal - */ -const DATABASE_TEMPLATES = { - postgres: "postgres", - mysql: "mysql", - redis: "redis", - mongodb: "mongo", -} as const; - -/** - * Base properties for creating a Railway database - */ -interface BaseDatabaseProps extends RailwayApiOptions { - /** - * The project to create the database in - */ - project: string | Project; - - /** - * The environment to create the database in (optional) - */ - environment?: string | Environment; - - /** - * Name of the database service - * - * @default ${app}-${stage}-${id} - */ - name?: string; - - /** - * Whether to delete the database when the resource is destroyed. - * Note: Deleting a database will permanently delete all data. - * @default true - */ - delete?: boolean; -} - -/** - * Properties for creating a Railway PostgreSQL database - */ -export interface PostgresDatabaseProps extends BaseDatabaseProps {} - -/** - * Properties for creating a Railway MySQL database - */ -export interface MysqlDatabaseProps extends BaseDatabaseProps {} - -/** - * Properties for creating a Railway Redis database - */ -export interface RedisDatabaseProps extends BaseDatabaseProps {} - -/** - * Properties for creating a Railway MongoDB database - */ -export interface MongoDatabaseProps extends BaseDatabaseProps {} - -/** - * Base output for Railway database - */ -interface BaseDatabase { - /** - * The ID of the database service - */ - id: string; - - /** - * Name of the database service - */ - name: string; - - /** - * The project ID this database belongs to - */ - projectId: string; - - /** - * Time at which the database was created - */ - createdAt: string; -} - -/** - * Output returned after Railway PostgreSQL database creation - */ -export interface PostgresDatabase extends BaseDatabase { - /** - * PostgreSQL connection URL - */ - connectionUrl: Secret; - - /** - * Database host - */ - host: string; - - /** - * Database port - */ - port: number; - - /** - * Database name - */ - database: string; - - /** - * Database user - */ - user: string; - - /** - * Database password - */ - password: Secret; - - /** - * Database type identifier - */ - type: "postgres"; -} - -/** - * Output returned after Railway MySQL database creation - */ -export interface MysqlDatabase extends BaseDatabase { - /** - * MySQL connection URL - */ - connectionUrl: Secret; - - /** - * Database host - */ - host: string; - - /** - * Database port - */ - port: number; - - /** - * Database name - */ - database: string; - - /** - * Database user - */ - user: string; - - /** - * Database password - */ - password: Secret; - - /** - * Database type identifier - */ - type: "mysql"; -} - -/** - * Output returned after Railway Redis database creation - */ -export interface RedisDatabase extends BaseDatabase { - /** - * Redis connection URL - */ - connectionUrl: Secret; - - /** - * Redis host - */ - host: string; - - /** - * Redis port - */ - port: number; - - /** - * Redis password - */ - password: Secret; - - /** - * Database type identifier - */ - type: "redis"; -} - -/** - * Output returned after Railway MongoDB database creation - */ -export interface MongoDatabase extends BaseDatabase { - /** - * MongoDB connection URL - */ - connectionUrl: Secret; - - /** - * Database type identifier - */ - type: "mongodb"; -} - -/** - * GraphQL response types - * @internal - */ -interface TemplateDeployResponse { - templateDeploy: { - projectId: string; - workflowId: string; - }; -} - -interface ServiceCreateResponse { - serviceCreate: { - id: string; - name: string; - projectId: string; - createdAt: string; - updatedAt: string; - }; -} - -interface VariablesQueryResponse { - variables: Record; -} - -interface ServicesQueryResponse { - project: { - services: { - edges: Array<{ - node: { - id: string; - name: string; - projectId: string; - createdAt: string; - updatedAt: string; - }; - }>; - }; - }; -} - -/** - * Type guards for Railway databases - */ -export function isPostgresDatabase( - resource: any, -): resource is PostgresDatabase { - return resource?.[ResourceKind] === "railway::PostgresDatabase"; -} - -export function isMysqlDatabase(resource: any): resource is MysqlDatabase { - return resource?.[ResourceKind] === "railway::MysqlDatabase"; -} - -export function isRedisDatabase(resource: any): resource is RedisDatabase { - return resource?.[ResourceKind] === "railway::RedisDatabase"; -} - -export function isMongoDatabase(resource: any): resource is MongoDatabase { - return resource?.[ResourceKind] === "railway::MongoDatabase"; -} - -/** - * Get the project ID from a project reference - */ -function getProjectId(project: string | Project): string { - if (isProject(project)) { - return project.id; - } - return project; -} - -/** - * Get the environment ID from an environment reference - */ -function getEnvironmentId( - environment: string | Environment | undefined, -): string | undefined { - if (environment === undefined) { - return undefined; - } - if (isEnvironment(environment)) { - return environment.id; - } - return environment; -} - -/** - * Helper to fetch variables for a service - */ -async function fetchServiceVariables( - api: ReturnType, - projectId: string, - serviceId: string, - environmentId?: string, -): Promise> { - // Get the first environment if not specified - let envId = environmentId; - if (!envId) { - const projectData = await api.request<{ - project: { - environments: { - edges: Array<{ node: { id: string; name: string } }>; - }; - }; - }>( - ` - query project($id: String!) { - project(id: $id) { - environments { - edges { - node { - id - name - } - } - } - } - } - `, - { id: projectId }, - ); - - const firstEnv = projectData.project.environments.edges[0]; - if (firstEnv) { - envId = firstEnv.node.id; - } - } - - if (!envId) { - return {}; - } - - const data = await api.request( - ` - query variables($projectId: String!, $environmentId: String!, $serviceId: String) { - variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) - } - `, - { - projectId, - environmentId: envId, - serviceId, - }, - ); - - return data.variables; -} - -/** - * Helper to delete a database service - */ -async function deleteDatabase( - api: ReturnType, - serviceId: string, -): Promise { - try { - await api.request( - ` - mutation serviceDelete($id: String!) { - serviceDelete(id: $id) - } - `, - { id: serviceId }, - ); - } catch (error) { - // Ignore 404 errors (service already deleted) - if (error instanceof RailwayError && error.code === "NOT_FOUND") { - // Already deleted, that's fine - } else { - throw error; - } - } -} - -/** - * Creates a Railway PostgreSQL database. - * - * @example - * // Create a PostgreSQL database: - * const db = await PostgresDatabase("db", { - * project: project, - * name: "my-postgres" - * }); - * - * // Use the connection URL in another service: - * await Variable("db-url", { - * project, - * environment: production, - * service: apiService, - * name: "DATABASE_URL", - * value: db.connectionUrl - * }); - */ -export const PostgresDatabase = Resource( - "railway::PostgresDatabase", - async function ( - this: Context, - id: string, - props: PostgresDatabaseProps, - ): Promise { - const api = createRailwayApi(props); - const projectId = getProjectId(props.project); - const environmentId = getEnvironmentId(props.environment); - const name = - props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); - - if (this.phase === "delete") { - if (props.delete !== false && this.output?.id) { - await deleteDatabase(api, this.output.id); - } - return this.destroy(); - } - - // For updates, we can only update the name - other properties require replacement - if (this.phase === "update" && this.output?.id) { - // Just return the current state - name updates would require service update - return this.output; - } - - // Create database service using Docker image - const data = await api.request( - ` - mutation serviceCreate($input: ServiceCreateInput!) { - serviceCreate(input: $input) { - id - name - projectId - createdAt - updatedAt - } - } - `, - { - input: { - name, - projectId, - source: { - image: "postgres:16", - }, - }, - }, - ); - - // Wait a moment for variables to be populated - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Fetch variables to get connection info - const variables = await fetchServiceVariables( - api, - projectId, - data.serviceCreate.id, - environmentId, - ); - - const connectionUrl = - variables.DATABASE_URL || variables.POSTGRES_URL || ""; - const host = variables.PGHOST || variables.POSTGRES_HOST || "localhost"; - const port = parseInt( - variables.PGPORT || variables.POSTGRES_PORT || "5432", - 10, - ); - const database = - variables.PGDATABASE || variables.POSTGRES_DB || "postgres"; - const user = variables.PGUSER || variables.POSTGRES_USER || "postgres"; - const password = variables.PGPASSWORD || variables.POSTGRES_PASSWORD || ""; - - return { - id: data.serviceCreate.id, - name: data.serviceCreate.name, - projectId: data.serviceCreate.projectId, - createdAt: data.serviceCreate.createdAt, - connectionUrl: Secret.wrap(connectionUrl), - host, - port, - database, - user, - password: Secret.wrap(password), - type: "postgres", - }; - }, -); - -/** - * Creates a Railway MySQL database. - * - * @example - * // Create a MySQL database: - * const db = await MysqlDatabase("db", { - * project: project, - * name: "my-mysql" - * }); - */ -export const MysqlDatabase = Resource( - "railway::MysqlDatabase", - async function ( - this: Context, - id: string, - props: MysqlDatabaseProps, - ): Promise { - const api = createRailwayApi(props); - const projectId = getProjectId(props.project); - const environmentId = getEnvironmentId(props.environment); - const name = - props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); - - if (this.phase === "delete") { - if (props.delete !== false && this.output?.id) { - await deleteDatabase(api, this.output.id); - } - return this.destroy(); - } - - if (this.phase === "update" && this.output?.id) { - return this.output; - } - - // Create database service using Docker image - const data = await api.request( - ` - mutation serviceCreate($input: ServiceCreateInput!) { - serviceCreate(input: $input) { - id - name - projectId - createdAt - updatedAt - } - } - `, - { - input: { - name, - projectId, - source: { - image: "mysql:8", - }, - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const variables = await fetchServiceVariables( - api, - projectId, - data.serviceCreate.id, - environmentId, - ); - - const connectionUrl = variables.DATABASE_URL || variables.MYSQL_URL || ""; - const host = variables.MYSQL_HOST || variables.MYSQLHOST || "localhost"; - const port = parseInt( - variables.MYSQL_PORT || variables.MYSQLPORT || "3306", - 10, - ); - const database = - variables.MYSQL_DATABASE || variables.MYSQLDATABASE || "mysql"; - const user = variables.MYSQL_USER || variables.MYSQLUSER || "root"; - const password = variables.MYSQL_PASSWORD || variables.MYSQLPASSWORD || ""; - - return { - id: data.serviceCreate.id, - name: data.serviceCreate.name, - projectId: data.serviceCreate.projectId, - createdAt: data.serviceCreate.createdAt, - connectionUrl: Secret.wrap(connectionUrl), - host, - port, - database, - user, - password: Secret.wrap(password), - type: "mysql", - }; - }, -); - -/** - * Creates a Railway Redis database. - * - * @example - * // Create a Redis database: - * const redis = await RedisDatabase("cache", { - * project: project, - * name: "my-redis" - * }); - */ -export const RedisDatabase = Resource( - "railway::RedisDatabase", - async function ( - this: Context, - id: string, - props: RedisDatabaseProps, - ): Promise { - const api = createRailwayApi(props); - const projectId = getProjectId(props.project); - const environmentId = getEnvironmentId(props.environment); - const name = - props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); - - if (this.phase === "delete") { - if (props.delete !== false && this.output?.id) { - await deleteDatabase(api, this.output.id); - } - return this.destroy(); - } - - if (this.phase === "update" && this.output?.id) { - return this.output; - } - - // Create database service using Docker image - const data = await api.request( - ` - mutation serviceCreate($input: ServiceCreateInput!) { - serviceCreate(input: $input) { - id - name - projectId - createdAt - updatedAt - } - } - `, - { - input: { - name, - projectId, - source: { - image: "redis:7", - }, - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const variables = await fetchServiceVariables( - api, - projectId, - data.serviceCreate.id, - environmentId, - ); - - const connectionUrl = variables.REDIS_URL || ""; - const host = variables.REDIS_HOST || variables.REDISHOST || "localhost"; - const port = parseInt( - variables.REDIS_PORT || variables.REDISPORT || "6379", - 10, - ); - const password = variables.REDIS_PASSWORD || variables.REDISPASSWORD || ""; - - return { - id: data.serviceCreate.id, - name: data.serviceCreate.name, - projectId: data.serviceCreate.projectId, - createdAt: data.serviceCreate.createdAt, - connectionUrl: Secret.wrap(connectionUrl), - host, - port, - password: Secret.wrap(password), - type: "redis", - }; - }, -); - -/** - * Creates a Railway MongoDB database. - * - * @example - * // Create a MongoDB database: - * const mongo = await MongoDatabase("db", { - * project: project, - * name: "my-mongo" - * }); - */ -export const MongoDatabase = Resource( - "railway::MongoDatabase", - async function ( - this: Context, - id: string, - props: MongoDatabaseProps, - ): Promise { - const api = createRailwayApi(props); - const projectId = getProjectId(props.project); - const environmentId = getEnvironmentId(props.environment); - const name = - props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); - - if (this.phase === "delete") { - if (props.delete !== false && this.output?.id) { - await deleteDatabase(api, this.output.id); - } - return this.destroy(); - } - - if (this.phase === "update" && this.output?.id) { - return this.output; - } - - // Create database service using Docker image - const data = await api.request( - ` - mutation serviceCreate($input: ServiceCreateInput!) { - serviceCreate(input: $input) { - id - name - projectId - createdAt - updatedAt - } - } - `, - { - input: { - name, - projectId, - source: { - image: "mongo:7", - }, - }, - }, - ); - - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const variables = await fetchServiceVariables( - api, - projectId, - data.serviceCreate.id, - environmentId, - ); - - const connectionUrl = variables.MONGO_URL || variables.MONGODB_URL || ""; - - return { - id: data.serviceCreate.id, - name: data.serviceCreate.name, - projectId: data.serviceCreate.projectId, - createdAt: data.serviceCreate.createdAt, - connectionUrl: Secret.wrap(connectionUrl), - type: "mongodb", - }; - }, -); diff --git a/alchemy/src/railway/index.ts b/alchemy/src/railway/index.ts index 4b904bc576..20f192ef10 100644 --- a/alchemy/src/railway/index.ts +++ b/alchemy/src/railway/index.ts @@ -61,22 +61,3 @@ export { type TcpProxyProps, type TcpProxy as TcpProxyOutput, } from "./tcp-proxy.ts"; - -export { - PostgresDatabase, - MysqlDatabase, - RedisDatabase, - MongoDatabase, - isPostgresDatabase, - isMysqlDatabase, - isRedisDatabase, - isMongoDatabase, - type PostgresDatabaseProps, - type MysqlDatabaseProps, - type RedisDatabaseProps, - type MongoDatabaseProps, - type PostgresDatabase as PostgresDatabaseOutput, - type MysqlDatabase as MysqlDatabaseOutput, - type RedisDatabase as RedisDatabaseOutput, - type MongoDatabase as MongoDatabaseOutput, -} from "./database.ts"; diff --git a/alchemy/test/railway/database.test.ts b/alchemy/test/railway/database.test.ts deleted file mode 100644 index d68a11fe67..0000000000 --- a/alchemy/test/railway/database.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect } from "vitest"; -import { alchemy } from "../../src/alchemy.ts"; -import { destroy } from "../../src/destroy.ts"; -import { createRailwayApi } from "../../src/railway/api.ts"; -import { - Project, - PostgresDatabase, - RedisDatabase, -} from "../../src/railway/index.ts"; -import { BRANCH_PREFIX } from "../util.ts"; -import "../../src/test/vitest.ts"; - -const api = createRailwayApi(); - -const test = alchemy.test(import.meta, { - prefix: BRANCH_PREFIX, -}); - -describe("Railway Database Resources", () => { - const testId = `${BRANCH_PREFIX}-test-railway-db`; - - test("create PostgreSQL database", async (scope) => { - let project: typeof Project.prototype | undefined; - let db: typeof PostgresDatabase.prototype | undefined; - - try { - project = await Project(`${testId}-pg-project`, { - name: `${testId}-postgres-project`, - }); - - db = await PostgresDatabase(`${testId}-postgres`, { - project, - name: `${testId}-postgres-db`, - }); - - expect(db.id).toBeTruthy(); - expect(db.name).toEqual(`${testId}-postgres-db`); - expect(db.projectId).toEqual(project.id); - expect(db.type).toEqual("postgres"); - expect(db.createdAt).toBeTruthy(); - - // Verify the service was created via API - const data = await api.request<{ - service: { - id: string; - name: string; - projectId: string; - }; - }>( - ` - query service($id: String!) { - service(id: $id) { - id - name - projectId - } - } - `, - { id: db.id }, - ); - - expect(data.service.projectId).toEqual(project.id); - } finally { - await destroy(scope); - } - }, 60000); - - test("create Redis database", async (scope) => { - let project: typeof Project.prototype | undefined; - let redis: typeof RedisDatabase.prototype | undefined; - - try { - project = await Project(`${testId}-redis-project`, { - name: `${testId}-redis-project`, - }); - - redis = await RedisDatabase(`${testId}-redis`, { - project, - name: `${testId}-redis-cache`, - }); - - expect(redis.id).toBeTruthy(); - expect(redis.name).toEqual(`${testId}-redis-cache`); - expect(redis.projectId).toEqual(project.id); - expect(redis.type).toEqual("redis"); - expect(redis.createdAt).toBeTruthy(); - } finally { - await destroy(scope); - } - }, 60000); -});