1import { Query } from "@distilled.cloud/core/query";
2import { Railway } from "@distilled.cloud/railway";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { withEnvironmentConfigLock } from "./transient.ts";
7
8/**
9 * `deploy.multiRegionConfig` on one service. `null` removes a region.
10 * Railway ignores `ServiceInstance.region`; this map is the placement.
11 */
12export type RegionPlacement = Record<
13 string,
14 { readonly numReplicas?: number | null } | null
15>;
16
17export class ServiceRegionNotApplied extends Data.TaggedError(
18 "Railway.ServiceRegionNotApplied",
19)<{
20 serviceId: string;
21 environmentId: string;
22 region: string;
23 observed: string | undefined;
24}> {
25 override get message() {
26 const where = this.observed ?? "no region";
27 return `Railway service ${this.serviceId} is in ${where} after requesting ${this.region}`;
28 }
29}
30
31class ServiceRegionPending extends Data.TaggedError(
32 "Railway.ServiceRegionPending",
33)<{
34 serviceId: string;
35 region: string;
36}> {}
37
38const isRecord = (value: unknown): value is Record<string, unknown> =>
39 value !== null && typeof value === "object" && !Array.isArray(value);
40
41/** Replicas Railway is actually placing. Empty objects and nulls are absent. */
42export const activeReplicaRegions = (
43 placement: RegionPlacement,
44): Array<{ region: string; replicas: number }> => {
45 const active: Array<{ region: string; replicas: number }> = [];
46 for (const [region, value] of Object.entries(placement)) {
47 const replicas = value?.numReplicas;
48 if (typeof replicas === "number" && replicas >= 1) {
49 active.push({ region, replicas });
50 }
51 }
52 return active;
53};
54
55/**
56 * The service's region when exactly one region has replicas. Several
57 * regions means there is no single region. An empty map falls back to
58 * the legacy `ServiceInstance.region` field.
59 */
60export const observedRegion = (
61 placement: RegionPlacement,
62 legacy: string | null | undefined,
63): string | undefined => {
64 const active = activeReplicaRegions(placement);
65 const only = active[0];
66 if (active.length === 1 && only !== undefined) return only.region;
67 if (active.length > 1) return undefined;
68 return legacy != null && legacy.length > 0 ? legacy : undefined;
69};
70
71/**
72 * Patch that leaves `desired` as the only region with replicas.
73 * `undefined` when that is already true. An existing count on `desired`
74 * is kept; otherwise the current replicas move with the service.
75 */
76export const regionPlacementPatch = (
77 desired: string,
78 placement: RegionPlacement,
79 fallbackReplicas?: number,
80): Record<string, { numReplicas: number } | null> | undefined => {
81 const active = activeReplicaRegions(placement);
82 const only = active[0];
83 if (active.length === 1 && only !== undefined && only.region === desired) {
84 return undefined;
85 }
86
87 const current = active.find((row) => row.region === desired)?.replicas;
88 const moved = active.reduce((sum, row) => sum + row.replicas, 0);
89 const fallback =
90 typeof fallbackReplicas === "number" && fallbackReplicas >= 1
91 ? fallbackReplicas
92 : 1;
93 const numReplicas = current ?? (moved > 0 ? moved : fallback);
94 const patch: Record<string, { numReplicas: number } | null> = {
95 [desired]: { numReplicas },
96 };
97 for (const region of Object.keys(placement)) {
98 if (region !== desired) patch[region] = null;
99 }
100 return patch;
101};
102
103/** `deploy.multiRegionConfig` for `serviceId` inside an environment config. */
104export const serviceRegionPlacement = (
105 config: unknown,
106 serviceId: string,
107): RegionPlacement => {
108 if (!isRecord(config)) return {};
109 const services = config.services;
110 if (!isRecord(services)) return {};
111 const service = services[serviceId];
112 if (!isRecord(service)) return {};
113 const deploy = service.deploy;
114 if (!isRecord(deploy)) return {};
115 const multiRegionConfig = deploy.multiRegionConfig;
116 if (!isRecord(multiRegionConfig)) return {};
117
118 const placement: RegionPlacement = {};
119 for (const [region, value] of Object.entries(multiRegionConfig)) {
120 if (value === null) {
121 placement[region] = null;
122 continue;
123 }
124 if (!isRecord(value)) continue;
125 const replicas = value.numReplicas;
126 placement[region] =
127 typeof replicas === "number" ? { numReplicas: replicas } : {};
128 }
129 return placement;
130};
131
132const readEnvironmentConfig = Query.fn(
133 (args: { id: string; projectId?: string }) => ({
134 config: Railway.environment(args).config,
135 }),
136);
137
138const environmentPatchCommit = Query.fn(
139 (args: { environmentId: string; commitMessage: string; patch: unknown }) =>
140 Railway.environmentPatchCommit(args),
141);
142
143const readPlacement = (input: {
144 environmentId: string;
145 projectId: string;
146 serviceId: string;
147}) =>
148 readEnvironmentConfig(
149 input.projectId.length > 0
150 ? { id: input.environmentId, projectId: input.projectId }
151 : { id: input.environmentId },
152 ).pipe(
153 Effect.map((environment) =>
154 serviceRegionPlacement(environment.config, input.serviceId),
155 ),
156 Effect.catchTag("RailwayNotFound", () =>
157 Effect.succeed({} as RegionPlacement),
158 ),
159 );
160
161/** Region reported by the environment config, without writing. */
162export const readServiceRegion = Effect.fn(function* (input: {
163 environmentId: string;
164 projectId: string;
165 serviceId: string;
166 legacy?: string | null;
167}) {
168 if (input.environmentId.length === 0 || input.serviceId.length === 0) {
169 return observedRegion({}, input.legacy);
170 }
171 const placement = yield* readPlacement(input);
172 return observedRegion(placement, input.legacy);
173});
174
175const waitUntilRegion = (input: {
176 environmentId: string;
177 projectId: string;
178 serviceId: string;
179 region: string;
180 legacy?: string | null;
181}) =>
182 readServiceRegion(input).pipe(
183 Effect.flatMap((observed) =>
184 observed === input.region
185 ? Effect.succeed(input.region)
186 : Effect.fail(
187 new ServiceRegionPending({
188 serviceId: input.serviceId,
189 region: input.region,
190 }),
191 ),
192 ),
193 Effect.retry({
194 while: (error) => error._tag === "Railway.ServiceRegionPending",
195 schedule: Schedule.spaced("1 second"),
196 times: 10,
197 }),
198 Effect.catchTag("Railway.ServiceRegionPending", () =>
199 readServiceRegion(input).pipe(
200 Effect.flatMap((observed) =>
201 observed === input.region
202 ? Effect.succeed(input.region)
203 : Effect.fail(
204 new ServiceRegionNotApplied({
205 serviceId: input.serviceId,
206 environmentId: input.environmentId,
207 region: input.region,
208 observed,
209 }),
210 ),
211 ),
212 ),
213 ),
214 );
215
216/**
217 * Make `region` the only replica placement, or read the current one when
218 * `region` is omitted. Replica moves go through `environmentPatchCommit`
219 * (`services[id].deploy.multiRegionConfig`), the same patch Railway's CLI
220 * applies for `railway scale`.
221 */
222export const syncServiceRegion = Effect.fn(function* (input: {
223 environmentId: string;
224 projectId: string;
225 serviceId: string;
226 region: string | undefined;
227 legacy?: string | null;
228 fallbackReplicas?: number | null;
229}) {
230 if (input.region === undefined) return yield* readServiceRegion(input);
231 const desired = input.region;
232 const patched = yield* withEnvironmentConfigLock(
233 input.environmentId,
234 Effect.gen(function* () {
235 const placement = yield* readPlacement(input);
236 const patch = regionPlacementPatch(
237 desired,
238 placement,
239 input.fallbackReplicas ?? undefined,
240 );
241 if (patch === undefined) return false;
242 yield* environmentPatchCommit({
243 environmentId: input.environmentId,
244 commitMessage: `Pin Railway service region to ${desired}`,
245 patch: {
246 services: {
247 [input.serviceId]: {
248 deploy: { multiRegionConfig: patch },
249 },
250 },
251 },
252 });
253 return true;
254 }),
255 );
256 if (!patched) return desired;
257 return yield* waitUntilRegion({ ...input, region: desired });
258});