1import * as Effect from "effect/Effect";
2import { createPhysicalName } from "../PhysicalName.ts";
3
4/**
5 * Railway has no labels. Ownership is stamped into the physical name via
6 * `createPhysicalName` (lowercase, max 32, leading letter). Railway's
7 * `projectCreate` rejects longer generated names (`Invalid project name`).
8 * `list()` filters with {@link matchesAlchemyPhysicalName} so nuke does
9 * not enumerate the whole workspace.
10 */
11export const RAILWAY_NAME_MAX_LENGTH = 32;
12
13/**
14 * Railway generates `{serviceName}-{environmentName}.up.railway.app` on
15 * `serviceDomainCreate`. That first DNS label must be ≤ 63 characters
16 * (`32 + 1 + 32 = 65` fails with "please try again"). Extra environments
17 * stay shorter so a 32-char service name still fits.
18 */
19export const RAILWAY_ENVIRONMENT_NAME_MAX_LENGTH = 24;
20
21const clipName = (raw: string, maxLength: number) => {
22 const clipped = raw.length > maxLength ? raw.slice(0, maxLength) : raw;
23 const forced = /^[a-z]/.test(clipped)
24 ? clipped
25 : `r${clipped}`.slice(0, maxLength);
26 return forced.replace(/-+$/g, "") || "r";
27};
28
29/**
30 * Railway Project / Service / Volume / Variable physical names:
31 * `createPhysicalName({ lowercase: true, maxLength: 32 })`, then force a
32 * leading letter (`r` prefix if needed). Unique per workspace.
33 */
34export const createRailwayName = Effect.fn(function* (id: string) {
35 const raw = yield* createPhysicalName({
36 id,
37 lowercase: true,
38 maxLength: RAILWAY_NAME_MAX_LENGTH,
39 });
40 return clipName(raw, RAILWAY_NAME_MAX_LENGTH);
41});
42
43/** Extra-environment names. See {@link RAILWAY_ENVIRONMENT_NAME_MAX_LENGTH}. */
44export const createRailwayEnvironmentName = Effect.fn(function* (id: string) {
45 const raw = yield* createPhysicalName({
46 id,
47 lowercase: true,
48 maxLength: RAILWAY_ENVIRONMENT_NAME_MAX_LENGTH,
49 });
50 return clipName(raw, RAILWAY_ENVIRONMENT_NAME_MAX_LENGTH);
51});
52
53/**
54 * Sanitize a user-supplied Railway name: lowercase, DNS-compatible
55 * (`[a-z0-9-]`), force a leading letter, max 32 chars.
56 */
57const sanitizeTo = (name: string, maxLength: number): string => {
58 const lowered = name
59 .toLowerCase()
60 .replace(/[^a-z0-9-]+/g, "-")
61 .replace(/-+/g, "-")
62 .replace(/^-+|-+$/g, "");
63 return clipName(lowered.length === 0 ? "r" : lowered, maxLength);
64};
65
66export const sanitizeRailwayName = (name: string): string =>
67 sanitizeTo(name, RAILWAY_NAME_MAX_LENGTH);
68
69export const sanitizeRailwayEnvironmentName = (name: string): string =>
70 sanitizeTo(name, RAILWAY_ENVIRONMENT_NAME_MAX_LENGTH);
71
72export const sanitize = sanitizeRailwayName;
73
74/**
75 * True when `name` matches the `createPhysicalName` + leading-letter shape
76 * used for alchemy-owned Railway resources.
77 *
78 * Untruncated names end with a hyphen plus an 8–16 char RFC4648 base32
79 * instance suffix. Truncated 32-char names keep that suffix (the human
80 * prefix is what gets cut).
81 */
82export const matchesAlchemyPhysicalName = (
83 name: string | undefined,
84): boolean => {
85 if (
86 name === undefined ||
87 name.length === 0 ||
88 name.length > RAILWAY_NAME_MAX_LENGTH
89 ) {
90 return false;
91 }
92 if (!/^[a-z][a-z0-9-]*$/.test(name)) return false;
93 const parts = name.split("-");
94 const last = parts.at(-1) ?? "";
95 if (
96 parts.length >= 2 &&
97 last.length >= 8 &&
98 last.length <= 16 &&
99 /^[a-z2-7]+$/.test(last)
100 ) {
101 return true;
102 }
103 const compact = name.replaceAll("-", "");
104 return /[a-z2-7]{16}$/.test(compact);
105};