1import { waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type ServiceDomain as RailwayServiceDomain,
6} from "@distilled.cloud/railway";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { sanitizeRailwayName } from "./Metadata.ts";
11import { withEnvironmentConfigLock } from "./transient.ts";
12
13const domainFields = <E>(domain: Query<RailwayServiceDomain, E>) => ({
14 id: domain.id,
15 domain: domain.domain,
16 serviceId: domain.serviceId,
17 environmentId: domain.environmentId,
18 projectId: domain.projectId,
19 targetPort: domain.targetPort,
20 suffix: domain.suffix,
21 deletedAt: domain.deletedAt,
22 syncStatus: domain.syncStatus,
23});
24type DomainsResponseServiceDomainsItem = UnwrapPlan<
25 ReturnType<typeof domainFields>
26>;
27
28const readServiceDomains = Query.fn(
29 (projectId: string, environmentId: string, serviceId: string) =>
30 Railway.domains({
31 environmentId,
32 projectId,
33 serviceId,
34 }).serviceDomains.pipe(Query.map(domainFields)),
35);
36
37const serviceDomainCreate = Query.fn(
38 (input: { environmentId: string; serviceId: string }) =>
39 domainFields(Railway.serviceDomainCreate({ input })),
40);
41
42const serviceDomainUpdate = Query.fn(
43 (input: {
44 domain: string;
45 environmentId: string;
46 serviceDomainId: string;
47 serviceId: string;
48 targetPort?: number;
49 }) => Railway.serviceDomainUpdate({ input }),
50);
51
52const serviceDomainDelete = Query.fn((id: string) =>
53 Railway.serviceDomainDelete({ id }),
54);
55
56const environmentPatchCommit = Query.fn(
57 (input: {
58 environmentId: string;
59 commitMessage: string;
60 patch: Record<string, unknown>;
61 }) => Railway.environmentPatchCommit(input),
62);
63
64/**
65 * A Railway-generated `*.up.railway.app` hostname on a Service. Created
66 * with `serviceDomainCreate`. Distinct from {@link CustomDomain} (a user
67 * hostname).
68 */
69export type ServiceDomainRecord = {
70 id: string;
71 domain: string;
72 serviceId: string;
73 environmentId: string;
74 projectId: string | undefined;
75 targetPort: number | undefined;
76 syncStatus: string;
77 url: string;
78};
79
80export class ServiceDomainNotCreated extends Data.TaggedError(
81 "Railway.ServiceDomainNotCreated",
82)<{
83 serviceId: string;
84 environmentId: string;
85}> {}
86
87type CloudDomain = DomainsResponseServiceDomainsItem;
88
89const isGone = (domain: CloudDomain | undefined) =>
90 domain === undefined ||
91 domain.deletedAt != null ||
92 domain.syncStatus === "DELETED" ||
93 domain.syncStatus === "DELETING";
94
95const toRecord = (domain: CloudDomain): ServiceDomainRecord => ({
96 id: domain.id,
97 domain: domain.domain,
98 serviceId: domain.serviceId,
99 environmentId: domain.environmentId,
100 projectId: domain.projectId ?? undefined,
101 targetPort: domain.targetPort ?? undefined,
102 syncStatus: domain.syncStatus,
103 url: `https://${domain.domain}`,
104});
105
106export const listServiceDomains = (
107 projectId: string,
108 environmentId: string,
109 serviceId: string,
110) =>
111 readServiceDomains(projectId, environmentId, serviceId).pipe(
112 Effect.map((serviceDomains) =>
113 serviceDomains.filter((domain) => !isGone(domain)),
114 ),
115 Effect.catchTag("RailwayNotFound", () =>
116 Effect.succeed([] as DomainsResponseServiceDomainsItem[]),
117 ),
118 );
119
120const findCloudDomainById = (input: {
121 projectId: string;
122 environmentId: string;
123 serviceId: string;
124 domainId: string;
125}) =>
126 readServiceDomains(
127 input.projectId,
128 input.environmentId,
129 input.serviceId,
130 ).pipe(
131 Effect.map((serviceDomains) =>
132 serviceDomains.find((candidate) => candidate.id === input.domainId),
133 ),
134 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
135 );
136
137export const findServiceDomainById = Effect.fn(function* (input: {
138 projectId: string;
139 environmentId: string;
140 serviceId: string;
141 domainId: string;
142}) {
143 const domain = yield* findCloudDomainById(input);
144 return domain === undefined || isGone(domain) ? undefined : toRecord(domain);
145});
146
147export const deleteServiceDomainById = Effect.fn(function* (input: {
148 projectId: string;
149 environmentId: string;
150 serviceId: string;
151 domainId: string;
152}) {
153 yield* deleteOwnedServiceDomain(input);
154});
155
156/**
157 * Remove the owned generated domain. Environment config is the source of
158 * truth (`serviceDomains[id]: null`); GraphQL delete is the fallback.
159 * Matches the recorded id and, if that id is missing from the live list,
160 * the recorded hostname — never every generated domain.
161 */
162export const deleteOwnedServiceDomain = Effect.fn(function* (input: {
163 projectId: string;
164 environmentId: string;
165 serviceId: string;
166 domainId?: string;
167 domain?: string;
168}) {
169 if (input.domainId === undefined && input.domain === undefined) return;
170
171 const live = yield* listServiceDomains(
172 input.projectId,
173 input.environmentId,
174 input.serviceId,
175 );
176 const owned = live.filter(
177 (row) =>
178 (input.domainId !== undefined && row.id === input.domainId) ||
179 (input.domain !== undefined && row.domain === input.domain),
180 );
181 const keys = new Set<string>([
182 ...(input.domainId !== undefined ? [input.domainId] : []),
183 ...owned.map((row) => row.id),
184 ]);
185 if (keys.size === 0) return;
186
187 yield* withEnvironmentConfigLock(
188 input.environmentId,
189 environmentPatchCommit({
190 environmentId: input.environmentId,
191 commitMessage: "Remove Railway service domain",
192 patch: {
193 services: {
194 [input.serviceId]: {
195 networking: {
196 serviceDomains: Object.fromEntries(
197 [...keys].map((id) => [id, null]),
198 ),
199 },
200 },
201 },
202 },
203 }),
204 ).pipe(Effect.catchTag("RailwayNotFound", () => Effect.void));
205
206 for (const row of owned) {
207 if (row.syncStatus === "DELETING") continue;
208 yield* withEnvironmentConfigLock(
209 input.environmentId,
210 serviceDomainDelete(row.id),
211 ).pipe(
212 Effect.catchTag("RailwayNotFound", () => Effect.void),
213 Effect.asVoid,
214 );
215 }
216
217 yield* waitUntilDeleted(
218 "ServiceDomain",
219 [...keys].join(","),
220 listServiceDomains(
221 input.projectId,
222 input.environmentId,
223 input.serviceId,
224 ).pipe(
225 Effect.map(
226 (rows) =>
227 !rows.some(
228 (row) =>
229 keys.has(row.id) ||
230 (input.domain !== undefined && row.domain === input.domain),
231 ),
232 ),
233 ),
234 10,
235 );
236});
237
238const listedOrUndefined = (input: {
239 projectId: string;
240 environmentId: string;
241 serviceId: string;
242 domainId?: string | null;
243}) =>
244 listServiceDomains(
245 input.projectId,
246 input.environmentId,
247 input.serviceId,
248 ).pipe(
249 Effect.map((rows) =>
250 input.domainId === undefined
251 ? (rows[0] as CloudDomain | undefined)
252 : input.domainId === null
253 ? undefined
254 : rows.find((domain) => domain.id === input.domainId),
255 ),
256 );
257
258/**
259 * Railway's own IaC compiler writes generated domains through
260 * `environmentPatchCommit` as `services[id].networking.serviceDomains[uuid]`.
261 * The GraphQL `serviceDomainCreate` mutation is a convenience wrapper the
262 * CLI also uses; the patch is what `.railway/railway.ts` apply goes through
263 * (and what the environment schema documents as "make publicly accessible
264 * over HTTP").
265 *
266 * @see https://backboard.railway.com/schema/environment.schema.json
267 */
268const createViaEnvironmentPatch = (input: {
269 environmentId: string;
270 serviceId: string;
271}) =>
272 Effect.gen(function* () {
273 const domainKey = yield* Effect.sync(() => crypto.randomUUID());
274 yield* withEnvironmentConfigLock(
275 input.environmentId,
276 environmentPatchCommit({
277 environmentId: input.environmentId,
278 commitMessage: "Generate Railway service domain",
279 patch: {
280 services: {
281 [input.serviceId]: {
282 networking: {
283 serviceDomains: {
284 [domainKey]: {},
285 },
286 },
287 },
288 },
289 },
290 }),
291 );
292 return domainKey;
293 });
294
295/**
296 * Official CLI / public-API mutation. Same input as
297 * `railway domain` (`environmentId` + `serviceId`; optional `targetPort`
298 * is omitted on create — pinning a port while PORT is set makes Railway
299 * reject the mutation).
300 *
301 * @see https://docs.railway.com/integrations/api/manage-domains
302 */
303const waitForServiceDomainById = (input: {
304 projectId: string;
305 environmentId: string;
306 serviceId: string;
307 domainId: string;
308}) =>
309 findCloudDomainById(input).pipe(
310 Effect.repeat({
311 schedule: Schedule.spaced("1 second"),
312 until: (domain) => domain !== undefined && !isGone(domain),
313 times: 8,
314 }),
315 Effect.map((domain) =>
316 domain !== undefined && !isGone(domain) ? domain : undefined,
317 ),
318 );
319
320/**
321 * The environment-config map key is supposed to be the GraphQL domain id,
322 * but the live `domains` list can lag or mint a different id. Prefer the
323 * patch key; otherwise take a domain that was not in the pre-patch set so
324 * we do not claim a foreign generated domain and do not `serviceDomainCreate`
325 * a second hostname.
326 */
327const waitForNewServiceDomain = (input: {
328 projectId: string;
329 environmentId: string;
330 serviceId: string;
331 preferId: string;
332 preexistingIds: ReadonlySet<string>;
333}) =>
334 listServiceDomains(
335 input.projectId,
336 input.environmentId,
337 input.serviceId,
338 ).pipe(
339 Effect.repeat({
340 schedule: Schedule.spaced("1 second"),
341 until: (rows) =>
342 rows.some(
343 (domain) =>
344 domain.id === input.preferId ||
345 !input.preexistingIds.has(domain.id),
346 ),
347 times: 10,
348 }),
349 Effect.map(
350 (rows) =>
351 rows.find((domain) => domain.id === input.preferId) ??
352 rows.find((domain) => !input.preexistingIds.has(domain.id)),
353 ),
354 );
355
356const createViaMutation = (input: {
357 projectId: string;
358 environmentId: string;
359 serviceId: string;
360 domainId?: string;
361}) => {
362 const listed =
363 input.domainId === undefined
364 ? listedOrUndefined(input)
365 : waitForServiceDomainById({ ...input, domainId: input.domainId });
366 const missing = () =>
367 new ServiceDomainNotCreated({
368 serviceId: input.serviceId,
369 environmentId: input.environmentId,
370 });
371 const listedOrFail = <E>(error: E) =>
372 listed.pipe(
373 Effect.flatMap((row) =>
374 row !== undefined ? Effect.succeed(row) : Effect.fail(error),
375 ),
376 );
377 const create = serviceDomainCreate({
378 environmentId: input.environmentId,
379 serviceId: input.serviceId,
380 }).pipe(
381 Effect.flatMap((created) =>
382 input.domainId === undefined
383 ? listedOrFail(missing())
384 : Effect.succeed(created),
385 ),
386 Effect.catchTag("RailwayServiceDomainCreateFailed", (error) =>
387 listedOrFail(error),
388 ),
389 Effect.catchTag("RailwayServiceInstanceNotFound", (error) =>
390 input.domainId !== undefined ? listedOrFail(error) : Effect.fail(error),
391 ),
392 Effect.catchTag("RailwayValidationError", (error) => listedOrFail(error)),
393 );
394
395 return withEnvironmentConfigLock(input.environmentId, create).pipe(
396 Effect.retry({
397 while: (error) =>
398 (input.domainId === undefined &&
399 error._tag === "RailwayServiceDomainCreateFailed") ||
400 error._tag === "RailwayServiceInstanceNotFound",
401 times: 10,
402 schedule: Schedule.spaced("5 seconds"),
403 }),
404 );
405};
406
407/**
408 * Terraform `railway_service_domain` requires `subdomain` and, after
409 * create, immediately `serviceDomainUpdate`s to `{subdomain}.{suffix}`.
410 * Create itself cannot take a subdomain — the GraphQL input only has
411 * `environmentId` / `serviceId` / optional `targetPort`.
412 *
413 * @see https://github.com/terraform-community-providers/terraform-provider-railway/blob/master/internal/provider/resource_service_domain.go
414 */
415const desiredDomainName = (input: {
416 subdomain: string | undefined;
417 suffix: string | null | undefined;
418}) => {
419 if (input.subdomain === undefined || input.subdomain.length === 0) {
420 return undefined;
421 }
422 if (
423 input.suffix === undefined ||
424 input.suffix === null ||
425 input.suffix.length === 0
426 ) {
427 return undefined;
428 }
429 const subdomain = sanitizeRailwayName(input.subdomain);
430 return `${subdomain}.${input.suffix}`;
431};
432
433const syncDomain = (input: {
434 current: CloudDomain;
435 subdomain: string | undefined;
436 targetPort: number | undefined;
437}) => {
438 const domainName = desiredDomainName({
439 subdomain: input.subdomain,
440 suffix: input.current.suffix,
441 });
442 const observedPort = input.current.targetPort ?? undefined;
443 const rename =
444 domainName !== undefined && domainName !== input.current.domain;
445 const retarget =
446 input.targetPort !== undefined && input.targetPort !== observedPort;
447 if (!rename && !retarget) return Effect.succeed(undefined);
448 return withEnvironmentConfigLock(
449 input.current.environmentId,
450 serviceDomainUpdate({
451 domain: domainName ?? input.current.domain,
452 environmentId: input.current.environmentId,
453 serviceDomainId: input.current.id,
454 serviceId: input.current.serviceId,
455 ...(retarget ? { targetPort: input.targetPort } : {}),
456 }),
457 );
458};
459
460/**
461 * Observe-ensure-sync a generated `*.up.railway.app` domain. Creates one
462 * when missing (environment-config patch, then the public mutation),
463 * claims a stable `{subdomain}.{suffix}` like Terraform, updates
464 * `targetPort` in place, and returns the live record.
465 *
466 * Create itself cannot take a subdomain — Railway assigns
467 * `{serviceName}-{environmentName}.up.railway.app`. That first DNS label
468 * must be ≤ 63 characters or the API returns "please try again". Extra
469 * environments are capped at 24 chars so a 32-char service still fits.
470 */
471export const ensureServiceDomain = Effect.fn(function* (input: {
472 projectId: string;
473 environmentId: string;
474 serviceId: string;
475 domainId?: string | null;
476 /** DNS label claimed via `serviceDomainUpdate`, Terraform-style. */
477 subdomain?: string;
478 targetPort?: number;
479}) {
480 let current: CloudDomain | undefined = yield* listedOrUndefined(input);
481
482 let createdDomainId: string | undefined;
483 if (current === undefined) {
484 const preexisting = yield* listServiceDomains(
485 input.projectId,
486 input.environmentId,
487 input.serviceId,
488 );
489 createdDomainId = yield* createViaEnvironmentPatch({
490 environmentId: input.environmentId,
491 serviceId: input.serviceId,
492 });
493 current = yield* waitForNewServiceDomain({
494 projectId: input.projectId,
495 environmentId: input.environmentId,
496 serviceId: input.serviceId,
497 preferId: createdDomainId,
498 preexistingIds: new Set(preexisting.map((domain) => domain.id)),
499 });
500 }
501
502 if (current === undefined) {
503 if (input.domainId === undefined) {
504 current = yield* createViaMutation({
505 projectId: input.projectId,
506 environmentId: input.environmentId,
507 serviceId: input.serviceId,
508 });
509 } else {
510 const domainId = createdDomainId;
511 if (domainId === undefined) {
512 return yield* new ServiceDomainNotCreated({
513 serviceId: input.serviceId,
514 environmentId: input.environmentId,
515 });
516 }
517 current = yield* createViaMutation({ ...input, domainId });
518 }
519 }
520
521 if (current === undefined || isGone(current)) {
522 return yield* new ServiceDomainNotCreated({
523 serviceId: input.serviceId,
524 environmentId: input.environmentId,
525 });
526 }
527
528 yield* syncDomain({
529 current,
530 subdomain: input.subdomain,
531 targetPort: input.targetPort,
532 });
533
534 current =
535 (yield* listedOrUndefined({ ...input, domainId: current.id })) ?? current;
536
537 if (current === undefined || isGone(current)) {
538 return yield* new ServiceDomainNotCreated({
539 serviceId: input.serviceId,
540 environmentId: input.environmentId,
541 });
542 }
543
544 return toRecord(current);
545});