1import {
2 environmentServiceInstances,
3 waitUntilDeleted,
4 projectServices,
5} from "./GraphQL.ts";
6import { randomBytes } from "node:crypto";
7import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
8import {
9 Railway,
10 type ServiceCreateInput,
11 type ServiceInstance,
12 type ServiceInstanceUpdateInput,
13 type Service as RailwayService,
14 type VariableUpsertInput,
15} from "@distilled.cloud/railway";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import * as Redacted from "effect/Redacted";
19import * as Schedule from "effect/Schedule";
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 {
26 ownedProjects,
27 projectEnvironmentIds,
28 type Project,
29} from "./Project.ts";
30import type { Providers } from "./Providers.ts";
31
32/**
33 * A resource-valued prop: the resource itself, or an Effect that produces
34 * it (so `yield* Project(...)` and `Project(...)` both type-check).
35 */
36type Ref<T> = T | Effect.Effect<T, never, Providers>;
37
38/**
39 * Environment identity a Redis service is deployed into. Accepts a
40 * `Railway.Project` (its primary environment), a `Railway.Environment`,
41 * or an `{ environmentId }` stub.
42 */
43export type RedisEnvironment = {
44 readonly environmentId: string;
45};
46
47export const DEFAULT_REDIS_IMAGE = "redis:7";
48export const REDIS_PORT = 6379;
49import { REDIS_URL_ENV } from "./RedisBinding.ts";
50
51const serviceFields = <E>(service: Query<RailwayService, E>) => ({
52 id: service.id,
53 name: service.name,
54 deletedAt: service.deletedAt,
55});
56const attributeInstanceFields = <E>(instance: Query<ServiceInstance, E>) => ({
57 source: instance.source.pipe(
58 Query.map((source) => ({ image: source.image })),
59 ),
60 region: instance.region,
61 latestDeployment: instance.latestDeployment.pipe(
62 Query.map((deployment) => ({
63 id: deployment.id,
64 status: deployment.status,
65 })),
66 ),
67});
68const instanceFields = <E>(instance: Query<ServiceInstance, E>) => ({
69 ...attributeInstanceFields(instance),
70 deletedAt: instance.deletedAt,
71 sleepApplication: instance.sleepApplication,
72 startCommand: instance.startCommand,
73});
74type CloudService = UnwrapPlan<ReturnType<typeof serviceFields>>;
75type AttributeInstance = UnwrapPlan<ReturnType<typeof attributeInstanceFields>>;
76type ServiceInstanceResponse = UnwrapPlan<ReturnType<typeof instanceFields>>;
77
78export { REDIS_URL_ENV };
79export const REDIS_PASSWORD_ENV = "REDISPASSWORD";
80
81export interface RedisProps {
82 /**
83 * Parent Railway Project. Accepts a `Railway.Project` or an Effect
84 * that produces one. Changing the Project replaces Redis.
85 */
86 project: Ref<Project>;
87 /**
88 * Environment to deploy into. Accepts a `Railway.Project` (primary
89 * environment), a `Railway.Environment`, or `{ environmentId }`.
90 * Defaults to the project's primary environment. Changing it replaces
91 * Redis.
92 */
93 environment?: Ref<RedisEnvironment>;
94 /**
95 * Service name. Unique per Project. If omitted, a unique name is
96 * generated from the stack, stage and logical ID. Changing it updates
97 * in place via `serviceUpdate`.
98 */
99 name?: string;
100 /**
101 * Docker image. Official Redis (`redis:7`) gets `--requirepass`.
102 * Bitnami (`bitnami/redis`) reads `REDIS_PASSWORD`.
103 *
104 * @default "redis:7"
105 */
106 image?: string;
107 /**
108 * Region for the service instance (`us-west2`, `us-east4`, …). If
109 * omitted, Railway picks the default. Updates in place.
110 */
111 region?: string;
112 /**
113 * Redis password. Wrap with `Redacted.make(...)`. If omitted, a
114 * password is generated on first create and kept. Never persisted in
115 * attributes.
116 */
117 password?: Redacted.Redacted<string> | string;
118}
119
120export type Redis = Resource<
121 "Railway.Redis",
122 RedisProps,
123 {
124 /** Railway service id. */
125 serviceId: string;
126 /** Physical service name (unique per project). */
127 name: string;
128 /** Parent Railway project id. */
129 projectId: string;
130 /** Environment the instance is deployed in. */
131 environmentId: string;
132 /** Observed `source.image`. */
133 image: string;
134 /** Observed region, if Railway reported one. */
135 region: string | undefined;
136 /** Redis listen port (`6379`). */
137 port: number;
138 /** Private hostname (`{name}.railway.internal`). */
139 privateHost: string;
140 /** Latest deployment id, if one exists. */
141 deploymentId: string | undefined;
142 /** Latest deployment status (`SUCCESS`, `DEPLOYING`, …). */
143 deploymentStatus: string | undefined;
144 },
145 never,
146 Providers
147>;
148
149const resolveRedisProps = (
150 props: RedisProps | Effect.Effect<RedisProps, never, Providers>,
151): Effect.Effect<RedisProps, never, Providers> =>
152 Effect.gen(function* () {
153 const resolved = Effect.isEffect(props) ? yield* props : props;
154 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
155 const project = Effect.isEffect(resolved.project)
156 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
157 : resolved.project;
158 const environment =
159 resolved.environment === undefined
160 ? undefined
161 : Effect.isEffect(resolved.environment)
162 ? yield* resolved.environment as Effect.Effect<
163 RedisEnvironment,
164 never,
165 Providers
166 >
167 : resolved.environment;
168 return { ...resolved, project, environment };
169 });
170
171const RedisResource = Resource<Redis>("Railway.Redis");
172
173/**
174 * A Railway.Redis is a Redis container in a Project (`redis:7` or
175 * `bitnami/redis`). Alchemy writes `REDIS_URL` / `REDISPASSWORD` on the
176 * service. Bind {@link ReadRedis}, {@link WriteRedis}, or
177 * {@link ReadWriteRedis} on a {@link Service}. Reach it from a laptop
178 * through {@link TcpProxy} on port `6379`.
179 *
180 * @see https://docs.railway.com/guides/redis
181 *
182 * ### Create Redis
183 * Pass a Project. Alchemy generates a unique name and password unless
184 * you pass them. Default image is `redis:7`.
185 *
186 * **Example:** Generated name
187 * ```typescript
188 * const site = yield* Railway.Project("Site");
189 * const cache = yield* Railway.Redis("Cache", { project: site });
190 * ```
191 *
192 * :::note
193 * Prefer omitting `name` in tests and CI so names stay unique and
194 * reclaimable.
195 * :::
196 *
197 * ### A stable name
198 * Pass `name` when you want a stable service name. Changing it later
199 * updates the service in place.
200 *
201 * **Example:** Explicit name
202 * ```typescript
203 * const cache = yield* Railway.Redis("Cache", {
204 * project: site,
205 * name: "cache",
206 * });
207 * ```
208 *
209 * ### Image
210 * Official Redis (`redis:7`) is started with `--requirepass`. Bitnami
211 * reads `REDIS_PASSWORD`. Updating the image is in place.
212 *
213 * **Example:** Bitnami
214 * ```typescript
215 * const cache = yield* Railway.Redis("Cache", {
216 * project: site,
217 * image: "bitnami/redis",
218 * });
219 * ```
220 *
221 * ### Public TCP
222 * Attach a {@link TcpProxy} on `6379` to reach Redis from outside the
223 * private network.
224 *
225 * **Example:** Laptop access
226 * ```typescript
227 * const cache = yield* Railway.Redis("Cache", { project: site });
228 * const proxy = yield* Railway.TcpProxy("CacheProxy", {
229 * redis: cache,
230 * environment: site,
231 * applicationPort: 6379,
232 * });
233 * ```
234 *
235 * ### Bind from a Service
236 * Yield {@link ReadWriteRedis} (or {@link ReadRedis} / {@link WriteRedis})
237 * in Service init. Provide the matching `*Http` layer. Runtime commands
238 * use `REDIS_URL` (`{name}.railway.internal`).
239 *
240 * **Example:** Read and write
241 * ```typescript
242 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
243 *
244 * export const Site = Railway.Project("Site");
245 * export const Cache = Railway.Redis("Cache", { project: Site });
246 *
247 * export default class Api extends Railway.Service<Api>()(
248 * "Api",
249 * { project: Site, main: import.meta.url },
250 * Effect.gen(function* () {
251 * const cache = yield* Railway.ReadWriteRedis(Cache);
252 * return {
253 * fetch: Effect.gen(function* () {
254 * yield* cache.set("marker", "hello");
255 * const value = yield* cache.get("marker");
256 * return HttpServerResponse.json({ value });
257 * }),
258 * };
259 * }).pipe(Effect.provide(Railway.ReadWriteRedisHttp)),
260 * ) {}
261 * ```
262 *
263 * ### Module-scope declarations
264 * Resource-valued props accept the resource or an Effect producing it.
265 *
266 * **Example:** Module-scope Redis
267 * ```typescript
268 * // src/cache.ts
269 * import * as Railway from "alchemy/Railway";
270 *
271 * export const Site = Railway.Project("Site");
272 * export const Cache = Railway.Redis("Cache", { project: Site });
273 * ```
274 *
275 * @resource
276 * @product Redis
277 */
278export const Redis: typeof RedisResource = Object.assign(
279 (
280 id: string,
281 props: RedisProps | Effect.Effect<RedisProps, never, Providers>,
282 ) => RedisResource(id, resolveRedisProps(props)),
283 RedisResource,
284);
285
286export {
287 CommandError as RedisCommandError,
288 UrlMissing as RedisUrlMissing,
289} from "../Redis/index.ts";
290
291export class RedisNotCreated extends Data.TaggedError(
292 "Railway.RedisNotCreated",
293)<{
294 name: string;
295 projectId: string;
296}> {}
297
298export class RedisProjectRequired extends Data.TaggedError(
299 "Railway.RedisProjectRequired",
300)<{
301 message: string;
302}> {}
303
304export class RedisDeployFailed extends Data.TaggedError(
305 "Railway.RedisDeployFailed",
306)<{
307 serviceId: string;
308 status: string;
309 deploymentId: string | undefined;
310}> {}
311
312class RedisPending extends Data.TaggedError("Railway.RedisPending")<{
313 serviceId: string;
314 status: string;
315}> {}
316
317class RedisDeployPending extends Data.TaggedError(
318 "Railway.RedisDeployPending",
319)<{
320 serviceId: string;
321 status: string;
322}> {}
323
324const projectIdOf = (value: unknown): string | undefined => {
325 if (value === null || typeof value !== "object") return undefined;
326 const rec = value as { projectId?: unknown };
327 return typeof rec.projectId === "string" && rec.projectId.length > 0
328 ? rec.projectId
329 : undefined;
330};
331
332const environmentIdOf = (value: unknown): string | undefined => {
333 if (value === null || typeof value !== "object") return undefined;
334 const rec = value as { environmentId?: unknown };
335 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
336 ? rec.environmentId
337 : undefined;
338};
339
340const isGoneService = (service: CloudService | undefined) =>
341 service === undefined || service.deletedAt != null;
342
343const isGoneInstance = (instance: ServiceInstanceResponse | undefined) =>
344 instance === undefined || instance.deletedAt != null;
345
346const resolveName = (id: string, name: string | undefined, existing?: string) =>
347 Effect.gen(function* () {
348 if (name !== undefined) return name;
349 if (existing !== undefined) return existing;
350 return yield* createRailwayName(id);
351 });
352
353const unwrapSecret = (value: Redacted.Redacted<string> | string): string =>
354 Redacted.isRedacted(value) ? Redacted.value(value) : value;
355
356const generatePassword = Effect.sync(() => {
357 const bytes = randomBytes(24);
358 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
359});
360
361const isBitnamiImage = (image: string) => /bitnami/i.test(image);
362
363export const isRedisImage = (image: string | null | undefined): boolean =>
364 image != null && /redis/i.test(image);
365
366const startCommandFor = (
367 image: string,
368 password: string,
369): string | undefined =>
370 isBitnamiImage(image) ? undefined : `redis-server --requirepass ${password}`;
371
372const privateHostOf = (name: string) => `${name}.railway.internal`;
373
374const sameImage = (observed: string | null | undefined, desired: string) => {
375 if (observed == null || observed.length === 0) return false;
376 if (observed === desired) return true;
377 if (observed === `${desired}:latest` || desired === `${observed}:latest`) {
378 return true;
379 }
380 return (
381 observed.endsWith(`/${desired}`) || observed.endsWith(`/${desired}:latest`)
382 );
383};
384
385const deployReady = (status: string | undefined) =>
386 status === "SUCCESS" || status === "SLEEPING";
387
388const deployFailed = (status: string | undefined) =>
389 status === "FAILED" || status === "CRASHED" || status === "REMOVED";
390
391const readService = Query.fn((id: string) =>
392 serviceFields(Railway.service({ id })),
393);
394
395const readInstance = Query.fn((environmentId: string, serviceId: string) =>
396 instanceFields(Railway.serviceInstance({ environmentId, serviceId })),
397);
398
399const readVariables = Query.fn(
400 (projectId: string, environmentId: string, serviceId: string) =>
401 Railway.variables({
402 projectId,
403 environmentId,
404 serviceId,
405 unrendered: true,
406 }),
407);
408
409const variableUpsert = Query.fn((input: VariableUpsertInput) =>
410 Railway.variableUpsert({ input }),
411);
412
413const serviceCreate = Query.fn((input: ServiceCreateInput) =>
414 serviceFields(Railway.serviceCreate({ input })),
415);
416
417const serviceUpdateName = Query.fn((id: string, name: string) =>
418 serviceFields(Railway.serviceUpdate({ id, input: { name } })),
419);
420
421const serviceInstanceUpdate = Query.fn(
422 (
423 environmentId: string,
424 serviceId: string,
425 input: ServiceInstanceUpdateInput,
426 ) => Railway.serviceInstanceUpdate({ environmentId, serviceId, input }),
427);
428
429const serviceInstanceDeploy = Query.fn(
430 (environmentId: string, serviceId: string) =>
431 Railway.serviceInstanceDeployV2({ environmentId, serviceId }),
432);
433
434const serviceDelete = Query.fn((id: string) => Railway.serviceDelete({ id }));
435
436const getById = (serviceId: string) =>
437 readService(serviceId).pipe(
438 Effect.map((service) => (isGoneService(service) ? undefined : service)),
439 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
440 );
441
442const getInstance = (environmentId: string, serviceId: string) =>
443 readInstance(environmentId, serviceId).pipe(
444 Effect.map((instance) => (isGoneInstance(instance) ? undefined : instance)),
445 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
446 );
447
448const listProjectServices = (projectId: string) =>
449 projectServices(projectId, serviceFields).pipe(
450 Effect.map((services) => services.filter((node) => !isGoneService(node))),
451 Effect.catchTag("RailwayNotFound", () =>
452 Effect.succeed([] as CloudService[]),
453 ),
454 );
455
456const findByName = (projectId: string, name: string) =>
457 listProjectServices(projectId).pipe(
458 Effect.map((services) => services.find((service) => service.name === name)),
459 );
460
461const waitForInstance = (environmentId: string, serviceId: string) =>
462 getInstance(environmentId, serviceId).pipe(
463 Effect.flatMap((instance) => {
464 if (instance === undefined) {
465 return Effect.fail(new RedisPending({ serviceId, status: "creating" }));
466 }
467 return Effect.succeed(instance);
468 }),
469 Effect.retry({
470 while: (e) => e._tag === "Railway.RedisPending",
471 // serviceCreate fans the instance out to each environment
472 // asynchronously; wait a bounded interval for it to appear.
473 times: 10,
474 schedule: Schedule.spaced("2 seconds"),
475 }),
476 Effect.catchTag("Railway.RedisPending", () =>
477 getInstance(environmentId, serviceId),
478 ),
479 );
480
481const waitForDeployment = (environmentId: string, serviceId: string) =>
482 Effect.gen(function* () {
483 const instance = yield* getInstance(environmentId, serviceId);
484 const latest = instance?.latestDeployment;
485 const status = latest?.status;
486 if (status !== undefined && deployFailed(status)) {
487 return yield* new RedisDeployFailed({
488 serviceId,
489 status,
490 deploymentId: latest?.id,
491 });
492 }
493 if (instance !== undefined && deployReady(status)) {
494 return instance;
495 }
496 return yield* new RedisDeployPending({
497 serviceId,
498 status: status ?? "pending",
499 });
500 }).pipe(
501 Effect.retry({
502 while: (e) => e._tag === "Railway.RedisDeployPending",
503 times: 10,
504 schedule: Schedule.spaced("5 seconds"),
505 }),
506 Effect.catchTag("Railway.RedisDeployPending", () =>
507 getInstance(environmentId, serviceId),
508 ),
509 );
510
511const asVariableMap = (value: unknown): Record<string, string> => {
512 if (value === null || typeof value !== "object" || Array.isArray(value)) {
513 return {};
514 }
515 const out: Record<string, string> = {};
516 for (const [key, item] of Object.entries(value)) {
517 if (typeof item === "string") {
518 out[key] = item;
519 }
520 }
521 return out;
522};
523
524const listVariableMap = (
525 projectId: string,
526 environmentId: string,
527 serviceId: string,
528) =>
529 readVariables(projectId, environmentId, serviceId).pipe(
530 Effect.map(asVariableMap),
531 Effect.catchTag("RailwayNotFound", () =>
532 Effect.succeed({} as Record<string, string>),
533 ),
534 );
535
536const upsertVariable = (input: {
537 projectId: string;
538 environmentId: string;
539 serviceId: string;
540 name: string;
541 value: string;
542}) =>
543 variableUpsert({
544 projectId: input.projectId,
545 environmentId: input.environmentId,
546 serviceId: input.serviceId,
547 name: input.name,
548 value: input.value,
549 skipDeploys: true,
550 });
551
552const redisUrlTemplate = `redis://default:\${{${REDIS_PASSWORD_ENV}}}@\${{RAILWAY_PRIVATE_DOMAIN}}:${REDIS_PORT}`;
553
554const desiredVariables = (password: string): Record<string, string> => ({
555 [REDIS_PASSWORD_ENV]: password,
556 REDIS_PASSWORD: password,
557 REDISUSER: "default",
558 REDISPORT: String(REDIS_PORT),
559 REDISHOST: "${{RAILWAY_PRIVATE_DOMAIN}}",
560 [REDIS_URL_ENV]: redisUrlTemplate,
561});
562
563const syncVariables = Effect.fn(function* (input: {
564 projectId: string;
565 environmentId: string;
566 serviceId: string;
567 desired: Record<string, string>;
568}) {
569 const observed = yield* listVariableMap(
570 input.projectId,
571 input.environmentId,
572 input.serviceId,
573 );
574 let changed = false;
575 for (const [name, value] of Object.entries(input.desired)) {
576 if (observed[name] !== value) {
577 yield* upsertVariable({
578 projectId: input.projectId,
579 environmentId: input.environmentId,
580 serviceId: input.serviceId,
581 name,
582 value,
583 });
584 changed = true;
585 }
586 }
587 return changed;
588});
589
590const toAttrs = (input: {
591 service: CloudService;
592 instance: AttributeInstance | undefined;
593 projectId: string;
594 environmentId: string;
595 image: string;
596}): Redis["Attributes"] => ({
597 serviceId: input.service.id,
598 name: input.service.name,
599 projectId: input.projectId,
600 environmentId: input.environmentId,
601 image: input.instance?.source?.image ?? input.image,
602 region: input.instance?.region ?? undefined,
603 port: REDIS_PORT,
604 privateHost: privateHostOf(input.service.name),
605 deploymentId: input.instance?.latestDeployment?.id,
606 deploymentStatus: input.instance?.latestDeployment?.status,
607});
608
609export {
610 command as redisCommand,
611 connectionUrl as redisConnectionUrl,
612 run as runRedisCommand,
613} from "../Redis/index.ts";
614
615export const RedisProvider = () =>
616 Provider.succeed(Redis, {
617 stables: ["serviceId", "projectId", "environmentId"],
618 nuke: { dependsOn: ["Railway.Project"] },
619
620 diff: Effect.fn(function* ({ news, output }) {
621 if (news === undefined || !isResolved(news)) return undefined;
622 if (output === undefined) return undefined;
623 const nextProject = projectIdOf(news.project);
624 const projectChanged =
625 nextProject !== undefined && nextProject !== output.projectId;
626 const nextEnv = environmentIdOf(news.environment);
627 const environmentChanged =
628 nextEnv !== undefined && nextEnv !== output.environmentId;
629 if (projectChanged || environmentChanged) {
630 return { action: "replace" as const };
631 }
632 return undefined;
633 }),
634
635 read: Effect.fn(function* ({ id, olds, output }) {
636 const projectId =
637 output?.projectId ??
638 (olds !== undefined ? projectIdOf(olds.project) : undefined);
639 const environmentId =
640 output?.environmentId ??
641 (olds !== undefined
642 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
643 : undefined);
644 const name = yield* resolveName(id, olds?.name, output?.name);
645 const byId =
646 output?.serviceId !== undefined && output.serviceId.length > 0
647 ? yield* getById(output.serviceId)
648 : undefined;
649 const found =
650 byId ??
651 (projectId !== undefined
652 ? yield* findByName(projectId, name)
653 : undefined);
654 if (found === undefined) return undefined;
655 const resolvedProjectId = projectIdOf(found) ?? projectId ?? "";
656 const resolvedEnvId =
657 environmentId ??
658 environmentIdOf(olds?.project) ??
659 output?.environmentId ??
660 "";
661 const instance =
662 resolvedEnvId.length > 0
663 ? yield* getInstance(resolvedEnvId, found.id)
664 : undefined;
665 const attrs = toAttrs({
666 service: found,
667 instance,
668 projectId: resolvedProjectId,
669 environmentId: resolvedEnvId,
670 image: instance?.source?.image ?? olds?.image ?? DEFAULT_REDIS_IMAGE,
671 });
672 if (output !== undefined) return attrs;
673 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
674 }),
675
676 list: Effect.fn(function* () {
677 const projects = yield* ownedProjects();
678 const rows = yield* Effect.forEach(projects, (project) =>
679 Effect.gen(function* () {
680 const services = new Map(
681 (yield* listProjectServices(project.projectId))
682 .filter((service) => matchesAlchemyPhysicalName(service.name))
683 .map((service) => [service.id, service]),
684 );
685 if (services.size === 0) return [];
686 const envIds = yield* projectEnvironmentIds(project);
687 const items = yield* Effect.forEach(envIds, (environmentId) =>
688 environmentServiceInstances(
689 environmentId,
690 project.projectId,
691 (instance) => ({
692 ...attributeInstanceFields(instance),
693 serviceId: instance.serviceId,
694 deletedAt: instance.deletedAt,
695 }),
696 ).pipe(
697 Effect.map((instances) =>
698 instances.flatMap((instance) => {
699 const service = services.get(instance.serviceId);
700 const image = instance.source?.image;
701 if (
702 instance.deletedAt != null ||
703 service === undefined ||
704 !isRedisImage(image)
705 )
706 return [];
707 return [
708 toAttrs({
709 service,
710 instance,
711 projectId: project.projectId,
712 environmentId,
713 image: image ?? DEFAULT_REDIS_IMAGE,
714 }),
715 ];
716 }),
717 ),
718 ),
719 );
720 return items.flat();
721 }),
722 );
723 return rows.flat();
724 }),
725
726 reconcile: Effect.fn(function* ({ id, news, output }) {
727 const props = news ?? ({} as RedisProps);
728 const projectId = projectIdOf(props.project) ?? output?.projectId;
729 if (projectId === undefined) {
730 return yield* new RedisProjectRequired({
731 message: "Redis requires a resolved Railway.Project",
732 });
733 }
734 const environmentId =
735 environmentIdOf(props.environment) ??
736 environmentIdOf(props.project) ??
737 output?.environmentId;
738 if (environmentId === undefined) {
739 return yield* new RedisProjectRequired({
740 message:
741 "Redis requires a Railway environment (pass environment or a Project with environmentId)",
742 });
743 }
744 const name = yield* resolveName(id, props.name, output?.name);
745 const image = props.image ?? output?.image ?? DEFAULT_REDIS_IMAGE;
746
747 let current: CloudService | undefined =
748 output?.serviceId !== undefined && output.serviceId.length > 0
749 ? yield* getById(output.serviceId)
750 : undefined;
751 if (current === undefined) {
752 current = yield* findByName(projectId, name);
753 }
754
755 const existingVars =
756 current !== undefined
757 ? yield* listVariableMap(projectId, environmentId, current.id)
758 : {};
759 const password =
760 props.password !== undefined
761 ? unwrapSecret(props.password)
762 : existingVars[REDIS_PASSWORD_ENV] !== undefined &&
763 existingVars[REDIS_PASSWORD_ENV].length > 0 &&
764 !existingVars[REDIS_PASSWORD_ENV].includes("${{")
765 ? existingVars[REDIS_PASSWORD_ENV]
766 : yield* generatePassword;
767 const env = desiredVariables(password);
768 const startCommand = startCommandFor(image, password);
769
770 if (current === undefined) {
771 const created = yield* serviceCreate({
772 projectId,
773 environmentId,
774 name,
775 source: { image },
776 variables: env,
777 }).pipe(
778 Effect.catchTag("RailwayValidationError", () =>
779 Effect.succeed(undefined),
780 ),
781 );
782 current = created ?? (yield* findByName(projectId, name));
783 }
784
785 if (current === undefined || isGoneService(current)) {
786 return yield* new RedisNotCreated({ name, projectId });
787 }
788
789 if (current.name !== name) {
790 current = yield* serviceUpdateName(current.id, name);
791 }
792
793 let instance = yield* waitForInstance(environmentId, current.id);
794 let needsDeploy = false;
795
796 const observedImage = instance?.source?.image ?? undefined;
797 const imageChanged = !sameImage(observedImage, image);
798 const observedRegion = instance?.region ?? undefined;
799 const regionChanged =
800 props.region !== undefined && props.region !== observedRegion;
801 const observedStart = instance?.startCommand ?? undefined;
802 const startChanged = (observedStart ?? undefined) !== startCommand;
803 if (imageChanged || regionChanged || startChanged) {
804 yield* serviceInstanceUpdate(environmentId, current.id, {
805 ...(imageChanged ? { source: { image } } : {}),
806 ...(regionChanged ? { region: props.region } : {}),
807 ...(startChanged ? { startCommand: startCommand ?? null } : {}),
808 });
809 needsDeploy = true;
810 instance = (yield* getInstance(environmentId, current.id)) ?? instance;
811 }
812
813 const envChanged = yield* syncVariables({
814 projectId,
815 environmentId,
816 serviceId: current.id,
817 desired: env,
818 });
819 if (envChanged) needsDeploy = true;
820
821 if (needsDeploy || instance?.latestDeployment == null) {
822 yield* serviceInstanceDeploy(environmentId, current.id).pipe(
823 Effect.catchTag("RailwayValidationError", () => Effect.void),
824 );
825 }
826
827 instance =
828 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
829
830 return toAttrs({
831 service: current,
832 instance,
833 projectId,
834 environmentId,
835 image,
836 });
837 }),
838
839 delete: Effect.fn(function* ({ output }) {
840 const serviceId = output.serviceId;
841 if (serviceId.length === 0) return;
842 yield* serviceDelete(serviceId).pipe(
843 Effect.catchTag("RailwayNotFound", () => Effect.void),
844 );
845 yield* waitUntilDeleted(
846 "Service",
847 serviceId,
848 getById(serviceId).pipe(Effect.map((service) => service === undefined)),
849 );
850 }),
851 });