1import { waitUntilDeleted, environmentVolumes } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type VolumeInstance,
6 type VolumeInstanceBackup,
7 type VolumeInstanceBackupScheduleKind,
8 type VolumeState,
9 type WorkflowId,
10} from "@distilled.cloud/railway";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import * as Stream from "effect/Stream";
15import { Unowned } from "../AdoptPolicy.ts";
16import { isResolved } from "../Diff.ts";
17import * as Provider from "../Provider.ts";
18import { Resource } from "../Resource.ts";
19import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
20import { ownedProjects } from "./Project.ts";
21import type { Providers } from "./Providers.ts";
22
23const backupFields = <E>(backup: Query<VolumeInstanceBackup, E>) => ({
24 id: backup.id,
25 name: backup.name,
26 createdAt: backup.createdAt,
27 expiresAt: backup.expiresAt,
28 usedMB: backup.usedMB,
29 referencedMB: backup.referencedMB,
30 volumeInstanceSizeMB: backup.volumeInstanceSizeMB,
31 scheduleId: backup.scheduleId,
32});
33const volumeFields = <E>(instance: Query<VolumeInstance, E>) => ({
34 id: instance.id,
35 volumeId: instance.volumeId,
36 environmentId: instance.environmentId,
37 serviceId: instance.serviceId,
38 deletedAt: instance.deletedAt,
39 isPendingDeletion: instance.isPendingDeletion,
40 state: instance.state,
41 mountPath: instance.mountPath,
42 volume: {
43 id: instance.volume.id,
44 name: instance.volume.name,
45 projectId: instance.volume.projectId,
46 },
47});
48
49/**
50 * A resource-valued prop: the resource itself, or an Effect that produces
51 * it (so `yield* Volume(...)` and `Volume(...)` both type-check).
52 */
53type Ref<T> = T | Effect.Effect<T, never, Providers>;
54
55/**
56 * Volume identity a backup snapshots. Accepts a `Railway.Volume` or a
57 * `{ volumeInstanceId }` stub.
58 */
59export type VolumeBackupVolume = {
60 readonly volumeInstanceId: string;
61 readonly volumeId?: string;
62 readonly projectId?: string;
63 readonly environmentId?: string;
64};
65
66/**
67 * Environment identity used to pick a volume instance when the Volume
68 * spans more than one. Accepts a `Railway.Project` (primary
69 * environment), a `Railway.Environment`, or `{ environmentId }`.
70 */
71export type VolumeBackupEnvironment = {
72 readonly environmentId: string;
73};
74
75export type VolumeBackupScheduleKind = VolumeInstanceBackupScheduleKind;
76
77export interface VolumeBackupProps {
78 /**
79 * Volume to snapshot. Accepts a `Railway.Volume` or a
80 * `{ volumeInstanceId }` stub. Changing it replaces the backup.
81 */
82 volume: Ref<VolumeBackupVolume>;
83 /**
84 * Environment whose volume instance to snapshot. Defaults to the
85 * Volume's environment. Changing it replaces the backup.
86 */
87 environment?: Ref<VolumeBackupEnvironment>;
88 /**
89 * Backup name. If omitted, a unique name is generated from the stack,
90 * stage and logical ID. Changing it replaces the backup.
91 */
92 name?: string;
93 /**
94 * Lock the backup so it does not expire. One-way — Railway has no
95 * unlock. Updates in place via `volumeInstanceBackupLock`.
96 *
97 * @default false
98 */
99 lock?: boolean;
100 /**
101 * Backup schedules on the volume instance (`DAILY`, `WEEKLY`,
102 * `MONTHLY`). Omit to leave schedules untouched. An empty array
103 * clears them. Updates in place via
104 * `volumeInstanceBackupScheduleUpdate`.
105 */
106 schedules?: readonly VolumeBackupScheduleKind[];
107}
108
109export type VolumeBackup = Resource<
110 "Railway.VolumeBackup",
111 VolumeBackupProps,
112 {
113 /** Railway volume-instance backup id. */
114 volumeInstanceBackupId: string;
115 /** Volume instance the backup belongs to. */
116 volumeInstanceId: string;
117 /** Parent volume id, if known. */
118 volumeId: string | undefined;
119 /** Parent Railway project id, if known. */
120 projectId: string | undefined;
121 /** Environment the volume instance is in, if known. */
122 environmentId: string | undefined;
123 /** Backup name (defaults to `'Manual'` on the API). */
124 name: string;
125 /** RFC3339 creation timestamp. */
126 createdAt: string;
127 /** RFC3339 expiration, or `undefined` when locked / never expires. */
128 expiresAt: string | undefined;
129 /** Unique bytes referenced by this backup, in MB. */
130 usedMB: number | undefined;
131 /** Logical size referenced, in MB. */
132 referencedMB: number | undefined;
133 /** Size of the volume instance when the backup was taken, in MB. */
134 volumeInstanceSizeMB: number | undefined;
135 /** Schedule that produced this backup, if it was not manual. */
136 scheduleId: string | undefined;
137 /** Whether expiration has been removed (`volumeInstanceBackupLock`). */
138 locked: boolean;
139 /** Observed schedule kinds on the volume instance. */
140 schedules: VolumeBackupScheduleKind[];
141 },
142 never,
143 Providers
144>;
145
146const resolveVolumeBackupProps = (
147 props: VolumeBackupProps | Effect.Effect<VolumeBackupProps, never, Providers>,
148): Effect.Effect<VolumeBackupProps, never, Providers> =>
149 Effect.gen(function* () {
150 const resolved = Effect.isEffect(props) ? yield* props : props;
151 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
152 const volume = Effect.isEffect(resolved.volume)
153 ? yield* resolved.volume as Effect.Effect<
154 VolumeBackupVolume,
155 never,
156 Providers
157 >
158 : resolved.volume;
159 const environment =
160 resolved.environment === undefined
161 ? undefined
162 : Effect.isEffect(resolved.environment)
163 ? yield* resolved.environment as Effect.Effect<
164 VolumeBackupEnvironment,
165 never,
166 Providers
167 >
168 : resolved.environment;
169 return { ...resolved, volume, environment };
170 });
171
172const VolumeBackupResource = Resource<VolumeBackup>("Railway.VolumeBackup");
173
174/**
175 * A Railway.VolumeBackup is a snapshot of a volume instance. Create it
176 * next to a `Railway.Volume`. Railway backups run as workflows —
177 * Alchemy waits for `workflowStatus` then reads the backup list.
178 *
179 * Restore is destructive to the volume instance; use
180 * {@link restoreVolumeBackup}. Point-in-time restore forks a new
181 * Postgres service via {@link restoreVolumePITR}.
182 *
183 * @see https://docs.railway.com/volumes/backups
184 * @see https://docs.railway.com/integrations/api/manage-volumes
185 *
186 * ### Create a backup
187 * Pass the Volume. Alchemy generates a unique name. The volume should
188 * be attached to a Service — Railway only backups mounted volumes.
189 *
190 * **Example:** Manual snapshot
191 * ```typescript
192 * const site = yield* Railway.Project("Site");
193 * const api = yield* Railway.Service("Api", {
194 * project: site,
195 * image: "hashicorp/http-echo",
196 * });
197 * const data = yield* Railway.Volume("Data", {
198 * project: site,
199 * mountPath: "/data",
200 * service: api,
201 * });
202 * const snap = yield* Railway.VolumeBackup("Nightly", { volume: data });
203 * ```
204 *
205 * :::caution[Changing `volume` or `environment` replaces the backup]
206 * A new snapshot is created on the new instance. The old backup is
207 * deleted.
208 * :::
209 *
210 * ### Name
211 * Omit `name` for an ownership-stamped name. Pass one to label the
212 * snapshot in the dashboard.
213 *
214 * **Example:** Explicit name
215 * ```typescript
216 * const snap = yield* Railway.VolumeBackup("Nightly", {
217 * volume: data,
218 * name: "pre-migrate",
219 * });
220 * ```
221 *
222 * :::caution[Changing `name` replaces the backup]
223 * Railway cannot rename a backup. A new snapshot is created, then the
224 * old one is deleted.
225 * :::
226 *
227 * ### Lock
228 * Lock to drop the expiration. One-way.
229 *
230 * **Example:** Lock a backup
231 * ```typescript
232 * const snap = yield* Railway.VolumeBackup("Nightly", {
233 * volume: data,
234 * lock: true,
235 * });
236 * ```
237 *
238 * ### Schedules
239 * `schedules` is volume-instance state, not per-snapshot. Set it on
240 * one VolumeBackup that owns the instance.
241 *
242 * **Example:** Daily + weekly
243 * ```typescript
244 * const snap = yield* Railway.VolumeBackup("Nightly", {
245 * volume: data,
246 * schedules: ["DAILY", "WEEKLY"],
247 * });
248 * ```
249 *
250 * ### Restore
251 * Restore is not a reconciler step. Call {@link restoreVolumeBackup}
252 * (destructive to the instance) or {@link restoreVolumePITR} (forks a
253 * new Postgres service).
254 *
255 * **Example:** Restore a snapshot
256 * ```typescript
257 * yield* Railway.restoreVolumeBackup({
258 * volumeInstanceId: snap.volumeInstanceId,
259 * volumeInstanceBackupId: snap.volumeInstanceBackupId,
260 * });
261 * ```
262 *
263 * ### Module-scope declarations
264 * Resource-valued props accept the resource or an Effect producing it.
265 *
266 * **Example:** Module-scope backup
267 * ```typescript
268 * // src/data.ts
269 * import * as Railway from "alchemy/Railway";
270 *
271 * export const Site = Railway.Project("Site");
272 * export const Data = Railway.Volume("Data", {
273 * project: Site,
274 * mountPath: "/data",
275 * });
276 * export const Nightly = Railway.VolumeBackup("Nightly", {
277 * volume: Data,
278 * });
279 * ```
280 *
281 * @resource
282 * @product Volume
283 */
284export const VolumeBackup: typeof VolumeBackupResource = Object.assign(
285 (
286 id: string,
287 props:
288 | VolumeBackupProps
289 | Effect.Effect<VolumeBackupProps, never, Providers>,
290 ) => VolumeBackupResource(id, resolveVolumeBackupProps(props)),
291 VolumeBackupResource,
292);
293
294export class VolumeBackupNotCreated extends Data.TaggedError(
295 "Railway.VolumeBackupNotCreated",
296)<{
297 name: string;
298 volumeInstanceId: string;
299}> {}
300
301export class VolumeBackupVolumeRequired extends Data.TaggedError(
302 "Railway.VolumeBackupVolumeRequired",
303)<{
304 message: string;
305}> {}
306
307export class VolumeBackupWorkflowFailed extends Data.TaggedError(
308 "Railway.VolumeBackupWorkflowFailed",
309)<{
310 workflowId: string;
311 error: string;
312}> {}
313
314class VolumeBackupPending extends Data.TaggedError(
315 "Railway.VolumeBackupPending",
316)<{
317 volumeInstanceId: string;
318 state: string;
319}> {}
320
321type CloudBackup = UnwrapPlan<ReturnType<typeof backupFields>>;
322type CloudInstance = UnwrapPlan<ReturnType<typeof volumeFields>>;
323
324const volumeInstanceIdOf = (value: unknown): string | undefined => {
325 if (value === null || typeof value !== "object") return undefined;
326 const rec = value as { volumeInstanceId?: unknown };
327 return typeof rec.volumeInstanceId === "string" &&
328 rec.volumeInstanceId.length > 0
329 ? rec.volumeInstanceId
330 : undefined;
331};
332
333const volumeIdOf = (value: unknown): string | undefined => {
334 if (value === null || typeof value !== "object") return undefined;
335 const rec = value as { volumeId?: unknown };
336 return typeof rec.volumeId === "string" && rec.volumeId.length > 0
337 ? rec.volumeId
338 : undefined;
339};
340
341const projectIdOf = (value: unknown): string | undefined => {
342 if (value === null || typeof value !== "object") return undefined;
343 const rec = value as { projectId?: unknown };
344 return typeof rec.projectId === "string" && rec.projectId.length > 0
345 ? rec.projectId
346 : undefined;
347};
348
349const environmentIdOf = (value: unknown): string | undefined => {
350 if (value === null || typeof value !== "object") return undefined;
351 const rec = value as { environmentId?: unknown };
352 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
353 ? rec.environmentId
354 : undefined;
355};
356
357const goneState = (state: VolumeState | null | undefined) =>
358 state === "DELETED" || state === "DELETING";
359
360const transientState = (state: VolumeState | null | undefined) =>
361 state === "UPDATING" ||
362 state === "MIGRATING" ||
363 state === "MIGRATION_PENDING" ||
364 state === "RESTORING";
365
366const isGoneInstance = (instance: CloudInstance | undefined) =>
367 instance === undefined ||
368 instance.deletedAt != null ||
369 instance.isPendingDeletion ||
370 goneState(instance.state);
371
372const uniqueKinds = (
373 kinds: readonly VolumeBackupScheduleKind[],
374): VolumeBackupScheduleKind[] => {
375 const seen = new Set<VolumeBackupScheduleKind>();
376 const out: VolumeBackupScheduleKind[] = [];
377 for (const kind of kinds) {
378 if (seen.has(kind)) continue;
379 seen.add(kind);
380 out.push(kind);
381 }
382 return out;
383};
384
385const kindsKey = (kinds: readonly VolumeBackupScheduleKind[]) =>
386 [...kinds].slice().sort().join(",");
387
388const resolveName = (id: string, existing?: string) =>
389 Effect.gen(function* () {
390 if (existing !== undefined && existing.length > 0) return existing;
391 return yield* createRailwayName(id);
392 });
393
394const volumeInstanceBackupList = Query.fn((volumeInstanceId: string) =>
395 Railway.volumeInstanceBackupList({ volumeInstanceId }).pipe(
396 Query.map(backupFields),
397 ),
398);
399
400const volumeInstanceBackupScheduleList = Query.fn((volumeInstanceId: string) =>
401 Railway.volumeInstanceBackupScheduleList({ volumeInstanceId }).pipe(
402 Query.map((schedule) => schedule.kind),
403 ),
404);
405
406const listBackups = (volumeInstanceId: string) =>
407 volumeInstanceBackupList(volumeInstanceId).pipe(
408 Effect.catchTag("RailwayNotFound", () =>
409 Effect.succeed([] as CloudBackup[]),
410 ),
411 );
412
413const listSchedules = (volumeInstanceId: string) =>
414 volumeInstanceBackupScheduleList(volumeInstanceId).pipe(
415 Effect.map((kinds) => uniqueKinds(kinds)),
416 Effect.catchTag("RailwayNotFound", () =>
417 Effect.succeed([] as VolumeBackupScheduleKind[]),
418 ),
419 );
420
421const toAttrs = (
422 backup: CloudBackup,
423 context: {
424 volumeInstanceId: string;
425 volumeId?: string;
426 projectId?: string;
427 environmentId?: string;
428 schedules?: VolumeBackupScheduleKind[];
429 },
430): VolumeBackup["Attributes"] => ({
431 volumeInstanceBackupId: backup.id,
432 volumeInstanceId: context.volumeInstanceId,
433 volumeId: context.volumeId,
434 projectId: context.projectId,
435 environmentId: context.environmentId,
436 name: backup.name ?? "",
437 createdAt: backup.createdAt,
438 expiresAt: backup.expiresAt ?? undefined,
439 usedMB: backup.usedMB ?? undefined,
440 referencedMB: backup.referencedMB ?? undefined,
441 volumeInstanceSizeMB: backup.volumeInstanceSizeMB ?? undefined,
442 scheduleId: backup.scheduleId ?? undefined,
443 locked: backup.expiresAt == null,
444 schedules: context.schedules ?? [],
445});
446
447const findBackup = (
448 backups: readonly CloudBackup[],
449 match: { id?: string; name?: string },
450) => {
451 if (match.id !== undefined && match.id.length > 0) {
452 const byId = backups.find((backup) => backup.id === match.id);
453 if (byId !== undefined) return byId;
454 }
455 if (match.name !== undefined && match.name.length > 0) {
456 return backups.find((backup) => backup.name === match.name);
457 }
458 return undefined;
459};
460
461const readVolumeInstance = Query.fn((id: string) =>
462 volumeFields(Railway.volumeInstance({ id })),
463);
464
465const getByInstanceId = (volumeInstanceId: string) =>
466 readVolumeInstance(volumeInstanceId).pipe(
467 Effect.map((instance): CloudInstance | undefined =>
468 isGoneInstance(instance) ? undefined : instance,
469 ),
470 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
471 );
472
473const listVolumeInstances = (environmentId: string, projectId: string) =>
474 environmentVolumes(environmentId, projectId, volumeFields).pipe(
475 Effect.map((rows) => rows.filter((node) => !isGoneInstance(node))),
476 Effect.catchTag("RailwayNotFound", () =>
477 Effect.succeed([] as CloudInstance[]),
478 ),
479 );
480
481const liveEnvironments = (projectId: string) =>
482 Query.items(
483 Railway.environments({ projectId, first: 50 }).pipe(
484 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
485 ),
486 );
487
488const listEnvironmentIds = (project: {
489 projectId: string;
490 environmentId: string;
491}) =>
492 liveEnvironments(project.projectId).pipe(
493 Stream.filter((env) => env.deletedAt == null),
494 Stream.map((env) => env.id),
495 Stream.runCollect,
496 Effect.map((ids) => {
497 const set = new Set(Array.from(ids));
498 if (project.environmentId.length > 0) {
499 set.add(project.environmentId);
500 }
501 return Array.from(set);
502 }),
503 Effect.catchTag("RailwayNotFound", () =>
504 Effect.succeed(
505 project.environmentId.length > 0 ? [project.environmentId] : [],
506 ),
507 ),
508 );
509
510const readWorkflowStatus = Query.fn((workflowId: string) => {
511 const workflow = Railway.workflowStatus({ workflowId });
512 return { status: workflow.status, error: workflow.error };
513});
514
515const workflowIdFields = <E>(result: Query<WorkflowId, E>) => ({
516 workflowId: result.workflowId,
517});
518
519const volumeInstanceBackupRestore = Query.fn(
520 (volumeInstanceBackupId: string, volumeInstanceId: string) =>
521 workflowIdFields(
522 Railway.volumeInstanceBackupRestore({
523 volumeInstanceBackupId,
524 volumeInstanceId,
525 }),
526 ),
527);
528
529const volumeInstancePITRRestore = Query.fn(
530 (args: {
531 volumeInstanceId: string;
532 targetTimestamp: string;
533 newServiceName?: string;
534 sourceRepoPath?: string;
535 }) => workflowIdFields(Railway.volumeInstancePITRRestore(args)),
536);
537
538const volumeInstanceBackupCreate = Query.fn(
539 (volumeInstanceId: string, name: string) =>
540 workflowIdFields(
541 Railway.volumeInstanceBackupCreate({ volumeInstanceId, name }),
542 ),
543);
544
545const volumeInstanceBackupDelete = Query.fn(
546 (volumeInstanceBackupId: string, volumeInstanceId: string) =>
547 workflowIdFields(
548 Railway.volumeInstanceBackupDelete({
549 volumeInstanceBackupId,
550 volumeInstanceId,
551 }),
552 ),
553);
554
555const volumeInstanceBackupLock = Query.fn(
556 (volumeInstanceBackupId: string, volumeInstanceId: string) =>
557 Railway.volumeInstanceBackupLock({
558 volumeInstanceBackupId,
559 volumeInstanceId,
560 }),
561);
562
563const volumeInstanceBackupScheduleUpdate = Query.fn(
564 (
565 volumeInstanceId: string,
566 kinds: ReadonlyArray<VolumeInstanceBackupScheduleKind>,
567 ) => Railway.volumeInstanceBackupScheduleUpdate({ volumeInstanceId, kinds }),
568);
569
570const waitForWorkflow = (
571 workflowId: string,
572 volumeInstanceId: string,
573 mode: "create" | "delete",
574) =>
575 Effect.gen(function* () {
576 const result = yield* readWorkflowStatus(workflowId);
577 if (result.status === "Complete") return result;
578 if (result.status === "Error") {
579 return yield* new VolumeBackupWorkflowFailed({
580 workflowId,
581 error: result.error ?? "backup workflow failed",
582 });
583 }
584 return yield* new VolumeBackupPending({
585 volumeInstanceId,
586 state: result.status,
587 });
588 }).pipe(
589 Effect.retry({
590 while: (e) => e._tag === "Railway.VolumeBackupPending",
591 times: 10,
592 schedule: Schedule.spaced("2 seconds"),
593 }),
594 Effect.catchTag("Railway.VolumeBackupPending", () =>
595 mode === "delete"
596 ? Effect.void
597 : Effect.fail(
598 new VolumeBackupWorkflowFailed({
599 workflowId,
600 error: "timed out waiting for backup workflow",
601 }),
602 ),
603 ),
604 );
605
606const instanceReady = (instance: CloudInstance | undefined) =>
607 instance !== undefined &&
608 !isGoneInstance(instance) &&
609 !transientState(instance.state) &&
610 instance.state !== "ERROR";
611
612const waitUntilReady = (volumeInstanceId: string) =>
613 getByInstanceId(volumeInstanceId).pipe(
614 Effect.flatMap((instance) => {
615 if (!instanceReady(instance)) {
616 return Effect.fail(
617 new VolumeBackupPending({
618 volumeInstanceId,
619 state: instance?.state ?? "missing",
620 }),
621 );
622 }
623 return Effect.succeed(instance);
624 }),
625 Effect.retry({
626 while: (e) => e._tag === "Railway.VolumeBackupPending",
627 times: 10,
628 schedule: Schedule.spaced("2 seconds"),
629 }),
630 Effect.catchTag("Railway.VolumeBackupPending", () =>
631 getByInstanceId(volumeInstanceId),
632 ),
633 );
634
635const waitForBackup = (input: {
636 volumeInstanceId: string;
637 name: string;
638 id?: string;
639 previousIds: ReadonlySet<string>;
640}) =>
641 listBackups(input.volumeInstanceId).pipe(
642 Effect.flatMap((backups) => {
643 const found =
644 findBackup(backups, { id: input.id, name: input.name }) ??
645 backups.find((backup) => !input.previousIds.has(backup.id));
646 if (found === undefined) {
647 return Effect.fail(
648 new VolumeBackupPending({
649 volumeInstanceId: input.volumeInstanceId,
650 state: "creating",
651 }),
652 );
653 }
654 return Effect.succeed(found);
655 }),
656 Effect.retry({
657 while: (e) => e._tag === "Railway.VolumeBackupPending",
658 times: 10,
659 schedule: Schedule.spaced("2 seconds"),
660 }),
661 Effect.catchTag("Railway.VolumeBackupPending", () =>
662 listBackups(input.volumeInstanceId).pipe(
663 Effect.map(
664 (backups) =>
665 findBackup(backups, { id: input.id, name: input.name }) ??
666 backups.find((backup) => !input.previousIds.has(backup.id)),
667 ),
668 ),
669 ),
670 );
671
672const waitUntilGone = (
673 volumeInstanceId: string,
674 volumeInstanceBackupId: string,
675) =>
676 waitUntilDeleted(
677 "VolumeBackup",
678 volumeInstanceBackupId,
679 listBackups(volumeInstanceId).pipe(
680 Effect.map(
681 (backups) =>
682 !backups.some((backup) => backup.id === volumeInstanceBackupId),
683 ),
684 ),
685 );
686
687const observeInstance = Effect.fn(function* (input: {
688 volumeInstanceId?: string;
689 volumeId?: string;
690 projectId?: string;
691 environmentId?: string;
692}) {
693 if (
694 input.volumeInstanceId !== undefined &&
695 input.volumeInstanceId.length > 0
696 ) {
697 const byId = yield* getByInstanceId(input.volumeInstanceId);
698 if (byId !== undefined) return byId;
699 }
700 if (
701 input.environmentId !== undefined &&
702 input.projectId !== undefined &&
703 input.volumeId !== undefined
704 ) {
705 const instances = yield* listVolumeInstances(
706 input.environmentId,
707 input.projectId,
708 );
709 return instances.find((instance) => instance.volumeId === input.volumeId);
710 }
711 return undefined;
712});
713
714/**
715 * Restore a volume instance from a backup. Destructive to the live
716 * volume — the instance is overwritten. Waits for the restore workflow.
717 */
718export const restoreVolumeBackup = Effect.fn(function* (input: {
719 volumeInstanceId: string;
720 volumeInstanceBackupId: string;
721}) {
722 const result = yield* volumeInstanceBackupRestore(
723 input.volumeInstanceBackupId,
724 input.volumeInstanceId,
725 );
726 if (result.workflowId != null && result.workflowId.length > 0) {
727 yield* waitForWorkflow(result.workflowId, input.volumeInstanceId, "create");
728 }
729 return result;
730});
731
732/**
733 * Point-in-time restore. Creates a new Postgres service whose volume
734 * is populated from the source archive. The source service is left
735 * online.
736 */
737export const restoreVolumePITR = Effect.fn(function* (input: {
738 volumeInstanceId: string;
739 targetTimestamp: string;
740 newServiceName?: string;
741 sourceRepoPath?: string;
742}) {
743 const result = yield* volumeInstancePITRRestore({
744 volumeInstanceId: input.volumeInstanceId,
745 targetTimestamp: input.targetTimestamp,
746 ...(input.newServiceName !== undefined
747 ? { newServiceName: input.newServiceName }
748 : {}),
749 ...(input.sourceRepoPath !== undefined
750 ? { sourceRepoPath: input.sourceRepoPath }
751 : {}),
752 });
753 if (result.workflowId != null && result.workflowId.length > 0) {
754 yield* waitForWorkflow(result.workflowId, input.volumeInstanceId, "create");
755 }
756 return result;
757});
758
759export const VolumeBackupProvider = () =>
760 Provider.succeed(VolumeBackup, {
761 stables: [
762 "volumeInstanceBackupId",
763 "volumeInstanceId",
764 "volumeId",
765 "projectId",
766 "environmentId",
767 "createdAt",
768 ],
769 nuke: { dependsOn: ["Railway.Volume", "Railway.Project"] },
770
771 diff: Effect.fn(function* ({ news, output }) {
772 if (news === undefined || !isResolved(news)) return undefined;
773 if (output === undefined) return undefined;
774 const nextInstance = volumeInstanceIdOf(news.volume);
775 const instanceChanged =
776 nextInstance !== undefined && nextInstance !== output.volumeInstanceId;
777 const nextEnv = environmentIdOf(news.environment);
778 const environmentChanged =
779 nextEnv !== undefined &&
780 output.environmentId !== undefined &&
781 nextEnv !== output.environmentId;
782 const nextVolumeId = volumeIdOf(news.volume);
783 const volumeChanged =
784 nextVolumeId !== undefined &&
785 output.volumeId !== undefined &&
786 nextVolumeId !== output.volumeId;
787 const nameChanged =
788 news.name !== undefined &&
789 news.name.length > 0 &&
790 news.name !== output.name;
791 if (
792 instanceChanged ||
793 environmentChanged ||
794 volumeChanged ||
795 nameChanged
796 ) {
797 return { action: "replace" as const };
798 }
799 return undefined;
800 }),
801
802 read: Effect.fn(function* ({ id, olds, output }) {
803 const volumeInstanceId =
804 output?.volumeInstanceId ??
805 (olds !== undefined ? volumeInstanceIdOf(olds.volume) : undefined);
806 const name = yield* resolveName(id, output?.name ?? olds?.name);
807 if (volumeInstanceId === undefined) return undefined;
808 const backups = yield* listBackups(volumeInstanceId);
809 const found = findBackup(backups, {
810 id: output?.volumeInstanceBackupId,
811 name,
812 });
813 if (found === undefined) return undefined;
814 const schedules = yield* listSchedules(volumeInstanceId);
815 const attrs = toAttrs(found, {
816 volumeInstanceId,
817 volumeId:
818 output?.volumeId ??
819 (olds !== undefined ? volumeIdOf(olds.volume) : undefined),
820 projectId:
821 output?.projectId ??
822 (olds !== undefined ? projectIdOf(olds.volume) : undefined),
823 environmentId:
824 output?.environmentId ??
825 (olds !== undefined
826 ? (environmentIdOf(olds.environment) ??
827 environmentIdOf(olds.volume))
828 : undefined),
829 schedules,
830 });
831 if (output !== undefined) return attrs;
832 return matchesAlchemyPhysicalName(found.name ?? undefined)
833 ? attrs
834 : Unowned(attrs);
835 }),
836
837 list: Effect.fn(function* () {
838 const projects = yield* ownedProjects();
839 const rows = yield* Effect.forEach(projects, (project) =>
840 Effect.gen(function* () {
841 const envRows = yield* liveEnvironments(project.projectId).pipe(
842 Stream.filter((env) => env.deletedAt == null),
843 Stream.runCollect,
844 Effect.map((chunk) => Array.from(chunk)),
845 Effect.catchTag("RailwayNotFound", () => Effect.succeed([])),
846 );
847 const instances = (yield* Effect.forEach(envRows, (env) =>
848 listVolumeInstances(env.id, project.projectId),
849 ))
850 .flat()
851 .filter((instance) =>
852 matchesAlchemyPhysicalName(instance.volume.name),
853 );
854 const nested = yield* Effect.forEach(instances, (instance) =>
855 Effect.gen(function* () {
856 const backups = yield* listBackups(instance.id);
857 const schedules = yield* listSchedules(instance.id);
858 return backups.map((backup) =>
859 toAttrs(backup, {
860 volumeInstanceId: instance.id,
861 volumeId: instance.volumeId,
862 projectId: instance.volume.projectId,
863 environmentId: instance.environmentId,
864 schedules,
865 }),
866 );
867 }),
868 );
869 return nested.flat();
870 }),
871 );
872 const seen = new Set<string>();
873 const unique: VolumeBackup["Attributes"][] = [];
874 for (const row of rows.flat()) {
875 if (seen.has(row.volumeInstanceBackupId)) continue;
876 seen.add(row.volumeInstanceBackupId);
877 unique.push(row);
878 }
879 return unique;
880 }),
881
882 reconcile: Effect.fn(function* ({ id, news, output }) {
883 const props = news ?? ({} as VolumeBackupProps);
884 const environmentId =
885 environmentIdOf(props.environment) ??
886 environmentIdOf(props.volume) ??
887 output?.environmentId;
888 const projectId = projectIdOf(props.volume) ?? output?.projectId;
889 const volumeId = volumeIdOf(props.volume) ?? output?.volumeId;
890 let volumeInstanceId =
891 volumeInstanceIdOf(props.volume) ?? output?.volumeInstanceId;
892
893 const instance = yield* observeInstance({
894 volumeInstanceId,
895 volumeId,
896 projectId,
897 environmentId,
898 });
899 if (instance !== undefined) {
900 volumeInstanceId = instance.id;
901 }
902 if (volumeInstanceId === undefined || volumeInstanceId.length === 0) {
903 return yield* new VolumeBackupVolumeRequired({
904 message:
905 "VolumeBackup requires a resolved Railway.Volume (volumeInstanceId)",
906 });
907 }
908
909 const name = yield* resolveName(id, props.name ?? output?.name);
910 const resolvedVolumeId = instance?.volumeId ?? volumeId;
911 const resolvedProjectId = instance?.volume.projectId ?? projectId;
912 const resolvedEnvironmentId = instance?.environmentId ?? environmentId;
913
914 let current: CloudBackup | undefined;
915 const existing = yield* listBackups(volumeInstanceId);
916 current = findBackup(existing, {
917 id: output?.volumeInstanceBackupId,
918 name,
919 });
920
921 if (current === undefined) {
922 const ready = yield* waitUntilReady(volumeInstanceId);
923 if (ready === undefined || isGoneInstance(ready)) {
924 return yield* new VolumeBackupNotCreated({
925 name,
926 volumeInstanceId,
927 });
928 }
929 const previousIds = new Set(existing.map((backup) => backup.id));
930 const created = yield* volumeInstanceBackupCreate(
931 volumeInstanceId,
932 name,
933 );
934 if (created.workflowId != null && created.workflowId.length > 0) {
935 yield* waitForWorkflow(
936 created.workflowId,
937 volumeInstanceId,
938 "create",
939 );
940 }
941 current = yield* waitForBackup({
942 volumeInstanceId,
943 name,
944 previousIds,
945 });
946 }
947
948 if (current === undefined) {
949 return yield* new VolumeBackupNotCreated({
950 name,
951 volumeInstanceId,
952 });
953 }
954
955 if (props.lock === true && current.expiresAt != null) {
956 yield* volumeInstanceBackupLock(current.id, volumeInstanceId);
957 const locked = yield* listBackups(volumeInstanceId).pipe(
958 Effect.map((backups) =>
959 findBackup(backups, { id: current!.id, name }),
960 ),
961 );
962 if (locked !== undefined) current = locked;
963 }
964
965 let schedules = yield* listSchedules(volumeInstanceId);
966 if (props.schedules !== undefined) {
967 const desired = uniqueKinds(props.schedules);
968 if (kindsKey(desired) !== kindsKey(schedules)) {
969 yield* volumeInstanceBackupScheduleUpdate(volumeInstanceId, desired);
970 schedules = yield* listSchedules(volumeInstanceId);
971 }
972 }
973
974 return toAttrs(current, {
975 volumeInstanceId,
976 volumeId: resolvedVolumeId,
977 projectId: resolvedProjectId,
978 environmentId: resolvedEnvironmentId,
979 schedules,
980 });
981 }),
982
983 delete: Effect.fn(function* ({ output }) {
984 const volumeInstanceBackupId = output.volumeInstanceBackupId;
985 const volumeInstanceId = output.volumeInstanceId;
986 if (
987 volumeInstanceBackupId.length === 0 ||
988 volumeInstanceId.length === 0
989 ) {
990 return;
991 }
992 const deleted = yield* volumeInstanceBackupDelete(
993 volumeInstanceBackupId,
994 volumeInstanceId,
995 ).pipe(
996 Effect.catchTag("RailwayNotFound", () =>
997 Effect.succeed({ workflowId: null as string | null }),
998 ),
999 );
1000 if (deleted.workflowId != null && deleted.workflowId.length > 0) {
1001 yield* waitForWorkflow(deleted.workflowId, volumeInstanceId, "delete");
1002 }
1003 yield* waitUntilGone(volumeInstanceId, volumeInstanceBackupId);
1004 }),
1005 });