1import { projectServices as fetchProjectServices } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type Project as RailwayProject,
6 type Service as RailwayService,
7 type Template as RailwayTemplate,
8 type TemplateDeployV2Input,
9} from "@distilled.cloud/railway";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import { Unowned } from "../AdoptPolicy.ts";
15import { isResolved } from "../Diff.ts";
16import * as Provider from "../Provider.ts";
17import { Resource } from "../Resource.ts";
18import { RailwayEnvironment } from "./Environment.ts";
19import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
20import {
21 createProject,
22 environmentIdOf as projectEnvironmentId,
23 ownedProjects,
24 workspaceIdOf as projectWorkspaceId,
25} from "./Project.ts";
26import type { Providers } from "./Providers.ts";
27
28const templateFields = <E>(template: Query<RailwayTemplate, E>) => ({
29 id: template.id,
30 code: template.code,
31 name: template.name,
32 serializedConfig: template.serializedConfig,
33});
34const templateSummaryFields = <E>(template: Query<RailwayTemplate, E>) => ({
35 id: template.id,
36 code: template.code,
37 name: template.name,
38});
39const projectFields = <E>(project: Query<RailwayProject, E>) => ({
40 id: project.id,
41 name: project.name,
42 description: project.description,
43 workspaceId: project.workspaceId,
44 primaryEnvironmentId: project.primaryEnvironmentId,
45 baseEnvironmentId: project.baseEnvironmentId,
46 deletedAt: project.deletedAt,
47});
48const serviceFields = <E>(service: Query<RailwayService, E>) => ({
49 id: service.id,
50 name: service.name,
51 deletedAt: service.deletedAt,
52 templateId: service.templateId,
53});
54type CloudTemplate = UnwrapPlan<ReturnType<typeof templateFields>>;
55type CloudProject = UnwrapPlan<ReturnType<typeof projectFields>>;
56type CloudService = UnwrapPlan<ReturnType<typeof serviceFields>>;
57
58/**
59 * A resource-valued prop: the resource itself, or an Effect that produces
60 * it (so `yield* Project(...)` and `Project(...)` both type-check).
61 */
62type Ref<T> = T | Effect.Effect<T, never, Providers>;
63
64/**
65 * Project identity a template deploys into. Accepts a `Railway.Project`
66 * or a `{ projectId }` stub.
67 */
68export type TemplateProject = {
69 readonly projectId: string;
70 readonly environmentId?: string;
71 readonly workspaceId?: string;
72};
73
74/**
75 * Environment identity a template deploys into. Accepts a
76 * `Railway.Project` (its primary environment), a `Railway.Environment`,
77 * or an `{ environmentId }` stub.
78 */
79export type TemplateEnvironment = {
80 readonly environmentId: string;
81};
82
83export interface TemplateProps {
84 /**
85 * Marketplace template id (UUID) or template code (`postgres`,
86 * `redis`). Changing it replaces the deployment.
87 */
88 templateId: string;
89 /**
90 * Project to deploy into. Accepts a `Railway.Project` or an Effect
91 * that produces one. If omitted, Alchemy creates an owned Project
92 * (serialized `createProject`) and deploys into it. Changing it
93 * replaces the deployment.
94 */
95 project?: Ref<TemplateProject>;
96 /**
97 * Environment to deploy into. Accepts a `Railway.Project` (primary
98 * environment), a `Railway.Environment`, or `{ environmentId }`.
99 * Defaults to the project's primary environment. Changing it
100 * replaces the deployment.
101 */
102 environment?: Ref<TemplateEnvironment>;
103 /**
104 * Serialized template config passed to `templateDeployV2`. If omitted,
105 * Alchemy fetches it from the marketplace template. Variables without
106 * a `value` are filled from `defaultValue`.
107 */
108 serializedConfig?: unknown;
109}
110
111export type Template = Resource<
112 "Railway.Template",
113 TemplateProps,
114 {
115 /** Canonical marketplace template id (UUID). */
116 templateId: string;
117 /** Marketplace template code (`postgres`, …), when its metadata is accessible. */
118 code: string | undefined;
119 /** Marketplace template display name, when its metadata is accessible. */
120 name: string | undefined;
121 /** Project the template was deployed into. */
122 projectId: string;
123 /** Environment the template was deployed into. */
124 environmentId: string;
125 /** Workspace the project lives in. */
126 workspaceId: string;
127 /** Latest `templateDeployV2` workflow id, if Railway returned one. */
128 workflowId: string | undefined;
129 /** Service ids created from the template. */
130 serviceIds: string[];
131 /**
132 * True when Alchemy created the Project because `project` was
133 * omitted. Delete then removes that Project.
134 */
135 ownsProject: boolean;
136 /** Dashboard URL (`https://railway.com/project/{projectId}`). */
137 url: string;
138 },
139 never,
140 Providers
141>;
142
143const resolveTemplateProps = (
144 props: TemplateProps | Effect.Effect<TemplateProps, never, Providers>,
145): Effect.Effect<TemplateProps, never, Providers> =>
146 Effect.gen(function* () {
147 const resolved = Effect.isEffect(props) ? yield* props : props;
148 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
149 const project =
150 resolved.project === undefined
151 ? undefined
152 : Effect.isEffect(resolved.project)
153 ? yield* resolved.project as Effect.Effect<
154 TemplateProject,
155 never,
156 Providers
157 >
158 : resolved.project;
159 const environment =
160 resolved.environment === undefined
161 ? undefined
162 : Effect.isEffect(resolved.environment)
163 ? yield* resolved.environment as Effect.Effect<
164 TemplateEnvironment,
165 never,
166 Providers
167 >
168 : resolved.environment;
169 return { ...resolved, project, environment };
170 });
171
172const TemplateResource = Resource<Template>("Railway.Template");
173
174/**
175 * A Railway.Template deploys a marketplace template into a Project.
176 * Alchemy looks up the template (`template` / `templates`), sends
177 * `templateDeployV2` with its `serializedConfig`, and adopts the
178 * project/services the workflow creates.
179 *
180 * Pass a Project to deploy into an existing one. Omit `project` and
181 * Alchemy creates an owned Project first (the workspace project-create
182 * cap is serialized).
183 *
184 * @see https://docs.railway.com/templates/deploy
185 *
186 * ### Deploy a marketplace template
187 * `templateId` is a marketplace UUID or code (`postgres`). Pass a
188 * Project to deploy into it.
189 *
190 * **Example:** Postgres into a Project
191 * ```typescript
192 * const site = yield* Railway.Project("Site");
193 * const db = yield* Railway.Template("Postgres", {
194 * templateId: "postgres",
195 * project: site,
196 * });
197 * ```
198 *
199 * :::caution[Changing `templateId`, `project`, or `environment` replaces]
200 * The previous template services (and the owned Project, if Alchemy
201 * created it) are deleted. A new deploy runs.
202 * :::
203 *
204 * ### Create the Project
205 * Omit `project` to let Alchemy create one. The name is stamped so
206 * nuke can reclaim it.
207 *
208 * **Example:** Generated Project
209 * ```typescript
210 * const db = yield* Railway.Template("Postgres", {
211 * templateId: "postgres",
212 * });
213 * ```
214 *
215 * ### Serialized config
216 * Omit `serializedConfig` to use the marketplace default. Pass a
217 * config (from `template.serializedConfig`, with service variable
218 * `value`s filled in) to override.
219 *
220 * **Example:** Override config
221 * ```typescript
222 * const db = yield* Railway.Template("Postgres", {
223 * templateId: "postgres",
224 * project: site,
225 * serializedConfig: config,
226 * });
227 * ```
228 *
229 * ### Environment
230 * Defaults to the Project's primary environment.
231 *
232 * **Example:** Extra environment
233 * ```typescript
234 * const staging = yield* Railway.Environment("Staging", { project: site });
235 * const db = yield* Railway.Template("StagingPostgres", {
236 * templateId: "postgres",
237 * project: site,
238 * environment: staging,
239 * });
240 * ```
241 *
242 * ### Module-scope declarations
243 * Resource-valued props accept the resource or an Effect producing it.
244 *
245 * **Example:** Module-scope Template
246 * ```typescript
247 * // src/db.ts
248 * import * as Railway from "alchemy/Railway";
249 *
250 * export const Site = Railway.Project("Site");
251 * export const Postgres = Railway.Template("Postgres", {
252 * templateId: "postgres",
253 * project: Site,
254 * });
255 * ```
256 *
257 * @resource
258 * @product Project
259 */
260export const Template: typeof TemplateResource = Object.assign(
261 (
262 id: string,
263 props: TemplateProps | Effect.Effect<TemplateProps, never, Providers>,
264 ) => TemplateResource(id, resolveTemplateProps(props)),
265 TemplateResource,
266);
267
268export class TemplateNotFound extends Data.TaggedError(
269 "Railway.TemplateNotFound",
270)<{
271 templateId: string;
272}> {}
273
274export class TemplateNotCreated extends Data.TaggedError(
275 "Railway.TemplateNotCreated",
276)<{
277 templateId: string;
278 projectId: string;
279}> {}
280
281export class TemplateConfigRequired extends Data.TaggedError(
282 "Railway.TemplateConfigRequired",
283)<{
284 templateId: string;
285}> {}
286
287export class TemplateWorkflowFailed extends Data.TaggedError(
288 "Railway.TemplateWorkflowFailed",
289)<{
290 workflowId: string;
291 error: string;
292}> {}
293
294class TemplatePending extends Data.TaggedError("Railway.TemplatePending")<{
295 projectId: string;
296 state: string;
297}> {}
298
299const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
300
301const projectIdOf = (value: unknown): string | undefined => {
302 if (value === null || typeof value !== "object") return undefined;
303 const rec = value as { projectId?: unknown };
304 return typeof rec.projectId === "string" && rec.projectId.length > 0
305 ? rec.projectId
306 : undefined;
307};
308
309const environmentIdOf = (value: unknown): string | undefined => {
310 if (value === null || typeof value !== "object") return undefined;
311 const rec = value as { environmentId?: unknown };
312 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
313 ? rec.environmentId
314 : undefined;
315};
316
317const workspaceIdOf = (value: unknown): string | undefined => {
318 if (value === null || typeof value !== "object") return undefined;
319 const rec = value as { workspaceId?: unknown };
320 return typeof rec.workspaceId === "string" && rec.workspaceId.length > 0
321 ? rec.workspaceId
322 : undefined;
323};
324
325const isRecord = (value: unknown): value is Record<string, unknown> =>
326 value !== null && typeof value === "object" && !Array.isArray(value);
327
328const isGoneProject = (project: CloudProject | undefined) =>
329 project === undefined || project.deletedAt != null;
330
331const isGoneService = (service: CloudService | undefined) =>
332 service === undefined || service.deletedAt != null;
333
334const currentWorkspaceId = Effect.fn(function* () {
335 const env = yield* yield* RailwayEnvironment;
336 return env.workspaceId;
337});
338
339const environmentIdFromProject = (project: CloudProject) =>
340 projectEnvironmentId(project);
341
342const toAttrs = (input: {
343 template: { id: string; code?: string; name?: string };
344 project: CloudProject;
345 environmentId: string;
346 workspaceId: string;
347 serviceIds: string[];
348 workflowId: string | undefined;
349 ownsProject: boolean;
350}): Template["Attributes"] => ({
351 templateId: input.template.id,
352 code: input.template.code,
353 name: input.template.name,
354 projectId: input.project.id,
355 environmentId: input.environmentId,
356 workspaceId: input.workspaceId || projectWorkspaceId(input.project) || "",
357 workflowId: input.workflowId,
358 serviceIds: input.serviceIds,
359 ownsProject: input.ownsProject,
360 url: `https://railway.com/project/${input.project.id}`,
361});
362
363const readProject = Query.fn((id: string) =>
364 projectFields(Railway.project({ id })),
365);
366
367const getProject = (projectId: string) =>
368 readProject(projectId).pipe(
369 Effect.map((project) => (isGoneProject(project) ? undefined : project)),
370 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
371 );
372
373const readTemplateById = Query.fn((id: string) =>
374 templateFields(Railway.template({ id })),
375);
376
377const readTemplateByCode = Query.fn((code: string) =>
378 templateFields(Railway.template({ code })),
379);
380
381const readTemplateSummary = Query.fn((id: string) =>
382 templateSummaryFields(Railway.template({ id })),
383);
384
385const getTemplateById = (id: string) =>
386 readTemplateById(id).pipe(
387 Effect.map((template): CloudTemplate | undefined => template),
388 );
389
390const getTemplateByCode = (code: string) =>
391 readTemplateByCode(code).pipe(
392 Effect.map((template): CloudTemplate | undefined => template),
393 );
394
395const findPublished = (needle: string) =>
396 Query.items(
397 Railway.templates({ first: 50 }).pipe(
398 Query.map((template) => ({ id: template.id, code: template.code })),
399 ),
400 ).pipe(
401 Stream.filter(
402 (template) => template.id === needle || template.code === needle,
403 ),
404 Stream.take(1),
405 Stream.runHead,
406 Effect.flatMap((option) =>
407 option._tag === "Some"
408 ? getTemplateByCode(option.value.code)
409 : Effect.succeed(undefined),
410 ),
411 );
412
413const resolveMarketplaceTemplate = (templateId: string) =>
414 Effect.gen(function* () {
415 const primary = UUID.test(templateId)
416 ? yield* getTemplateById(templateId).pipe(
417 Effect.catchTag("RailwayForbidden", (failure) =>
418 findPublished(templateId).pipe(
419 Effect.flatMap((template) =>
420 template === undefined
421 ? Effect.fail(failure)
422 : Effect.succeed(template),
423 ),
424 ),
425 ),
426 )
427 : yield* getTemplateByCode(templateId);
428 if (primary != null) return primary;
429 const secondary = UUID.test(templateId)
430 ? yield* getTemplateByCode(templateId)
431 : yield* getTemplateById(templateId);
432 if (secondary != null) return secondary;
433 return yield* findPublished(templateId);
434 });
435
436const readSourceForProject = Query.fn((projectId: string) =>
437 Railway.templateSourceForProject({ projectId }).pipe(
438 Query.map((source) => ({
439 id: source.id,
440 code: source.code,
441 name: source.name,
442 })),
443 ),
444);
445
446const sourceForProject = (projectId: string) =>
447 readSourceForProject(projectId).pipe(
448 Effect.map((value) => value ?? undefined),
449 Effect.catchTag("RailwayForbidden", () => Effect.succeed(undefined)),
450 );
451
452const listProjectServices = (projectId: string) =>
453 fetchProjectServices(projectId, serviceFields).pipe(
454 Effect.map((services) => services.filter((node) => !isGoneService(node))),
455 Effect.catchTag("RailwayNotFound", () =>
456 Effect.succeed([] as CloudService[]),
457 ),
458 );
459
460const readService = Query.fn((id: string) =>
461 serviceFields(Railway.service({ id })),
462);
463
464const hydrateService = (serviceId: string) =>
465 readService(serviceId).pipe(
466 Effect.map((service) => (isGoneService(service) ? undefined : service)),
467 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
468 );
469
470const hydrateServices = (services: readonly CloudService[]) =>
471 Effect.forEach(services, (service) => hydrateService(service.id), {
472 concurrency: 4,
473 }).pipe(
474 Effect.map((rows) =>
475 rows.filter((row): row is CloudService => row !== undefined),
476 ),
477 );
478
479const matchingServices = (input: {
480 services: readonly CloudService[];
481 templateId: string;
482 sourceId: string | undefined;
483 ownsProject: boolean;
484}): CloudService[] => {
485 const byTemplate = input.services.filter(
486 (service) => service.templateId === input.templateId,
487 );
488 if (byTemplate.length > 0) return byTemplate;
489 if (input.sourceId === input.templateId || input.ownsProject) {
490 return [...input.services];
491 }
492 return [];
493};
494
495const parseConfig = (config: unknown) =>
496 Effect.sync(() => {
497 if (typeof config !== "string") return config;
498 try {
499 return JSON.parse(config) as unknown;
500 } catch {
501 return config;
502 }
503 });
504
505const normalizeVariables = (variables: unknown): unknown => {
506 if (!isRecord(variables)) return variables;
507 const out: Record<string, unknown> = {};
508 for (const [key, item] of Object.entries(variables)) {
509 if (!isRecord(item)) {
510 out[key] = item;
511 continue;
512 }
513 const value =
514 typeof item.value === "string"
515 ? item.value
516 : typeof item.defaultValue === "string"
517 ? item.defaultValue
518 : "";
519 out[key] = { ...item, value };
520 }
521 return out;
522};
523
524const normalizeConfig = (config: unknown): unknown => {
525 if (!isRecord(config) || !isRecord(config.services)) return config;
526 const services: Record<string, unknown> = {};
527 for (const [id, service] of Object.entries(config.services)) {
528 if (!isRecord(service)) {
529 services[id] = service;
530 continue;
531 }
532 services[id] =
533 service.variables === undefined
534 ? service
535 : { ...service, variables: normalizeVariables(service.variables) };
536 }
537 return { ...config, services };
538};
539
540const readWorkflowStatus = Query.fn((workflowId: string) => {
541 const result = Railway.workflowStatus({ workflowId });
542 return { status: result.status, error: result.error };
543});
544
545const waitForWorkflow = (workflowId: string, projectId: string) =>
546 Effect.gen(function* () {
547 const result = yield* readWorkflowStatus(workflowId);
548 if (result.status === "Complete") return result;
549 if (result.status === "Error") {
550 return yield* new TemplateWorkflowFailed({
551 workflowId,
552 error: result.error ?? "template deploy failed",
553 });
554 }
555 return yield* new TemplatePending({
556 projectId,
557 state: result.status,
558 });
559 }).pipe(
560 Effect.retry({
561 while: (e) => e._tag === "Railway.TemplatePending",
562 times: 10,
563 schedule: Schedule.spaced("3 seconds"),
564 }),
565 );
566
567const waitForServices = (input: {
568 projectId: string;
569 templateId: string;
570 sourceId: string | undefined;
571 ownsProject: boolean;
572}) =>
573 listProjectServices(input.projectId).pipe(
574 Effect.flatMap((listed) => hydrateServices(listed)),
575 Effect.flatMap((services) => {
576 const matched = matchingServices({
577 services,
578 templateId: input.templateId,
579 sourceId: input.sourceId,
580 ownsProject: input.ownsProject,
581 });
582 if (matched.length === 0) {
583 return Effect.fail(
584 new TemplatePending({
585 projectId: input.projectId,
586 state: "services",
587 }),
588 );
589 }
590 return Effect.succeed(matched);
591 }),
592 Effect.retry({
593 while: (e) => e._tag === "Railway.TemplatePending",
594 times: 8,
595 schedule: Schedule.spaced("2 seconds"),
596 }),
597 );
598
599const waitUntilServiceGone = (serviceId: string, projectId: string) =>
600 hydrateService(serviceId).pipe(
601 Effect.flatMap((service) =>
602 service === undefined
603 ? Effect.void
604 : Effect.fail(
605 new TemplatePending({
606 projectId,
607 state: `service ${serviceId} deletion`,
608 }),
609 ),
610 ),
611 Effect.retry({
612 schedule: Schedule.spaced("1 second"),
613 while: (error) => error._tag === "Railway.TemplatePending",
614 times: 8,
615 }),
616 );
617
618const waitUntilProjectGone = (projectId: string) =>
619 getProject(projectId).pipe(
620 Effect.flatMap((project) =>
621 project === undefined
622 ? Effect.void
623 : Effect.fail(
624 new TemplatePending({ projectId, state: "project deletion" }),
625 ),
626 ),
627 Effect.retry({
628 schedule: Schedule.spaced("1 second"),
629 while: (error) => error._tag === "Railway.TemplatePending",
630 times: 8,
631 }),
632 );
633
634const templateDeploy = Query.fn((input: TemplateDeployV2Input) => {
635 const deployed = Railway.templateDeployV2({ input });
636 return { projectId: deployed.projectId, workflowId: deployed.workflowId };
637});
638
639const serviceDelete = Query.fn((id: string) => Railway.serviceDelete({ id }));
640
641const projectDelete = Query.fn((id: string) => Railway.projectDelete({ id }));
642
643const observeDeployment = Effect.fn(function* (input: {
644 projectId: string;
645 templateId: string;
646 ownsProject: boolean;
647}) {
648 const project = yield* getProject(input.projectId);
649 if (project === undefined) {
650 return undefined;
651 }
652 const source = yield* sourceForProject(input.projectId);
653 const listed = yield* listProjectServices(input.projectId);
654 const services = yield* hydrateServices(listed);
655 const matched = matchingServices({
656 services,
657 templateId: input.templateId,
658 sourceId: source?.id,
659 ownsProject: input.ownsProject,
660 });
661 return { project, source, services: matched };
662});
663
664export const TemplateProvider = () =>
665 Provider.succeed(Template, {
666 stables: ["templateId", "code", "projectId", "workspaceId"],
667 nuke: { dependsOn: ["Railway.Project"] },
668
669 diff: Effect.fn(function* ({ news, output }) {
670 if (news === undefined || !isResolved(news)) return undefined;
671 if (output === undefined) return undefined;
672 const templateChanged =
673 news.templateId !== output.templateId &&
674 news.templateId !== output.code;
675 const nextProject = projectIdOf(news.project);
676 const projectChanged =
677 nextProject !== undefined && nextProject !== output.projectId;
678 const nextEnv = environmentIdOf(news.environment);
679 const environmentChanged =
680 nextEnv !== undefined && nextEnv !== output.environmentId;
681 if (templateChanged || projectChanged || environmentChanged) {
682 return { action: "replace" as const };
683 }
684 return undefined;
685 }),
686
687 read: Effect.fn(function* ({ olds, output }) {
688 const templateRef = output?.templateId ?? olds?.templateId;
689 if (templateRef === undefined || templateRef.length === 0) {
690 return undefined;
691 }
692 const marketplace = yield* resolveMarketplaceTemplate(templateRef);
693 const templateId = marketplace?.id ?? output?.templateId ?? templateRef;
694 const projectId =
695 output?.projectId ??
696 (olds !== undefined ? projectIdOf(olds.project) : undefined);
697 if (projectId === undefined) return undefined;
698 const ownsProject = output?.ownsProject ?? olds?.project === undefined;
699 const observed = yield* observeDeployment({
700 projectId,
701 templateId,
702 ownsProject,
703 });
704 if (observed === undefined || observed.services.length === 0) {
705 return undefined;
706 }
707 const template = marketplace ?? observed.source;
708 if (template === undefined) return undefined;
709 const environmentId =
710 output?.environmentId ??
711 (olds !== undefined
712 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
713 : undefined) ??
714 environmentIdFromProject(observed.project);
715 const workspaceId =
716 output?.workspaceId ??
717 (olds !== undefined ? workspaceIdOf(olds.project) : undefined) ??
718 projectWorkspaceId(observed.project);
719 const attrs = toAttrs({
720 template,
721 project: observed.project,
722 environmentId,
723 workspaceId,
724 serviceIds: observed.services.map((service) => service.id),
725 workflowId: output?.workflowId,
726 ownsProject,
727 });
728 if (output !== undefined) return attrs;
729 return matchesAlchemyPhysicalName(observed.project.name)
730 ? attrs
731 : Unowned(attrs);
732 }),
733
734 list: Effect.fn(function* () {
735 const projects = yield* ownedProjects();
736 const rows = yield* Effect.forEach(projects, (project) =>
737 Effect.gen(function* () {
738 const live = yield* readProject(project.projectId).pipe(
739 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
740 );
741 if (live === undefined || live.deletedAt != null) {
742 return [] as Template["Attributes"][];
743 }
744 const services = yield* listProjectServices(project.projectId);
745 const source = yield* sourceForProject(project.projectId);
746 const deployments = new Map<string, CloudService[]>();
747 for (const service of services) {
748 const templateId = service.templateId ?? source?.id;
749 if (templateId == null) continue;
750 const group = deployments.get(templateId) ?? [];
751 group.push(service);
752 deployments.set(templateId, group);
753 }
754 return yield* Effect.forEach(deployments, ([templateId, members]) =>
755 Effect.gen(function* () {
756 const template =
757 source?.id === templateId
758 ? source
759 : yield* readTemplateSummary(templateId).pipe(
760 Effect.catchTag("RailwayForbidden", () =>
761 Effect.succeed({ id: templateId }),
762 ),
763 );
764 return toAttrs({
765 template: template ?? { id: templateId },
766 project: live,
767 environmentId: project.environmentId,
768 workspaceId: project.workspaceId,
769 serviceIds: members.map((service) => service.id),
770 workflowId: undefined,
771 ownsProject: false,
772 });
773 }),
774 );
775 }),
776 );
777 const seen = new Set<string>();
778 const unique: Template["Attributes"][] = [];
779 for (const row of rows.flat()) {
780 const key = `${row.projectId}:${row.templateId}`;
781 if (seen.has(key)) continue;
782 seen.add(key);
783 unique.push(row);
784 }
785 return unique;
786 }),
787
788 reconcile: Effect.fn(function* ({ id, news, output }) {
789 const props = news ?? ({} as TemplateProps);
790 const templateRef = props.templateId ?? output?.templateId;
791 if (templateRef === undefined || templateRef.length === 0) {
792 return yield* new TemplateNotFound({ templateId: "" });
793 }
794 const marketplace = yield* resolveMarketplaceTemplate(templateRef);
795 if (marketplace === undefined) {
796 return yield* new TemplateNotFound({ templateId: templateRef });
797 }
798
799 const providedProject = props.project !== undefined;
800 let ownsProject = providedProject ? false : (output?.ownsProject ?? true);
801 let projectId = projectIdOf(props.project) ?? output?.projectId;
802 let environmentId =
803 environmentIdOf(props.environment) ??
804 environmentIdOf(props.project) ??
805 output?.environmentId;
806 let workspaceId =
807 workspaceIdOf(props.project) ??
808 output?.workspaceId ??
809 (yield* currentWorkspaceId());
810
811 let current: CloudProject | undefined =
812 projectId !== undefined && projectId.length > 0
813 ? yield* getProject(projectId)
814 : undefined;
815
816 if (current === undefined && !providedProject) {
817 const name = yield* createRailwayName(id);
818 const created = yield* createProject({
819 name,
820 workspaceId,
821 }).pipe(
822 Effect.catchTag("RailwayValidationError", () =>
823 Effect.succeed(undefined),
824 ),
825 );
826 current = created;
827 ownsProject = true;
828 }
829
830 if (current === undefined || isGoneProject(current)) {
831 return yield* new TemplateNotCreated({
832 templateId: marketplace.id,
833 projectId: projectId ?? "",
834 });
835 }
836
837 projectId = current.id;
838 environmentId =
839 environmentId && environmentId.length > 0
840 ? environmentId
841 : environmentIdFromProject(current);
842 workspaceId = projectWorkspaceId(current, workspaceId);
843
844 const observed = yield* observeDeployment({
845 projectId,
846 templateId: marketplace.id,
847 ownsProject,
848 });
849 let services = observed?.services ?? [];
850 let workflowId = output?.workflowId;
851
852 if (services.length === 0) {
853 const rawConfig =
854 props.serializedConfig ?? marketplace.serializedConfig;
855 if (rawConfig == null) {
856 return yield* new TemplateConfigRequired({
857 templateId: marketplace.id,
858 });
859 }
860 const parsed = yield* parseConfig(rawConfig);
861 const serializedConfig = normalizeConfig(parsed);
862 const deployed = yield* templateDeploy({
863 templateId: marketplace.id,
864 serializedConfig,
865 projectId,
866 ...(environmentId.length > 0 ? { environmentId } : {}),
867 workspaceId,
868 });
869 projectId = deployed.projectId || projectId;
870 workflowId = deployed.workflowId ?? workflowId;
871 if (
872 deployed.projectId.length > 0 &&
873 deployed.projectId !== current.id
874 ) {
875 const created = yield* getProject(deployed.projectId);
876 if (created !== undefined) current = created;
877 }
878 if (workflowId !== undefined && workflowId.length > 0) {
879 yield* waitForWorkflow(workflowId, projectId);
880 }
881 const source = yield* sourceForProject(projectId);
882 services = yield* waitForServices({
883 projectId,
884 templateId: marketplace.id,
885 sourceId: source?.id,
886 ownsProject,
887 });
888 }
889
890 if (services.length === 0) {
891 return yield* new TemplateNotCreated({
892 templateId: marketplace.id,
893 projectId,
894 });
895 }
896
897 const live = (yield* getProject(projectId)) ?? current;
898 if (environmentId.length === 0) {
899 environmentId = environmentIdFromProject(live);
900 }
901
902 return toAttrs({
903 template: marketplace,
904 project: live,
905 environmentId,
906 workspaceId,
907 serviceIds: services.map((service) => service.id),
908 workflowId,
909 ownsProject,
910 });
911 }),
912
913 delete: Effect.fn(function* ({ output }) {
914 const serviceIds = output.serviceIds ?? [];
915 for (const serviceId of serviceIds) {
916 if (serviceId.length === 0) continue;
917 yield* serviceDelete(serviceId).pipe(
918 Effect.catchTag("RailwayNotFound", () => Effect.void),
919 );
920 yield* waitUntilServiceGone(serviceId, output.projectId);
921 }
922 if (!output.ownsProject) return;
923 const projectId = output.projectId;
924 if (projectId.length === 0) return;
925 yield* projectDelete(projectId).pipe(
926 Effect.catchTag("RailwayNotFound", () => Effect.void),
927 );
928 yield* waitUntilProjectGone(projectId);
929 }),
930 });