Skip to content

feat(cloud-railway): implement real GraphQL API for provision/list/de…#440

Open
emil07770 wants to merge 1 commit into
profullstack:masterfrom
emil07770:emil07770-patch-1
Open

feat(cloud-railway): implement real GraphQL API for provision/list/de…#440
emil07770 wants to merge 1 commit into
profullstack:masterfrom
emil07770:emil07770-patch-1

Conversation

@emil07770
Copy link
Copy Markdown
Contributor

Converts the packages/cloud/railway adapter from stubs to real Railway GraphQL v2 API calls.

Changes

  • provision() — calls serviceCreate GraphQL mutation
  • list() — queries project.services.edges via ProjectServices query
  • destroy() — calls serviceDelete mutation
  • status() — queries service by ID with serviceInstances status
  • Added railwayGql<T>() helper for authenticated GraphQL requests
  • Added serviceToInstance() to map Railway status strings to Instance["status"]
  • GPU workloads explicitly rejected with a message directing to cloud-runpod

Test results

✓ packages/cloud/railway/src/index.test.ts (3 tests) 3ms

Typecheck: clean

…stroy/status

Replace stubs with actual Railway GraphQL v2 API calls.
All three smoke tests pass; typecheck clean.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 26, 2026

Greptile Summary

This PR converts the Railway cloud adapter from stub implementations to real Railway GraphQL v2 API calls for provision, list, destroy, and status, adding a shared railwayGql<T>() helper and a serviceToInstance() status mapper.

  • railwayGql: Calls res.json() before checking !res.ok, so non-JSON error responses (rate limits, 5xx HTML pages) throw an opaque SyntaxError rather than a meaningful HTTP error message.
  • Null data paths: list() and status() dereference data.project and data.service without null guards; Railway returns { data: { project: null } } for unknown/inaccessible IDs, causing a TypeError crash.
  • GPU dry-run bypass: The spec.kind === 'gpu' guard appears after the dryRun short-circuit, so a dry-run GPU call silently returns success instead of the intended error.

Confidence Score: 2/5

The adapter will crash at runtime on common Railway API responses — unknown project/service IDs return null data that is immediately dereferenced, and non-JSON HTTP error bodies (rate limits, 5xx) cause opaque parse failures instead of actionable errors.

Three independent crash paths exist on the changed code's hot routes (list, status, railwayGql). All are triggered by realistic inputs — an invalid project ID, a deleted service, or a rate-limit response — making these likely to surface in normal use rather than edge cases only.

packages/cloud/railway/src/index.ts — the null-data dereferences in list() and status(), and the error-handling order in railwayGql, all need fixes before this is production-safe.

Important Files Changed

Filename Overview
packages/cloud/railway/src/index.ts Replaces stub implementations with real Railway GraphQL v2 API calls; introduces several runtime crash paths: non-JSON HTTP error responses, null project/service in GraphQL responses, and incorrect status defaulting for services with no instances.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Adapter as cloud-railway adapter
    participant GQL as railwayGql()
    participant Railway as Railway GraphQL v2

    Caller->>Adapter: provision(ctx, spec, config)
    Adapter->>Adapter: check projectId and GPU kind
    Adapter->>GQL: serviceCreate mutation
    GQL->>Railway: POST /graphql/v2
    Railway-->>GQL: data.serviceCreate
    GQL-->>Adapter: data
    Adapter-->>Caller: Instance

    Caller->>Adapter: list(ctx, config)
    Adapter->>GQL: ProjectServices query
    GQL->>Railway: POST /graphql/v2
    Railway-->>GQL: data.project or null
    Note over GQL,Railway: project null causes TypeError
    GQL-->>Adapter: data
    Adapter-->>Caller: Instance[]

    Caller->>Adapter: status(ctx, id)
    Adapter->>GQL: ServiceStatus query
    GQL->>Railway: POST /graphql/v2
    Railway-->>GQL: data.service or null
    Note over GQL,Railway: service null causes TypeError
    GQL-->>Adapter: data
    Adapter-->>Caller: Instance

    Caller->>Adapter: destroy(ctx, id)
    Adapter->>GQL: ServiceDelete mutation
    GQL->>Railway: POST /graphql/v2
    Railway-->>GQL: serviceDelete result
    GQL-->>Adapter: void
Loading

Reviews (1): Last reviewed commit: "feat(cloud-railway): implement real Grap..." | Re-trigger Greptile

Comment on lines +29 to +32
const json = (await res.json()) as { data?: T; errors?: Array<{ message: string }> };
if (json.errors?.length) throw new Error(`Railway: ${json.errors![0]!.message}`);
if (!res.ok) throw new Error(`Railway HTTP ${res.status}`);
return json.data as T;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 res.json() evaluated before HTTP error check

res.json() is called unconditionally on line 29, before !res.ok is checked. When Railway returns a non-JSON error body — e.g. an HTML 429 rate-limit page or a 503 with a plain-text message — the await res.json() call throws a SyntaxError that propagates to the caller as an unintelligible parse failure instead of a meaningful "Railway HTTP 429". Swap the order: check !res.ok with await res.text() first, then parse JSON only when the response looks valid.

Comment on lines +73 to +75
if (!config.projectId) throw new Error('config.projectId is required for Railway provisioning');
if (spec.kind === 'gpu') throw new Error('GPU workloads not supported on Railway — use cloud-runpod instead');
if (ctx.dryRun) return { id: 'dry-run', kind: spec.kind, status: 'provisioning', createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 GPU rejection happens after dryRun short-circuit

A caller that passes spec.kind === 'gpu' with ctx.dryRun = true will receive a successful Instance response instead of the intended error. The GPU guard should be validated before any branching so that dry-run calls also reject GPU workloads consistently.

Suggested change
if (!config.projectId) throw new Error('config.projectId is required for Railway provisioning');
if (spec.kind === 'gpu') throw new Error('GPU workloads not supported on Railway — use cloud-runpod instead');
if (ctx.dryRun) return { id: 'dry-run', kind: spec.kind, status: 'provisioning', createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' };
if (!config.projectId) throw new Error('config.projectId is required for Railway provisioning');
if (spec.kind === 'gpu') throw new Error('GPU workloads not supported on Railway — use cloud-runpod instead');
if (ctx.dryRun) return { id: 'dry-run', kind: spec.kind, status: 'provisioning' as const, createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' };

}`,
{ id: config.projectId },
);
return data.project.services.edges.map(e => serviceToInstance(e.node));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 data.project can be null, causing a runtime TypeError

Railway's GraphQL API returns { data: { project: null } } (not an errors array) when the project ID is unknown or the token lacks access. Accessing .services.edges on a null project throws a TypeError that surfaces as an unhandled crash rather than a meaningful error. Add a null guard after the API call.

Suggested change
return data.project.services.edges.map(e => serviceToInstance(e.node));
if (!data.project) throw new Error(`Railway: project ${config.projectId} not found or not accessible`);
return data.project.services.edges.map(e => serviceToInstance(e.node));

}`,
{ id },
);
return serviceToInstance(data.service);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 data.service can be null for an unknown or deleted service ID

Same GraphQL null-data pattern as in list(): querying a non-existent or already-deleted service returns { data: { service: null } }. Passing null to serviceToInstance results in an immediate TypeError on s.id.

Suggested change
return serviceToInstance(data.service);
if (!data.service) throw new Error(`Railway: service ${id} not found or not accessible`);
return serviceToInstance(data.service);

createdAt?: string;
serviceInstances?: { edges: Array<{ node: { status?: string } }> };
}, kind: Instance['kind'] = 'cpu-vps'): Instance {
const rawStatus = s.serviceInstances?.edges[0]?.node?.status ?? 'ACTIVE';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty serviceInstances defaults status to 'running'

When serviceInstances.edges is empty (e.g. a freshly created service before its first deployment), the ?? 'ACTIVE' fallback maps to 'running'. A service with no instances is not running — 'provisioning' would be a far more accurate default for new services with no recorded instance state.

Suggested change
const rawStatus = s.serviceInstances?.edges[0]?.node?.status ?? 'ACTIVE';
const rawStatus = s.serviceInstances?.edges[0]?.node?.status ?? 'BUILDING';

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant