From 169830f1dda3758f8bd5aade9a5a611ef99d057d Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Thu, 11 Jun 2026 22:34:47 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(railway):=20Railway=20provider=20?= =?UTF-8?q?=E2=80=94=20Project,=20Environment,=20Service,=20Function,=20Da?= =?UTF-8?q?tabase,=20and=20friends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Railway cloud provider with resources (Project, Environment, Service, Function, Database, Variables, Volume, TcpProxy, ServiceDomain, CustomDomain, ProjectToken, Webhook), the DatabaseUrl/VolumeMount bindings, live integration tests sharing a single rate-limit-friendly project, and an end-to-end examples/railway-postgres app (Function + Postgres + DatabaseUrl binding) verified against Railway. Co-authored-by: Cursor --- bun.lock | 117 ++ distilled | 2 +- examples/railway-postgres/README.md | 59 + examples/railway-postgres/alchemy.run.ts | 21 + examples/railway-postgres/package.json | 27 + examples/railway-postgres/src/Api.ts | 98 ++ examples/railway-postgres/src/Postgres.ts | 25 + examples/railway-postgres/src/Project.ts | 9 + examples/railway-postgres/tsconfig.json | 16 + package.json | 1 + packages/alchemy/package.json | 13 + packages/alchemy/src/Platform.ts | 15 +- packages/alchemy/src/Railway/AuthProvider.ts | 250 ++++ packages/alchemy/src/Railway/Connect.ts | 148 ++ packages/alchemy/src/Railway/Credentials.ts | 64 + packages/alchemy/src/Railway/CustomDomain.ts | 230 ++++ packages/alchemy/src/Railway/Database.ts | 430 ++++++ packages/alchemy/src/Railway/DatabaseUrl.ts | 99 ++ packages/alchemy/src/Railway/Environment.ts | 249 ++++ packages/alchemy/src/Railway/Function.ts | 1192 +++++++++++++++++ packages/alchemy/src/Railway/Project.ts | 416 ++++++ packages/alchemy/src/Railway/ProjectToken.ts | 201 +++ packages/alchemy/src/Railway/Providers.ts | 102 ++ packages/alchemy/src/Railway/Reference.ts | 90 ++ packages/alchemy/src/Railway/Service.ts | 721 ++++++++++ packages/alchemy/src/Railway/ServiceDomain.ts | 195 +++ packages/alchemy/src/Railway/TcpProxy.ts | 215 +++ packages/alchemy/src/Railway/Variables.ts | 200 +++ packages/alchemy/src/Railway/Volume.ts | 186 +++ packages/alchemy/src/Railway/VolumeMount.ts | 95 ++ packages/alchemy/src/Railway/Webhook.ts | 279 ++++ packages/alchemy/src/Railway/index.ts | 18 + .../alchemy/test/Railway/CustomDomain.test.ts | 97 ++ .../alchemy/test/Railway/Environment.test.ts | 160 +++ .../alchemy/test/Railway/Function.test.ts | 122 ++ packages/alchemy/test/Railway/Project.test.ts | 61 + .../alchemy/test/Railway/ProjectToken.test.ts | 92 ++ packages/alchemy/test/Railway/Service.test.ts | 163 +++ .../test/Railway/ServiceDomain.test.ts | 95 ++ .../alchemy/test/Railway/TcpProxy.test.ts | 81 ++ .../alchemy/test/Railway/Variables.test.ts | 148 ++ packages/alchemy/test/Railway/Volume.test.ts | 75 ++ packages/alchemy/test/Railway/Webhook.test.ts | 82 ++ .../alchemy/test/Railway/fixtures/function.ts | 67 + packages/alchemy/test/Railway/harness.ts | 99 ++ 45 files changed, 7123 insertions(+), 2 deletions(-) create mode 100644 examples/railway-postgres/README.md create mode 100644 examples/railway-postgres/alchemy.run.ts create mode 100644 examples/railway-postgres/package.json create mode 100644 examples/railway-postgres/src/Api.ts create mode 100644 examples/railway-postgres/src/Postgres.ts create mode 100644 examples/railway-postgres/src/Project.ts create mode 100644 examples/railway-postgres/tsconfig.json create mode 100644 packages/alchemy/src/Railway/AuthProvider.ts create mode 100644 packages/alchemy/src/Railway/Connect.ts create mode 100644 packages/alchemy/src/Railway/Credentials.ts create mode 100644 packages/alchemy/src/Railway/CustomDomain.ts create mode 100644 packages/alchemy/src/Railway/Database.ts create mode 100644 packages/alchemy/src/Railway/DatabaseUrl.ts create mode 100644 packages/alchemy/src/Railway/Environment.ts create mode 100644 packages/alchemy/src/Railway/Function.ts create mode 100644 packages/alchemy/src/Railway/Project.ts create mode 100644 packages/alchemy/src/Railway/ProjectToken.ts create mode 100644 packages/alchemy/src/Railway/Providers.ts create mode 100644 packages/alchemy/src/Railway/Reference.ts create mode 100644 packages/alchemy/src/Railway/Service.ts create mode 100644 packages/alchemy/src/Railway/ServiceDomain.ts create mode 100644 packages/alchemy/src/Railway/TcpProxy.ts create mode 100644 packages/alchemy/src/Railway/Variables.ts create mode 100644 packages/alchemy/src/Railway/Volume.ts create mode 100644 packages/alchemy/src/Railway/VolumeMount.ts create mode 100644 packages/alchemy/src/Railway/Webhook.ts create mode 100644 packages/alchemy/src/Railway/index.ts create mode 100644 packages/alchemy/test/Railway/CustomDomain.test.ts create mode 100644 packages/alchemy/test/Railway/Environment.test.ts create mode 100644 packages/alchemy/test/Railway/Function.test.ts create mode 100644 packages/alchemy/test/Railway/Project.test.ts create mode 100644 packages/alchemy/test/Railway/ProjectToken.test.ts create mode 100644 packages/alchemy/test/Railway/Service.test.ts create mode 100644 packages/alchemy/test/Railway/ServiceDomain.test.ts create mode 100644 packages/alchemy/test/Railway/TcpProxy.test.ts create mode 100644 packages/alchemy/test/Railway/Variables.test.ts create mode 100644 packages/alchemy/test/Railway/Volume.test.ts create mode 100644 packages/alchemy/test/Railway/Webhook.test.ts create mode 100644 packages/alchemy/test/Railway/fixtures/function.ts create mode 100644 packages/alchemy/test/Railway/harness.ts diff --git a/bun.lock b/bun.lock index 1d2d78e6dd..c03d746d9f 100644 --- a/bun.lock +++ b/bun.lock @@ -290,6 +290,22 @@ "effect": "catalog:", }, }, + "distilled/packages/railway": { + "name": "@distilled.cloud/railway", + "version": "0.0.0", + "dependencies": { + "@distilled.cloud/core": "workspace:*", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "dotenv": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "effect": "catalog:", + }, + }, "distilled/packages/stripe": { "name": "@distilled.cloud/stripe", "version": "0.24.9", @@ -752,6 +768,19 @@ "vite": "catalog:", }, }, + "examples/railway-postgres": { + "name": "railway-postgres", + "version": "0.0.0", + "dependencies": { + "@effect/platform-node": "catalog:", + "alchemy": "workspace:*", + "effect": "catalog:", + "pg": "^8.16.3", + }, + "devDependencies": { + "@types/pg": "^8.15.6", + }, + }, "packages/alchemy": { "name": "alchemy", "version": "2.0.0-beta.55", @@ -771,6 +800,7 @@ "@distilled.cloud/core": "catalog:", "@distilled.cloud/neon": "catalog:", "@distilled.cloud/planetscale": "catalog:", + "@distilled.cloud/railway": "catalog:", "@effect/vitest": "catalog:", "@libsql/client": "catalog:", "@octokit/rest": "^22.0.1", @@ -915,6 +945,7 @@ "@distilled.cloud/core": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", + "@distilled.cloud/railway": "workspace:*", "@effect/language-service": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-bun": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-node": ">=4.0.0-beta.78 || >=4.0.0", @@ -1219,6 +1250,8 @@ "@distilled.cloud/prisma-postgres": ["@distilled.cloud/prisma-postgres@workspace:distilled/packages/prisma-postgres"], + "@distilled.cloud/railway": ["@distilled.cloud/railway@workspace:distilled/packages/railway"], + "@distilled.cloud/stripe": ["@distilled.cloud/stripe@workspace:distilled/packages/stripe"], "@distilled.cloud/supabase": ["@distilled.cloud/supabase@workspace:distilled/packages/supabase"], @@ -3627,6 +3660,8 @@ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "railway-postgres": ["railway-postgres@workspace:examples/railway-postgres"], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], @@ -4321,6 +4356,10 @@ "@distilled.cloud/prisma-postgres/vitest": ["vitest@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="], + "@distilled.cloud/railway/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "@distilled.cloud/railway/vitest": ["vitest@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="], + "@distilled.cloud/stripe/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@distilled.cloud/stripe/vitest": ["vitest@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="], @@ -5151,6 +5190,30 @@ "@distilled.cloud/prisma-postgres/vitest/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], + "@distilled.cloud/railway/vitest/@vitest/expect": ["@vitest/expect@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], + + "@distilled.cloud/railway/vitest/@vitest/mocker": ["@vitest/mocker@3.2.6", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="], + + "@distilled.cloud/railway/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.6", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA=="], + + "@distilled.cloud/railway/vitest/@vitest/runner": ["@vitest/runner@3.2.6", "", { "dependencies": { "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q=="], + + "@distilled.cloud/railway/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.6", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw=="], + + "@distilled.cloud/railway/vitest/@vitest/spy": ["@vitest/spy@3.2.6", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg=="], + + "@distilled.cloud/railway/vitest/@vitest/utils": ["@vitest/utils@3.2.6", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg=="], + + "@distilled.cloud/railway/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "@distilled.cloud/railway/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "@distilled.cloud/railway/vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "@distilled.cloud/railway/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@distilled.cloud/railway/vitest/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], + "@distilled.cloud/stripe/vitest/@vitest/expect": ["@vitest/expect@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], "@distilled.cloud/stripe/vitest/@vitest/mocker": ["@vitest/mocker@3.2.6", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="], @@ -5947,6 +6010,8 @@ "@distilled.cloud/prisma-postgres/vitest/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "@distilled.cloud/railway/vitest/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "@distilled.cloud/stripe/vitest/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "@distilled.cloud/supabase/vitest/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], @@ -7117,6 +7182,58 @@ "@distilled.cloud/prisma-postgres/vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@distilled.cloud/railway/vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@distilled.cloud/stripe/vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@distilled.cloud/stripe/vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], diff --git a/distilled b/distilled index 40ff67c2ba..da9ea9128c 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 40ff67c2ba0ce75aa66e6941e0eaa3363886a12f +Subproject commit da9ea9128ce94691486c49503fef7514aba1cff5 diff --git a/examples/railway-postgres/README.md b/examples/railway-postgres/README.md new file mode 100644 index 0000000000..014dbc7233 --- /dev/null +++ b/examples/railway-postgres/README.md @@ -0,0 +1,59 @@ +# railway-postgres + +An end-to-end Effect-native Railway application built with Alchemy: + +- **`Railway.Project`** — the project containing every service ([src/Project.ts](./src/Project.ts)) +- **`Railway.Database`** — a Postgres database with a persistent volume, mirroring Railway's official template ([src/Postgres.ts](./src/Postgres.ts)) +- **`Railway.Function`** — an Effect-native HTTP service bundled and uploaded straight to Railway's build pipeline, exposed on a public `*.up.railway.app` domain ([src/Api.ts](./src/Api.ts)) +- **`Railway.DatabaseUrl`** — binds the database's connection string into the Function via Railway reference variables (`${{Postgres.DATABASE_URL}}`), surfaced at runtime as a `Redacted` +- **`effect/Config`** — `GREETING` is read with `Config.string` in the Init phase, automatically published as a service variable, and re-read from the environment at runtime + +## Routes + +| Route | Behavior | +| --------- | ----------------------------------------------------------------- | +| `/` | Returns the `GREETING` config value | +| `/db` | Runs `select now(), current_database(), version()` over the bound Postgres | +| `/health` | Railway healthcheck | + +## Deploy + +Authenticate with a Railway API token (or run `alchemy login railway`): + +```sh +export RAILWAY_API_TOKEN=... +``` + +Then, from this directory: + +```sh +bun i +bun run deploy +``` + +The stack outputs include the public URL: + +```sh +curl https://.up.railway.app/ +curl https://.up.railway.app/db +``` + +Optionally set `GREETING` in your environment (or `.env`) before deploying to +see the config value flow through to the service. + +## Destroy + +```sh +bun run destroy +``` + +## Notes + +- The Railway Function runtime does not currently provide `RuntimeContext` + to the `fetch` handler, so binding accessors (like the one returned by + `Railway.DatabaseUrl.bind`) fail at runtime with + `Service not found: RuntimeContext`. As a workaround, [src/Api.ts](./src/Api.ts) + captures `RuntimeContext` during Init and re-provides it on the handler. +- Railway rejects service names containing underscores, so the services set + explicit `name`s instead of relying on the generated physical name (which + embeds the stage, e.g. `dev_yourname`). diff --git a/examples/railway-postgres/alchemy.run.ts b/examples/railway-postgres/alchemy.run.ts new file mode 100644 index 0000000000..9ab6546c36 --- /dev/null +++ b/examples/railway-postgres/alchemy.run.ts @@ -0,0 +1,21 @@ +import * as Alchemy from "alchemy"; +import * as Railway from "alchemy/Railway"; +import * as Effect from "effect/Effect"; +import Api from "./src/Api.ts"; + +export default Alchemy.Stack( + "RailwayPostgresExample", + { + providers: Railway.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + // The Project and Postgres database are registered by the Api + // Function itself (see src/Api.ts) — yielding Api deploys all three. + const api = yield* Api; + return { + url: api.url.as(), + projectId: api.projectId, + }; + }), +); diff --git a/examples/railway-postgres/package.json b/examples/railway-postgres/package.json new file mode 100644 index 0000000000..0ba2f56b00 --- /dev/null +++ b/examples/railway-postgres/package.json @@ -0,0 +1,27 @@ +{ + "name": "railway-postgres", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy-effect.git", + "directory": "examples/railway-postgres" + }, + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "dev": "alchemy dev", + "destroy": "alchemy destroy", + "logs": "alchemy logs" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "alchemy": "workspace:*", + "effect": "catalog:", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/pg": "^8.15.6" + } +} diff --git a/examples/railway-postgres/src/Api.ts b/examples/railway-postgres/src/Api.ts new file mode 100644 index 0000000000..bf8a45f6b1 --- /dev/null +++ b/examples/railway-postgres/src/Api.ts @@ -0,0 +1,98 @@ +import * as Railway from "alchemy/Railway"; +import { RuntimeContext } from "alchemy/RuntimeContext"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import pg from "pg"; +import { Postgres } from "./Postgres.ts"; +import { Project } from "./Project.ts"; + +/** + * An Effect-native HTTP service deployed as a Railway Function: + * the program below is bundled, shipped to Railway's build pipeline, + * and served on a public `*.up.railway.app` domain. + * + * - `GET /` — greeting read from `effect/Config` (a service variable) + * - `GET /db` — round-trips a query through the bound Postgres database + * - `GET /health` — healthcheck probed by Railway before routing traffic + */ +export default class Api extends Railway.Function()( + "Api", + Effect.gen(function* () { + const project = yield* Project; + return { + main: import.meta.filename, + project, + name: "api", + // `pg` stays external to the bundle; Railway installs it during + // the image build (see FunctionProps.external). + external: ["pg"], + healthcheckPath: "/health", + }; + }), + Effect.gen(function* () { + // Resolved at deploy time, published as a Railway service variable, + // and re-read from process.env at runtime — no binding needed. + const greeting = yield* Config.string("GREETING").pipe( + Config.withDefault("Hello from Railway!"), + ); + + // Deploy time: writes `POSTGRES_URL=${{.DATABASE_URL}}` onto this + // service via Railway reference-variable syntax. Runtime: yields the + // resolved connection string as a Redacted. + const postgres = yield* Postgres; + const databaseUrl = yield* Railway.DatabaseUrl.bind(postgres); + + // TODO(alchemy): the Railway Function runtime does not currently + // provide RuntimeContext to the fetch handler, so binding accessors + // like `databaseUrl` fail with "Service not found: RuntimeContext" + // at runtime. Capture it during Init and re-provide it below. + const runtimeContext = yield* RuntimeContext; + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + + if (request.url.startsWith("/health")) { + return HttpServerResponse.text("ok"); + } + + if (request.url.startsWith("/db")) { + const url = Redacted.value(yield* databaseUrl); + return yield* queryDb(url).pipe( + Effect.flatMap((row) => HttpServerResponse.json(row)), + Effect.catchTag("UnknownError", (error) => + HttpServerResponse.json( + { error: String(error.cause) }, + { status: 500 }, + ), + ), + ); + } + + return yield* HttpServerResponse.json({ message: greeting }); + }).pipe(Effect.provideService(RuntimeContext, runtimeContext)), + }; + }).pipe(Effect.provide(Railway.DatabaseUrlLive)), +) {} + +/** Run a single query over a fresh connection and close it. */ +const queryDb = (connectionString: string) => + Effect.tryPromise(async () => { + const client = new pg.Client({ connectionString }); + await client.connect(); + try { + const result = await client.query( + "select now() as now, current_database() as database, version() as version", + ); + return result.rows[0] as { + now: string; + database: string; + version: string; + }; + } finally { + await client.end(); + } + }); diff --git a/examples/railway-postgres/src/Postgres.ts b/examples/railway-postgres/src/Postgres.ts new file mode 100644 index 0000000000..7f1d4e487a --- /dev/null +++ b/examples/railway-postgres/src/Postgres.ts @@ -0,0 +1,25 @@ +import * as Railway from "alchemy/Railway"; +import * as Effect from "effect/Effect"; +import { Project } from "./Project.ts"; + +/** + * A Railway-hosted Postgres database — same image/volume/variable layout + * as Railway's official Postgres template, fully reconciled by Alchemy. + * + * The project must be `yield*`ed before it can be referenced from the + * database's props, so this is an Effect that registers (or resolves) + * both resources. + * + * The connection string is consumed by the API Function through the + * `Railway.DatabaseUrl` binding (see src/Api.ts). + */ +export const Postgres = Effect.gen(function* () { + const project = yield* Project; + return yield* Railway.Database("Postgres", { + project, + // Deploy into the project's default environment explicitly. + environment: { environmentId: project.defaultEnvironmentId }, + kind: "postgres", + name: "postgres", + }); +}); diff --git a/examples/railway-postgres/src/Project.ts b/examples/railway-postgres/src/Project.ts new file mode 100644 index 0000000000..8375712a98 --- /dev/null +++ b/examples/railway-postgres/src/Project.ts @@ -0,0 +1,9 @@ +import * as Railway from "alchemy/Railway"; + +/** + * The Railway project that contains every service in this example + * (the API Function and the Postgres database). + */ +export const Project = Railway.Project("Project", { + name: "railway-postgres-example", +}); diff --git a/examples/railway-postgres/tsconfig.json b/examples/railway-postgres/tsconfig.json new file mode 100644 index 0000000000..586e7d2229 --- /dev/null +++ b/examples/railway-postgres/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["alchemy.run.ts", "src/**/*.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/package.json b/package.json index 67f7aa5047..3695847e24 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@distilled.cloud/core": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", + "@distilled.cloud/railway": "workspace:*", "@effect/language-service": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-bun": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-node-shared": ">=4.0.0-beta.78 || >=4.0.0", diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index 2dc80548ce..cdd7c59a8a 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -208,6 +208,18 @@ "worker": "./src/Planetscale/*/index.ts", "import": "./lib/Planetscale/*/index.js" }, + "./Railway": { + "types": "./lib/Railway/index.d.ts", + "bun": "./src/Railway/index.ts", + "worker": "./src/Railway/index.ts", + "import": "./lib/Railway/index.js" + }, + "./Railway/*": { + "types": "./lib/Railway/*/index.d.ts", + "bun": "./src/Railway/*/index.ts", + "worker": "./src/Railway/*/index.ts", + "import": "./lib/Railway/*/index.js" + }, "./Runtime": { "types": "./lib/Runtime.d.ts", "bun": "./src/Runtime.ts", @@ -300,6 +312,7 @@ "@distilled.cloud/core": "catalog:", "@distilled.cloud/neon": "catalog:", "@distilled.cloud/planetscale": "catalog:", + "@distilled.cloud/railway": "catalog:", "@effect/vitest": "catalog:", "@libsql/client": "catalog:", "@octokit/rest": "^22.0.1", diff --git a/packages/alchemy/src/Platform.ts b/packages/alchemy/src/Platform.ts index 2f13852d48..9c921dbd36 100644 --- a/packages/alchemy/src/Platform.ts +++ b/packages/alchemy/src/Platform.ts @@ -309,7 +309,20 @@ export const Platform = < Self, Effect.flatMap( Effect.all([ - Effect.isEffect(props) ? props : Effect.succeed(props ?? {}), + // The props effect describes deploy-time configuration and may + // require deploy-only services (cloud credentials, API calls). + // Inside the deployed runtime nothing consumes Props — the + // RuntimeContext/env is the source of truth — so skip it there + // instead of crashing on missing deploy-time services. + Effect.isEffect(props) + ? ALCHEMY_PHASE.pipe( + Effect.flatMap((phase) => + phase === "runtime" + ? Effect.succeed({}) + : (props as Effect.Effect), + ), + ) + : Effect.succeed(props ?? {}), Effect.sync(() => hooks.createRuntimeContext(id)), Effect.context(), ]), diff --git a/packages/alchemy/src/Railway/AuthProvider.ts b/packages/alchemy/src/Railway/AuthProvider.ts new file mode 100644 index 0000000000..51c76439e3 --- /dev/null +++ b/packages/alchemy/src/Railway/AuthProvider.ts @@ -0,0 +1,250 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Match from "effect/Match"; +import * as Redacted from "effect/Redacted"; +import { + AuthError, + AuthProviderLayer, + type ConfigureContext, +} from "../Auth/AuthProvider.ts"; +import { CredentialsStore, displayRedacted } from "../Auth/Credentials.ts"; +import { getEnvRedacted, retryOnce } from "../Auth/Env.ts"; +import { AlchemyProfile } from "../Auth/Profile.ts"; +import * as Clank from "../Util/Clank.ts"; + +export const RAILWAY_AUTH_PROVIDER_NAME = "Railway"; + +export type RailwayAuthConfig = { method: "env" } | { method: "stored" }; + +export type RailwayStoredCredentials = { + type: "apiToken"; + apiToken: string; +}; + +export type RailwayResolvedCredentials = { + type: "apiToken" | "projectToken"; + token: Redacted.Redacted; + source: { type: RailwayAuthConfig["method"]; details?: string }; +}; + +const options: Array<{ + value: RailwayAuthConfig["method"]; + label: string; + hint?: string; +}> = [ + { + value: "env", + label: "Environment Variable", + hint: "RAILWAY_API_TOKEN or RAILWAY_PROJECT_TOKEN", + }, + { + value: "stored", + label: "API Token", + hint: "enter interactively, stored in ~/.alchemy/credentials", + }, +]; + +/** + * Layer that registers the Railway {@link AuthProvider} into the + * {@link AuthProviders} registry. + */ +export const RailwayAuth = AuthProviderLayer< + RailwayAuthConfig, + RailwayResolvedCredentials +>()( + RAILWAY_AUTH_PROVIDER_NAME, + Effect.gen(function* () { + const profiles = yield* AlchemyProfile; + const store = yield* CredentialsStore; + + const loginStored = Effect.fnUntraced(function* (profileName: string) { + const apiToken = yield* Clank.password({ + message: "Railway API Token (https://railway.com/account/tokens)", + validate: (v) => (v.length === 0 ? "Required" : undefined), + }).pipe(retryOnce); + + yield* store.write( + profileName, + "railway-stored", + { + type: "apiToken", + apiToken, + }, + ); + yield* Clank.success("Railway: credentials saved."); + return { method: "stored" as const }; + }); + + const configureInteractive = (profileName: string) => + Clank.select({ + message: "Railway authentication method", + options, + }).pipe( + Effect.flatMap((method) => + Match.value(method).pipe( + Match.when("env", () => Effect.succeed({ method: "env" as const })), + Match.when("stored", () => loginStored(profileName)), + Match.exhaustive, + ), + ), + ); + + const configureCredentials = (profileName: string, ctx: ConfigureContext) => + Effect.gen(function* () { + if (ctx.ci) { + return { method: "env" as const }; + } + return yield* configureInteractive(profileName); + }).pipe( + Effect.mapError( + (e) => + new AuthError({ + message: "failed to configure credentials", + cause: e, + }), + ), + ); + + const resolveCredentials = ( + profileName: string, + config: RailwayAuthConfig, + ): Effect.Effect => + Match.value(config).pipe( + Match.when( + { method: "env" }, + Effect.fnUntraced(function* () { + const apiToken = yield* getEnvRedacted("RAILWAY_API_TOKEN"); + if (apiToken) { + return { + type: "apiToken" as const, + token: apiToken, + source: { type: "env" as const, details: "RAILWAY_API_TOKEN" }, + }; + } + const projectToken = yield* getEnvRedacted("RAILWAY_PROJECT_TOKEN"); + if (projectToken) { + return { + type: "projectToken" as const, + token: projectToken, + source: { + type: "env" as const, + details: "RAILWAY_PROJECT_TOKEN", + }, + }; + } + return yield* new AuthError({ + message: + "Railway env credentials not found. Set RAILWAY_API_TOKEN (or RAILWAY_PROJECT_TOKEN).", + }); + }), + ), + Match.when({ method: "stored" }, () => + store + .read(profileName, "railway-stored") + .pipe( + Effect.flatMap((creds) => + creds == null + ? Effect.fail( + new AuthError({ + message: + "Railway stored credentials not found. Run: alchemy-effect login --configure", + }), + ) + : Effect.succeed({ + type: "apiToken" as const, + token: Redacted.make(creds.apiToken), + source: { type: "stored" as const }, + }), + ), + ), + ), + Match.exhaustive, + ); + + const logout = (profileName: string, config: RailwayAuthConfig) => + Match.value(config).pipe( + Match.when({ method: "env" }, () => Effect.void), + Match.when({ method: "stored" }, () => + store + .delete(profileName, "railway-stored") + .pipe( + Effect.andThen( + Clank.success("Railway: stored credentials removed"), + ), + ), + ), + Match.exhaustive, + ); + + const login = (profileName: string, config: RailwayAuthConfig) => + Match.value(config) + .pipe( + Match.when({ method: "env" }, () => + // If neither env var is set, fall through to the interactive + // picker so the user can switch to `stored` instead of silently + // failing later in `read`. + getEnvRedacted("RAILWAY_API_TOKEN").pipe( + Effect.flatMap((apiToken) => + apiToken + ? Effect.void + : getEnvRedacted("RAILWAY_PROJECT_TOKEN").pipe( + Effect.flatMap((projectToken) => + projectToken + ? Effect.void + : Effect.gen(function* () { + const next = + yield* configureInteractive(profileName); + const existing = + yield* profiles.getProfile(profileName); + yield* profiles.setProfile(profileName, { + ...existing, + [RAILWAY_AUTH_PROVIDER_NAME]: next, + }); + }), + ), + ), + ), + ), + ), + Match.when({ method: "stored" }, () => + store + .read(profileName, "railway-stored") + .pipe( + Effect.flatMap((creds) => + creds == null ? loginStored(profileName) : Effect.void, + ), + ), + ), + Match.exhaustive, + ) + .pipe( + Effect.mapError( + (e) => new AuthError({ message: "login failed", cause: e }), + ), + ); + + const prettyPrint = (profileName: string, config: RailwayAuthConfig) => + resolveCredentials(profileName, config).pipe( + Effect.tap((creds) => { + const sourceStr = creds.source.details + ? `${creds.source.type} - ${creds.source.details}` + : creds.source.type; + return Effect.all([ + Console.log(` token: ${displayRedacted(creds.token, 9)}`), + Console.log(` source: ${sourceStr}`), + ]); + }), + Effect.catch((e) => + Console.error(` Failed to retrieve credentials: ${e}`), + ), + ); + + return { + configure: configureCredentials, + logout, + login, + prettyPrint, + read: resolveCredentials, + }; + }), +); diff --git a/packages/alchemy/src/Railway/Connect.ts b/packages/alchemy/src/Railway/Connect.ts new file mode 100644 index 0000000000..f04f91c201 --- /dev/null +++ b/packages/alchemy/src/Railway/Connect.ts @@ -0,0 +1,148 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Binding from "../Binding.ts"; +import { RuntimeContext } from "../RuntimeContext.ts"; +import { + bindingEnvPrefix, + isBindingHost, + serviceReference, + type BindingHost, +} from "./Reference.ts"; + +/** + * Typed runtime handle to a bound Railway service, returned by + * `Connect.bind(service)`. Every accessor requires {@link RuntimeContext} — + * they only make sense inside a deployed Railway service whose variables + * were reconciled with the `ConnectPolicy` env bindings. + */ +export interface ServiceConnection { + /** + * The target's private-network domain, e.g. `my-api.railway.internal`. + * Only reachable from inside the same Railway environment. + */ + readonly privateDomain: Effect.Effect; + /** + * `http://` (optionally with a port). Railway's private + * network is HTTP — TLS terminates at the public edge only. + */ + readonly privateUrl: (options?: { + readonly port?: number; + }) => Effect.Effect; + /** + * The target's public domain (e.g. `my-api-production-xxxx.up.railway.app`), + * or `undefined` when the target has no public domain attached. + */ + readonly publicDomain: Effect.Effect< + string | undefined, + never, + RuntimeContext + >; + /** + * `https://`, or `undefined` when the target has no public + * domain attached. + */ + readonly publicUrl: Effect.Effect; +} + +/** + * Service-to-service connectivity binding. + * + * At deploy time, {@link ConnectPolicy} injects the target service's + * private and public domains into the consuming service's variables using + * Railway reference-variable syntax (`${{Name.RAILWAY_PRIVATE_DOMAIN}}`), + * so Railway keeps the values fresh even if the target's domains change. + * + * At runtime, `Connect.bind(service)` returns a {@link ServiceConnection} + * with typed URL accessors. + * + * @example + * ```typescript + * const api = yield* Connect.bind(apiService); + * const url = yield* api.privateUrl({ port: 8080 }); + * // => http://my-api.railway.internal:8080 + * ``` + */ +export class Connect extends Binding.Service< + Connect, + (service: BindingHost) => Effect.Effect +>()("Railway.Connect") {} + +const readEnv = (key: string) => + RuntimeContext.pipe( + Effect.map((ctx) => { + const value = ctx.env[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; + }), + ); + +export const ConnectLive = Layer.effect( + Connect, + Effect.gen(function* () { + const Policy = yield* ConnectPolicy; + + return Effect.fn(function* (service: BindingHost) { + yield* Policy(service); + const prefix = bindingEnvPrefix(service); + + const privateDomain = readEnv(`${prefix}_PRIVATE_DOMAIN`).pipe( + Effect.flatMap((domain) => + domain !== undefined + ? Effect.succeed(domain) + : Effect.die( + `Railway.Connect: env var '${prefix}_PRIVATE_DOMAIN' is not set — ` + + `was '${service.LogicalId}' bound via ConnectPolicy at deploy time?`, + ), + ), + ); + const publicDomain = readEnv(`${prefix}_PUBLIC_DOMAIN`); + + const connection: ServiceConnection = { + privateDomain, + privateUrl: (options) => + Effect.map(privateDomain, (domain) => + options?.port !== undefined + ? `http://${domain}:${options.port}` + : `http://${domain}`, + ), + publicDomain, + publicUrl: Effect.map(publicDomain, (domain) => + domain !== undefined ? `https://${domain}` : undefined, + ), + }; + return connection; + }); + }), +); + +/** + * Deploy-time half of {@link Connect}: records the target service's + * private/public domains as env vars on the consuming Railway service. + */ +export class ConnectPolicy extends Binding.Policy< + ConnectPolicy, + (service: BindingHost) => Effect.Effect +>()("Railway.Connect") {} + +export const ConnectPolicyLive = ConnectPolicy.layer.succeed( + Effect.fn(function* (host, service) { + if (isBindingHost(host)) { + const prefix = bindingEnvPrefix(service); + yield* host.bind`Connect(${host}, ${service})`({ + env: { + [`${prefix}_PRIVATE_DOMAIN`]: serviceReference( + service, + "RAILWAY_PRIVATE_DOMAIN", + ), + [`${prefix}_PUBLIC_DOMAIN`]: serviceReference( + service, + "RAILWAY_PUBLIC_DOMAIN", + ), + }, + }); + } else { + return yield* Effect.die( + `ConnectPolicy does not support runtime '${host.Type}'`, + ); + } + }), +); diff --git a/packages/alchemy/src/Railway/Credentials.ts b/packages/alchemy/src/Railway/Credentials.ts new file mode 100644 index 0000000000..f5f402af93 --- /dev/null +++ b/packages/alchemy/src/Railway/Credentials.ts @@ -0,0 +1,64 @@ +import { ConfigError } from "@distilled.cloud/core/errors"; +import { Credentials, DEFAULT_API_BASE_URL } from "@distilled.cloud/railway"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { getAuthProvider } from "../Auth/AuthProvider.ts"; +import { ALCHEMY_PROFILE, AlchemyProfile } from "../Auth/Profile.ts"; +import { + RAILWAY_AUTH_PROVIDER_NAME, + type RailwayAuthConfig, + type RailwayResolvedCredentials, +} from "./AuthProvider.ts"; + +export { Credentials } from "@distilled.cloud/railway"; + +export const fromAuthProvider = () => + Layer.effect( + Credentials, + Effect.gen(function* () { + const profile = yield* AlchemyProfile; + const auth = yield* getAuthProvider< + RailwayAuthConfig, + RailwayResolvedCredentials + >(RAILWAY_AUTH_PROVIDER_NAME); + const profileName = yield* ALCHEMY_PROFILE; + const ci = yield* Config.boolean("CI").pipe(Config.withDefault(false)); + const apiBaseUrl = yield* Config.string("RAILWAY_API_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ); + + // Env-first: when RAILWAY_API_TOKEN / RAILWAY_PROJECT_TOKEN is set we + // never prompt — read directly without touching the profile store. + const envCreds = yield* auth + .read(profileName, { method: "env" }) + .pipe(Effect.option); + if (envCreds._tag === "Some") { + const creds = envCreds.value; + return Effect.succeed( + creds.type === "projectToken" + ? { projectToken: creds.token, apiBaseUrl } + : { apiToken: creds.token, apiBaseUrl }, + ); + } + + return yield* profile.loadOrConfigure(auth, profileName, { ci }).pipe( + Effect.flatMap((config) => + auth.read(profileName, config as RailwayAuthConfig), + ), + Effect.map((creds) => + creds.type === "projectToken" + ? { projectToken: creds.token, apiBaseUrl } + : { apiToken: creds.token, apiBaseUrl }, + ), + Effect.mapError( + (e) => + new ConfigError({ + message: `Failed to resolve Railway credentials for profile '${profileName}': ${(e as { message?: string }).message ?? String(e)}`, + }), + ), + Effect.orDie, + Effect.cached, + ); + }), + ); diff --git a/packages/alchemy/src/Railway/CustomDomain.ts b/packages/alchemy/src/Railway/CustomDomain.ts new file mode 100644 index 0000000000..b0bd181d0d --- /dev/null +++ b/packages/alchemy/src/Railway/CustomDomain.ts @@ -0,0 +1,230 @@ +import { + createCustomDomain, + deleteCustomDomain, + getDomains, + getService, + updateCustomDomain, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { resolveEnvironmentId, type EnvironmentSource } from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { resolveServiceId, type ServiceSource } from "./Service.ts"; + +export type DnsRecord = { + fqdn: string; + recordType: string; + requiredValue: string; + currentValue: string | undefined; + zone: string | undefined; + status: string | undefined; +}; + +export type CustomDomainProps = { + /** + * The Railway service (or `{ serviceId }`) the domain routes to. + */ + service: ServiceSource; + /** + * The environment (or `{ environmentId }`) the domain routes to. + * Defaults to the environment of the given `Service` resource. + */ + environment?: EnvironmentSource; + /** + * Fully-qualified domain name to attach, e.g. `api.example.com`. + * Changing it replaces the domain. + */ + domain: string; + /** + * Container port the domain routes to. Defaults to Railway's detected + * port. + */ + targetPort?: number; +}; + +export type CustomDomain = Resource< + "Railway.CustomDomain", + CustomDomainProps, + { + domainId: string; + domain: string; + projectId: string; + serviceId: string; + environmentId: string; + targetPort: number | undefined; + /** + * DNS records that must be created at your DNS provider for the + * domain to verify and route to Railway. + */ + dnsRecords: DnsRecord[]; + }, + never, + Providers +>; + +/** + * A custom domain attached to a Railway service. Railway provisions the + * TLS certificate once the required DNS records are in place. + * + * @section Attaching a Custom Domain + * @example Attach a domain to a service + * ```typescript + * const api = yield* Railway.Service("api", { + * project, + * source: { image: "nginx:alpine" }, + * }); + * const domain = yield* Railway.CustomDomain("api-domain", { + * service: api, + * domain: "api.example.com", + * }); + * // create the records in `domain.dnsRecords` at your DNS provider + * ``` + * + * @see https://docs.railway.com/guides/public-networking#custom-domains + */ +export const CustomDomain = Resource("Railway.CustomDomain"); + +const resolveIds = (props: CustomDomainProps) => { + const serviceId = resolveServiceId(props.service); + const environmentId = props.environment + ? resolveEnvironmentId(props.environment) + : "environmentId" in props.service && props.service.environmentId + ? (props.service.environmentId as unknown as string) + : undefined; + if (!environmentId) { + throw new Error( + "Railway.CustomDomain requires `environment` when `service` is not a Service resource", + ); + } + return { serviceId, environmentId }; +}; + +const toDnsRecords = ( + records: + | ReadonlyArray<{ + fqdn: string; + recordType: string; + requiredValue: string; + currentValue?: string | null; + zone?: string | null; + status?: string | null; + }> + | null + | undefined, +): DnsRecord[] => + (records ?? []).map((r) => ({ + fqdn: r.fqdn, + recordType: r.recordType, + requiredValue: r.requiredValue, + currentValue: r.currentValue ?? undefined, + zone: r.zone ?? undefined, + status: r.status ?? undefined, + })); + +export const CustomDomainProvider = () => + Provider.succeed(CustomDomain, { + stables: ["domainId", "projectId", "serviceId", "environmentId"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const { serviceId, environmentId } = resolveIds(news); + if ( + news.domain !== output.domain || + serviceId !== output.serviceId || + environmentId !== output.environmentId + ) { + return { action: "replace" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.domainId) return undefined; + const domains = yield* getDomains({ + projectId: output.projectId, + environmentId: output.environmentId, + serviceId: output.serviceId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + const match = domains?.customDomains.find( + (d) => d.id === output.domainId, + ); + if (!match) return undefined; + return { + ...output, + domain: match.domain, + targetPort: match.targetPort ?? undefined, + }; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { serviceId, environmentId } = resolveIds(news); + const projectId = + output?.projectId ?? (yield* getService({ id: serviceId })).projectId; + + // Observe — find the existing custom domain by id or name. + const domains = yield* getDomains({ + projectId, + environmentId, + serviceId, + }); + const observed = domains.customDomains.find( + (d) => d.id === output?.domainId || d.domain === news.domain, + ); + + if (!observed) { + // Ensure — attach the domain. + const created = yield* createCustomDomain({ + input: { + projectId, + serviceId, + environmentId, + domain: news.domain, + targetPort: news.targetPort, + }, + }); + return { + domainId: created.id, + domain: created.domain, + projectId, + serviceId, + environmentId, + targetPort: created.targetPort ?? undefined, + dnsRecords: toDnsRecords(created.status.dnsRecords as any), + }; + } + + // Sync — adjust the target port when it differs. + if ( + news.targetPort !== undefined && + (observed.targetPort ?? undefined) !== news.targetPort + ) { + yield* updateCustomDomain({ + id: observed.id, + environmentId, + targetPort: news.targetPort, + }); + } + + return { + domainId: observed.id, + domain: observed.domain, + projectId, + serviceId, + environmentId, + targetPort: news.targetPort ?? observed.targetPort ?? undefined, + // dnsRecords are only returned on create — keep the cached copy. + dnsRecords: output?.dnsRecords ?? [], + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteCustomDomain({ id: output.domainId }).pipe( + Effect.catchTags({ + NotAuthorized: () => Effect.void, + ProblemProcessingRequest: () => Effect.void, + }), + ); + }), + }); diff --git a/packages/alchemy/src/Railway/Database.ts b/packages/alchemy/src/Railway/Database.ts new file mode 100644 index 0000000000..159d84fcab --- /dev/null +++ b/packages/alchemy/src/Railway/Database.ts @@ -0,0 +1,430 @@ +import { + createService, + createVolume, + deleteService, + deleteVolume, + deployServiceInstanceV2, + getService, + getServiceInstance, + getVariables, + updateService, + upsertVariableCollection, +} from "@distilled.cloud/railway"; +import * as crypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + resolveEnvironmentId, + resolveProjectId, + type EnvironmentSource, + type ProjectSource, +} from "./Environment.ts"; +import { resolveDefaultEnvironment } from "./Project.ts"; +import type { Providers } from "./Providers.ts"; +import { + findServiceByName, + waitForDeployment, + type DeploymentStatus, +} from "./Service.ts"; + +/** + * The built-in database engines Railway offers via its official + * templates. + */ +export type DatabaseKind = "postgres" | "redis" | "mysql" | "mongo"; + +interface DatabaseSpec { + /** Docker image the official Railway template deploys. */ + image: string; + /** Port the engine listens on inside the private network. */ + port: number; + /** Where the data volume is mounted. */ + mountPath: string; + /** Name of the service variable carrying the connection URL. */ + urlVariable: string; + /** Default username baked into the engine variables. */ + username: string; + /** Default logical database name (where the engine has one). */ + database: string | undefined; + /** + * Build the service variables. `RAILWAY_PRIVATE_DOMAIN` is referenced + * with Railway's own-service template syntax so the connection URL + * always tracks the live private domain. + */ + variables: (password: string) => Record; +} + +const PRIVATE_DOMAIN = "${{RAILWAY_PRIVATE_DOMAIN}}"; + +/** + * Mirrors the variable/volume layout of Railway's official database + * templates (the SDK exposes no way to discover the service a + * `templateDeployV2` workflow created, so we deploy the same images + * directly — every aspect stays observable and reconcilable). + */ +const DATABASE_SPECS: Record = { + postgres: { + image: "ghcr.io/railwayapp-templates/postgres-ssl:16", + port: 5432, + mountPath: "/var/lib/postgresql/data", + urlVariable: "DATABASE_URL", + username: "postgres", + database: "railway", + variables: (password) => ({ + PGDATA: "/var/lib/postgresql/data/pgdata", + POSTGRES_USER: "postgres", + POSTGRES_PASSWORD: password, + POSTGRES_DB: "railway", + DATABASE_URL: `postgresql://postgres:${password}@${PRIVATE_DOMAIN}:5432/railway`, + }), + }, + redis: { + image: "bitnami/redis:7.2.5", + port: 6379, + mountPath: "/bitnami", + urlVariable: "REDIS_URL", + username: "default", + database: undefined, + variables: (password) => ({ + REDIS_PASSWORD: password, + // bitnami images run as non-root; Railway volumes need uid 0. + RAILWAY_RUN_UID: "0", + REDIS_URL: `redis://default:${password}@${PRIVATE_DOMAIN}:6379`, + }), + }, + mysql: { + image: "mysql:8", + port: 3306, + mountPath: "/var/lib/mysql", + urlVariable: "MYSQL_URL", + username: "root", + database: "railway", + variables: (password) => ({ + MYSQL_ROOT_PASSWORD: password, + MYSQL_DATABASE: "railway", + MYSQL_URL: `mysql://root:${password}@${PRIVATE_DOMAIN}:3306/railway`, + }), + }, + mongo: { + image: "mongo:7", + port: 27017, + mountPath: "/data/db", + urlVariable: "MONGO_URL", + username: "mongo", + database: undefined, + variables: (password) => ({ + MONGO_INITDB_ROOT_USERNAME: "mongo", + MONGO_INITDB_ROOT_PASSWORD: password, + MONGO_URL: `mongodb://mongo:${password}@${PRIVATE_DOMAIN}:27017`, + }), + }, +}; + +export type DatabaseProps = { + /** + * The Railway project (or `{ projectId }`) to create the database in. + */ + project: ProjectSource; + /** + * The environment (or `{ environmentId }`) to deploy into. Defaults to + * the project's base environment when the project is a `Project` + * resource (otherwise required). + */ + environment?: EnvironmentSource; + /** + * The database engine to deploy. + */ + kind: DatabaseKind; + /** + * Service name. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. + */ + name?: string; + /** + * Region to deploy into, e.g. `us-west2`. + */ + region?: string; +}; + +export type Database = Resource< + "Railway.Database", + DatabaseProps, + { + serviceId: string; + name: string; + projectId: string; + environmentId: string; + volumeId: string; + kind: DatabaseKind; + /** Port the engine listens on inside the private network. */ + port: number; + /** Name of the service variable carrying the connection URL, e.g. `DATABASE_URL`. */ + urlVariable: string; + username: string; + database: string | undefined; + /** Generated admin password (persisted in state). */ + password: string; + deploymentId: string | undefined; + deploymentStatus: DeploymentStatus | undefined; + }, + never, + Providers +>; + +/** + * A Railway-hosted database — the same images, volumes, and variables as + * Railway's official database templates (Postgres, Redis, MySQL, + * MongoDB), deployed as a fully reconciled service with a persistent + * volume and a private-network connection URL. + * + * Consume the connection string from another Service/Function with the + * `DatabaseUrl` binding, which injects it via Railway reference-variable + * syntax (`${{Postgres.DATABASE_URL}}`) so Railway keeps it fresh. + * + * @section Creating a Database + * @example Postgres + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const db = yield* Railway.Database("db", { + * project, + * kind: "postgres", + * }); + * ``` + * + * @example Redis + * ```typescript + * const cache = yield* Railway.Database("cache", { + * project, + * kind: "redis", + * }); + * ``` + * + * @section Consuming a Database + * @example Bind the connection URL into a Function + * ```typescript + * Effect.gen(function* () { + * const url = yield* Railway.DatabaseUrl.bind(db); + * return { + * fetch: Effect.gen(function* () { + * const connectionString = Redacted.value(yield* url); + * // ... connect with your driver of choice + * }), + * }; + * }).pipe(Effect.provide(Railway.DatabaseUrlLive)) + * ``` + * + * @see https://docs.railway.com/guides/databases + */ +export const Database = Resource("Railway.Database"); + +export const isDatabase = (value: any): value is Database => + typeof value === "object" && + value !== null && + "Type" in value && + value.Type === "Railway.Database"; + +const generatePassword = Effect.sync(() => + crypto.randomBytes(24).toString("base64url"), +); + +export const DatabaseProvider = () => + Provider.succeed(Database, { + stables: [ + "serviceId", + "projectId", + "environmentId", + "volumeId", + "kind", + "port", + "urlVariable", + "username", + "database", + "password", + ], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const projectId = resolveProjectId(news.project as ProjectSource); + if (projectId !== output.projectId) { + return { action: "replace" } as const; + } + if ( + news.environment && + resolveEnvironmentId(news.environment as EnvironmentSource) !== + output.environmentId + ) { + return { action: "replace" } as const; + } + if (news.kind !== output.kind) { + return { action: "replace" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.serviceId) return undefined; + return yield* getService({ id: output.serviceId }).pipe( + Effect.map((service) => ({ + ...output, + name: service.name, + projectId: service.projectId, + })), + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const spec = DATABASE_SPECS[news.kind]; + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = news.environment + ? resolveEnvironmentId(news.environment as EnvironmentSource) + : (yield* resolveDefaultEnvironment(projectId)).id; + // Railway service names are documented at max 32 characters. + const name = + news.name ?? + (yield* createPhysicalName({ id, lowercase: true, maxLength: 32 })); + + // The password is generated once and persisted in state; rotation + // would require coordinated re-provisioning of the engine's data + // directory, so it is deliberately stable. + const password = output?.password ?? (yield* generatePassword); + const desiredVariables = spec.variables(password); + + // Observe — does the service still exist? Soft-deleted services are + // still returned by getService with `deletedAt` set. + const observed = output?.serviceId + ? yield* getService({ id: output.serviceId }).pipe( + Effect.map((s) => (s.deletedAt !== null ? undefined : s)), + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ) + : undefined; + + // Ensure — create the database service if missing, else sync name. + let serviceId: string; + let createdNow = false; + if (!observed) { + const created = yield* createService({ + input: { + projectId, + environmentId, + name, + source: { image: spec.image }, + variables: desiredVariables, + }, + }).pipe( + // AlreadyExists race / orphan from a crashed run — adopt the + // existing service with that name; the sync steps below converge + // it. If the name is held by a mid-deletion service, the + // conflict is transient — retry until it frees up. + Effect.catchTag("ServiceNameConflict", (conflict) => + findServiceByName(environmentId, name).pipe( + Effect.flatMap((match) => + match + ? Effect.succeed({ id: match.serviceId }) + : Effect.fail(conflict), + ), + ), + ), + Effect.retry({ + while: (e) => e._tag === "ServiceNameConflict", + schedule: Schedule.spaced("2 seconds"), + times: 90, + }), + ); + serviceId = created.id; + createdNow = true; + } else { + serviceId = observed.id; + if (observed.name !== name) { + yield* updateService({ id: serviceId, input: { name } }); + } + } + + // Ensure — the data volume. Railway exposes no read API for volume + // instances, so `output.volumeId` is the cache of record here. + let volumeId = output?.volumeId; + if (!volumeId) { + const volume = yield* createVolume({ + input: { + projectId, + environmentId, + serviceId, + mountPath: spec.mountPath, + region: news.region, + }, + }); + volumeId = volume.id; + } + + // Sync — variables, diffed against observed cloud variables. + const observedVariables = (yield* getVariables({ + projectId, + environmentId, + serviceId, + })) as Record; + const upserts: Record = {}; + for (const [key, value] of Object.entries(desiredVariables)) { + if (observedVariables[key] !== value) upserts[key] = value; + } + const variablesChanged = Object.keys(upserts).length > 0; + if (variablesChanged) { + yield* upsertVariableCollection({ + input: { + projectId, + environmentId, + serviceId, + variables: upserts, + skipDeploys: true, + }, + }); + } + + // Deploy — when anything changed or no healthy deployment exists. + const instance = yield* getServiceInstance({ serviceId, environmentId }); + let deploymentId = instance.latestDeployment?.id ?? undefined; + let deploymentStatus = (instance.latestDeployment?.status ?? + undefined) as DeploymentStatus | undefined; + const shouldDeploy = + createdNow || + variablesChanged || + deploymentStatus === undefined || + deploymentStatus === "FAILED" || + deploymentStatus === "CRASHED" || + deploymentStatus === "REMOVED" || + deploymentStatus === "SKIPPED"; + if (shouldDeploy) { + deploymentId = yield* deployServiceInstanceV2({ + serviceId, + environmentId, + }); + deploymentStatus = yield* waitForDeployment(serviceId, deploymentId); + } + + return { + serviceId, + name, + projectId, + environmentId, + volumeId, + kind: news.kind, + port: spec.port, + urlVariable: spec.urlVariable, + username: spec.username, + database: spec.database, + password, + deploymentId, + deploymentStatus, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteService({ id: output.serviceId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + yield* deleteVolume({ volumeId: output.volumeId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + }), + }); diff --git a/packages/alchemy/src/Railway/DatabaseUrl.ts b/packages/alchemy/src/Railway/DatabaseUrl.ts new file mode 100644 index 0000000000..ffcf860c0e --- /dev/null +++ b/packages/alchemy/src/Railway/DatabaseUrl.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Binding from "../Binding.ts"; +import { RuntimeContext } from "../RuntimeContext.ts"; +import type { Database } from "./Database.ts"; +import { + bindingEnvPrefix, + isBindingHost, + serviceReference, +} from "./Reference.ts"; + +/** + * Database connection binding. + * + * Injects a `Railway.Database`'s connection URL into the consuming + * Service/Function: + * + * - At deploy time, {@link DatabaseUrlPolicy} writes + * `_URL=${{.}}` (e.g. + * `${{Postgres.DATABASE_URL}}`) onto the host's variables using Railway + * reference-variable syntax, so Railway resolves it server-side and + * keeps it fresh if the database's domain or credentials change. + * - At runtime, `DatabaseUrl.bind(db)` returns an Effect producing the + * resolved connection URL as a `Redacted`. It requires + * {@link RuntimeContext}. + * + * @example + * ```typescript + * Effect.gen(function* () { + * const url = yield* Railway.DatabaseUrl.bind(db); + * return { + * fetch: Effect.gen(function* () { + * const connectionString = Redacted.value(yield* url); + * // pass to pg/mysql2/mongodb/ioredis... + * }), + * }; + * }).pipe(Effect.provide(Railway.DatabaseUrlLive)) + * ``` + */ +export class DatabaseUrl extends Binding.Service< + DatabaseUrl, + ( + database: Database, + ) => Effect.Effect< + Effect.Effect, never, RuntimeContext> + > +>()("Railway.DatabaseUrl") {} + +export const DatabaseUrlLive = Layer.effect( + DatabaseUrl, + Effect.gen(function* () { + const Policy = yield* DatabaseUrlPolicy; + + return Effect.fn(function* (database: Database) { + yield* Policy(database); + const key = `${bindingEnvPrefix(database)}_URL`; + return RuntimeContext.pipe( + Effect.flatMap((ctx) => { + const value = ctx.env[key]; + return typeof value === "string" && value.length > 0 + ? Effect.succeed(Redacted.make(value)) + : Effect.die( + `Railway.DatabaseUrl: env var '${key}' is not set — ` + + `was '${database.LogicalId}' bound via DatabaseUrlPolicy at deploy time?`, + ); + }), + ); + }); + }), +); + +/** + * Deploy-time half of {@link DatabaseUrl}: records the reference-variable + * env var on the consuming Railway service. + */ +export class DatabaseUrlPolicy extends Binding.Policy< + DatabaseUrlPolicy, + (database: Database) => Effect.Effect +>()("Railway.DatabaseUrl") {} + +export const DatabaseUrlPolicyLive = DatabaseUrlPolicy.layer.succeed( + Effect.fn(function* (host, database) { + if (isBindingHost(host)) { + const prefix = bindingEnvPrefix(database); + yield* host.bind`DatabaseUrl(${host}, ${database})`({ + env: { + // `${{.DATABASE_URL>}}`-style reference; `urlVariable` + // is itself an Output (DATABASE_URL / REDIS_URL / ...). + [`${prefix}_URL`]: serviceReference(database, database.urlVariable), + }, + }); + } else { + return yield* Effect.die( + `DatabaseUrlPolicy does not support runtime '${host.Type}'`, + ); + } + }), +); diff --git a/packages/alchemy/src/Railway/Environment.ts b/packages/alchemy/src/Railway/Environment.ts new file mode 100644 index 0000000000..93f3589245 --- /dev/null +++ b/packages/alchemy/src/Railway/Environment.ts @@ -0,0 +1,249 @@ +import { + createEnvironment, + deleteEnvironment, + getEnvironment, + getEnvironments, + type GetEnvironmentsOutput, + renameEnvironment, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import type { Project } from "./Project.ts"; +import type { Providers } from "./Providers.ts"; + +export type ProjectSource = Project | { projectId: string }; + +export type EnvironmentSource = Environment | { environmentId: string }; + +export const resolveProjectId = (source: ProjectSource): string => { + if ("projectId" in source && source.projectId) { + return source.projectId as unknown as string; + } + throw new Error( + "Invalid Railway project source: must be a Project or { projectId }", + ); +}; + +export const resolveEnvironmentId = (source: EnvironmentSource): string => { + if ("environmentId" in source && source.environmentId) { + return source.environmentId as unknown as string; + } + throw new Error( + "Invalid Railway environment source: must be an Environment or { environmentId }", + ); +}; + +export type EnvironmentProps = { + /** + * The Railway project (or `{ projectId }`) to create the environment in. + */ + project: ProjectSource; + /** + * Environment name. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. + */ + name?: string; + /** + * Source environment to fork (duplicate services/variables) from. + * Cannot be changed after creation. + */ + sourceEnvironment?: EnvironmentSource; + /** + * Skip the initial deploys of duplicated services when forking from a + * source environment. + * + * @default false + */ + skipInitialDeploys?: boolean; + /** + * Whether the environment is ephemeral (e.g. a PR environment). + * + * @default false + */ + ephemeral?: boolean; +}; + +export type Environment = Resource< + "Railway.Environment", + EnvironmentProps, + { + environmentId: string; + name: string; + projectId: string; + isEphemeral: boolean; + }, + never, + Providers +>; + +/** + * A Railway environment — an isolated instance of every service in a + * project (e.g. `production`, `staging`, per-PR preview environments). + * + * @section Creating an Environment + * @example Basic environment + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const staging = yield* Railway.Environment("staging", { project }); + * ``` + * + * @example Fork an environment from production + * ```typescript + * const staging = yield* Railway.Environment("staging", { + * project, + * sourceEnvironment: { environmentId: project.defaultEnvironmentId }, + * skipInitialDeploys: true, + * }); + * ``` + * + * @see https://docs.railway.com/reference/environments + */ +export const Environment = Resource("Railway.Environment"); + +export const EnvironmentProvider = () => + Provider.succeed(Environment, { + stables: ["environmentId", "projectId"], + diff: Effect.fn(function* ({ id, olds = {}, news, output }) { + if (!isResolved(news)) return undefined; + const newProjectId = resolveProjectId(news.project as ProjectSource); + if (output?.projectId && newProjectId !== output.projectId) { + return { action: "replace" } as const; + } + const name = yield* createEnvironmentName(id, news.name); + const oldName = + output?.name ?? (yield* createEnvironmentName(id, olds.name)); + if (name !== oldName) { + return { action: "update" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, output, olds }) { + if (output?.environmentId) { + return yield* getEnvironment({ id: output.environmentId }).pipe( + Effect.map((env) => + // Railway soft-deletes environments — a deleted environment is + // still returned by getEnvironment with `deletedAt` set. + env.deletedAt !== null + ? undefined + : { + ...output, + name: env.name, + projectId: env.projectId, + isEphemeral: env.isEphemeral, + }, + ), + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + } + if (!olds?.project) return undefined; + const projectId = resolveProjectId(olds.project as ProjectSource); + const name = yield* createEnvironmentName(id, olds.name); + const match = yield* findEnvironmentByName(projectId, name); + if (!match) return undefined; + return { + environmentId: match.id, + name: match.name, + projectId: match.projectId, + isEphemeral: match.isEphemeral, + }; + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const name = yield* createEnvironmentName(id, news.name); + + if (output) { + // Sync — rename if the desired name differs from the observed name. + const observed = yield* getEnvironment({ id: output.environmentId }); + if (observed.name !== name) { + yield* renameEnvironment({ + id: output.environmentId, + input: { name }, + }).pipe( + // A just-deleted environment can hold the name while its + // deletion propagates — retry until the name frees up. + Effect.retry({ + while: (e) => e._tag === "EnvironmentNameConflict", + schedule: Schedule.spaced("2 seconds"), + times: 90, + }), + ); + } + return { + environmentId: output.environmentId, + name, + projectId: observed.projectId, + isEphemeral: observed.isEphemeral, + }; + } + + const projectId = resolveProjectId(news.project as ProjectSource); + const created = yield* createEnvironment({ + input: { + projectId, + name, + sourceEnvironmentId: news.sourceEnvironment + ? resolveEnvironmentId(news.sourceEnvironment as EnvironmentSource) + : undefined, + skipInitialDeploys: news.skipInitialDeploys, + ephemeral: news.ephemeral, + }, + }).pipe( + // AlreadyExists race / orphan from a crashed run — adopt the + // existing environment with that name instead of failing. If the + // name is held by an environment that is mid-deletion (it no longer + // shows up in the list), the conflict is transient — retry below. + Effect.catchTag("EnvironmentNameConflict", (conflict) => + findEnvironmentByName(projectId, name).pipe( + Effect.flatMap((match) => + match ? Effect.succeed(match) : Effect.fail(conflict), + ), + ), + ), + Effect.retry({ + while: (e) => e._tag === "EnvironmentNameConflict", + schedule: Schedule.spaced("2 seconds"), + times: 90, + }), + ); + return { + environmentId: created.id, + name: created.name, + projectId: created.projectId, + isEphemeral: created.isEphemeral, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteEnvironment({ id: output.environmentId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + }), + }); + +const createEnvironmentName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return name ?? (yield* createPhysicalName({ id, lowercase: true })); + }); + +const findEnvironmentByName = (projectId: string, name: string) => + Effect.gen(function* () { + let after: string | undefined; + while (true) { + const page: GetEnvironmentsOutput = yield* getEnvironments({ + projectId, + first: 100, + ...(after !== undefined ? { after } : {}), + }); + // Skip soft-deleted environments — their names are free to reuse. + const match = page.edges.find( + (e) => e.node.name === name && e.node.deletedAt === null, + ); + if (match) return match.node; + if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) { + return undefined; + } + after = page.pageInfo.endCursor; + } + }); diff --git a/packages/alchemy/src/Railway/Function.ts b/packages/alchemy/src/Railway/Function.ts new file mode 100644 index 0000000000..6af61f4ff6 --- /dev/null +++ b/packages/alchemy/src/Railway/Function.ts @@ -0,0 +1,1192 @@ +import { + Credentials, + createService, + createServiceDomain, + deleteService, + deleteVariable, + getDomains, + getEnvironments, + getProject, + getService, + getServiceInstance, + getVariables, + updateService, + updateServiceInstance, + upsertVariableCollection, +} from "@distilled.cloud/railway"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { ChildProcess } from "effect/unstable/process"; +import type * as rolldown from "rolldown"; +import * as Bundle from "../Bundle/Bundle.ts"; +import { findCwdForBundle } from "../Bundle/TempRoot.ts"; +import { isResolved } from "../Diff.ts"; +import { HttpServer, type HttpEffect } from "../Http.ts"; +import * as Output from "../Output.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import { + Platform, + type Main, + type PlatformProps, + type PlatformServices, +} from "../Platform.ts"; +import * as Provider from "../Provider.ts"; +import type { Resource, ResourceBinding } from "../Resource.ts"; +import { Self } from "../Self.ts"; +import type * as Server from "../Server/Process.ts"; +import { Stack } from "../Stack.ts"; +import { exec } from "../Util/exec.ts"; +import { sha256Object } from "../Util/sha256.ts"; +import { + resolveEnvironmentId, + resolveProjectId, + type EnvironmentSource, + type ProjectSource, +} from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { + findServiceByName, + syncBoundVolumes, + waitForDeployment, + type DeploymentStatus, + type RailwayRestartPolicy, + type ServiceBindingContract, +} from "./Service.ts"; + +export type FunctionTypeId = "Railway.Function"; +export const FunctionTypeId = "Railway.Function"; + +/** + * Creating the gzipped source tarball failed (the system `tar` binary + * exited non-zero or could not be spawned). + */ +export class CodeArchiveError extends Data.TaggedError("CodeArchiveError")<{ + readonly message: string; +}> {} + +/** + * The `railway up` code upload was rejected by Railway's REST endpoint, + * or no new deployment appeared after a successful upload. + */ +export class CodeUploadFailed extends Data.TaggedError("CodeUploadFailed")<{ + readonly message: string; + readonly status?: number; +}> {} + +export interface FunctionProps extends PlatformProps { + /** + * Module entrypoint for the bundled program. This should typically be + * `import.meta.filename` from an inline Effect program. + */ + main: string; + /** + * Named export to load from `main`. + * @default "default" + */ + handler?: string; + /** + * The Railway project (or `{ projectId }`) to create the service in. + */ + project: ProjectSource; + /** + * The environment (or `{ environmentId }`) to deploy into. Defaults to + * the project's base environment. + */ + environment?: EnvironmentSource; + /** + * Railway service name. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. + */ + name?: string; + /** + * HTTP port the bundled program listens on. The provider sets the + * `PORT` variable on the service and points the public domain at it. + * @default 3000 + */ + port?: number; + /** + * Whether to expose the service on a Railway-generated public domain + * (`*.up.railway.app`). The domain is reported as the `url` attribute. + * @default true + */ + url?: boolean; + /** + * Additional environment variables for the service. + */ + env?: Record; + /** + * JavaScript runtime used inside the container. + * @default "node" + */ + runtime?: "bun" | "node"; + /** + * Module specifiers to leave external when bundling. The packages are + * installed during Railway's image build before the entrypoint runs. + */ + external?: string[]; + /** + * Bundler configuration overrides for the entrypoint. + */ + build?: { + input?: Partial; + output?: Partial; + }; + /** + * Number of replicas to run. + * @default 1 + */ + numReplicas?: number; + /** + * Region to deploy the service into, e.g. `us-west2`. + */ + region?: string; + /** + * Path Railway probes to decide a deployment is healthy, e.g. `/health`. + */ + healthcheckPath?: string; + /** + * Restart policy for crashed containers. + * @default "ON_FAILURE" + */ + restartPolicyType?: RailwayRestartPolicy; + /** + * Enable app sleeping — scale to zero when no traffic. + * @default false + */ + sleepApplication?: boolean; +} + +export interface Function extends Resource< + "Railway.Function", + FunctionProps, + { + serviceId: string; + name: string; + projectId: string; + environmentId: string; + /** Port the program listens on (also the domain's target port). */ + port: number; + /** Railway-generated public domain, when `url` is enabled. */ + domain: string | undefined; + domainId: string | undefined; + /** `https://` URL of the public domain, when `url` is enabled. */ + url: string | undefined; + /** ID of the most recent deployment triggered/observed by Alchemy. */ + deploymentId: string | undefined; + deploymentStatus: DeploymentStatus | undefined; + /** Names of the service variables managed by this resource. */ + variableNames: string[]; + code: { + hash: string; + }; + }, + ServiceBindingContract, + Providers +> {} + +export type FunctionServices = + | Function + | PlatformServices + | Server.ProcessServices; + +export type FunctionShape = Main; + +export interface FunctionRuntimeContext extends Server.ProcessContext { + readonly Type: "Railway.Function"; +} + +/** + * An Effect-native runnable unit on Railway — your program is bundled, + * staged with a deterministic Dockerfile, gzipped into a source tarball, + * and uploaded straight to Railway's build pipeline as a + * [Railway service](https://docs.railway.com/reference/services) with its + * variables, public domain, and deployment lifecycle fully reconciled. + * + * **How code gets deployed:** Railway's GraphQL API has no code-upload + * mutation, but the Railway CLI's `railway up` uses a REST endpoint that + * accepts a gzipped tarball of the source directory. `Railway.Function` + * bundles your `main` entrypoint (rolldown), writes a staging directory + * containing `index.mjs`, a minimal `package.json`, and a Dockerfile + * (so Railway's builder is deterministic), tars it up, and POSTs it to + * `…/project/{projectId}/environment/{environmentId}/up?serviceId={serviceId}`. + * Railway builds the image remotely and deploys it — no local Docker + * daemon or registry required. + * + * Bindings reuse the `Railway.Service` binding contract + * (`{ env?, volumes? }`), so `Connect`, `DatabaseUrl`, `VolumeMount`, + * and any other Railway policy attach to a Function exactly like they do + * to a Service: the policy records env vars / volume attachments at + * deploy time and the provider reconciles them onto the service before + * triggering the deployment. Plain config/secrets are not bindings — + * `yield*` an `effect/Config` in the Init phase and it is captured as a + * service variable automatically (see the Configuration section). + * + * @section Creating a Function + * @example HTTP service from an inline Effect program + * ```typescript + * export default class Api extends Railway.Function()( + * "Api", + * Effect.gen(function* () { + * const project = yield* Railway.Project("MyProject"); + * return { main: import.meta.filename, project }; + * }), + * Effect.gen(function* () { + * return { + * fetch: Effect.gen(function* () { + * return HttpServerResponse.text("Hello from Railway!"); + * }), + * }; + * }), + * ) {} + * ``` + * + * @section Bindings + * @example Read config/secrets with effect/Config + * ```typescript + * Effect.gen(function* () { + * // resolved at deploy time, published as a service variable, and + * // re-read from process.env at runtime — no binding needed. + * const greeting = yield* Config.string("GREETING"); + * const apiKey = yield* Config.redacted("API_KEY"); + * return { + * fetch: Effect.gen(function* () { + * return HttpServerResponse.text(greeting); + * }), + * }; + * }) + * ``` + * + * @example Connect to another Railway service + * ```typescript + * const api = yield* Railway.Connect.bind(apiService); + * const url = yield* api.privateUrl({ port: 8080 }); + * ``` + * + * @example Bind a database connection URL + * ```typescript + * const url = yield* Railway.DatabaseUrl.bind(postgres); + * // at runtime: Redacted.value(yield* url) + * ``` + * + * @section Configuration + * @example Healthcheck, replicas, and region + * ```typescript + * const fn = yield* Railway.Function("Api", { + * main: import.meta.filename, + * project, + * healthcheckPath: "/health", + * numReplicas: 2, + * region: "us-west2", + * }); + * ``` + */ +export const Function: Platform< + Function, + FunctionServices, + FunctionShape, + FunctionRuntimeContext +> = Platform(FunctionTypeId, { + createRuntimeContext: (id: string): FunctionRuntimeContext => { + const runners: Effect.Effect[] = []; + // At runtime (inside the deployed container) expose the live process + // environment so binding accessors (Connect, DatabaseUrl, VolumeMount) + // can read the variables that were reconciled onto the service. At plan + // time this is a fresh record that collects Output references which + // the provider publishes as service variables. + const env: Record = + typeof process !== "undefined" && process.env.ALCHEMY_PHASE === "runtime" + ? (process.env as Record) + : {}; + + const serve = (handler: HttpEffect) => + // Capture the ambient context — `serve` runs inside the scope where + // Platform.ts provides RuntimeContext / Self / the ConfigProvider + // interceptor, but the runner executes later from the bootstrap's + // `exports.default`, outside that scope. Without re-providing it the + // handler dies at runtime with "Service not found: RuntimeContext". + Effect.context().pipe( + Effect.map((ctx) => { + runners.push( + Effect.gen(function* () { + const httpServer = yield* Effect.serviceOption(HttpServer).pipe( + Effect.map(Option.getOrUndefined), + ); + if (httpServer) { + yield* httpServer.serve(handler); + yield* Effect.never; + } + // no HttpServer means we're at plan time — nothing to serve + }).pipe(Effect.provide(ctx), Effect.orDie), + ); + }), + ); + + return { + Type: FunctionTypeId, + id, + env, + set: (bindingId: string, output: Output.Output) => + Effect.sync(() => { + // Key is already canonical (see RuntimeContext.sanitizeKey). + // Preserve `Redacted`-ness across the Output → env var round + // trip (`JSON.stringify(Redacted)` would emit `""`). + env[bindingId] = output.pipe( + Output.map((value) => + Redacted.isRedacted(value) + ? JSON.stringify({ + _tag: "Redacted", + value: Redacted.value(value), + }) + : JSON.stringify(value), + ), + ); + return bindingId; + }), + get: (key: string) => + // Read straight from `process.env`. Going through `Config` here + // would re-enter the ConfigProvider interceptor installed by + // `Platform.ts` for the same key and recurse forever (see the + // AWS Lambda runtime context for the full story). + Effect.sync(() => { + const val = process.env[key]; + if (val === undefined) { + return undefined; + } + try { + const value = JSON.parse(val); + if ( + typeof value === "object" && + value?._tag === "Redacted" && + "value" in value + ) { + return Redacted.make( + (value as { value: unknown }).value, + ) as unknown as T; + } + return value as T; + } catch { + return val as unknown as T; // assume it's just a string + } + }), + run: ((effect: Effect.Effect) => + Effect.sync(() => { + runners.push(effect); + })) as unknown as Server.ProcessContext["run"], + serve, + exports: Effect.sync(() => ({ + default: Effect.all( + runners.map((eff) => + Effect.forever( + eff.pipe( + // Log and ignore errors (daemon mode, it should re-run) + Effect.tapError((err) => Effect.logError(err)), + Effect.ignore, + ), + ), + ), + { + concurrency: "unbounded", + }, + ), + })), + } as FunctionRuntimeContext; + }, +}); + +/** + * Result of the `railway up` REST endpoint, parsed leniently — Railway + * has shipped different shapes over time (`url`, `logsUrl`, + * `deploymentDomain`, sometimes a deployment id), and all fields are + * optional from our point of view. + */ +export interface UpResponse { + readonly url: string | undefined; + readonly logsUrl: string | undefined; + readonly deploymentId: string | undefined; +} + +/** + * Upload a gzipped source tarball to Railway via the same REST endpoint + * the Railway CLI's `railway up` uses: + * + * `POST {host}/project/{projectId}/environment/{environmentId}/up?serviceId={serviceId}` + * + * where `{host}` is the GraphQL API base URL with any `/graphql/v2` + * suffix stripped (default `https://backboard.railway.com`). The body is + * the raw tar.gz bytes; the CLI labels them `Content-Type: + * multipart/form-data` even though no multipart envelope is sent, so we + * mirror that. Auth matches the GraphQL API: `Authorization: Bearer` + * for account/workspace tokens, `Project-Access-Token` for project + * tokens. The whole protocol lives in this one helper so the + * content-type/body shape is trivially adjustable. + */ +export const uploadCode = Effect.fnUntraced(function* ( + projectId: string, + environmentId: string, + serviceId: string, + tarGzBytes: Uint8Array, +) { + const http = yield* HttpClient.HttpClient; + const credentials = yield* yield* Credentials; + + const host = credentials.apiBaseUrl + .replace(/\/graphql(\/v2)?\/?$/, "") + .replace(/\/$/, ""); + const url = `${host}/project/${projectId}/environment/${environmentId}/up?serviceId=${serviceId}`; + + const headers: Record = credentials.apiToken + ? { Authorization: `Bearer ${tokenValue(credentials.apiToken)}` } + : credentials.projectToken + ? { "Project-Access-Token": tokenValue(credentials.projectToken) } + : {}; + + const response = yield* http.execute( + HttpClientRequest.post(url).pipe( + HttpClientRequest.setHeaders(headers), + // Raw tar.gz bytes as the body. The CLI sends this content type + // without an actual multipart envelope; Railway accepts the raw + // bytes either way. + HttpClientRequest.bodyUint8Array(tarGzBytes, "multipart/form-data"), + ), + ); + + if (response.status >= 300) { + const body = yield* response.text.pipe( + Effect.catch(() => Effect.succeed("")), + ); + return yield* new CodeUploadFailed({ + status: response.status, + message: `Railway code upload failed (${response.status}): ${body.slice(0, 1024)}`, + }); + } + + const json = yield* response.json.pipe( + Effect.catch(() => Effect.succeed(undefined as unknown)), + ); + const record = + typeof json === "object" && json !== null + ? (json as Record) + : {}; + const str = (key: string) => + typeof record[key] === "string" ? (record[key] as string) : undefined; + + return { + url: str("url"), + logsUrl: str("logsUrl"), + deploymentId: str("deploymentId") ?? str("deployId"), + }; +}); + +const tokenValue = (token: Redacted.Redacted | string): string => + Redacted.isRedacted(token) ? Redacted.value(token) : token; + +export const FunctionProvider = () => + Provider.effect( + Function, + Effect.gen(function* () { + const stack = yield* Stack; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; + + const alchemyEnv = { + ALCHEMY_STACK_NAME: stack.name, + ALCHEMY_STAGE: stack.stage, + ALCHEMY_PHASE: "runtime", + }; + + const createServiceName = (id: string, name: string | undefined) => + Effect.gen(function* () { + // Railway service names are documented at max 32 characters. + return ( + name ?? + (yield* createPhysicalName({ id, lowercase: true, maxLength: 32 })) + ); + }); + + const bundleProgram = Effect.fnUntraced(function* (props: FunctionProps) { + const runtime = props.runtime ?? "node"; + const handler = props.handler ?? "default"; + const external = props.external ?? []; + const realMain = yield* fs.realPath(props.main); + const cwd = yield* findCwdForBundle(realMain); + + const buildBundle = Effect.fnUntraced(function* ( + entry: string, + plugins?: rolldown.RolldownPluginOption, + ) { + return yield* Bundle.build( + { + ...props.build?.input, + input: entry, + cwd, + external: [ + ...(runtime === "bun" ? ["bun", "bun:*"] : []), + ...external, + ], + platform: "node", + resolve: { + conditionNames: + runtime === "bun" + ? ["bun", "import", "module", "default"] + : ["node", "import", "module", "default"], + }, + plugins: [props.build?.input?.plugins, plugins], + treeshake: true, + }, + { + ...props.build?.output, + format: "esm", + sourcemap: props.build?.output?.sourcemap ?? false, + minify: props.build?.output?.minify ?? true, + entryFileNames: "index.js", + }, + ); + }); + + const bundleOutput = props.isExternal + ? yield* buildBundle(realMain) + : yield* buildBundle( + realMain, + virtualEntryPlugin( + (importPath) => ` +${ + runtime === "bun" + ? ` +import { BunServices } from "@effect/platform-bun"; +import { BunHttpServer } from "alchemy/Http"; +const HttpServer = BunHttpServer; +` + : ` +import { NodeServices } from "@effect/platform-node"; +import { NodeHttpServer } from "alchemy/Http"; +const HttpServer = NodeHttpServer; +` +} +import { Stack } from "alchemy/Stack"; +import { makeEntrypointLayer } from "alchemy/Runtime"; +import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Context from "effect/Context"; +import { MinimumLogLevel } from "effect/References"; + +import ${handler === "default" ? "entrypoint" : `{ ${handler} as entrypoint }`} from ${JSON.stringify(importPath)}; + +const tag = Context.Service("${Self.key}") +const layer = makeEntrypointLayer(tag, entrypoint); + +const platform = Layer.mergeAll( + ${runtime === "bun" ? "BunServices.layer" : "NodeServices.layer"}, + FetchHttpClient.layer, + Logger.layer([Logger.consolePretty()]), +); + +const stack = Layer.succeed(Stack, { + name: ${JSON.stringify(stack.name)}, + stage: ${JSON.stringify(stack.stage)}, + bindings: {}, + resources: {} +}); + +const serverEffect = tag.pipe( + Effect.flatMap(func => func.RuntimeContext.exports), + Effect.flatMap(exports => exports.default), + Effect.provide( + layer.pipe( + Layer.provideMerge(stack), + Layer.provideMerge(HttpServer()), + Layer.provideMerge(platform), + Layer.provideMerge( + Layer.succeed( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", + ) + ), + ) + ), + Effect.scoped +); + +console.log("Railway function bootstrap starting..."); +await Effect.runPromise(serverEffect).catch((err) => { + console.error("Railway function bootstrap failed:", err); + process.exit(1); +})`, + ), + ); + + // Rolldown can emit multiple chunk files (entry + shared chunks); + // every one of them must land in the source tarball. + const files = bundleOutput.files.map((f) => ({ + path: f.path, + content: + typeof f.content === "string" + ? new TextEncoder().encode(f.content) + : f.content, + })); + + return { files, hash: bundleOutput.hash }; + }); + + /** + * Deterministic Dockerfile included in the source tarball so + * Railway's builder produces the same image for the same code — + * no buildpack detection heuristics involved. + */ + const buildDockerfile = (props: FunctionProps): string => { + const runtime = props.runtime ?? "node"; + const external = props.external ?? []; + const base = + runtime === "bun" ? "FROM oven/bun:1" : "FROM node:22-slim"; + const runtimeBin = runtime === "bun" ? "bun" : "node"; + const installCmd = runtime === "bun" ? "bun add" : "npm install"; + const installStep = + external.length > 0 ? `RUN ${installCmd} ${external.join(" ")}` : ""; + return [ + base, + "", + "WORKDIR /app", + "COPY . /app", + ...(installStep ? [installStep, ""] : []), + `CMD ["${runtimeBin}", "/app/index.mjs"]`, + "", + ].join("\n"); + }; + + const buildPackageJson = (props: FunctionProps): string => { + const runtimeBin = (props.runtime ?? "node") === "bun" ? "bun" : "node"; + return JSON.stringify( + { + name: "alchemy-railway-function", + private: true, + type: "module", + scripts: { start: `${runtimeBin} index.mjs` }, + }, + null, + 2, + ); + }; + + /** + * Bundle the program and derive the content hash that gates + * re-uploads. Everything that lands in the tarball participates in + * the hash so any change to the shipped artifact triggers a deploy. + */ + const computeCode = Effect.fnUntraced(function* ( + id: string, + props: FunctionProps, + ) { + const { files, hash: bundleHash } = yield* bundleProgram(props); + const dockerfile = buildDockerfile(props); + const packageJson = buildPackageJson(props); + const codeHash = (yield* sha256Object({ + bundleHash, + dockerfile, + packageJson, + })).slice(0, 16); + const name = yield* createServiceName(id, props.name); + return { files, dockerfile, packageJson, codeHash, name }; + }); + + /** + * Write the staging directory (bundle chunks + package.json + + * Dockerfile) and tar+gzip it with the system `tar` binary via the + * Effect process service. Returns the tar.gz bytes. + */ + const stageAndArchive = Effect.fnUntraced(function* ({ + files, + dockerfile, + packageJson, + }: { + files: ReadonlyArray<{ path: string; content: Uint8Array }>; + dockerfile: string; + packageJson: string; + }) { + const staging = yield* fs.makeTempDirectory({ + prefix: "alchemy-railway-up-", + }); + const tarDir = yield* fs.makeTempDirectory({ + prefix: "alchemy-railway-tar-", + }); + const cleanup = Effect.all([ + fs.remove(staging, { recursive: true }).pipe(Effect.ignore), + fs.remove(tarDir, { recursive: true }).pipe(Effect.ignore), + ]); + + return yield* Effect.gen(function* () { + for (const [i, file] of files.entries()) { + // Entry chunk becomes `index.mjs` (the Dockerfile CMD); every + // other chunk keeps its rolldown-assigned filename so relative + // imports resolve at runtime. + const rel = i === 0 ? "index.mjs" : file.path; + const fullPath = path.join(staging, rel); + yield* fs.makeDirectory(path.dirname(fullPath), { + recursive: true, + }); + yield* fs.writeFile(fullPath, file.content); + } + yield* fs.writeFileString( + path.join(staging, "package.json"), + packageJson, + ); + yield* fs.writeFileString( + path.join(staging, "Dockerfile"), + dockerfile, + ); + + const tarPath = path.join(tarDir, "code.tar.gz"); + const { exitCode, stderr } = yield* exec( + ChildProcess.make("tar", ["-czf", tarPath, "-C", staging, "."]), + ).pipe( + Effect.catch((e) => + Effect.fail( + new CodeArchiveError({ + message: `Failed to run tar: ${e instanceof Error ? e.message : String(e)}`, + }), + ), + ), + ); + if (exitCode !== 0) { + return yield* new CodeArchiveError({ + message: `tar exited with ${exitCode}: ${stderr}`, + }); + } + + return yield* fs.readFile(tarPath); + }).pipe(Effect.ensuring(cleanup)); + }); + + /** + * Resolve the deployment created by an upload. Newer responses + * carry a deployment id directly; otherwise poll the service + * instance until `latestDeployment` moves past the pre-upload one. + */ + const awaitUploadDeployment = Effect.fnUntraced(function* ( + serviceId: string, + environmentId: string, + previousDeploymentId: string | undefined, + uploadedDeploymentId: string | undefined, + ) { + if (uploadedDeploymentId) { + return uploadedDeploymentId; + } + const instance = yield* getServiceInstance({ + serviceId, + environmentId, + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("3 seconds"), + until: (i) => + i.latestDeployment?.id !== undefined && + i.latestDeployment.id !== null && + i.latestDeployment.id !== previousDeploymentId, + times: 100, + }), + ); + const deploymentId = instance.latestDeployment?.id ?? undefined; + if (!deploymentId || deploymentId === previousDeploymentId) { + return yield* new CodeUploadFailed({ + message: `Code upload for service ${serviceId} succeeded but no new deployment appeared`, + }); + } + return deploymentId; + }); + + const collectBindingEnv = ( + bindings: ResourceBinding[], + ): Record => + bindings + .filter( + ( + binding: ResourceBinding & { + action?: string; + }, + ) => binding.action !== "delete", + ) + .map((binding) => binding?.data?.env ?? {}) + .reduce((acc, env) => ({ ...acc, ...env }), {}); + + const stringifyEnvValue = (value: any): string => + typeof value === "string" ? value : JSON.stringify(value ?? null); + + const desiredVariablesFor = ( + props: FunctionProps, + port: number, + bindingEnv: Record, + ): Record => { + const variables: Record = { ...alchemyEnv }; + variables.PORT = String(port); + for (const [key, value] of Object.entries(bindingEnv)) { + variables[key] = stringifyEnvValue(value); + } + for (const [key, value] of Object.entries(props.env ?? {})) { + variables[key] = stringifyEnvValue(value); + } + return variables; + }; + + return Function.Provider.of({ + stables: ["serviceId", "projectId", "environmentId"], + diff: Effect.fnUntraced(function* ({ id, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + const newProjectId = resolveProjectId(news.project as ProjectSource); + if (newProjectId !== output.projectId) { + return { action: "replace" } as const; + } + if ( + news.environment && + resolveEnvironmentId(news.environment as EnvironmentSource) !== + output.environmentId + ) { + return { action: "replace" } as const; + } + // Detect code changes that are invisible to prop comparison. + const { codeHash } = yield* computeCode(id, news); + if (codeHash !== output.code?.hash) { + return { action: "update" } as const; + } + return undefined; + }), + read: Effect.fnUntraced(function* ({ output }) { + if (!output?.serviceId) return undefined; + return yield* getService({ id: output.serviceId }).pipe( + // Railway soft-deletes — a resolved service with `deletedAt` + // set is gone for our purposes. + Effect.map((service) => + service.deletedAt + ? undefined + : { + ...output, + name: service.name, + projectId: service.projectId, + }, + ), + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + Effect.catchTag("ProjectNotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fnUntraced(function* ({ + id, + news, + olds, + output, + bindings, + session, + }) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = news.environment + ? resolveEnvironmentId(news.environment as EnvironmentSource) + : yield* defaultEnvironmentId(news.project as ProjectSource); + const port = news.port ?? 3000; + + const { files, dockerfile, packageJson, codeHash, name } = + yield* computeCode(id, news); + + const bindingEnv = collectBindingEnv(bindings); + const desiredVariables = desiredVariablesFor(news, port, bindingEnv); + + // Observe — does the service still exist? (Railway soft-deletes, + // so a resolved service with `deletedAt` set counts as missing.) + const observed = output?.serviceId + ? yield* getService({ id: output.serviceId }).pipe( + Effect.map((service) => + service.deletedAt ? undefined : service, + ), + Effect.catchTag("NotAuthorized", () => + Effect.succeed(undefined), + ), + Effect.catchTag("ProjectNotFound", () => + Effect.succeed(undefined), + ), + ) + : undefined; + + const codeChanged = codeHash !== output?.code?.hash; + + // Ensure — create the service if missing, otherwise sync name. + // The service is created without a source; the code upload + // below attaches the uploaded tarball as the deploy source. + let serviceId: string; + let createdNow = false; + if (!observed) { + const create = createService({ + input: { + projectId, + environmentId, + name, + variables: desiredVariables, + }, + }); + const created = yield* create.pipe( + // A same-named service already exists (orphan from a run + // whose state was lost, or a create race). Find its id via + // the environment's service-instance connection. A healthy + // service (one with an instance in this environment) is + // adopted and the sync steps below converge it; an + // instance-less husk (interrupted create) is deleted and + // recreated. + Effect.catchTag( + "ServiceNameConflict", + Effect.fnUntraced(function* (error) { + const existing = yield* findServiceByName( + environmentId, + name, + ); + if (!existing) { + return yield* Effect.fail(error); + } + const hasInstance = yield* getServiceInstance({ + serviceId: existing.serviceId, + environmentId, + }).pipe( + Effect.as(true), + Effect.catchTag("ProblemProcessingRequest", () => + Effect.succeed(false), + ), + Effect.catchTag("NotAuthorized", () => + Effect.succeed(false), + ), + ); + if (hasInstance) { + return { id: existing.serviceId }; + } + yield* deleteService({ id: existing.serviceId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + return yield* create; + }), + ), + ); + serviceId = created.id; + createdNow = true; + } else { + serviceId = observed.id; + if (observed.name !== name) { + yield* updateService({ id: serviceId, input: { name } }); + } + } + + // Sync — service instance configuration, diffed against observed. + const instance = yield* getServiceInstance({ + serviceId, + environmentId, + }); + const patch: Record = {}; + const sync = (key: string, desired: unknown, current: unknown) => { + if (desired !== undefined && desired !== (current ?? undefined)) { + patch[key] = desired; + } + }; + sync("numReplicas", news.numReplicas, instance.numReplicas); + sync("region", news.region, instance.region); + sync( + "healthcheckPath", + news.healthcheckPath, + instance.healthcheckPath, + ); + sync( + "restartPolicyType", + news.restartPolicyType, + instance.restartPolicyType, + ); + sync( + "sleepApplication", + news.sleepApplication, + instance.sleepApplication, + ); + const configChanged = Object.keys(patch).length > 0; + if (configChanged) { + yield* updateServiceInstance({ + serviceId, + environmentId, + input: patch, + }); + } + + // Sync — service variables, diffed against observed cloud state. + const observedVariables = (yield* getVariables({ + projectId, + environmentId, + serviceId, + })) as Record; + const managedNames = new Set([ + ...(output?.variableNames ?? []), + ...Object.keys(olds?.env ?? {}), + ]); + const upserts: Record = {}; + for (const [key, value] of Object.entries(desiredVariables)) { + if (observedVariables[key] !== value) { + upserts[key] = value; + } + } + const removals = [...managedNames].filter( + (key) => + !(key in desiredVariables) && key in (observedVariables ?? {}), + ); + const variablesChanged = + Object.keys(upserts).length > 0 || removals.length > 0; + if (Object.keys(upserts).length > 0) { + yield* upsertVariableCollection({ + input: { + projectId, + environmentId, + serviceId, + variables: upserts, + skipDeploys: true, + }, + }); + } + for (const key of removals) { + yield* deleteVariable({ + input: { projectId, environmentId, serviceId, name: key }, + }).pipe(Effect.catchTag("NotAuthorized", () => Effect.void)); + } + + // Sync — volume attachments requested through bindings. + const volumesChanged = yield* syncBoundVolumes( + serviceId, + environmentId, + bindings, + ); + + // Sync — public domain. Reconciled against observed domains so + // adoption and partial-state recoveries converge. + let domainId: string | undefined; + let domain: string | undefined; + if (news.url !== false) { + const domains = yield* getDomains({ + projectId, + environmentId, + serviceId, + }); + const existing = + domains.serviceDomains.find((d) => d.id === output?.domainId) ?? + domains.serviceDomains[0]; + const ensured = + existing ?? + (yield* createServiceDomain({ + input: { serviceId, environmentId, targetPort: port }, + })); + domainId = ensured.id; + domain = ensured.domain; + } + + // Deploy — upload the source tarball when anything changed (or + // no healthy deployment exists yet). The upload itself triggers + // Railway's build + deployment, so config/variable-only changes + // also go through a fresh upload of the same code. + let deploymentId = instance.latestDeployment?.id ?? undefined; + let deploymentStatus = (instance.latestDeployment?.status ?? + undefined) as DeploymentStatus | undefined; + const shouldDeploy = + createdNow || + codeChanged || + configChanged || + variablesChanged || + volumesChanged || + deploymentStatus === undefined || + deploymentStatus === "FAILED" || + deploymentStatus === "CRASHED" || + deploymentStatus === "REMOVED" || + deploymentStatus === "SKIPPED"; + if (shouldDeploy) { + yield* Effect.logInfo( + `Railway Function: uploading code for ${name} (${codeHash})`, + ); + if (session) { + yield* session.note(`Uploading code for ${name}...`); + } + const tarGzBytes = yield* stageAndArchive({ + files, + dockerfile, + packageJson, + }); + const previousDeploymentId = deploymentId; + const response = yield* uploadCode( + projectId, + environmentId, + serviceId, + tarGzBytes, + ); + if (session) { + yield* session.note(`Waiting for ${name} deployment...`); + } + deploymentId = yield* awaitUploadDeployment( + serviceId, + environmentId, + previousDeploymentId, + response.deploymentId, + ); + deploymentStatus = yield* waitForDeployment( + serviceId, + deploymentId, + ); + } + + return { + serviceId, + name, + projectId, + environmentId, + port, + domain, + domainId, + url: domain ? `https://${domain}` : undefined, + deploymentId, + deploymentStatus, + variableNames: Object.keys(desiredVariables), + code: { + hash: codeHash, + }, + }; + }), + delete: Effect.fnUntraced(function* ({ output }) { + yield* deleteService({ id: output.serviceId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + Effect.catchTag("ProjectNotFound", () => Effect.void), + ); + }), + }); + }), + ); + +const defaultEnvironmentId = (source: ProjectSource) => + Effect.gen(function* () { + // The resolved `Railway.Project` attributes already carry the default + // environment id — prefer it over a refetch (`getProject` can briefly + // report no base environment for freshly-created projects). + if ( + "defaultEnvironmentId" in source && + typeof source.defaultEnvironmentId === "string" && + source.defaultEnvironmentId + ) { + return source.defaultEnvironmentId; + } + const projectId = resolveProjectId(source); + const project = yield* getProject({ id: projectId }); + const baseEnvironmentId = + project.baseEnvironmentId ?? project.baseEnvironment?.id; + if (baseEnvironmentId) { + return baseEnvironmentId; + } + // Fall back to listing environments. + const page = yield* getEnvironments({ projectId, first: 100 }); + const nodes = page.edges.map((e) => e.node); + const match = nodes.find((n) => !n.isEphemeral) ?? nodes[0]; + if (!match) { + return yield* Effect.die( + `Railway project ${projectId} has no environments`, + ); + } + return match.id; + }); diff --git a/packages/alchemy/src/Railway/Project.ts b/packages/alchemy/src/Railway/Project.ts new file mode 100644 index 0000000000..bd057c5049 --- /dev/null +++ b/packages/alchemy/src/Railway/Project.ts @@ -0,0 +1,416 @@ +import { + createProject, + deleteProject, + getEnvironments, + getProject, + getProjects, + type GetProjectsOutput, + updateProject, +} from "@distilled.cloud/railway"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import type { Providers } from "./Providers.ts"; + +export type ProjectProps = { + /** + * Name of the project. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. + */ + name?: string; + /** + * Description of the project. + */ + description?: string; + /** + * Name of the default environment created with the project. + * Cannot be changed after creation. + * + * @default "production" + */ + defaultEnvironmentName?: string; + /** + * Whether the project is publicly visible. + * + * @default false + */ + isPublic?: boolean; + /** + * Whether PR deploys (preview environments for pull requests) are + * enabled on the project. + * + * @default false + */ + prDeploys?: boolean; + /** + * Railway workspace ID to create the project in. Defaults to the + * token's workspace. Cannot be changed after creation. + */ + workspaceId?: string; +}; + +export type Project = Resource< + "Railway.Project", + ProjectProps, + { + projectId: string; + name: string; + description: string | undefined; + /** ID of the project's default (base) environment. */ + defaultEnvironmentId: string; + /** Name of the project's default (base) environment. */ + defaultEnvironmentName: string; + workspaceId: string | undefined; + isPublic: boolean; + prDeploys: boolean; + }, + never, + Providers +>; + +/** + * A Railway project — the top-level container for environments, services + * and volumes. + * + * Creating a project also provisions its default environment (named + * `production` by default), exposed as `defaultEnvironmentId`. + * + * @section Creating a Project + * @example Basic project + * ```typescript + * const project = yield* Railway.Project("my-project"); + * ``` + * + * @example Project with explicit name and description + * ```typescript + * const project = yield* Railway.Project("my-project", { + * name: "my-app", + * description: "My production application", + * }); + * ``` + * + * @section Environments + * @example Use the default environment + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const service = yield* Railway.Service("api", { + * project, + * source: { image: "nginx:alpine" }, + * }); + * ``` + * + * @see https://docs.railway.com/reference/projects + */ +export const Project = Resource("Railway.Project"); + +export const ProjectProvider = () => + Provider.succeed(Project, { + stables: ["projectId", "defaultEnvironmentId", "workspaceId"], + diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { + if (!isResolved(news)) return undefined; + if ( + news.workspaceId !== undefined && + output?.workspaceId !== undefined && + news.workspaceId !== output.workspaceId + ) { + return { action: "replace" } as const; + } + if ( + news.defaultEnvironmentName !== undefined && + output?.defaultEnvironmentName !== undefined && + news.defaultEnvironmentName !== output.defaultEnvironmentName + ) { + return { action: "replace" } as const; + } + const name = yield* createProjectName(id, news.name); + const oldName = output?.name ?? (yield* createProjectName(id, olds.name)); + if ( + name !== oldName || + (news.description ?? undefined) !== + (output?.description ?? undefined) || + (news.isPublic ?? false) !== (output?.isPublic ?? false) || + (news.prDeploys ?? false) !== (output?.prDeploys ?? false) + ) { + return { action: "update" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, output, olds }) { + if (output?.projectId) { + return yield* getProject({ id: output.projectId }).pipe( + Effect.map((project) => ({ + ...output, + name: project.name, + description: project.description ?? undefined, + isPublic: project.isPublic, + prDeploys: project.prDeploys, + workspaceId: project.workspaceId ?? undefined, + })), + Effect.catchTags({ + ProjectNotFound: () => Effect.succeed(undefined), + // Railway returns Not Authorized for deleted/foreign projects. + NotAuthorized: () => Effect.succeed(undefined), + }), + ); + } + const name = yield* createProjectName(id, olds?.name); + const match = yield* findProjectByName(name); + if (!match) return undefined; + const project = yield* getProject({ id: match.id }).pipe( + Effect.catchTags({ + ProjectNotFound: () => Effect.succeed(undefined), + NotAuthorized: () => Effect.succeed(undefined), + }), + ); + if (!project) return undefined; + const baseEnv = yield* resolveDefaultEnvironment( + project.id, + olds?.defaultEnvironmentName, + ); + return { + projectId: project.id, + name: project.name, + description: project.description ?? undefined, + defaultEnvironmentId: baseEnv.id, + defaultEnvironmentName: baseEnv.name, + workspaceId: project.workspaceId ?? undefined, + isPublic: project.isPublic, + prDeploys: project.prDeploys, + }; + }), + reconcile: Effect.fn(function* ({ id, news = {}, output, session }) { + const name = yield* createProjectName(id, news.name); + + if (output) { + // Sync mutable scalar fields on the existing project. + const updated = yield* updateProject({ + id: output.projectId, + input: { + name, + description: news.description ?? null, + isPublic: news.isPublic, + prDeploys: news.prDeploys, + }, + }); + return { + ...output, + name: updated.name, + description: updated.description ?? undefined, + isPublic: updated.isPublic, + prDeploys: updated.prDeploys, + }; + } + + // Railway rate-limits project creation (1 per 30s per workspace) — + // serialize local deployers through a host-wide lock, then retry the + // typed throttling error until the window opens. + yield* session.note(`Creating project '${name}'...`); + const created = yield* withProjectCreateLock( + createProject({ + input: { + name, + description: news.description, + defaultEnvironmentName: news.defaultEnvironmentName, + isPublic: news.isPublic, + prDeploys: news.prDeploys, + workspaceId: news.workspaceId, + }, + }).pipe( + Effect.tapError((e) => + e._tag === "ProjectCreateRateLimited" + ? session.note( + "Rate limited (Railway allows 1 project create per 30s per workspace) — retrying in ~35s...", + ) + : Effect.void, + ), + Effect.retry({ + while: (e) => e._tag === "ProjectCreateRateLimited", + // Failed attempts count against the rate window, so wait longer + // than the 30s window between attempts. Jitter desynchronizes + // remote deployers competing for the same window. + schedule: Schedule.jittered(Schedule.spaced("35 seconds")), + times: 20, + }), + ), + (waitedMillis) => + session.note( + `Waiting for host-wide project-create lock (held by another deploy, ${Math.round(waitedMillis / 1000)}s)...`, + ), + ); + // The default environment is provisioned asynchronously — poll until + // it appears. Railway does not reliably populate `baseEnvironmentId` + // for API-created projects, so fall back to listing environments. + yield* session.note( + `Project ${created.id} created — waiting for default environment...`, + ); + const baseEnv = yield* resolveDefaultEnvironment( + created.id, + news.defaultEnvironmentName, + ); + return { + projectId: created.id, + name: created.name, + description: created.description ?? undefined, + defaultEnvironmentId: baseEnv.id, + defaultEnvironmentName: baseEnv.name, + workspaceId: created.workspaceId ?? undefined, + isPublic: created.isPublic, + prDeploys: created.prDeploys, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteProject({ id: output.projectId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + }), + }); + +export class DefaultEnvironmentNotFound extends Data.TaggedError( + "DefaultEnvironmentNotFound", +)<{ projectId: string }> {} + +class ProjectCreateLockBusy extends Data.TaggedError( + "ProjectCreateLockBusy", +)<{}> {} + +/** + * Railway's create rate limit (1 project per 30s) is workspace-wide and + * failed attempts count against the window, so concurrent deployers on the + * same host (e.g. parallel test runs) livelock each other when they retry + * independently. Serialize project creation through a host-wide directory + * lock so at most one local process competes for the window at a time. + */ +const PROJECT_CREATE_LOCK_DIR = "/tmp/alchemy-railway-project-create.lock"; +/** + * Steal locks left behind by crashed processes after this long. A create + * normally holds the lock for well under a minute (one create + rate-limit + * retries), so anything older is presumed dead. + */ +const PROJECT_CREATE_LOCK_STALE_MILLIS = 2 * 60 * 1000; + +const withProjectCreateLock = ( + effect: Effect.Effect, + onWait?: (waitedMillis: number) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const startedAt = yield* Effect.sync(() => Date.now()); + const tryAcquire = Effect.gen(function* () { + const acquired = yield* fs.makeDirectory(PROJECT_CREATE_LOCK_DIR).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + if (acquired) return; + // Steal the lock if its holder appears to have crashed. + const stat = yield* fs.stat(PROJECT_CREATE_LOCK_DIR).pipe(Effect.option); + const mtime = Option.flatMap(stat, (s) => s.mtime); + const now = yield* Effect.sync(() => Date.now()); + if ( + Option.isSome(mtime) && + now - mtime.value.getTime() > PROJECT_CREATE_LOCK_STALE_MILLIS + ) { + yield* fs + .remove(PROJECT_CREATE_LOCK_DIR, { recursive: true }) + .pipe(Effect.ignore); + } + if (onWait) yield* onWait(now - startedAt); + return yield* Effect.fail(new ProjectCreateLockBusy()); + }); + yield* tryAcquire.pipe( + Effect.retry({ + while: (e) => e._tag === "ProjectCreateLockBusy", + schedule: Schedule.spaced("5 seconds"), + times: 240, + }), + ); + return yield* effect.pipe( + Effect.ensuring( + fs + .remove(PROJECT_CREATE_LOCK_DIR, { recursive: true }) + .pipe(Effect.ignore), + ), + ); + }); + +/** + * Resolve a project's default environment. Railway does not reliably set + * `baseEnvironmentId` on API-created projects, so prefer it when present + * but fall back to listing the project's environments — picking the one + * matching `preferredName` (default `production`), else the first + * non-ephemeral environment. Polls briefly because the default environment + * is provisioned asynchronously after project creation. + */ +export const resolveDefaultEnvironment = ( + projectId: string, + preferredName?: string, +) => + Effect.gen(function* () { + const lookup = Effect.gen(function* () { + const project = yield* getProject({ id: projectId }); + const baseEnvId = + project.baseEnvironmentId ?? project.baseEnvironment?.id; + if (baseEnvId) { + return { + id: baseEnvId, + name: project.baseEnvironment?.name ?? preferredName ?? "production", + }; + } + const page = yield* getEnvironments({ projectId, first: 100 }); + const nodes = page.edges.map((e) => e.node); + const match = + nodes.find((n) => n.name === (preferredName ?? "production")) ?? + nodes.find((n) => !n.isEphemeral) ?? + nodes[0]; + return match ? { id: match.id, name: match.name } : undefined; + }); + const env = yield* lookup.pipe( + // Read-after-write: a freshly created project may briefly 404 (or + // surface as Not Authorized) until it is visible to reads. + Effect.retry({ + while: (e) => + e._tag === "ProjectNotFound" || e._tag === "NotAuthorized", + schedule: Schedule.spaced("2 seconds"), + times: 30, + }), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (e) => e !== undefined, + times: 30, + }), + ); + if (!env) { + return yield* new DefaultEnvironmentNotFound({ projectId }); + } + return env; + }); + +const createProjectName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return name ?? (yield* createPhysicalName({ id, lowercase: true })); + }); + +/** + * Find a project by exact name, paging through the workspace's projects. + */ +export const findProjectByName = (name: string) => + Effect.gen(function* () { + let after: string | undefined; + while (true) { + const page: GetProjectsOutput = yield* getProjects({ + first: 100, + ...(after !== undefined ? { after } : {}), + }); + const match = page.edges.find((e) => e.node.name === name); + if (match) return match.node; + if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) { + return undefined; + } + after = page.pageInfo.endCursor; + } + }); diff --git a/packages/alchemy/src/Railway/ProjectToken.ts b/packages/alchemy/src/Railway/ProjectToken.ts new file mode 100644 index 0000000000..d260ab8dba --- /dev/null +++ b/packages/alchemy/src/Railway/ProjectToken.ts @@ -0,0 +1,201 @@ +import { + createProjectToken, + deleteProjectToken, + getProjectTokens, + type GetProjectTokensOutput, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + resolveEnvironmentId, + resolveProjectId, + type EnvironmentSource, + type ProjectSource, +} from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; + +export type ProjectTokenProps = { + /** + * The Railway project (or `{ projectId }`) the token grants access to. + * Changing it replaces the token. + */ + project: ProjectSource; + /** + * The environment (or `{ environmentId }`) the token is scoped to. + * Changing it replaces the token. + */ + environment: EnvironmentSource; + /** + * Token name. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. Changing it replaces the token. + */ + name?: string; +}; + +export type ProjectToken = Resource< + "Railway.ProjectToken", + ProjectTokenProps, + { + tokenId: string; + name: string; + projectId: string; + environmentId: string; + /** + * The token secret. Only returned by the API at creation time — + * persisted in state so it remains available across deploys. + */ + token: string; + }, + never, + Providers +>; + +/** + * A Railway project token — grants API access scoped to a single project + * and environment (sent as the `Project-Access-Token` header, or via + * `RAILWAY_PROJECT_TOKEN` for the Railway CLI). Commonly used in CI to + * deploy or read variables for one environment. + * + * The token secret is only returned by Railway at creation time; Alchemy + * persists it in state and exposes it as the `token` attribute. + * + * @section Creating a Project Token + * @example Token for the project's default environment + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const token = yield* Railway.ProjectToken("ci-token", { + * project, + * environment: { environmentId: project.defaultEnvironmentId }, + * }); + * // use `token.token` as RAILWAY_PROJECT_TOKEN in CI + * ``` + * + * @example Token for a specific environment + * ```typescript + * const staging = yield* Railway.Environment("staging", { project }); + * const token = yield* Railway.ProjectToken("staging-token", { + * project, + * environment: staging, + * name: "staging-ci", + * }); + * ``` + * + * @see https://docs.railway.com/guides/public-api#authentication + */ +export const ProjectToken = Resource("Railway.ProjectToken"); + +export const ProjectTokenProvider = () => + Provider.succeed(ProjectToken, { + stables: ["tokenId", "name", "projectId", "environmentId", "token"], + diff: Effect.fn(function* ({ id, news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = resolveEnvironmentId( + news.environment as EnvironmentSource, + ); + const name = yield* createTokenName(id, news.name); + if ( + projectId !== output.projectId || + environmentId !== output.environmentId || + name !== output.name + ) { + // Tokens have no update API — any change is a replacement. + return { action: "replace" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.tokenId) return undefined; + const match = yield* findToken( + output.projectId, + (t) => t.id === output.tokenId, + ).pipe(Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined))); + if (!match) return undefined; + return { ...output, name: match.name }; + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = resolveEnvironmentId( + news.environment as EnvironmentSource, + ); + const name = yield* createTokenName(id, news.name); + + // Observe — does the token still exist? The secret is only returned + // on create, so an existing token can only be reused when we still + // have its secret cached in `output`. + const observed = output?.tokenId + ? yield* findToken(projectId, (t) => t.id === output.tokenId) + : undefined; + if (observed && output) { + return { + tokenId: observed.id, + name: observed.name, + projectId: observed.projectId, + environmentId: observed.environmentId, + token: output.token, + }; + } + + // Ensure — create the token. + const token = yield* createProjectToken({ + input: { projectId, environmentId, name }, + }); + const created = yield* findToken( + projectId, + (t) => + t.name === name && + t.environmentId === environmentId && + t.id !== output?.tokenId, + ); + if (!created) { + return yield* Effect.die( + `Railway project token ${name} not found after creation`, + ); + } + return { + tokenId: created.id, + name: created.name, + projectId: created.projectId, + environmentId: created.environmentId, + token, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteProjectToken({ id: output.tokenId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + }), + }); + +const createTokenName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return name ?? (yield* createPhysicalName({ id, lowercase: true })); + }); + +type ProjectTokenNode = GetProjectTokensOutput["edges"][number]["node"]; + +const findToken = ( + projectId: string, + predicate: (token: ProjectTokenNode) => boolean, +) => + Effect.gen(function* () { + let after: string | undefined; + while (true) { + const page: GetProjectTokensOutput = yield* getProjectTokens({ + projectId, + first: 100, + ...(after !== undefined ? { after } : {}), + }); + const match = page.edges.find((e) => predicate(e.node)); + if (match) return match.node; + if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) { + return undefined; + } + after = page.pageInfo.endCursor; + } + }); diff --git a/packages/alchemy/src/Railway/Providers.ts b/packages/alchemy/src/Railway/Providers.ts new file mode 100644 index 0000000000..8b5740e003 --- /dev/null +++ b/packages/alchemy/src/Railway/Providers.ts @@ -0,0 +1,102 @@ +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { CredentialsStoreLive } from "../Auth/Credentials.ts"; +import { ProfileLive } from "../Auth/Profile.ts"; +import * as Provider from "../Provider.ts"; +import { RailwayAuth } from "./AuthProvider.ts"; +import { ConnectPolicy, ConnectPolicyLive } from "./Connect.ts"; +import * as Credentials from "./Credentials.ts"; +import { CustomDomain, CustomDomainProvider } from "./CustomDomain.ts"; +import { Database, DatabaseProvider } from "./Database.ts"; +import { DatabaseUrlPolicy, DatabaseUrlPolicyLive } from "./DatabaseUrl.ts"; +import { Environment, EnvironmentProvider } from "./Environment.ts"; +import { Function, FunctionProvider } from "./Function.ts"; +import { Project, ProjectProvider } from "./Project.ts"; +import { ProjectToken, ProjectTokenProvider } from "./ProjectToken.ts"; +import { Service, ServiceProvider } from "./Service.ts"; +import { ServiceDomain, ServiceDomainProvider } from "./ServiceDomain.ts"; +import { TcpProxy, TcpProxyProvider } from "./TcpProxy.ts"; +import { Variables, VariablesProvider } from "./Variables.ts"; +import { Volume, VolumeProvider } from "./Volume.ts"; +import { VolumeMountPolicy, VolumeMountPolicyLive } from "./VolumeMount.ts"; +import { Webhook, WebhookProvider } from "./Webhook.ts"; + +export class Providers extends Provider.ProviderCollection()( + "Railway", +) {} + +/** + * Build a layer that registers all Railway resource providers, the Railway + * `AuthProvider`, the resolved `Credentials`, and an `HttpClient`. Include + * this from your stack alongside other cloud `providers()` layers. + * + * @example + * ```typescript + * import * as Alchemy from "alchemy"; + * import * as Railway from "alchemy/Railway"; + * import * as Effect from "effect/Effect"; + * + * export default Alchemy.Stack( + * "MyStack", + * { providers: Railway.providers(), state: Alchemy.localState() }, + * Effect.gen(function* () { + * const project = yield* Railway.Project("app"); + * const api = yield* Railway.Service("api", { + * project, + * source: { image: "nginx:alpine" }, + * }); + * const domain = yield* Railway.ServiceDomain("api-domain", { + * service: api, + * }); + * return { url: domain.url }; + * }), + * ); + * ``` + */ +export const providers = () => + Layer.effect( + Providers, + Provider.collection([ + Project, + ProjectToken, + Environment, + Service, + Function, + ServiceDomain, + CustomDomain, + TcpProxy, + Volume, + Variables, + Database, + Webhook, + ConnectPolicy, + DatabaseUrlPolicy, + VolumeMountPolicy, + ]), + ).pipe( + Layer.provide( + Layer.mergeAll( + ProjectProvider(), + ProjectTokenProvider(), + EnvironmentProvider(), + ServiceProvider(), + FunctionProvider(), + ServiceDomainProvider(), + CustomDomainProvider(), + TcpProxyProvider(), + VolumeProvider(), + VariablesProvider(), + DatabaseProvider(), + WebhookProvider(), + ConnectPolicyLive, + DatabaseUrlPolicyLive, + VolumeMountPolicyLive, + ), + ), + Layer.provideMerge(Credentials.fromAuthProvider()), + Layer.provideMerge(RailwayAuth), + Layer.provideMerge(ProfileLive), + Layer.provideMerge(CredentialsStoreLive), + Layer.provideMerge(FetchHttpClient.layer), + Layer.orDie, + ); diff --git a/packages/alchemy/src/Railway/Reference.ts b/packages/alchemy/src/Railway/Reference.ts new file mode 100644 index 0000000000..d8e588c76f --- /dev/null +++ b/packages/alchemy/src/Railway/Reference.ts @@ -0,0 +1,90 @@ +import * as Output from "../Output.ts"; +import { sanitizeKey } from "../RuntimeContext.ts"; +import { FunctionTypeId, type Function } from "./Function.ts"; +import { isService, type Service } from "./Service.ts"; + +/** + * # Railway binding model + * + * Railway has no IAM — its native "binding" mechanism is **environment + * variables** plus **private networking** (every service in an environment + * can reach every other service at `.railway.internal`). Railway + * additionally supports *reference variables* — template values like + * `${{Postgres.DATABASE_URL}}` or `${{shared.LOG_LEVEL}}` that Railway + * resolves server-side when the variable is read by a deployment. + * + * Alchemy maps **real resource relationships** onto the standard + * `Binding.Service` + `Binding.Policy` split: + * + * - The **Policy** (deploy-time) calls `host.bind(...)({ env, volumes })` + * on the consuming `Railway.Service`/`Railway.Function`, recording env + * vars and volume attachments on the Stack. The host provider's + * `reconcile` merges the binding env into the service-scoped variables + * it upserts and applies volume attachments, then redeploys. + * - The **Service** (runtime) returns a typed accessor whose inner Effects + * require {@link RuntimeContext} (they only make sense inside a deployed + * Railway service) and read the deterministic env var names written by + * the Policy from the runtime environment. + * + * Bindings (resource relationships only): + * + * - {@link Connect} — service-to-service: injects the target's private / + * public domains (via `${{Name.RAILWAY_PRIVATE_DOMAIN}}` references) and + * exposes typed URL accessors at runtime. + * - {@link DatabaseUrl} — injects a `Railway.Database`'s connection URL + * (via `${{Name.DATABASE_URL}}`-style references) with a + * RuntimeContext-colored accessor returning the redacted URL. + * - {@link VolumeMount} — attaches a `Railway.Volume` to the consuming + * service and exposes the mount path at runtime. + * + * Plain configuration and secrets are **not** bindings: they flow through + * `effect/Config`. Any `Config` value `yield*`ed in a Function's Init + * phase is automatically captured by the Platform's ConfigProvider + * interceptor, published as a Railway service variable at deploy time, + * and re-read from `process.env` at runtime — see + * `website/src/content/docs/concepts/secrets.mdx`. + */ + +/** + * A resource that can host Railway bindings (receives env vars / volume + * attachments through the `ServiceBindingContract`). + */ +export type BindingHost = Service | Function; + +export const isBindingHost = (value: any): value is BindingHost => + isService(value) || + (typeof value === "object" && + value !== null && + "Type" in value && + value.Type === FunctionTypeId); + +/** + * Build a Railway reference-variable expression for a service-scoped + * variable, e.g. `${{my-api.RAILWAY_PRIVATE_DOMAIN}}`. Railway resolves the + * reference server-side whenever the consuming service's variables are + * materialized. + * + * @see https://docs.railway.com/guides/variables#reference-variables + */ +export const serviceReference = ( + service: { readonly name: Output.Output }, + variable: string | Output.Output, +) => Output.interpolate`\${{${service.name}.${variable}}}`; + +/** + * Build a Railway reference-variable expression for a shared + * (environment-wide) variable, e.g. `${{shared.LOG_LEVEL}}`. Useful for + * wiring `Railway.Variables`-managed shared values into a service's + * `variables` props. + */ +export const sharedReference = (name: string) => `\${{shared.${name}}}`; + +/** + * Deterministic env-var prefix for a bound resource, derived from its + * Logical ID — e.g. logical id `api-server` -> `API_SERVER`. Both the + * deploy-time Policy (which writes `_PRIVATE_DOMAIN` etc.) and the + * runtime accessor (which reads them) derive the same prefix, so the two + * halves agree without any out-of-band coordination. + */ +export const bindingEnvPrefix = (resource: { LogicalId: string }) => + sanitizeKey(resource.LogicalId).toUpperCase(); diff --git a/packages/alchemy/src/Railway/Service.ts b/packages/alchemy/src/Railway/Service.ts new file mode 100644 index 0000000000..45be9b938e --- /dev/null +++ b/packages/alchemy/src/Railway/Service.ts @@ -0,0 +1,721 @@ +import { + createService, + deleteService, + deployServiceInstanceV2, + getDeployment, + getEnvironmentServiceInstances, + type GetEnvironmentServiceInstancesOutput, + getService, + getServiceInstance, + getVariables, + updateService, + updateServiceInstance, + updateVolumeInstance, + upsertVariableCollection, + deleteVariable, +} from "@distilled.cloud/railway"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource, type ResourceBinding } from "../Resource.ts"; +import { + resolveEnvironmentId, + resolveProjectId, + type EnvironmentSource, + type ProjectSource, +} from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { resolveDefaultEnvironment } from "./Project.ts"; + +export type ServiceSource = Service | { serviceId: string }; + +/** + * A volume attachment requested through the Service/Function binding + * contract (see {@link VolumeMount}). The host's reconcile applies it via + * `updateVolumeInstance`, attaching the volume to the host service at the + * given mount path. + */ +export interface ServiceVolumeMount { + volumeId: string; + mountPath: string; +} + +/** + * The binding contract shared by `Railway.Service` and `Railway.Function`: + * policies attach env vars (Railway's native binding mechanism) and/or + * volume attachments to the consuming service. + */ +export interface ServiceBindingContract { + env?: Record; + volumes?: ServiceVolumeMount[]; +} + +export const resolveServiceId = (source: ServiceSource): string => { + if ("serviceId" in source && source.serviceId) { + return source.serviceId as unknown as string; + } + throw new Error( + "Invalid Railway service source: must be a Service or { serviceId }", + ); +}; + +/** + * Type guard for the `Railway.Service` resource — used by `Binding.Policy` + * implementations to verify the binding host is a Railway service before + * attaching env-var binding data to it. + * + * `Railway.Function` (the Effect-native runnable unit) deploys as a + * Railway service and shares the same `{ env? }` binding contract, so + * policies treat it as a service host too. + */ +export const isService = (value: any): value is Service => + typeof value === "object" && + value !== null && + "Type" in value && + (value.Type === "Railway.Service" || value.Type === "Railway.Function"); + +export type RailwayBuilder = "HEROKU" | "NIXPACKS" | "PAKETO" | "RAILPACK"; +export type RailwayRestartPolicy = "ALWAYS" | "NEVER" | "ON_FAILURE"; + +export type DeploymentStatus = + | "BUILDING" + | "CRASHED" + | "DEPLOYING" + | "FAILED" + | "INITIALIZING" + | "NEEDS_APPROVAL" + | "QUEUED" + | "REMOVED" + | "REMOVING" + | "SKIPPED" + | "SLEEPING" + | "SUCCESS" + | "WAITING"; + +export type ServiceProps = { + /** + * The Railway project (or `{ projectId }`) to create the service in. + */ + project: ProjectSource; + /** + * The environment (or `{ environmentId }`) whose service instance is + * managed. Defaults to the project's default (base) environment. + */ + environment?: EnvironmentSource; + /** + * Service name. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. + */ + name?: string; + /** + * The deployment source of the service — either a Docker `image` + * (e.g. `nginx:alpine`) or a GitHub `repo` (e.g. `owner/repo`). + */ + source?: { + /** Docker image to deploy, e.g. `ghcr.io/foo/bar:latest`. */ + image?: string; + /** GitHub repository full name, e.g. `railwayapp/starters`. */ + repo?: string; + /** Git branch to deploy when `repo` is set. */ + branch?: string; + }; + /** + * Credentials for pulling images from a private Docker registry. + */ + registryCredentials?: { username: string; password: string }; + /** + * Environment variables scoped to this service in the managed + * environment. Values may reference other Railway resources with + * Railway's `${{Service.VAR}}` template syntax. + */ + variables?: Record; + /** + * Command used to build the service (builder-dependent). + */ + buildCommand?: string; + /** + * Buildpack/builder to use when deploying from a repo. + */ + builder?: RailwayBuilder; + /** + * Cron schedule (e.g. `0 0 * * *`). When set, the service runs as a + * cron job — started on schedule and expected to exit. + */ + cronSchedule?: string; + /** + * Path to the Dockerfile when building from a repo. + */ + dockerfilePath?: string; + /** + * Seconds to wait for connections to drain on shutdown. + */ + drainingSeconds?: number; + /** + * Path Railway probes to decide a deployment is healthy, e.g. `/health`. + */ + healthcheckPath?: string; + /** + * Seconds to wait for the healthcheck to pass. + */ + healthcheckTimeout?: number; + /** + * Enable IPv6 egress for the service. + */ + ipv6EgressEnabled?: boolean; + /** + * Per-region replica configuration, e.g. + * `{ "us-west2": { numReplicas: 2 }, "europe-west4": { numReplicas: 1 } }`. + * Takes precedence over `region`/`numReplicas` when set. + */ + multiRegionConfig?: Record; + /** + * Nixpacks build plan override (raw JSON) when `builder` is `NIXPACKS`. + */ + nixpacksPlan?: unknown; + /** + * Number of replicas to run. + * + * @default 1 + */ + numReplicas?: number; + /** + * Seconds the previous deployment keeps running alongside the new one + * during a deploy (zero-downtime overlap). + */ + overlapSeconds?: number; + /** + * Commands run before each deploy (e.g. migrations). + */ + preDeployCommand?: string[]; + /** + * Path to a Railway config file (e.g. `railway.json`) within the repo. + */ + railwayConfigFile?: string; + /** + * Region to deploy the service into, e.g. `us-west2`. + */ + region?: string; + /** + * Restart policy for crashed containers. + * + * @default "ON_FAILURE" + */ + restartPolicyType?: RailwayRestartPolicy; + /** + * Max restart retries when `restartPolicyType` is `ON_FAILURE`. + */ + restartPolicyMaxRetries?: number; + /** + * Root directory of the service within the repo. + */ + rootDirectory?: string; + /** + * Enable app sleeping — scale to zero when no traffic. + * + * @default false + */ + sleepApplication?: boolean; + /** + * Command used to start the service. + */ + startCommand?: string; + /** + * File patterns that trigger redeploys on change (repo sources). + */ + watchPatterns?: string[]; + /** + * Whether to trigger a deployment after reconciling configuration and + * wait for it to succeed. + * + * @default true when `source` is set + */ + deploy?: boolean; +}; + +export type Service = Resource< + "Railway.Service", + ServiceProps, + { + serviceId: string; + name: string; + projectId: string; + environmentId: string; + /** ID of the most recent deployment triggered/observed by Alchemy. */ + deploymentId: string | undefined; + /** Status of that deployment. */ + deploymentStatus: DeploymentStatus | undefined; + /** Names of the service variables managed by this resource. */ + variableNames: string[]; + }, + ServiceBindingContract, + Providers +>; + +/** + * A Railway service — a deployable workload (Docker image or GitHub repo) + * within a project, configured per environment. + * + * The provider reconciles the service's instance configuration (start + * command, replicas, regions, healthcheck, cron schedule, ...) and its + * service-scoped variables, then triggers a deployment and waits for it + * to become healthy. + * + * @section Creating a Service + * @example Deploy a Docker image + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const api = yield* Railway.Service("api", { + * project, + * source: { image: "nginx:alpine" }, + * }); + * ``` + * + * @example Deploy from a GitHub repository + * ```typescript + * const app = yield* Railway.Service("app", { + * project, + * source: { repo: "railwayapp-templates/django", branch: "main" }, + * }); + * ``` + * + * @section Configuration + * @example Service with variables and healthcheck + * ```typescript + * const api = yield* Railway.Service("api", { + * project, + * source: { image: "ghcr.io/acme/api:latest" }, + * variables: { + * LOG_LEVEL: "info", + * DATABASE_URL: "${{Postgres.DATABASE_URL}}", + * }, + * healthcheckPath: "/health", + * numReplicas: 2, + * }); + * ``` + * + * @example Cron job service + * ```typescript + * const job = yield* Railway.Service("nightly-job", { + * project, + * source: { image: "ghcr.io/acme/job:latest" }, + * cronSchedule: "0 3 * * *", + * }); + * ``` + * + * @see https://docs.railway.com/reference/services + */ +export const Service = Resource("Railway.Service"); + +export class DeploymentFailed extends Data.TaggedError("DeploymentFailed")<{ + deploymentId: string; + status: DeploymentStatus; + serviceId: string; +}> {} + +export const ServiceProvider = () => + Provider.succeed(Service, { + stables: ["serviceId", "projectId", "environmentId"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const newProjectId = resolveProjectId(news.project as ProjectSource); + if (newProjectId !== output.projectId) { + return { action: "replace" } as const; + } + if ( + news.environment && + resolveEnvironmentId(news.environment as EnvironmentSource) !== + output.environmentId + ) { + return { action: "replace" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.serviceId) return undefined; + return yield* getService({ id: output.serviceId }).pipe( + Effect.map((service) => + // Railway soft-deletes services — a deleted service is still + // returned by getService with `deletedAt` set. + service.deletedAt !== null + ? undefined + : { + ...output, + name: service.name, + projectId: service.projectId, + }, + ), + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ id, news, olds, output, bindings }) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = news.environment + ? resolveEnvironmentId(news.environment as EnvironmentSource) + : yield* defaultEnvironmentId(projectId); + const name = yield* createServiceName(id, news.name); + + // Desired variables = props.variables + env injected by bindings. + const bindingEnv = collectBindingEnv(bindings); + const desiredVariables: Record = { + ...news.variables, + ...bindingEnv, + }; + + // Observe — does the service still exist? Soft-deleted services are + // still returned by getService with `deletedAt` set. + const observed = output?.serviceId + ? yield* getService({ id: output.serviceId }).pipe( + Effect.map((s) => (s.deletedAt !== null ? undefined : s)), + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ) + : undefined; + + // Ensure — create the service if missing, otherwise sync its name. + let serviceId: string; + let createdNow = false; + if (!observed) { + const created = yield* createService({ + input: { + projectId, + environmentId, + name, + branch: news.source?.branch, + source: + news.source?.image || news.source?.repo + ? { image: news.source.image, repo: news.source.repo } + : undefined, + registryCredentials: news.registryCredentials, + variables: + Object.keys(desiredVariables).length > 0 + ? desiredVariables + : undefined, + }, + }).pipe( + // AlreadyExists race / orphan from a crashed run — adopt the + // existing service with that name instead of failing. If the + // name is held by a service that is mid-deletion (it no longer + // shows up in the environment), the conflict is transient — + // retry below until the name frees up. + Effect.catchTag("ServiceNameConflict", (conflict) => + findServiceByName(environmentId, name).pipe( + Effect.flatMap((match) => + match + ? Effect.succeed({ id: match.serviceId }) + : Effect.fail(conflict), + ), + ), + ), + Effect.retry({ + while: (e) => e._tag === "ServiceNameConflict", + schedule: Schedule.spaced("2 seconds"), + times: 90, + }), + ); + serviceId = created.id; + createdNow = true; + } else { + serviceId = observed.id; + if (observed.name !== name) { + yield* updateService({ id: serviceId, input: { name } }); + } + } + + // Sync — service instance configuration, diffed against observed. + const instance = yield* getServiceInstance({ + serviceId, + environmentId, + }); + const patch = buildInstancePatch(news, instance, olds); + const configChanged = Object.keys(patch).length > 0; + if (configChanged) { + yield* updateServiceInstance({ + serviceId, + environmentId, + input: patch, + }); + } + + // Sync — service variables, diffed against observed cloud variables. + const observedVariables = (yield* getVariables({ + projectId, + environmentId, + serviceId, + })) as Record; + const managedNames = new Set([ + ...(output?.variableNames ?? []), + ...Object.keys(olds?.variables ?? {}), + ]); + const upserts: Record = {}; + for (const [key, value] of Object.entries(desiredVariables)) { + if (observedVariables[key] !== value) { + upserts[key] = value; + } + } + const removals = [...managedNames].filter( + (key) => !(key in desiredVariables) && key in (observedVariables ?? {}), + ); + const variablesChanged = + Object.keys(upserts).length > 0 || removals.length > 0; + if (Object.keys(upserts).length > 0) { + yield* upsertVariableCollection({ + input: { + projectId, + environmentId, + serviceId, + variables: upserts, + skipDeploys: true, + }, + }); + } + for (const key of removals) { + yield* deleteVariable({ + input: { projectId, environmentId, serviceId, name: key }, + }).pipe(Effect.catchTag("NotAuthorized", () => Effect.void)); + } + + // Sync — volume attachments requested through bindings. + const volumesChanged = yield* syncBoundVolumes( + serviceId, + environmentId, + bindings, + ); + + // Deploy — trigger a new deployment when anything changed (or no + // healthy deployment exists yet) and wait for it to settle. + let deploymentId = instance.latestDeployment?.id ?? undefined; + let deploymentStatus = (instance.latestDeployment?.status ?? + undefined) as DeploymentStatus | undefined; + const shouldDeploy = + (news.deploy ?? Boolean(news.source)) && + (createdNow || + configChanged || + variablesChanged || + volumesChanged || + deploymentStatus === undefined || + deploymentStatus === "FAILED" || + deploymentStatus === "CRASHED" || + deploymentStatus === "REMOVED" || + deploymentStatus === "SKIPPED"); + if (shouldDeploy) { + deploymentId = yield* deployServiceInstanceV2({ + serviceId, + environmentId, + }); + deploymentStatus = yield* waitForDeployment(serviceId, deploymentId); + } + + return { + serviceId, + name, + projectId, + environmentId, + deploymentId, + deploymentStatus, + variableNames: Object.keys(desiredVariables), + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteService({ id: output.serviceId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + }), + }); + +const createServiceName = (id: string, name: string | undefined) => + Effect.gen(function* () { + // Railway service names are documented at max 32 characters — longer + // names fail with ServiceNameInvalid. + return ( + name ?? + (yield* createPhysicalName({ id, lowercase: true, maxLength: 32 })) + ); + }); + +/** + * Find a live (non-soft-deleted) service by name via the environment's + * service-instance connection — Railway's only API for enumerating a + * project's services. + */ +export const findServiceByName = (environmentId: string, name: string) => + Effect.gen(function* () { + let after: string | undefined; + while (true) { + const page: GetEnvironmentServiceInstancesOutput = + yield* getEnvironmentServiceInstances({ + id: environmentId, + first: 100, + ...(after !== undefined ? { after } : {}), + }); + const match = page.serviceInstances.edges.find( + (e) => e.node.serviceName === name && e.node.deletedAt === null, + ); + if (match) return match.node; + const { hasNextPage, endCursor } = page.serviceInstances.pageInfo; + if (!hasNextPage || !endCursor) { + return undefined; + } + after = endCursor; + } + }); + +const defaultEnvironmentId = (projectId: string) => + Effect.map(resolveDefaultEnvironment(projectId), (env) => env.id); + +/** + * Apply volume attachments requested through the binding contract. + * + * Bindings arrive as plan nodes carrying an `action`; only changed + * bindings touch the API (`create`/`update` attach, `delete` detaches, + * `noop` skips entirely). Railway has no read API for volume instances, + * so the binding action is the change signal. Returns `true` when any + * attachment was mutated (callers fold this into their redeploy + * decision). + */ +export const syncBoundVolumes = ( + serviceId: string, + environmentId: string, + bindings: (ResourceBinding & { action?: string })[], +) => + Effect.gen(function* () { + let changed = false; + for (const binding of bindings) { + for (const mount of binding.data?.volumes ?? []) { + if (binding.action === "noop") continue; + changed = true; + if (binding.action === "delete") { + yield* updateVolumeInstance({ + volumeId: mount.volumeId, + environmentId, + input: { serviceId: null }, + }).pipe(Effect.catchTag("NotAuthorized", () => Effect.void)); + } else { + yield* updateVolumeInstance({ + volumeId: mount.volumeId, + environmentId, + input: { serviceId, mountPath: mount.mountPath }, + }); + } + } + } + return changed; + }); + +const collectBindingEnv = ( + bindings: ResourceBinding[], +): Record => + bindings + .filter( + (binding: ResourceBinding & { action?: string }) => + binding.action !== "delete", + ) + .map((binding) => binding?.data?.env ?? {}) + .reduce((acc, env) => ({ ...acc, ...env }), {}); + +type ServiceInstance = Effect.Success>; + +/** + * Build the minimal `updateServiceInstance` patch — only fields the user + * specified whose observed value differs from the desired value. + */ +const buildInstancePatch = ( + news: ServiceProps, + observed: ServiceInstance, + olds: ServiceProps | undefined, +) => { + const patch: Record = {}; + const sync = (key: string, desired: unknown, current: unknown) => { + if (desired !== undefined && !deepEqual(desired, current ?? undefined)) { + patch[key] = desired; + } + }; + sync("buildCommand", news.buildCommand, observed.buildCommand); + sync("builder", news.builder, observed.builder); + sync("cronSchedule", news.cronSchedule, observed.cronSchedule); + sync("dockerfilePath", news.dockerfilePath, observed.dockerfilePath); + sync("drainingSeconds", news.drainingSeconds, observed.drainingSeconds); + sync("healthcheckPath", news.healthcheckPath, observed.healthcheckPath); + sync( + "healthcheckTimeout", + news.healthcheckTimeout, + observed.healthcheckTimeout, + ); + sync("ipv6EgressEnabled", news.ipv6EgressEnabled, observed.ipv6EgressEnabled); + // multiRegionConfig is not observable via getServiceInstance — fall back + // to the previous props as a hint to skip a no-op patch. + if ( + news.multiRegionConfig !== undefined && + !deepEqual(news.multiRegionConfig, olds?.multiRegionConfig) + ) { + patch.multiRegionConfig = news.multiRegionConfig; + } + sync("nixpacksPlan", news.nixpacksPlan, observed.nixpacksPlan ?? undefined); + sync("numReplicas", news.numReplicas, observed.numReplicas); + sync("overlapSeconds", news.overlapSeconds, observed.overlapSeconds); + sync("preDeployCommand", news.preDeployCommand, observed.preDeployCommand); + sync("railwayConfigFile", news.railwayConfigFile, observed.railwayConfigFile); + sync("region", news.region, observed.region); + sync( + "restartPolicyMaxRetries", + news.restartPolicyMaxRetries, + observed.restartPolicyMaxRetries, + ); + sync("restartPolicyType", news.restartPolicyType, observed.restartPolicyType); + sync("rootDirectory", news.rootDirectory, observed.rootDirectory); + sync("sleepApplication", news.sleepApplication, observed.sleepApplication); + sync("startCommand", news.startCommand, observed.startCommand); + sync("watchPatterns", news.watchPatterns, observed.watchPatterns); + if ( + news.source && + (news.source.image || news.source.repo) && + (news.source.image !== (observed.source?.image ?? undefined) || + news.source.repo !== (observed.source?.repo ?? undefined)) + ) { + patch.source = { image: news.source.image, repo: news.source.repo }; + } + if (news.registryCredentials) { + // Credentials are write-only — always re-assert when provided. + patch.registryCredentials = news.registryCredentials; + } + return patch; +}; + +const deepEqual = (a: unknown, b: unknown): boolean => + a === b || JSON.stringify(a) === JSON.stringify(b); + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "SUCCESS", + "FAILED", + "CRASHED", + "REMOVED", + "SKIPPED", + "SLEEPING", +]); + +/** + * Poll a deployment until it reaches a terminal status. Fails with + * {@link DeploymentFailed} when the deployment ends in `FAILED`/`CRASHED`. + */ +export const waitForDeployment = (serviceId: string, deploymentId: string) => + Effect.gen(function* () { + const deployment = yield* getDeployment({ id: deploymentId }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("5 seconds"), + until: (d) => TERMINAL_STATUSES.has(d.status as DeploymentStatus), + times: 180, + }), + ); + const status = deployment.status as DeploymentStatus; + if (status === "FAILED" || status === "CRASHED") { + return yield* new DeploymentFailed({ + deploymentId, + status, + serviceId, + }); + } + return status; + }); diff --git a/packages/alchemy/src/Railway/ServiceDomain.ts b/packages/alchemy/src/Railway/ServiceDomain.ts new file mode 100644 index 0000000000..9e2921d583 --- /dev/null +++ b/packages/alchemy/src/Railway/ServiceDomain.ts @@ -0,0 +1,195 @@ +import { + createServiceDomain, + deleteServiceDomain, + getDomains, + getService, + updateServiceDomain, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { resolveEnvironmentId, type EnvironmentSource } from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { resolveServiceId, type ServiceSource } from "./Service.ts"; + +export type ServiceDomainProps = { + /** + * The Railway service (or `{ serviceId }`) to expose. + */ + service: ServiceSource; + /** + * The environment (or `{ environmentId }`) to expose the service in. + * Defaults to the environment of the given `Service` resource. + */ + environment?: EnvironmentSource; + /** + * Container port the domain routes to. Defaults to Railway's detected + * port. + */ + targetPort?: number; +}; + +export type ServiceDomain = Resource< + "Railway.ServiceDomain", + ServiceDomainProps, + { + domainId: string; + /** The generated `*.up.railway.app` domain. */ + domain: string; + /** Convenience `https://` URL for the domain. */ + url: string; + projectId: string; + serviceId: string; + environmentId: string; + targetPort: number | undefined; + }, + never, + Providers +>; + +/** + * A Railway-generated public domain (`*.up.railway.app`) for a service, + * with TLS provisioned automatically. + * + * @section Exposing a Service + * @example Generate a railway.app domain + * ```typescript + * const api = yield* Railway.Service("api", { + * project, + * source: { image: "nginx:alpine" }, + * }); + * const domain = yield* Railway.ServiceDomain("api-domain", { + * service: api, + * }); + * // => domain.url: https://api-production-xxxx.up.railway.app + * ``` + * + * @example Route to an explicit container port + * ```typescript + * const domain = yield* Railway.ServiceDomain("api-domain", { + * service: api, + * targetPort: 8080, + * }); + * ``` + * + * @see https://docs.railway.com/guides/public-networking + */ +export const ServiceDomain = Resource("Railway.ServiceDomain"); + +const resolveIds = (props: ServiceDomainProps) => { + const serviceId = resolveServiceId(props.service); + const environmentId = props.environment + ? resolveEnvironmentId(props.environment) + : "environmentId" in props.service && props.service.environmentId + ? (props.service.environmentId as unknown as string) + : undefined; + if (!environmentId) { + throw new Error( + "Railway.ServiceDomain requires `environment` when `service` is not a Service resource", + ); + } + return { serviceId, environmentId }; +}; + +export const ServiceDomainProvider = () => + Provider.succeed(ServiceDomain, { + stables: [ + "domainId", + "domain", + "url", + "projectId", + "serviceId", + "environmentId", + ], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const { serviceId, environmentId } = resolveIds(news); + if ( + serviceId !== output.serviceId || + environmentId !== output.environmentId + ) { + return { action: "replace" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.domainId) return undefined; + const domains = yield* getDomains({ + projectId: output.projectId, + environmentId: output.environmentId, + serviceId: output.serviceId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + const match = domains?.serviceDomains.find( + (d) => d.id === output.domainId, + ); + if (!match) return undefined; + return { + ...output, + domain: match.domain, + url: `https://${match.domain}`, + targetPort: match.targetPort ?? undefined, + }; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { serviceId, environmentId } = resolveIds(news); + const projectId = + output?.projectId ?? (yield* getService({ id: serviceId })).projectId; + + // Observe — find the existing generated domain, if any. + const domains = yield* getDomains({ + projectId, + environmentId, + serviceId, + }); + const observed = output?.domainId + ? domains.serviceDomains.find((d) => d.id === output.domainId) + : undefined; + + // Ensure — create the domain if missing. + const domain = + observed ?? + (yield* createServiceDomain({ + input: { serviceId, environmentId, targetPort: news.targetPort }, + })); + + // Sync — adjust the target port if it differs from the observed one. + if ( + observed && + news.targetPort !== undefined && + (observed.targetPort ?? undefined) !== news.targetPort + ) { + yield* updateServiceDomain({ + input: { + serviceDomainId: domain.id, + serviceId, + environmentId, + domain: domain.domain, + targetPort: news.targetPort, + }, + }); + } + + return { + domainId: domain.id, + domain: domain.domain, + url: `https://${domain.domain}`, + projectId, + serviceId, + environmentId, + targetPort: news.targetPort ?? domain.targetPort ?? undefined, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteServiceDomain({ id: output.domainId }).pipe( + Effect.catchTags({ + NotAuthorized: () => Effect.void, + ProblemProcessingRequest: () => Effect.void, + }), + ); + }), + }); diff --git a/packages/alchemy/src/Railway/TcpProxy.ts b/packages/alchemy/src/Railway/TcpProxy.ts new file mode 100644 index 0000000000..1a5e18f558 --- /dev/null +++ b/packages/alchemy/src/Railway/TcpProxy.ts @@ -0,0 +1,215 @@ +import { + createTcpProxy, + deleteTcpProxy, + getTcpProxies, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { resolveEnvironmentId, type EnvironmentSource } from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { resolveServiceId, type ServiceSource } from "./Service.ts"; + +export type TcpProxyProps = { + /** + * The Railway service (or `{ serviceId }`) to proxy to. + */ + service: ServiceSource; + /** + * The environment (or `{ environmentId }`) the proxy lives in. + * Defaults to the environment of the given `Service` resource. + */ + environment?: EnvironmentSource; + /** + * The port your application listens on inside the container. + * Changing it replaces the proxy. + */ + applicationPort: number; +}; + +export type TcpProxy = Resource< + "Railway.TcpProxy", + TcpProxyProps, + { + proxyId: string; + /** Public domain of the proxy, e.g. `yamabiko.proxy.rlwy.net`. */ + domain: string; + /** Public port assigned by Railway. */ + proxyPort: number; + applicationPort: number; + serviceId: string; + environmentId: string; + }, + never, + Providers +>; + +/** + * A Railway TCP proxy — exposes a raw TCP port of a service publicly + * (e.g. for databases or game servers). + * + * Note: Railway requires a redeploy of the service for a newly created + * proxy to become active; trigger one by updating the `Service` (or rely + * on the next deploy). + * + * @section Exposing a TCP Port + * @example Proxy a Postgres service + * ```typescript + * const db = yield* Railway.Service("db", { + * project, + * source: { image: "postgres:16" }, + * variables: { POSTGRES_PASSWORD: "secret" }, + * }); + * const proxy = yield* Railway.TcpProxy("db-proxy", { + * service: db, + * applicationPort: 5432, + * }); + * // connect via `${proxy.domain}:${proxy.proxyPort}` + * ``` + * + * @see https://docs.railway.com/guides/public-networking#tcp-proxying + */ +export const TcpProxy = Resource("Railway.TcpProxy"); + +const resolveIds = (props: TcpProxyProps) => { + const serviceId = resolveServiceId(props.service); + const environmentId = props.environment + ? resolveEnvironmentId(props.environment) + : "environmentId" in props.service && props.service.environmentId + ? (props.service.environmentId as unknown as string) + : undefined; + if (!environmentId) { + throw new Error( + "Railway.TcpProxy requires `environment` when `service` is not a Service resource", + ); + } + return { serviceId, environmentId }; +}; + +export const TcpProxyProvider = () => + Provider.succeed(TcpProxy, { + stables: ["proxyId", "domain", "proxyPort", "serviceId", "environmentId"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const { serviceId, environmentId } = resolveIds(news); + if ( + serviceId !== output.serviceId || + environmentId !== output.environmentId || + news.applicationPort !== output.applicationPort + ) { + // The proxy has no update API — any change is a replacement. + // Railway allows only a single TCP proxy per service instance, + // so the old proxy must be deleted before the new one is created. + return { action: "replace", deleteFirst: true } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.proxyId) return undefined; + const proxies = yield* getTcpProxies({ + serviceId: output.serviceId, + environmentId: output.environmentId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + const match = proxies?.find((p) => p.id === output.proxyId); + if (!match) return undefined; + return { + ...output, + domain: match.domain, + proxyPort: match.proxyPort, + applicationPort: match.applicationPort, + }; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { serviceId, environmentId } = resolveIds(news); + + // Observe — an existing proxy for this application port. + const proxies = yield* getTcpProxies({ serviceId, environmentId }); + const observed = proxies.find( + (p) => + p.id === output?.proxyId || + p.applicationPort === news.applicationPort, + ); + + // Railway allows only a single TCP proxy per service instance, so a + // stale proxy (e.g. the old generation during a replacement) must be + // deleted before the desired one can be created. + if (!observed) { + for (const stale of proxies) { + yield* deleteTcpProxyIdempotent(stale.id, serviceId, environmentId); + } + } + + // Ensure — create when missing. Retry the single-proxy conflict in + // case a just-deleted proxy is still releasing its slot. + const proxy = + observed ?? + (yield* createTcpProxy({ + input: { + serviceId, + environmentId, + applicationPort: news.applicationPort, + }, + }).pipe( + Effect.retry({ + while: (e) => e._tag === "TcpProxyLimitExceeded", + schedule: Schedule.spaced("2 seconds"), + times: 30, + }), + )); + + return { + proxyId: proxy.id, + domain: proxy.domain, + proxyPort: proxy.proxyPort, + applicationPort: proxy.applicationPort, + serviceId, + environmentId, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteTcpProxyIdempotent( + output.proxyId, + output.serviceId, + output.environmentId, + ); + }), + }); + +/** + * Delete a TCP proxy idempotently. Railway answers + * `TcpProxyOperationInProgress` both for a genuinely busy proxy AND for a + * proxy that is already deleted/deleting — so on that error we observe the + * live proxy list and only keep retrying while the proxy still exists. + */ +const deleteTcpProxyIdempotent = ( + proxyId: string, + serviceId: string, + environmentId: string, +) => + deleteTcpProxy({ id: proxyId }).pipe( + Effect.asVoid, + Effect.catchTags({ + NotAuthorized: () => Effect.void, + ProblemProcessingRequest: () => Effect.void, + TcpProxyOperationInProgress: (err) => + getTcpProxies({ serviceId, environmentId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed([])), + Effect.flatMap((proxies) => + proxies.some((p) => p.id === proxyId) + ? Effect.fail(err) + : Effect.void, + ), + ), + }), + Effect.retry({ + while: (e) => e._tag === "TcpProxyOperationInProgress", + schedule: Schedule.spaced("2 seconds"), + times: 30, + }), + ); diff --git a/packages/alchemy/src/Railway/Variables.ts b/packages/alchemy/src/Railway/Variables.ts new file mode 100644 index 0000000000..88603ed059 --- /dev/null +++ b/packages/alchemy/src/Railway/Variables.ts @@ -0,0 +1,200 @@ +import { + deleteVariable, + getVariables, + upsertVariableCollection, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { recordsEqual } from "../Util/equal.ts"; +import { + resolveEnvironmentId, + resolveProjectId, + type EnvironmentSource, + type ProjectSource, +} from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { resolveServiceId, type ServiceSource } from "./Service.ts"; + +export type VariablesProps = { + /** + * The Railway project (or `{ projectId }`) the variables belong to. + */ + project: ProjectSource; + /** + * The environment (or `{ environmentId }`) the variables belong to. + */ + environment: EnvironmentSource; + /** + * The service (or `{ serviceId }`) to scope the variables to. When + * omitted, the variables are shared (environment-wide). + */ + service?: ServiceSource; + /** + * The variables to manage. Only variables listed here are owned by + * this resource — removing a key deletes it from Railway, but foreign + * variables are left untouched. + */ + variables: Record; + /** + * Skip redeploys of affected services when variables change. + * + * @default false + */ + skipDeploys?: boolean; +}; + +export type Variables = Resource< + "Railway.Variables", + VariablesProps, + { + projectId: string; + environmentId: string; + serviceId: string | undefined; + /** Names of the variables managed by this resource. */ + names: string[]; + /** The managed variables as last reconciled. */ + variables: Record; + }, + never, + Providers +>; + +/** + * A set of Railway environment variables — either shared across an + * environment or scoped to a single service. + * + * @section Managing Variables + * @example Shared (environment-wide) variables + * ```typescript + * const project = yield* Railway.Project("my-project"); + * yield* Railway.Variables("shared-config", { + * project, + * environment: { environmentId: project.defaultEnvironmentId }, + * variables: { + * LOG_LEVEL: "info", + * REGION: "us-west2", + * }, + * }); + * ``` + * + * @example Service-scoped variables + * ```typescript + * yield* Railway.Variables("api-config", { + * project, + * environment: { environmentId: project.defaultEnvironmentId }, + * service: api, + * variables: { PORT: "8080" }, + * }); + * ``` + * + * @see https://docs.railway.com/guides/variables + */ +export const Variables = Resource("Railway.Variables"); + +export const VariablesProvider = () => + Provider.succeed(Variables, { + stables: ["projectId", "environmentId", "serviceId"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = resolveEnvironmentId( + news.environment as EnvironmentSource, + ); + const serviceId = news.service + ? resolveServiceId(news.service as ServiceSource) + : undefined; + if ( + projectId !== output.projectId || + environmentId !== output.environmentId || + serviceId !== output.serviceId + ) { + return { action: "replace" } as const; + } + if (!recordsEqual(news.variables ?? {}, output.variables ?? {})) { + return { action: "update" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output) return undefined; + const observed = (yield* getVariables({ + projectId: output.projectId, + environmentId: output.environmentId, + serviceId: output.serviceId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + )) as Record | undefined; + if (observed === undefined) return undefined; + const variables: Record = {}; + for (const name of output.names) { + if (name in observed) variables[name] = observed[name]!; + } + return { ...output, names: Object.keys(variables), variables }; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = resolveEnvironmentId( + news.environment as EnvironmentSource, + ); + const serviceId = news.service + ? resolveServiceId(news.service as ServiceSource) + : undefined; + const desired = news.variables ?? {}; + + // Observe — current variables in the cloud. + const observed = (yield* getVariables({ + projectId, + environmentId, + serviceId, + })) as Record; + + // Sync — upsert changed values; delete managed names that were removed. + const upserts: Record = {}; + for (const [key, value] of Object.entries(desired)) { + if (observed[key] !== value) upserts[key] = value; + } + if (Object.keys(upserts).length > 0) { + yield* upsertVariableCollection({ + input: { + projectId, + environmentId, + serviceId, + variables: upserts, + skipDeploys: news.skipDeploys, + }, + }); + } + const managed = new Set(output?.names ?? []); + for (const key of managed) { + if (!(key in desired) && key in observed) { + yield* deleteVariable({ + input: { projectId, environmentId, serviceId, name: key }, + }).pipe(Effect.catchTag("NotAuthorized", () => Effect.void)); + } + } + + return { + projectId, + environmentId, + serviceId, + names: Object.keys(desired), + variables: desired, + }; + }), + delete: Effect.fn(function* ({ output }) { + for (const name of output.names) { + yield* deleteVariable({ + input: { + projectId: output.projectId, + environmentId: output.environmentId, + serviceId: output.serviceId, + name, + }, + }).pipe(Effect.catchTag("NotAuthorized", () => Effect.void)); + } + }), + }); diff --git a/packages/alchemy/src/Railway/Volume.ts b/packages/alchemy/src/Railway/Volume.ts new file mode 100644 index 0000000000..738b1ff4ac --- /dev/null +++ b/packages/alchemy/src/Railway/Volume.ts @@ -0,0 +1,186 @@ +import { + createVolume, + deleteVolume, + updateVolume, + updateVolumeInstance, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + resolveEnvironmentId, + resolveProjectId, + type EnvironmentSource, + type ProjectSource, +} from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; +import { resolveServiceId, type ServiceSource } from "./Service.ts"; + +export type VolumeProps = { + /** + * The Railway project (or `{ projectId }`) to create the volume in. + */ + project: ProjectSource; + /** + * The environment (or `{ environmentId }`) the volume instance lives in. + * Defaults to the project's default environment. + */ + environment?: EnvironmentSource; + /** + * The service (or `{ serviceId }`) to attach the volume to. + */ + service?: ServiceSource; + /** + * Path inside the container where the volume is mounted, + * e.g. `/data`. + */ + mountPath: string; + /** + * Volume name. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. + */ + name?: string; + /** + * Region to provision the volume in. Cannot be changed after creation. + */ + region?: string; +}; + +export type Volume = Resource< + "Railway.Volume", + VolumeProps, + { + volumeId: string; + name: string; + projectId: string; + environmentId: string | undefined; + serviceId: string | undefined; + mountPath: string; + region: string | undefined; + }, + never, + Providers +>; + +/** + * A persistent Railway volume mounted into a service. + * + * @section Creating a Volume + * @example Volume attached to a service + * ```typescript + * const db = yield* Railway.Service("db", { + * project, + * source: { image: "postgres:16" }, + * }); + * const data = yield* Railway.Volume("db-data", { + * project, + * service: db, + * mountPath: "/var/lib/postgresql/data", + * }); + * ``` + * + * @see https://docs.railway.com/reference/volumes + */ +export const Volume = Resource("Railway.Volume"); + +export const VolumeProvider = () => + Provider.succeed(Volume, { + stables: ["volumeId", "projectId", "environmentId", "region"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const projectId = resolveProjectId(news.project as ProjectSource); + if (projectId !== output.projectId) { + return { action: "replace" } as const; + } + if ( + news.region !== undefined && + output.region !== undefined && + news.region !== output.region + ) { + return { action: "replace" } as const; + } + } + return undefined; + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const projectId = resolveProjectId(news.project as ProjectSource); + const environmentId = news.environment + ? resolveEnvironmentId(news.environment as EnvironmentSource) + : undefined; + const serviceId = news.service + ? resolveServiceId(news.service as ServiceSource) + : undefined; + const name = yield* createVolumeName(id, news.name); + + if (!output) { + const created = yield* createVolume({ + input: { + projectId, + environmentId, + serviceId, + mountPath: news.mountPath, + region: news.region, + }, + }); + if (created.name !== name) { + yield* updateVolume({ + volumeId: created.id, + input: { name }, + }).pipe(Effect.catchTag("NotAuthorized", () => Effect.void)); + } + return { + volumeId: created.id, + name, + projectId, + environmentId, + serviceId, + mountPath: news.mountPath, + region: news.region, + }; + } + + // Sync — name and per-instance mount path / service attachment. + if (output.name !== name) { + yield* updateVolume({ volumeId: output.volumeId, input: { name } }); + } + if ( + news.mountPath !== output.mountPath || + serviceId !== output.serviceId + ) { + yield* updateVolumeInstance({ + volumeId: output.volumeId, + environmentId: environmentId ?? output.environmentId, + input: { + mountPath: news.mountPath, + serviceId, + }, + }); + } + return { + volumeId: output.volumeId, + name, + projectId, + environmentId: environmentId ?? output.environmentId, + serviceId, + mountPath: news.mountPath, + region: output.region, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteVolume({ volumeId: output.volumeId }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.void), + ); + }), + }); + +const createVolumeName = (id: string, name: string | undefined) => + Effect.gen(function* () { + // Railway volume names have the same ~32 char limit as service names. + return ( + name ?? + (yield* createPhysicalName({ id, lowercase: true, maxLength: 32 })) + ); + }); diff --git a/packages/alchemy/src/Railway/VolumeMount.ts b/packages/alchemy/src/Railway/VolumeMount.ts new file mode 100644 index 0000000000..931abb9118 --- /dev/null +++ b/packages/alchemy/src/Railway/VolumeMount.ts @@ -0,0 +1,95 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Binding from "../Binding.ts"; +import { RuntimeContext } from "../RuntimeContext.ts"; +import { bindingEnvPrefix, isBindingHost } from "./Reference.ts"; +import type { Volume } from "./Volume.ts"; + +/** + * Volume mount binding. + * + * Attaches a `Railway.Volume` to the consuming Service/Function: + * + * - At deploy time, {@link VolumeMountPolicy} records the volume + * attachment (`{ volumes: [{ volumeId, mountPath }] }`) on the host's + * binding contract; the host provider's reconcile applies it via + * `updateVolumeInstance` and redeploys so the mount takes effect. The + * mount path is also published as `_MOUNT_PATH` so the runtime + * accessor can find it. + * - At runtime, `VolumeMount.bind(volume)` returns an Effect producing + * the absolute mount path inside the container. It requires + * {@link RuntimeContext}. + * + * Create the volume *without* `service` props when binding it — the + * binding owns the attachment: + * + * @example + * ```typescript + * // stack: const data = yield* Railway.Volume("data", { project, mountPath: "/data" }); + * // function Init: + * const dataDir = yield* Railway.VolumeMount.bind(data); + * // runtime: + * const dir = yield* dataDir; // "/data" + * ``` + */ +export class VolumeMount extends Binding.Service< + VolumeMount, + ( + volume: Volume, + ) => Effect.Effect> +>()("Railway.VolumeMount") {} + +export const VolumeMountLive = Layer.effect( + VolumeMount, + Effect.gen(function* () { + const Policy = yield* VolumeMountPolicy; + + return Effect.fn(function* (volume: Volume) { + yield* Policy(volume); + const key = `${bindingEnvPrefix(volume)}_MOUNT_PATH`; + return RuntimeContext.pipe( + Effect.flatMap((ctx) => { + const value = ctx.env[key]; + return typeof value === "string" && value.length > 0 + ? Effect.succeed(value) + : Effect.die( + `Railway.VolumeMount: env var '${key}' is not set — ` + + `was '${volume.LogicalId}' bound via VolumeMountPolicy at deploy time?`, + ); + }), + ); + }); + }), +); + +/** + * Deploy-time half of {@link VolumeMount}: records the volume attachment + * and the mount-path env var on the consuming Railway service. + */ +export class VolumeMountPolicy extends Binding.Policy< + VolumeMountPolicy, + (volume: Volume) => Effect.Effect +>()("Railway.VolumeMount") {} + +export const VolumeMountPolicyLive = VolumeMountPolicy.layer.succeed( + Effect.fn(function* (host, volume) { + if (isBindingHost(host)) { + const prefix = bindingEnvPrefix(volume); + yield* host.bind`Mount(${host}, ${volume})`({ + env: { + [`${prefix}_MOUNT_PATH`]: volume.mountPath, + }, + volumes: [ + { + volumeId: volume.volumeId, + mountPath: volume.mountPath, + }, + ], + }); + } else { + return yield* Effect.die( + `VolumeMountPolicy does not support runtime '${host.Type}'`, + ); + } + }), +); diff --git a/packages/alchemy/src/Railway/Webhook.ts b/packages/alchemy/src/Railway/Webhook.ts new file mode 100644 index 0000000000..e23e015543 --- /dev/null +++ b/packages/alchemy/src/Railway/Webhook.ts @@ -0,0 +1,279 @@ +import { + createNotificationRule, + deleteNotificationRule, + getNotificationRules, + getProject, + updateNotificationRule, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { resolveProjectId, type ProjectSource } from "./Environment.ts"; +import type { Providers } from "./Providers.ts"; + +export type WebhookSeverity = "CRITICAL" | "INFO" | "NOTICE" | "WARNING"; + +export type WebhookProps = { + /** + * The Railway project (or `{ projectId }`) the webhook is scoped to. + * Changing it replaces the webhook. + */ + project: ProjectSource; + /** + * The HTTPS endpoint that receives event payloads. Railway POSTs a JSON + * payload to this URL whenever a subscribed event fires. + */ + url: string; + /** + * Event types to subscribe to, e.g. `["Deployment.failed", + * "Deployment.crashed"]`. Railway does not validate these — see the + * webhooks docs for the payload `type` values it emits. + */ + eventTypes: string[]; + /** + * Only deliver events with one of these severities. When omitted, + * events of every severity are delivered. + */ + severities?: WebhookSeverity[]; +}; + +export type Webhook = Resource< + "Railway.Webhook", + WebhookProps, + { + /** ID of the backing notification rule. */ + webhookId: string; + url: string; + eventTypes: string[]; + projectId: string; + workspaceId: string; + }, + never, + Providers +>; + +/** + * A Railway project webhook — POSTs a JSON payload to a URL when platform + * events (deployment status changes, volume/usage alerts, …) fire for the + * project. + * + * Railway models webhooks as *notification rules* with a `WEBHOOK` + * channel; this resource manages one rule scoped to the given project. + * + * Note: Railway currently rejects in-place updates and deletes of + * notification rules for workspace-scoped tokens (`Not Authorized`). With + * such a token, Alchemy converges updates by creating a replacement rule + * and logs a warning when the superseded rule cannot be removed. Use a + * personal (account) token to avoid leaking superseded rules. + * + * @section Creating a Webhook + * @example Notify an endpoint on failed deploys + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const webhook = yield* Railway.Webhook("alerts", { + * project, + * url: "https://hooks.example.com/railway", + * eventTypes: ["Deployment.failed", "Deployment.crashed"], + * }); + * ``` + * + * @example Only deliver critical events + * ```typescript + * const webhook = yield* Railway.Webhook("pager", { + * project, + * url: "https://hooks.example.com/pager", + * eventTypes: ["Deployment.failed"], + * severities: ["CRITICAL"], + * }); + * ``` + * + * @see https://docs.railway.com/observability/webhooks + */ +export const Webhook = Resource("Railway.Webhook"); + +/** Extract the URL of a rule's `WEBHOOK` channel, if it has one. */ +const getWebhookUrl = ( + channels: ReadonlyArray<{ config: unknown }>, +): string | undefined => { + for (const { config } of channels) { + if ( + typeof config === "object" && + config !== null && + "type" in config && + config.type === "WEBHOOK" && + "url" in config && + typeof config.url === "string" + ) { + return config.url; + } + } + return undefined; +}; + +/** Order-insensitive comparison of two string lists. */ +const sameMembers = (a: ReadonlyArray, b: ReadonlyArray) => + a.length === b.length && + [...a].sort().join("\n") === [...b].sort().join("\n"); + +const resolveWorkspaceId = (projectId: string) => + Effect.gen(function* () { + const project = yield* getProject({ id: projectId }); + if (!project.workspaceId) { + throw new Error( + `Railway project ${projectId} has no workspaceId — cannot manage webhooks`, + ); + } + return project.workspaceId; + }); + +/** + * Best-effort removal of a notification rule. Railway rejects + * `notificationRuleDelete` for workspace-scoped tokens (and also returns + * `Not Authorized` for rules that are already gone) — verify whether the + * rule is still visible and warn instead of failing the operation. + */ +const removeRule = (args: { + ruleId: string; + workspaceId: string; + projectId: string; +}) => + Effect.gen(function* () { + yield* deleteNotificationRule({ id: args.ruleId }).pipe( + Effect.catchTags({ + ProblemProcessingRequest: () => Effect.void, + NotAuthorized: () => + Effect.gen(function* () { + const rules = yield* getNotificationRules({ + workspaceId: args.workspaceId, + projectId: args.projectId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + if (rules?.some((r) => r.id === args.ruleId)) { + yield* Effect.logWarning( + `Railway rejected deletion of notification rule ${args.ruleId} ` + + `(workspace-scoped tokens cannot delete notification rules). ` + + `The webhook may keep firing until removed in the dashboard ` + + `or its project is deleted.`, + ); + } + }), + }), + ); + }); + +export const WebhookProvider = () => + Provider.succeed(Webhook, { + stables: ["projectId", "workspaceId"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (output) { + const projectId = resolveProjectId(news.project as ProjectSource); + if (projectId !== output.projectId) { + return { action: "replace" } as const; + } + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.webhookId) return undefined; + const rules = yield* getNotificationRules({ + workspaceId: output.workspaceId, + projectId: output.projectId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + const match = rules?.find((r) => r.id === output.webhookId); + if (!match) return undefined; + return { + ...output, + url: getWebhookUrl(match.channels) ?? output.url, + eventTypes: [...match.eventTypes], + }; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const projectId = resolveProjectId(news.project as ProjectSource); + const workspaceId = + output?.workspaceId ?? (yield* resolveWorkspaceId(projectId)); + const desiredChannel = { type: "WEBHOOK", url: news.url }; + + // Observe — the backing notification rule, if it still exists. + const rules = yield* getNotificationRules({ workspaceId, projectId }); + const observed = output?.webhookId + ? rules.find((r) => r.id === output.webhookId) + : undefined; + + // Ensure — create the rule when missing. + const created = observed + ? undefined + : yield* createNotificationRule({ + input: { + workspaceId, + projectId, + eventTypes: news.eventTypes, + severities: news.severities, + channelConfigs: [desiredChannel], + }, + }); + + // Sync — converge url / eventTypes / severities on the observed rule. + let webhookId = (observed ?? created!).id; + if (observed) { + const drifted = + getWebhookUrl(observed.channels) !== news.url || + !sameMembers(observed.eventTypes, news.eventTypes) || + (news.severities !== undefined && + !sameMembers(observed.severities, news.severities)); + if (drifted) { + yield* updateNotificationRule({ + id: observed.id, + input: { + channelConfigs: [desiredChannel], + eventTypes: news.eventTypes, + severities: news.severities, + }, + }).pipe( + // Workspace-scoped tokens cannot mutate rules in place — + // converge by creating a replacement and removing the old + // rule on a best-effort basis. + Effect.catchTag("NotAuthorized", () => + Effect.gen(function* () { + const replacement = yield* createNotificationRule({ + input: { + workspaceId, + projectId, + eventTypes: news.eventTypes, + severities: news.severities, + channelConfigs: [desiredChannel], + }, + }); + webhookId = replacement.id; + yield* removeRule({ + ruleId: observed.id, + workspaceId, + projectId, + }); + return replacement; + }), + ), + ); + } + } + + return { + webhookId, + url: news.url, + eventTypes: [...news.eventTypes], + projectId, + workspaceId, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* removeRule({ + ruleId: output.webhookId, + workspaceId: output.workspaceId, + projectId: output.projectId, + }); + }), + }); diff --git a/packages/alchemy/src/Railway/index.ts b/packages/alchemy/src/Railway/index.ts new file mode 100644 index 0000000000..9107c4ebcf --- /dev/null +++ b/packages/alchemy/src/Railway/index.ts @@ -0,0 +1,18 @@ +export * from "./AuthProvider.ts"; +export * from "./Connect.ts"; +export * from "./CustomDomain.ts"; +export * from "./Database.ts"; +export * from "./DatabaseUrl.ts"; +export * from "./Environment.ts"; +export * from "./Function.ts"; +export * from "./Project.ts"; +export * from "./ProjectToken.ts"; +export * from "./Providers.ts"; +export * from "./Reference.ts"; +export * from "./Service.ts"; +export * from "./ServiceDomain.ts"; +export * from "./TcpProxy.ts"; +export * from "./Variables.ts"; +export * from "./Volume.ts"; +export * from "./VolumeMount.ts"; +export * from "./Webhook.ts"; diff --git a/packages/alchemy/test/Railway/CustomDomain.test.ts b/packages/alchemy/test/Railway/CustomDomain.test.ts new file mode 100644 index 0000000000..386d3ec734 --- /dev/null +++ b/packages/alchemy/test/Railway/CustomDomain.test.ts @@ -0,0 +1,97 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getDomains } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// DNS for this domain is never configured — the test only asserts that the +// domain attaches, reports its required DNS records, and detaches cleanly. +const TEST_DOMAIN = "alchemy-railway-test.alchemy-effect.example.com"; + +test.provider( + "attach and detach a custom domain (no DNS validation)", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { service, domain } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Web", { + project: { projectId }, + environment: { environmentId }, + source: { image: "nginx:alpine" }, + deploy: false, + }); + const domain = yield* Railway.CustomDomain("WebDomain", { + service, + domain: TEST_DOMAIN, + }); + return { service, domain }; + }), + ); + + expect(domain.domainId).toBeDefined(); + expect(domain.domain).toEqual(TEST_DOMAIN); + expect(domain.serviceId).toEqual(service.serviceId); + expect(domain.environmentId).toEqual(service.environmentId); + expect(domain.dnsRecords.length).toBeGreaterThan(0); + expect(domain.dnsRecords[0]!.requiredValue).toBeDefined(); + + const observed = yield* getDomains({ + projectId: domain.projectId, + environmentId: domain.environmentId, + serviceId: domain.serviceId, + }); + const match = observed.customDomains.find( + (d) => d.id === domain.domainId, + ); + expect(match?.domain).toEqual(TEST_DOMAIN); + + // Re-deploy with identical props — must be a stable no-op. + const { domain: stable } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Web", { + project: { projectId }, + environment: { environmentId }, + source: { image: "nginx:alpine" }, + deploy: false, + }); + const domain = yield* Railway.CustomDomain("WebDomain", { + service, + domain: TEST_DOMAIN, + }); + return { service, domain }; + }), + ); + expect(stable.domainId).toEqual(domain.domainId); + + yield* stack.destroy(); + + const after = yield* getDomains({ + projectId: domain.projectId, + environmentId: domain.environmentId, + serviceId: domain.serviceId, + }).pipe( + Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), + ); + expect( + after?.customDomains.find((d) => d.id === domain.domainId), + ).toBeUndefined(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/Railway/Environment.test.ts b/packages/alchemy/test/Railway/Environment.test.ts new file mode 100644 index 0000000000..f201c0ff1e --- /dev/null +++ b/packages/alchemy/test/Railway/Environment.test.ts @@ -0,0 +1,160 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getEnvironment, getVariables } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Uses the shared test project (see harness.ts) — Railway rate-limits +// project creation to 1/30s per workspace, so tests must not create one. +test.provider( + "create, rename, fork and delete environments with shared variables", + (stack) => + Effect.gen(function* () { + const { projectId } = yield* ensureSharedProject; + + // Pre-clean: crashed runs leave orphan environments behind (scratch + // state is in-memory, so it doesn't survive across runs). Deploy the + // full set of names this test uses — create adopts an existing + // environment on name conflict. The initial deploy below then + // garbage-collects "Renamed" and "Forked" (they drop out of the + // program) while "Env" carries straight into the test, so the names + // this test is about to use are never deleted-and-recreated (a + // just-deleted name can stay reserved while deletion propagates). + yield* stack.deploy( + Effect.gen(function* () { + yield* Railway.Environment("Env", { + project: { projectId }, + name: "alchemy-test-env", + }); + yield* Railway.Environment("Renamed", { + project: { projectId }, + name: "alchemy-test-env-renamed", + }); + yield* Railway.Environment("Forked", { + project: { projectId }, + name: "alchemy-test-env-fork", + }); + }), + ); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const environment = yield* Railway.Environment("Env", { + project: { projectId }, + name: "alchemy-test-env", + }); + const vars = yield* Railway.Variables("SharedVars", { + project: { projectId }, + environment, + variables: { LOG_LEVEL: "info", REGION: "us-west2" }, + }); + return { environment, vars }; + }), + ); + const { environment } = initial; + + expect(environment.environmentId).toBeDefined(); + expect(environment.name).toEqual("alchemy-test-env"); + expect(environment.projectId).toEqual(projectId); + expect(environment.isEphemeral).toBe(false); + + const fetched = yield* getEnvironment({ + id: environment.environmentId, + }); + expect(fetched.id).toEqual(environment.environmentId); + expect(fetched.name).toEqual("alchemy-test-env"); + expect(fetched.projectId).toEqual(projectId); + + const observedVars = (yield* getVariables({ + projectId, + environmentId: environment.environmentId, + })) as Record; + expect(observedVars.LOG_LEVEL).toEqual("info"); + expect(observedVars.REGION).toEqual("us-west2"); + + // Update — rename in place, fork a second environment from it and + // shrink the managed variable set. + const updated = yield* stack.deploy( + Effect.gen(function* () { + const environment = yield* Railway.Environment("Env", { + project: { projectId }, + name: "alchemy-test-env-renamed", + }); + const forked = yield* Railway.Environment("Forked", { + project: { projectId }, + name: "alchemy-test-env-fork", + sourceEnvironment: { environmentId: environment.environmentId }, + skipInitialDeploys: true, + }); + const vars = yield* Railway.Variables("SharedVars", { + project: { projectId }, + environment, + variables: { LOG_LEVEL: "debug" }, + }); + return { environment, forked, vars }; + }), + ); + + expect(updated.environment.environmentId).toEqual( + environment.environmentId, + ); + expect(updated.environment.name).toEqual("alchemy-test-env-renamed"); + + const refetched = yield* getEnvironment({ + id: environment.environmentId, + }); + expect(refetched.name).toEqual("alchemy-test-env-renamed"); + + expect(updated.forked.environmentId).toBeDefined(); + expect(updated.forked.environmentId).not.toEqual( + environment.environmentId, + ); + expect(updated.forked.name).toEqual("alchemy-test-env-fork"); + + const updatedVars = (yield* getVariables({ + projectId, + environmentId: environment.environmentId, + })) as Record; + expect(updatedVars.LOG_LEVEL).toEqual("debug"); + // REGION was removed from the managed set and must be deleted. + expect(updatedVars.REGION).toBeUndefined(); + + yield* stack.destroy(); + + // Railway soft-deletes environments: getEnvironment keeps succeeding + // with `deletedAt` set (and may eventually return NotAuthorized). + // Poll until either signal shows up. + const gone = yield* getEnvironment({ + id: environment.environmentId, + }).pipe( + Effect.map((env) => + env.deletedAt !== null ? ("deleted" as const) : ("exists" as const), + ), + Effect.catchTag("NotAuthorized", () => + Effect.succeed("deleted" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (state) => state === "deleted", + times: 30, + }), + ); + expect(gone).toEqual("deleted"); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 900_000 }, +); diff --git a/packages/alchemy/test/Railway/Function.test.ts b/packages/alchemy/test/Railway/Function.test.ts new file mode 100644 index 0000000000..1c0668b820 --- /dev/null +++ b/packages/alchemy/test/Railway/Function.test.ts @@ -0,0 +1,122 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getService, getVariables } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import TestFunction, { GREETING_ENV_KEY } from "./fixtures/function.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +/** + * `Config.string(GREETING_ENV_KEY)` resolves against the active + * ConfigProvider at deploy time. The default provider reads from + * `process.env`, so populate it before `stack.deploy` compiles the + * stack — proving the deploy-time → service variable → runtime + * round-trip rather than the bundled default. + */ +const GREETING_VALUE = "hello-from-config"; +process.env[GREETING_ENV_KEY] = GREETING_VALUE; + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +/** + * Retry the first request through Railway's edge propagation — fresh + * `*.up.railway.app` domains take a few seconds to start serving 200s. + */ +const getOk = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? Effect.succeed(response) + : Effect.fail(new Error(`${url} returned ${response.status}`)), + ), + Effect.tapError((error) => Effect.logError(error)), + Effect.retry({ + schedule: Schedule.exponential(1000).pipe( + Schedule.both(Schedule.recurs(12)), + ), + }), + ); + +test.provider( + "deploy, serve HTTP, update code, delete", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { fn } = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* TestFunction; + return { fn }; + }), + ); + + expect(fn.serviceId).toBeDefined(); + expect(fn.projectId).toBeDefined(); + expect(fn.environmentId).toBeDefined(); + expect(fn.deploymentId).toBeDefined(); + expect(fn.deploymentStatus).toEqual("SUCCESS"); + expect(fn.port).toEqual(3000); + expect(fn.domain).toBeTruthy(); + expect(fn.url).toMatch(/^https:\/\//); + expect(fn.code.hash).toBeDefined(); + + // PORT + alchemy phase markers are reconciled onto the service. + const variables = (yield* getVariables({ + projectId: fn.projectId, + environmentId: fn.environmentId, + serviceId: fn.serviceId, + })) as Record; + expect(variables.PORT).toEqual("3000"); + expect(variables.ALCHEMY_PHASE).toEqual("runtime"); + // The Init-phase Config was captured and published as a variable. + expect(variables[GREETING_ENV_KEY]).toBeDefined(); + + // Drive the deployed program over HTTP — one route per behavior. + const hello = yield* getOk(`${fn.url}/hello`); + const helloBody = (yield* hello.json) as { message: string }; + expect(helloBody.message).toEqual("hello from railway"); + + // effect/Config round-trip: the Init-phase `Config.string` was + // captured at deploy time, published as a service variable, and + // re-read from the container's environment at runtime. + const greeting = yield* getOk(`${fn.url}/greeting`); + const greetingBody = (yield* greeting.json) as { greeting: string }; + expect(greetingBody.greeting).toEqual(GREETING_VALUE); + + // Re-deploy with no changes — the code hash gates the upload, so + // the same deployment remains in place. + const { fn: unchanged } = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* TestFunction; + return { fn }; + }), + ); + expect(unchanged.serviceId).toEqual(fn.serviceId); + expect(unchanged.code.hash).toEqual(fn.code.hash); + + yield* stack.destroy(); + + // Railway soft-deletes: getService may still resolve the service + // with `deletedAt` set, return NotAuthorized, or ProjectNotFound + // once the containing project is gone — all of these mean deleted. + const gone = yield* getService({ id: fn.serviceId }).pipe( + Effect.map((service) => (service.deletedAt ? "deleted" : "exists")), + Effect.catchTag("NotAuthorized", () => Effect.succeed("deleted")), + Effect.catchTag("ProjectNotFound", () => Effect.succeed("deleted")), + ); + expect(gone).toEqual("deleted"); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 1_800_000 }, +); diff --git a/packages/alchemy/test/Railway/Project.test.ts b/packages/alchemy/test/Railway/Project.test.ts new file mode 100644 index 0000000000..fa0f25f4cf --- /dev/null +++ b/packages/alchemy/test/Railway/Project.test.ts @@ -0,0 +1,61 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getProject } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// A single multi-step test so the whole file costs one project creation — +// Railway rate-limits project creation aggressively at the workspace level. +test.provider( + "create, update and delete project", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + return yield* Railway.Project("Project", { + name: "alchemy-test-railway-project", + }); + }), + ); + + expect(initial.projectId).toBeDefined(); + expect(initial.name).toEqual("alchemy-test-railway-project"); + expect(initial.defaultEnvironmentId).toBeDefined(); + expect(initial.defaultEnvironmentName).toBeDefined(); + expect(initial.description).toBeUndefined(); + + const fetched = yield* getProject({ id: initial.projectId }); + expect(fetched.id).toEqual(initial.projectId); + expect(fetched.name).toEqual(initial.name); + + // Update — rename and set a description; same physical project. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Railway.Project("Project", { + name: "alchemy-test-railway-project-2", + description: "updated by alchemy test", + }); + }), + ); + expect(updated.projectId).toEqual(initial.projectId); + expect(updated.name).toEqual("alchemy-test-railway-project-2"); + expect(updated.description).toEqual("updated by alchemy test"); + + const refetched = yield* getProject({ id: updated.projectId }); + expect(refetched.name).toEqual("alchemy-test-railway-project-2"); + expect(refetched.description).toEqual("updated by alchemy test"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 900_000 }, +); diff --git a/packages/alchemy/test/Railway/ProjectToken.test.ts b/packages/alchemy/test/Railway/ProjectToken.test.ts new file mode 100644 index 0000000000..704f088ce6 --- /dev/null +++ b/packages/alchemy/test/Railway/ProjectToken.test.ts @@ -0,0 +1,92 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getProjectTokens } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider( + "create and replace project token", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const token = yield* Railway.ProjectToken("CiToken", { + project: { projectId }, + environment: { environmentId }, + }); + return { token }; + }), + ); + + expect(initial.token.tokenId).toBeDefined(); + expect(initial.token.token.length).toBeGreaterThan(0); + expect(initial.token.name).toBeDefined(); + + const tokens = yield* getProjectTokens({ + projectId: initial.token.projectId, + }); + const found = tokens.edges.find( + (e) => e.node.id === initial.token.tokenId, + ); + expect(found).toBeDefined(); + expect(found!.node.environmentId).toEqual(initial.token.environmentId); + + // Re-deploying with no changes keeps the same token (and secret). + const unchanged = yield* stack.deploy( + Effect.gen(function* () { + const token = yield* Railway.ProjectToken("CiToken", { + project: { projectId }, + environment: { environmentId }, + }); + return { token }; + }), + ); + expect(unchanged.token.tokenId).toEqual(initial.token.tokenId); + expect(unchanged.token.token).toEqual(initial.token.token); + + // Renaming replaces the token (no update API). + const replaced = yield* stack.deploy( + Effect.gen(function* () { + const token = yield* Railway.ProjectToken("CiToken", { + project: { projectId }, + environment: { environmentId }, + name: "alchemy-test-ci-token-renamed", + }); + return { token }; + }), + ); + expect(replaced.token.tokenId).not.toEqual(initial.token.tokenId); + expect(replaced.token.name).toEqual("alchemy-test-ci-token-renamed"); + expect(replaced.token.token).not.toEqual(initial.token.token); + + const after = yield* getProjectTokens({ + projectId: replaced.token.projectId, + }); + expect(after.edges.map((e) => e.node.id)).toContain( + replaced.token.tokenId, + ); + expect(after.edges.map((e) => e.node.id)).not.toContain( + initial.token.tokenId, + ); + + yield* stack.destroy(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 1_500_000 }, +); diff --git a/packages/alchemy/test/Railway/Service.test.ts b/packages/alchemy/test/Railway/Service.test.ts new file mode 100644 index 0000000000..3b6e99aac9 --- /dev/null +++ b/packages/alchemy/test/Railway/Service.test.ts @@ -0,0 +1,163 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { + getService, + getServiceInstance, + getVariables, +} from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider( + "create, update config and variables, delete service", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { service } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Api", { + project: { projectId }, + environment: { environmentId }, + source: { image: "nginx:alpine" }, + variables: { LOG_LEVEL: "info", DROP_ME: "soon" }, + }); + return { service }; + }), + ); + + expect(service.serviceId).toBeDefined(); + expect(service.projectId).toEqual(projectId); + expect(service.environmentId).toEqual(environmentId); + expect(service.deploymentId).toBeDefined(); + expect(service.deploymentStatus).toEqual("SUCCESS"); + expect(service.variableNames.sort()).toEqual(["DROP_ME", "LOG_LEVEL"]); + + const fetched = yield* getService({ id: service.serviceId }); + expect(fetched.id).toEqual(service.serviceId); + + // Update — instance config (replicas, start command, restart policy) + // and variables (change one, drop one, add one). + const { service: updated } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Api", { + project: { projectId }, + environment: { environmentId }, + source: { image: "nginx:alpine" }, + variables: { LOG_LEVEL: "debug", ADDED: "yes" }, + numReplicas: 2, + restartPolicyType: "ALWAYS", + }); + return { service }; + }), + ); + + expect(updated.serviceId).toEqual(service.serviceId); + expect(updated.deploymentStatus).toEqual("SUCCESS"); + expect(updated.variableNames.sort()).toEqual(["ADDED", "LOG_LEVEL"]); + + const instance = yield* getServiceInstance({ + serviceId: updated.serviceId, + environmentId: updated.environmentId, + }); + expect(instance.numReplicas).toEqual(2); + expect(instance.restartPolicyType).toEqual("ALWAYS"); + expect(instance.source?.image).toEqual("nginx:alpine"); + + const observedVars = (yield* getVariables({ + projectId: updated.projectId, + environmentId: updated.environmentId, + serviceId: updated.serviceId, + })) as Record; + expect(observedVars.LOG_LEVEL).toEqual("debug"); + expect(observedVars.ADDED).toEqual("yes"); + expect(observedVars.DROP_ME).toBeUndefined(); + + yield* stack.destroy(); + + // Railway soft-deletes services: getService keeps succeeding with + // `deletedAt` set (and may eventually return NotAuthorized). Poll + // until either signal shows up. + const gone = yield* getService({ id: service.serviceId }).pipe( + Effect.map((svc) => + svc.deletedAt !== null ? ("deleted" as const) : ("exists" as const), + ), + Effect.catchTag("NotAuthorized", () => + Effect.succeed("deleted" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (state) => state === "deleted", + times: 30, + }), + ); + expect(gone).toEqual("deleted"); + }).pipe(Effect.ensuring(Effect.ignore(stack.destroy())), logLevel), + { timeout: 900_000 }, +); + +test.provider( + "cron service without deployment", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { service } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Job", { + project: { projectId }, + environment: { environmentId }, + source: { image: "alpine:3" }, + cronSchedule: "0 3 * * *", + startCommand: "echo done", + deploy: false, + }); + return { service }; + }), + ); + + const instance = yield* getServiceInstance({ + serviceId: service.serviceId, + environmentId: service.environmentId, + }); + expect(instance.cronSchedule).toEqual("0 3 * * *"); + expect(instance.startCommand).toEqual("echo done"); + + // Update — change the cron schedule in place. + const { service: updated } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Job", { + project: { projectId }, + environment: { environmentId }, + source: { image: "alpine:3" }, + cronSchedule: "30 4 * * *", + startCommand: "echo done", + deploy: false, + }); + return { service }; + }), + ); + + expect(updated.serviceId).toEqual(service.serviceId); + const reread = yield* getServiceInstance({ + serviceId: updated.serviceId, + environmentId: updated.environmentId, + }); + expect(reread.cronSchedule).toEqual("30 4 * * *"); + + yield* stack.destroy(); + }).pipe(Effect.ensuring(Effect.ignore(stack.destroy())), logLevel), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/Railway/ServiceDomain.test.ts b/packages/alchemy/test/Railway/ServiceDomain.test.ts new file mode 100644 index 0000000000..223d7f653b --- /dev/null +++ b/packages/alchemy/test/Railway/ServiceDomain.test.ts @@ -0,0 +1,95 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getDomains } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider( + "create, update target port and delete service domain", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { service, domain } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Web", { + project: { projectId }, + environment: { environmentId }, + source: { image: "nginx:alpine" }, + deploy: false, + }); + const domain = yield* Railway.ServiceDomain("WebDomain", { + service, + targetPort: 80, + }); + return { service, domain }; + }), + ); + + expect(domain.domainId).toBeDefined(); + expect(domain.domain).toContain("up.railway.app"); + expect(domain.url).toEqual(`https://${domain.domain}`); + expect(domain.serviceId).toEqual(service.serviceId); + expect(domain.environmentId).toEqual(service.environmentId); + expect(domain.targetPort).toEqual(80); + + const observed = yield* getDomains({ + projectId: domain.projectId, + environmentId: domain.environmentId, + serviceId: domain.serviceId, + }); + const match = observed.serviceDomains.find( + (d) => d.id === domain.domainId, + ); + expect(match?.domain).toEqual(domain.domain); + expect(match?.targetPort).toEqual(80); + + // Update — change the target port in place. + const { domain: updated } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Web", { + project: { projectId }, + environment: { environmentId }, + source: { image: "nginx:alpine" }, + deploy: false, + }); + const domain = yield* Railway.ServiceDomain("WebDomain", { + service, + targetPort: 8080, + }); + return { service, domain }; + }), + ); + + expect(updated.domainId).toEqual(domain.domainId); + expect(updated.targetPort).toEqual(8080); + + const reobserved = yield* getDomains({ + projectId: updated.projectId, + environmentId: updated.environmentId, + serviceId: updated.serviceId, + }); + const rematch = reobserved.serviceDomains.find( + (d) => d.id === updated.domainId, + ); + expect(rematch?.targetPort).toEqual(8080); + + yield* stack.destroy(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/Railway/TcpProxy.test.ts b/packages/alchemy/test/Railway/TcpProxy.test.ts new file mode 100644 index 0000000000..694ab52206 --- /dev/null +++ b/packages/alchemy/test/Railway/TcpProxy.test.ts @@ -0,0 +1,81 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getTcpProxies } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider( + "create and replace tcp proxy", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("TcpProxyService", { + project: { projectId }, + environment: { environmentId }, + // No deployment — proxy lifecycle is independent of deploys, and + // an in-flight deployment blocks proxy deletes with + // "operation is already in progress". + deploy: false, + }); + const proxy = yield* Railway.TcpProxy("TcpProxy", { + service, + applicationPort: 5432, + }); + return { proxy }; + }), + ); + + expect(initial.proxy.proxyId).toBeDefined(); + expect(initial.proxy.domain).toContain("."); + expect(initial.proxy.proxyPort).toBeGreaterThan(0); + expect(initial.proxy.applicationPort).toEqual(5432); + + const observed = yield* getTcpProxies({ + serviceId: initial.proxy.serviceId, + environmentId: initial.proxy.environmentId, + }); + expect(observed.map((p) => p.id)).toContain(initial.proxy.proxyId); + + // Changing the application port replaces the proxy. + const replaced = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("TcpProxyService", { + project: { projectId }, + environment: { environmentId }, + deploy: false, + }); + const proxy = yield* Railway.TcpProxy("TcpProxy", { + service, + applicationPort: 6543, + }); + return { proxy }; + }), + ); + + expect(replaced.proxy.proxyId).not.toEqual(initial.proxy.proxyId); + expect(replaced.proxy.applicationPort).toEqual(6543); + + const after = yield* getTcpProxies({ + serviceId: replaced.proxy.serviceId, + environmentId: replaced.proxy.environmentId, + }); + expect(after.map((p) => p.id)).toContain(replaced.proxy.proxyId); + expect(after.map((p) => p.id)).not.toContain(initial.proxy.proxyId); + + yield* stack.destroy(); + }).pipe(Effect.ensuring(Effect.ignore(stack.destroy())), logLevel), + { timeout: 1_500_000 }, +); diff --git a/packages/alchemy/test/Railway/Variables.test.ts b/packages/alchemy/test/Railway/Variables.test.ts new file mode 100644 index 0000000000..3941894d2e --- /dev/null +++ b/packages/alchemy/test/Railway/Variables.test.ts @@ -0,0 +1,148 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getVariables } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider( + "create, upsert, remove and delete shared variables", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { vars } = yield* stack.deploy( + Effect.gen(function* () { + const vars = yield* Railway.Variables("SharedVars", { + project: { projectId }, + environment: { environmentId }, + variables: { + LOG_LEVEL: "info", + REGION: "us-west2", + }, + }); + return { vars }; + }), + ); + + expect(vars.projectId).toEqual(projectId); + expect(vars.environmentId).toEqual(environmentId); + expect(vars.serviceId).toBeUndefined(); + expect(vars.names.sort()).toEqual(["LOG_LEVEL", "REGION"]); + + const observed = (yield* getVariables({ + projectId: vars.projectId, + environmentId: vars.environmentId, + })) as Record; + expect(observed.LOG_LEVEL).toEqual("info"); + expect(observed.REGION).toEqual("us-west2"); + + // Update — change one value, add one, remove one. + const { vars: updated } = yield* stack.deploy( + Effect.gen(function* () { + const vars = yield* Railway.Variables("SharedVars", { + project: { projectId }, + environment: { environmentId }, + variables: { + LOG_LEVEL: "debug", + NEW_FLAG: "on", + }, + }); + return { vars }; + }), + ); + + expect(updated.names.sort()).toEqual(["LOG_LEVEL", "NEW_FLAG"]); + + const reobserved = (yield* getVariables({ + projectId: updated.projectId, + environmentId: updated.environmentId, + })) as Record; + expect(reobserved.LOG_LEVEL).toEqual("debug"); + expect(reobserved.NEW_FLAG).toEqual("on"); + expect(reobserved.REGION).toBeUndefined(); + + yield* stack.destroy(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 600_000 }, +); + +test.provider( + "service-scoped variables only touch managed names", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { service, vars } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Svc", { + project: { projectId }, + environment: { environmentId }, + variables: { FOREIGN: "untouched" }, + deploy: false, + }); + const vars = yield* Railway.Variables("SvcVars", { + project: { projectId }, + environment: { environmentId: service.environmentId }, + service, + variables: { MANAGED: "yes" }, + }); + return { service, vars }; + }), + ); + + expect(vars.serviceId).toEqual(service.serviceId); + + const observed = (yield* getVariables({ + projectId, + environmentId: service.environmentId, + serviceId: service.serviceId, + })) as Record; + expect(observed.MANAGED).toEqual("yes"); + expect(observed.FOREIGN).toEqual("untouched"); + + // Destroy only the Variables resource — foreign variables survive. + const { service: remaining } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Svc", { + project: { projectId }, + environment: { environmentId }, + variables: { FOREIGN: "untouched" }, + deploy: false, + }); + return { service }; + }), + ); + + const after = (yield* getVariables({ + projectId, + environmentId: remaining.environmentId, + serviceId: remaining.serviceId, + })) as Record; + expect(after.MANAGED).toBeUndefined(); + expect(after.FOREIGN).toEqual("untouched"); + + yield* stack.destroy(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/Railway/Volume.test.ts b/packages/alchemy/test/Railway/Volume.test.ts new file mode 100644 index 0000000000..5c66df572c --- /dev/null +++ b/packages/alchemy/test/Railway/Volume.test.ts @@ -0,0 +1,75 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider( + "create, remount and delete volume attached to a service", + (stack) => + Effect.gen(function* () { + const { projectId, environmentId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const { service, volume } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Db", { + project: { projectId }, + environment: { environmentId }, + deploy: false, + }); + const volume = yield* Railway.Volume("Data", { + project: { projectId }, + environment: { environmentId: service.environmentId }, + service, + mountPath: "/data", + }); + return { service, volume }; + }), + ); + + expect(volume.volumeId).toBeDefined(); + expect(volume.projectId).toEqual(projectId); + expect(volume.serviceId).toEqual(service.serviceId); + expect(volume.mountPath).toEqual("/data"); + + // Update — change the mount path in place. + const { volume: remounted } = yield* stack.deploy( + Effect.gen(function* () { + const service = yield* Railway.Service("Db", { + project: { projectId }, + environment: { environmentId }, + deploy: false, + }); + const volume = yield* Railway.Volume("Data", { + project: { projectId }, + environment: { environmentId: service.environmentId }, + service, + mountPath: "/data-v2", + }); + return { service, volume }; + }), + ); + + expect(remounted.volumeId).toEqual(volume.volumeId); + expect(remounted.mountPath).toEqual("/data-v2"); + + yield* stack.destroy(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + // Generous timeout: Railway rate-limits project creation to 1 per 30s per + // workspace, so concurrent test runs can spend minutes waiting to create. + { timeout: 1_200_000 }, +); diff --git a/packages/alchemy/test/Railway/Webhook.test.ts b/packages/alchemy/test/Railway/Webhook.test.ts new file mode 100644 index 0000000000..a931882f33 --- /dev/null +++ b/packages/alchemy/test/Railway/Webhook.test.ts @@ -0,0 +1,82 @@ +import * as Railway from "@/Railway"; +import * as Test from "@/Test/Vitest"; +import { getNotificationRules } from "@distilled.cloud/railway"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import { ensureSharedProject } from "./harness.ts"; + +const { test } = Test.make({ providers: Railway.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// A single multi-step test so the whole file costs one project creation — +// Railway rate-limits project creation aggressively at the workspace level. +test.provider( + "create, update url and delete webhook", + (stack) => + Effect.gen(function* () { + const { projectId } = yield* ensureSharedProject; + yield* stack.destroy(); + + const deploy = (url: string) => + stack.deploy( + Effect.gen(function* () { + const webhook = yield* Railway.Webhook("Hook", { + project: { projectId }, + url, + eventTypes: ["Deployment.failed", "Deployment.crashed"], + }); + return { webhook }; + }), + ); + + const { webhook } = yield* deploy("https://example.com/hook"); + + expect(webhook.webhookId).toBeDefined(); + expect(webhook.url).toEqual("https://example.com/hook"); + expect(webhook.projectId).toEqual(projectId); + expect(webhook.workspaceId).toBeDefined(); + expect([...webhook.eventTypes].sort()).toEqual([ + "Deployment.crashed", + "Deployment.failed", + ]); + + const observed = yield* getNotificationRules({ + workspaceId: webhook.workspaceId, + projectId: webhook.projectId, + }); + const match = observed.find((r) => r.id === webhook.webhookId); + expect(match).toBeDefined(); + expect([...match!.eventTypes].sort()).toEqual([ + "Deployment.crashed", + "Deployment.failed", + ]); + + // Update — change the webhook URL; the backing rule converges to the + // new URL (in place with a personal token, by replacement with a + // workspace token). + const { webhook: updated } = yield* deploy("https://example.com/hook-2"); + + expect(updated.url).toEqual("https://example.com/hook-2"); + expect(updated.projectId).toEqual(projectId); + + const reobserved = yield* getNotificationRules({ + workspaceId: updated.workspaceId, + projectId: updated.projectId, + }); + const rematch = reobserved.find((r) => r.id === updated.webhookId); + expect(rematch).toBeDefined(); + + yield* stack.destroy(); + }).pipe( + // Always tear down what the test deployed, even when an assertion or + // deploy fails mid-way — keeps the shared project free of orphans. + Effect.ensuring(Effect.ignore(stack.destroy())), + logLevel, + ), + { timeout: 900_000 }, +); diff --git a/packages/alchemy/test/Railway/fixtures/function.ts b/packages/alchemy/test/Railway/fixtures/function.ts new file mode 100644 index 0000000000..fee5688495 --- /dev/null +++ b/packages/alchemy/test/Railway/fixtures/function.ts @@ -0,0 +1,67 @@ +import * as Railway from "@/Railway"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { ensureSharedProject } from "../harness.ts"; + +/** + * Name of the deploy-time `process.env` variable the test populates + * before deploying — sourced via `Config.string(...)` inside the + * Function's init phase. + */ +export const GREETING_ENV_KEY = "ALCHEMY_RAILWAY_FN_TEST_GREETING"; + +/** Fallback used when the deploy-time env var is not populated. */ +export const GREETING_DEFAULT = "hello-from-config-default"; + +/** + * Effect-native Railway Function fixture: an HTTP service with one route + * per behavior under test, deployed for real by `Function.test.ts`. + * + * Plain config flows through `effect/Config` (NOT a binding): the value + * is resolved against the active ConfigProvider (process.env) at deploy + * time, published as a Railway service variable by the Function + * provider, and re-read from the container's environment at runtime. + */ +export default class TestFunction extends Railway.Function()( + "RailwayTestFn", + Effect.gen(function* () { + // Deploy-time only — props effects are skipped inside the deployed + // container (ALCHEMY_PHASE=runtime). Typed lookup errors are fatal here. + const { projectId, environmentId } = + yield* Effect.orDie(ensureSharedProject); + return { + main: import.meta.filename, + project: { projectId }, + environment: { environmentId }, + name: "alchemy-test-railway-fn-svc", + }; + }), + Effect.gen(function* () { + // Captured during Init — Alchemy resolves this at deploy time, + // reconciles it onto the service's variables, and the runtime + // ConfigProvider re-resolves it from process.env here. + const greeting = yield* Config.string(GREETING_ENV_KEY).pipe( + Config.withDefault(GREETING_DEFAULT), + ); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + + if (request.url.startsWith("/hello")) { + return yield* HttpServerResponse.json({ + message: "hello from railway", + }); + } + + if (request.url.startsWith("/greeting")) { + return yield* HttpServerResponse.json({ greeting }); + } + + return HttpServerResponse.text("ok"); + }), + }; + }), +) {} diff --git a/packages/alchemy/test/Railway/harness.ts b/packages/alchemy/test/Railway/harness.ts new file mode 100644 index 0000000000..91983696fa --- /dev/null +++ b/packages/alchemy/test/Railway/harness.ts @@ -0,0 +1,99 @@ +import { + createProject, + getEnvironments, + getProjects, + type GetProjectsOutput, +} from "@distilled.cloud/railway"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +/** + * Railway rate-limits project creation to 1 per 30s per workspace (and + * failed attempts re-arm the window), so the test suite must NOT create a + * project per file/run. Instead every test file (except Project.test.ts, + * which tests the project lifecycle itself) shares one persistent project, + * found by deterministic name or created at most once, and never destroyed. + * + * Tests reference it as `project: { projectId }` so `stack.destroy()` only + * tears down the resources the test itself created inside the project. + */ +export const SHARED_PROJECT_NAME = "alchemy-railway-shared-test"; + +let cached: { projectId: string; environmentId: string } | undefined; + +const findProjectByName = (name: string) => + Effect.gen(function* () { + let after: string | undefined; + while (true) { + const page: GetProjectsOutput = yield* getProjects({ + first: 100, + ...(after !== undefined ? { after } : {}), + }); + const match = page.edges.find((e) => e.node.name === name); + if (match) return match.node; + if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) { + return undefined; + } + after = page.pageInfo.endCursor; + } + }); + +const baseEnvironmentId = (projectId: string) => + Effect.gen(function* () { + const page = yield* getEnvironments({ projectId, first: 100 }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (page) => page.edges.length > 0, + times: 30, + }), + ); + const nodes = page.edges.map((e) => e.node); + const env = + nodes.find((n) => n.name === "production") ?? + nodes.find((n) => !n.isEphemeral) ?? + nodes[0]; + if (!env) { + return yield* Effect.die( + `Shared test project ${projectId} has no environments`, + ); + } + return env.id; + }); + +/** + * Find-or-create the shared test project. Cached per process so a test + * file only pays one lookup. + */ +export const ensureSharedProject = Effect.gen(function* () { + if (cached) return cached; + const existing = yield* findProjectByName(SHARED_PROJECT_NAME); + if (existing) { + cached = { + projectId: existing.id, + environmentId: yield* baseEnvironmentId(existing.id), + }; + return cached; + } + yield* Effect.log( + `Shared test project '${SHARED_PROJECT_NAME}' not found — creating (Railway allows 1 project create per 30s)...`, + ); + const created = yield* createProject({ + input: { name: SHARED_PROJECT_NAME }, + }).pipe( + Effect.tapError((e) => + e._tag === "ProjectCreateRateLimited" + ? Effect.log("Rate limited — retrying in ~35s...") + : Effect.void, + ), + Effect.retry({ + while: (e) => e._tag === "ProjectCreateRateLimited", + schedule: Schedule.jittered(Schedule.spaced("35 seconds")), + times: 20, + }), + ); + cached = { + projectId: created.id, + environmentId: yield* baseEnvironmentId(created.id), + }; + return cached; +}); From b85fee87261739422a9a47dfb6164402d807b1b3 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Mon, 15 Jun 2026 20:09:57 -0700 Subject: [PATCH 2/2] database --- .vendor/distilled | 1 + bun.lock | 18 ++ examples/railway-postgres-drizzle/README.md | 44 ++++ .../railway-postgres-drizzle/alchemy.run.ts | 21 ++ .../railway-postgres-drizzle/package.json | 29 +++ examples/railway-postgres-drizzle/src/Api.ts | 98 ++++++++ .../railway-postgres-drizzle/src/Postgres.ts | 19 ++ .../railway-postgres-drizzle/src/Project.ts | 9 + .../railway-postgres-drizzle/src/schema.ts | 11 + .../railway-postgres-drizzle/tsconfig.json | 16 ++ examples/railway-postgres/README.md | 13 +- examples/railway-postgres/package.json | 1 + examples/railway-postgres/src/Api.ts | 70 ++---- examples/railway-postgres/src/Postgres.ts | 7 +- packages/alchemy/package.json | 6 + .../{Database.ts => DatabaseEngine.ts} | 238 +++++------------- packages/alchemy/src/Railway/DatabaseUrl.ts | 99 -------- packages/alchemy/src/Railway/Function.ts | 22 +- packages/alchemy/src/Railway/MySQLDatabase.ts | 77 ++++++ .../src/Railway/MySQLDatabaseBinding.ts | 148 +++++++++++ .../alchemy/src/Railway/PostgresDatabase.ts | 82 ++++++ .../src/Railway/PostgresDatabaseBinding.ts | 148 +++++++++++ packages/alchemy/src/Railway/Providers.ts | 27 +- packages/alchemy/src/Railway/Reference.ts | 8 +- packages/alchemy/src/Railway/index.ts | 6 +- packages/alchemy/src/Sql/Postgres.ts | 61 +++++ packages/alchemy/src/Sql/index.ts | 1 + 27 files changed, 937 insertions(+), 343 deletions(-) create mode 160000 .vendor/distilled create mode 100644 examples/railway-postgres-drizzle/README.md create mode 100644 examples/railway-postgres-drizzle/alchemy.run.ts create mode 100644 examples/railway-postgres-drizzle/package.json create mode 100644 examples/railway-postgres-drizzle/src/Api.ts create mode 100644 examples/railway-postgres-drizzle/src/Postgres.ts create mode 100644 examples/railway-postgres-drizzle/src/Project.ts create mode 100644 examples/railway-postgres-drizzle/src/schema.ts create mode 100644 examples/railway-postgres-drizzle/tsconfig.json rename packages/alchemy/src/Railway/{Database.ts => DatabaseEngine.ts} (60%) delete mode 100644 packages/alchemy/src/Railway/DatabaseUrl.ts create mode 100644 packages/alchemy/src/Railway/MySQLDatabase.ts create mode 100644 packages/alchemy/src/Railway/MySQLDatabaseBinding.ts create mode 100644 packages/alchemy/src/Railway/PostgresDatabase.ts create mode 100644 packages/alchemy/src/Railway/PostgresDatabaseBinding.ts create mode 100644 packages/alchemy/src/Sql/Postgres.ts diff --git a/.vendor/distilled b/.vendor/distilled new file mode 160000 index 0000000000..d571294f77 --- /dev/null +++ b/.vendor/distilled @@ -0,0 +1 @@ +Subproject commit d571294f77a12ab81e22786a0cac3142ad7d65cc diff --git a/bun.lock b/bun.lock index c03d746d9f..1a9429d161 100644 --- a/bun.lock +++ b/bun.lock @@ -773,6 +773,7 @@ "version": "0.0.0", "dependencies": { "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", "alchemy": "workspace:*", "effect": "catalog:", "pg": "^8.16.3", @@ -781,6 +782,21 @@ "@types/pg": "^8.15.6", }, }, + "examples/railway-postgres-drizzle": { + "name": "railway-postgres-drizzle", + "version": "0.0.0", + "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + "pg": "^8.16.3", + }, + "devDependencies": { + "@types/pg": "^8.15.6", + }, + }, "packages/alchemy": { "name": "alchemy", "version": "2.0.0-beta.55", @@ -3662,6 +3678,8 @@ "railway-postgres": ["railway-postgres@workspace:examples/railway-postgres"], + "railway-postgres-drizzle": ["railway-postgres-drizzle@workspace:examples/railway-postgres-drizzle"], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], diff --git a/examples/railway-postgres-drizzle/README.md b/examples/railway-postgres-drizzle/README.md new file mode 100644 index 0000000000..454fe41ffd --- /dev/null +++ b/examples/railway-postgres-drizzle/README.md @@ -0,0 +1,44 @@ +# railway-postgres-drizzle + +The [railway-postgres](../railway-postgres/) stack with Drizzle ORM +(`drizzle-orm/effect-postgres`) instead of raw SQL: + +- **`Railway.Project`** — the project containing every service ([src/Project.ts](./src/Project.ts)) +- **`Railway.PostgresDatabase`** — a Postgres database with a persistent volume ([src/Postgres.ts](./src/Postgres.ts)) +- **`Railway.Function`** — an Effect-native HTTP service on a public `*.up.railway.app` domain ([src/Api.ts](./src/Api.ts)) +- **`Railway.PostgresDatabase.bind`** — injects the connection details via Railway reference variables +- **`Drizzle.postgres`** — Drizzle over Effect's native Postgres client; the pool is built lazily on the first query and shared for the process lifetime + +## Routes + +| Route | Behavior | +| --------- | ------------------------------ | +| `GET /` | List users | +| `POST /` | Insert a random user | +| `/health` | Railway healthcheck | + +## Deploy + +Authenticate with a Railway API token (or run `alchemy login railway`): + +```sh +export RAILWAY_API_TOKEN=... +``` + +Then, from this directory: + +```sh +bun i +bun run deploy +``` + +```sh +curl https://.up.railway.app/ +curl -X POST https://.up.railway.app/ +``` + +## Destroy + +```sh +bun run destroy +``` diff --git a/examples/railway-postgres-drizzle/alchemy.run.ts b/examples/railway-postgres-drizzle/alchemy.run.ts new file mode 100644 index 0000000000..43840eab7b --- /dev/null +++ b/examples/railway-postgres-drizzle/alchemy.run.ts @@ -0,0 +1,21 @@ +import * as Alchemy from "alchemy"; +import * as Railway from "alchemy/Railway"; +import * as Effect from "effect/Effect"; +import Api from "./src/Api.ts"; + +export default Alchemy.Stack( + "RailwayPostgresDrizzleExample", + { + providers: Railway.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + // The Project and Postgres database are registered by the Api + // Function itself (see src/Api.ts) — yielding Api deploys all three. + const api = yield* Api; + return { + url: api.url.as(), + projectId: api.projectId, + }; + }), +); diff --git a/examples/railway-postgres-drizzle/package.json b/examples/railway-postgres-drizzle/package.json new file mode 100644 index 0000000000..5f45a4d54e --- /dev/null +++ b/examples/railway-postgres-drizzle/package.json @@ -0,0 +1,29 @@ +{ + "name": "railway-postgres-drizzle", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy-effect.git", + "directory": "examples/railway-postgres-drizzle" + }, + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "dev": "alchemy dev", + "destroy": "alchemy destroy", + "logs": "alchemy logs" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/pg": "^8.15.6" + } +} diff --git a/examples/railway-postgres-drizzle/src/Api.ts b/examples/railway-postgres-drizzle/src/Api.ts new file mode 100644 index 0000000000..7dc91f5918 --- /dev/null +++ b/examples/railway-postgres-drizzle/src/Api.ts @@ -0,0 +1,98 @@ +import * as Drizzle from "alchemy/Drizzle"; +import * as Railway from "alchemy/Railway"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Postgres } from "./Postgres.ts"; +import { Project } from "./Project.ts"; +import { Users } from "./schema.ts"; + +/** + * An Effect-native HTTP service deployed as a Railway Function, querying + * the bound Postgres database through Drizzle ORM + * (`drizzle-orm/effect-postgres`). + * + * - `GET /` — list users + * - `POST /` — insert a random user + * - `GET /health` — healthcheck probed by Railway before routing traffic + */ +export default class Api extends Railway.Function()( + "Api", + Effect.gen(function* () { + const project = yield* Project; + return { + main: import.meta.filename, + project, + name: "api", + // `pg` (the driver under @effect/sql-pg) stays external to the + // bundle; Railway installs it during the image build. + external: ["pg"], + healthcheckPath: "/health", + }; + }), + Effect.gen(function* () { + // Deploy time: writes `POSTGRES_URL=${{.DATABASE_URL}}` (plus + // `_HOST`, `_PORT`, `_USER`, `_PASSWORD`, `_DATABASE`) onto this + // service via Railway reference-variable syntax. Runtime: typed + // accessors over those variables. + const conn = yield* Railway.PostgresDatabase.bind(Postgres); + + // Drizzle over Effect's native Postgres client. The pool is built + // lazily on the first query and shared for the process lifetime. + const db = yield* Drizzle.postgres(conn.connectionString); + + // Idempotent schema bootstrap, run once per process before the + // first query (a real app would apply migrations out of band). + const ensureSchema = yield* Effect.cached( + Effect.gen(function* () { + yield* db.$client.unsafe( + `create table if not exists users ( + id serial primary key, + email text not null unique, + name text not null, + created_at timestamptz not null default now() + )`, + ); + }), + ); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + + if (request.url.startsWith("/health")) { + return HttpServerResponse.text("ok"); + } + + yield* ensureSchema; + + switch (request.method) { + case "GET": { + const users = yield* db.select().from(Users); + return yield* HttpServerResponse.json({ users }); + } + case "POST": { + const [user] = yield* db + .insert(Users) + .values({ + name: crypto.randomUUID(), + email: `${crypto.randomUUID()}@example.com`, + }) + .returning(); + return yield* HttpServerResponse.json({ user }); + } + default: { + return yield* HttpServerResponse.json( + { error: "Method not allowed" }, + { status: 405 }, + ); + } + } + }).pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: String(error) }, { status: 500 }), + ), + ), + }; + }).pipe(Effect.provide(Railway.PostgresDatabaseBindingLive)), +) {} diff --git a/examples/railway-postgres-drizzle/src/Postgres.ts b/examples/railway-postgres-drizzle/src/Postgres.ts new file mode 100644 index 0000000000..ab2cfcda62 --- /dev/null +++ b/examples/railway-postgres-drizzle/src/Postgres.ts @@ -0,0 +1,19 @@ +import * as Railway from "alchemy/Railway"; +import * as Effect from "effect/Effect"; +import { Project } from "./Project.ts"; + +/** + * A Railway-hosted Postgres database — same image/volume/variable layout + * as Railway's official Postgres template, fully reconciled by Alchemy. + * + * The connection details are consumed by the API Function through + * `Railway.PostgresDatabase.bind` and handed to Drizzle (see src/Api.ts). + */ +export const Postgres = Effect.gen(function* () { + const project = yield* Project; + return yield* Railway.PostgresDatabase("Postgres", { + project, + environment: { environmentId: project.defaultEnvironmentId }, + name: "postgres", + }); +}); diff --git a/examples/railway-postgres-drizzle/src/Project.ts b/examples/railway-postgres-drizzle/src/Project.ts new file mode 100644 index 0000000000..ce443d58af --- /dev/null +++ b/examples/railway-postgres-drizzle/src/Project.ts @@ -0,0 +1,9 @@ +import * as Railway from "alchemy/Railway"; + +/** + * The Railway project that contains every service in this example + * (the API Function and the Postgres database). + */ +export const Project = Railway.Project("Project", { + name: "railway-postgres-drizzle-example", +}); diff --git a/examples/railway-postgres-drizzle/src/schema.ts b/examples/railway-postgres-drizzle/src/schema.ts new file mode 100644 index 0000000000..b469e18c4a --- /dev/null +++ b/examples/railway-postgres-drizzle/src/schema.ts @@ -0,0 +1,11 @@ +import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const Users = pgTable("users", { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export type User = typeof Users.$inferSelect; diff --git a/examples/railway-postgres-drizzle/tsconfig.json b/examples/railway-postgres-drizzle/tsconfig.json new file mode 100644 index 0000000000..586e7d2229 --- /dev/null +++ b/examples/railway-postgres-drizzle/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["alchemy.run.ts", "src/**/*.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/examples/railway-postgres/README.md b/examples/railway-postgres/README.md index 014dbc7233..65dd004af4 100644 --- a/examples/railway-postgres/README.md +++ b/examples/railway-postgres/README.md @@ -3,11 +3,15 @@ An end-to-end Effect-native Railway application built with Alchemy: - **`Railway.Project`** — the project containing every service ([src/Project.ts](./src/Project.ts)) -- **`Railway.Database`** — a Postgres database with a persistent volume, mirroring Railway's official template ([src/Postgres.ts](./src/Postgres.ts)) +- **`Railway.PostgresDatabase`** — a Postgres database with a persistent volume, mirroring Railway's official template ([src/Postgres.ts](./src/Postgres.ts)) - **`Railway.Function`** — an Effect-native HTTP service bundled and uploaded straight to Railway's build pipeline, exposed on a public `*.up.railway.app` domain ([src/Api.ts](./src/Api.ts)) -- **`Railway.DatabaseUrl`** — binds the database's connection string into the Function via Railway reference variables (`${{Postgres.DATABASE_URL}}`), surfaced at runtime as a `Redacted` +- **`Railway.PostgresDatabase.bind`** — binds the database's connection details into the Function via Railway reference variables (`${{Postgres.DATABASE_URL}}`), surfaced at runtime as typed accessors (`connectionString`, `host`, `port`, `user`, `password`, `database`) +- **`Sql.postgres`** — Effect's native Postgres client (`@effect/sql-pg`), opened from the bound connection string; the pool is built lazily on the first query and shared for the process lifetime - **`effect/Config`** — `GREETING` is read with `Config.string` in the Init phase, automatically published as a service variable, and re-read from the environment at runtime +For the same stack with Drizzle ORM, see +[railway-postgres-drizzle](../railway-postgres-drizzle/). + ## Routes | Route | Behavior | @@ -49,11 +53,6 @@ bun run destroy ## Notes -- The Railway Function runtime does not currently provide `RuntimeContext` - to the `fetch` handler, so binding accessors (like the one returned by - `Railway.DatabaseUrl.bind`) fail at runtime with - `Service not found: RuntimeContext`. As a workaround, [src/Api.ts](./src/Api.ts) - captures `RuntimeContext` during Init and re-provides it on the handler. - Railway rejects service names containing underscores, so the services set explicit `name`s instead of relying on the generated physical name (which embeds the stage, e.g. `dev_yourname`). diff --git a/examples/railway-postgres/package.json b/examples/railway-postgres/package.json index 0ba2f56b00..620f44dc5d 100644 --- a/examples/railway-postgres/package.json +++ b/examples/railway-postgres/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", "alchemy": "workspace:*", "effect": "catalog:", "pg": "^8.16.3" diff --git a/examples/railway-postgres/src/Api.ts b/examples/railway-postgres/src/Api.ts index bf8a45f6b1..401397d2de 100644 --- a/examples/railway-postgres/src/Api.ts +++ b/examples/railway-postgres/src/Api.ts @@ -1,11 +1,9 @@ import * as Railway from "alchemy/Railway"; -import { RuntimeContext } from "alchemy/RuntimeContext"; +import * as Sql from "alchemy/Sql"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; -import * as Redacted from "effect/Redacted"; import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import pg from "pg"; import { Postgres } from "./Postgres.ts"; import { Project } from "./Project.ts"; @@ -26,8 +24,8 @@ export default class Api extends Railway.Function()( main: import.meta.filename, project, name: "api", - // `pg` stays external to the bundle; Railway installs it during - // the image build (see FunctionProps.external). + // `pg` (the driver under @effect/sql-pg) stays external to the + // bundle; Railway installs it during the image build. external: ["pg"], healthcheckPath: "/health", }; @@ -39,17 +37,16 @@ export default class Api extends Railway.Function()( Config.withDefault("Hello from Railway!"), ); - // Deploy time: writes `POSTGRES_URL=${{.DATABASE_URL}}` onto this - // service via Railway reference-variable syntax. Runtime: yields the - // resolved connection string as a Redacted. - const postgres = yield* Postgres; - const databaseUrl = yield* Railway.DatabaseUrl.bind(postgres); + // Deploy time: writes `POSTGRES_URL=${{.DATABASE_URL}}` (plus + // `_HOST`, `_PORT`, `_USER`, `_PASSWORD`, `_DATABASE`) onto this + // service via Railway reference-variable syntax. Runtime: typed + // accessors over those variables. + const db = yield* Railway.PostgresDatabase.bind(Postgres); - // TODO(alchemy): the Railway Function runtime does not currently - // provide RuntimeContext to the fetch handler, so binding accessors - // like `databaseUrl` fail with "Service not found: RuntimeContext" - // at runtime. Capture it during Init and re-provide it below. - const runtimeContext = yield* RuntimeContext; + // Effect's native Postgres client (@effect/sql-pg). The pool is + // built lazily on the first query and shared for the lifetime of + // the process. + const sql = yield* Sql.postgres(db.connectionString); return { fetch: Effect.gen(function* () { @@ -60,39 +57,20 @@ export default class Api extends Railway.Function()( } if (request.url.startsWith("/db")) { - const url = Redacted.value(yield* databaseUrl); - return yield* queryDb(url).pipe( - Effect.flatMap((row) => HttpServerResponse.json(row)), - Effect.catchTag("UnknownError", (error) => - HttpServerResponse.json( - { error: String(error.cause) }, - { status: 500 }, - ), - ), - ); + const rows = yield* sql<{ + now: string; + database: string; + version: string; + }>`select now() as now, current_database() as database, version() as version`; + return yield* HttpServerResponse.json(rows[0]); } return yield* HttpServerResponse.json({ message: greeting }); - }).pipe(Effect.provideService(RuntimeContext, runtimeContext)), + }).pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: String(error) }, { status: 500 }), + ), + ), }; - }).pipe(Effect.provide(Railway.DatabaseUrlLive)), + }).pipe(Effect.provide(Railway.PostgresDatabaseBindingLive)), ) {} - -/** Run a single query over a fresh connection and close it. */ -const queryDb = (connectionString: string) => - Effect.tryPromise(async () => { - const client = new pg.Client({ connectionString }); - await client.connect(); - try { - const result = await client.query( - "select now() as now, current_database() as database, version() as version", - ); - return result.rows[0] as { - now: string; - database: string; - version: string; - }; - } finally { - await client.end(); - } - }); diff --git a/examples/railway-postgres/src/Postgres.ts b/examples/railway-postgres/src/Postgres.ts index 7f1d4e487a..b8d0e68e5d 100644 --- a/examples/railway-postgres/src/Postgres.ts +++ b/examples/railway-postgres/src/Postgres.ts @@ -10,16 +10,15 @@ import { Project } from "./Project.ts"; * database's props, so this is an Effect that registers (or resolves) * both resources. * - * The connection string is consumed by the API Function through the - * `Railway.DatabaseUrl` binding (see src/Api.ts). + * The connection details are consumed by the API Function through + * `Railway.PostgresDatabase.bind` (see src/Api.ts). */ export const Postgres = Effect.gen(function* () { const project = yield* Project; - return yield* Railway.Database("Postgres", { + return yield* Railway.PostgresDatabase("Postgres", { project, // Deploy into the project's default environment explicitly. environment: { environmentId: project.defaultEnvironmentId }, - kind: "postgres", name: "postgres", }); }); diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index cdd7c59a8a..80d73b7f52 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -232,6 +232,12 @@ "worker": "./src/Process/index.ts", "import": "./lib/Process/index.js" }, + "./Sql": { + "types": "./lib/Sql/index.d.ts", + "bun": "./src/Sql/index.ts", + "worker": "./src/Sql/index.ts", + "import": "./lib/Sql/index.js" + }, "./SQLite": { "types": "./lib/SQLite/index.d.ts", "bun": "./src/SQLite/index.ts", diff --git a/packages/alchemy/src/Railway/Database.ts b/packages/alchemy/src/Railway/DatabaseEngine.ts similarity index 60% rename from packages/alchemy/src/Railway/Database.ts rename to packages/alchemy/src/Railway/DatabaseEngine.ts index 159d84fcab..f1d282e6ec 100644 --- a/packages/alchemy/src/Railway/Database.ts +++ b/packages/alchemy/src/Railway/DatabaseEngine.ts @@ -16,7 +16,7 @@ import * as Schedule from "effect/Schedule"; import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; -import { Resource } from "../Resource.ts"; +import type { Resource } from "../Resource.ts"; import { resolveEnvironmentId, resolveProjectId, @@ -32,99 +32,39 @@ import { } from "./Service.ts"; /** - * The built-in database engines Railway offers via its official - * templates. + * Railway resolves `${{RAILWAY_PRIVATE_DOMAIN}}` (own-service template + * syntax) server-side, so connection URLs always track the live private + * domain. */ -export type DatabaseKind = "postgres" | "redis" | "mysql" | "mongo"; +export const PRIVATE_DOMAIN = "${{RAILWAY_PRIVATE_DOMAIN}}"; -interface DatabaseSpec { +/** + * The image/volume/variable layout of a Railway database engine — + * mirrors Railway's official database templates (the SDK exposes no way + * to discover the service a `templateDeployV2` workflow created, so we + * deploy the same images directly; every aspect stays observable and + * reconcilable). + */ +export interface DatabaseEngineSpec { /** Docker image the official Railway template deploys. */ image: string; /** Port the engine listens on inside the private network. */ port: number; /** Where the data volume is mounted. */ mountPath: string; - /** Name of the service variable carrying the connection URL. */ - urlVariable: string; - /** Default username baked into the engine variables. */ + /** Default admin username baked into the engine variables. */ username: string; /** Default logical database name (where the engine has one). */ database: string | undefined; - /** - * Build the service variables. `RAILWAY_PRIVATE_DOMAIN` is referenced - * with Railway's own-service template syntax so the connection URL - * always tracks the live private domain. - */ + /** Build the engine's service variables from the generated password. */ variables: (password: string) => Record; } -const PRIVATE_DOMAIN = "${{RAILWAY_PRIVATE_DOMAIN}}"; - /** - * Mirrors the variable/volume layout of Railway's official database - * templates (the SDK exposes no way to discover the service a - * `templateDeployV2` workflow created, so we deploy the same images - * directly — every aspect stays observable and reconcilable). + * Props shared by every Railway database engine resource + * (`PostgresDatabase`, `MySQLDatabase`). */ -const DATABASE_SPECS: Record = { - postgres: { - image: "ghcr.io/railwayapp-templates/postgres-ssl:16", - port: 5432, - mountPath: "/var/lib/postgresql/data", - urlVariable: "DATABASE_URL", - username: "postgres", - database: "railway", - variables: (password) => ({ - PGDATA: "/var/lib/postgresql/data/pgdata", - POSTGRES_USER: "postgres", - POSTGRES_PASSWORD: password, - POSTGRES_DB: "railway", - DATABASE_URL: `postgresql://postgres:${password}@${PRIVATE_DOMAIN}:5432/railway`, - }), - }, - redis: { - image: "bitnami/redis:7.2.5", - port: 6379, - mountPath: "/bitnami", - urlVariable: "REDIS_URL", - username: "default", - database: undefined, - variables: (password) => ({ - REDIS_PASSWORD: password, - // bitnami images run as non-root; Railway volumes need uid 0. - RAILWAY_RUN_UID: "0", - REDIS_URL: `redis://default:${password}@${PRIVATE_DOMAIN}:6379`, - }), - }, - mysql: { - image: "mysql:8", - port: 3306, - mountPath: "/var/lib/mysql", - urlVariable: "MYSQL_URL", - username: "root", - database: "railway", - variables: (password) => ({ - MYSQL_ROOT_PASSWORD: password, - MYSQL_DATABASE: "railway", - MYSQL_URL: `mysql://root:${password}@${PRIVATE_DOMAIN}:3306/railway`, - }), - }, - mongo: { - image: "mongo:7", - port: 27017, - mountPath: "/data/db", - urlVariable: "MONGO_URL", - username: "mongo", - database: undefined, - variables: (password) => ({ - MONGO_INITDB_ROOT_USERNAME: "mongo", - MONGO_INITDB_ROOT_PASSWORD: password, - MONGO_URL: `mongodb://mongo:${password}@${PRIVATE_DOMAIN}:27017`, - }), - }, -}; - -export type DatabaseProps = { +export type DatabaseEngineProps = { /** * The Railway project (or `{ projectId }`) to create the database in. */ @@ -135,10 +75,6 @@ export type DatabaseProps = { * resource (otherwise required). */ environment?: EnvironmentSource; - /** - * The database engine to deploy. - */ - kind: DatabaseKind; /** * Service name. If omitted, a unique name is generated from * `${app}-${stage}-${id}`. @@ -150,97 +86,59 @@ export type DatabaseProps = { region?: string; }; -export type Database = Resource< - "Railway.Database", - DatabaseProps, - { - serviceId: string; - name: string; - projectId: string; - environmentId: string; - volumeId: string; - kind: DatabaseKind; - /** Port the engine listens on inside the private network. */ - port: number; - /** Name of the service variable carrying the connection URL, e.g. `DATABASE_URL`. */ - urlVariable: string; - username: string; - database: string | undefined; - /** Generated admin password (persisted in state). */ - password: string; - deploymentId: string | undefined; - deploymentStatus: DeploymentStatus | undefined; - }, - never, - Providers ->; - /** - * A Railway-hosted database — the same images, volumes, and variables as - * Railway's official database templates (Postgres, Redis, MySQL, - * MongoDB), deployed as a fully reconciled service with a persistent - * volume and a private-network connection URL. - * - * Consume the connection string from another Service/Function with the - * `DatabaseUrl` binding, which injects it via Railway reference-variable - * syntax (`${{Postgres.DATABASE_URL}}`) so Railway keeps it fresh. - * - * @section Creating a Database - * @example Postgres - * ```typescript - * const project = yield* Railway.Project("my-project"); - * const db = yield* Railway.Database("db", { - * project, - * kind: "postgres", - * }); - * ``` - * - * @example Redis - * ```typescript - * const cache = yield* Railway.Database("cache", { - * project, - * kind: "redis", - * }); - * ``` - * - * @section Consuming a Database - * @example Bind the connection URL into a Function - * ```typescript - * Effect.gen(function* () { - * const url = yield* Railway.DatabaseUrl.bind(db); - * return { - * fetch: Effect.gen(function* () { - * const connectionString = Redacted.value(yield* url); - * // ... connect with your driver of choice - * }), - * }; - * }).pipe(Effect.provide(Railway.DatabaseUrlLive)) - * ``` - * - * @see https://docs.railway.com/guides/databases + * Output attributes shared by every Railway database engine resource. */ -export const Database = Resource("Railway.Database"); - -export const isDatabase = (value: any): value is Database => - typeof value === "object" && - value !== null && - "Type" in value && - value.Type === "Railway.Database"; +export type DatabaseEngineAttributes = { + serviceId: string; + name: string; + projectId: string; + environmentId: string; + volumeId: string; + /** Port the engine listens on inside the private network. */ + port: number; + /** Default admin username. */ + username: string; + /** Default logical database name (where the engine has one). */ + database: string | undefined; + /** Generated admin password (persisted in state). */ + password: string; + deploymentId: string | undefined; + deploymentStatus: DeploymentStatus | undefined; +}; const generatePassword = Effect.sync(() => crypto.randomBytes(24).toString("base64url"), ); -export const DatabaseProvider = () => - Provider.succeed(Database, { +/** + * A Railway database engine resource — `PostgresDatabase` and + * `MySQLDatabase` are instances of this shape; only the type string (and + * the engine spec wired into the provider) differ. + */ +export type DatabaseEngineResource = Resource< + Type, + DatabaseEngineProps, + DatabaseEngineAttributes, + never, + Providers +>; + +/** + * Build the provider for a database engine resource. The lifecycle is + * identical across engines — only the image/port/variable spec differs. + */ +export const makeDatabaseEngineProvider = ( + cls: Parameters>>[0], + spec: DatabaseEngineSpec, +) => + Provider.succeed(cls, { stables: [ "serviceId", "projectId", "environmentId", "volumeId", - "kind", "port", - "urlVariable", "username", "database", "password", @@ -259,25 +157,25 @@ export const DatabaseProvider = () => ) { return { action: "replace" } as const; } - if (news.kind !== output.kind) { - return { action: "replace" } as const; - } } return undefined; }), read: Effect.fn(function* ({ output }) { if (!output?.serviceId) return undefined; return yield* getService({ id: output.serviceId }).pipe( - Effect.map((service) => ({ - ...output, - name: service.name, - projectId: service.projectId, - })), + Effect.map((service) => + service.deletedAt !== null + ? undefined + : { + ...output, + name: service.name, + projectId: service.projectId, + }, + ), Effect.catchTag("NotAuthorized", () => Effect.succeed(undefined)), ); }), reconcile: Effect.fn(function* ({ id, news, output }) { - const spec = DATABASE_SPECS[news.kind]; const projectId = resolveProjectId(news.project as ProjectSource); const environmentId = news.environment ? resolveEnvironmentId(news.environment as EnvironmentSource) @@ -409,9 +307,7 @@ export const DatabaseProvider = () => projectId, environmentId, volumeId, - kind: news.kind, port: spec.port, - urlVariable: spec.urlVariable, username: spec.username, database: spec.database, password, diff --git a/packages/alchemy/src/Railway/DatabaseUrl.ts b/packages/alchemy/src/Railway/DatabaseUrl.ts deleted file mode 100644 index ffcf860c0e..0000000000 --- a/packages/alchemy/src/Railway/DatabaseUrl.ts +++ /dev/null @@ -1,99 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import * as Binding from "../Binding.ts"; -import { RuntimeContext } from "../RuntimeContext.ts"; -import type { Database } from "./Database.ts"; -import { - bindingEnvPrefix, - isBindingHost, - serviceReference, -} from "./Reference.ts"; - -/** - * Database connection binding. - * - * Injects a `Railway.Database`'s connection URL into the consuming - * Service/Function: - * - * - At deploy time, {@link DatabaseUrlPolicy} writes - * `_URL=${{.}}` (e.g. - * `${{Postgres.DATABASE_URL}}`) onto the host's variables using Railway - * reference-variable syntax, so Railway resolves it server-side and - * keeps it fresh if the database's domain or credentials change. - * - At runtime, `DatabaseUrl.bind(db)` returns an Effect producing the - * resolved connection URL as a `Redacted`. It requires - * {@link RuntimeContext}. - * - * @example - * ```typescript - * Effect.gen(function* () { - * const url = yield* Railway.DatabaseUrl.bind(db); - * return { - * fetch: Effect.gen(function* () { - * const connectionString = Redacted.value(yield* url); - * // pass to pg/mysql2/mongodb/ioredis... - * }), - * }; - * }).pipe(Effect.provide(Railway.DatabaseUrlLive)) - * ``` - */ -export class DatabaseUrl extends Binding.Service< - DatabaseUrl, - ( - database: Database, - ) => Effect.Effect< - Effect.Effect, never, RuntimeContext> - > ->()("Railway.DatabaseUrl") {} - -export const DatabaseUrlLive = Layer.effect( - DatabaseUrl, - Effect.gen(function* () { - const Policy = yield* DatabaseUrlPolicy; - - return Effect.fn(function* (database: Database) { - yield* Policy(database); - const key = `${bindingEnvPrefix(database)}_URL`; - return RuntimeContext.pipe( - Effect.flatMap((ctx) => { - const value = ctx.env[key]; - return typeof value === "string" && value.length > 0 - ? Effect.succeed(Redacted.make(value)) - : Effect.die( - `Railway.DatabaseUrl: env var '${key}' is not set — ` + - `was '${database.LogicalId}' bound via DatabaseUrlPolicy at deploy time?`, - ); - }), - ); - }); - }), -); - -/** - * Deploy-time half of {@link DatabaseUrl}: records the reference-variable - * env var on the consuming Railway service. - */ -export class DatabaseUrlPolicy extends Binding.Policy< - DatabaseUrlPolicy, - (database: Database) => Effect.Effect ->()("Railway.DatabaseUrl") {} - -export const DatabaseUrlPolicyLive = DatabaseUrlPolicy.layer.succeed( - Effect.fn(function* (host, database) { - if (isBindingHost(host)) { - const prefix = bindingEnvPrefix(database); - yield* host.bind`DatabaseUrl(${host}, ${database})`({ - env: { - // `${{.DATABASE_URL>}}`-style reference; `urlVariable` - // is itself an Output (DATABASE_URL / REDIS_URL / ...). - [`${prefix}_URL`]: serviceReference(database, database.urlVariable), - }, - }); - } else { - return yield* Effect.die( - `DatabaseUrlPolicy does not support runtime '${host.Type}'`, - ); - } - }), -); diff --git a/packages/alchemy/src/Railway/Function.ts b/packages/alchemy/src/Railway/Function.ts index 6af61f4ff6..f6d8e4ac3f 100644 --- a/packages/alchemy/src/Railway/Function.ts +++ b/packages/alchemy/src/Railway/Function.ts @@ -28,6 +28,7 @@ import type * as rolldown from "rolldown"; import * as Bundle from "../Bundle/Bundle.ts"; import { findCwdForBundle } from "../Bundle/TempRoot.ts"; import { isResolved } from "../Diff.ts"; +import { ExecutionContext } from "../ExecutionContext.ts"; import { HttpServer, type HttpEffect } from "../Http.ts"; import * as Output from "../Output.ts"; import { createPhysicalName } from "../PhysicalName.ts"; @@ -220,7 +221,7 @@ export interface FunctionRuntimeContext extends Server.ProcessContext { * daemon or registry required. * * Bindings reuse the `Railway.Service` binding contract - * (`{ env?, volumes? }`), so `Connect`, `DatabaseUrl`, `VolumeMount`, + * (`{ env?, volumes? }`), so `Connect`, `PostgresDatabaseBinding`, `VolumeMount`, * and any other Railway policy attach to a Function exactly like they do * to a Service: the policy records env vars / volume attachments at * deploy time and the provider reconciles them onto the service before @@ -271,8 +272,8 @@ export interface FunctionRuntimeContext extends Server.ProcessContext { * * @example Bind a database connection URL * ```typescript - * const url = yield* Railway.DatabaseUrl.bind(postgres); - * // at runtime: Redacted.value(yield* url) + * const db = yield* Railway.PostgresDatabase.bind(postgres); + * // at runtime: Redacted.value(yield* db.connectionString) * ``` * * @section Configuration @@ -296,7 +297,7 @@ export const Function: Platform< createRuntimeContext: (id: string): FunctionRuntimeContext => { const runners: Effect.Effect[] = []; // At runtime (inside the deployed container) expose the live process - // environment so binding accessors (Connect, DatabaseUrl, VolumeMount) + // environment so binding accessors (Connect, database bindings, VolumeMount) // can read the variables that were reconciled onto the service. At plan // time this is a fresh record that collects Output references which // the provider publishes as service variables. @@ -319,7 +320,18 @@ export const Function: Platform< Effect.map(Option.getOrUndefined), ); if (httpServer) { - yield* httpServer.serve(handler); + // A Railway Function is a long-lived process, so the + // ExecutionContext spans the whole runtime: one scope and + // one cache for every request the server handles. Helpers + // like `Drizzle.postgres` / `Sql.postgres` memoize their + // connection pool on it. + const scope = yield* Effect.scope; + yield* httpServer.serve(handler).pipe( + Effect.provideService(ExecutionContext, { + scope, + cache: {}, + }), + ); yield* Effect.never; } // no HttpServer means we're at plan time — nothing to serve diff --git a/packages/alchemy/src/Railway/MySQLDatabase.ts b/packages/alchemy/src/Railway/MySQLDatabase.ts new file mode 100644 index 0000000000..c9fdd7439f --- /dev/null +++ b/packages/alchemy/src/Railway/MySQLDatabase.ts @@ -0,0 +1,77 @@ +import { Resource } from "../Resource.ts"; +import { + makeDatabaseEngineProvider, + PRIVATE_DOMAIN, + type DatabaseEngineAttributes, + type DatabaseEngineProps, +} from "./DatabaseEngine.ts"; +import { MySQLDatabaseBinding } from "./MySQLDatabaseBinding.ts"; +import type { Providers } from "./Providers.ts"; + +export type MySQLDatabaseProps = DatabaseEngineProps; + +export type MySQLDatabase = Resource< + "Railway.MySQLDatabase", + MySQLDatabaseProps, + DatabaseEngineAttributes, + never, + Providers +>; + +/** + * A Railway-hosted MySQL database — the same image, volume, and variable + * layout as Railway's official MySQL template, deployed as a fully + * reconciled service with a persistent volume and a private-network + * connection URL. + * + * Consume it from a `Railway.Service`/`Railway.Function` with + * `MySQLDatabase.bind(db)`, which injects the connection details via + * Railway reference-variable syntax (`${{MySQL.MYSQL_URL}}`) so Railway + * keeps them fresh, and returns a typed runtime accessor. + * + * @section Creating a Database + * @example MySQL database in a project + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const db = yield* Railway.MySQLDatabase("db", { project }); + * ``` + * + * @section Binding to a Function + * @example Read the connection string at runtime + * ```typescript + * Effect.gen(function* () { + * const db = yield* Railway.MySQLDatabase.bind(MyDatabase); + * return { + * fetch: Effect.gen(function* () { + * const url = Redacted.value(yield* db.connectionString); + * // pass to mysql2 / drizzle / kysely... + * }), + * }; + * }).pipe(Effect.provide(Railway.MySQLDatabaseBindingLive)) + * ``` + * + * @see https://docs.railway.com/guides/mysql + */ +export const MySQLDatabase = Resource("Railway.MySQLDatabase")({ + bind: MySQLDatabaseBinding.bind, +}); + +export const isMySQLDatabase = (value: any): value is MySQLDatabase => + typeof value === "object" && + value !== null && + "Type" in value && + value.Type === "Railway.MySQLDatabase"; + +export const MySQLDatabaseProvider = () => + makeDatabaseEngineProvider<"Railway.MySQLDatabase">(MySQLDatabase, { + image: "mysql:8", + port: 3306, + mountPath: "/var/lib/mysql", + username: "root", + database: "railway", + variables: (password) => ({ + MYSQL_ROOT_PASSWORD: password, + MYSQL_DATABASE: "railway", + MYSQL_URL: `mysql://root:${password}@${PRIVATE_DOMAIN}:3306/railway`, + }), + }); diff --git a/packages/alchemy/src/Railway/MySQLDatabaseBinding.ts b/packages/alchemy/src/Railway/MySQLDatabaseBinding.ts new file mode 100644 index 0000000000..c072dab870 --- /dev/null +++ b/packages/alchemy/src/Railway/MySQLDatabaseBinding.ts @@ -0,0 +1,148 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Binding from "../Binding.ts"; +import * as Output from "../Output.ts"; +import { RuntimeContext } from "../RuntimeContext.ts"; +import type { MySQLDatabase } from "./MySQLDatabase.ts"; +import { + bindingEnvPrefix, + isBindingHost, + serviceReference, +} from "./Reference.ts"; + +/** + * Typed runtime accessor for a bound {@link MySQLDatabase}. Every Effect + * requires {@link RuntimeContext} — the values only exist inside the + * deployed Railway service whose variables the deploy-time policy wrote. + */ +export interface MySQLDatabaseClient { + /** + * A MySQL connection string for use with a driver/ORM, e.g. + * `mysql://root:…@.railway.internal:3306/railway`. + */ + connectionString: Effect.Effect< + Redacted.Redacted, + never, + RuntimeContext + >; + /** Private-network hostname of the database service. */ + host: Effect.Effect; + /** Port to pair with `host`. */ + port: Effect.Effect; + /** Database user. */ + user: Effect.Effect; + /** Database password. */ + password: Effect.Effect, never, RuntimeContext>; + /** Logical database name. */ + database: Effect.Effect; +} + +/** + * MySQL database binding. + * + * - At deploy time, {@link MySQLDatabaseBindingPolicy} writes + * `_URL=${{.MYSQL_URL}}` (plus `_HOST`, `_PORT`, + * `_USER`, `_PASSWORD`, `_DATABASE`) onto the host's variables using + * Railway reference-variable syntax, so Railway resolves them + * server-side and keeps them fresh if the database's domain or + * credentials change. + * - At runtime, `MySQLDatabase.bind(db)` returns a typed + * {@link MySQLDatabaseClient} whose Effects read those variables. + * + * @example + * ```typescript + * Effect.gen(function* () { + * const db = yield* Railway.MySQLDatabase.bind(MyDatabase); + * return { + * fetch: Effect.gen(function* () { + * const url = Redacted.value(yield* db.connectionString); + * // pass to mysql2 / drizzle / kysely... + * }), + * }; + * }).pipe(Effect.provide(Railway.MySQLDatabaseBindingLive)) + * ``` + * + * @binding + */ +export class MySQLDatabaseBinding extends Binding.Service< + MySQLDatabaseBinding, + (database: MySQLDatabase) => Effect.Effect +>()("Railway.MySQLDatabase.Binding") {} + +/** Read a required binding env var from the runtime environment. */ +const readEnv = (database: MySQLDatabase, suffix: string) => + RuntimeContext.pipe( + Effect.flatMap((ctx) => { + const key = `${bindingEnvPrefix(database)}_${suffix}`; + const value = ctx.env[key]; + return typeof value === "string" && value.length > 0 + ? Effect.succeed(value) + : Effect.die( + `Railway.MySQLDatabase: env var '${key}' is not set — ` + + `was '${database.LogicalId}' bound at deploy time?`, + ); + }), + ); + +export const MySQLDatabaseBindingLive = Layer.effect( + MySQLDatabaseBinding, + Effect.gen(function* () { + const Policy = yield* MySQLDatabaseBindingPolicy; + + return Effect.fn(function* (database: MySQLDatabase) { + yield* Policy(database); + return { + connectionString: readEnv(database, "URL").pipe( + Effect.map(Redacted.make), + ), + host: readEnv(database, "HOST"), + port: readEnv(database, "PORT").pipe(Effect.map(Number)), + user: readEnv(database, "USER"), + password: readEnv(database, "PASSWORD").pipe(Effect.map(Redacted.make)), + database: readEnv(database, "DATABASE"), + } satisfies MySQLDatabaseClient; + }); + }), +); + +/** + * Deploy-time half of {@link MySQLDatabaseBinding}: records the + * reference-variable env vars on the consuming Railway service. + */ +export class MySQLDatabaseBindingPolicy extends Binding.Policy< + MySQLDatabaseBindingPolicy, + (database: MySQLDatabase) => Effect.Effect +>()("Railway.MySQLDatabase.Binding") {} + +export const MySQLDatabaseBindingPolicyLive = + MySQLDatabaseBindingPolicy.layer.succeed( + Effect.fn(function* (host, database) { + if (!isBindingHost(host)) { + return yield* Effect.die( + `MySQLDatabaseBinding does not support runtime '${host.Type}'`, + ); + } + const prefix = bindingEnvPrefix(database); + yield* host.bind`MySQLDatabase(${host}, ${database})`({ + env: { + // `${{.MYSQL_URL>}}`-style references — Railway resolves + // them server-side when the variables are read. + [`${prefix}_URL`]: serviceReference(database, "MYSQL_URL"), + [`${prefix}_HOST`]: serviceReference( + database, + "RAILWAY_PRIVATE_DOMAIN", + ), + [`${prefix}_PORT`]: Output.interpolate`${database.port}`, + // The root user has no dedicated variable on the engine + // service — it is fixed by the official template. + [`${prefix}_USER`]: Output.interpolate`${database.username}`, + [`${prefix}_PASSWORD`]: serviceReference( + database, + "MYSQL_ROOT_PASSWORD", + ), + [`${prefix}_DATABASE`]: serviceReference(database, "MYSQL_DATABASE"), + }, + }); + }), + ); diff --git a/packages/alchemy/src/Railway/PostgresDatabase.ts b/packages/alchemy/src/Railway/PostgresDatabase.ts new file mode 100644 index 0000000000..8a5c1ed640 --- /dev/null +++ b/packages/alchemy/src/Railway/PostgresDatabase.ts @@ -0,0 +1,82 @@ +import { Resource } from "../Resource.ts"; +import { + makeDatabaseEngineProvider, + PRIVATE_DOMAIN, + type DatabaseEngineAttributes, + type DatabaseEngineProps, +} from "./DatabaseEngine.ts"; +import { PostgresDatabaseBinding } from "./PostgresDatabaseBinding.ts"; +import type { Providers } from "./Providers.ts"; + +export type PostgresDatabaseProps = DatabaseEngineProps; + +export type PostgresDatabase = Resource< + "Railway.PostgresDatabase", + PostgresDatabaseProps, + DatabaseEngineAttributes, + never, + Providers +>; + +/** + * A Railway-hosted Postgres database — the same image, volume, and + * variable layout as Railway's official Postgres template, deployed as a + * fully reconciled service with a persistent volume and a + * private-network connection URL. + * + * Consume it from a `Railway.Service`/`Railway.Function` with + * `PostgresDatabase.bind(db)`, which injects the connection details via + * Railway reference-variable syntax (`${{Postgres.DATABASE_URL}}`) so + * Railway keeps them fresh, and returns a typed runtime accessor. + * + * @section Creating a Database + * @example Postgres database in a project + * ```typescript + * const project = yield* Railway.Project("my-project"); + * const db = yield* Railway.PostgresDatabase("db", { project }); + * ``` + * + * @section Binding to a Function + * @example Connect with Effect's native Postgres client + * ```typescript + * Effect.gen(function* () { + * const db = yield* Railway.PostgresDatabase.bind(MyDatabase); + * const sql = yield* Sql.postgres(db.connectionString); + * return { + * fetch: Effect.gen(function* () { + * const rows = yield* sql`select now()`; + * return yield* HttpServerResponse.json(rows); + * }), + * }; + * }).pipe(Effect.provide(Railway.PostgresDatabaseBindingLive)) + * ``` + * + * @see https://docs.railway.com/guides/postgresql + */ +export const PostgresDatabase = Resource( + "Railway.PostgresDatabase", +)({ + bind: PostgresDatabaseBinding.bind, +}); + +export const isPostgresDatabase = (value: any): value is PostgresDatabase => + typeof value === "object" && + value !== null && + "Type" in value && + value.Type === "Railway.PostgresDatabase"; + +export const PostgresDatabaseProvider = () => + makeDatabaseEngineProvider<"Railway.PostgresDatabase">(PostgresDatabase, { + image: "ghcr.io/railwayapp-templates/postgres-ssl:16", + port: 5432, + mountPath: "/var/lib/postgresql/data", + username: "postgres", + database: "railway", + variables: (password) => ({ + PGDATA: "/var/lib/postgresql/data/pgdata", + POSTGRES_USER: "postgres", + POSTGRES_PASSWORD: password, + POSTGRES_DB: "railway", + DATABASE_URL: `postgresql://postgres:${password}@${PRIVATE_DOMAIN}:5432/railway`, + }), + }); diff --git a/packages/alchemy/src/Railway/PostgresDatabaseBinding.ts b/packages/alchemy/src/Railway/PostgresDatabaseBinding.ts new file mode 100644 index 0000000000..d1226917a5 --- /dev/null +++ b/packages/alchemy/src/Railway/PostgresDatabaseBinding.ts @@ -0,0 +1,148 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Output from "../Output.ts"; +import * as Redacted from "effect/Redacted"; +import * as Binding from "../Binding.ts"; +import { RuntimeContext } from "../RuntimeContext.ts"; +import type { PostgresDatabase } from "./PostgresDatabase.ts"; +import { + bindingEnvPrefix, + isBindingHost, + serviceReference, +} from "./Reference.ts"; + +/** + * Typed runtime accessor for a bound {@link PostgresDatabase}. Every + * Effect requires {@link RuntimeContext} — the values only exist inside + * the deployed Railway service whose variables the deploy-time policy + * wrote. + */ +export interface PostgresDatabaseClient { + /** + * A Postgres connection string for use with a driver/ORM, e.g. + * `postgresql://postgres:…@.railway.internal:5432/railway`. + */ + connectionString: Effect.Effect< + Redacted.Redacted, + never, + RuntimeContext + >; + /** Private-network hostname of the database service. */ + host: Effect.Effect; + /** Port to pair with `host`. */ + port: Effect.Effect; + /** Database user. */ + user: Effect.Effect; + /** Database password. */ + password: Effect.Effect, never, RuntimeContext>; + /** Logical database name. */ + database: Effect.Effect; +} + +/** + * Postgres database binding. + * + * - At deploy time, {@link PostgresDatabaseBindingPolicy} writes + * `_URL=${{.DATABASE_URL}}` (plus `_HOST`, `_PORT`, + * `_USER`, `_PASSWORD`, `_DATABASE`) onto the host's variables using + * Railway reference-variable syntax, so Railway resolves them + * server-side and keeps them fresh if the database's domain or + * credentials change. + * - At runtime, `PostgresDatabase.bind(db)` returns a typed + * {@link PostgresDatabaseClient} whose Effects read those variables. + * + * @example + * ```typescript + * Effect.gen(function* () { + * const db = yield* Railway.PostgresDatabase.bind(MyDatabase); + * const sql = yield* Sql.postgres(db.connectionString); + * return { + * fetch: Effect.gen(function* () { + * const rows = yield* sql`select now()`; + * return yield* HttpServerResponse.json(rows); + * }), + * }; + * }).pipe(Effect.provide(Railway.PostgresDatabaseBindingLive)) + * ``` + * + * @binding + */ +export class PostgresDatabaseBinding extends Binding.Service< + PostgresDatabaseBinding, + (database: PostgresDatabase) => Effect.Effect +>()("Railway.PostgresDatabase.Binding") {} + +export const PostgresDatabaseBindingLive = Layer.effect( + PostgresDatabaseBinding, + Effect.gen(function* () { + const Policy = yield* PostgresDatabaseBindingPolicy; + + return Effect.fn(function* (database: PostgresDatabase) { + yield* Policy(database); + return { + connectionString: readEnv(database, "URL").pipe( + Effect.map(Redacted.make), + ), + host: readEnv(database, "HOST"), + port: readEnv(database, "PORT").pipe(Effect.map(Number)), + user: readEnv(database, "USER"), + password: readEnv(database, "PASSWORD").pipe(Effect.map(Redacted.make)), + database: readEnv(database, "DATABASE"), + } satisfies PostgresDatabaseClient; + }); + }), +); + +/** + * Deploy-time half of {@link PostgresDatabaseBinding}: records the + * reference-variable env vars on the consuming Railway service. + */ +export class PostgresDatabaseBindingPolicy extends Binding.Policy< + PostgresDatabaseBindingPolicy, + (database: PostgresDatabase) => Effect.Effect +>()("Railway.PostgresDatabase.Binding") {} + +export const PostgresDatabaseBindingPolicyLive = + PostgresDatabaseBindingPolicy.layer.succeed( + Effect.fn(function* (host, database) { + if (!isBindingHost(host)) { + return yield* Effect.die( + `PostgresDatabaseBinding does not support runtime '${host.Type}'`, + ); + } + const prefix = bindingEnvPrefix(database); + yield* host.bind`PostgresDatabase(${host}, ${database})`({ + env: { + // `${{.DATABASE_URL>}}`-style references — Railway + // resolves them server-side when the variables are read. + [`${prefix}_URL`]: serviceReference(database, "DATABASE_URL"), + [`${prefix}_HOST`]: serviceReference( + database, + "RAILWAY_PRIVATE_DOMAIN", + ), + [`${prefix}_PORT`]: Output.interpolate`${database.port}`, + [`${prefix}_USER`]: serviceReference(database, "POSTGRES_USER"), + [`${prefix}_PASSWORD`]: serviceReference( + database, + "POSTGRES_PASSWORD", + ), + [`${prefix}_DATABASE`]: serviceReference(database, "POSTGRES_DB"), + }, + }); + }), + ); + +/** Read a required binding env var from the runtime environment. */ +const readEnv = (database: PostgresDatabase, suffix: string) => + RuntimeContext.pipe( + Effect.flatMap((ctx) => { + const key = `${bindingEnvPrefix(database)}_${suffix}`; + const value = ctx.env[key]; + return typeof value === "string" && value.length > 0 + ? Effect.succeed(value) + : Effect.die( + `Railway.PostgresDatabase: env var '${key}' is not set — ` + + `was '${database.LogicalId}' bound at deploy time?`, + ); + }), + ); diff --git a/packages/alchemy/src/Railway/Providers.ts b/packages/alchemy/src/Railway/Providers.ts index 8b5740e003..22a33e2f5f 100644 --- a/packages/alchemy/src/Railway/Providers.ts +++ b/packages/alchemy/src/Railway/Providers.ts @@ -7,10 +7,21 @@ import { RailwayAuth } from "./AuthProvider.ts"; import { ConnectPolicy, ConnectPolicyLive } from "./Connect.ts"; import * as Credentials from "./Credentials.ts"; import { CustomDomain, CustomDomainProvider } from "./CustomDomain.ts"; -import { Database, DatabaseProvider } from "./Database.ts"; -import { DatabaseUrlPolicy, DatabaseUrlPolicyLive } from "./DatabaseUrl.ts"; import { Environment, EnvironmentProvider } from "./Environment.ts"; import { Function, FunctionProvider } from "./Function.ts"; +import { MySQLDatabase, MySQLDatabaseProvider } from "./MySQLDatabase.ts"; +import { + MySQLDatabaseBindingPolicy, + MySQLDatabaseBindingPolicyLive, +} from "./MySQLDatabaseBinding.ts"; +import { + PostgresDatabase, + PostgresDatabaseProvider, +} from "./PostgresDatabase.ts"; +import { + PostgresDatabaseBindingPolicy, + PostgresDatabaseBindingPolicyLive, +} from "./PostgresDatabaseBinding.ts"; import { Project, ProjectProvider } from "./Project.ts"; import { ProjectToken, ProjectTokenProvider } from "./ProjectToken.ts"; import { Service, ServiceProvider } from "./Service.ts"; @@ -67,10 +78,12 @@ export const providers = () => TcpProxy, Volume, Variables, - Database, + PostgresDatabase, + MySQLDatabase, Webhook, ConnectPolicy, - DatabaseUrlPolicy, + PostgresDatabaseBindingPolicy, + MySQLDatabaseBindingPolicy, VolumeMountPolicy, ]), ).pipe( @@ -86,10 +99,12 @@ export const providers = () => TcpProxyProvider(), VolumeProvider(), VariablesProvider(), - DatabaseProvider(), + PostgresDatabaseProvider(), + MySQLDatabaseProvider(), WebhookProvider(), ConnectPolicyLive, - DatabaseUrlPolicyLive, + PostgresDatabaseBindingPolicyLive, + MySQLDatabaseBindingPolicyLive, VolumeMountPolicyLive, ), ), diff --git a/packages/alchemy/src/Railway/Reference.ts b/packages/alchemy/src/Railway/Reference.ts index d8e588c76f..82c89c4809 100644 --- a/packages/alchemy/src/Railway/Reference.ts +++ b/packages/alchemy/src/Railway/Reference.ts @@ -31,9 +31,11 @@ import { isService, type Service } from "./Service.ts"; * - {@link Connect} — service-to-service: injects the target's private / * public domains (via `${{Name.RAILWAY_PRIVATE_DOMAIN}}` references) and * exposes typed URL accessors at runtime. - * - {@link DatabaseUrl} — injects a `Railway.Database`'s connection URL - * (via `${{Name.DATABASE_URL}}`-style references) with a - * RuntimeContext-colored accessor returning the redacted URL. + * - {@link PostgresDatabaseBinding} / {@link MySQLDatabaseBinding} — + * inject a database's connection details (via + * `${{Name.DATABASE_URL}}`-style references) with RuntimeContext-colored + * accessors (`connectionString`, `host`, `port`, `user`, `password`, + * `database`). * - {@link VolumeMount} — attaches a `Railway.Volume` to the consuming * service and exposes the mount path at runtime. * diff --git a/packages/alchemy/src/Railway/index.ts b/packages/alchemy/src/Railway/index.ts index 9107c4ebcf..b6ab968972 100644 --- a/packages/alchemy/src/Railway/index.ts +++ b/packages/alchemy/src/Railway/index.ts @@ -1,10 +1,12 @@ export * from "./AuthProvider.ts"; export * from "./Connect.ts"; export * from "./CustomDomain.ts"; -export * from "./Database.ts"; -export * from "./DatabaseUrl.ts"; export * from "./Environment.ts"; export * from "./Function.ts"; +export * from "./MySQLDatabase.ts"; +export * from "./MySQLDatabaseBinding.ts"; +export * from "./PostgresDatabase.ts"; +export * from "./PostgresDatabaseBinding.ts"; export * from "./Project.ts"; export * from "./ProjectToken.ts"; export * from "./Providers.ts"; diff --git a/packages/alchemy/src/Sql/Postgres.ts b/packages/alchemy/src/Sql/Postgres.ts new file mode 100644 index 0000000000..9cf1bf543c --- /dev/null +++ b/packages/alchemy/src/Sql/Postgres.ts @@ -0,0 +1,61 @@ +import * as PgClient from "@effect/sql-pg/PgClient"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { ExecutionContext } from "../ExecutionContext.ts"; +import { proxyChain } from "../Util/proxy-chain.ts"; + +/** + * Open Effect's native Postgres client (`@effect/sql-pg`) from a + * connection URL. + * + * Returns a chainable Proxy over `PgClient` (via `proxyChain`) — every + * property read records a step, every call records args, and the chain is + * replayed against the resolved client when it's finally yielded as an + * Effect. Callers don't need a separate `yield* conn` step: + * + * ```typescript + * const db = yield* Railway.PostgresDatabase.bind(MyDatabase); + * const sql = yield* Sql.postgres(db.connectionString); + * + * fetch: Effect.gen(function* () { + * const rows = yield* sql`select now() as now`; + * }); + * ``` + * + * The connect work is deferred until the first query and memoized on the + * current `ExecutionContext` (`ctx.cache`), so the pool is built at most + * once per execution — a Worker `fetch`/`queue`/`scheduled` event on + * Cloudflare, or the whole process lifetime on a serverful runtime like a + * Railway Function — and reused across every query in that execution. + * Yielding the connection string is likewise deferred, so deploy / + * plan-time invocations (where the runtime environment isn't provided) + * never trigger a real connection attempt. + * + * The pool is built against the execution's `Scope` (`ctx.scope`), so its + * `end` finalizer fires when that scope closes — when the request / run / + * process settles, not when the init scope closes. + * + * @binding + */ +export const postgres = ( + connectionString: Effect.Effect, E, R>, + config?: Omit, +) => + Effect.sync(function () { + const symbol = Symbol(); + + return proxyChain( + Effect.gen(function* () { + const ctx = yield* ExecutionContext; + return yield* (ctx.cache[symbol] ??= yield* Effect.gen(function* () { + const pgCtx = yield* Layer.buildWithScope( + PgClient.layer({ ...config, url: yield* connectionString }), + ctx.scope, + ); + return Context.get(pgCtx, PgClient.PgClient); + }).pipe(Effect.cached)); + }) as Effect.Effect, + ); + }); diff --git a/packages/alchemy/src/Sql/index.ts b/packages/alchemy/src/Sql/index.ts index 2c77bfdea0..5dc7000c78 100644 --- a/packages/alchemy/src/Sql/index.ts +++ b/packages/alchemy/src/Sql/index.ts @@ -1 +1,2 @@ +export * from "./Postgres.ts"; export * from "./SqlFile.ts";