1import { waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type AggregatedUsage,
6 type EstimatedUsage,
7 type MetricMeasurement,
8 type MetricTag,
9 type UsageLimit as RailwayUsageLimit,
10 type UsageLimitSetInput,
11} from "@distilled.cloud/railway";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import { isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { RailwayEnvironment, resolveWorkspace } from "./Environment.ts";
18
19import type { Providers } from "./Providers.ts";
20
21const usageFields = <E>(row: Query<AggregatedUsage, E>) => ({
22 measurement: row.measurement,
23 value: row.value,
24 tags: {
25 deploymentId: row.tags.deploymentId,
26 deploymentInstanceId: row.tags.deploymentInstanceId,
27 environmentId: row.tags.environmentId,
28 projectId: row.tags.projectId,
29 region: row.tags.region,
30 serviceId: row.tags.serviceId,
31 volumeId: row.tags.volumeId,
32 volumeInstanceId: row.tags.volumeInstanceId,
33 },
34});
35type UsageResultItem = UnwrapPlan<ReturnType<typeof usageFields>>;
36
37const estimatedUsageFields = <E>(row: Query<EstimatedUsage, E>) => ({
38 measurement: row.measurement,
39 estimatedValue: row.estimatedValue,
40 projectId: row.projectId,
41});
42type EstimatedUsageResultItem = UnwrapPlan<
43 ReturnType<typeof estimatedUsageFields>
44>;
45
46const limitFields = <E>(limit: Query<RailwayUsageLimit, E>) => ({
47 id: limit.id,
48 customerId: limit.customerId,
49 softLimit: limit.softLimit,
50 hardLimit: limit.hardLimit,
51 isOverLimit: limit.isOverLimit,
52});
53type CloudLimit = UnwrapPlan<ReturnType<typeof limitFields>>;
54
55/**
56 * A resource-valued prop: the resource itself, or an Effect that produces
57 * it (so `yield* Project(...)` and `Project(...)` both type-check).
58 */
59type Ref<T> = T | Effect.Effect<T, never, Providers>;
60
61export type {
62 EstimatedUsageResultItem,
63 MetricMeasurement,
64 MetricTag,
65 UsageResultItem,
66};
67
68/**
69 * Workspace identity a usage limit applies to. Accepts a
70 * `Railway.Project` (`workspaceId`), `{ workspaceId }`, or `{ id }`.
71 */
72export type UsageLimitWorkspace = {
73 readonly workspaceId?: string;
74 readonly id?: string;
75};
76
77/**
78 * Project whose workspace a usage limit applies to. Accepts a
79 * `Railway.Project` or a `{ workspaceId, projectId? }` stub.
80 */
81export type UsageLimitProject = {
82 readonly workspaceId: string;
83 readonly projectId?: string;
84};
85
86/**
87 * Dollar cap passed to `usageLimitSet`. A number is the soft limit.
88 */
89export type UsageLimitAmount = {
90 readonly softLimitDollars: number;
91 readonly hardLimitDollars?: number;
92};
93
94export interface UsageQuery {
95 /**
96 * Project to query. Accepts a project id, a `Railway.Project`, or a
97 * `{ projectId }` stub. Omit for workspace-wide usage.
98 */
99 project?: string | { readonly projectId: string };
100 /**
101 * Workspace to query. Accepts a workspace id, `{ workspaceId }` /
102 * `{ id }`, or a `Railway.Project`. Defaults to the current token
103 * workspace.
104 */
105 workspace?: string | UsageLimitWorkspace;
106 /**
107 * Measurements to aggregate (`CPU_USAGE`, `MEMORY_USAGE_GB`, …).
108 */
109 measurements: ReadonlyArray<MetricMeasurement>;
110 /**
111 * Inclusive start of the window (RFC3339). Omit for the current
112 * billing period of the project owner.
113 */
114 startDate?: string;
115 /**
116 * Inclusive end of the window (RFC3339). Omit for now.
117 */
118 endDate?: string;
119 /**
120 * Tags to group by (`PROJECT_ID`, `SERVICE_ID`, …). Default is the
121 * whole project / workspace.
122 */
123 groupBy?: ReadonlyArray<MetricTag>;
124 /**
125 * Include deleted projects in the aggregation.
126 *
127 * @default false
128 */
129 includeDeleted?: boolean;
130}
131
132export interface EstimatedUsageQuery {
133 /**
134 * Project to estimate. Accepts a project id, a `Railway.Project`, or a
135 * `{ projectId }` stub. Omit for workspace-wide estimates.
136 */
137 project?: string | { readonly projectId: string };
138 /**
139 * Workspace to estimate. Defaults to the current token workspace.
140 */
141 workspace?: string | UsageLimitWorkspace;
142 /**
143 * Measurements to estimate (`CPU_USAGE`, `MEMORY_USAGE_GB`, …).
144 */
145 measurements: ReadonlyArray<MetricMeasurement>;
146 /**
147 * Include deleted projects in the estimate.
148 *
149 * @default false
150 */
151 includeDeleted?: boolean;
152}
153
154export interface UsageLimitProps {
155 /**
156 * Workspace whose customer this limit applies to. Accepts a
157 * `Railway.Project`, `{ workspaceId }`, or `{ id }`. Defaults to the
158 * current token's workspace. Changing it to a different workspace
159 * replaces the limit.
160 */
161 workspace?: Ref<UsageLimitWorkspace>;
162 /**
163 * Project whose workspace this limit applies to. Alternative to
164 * `workspace`. Changing it to a project in a different workspace
165 * replaces the limit.
166 */
167 project?: Ref<UsageLimitProject>;
168 /**
169 * Dollar cap. A number is `softLimitDollars`. Pass
170 * `{ softLimitDollars, hardLimitDollars }` for both. Updates in place
171 * via `usageLimitSet`.
172 */
173 limit: number | UsageLimitAmount;
174}
175
176export type UsageLimit = Resource<
177 "Railway.UsageLimit",
178 UsageLimitProps,
179 {
180 /** Railway usage-limit id. */
181 usageLimitId: string;
182 /** Stripe / Railway customer id the cap is set on. */
183 customerId: string;
184 /** Workspace the customer belongs to. */
185 workspaceId: string;
186 /** Project used to resolve the workspace, if one was passed. */
187 projectId: string | undefined;
188 /** Soft dollar cap (`usageLimitSet.softLimitDollars`). */
189 softLimitDollars: number;
190 /** Hard dollar cap, if set. */
191 hardLimitDollars: number | undefined;
192 /** Whether current spend is over the soft cap. */
193 isOverLimit: boolean;
194 },
195 never,
196 Providers
197>;
198
199const projectIdOf = (value: unknown): string | undefined => {
200 if (typeof value === "string" && value.length > 0) return value;
201 if (value === null || typeof value !== "object") return undefined;
202 const rec = value as { projectId?: unknown };
203 return typeof rec.projectId === "string" && rec.projectId.length > 0
204 ? rec.projectId
205 : undefined;
206};
207
208const workspaceIdOf = (value: unknown): string | undefined => {
209 if (typeof value === "string" && value.length > 0) return value;
210 if (value === null || typeof value !== "object") return undefined;
211 const rec = value as { workspaceId?: unknown; id?: unknown };
212 if (typeof rec.workspaceId === "string" && rec.workspaceId.length > 0) {
213 return rec.workspaceId;
214 }
215 if (typeof rec.id === "string" && rec.id.length > 0) {
216 return rec.id;
217 }
218 return undefined;
219};
220
221const parseLimit = (limit: number | UsageLimitAmount): UsageLimitAmount => {
222 if (typeof limit === "number") {
223 return { softLimitDollars: limit };
224 }
225 return {
226 softLimitDollars: limit.softLimitDollars,
227 ...(limit.hardLimitDollars !== undefined
228 ? { hardLimitDollars: limit.hardLimitDollars }
229 : {}),
230 };
231};
232
233const readUsage = Query.fn(
234 (args: {
235 measurements: ReadonlyArray<MetricMeasurement>;
236 workspaceId: string;
237 projectId?: string;
238 startDate?: string;
239 endDate?: string;
240 groupBy?: ReadonlyArray<MetricTag>;
241 includeDeleted?: boolean;
242 }) => Railway.usage(args).pipe(Query.map(usageFields)),
243);
244
245const readEstimatedUsage = Query.fn(
246 (args: {
247 measurements: ReadonlyArray<MetricMeasurement>;
248 workspaceId: string;
249 projectId?: string;
250 includeDeleted?: boolean;
251 }) => Railway.estimatedUsage(args).pipe(Query.map(estimatedUsageFields)),
252);
253
254/**
255 * Billing usage for a project, workspace, or the current user.
256 *
257 * Pass `measurements`. Omit `startDate` / `endDate` for the current
258 * billing period. Omit `project` for the current workspace.
259 *
260 * @example Current workspace CPU
261 * ```typescript
262 * const rows = yield* Railway.usage({
263 * measurements: ["CPU_USAGE"],
264 * });
265 * ```
266 *
267 * @example Project window
268 * ```typescript
269 * const rows = yield* Railway.usage({
270 * project: site,
271 * measurements: ["CPU_USAGE", "MEMORY_USAGE_GB"],
272 * startDate: "2026-01-01T00:00:00.000Z",
273 * endDate: "2026-01-31T23:59:59.000Z",
274 * });
275 * ```
276 */
277export const usage = Effect.fn(function* (query: UsageQuery) {
278 const projectId = projectIdOf(query.project);
279 const workspaceId =
280 workspaceIdOf(query.workspace) ?? (yield* resolveWorkspace()).id;
281 const rows = yield* readUsage({
282 measurements: [...query.measurements],
283 workspaceId,
284 ...(projectId !== undefined ? { projectId } : {}),
285 ...(query.startDate !== undefined ? { startDate: query.startDate } : {}),
286 ...(query.endDate !== undefined ? { endDate: query.endDate } : {}),
287 ...(query.groupBy !== undefined ? { groupBy: [...query.groupBy] } : {}),
288 ...(query.includeDeleted !== undefined
289 ? { includeDeleted: query.includeDeleted }
290 : {}),
291 });
292 return rows ?? [];
293});
294
295/** Catalog helper. Alias of {@link usage}. */
296export const Usage = usage;
297
298/**
299 * Estimated total cost at the end of the current billing cycle.
300 *
301 * @example Workspace estimate
302 * ```typescript
303 * const rows = yield* Railway.estimatedUsage({
304 * measurements: ["CPU_USAGE"],
305 * });
306 * ```
307 */
308export const estimatedUsage = Effect.fn(function* (query: EstimatedUsageQuery) {
309 const projectId = projectIdOf(query.project);
310 const workspaceId =
311 workspaceIdOf(query.workspace) ?? (yield* resolveWorkspace()).id;
312 const rows = yield* readEstimatedUsage({
313 measurements: [...query.measurements],
314 workspaceId,
315 ...(projectId !== undefined ? { projectId } : {}),
316 ...(query.includeDeleted !== undefined
317 ? { includeDeleted: query.includeDeleted }
318 : {}),
319 });
320 return rows ?? [];
321});
322
323const resolveUsageLimitProps = (
324 props: UsageLimitProps | Effect.Effect<UsageLimitProps, never, Providers>,
325): Effect.Effect<UsageLimitProps, never, Providers> =>
326 Effect.gen(function* () {
327 const resolved = Effect.isEffect(props) ? yield* props : props;
328 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
329 const workspace =
330 resolved.workspace === undefined
331 ? undefined
332 : Effect.isEffect(resolved.workspace)
333 ? yield* resolved.workspace as Effect.Effect<
334 UsageLimitWorkspace,
335 never,
336 Providers
337 >
338 : resolved.workspace;
339 const project =
340 resolved.project === undefined
341 ? undefined
342 : Effect.isEffect(resolved.project)
343 ? yield* resolved.project as Effect.Effect<
344 UsageLimitProject,
345 never,
346 Providers
347 >
348 : resolved.project;
349 return { ...resolved, workspace, project };
350 });
351
352const UsageLimitResource = Resource<UsageLimit>("Railway.UsageLimit");
353
354/**
355 * A Railway.UsageLimit is a soft/hard dollar cap on a workspace
356 * customer. One limit per customer. `usageLimitRemove` is idempotent.
357 *
358 * @see https://docs.railway.com/reference/pricing
359 *
360 * ### Query usage
361 * {@link usage} is a catalog helper — not a resource. It returns
362 * aggregated rows for the current workspace (or a project).
363 *
364 * **Example:** Workspace usage
365 * ```typescript
366 * const rows = yield* Railway.usage({
367 * measurements: ["CPU_USAGE", "MEMORY_USAGE_GB"],
368 * });
369 * ```
370 *
371 * ### Set a limit
372 * Omit `workspace` / `project` to cap the current token workspace. A
373 * number is the soft dollar cap.
374 *
375 * **Example:** Soft cap
376 * ```typescript
377 * const cap = yield* Railway.UsageLimit("SpendCap", {
378 * limit: 50,
379 * });
380 * ```
381 *
382 * **Example:** Soft and hard
383 * ```typescript
384 * const cap = yield* Railway.UsageLimit("SpendCap", {
385 * project: site,
386 * limit: { softLimitDollars: 50, hardLimitDollars: 100 },
387 * });
388 * ```
389 *
390 * :::caution[Changing `workspace` or `project` to another workspace replaces the limit]
391 * The cap is removed from the old customer, then set on the new one.
392 * :::
393 *
394 * ### Module-scope declarations
395 * Resource-valued props accept the resource or an Effect producing it.
396 *
397 * **Example:** Module-scope limit
398 * ```typescript
399 * // src/billing.ts
400 * import * as Railway from "alchemy/Railway";
401 *
402 * export const Site = Railway.Project("Site");
403 * export const SpendCap = Railway.UsageLimit("SpendCap", {
404 * project: Site,
405 * limit: 50,
406 * });
407 * ```
408 *
409 * @resource
410 * @product Workspace
411 */
412export const UsageLimit: typeof UsageLimitResource = Object.assign(
413 (
414 id: string,
415 props: UsageLimitProps | Effect.Effect<UsageLimitProps, never, Providers>,
416 ) => UsageLimitResource(id, resolveUsageLimitProps(props)),
417 UsageLimitResource,
418);
419
420export class UsageLimitNotCreated extends Data.TaggedError(
421 "Railway.UsageLimitNotCreated",
422)<{
423 customerId: string;
424 workspaceId: string;
425}> {}
426
427export class UsageLimitWorkspaceNotFound extends Data.TaggedError(
428 "Railway.UsageLimitWorkspaceNotFound",
429)<{
430 workspaceId: string;
431}> {}
432
433const currentWorkspaceId = Effect.fn(function* () {
434 const env = yield* yield* RailwayEnvironment;
435 return env.workspaceId;
436});
437
438const readWorkspaceLimit = Query.fn((workspaceId: string) => {
439 const customer = Railway.workspace({ workspaceId }).customer;
440 return {
441 customer: {
442 id: customer.id,
443 usageLimit: customer.usageLimit.pipe(Query.map(limitFields)),
444 },
445 };
446});
447
448const getWorkspace = (workspaceId: string) =>
449 readWorkspaceLimit(workspaceId).pipe(
450 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
451 );
452
453const toAttrs = (
454 limit: CloudLimit,
455 fallback: {
456 workspaceId: string;
457 customerId: string;
458 projectId?: string;
459 },
460): UsageLimit["Attributes"] => ({
461 usageLimitId: limit.id,
462 customerId: limit.customerId || fallback.customerId,
463 workspaceId: fallback.workspaceId,
464 projectId: fallback.projectId,
465 softLimitDollars: limit.softLimit,
466 hardLimitDollars: limit.hardLimit ?? undefined,
467 isOverLimit: limit.isOverLimit,
468});
469
470const observe = Effect.fn(function* (workspaceId: string) {
471 const workspace = yield* getWorkspace(workspaceId);
472 if (workspace === undefined) return undefined;
473 return {
474 workspace,
475 customerId: workspace.customer.id,
476 limit: workspace.customer.usageLimit ?? undefined,
477 };
478});
479
480const usageLimitSet = Query.fn((input: UsageLimitSetInput) =>
481 Railway.usageLimitSet({ input }),
482);
483
484const usageLimitRemove = Query.fn((customerId: string) =>
485 Railway.usageLimitRemove({ input: { customerId } }),
486);
487
488const setLimit = (input: {
489 customerId: string;
490 softLimitDollars: number;
491 hardLimitDollars?: number | null;
492}) =>
493 usageLimitSet({
494 customerId: input.customerId,
495 softLimitDollars: input.softLimitDollars,
496 ...(input.hardLimitDollars !== undefined
497 ? { hardLimitDollars: input.hardLimitDollars }
498 : {}),
499 });
500
501const resolveScope = Effect.fn(function* (input: {
502 news?: UsageLimitProps;
503 olds?: UsageLimitProps;
504 output?: UsageLimit["Attributes"];
505}) {
506 const projectId =
507 projectIdOf(input.news?.project) ??
508 input.output?.projectId ??
509 projectIdOf(input.olds?.project);
510 const workspaceId =
511 workspaceIdOf(input.news?.workspace) ??
512 workspaceIdOf(input.news?.project) ??
513 input.output?.workspaceId ??
514 workspaceIdOf(input.olds?.workspace) ??
515 workspaceIdOf(input.olds?.project) ??
516 (yield* currentWorkspaceId());
517 return { workspaceId, projectId };
518});
519
520const waitUntilGone = (workspaceId: string, usageLimitId: string) =>
521 waitUntilDeleted(
522 "UsageLimit",
523 usageLimitId,
524 observe(workspaceId).pipe(
525 Effect.map((found) => {
526 if (found?.limit === undefined) return true;
527 return found.limit.id !== usageLimitId;
528 }),
529 ),
530 );
531
532export const UsageLimitProvider = () =>
533 Provider.succeed(UsageLimit, {
534 stables: ["usageLimitId", "customerId", "workspaceId"],
535
536 diff: Effect.fn(function* ({ news, output }) {
537 if (news === undefined || !isResolved(news)) return undefined;
538 if (output === undefined) return undefined;
539 const nextWorkspace =
540 workspaceIdOf(news.workspace) ?? workspaceIdOf(news.project);
541 if (nextWorkspace !== undefined && nextWorkspace !== output.workspaceId) {
542 return { action: "replace" as const };
543 }
544 return undefined;
545 }),
546
547 read: Effect.fn(function* ({ olds, output }) {
548 const { workspaceId, projectId } = yield* resolveScope({
549 olds,
550 output,
551 });
552 const found = yield* observe(workspaceId);
553 if (found?.limit === undefined) return undefined;
554 return toAttrs(found.limit, {
555 workspaceId,
556 customerId: found.customerId,
557 projectId,
558 });
559 }),
560
561 list: Effect.fn(function* () {
562 const workspaceId = yield* currentWorkspaceId();
563 const found = yield* observe(workspaceId);
564 if (found?.limit === undefined) return [];
565 return [
566 toAttrs(found.limit, {
567 workspaceId,
568 customerId: found.customerId,
569 }),
570 ];
571 }),
572
573 reconcile: Effect.fn(function* ({ news, output }) {
574 const props = news ?? ({} as UsageLimitProps);
575 const { workspaceId, projectId } = yield* resolveScope({
576 news: props,
577 output,
578 });
579 const desired = parseLimit(props.limit ?? output?.softLimitDollars ?? 0);
580
581 let found = yield* observe(workspaceId);
582 if (found === undefined) {
583 return yield* new UsageLimitWorkspaceNotFound({ workspaceId });
584 }
585
586 const observed = found.limit;
587 const hardChanged =
588 desired.hardLimitDollars !== undefined
589 ? (observed?.hardLimit ?? undefined) !== desired.hardLimitDollars
590 : observed?.hardLimit != null;
591 const needsSet =
592 observed === undefined ||
593 observed.softLimit !== desired.softLimitDollars ||
594 hardChanged;
595
596 if (needsSet) {
597 yield* setLimit({
598 customerId: found.customerId,
599 softLimitDollars: desired.softLimitDollars,
600 ...(desired.hardLimitDollars !== undefined
601 ? { hardLimitDollars: desired.hardLimitDollars }
602 : observed?.hardLimit != null
603 ? { hardLimitDollars: null }
604 : {}),
605 }).pipe(Effect.catchTag("RailwayValidationError", () => Effect.void));
606 found = yield* observe(workspaceId);
607 }
608
609 if (found?.limit === undefined) {
610 return yield* new UsageLimitNotCreated({
611 customerId: found?.customerId ?? output?.customerId ?? "",
612 workspaceId,
613 });
614 }
615
616 return toAttrs(found.limit, {
617 workspaceId,
618 customerId: found.customerId,
619 projectId,
620 });
621 }),
622
623 delete: Effect.fn(function* ({ output }) {
624 const customerId = output.customerId;
625 const workspaceId = output.workspaceId;
626 if (customerId.length === 0) return;
627 yield* usageLimitRemove(customerId);
628 if (workspaceId.length > 0 && output.usageLimitId.length > 0) {
629 yield* waitUntilGone(workspaceId, output.usageLimitId);
630 }
631 }),
632 });