1import {
2 waitUntilDeleted,
3 projectServices as fetchProjectServices,
4 projectBuckets as fetchProjectBuckets,
5 projectGroups as fetchProjectGroups,
6 environmentVolumes,
7} from "./GraphQL.ts";
8import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
9import {
10 Railway,
11 type Bucket as RailwayBucket,
12 type Group as RailwayGroup,
13 type Service as RailwayService,
14 type VolumeInstance as RailwayVolumeInstance,
15} from "@distilled.cloud/railway";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import * as Schedule from "effect/Schedule";
19import * as Stream from "effect/Stream";
20import { Unowned } from "../AdoptPolicy.ts";
21import { isResolved } from "../Diff.ts";
22import * as Provider from "../Provider.ts";
23import { Resource } from "../Resource.ts";
24import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
25import { ownedProjects, type Project } from "./Project.ts";
26import type { Providers } from "./Providers.ts";
27import { withEnvironmentConfigLock } from "./transient.ts";
28
29const groupSelection = (group: Query<RailwayGroup>) => ({
30 id: group.id,
31 name: group.name,
32 color: group.color,
33 icon: group.icon,
34 isCollapsed: group.isCollapsed,
35});
36const serviceSelection = (service: Query<RailwayService>) => ({
37 id: service.id,
38 groupId: service.groupId,
39 deletedAt: service.deletedAt,
40});
41const bucketSelection = (bucket: Query<RailwayBucket>) => ({
42 id: bucket.id,
43 groupId: bucket.groupId,
44});
45const volumeSelection = (volume: Query<RailwayVolumeInstance>) => ({
46 id: volume.id,
47 volumeId: volume.volumeId,
48 deletedAt: volume.deletedAt,
49 state: volume.state,
50 volume: { id: volume.volume.id },
51});
52type ProjectResponseBucketsEdgesItemNode = UnwrapPlan<
53 ReturnType<typeof bucketSelection>
54>;
55type ProjectResponseGroupsEdgesItemNode = UnwrapPlan<
56 ReturnType<typeof groupSelection>
57>;
58type ProjectResponseServicesEdgesItemNode = UnwrapPlan<
59 ReturnType<typeof serviceSelection>
60>;
61type EnvironmentVolumeNode = UnwrapPlan<ReturnType<typeof volumeSelection>>;
62type ProjectResponse = {
63 readonly deletedAt: string | null;
64 readonly services: readonly ProjectResponseServicesEdgesItemNode[];
65 readonly buckets: readonly ProjectResponseBucketsEdgesItemNode[];
66 readonly groups: readonly ProjectResponseGroupsEdgesItemNode[];
67};
68type EnvironmentResponse = {
69 readonly deletedAt: string | null;
70 readonly config: unknown;
71 readonly canvasGroupRefs: unknown;
72 readonly volumeInstances: readonly EnvironmentVolumeNode[];
73};
74
75const readProjectDeletedAt = Query.fn((id: string) => ({
76 deletedAt: Railway.project({ id }).deletedAt,
77}));
78
79const readEnvironment = Query.fn((id: string, projectId: string) => {
80 const environment = Railway.environment({ id, projectId });
81 return {
82 deletedAt: environment.deletedAt,
83 config: environment.config,
84 canvasGroupRefs: environment.canvasGroupRefs,
85 };
86});
87
88const environmentPatchCommit = Query.fn(
89 (input: { environmentId: string; commitMessage: string; patch: unknown }) =>
90 Railway.environmentPatchCommit(input),
91);
92
93const canvasViewMergePreview = Query.fn(
94 (sourceEnvironmentId: string, targetEnvironmentId: string) => ({
95 mutations: Railway.canvasViewMergePreview({
96 sourceEnvironmentId,
97 targetEnvironmentId,
98 }).mutations,
99 }),
100);
101
102const canvasViewMerge = Query.fn(
103 (sourceEnvironmentId: string, targetEnvironmentId: string) =>
104 Railway.canvasViewMerge({ sourceEnvironmentId, targetEnvironmentId }),
105);
106
107/**
108 * A resource-valued prop: the resource itself, or an Effect that produces
109 * it (so `yield* Project(...)` and `Project(...)` both type-check).
110 */
111type Ref<T> = T | Effect.Effect<T, never, Providers>;
112
113/**
114 * Environment identity a Group is drawn on. Accepts a `Railway.Project`
115 * (its primary environment), a `Railway.Environment`, or an
116 * `{ environmentId }` stub.
117 */
118export type GroupEnvironment = {
119 readonly environmentId: string;
120};
121
122/**
123 * A canvas-group member. Accepts a `Railway.Service` / Postgres / Redis /
124 * MySQL / Mongo (`serviceId`), a `Railway.Volume` (`volumeId`), a
125 * `Railway.Bucket` (`bucketId`), or a stub with those ids.
126 */
127export type GroupMember = {
128 readonly serviceId?: string;
129 readonly volumeId?: string;
130 readonly bucketId?: string;
131};
132
133export interface GroupProps {
134 /**
135 * Parent Railway Project. Changing it replaces the Group.
136 */
137 project: Ref<Project>;
138 /**
139 * Environment whose canvas this group lives on. Accepts a
140 * `Railway.Project` (primary environment), a `Railway.Environment`, or
141 * `{ environmentId }`. Defaults to the project's primary environment.
142 * Changing it replaces the Group.
143 */
144 environment?: Ref<GroupEnvironment>;
145 /**
146 * Canvas group name. If omitted, a unique name is generated from the
147 * stack, stage and logical ID. Updates in place.
148 */
149 name?: string;
150 /**
151 * Members to place in the group — services, databases, volumes, and
152 * buckets. Service membership is persisted as `groupId`; volume ids
153 * are observed from `canvasGroupRefs`; bucket ids from `Bucket.groupId`.
154 */
155 resources: ReadonlyArray<Ref<GroupMember>>;
156 /**
157 * Optional canvas color. Updates in place.
158 */
159 color?: string;
160 /**
161 * Optional canvas icon. Updates in place.
162 */
163 icon?: string;
164 /**
165 * Whether the group is collapsed on the canvas.
166 *
167 * @default false
168 */
169 collapsed?: boolean;
170}
171
172export type Group = Resource<
173 "Railway.Group",
174 GroupProps,
175 {
176 /** Railway group id (EnvironmentConfig key / Group.id). */
177 groupId: string;
178 /** Parent Railway project id. */
179 projectId: string;
180 /** Environment whose canvas this group is on. */
181 environmentId: string;
182 /** Canvas group name. */
183 name: string;
184 /** Canvas color, if set. */
185 color: string | undefined;
186 /** Canvas icon, if set. */
187 icon: string | undefined;
188 /** Whether the group is collapsed on the canvas. */
189 collapsed: boolean;
190 /** Member service ids (`Service.groupId` / Postgres / Redis / …). */
191 serviceIds: string[];
192 /** Member volume ids. */
193 volumeIds: string[];
194 /** Member bucket ids (`Bucket.groupId`). */
195 bucketIds: string[];
196 },
197 never,
198 Providers
199>;
200
201const resolveGroupProps = (
202 props: GroupProps | Effect.Effect<GroupProps, never, Providers>,
203): Effect.Effect<GroupProps, never, Providers> =>
204 Effect.gen(function* () {
205 const resolved = Effect.isEffect(props) ? yield* props : props;
206 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
207 const project = Effect.isEffect(resolved.project)
208 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
209 : resolved.project;
210 const environment =
211 resolved.environment === undefined
212 ? undefined
213 : Effect.isEffect(resolved.environment)
214 ? yield* resolved.environment as Effect.Effect<
215 GroupEnvironment,
216 never,
217 Providers
218 >
219 : resolved.environment;
220 const resources = yield* Effect.forEach(resolved.resources, (item) =>
221 Effect.isEffect(item)
222 ? (item as Effect.Effect<GroupMember, never, Providers>)
223 : Effect.succeed(item),
224 );
225 return { ...resolved, project, environment, resources };
226 });
227
228const GroupResource = Resource<Group>("Railway.Group");
229
230/**
231 * A Railway.Group organizes services, databases, volumes, and buckets on
232 * the project canvas. IaC parity with `group("Backend", [api, worker, db])`.
233 *
234 * Groups have no dedicated create/delete mutation. Alchemy writes
235 * `EnvironmentConfig.groups` (and `services[id].groupId`) via
236 * `environmentPatchCommit`, matching Railway's own IaC compiler. Volume
237 * membership is observed from `environment.canvasGroupRefs`. When Railway
238 * does not persist a first-class Group id, the resource still records
239 * member service/volume/bucket ids. `canvasViewMerge` /
240 * `canvasViewMergePreview` copy canvas layout between environments.
241 *
242 * @see https://docs.railway.com/infrastructure-as-code/reference
243 *
244 * ### Create a Group
245 * Pass a Project and the members to group. Alchemy generates a unique
246 * name unless you pass one.
247 *
248 * **Example:** Group two services
249 * ```typescript
250 * const site = yield* Railway.Project("Site");
251 * const api = yield* Railway.Service("Api", {
252 * project: site,
253 * image: "hashicorp/http-echo",
254 * });
255 * const worker = yield* Railway.Service("Worker", {
256 * project: site,
257 * image: "hashicorp/http-echo",
258 * });
259 * const backend = yield* Railway.Group("Backend", {
260 * project: site,
261 * resources: [api, worker],
262 * });
263 * ```
264 *
265 * :::caution[Changing `project` replaces the Group]
266 * The Group is created in the new Project. The old Group is deleted.
267 * :::
268 *
269 * ### Environment
270 * Defaults to the Project's primary environment. Pass a
271 * `Railway.Environment` (or `{ environmentId }`) to target another one.
272 *
273 * **Example:** Extra environment
274 * ```typescript
275 * const staging = yield* Railway.Environment("Staging", { project: site });
276 * const backend = yield* Railway.Group("Backend", {
277 * project: site,
278 * environment: staging,
279 * resources: [api],
280 * });
281 * ```
282 *
283 * :::caution[Changing `environment` replaces the Group]
284 * The Group is created on the new environment's canvas. The old Group is
285 * deleted.
286 * :::
287 *
288 * ### Databases, volumes, and buckets
289 * Postgres/Redis/MySQL/Mongo group as services (`serviceId`). Volumes and
290 * buckets are accepted as members (`volumeId` / `bucketId`).
291 *
292 * **Example:** Group a database and API
293 * ```typescript
294 * const db = yield* Railway.Postgres("Db", { project: site });
295 * const backend = yield* Railway.Group("Backend", {
296 * project: site,
297 * resources: [api, db],
298 * });
299 * ```
300 *
301 * ### Module-scope declarations
302 * Declare the Project once. Pass it into every child. Resource-valued
303 * props accept the resource or an Effect producing it.
304 *
305 * **Example:** Module-scope Group
306 * ```typescript
307 * // src/backend.ts
308 * import * as Railway from "alchemy/Railway";
309 *
310 * export const Site = Railway.Project("Site");
311 * export const Api = Railway.Service("Api", {
312 * project: Site,
313 * image: "hashicorp/http-echo",
314 * });
315 * export const Backend = Railway.Group("Backend", {
316 * project: Site,
317 * resources: [Api],
318 * });
319 * ```
320 *
321 * @resource
322 * @product Project
323 */
324export const Group: typeof GroupResource = Object.assign(
325 (
326 id: string,
327 props: GroupProps | Effect.Effect<GroupProps, never, Providers>,
328 ) => GroupResource(id, resolveGroupProps(props)),
329 GroupResource,
330);
331
332export class GroupProjectRequired extends Data.TaggedError(
333 "Railway.GroupProjectRequired",
334)<{
335 message: string;
336}> {}
337
338export class GroupEnvironmentRequired extends Data.TaggedError(
339 "Railway.GroupEnvironmentRequired",
340)<{
341 message: string;
342}> {}
343
344class GroupPending extends Data.TaggedError("Railway.GroupPending")<{
345 groupId: string;
346 state: string;
347}> {}
348
349type GroupConfig = {
350 name?: string | null;
351 color?: string | null;
352 icon?: string | null;
353 isCollapsed?: boolean | null;
354 isDeleted?: boolean | null;
355 isCreated?: boolean | null;
356 groupId?: string | null;
357};
358
359type ServiceConfig = {
360 groupId?: string | null;
361 isDeleted?: boolean | null;
362};
363
364type BucketConfig = {
365 groupId?: string | null;
366 isDeleted?: boolean | null;
367};
368
369type EnvironmentConfigShape = {
370 groups?: Record<string, GroupConfig | null> | null;
371 services?: Record<string, ServiceConfig | null> | null;
372 buckets?: Record<string, BucketConfig | null> | null;
373};
374
375type CloudGroup = {
376 groupId: string;
377 name: string;
378 color: string | undefined;
379 icon: string | undefined;
380 collapsed: boolean;
381 serviceIds: string[];
382 volumeIds: string[];
383 bucketIds: string[];
384};
385
386const projectIdOf = (value: unknown): string | undefined => {
387 if (value === null || typeof value !== "object") return undefined;
388 const rec = value as { projectId?: unknown };
389 return typeof rec.projectId === "string" && rec.projectId.length > 0
390 ? rec.projectId
391 : undefined;
392};
393
394const environmentIdOf = (value: unknown): string | undefined => {
395 if (value === null || typeof value !== "object") return undefined;
396 const rec = value as { environmentId?: unknown };
397 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
398 ? rec.environmentId
399 : undefined;
400};
401
402const uniqueSorted = (ids: readonly string[]): string[] =>
403 Array.from(new Set(ids.filter((id) => id.length > 0))).sort();
404
405const sameIds = (left: readonly string[], right: readonly string[]) =>
406 uniqueSorted(left).join("\0") === uniqueSorted(right).join("\0");
407
408const classifyMember = (
409 member: GroupMember | null | undefined,
410):
411 | { kind: "service"; id: string }
412 | { kind: "volume"; id: string }
413 | { kind: "bucket"; id: string }
414 | undefined => {
415 if (member == null) return undefined;
416 if (typeof member.serviceId === "string" && member.serviceId.length > 0) {
417 return { kind: "service", id: member.serviceId };
418 }
419 if (typeof member.bucketId === "string" && member.bucketId.length > 0) {
420 return { kind: "bucket", id: member.bucketId };
421 }
422 if (typeof member.volumeId === "string" && member.volumeId.length > 0) {
423 return { kind: "volume", id: member.volumeId };
424 }
425 return undefined;
426};
427
428const membersOf = (resources: readonly GroupMember[] | undefined) => {
429 const serviceIds: string[] = [];
430 const volumeIds: string[] = [];
431 const bucketIds: string[] = [];
432 for (const resource of resources ?? []) {
433 const classified = classifyMember(resource);
434 if (classified === undefined) continue;
435 if (classified.kind === "service") serviceIds.push(classified.id);
436 else if (classified.kind === "volume") volumeIds.push(classified.id);
437 else bucketIds.push(classified.id);
438 }
439 return {
440 serviceIds: uniqueSorted(serviceIds),
441 volumeIds: uniqueSorted(volumeIds),
442 bucketIds: uniqueSorted(bucketIds),
443 };
444};
445
446const parseRecord = <T>(
447 value: unknown,
448): Record<string, T | null> | undefined =>
449 value !== null && typeof value === "object" && !Array.isArray(value)
450 ? (value as Record<string, T | null>)
451 : undefined;
452
453const parseEnvironmentConfig = (value: unknown): EnvironmentConfigShape => {
454 if (value === null || typeof value !== "object" || Array.isArray(value)) {
455 return {};
456 }
457 const rec = value as {
458 groups?: unknown;
459 services?: unknown;
460 buckets?: unknown;
461 };
462 return {
463 groups: parseRecord<GroupConfig>(rec.groups),
464 services: parseRecord<ServiceConfig>(rec.services),
465 buckets: parseRecord<BucketConfig>(rec.buckets),
466 };
467};
468
469/**
470 * Railway stores canvas grouping as `{ [resourceId]: groupId }`
471 * (`environment.canvasGroupRefs[volume.id]` in railway/iac).
472 */
473const parseCanvasGroupRefs = (value: unknown): Record<string, string> => {
474 if (value === null || typeof value !== "object" || Array.isArray(value)) {
475 return {};
476 }
477 const out: Record<string, string> = {};
478 for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
479 if (typeof item === "string" && item.length > 0) {
480 out[key] = item;
481 continue;
482 }
483 if (item !== null && typeof item === "object" && !Array.isArray(item)) {
484 const rec = item as { groupId?: unknown };
485 if (typeof rec.groupId === "string" && rec.groupId.length > 0) {
486 out[key] = rec.groupId;
487 }
488 }
489 }
490 return out;
491};
492
493const idsForGroup = (
494 refs: Record<string, string>,
495 groupId: string,
496): string[] => {
497 const ids: string[] = [];
498 for (const [resourceId, assigned] of Object.entries(refs)) {
499 if (assigned === groupId) ids.push(resourceId);
500 }
501 return uniqueSorted(ids);
502};
503
504const groupRow = (
505 config: EnvironmentConfigShape,
506 groupId: string,
507): GroupConfig | undefined => {
508 const row = config.groups?.[groupId];
509 if (row === undefined || row === null || row.isDeleted === true) {
510 return undefined;
511 }
512 return row;
513};
514
515const servicesForGroup = (
516 config: EnvironmentConfigShape,
517 groupId: string,
518): string[] => {
519 const services = config.services;
520 if (services === undefined || services === null) return [];
521 const ids: string[] = [];
522 for (const [serviceId, row] of Object.entries(services)) {
523 if (row === null || row.isDeleted === true) continue;
524 if (row.groupId === groupId) ids.push(serviceId);
525 }
526 return uniqueSorted(ids);
527};
528
529const bucketsForGroup = (
530 config: EnvironmentConfigShape,
531 groupId: string,
532): string[] => {
533 const buckets = config.buckets;
534 if (buckets === undefined || buckets === null) return [];
535 const ids: string[] = [];
536 for (const [bucketId, row] of Object.entries(buckets)) {
537 if (row === null || row.isDeleted === true) continue;
538 if (row.groupId === groupId) ids.push(bucketId);
539 }
540 return uniqueSorted(ids);
541};
542
543const liveServicesForGroup = (
544 services: readonly ProjectResponseServicesEdgesItemNode[],
545 groupId: string,
546): string[] =>
547 uniqueSorted(
548 services
549 .filter(
550 (service) => service.deletedAt == null && service.groupId === groupId,
551 )
552 .map((service) => service.id),
553 );
554
555const liveBucketsForGroup = (
556 buckets: readonly ProjectResponseBucketsEdgesItemNode[],
557 groupId: string,
558): string[] =>
559 uniqueSorted(
560 buckets
561 .filter((bucket) => bucket.groupId === groupId)
562 .map((bucket) => bucket.id),
563 );
564
565const optionalString = (value: string | null | undefined) =>
566 value !== null && value !== undefined && value.length > 0 ? value : undefined;
567
568const volumeIdsOf = (env: EnvironmentResponse | undefined): string[] => {
569 if (env === undefined) return [];
570 const ids: string[] = [];
571 for (const node of env.volumeInstances) {
572 if (node.deletedAt != null || node.state === "DELETED") continue;
573 ids.push(node.volumeId, node.volume.id, node.id);
574 }
575 return uniqueSorted(ids);
576};
577
578const toAttrs = (
579 group: CloudGroup,
580 extra: { projectId: string; environmentId: string },
581): Group["Attributes"] => ({
582 groupId: group.groupId,
583 projectId: extra.projectId,
584 environmentId: extra.environmentId,
585 name: group.name,
586 color: group.color,
587 icon: group.icon,
588 collapsed: group.collapsed,
589 serviceIds: group.serviceIds,
590 volumeIds: group.volumeIds,
591 bucketIds: group.bucketIds,
592});
593
594const resolveName = (id: string, name: string | undefined, existing?: string) =>
595 Effect.gen(function* () {
596 if (name !== undefined && name.trim().length > 0) return name.trim();
597 if (existing !== undefined && existing.length > 0) return existing;
598 return yield* createRailwayName(id);
599 });
600
601const getProject = (projectId: string) =>
602 Effect.gen(function* () {
603 const project = yield* readProjectDeletedAt(projectId);
604 const services = yield* fetchProjectServices(projectId, serviceSelection);
605 const buckets = yield* fetchProjectBuckets(projectId, bucketSelection);
606 const groups = yield* fetchProjectGroups(projectId, groupSelection);
607 return {
608 deletedAt: project.deletedAt,
609 services,
610 buckets,
611 groups,
612 } satisfies ProjectResponse;
613 }).pipe(
614 Effect.map((project) => (project.deletedAt != null ? undefined : project)),
615 Effect.catchTag("RailwayNotFound", () =>
616 Effect.succeed(undefined as ProjectResponse | undefined),
617 ),
618 );
619
620const getEnvironment = (environmentId: string, projectId: string) =>
621 Effect.gen(function* () {
622 const environment = yield* readEnvironment(environmentId, projectId);
623 const volumes = yield* environmentVolumes(
624 environmentId,
625 projectId,
626 volumeSelection,
627 );
628 return {
629 ...environment,
630 volumeInstances: volumes,
631 } satisfies EnvironmentResponse;
632 }).pipe(Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)));
633
634const getEnvironmentConfig = (environmentId: string, projectId: string) =>
635 getEnvironment(environmentId, projectId).pipe(
636 Effect.map((env) =>
637 env === undefined
638 ? ({} as EnvironmentConfigShape)
639 : parseEnvironmentConfig(env.config),
640 ),
641 );
642
643const listEnvironmentIds = (project: {
644 projectId: string;
645 environmentId: string;
646}) =>
647 Query.items(
648 Railway.environments({ projectId: project.projectId, first: 50 }).pipe(
649 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
650 ),
651 ).pipe(
652 Stream.filter((env) => env.deletedAt == null),
653 Stream.map((env) => env.id),
654 Stream.runCollect,
655 Effect.map((ids) => {
656 const set = new Set(Array.from(ids));
657 if (project.environmentId.length > 0) {
658 set.add(project.environmentId);
659 }
660 return Array.from(set);
661 }),
662 Effect.catchTag("RailwayNotFound", () =>
663 Effect.succeed(
664 project.environmentId.length > 0 ? [project.environmentId] : [],
665 ),
666 ),
667 );
668
669const commitPatch = (input: {
670 environmentId: string;
671 commitMessage: string;
672 patch: Record<string, unknown>;
673}) =>
674 withEnvironmentConfigLock(
675 input.environmentId,
676 environmentPatchCommit({
677 environmentId: input.environmentId,
678 commitMessage: input.commitMessage,
679 patch: input.patch,
680 }),
681 ).pipe(
682 Effect.catchTag(["RailwayValidationError", "RailwayInternalError"], () =>
683 Effect.succeed(""),
684 ),
685 );
686
687const previewCanvas = (environmentId: string) =>
688 canvasViewMergePreview(environmentId, environmentId).pipe(
689 Effect.catchTag(
690 ["RailwayNotFound", "RailwayValidationError", "RailwayInternalError"],
691 () => Effect.succeed(undefined),
692 ),
693 );
694
695const mergeCanvas = (
696 sourceEnvironmentId: string,
697 targetEnvironmentId: string,
698) =>
699 canvasViewMerge(sourceEnvironmentId, targetEnvironmentId).pipe(
700 Effect.catchTag(
701 ["RailwayNotFound", "RailwayValidationError", "RailwayInternalError"],
702 () => Effect.succeed(false),
703 ),
704 );
705
706const projectGroups = (project: ProjectResponse | undefined) =>
707 project?.groups ?? [];
708
709const projectServices = (project: ProjectResponse | undefined) =>
710 project?.services ?? [];
711
712const projectBuckets = (project: ProjectResponse | undefined) =>
713 project?.buckets ?? [];
714
715const matchProjectGroup = (
716 groups: readonly ProjectResponseGroupsEdgesItemNode[],
717 match: { groupId?: string; name?: string },
718) => {
719 if (match.groupId !== undefined && match.groupId.length > 0) {
720 const byId = groups.find((group) => group.id === match.groupId);
721 if (byId !== undefined) return byId;
722 }
723 if (match.name !== undefined && match.name.length > 0) {
724 return groups.find((group) => group.name === match.name);
725 }
726 return undefined;
727};
728
729const matchConfigGroup = (
730 config: EnvironmentConfigShape,
731 match: { groupId?: string; name?: string },
732): { groupId: string; row: GroupConfig } | undefined => {
733 const groups = config.groups;
734 if (groups === undefined || groups === null) return undefined;
735 if (match.groupId !== undefined && match.groupId.length > 0) {
736 const row = groupRow(config, match.groupId);
737 if (row !== undefined) return { groupId: match.groupId, row };
738 }
739 if (match.name !== undefined && match.name.length > 0) {
740 for (const [groupId, row] of Object.entries(groups)) {
741 if (row === null || row.isDeleted === true) continue;
742 if (row.name === match.name) return { groupId, row };
743 }
744 }
745 return undefined;
746};
747
748const observe = Effect.fn(function* (input: {
749 projectId: string;
750 environmentId: string;
751 groupId?: string;
752 name?: string;
753 volumeIds?: readonly string[];
754}) {
755 const env = yield* getEnvironment(input.environmentId, input.projectId);
756 if (env === undefined || env.deletedAt != null) return undefined;
757 const config = parseEnvironmentConfig(env.config);
758 const project = yield* getProject(input.projectId);
759 const fromConfig = matchConfigGroup(config, input);
760 const fromProject = matchProjectGroup(projectGroups(project), input);
761 const groupId = fromConfig?.groupId ?? fromProject?.id ?? input.groupId;
762 if (groupId === undefined || groupId.length === 0) return undefined;
763
764 const row = fromConfig?.row;
765 const canvasRefs = parseCanvasGroupRefs(env.canvasGroupRefs);
766 const fromCanvas = idsForGroup(canvasRefs, groupId);
767 const knownServices = projectServices(project);
768 const knownBuckets = projectBuckets(project);
769 const knownVolumeIds = new Set([
770 ...volumeIdsOf(env),
771 ...(input.volumeIds ?? []),
772 ]);
773 const knownServiceIds = new Set(knownServices.map((service) => service.id));
774 const knownBucketIds = new Set(knownBuckets.map((bucket) => bucket.id));
775
776 const serviceIds = uniqueSorted([
777 ...servicesForGroup(config, groupId),
778 ...liveServicesForGroup(knownServices, groupId),
779 ...fromCanvas.filter((id) => knownServiceIds.has(id)),
780 ]);
781 const bucketIds = uniqueSorted([
782 ...bucketsForGroup(config, groupId),
783 ...liveBucketsForGroup(knownBuckets, groupId),
784 ...fromCanvas.filter((id) => knownBucketIds.has(id)),
785 ]);
786 const volumeIds = uniqueSorted([
787 ...(input.volumeIds ?? []).filter((id) => canvasRefs[id] === groupId),
788 ...fromCanvas.filter(
789 (id) =>
790 knownVolumeIds.has(id) ||
791 (!knownServiceIds.has(id) && !knownBucketIds.has(id)),
792 ),
793 ]);
794
795 const name =
796 optionalString(row?.name ?? fromProject?.name) ?? input.name ?? "";
797 if (fromConfig === undefined && fromProject === undefined) {
798 if (
799 serviceIds.length === 0 &&
800 bucketIds.length === 0 &&
801 volumeIds.length === 0
802 ) {
803 return undefined;
804 }
805 }
806
807 return {
808 groupId,
809 name,
810 color: optionalString(row?.color ?? fromProject?.color),
811 icon: optionalString(row?.icon ?? fromProject?.icon),
812 collapsed: (row?.isCollapsed ?? fromProject?.isCollapsed) === true,
813 serviceIds,
814 volumeIds,
815 bucketIds,
816 } satisfies CloudGroup;
817});
818
819const waitUntilPresent = (input: {
820 projectId: string;
821 environmentId: string;
822 groupId: string;
823 name: string;
824 volumeIds?: readonly string[];
825}) =>
826 observe({
827 projectId: input.projectId,
828 environmentId: input.environmentId,
829 groupId: input.groupId,
830 name: input.name,
831 volumeIds: input.volumeIds,
832 }).pipe(
833 Effect.flatMap((group) => {
834 if (group === undefined) {
835 return Effect.fail(
836 new GroupPending({ groupId: input.groupId, state: "creating" }),
837 );
838 }
839 return Effect.succeed(group);
840 }),
841 Effect.retry({
842 while: (e) => e._tag === "Railway.GroupPending",
843 times: 4,
844 schedule: Schedule.spaced("1 second"),
845 }),
846 Effect.catchTag("Railway.GroupPending", () =>
847 observe({
848 projectId: input.projectId,
849 environmentId: input.environmentId,
850 groupId: input.groupId,
851 name: input.name,
852 volumeIds: input.volumeIds,
853 }),
854 ),
855 );
856
857const waitUntilGone = (input: {
858 projectId: string;
859 environmentId: string;
860 groupId: string;
861 name: string;
862}) =>
863 waitUntilDeleted(
864 "Group",
865 input.groupId,
866 observe({
867 projectId: input.projectId,
868 environmentId: input.environmentId,
869 groupId: input.groupId,
870 name: input.name,
871 }).pipe(Effect.map((group) => group === undefined)),
872 4,
873 );
874
875const groupPatch = (input: {
876 groupId: string;
877 name: string;
878 color?: string;
879 icon?: string;
880 collapsed: boolean;
881 create: boolean;
882 remove?: boolean;
883}): Record<string, GroupConfig> => ({
884 [input.groupId]: input.remove
885 ? { isDeleted: true }
886 : {
887 name: input.name,
888 ...(input.color !== undefined ? { color: input.color } : {}),
889 ...(input.icon !== undefined ? { icon: input.icon } : {}),
890 isCollapsed: input.collapsed,
891 ...(input.create ? { isCreated: true } : {}),
892 },
893});
894
895const servicePatch = (
896 assignments: ReadonlyArray<{ serviceId: string; groupId: string | null }>,
897): Record<string, { groupId: string | null }> => {
898 const out: Record<string, { groupId: string | null }> = {};
899 for (const row of assignments) {
900 out[row.serviceId] = { groupId: row.groupId };
901 }
902 return out;
903};
904
905const bucketPatch = (
906 assignments: ReadonlyArray<{ bucketId: string; groupId: string | null }>,
907): Record<string, { groupId: string | null }> => {
908 const out: Record<string, { groupId: string | null }> = {};
909 for (const row of assignments) {
910 out[row.bucketId] = { groupId: row.groupId };
911 }
912 return out;
913};
914
915const membershipPatch = (input: {
916 groupId: string;
917 name: string;
918 color?: string;
919 icon?: string;
920 collapsed: boolean;
921 create: boolean;
922 remove?: boolean;
923 services?: ReadonlyArray<{ serviceId: string; groupId: string | null }>;
924 buckets?: ReadonlyArray<{ bucketId: string; groupId: string | null }>;
925}): Record<string, unknown> => {
926 const patch: Record<string, unknown> = {
927 groups: groupPatch(input),
928 };
929 if (input.services !== undefined && input.services.length > 0) {
930 patch.services = servicePatch(input.services);
931 }
932 if (input.buckets !== undefined && input.buckets.length > 0) {
933 patch.buckets = bucketPatch(input.buckets);
934 }
935 return patch;
936};
937
938const fallbackGroup = (input: {
939 groupId: string;
940 name: string;
941 color?: string;
942 icon?: string;
943 collapsed: boolean;
944 serviceIds: readonly string[];
945 volumeIds: readonly string[];
946 bucketIds: readonly string[];
947}): CloudGroup => ({
948 groupId: input.groupId,
949 name: input.name,
950 color: input.color,
951 icon: input.icon,
952 collapsed: input.collapsed,
953 serviceIds: uniqueSorted(input.serviceIds),
954 volumeIds: uniqueSorted(input.volumeIds),
955 bucketIds: uniqueSorted(input.bucketIds),
956});
957
958export const GroupProvider = () =>
959 Provider.succeed(Group, {
960 stables: ["groupId", "projectId", "environmentId"],
961 nuke: { dependsOn: ["Railway.Project"] },
962
963 diff: Effect.fn(function* ({ news, output }) {
964 if (news === undefined || !isResolved(news)) return undefined;
965 if (output === undefined) return undefined;
966 const nextProject = projectIdOf(news.project);
967 const projectChanged =
968 nextProject !== undefined && nextProject !== output.projectId;
969 const nextEnv = environmentIdOf(news.environment);
970 const environmentChanged =
971 nextEnv !== undefined && nextEnv !== output.environmentId;
972 if (projectChanged || environmentChanged) {
973 return { action: "replace" as const };
974 }
975 return undefined;
976 }),
977
978 read: Effect.fn(function* ({ id, olds, output }) {
979 const projectId =
980 output?.projectId ??
981 (olds !== undefined ? projectIdOf(olds.project) : undefined);
982 const environmentId =
983 output?.environmentId ??
984 (olds !== undefined
985 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
986 : undefined);
987 if (projectId === undefined || environmentId === undefined) {
988 return undefined;
989 }
990 const name = yield* resolveName(id, olds?.name, output?.name);
991 const desired = membersOf(
992 (olds?.resources as readonly GroupMember[] | undefined) ?? [],
993 );
994 const found = yield* observe({
995 projectId,
996 environmentId,
997 groupId: output?.groupId,
998 name,
999 volumeIds: output?.volumeIds ?? desired.volumeIds,
1000 });
1001 if (found === undefined) return undefined;
1002 const attrs = toAttrs(found, { projectId, environmentId });
1003 if (output !== undefined) return attrs;
1004 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
1005 }),
1006
1007 list: Effect.fn(function* () {
1008 const projects = yield* ownedProjects();
1009 const rows = yield* Effect.forEach(projects, (project) =>
1010 fetchProjectGroups(project.projectId, groupSelection).pipe(
1011 Effect.map((live) =>
1012 live.flatMap((group) => {
1013 const name = group.name ?? "";
1014 if (!matchesAlchemyPhysicalName(name)) return [];
1015 return [
1016 toAttrs(
1017 {
1018 groupId: group.id,
1019 name,
1020 color: group.color ?? undefined,
1021 icon: group.icon ?? undefined,
1022 collapsed: group.isCollapsed === true,
1023 serviceIds: [] as string[],
1024 volumeIds: [] as string[],
1025 bucketIds: [] as string[],
1026 },
1027 {
1028 projectId: project.projectId,
1029 environmentId: project.environmentId,
1030 },
1031 ),
1032 ];
1033 }),
1034 ),
1035 Effect.catchTag("RailwayNotFound", () =>
1036 Effect.succeed([] as Group["Attributes"][]),
1037 ),
1038 ),
1039 );
1040 const seen = new Set<string>();
1041 const unique: Group["Attributes"][] = [];
1042 for (const row of rows.flat()) {
1043 const key = `${row.groupId}:${row.environmentId}`;
1044 if (seen.has(key)) continue;
1045 seen.add(key);
1046 unique.push(row);
1047 }
1048 return unique;
1049 }),
1050
1051 reconcile: Effect.fn(function* ({ id, news, output }) {
1052 const props = news ?? ({} as GroupProps);
1053 const projectId = projectIdOf(props.project) ?? output?.projectId;
1054 if (projectId === undefined) {
1055 return yield* new GroupProjectRequired({
1056 message: "Group requires a resolved Railway.Project",
1057 });
1058 }
1059 const environmentId =
1060 environmentIdOf(props.environment) ??
1061 environmentIdOf(props.project) ??
1062 output?.environmentId;
1063 if (environmentId === undefined) {
1064 return yield* new GroupEnvironmentRequired({
1065 message:
1066 "Group requires a Railway environment (pass environment or a Project with environmentId)",
1067 });
1068 }
1069 const name = yield* resolveName(id, props.name, output?.name);
1070 const desired = membersOf(props.resources as readonly GroupMember[]);
1071 const collapsed = props.collapsed ?? output?.collapsed ?? false;
1072
1073 let current = yield* observe({
1074 projectId,
1075 environmentId,
1076 groupId: output?.groupId,
1077 name,
1078 volumeIds: desired.volumeIds,
1079 });
1080
1081 // Railway IaC keys new groups by name (`isCreated: true`) and stamps
1082 // `service.groupId = name`. After apply, Railway remaps the key to a
1083 // UUID — observe-by-name picks that up.
1084 const groupId = current?.groupId ?? output?.groupId ?? name;
1085
1086 if (current === undefined) {
1087 yield* commitPatch({
1088 environmentId,
1089 commitMessage: `Alchemy: create group ${name}`,
1090 patch: membershipPatch({
1091 groupId,
1092 name,
1093 color: props.color,
1094 icon: props.icon,
1095 collapsed,
1096 create: true,
1097 services: desired.serviceIds.map((serviceId) => ({
1098 serviceId,
1099 groupId,
1100 })),
1101 }),
1102 });
1103 if (desired.bucketIds.length > 0) {
1104 yield* commitPatch({
1105 environmentId,
1106 commitMessage: `Alchemy: assign buckets to group ${name}`,
1107 patch: {
1108 buckets: bucketPatch(
1109 desired.bucketIds.map((bucketId) => ({
1110 bucketId,
1111 groupId,
1112 })),
1113 ),
1114 },
1115 });
1116 }
1117 current =
1118 (yield* waitUntilPresent({
1119 projectId,
1120 environmentId,
1121 groupId,
1122 name,
1123 volumeIds: desired.volumeIds,
1124 })) ?? undefined;
1125 }
1126
1127 if (current !== undefined) {
1128 const nameChanged = current.name !== name;
1129 const colorChanged =
1130 props.color !== undefined && props.color !== current.color;
1131 const iconChanged =
1132 props.icon !== undefined && props.icon !== current.icon;
1133 const collapsedChanged =
1134 props.collapsed !== undefined && collapsed !== current.collapsed;
1135 const servicesChanged = !sameIds(
1136 current.serviceIds,
1137 desired.serviceIds,
1138 );
1139 const bucketsChanged = !sameIds(current.bucketIds, desired.bucketIds);
1140 if (
1141 nameChanged ||
1142 colorChanged ||
1143 iconChanged ||
1144 collapsedChanged ||
1145 servicesChanged ||
1146 bucketsChanged
1147 ) {
1148 const services = [
1149 ...desired.serviceIds
1150 .filter((serviceId) => !current!.serviceIds.includes(serviceId))
1151 .map((serviceId) => ({
1152 serviceId,
1153 groupId: current!.groupId,
1154 })),
1155 ...current.serviceIds
1156 .filter((serviceId) => !desired.serviceIds.includes(serviceId))
1157 .map((serviceId) => ({
1158 serviceId,
1159 groupId: null,
1160 })),
1161 ];
1162 const buckets = [
1163 ...desired.bucketIds
1164 .filter((bucketId) => !current!.bucketIds.includes(bucketId))
1165 .map((bucketId) => ({
1166 bucketId,
1167 groupId: current!.groupId,
1168 })),
1169 ...current.bucketIds
1170 .filter((bucketId) => !desired.bucketIds.includes(bucketId))
1171 .map((bucketId) => ({
1172 bucketId,
1173 groupId: null,
1174 })),
1175 ];
1176 yield* commitPatch({
1177 environmentId,
1178 commitMessage: `Alchemy: update group ${name}`,
1179 patch: membershipPatch({
1180 groupId: current.groupId,
1181 name,
1182 color: props.color ?? current.color,
1183 icon: props.icon ?? current.icon,
1184 collapsed,
1185 create: false,
1186 services,
1187 buckets,
1188 }),
1189 });
1190 current =
1191 (yield* observe({
1192 projectId,
1193 environmentId,
1194 groupId: current.groupId,
1195 name,
1196 volumeIds: desired.volumeIds,
1197 })) ?? current;
1198 }
1199 }
1200
1201 const preview = yield* previewCanvas(environmentId);
1202 if (preview !== undefined && preview.mutations.length > 0) {
1203 yield* mergeCanvas(environmentId, environmentId);
1204 }
1205
1206 const observed = yield* observe({
1207 projectId,
1208 environmentId,
1209 groupId: current?.groupId ?? groupId,
1210 name,
1211 volumeIds: desired.volumeIds,
1212 });
1213
1214 const resolved = observed ?? current;
1215 return toAttrs(
1216 fallbackGroup({
1217 groupId: resolved?.groupId ?? groupId,
1218 name: resolved?.name || name,
1219 color: props.color ?? resolved?.color,
1220 icon: props.icon ?? resolved?.icon,
1221 collapsed: resolved?.collapsed ?? collapsed,
1222 serviceIds:
1223 resolved !== undefined && resolved.serviceIds.length > 0
1224 ? resolved.serviceIds
1225 : desired.serviceIds,
1226 volumeIds: uniqueSorted([
1227 ...(resolved?.volumeIds ?? []),
1228 ...desired.volumeIds,
1229 ]),
1230 bucketIds: uniqueSorted([
1231 ...(resolved?.bucketIds ?? []),
1232 ...desired.bucketIds,
1233 ]),
1234 }),
1235 { projectId, environmentId },
1236 );
1237 }),
1238
1239 delete: Effect.fn(function* ({ output }) {
1240 const groupId = output.groupId;
1241 const environmentId = output.environmentId;
1242 const projectId = output.projectId;
1243 if (groupId.length === 0 || environmentId.length === 0) return;
1244 const current = yield* observe({
1245 projectId,
1246 environmentId,
1247 groupId,
1248 name: output.name,
1249 volumeIds: output.volumeIds,
1250 });
1251 if (current === undefined) return;
1252 yield* commitPatch({
1253 environmentId,
1254 commitMessage: `Alchemy: delete group ${output.name}`,
1255 patch: membershipPatch({
1256 groupId,
1257 name: output.name,
1258 collapsed: false,
1259 create: false,
1260 remove: true,
1261 services: current.serviceIds.map((serviceId) => ({
1262 serviceId,
1263 groupId: null,
1264 })),
1265 buckets: current.bucketIds.map((bucketId) => ({
1266 bucketId,
1267 groupId: null,
1268 })),
1269 }),
1270 }).pipe(Effect.catchTag("RailwayNotFound", () => Effect.succeed("")));
1271 if (projectId.length > 0) {
1272 yield* waitUntilGone({
1273 projectId,
1274 environmentId,
1275 groupId,
1276 name: output.name,
1277 });
1278 }
1279 }),
1280 });