1import { waitUntilDeleted } from "./GraphQL.ts";
2import { createHash } from "node:crypto";
3import { Query } from "@distilled.cloud/core/query";
4import { Railway, type VariableUpsertInput } from "@distilled.cloud/railway";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Stream from "effect/Stream";
9import { Unowned } from "../AdoptPolicy.ts";
10import { isResolved } from "../Diff.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
14import {
15 ownedProjects,
16 projectEnvironmentIds,
17 type Project,
18} from "./Project.ts";
19import type { Providers } from "./Providers.ts";
20
21/**
22 * A resource-valued prop: the resource itself, or an Effect that produces
23 * it (so `yield* Project(...)` and `Project(...)` both type-check).
24 */
25type Ref<T> = T | Effect.Effect<T, never, Providers>;
26
27/**
28 * Environment identity a Variable is stored in. Accepts a
29 * `Railway.Project` (its primary environment), a `Railway.Environment`,
30 * or an `{ environmentId }` stub.
31 */
32export type VariableEnvironment = {
33 readonly environmentId: string;
34};
35
36/**
37 * Service identity a Variable can be scoped to. Accepts a
38 * `Railway.Service` or a `{ serviceId }` stub.
39 */
40export type VariableService = {
41 readonly serviceId: string;
42};
43
44export interface VariableProps {
45 /**
46 * Parent Railway Project. Changing it replaces the variable.
47 */
48 project: Ref<Project>;
49 /**
50 * Environment to store the variable in. Accepts a `Railway.Project`
51 * (primary environment), a `Railway.Environment`, or `{ environmentId }`.
52 * Defaults to the project's primary environment. Changing it replaces
53 * the variable.
54 */
55 environment?: Ref<VariableEnvironment>;
56 /**
57 * Service to scope the variable to. Omit for a shared (project-level)
58 * variable. Accepts a `Railway.Service` or `{ serviceId }`. Changing it
59 * replaces the variable.
60 */
61 service?: Ref<VariableService>;
62 /**
63 * Variable name (the env-var name services see). Stored as-is when set
64 * by the user (case-sensitive). If omitted, a unique name is generated
65 * from the stack, stage and logical ID. Changing it replaces the
66 * variable.
67 */
68 name?: string;
69 /**
70 * Variable value. Wrap secrets with `Redacted.make(...)` so they are
71 * never logged. May also be a `Railway.ref(...)` template
72 * (`${{Db.DATABASE_URL}}`, `${{shared.NAME}}`); Railway stores the
73 * template and interpolates it at build/runtime. Updated in place via
74 * `variableUpsert`. Never persisted in attributes.
75 */
76 value: Redacted.Redacted<string> | string;
77}
78
79export type Variable = Resource<
80 "Railway.Variable",
81 VariableProps,
82 {
83 /** Parent Railway project id. */
84 projectId: string;
85 /** Environment the variable lives in. */
86 environmentId: string;
87 /** Service the variable is scoped to, if any. */
88 serviceId: string | undefined;
89 /** Variable name (unique per project + environment + service). */
90 name: string;
91 /** sha256 of the current value. Not the plaintext. */
92 digest: string;
93 },
94 never,
95 Providers
96>;
97
98const resolveVariableProps = (
99 props: VariableProps | Effect.Effect<VariableProps, never, Providers>,
100): Effect.Effect<VariableProps, never, Providers> =>
101 Effect.gen(function* () {
102 const resolved = Effect.isEffect(props) ? yield* props : props;
103 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
104 const project = Effect.isEffect(resolved.project)
105 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
106 : resolved.project;
107 const environment =
108 resolved.environment === undefined
109 ? undefined
110 : Effect.isEffect(resolved.environment)
111 ? yield* resolved.environment as Effect.Effect<
112 VariableEnvironment,
113 never,
114 Providers
115 >
116 : resolved.environment;
117 const service =
118 resolved.service === undefined
119 ? undefined
120 : Effect.isEffect(resolved.service)
121 ? yield* resolved.service as Effect.Effect<
122 VariableService,
123 never,
124 Providers
125 >
126 : resolved.service;
127 return { ...resolved, project, environment, service };
128 });
129
130const VariableResource = Resource<Variable>("Railway.Variable");
131
132/**
133 * A Railway.Variable is a project- or service-scoped env var. Railway
134 * injects it into services in that environment. The plaintext is never
135 * stored in attributes.
136 *
137 * @see https://docs.railway.com/guides/variables
138 *
139 * ### Create a Variable
140 * Wrap the value with `Redacted.make` so it is never logged. Omit `name`
141 * and Alchemy generates an ownership-stamped name. Shared (no `service`)
142 * variables apply to every service in the environment.
143 *
144 * **Example:** Generated name
145 * ```typescript
146 * const site = yield* Railway.Project("Site");
147 * const dbUrl = yield* Railway.Variable("DatabaseUrl", {
148 * project: site,
149 * value: Redacted.make("postgres://…"),
150 * });
151 * ```
152 *
153 * :::caution[Changing `project` replaces the Variable]
154 * The value is created on the new Project. The old name is deleted.
155 * :::
156 *
157 * ### Env-var name
158 * `name` is the env-var services see. It is stored as-is
159 * (case-sensitive).
160 *
161 * **Example:** Explicit name
162 * ```typescript
163 * export const ApiToken = Railway.Variable("ApiToken", {
164 * project: Site,
165 * name: "API_TOKEN",
166 * value: Redacted.make("sk_live_…"),
167 * });
168 * ```
169 *
170 * :::caution[Changing `name` replaces the Variable]
171 * Railway cannot rename a variable. Alchemy creates the new name, then
172 * deletes the old one.
173 * :::
174 *
175 * ### Environment
176 * Defaults to the Project's primary environment. Pass a
177 * `Railway.Environment` (or `{ environmentId }`) to target another one.
178 *
179 * **Example:** Extra environment
180 * ```typescript
181 * const staging = yield* Railway.Environment("Staging", { project: site });
182 * const token = yield* Railway.Variable("StagingToken", {
183 * project: site,
184 * environment: staging,
185 * value: Redacted.make("sk_staging_…"),
186 * });
187 * ```
188 *
189 * :::caution[Changing `environment` replaces the Variable]
190 * The value is created in the new environment. The old name is deleted.
191 * :::
192 *
193 * ### Service-scoped
194 * Pass `service` to attach the variable to one service instead of the
195 * shared project set.
196 *
197 * **Example:** Service variable
198 * ```typescript
199 * const api = yield* Railway.Service("Api", {
200 * project: site,
201 * image: "hashicorp/http-echo",
202 * });
203 * const token = yield* Railway.Variable("ApiToken", {
204 * project: site,
205 * service: api,
206 * name: "API_TOKEN",
207 * value: Redacted.make("sk_live_…"),
208 * });
209 * ```
210 *
211 * ### Variable references
212 * `value` may be a `Railway.ref(resource, key)` template instead of a
213 * plaintext secret. Upsert stores `${{LogicalName.KEY}}` (or
214 * `${{shared.NAME}}`) — not a resolved URI. Railway interpolates it.
215 * Next to {@link ConnectPostgres}: use `ConnectPostgres` for a typed
216 * client inside an Effect-native Service; use `Railway.ref` when you
217 * want Railway's own `${{Db.DATABASE_URL}}` interpolation (IaC
218 * `db.env.DATABASE_URL`).
219 *
220 * **Example:** Reference Postgres DATABASE_URL
221 * ```typescript
222 * const db = yield* Railway.Postgres("Db", { project: site });
223 * const api = yield* Railway.Service("Api", {
224 * project: site,
225 * image: "hashicorp/http-echo",
226 * });
227 * const databaseUrl = yield* Railway.Variable("DatabaseUrl", {
228 * project: site,
229 * service: api,
230 * name: "DATABASE_URL",
231 * value: Railway.ref(db, "DATABASE_URL"),
232 * });
233 * ```
234 *
235 * **Example:** Shared variable
236 * ```typescript
237 * yield* Railway.Variable("SentryDsn", {
238 * project: site,
239 * name: "SENTRY_DSN",
240 * value: Redacted.make("https://…"),
241 * });
242 * yield* Railway.Variable("ApiSentry", {
243 * project: site,
244 * service: api,
245 * name: "SENTRY_DSN",
246 * value: Railway.ref("shared", "SENTRY_DSN"),
247 * });
248 * ```
249 *
250 * ### Rotate the value
251 * Updating `value` is in place via `variableUpsert`. Deploys are skipped
252 * (`skipDeploys: true`); the Service resource owns deploys.
253 *
254 * **Example:** New value
255 * ```typescript
256 * export const ApiToken = Railway.Variable("ApiToken", {
257 * project: Site,
258 * name: "API_TOKEN",
259 * value: Redacted.make("sk_live_rotated"),
260 * });
261 * ```
262 *
263 * ### Module-scope declarations
264 * Declare the Project once. Pass it into every child. Resource-valued
265 * props accept the resource or an Effect producing it.
266 *
267 * **Example:** Module-scope Variable
268 * ```typescript
269 * // src/secrets.ts
270 * import * as Railway from "alchemy/Railway";
271 * import * as Redacted from "effect/Redacted";
272 *
273 * export const Site = Railway.Project("Site");
274 * export const ApiToken = Railway.Variable("ApiToken", {
275 * project: Site,
276 * name: "API_TOKEN",
277 * value: Redacted.make("sk_live_…"),
278 * });
279 * ```
280 *
281 * @resource
282 * @product Service
283 */
284export const Variable: typeof VariableResource = Object.assign(
285 (
286 id: string,
287 props: VariableProps | Effect.Effect<VariableProps, never, Providers>,
288 ) => VariableResource(id, resolveVariableProps(props)),
289 VariableResource,
290);
291
292export class VariableNotCreated extends Data.TaggedError(
293 "Railway.VariableNotCreated",
294)<{
295 projectId: string;
296 environmentId: string;
297 name: string;
298}> {}
299
300export class VariableProjectRequired extends Data.TaggedError(
301 "Railway.VariableProjectRequired",
302)<{
303 message: string;
304}> {}
305
306export class VariableEnvironmentRequired extends Data.TaggedError(
307 "Railway.VariableEnvironmentRequired",
308)<{
309 message: string;
310}> {}
311
312const projectIdOf = (value: unknown): string | undefined => {
313 if (value === null || typeof value !== "object") return undefined;
314 const rec = value as { projectId?: unknown };
315 return typeof rec.projectId === "string" && rec.projectId.length > 0
316 ? rec.projectId
317 : undefined;
318};
319
320const environmentIdOf = (value: unknown): string | undefined => {
321 if (value === null || typeof value !== "object") return undefined;
322 const rec = value as { environmentId?: unknown };
323 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
324 ? rec.environmentId
325 : undefined;
326};
327
328const serviceIdOf = (value: unknown): string | undefined => {
329 if (value === null || typeof value !== "object") return undefined;
330 const rec = value as { serviceId?: unknown };
331 return typeof rec.serviceId === "string" && rec.serviceId.length > 0
332 ? rec.serviceId
333 : undefined;
334};
335
336const unwrapSecret = (value: Redacted.Redacted<string> | string): string =>
337 Redacted.isRedacted(value) ? Redacted.value(value) : value;
338
339const digestOf = (plain: string) =>
340 Effect.sync(() =>
341 createHash("sha256").update(Buffer.from(plain, "utf8")).digest("hex"),
342 );
343
344const resolveName = (id: string, name: string | undefined, existing?: string) =>
345 Effect.gen(function* () {
346 if (name !== undefined) return name;
347 if (existing !== undefined) return existing;
348 return yield* createRailwayName(id);
349 });
350
351const toAttrs = (input: {
352 projectId: string;
353 environmentId: string;
354 serviceId: string | undefined;
355 name: string;
356 digest: string;
357}): Variable["Attributes"] => input;
358
359const asVariableMap = (value: unknown): Record<string, string> => {
360 if (value === null || typeof value !== "object" || Array.isArray(value)) {
361 return {};
362 }
363 const out: Record<string, string> = {};
364 for (const [key, item] of Object.entries(value)) {
365 if (typeof item === "string") {
366 out[key] = item;
367 }
368 }
369 return out;
370};
371
372const readVariables = Query.fn(
373 (projectId: string, environmentId: string, serviceId?: string) =>
374 Railway.variables({
375 projectId,
376 environmentId,
377 ...(serviceId !== undefined ? { serviceId } : {}),
378 unrendered: true,
379 }),
380);
381
382const variableUpsert = Query.fn((input: VariableUpsertInput) =>
383 Railway.variableUpsert({ input }),
384);
385
386const variableDelete = Query.fn(
387 (input: {
388 projectId: string;
389 environmentId: string;
390 name: string;
391 serviceId?: string;
392 }) => Railway.variableDelete({ input }),
393);
394
395const listVariableMap = (
396 projectId: string,
397 environmentId: string,
398 serviceId?: string,
399) =>
400 readVariables(projectId, environmentId, serviceId).pipe(
401 Effect.map(asVariableMap),
402 Effect.catchTag("RailwayNotFound", () =>
403 Effect.succeed({} as Record<string, string>),
404 ),
405 );
406
407const getValue = (
408 projectId: string,
409 environmentId: string,
410 name: string,
411 serviceId?: string,
412) =>
413 listVariableMap(projectId, environmentId, serviceId).pipe(
414 Effect.map((vars) => vars[name]),
415 );
416
417const upsertVariable = (input: {
418 projectId: string;
419 environmentId: string;
420 name: string;
421 value: string;
422 serviceId?: string;
423}) =>
424 variableUpsert({
425 projectId: input.projectId,
426 environmentId: input.environmentId,
427 name: input.name,
428 value: input.value,
429 skipDeploys: true,
430 ...(input.serviceId !== undefined ? { serviceId: input.serviceId } : {}),
431 });
432
433const listEnvironmentIds = (project: {
434 projectId: string;
435 environmentId: string;
436}) =>
437 Query.items(
438 Railway.environments({ projectId: project.projectId, first: 50 }).pipe(
439 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
440 ),
441 ).pipe(
442 Stream.filter((env) => env.deletedAt == null),
443 Stream.map((env) => env.id),
444 Stream.runCollect,
445 Effect.map((ids) => {
446 const set = new Set(Array.from(ids));
447 if (project.environmentId.length > 0) {
448 set.add(project.environmentId);
449 }
450 return Array.from(set);
451 }),
452 Effect.catchTag("RailwayNotFound", () =>
453 Effect.succeed(
454 project.environmentId.length > 0 ? [project.environmentId] : [],
455 ),
456 ),
457 );
458
459export const VariableProvider = () =>
460 Provider.succeed(Variable, {
461 stables: ["projectId", "environmentId", "serviceId", "name"],
462 nuke: { dependsOn: ["Railway.Project"] },
463
464 diff: Effect.fn(function* ({ news, output }) {
465 if (news === undefined || !isResolved(news)) return undefined;
466 if (output === undefined) return undefined;
467 const desiredName = news.name !== undefined ? news.name : output.name;
468 const nameChanged = desiredName !== output.name;
469 const nextProject = projectIdOf(news.project);
470 const projectChanged =
471 nextProject !== undefined && nextProject !== output.projectId;
472 const nextEnv = environmentIdOf(news.environment);
473 const environmentChanged =
474 nextEnv !== undefined && nextEnv !== output.environmentId;
475 const nextService = serviceIdOf(news.service);
476 const serviceChanged =
477 news.service !== undefined && nextService !== output.serviceId;
478 if (
479 nameChanged ||
480 projectChanged ||
481 environmentChanged ||
482 serviceChanged
483 ) {
484 return {
485 action: "replace" as const,
486 deleteFirst:
487 nameChanged &&
488 !projectChanged &&
489 !environmentChanged &&
490 !serviceChanged,
491 };
492 }
493 return undefined;
494 }),
495
496 read: Effect.fn(function* ({ id, olds, output }) {
497 const projectId =
498 output?.projectId ??
499 (olds !== undefined ? projectIdOf(olds.project) : undefined);
500 const environmentId =
501 output?.environmentId ??
502 (olds !== undefined
503 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
504 : undefined);
505 if (projectId === undefined || environmentId === undefined) {
506 return undefined;
507 }
508 const name = yield* resolveName(id, olds?.name, output?.name);
509 const serviceId =
510 output?.serviceId ??
511 (olds !== undefined ? serviceIdOf(olds.service) : undefined);
512 const value = yield* getValue(projectId, environmentId, name, serviceId);
513 if (value === undefined) return undefined;
514 const digest = yield* digestOf(value);
515 const attrs = toAttrs({
516 projectId,
517 environmentId,
518 serviceId,
519 name,
520 digest,
521 });
522 if (output !== undefined) return attrs;
523 return matchesAlchemyPhysicalName(name) ? attrs : Unowned(attrs);
524 }),
525
526 list: Effect.fn(function* () {
527 const projects = yield* ownedProjects();
528 const rows = yield* Effect.forEach(projects, (project) =>
529 Effect.gen(function* () {
530 const envIds = yield* projectEnvironmentIds(project);
531 const nested = yield* Effect.forEach(envIds, (environmentId) =>
532 listVariableMap(project.projectId, environmentId).pipe(
533 Effect.flatMap((vars) =>
534 Effect.forEach(
535 Object.keys(vars).filter((name) =>
536 matchesAlchemyPhysicalName(name),
537 ),
538 (name) =>
539 digestOf(vars[name]!).pipe(
540 Effect.map((digest) =>
541 toAttrs({
542 projectId: project.projectId,
543 environmentId,
544 serviceId: undefined,
545 name,
546 digest,
547 }),
548 ),
549 ),
550 ),
551 ),
552 ),
553 );
554 return nested.flat();
555 }),
556 );
557 return rows.flat();
558 }),
559
560 reconcile: Effect.fn(function* ({ id, news, output }) {
561 const props = news ?? ({} as VariableProps);
562 const projectId = projectIdOf(props.project) ?? output?.projectId;
563 if (projectId === undefined) {
564 return yield* new VariableProjectRequired({
565 message: "Variable requires a resolved Railway.Project",
566 });
567 }
568 const environmentId =
569 environmentIdOf(props.environment) ??
570 environmentIdOf(props.project) ??
571 output?.environmentId;
572 if (environmentId === undefined) {
573 return yield* new VariableEnvironmentRequired({
574 message:
575 "Variable requires a Railway environment (pass environment or a Project with environmentId)",
576 });
577 }
578 const serviceId =
579 props.service !== undefined
580 ? serviceIdOf(props.service)
581 : output?.serviceId;
582 const name = yield* resolveName(id, props.name, output?.name);
583 const desiredPlain = unwrapSecret(props.value);
584
585 let current =
586 output !== undefined
587 ? yield* getValue(
588 output.projectId,
589 output.environmentId,
590 output.name,
591 output.serviceId,
592 )
593 : undefined;
594 if (
595 current === undefined &&
596 (output === undefined ||
597 output.projectId !== projectId ||
598 output.environmentId !== environmentId ||
599 output.name !== name ||
600 output.serviceId !== serviceId)
601 ) {
602 current = yield* getValue(projectId, environmentId, name, serviceId);
603 }
604
605 let createdThisRun = false;
606 if (current === undefined) {
607 yield* upsertVariable({
608 projectId,
609 environmentId,
610 name,
611 value: desiredPlain,
612 serviceId,
613 });
614 current = yield* getValue(projectId, environmentId, name, serviceId);
615 createdThisRun = true;
616 }
617
618 if (current === undefined) {
619 return yield* new VariableNotCreated({
620 projectId,
621 environmentId,
622 name,
623 });
624 }
625
626 if (!createdThisRun && current !== desiredPlain) {
627 yield* upsertVariable({
628 projectId,
629 environmentId,
630 name,
631 value: desiredPlain,
632 serviceId,
633 });
634 current =
635 (yield* getValue(projectId, environmentId, name, serviceId)) ??
636 desiredPlain;
637 }
638
639 const digest = yield* digestOf(current);
640 return toAttrs({
641 projectId,
642 environmentId,
643 serviceId,
644 name,
645 digest,
646 });
647 }),
648
649 delete: Effect.fn(function* ({ output }) {
650 if (
651 output.projectId.length === 0 ||
652 output.environmentId.length === 0 ||
653 output.name.length === 0
654 ) {
655 return;
656 }
657 yield* variableDelete({
658 projectId: output.projectId,
659 environmentId: output.environmentId,
660 name: output.name,
661 ...(output.serviceId !== undefined
662 ? { serviceId: output.serviceId }
663 : {}),
664 }).pipe(Effect.catchTag("RailwayNotFound", () => Effect.void));
665 yield* waitUntilDeleted(
666 "Variable",
667 `${output.environmentId}/${output.serviceId ?? "shared"}/${output.name}`,
668 getValue(
669 output.projectId,
670 output.environmentId,
671 output.name,
672 output.serviceId,
673 ).pipe(Effect.map((value) => value === undefined)),
674 );
675 }),
676 });