| 1 | import { waitUntilDeleted } from "./GraphQL.ts"; |
| 2 | import { Query, type UnwrapPlan } from "@distilled.cloud/core/query"; |
| 3 | import { |
| 4 | Railway, |
| 5 | type Project as RailwayProject, |
| 6 | } from "@distilled.cloud/railway"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import { Unowned } from "../AdoptPolicy.ts"; |
| 11 | import { isResolved } from "../Diff.ts"; |
| 12 | import * as Provider from "../Provider.ts"; |
| 13 | import { Resource } from "../Resource.ts"; |
| 14 | import { RailwayEnvironment } from "./Environment.ts"; |
| 15 | import { waitOutCreateRateLimit } from "./transient.ts"; |
| 16 | import { |
| 17 | createRailwayName, |
| 18 | matchesAlchemyPhysicalName, |
| 19 | sanitizeRailwayName, |
| 20 | } from "./Metadata.ts"; |
| 21 | import type { Providers } from "./Providers.ts"; |
| 22 | |
| 23 | const projectFields = <E>(project: Query<RailwayProject, E>) => ({ |
| 24 | id: project.id, |
| 25 | name: project.name, |
| 26 | description: project.description, |
| 27 | workspaceId: project.workspaceId, |
| 28 | primaryEnvironmentId: project.primaryEnvironmentId, |
| 29 | baseEnvironmentId: project.baseEnvironmentId, |
| 30 | deletedAt: project.deletedAt, |
| 31 | }); |
| 32 | type CloudProject = UnwrapPlan<ReturnType<typeof projectFields>>; |
| 33 | |
| 34 | export interface ProjectProps { |
| 35 | /** |
| 36 | * Project name. Unique per workspace. If omitted, a unique name is |
| 37 | * generated from the stack, stage and logical ID. Changing it updates |
| 38 | * the project in place. |
| 39 | */ |
| 40 | name?: string; |
| 41 | /** |
| 42 | * Workspace to create the project in. Defaults to the current token's |
| 43 | * workspace (`me.workspace ?? me.workspaces[0]`). Changing it replaces |
| 44 | * the project. |
| 45 | */ |
| 46 | workspaceId?: string; |
| 47 | /** |
| 48 | * Human-readable description. Updates in place. |
| 49 | */ |
| 50 | description?: string; |
| 51 | /** |
| 52 | * Name of the environment created with the project (Railway's default |
| 53 | * is `production`). Create-only; ignored on update. |
| 54 | */ |
| 55 | defaultEnvironmentName?: string; |
| 56 | } |
| 57 | |
| 58 | export type Project = Resource< |
| 59 | "Railway.Project", |
| 60 | ProjectProps, |
| 61 | { |
| 62 | /** Railway project id. */ |
| 63 | projectId: string; |
| 64 | /** Physical project name (unique per workspace). */ |
| 65 | name: string; |
| 66 | /** Workspace the project lives in. */ |
| 67 | workspaceId: string; |
| 68 | /** Primary / base environment id created with the project. */ |
| 69 | environmentId: string; |
| 70 | /** Dashboard URL (`https://railway.com/project/{projectId}`). */ |
| 71 | url: string; |
| 72 | }, |
| 73 | never, |
| 74 | Providers |
| 75 | >; |
| 76 | |
| 77 | /** |
| 78 | * A Railway.Project is a workspace-scoped namespace. It owns environments |
| 79 | * and services. Names are unique per workspace. |
| 80 | * |
| 81 | * @see https://docs.railway.com/guides/projects |
| 82 | * |
| 83 | * ### Create a Project |
| 84 | * Alchemy generates a unique name unless you pass one. `url` is the |
| 85 | * dashboard URL. A production environment is created with the project — |
| 86 | * do not recreate it as an Environment resource. |
| 87 | * |
| 88 | * **Example:** Generated name |
| 89 | * ```typescript |
| 90 | * const site = yield* Railway.Project("Site"); |
| 91 | * ``` |
| 92 | * |
| 93 | * :::note |
| 94 | * Prefer omitting `name` in tests and CI so names stay unique and |
| 95 | * reclaimable. |
| 96 | * ::: |
| 97 | * |
| 98 | * ### A stable name |
| 99 | * Pass `name` when you need a stable project name. Changing it later |
| 100 | * updates the project in place. |
| 101 | * |
| 102 | * **Example:** Explicit name |
| 103 | * ```typescript |
| 104 | * const site = yield* Railway.Project("Site", { |
| 105 | * name: "my-site", |
| 106 | * }); |
| 107 | * ``` |
| 108 | * |
| 109 | * ### Description |
| 110 | * `description` is optional and updates in place. |
| 111 | * |
| 112 | * **Example:** With description |
| 113 | * ```typescript |
| 114 | * const site = yield* Railway.Project("Site", { |
| 115 | * description: "production web app", |
| 116 | * }); |
| 117 | * ``` |
| 118 | * |
| 119 | * ### Workspace |
| 120 | * Workspace defaults to the current token. Pass `workspaceId` to pin it. |
| 121 | * |
| 122 | * **Example:** Pin a workspace |
| 123 | * ```typescript |
| 124 | * const site = yield* Railway.Project("Site", { |
| 125 | * workspaceId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", |
| 126 | * }); |
| 127 | * ``` |
| 128 | * |
| 129 | * :::caution[Changing `workspaceId` replaces the Project] |
| 130 | * The Project is created in the new workspace. The old Project is deleted. |
| 131 | * ::: |
| 132 | * |
| 133 | * ### Default environment name |
| 134 | * `defaultEnvironmentName` is create-only. Railway's default is |
| 135 | * `production`. Extra environments (staging) are a separate Environment |
| 136 | * resource. |
| 137 | * |
| 138 | * **Example:** Custom default environment |
| 139 | * ```typescript |
| 140 | * const site = yield* Railway.Project("Site", { |
| 141 | * defaultEnvironmentName: "production", |
| 142 | * }); |
| 143 | * ``` |
| 144 | * |
| 145 | * ### Module-scope declarations |
| 146 | * Declare the Project once. Pass it into every child. Resource-valued |
| 147 | * props accept the resource or an Effect producing it. |
| 148 | * |
| 149 | * **Example:** Module-scope Project |
| 150 | * ```typescript |
| 151 | * // src/project.ts |
| 152 | * import * as Railway from "alchemy/Railway"; |
| 153 | * |
| 154 | * export const Site = Railway.Project("Site"); |
| 155 | * ``` |
| 156 | * |
| 157 | * @resource |
| 158 | * @product Project |
| 159 | */ |
| 160 | export const Project = Resource<Project>("Railway.Project"); |
| 161 | |
| 162 | export class ProjectNotCreated extends Data.TaggedError( |
| 163 | "Railway.ProjectNotCreated", |
| 164 | )<{ |
| 165 | name: string; |
| 166 | }> {} |
| 167 | |
| 168 | /** Workspace id from a get-by-id or list-node Project payload. */ |
| 169 | export const workspaceIdOf = ( |
| 170 | project: { |
| 171 | readonly workspaceId?: string | null; |
| 172 | readonly workspace?: { readonly id?: string | null } | null; |
| 173 | }, |
| 174 | fallback?: string, |
| 175 | ): string => project.workspaceId ?? project.workspace?.id ?? fallback ?? ""; |
| 176 | |
| 177 | /** Primary environment id from a get-by-id or list-node Project payload. */ |
| 178 | export const environmentIdOf = (project: { |
| 179 | readonly primaryEnvironmentId?: string | null; |
| 180 | readonly baseEnvironmentId?: string | null; |
| 181 | readonly baseEnvironment?: { readonly id?: string | null } | null; |
| 182 | }): string => |
| 183 | project.primaryEnvironmentId ?? |
| 184 | project.baseEnvironmentId ?? |
| 185 | project.baseEnvironment?.id ?? |
| 186 | ""; |
| 187 | |
| 188 | const toAttrs = ( |
| 189 | project: CloudProject, |
| 190 | fallback?: { name?: string; workspaceId?: string; environmentId?: string }, |
| 191 | ): Project["Attributes"] => { |
| 192 | const name = project.name || fallback?.name || ""; |
| 193 | return { |
| 194 | projectId: project.id, |
| 195 | name, |
| 196 | workspaceId: workspaceIdOf(project, fallback?.workspaceId), |
| 197 | environmentId: environmentIdOf(project) || fallback?.environmentId || "", |
| 198 | url: `https://railway.com/project/${project.id}`, |
| 199 | }; |
| 200 | }; |
| 201 | |
| 202 | const liveEnvironments = (projectId: string, first: number) => |
| 203 | Query.items( |
| 204 | Railway.environments({ projectId, first }).pipe( |
| 205 | Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })), |
| 206 | ), |
| 207 | ); |
| 208 | |
| 209 | const fillEnvironmentId = (attrs: Project["Attributes"]) => { |
| 210 | if (attrs.environmentId.length > 0) return Effect.succeed(attrs); |
| 211 | return liveEnvironments(attrs.projectId, 5).pipe( |
| 212 | Stream.filter((env) => env.deletedAt == null), |
| 213 | Stream.take(1), |
| 214 | Stream.runHead, |
| 215 | Effect.map((option) => |
| 216 | option._tag === "Some" |
| 217 | ? { ...attrs, environmentId: option.value.id } |
| 218 | : attrs, |
| 219 | ), |
| 220 | Effect.catchTag("RailwayNotFound", () => Effect.succeed(attrs)), |
| 221 | ); |
| 222 | }; |
| 223 | |
| 224 | const resolveName = (id: string, name: string | undefined, existing?: string) => |
| 225 | Effect.gen(function* () { |
| 226 | if (name !== undefined) return sanitizeRailwayName(name); |
| 227 | if (existing !== undefined) return existing; |
| 228 | return yield* createRailwayName(id); |
| 229 | }); |
| 230 | |
| 231 | const isGone = (project: CloudProject | undefined) => |
| 232 | project === undefined || project.deletedAt != null; |
| 233 | |
| 234 | const readProject = Query.fn((id: string) => |
| 235 | projectFields(Railway.project({ id })), |
| 236 | ); |
| 237 | |
| 238 | const getById = (projectId: string) => |
| 239 | readProject(projectId).pipe( |
| 240 | Effect.map((project) => (isGone(project) ? undefined : project)), |
| 241 | Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)), |
| 242 | ); |
| 243 | |
| 244 | const currentWorkspaceId = Effect.fn(function* () { |
| 245 | const env = yield* yield* RailwayEnvironment; |
| 246 | return env.workspaceId; |
| 247 | }); |
| 248 | |
| 249 | const projectCreate = Query.fn( |
| 250 | (input: { |
| 251 | name: string; |
| 252 | workspaceId: string; |
| 253 | description?: string; |
| 254 | defaultEnvironmentName?: string; |
| 255 | }) => projectFields(Railway.projectCreate({ input })), |
| 256 | ); |
| 257 | |
| 258 | export const createProject = (input: { |
| 259 | name: string; |
| 260 | workspaceId: string; |
| 261 | description?: string; |
| 262 | defaultEnvironmentName?: string; |
| 263 | }) => |
| 264 | waitOutCreateRateLimit( |
| 265 | projectCreate({ |
| 266 | name: input.name, |
| 267 | workspaceId: input.workspaceId, |
| 268 | ...(input.description !== undefined |
| 269 | ? { description: input.description } |
| 270 | : {}), |
| 271 | ...(input.defaultEnvironmentName !== undefined |
| 272 | ? { defaultEnvironmentName: input.defaultEnvironmentName } |
| 273 | : {}), |
| 274 | }), |
| 275 | ); |
| 276 | |
| 277 | const projectUpdate = Query.fn( |
| 278 | (id: string, input: { name?: string; description?: string }) => |
| 279 | projectFields(Railway.projectUpdate({ id, input })), |
| 280 | ); |
| 281 | |
| 282 | const projectDelete = Query.fn((id: string) => Railway.projectDelete({ id })); |
| 283 | |
| 284 | const workspaceProjects = (workspaceId: string) => |
| 285 | Query.items( |
| 286 | Railway.projects({ workspaceId, first: 50, includeDeleted: false }).pipe( |
| 287 | Query.map(projectFields), |
| 288 | ), |
| 289 | ); |
| 290 | |
| 291 | const findByName = (workspaceId: string, name: string) => |
| 292 | workspaceProjects(workspaceId).pipe( |
| 293 | Stream.filter((project) => !isGone(project) && project.name === name), |
| 294 | Stream.take(1), |
| 295 | Stream.runHead, |
| 296 | Effect.map((option) => (option._tag === "Some" ? option.value : undefined)), |
| 297 | ); |
| 298 | |
| 299 | /** |
| 300 | * Workspace projects Alchemy owns. Distilled `projects.items` plus the |
| 301 | * physical-name filter — Railway has no tags. No lock, no retry override. |
| 302 | */ |
| 303 | export const ownedProjects = Effect.fn(function* () { |
| 304 | const workspaceId = yield* currentWorkspaceId(); |
| 305 | const projects = yield* workspaceProjects(workspaceId).pipe( |
| 306 | Stream.runCollect, |
| 307 | ); |
| 308 | return Array.from(projects) |
| 309 | .filter( |
| 310 | (project) => !isGone(project) && matchesAlchemyPhysicalName(project.name), |
| 311 | ) |
| 312 | .map((project) => toAttrs(project, { workspaceId })); |
| 313 | }); |
| 314 | |
| 315 | export const listOwnedProjects = ownedProjects; |
| 316 | |
| 317 | /** |
| 318 | * Live environment ids under a project, including the stamped primary. |
| 319 | * `list()` walks these so partition environments are not invisible. |
| 320 | */ |
| 321 | export const projectEnvironmentIds = (project: { |
| 322 | projectId: string; |
| 323 | environmentId: string; |
| 324 | }) => |
| 325 | liveEnvironments(project.projectId, 50).pipe( |
| 326 | Stream.filter((env) => env.deletedAt == null), |
| 327 | Stream.map((env) => env.id), |
| 328 | Stream.runCollect, |
| 329 | Effect.map((ids) => { |
| 330 | const set = new Set(Array.from(ids)); |
| 331 | if (project.environmentId.length > 0) { |
| 332 | set.add(project.environmentId); |
| 333 | } |
| 334 | return Array.from(set); |
| 335 | }), |
| 336 | Effect.catchTag("RailwayNotFound", () => |
| 337 | Effect.succeed( |
| 338 | project.environmentId.length > 0 ? [project.environmentId] : [], |
| 339 | ), |
| 340 | ), |
| 341 | ); |
| 342 | |
| 343 | export const ProjectProvider = () => |
| 344 | Provider.succeed(Project, { |
| 345 | stables: ["projectId", "workspaceId", "environmentId"], |
| 346 | |
| 347 | diff: Effect.fn(function* ({ news, output }) { |
| 348 | if (news === undefined || !isResolved(news)) return undefined; |
| 349 | if (output === undefined) return undefined; |
| 350 | const workspaceChanged = |
| 351 | news.workspaceId !== undefined && |
| 352 | news.workspaceId !== output.workspaceId; |
| 353 | if (workspaceChanged) { |
| 354 | return { action: "replace" as const }; |
| 355 | } |
| 356 | return undefined; |
| 357 | }), |
| 358 | |
| 359 | read: Effect.fn(function* ({ id, olds, output }) { |
| 360 | const name = yield* resolveName(id, olds?.name, output?.name); |
| 361 | const workspaceId = |
| 362 | output?.workspaceId ?? |
| 363 | olds?.workspaceId ?? |
| 364 | (yield* currentWorkspaceId()); |
| 365 | const found = |
| 366 | (output?.projectId !== undefined |
| 367 | ? yield* getById(output.projectId) |
| 368 | : undefined) ?? (yield* findByName(workspaceId, name)); |
| 369 | if (found === undefined) return undefined; |
| 370 | const attrs = yield* fillEnvironmentId( |
| 371 | toAttrs(found, { |
| 372 | name, |
| 373 | workspaceId, |
| 374 | environmentId: output?.environmentId, |
| 375 | }), |
| 376 | ); |
| 377 | if (output !== undefined) return attrs; |
| 378 | return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs); |
| 379 | }), |
| 380 | |
| 381 | list: listOwnedProjects, |
| 382 | |
| 383 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 384 | const props = news ?? {}; |
| 385 | const name = yield* resolveName(id, props.name, output?.name); |
| 386 | const workspaceId = |
| 387 | props.workspaceId ?? |
| 388 | output?.workspaceId ?? |
| 389 | (yield* currentWorkspaceId()); |
| 390 | |
| 391 | let current: CloudProject | undefined = |
| 392 | output?.projectId !== undefined |
| 393 | ? yield* getById(output.projectId) |
| 394 | : undefined; |
| 395 | if (current === undefined) { |
| 396 | current = yield* findByName(workspaceId, name); |
| 397 | } |
| 398 | |
| 399 | if (current === undefined) { |
| 400 | const created = yield* createProject({ |
| 401 | name, |
| 402 | workspaceId, |
| 403 | ...(props.description !== undefined |
| 404 | ? { description: props.description } |
| 405 | : {}), |
| 406 | ...(props.defaultEnvironmentName !== undefined |
| 407 | ? { defaultEnvironmentName: props.defaultEnvironmentName } |
| 408 | : {}), |
| 409 | }).pipe( |
| 410 | Effect.catchTag("RailwayValidationError", () => |
| 411 | Effect.succeed(undefined), |
| 412 | ), |
| 413 | ); |
| 414 | current = created ?? (yield* findByName(workspaceId, name)); |
| 415 | } |
| 416 | |
| 417 | if (current === undefined || isGone(current)) { |
| 418 | return yield* new ProjectNotCreated({ name }); |
| 419 | } |
| 420 | |
| 421 | const nameChanged = current.name !== name; |
| 422 | const observedDescription = current.description ?? undefined; |
| 423 | const descriptionChanged = |
| 424 | props.description !== undefined && |
| 425 | props.description !== observedDescription; |
| 426 | if (nameChanged || descriptionChanged) { |
| 427 | current = yield* projectUpdate(current.id, { |
| 428 | ...(nameChanged ? { name } : {}), |
| 429 | ...(descriptionChanged ? { description: props.description } : {}), |
| 430 | }); |
| 431 | } |
| 432 | |
| 433 | return yield* fillEnvironmentId( |
| 434 | toAttrs(current, { |
| 435 | name, |
| 436 | workspaceId, |
| 437 | environmentId: output?.environmentId, |
| 438 | }), |
| 439 | ); |
| 440 | }), |
| 441 | |
| 442 | delete: Effect.fn(function* ({ output }) { |
| 443 | const projectId = output.projectId; |
| 444 | if (projectId.length === 0) return; |
| 445 | yield* projectDelete(projectId).pipe( |
| 446 | Effect.catchTag("RailwayNotFound", () => Effect.void), |
| 447 | ); |
| 448 | yield* waitUntilDeleted( |
| 449 | "Project", |
| 450 | projectId, |
| 451 | getById(projectId).pipe(Effect.map((project) => project === undefined)), |
| 452 | ); |
| 453 | }), |
| 454 | }); |