1import { Query } from "@distilled.cloud/core/query";
2import type { Config } from "@distilled.cloud/railway";
3import { Credentials, GraphQLLive, Railway } from "@distilled.cloud/railway";
4import * as Context from "effect/Context";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as HttpClient from "effect/http/HttpClient";
9
10export class RailwayWorkspaceNotFound extends Data.TaggedError(
11 "Railway.WorkspaceNotFound",
12)<{
13 message: string;
14}> {}
15
16/**
17 * Default Railway workspace for the current token
18 * (`me.workspace ?? me.workspaces[0]`). Not a resource — the Environment
19 * *resource* is a project-scoped deploy environment.
20 */
21export type RailwayWorkspace = {
22 readonly id: string;
23 readonly name: string;
24};
25
26/**
27 * Fully-resolved Railway environment for a stack.
28 *
29 * `{ token, tokenKind, apiBaseUrl }` comes from distilled `Credentials`.
30 * `workspaceId` is the current token's default workspace
31 * (`me.workspace ?? me.workspaces[0]`), cached for the process. Resolve
32 * it inside lifecycle operations with `RailwayEnvironment.current`.
33 */
34export type RailwayEnvironmentShape = Config & {
35 readonly workspaceId: string;
36};
37
38export class RailwayEnvironment extends Context.Service<
39 RailwayEnvironment,
40 Effect.Effect<RailwayEnvironmentShape>
41>()("Railway::Environment") {
42 static current = RailwayEnvironment.use((env) => env);
43 readonly kind = "Environment" as const;
44}
45
46/**
47 * Discover the current token's default workspace.
48 *
49 * Personal account tokens answer `me.workspace ?? me.workspaces[0]`.
50 * Workspace/team tokens reject `me` with {@link RailwayForbidden}
51 * (`Not Authorized`); fall back to `apiToken.workspaces[0]`.
52 */
53const meWorkspaces = Query.fn(() => {
54 const me = Railway.me();
55 return {
56 workspace: { id: me.workspace.id, name: me.workspace.name },
57 workspaces: me.workspaces.pipe(
58 Query.map((workspace) => ({ id: workspace.id, name: workspace.name })),
59 ),
60 };
61});
62
63const tokenWorkspaces = Query.fn(() =>
64 Railway.apiToken().workspaces.pipe(
65 Query.map((workspace) => ({ id: workspace.id, name: workspace.name })),
66 ),
67);
68
69export const resolveWorkspace = Effect.fn(function* () {
70 const fromMe = yield* meWorkspaces().pipe(
71 Effect.map((me) =>
72 me.workspace.id !== null ? me.workspace : me.workspaces[0],
73 ),
74 Effect.catchTag(["RailwayForbidden", "RailwayUnauthenticated"], () =>
75 Effect.succeed(undefined),
76 ),
77 );
78 if (fromMe?.id != null && fromMe.id.length > 0) {
79 return {
80 id: fromMe.id,
81 name: fromMe.name ?? "",
82 } satisfies RailwayWorkspace;
83 }
84
85 const workspace = (yield* tokenWorkspaces())[0];
86 if (workspace === undefined || workspace.id.length === 0) {
87 return yield* new RailwayWorkspaceNotFound({
88 message:
89 "Railway current token did not include a workspace. Check RAILWAY_API_TOKEN is an account or workspace token.",
90 });
91 }
92 return {
93 id: workspace.id,
94 name: workspace.name,
95 } satisfies RailwayWorkspace;
96});
97
98/**
99 * Current token workspace id (`me.workspace ?? me.workspaces[0]`).
100 */
101export const resolveWorkspaceId = Effect.fn(function* () {
102 const workspace = yield* resolveWorkspace();
103 return workspace.id;
104});
105
106/**
107 * Build a `RailwayEnvironment` layer from the distilled `Credentials`
108 * service. Provide this after `Credentials.fromAuthProvider()`.
109 *
110 * `workspaceId` is resolved from `me` and cached so Project.list /
111 * Catalog do not re-hit `me` on every call.
112 */
113export const fromCredentials = () =>
114 Layer.effect(
115 RailwayEnvironment,
116 Effect.gen(function* () {
117 const creds = yield* Credentials;
118 const http = yield* HttpClient.HttpClient;
119 return yield* creds.pipe(
120 Effect.flatMap((resolved) =>
121 resolveWorkspaceId().pipe(
122 Effect.provide(
123 Layer.mergeAll(
124 GraphQLLive,
125 Layer.succeed(Credentials, creds),
126 Layer.succeed(HttpClient.HttpClient, http),
127 ),
128 ),
129 Effect.map((workspaceId) => ({
130 ...resolved,
131 workspaceId,
132 })),
133 ),
134 ),
135 Effect.orDie,
136 Effect.cached,
137 );
138 }),
139 );