1import { waitUntilDeleted, projectServices } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type Builder,
6 type DeploymentTriggerCreateInput,
7 type DeploymentTriggerUpdateInput,
8 type RestartPolicyType,
9 type Service as RailwayService,
10 type ServiceCreateInput,
11 type ServiceInstance as RailwayServiceInstance,
12 type ServiceInstanceAutoDeployUpdateInput,
13 type ServiceInstanceUpdateInput,
14 type VariableUpsertInput,
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 type * as Types from "effect/Types";
21import { AlchemyContext } from "../AlchemyContext.ts";
22import { Unowned } from "../AdoptPolicy.ts";
23import * as Bundle from "../Bundle/Bundle.ts";
24import { deepEqual, isResolved, stripEffects } from "../Diff.ts";
25import * as Provider from "../Provider.ts";
26import { Stack } from "../Stack.ts";
27import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
28import { assertHostDisk, type MountSpec } from "./MountVolume.ts";
29import { ownedProjects } from "./Project.ts";
30import { attachVolumeToService, listServiceVolumes } from "./Volume.ts";
31import {
32 deleteOwnedServiceDomain,
33 ensureServiceDomain,
34 findServiceDomainById,
35 type ServiceDomainRecord,
36} from "./ServiceDomain.ts";
37import {
38 collectBindingState,
39 createRailwayHostedSupport,
40 DEFAULT_PORT,
41 plainEnvValue,
42 toEnvRecord,
43} from "./hosted.ts";
44import { RPC_TOKEN_ENV } from "./rpc-token.ts";
45import { tarGzipDirectory } from "../Util/tarGzip.ts";
46import {
47 hashRailwayLocalContext,
48 prepareRailwayLocalContext,
49 resolveRailwayServiceSource,
50 ServiceSourceInvalid,
51 type RailwayLocalContextSource,
52} from "./local-context.ts";
53import { uploadDeployTarball } from "./Up.ts";
54import { readServiceRegion, syncServiceRegion } from "./ServiceRegion.ts";
55import { Service } from "./Service.ts";
56
57export { ServiceRegionNotApplied } from "./ServiceRegion.ts";
58
59const serviceFields = <E>(service: Query<RailwayService, E>) => ({
60 id: service.id,
61 name: service.name,
62 deletedAt: service.deletedAt,
63});
64type CloudService = UnwrapPlan<ReturnType<typeof serviceFields>>;
65
66const instanceFields = <E>(instance: Query<RailwayServiceInstance, E>) => ({
67 deletedAt: instance.deletedAt,
68 source: instance.source.pipe(
69 Query.map((source) => ({ image: source.image, repo: source.repo })),
70 ),
71 region: instance.region,
72 sleepApplication: instance.sleepApplication,
73 latestDeployment: instance.latestDeployment.pipe(
74 Query.map((deployment) => ({
75 id: deployment.id,
76 status: deployment.status,
77 })),
78 ),
79 activeDeployments: instance.activeDeployments.pipe(
80 Query.map((deployment) => ({
81 id: deployment.id,
82 status: deployment.status,
83 })),
84 ),
85 buildCommand: instance.buildCommand,
86 builder: instance.builder,
87 cronSchedule: instance.cronSchedule,
88 dockerfilePath: instance.dockerfilePath,
89 drainingSeconds: instance.drainingSeconds,
90 healthcheckPath: instance.healthcheckPath,
91 healthcheckTimeout: instance.healthcheckTimeout,
92 numReplicas: instance.numReplicas,
93 overlapSeconds: instance.overlapSeconds,
94 preDeployCommand: instance.preDeployCommand,
95 restartPolicyMaxRetries: instance.restartPolicyMaxRetries,
96 restartPolicyType: instance.restartPolicyType,
97 rootDirectory: instance.rootDirectory,
98 startCommand: instance.startCommand,
99 watchPatterns: instance.watchPatterns,
100});
101type ServiceInstanceResponse = UnwrapPlan<ReturnType<typeof instanceFields>>;
102
103/** Mutable builder for a {@link ServiceInstanceUpdateInput} delta. */
104type InstanceSettingsDelta = Types.Mutable<ServiceInstanceUpdateInput>;
105
106export {
107 ServiceContextPathInvalid,
108 ServiceContextPathUnsupported,
109 ServiceContextSymlinkUnsupported,
110 ServiceContextTooLarge,
111 ServiceDockerfileOutsideContext,
112 ServiceDockerfilePathInvalid,
113 ServiceImageOrMainRequired,
114 ServiceSourceInvalid,
115} from "./local-context.ts";
116
117export class ServiceNotCreated extends Data.TaggedError(
118 "Railway.ServiceNotCreated",
119)<{
120 name: string;
121 projectId: string;
122}> {}
123
124export class ServiceProjectRequired extends Data.TaggedError(
125 "Railway.ServiceProjectRequired",
126)<{
127 message: string;
128}> {}
129
130export class ServiceDeployFailed extends Data.TaggedError(
131 "Railway.ServiceDeployFailed",
132)<{
133 serviceId: string;
134 status: string;
135 deploymentId: string | undefined;
136 logs: string;
137}> {
138 override get message() {
139 return this.logs.length > 0
140 ? `Service deploy ${this.status}: ${this.logs}`
141 : `Service deploy ${this.status}`;
142 }
143}
144
145class ServicePending extends Data.TaggedError("Railway.ServicePending")<{
146 serviceId: string;
147 status: string;
148}> {}
149
150class ServiceDeployPending extends Data.TaggedError(
151 "Railway.ServiceDeployPending",
152)<{
153 serviceId: string;
154 status: string;
155}> {
156 override get message() {
157 return `deployment still ${this.status}`;
158 }
159}
160
161const projectIdOf = (value: unknown): string | undefined => {
162 if (value === null || typeof value !== "object") return undefined;
163 const rec = value as { projectId?: unknown };
164 return typeof rec.projectId === "string" && rec.projectId.length > 0
165 ? rec.projectId
166 : undefined;
167};
168
169const environmentIdOf = (value: unknown): string | undefined => {
170 if (value === null || typeof value !== "object") return undefined;
171 const rec = value as { environmentId?: unknown };
172 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
173 ? rec.environmentId
174 : undefined;
175};
176
177const isGoneService = (service: CloudService | undefined) =>
178 service === undefined || service.deletedAt != null;
179
180const isGoneInstance = (instance: ServiceInstanceResponse | undefined) =>
181 instance === undefined || instance.deletedAt != null;
182
183const resolveName = (id: string, name: string | undefined, existing?: string) =>
184 Effect.gen(function* () {
185 if (name !== undefined) return name;
186 if (existing !== undefined) return existing;
187 return yield* createRailwayName(id);
188 });
189
190const readService = Query.fn((id: string) =>
191 serviceFields(Railway.service({ id })),
192);
193
194const readInstance = Query.fn((environmentId: string, serviceId: string) =>
195 instanceFields(Railway.serviceInstance({ environmentId, serviceId })),
196);
197
198const getById = (serviceId: string) =>
199 readService(serviceId).pipe(
200 Effect.map((service) => (isGoneService(service) ? undefined : service)),
201 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
202 );
203
204const getInstance = (environmentId: string, serviceId: string) =>
205 readInstance(environmentId, serviceId).pipe(
206 Effect.map((instance) => (isGoneInstance(instance) ? undefined : instance)),
207 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
208 );
209
210const listProjectServices = (projectId: string) =>
211 projectServices(projectId, serviceFields).pipe(
212 Effect.map((services) => services.filter((node) => !isGoneService(node))),
213 Effect.catchTag("RailwayNotFound", () =>
214 Effect.succeed([] as CloudService[]),
215 ),
216 );
217
218const findByName = (projectId: string, name: string) =>
219 listProjectServices(projectId).pipe(
220 Effect.map((services) => services.find((service) => service.name === name)),
221 );
222
223const sameImage = (observed: string | null | undefined, desired: string) => {
224 if (observed == null || observed.length === 0) return false;
225 if (observed === desired) return true;
226 if (observed === `${desired}:latest` || desired === `${observed}:latest`) {
227 return true;
228 }
229 return (
230 observed.endsWith(`/${desired}`) || observed.endsWith(`/${desired}:latest`)
231 );
232};
233
234const deployReady = (status: string | undefined) =>
235 status === "SUCCESS" || status === "SLEEPING";
236
237const deployFailed = (status: string | undefined) =>
238 status === "FAILED" || status === "CRASHED" || status === "REMOVED";
239
240const undef = <T>(value: T | null | undefined): T | undefined =>
241 value == null ? undefined : value;
242
243const sameWatchPatterns = (
244 observed: readonly string[] | null | undefined,
245 desired: readonly string[] | undefined,
246) => desired === undefined || deepEqual([...(observed ?? [])], [...desired]);
247
248const samePreDeployCommand = (
249 observed: unknown,
250 desired: string | null | undefined,
251) =>
252 desired === undefined ||
253 (desired === null
254 ? observed == null || (Array.isArray(observed) && observed.length === 0)
255 : observed === desired ||
256 (Array.isArray(observed) &&
257 observed.length === 1 &&
258 observed[0] === desired));
259
260const assignIfChanged = <K extends keyof InstanceSettingsDelta>(
261 input: InstanceSettingsDelta,
262 key: K,
263 desired: InstanceSettingsDelta[K] | undefined,
264 observed: unknown,
265): boolean => {
266 if (desired === undefined) return false;
267 if (
268 desired === null
269 ? observed == null
270 : deepEqual(undef(observed as never), desired)
271 ) {
272 return false;
273 }
274 input[key] = desired;
275 return true;
276};
277
278const instanceSettingsDelta = (input: {
279 instance: ServiceInstanceResponse | undefined;
280 sourceImage: string | undefined;
281 sourceRepo: string | undefined;
282 registryCredentials: { username: string; password: string } | undefined;
283 props: {
284 rootDirectory?: string;
285 buildCommand?: string;
286 preDeploy?: { command: string | null };
287 startCommand?: string;
288 healthcheckPath?: string;
289 healthcheck?: string;
290 healthcheckTimeout?: number;
291 cronSchedule?: string;
292 restartPolicyType?: RestartPolicyType;
293 restartPolicyMaxRetries?: number;
294 drainingSeconds?: number;
295 overlapSeconds?: number;
296 sleepApplication?: boolean;
297 dockerfilePath?: string | null;
298 builder?: Builder;
299 watchPatterns?: string[];
300 };
301}): ServiceInstanceUpdateInput | undefined => {
302 const instance = input.instance;
303 const delta: InstanceSettingsDelta = {};
304 let changed = false;
305
306 if (input.sourceRepo !== undefined) {
307 if (undef(instance?.source?.repo) !== input.sourceRepo) {
308 delta.source = { repo: input.sourceRepo };
309 changed = true;
310 }
311 } else if (
312 input.sourceImage !== undefined &&
313 !sameImage(instance?.source?.image, input.sourceImage)
314 ) {
315 delta.source = { image: input.sourceImage };
316 changed = true;
317 }
318
319 if (input.registryCredentials !== undefined) {
320 delta.registryCredentials = input.registryCredentials;
321 changed = true;
322 }
323
324 // Placement is `deploy.multiRegionConfig`, not ServiceInstance.region.
325 // Writing `region` here leaves the workspace default in place.
326 changed =
327 assignIfChanged(
328 delta,
329 "rootDirectory",
330 input.props.rootDirectory,
331 instance?.rootDirectory,
332 ) || changed;
333 changed =
334 assignIfChanged(
335 delta,
336 "buildCommand",
337 input.props.buildCommand,
338 instance?.buildCommand,
339 ) || changed;
340 const preDeployCommand =
341 input.props.preDeploy === undefined
342 ? undefined
343 : input.props.preDeploy.command;
344 if (
345 preDeployCommand !== undefined &&
346 !samePreDeployCommand(instance?.preDeployCommand, preDeployCommand)
347 ) {
348 // Railway's GraphQL field is `[String]`. `null` is a no-op; `[]` clears.
349 delta.preDeployCommand =
350 preDeployCommand === null ? [] : [preDeployCommand];
351 changed = true;
352 }
353 changed =
354 assignIfChanged(
355 delta,
356 "startCommand",
357 input.props.startCommand,
358 instance?.startCommand,
359 ) || changed;
360 changed =
361 assignIfChanged(
362 delta,
363 "healthcheckPath",
364 input.props.healthcheckPath ?? input.props.healthcheck,
365 instance?.healthcheckPath,
366 ) || changed;
367 changed =
368 assignIfChanged(
369 delta,
370 "healthcheckTimeout",
371 input.props.healthcheckTimeout,
372 instance?.healthcheckTimeout,
373 ) || changed;
374 changed =
375 assignIfChanged(
376 delta,
377 "cronSchedule",
378 input.props.cronSchedule,
379 instance?.cronSchedule,
380 ) || changed;
381 changed =
382 assignIfChanged(
383 delta,
384 "restartPolicyType",
385 input.props.restartPolicyType,
386 instance?.restartPolicyType,
387 ) || changed;
388 changed =
389 assignIfChanged(
390 delta,
391 "restartPolicyMaxRetries",
392 input.props.restartPolicyMaxRetries,
393 instance?.restartPolicyMaxRetries,
394 ) || changed;
395 changed =
396 assignIfChanged(
397 delta,
398 "drainingSeconds",
399 input.props.drainingSeconds,
400 instance?.drainingSeconds,
401 ) || changed;
402 changed =
403 assignIfChanged(
404 delta,
405 "overlapSeconds",
406 input.props.overlapSeconds,
407 instance?.overlapSeconds,
408 ) || changed;
409 changed =
410 assignIfChanged(
411 delta,
412 "sleepApplication",
413 input.props.sleepApplication,
414 instance?.sleepApplication,
415 ) || changed;
416 changed =
417 assignIfChanged(
418 delta,
419 "dockerfilePath",
420 input.props.dockerfilePath,
421 instance?.dockerfilePath,
422 ) || changed;
423 changed =
424 assignIfChanged(delta, "builder", input.props.builder, instance?.builder) ||
425 changed;
426
427 const watchPatterns = input.props.watchPatterns;
428 if (
429 watchPatterns !== undefined &&
430 !sameWatchPatterns(instance?.watchPatterns, watchPatterns)
431 ) {
432 delta.watchPatterns = watchPatterns;
433 changed = true;
434 }
435
436 // `serviceInstanceUpdate` defaults `numReplicas` to 1 when omitted.
437 // Pass through the observed count so a dashboard/CLI scale is not
438 // reset on an unrelated settings update.
439 if (changed && instance?.numReplicas != null) {
440 delta.numReplicas = instance.numReplicas;
441 }
442
443 return changed ? delta : undefined;
444};
445
446const listDeploymentTriggers = (
447 projectId: string,
448 environmentId: string,
449 serviceId: string,
450) =>
451 Query.items(
452 Railway.deploymentTriggers({
453 projectId,
454 environmentId,
455 serviceId,
456 first: 50,
457 }).pipe(
458 Query.map((trigger) => ({
459 id: trigger.id,
460 branch: trigger.branch,
461 repository: trigger.repository,
462 provider: trigger.provider,
463 })),
464 ),
465 ).pipe(
466 Stream.runCollect,
467 Effect.map((triggers) => Array.from(triggers)),
468 );
469
470const deploymentTriggerCreate = Query.fn(
471 (input: DeploymentTriggerCreateInput) => ({
472 id: Railway.deploymentTriggerCreate({ input }).id,
473 }),
474);
475
476const deploymentTriggerUpdate = Query.fn(
477 (args: { id: string; input: DeploymentTriggerUpdateInput }) => ({
478 id: Railway.deploymentTriggerUpdate(args).id,
479 }),
480);
481
482const deploymentTriggerDelete = Query.fn((id: string) =>
483 Railway.deploymentTriggerDelete({ id }),
484);
485
486const syncBranch = Effect.fn(function* (input: {
487 projectId: string;
488 environmentId: string;
489 serviceId: string;
490 repo: string;
491 branch: string | undefined;
492}) {
493 if (input.branch === undefined) return false;
494 const triggers = yield* listDeploymentTriggers(
495 input.projectId,
496 input.environmentId,
497 input.serviceId,
498 );
499 const current = triggers[0];
500 if (current === undefined) {
501 yield* deploymentTriggerCreate({
502 branch: input.branch,
503 environmentId: input.environmentId,
504 projectId: input.projectId,
505 provider: "github",
506 repository: input.repo,
507 serviceId: input.serviceId,
508 });
509 return true;
510 }
511 const branchChanged = current.branch !== input.branch;
512 const repoChanged = current.repository !== input.repo;
513 if (!branchChanged && !repoChanged) return false;
514 yield* deploymentTriggerUpdate({
515 id: current.id,
516 input: {
517 ...(branchChanged ? { branch: input.branch } : {}),
518 ...(repoChanged ? { repository: input.repo } : {}),
519 },
520 });
521 return true;
522});
523
524const clearDeploymentTriggers = Effect.fn(function* (input: {
525 projectId: string;
526 environmentId: string;
527 serviceId: string;
528}) {
529 const triggers = yield* listDeploymentTriggers(
530 input.projectId,
531 input.environmentId,
532 input.serviceId,
533 );
534 const github = triggers.filter((trigger) => trigger.provider === "github");
535 yield* Effect.forEach(github, (trigger) =>
536 deploymentTriggerDelete(trigger.id),
537 );
538 return github.length > 0;
539});
540
541const autoDeployStatus = Query.fn(
542 (args: { environmentId: string; projectId: string; serviceId: string }) => ({
543 enabled: Railway.serviceInstanceAutoDeployStatus(args).enabled,
544 }),
545);
546
547const autoDeployUpdate = Query.fn(
548 (input: ServiceInstanceAutoDeployUpdateInput) => ({
549 enabled: Railway.serviceInstanceAutoDeployUpdate({ input }).enabled,
550 }),
551);
552
553const syncAutoUpdates = Effect.fn(function* (input: {
554 projectId: string;
555 environmentId: string;
556 serviceId: string;
557 enabled: boolean | undefined;
558}) {
559 if (input.enabled === undefined) return;
560 const status = yield* autoDeployStatus({
561 environmentId: input.environmentId,
562 projectId: input.projectId,
563 serviceId: input.serviceId,
564 });
565 if (status.enabled === input.enabled) return;
566 yield* autoDeployUpdate({
567 enabled: input.enabled,
568 environmentId: input.environmentId,
569 projectId: input.projectId,
570 serviceId: input.serviceId,
571 });
572});
573
574const waitForInstance = (environmentId: string, serviceId: string) =>
575 getInstance(environmentId, serviceId).pipe(
576 Effect.flatMap((instance) => {
577 if (instance === undefined) {
578 return Effect.fail(
579 new ServicePending({ serviceId, status: "creating" }),
580 );
581 }
582 return Effect.succeed(instance);
583 }),
584 Effect.retry({
585 while: (e) => e._tag === "Railway.ServicePending",
586 // serviceCreate fans the instance out to each environment
587 // asynchronously; wait a bounded interval for it to appear.
588 times: 10,
589 schedule: Schedule.spaced("2 seconds"),
590 }),
591 Effect.catchTag("Railway.ServicePending", () =>
592 getInstance(environmentId, serviceId),
593 ),
594 );
595
596const deploymentLogs = Query.fn((deploymentId: string) =>
597 Railway.deploymentLogs({ deploymentId, limit: 80 }).pipe(
598 Query.map((row) => ({ message: row.message, severity: row.severity })),
599 ),
600);
601
602const fetchDeployLogs = (deploymentId: string | undefined) =>
603 deploymentId === undefined || deploymentId.length === 0
604 ? Effect.succeed("")
605 : deploymentLogs(deploymentId).pipe(
606 Effect.map((rows) =>
607 rows
608 .map((row) =>
609 row.severity != null
610 ? `[${row.severity}] ${row.message}`
611 : row.message,
612 )
613 .join("\n"),
614 ),
615 Effect.orElseSucceed(() => ""),
616 );
617
618// Railway builds can outlast a 50-second poll window. Allow up to 90 seconds
619// while bounding both the retry count and time spent in API requests.
620const waitForDeployment = (environmentId: string, serviceId: string) =>
621 Effect.gen(function* () {
622 const instance = yield* getInstance(environmentId, serviceId);
623 const latest = instance?.latestDeployment;
624 const status = latest?.status;
625 if (status !== undefined && deployFailed(status)) {
626 const logs = yield* fetchDeployLogs(latest?.id);
627 return yield* new ServiceDeployFailed({
628 serviceId,
629 status,
630 deploymentId: latest?.id,
631 logs,
632 });
633 }
634 if (instance !== undefined && deployReady(status)) {
635 return instance;
636 }
637 return yield* new ServiceDeployPending({
638 serviceId,
639 status: status ?? "pending",
640 });
641 }).pipe(
642 Effect.retry({
643 while: (e) => e._tag === "Railway.ServiceDeployPending",
644 times: 10,
645 schedule: Schedule.spaced("8 seconds"),
646 }),
647 Effect.timeoutOrElse({
648 duration: "90 seconds",
649 orElse: () =>
650 Effect.fail(new ServiceDeployPending({ serviceId, status: "pending" })),
651 }),
652 );
653
654type DeployRef = {
655 id: string;
656 status: string | undefined;
657};
658
659const asDeployRef = (
660 row: { id: string; status?: string | null } | undefined,
661): DeployRef | undefined =>
662 row === undefined
663 ? undefined
664 : { id: row.id, status: row.status ?? undefined };
665
666const matchUploadedDeployment = (
667 instance: ServiceInstanceResponse | undefined,
668 deploymentId: string,
669): DeployRef | undefined => {
670 const latest = instance?.latestDeployment;
671 if (latest?.id === deploymentId) return asDeployRef(latest);
672 return asDeployRef(
673 (instance?.activeDeployments ?? []).find(
674 (deployment) => deployment.id === deploymentId,
675 ),
676 );
677};
678
679const listUploadedDeployment = (input: {
680 deploymentId: string;
681 serviceId: string;
682 environmentId: string;
683}) =>
684 Query.items(
685 Railway.deployments({
686 first: 10,
687 input: {
688 serviceId: input.serviceId,
689 environmentId: input.environmentId,
690 },
691 }).pipe(
692 Query.map((deployment) => ({
693 id: deployment.id,
694 status: deployment.status,
695 })),
696 ),
697 ).pipe(
698 Stream.filter((row) => row.id === input.deploymentId),
699 Stream.take(1),
700 Stream.runCollect,
701 Effect.map((chunk) => Array.from(chunk)[0]),
702 Effect.timeoutOrElse({
703 duration: "8 seconds",
704 orElse: () => Effect.succeed(undefined),
705 }),
706 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
707 );
708
709const waitForDeploymentById = (input: {
710 deploymentId: string;
711 serviceId: string;
712 environmentId: string;
713}) =>
714 Effect.gen(function* () {
715 // Poll the instance, not `deployment(id)`. Distilled's deployment
716 // query is a huge nested selection that 404s/times out for /up ids;
717 // `serviceInstance.latestDeployment` is the same record the rest of
718 // reconcile already uses. The placeholder `hashicorp/http-echo`
719 // deploy often FAILED (wrong port / no `/health`) before `railway up`
720 // replaces it — do not inherit that FAILED onto this wait.
721 const instance = yield* getInstance(input.environmentId, input.serviceId);
722 const match =
723 matchUploadedDeployment(instance, input.deploymentId) ??
724 asDeployRef(yield* listUploadedDeployment(input));
725 if (match === undefined) {
726 return yield* new ServiceDeployPending({
727 serviceId: input.serviceId,
728 status: "pending",
729 });
730 }
731 const status = match.status;
732 if (status !== undefined && deployFailed(status)) {
733 const logs = yield* fetchDeployLogs(match.id);
734 return yield* new ServiceDeployFailed({
735 serviceId: input.serviceId,
736 status,
737 deploymentId: match.id,
738 logs,
739 });
740 }
741 if (instance !== undefined && deployReady(status)) {
742 return instance;
743 }
744 return yield* new ServiceDeployPending({
745 serviceId: input.serviceId,
746 status: status ?? "pending",
747 });
748 }).pipe(
749 Effect.retry({
750 while: (e) => e._tag === "Railway.ServiceDeployPending",
751 times: 10,
752 schedule: Schedule.spaced("8 seconds"),
753 }),
754 // Surface the budget as a pending deploy so the final check below still
755 // reports a ready instance, or a failed deploy with its logs.
756 Effect.timeoutOrElse({
757 duration: "90 seconds",
758 orElse: () =>
759 Effect.fail(
760 new ServiceDeployPending({
761 serviceId: input.serviceId,
762 status: "pending",
763 }),
764 ),
765 }),
766 Effect.catchTag("Railway.ServiceDeployPending", (pending) =>
767 Effect.gen(function* () {
768 const instance = yield* getInstance(
769 input.environmentId,
770 input.serviceId,
771 );
772 const match =
773 matchUploadedDeployment(instance, input.deploymentId) ??
774 asDeployRef(yield* listUploadedDeployment(input));
775 const status = match?.status;
776 if (
777 match !== undefined &&
778 status !== undefined &&
779 deployFailed(status)
780 ) {
781 const logs = yield* fetchDeployLogs(match.id);
782 return yield* new ServiceDeployFailed({
783 serviceId: input.serviceId,
784 status,
785 deploymentId: match.id,
786 logs,
787 });
788 }
789 if (instance !== undefined && deployReady(status)) {
790 return instance;
791 }
792 return yield* new ServiceDeployPending({
793 serviceId: input.serviceId,
794 status: status ?? pending.status,
795 });
796 }).pipe(
797 Effect.timeoutOrElse({
798 duration: "10 seconds",
799 orElse: () => Effect.fail(pending),
800 }),
801 ),
802 ),
803 );
804
805const variableUpsert = Query.fn((input: VariableUpsertInput) =>
806 Railway.variableUpsert({ input }),
807);
808
809const readVariables = Query.fn(
810 (args: {
811 projectId: string;
812 environmentId: string;
813 serviceId: string;
814 unrendered: boolean;
815 }) => Railway.variables(args),
816);
817
818const upsertVariable = (input: {
819 projectId: string;
820 environmentId: string;
821 serviceId: string;
822 name: string;
823 value: string;
824}) =>
825 variableUpsert({
826 projectId: input.projectId,
827 environmentId: input.environmentId,
828 serviceId: input.serviceId,
829 name: input.name,
830 value: input.value,
831 skipDeploys: true,
832 });
833
834const asVariableMap = (value: unknown): Record<string, string> => {
835 if (value === null || typeof value !== "object" || Array.isArray(value)) {
836 return {};
837 }
838 const out: Record<string, string> = {};
839 for (const [key, item] of Object.entries(value)) {
840 if (typeof item === "string") {
841 out[key] = item;
842 }
843 }
844 return out;
845};
846
847const listVariableMap = (
848 projectId: string,
849 environmentId: string,
850 serviceId: string,
851) =>
852 readVariables({
853 projectId,
854 environmentId,
855 serviceId,
856 unrendered: true,
857 }).pipe(
858 Effect.map(asVariableMap),
859 Effect.catchTag("RailwayNotFound", () =>
860 Effect.succeed({} as Record<string, string>),
861 ),
862 );
863
864const syncEnv = Effect.fn(function* (input: {
865 projectId: string;
866 environmentId: string;
867 serviceId: string;
868 desired: Record<string, string>;
869}) {
870 if (Object.keys(input.desired).length === 0) return false;
871 const observed = yield* listVariableMap(
872 input.projectId,
873 input.environmentId,
874 input.serviceId,
875 );
876 let changed = false;
877 for (const [name, value] of Object.entries(input.desired)) {
878 if (observed[name] !== value) {
879 yield* upsertVariable({
880 projectId: input.projectId,
881 environmentId: input.environmentId,
882 serviceId: input.serviceId,
883 name,
884 value,
885 });
886 changed = true;
887 }
888 }
889 return changed;
890});
891
892const syncMounts = Effect.fn(function* (input: {
893 environmentId: string;
894 projectId: string;
895 serviceId: string;
896 mounts: MountSpec[];
897}) {
898 for (const mount of input.mounts) {
899 yield* attachVolumeToService({
900 environmentId: input.environmentId,
901 projectId: input.projectId,
902 serviceId: input.serviceId,
903 volumeId: mount.volumeId,
904 mountPath: mount.path,
905 });
906 }
907});
908
909const serviceCreate = Query.fn((input: ServiceCreateInput) =>
910 serviceFields(Railway.serviceCreate({ input })),
911);
912
913const serviceUpdate = Query.fn((id: string, name: string) =>
914 serviceFields(Railway.serviceUpdate({ id, input: { name } })),
915);
916
917const serviceDisconnect = Query.fn((id: string) => ({
918 id: Railway.serviceDisconnect({ id }).id,
919}));
920
921const serviceInstanceUpdate = Query.fn(
922 (args: {
923 environmentId: string;
924 serviceId: string;
925 input: ServiceInstanceUpdateInput;
926 }) => Railway.serviceInstanceUpdate(args),
927);
928
929const serviceInstanceDeploy = Query.fn(
930 (environmentId: string, serviceId: string) =>
931 Railway.serviceInstanceDeployV2({ environmentId, serviceId }),
932);
933
934const serviceDelete = Query.fn((id: string) => Railway.serviceDelete({ id }));
935
936const toAttrs = (input: {
937 service: CloudService;
938 instance: ServiceInstanceResponse | undefined;
939 domain: ServiceDomainRecord | undefined;
940 projectId: string;
941 environmentId: string;
942 port: number | undefined;
943 codeHash: string;
944 rpcToken: string;
945 region: string | undefined;
946}): Service["Attributes"] => ({
947 serviceId: input.service.id,
948 name: input.service.name,
949 projectId: input.projectId,
950 environmentId: input.environmentId,
951 image: input.instance?.source?.image ?? undefined,
952 repo: input.instance?.source?.repo ?? undefined,
953 healthcheckPath: input.instance?.healthcheckPath ?? undefined,
954 healthcheckTimeout: input.instance?.healthcheckTimeout ?? undefined,
955 replicas: input.instance?.numReplicas ?? undefined,
956 buildCommand: input.instance?.buildCommand ?? undefined,
957 startCommand: input.instance?.startCommand ?? undefined,
958 cronSchedule: input.instance?.cronSchedule ?? undefined,
959 rootDirectory: input.instance?.rootDirectory ?? undefined,
960 region: input.region,
961 port: input.port,
962 url: input.domain?.url,
963 domain: input.domain?.domain,
964 dnsName: `${input.service.name}.railway.internal`,
965 rpcToken: input.rpcToken,
966 domainId: input.domain?.id,
967 deploymentId: input.instance?.latestDeployment?.id,
968 deploymentStatus: input.instance?.latestDeployment?.status,
969 code: { hash: input.codeHash },
970});
971
972export const ServiceProvider = () =>
973 Provider.effect(
974 Service,
975 Effect.gen(function* () {
976 const stack = yield* Stack;
977 const { dotAlchemy } = yield* AlchemyContext;
978 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
979 const hosted = createRailwayHostedSupport({
980 stackName: stack.name,
981 stage: stack.stage,
982 virtualEntryPlugin,
983 dotAlchemy,
984 });
985
986 return Service.Provider.of({
987 stables: ["serviceId", "projectId", "environmentId"],
988 nuke: { dependsOn: ["Railway.Project"] },
989
990 diff: Effect.fn(function* ({ news: desired, output }) {
991 // Runtime exports contain Effects that remain unevaluated during
992 // planning. The bundle hash covers their code; they must not prevent
993 // code-only changes from reaching the hash comparison below.
994 const news = stripEffects(desired);
995 if (news === undefined || !isResolved(news)) return undefined;
996 if (output === undefined) return undefined;
997 const nextProject = projectIdOf(news.project);
998 const projectChanged =
999 nextProject !== undefined && nextProject !== output.projectId;
1000 const nextEnv = environmentIdOf(news.environment);
1001 const environmentChanged =
1002 nextEnv !== undefined && nextEnv !== output.environmentId;
1003 if (projectChanged || environmentChanged) {
1004 return { action: "replace" as const };
1005 }
1006 const source = yield* resolveRailwayServiceSource(news);
1007 if (source.mode === "main") {
1008 const hash = yield* hosted.hash({
1009 main: source.main,
1010 handler: news.handler,
1011 port: news.port,
1012 image: news.image,
1013 env: news.env,
1014 isExternal: news.isExternal,
1015 build: news.build,
1016 extraFiles: news.extraFiles,
1017 });
1018 if (hash !== output.code.hash) {
1019 return { action: "update" as const };
1020 }
1021 } else if (source.mode === "context") {
1022 const hash = yield* hashRailwayLocalContext(source);
1023 if (hash !== output.code.hash) {
1024 return { action: "update" as const };
1025 }
1026 }
1027 return undefined;
1028 }),
1029
1030 read: Effect.fn(function* ({ id, olds, output }) {
1031 const projectId =
1032 output?.projectId ??
1033 (olds !== undefined ? projectIdOf(olds.project) : undefined);
1034 const environmentId =
1035 output?.environmentId ??
1036 (olds !== undefined
1037 ? (environmentIdOf(olds.environment) ??
1038 environmentIdOf(olds.project))
1039 : undefined);
1040 const name = yield* resolveName(id, olds?.name, output?.name);
1041 const byId =
1042 output?.serviceId !== undefined && output.serviceId.length > 0
1043 ? yield* getById(output.serviceId)
1044 : undefined;
1045 const found =
1046 byId ??
1047 (projectId !== undefined
1048 ? yield* findByName(projectId, name)
1049 : undefined);
1050 if (found === undefined) return undefined;
1051 const resolvedProjectId = projectIdOf(found) ?? projectId ?? "";
1052 const resolvedEnvId =
1053 environmentId ??
1054 environmentIdOf(olds?.project) ??
1055 output?.environmentId ??
1056 "";
1057 const instance =
1058 resolvedEnvId.length > 0
1059 ? yield* getInstance(resolvedEnvId, found.id)
1060 : undefined;
1061 // A recorded domain id is the ownership boundary. Refresh an owned
1062 // domain, but do not claim a generated domain found during adoption.
1063 const domain =
1064 output?.domainId !== undefined &&
1065 resolvedProjectId.length > 0 &&
1066 resolvedEnvId.length > 0
1067 ? yield* findServiceDomainById({
1068 projectId: resolvedProjectId,
1069 environmentId: resolvedEnvId,
1070 serviceId: found.id,
1071 domainId: output.domainId,
1072 })
1073 : undefined;
1074 const region =
1075 resolvedEnvId.length > 0
1076 ? yield* readServiceRegion({
1077 environmentId: resolvedEnvId,
1078 projectId: resolvedProjectId,
1079 serviceId: found.id,
1080 legacy: instance?.region,
1081 })
1082 : undefined;
1083 const attrs = toAttrs({
1084 service: found,
1085 instance,
1086 domain,
1087 projectId: resolvedProjectId,
1088 environmentId: resolvedEnvId,
1089 port: output?.port ?? olds?.port,
1090 codeHash: output?.code.hash ?? "",
1091 rpcToken: output?.rpcToken ?? "",
1092 region,
1093 });
1094 if (output !== undefined) {
1095 // Keep the recorded ownership id even if the live list lags.
1096 // Wiping it makes `publicDomain: false` a no-op.
1097 if (domain === undefined && output.domainId !== undefined) {
1098 return {
1099 ...attrs,
1100 domainId: output.domainId,
1101 domain: output.domain,
1102 url: output.url,
1103 };
1104 }
1105 return attrs;
1106 }
1107 return matchesAlchemyPhysicalName(found.name)
1108 ? attrs
1109 : Unowned(attrs);
1110 }),
1111
1112 list: Effect.fn(function* () {
1113 const projects = yield* ownedProjects();
1114 const rows = yield* Effect.forEach(projects, (project) =>
1115 listProjectServices(project.projectId).pipe(
1116 Effect.map((services) =>
1117 services
1118 .filter((service) => matchesAlchemyPhysicalName(service.name))
1119 .map((service) =>
1120 toAttrs({
1121 service,
1122 instance: undefined,
1123 domain: undefined,
1124 projectId: project.projectId,
1125 environmentId: project.environmentId,
1126 port: undefined,
1127 codeHash: "",
1128 rpcToken: "",
1129 region: undefined,
1130 }),
1131 ),
1132 ),
1133 ),
1134 );
1135 return rows.flat();
1136 }),
1137
1138 // Circular Service↔Function RPC binds `dnsName` / `port` / `rpcToken`.
1139 // Those are knowable from the physical name and props; the cloud
1140 // service is created in reconcile (and re-synced in converge).
1141 precreate: Effect.fn(function* ({ id, news }) {
1142 const name = yield* resolveName(
1143 id,
1144 typeof news.name === "string" ? news.name : undefined,
1145 );
1146 const port = typeof news.port === "number" ? news.port : DEFAULT_PORT;
1147 return {
1148 serviceId: "",
1149 name,
1150 projectId: "",
1151 environmentId: "",
1152 image: undefined,
1153 repo: undefined,
1154 healthcheckPath: undefined,
1155 healthcheckTimeout: undefined,
1156 replicas: undefined,
1157 buildCommand: undefined,
1158 startCommand: undefined,
1159 cronSchedule: undefined,
1160 rootDirectory: undefined,
1161 region: undefined,
1162 port,
1163 url: undefined,
1164 domain: undefined,
1165 dnsName: `${name}.railway.internal`,
1166 rpcToken: plainEnvValue(news.rpcToken) ?? "",
1167 domainId: undefined,
1168 deploymentId: undefined,
1169 deploymentStatus: undefined,
1170 code: { hash: "" },
1171 } satisfies Service["Attributes"];
1172 }),
1173
1174 reconcile: Effect.fn(function* ({
1175 id,
1176 news,
1177 olds,
1178 output,
1179 bindings,
1180 session,
1181 }) {
1182 const props = news;
1183 const projectId = projectIdOf(props.project) ?? output?.projectId;
1184 if (projectId === undefined) {
1185 return yield* new ServiceProjectRequired({
1186 message: "Service requires a resolved Railway.Project",
1187 });
1188 }
1189 const environmentId =
1190 environmentIdOf(props.environment) ??
1191 environmentIdOf(props.project) ??
1192 output?.environmentId;
1193 if (environmentId === undefined) {
1194 return yield* new ServiceProjectRequired({
1195 message:
1196 "Service requires a Railway environment (pass environment or a Project with environmentId)",
1197 });
1198 }
1199 const name = yield* resolveName(id, props.name, output?.name);
1200 const source = yield* resolveRailwayServiceSource(props);
1201 const hostedMain = source.mode === "main" ? source.main : undefined;
1202 const localContext: RailwayLocalContextSource | undefined =
1203 source.mode === "context" ? source : undefined;
1204 const bound = collectBindingState(bindings ?? []);
1205 yield* assertHostDisk({
1206 name,
1207 mounts: bound.mounts,
1208 });
1209 const port =
1210 hostedMain !== undefined
1211 ? (props.port ?? DEFAULT_PORT)
1212 : props.port;
1213 const rpcToken =
1214 plainEnvValue(props.rpcToken) ?? output?.rpcToken ?? "";
1215 const env = {
1216 ...bound.env,
1217 ...(hostedMain !== undefined ? hosted.alchemyEnv : {}),
1218 ...(port !== undefined ? { PORT: String(port) } : {}),
1219 ...toEnvRecord(props.env),
1220 [RPC_TOKEN_ENV]: rpcToken,
1221 };
1222
1223 let sourceImage: string | undefined;
1224 let sourceRepo: string | undefined;
1225 let localPrepared:
1226 | {
1227 codeHash: string;
1228 dockerfilePath: string;
1229 tarball: Uint8Array;
1230 }
1231 | undefined;
1232 let codeHash = output?.code.hash ?? "";
1233 let hashed:
1234 | {
1235 bundled: {
1236 files: ReadonlyArray<{
1237 path: string;
1238 content: string | Uint8Array;
1239 }>;
1240 };
1241 dockerfile: string;
1242 codeHash: string;
1243 packageJson: string | undefined;
1244 }
1245 | undefined;
1246 if (source.mode === "main") {
1247 yield* (session?.note ?? ((_message: string) => Effect.void))(
1248 `Bundling ${id} program...`,
1249 );
1250 hashed = yield* hosted.computeCodeHash({
1251 main: source.main,
1252 handler: props.handler,
1253 port,
1254 image: props.image,
1255 env: props.env,
1256 isExternal: props.isExternal,
1257 build: props.build,
1258 extraFiles: props.extraFiles,
1259 });
1260 codeHash = hashed.codeHash;
1261 } else if (source.mode === "context") {
1262 localPrepared = yield* prepareRailwayLocalContext(source);
1263 codeHash = localPrepared.codeHash;
1264 } else if (source.mode === "image") {
1265 sourceImage = source.image;
1266 } else {
1267 sourceRepo = source.repo;
1268 }
1269
1270 let current: CloudService | undefined =
1271 output?.serviceId !== undefined && output.serviceId.length > 0
1272 ? yield* getById(output.serviceId)
1273 : undefined;
1274 if (current === undefined) {
1275 current = yield* findByName(projectId, name);
1276 }
1277
1278 if (current === undefined) {
1279 const created = yield* serviceCreate({
1280 projectId,
1281 environmentId,
1282 name,
1283 ...(sourceRepo !== undefined
1284 ? { source: { repo: sourceRepo } }
1285 : {
1286 source: {
1287 image: sourceImage ?? "hashicorp/http-echo",
1288 },
1289 }),
1290 ...(sourceRepo !== undefined && props.branch !== undefined
1291 ? { branch: props.branch }
1292 : {}),
1293 }).pipe(
1294 Effect.catchTag("RailwayValidationError", () =>
1295 Effect.succeed(undefined),
1296 ),
1297 );
1298 current = created ?? (yield* findByName(projectId, name));
1299 }
1300
1301 if (current === undefined || isGoneService(current)) {
1302 return yield* new ServiceNotCreated({ name, projectId });
1303 }
1304
1305 if (current.name !== name) {
1306 current = yield* serviceUpdate(current.id, name);
1307 }
1308
1309 // The service instance must exist in this environment before a
1310 // domain can be generated (`railway domain` / Terraform both
1311 // operate on a live service instance). Extra non-fork
1312 // environments lag — `serviceCreate` fans out to every
1313 // non-fork env and `serviceInstance` 404s until it lands.
1314 let instance = yield* waitForInstance(environmentId, current.id);
1315
1316 const publicDomain = props.publicDomain !== false;
1317 let domain: ServiceDomainRecord | undefined;
1318 if (publicDomain) {
1319 // Railway's generated-domain API refuses a service that already
1320 // has PORT (or other env) set — it returns "please try again"
1321 // forever. Create the hostname on a bare service, then sync env.
1322 yield* (session?.note ?? ((_message: string) => Effect.void))(
1323 `Creating service domain for ${id}...`,
1324 );
1325 domain = yield* ensureServiceDomain({
1326 projectId,
1327 environmentId,
1328 serviceId: current.id,
1329 domainId: output?.domainId ?? null,
1330 });
1331 } else {
1332 // Only the recorded generated domain belongs to this resource.
1333 // Adoption does not populate domainId/domain, so a foreign
1334 // domain stays. Null the environment-config key — GraphQL
1335 // delete alone is not enough; Railway recreates from config.
1336 yield* deleteOwnedServiceDomain({
1337 projectId,
1338 environmentId,
1339 serviceId: current.id,
1340 domainId: output?.domainId,
1341 domain: output?.domain,
1342 });
1343 }
1344
1345 const attached = yield* listServiceVolumes(
1346 environmentId,
1347 projectId,
1348 current.id,
1349 );
1350 yield* assertHostDisk({
1351 name,
1352 mounts: [
1353 ...bound.mounts,
1354 ...attached.map((row) => ({
1355 volumeId: row.volumeId,
1356 path: row.mountPath,
1357 })),
1358 ],
1359 });
1360 let needsDeploy = false;
1361 let localSourceChanged =
1362 localContext !== undefined &&
1363 (instance?.source?.repo != null || instance?.source?.image != null);
1364
1365 if (localSourceChanged) {
1366 yield* serviceDisconnect(current.id);
1367 needsDeploy = true;
1368 instance =
1369 (yield* getInstance(environmentId, current.id)) ?? instance;
1370 }
1371
1372 const leavingContext =
1373 olds?.context !== undefined && olds.context.length > 0;
1374 const clearsDockerfilePath =
1375 leavingContext &&
1376 (source.mode === "image" ||
1377 (source.mode === "repo" && props.dockerfilePath === undefined));
1378 const dockerfilePath =
1379 localPrepared?.dockerfilePath ??
1380 (clearsDockerfilePath ? null : props.dockerfilePath);
1381 const instanceDelta = instanceSettingsDelta({
1382 instance,
1383 sourceImage,
1384 sourceRepo,
1385 registryCredentials: undefined,
1386 props: {
1387 ...props,
1388 dockerfilePath,
1389 // Effect-native images must answer HTTP before Railway
1390 // stamps SUCCESS — otherwise waitForDeploymentById returns
1391 // while `/up` is still building and public GET hangs.
1392 // Default `/health`, not `/`: user fetch handlers often 404
1393 // `/` (BucketApi) and Railway treats that as FAILED.
1394 healthcheckPath:
1395 props.healthcheckPath ??
1396 props.healthcheck ??
1397 (hostedMain !== undefined ? "/health" : undefined),
1398 healthcheckTimeout:
1399 props.healthcheckTimeout ??
1400 (hostedMain !== undefined ? 300 : undefined),
1401 },
1402 });
1403 if (instanceDelta !== undefined) {
1404 yield* serviceInstanceUpdate({
1405 environmentId,
1406 serviceId: current.id,
1407 input: instanceDelta,
1408 });
1409 needsDeploy = true;
1410 instance =
1411 (yield* getInstance(environmentId, current.id)) ?? instance;
1412 }
1413
1414 // Pin `deploy.multiRegionConfig` before the build, then again after,
1415 // in case the deploy wrote the workspace default back.
1416 const serviceId = current.id;
1417 const placeRegion = () =>
1418 syncServiceRegion({
1419 environmentId,
1420 projectId,
1421 serviceId,
1422 region: props.region,
1423 legacy: instance?.region,
1424 fallbackReplicas: instance?.numReplicas,
1425 });
1426 let region = yield* placeRegion();
1427
1428 if (sourceRepo !== undefined) {
1429 const branchChanged = yield* syncBranch({
1430 projectId,
1431 environmentId,
1432 serviceId: current.id,
1433 repo: sourceRepo,
1434 branch: props.branch,
1435 });
1436 if (branchChanged) needsDeploy = true;
1437 }
1438
1439 if (localContext !== undefined) {
1440 const triggersChanged = yield* clearDeploymentTriggers({
1441 projectId,
1442 environmentId,
1443 serviceId: current.id,
1444 });
1445 if (triggersChanged) {
1446 needsDeploy = true;
1447 localSourceChanged = true;
1448 }
1449 }
1450
1451 yield* syncAutoUpdates({
1452 projectId,
1453 environmentId,
1454 serviceId: current.id,
1455 enabled: props.autoUpdates,
1456 });
1457
1458 const envChanged = yield* syncEnv({
1459 projectId,
1460 environmentId,
1461 serviceId: current.id,
1462 desired: env,
1463 });
1464 if (envChanged) needsDeploy = true;
1465
1466 if (publicDomain && port !== undefined) {
1467 domain = yield* ensureServiceDomain({
1468 projectId,
1469 environmentId,
1470 serviceId: current.id,
1471 domainId: domain?.id,
1472 targetPort: port,
1473 });
1474 }
1475
1476 yield* syncMounts({
1477 environmentId,
1478 projectId,
1479 serviceId: current.id,
1480 mounts: bound.mounts,
1481 });
1482
1483 const uploadSource =
1484 hostedMain !== undefined || localContext !== undefined;
1485 const latestOk = deployReady(instance?.latestDeployment?.status);
1486 const shouldUpload =
1487 uploadSource &&
1488 (codeHash !== output?.code.hash || !latestOk || localSourceChanged);
1489
1490 if (shouldUpload) {
1491 const note = session?.note ?? ((_message: string) => Effect.void);
1492 yield* note(`Preparing ${id} build context...`);
1493 const tarball =
1494 localPrepared !== undefined
1495 ? localPrepared.tarball
1496 : hostedMain !== undefined && hashed !== undefined
1497 ? yield* hosted
1498 .writeContext({
1499 id,
1500 props: {
1501 main: hostedMain,
1502 handler: props.handler,
1503 port,
1504 image: props.image,
1505 env: props.env,
1506 isExternal: props.isExternal,
1507 build: props.build,
1508 extraFiles: props.extraFiles,
1509 },
1510 hashed,
1511 })
1512 .pipe(Effect.flatMap(tarGzipDirectory))
1513 : yield* new ServiceSourceInvalid({
1514 message: "Railway.Service upload source was not prepared",
1515 });
1516 yield* note(`Uploading ${id} build context to Railway...`);
1517 const uploaded = yield* uploadDeployTarball({
1518 projectId,
1519 environmentId,
1520 serviceId: current.id,
1521 tarball,
1522 message: `alchemy ${id} ${codeHash}`,
1523 });
1524 yield* note(`Queued Railway build ${uploaded.deploymentId}`);
1525 instance =
1526 (yield* waitForDeploymentById({
1527 deploymentId: uploaded.deploymentId,
1528 serviceId: current.id,
1529 environmentId,
1530 })) ?? instance;
1531 } else if (
1532 !uploadSource &&
1533 (needsDeploy || instance?.latestDeployment == null)
1534 ) {
1535 yield* serviceInstanceDeploy(environmentId, current.id).pipe(
1536 Effect.catchTag("RailwayValidationError", () => Effect.void),
1537 );
1538 instance =
1539 sourceRepo !== undefined
1540 ? ((yield* getInstance(environmentId, current.id)) ?? instance)
1541 : ((yield* waitForDeployment(environmentId, current.id)) ??
1542 instance);
1543 } else if (uploadSource && needsDeploy) {
1544 yield* serviceInstanceDeploy(environmentId, current.id).pipe(
1545 Effect.catchTag("RailwayValidationError", () => Effect.void),
1546 );
1547 instance =
1548 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
1549 }
1550
1551 region = yield* placeRegion();
1552
1553 return toAttrs({
1554 service: current,
1555 instance,
1556 domain,
1557 projectId,
1558 environmentId,
1559 port,
1560 codeHash,
1561 rpcToken,
1562 region,
1563 });
1564 }),
1565
1566 delete: Effect.fn(function* ({ output }) {
1567 const serviceId = output.serviceId;
1568 if (serviceId.length === 0) return;
1569 yield* serviceDelete(serviceId).pipe(
1570 Effect.catchTag("RailwayNotFound", () => Effect.void),
1571 );
1572 yield* waitUntilDeleted(
1573 "Service",
1574 serviceId,
1575 getById(serviceId).pipe(
1576 Effect.map((service) => service === undefined),
1577 ),
1578 );
1579 }),
1580 });
1581 }),
1582 );