From 74bf5c4538cd8e065cd22c6caaa598408dc8a085 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Feb 2026 17:49:47 +0100 Subject: [PATCH 01/16] feat(railway): add Railway provider with 7 resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new Railway cloud infrastructure provider with resources for: - Project — create/update/delete with workspace auto-detection and adopt support - Service — deploy from Docker images or GitHub repos with instance config - Environment — isolated environments with immutable name (replace on change) - Variable — bulk upsert/delete with Secret support and key tracking - Volume — persistent storage with immutable mountPath and conditional deletion - Domain — Railway-generated (*.up.railway.app) or custom domains - TCPProxy — expose non-HTTP services with immutable applicationPort Includes: - GraphQL API client using safeFetch with Bearer token auth - 7 test suites (all passing against live Railway API) - Provider docs (index + 7 resource pages) - Getting started guide - Package.json export ```ts import { Project, Service, Variable, Domain } from "alchemy/railway"; const project = await Project("my-app", { name: "my-app" }); const api = await Service("api", { project, source: { image: "node:20" }, startCommand: "node server.js", }); await Variable("vars", { project, environment: project.defaultEnvironmentId, service: api, variables: { NODE_ENV: "production" }, }); const domain = await Domain("domain", { service: api, environment: project.defaultEnvironmentId, }); ``` Co-Authored-By: Claude Opus 4.6 --- .../src/content/docs/guides/railway.mdx | 145 ++++++ .../content/docs/providers/railway/domain.md | 68 +++ .../docs/providers/railway/environment.md | 54 +++ .../content/docs/providers/railway/index.md | 73 +++ .../content/docs/providers/railway/project.md | 76 +++ .../content/docs/providers/railway/service.md | 112 +++++ .../docs/providers/railway/tcp-proxy.md | 56 +++ .../docs/providers/railway/variable.md | 71 +++ .../content/docs/providers/railway/volume.md | 69 +++ alchemy/package.json | 4 + alchemy/src/railway/api.ts | 98 ++++ alchemy/src/railway/domain.ts | 269 +++++++++++ alchemy/src/railway/environment.ts | 226 +++++++++ alchemy/src/railway/index.ts | 8 + alchemy/src/railway/project.ts | 343 ++++++++++++++ alchemy/src/railway/service.ts | 437 ++++++++++++++++++ alchemy/src/railway/tcp-proxy.ts | 186 ++++++++ alchemy/src/railway/variable.ts | 204 ++++++++ alchemy/src/railway/volume.ts | 209 +++++++++ alchemy/test/railway/domain.test.ts | 61 +++ alchemy/test/railway/environment.test.ts | 53 +++ alchemy/test/railway/project.test.ts | 94 ++++ alchemy/test/railway/service.test.ts | 67 +++ alchemy/test/railway/tcp-proxy.test.ts | 63 +++ alchemy/test/railway/variable.test.ts | 80 ++++ alchemy/test/railway/volume.test.ts | 61 +++ 26 files changed, 3187 insertions(+) create mode 100644 alchemy-web/src/content/docs/guides/railway.mdx 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/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/domain.test.ts create mode 100644 alchemy/test/railway/environment.test.ts create mode 100644 alchemy/test/railway/project.test.ts create mode 100644 alchemy/test/railway/service.test.ts create mode 100644 alchemy/test/railway/tcp-proxy.test.ts create mode 100644 alchemy/test/railway/variable.test.ts create mode 100644 alchemy/test/railway/volume.test.ts 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 000000000..18a64ccb2 --- /dev/null +++ b/alchemy-web/src/content/docs/guides/railway.mdx @@ -0,0 +1,145 @@ +--- +title: Railway +description: Learn how to deploy services and infrastructure on Railway using Alchemy. +sidebar: + order: 22 +--- + +import { Steps } from '@astrojs/starlight/components'; + +This guide walks you through deploying a service to Railway using Alchemy, including environment variables and a public domain. + +:::note +For the full Railway API reference, see the [Railway Provider](/providers/railway/) documentation. +::: + + + +1. **Install Alchemy** + + ::: code-group + + ```sh [bun] + bun add alchemy + ``` + + ```sh [npm] + npm install alchemy + ``` + + ```sh [pnpm] + pnpm add alchemy + ``` + + ```sh [yarn] + yarn add alchemy + ``` + + ::: + +2. **Get your Railway API token** + + Create an API token from your [Railway account settings](https://railway.com/account/tokens) and add it to your `.env` file: + + ```bash title=".env" + RAILWAY_API_TOKEN=your-token-here + ``` + +3. **Create `alchemy.run.ts`** + + ```ts title="alchemy.run.ts" + import alchemy from "alchemy"; + import { Project, Service, Variable, Domain } from "alchemy/railway"; + + const app = await alchemy("railway-app", { + phase: process.argv.includes("--destroy") ? "destroy" : "up", + }); + + // Create a Railway project + const project = await Project("my-app", { + name: "my-app", + description: "Deployed with Alchemy", + }); + + // Deploy a service from a Docker image + const service = await Service("web", { + project, + name: "web-server", + source: { image: "nginx:latest" }, + }); + + // Set environment variables + await Variable("web-vars", { + project, + environment: project.defaultEnvironmentId, + service, + variables: { + PORT: "80", + }, + }); + + // Add a public domain + const domain = await Domain("web-domain", { + service, + environment: project.defaultEnvironmentId, + }); + + console.log(`Service URL: https://${domain.domain}`); + + await app.finalize(); + ``` + +4. **Deploy** + + Run `alchemy.run.ts` to deploy: + + ::: code-group + + ```sh [bun] + bun ./alchemy.run + ``` + + ```sh [npm] + npx tsx ./alchemy.run + ``` + + ```sh [pnpm] + pnpm tsx ./alchemy.run + ``` + + ```sh [yarn] + yarn tsx ./alchemy.run + ``` + + ::: + + It should log out the service URL: + ``` + Service URL: https://web-server-production-xxxx.up.railway.app + ``` + +5. **Tear Down** + + That's it! You can now tear down the app (if you want to): + + ::: code-group + + ```sh [bun] + bun ./alchemy.run --destroy + ``` + + ```sh [npm] + npx tsx ./alchemy.run --destroy + ``` + + ```sh [pnpm] + pnpm tsx ./alchemy.run --destroy + ``` + + ```sh [yarn] + yarn tsx ./alchemy.run --destroy + ``` + + ::: + + 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 000000000..5b5d3bb66 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/domain.md @@ -0,0 +1,68 @@ +--- +title: Domain +description: Add domains to Railway services using Alchemy. +--- + +The `Domain` resource creates and manages domains for [Railway](https://railway.com) services. You can create Railway-generated domains (*.up.railway.app) or attach custom domains. + +## Railway Domain + +Get a Railway-generated domain: + +```ts +import { Project, Service, Domain } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const api = await Service("api", { + project, + name: "api", + source: { image: "node:20" }, +}); + +const domain = await Domain("api-domain", { + service: api, + environment: project.defaultEnvironmentId, +}); + +console.log(`https://${domain.domain}`); +// e.g. https://api-production-xxxx.up.railway.app +``` + +## Custom Domain + +Attach a custom domain to a service: + +```ts +import { Project, Service, Domain } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const api = await Service("api", { project, name: "api" }); + +const domain = await Domain("custom", { + service: api, + environment: project.defaultEnvironmentId, + domain: "api.example.com", + projectId: project.projectId, +}); +``` + +## With Target Port + +Route traffic to a specific port: + +```ts +import { Project, Service, Domain } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const api = await Service("api", { project, name: "api" }); + +const domain = await Domain("api-domain", { + service: api, + environment: project.defaultEnvironmentId, + targetPort: 3000, +}); +``` + +:::note +Domain names are immutable. If the custom domain changes, the domain will be replaced (deleted and recreated). +::: 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 000000000..7cbb890b7 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/environment.md @@ -0,0 +1,54 @@ +--- +title: Environment +description: Create and manage Railway environments using Alchemy. +--- + +The `Environment` resource creates and manages environments within a [Railway](https://railway.com) project. Environments provide isolated deployments for staging, development, or feature branches. + +## Minimal Example + +Create a staging environment: + +```ts +import { Project, Environment } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const staging = await Environment("staging", { + project, + name: "staging", +}); +``` + +## With Project ID + +Reference a project by its ID string: + +```ts +import { Environment } from "alchemy/railway"; + +const env = await Environment("dev", { + project: "project-id-123", + name: "development", +}); +``` + +## Adopt Existing + +Adopt an environment that already exists: + +```ts +import { Project, Environment } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const env = await Environment("staging", { + project, + name: "staging", + adopt: true, +}); +``` + +:::note +Environment names are immutable in Railway. If the name changes, the environment will be replaced (deleted and recreated). +::: 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 000000000..a908f49a9 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/index.md @@ -0,0 +1,73 @@ +--- +title: Railway +description: Deploy and manage Railway projects, services, environments, variables, volumes, domains, and TCP proxies using Alchemy. +--- + +Railway is a cloud infrastructure platform for deploying applications, databases, and services with zero configuration. Alchemy provides resources to manage Railway infrastructure programmatically using the Railway GraphQL API. + +[Official Railway Documentation](https://docs.railway.com) | [Railway API Reference](https://docs.railway.com/guides/public-api) + +## Resources + +- [Project](/providers/railway/project) - Create and manage Railway projects +- [Service](/providers/railway/service) - Deploy and configure services within projects +- [Environment](/providers/railway/environment) - Create isolated environments for staging, development, etc. +- [Variable](/providers/railway/variable) - Manage environment variables for services +- [Volume](/providers/railway/volume) - Attach persistent storage to services +- [Domain](/providers/railway/domain) - Add Railway-generated or custom domains to services +- [TCPProxy](/providers/railway/tcp-proxy) - Expose non-HTTP services via TCP + +## Authentication + +Railway uses API tokens for authentication. Set the `RAILWAY_API_TOKEN` environment variable, or pass the token directly via the `apiToken` property on any resource. + +```bash +export RAILWAY_API_TOKEN=your-token-here +``` + +You can create an API token from your [Railway account settings](https://railway.com/account/tokens). + +## Example Usage + +```ts +import { + Project, + Service, + Variable, + Domain, +} from "alchemy/railway"; + +// Create a project +const project = await Project("my-app", { + name: "my-app", + description: "Production application", +}); + +// Deploy a service +const api = await Service("api", { + project, + name: "api-service", + source: { image: "node:20" }, + startCommand: "node server.js", +}); + +// Set environment variables +await Variable("api-vars", { + project, + environment: project.defaultEnvironmentId, + service: api, + variables: { + NODE_ENV: "production", + PORT: "3000", + DATABASE_URL: alchemy.secret.env.DATABASE_URL, + }, +}); + +// Add a domain +const domain = await Domain("api-domain", { + service: api, + environment: project.defaultEnvironmentId, +}); + +console.log(`API available at: https://${domain.domain}`); +``` 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 000000000..5a9a6bbf6 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/project.md @@ -0,0 +1,76 @@ +--- +title: Project +description: Create and manage Railway projects using Alchemy. +--- + +The `Project` resource creates and manages [Railway](https://railway.com) projects. + +## Minimal Example + +Create a basic Railway project: + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("my-app", { + name: "my-app", +}); +``` + +## With Description + +Create a project with a description: + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("my-app", { + name: "my-app", + description: "Production application backend", +}); +``` + +## Adopt Existing Project + +Adopt a project that already exists in Railway: + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("my-app", { + name: "existing-project-name", + adopt: true, +}); +``` + +## Prevent Deletion + +Keep the project when removed from Alchemy: + +```ts +import { Project } from "alchemy/railway"; + +const project = await Project("my-app", { + name: "my-app", + delete: false, +}); +``` + +## Access Default Environment + +Every project has a default production environment: + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-app", { + name: "my-app", +}); + +// Use the default environment +const service = await Service("api", { + project, + name: "api", + // Uses project.defaultEnvironmentId automatically +}); +``` 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 000000000..c28cb7f44 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/service.md @@ -0,0 +1,112 @@ +--- +title: Service +description: Deploy and configure Railway services using Alchemy. +--- + +The `Service` resource creates and manages services within a [Railway](https://railway.com) project. + +## Minimal Example + +Create a basic service in a project: + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const service = await Service("api", { + project, + name: "api-service", +}); +``` + +## Docker Image + +Deploy a service from a Docker image: + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const service = await Service("web", { + project, + name: "web-server", + source: { image: "nginx:latest" }, +}); +``` + +## GitHub Repository + +Deploy from a GitHub repository: + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const service = await Service("api", { + project, + name: "api", + source: { repo: "myorg/myrepo" }, + buildCommand: "npm run build", + startCommand: "npm start", +}); +``` + +## Custom Configuration + +Configure replicas, healthchecks, and more: + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const service = await Service("api", { + project, + name: "api", + startCommand: "node server.js", + healthcheckPath: "/health", + numReplicas: 2, + region: "us-west1", +}); +``` + +## Cron Service + +Create a scheduled cron job: + +```ts +import { Project, Service } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +const cron = await Service("cleanup", { + project, + name: "cleanup-job", + startCommand: "node cleanup.js", + cronSchedule: "0 0 * * *", // Daily at midnight +}); +``` + +## Specific Environment + +Deploy a service in a specific environment: + +```ts +import { Project, Service, Environment } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const staging = await Environment("staging", { + project, + name: "staging", +}); + +const service = await Service("api", { + project, + name: "api", + environment: staging, + startCommand: "npm start", +}); +``` 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 000000000..f2c83c34b --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/tcp-proxy.md @@ -0,0 +1,56 @@ +--- +title: TCPProxy +description: Expose non-HTTP Railway services via TCP using Alchemy. +--- + +The `TCPProxy` resource creates and manages TCP proxies for [Railway](https://railway.com) services. This is useful for exposing databases, Redis, and other non-HTTP services externally. + +## Minimal Example + +Expose a service via TCP: + +```ts +import { Project, Service, TCPProxy } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const db = await Service("postgres", { + project, + name: "postgres", + source: { image: "postgres:16" }, +}); + +const proxy = await TCPProxy("db-proxy", { + service: db, + environment: project.defaultEnvironmentId, + applicationPort: 5432, +}); + +console.log(`postgres://user:pass@${proxy.domain}:${proxy.proxyPort}/mydb`); +``` + +## Redis Proxy + +Expose a Redis service externally: + +```ts +import { Project, Service, TCPProxy } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const redis = await Service("redis", { + project, + name: "redis", + source: { image: "redis:7" }, +}); + +const proxy = await TCPProxy("redis-proxy", { + service: redis, + environment: project.defaultEnvironmentId, + applicationPort: 6379, +}); + +console.log(`redis://${proxy.domain}:${proxy.proxyPort}`); +``` + +:::note +The `applicationPort` is immutable. If it changes, the TCP proxy will be replaced (deleted and recreated). +::: 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 000000000..9e8fe16d5 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/variable.md @@ -0,0 +1,71 @@ +--- +title: Variable +description: Manage Railway environment variables using Alchemy. +--- + +The `Variable` resource creates and manages environment variables in a [Railway](https://railway.com) project. Variables can be scoped to a specific service or shared across all services in an environment. + +## Service Variables + +Set environment variables for a specific service: + +```ts +import { Project, Service, Variable } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const api = await Service("api", { project, name: "api" }); + +await Variable("api-vars", { + project, + environment: project.defaultEnvironmentId, + service: api, + variables: { + NODE_ENV: "production", + PORT: "3000", + }, +}); +``` + +## Shared Variables + +Set variables shared across all services in an environment: + +```ts +import { Project, Variable } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); + +await Variable("shared-vars", { + project, + environment: project.defaultEnvironmentId, + variables: { + APP_NAME: "My App", + LOG_LEVEL: "info", + }, +}); +``` + +## Secret Values + +Use `alchemy.secret()` for sensitive values: + +```ts +import { Project, Service, Variable } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const api = await Service("api", { project, name: "api" }); + +await Variable("api-secrets", { + project, + environment: project.defaultEnvironmentId, + service: api, + variables: { + DATABASE_URL: alchemy.secret.env.DATABASE_URL, + API_KEY: alchemy.secret.env.API_KEY, + }, +}); +``` + +:::note +When variables are updated, any removed keys are automatically deleted from Railway. Only the keys in the current `variables` object will exist after an update. +::: 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 000000000..b0ae80350 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/railway/volume.md @@ -0,0 +1,69 @@ +--- +title: Volume +description: Attach persistent storage to Railway services using Alchemy. +--- + +The `Volume` resource creates and manages persistent volumes attached to [Railway](https://railway.com) services. + +## Minimal Example + +Attach a persistent volume to a service: + +```ts +import { Project, Service, Volume } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const api = await Service("api", { project, name: "api" }); + +const volume = await Volume("data", { + project, + service: api, + environment: project.defaultEnvironmentId, + mountPath: "/data", +}); +``` + +## Database Storage + +Attach a volume for database storage: + +```ts +import { Project, Service, Volume } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const db = await Service("postgres", { + project, + name: "postgres", + source: { image: "postgres:16" }, +}); + +const volume = await Volume("pg-data", { + project, + service: db, + environment: project.defaultEnvironmentId, + mountPath: "/var/lib/postgresql/data", +}); +``` + +## Prevent Deletion + +Keep the volume even when removed from Alchemy: + +```ts +import { Project, Service, Volume } from "alchemy/railway"; + +const project = await Project("my-app", { name: "my-app" }); +const db = await Service("db", { project, name: "db" }); + +const volume = await Volume("db-data", { + project, + service: db, + environment: project.defaultEnvironmentId, + mountPath: "/data", + delete: false, +}); +``` + +:::note +The `mountPath` is immutable. If it changes, the volume will be replaced (deleted and recreated). +::: diff --git a/alchemy/package.json b/alchemy/package.json index baa693c35..52117fb9b 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 000000000..6e1438717 --- /dev/null +++ b/alchemy/src/railway/api.ts @@ -0,0 +1,98 @@ +import type { Secret } from "../secret.ts"; +import { safeFetch } from "../util/safe-fetch.ts"; + +/** + * Options for Railway API requests + */ +export interface RailwayApiOptions { + /** + * Railway API token for authentication. + * Falls back to `RAILWAY_API_TOKEN` environment variable. + */ + apiToken?: Secret; +} + +/** + * Error from the Railway GraphQL API + */ +export class RailwayError extends Error { + readonly errors: Array<{ + message: string; + extensions?: Record; + }>; + + constructor( + message: string, + errors: Array<{ message: string; extensions?: Record }>, + ) { + super(message); + this.name = "RailwayError"; + this.errors = errors; + } +} + +/** + * Minimal GraphQL client for the Railway API + */ +export class RailwayApi { + /** Railway API token */ + readonly token: string; + + /** GraphQL endpoint */ + readonly endpoint = "https://backboard.railway.com/graphql/v2"; + + /** + * Create a new Railway API client + * + * @param options API options + */ + constructor(options: RailwayApiOptions = {}) { + this.token = + options.apiToken?.unencrypted ?? process.env.RAILWAY_API_TOKEN ?? ""; + + if (!this.token) { + throw new Error( + "Railway API token is required. Set RAILWAY_API_TOKEN environment variable or pass apiToken option.", + ); + } + } + + /** + * Execute a GraphQL query or mutation + * + * @param query GraphQL query string + * @param variables Query variables + * @returns The data from the response + */ + async query( + query: string, + variables?: Record, + ): Promise { + const response = await safeFetch(this.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ query, variables }), + }); + + const json = (await response.json()) as { + data?: T; + errors?: Array<{ message: string; extensions?: Record }>; + }; + + if (json.errors?.length) { + throw new RailwayError( + `Railway API error: ${json.errors.map((e) => e.message).join(", ")}`, + json.errors, + ); + } + + if (!json.data) { + throw new RailwayError("Railway API returned no data", []); + } + + return json.data; + } +} diff --git a/alchemy/src/railway/domain.ts b/alchemy/src/railway/domain.ts new file mode 100644 index 000000000..f9b370a28 --- /dev/null +++ b/alchemy/src/railway/domain.ts @@ -0,0 +1,269 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import type { Environment } from "./environment.ts"; +import type { Service } from "./service.ts"; + +/** + * Properties for creating or updating a Railway Domain + */ +export interface DomainProps extends RailwayApiOptions { + /** + * The service to attach the domain to + */ + service: string | Service; + + /** + * The environment for the domain + */ + environment: string | Environment; + + /** + * Custom domain name. If omitted, a Railway-generated domain is created. + */ + domain?: string; + + /** + * The port to route traffic to on the service + */ + targetPort?: number; + + /** + * The project ID (required for custom domains) + */ + projectId?: string; +} + +/** + * Output type for a Railway Domain + */ +export type Domain = Omit< + DomainProps, + "service" | "environment" | "projectId" +> & { + /** + * The Railway domain ID + */ + domainId: string; + + /** + * The service ID + */ + serviceId: string; + + /** + * The environment ID + */ + environmentId: string; + + /** + * The fully qualified domain name + */ + domain: string; + + /** + * The port traffic is routed to + */ + targetPort?: number; +}; + +/** + * Type guard for Railway Domain + */ +export function isDomain(resource: any): resource is Domain { + return resource?.[ResourceKind] === "railway::Domain"; +} + +/** + * Creates and manages a domain for a Railway service. + * If a `domain` prop is provided, a custom domain is created. + * Otherwise, a Railway-generated domain (*.up.railway.app) is created. + * + * @example + * ## Create a Railway domain + * + * Get a Railway-generated domain for a service. + * + * ```ts + * const domain = await Domain("api-domain", { + * service, + * environment: project.defaultEnvironmentId, + * }); + * console.log(domain.domain); // e.g. "my-app-production.up.railway.app" + * ``` + * + * @example + * ## Create a custom domain + * + * Attach a custom domain to a service. + * + * ```ts + * const domain = await Domain("custom-domain", { + * service, + * environment: project.defaultEnvironmentId, + * domain: "api.example.com", + * targetPort: 3000, + * }); + * ``` + */ +export const Domain = Resource( + "railway::Domain", + async function ( + this: Context, + id: string, + props: DomainProps, + ): Promise { + const api = new RailwayApi(props); + const serviceId = + typeof props.service === "string" + ? props.service + : props.service.serviceId; + const environmentId = + typeof props.environment === "string" + ? props.environment + : props.environment.environmentId; + + if (this.phase === "delete") { + if (this.output?.domainId) { + try { + if (this.output.domain?.endsWith(".up.railway.app")) { + await api.query( + `mutation serviceDomainDelete($id: String!) { + serviceDomainDelete(id: $id) + }`, + { id: this.output.domainId }, + ); + } else { + await api.query( + `mutation customDomainDelete($id: String!) { + customDomainDelete(id: $id) + }`, + { id: this.output.domainId }, + ); + } + } catch (error: any) { + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + // Domain name is immutable — replace if changed + if ( + this.phase === "update" && + props.domain && + this.output?.domain !== props.domain + ) { + return this.replace(); + } + + const domainId = this.output?.domainId; + + if (domainId && this.output) { + // Update — only targetPort can be updated via serviceInstanceUpdate + if ( + props.targetPort !== undefined && + props.targetPort !== this.output.targetPort + ) { + await api.query( + `mutation serviceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + }`, + { + serviceId, + environmentId, + input: {}, + }, + ); + } + + return { + domainId: this.output.domainId, + serviceId: this.output.serviceId, + environmentId: this.output.environmentId, + domain: this.output.domain, + targetPort: props.targetPort, + }; + } + + // Create + let domain: string; + let newDomainId: string; + + if (props.domain) { + // Custom domain + const projectId = + props.projectId ?? + (typeof props.service !== "string" + ? props.service.projectId + : undefined); + + if (!projectId) { + throw new Error( + "projectId is required for custom domains. Pass it directly or use a Service resource reference.", + ); + } + + const data = await api.query<{ + customDomainCreate: { + id: string; + domain: string; + }; + }>( + `mutation customDomainCreate($input: CustomDomainCreateInput!) { + customDomainCreate(input: $input) { + id + domain + } + }`, + { + input: { + projectId, + environmentId, + serviceId, + domain: props.domain, + targetPort: props.targetPort, + }, + }, + ); + + domain = data.customDomainCreate.domain; + newDomainId = data.customDomainCreate.id; + } else { + // Railway-generated domain + const data = await api.query<{ + serviceDomainCreate: { + id: string; + domain: string; + }; + }>( + `mutation serviceDomainCreate($input: ServiceDomainCreateInput!) { + serviceDomainCreate(input: $input) { + id + domain + } + }`, + { + input: { + serviceId, + environmentId, + targetPort: props.targetPort, + }, + }, + ); + + domain = data.serviceDomainCreate.domain; + newDomainId = data.serviceDomainCreate.id; + } + + return { + domainId: newDomainId, + serviceId, + environmentId, + domain, + targetPort: props.targetPort, + }; + }, +); diff --git a/alchemy/src/railway/environment.ts b/alchemy/src/railway/environment.ts new file mode 100644 index 000000000..ebdd5fa7a --- /dev/null +++ b/alchemy/src/railway/environment.ts @@ -0,0 +1,226 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import type { Project } from "./project.ts"; + +/** + * Properties for creating or updating a Railway Environment + */ +export interface EnvironmentProps extends RailwayApiOptions { + /** + * The project this environment belongs to + */ + project: string | Project; + + /** + * Name of the environment + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Whether to adopt an existing environment if one with the same name is found + * @default false + */ + adopt?: boolean; +} + +/** + * Output type for a Railway Environment + */ +export type Environment = Omit & { + /** + * The Railway environment ID + */ + environmentId: string; + + /** + * The project ID this environment belongs to + */ + projectId: string; + + /** + * Name of the environment + */ + name: string; + + /** + * Time the environment was created + */ + createdAt: string; + + /** + * Time the environment was last updated + */ + updatedAt: string; +}; + +/** + * Type guard for Railway Environment + */ +export function isEnvironment(resource: any): resource is Environment { + return resource?.[ResourceKind] === "railway::Environment"; +} + +/** + * Creates and manages a Railway environment within a project. + * + * @example + * ## Create a staging environment + * + * Create a new environment in a Railway project. + * + * ```ts + * const project = await Project("my-project", { name: "My App" }); + * const staging = await Environment("staging", { + * project, + * name: "staging", + * }); + * ``` + * + * @example + * ## Create an environment with project ID + * + * Reference a project by its ID string. + * + * ```ts + * const env = await Environment("dev", { + * project: "project-id-123", + * name: "development", + * }); + * ``` + */ +export const Environment = Resource( + "railway::Environment", + async function ( + this: Context, + id: string, + props: EnvironmentProps, + ): Promise { + const api = new RailwayApi(props); + const projectId = + typeof props.project === "string" + ? props.project + : props.project.projectId; + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (this.output?.environmentId) { + try { + await api.query( + `mutation environmentDelete($id: String!) { + environmentDelete(id: $id) + }`, + { id: this.output.environmentId }, + ); + } catch (error: any) { + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + // Name is immutable — replace if changed + if (this.phase === "update" && this.output?.name !== name) { + return this.replace(); + } + + const environmentId = this.output?.environmentId; + + if (environmentId && this.output) { + // Update — name is immutable, so just return current state + return this.output; + } + + // Create + const adopt = props.adopt ?? this.scope.adopt; + if (adopt) { + const existing = await findEnvironmentByName(api, projectId, name); + if (existing) { + return existing; + } + } + + const data = await api.query<{ + environmentCreate: { + id: string; + name: string; + createdAt: string; + updatedAt: string; + }; + }>( + `mutation environmentCreate($input: EnvironmentCreateInput!) { + environmentCreate(input: $input) { + id + name + createdAt + updatedAt + } + }`, + { + input: { + name, + projectId, + }, + }, + ); + + const env = data.environmentCreate; + + return { + environmentId: env.id, + projectId, + name: env.name, + createdAt: env.createdAt, + updatedAt: env.updatedAt, + }; + }, +); + +async function findEnvironmentByName( + api: RailwayApi, + projectId: string, + name: string, +): Promise { + const data = await api.query<{ + environments: { + edges: Array<{ + node: { + id: string; + name: string; + createdAt: string; + updatedAt: string; + }; + }>; + }; + }>( + `query environments($projectId: String!) { + environments(projectId: $projectId) { + edges { + node { + id + name + createdAt + updatedAt + } + } + } + }`, + { projectId }, + ); + + const match = data.environments.edges.find((e) => e.node.name === name); + if (!match) return undefined; + + return { + environmentId: match.node.id, + projectId, + name: match.node.name, + createdAt: match.node.createdAt, + updatedAt: match.node.updatedAt, + }; +} diff --git a/alchemy/src/railway/index.ts b/alchemy/src/railway/index.ts new file mode 100644 index 000000000..825bd6a80 --- /dev/null +++ b/alchemy/src/railway/index.ts @@ -0,0 +1,8 @@ +export * from "./api.ts"; +export * from "./domain.ts"; +export * from "./environment.ts"; +export * from "./project.ts"; +export * from "./service.ts"; +export * from "./tcp-proxy.ts"; +export * from "./variable.ts"; +export * from "./volume.ts"; diff --git a/alchemy/src/railway/project.ts b/alchemy/src/railway/project.ts new file mode 100644 index 000000000..046409c3e --- /dev/null +++ b/alchemy/src/railway/project.ts @@ -0,0 +1,343 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; + +/** + * Properties for creating or updating a Railway Project + */ +export interface ProjectProps extends RailwayApiOptions { + /** + * Name of the project + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Description of the project + */ + description?: string; + + /** + * The workspace ID to create the project in. + * Falls back to `RAILWAY_WORKSPACE_ID` env var, then auto-detects the first workspace. + */ + workspaceId?: string; + + /** + * Whether to adopt an existing project if one with the same name is found + * @default false + */ + adopt?: boolean; + + /** + * Whether to delete the project when removed from Alchemy + * @default true + */ + delete?: boolean; +} + +/** + * Output type for a Railway Project + */ +export type Project = Omit & { + /** + * The Railway project ID + */ + projectId: string; + + /** + * Name of the project + */ + name: string; + + /** + * The default environment ID (production) + */ + defaultEnvironmentId: string; + + /** + * Time the project was created + */ + createdAt: string; + + /** + * Time the project was last updated + */ + updatedAt: string; +}; + +/** + * Type guard for Railway Project + */ +export function isProject(resource: any): resource is Project { + return resource?.[ResourceKind] === "railway::Project"; +} + +/** + * Creates and manages a Railway project. + * + * @example + * ## Create a basic project + * + * Create a new Railway project with default settings. + * + * ```ts + * const project = await Project("my-project", { + * name: "My App", + * }); + * ``` + * + * @example + * ## Create a project with description + * + * Create a Railway project with a description. + * + * ```ts + * const project = await Project("my-project", { + * name: "My App", + * description: "Production application", + * }); + * ``` + * + * @example + * ## Adopt an existing project + * + * Adopt a project that already exists in Railway. + * + * ```ts + * const project = await Project("my-project", { + * name: "existing-project-name", + * adopt: true, + * }); + * ``` + */ +export const Project = Resource( + "railway::Project", + async function ( + this: Context, + id: string, + props: ProjectProps, + ): Promise { + const api = new RailwayApi(props); + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + const adopt = props.adopt ?? this.scope.adopt; + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.projectId) { + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: this.output.projectId }, + ); + } catch (error: any) { + // Ignore not found errors + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + const projectId = this.output?.projectId; + + if (projectId && this.output) { + // Update + const data = await api.query<{ + projectUpdate: { + id: string; + name: string; + description: string; + updatedAt: string; + }; + }>( + `mutation projectUpdate($id: String!, $input: ProjectUpdateInput!) { + projectUpdate(id: $id, input: $input) { + id + name + description + updatedAt + } + }`, + { + id: projectId, + input: { + name, + description: props.description ?? null, + }, + }, + ); + + return { + projectId: this.output.projectId, + defaultEnvironmentId: this.output.defaultEnvironmentId, + createdAt: this.output.createdAt, + name: data.projectUpdate.name, + description: data.projectUpdate.description, + updatedAt: data.projectUpdate.updatedAt, + }; + } + + // Create + if (adopt) { + const existing = await findProjectByName(api, name); + if (existing) { + return existing; + } + } + + const workspaceId = await resolveWorkspaceId(api, props.workspaceId); + + const data = await api.query<{ + projectCreate: { + id: string; + name: string; + description: string; + createdAt: string; + updatedAt: string; + environments: { + edges: Array<{ node: { id: string; name: string } }>; + }; + }; + }>( + `mutation projectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + id + name + description + createdAt + updatedAt + environments { + edges { + node { + id + name + } + } + } + } + }`, + { + input: { + name, + description: props.description, + workspaceId, + }, + }, + ); + + const project = data.projectCreate; + const defaultEnv = project.environments.edges.find( + (e) => e.node.name === "production", + ); + + return { + projectId: project.id, + name: project.name, + description: project.description, + defaultEnvironmentId: + defaultEnv?.node.id ?? project.environments.edges[0]?.node.id ?? "", + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; + }, +); + +async function findProjectByName( + api: RailwayApi, + name: string, +): Promise { + const data = await api.query<{ + projects: { + edges: Array<{ + node: { + id: string; + name: string; + description: string; + createdAt: string; + updatedAt: string; + environments: { + edges: Array<{ node: { id: string; name: string } }>; + }; + }; + }>; + }; + }>( + `query { + projects { + edges { + node { + id + name + description + createdAt + updatedAt + environments { + edges { + node { + id + name + } + } + } + } + } + } + }`, + ); + + const match = data.projects.edges.find((e) => e.node.name === name); + if (!match) return undefined; + + const project = match.node; + const defaultEnv = project.environments.edges.find( + (e) => e.node.name === "production", + ); + + return { + projectId: project.id, + name: project.name, + description: project.description, + defaultEnvironmentId: + defaultEnv?.node.id ?? project.environments.edges[0]?.node.id ?? "", + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; +} + +async function resolveWorkspaceId( + api: RailwayApi, + workspaceId?: string, +): Promise { + if (workspaceId) return workspaceId; + + const envWorkspaceId = process.env.RAILWAY_WORKSPACE_ID; + if (envWorkspaceId) return envWorkspaceId; + + const data = await api.query<{ + me: { + workspaces: Array<{ id: string; name: string }>; + }; + }>( + `query { + me { + workspaces { + id + name + } + } + }`, + ); + + if (!data.me.workspaces.length) { + throw new Error( + "No Railway workspaces found. Create a workspace or provide a workspaceId.", + ); + } + + return data.me.workspaces[0].id; +} diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts new file mode 100644 index 000000000..3e89a8afb --- /dev/null +++ b/alchemy/src/railway/service.ts @@ -0,0 +1,437 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import type { Environment } from "./environment.ts"; +import type { Project } from "./project.ts"; + +/** + * Source configuration for a Railway service + */ +export interface ServiceSource { + /** + * GitHub repository URL (e.g. "owner/repo") + */ + repo?: string; + + /** + * Docker image to deploy + */ + image?: string; +} + +/** + * Properties for creating or updating a Railway Service + */ +export interface ServiceProps extends RailwayApiOptions { + /** + * The project this service belongs to + */ + project: string | Project; + + /** + * Name of the service + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Source configuration (repo or image) + */ + source?: ServiceSource; + + /** + * Build command for the service + */ + buildCommand?: string; + + /** + * Start command for the service + */ + startCommand?: string; + + /** + * Healthcheck path + */ + healthcheckPath?: string; + + /** + * Number of replicas + */ + numReplicas?: number; + + /** + * Cron schedule expression + */ + cronSchedule?: string; + + /** + * Region for the service instance + */ + region?: string; + + /** + * The environment to configure the service instance in. + * If not provided, uses the project's default production environment. + */ + environment?: string | Environment; + + /** + * Whether to adopt an existing service if one with the same name is found + * @default false + */ + adopt?: boolean; +} + +/** + * Output type for a Railway Service + */ +export type Service = Omit< + ServiceProps, + "adopt" | "project" | "environment" | "source" +> & { + /** + * The Railway service ID + */ + serviceId: string; + + /** + * The project ID this service belongs to + */ + projectId: string; + + /** + * The environment ID used for instance config + */ + environmentId: string; + + /** + * Name of the service + */ + name: string; + + /** + * Time the service was created + */ + createdAt: string; + + /** + * Time the service was last updated + */ + updatedAt: string; +}; + +/** + * Type guard for Railway Service + */ +export function isService(resource: any): resource is Service { + return resource?.[ResourceKind] === "railway::Service"; +} + +/** + * Creates and manages a Railway service within a project. + * + * @example + * ## Create a service from a Docker image + * + * Deploy a service using a Docker image. + * + * ```ts + * const project = await Project("my-project", { name: "My App" }); + * const service = await Service("api", { + * project, + * name: "api-service", + * source: { image: "nginx:latest" }, + * }); + * ``` + * + * @example + * ## Create a service with build and start commands + * + * Configure a service with custom commands. + * + * ```ts + * const service = await Service("web", { + * project: "project-id", + * name: "web-app", + * source: { repo: "myorg/myrepo" }, + * buildCommand: "npm run build", + * startCommand: "npm start", + * healthcheckPath: "/health", + * numReplicas: 2, + * }); + * ``` + */ +export const Service = Resource( + "railway::Service", + async function ( + this: Context, + id: string, + props: ServiceProps, + ): Promise { + const api = new RailwayApi(props); + const projectId = + typeof props.project === "string" + ? props.project + : props.project.projectId; + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + // Resolve environment ID + let environmentId: string; + if (props.environment) { + environmentId = + typeof props.environment === "string" + ? props.environment + : props.environment.environmentId; + } else if (this.output?.environmentId) { + environmentId = this.output.environmentId; + } else { + // Get the default production environment + environmentId = await getDefaultEnvironmentId(api, projectId); + } + + if (this.phase === "delete") { + if (this.output?.serviceId) { + try { + await api.query( + `mutation serviceDelete($id: String!) { + serviceDelete(id: $id) + }`, + { id: this.output.serviceId }, + ); + } catch (error: any) { + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + const serviceId = this.output?.serviceId; + + if (serviceId && this.output) { + // Update service name + await api.query( + `mutation serviceUpdate($id: String!, $input: ServiceUpdateInput!) { + serviceUpdate(id: $id, input: $input) { + id + } + }`, + { + id: serviceId, + input: { name }, + }, + ); + + // Update service instance config + await updateServiceInstance(api, serviceId, environmentId, props); + + return { + serviceId: this.output.serviceId, + projectId: this.output.projectId, + environmentId: this.output.environmentId, + createdAt: this.output.createdAt, + updatedAt: this.output.updatedAt, + name, + buildCommand: props.buildCommand, + startCommand: props.startCommand, + healthcheckPath: props.healthcheckPath, + numReplicas: props.numReplicas, + cronSchedule: props.cronSchedule, + region: props.region, + }; + } + + // Create + const adopt = props.adopt ?? this.scope.adopt; + if (adopt) { + const existing = await findServiceByName(api, projectId, name); + if (existing) { + // Update instance config on adopt + await updateServiceInstance( + api, + existing.serviceId, + environmentId, + props, + ); + return { + ...existing, + environmentId, + buildCommand: props.buildCommand, + startCommand: props.startCommand, + healthcheckPath: props.healthcheckPath, + numReplicas: props.numReplicas, + cronSchedule: props.cronSchedule, + region: props.region, + }; + } + } + + const input: Record = { + name, + projectId, + }; + + if (props.source?.repo) { + input.source = { repo: props.source.repo }; + } else if (props.source?.image) { + input.source = { image: props.source.image }; + } + + const data = await api.query<{ + serviceCreate: { + id: string; + name: string; + createdAt: string; + updatedAt: string; + }; + }>( + `mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + createdAt + updatedAt + } + }`, + { input }, + ); + + const service = data.serviceCreate; + + // Configure service instance + await updateServiceInstance(api, service.id, environmentId, props); + + return { + serviceId: service.id, + projectId, + environmentId, + name: service.name, + buildCommand: props.buildCommand, + startCommand: props.startCommand, + healthcheckPath: props.healthcheckPath, + numReplicas: props.numReplicas, + cronSchedule: props.cronSchedule, + region: props.region, + createdAt: service.createdAt, + updatedAt: service.updatedAt, + }; + }, +); + +async function updateServiceInstance( + api: RailwayApi, + 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.healthcheckPath !== undefined) + input.healthcheckPath = props.healthcheckPath; + if (props.numReplicas !== undefined) input.numReplicas = props.numReplicas; + if (props.cronSchedule !== undefined) input.cronSchedule = props.cronSchedule; + if (props.region !== undefined) input.region = props.region; + + if (Object.keys(input).length === 0) return; + + await api.query( + `mutation serviceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + }`, + { serviceId, environmentId, input }, + ); +} + +async function getDefaultEnvironmentId( + api: RailwayApi, + projectId: string, +): Promise { + const data = await api.query<{ + environments: { + edges: Array<{ node: { id: string; name: string } }>; + }; + }>( + `query environments($projectId: String!) { + environments(projectId: $projectId) { + edges { + node { + id + name + } + } + } + }`, + { projectId }, + ); + + const production = data.environments.edges.find( + (e) => e.node.name === "production", + ); + if (production) return production.node.id; + + if (data.environments.edges.length > 0) { + return data.environments.edges[0].node.id; + } + + throw new Error( + `No environments found for project ${projectId}. Create an environment first.`, + ); +} + +async function findServiceByName( + api: RailwayApi, + projectId: string, + name: string, +): Promise< + | Pick< + Service, + "serviceId" | "projectId" | "name" | "createdAt" | "updatedAt" + > + | undefined +> { + const data = await api.query<{ + project: { + services: { + edges: Array<{ + node: { + id: string; + name: string; + createdAt: string; + updatedAt: string; + }; + }>; + }; + }; + }>( + `query project($id: String!) { + project(id: $id) { + services { + edges { + node { + id + name + createdAt + updatedAt + } + } + } + } + }`, + { id: projectId }, + ); + + const match = data.project.services.edges.find((e) => e.node.name === name); + if (!match) return undefined; + + return { + serviceId: match.node.id, + projectId, + name: match.node.name, + createdAt: match.node.createdAt, + updatedAt: match.node.updatedAt, + }; +} diff --git a/alchemy/src/railway/tcp-proxy.ts b/alchemy/src/railway/tcp-proxy.ts new file mode 100644 index 000000000..9f1fe4767 --- /dev/null +++ b/alchemy/src/railway/tcp-proxy.ts @@ -0,0 +1,186 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import type { Environment } from "./environment.ts"; +import type { Service } from "./service.ts"; + +/** + * Properties for creating or updating a Railway TCP Proxy + */ +export interface TCPProxyProps extends RailwayApiOptions { + /** + * The service to attach the TCP proxy to + */ + service: string | Service; + + /** + * The environment for the TCP proxy + */ + environment: string | Environment; + + /** + * The application port to proxy traffic to + */ + applicationPort: number; +} + +/** + * Output type for a Railway TCP Proxy + */ +export type TCPProxy = Omit & { + /** + * The Railway TCP proxy ID + */ + proxyId: string; + + /** + * The service ID + */ + serviceId: string; + + /** + * The environment ID + */ + environmentId: string; + + /** + * The application port being proxied + */ + applicationPort: number; + + /** + * The external domain for the proxy + */ + domain: string; + + /** + * The external port assigned to the proxy + */ + proxyPort: number; +}; + +/** + * Type guard for Railway TCPProxy + */ +export function isTCPProxy(resource: any): resource is TCPProxy { + return resource?.[ResourceKind] === "railway::TCPProxy"; +} + +/** + * Creates and manages a Railway TCP proxy for exposing non-HTTP services. + * + * @example + * ## Create a TCP proxy for a database + * + * Expose a database service via TCP. + * + * ```ts + * const proxy = await TCPProxy("db-proxy", { + * service, + * environment: project.defaultEnvironmentId, + * applicationPort: 5432, + * }); + * console.log(`${proxy.domain}:${proxy.proxyPort}`); + * ``` + * + * @example + * ## Create a TCP proxy for Redis + * + * Expose a Redis service externally. + * + * ```ts + * const proxy = await TCPProxy("redis-proxy", { + * service: "service-id", + * environment: "env-id", + * applicationPort: 6379, + * }); + * ``` + */ +export const TCPProxy = Resource( + "railway::TCPProxy", + async function ( + this: Context, + id: string, + props: TCPProxyProps, + ): Promise { + const api = new RailwayApi(props); + const serviceId = + typeof props.service === "string" + ? props.service + : props.service.serviceId; + const environmentId = + typeof props.environment === "string" + ? props.environment + : props.environment.environmentId; + + if (this.phase === "delete") { + if (this.output?.proxyId) { + try { + await api.query( + `mutation tcpProxyDelete($id: String!) { + tcpProxyDelete(id: $id) + }`, + { id: this.output.proxyId }, + ); + } catch (error: any) { + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + // applicationPort is immutable — replace if changed + if ( + this.phase === "update" && + this.output?.applicationPort !== props.applicationPort + ) { + return this.replace(); + } + + const proxyId = this.output?.proxyId; + + if (proxyId && this.output) { + // No update needed — applicationPort is immutable + return this.output; + } + + // Create + const data = await api.query<{ + tcpProxyCreate: { + id: string; + domain: string; + proxyPort: number; + applicationPort: number; + }; + }>( + `mutation tcpProxyCreate($input: TCPProxyCreateInput!) { + tcpProxyCreate(input: $input) { + id + domain + proxyPort + applicationPort + } + }`, + { + input: { + serviceId, + environmentId, + applicationPort: props.applicationPort, + }, + }, + ); + + const proxy = data.tcpProxyCreate; + + return { + proxyId: proxy.id, + serviceId, + environmentId, + applicationPort: proxy.applicationPort, + domain: proxy.domain, + proxyPort: proxy.proxyPort, + }; + }, +); diff --git a/alchemy/src/railway/variable.ts b/alchemy/src/railway/variable.ts new file mode 100644 index 000000000..43cffad36 --- /dev/null +++ b/alchemy/src/railway/variable.ts @@ -0,0 +1,204 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { Secret } from "../secret.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import type { Environment } from "./environment.ts"; +import type { Project } from "./project.ts"; +import type { Service } from "./service.ts"; + +/** + * Properties for creating or updating Railway Variables + */ +export interface VariableProps extends RailwayApiOptions { + /** + * The project the variables belong to + */ + project: string | Project; + + /** + * The environment the variables belong to + */ + environment: string | Environment; + + /** + * The service the variables are scoped to (optional, for shared variables omit this) + */ + service?: string | Service; + + /** + * The variables to set as key-value pairs. + * Values can be strings or Secrets for sensitive data. + */ + variables: Record; +} + +/** + * Output type for Railway Variables + */ +export type Variable = Omit< + VariableProps, + "project" | "environment" | "service" | "variables" +> & { + /** + * The project ID + */ + projectId: string; + + /** + * The environment ID + */ + environmentId: string; + + /** + * The service ID (if scoped to a service) + */ + serviceId?: string; + + /** + * The variable keys that were set + */ + keys: string[]; +}; + +/** + * Type guard for Railway Variable + */ +export function isVariable(resource: any): resource is Variable { + return resource?.[ResourceKind] === "railway::Variable"; +} + +/** + * Creates and manages Railway environment variables. + * + * @example + * ## Set variables on a service + * + * Set environment variables for a specific service. + * + * ```ts + * const vars = await Variable("api-vars", { + * project, + * environment: project.defaultEnvironmentId, + * service, + * variables: { + * NODE_ENV: "production", + * PORT: "3000", + * DATABASE_URL: alchemy.secret.env.DATABASE_URL, + * }, + * }); + * ``` + * + * @example + * ## Set shared project variables + * + * Set variables shared across all services in an environment. + * + * ```ts + * const vars = await Variable("shared-vars", { + * project: "project-id", + * environment: "environment-id", + * variables: { + * APP_NAME: "My App", + * }, + * }); + * ``` + */ +export const Variable = Resource( + "railway::Variable", + async function ( + this: Context, + id: string, + props: VariableProps, + ): Promise { + const api = new RailwayApi(props); + const projectId = + typeof props.project === "string" + ? props.project + : props.project.projectId; + const environmentId = + typeof props.environment === "string" + ? props.environment + : props.environment.environmentId; + const serviceId = props.service + ? typeof props.service === "string" + ? props.service + : props.service.serviceId + : undefined; + + if (this.phase === "delete") { + // Delete all tracked keys + const keys = this.output?.keys ?? []; + for (const key of keys) { + try { + await api.query( + `mutation variableDelete($input: VariableDeleteInput!) { + variableDelete(input: $input) + }`, + { + input: { + projectId, + environmentId, + serviceId, + name: key, + }, + }, + ); + } catch (error: any) { + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + // Build the variables object with unwrapped secrets + const variables: Record = {}; + for (const [key, value] of Object.entries(props.variables)) { + variables[key] = Secret.unwrap(value) as string; + } + + // Detect removed keys on update + if (this.output?.keys) { + const newKeys = new Set(Object.keys(props.variables)); + const removedKeys = this.output.keys.filter((k) => !newKeys.has(k)); + for (const key of removedKeys) { + await api.query( + `mutation variableDelete($input: VariableDeleteInput!) { + variableDelete(input: $input) + }`, + { + input: { + projectId, + environmentId, + serviceId, + name: key, + }, + }, + ); + } + } + + // Upsert all variables + await api.query( + `mutation variableCollectionUpsert($input: VariableCollectionUpsertInput!) { + variableCollectionUpsert(input: $input) + }`, + { + input: { + projectId, + environmentId, + serviceId, + variables, + }, + }, + ); + + return { + projectId, + environmentId, + serviceId, + keys: Object.keys(props.variables), + }; + }, +); diff --git a/alchemy/src/railway/volume.ts b/alchemy/src/railway/volume.ts new file mode 100644 index 000000000..242b95fbd --- /dev/null +++ b/alchemy/src/railway/volume.ts @@ -0,0 +1,209 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import type { Environment } from "./environment.ts"; +import type { Project } from "./project.ts"; +import type { Service } from "./service.ts"; + +/** + * Properties for creating or updating a Railway Volume + */ +export interface VolumeProps extends RailwayApiOptions { + /** + * The project this volume belongs to + */ + project: string | Project; + + /** + * The service to attach the volume to + */ + service: string | Service; + + /** + * The environment for the volume + */ + environment: string | Environment; + + /** + * Mount path inside the container + */ + mountPath: string; + + /** + * Name of the volume + * + * @default ${app}-${stage}-${id} + */ + name?: string; + + /** + * Whether to delete the volume when removed from Alchemy + * @default true + */ + delete?: boolean; +} + +/** + * Output type for a Railway Volume + */ +export type Volume = Omit< + VolumeProps, + "delete" | "project" | "service" | "environment" +> & { + /** + * The Railway volume ID + */ + volumeId: string; + + /** + * The project ID + */ + projectId: string; + + /** + * The service ID + */ + serviceId: string; + + /** + * The environment ID + */ + environmentId: string; + + /** + * Name of the volume + */ + name: string; + + /** + * Mount path of the volume + */ + mountPath: string; +}; + +/** + * Type guard for Railway Volume + */ +export function isVolume(resource: any): resource is Volume { + return resource?.[ResourceKind] === "railway::Volume"; +} + +/** + * Creates and manages a Railway persistent volume attached to a service. + * + * @example + * ## Create a volume for data persistence + * + * Attach a persistent volume to a service. + * + * ```ts + * const volume = await Volume("data", { + * project, + * service, + * environment: project.defaultEnvironmentId, + * mountPath: "/data", + * name: "app-data", + * }); + * ``` + * + * @example + * ## Create a volume with delete protection + * + * Keep the volume even when removed from Alchemy. + * + * ```ts + * const volume = await Volume("db-data", { + * project: "project-id", + * service: "service-id", + * environment: "env-id", + * mountPath: "/var/lib/postgresql/data", + * delete: false, + * }); + * ``` + */ +export const Volume = Resource( + "railway::Volume", + async function ( + this: Context, + id: string, + props: VolumeProps, + ): Promise { + const api = new RailwayApi(props); + const projectId = + typeof props.project === "string" + ? props.project + : props.project.projectId; + const serviceId = + typeof props.service === "string" + ? props.service + : props.service.serviceId; + const environmentId = + typeof props.environment === "string" + ? props.environment + : props.environment.environmentId; + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.volumeId) { + try { + await api.query( + `mutation volumeDelete($volumeId: String!) { + volumeDelete(volumeId: $volumeId) + }`, + { volumeId: this.output.volumeId }, + ); + } catch (error: any) { + if (!error.message?.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + // mountPath is immutable — replace if changed + if (this.phase === "update" && this.output?.mountPath !== props.mountPath) { + return this.replace(); + } + + const volumeId = this.output?.volumeId; + + if (volumeId && this.output) { + // No update needed — mountPath and name are immutable + return this.output; + } + + // Create + const data = await api.query<{ + volumeCreate: { + id: string; + name: string; + }; + }>( + `mutation volumeCreate($input: VolumeCreateInput!) { + volumeCreate(input: $input) { + id + name + } + }`, + { + input: { + projectId, + serviceId, + environmentId, + mountPath: props.mountPath, + }, + }, + ); + + return { + volumeId: data.volumeCreate.id, + projectId, + serviceId, + environmentId, + name: data.volumeCreate.name || name, + mountPath: props.mountPath, + }; + }, +); diff --git a/alchemy/test/railway/domain.test.ts b/alchemy/test/railway/domain.test.ts new file mode 100644 index 000000000..3a1508ab3 --- /dev/null +++ b/alchemy/test/railway/domain.test.ts @@ -0,0 +1,61 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Domain } from "../../src/railway/domain.ts"; +import { Project } from "../../src/railway/project.ts"; +import { Service } from "../../src/railway/service.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Domain", () => { + const testId = `${BRANCH_PREFIX}-railway-domain`; + + test("create and delete railway domain", async (scope) => { + let project: Project | undefined; + try { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + + const service = await Service(`${testId}-svc`, { + project, + name: `${testId}-svc`, + source: { image: "nginx:latest" }, + }); + + const domain = await Domain(testId, { + service, + environment: project.defaultEnvironmentId, + }); + + expect(domain.domainId).toBeTruthy(); + expect(domain.domain).toBeTruthy(); + expect(domain.domain).toContain(".up.railway.app"); + expect(domain.serviceId).toEqual(service.serviceId); + expect(domain.environmentId).toEqual(project.defaultEnvironmentId); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + } + }); +}); diff --git a/alchemy/test/railway/environment.test.ts b/alchemy/test/railway/environment.test.ts new file mode 100644 index 000000000..c4d1f8917 --- /dev/null +++ b/alchemy/test/railway/environment.test.ts @@ -0,0 +1,53 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Environment } from "../../src/railway/environment.ts"; +import { Project } from "../../src/railway/project.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Environment", () => { + const testId = `${BRANCH_PREFIX}-railway-env`; + + test("create and delete environment", async (scope) => { + let project: Project | undefined; + try { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + + const env = await Environment(testId, { + project, + name: `${testId}-staging`, + }); + + expect(env.environmentId).toBeTruthy(); + expect(env.name).toEqual(`${testId}-staging`); + expect(env.projectId).toEqual(project.projectId); + expect(env.createdAt).toBeTruthy(); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + } + }); +}); diff --git a/alchemy/test/railway/project.test.ts b/alchemy/test/railway/project.test.ts new file mode 100644 index 000000000..172bcb38a --- /dev/null +++ b/alchemy/test/railway/project.test.ts @@ -0,0 +1,94 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Project } from "../../src/railway/project.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Project", () => { + const testId = `${BRANCH_PREFIX}-railway-project`; + + test("create, update, and delete project", async (scope) => { + let project: Project | undefined; + try { + project = await Project(testId, { + name: `${testId}-test`, + description: "Test project from Alchemy", + }); + + expect(project.projectId).toBeTruthy(); + expect(project.name).toEqual(`${testId}-test`); + expect(project.description).toEqual("Test project from Alchemy"); + expect(project.defaultEnvironmentId).toBeTruthy(); + expect(project.createdAt).toBeTruthy(); + expect(project.updatedAt).toBeTruthy(); + + // Update + project = await Project(testId, { + name: `${testId}-test-updated`, + description: "Updated test project", + }); + + expect(project.name).toEqual(`${testId}-test-updated`); + expect(project.description).toEqual("Updated test project"); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `query project($id: String!) { + project(id: $id) { id } + }`, + { id: project.projectId }, + ); + expect.fail("Project should have been deleted"); + } catch { + // Expected: project not found + } + } + } + }); + + test("does not delete project when delete is false", async (scope) => { + let project: Project | undefined; + const api = new RailwayApi(); + try { + project = await Project(`${testId}-no-delete`, { + name: `${testId}-no-delete`, + delete: false, + }); + + expect(project.projectId).toBeTruthy(); + } finally { + await destroy(scope); + + if (project?.projectId) { + // Verify project still exists + const data = await api.query<{ project: { id: string } }>( + `query project($id: String!) { + project(id: $id) { id } + }`, + { id: project.projectId }, + ); + expect(data.project.id).toEqual(project.projectId); + + // Clean up manually + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } + } + }); +}); diff --git a/alchemy/test/railway/service.test.ts b/alchemy/test/railway/service.test.ts new file mode 100644 index 000000000..56bcec3c4 --- /dev/null +++ b/alchemy/test/railway/service.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Project } from "../../src/railway/project.ts"; +import { Service } from "../../src/railway/service.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Service", () => { + const testId = `${BRANCH_PREFIX}-railway-service`; + + test("create, update, and delete service", async (scope) => { + let project: Project | undefined; + try { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + + let service = await Service(testId, { + project, + name: `${testId}-svc`, + startCommand: "node index.js", + }); + + expect(service.serviceId).toBeTruthy(); + expect(service.name).toEqual(`${testId}-svc`); + expect(service.projectId).toEqual(project.projectId); + expect(service.environmentId).toBeTruthy(); + expect(service.startCommand).toEqual("node index.js"); + + // Update + service = await Service(testId, { + project, + name: `${testId}-svc-updated`, + startCommand: "npm start", + buildCommand: "npm run build", + }); + + expect(service.name).toEqual(`${testId}-svc-updated`); + expect(service.startCommand).toEqual("npm start"); + expect(service.buildCommand).toEqual("npm run build"); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + } + }); +}); diff --git a/alchemy/test/railway/tcp-proxy.test.ts b/alchemy/test/railway/tcp-proxy.test.ts new file mode 100644 index 000000000..4079fedb5 --- /dev/null +++ b/alchemy/test/railway/tcp-proxy.test.ts @@ -0,0 +1,63 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Project } from "../../src/railway/project.ts"; +import { Service } from "../../src/railway/service.ts"; +import { TCPProxy } from "../../src/railway/tcp-proxy.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway TCPProxy", () => { + const testId = `${BRANCH_PREFIX}-railway-tcp`; + + test("create and delete tcp proxy", async (scope) => { + let project: Project | undefined; + try { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + + const service = await Service(`${testId}-svc`, { + project, + name: `${testId}-svc`, + source: { image: "redis:latest" }, + }); + + const proxy = await TCPProxy(testId, { + service, + environment: project.defaultEnvironmentId, + applicationPort: 6379, + }); + + expect(proxy.proxyId).toBeTruthy(); + expect(proxy.domain).toBeTruthy(); + expect(proxy.proxyPort).toBeGreaterThan(0); + expect(proxy.applicationPort).toEqual(6379); + expect(proxy.serviceId).toEqual(service.serviceId); + expect(proxy.environmentId).toEqual(project.defaultEnvironmentId); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + } + }); +}); diff --git a/alchemy/test/railway/variable.test.ts b/alchemy/test/railway/variable.test.ts new file mode 100644 index 000000000..37a5c9518 --- /dev/null +++ b/alchemy/test/railway/variable.test.ts @@ -0,0 +1,80 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Project } from "../../src/railway/project.ts"; +import { Service } from "../../src/railway/service.ts"; +import { Variable } from "../../src/railway/variable.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Variable", () => { + const testId = `${BRANCH_PREFIX}-railway-var`; + + test("create, update, and delete variables", async (scope) => { + let project: Project | undefined; + try { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + + const service = await Service(`${testId}-svc`, { + project, + name: `${testId}-svc`, + }); + + // Create + let vars = await Variable(testId, { + project, + environment: project.defaultEnvironmentId, + service, + variables: { + NODE_ENV: "production", + PORT: "3000", + }, + }); + + expect(vars.keys).toContain("NODE_ENV"); + expect(vars.keys).toContain("PORT"); + expect(vars.projectId).toEqual(project.projectId); + expect(vars.environmentId).toEqual(project.defaultEnvironmentId); + + // Update — add a var, remove PORT + vars = await Variable(testId, { + project, + environment: project.defaultEnvironmentId, + service, + variables: { + NODE_ENV: "production", + APP_NAME: "test-app", + }, + }); + + expect(vars.keys).toContain("NODE_ENV"); + expect(vars.keys).toContain("APP_NAME"); + expect(vars.keys).not.toContain("PORT"); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + } + }); +}); diff --git a/alchemy/test/railway/volume.test.ts b/alchemy/test/railway/volume.test.ts new file mode 100644 index 000000000..5a2860f7b --- /dev/null +++ b/alchemy/test/railway/volume.test.ts @@ -0,0 +1,61 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Project } from "../../src/railway/project.ts"; +import { Service } from "../../src/railway/service.ts"; +import { Volume } from "../../src/railway/volume.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Volume", () => { + const testId = `${BRANCH_PREFIX}-railway-vol`; + + test("create and delete volume", async (scope) => { + let project: Project | undefined; + try { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + + const service = await Service(`${testId}-svc`, { + project, + name: `${testId}-svc`, + }); + + const volume = await Volume(testId, { + project, + service, + environment: project.defaultEnvironmentId, + mountPath: "/data", + }); + + expect(volume.volumeId).toBeTruthy(); + expect(volume.mountPath).toEqual("/data"); + expect(volume.projectId).toEqual(project.projectId); + expect(volume.serviceId).toEqual(service.serviceId); + } finally { + await destroy(scope); + + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + } + }); +}); From 0aae25e32dfbc4da77d1dff1a1ddc10534e7144e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Feb 2026 19:37:29 +0100 Subject: [PATCH 02/16] chore: retrigger CI From 6fd973a4f6f0f6bfac3f6503f011d59954ce70e3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 12:30:52 +0100 Subject: [PATCH 03/16] test --- alchemy/src/railway/domain.ts | 2 +- alchemy/test/railway/domain-replace.test.ts | 75 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 alchemy/test/railway/domain-replace.test.ts diff --git a/alchemy/src/railway/domain.ts b/alchemy/src/railway/domain.ts index f9b370a28..aa21e4372 100644 --- a/alchemy/src/railway/domain.ts +++ b/alchemy/src/railway/domain.ts @@ -161,7 +161,7 @@ export const Domain = Resource( const domainId = this.output?.domainId; - if (domainId && this.output) { + if (this.phase === "update" && domainId && this.output) { // Update — only targetPort can be updated via serviceInstanceUpdate if ( props.targetPort !== undefined && diff --git a/alchemy/test/railway/domain-replace.test.ts b/alchemy/test/railway/domain-replace.test.ts new file mode 100644 index 000000000..faf780141 --- /dev/null +++ b/alchemy/test/railway/domain-replace.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, vi } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Domain } from "../../src/railway/domain.ts"; +import "../../src/test/vitest.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Railway Domain Replace", () => { + test("replacing a generated domain with a custom domain creates the custom domain", async (scope) => { + process.env.RAILWAY_API_TOKEN = "test-token"; + + const querySpy = vi + .spyOn(RailwayApi.prototype, "query") + .mockImplementation(async (query: string) => { + if (query.includes("serviceDomainCreate")) { + return { + serviceDomainCreate: { + id: "svc-domain-old", + domain: "api-production-1234.up.railway.app", + }, + } as never; + } + + if (query.includes("customDomainCreate")) { + return { + customDomainCreate: { + id: "custom-domain-new", + domain: "api.example.com", + }, + } as never; + } + + if (query.includes("serviceDomainDelete") || query.includes("customDomainDelete")) { + return {} as never; + } + + if (query.includes("serviceInstanceUpdate")) { + return { serviceInstanceUpdate: true } as never; + } + + throw new Error(`Unexpected Railway API query in test: ${query}`); + }); + + try { + const domainResourceId = `api-domain-${Date.now()}`; + + const generated = await Domain(domainResourceId, { + service: "service-id", + environment: "environment-id", + }); + + expect(generated.domain).toBe("api-production-1234.up.railway.app"); + + const custom = await Domain(domainResourceId, { + service: "service-id", + environment: "environment-id", + projectId: "project-id", + domain: "api.example.com", + }); + + expect(custom.domain).toBe("api.example.com"); + expect( + querySpy.mock.calls.some(([query]) => query.includes("customDomainCreate")), + ).toBe(true); + } finally { + await scope.finalize(); + vi.restoreAllMocks(); + delete process.env.RAILWAY_API_TOKEN; + } + }); +}); From 1b841943a2dce5545755aa86c103460e10d1f2b4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 12:43:24 +0100 Subject: [PATCH 04/16] fix: domain deletion --- alchemy/src/railway/delete-retry.ts | 67 +++++++++++++++++ alchemy/src/railway/domain.ts | 36 ++++----- alchemy/src/railway/environment.ts | 18 ++--- alchemy/src/railway/project.ts | 19 ++--- alchemy/src/railway/service.ts | 18 ++--- alchemy/src/railway/tcp-proxy.ts | 18 ++--- alchemy/src/railway/variable.ts | 37 +++++----- alchemy/src/railway/volume.ts | 18 ++--- alchemy/test/railway/delete-retry.test.ts | 66 +++++++++++++++++ alchemy/test/railway/domain-replace.test.ts | 82 ++++++++++++++++++++- 10 files changed, 286 insertions(+), 93 deletions(-) create mode 100644 alchemy/src/railway/delete-retry.ts create mode 100644 alchemy/test/railway/delete-retry.test.ts diff --git a/alchemy/src/railway/delete-retry.ts b/alchemy/src/railway/delete-retry.ts new file mode 100644 index 000000000..a79c72ca6 --- /dev/null +++ b/alchemy/src/railway/delete-retry.ts @@ -0,0 +1,67 @@ +import { withExponentialBackoff } from "../util/retry.ts"; +import { RailwayError } from "./api.ts"; + +const NOT_FOUND_PATTERNS = ["not found"]; +const OPERATION_IN_PROGRESS_PATTERNS = [ + "operation is already in progress", + "already in progress", +]; + +export interface RailwayDeleteRetryOptions { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; +} + +function getErrorMessages(error: unknown): string[] { + if (error instanceof RailwayError) { + return [error.message, ...error.errors.map((item) => item.message)]; + } + if (error instanceof Error) { + return [error.message]; + } + return [String(error)]; +} + +function hasAnyPattern(error: unknown, patterns: readonly string[]): boolean { + const messages = getErrorMessages(error).map((message) => + message.toLowerCase(), + ); + return messages.some((message) => + patterns.some((pattern) => message.includes(pattern)), + ); +} + +export function isRailwayNotFoundError(error: unknown): boolean { + return hasAnyPattern(error, NOT_FOUND_PATTERNS); +} + +export function isRailwayOperationInProgressError(error: unknown): boolean { + return hasAnyPattern(error, OPERATION_IN_PROGRESS_PATTERNS); +} + +export async function runRailwayDeleteMutation( + operation: () => Promise, + options: RailwayDeleteRetryOptions = {}, +): Promise { + const { + maxAttempts = 8, + initialDelayMs = 500, + maxDelayMs = 5000, + } = options; + + try { + await withExponentialBackoff( + operation, + isRailwayOperationInProgressError, + maxAttempts, + initialDelayMs, + maxDelayMs, + ); + } catch (error) { + if (isRailwayNotFoundError(error)) { + return; + } + throw error; + } +} diff --git a/alchemy/src/railway/domain.ts b/alchemy/src/railway/domain.ts index aa21e4372..0a0d1be99 100644 --- a/alchemy/src/railway/domain.ts +++ b/alchemy/src/railway/domain.ts @@ -1,6 +1,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Environment } from "./environment.ts"; import type { Service } from "./service.ts"; @@ -124,28 +125,19 @@ export const Domain = Resource( : props.environment.environmentId; if (this.phase === "delete") { - if (this.output?.domainId) { - try { - if (this.output.domain?.endsWith(".up.railway.app")) { - await api.query( - `mutation serviceDomainDelete($id: String!) { - serviceDomainDelete(id: $id) - }`, - { id: this.output.domainId }, - ); - } else { - await api.query( - `mutation customDomainDelete($id: String!) { - customDomainDelete(id: $id) - }`, - { id: this.output.domainId }, - ); - } - } catch (error: any) { - if (!error.message?.includes("not found")) { - throw error; - } - } + const domainId = this.output?.domainId; + if (domainId) { + const deleteMutation = this.output?.domain?.endsWith(".up.railway.app") + ? `mutation serviceDomainDelete($id: String!) { + serviceDomainDelete(id: $id) + }` + : `mutation customDomainDelete($id: String!) { + customDomainDelete(id: $id) + }`; + + await runRailwayDeleteMutation(() => + api.query(deleteMutation, { id: domainId }), + ); } return this.destroy(); } diff --git a/alchemy/src/railway/environment.ts b/alchemy/src/railway/environment.ts index ebdd5fa7a..09eee571c 100644 --- a/alchemy/src/railway/environment.ts +++ b/alchemy/src/railway/environment.ts @@ -1,6 +1,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Project } from "./project.ts"; /** @@ -107,19 +108,16 @@ export const Environment = Resource( props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); if (this.phase === "delete") { - if (this.output?.environmentId) { - try { - await api.query( + const environmentId = this.output?.environmentId; + if (environmentId) { + await runRailwayDeleteMutation(() => + api.query( `mutation environmentDelete($id: String!) { environmentDelete(id: $id) }`, - { id: this.output.environmentId }, - ); - } catch (error: any) { - if (!error.message?.includes("not found")) { - throw error; - } - } + { id: environmentId }, + ), + ); } return this.destroy(); } diff --git a/alchemy/src/railway/project.ts b/alchemy/src/railway/project.ts index 046409c3e..99ee56584 100644 --- a/alchemy/src/railway/project.ts +++ b/alchemy/src/railway/project.ts @@ -1,6 +1,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; /** * Properties for creating or updating a Railway Project @@ -125,20 +126,16 @@ export const Project = Resource( const adopt = props.adopt ?? this.scope.adopt; if (this.phase === "delete") { - if (props.delete !== false && this.output?.projectId) { - try { - await api.query( + const projectId = this.output?.projectId; + if (props.delete !== false && projectId) { + await runRailwayDeleteMutation(() => + api.query( `mutation projectDelete($id: String!) { projectDelete(id: $id) }`, - { id: this.output.projectId }, - ); - } catch (error: any) { - // Ignore not found errors - if (!error.message?.includes("not found")) { - throw error; - } - } + { id: projectId }, + ), + ); } return this.destroy(); } diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index 3e89a8afb..40b403fdf 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -1,6 +1,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Environment } from "./environment.ts"; import type { Project } from "./project.ts"; @@ -192,19 +193,16 @@ export const Service = Resource( } if (this.phase === "delete") { - if (this.output?.serviceId) { - try { - await api.query( + const serviceId = this.output?.serviceId; + if (serviceId) { + await runRailwayDeleteMutation(() => + api.query( `mutation serviceDelete($id: String!) { serviceDelete(id: $id) }`, - { id: this.output.serviceId }, - ); - } catch (error: any) { - if (!error.message?.includes("not found")) { - throw error; - } - } + { id: serviceId }, + ), + ); } return this.destroy(); } diff --git a/alchemy/src/railway/tcp-proxy.ts b/alchemy/src/railway/tcp-proxy.ts index 9f1fe4767..0e0497a43 100644 --- a/alchemy/src/railway/tcp-proxy.ts +++ b/alchemy/src/railway/tcp-proxy.ts @@ -1,6 +1,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Environment } from "./environment.ts"; import type { Service } from "./service.ts"; @@ -114,19 +115,16 @@ export const TCPProxy = Resource( : props.environment.environmentId; if (this.phase === "delete") { - if (this.output?.proxyId) { - try { - await api.query( + const proxyId = this.output?.proxyId; + if (proxyId) { + await runRailwayDeleteMutation(() => + api.query( `mutation tcpProxyDelete($id: String!) { tcpProxyDelete(id: $id) }`, - { id: this.output.proxyId }, - ); - } catch (error: any) { - if (!error.message?.includes("not found")) { - throw error; - } - } + { id: proxyId }, + ), + ); } return this.destroy(); } diff --git a/alchemy/src/railway/variable.ts b/alchemy/src/railway/variable.ts index 43cffad36..aa3b35098 100644 --- a/alchemy/src/railway/variable.ts +++ b/alchemy/src/railway/variable.ts @@ -2,6 +2,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Secret } from "../secret.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Environment } from "./environment.ts"; import type { Project } from "./project.ts"; import type { Service } from "./service.ts"; @@ -129,8 +130,8 @@ export const Variable = Resource( // Delete all tracked keys const keys = this.output?.keys ?? []; for (const key of keys) { - try { - await api.query( + await runRailwayDeleteMutation(() => + api.query( `mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, @@ -142,12 +143,8 @@ export const Variable = Resource( name: key, }, }, - ); - } catch (error: any) { - if (!error.message?.includes("not found")) { - throw error; - } - } + ), + ); } return this.destroy(); } @@ -163,18 +160,20 @@ export const Variable = Resource( const newKeys = new Set(Object.keys(props.variables)); const removedKeys = this.output.keys.filter((k) => !newKeys.has(k)); for (const key of removedKeys) { - await api.query( - `mutation variableDelete($input: VariableDeleteInput!) { - variableDelete(input: $input) - }`, - { - input: { - projectId, - environmentId, - serviceId, - name: key, + await runRailwayDeleteMutation(() => + api.query( + `mutation variableDelete($input: VariableDeleteInput!) { + variableDelete(input: $input) + }`, + { + input: { + projectId, + environmentId, + serviceId, + name: key, + }, }, - }, + ), ); } } diff --git a/alchemy/src/railway/volume.ts b/alchemy/src/railway/volume.ts index 242b95fbd..c92018b22 100644 --- a/alchemy/src/railway/volume.ts +++ b/alchemy/src/railway/volume.ts @@ -1,6 +1,7 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Environment } from "./environment.ts"; import type { Project } from "./project.ts"; import type { Service } from "./service.ts"; @@ -145,19 +146,16 @@ export const Volume = Resource( props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); if (this.phase === "delete") { - if (props.delete !== false && this.output?.volumeId) { - try { - await api.query( + const volumeId = this.output?.volumeId; + if (props.delete !== false && volumeId) { + await runRailwayDeleteMutation(() => + api.query( `mutation volumeDelete($volumeId: String!) { volumeDelete(volumeId: $volumeId) }`, - { volumeId: this.output.volumeId }, - ); - } catch (error: any) { - if (!error.message?.includes("not found")) { - throw error; - } - } + { volumeId }, + ), + ); } return this.destroy(); } diff --git a/alchemy/test/railway/delete-retry.test.ts b/alchemy/test/railway/delete-retry.test.ts new file mode 100644 index 000000000..4b1161720 --- /dev/null +++ b/alchemy/test/railway/delete-retry.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test, vi } from "vitest"; +import { RailwayError } from "../../src/railway/api.ts"; +import { runRailwayDeleteMutation } from "../../src/railway/delete-retry.ts"; +import "../../src/test/vitest.ts"; + +describe("Railway delete retry", () => { + test("retries when Railway reports operation already in progress", async () => { + const operation = vi.fn(async () => { + if (operation.mock.calls.length < 3) { + throw new RailwayError( + "Railway API error: Cannot delete service domain: an operation is already in progress", + [ + { + message: + "Cannot delete service domain: an operation is already in progress", + }, + ], + ); + } + }); + + await runRailwayDeleteMutation(operation, { + maxAttempts: 5, + initialDelayMs: 1, + maxDelayMs: 2, + }); + + expect(operation).toHaveBeenCalledTimes(3); + }); + + test("treats not found as already deleted", async () => { + const operation = vi.fn(async () => { + throw new RailwayError("Railway API error: not found", [ + { message: "not found" }, + ]); + }); + + await expect( + runRailwayDeleteMutation(operation, { + maxAttempts: 5, + initialDelayMs: 1, + maxDelayMs: 2, + }), + ).resolves.toBeUndefined(); + + expect(operation).toHaveBeenCalledTimes(1); + }); + + test("throws non-retryable delete failures", async () => { + const operation = vi.fn(async () => { + throw new RailwayError("Railway API error: permission denied", [ + { message: "permission denied" }, + ]); + }); + + await expect( + runRailwayDeleteMutation(operation, { + maxAttempts: 5, + initialDelayMs: 1, + maxDelayMs: 2, + }), + ).rejects.toThrow("permission denied"); + + expect(operation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/alchemy/test/railway/domain-replace.test.ts b/alchemy/test/railway/domain-replace.test.ts index faf780141..8b68f77a8 100644 --- a/alchemy/test/railway/domain-replace.test.ts +++ b/alchemy/test/railway/domain-replace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, vi } from "vitest"; import { alchemy } from "../../src/alchemy.ts"; -import { RailwayApi } from "../../src/railway/api.ts"; +import { RailwayApi, RailwayError } from "../../src/railway/api.ts"; import { Domain } from "../../src/railway/domain.ts"; import "../../src/test/vitest.ts"; import { BRANCH_PREFIX } from "../util.ts"; @@ -72,4 +72,84 @@ describe("Railway Domain Replace", () => { delete process.env.RAILWAY_API_TOKEN; } }); + + test("retries transient domain delete lock errors during replacement cleanup", async (scope) => { + process.env.RAILWAY_API_TOKEN = "test-token"; + + let deleteAttempts = 0; + + const querySpy = vi + .spyOn(RailwayApi.prototype, "query") + .mockImplementation(async (query: string) => { + if (query.includes("serviceDomainCreate")) { + return { + serviceDomainCreate: { + id: "svc-domain-old", + domain: "api-production-1234.up.railway.app", + }, + } as never; + } + + if (query.includes("customDomainCreate")) { + return { + customDomainCreate: { + id: "custom-domain-new", + domain: "api.example.com", + }, + } as never; + } + + if (query.includes("serviceDomainDelete")) { + deleteAttempts += 1; + if (deleteAttempts === 1) { + throw new RailwayError( + "Railway API error: Cannot delete service domain: an operation is already in progress", + [ + { + message: + "Cannot delete service domain: an operation is already in progress", + }, + ], + ); + } + return {} as never; + } + + if (query.includes("customDomainDelete")) { + return {} as never; + } + + if (query.includes("serviceInstanceUpdate")) { + return { serviceInstanceUpdate: true } as never; + } + + throw new Error(`Unexpected Railway API query in test: ${query}`); + }); + + try { + const domainResourceId = `api-domain-retry-${Date.now()}`; + + await Domain(domainResourceId, { + service: "service-id", + environment: "environment-id", + }); + + const custom = await Domain(domainResourceId, { + service: "service-id", + environment: "environment-id", + projectId: "project-id", + domain: "api.example.com", + }); + + expect(custom.domain).toBe("api.example.com"); + await scope.finalize(); + expect(deleteAttempts).toBe(2); + expect( + querySpy.mock.calls.some(([query]) => query.includes("serviceDomainDelete")), + ).toBe(true); + } finally { + vi.restoreAllMocks(); + delete process.env.RAILWAY_API_TOKEN; + } + }); }); From 52a47f581eedefff80424c423376e7633e252b2d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 14:28:01 +0100 Subject: [PATCH 05/16] fix --- alchemy/src/railway/api.ts | 8 +- alchemy/src/railway/delete-retry.ts | 6 +- alchemy/src/railway/domain.ts | 2 +- alchemy/src/railway/project.ts | 2 +- alchemy/src/railway/service.ts | 397 +++++++++++++++++- alchemy/src/railway/variable.ts | 64 +-- alchemy/test/railway/domain-replace.test.ts | 13 +- .../service-deployment-trigger.test.ts | 205 +++++++++ 8 files changed, 653 insertions(+), 44 deletions(-) create mode 100644 alchemy/test/railway/service-deployment-trigger.test.ts diff --git a/alchemy/src/railway/api.ts b/alchemy/src/railway/api.ts index 6e1438717..c4bd5e66b 100644 --- a/alchemy/src/railway/api.ts +++ b/alchemy/src/railway/api.ts @@ -1,4 +1,4 @@ -import type { Secret } from "../secret.ts"; +import { Secret } from "../secret.ts"; import { safeFetch } from "../util/safe-fetch.ts"; /** @@ -48,7 +48,11 @@ export class RailwayApi { */ constructor(options: RailwayApiOptions = {}) { this.token = - options.apiToken?.unencrypted ?? process.env.RAILWAY_API_TOKEN ?? ""; + (options.apiToken + ? (Secret.unwrap(options.apiToken) as string) + : undefined) ?? + process.env.RAILWAY_API_TOKEN ?? + ""; if (!this.token) { throw new Error( diff --git a/alchemy/src/railway/delete-retry.ts b/alchemy/src/railway/delete-retry.ts index a79c72ca6..c564e241f 100644 --- a/alchemy/src/railway/delete-retry.ts +++ b/alchemy/src/railway/delete-retry.ts @@ -44,11 +44,7 @@ export async function runRailwayDeleteMutation( operation: () => Promise, options: RailwayDeleteRetryOptions = {}, ): Promise { - const { - maxAttempts = 8, - initialDelayMs = 500, - maxDelayMs = 5000, - } = options; + const { maxAttempts = 8, initialDelayMs = 500, maxDelayMs = 5000 } = options; try { await withExponentialBackoff( diff --git a/alchemy/src/railway/domain.ts b/alchemy/src/railway/domain.ts index 0a0d1be99..5258b469b 100644 --- a/alchemy/src/railway/domain.ts +++ b/alchemy/src/railway/domain.ts @@ -166,7 +166,7 @@ export const Domain = Resource( { serviceId, environmentId, - input: {}, + input: { targetPort: props.targetPort }, }, ); } diff --git a/alchemy/src/railway/project.ts b/alchemy/src/railway/project.ts index 99ee56584..a00cab5c3 100644 --- a/alchemy/src/railway/project.ts +++ b/alchemy/src/railway/project.ts @@ -41,7 +41,7 @@ export interface ProjectProps extends RailwayApiOptions { /** * Output type for a Railway Project */ -export type Project = Omit & { +export type Project = Omit & { /** * The Railway project ID */ diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index 40b403fdf..aeb1f2327 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -1,6 +1,6 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; -import { RailwayApi, type RailwayApiOptions } from "./api.ts"; +import { RailwayApi, RailwayError, type RailwayApiOptions } from "./api.ts"; import { runRailwayDeleteMutation } from "./delete-retry.ts"; import type { Environment } from "./environment.ts"; import type { Project } from "./project.ts"; @@ -20,6 +20,37 @@ export interface ServiceSource { image?: string; } +export type ServiceDeploymentTriggerProvider = "github"; + +export interface ServiceDeploymentTrigger { + /** + * Branch to trigger deployments from (e.g. "main") + */ + branch: string; + + /** + * Trigger provider + * + * @default "github" + */ + provider?: ServiceDeploymentTriggerProvider; + + /** + * Repository to watch (owner/repo). Defaults to source.repo. + */ + repository?: string; + + /** + * Optional root directory for trigger-scoped builds. + */ + rootDirectory?: string; + + /** + * Whether to require check suites on trigger. + */ + checkSuites?: boolean; +} + /** * Properties for creating or updating a Railway Service */ @@ -41,6 +72,11 @@ export interface ServiceProps extends RailwayApiOptions { */ source?: ServiceSource; + /** + * Deployment trigger configuration for repository-based deployments. + */ + deploymentTrigger?: ServiceDeploymentTrigger; + /** * Build command for the service */ @@ -89,7 +125,7 @@ export interface ServiceProps extends RailwayApiOptions { */ export type Service = Omit< ServiceProps, - "adopt" | "project" | "environment" | "source" + "adopt" | "project" | "environment" | "source" | "deploymentTrigger" > & { /** * The Railway service ID @@ -120,6 +156,26 @@ export type Service = Omit< * Time the service was last updated */ updatedAt: string; + + /** + * Managed deployment trigger ID + */ + deploymentTriggerId?: string; + + /** + * Managed deployment trigger branch + */ + deploymentTriggerBranch?: string; + + /** + * Managed deployment trigger provider + */ + deploymentTriggerProvider?: ServiceDeploymentTriggerProvider; + + /** + * Managed deployment trigger repository + */ + deploymentTriggerRepository?: string; }; /** @@ -177,6 +233,7 @@ export const Service = Resource( : props.project.projectId; const name = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + const desiredDeploymentTrigger = resolveDesiredDeploymentTrigger(props); // Resolve environment ID let environmentId: string; @@ -226,6 +283,20 @@ export const Service = Resource( // Update service instance config await updateServiceInstance(api, serviceId, environmentId, props); + let managedDeploymentTrigger: ManagedDeploymentTriggerOutput | undefined; + if (desiredDeploymentTrigger) { + managedDeploymentTrigger = await reconcileDeploymentTrigger({ + api, + serviceId, + projectId, + environmentId, + desired: desiredDeploymentTrigger, + previousTriggerId: this.output.deploymentTriggerId, + }); + } else if (this.output.deploymentTriggerId) { + await deleteDeploymentTrigger(api, this.output.deploymentTriggerId); + } + return { serviceId: this.output.serviceId, projectId: this.output.projectId, @@ -239,6 +310,7 @@ export const Service = Resource( numReplicas: props.numReplicas, cronSchedule: props.cronSchedule, region: props.region, + ...toDeploymentTriggerOutput(managedDeploymentTrigger), }; } @@ -254,6 +326,17 @@ export const Service = Resource( environmentId, props, ); + + const managedDeploymentTrigger = desiredDeploymentTrigger + ? await reconcileDeploymentTrigger({ + api, + serviceId: existing.serviceId, + projectId, + environmentId, + desired: desiredDeploymentTrigger, + }) + : undefined; + return { ...existing, environmentId, @@ -263,6 +346,7 @@ export const Service = Resource( numReplicas: props.numReplicas, cronSchedule: props.cronSchedule, region: props.region, + ...toDeploymentTriggerOutput(managedDeploymentTrigger), }; } } @@ -301,6 +385,15 @@ export const Service = Resource( // Configure service instance await updateServiceInstance(api, service.id, environmentId, props); + const managedDeploymentTrigger = desiredDeploymentTrigger + ? await reconcileDeploymentTrigger({ + api, + serviceId: service.id, + projectId, + environmentId, + desired: desiredDeploymentTrigger, + }) + : undefined; return { serviceId: service.id, @@ -315,10 +408,310 @@ export const Service = Resource( region: props.region, createdAt: service.createdAt, updatedAt: service.updatedAt, + ...toDeploymentTriggerOutput(managedDeploymentTrigger), }; }, ); +interface DesiredDeploymentTrigger { + provider: ServiceDeploymentTriggerProvider; + repository: string; + branch: string; + rootDirectory?: string; + checkSuites?: boolean; +} + +interface DeploymentTrigger { + id: string; + provider: string; + repository: string; + branch: string; + serviceId?: string | null; + checkSuites: boolean; +} + +interface ManagedDeploymentTriggerOutput { + id: string; + provider: ServiceDeploymentTriggerProvider; + repository: string; + branch: string; +} + +function resolveDesiredDeploymentTrigger( + props: ServiceProps, +): DesiredDeploymentTrigger | undefined { + const trigger = props.deploymentTrigger; + if (!trigger) return undefined; + + const provider = trigger.provider ?? "github"; + if (provider !== "github") { + throw new Error( + `Unsupported deployment trigger provider "${provider}". Currently only "github" is supported.`, + ); + } + + const repository = (trigger.repository ?? props.source?.repo)?.trim(); + if (!repository) { + throw new Error( + "deploymentTrigger requires source.repo or deploymentTrigger.repository.", + ); + } + + const branch = trigger.branch?.trim(); + if (!branch) { + throw new Error("deploymentTrigger.branch is required."); + } + + return { + provider, + repository, + branch, + rootDirectory: trigger.rootDirectory, + checkSuites: trigger.checkSuites, + }; +} + +function toDeploymentTriggerOutput( + managed: ManagedDeploymentTriggerOutput | undefined, +): Pick< + Service, + | "deploymentTriggerId" + | "deploymentTriggerBranch" + | "deploymentTriggerProvider" + | "deploymentTriggerRepository" +> { + return { + deploymentTriggerId: managed?.id, + deploymentTriggerBranch: managed?.branch, + deploymentTriggerProvider: managed?.provider, + deploymentTriggerRepository: managed?.repository, + }; +} + +async function reconcileDeploymentTrigger({ + api, + serviceId, + projectId, + environmentId, + desired, + previousTriggerId, +}: { + api: RailwayApi; + serviceId: string; + projectId: string; + environmentId: string; + desired: DesiredDeploymentTrigger; + previousTriggerId?: string; +}): Promise { + const triggers = await listServiceRepoTriggers(api, serviceId); + const previousTrigger = previousTriggerId + ? triggers.find((trigger) => trigger.id === previousTriggerId) + : undefined; + const matchingTrigger = triggers.find( + (trigger) => + trigger.provider === desired.provider && + trigger.repository === desired.repository && + (!trigger.serviceId || trigger.serviceId === serviceId), + ); + const managedTrigger = previousTrigger ?? matchingTrigger; + + if (!managedTrigger) { + return await createDeploymentTrigger(api, { + serviceId, + projectId, + environmentId, + desired, + }); + } + + const shouldUpdate = + managedTrigger.branch !== desired.branch || + managedTrigger.repository !== desired.repository || + (desired.checkSuites !== undefined && + managedTrigger.checkSuites !== desired.checkSuites); + + if (!shouldUpdate) { + return { + id: managedTrigger.id, + branch: managedTrigger.branch, + provider: desired.provider, + repository: managedTrigger.repository, + }; + } + + return await updateDeploymentTrigger(api, managedTrigger.id, desired); +} + +async function listServiceRepoTriggers( + api: RailwayApi, + serviceId: string, +): Promise { + const data = await api.query<{ + service: { + repoTriggers: { + edges: Array<{ + node: DeploymentTrigger; + }>; + }; + }; + }>( + `query service($id: String!) { + service(id: $id) { + repoTriggers(first: 50) { + edges { + node { + id + provider + repository + branch + serviceId + checkSuites + } + } + } + } + }`, + { id: serviceId }, + ); + + return data.service.repoTriggers.edges.map((edge) => edge.node); +} + +async function createDeploymentTrigger( + api: RailwayApi, + { + serviceId, + projectId, + environmentId, + desired, + }: { + serviceId: string; + projectId: string; + environmentId: string; + desired: DesiredDeploymentTrigger; + }, +): Promise { + const input: Record = { + provider: desired.provider, + repository: desired.repository, + branch: desired.branch, + projectId, + environmentId, + serviceId, + }; + if (desired.rootDirectory !== undefined) { + input.rootDirectory = desired.rootDirectory; + } + if (desired.checkSuites !== undefined) { + input.checkSuites = desired.checkSuites; + } + + const data = await api.query<{ + deploymentTriggerCreate: { + id: string; + branch: string; + provider: string; + repository: string; + }; + }>( + `mutation deploymentTriggerCreate($input: DeploymentTriggerCreateInput!) { + deploymentTriggerCreate(input: $input) { + id + branch + provider + repository + } + }`, + { input }, + ); + + return { + id: data.deploymentTriggerCreate.id, + branch: data.deploymentTriggerCreate.branch, + provider: desired.provider, + repository: data.deploymentTriggerCreate.repository, + }; +} + +async function updateDeploymentTrigger( + api: RailwayApi, + triggerId: string, + desired: DesiredDeploymentTrigger, +): Promise { + const input: Record = { + branch: desired.branch, + repository: desired.repository, + }; + if (desired.rootDirectory !== undefined) { + input.rootDirectory = desired.rootDirectory; + } + if (desired.checkSuites !== undefined) { + input.checkSuites = desired.checkSuites; + } + + const data = await api.query<{ + deploymentTriggerUpdate: { + id: string; + branch: string; + provider: string; + repository: string; + }; + }>( + `mutation deploymentTriggerUpdate($id: String!, $input: DeploymentTriggerUpdateInput!) { + deploymentTriggerUpdate(id: $id, input: $input) { + id + branch + provider + repository + } + }`, + { + id: triggerId, + input, + }, + ); + + return { + id: data.deploymentTriggerUpdate.id, + branch: data.deploymentTriggerUpdate.branch, + provider: desired.provider, + repository: data.deploymentTriggerUpdate.repository, + }; +} + +async function deleteDeploymentTrigger( + api: RailwayApi, + triggerId: string, +): Promise { + await runRailwayDeleteMutation(async () => { + try { + await api.query( + `mutation deploymentTriggerDelete($id: String!) { + deploymentTriggerDelete(id: $id) + }`, + { id: triggerId }, + ); + } catch (error) { + if (isDeploymentTriggerMissingError(error)) { + return; + } + throw error; + } + }); +} + +function isDeploymentTriggerMissingError(error: unknown): boolean { + if (!(error instanceof RailwayError)) { + return false; + } + + return error.errors.some((e) => + /deployment trigger .*not found|could not find deployment trigger|not found/i.test( + e.message, + ), + ); +} + async function updateServiceInstance( api: RailwayApi, serviceId: string, diff --git a/alchemy/src/railway/variable.ts b/alchemy/src/railway/variable.ts index aa3b35098..b9f907714 100644 --- a/alchemy/src/railway/variable.ts +++ b/alchemy/src/railway/variable.ts @@ -129,23 +129,25 @@ export const Variable = Resource( if (this.phase === "delete") { // Delete all tracked keys const keys = this.output?.keys ?? []; - for (const key of keys) { - await runRailwayDeleteMutation(() => - api.query( - `mutation variableDelete($input: VariableDeleteInput!) { - variableDelete(input: $input) - }`, - { - input: { - projectId, - environmentId, - serviceId, - name: key, + await Promise.all( + keys.map((key) => + runRailwayDeleteMutation(() => + api.query( + `mutation variableDelete($input: VariableDeleteInput!) { + variableDelete(input: $input) + }`, + { + input: { + projectId, + environmentId, + serviceId, + name: key, + }, }, - }, + ), ), - ); - } + ), + ); return this.destroy(); } @@ -159,23 +161,25 @@ export const Variable = Resource( if (this.output?.keys) { const newKeys = new Set(Object.keys(props.variables)); const removedKeys = this.output.keys.filter((k) => !newKeys.has(k)); - for (const key of removedKeys) { - await runRailwayDeleteMutation(() => - api.query( - `mutation variableDelete($input: VariableDeleteInput!) { - variableDelete(input: $input) - }`, - { - input: { - projectId, - environmentId, - serviceId, - name: key, + await Promise.all( + removedKeys.map((key) => + runRailwayDeleteMutation(() => + api.query( + `mutation variableDelete($input: VariableDeleteInput!) { + variableDelete(input: $input) + }`, + { + input: { + projectId, + environmentId, + serviceId, + name: key, + }, }, - }, + ), ), - ); - } + ), + ); } // Upsert all variables diff --git a/alchemy/test/railway/domain-replace.test.ts b/alchemy/test/railway/domain-replace.test.ts index 8b68f77a8..a06f9939b 100644 --- a/alchemy/test/railway/domain-replace.test.ts +++ b/alchemy/test/railway/domain-replace.test.ts @@ -34,7 +34,10 @@ describe("Railway Domain Replace", () => { } as never; } - if (query.includes("serviceDomainDelete") || query.includes("customDomainDelete")) { + if ( + query.includes("serviceDomainDelete") || + query.includes("customDomainDelete") + ) { return {} as never; } @@ -64,7 +67,9 @@ describe("Railway Domain Replace", () => { expect(custom.domain).toBe("api.example.com"); expect( - querySpy.mock.calls.some(([query]) => query.includes("customDomainCreate")), + querySpy.mock.calls.some(([query]) => + query.includes("customDomainCreate"), + ), ).toBe(true); } finally { await scope.finalize(); @@ -145,7 +150,9 @@ describe("Railway Domain Replace", () => { await scope.finalize(); expect(deleteAttempts).toBe(2); expect( - querySpy.mock.calls.some(([query]) => query.includes("serviceDomainDelete")), + querySpy.mock.calls.some(([query]) => + query.includes("serviceDomainDelete"), + ), ).toBe(true); } finally { vi.restoreAllMocks(); diff --git a/alchemy/test/railway/service-deployment-trigger.test.ts b/alchemy/test/railway/service-deployment-trigger.test.ts new file mode 100644 index 000000000..4b89fe877 --- /dev/null +++ b/alchemy/test/railway/service-deployment-trigger.test.ts @@ -0,0 +1,205 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { RailwayApi } from "../../src/railway/api.ts"; +import { Project } from "../../src/railway/project.ts"; +import { Service } from "../../src/railway/service.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; + +describe.skipIf(skipIfNoToken)("Railway Service deployment trigger", () => { + const runId = randomUUID().slice(0, 8); + const testId = `${BRANCH_PREFIX}-rsvc-${runId}`; + let project: Project | undefined; + + test.beforeAll(async () => { + project = await Project(`${testId}-proj`, { + name: `${testId}-proj`, + }); + }); + + test.afterAll(async () => { + if (project?.projectId) { + const api = new RailwayApi(); + try { + await api.query( + `mutation projectDelete($id: String!) { + projectDelete(id: $id) + }`, + { id: project.projectId }, + ); + } catch { + // OK if already deleted + } + } + }); + + test("create, update, and remove managed deployment trigger", async (scope) => { + try { + if (!project) { + throw new Error("Expected test project to be initialized"); + } + + const serviceName = `${testId}-svc`; + let service = await Service(`${testId}-svc`, { + project, + name: serviceName, + source: { repo: "Makisuo/maple" }, + deploymentTrigger: { + branch: "main", + rootDirectory: "/", + }, + }); + + expect(service.deploymentTriggerId).toBeTruthy(); + expect(service.deploymentTriggerBranch).toEqual("main"); + expect(service.deploymentTriggerRepository).toEqual("Makisuo/maple"); + const initialTriggerId = service.deploymentTriggerId!; + + let triggers = await listServiceTriggers(service.serviceId); + expect(triggers.some((trigger) => trigger.id === initialTriggerId)).toBe( + true, + ); + + service = await Service(`${testId}-svc`, { + project, + name: serviceName, + source: { repo: "Makisuo/maple" }, + deploymentTrigger: { + branch: "develop", + rootDirectory: "/", + }, + }); + + expect(service.deploymentTriggerId).toEqual(initialTriggerId); + expect(service.deploymentTriggerBranch).toEqual("develop"); + + triggers = await listServiceTriggers(service.serviceId); + const updated = triggers.find( + (trigger) => trigger.id === initialTriggerId, + ); + expect(updated).toBeTruthy(); + expect(updated?.branch).toEqual("develop"); + + service = await Service(`${testId}-svc`, { + project, + name: serviceName, + source: { repo: "Makisuo/maple" }, + }); + + expect(service.deploymentTriggerId).toBeUndefined(); + expect(service.deploymentTriggerBranch).toBeUndefined(); + expect(service.deploymentTriggerRepository).toBeUndefined(); + + triggers = await listServiceTriggers(service.serviceId); + expect(triggers.some((trigger) => trigger.id === initialTriggerId)).toBe( + false, + ); + } finally { + await destroy(scope); + } + }); + + test("throws when deploymentTrigger is configured without source.repo", async () => { + await expect( + Service(`${testId}-missing-source`, { + project: "project-id", + name: `${testId}-missing-source`, + deploymentTrigger: { + branch: "main", + }, + }), + ).rejects.toThrow( + "deploymentTrigger requires source.repo or deploymentTrigger.repository.", + ); + }); + + test("adopt existing service and reconcile deployment trigger", async (scope) => { + try { + if (!project) { + throw new Error("Expected test project to be initialized"); + } + const api = new RailwayApi(); + const serviceName = `${testId}-adopt-svc`; + + const created = await api.query<{ + serviceCreate: { + id: string; + }; + }>( + `mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + } + }`, + { + input: { + projectId: project.projectId, + name: serviceName, + source: { + repo: "Makisuo/maple", + }, + }, + }, + ); + + const service = await Service(`${testId}-adopt-svc`, { + project, + name: serviceName, + source: { repo: "Makisuo/maple" }, + adopt: true, + deploymentTrigger: { + branch: "main", + rootDirectory: "/", + }, + }); + + expect(service.serviceId).toEqual(created.serviceCreate.id); + expect(service.deploymentTriggerId).toBeTruthy(); + expect(service.deploymentTriggerBranch).toEqual("main"); + } finally { + await destroy(scope); + } + }); +}); + +async function listServiceTriggers(serviceId: string) { + const api = new RailwayApi(); + const data = await api.query<{ + service: { + repoTriggers: { + edges: Array<{ + node: { + id: string; + branch: string; + repository: string; + }; + }>; + }; + }; + }>( + `query service($id: String!) { + service(id: $id) { + repoTriggers(first: 50) { + edges { + node { + id + branch + repository + } + } + } + } + }`, + { id: serviceId }, + ); + + return data.service.repoTriggers.edges.map((edge) => edge.node); +} From 3590becfc3f2327e69f145d8deecd1511816d55d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 16:10:08 +0100 Subject: [PATCH 06/16] fix --- alchemy/src/railway/service.ts | 15 +- alchemy/test/railway/domain-replace.test.ts | 154 +++++++++----------- 2 files changed, 80 insertions(+), 89 deletions(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index aeb1f2327..880fd90fe 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -125,7 +125,7 @@ export interface ServiceProps extends RailwayApiOptions { */ export type Service = Omit< ServiceProps, - "adopt" | "project" | "environment" | "source" | "deploymentTrigger" + "adopt" | "project" | "environment" | "deploymentTrigger" > & { /** * The Railway service ID @@ -267,6 +267,16 @@ export const Service = Resource( const serviceId = this.output?.serviceId; if (serviceId && this.output) { + // Source is immutable — replace if changed + const prevSource = this.output.source; + if ( + props.source && + (props.source.repo !== prevSource?.repo || + props.source.image !== prevSource?.image) + ) { + return this.replace(); + } + // Update service name await api.query( `mutation serviceUpdate($id: String!, $input: ServiceUpdateInput!) { @@ -304,6 +314,7 @@ export const Service = Resource( createdAt: this.output.createdAt, updatedAt: this.output.updatedAt, name, + source: props.source, buildCommand: props.buildCommand, startCommand: props.startCommand, healthcheckPath: props.healthcheckPath, @@ -340,6 +351,7 @@ export const Service = Resource( return { ...existing, environmentId, + source: props.source, buildCommand: props.buildCommand, startCommand: props.startCommand, healthcheckPath: props.healthcheckPath, @@ -400,6 +412,7 @@ export const Service = Resource( projectId, environmentId, name: service.name, + source: props.source, buildCommand: props.buildCommand, startCommand: props.startCommand, healthcheckPath: props.healthcheckPath, diff --git a/alchemy/test/railway/domain-replace.test.ts b/alchemy/test/railway/domain-replace.test.ts index a06f9939b..acd2d78e1 100644 --- a/alchemy/test/railway/domain-replace.test.ts +++ b/alchemy/test/railway/domain-replace.test.ts @@ -9,47 +9,59 @@ const test = alchemy.test(import.meta, { prefix: BRANCH_PREFIX, }); -describe("Railway Domain Replace", () => { - test("replacing a generated domain with a custom domain creates the custom domain", async (scope) => { - process.env.RAILWAY_API_TOKEN = "test-token"; - - const querySpy = vi - .spyOn(RailwayApi.prototype, "query") - .mockImplementation(async (query: string) => { - if (query.includes("serviceDomainCreate")) { - return { - serviceDomainCreate: { - id: "svc-domain-old", - domain: "api-production-1234.up.railway.app", - }, - } as never; +function mockRailwayApi( + overrides?: Partial any>>, +) { + process.env.RAILWAY_API_TOKEN = "test-token"; + return vi + .spyOn(RailwayApi.prototype, "query") + .mockImplementation(async (query: string) => { + if (query.includes("serviceDomainCreate")) { + return { + serviceDomainCreate: { + id: "svc-domain-old", + domain: "api-production-1234.up.railway.app", + }, + } as never; + } + + if (query.includes("customDomainCreate")) { + return { + customDomainCreate: { + id: "custom-domain-new", + domain: "api.example.com", + }, + } as never; + } + + for (const [key, handler] of Object.entries(overrides ?? {})) { + if (query.includes(key)) { + return handler(query) as never; } + } - if (query.includes("customDomainCreate")) { - return { - customDomainCreate: { - id: "custom-domain-new", - domain: "api.example.com", - }, - } as never; - } + if ( + query.includes("serviceDomainDelete") || + query.includes("customDomainDelete") + ) { + return {} as never; + } - if ( - query.includes("serviceDomainDelete") || - query.includes("customDomainDelete") - ) { - return {} as never; - } + if (query.includes("serviceInstanceUpdate")) { + return { serviceInstanceUpdate: true } as never; + } - if (query.includes("serviceInstanceUpdate")) { - return { serviceInstanceUpdate: true } as never; - } + throw new Error(`Unexpected Railway API query in test: ${query}`); + }); +} - throw new Error(`Unexpected Railway API query in test: ${query}`); - }); +// Must be sequential — tests share process.env and RailwayApi.prototype mock +describe.sequential("Railway Domain Replace", () => { + test("replacing a generated domain with a custom domain creates the custom domain", async (scope) => { + const querySpy = mockRailwayApi(); try { - const domainResourceId = `api-domain-${Date.now()}`; + const domainResourceId = `${BRANCH_PREFIX}-domain-replace-${Date.now()}`; const generated = await Domain(domainResourceId, { service: "service-id", @@ -72,6 +84,7 @@ describe("Railway Domain Replace", () => { ), ).toBe(true); } finally { + // Finalize while mock is active so pending deletions and orphan cleanup use the mock await scope.finalize(); vi.restoreAllMocks(); delete process.env.RAILWAY_API_TOKEN; @@ -79,60 +92,28 @@ describe("Railway Domain Replace", () => { }); test("retries transient domain delete lock errors during replacement cleanup", async (scope) => { - process.env.RAILWAY_API_TOKEN = "test-token"; - let deleteAttempts = 0; - const querySpy = vi - .spyOn(RailwayApi.prototype, "query") - .mockImplementation(async (query: string) => { - if (query.includes("serviceDomainCreate")) { - return { - serviceDomainCreate: { - id: "svc-domain-old", - domain: "api-production-1234.up.railway.app", - }, - } as never; - } - - if (query.includes("customDomainCreate")) { - return { - customDomainCreate: { - id: "custom-domain-new", - domain: "api.example.com", - }, - } as never; - } - - if (query.includes("serviceDomainDelete")) { - deleteAttempts += 1; - if (deleteAttempts === 1) { - throw new RailwayError( - "Railway API error: Cannot delete service domain: an operation is already in progress", - [ - { - message: - "Cannot delete service domain: an operation is already in progress", - }, - ], - ); - } - return {} as never; + mockRailwayApi({ + serviceDomainDelete: () => { + deleteAttempts += 1; + if (deleteAttempts === 1) { + throw new RailwayError( + "Railway API error: Cannot delete service domain: an operation is already in progress", + [ + { + message: + "Cannot delete service domain: an operation is already in progress", + }, + ], + ); } - - if (query.includes("customDomainDelete")) { - return {} as never; - } - - if (query.includes("serviceInstanceUpdate")) { - return { serviceInstanceUpdate: true } as never; - } - - throw new Error(`Unexpected Railway API query in test: ${query}`); - }); + return {}; + }, + }); try { - const domainResourceId = `api-domain-retry-${Date.now()}`; + const domainResourceId = `${BRANCH_PREFIX}-domain-retry-${Date.now()}`; await Domain(domainResourceId, { service: "service-id", @@ -147,13 +128,10 @@ describe("Railway Domain Replace", () => { }); expect(custom.domain).toBe("api.example.com"); + + // Finalize triggers pending deletions (the old domain cleanup with retries) await scope.finalize(); expect(deleteAttempts).toBe(2); - expect( - querySpy.mock.calls.some(([query]) => - query.includes("serviceDomainDelete"), - ), - ).toBe(true); } finally { vi.restoreAllMocks(); delete process.env.RAILWAY_API_TOKEN; From aa4248831ab90983a7f4b3fbcb273a8783778941 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 16:37:01 +0100 Subject: [PATCH 07/16] Update service.ts --- alchemy/src/railway/service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index 880fd90fe..b62372499 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -268,11 +268,13 @@ export const Service = Resource( if (serviceId && this.output) { // Source is immutable — replace if changed + // Guard: if prevSource is undefined (old state format), skip comparison const prevSource = this.output.source; if ( + prevSource && props.source && - (props.source.repo !== prevSource?.repo || - props.source.image !== prevSource?.image) + (props.source.repo !== prevSource.repo || + props.source.image !== prevSource.image) ) { return this.replace(); } From 0361f7b677e6d32f654bdb31cf6ca7412659b25e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 16:43:21 +0100 Subject: [PATCH 08/16] fix: handle "already exists" error in Railway Service create with adopt fallback When alchemy state is lost or stale but the Railway service already exists, serviceCreate fails with "already exists". If adopt is enabled, catch this error and fall back to finding and adopting the existing service. Co-Authored-By: Claude Opus 4.6 --- alchemy/src/railway/service.ts | 92 +++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index b62372499..3820a2d50 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -376,26 +376,71 @@ export const Service = Resource( input.source = { image: props.source.image }; } - const data = await api.query<{ - serviceCreate: { - id: string; - name: string; - createdAt: string; - updatedAt: string; - }; - }>( - `mutation serviceCreate($input: ServiceCreateInput!) { - serviceCreate(input: $input) { - id - name - createdAt - updatedAt - } - }`, - { input }, - ); + let service: { + id: string; + name: string; + createdAt: string; + updatedAt: string; + }; - const service = data.serviceCreate; + try { + const data = await api.query<{ + serviceCreate: { + id: string; + name: string; + createdAt: string; + updatedAt: string; + }; + }>( + `mutation serviceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + createdAt + updatedAt + } + }`, + { input }, + ); + service = data.serviceCreate; + } catch (error) { + // If the service already exists and adopt is enabled, fall back to adopting + if (adopt && isServiceAlreadyExistsError(error)) { + const existing = await findServiceByName(api, projectId, name); + if (existing) { + await updateServiceInstance( + api, + existing.serviceId, + environmentId, + props, + ); + + const managedDeploymentTrigger = desiredDeploymentTrigger + ? await reconcileDeploymentTrigger({ + api, + serviceId: existing.serviceId, + projectId, + environmentId, + desired: desiredDeploymentTrigger, + }) + : undefined; + + return { + ...existing, + environmentId, + source: props.source, + buildCommand: props.buildCommand, + startCommand: props.startCommand, + healthcheckPath: props.healthcheckPath, + numReplicas: props.numReplicas, + cronSchedule: props.cronSchedule, + region: props.region, + ...toDeploymentTriggerOutput(managedDeploymentTrigger), + }; + } + } + throw error; + } // Configure service instance await updateServiceInstance(api, service.id, environmentId, props); @@ -841,3 +886,12 @@ async function findServiceByName( updatedAt: match.node.updatedAt, }; } + +function isServiceAlreadyExistsError(error: unknown): boolean { + if (!(error instanceof RailwayError)) { + return false; + } + return error.errors.some((e) => + /already exists/i.test(e.message), + ); +} From d2de89cc36cc2c53f5d2acee13bbf12d6dd01a3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 16:58:49 +0100 Subject: [PATCH 09/16] fix: skip serviceUpdate when name unchanged Railway API rejects serviceUpdate with the same name, returning "A service named X already exists in this project". Skip the mutation when the name hasn't actually changed. Co-Authored-By: Claude Opus 4.6 --- alchemy/src/railway/service.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index 3820a2d50..19e3b912b 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -279,18 +279,20 @@ export const Service = Resource( return this.replace(); } - // Update service name - await api.query( - `mutation serviceUpdate($id: String!, $input: ServiceUpdateInput!) { - serviceUpdate(id: $id, input: $input) { - id - } - }`, - { - id: serviceId, - input: { name }, - }, - ); + // Update service name only if changed + if (name !== this.output.name) { + await api.query( + `mutation serviceUpdate($id: String!, $input: ServiceUpdateInput!) { + serviceUpdate(id: $id, input: $input) { + id + } + }`, + { + id: serviceId, + input: { name }, + }, + ); + } // Update service instance config await updateServiceInstance(api, serviceId, environmentId, props); From 76e83db08d9e824ad83368a274aa24f446e4606f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 17:08:36 +0100 Subject: [PATCH 10/16] fix(railway): detect externally-deleted services and recreate them Add serviceExists check in update path. If a service was deleted from Railway but alchemy state still references it, fall through to the create path instead of silently skipping. --- alchemy/src/railway/service.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index 19e3b912b..f7fd73f58 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -266,7 +266,11 @@ export const Service = Resource( const serviceId = this.output?.serviceId; - if (serviceId && this.output) { + if ( + serviceId && + this.output && + (await serviceExists(api, serviceId)) + ) { // Source is immutable — replace if changed // Guard: if prevSource is undefined (old state format), skip comparison const prevSource = this.output.source; @@ -889,6 +893,28 @@ async function findServiceByName( }; } +async function serviceExists( + api: RailwayApi, + serviceId: string, +): Promise { + try { + await api.query<{ service: { id: string } }>( + `query service($id: String!) { + service(id: $id) { + id + } + }`, + { id: serviceId }, + ); + return true; + } catch (error) { + if (error instanceof RailwayError) { + return false; + } + throw error; + } +} + function isServiceAlreadyExistsError(error: unknown): boolean { if (!(error instanceof RailwayError)) { return false; From fed1e292f3e73db9269c3df9765dda9426e713a1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 17:14:13 +0100 Subject: [PATCH 11/16] fix(railway): check service exists in project, not just queryable Deleted/detached services are still queryable by ID via the Railway API but don't appear in the project's service list. Check the project's services instead to properly detect externally-deleted services. --- alchemy/src/railway/service.ts | 39 ++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index f7fd73f58..cd4ae1fb3 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -269,7 +269,7 @@ export const Service = Resource( if ( serviceId && this.output && - (await serviceExists(api, serviceId)) + (await serviceExists(api, projectId, serviceId)) ) { // Source is immutable — replace if changed // Guard: if prevSource is undefined (old state format), skip comparison @@ -895,24 +895,31 @@ async function findServiceByName( async function serviceExists( api: RailwayApi, + projectId: string, serviceId: string, ): Promise { - try { - await api.query<{ service: { id: string } }>( - `query service($id: String!) { - service(id: $id) { - id + const data = await api.query<{ + project: { + services: { + edges: Array<{ node: { id: string } }>; + }; + }; + }>( + `query project($id: String!) { + project(id: $id) { + services { + edges { + node { + id + } + } } - }`, - { id: serviceId }, - ); - return true; - } catch (error) { - if (error instanceof RailwayError) { - return false; - } - throw error; - } + } + }`, + { id: projectId }, + ); + + return data.project.services.edges.some((e) => e.node.id === serviceId); } function isServiceAlreadyExistsError(error: unknown): boolean { From 5e8b2b056985b179c72b1db77c46004955bf9d3b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 17:21:15 +0100 Subject: [PATCH 12/16] add tests and more --- alchemy/src/railway/README.md | 78 ++++++++++++++++++++++++ alchemy/src/railway/domain.ts | 16 ++++- alchemy/src/railway/environment.ts | 16 ++++- alchemy/src/railway/project.ts | 17 +++++- alchemy/src/railway/service.ts | 55 ++++++++++++----- alchemy/src/railway/tcp-proxy.ts | 17 +++++- alchemy/src/railway/variable.ts | 16 ++++- alchemy/src/railway/volume.ts | 17 +++++- alchemy/test/railway/domain.test.ts | 22 ++++++- alchemy/test/railway/environment.test.ts | 38 +++++++++++- alchemy/test/railway/service.test.ts | 23 ++++++- alchemy/test/railway/tcp-proxy.test.ts | 37 ++++++++++- alchemy/test/railway/variable.test.ts | 36 ++++++++++- alchemy/test/railway/volume.test.ts | 38 +++++++++++- 14 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 alchemy/src/railway/README.md diff --git a/alchemy/src/railway/README.md b/alchemy/src/railway/README.md new file mode 100644 index 000000000..e2ab56cf1 --- /dev/null +++ b/alchemy/src/railway/README.md @@ -0,0 +1,78 @@ +# Railway Provider + +Alchemy provider for [Railway](https://railway.com), a modern cloud platform for deploying applications, databases, and services. + +## Resources + +| Resource | Kind | Lifecycle | Immutable Props | +|----------|------|-----------|-----------------| +| [Project](./project.ts) | `railway::Project` | Create, Update, Delete | - | +| [Service](./service.ts) | `railway::Service` | Create, Update, Delete | `source` | +| [Environment](./environment.ts) | `railway::Environment` | Create, Replace, Delete | `name` | +| [Domain](./domain.ts) | `railway::Domain` | Create, Update, Replace, Delete | `domain` (custom) | +| [Variable](./variable.ts) | `railway::Variable` | Create, Update, Delete | - | +| [Volume](./volume.ts) | `railway::Volume` | Create, Replace, Delete | `mountPath` | +| [TCPProxy](./tcp-proxy.ts) | `railway::TCPProxy` | Create, Replace, Delete | `applicationPort` | + +## Architecture + +### API Client (`api.ts`) + +`RailwayApi` is a minimal GraphQL client targeting `https://backboard.railway.com/graphql/v2`. Authentication is via a Bearer token from: +1. `apiToken` prop (as `Secret`) +2. `RAILWAY_API_TOKEN` environment variable + +Errors are wrapped in `RailwayError` which preserves the GraphQL error array for pattern matching in retry logic. + +### Delete Retry (`delete-retry.ts`) + +`runRailwayDeleteMutation` wraps delete operations with exponential backoff to handle Railway's transient "operation is already in progress" lock errors. It also swallows "not found" errors (resource already deleted). + +Configuration: up to 8 attempts, 500ms initial delay, 5s max delay. + +All 7 resources use this for their delete phase. + +### Immutable Property Replacement + +When an immutable property changes (e.g., `mountPath` on Volume, `name` on Environment), the resource calls `this.replace()` which: +1. Creates a new resource with the updated properties +2. Schedules the old resource for deletion during `scope.finalize()` + +### Resource References + +Props that reference other Railway entities accept `string | Resource` unions: +- `project: string | Project` (resolves to `projectId`) +- `service: string | Service` (resolves to `serviceId`) +- `environment: string | Environment` (resolves to `environmentId`) + +This enables both Alchemy-managed references and raw ID strings for external resources. + +## Resource Details + +### Project + +Root resource. Supports `adopt: true` to adopt existing projects by name. `delete: false` prevents deletion on teardown. Resolves `workspaceId` from props, `RAILWAY_WORKSPACE_ID` env var, or auto-detects the first workspace. + +### Service + +Most complex resource. Supports GitHub repo and Docker image sources. Manages deployment triggers (GitHub webhook integration) with full reconciliation on update. Configurable build/start commands, health checks, replicas, cron schedules, and region. + +### Environment + +Creates non-production environments within a project. Name is immutable (triggers replacement). + +### Domain + +Two types: Railway-generated (`*.up.railway.app`) and custom domains. Custom domain changes trigger replacement. `targetPort` can be updated in-place via `serviceInstanceUpdate`. + +### Variable + +Manages key-value environment variables scoped to a project+environment, optionally to a service. Tracks keys for differential updates: new keys are upserted, removed keys are deleted concurrently via `Promise.all`. Supports `Secret` values. + +### Volume + +Persistent storage attached to a service. `mountPath` is immutable (triggers replacement). Supports `delete: false` to preserve data on teardown. + +### TCPProxy + +Exposes non-HTTP services via a public TCP endpoint. Railway assigns the proxy domain and port. `applicationPort` is immutable (triggers replacement). diff --git a/alchemy/src/railway/domain.ts b/alchemy/src/railway/domain.ts index 5258b469b..9cae795d7 100644 --- a/alchemy/src/railway/domain.ts +++ b/alchemy/src/railway/domain.ts @@ -114,7 +114,6 @@ export const Domain = Resource( id: string, props: DomainProps, ): Promise { - const api = new RailwayApi(props); const serviceId = typeof props.service === "string" ? props.service @@ -124,6 +123,21 @@ export const Domain = Resource( ? props.environment : props.environment.environmentId; + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + domainId: this.output?.domainId ?? "", + serviceId, + environmentId, + domain: props.domain ?? this.output?.domain ?? "local.up.railway.app", + targetPort: props.targetPort, + }; + } + + const api = new RailwayApi(props); + if (this.phase === "delete") { const domainId = this.output?.domainId; if (domainId) { diff --git a/alchemy/src/railway/environment.ts b/alchemy/src/railway/environment.ts index 09eee571c..64a740482 100644 --- a/alchemy/src/railway/environment.ts +++ b/alchemy/src/railway/environment.ts @@ -99,7 +99,6 @@ export const Environment = Resource( id: string, props: EnvironmentProps, ): Promise { - const api = new RailwayApi(props); const projectId = typeof props.project === "string" ? props.project @@ -107,6 +106,21 @@ export const Environment = Resource( const name = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + environmentId: this.output?.environmentId ?? "", + projectId, + name, + createdAt: this.output?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + const api = new RailwayApi(props); + if (this.phase === "delete") { const environmentId = this.output?.environmentId; if (environmentId) { diff --git a/alchemy/src/railway/project.ts b/alchemy/src/railway/project.ts index a00cab5c3..0d7840879 100644 --- a/alchemy/src/railway/project.ts +++ b/alchemy/src/railway/project.ts @@ -120,11 +120,26 @@ export const Project = Resource( id: string, props: ProjectProps, ): Promise { - const api = new RailwayApi(props); const name = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); const adopt = props.adopt ?? this.scope.adopt; + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + projectId: this.output?.projectId ?? "", + name, + description: props.description, + defaultEnvironmentId: this.output?.defaultEnvironmentId ?? "", + createdAt: this.output?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + const api = new RailwayApi(props); + if (this.phase === "delete") { const projectId = this.output?.projectId; if (props.delete !== false && projectId) { diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index cd4ae1fb3..0c7907d46 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -226,7 +226,6 @@ export const Service = Resource( id: string, props: ServiceProps, ): Promise { - const api = new RailwayApi(props); const projectId = typeof props.project === "string" ? props.project @@ -235,15 +234,40 @@ export const Service = Resource( props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); const desiredDeploymentTrigger = resolveDesiredDeploymentTrigger(props); - // Resolve environment ID + // Resolve environment ID (without API call for local mode) + const resolvedEnvironmentId = props.environment + ? typeof props.environment === "string" + ? props.environment + : props.environment.environmentId + : this.output?.environmentId; + + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + serviceId: this.output?.serviceId ?? "", + projectId, + environmentId: resolvedEnvironmentId ?? "", + name, + source: props.source, + buildCommand: props.buildCommand, + startCommand: props.startCommand, + healthcheckPath: props.healthcheckPath, + numReplicas: props.numReplicas, + cronSchedule: props.cronSchedule, + region: props.region, + createdAt: this.output?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + const api = new RailwayApi(props); + + // Resolve environment ID via API if not provided let environmentId: string; - if (props.environment) { - environmentId = - typeof props.environment === "string" - ? props.environment - : props.environment.environmentId; - } else if (this.output?.environmentId) { - environmentId = this.output.environmentId; + if (resolvedEnvironmentId) { + environmentId = resolvedEnvironmentId; } else { // Get the default production environment environmentId = await getDefaultEnvironmentId(api, projectId); @@ -284,11 +308,15 @@ export const Service = Resource( } // Update service name only if changed + let updatedAt = this.output.updatedAt; if (name !== this.output.name) { - await api.query( + const data = await api.query<{ + serviceUpdate: { id: string; updatedAt: string }; + }>( `mutation serviceUpdate($id: String!, $input: ServiceUpdateInput!) { serviceUpdate(id: $id, input: $input) { id + updatedAt } }`, { @@ -296,6 +324,7 @@ export const Service = Resource( input: { name }, }, ); + updatedAt = data.serviceUpdate.updatedAt; } // Update service instance config @@ -320,7 +349,7 @@ export const Service = Resource( projectId: this.output.projectId, environmentId: this.output.environmentId, createdAt: this.output.createdAt, - updatedAt: this.output.updatedAt, + updatedAt, name, source: props.source, buildCommand: props.buildCommand, @@ -926,7 +955,5 @@ function isServiceAlreadyExistsError(error: unknown): boolean { if (!(error instanceof RailwayError)) { return false; } - return error.errors.some((e) => - /already exists/i.test(e.message), - ); + return error.errors.some((e) => /already exists/i.test(e.message)); } diff --git a/alchemy/src/railway/tcp-proxy.ts b/alchemy/src/railway/tcp-proxy.ts index 0e0497a43..e67176209 100644 --- a/alchemy/src/railway/tcp-proxy.ts +++ b/alchemy/src/railway/tcp-proxy.ts @@ -104,7 +104,6 @@ export const TCPProxy = Resource( id: string, props: TCPProxyProps, ): Promise { - const api = new RailwayApi(props); const serviceId = typeof props.service === "string" ? props.service @@ -114,6 +113,22 @@ export const TCPProxy = Resource( ? props.environment : props.environment.environmentId; + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + proxyId: this.output?.proxyId ?? "", + serviceId, + environmentId, + applicationPort: props.applicationPort, + domain: this.output?.domain ?? "local.railway.internal", + proxyPort: this.output?.proxyPort ?? props.applicationPort, + }; + } + + const api = new RailwayApi(props); + if (this.phase === "delete") { const proxyId = this.output?.proxyId; if (proxyId) { diff --git a/alchemy/src/railway/variable.ts b/alchemy/src/railway/variable.ts index b9f907714..f2f0b28ed 100644 --- a/alchemy/src/railway/variable.ts +++ b/alchemy/src/railway/variable.ts @@ -111,7 +111,6 @@ export const Variable = Resource( id: string, props: VariableProps, ): Promise { - const api = new RailwayApi(props); const projectId = typeof props.project === "string" ? props.project @@ -126,6 +125,21 @@ export const Variable = Resource( : props.service.serviceId : undefined; + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + projectId, + environmentId, + serviceId, + variables: props.variables, + keys: Object.keys(props.variables), + }; + } + + const api = new RailwayApi(props); + if (this.phase === "delete") { // Delete all tracked keys const keys = this.output?.keys ?? []; diff --git a/alchemy/src/railway/volume.ts b/alchemy/src/railway/volume.ts index c92018b22..bd3e99db7 100644 --- a/alchemy/src/railway/volume.ts +++ b/alchemy/src/railway/volume.ts @@ -129,7 +129,6 @@ export const Volume = Resource( id: string, props: VolumeProps, ): Promise { - const api = new RailwayApi(props); const projectId = typeof props.project === "string" ? props.project @@ -145,6 +144,22 @@ export const Volume = Resource( const name = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); + if (this.scope.local) { + if (this.phase === "delete") { + return this.destroy(); + } + return { + volumeId: this.output?.volumeId ?? "", + projectId, + serviceId, + environmentId, + name, + mountPath: props.mountPath, + }; + } + + const api = new RailwayApi(props); + if (this.phase === "delete") { const volumeId = this.output?.volumeId; if (props.delete !== false && volumeId) { diff --git a/alchemy/test/railway/domain.test.ts b/alchemy/test/railway/domain.test.ts index 3a1508ab3..69fd66a2a 100644 --- a/alchemy/test/railway/domain.test.ts +++ b/alchemy/test/railway/domain.test.ts @@ -19,6 +19,7 @@ describe.skipIf(skipIfNoToken)("Railway Domain", () => { test("create and delete railway domain", async (scope) => { let project: Project | undefined; + let domain: Domain | undefined; try { project = await Project(`${testId}-proj`, { name: `${testId}-proj`, @@ -30,7 +31,7 @@ describe.skipIf(skipIfNoToken)("Railway Domain", () => { source: { image: "nginx:latest" }, }); - const domain = await Domain(testId, { + domain = await Domain(testId, { service, environment: project.defaultEnvironmentId, }); @@ -43,6 +44,10 @@ describe.skipIf(skipIfNoToken)("Railway Domain", () => { } finally { await destroy(scope); + if (domain?.domainId) { + await assertDomainDoesNotExist(domain.domainId); + } + if (project?.projectId) { const api = new RailwayApi(); try { @@ -59,3 +64,18 @@ describe.skipIf(skipIfNoToken)("Railway Domain", () => { } }); }); + +async function assertDomainDoesNotExist(domainId: string): Promise { + const api = new RailwayApi(); + try { + await api.query( + `query domain($id: String!) { + domain(id: $id) { id } + }`, + { id: domainId }, + ); + expect.fail("Domain should have been deleted"); + } catch { + // Expected: domain not found + } +} diff --git a/alchemy/test/railway/environment.test.ts b/alchemy/test/railway/environment.test.ts index c4d1f8917..7a5642ba5 100644 --- a/alchemy/test/railway/environment.test.ts +++ b/alchemy/test/railway/environment.test.ts @@ -16,14 +16,15 @@ const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; describe.skipIf(skipIfNoToken)("Railway Environment", () => { const testId = `${BRANCH_PREFIX}-railway-env`; - test("create and delete environment", async (scope) => { + test("create, replace, and delete environment", async (scope) => { let project: Project | undefined; + let env: Environment | undefined; try { project = await Project(`${testId}-proj`, { name: `${testId}-proj`, }); - const env = await Environment(testId, { + env = await Environment(testId, { project, name: `${testId}-staging`, }); @@ -32,9 +33,25 @@ describe.skipIf(skipIfNoToken)("Railway Environment", () => { expect(env.name).toEqual(`${testId}-staging`); expect(env.projectId).toEqual(project.projectId); expect(env.createdAt).toBeTruthy(); + + const originalEnvId = env.environmentId; + + // Replace — name is immutable + env = await Environment(testId, { + project, + name: `${testId}-staging-v2`, + }); + + expect(env.environmentId).toBeTruthy(); + expect(env.environmentId).not.toEqual(originalEnvId); + expect(env.name).toEqual(`${testId}-staging-v2`); } finally { await destroy(scope); + if (env?.environmentId) { + await assertEnvironmentDoesNotExist(env.environmentId); + } + if (project?.projectId) { const api = new RailwayApi(); try { @@ -51,3 +68,20 @@ describe.skipIf(skipIfNoToken)("Railway Environment", () => { } }); }); + +async function assertEnvironmentDoesNotExist( + environmentId: string, +): Promise { + const api = new RailwayApi(); + try { + await api.query( + `query environment($id: String!) { + environment(id: $id) { id } + }`, + { id: environmentId }, + ); + expect.fail("Environment should have been deleted"); + } catch { + // Expected: environment not found + } +} diff --git a/alchemy/test/railway/service.test.ts b/alchemy/test/railway/service.test.ts index 56bcec3c4..61f0096fc 100644 --- a/alchemy/test/railway/service.test.ts +++ b/alchemy/test/railway/service.test.ts @@ -18,12 +18,13 @@ describe.skipIf(skipIfNoToken)("Railway Service", () => { test("create, update, and delete service", async (scope) => { let project: Project | undefined; + let service: Service | undefined; try { project = await Project(`${testId}-proj`, { name: `${testId}-proj`, }); - let service = await Service(testId, { + service = await Service(testId, { project, name: `${testId}-svc`, startCommand: "node index.js", @@ -47,8 +48,13 @@ describe.skipIf(skipIfNoToken)("Railway Service", () => { expect(service.startCommand).toEqual("npm start"); expect(service.buildCommand).toEqual("npm run build"); } finally { + const serviceId = service?.serviceId; await destroy(scope); + if (serviceId) { + await assertServiceDoesNotExist(serviceId); + } + if (project?.projectId) { const api = new RailwayApi(); try { @@ -65,3 +71,18 @@ describe.skipIf(skipIfNoToken)("Railway Service", () => { } }); }); + +async function assertServiceDoesNotExist(serviceId: string): Promise { + const api = new RailwayApi(); + try { + await api.query( + `query service($id: String!) { + service(id: $id) { id } + }`, + { id: serviceId }, + ); + expect.fail("Service should have been deleted"); + } catch { + // Expected: service not found + } +} diff --git a/alchemy/test/railway/tcp-proxy.test.ts b/alchemy/test/railway/tcp-proxy.test.ts index 4079fedb5..68a3a1021 100644 --- a/alchemy/test/railway/tcp-proxy.test.ts +++ b/alchemy/test/railway/tcp-proxy.test.ts @@ -17,8 +17,9 @@ const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; describe.skipIf(skipIfNoToken)("Railway TCPProxy", () => { const testId = `${BRANCH_PREFIX}-railway-tcp`; - test("create and delete tcp proxy", async (scope) => { + test("create, replace, and delete tcp proxy", async (scope) => { let project: Project | undefined; + let proxy: TCPProxy | undefined; try { project = await Project(`${testId}-proj`, { name: `${testId}-proj`, @@ -30,7 +31,7 @@ describe.skipIf(skipIfNoToken)("Railway TCPProxy", () => { source: { image: "redis:latest" }, }); - const proxy = await TCPProxy(testId, { + proxy = await TCPProxy(testId, { service, environment: project.defaultEnvironmentId, applicationPort: 6379, @@ -42,9 +43,26 @@ describe.skipIf(skipIfNoToken)("Railway TCPProxy", () => { expect(proxy.applicationPort).toEqual(6379); expect(proxy.serviceId).toEqual(service.serviceId); expect(proxy.environmentId).toEqual(project.defaultEnvironmentId); + + const originalProxyId = proxy.proxyId; + + // Replace — applicationPort is immutable + proxy = await TCPProxy(testId, { + service, + environment: project.defaultEnvironmentId, + applicationPort: 6380, + }); + + expect(proxy.proxyId).toBeTruthy(); + expect(proxy.proxyId).not.toEqual(originalProxyId); + expect(proxy.applicationPort).toEqual(6380); } finally { await destroy(scope); + if (proxy?.proxyId) { + await assertTCPProxyDoesNotExist(proxy.proxyId); + } + if (project?.projectId) { const api = new RailwayApi(); try { @@ -61,3 +79,18 @@ describe.skipIf(skipIfNoToken)("Railway TCPProxy", () => { } }); }); + +async function assertTCPProxyDoesNotExist(proxyId: string): Promise { + const api = new RailwayApi(); + try { + await api.query( + `query tcpProxy($id: String!) { + tcpProxy(id: $id) { id } + }`, + { id: proxyId }, + ); + expect.fail("TCPProxy should have been deleted"); + } catch { + // Expected: tcp proxy not found + } +} diff --git a/alchemy/test/railway/variable.test.ts b/alchemy/test/railway/variable.test.ts index 37a5c9518..97648805e 100644 --- a/alchemy/test/railway/variable.test.ts +++ b/alchemy/test/railway/variable.test.ts @@ -19,18 +19,20 @@ describe.skipIf(skipIfNoToken)("Railway Variable", () => { test("create, update, and delete variables", async (scope) => { let project: Project | undefined; + let service: Service | undefined; + let vars: Variable | undefined; try { project = await Project(`${testId}-proj`, { name: `${testId}-proj`, }); - const service = await Service(`${testId}-svc`, { + service = await Service(`${testId}-svc`, { project, name: `${testId}-svc`, }); // Create - let vars = await Variable(testId, { + vars = await Variable(testId, { project, environment: project.defaultEnvironmentId, service, @@ -62,6 +64,14 @@ describe.skipIf(skipIfNoToken)("Railway Variable", () => { } finally { await destroy(scope); + if (service?.serviceId && project) { + await assertVariablesDoNotExist( + project.projectId, + project.defaultEnvironmentId, + service.serviceId, + ); + } + if (project?.projectId) { const api = new RailwayApi(); try { @@ -78,3 +88,25 @@ describe.skipIf(skipIfNoToken)("Railway Variable", () => { } }); }); + +async function assertVariablesDoNotExist( + projectId: string, + environmentId: string, + serviceId: string, +): Promise { + const api = new RailwayApi(); + try { + const data = await api.query<{ + variables: Record; + }>( + `query variables($projectId: String!, $environmentId: String!, $serviceId: String!) { + variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) + }`, + { projectId, environmentId, serviceId }, + ); + // After deletion, either the query fails or returns empty + expect(Object.keys(data.variables)).toHaveLength(0); + } catch { + // Expected: service/project not found after deletion + } +} diff --git a/alchemy/test/railway/volume.test.ts b/alchemy/test/railway/volume.test.ts index 5a2860f7b..db89a9faa 100644 --- a/alchemy/test/railway/volume.test.ts +++ b/alchemy/test/railway/volume.test.ts @@ -17,8 +17,9 @@ const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; describe.skipIf(skipIfNoToken)("Railway Volume", () => { const testId = `${BRANCH_PREFIX}-railway-vol`; - test("create and delete volume", async (scope) => { + test("create, replace, and delete volume", async (scope) => { let project: Project | undefined; + let volume: Volume | undefined; try { project = await Project(`${testId}-proj`, { name: `${testId}-proj`, @@ -29,7 +30,7 @@ describe.skipIf(skipIfNoToken)("Railway Volume", () => { name: `${testId}-svc`, }); - const volume = await Volume(testId, { + volume = await Volume(testId, { project, service, environment: project.defaultEnvironmentId, @@ -40,9 +41,27 @@ describe.skipIf(skipIfNoToken)("Railway Volume", () => { expect(volume.mountPath).toEqual("/data"); expect(volume.projectId).toEqual(project.projectId); expect(volume.serviceId).toEqual(service.serviceId); + + const originalVolumeId = volume.volumeId; + + // Replace — mountPath is immutable + volume = await Volume(testId, { + project, + service, + environment: project.defaultEnvironmentId, + mountPath: "/data/v2", + }); + + expect(volume.volumeId).toBeTruthy(); + expect(volume.volumeId).not.toEqual(originalVolumeId); + expect(volume.mountPath).toEqual("/data/v2"); } finally { await destroy(scope); + if (volume?.volumeId) { + await assertVolumeDoesNotExist(volume.volumeId); + } + if (project?.projectId) { const api = new RailwayApi(); try { @@ -59,3 +78,18 @@ describe.skipIf(skipIfNoToken)("Railway Volume", () => { } }); }); + +async function assertVolumeDoesNotExist(volumeId: string): Promise { + const api = new RailwayApi(); + try { + await api.query( + `query volume($id: String!) { + volume(id: $id) { id } + }`, + { id: volumeId }, + ); + expect.fail("Volume should have been deleted"); + } catch { + // Expected: volume not found + } +} From 2cb03577df86de5b1ac1ab09ca99f61a5b0c77fd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 17:22:29 +0100 Subject: [PATCH 13/16] Update variable.ts --- alchemy/src/railway/variable.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/alchemy/src/railway/variable.ts b/alchemy/src/railway/variable.ts index f2f0b28ed..0e77a26f8 100644 --- a/alchemy/src/railway/variable.ts +++ b/alchemy/src/railway/variable.ts @@ -133,7 +133,6 @@ export const Variable = Resource( projectId, environmentId, serviceId, - variables: props.variables, keys: Object.keys(props.variables), }; } From e3f48848e5d0fda28d623f8e70a91ae6bf0c7193 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 15 Feb 2026 17:24:03 +0100 Subject: [PATCH 14/16] fix: remove serviceExists pre-check from Railway Service provider The serviceExists check was added as a workaround for stale state but is not standard alchemy practice. The proper fix is to clear stale state rather than query Railway on every update. Co-Authored-By: Claude Opus 4.6 --- alchemy/src/railway/service.ts | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/alchemy/src/railway/service.ts b/alchemy/src/railway/service.ts index 0c7907d46..210832dd1 100644 --- a/alchemy/src/railway/service.ts +++ b/alchemy/src/railway/service.ts @@ -290,11 +290,7 @@ export const Service = Resource( const serviceId = this.output?.serviceId; - if ( - serviceId && - this.output && - (await serviceExists(api, projectId, serviceId)) - ) { + if (serviceId && this.output) { // Source is immutable — replace if changed // Guard: if prevSource is undefined (old state format), skip comparison const prevSource = this.output.source; @@ -922,34 +918,6 @@ async function findServiceByName( }; } -async function serviceExists( - api: RailwayApi, - projectId: string, - serviceId: string, -): Promise { - const data = await api.query<{ - project: { - services: { - edges: Array<{ node: { id: string } }>; - }; - }; - }>( - `query project($id: String!) { - project(id: $id) { - services { - edges { - node { - id - } - } - } - } - }`, - { id: projectId }, - ); - - return data.project.services.edges.some((e) => e.node.id === serviceId); -} function isServiceAlreadyExistsError(error: unknown): boolean { if (!(error instanceof RailwayError)) { From 7da3988f84091cf7604dcffbb2a14eef941f03b7 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:02:32 -0500 Subject: [PATCH 15/16] handle project create rate limit --- alchemy/src/railway/delete-retry.ts | 9 +- alchemy/src/railway/project.ts | 162 +++++++++++++--------------- 2 files changed, 79 insertions(+), 92 deletions(-) diff --git a/alchemy/src/railway/delete-retry.ts b/alchemy/src/railway/delete-retry.ts index c564e241f..a61962c05 100644 --- a/alchemy/src/railway/delete-retry.ts +++ b/alchemy/src/railway/delete-retry.ts @@ -23,7 +23,10 @@ function getErrorMessages(error: unknown): string[] { return [String(error)]; } -function hasAnyPattern(error: unknown, patterns: readonly string[]): boolean { +export function hasRailwayErrorPattern( + error: unknown, + patterns: readonly string[], +): boolean { const messages = getErrorMessages(error).map((message) => message.toLowerCase(), ); @@ -33,11 +36,11 @@ function hasAnyPattern(error: unknown, patterns: readonly string[]): boolean { } export function isRailwayNotFoundError(error: unknown): boolean { - return hasAnyPattern(error, NOT_FOUND_PATTERNS); + return hasRailwayErrorPattern(error, NOT_FOUND_PATTERNS); } export function isRailwayOperationInProgressError(error: unknown): boolean { - return hasAnyPattern(error, OPERATION_IN_PROGRESS_PATTERNS); + return hasRailwayErrorPattern(error, OPERATION_IN_PROGRESS_PATTERNS); } export async function runRailwayDeleteMutation( diff --git a/alchemy/src/railway/project.ts b/alchemy/src/railway/project.ts index 0d7840879..3d5d328b2 100644 --- a/alchemy/src/railway/project.ts +++ b/alchemy/src/railway/project.ts @@ -1,7 +1,11 @@ import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; +import { withExponentialBackoff } from "../util/retry.ts"; import { RailwayApi, type RailwayApiOptions } from "./api.ts"; -import { runRailwayDeleteMutation } from "./delete-retry.ts"; +import { + hasRailwayErrorPattern, + runRailwayDeleteMutation, +} from "./delete-retry.ts"; /** * Properties for creating or updating a Railway Project @@ -155,9 +159,7 @@ export const Project = Resource( return this.destroy(); } - const projectId = this.output?.projectId; - - if (projectId && this.output) { + if (this.output?.projectId) { // Update const data = await api.query<{ projectUpdate: { @@ -176,7 +178,7 @@ export const Project = Resource( } }`, { - id: projectId, + id: this.output.projectId, input: { name, description: props.description ?? null, @@ -195,54 +197,22 @@ export const Project = Resource( } // Create + + let project: ProjectFragment | undefined; + if (adopt) { - const existing = await findProjectByName(api, name); - if (existing) { - return existing; - } + project = await findProjectByName(api, name); } - const workspaceId = await resolveWorkspaceId(api, props.workspaceId); - - const data = await api.query<{ - projectCreate: { - id: string; - name: string; - description: string; - createdAt: string; - updatedAt: string; - environments: { - edges: Array<{ node: { id: string; name: string } }>; - }; - }; - }>( - `mutation projectCreate($input: ProjectCreateInput!) { - projectCreate(input: $input) { - id - name - description - createdAt - updatedAt - environments { - edges { - node { - id - name - } - } - } - } - }`, - { - input: { - name, - description: props.description, - workspaceId, - }, - }, - ); + if (!project) { + const workspaceId = await resolveWorkspaceId(api, props.workspaceId); + project = await createProject(api, { + name, + description: props.description, + workspaceId, + }); + } - const project = data.projectCreate; const defaultEnv = project.environments.edges.find( (e) => e.node.name === "production", ); @@ -259,23 +229,41 @@ export const Project = Resource( }, ); +const PROJECT_FRAGMENT = ` + id + name + description + createdAt + updatedAt + environments { + edges { + node { + id + name + } + } + } +`; + +interface ProjectFragment { + id: string; + name: string; + description: string; + createdAt: string; + updatedAt: string; + environments: { + edges: Array<{ node: { id: string; name: string } }>; + }; +} + async function findProjectByName( api: RailwayApi, name: string, -): Promise { +): Promise { const data = await api.query<{ projects: { edges: Array<{ - node: { - id: string; - name: string; - description: string; - createdAt: string; - updatedAt: string; - environments: { - edges: Array<{ node: { id: string; name: string } }>; - }; - }; + node: ProjectFragment; }>; }; }>( @@ -283,42 +271,38 @@ async function findProjectByName( projects { edges { node { - id - name - description - createdAt - updatedAt - environments { - edges { - node { - id - name - } - } - } + ${PROJECT_FRAGMENT} } } } }`, ); + return data.projects.edges.find((e) => e.node.name === name)?.node; +} - const match = data.projects.edges.find((e) => e.node.name === name); - if (!match) return undefined; - - const project = match.node; - const defaultEnv = project.environments.edges.find( - (e) => e.node.name === "production", +async function createProject( + api: RailwayApi, + input: { name: string; description: string | undefined; workspaceId: string }, +): Promise { + return await withExponentialBackoff( + async () => { + const data = await api.query<{ + projectCreate: ProjectFragment; + }>( + `mutation projectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + ${PROJECT_FRAGMENT} + } + }`, + { input }, + ); + return data.projectCreate; + }, + // handle "Whoa there pal! Only one project can be created per user every 30s. Try again in a sec" + (error) => hasRailwayErrorPattern(error, ["try again in a sec"]), + 10, + 5000, ); - - return { - projectId: project.id, - name: project.name, - description: project.description, - defaultEnvironmentId: - defaultEnv?.node.id ?? project.environments.edges[0]?.node.id ?? "", - createdAt: project.createdAt, - updatedAt: project.updatedAt, - }; } async function resolveWorkspaceId( From 05c99591c9266b86629f39874ea85eb1bd4421e9 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:04:12 -0500 Subject: [PATCH 16/16] project test: fix invalid project name (cannot contain "railway" apparently?) --- alchemy/test/railway/project.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alchemy/test/railway/project.test.ts b/alchemy/test/railway/project.test.ts index 172bcb38a..b9b51a517 100644 --- a/alchemy/test/railway/project.test.ts +++ b/alchemy/test/railway/project.test.ts @@ -3,8 +3,8 @@ import { alchemy } from "../../src/alchemy.ts"; import { destroy } from "../../src/destroy.ts"; import { RailwayApi } from "../../src/railway/api.ts"; import { Project } from "../../src/railway/project.ts"; -import { BRANCH_PREFIX } from "../util.ts"; import "../../src/test/vitest.ts"; +import { BRANCH_PREFIX } from "../util.ts"; const test = alchemy.test(import.meta, { prefix: BRANCH_PREFIX, @@ -13,7 +13,7 @@ const test = alchemy.test(import.meta, { const skipIfNoToken = !process.env.RAILWAY_API_TOKEN; describe.skipIf(skipIfNoToken)("Railway Project", () => { - const testId = `${BRANCH_PREFIX}-railway-project`; + const testId = `${BRANCH_PREFIX}-project`; test("create, update, and delete project", async (scope) => { let project: Project | undefined;