1import { Query } from "@distilled.cloud/core/query";
2import {
3 Railway,
4 type Bucket,
5 type Group,
6 type Service,
7 type ServiceInstance,
8 type VolumeInstance,
9} from "@distilled.cloud/railway";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14
15/**
16 * Collect every node of a connection. `select` projects one node, e.g.
17 * `(service) => ({ id: service.id, name: service.name })`.
18 */
19const collect = <Item, Error>(
20 connection: Query<ReadonlyArray<Item> | null, Error>,
21) =>
22 Stream.runCollect(Query.items(connection)).pipe(
23 Effect.map((items) => Array.from(items)),
24 );
25
26/** Every service of a project, projected by `select`. */
27export const projectServices = <Mapped>(
28 projectId: string,
29 select: (service: Query<Service>) => Mapped,
30) =>
31 collect(
32 Railway.project({ id: projectId })
33 .services({ first: 50 })
34 .pipe(Query.map(select)),
35 );
36
37/** Every bucket of a project, projected by `select`. */
38export const projectBuckets = <Mapped>(
39 projectId: string,
40 select: (bucket: Query<Bucket>) => Mapped,
41) =>
42 collect(
43 Railway.project({ id: projectId })
44 .buckets({ first: 50 })
45 .pipe(Query.map(select)),
46 );
47
48/** Every group of a project, projected by `select`. */
49export const projectGroups = <Mapped>(
50 projectId: string,
51 select: (group: Query<Group>) => Mapped,
52) =>
53 collect(
54 Railway.project({ id: projectId })
55 .groups({ first: 50 })
56 .pipe(Query.map(select)),
57 );
58
59const environmentDeleted = Query.fn((id: string, projectId: string) => ({
60 deletedAt: Railway.environment({ id, projectId }).deletedAt,
61}));
62
63/** Volumes are read through their environment so access checks remain scoped. */
64export const environmentVolumes = <Mapped>(
65 environmentId: string,
66 projectId: string,
67 select: (volume: Query<VolumeInstance>) => Mapped,
68) =>
69 Effect.gen(function* () {
70 const { deletedAt } = yield* environmentDeleted(environmentId, projectId);
71 if (deletedAt !== null) return [];
72 return yield* collect(
73 Railway.environment({ id: environmentId, projectId })
74 .volumeInstances({ first: 50 })
75 .pipe(Query.map(select)),
76 );
77 });
78
79/** Enumerate existing service instances without probing absent service/environment pairs. */
80export const environmentServiceInstances = <Mapped>(
81 environmentId: string,
82 projectId: string,
83 select: (instance: Query<ServiceInstance>) => Mapped,
84) =>
85 Effect.gen(function* () {
86 const { deletedAt } = yield* environmentDeleted(environmentId, projectId);
87 if (deletedAt !== null) return [];
88 return yield* collect(
89 Railway.environment({ id: environmentId, projectId })
90 .serviceInstances({ first: 50 })
91 .pipe(Query.map(select)),
92 );
93 });
94
95/** A delete remains pending until its read path confirms absence. */
96export class ResourceDeletionPending extends Data.TaggedError(
97 "Railway.ResourceDeletionPending",
98)<{
99 resourceType: string;
100 resourceId: string;
101}> {}
102
103export const waitUntilDeleted = <E, R>(
104 resourceType: string,
105 resourceId: string,
106 absent: Effect.Effect<boolean, E, R>,
107 times: 4 | 8 | 10 = 8,
108) =>
109 absent.pipe(
110 Effect.repeat({
111 schedule: Schedule.spaced("1 second"),
112 until: (gone) => gone,
113 times,
114 }),
115 Effect.flatMap((gone) =>
116 gone
117 ? Effect.void
118 : Effect.fail(
119 new ResourceDeletionPending({ resourceType, resourceId }),
120 ),
121 ),
122 );