Add Prime Intellect client, API, and MCP server
packages/prime — a hand-written typed client, because the first-party SDK is Python only. Deliberately narrow: PIG reads availability and nothing else, and the key it holds should be scoped so it could not provision even if the code tried. Rate limits are undocumented upstream, so it backs off empirically with full jitter and honours Retry-After. Unknown fields survive in `raw` rather than being dropped. apps/api — Hono, with authentication and authorization kept firmly apart. A verified JWT proves someone has an account in the identity project, which may be shared with other applications; it does NOT prove they belong here. Access requires a row in PIG's own users table, and a token without one gets 403 needs_profile rather than entry. The capacity service is the business logic: availability counts sold and held separately, so a live hold removes inventory from everyone else's availability without inflating utilisation. Expired holds are ignored at read time, so the numbers stay right even when the sweeper is behind. Matching treats interconnect as a hard filter and excludes Unknown as well as Ethernet — unverified is not the same as adequate. apps/mcp — nine tools over stdio, so a team member drives PIG from Claude Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the same HTTP API the browser does, with no database credentials, so an agent can never reach further than the person it acts for. Results are formatted as prose rather than raw JSON. Theme preferences live in the database rather than localStorage, so a chosen accent follows someone from laptop to phone. Status colours stay independent of the accent: if "at risk" re-tinted to whatever a user picked, the signal would be gone. Note on the SDK import: its package exports use a `./*` wildcard whose types entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The runtime specifier must keep the .js suffix, so the types are mapped via tsconfig paths rather than by writing an import that would fail at runtime. Verified: all five packages typecheck; the MCP server constructs and registers its tools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@pig/prime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": { "typecheck": "tsc --noEmit" },
|
||||
"dependencies": { "@pig/core": "*" }
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* A typed client for the Prime Intellect compute API.
|
||||
*
|
||||
* Written by hand because no first-party TypeScript SDK exists — the official
|
||||
* SDK and CLI are Python. The surface here is deliberately narrow: PIG reads
|
||||
* GPU availability to populate its inventory and does nothing else. It cannot
|
||||
* provision, terminate, or spend money, and the API key it holds should be
|
||||
* scoped so that it could not even if the code tried.
|
||||
*
|
||||
* Two operational realities shape this file.
|
||||
*
|
||||
* **Rate limits are undocumented.** There is no published quota, so the client
|
||||
* cannot pace itself against a known budget. It instead backs off empirically:
|
||||
* exponential with jitter on 429 and 5xx, honouring `Retry-After` when the
|
||||
* server sends one, and giving up rather than hammering.
|
||||
*
|
||||
* **The upstream shape may drift.** Responses are parsed defensively; unknown
|
||||
* fields are preserved verbatim in `raw` rather than dropped, so a field that
|
||||
* appears upstream tomorrow is already captured today.
|
||||
*/
|
||||
|
||||
export interface PrimeClientOptions {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
/** Total attempts per request, including the first. */
|
||||
maxAttempts?: number;
|
||||
/** Ceiling on backoff between attempts. */
|
||||
maxBackoffMs?: number;
|
||||
/** Per-request timeout. */
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
onRetry?: (info: { attempt: number; delayMs: number; reason: string }) => void;
|
||||
}
|
||||
|
||||
export class PrimeApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PrimeApiError';
|
||||
}
|
||||
|
||||
/** Retrying a 4xx that is not 429 will fail identically every time. */
|
||||
get isRetryable(): boolean {
|
||||
return this.status === 429 || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/** A GPU availability listing, as returned by the availability endpoints. */
|
||||
export interface PrimeGpuListing {
|
||||
cloudId?: string;
|
||||
gpuType?: string;
|
||||
socket?: string;
|
||||
provider?: string;
|
||||
region?: string;
|
||||
dataCenter?: string;
|
||||
country?: string;
|
||||
gpuCount?: number;
|
||||
gpuMemory?: number;
|
||||
vcpu?: { defaultCount?: number } | number;
|
||||
memory?: { defaultCount?: number } | number;
|
||||
disk?: {
|
||||
minCount?: number;
|
||||
defaultCount?: number;
|
||||
maxCount?: number;
|
||||
pricePerUnit?: number;
|
||||
};
|
||||
internetSpeed?: number;
|
||||
interconnect?: number;
|
||||
interconnectType?: string;
|
||||
provisioningTime?: number;
|
||||
stockStatus?: string;
|
||||
security?: string;
|
||||
prices?: {
|
||||
onDemand?: number | null;
|
||||
communityPrice?: number | null;
|
||||
isVariable?: boolean;
|
||||
currency?: string;
|
||||
};
|
||||
images?: string[];
|
||||
isSpot?: boolean;
|
||||
prepaidTime?: number;
|
||||
/** Everything the upstream sent, including fields not modelled above. */
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PrimeAvailabilityQuery {
|
||||
regions?: string[];
|
||||
gpuCount?: number;
|
||||
gpuType?: string;
|
||||
socket?: string;
|
||||
security?: 'secure_cloud' | 'community_cloud';
|
||||
dataCenterId?: string;
|
||||
cloudId?: string;
|
||||
page?: number;
|
||||
/** Upstream caps this at 100. Values above are clamped rather than rejected. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.primeintellect.ai';
|
||||
|
||||
export class PrimeClient {
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly onRetry: PrimeClientOptions['onRetry'];
|
||||
|
||||
constructor(options: PrimeClientOptions) {
|
||||
if (!options.apiKey) throw new Error('PrimeClient requires an apiKey.');
|
||||
this.apiKey = options.apiKey;
|
||||
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.maxAttempts = options.maxAttempts ?? 5;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 30_000;
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.onRetry = options.onRetry;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of GPU availability.
|
||||
*
|
||||
* Returns the listings plus `totalCount` so a caller can page without
|
||||
* guessing. Note that upstream paginates from page 1, not 0.
|
||||
*/
|
||||
async listGpuAvailability(
|
||||
query: PrimeAvailabilityQuery = {},
|
||||
): Promise<{ items: PrimeGpuListing[]; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
for (const region of query.regions ?? []) params.append('regions', region);
|
||||
if (query.gpuCount != null) params.set('gpu_count', String(query.gpuCount));
|
||||
if (query.gpuType) params.set('gpu_type', query.gpuType);
|
||||
if (query.socket) params.set('socket', query.socket);
|
||||
if (query.security) params.set('security', query.security);
|
||||
if (query.dataCenterId) params.set('data_center_id', query.dataCenterId);
|
||||
if (query.cloudId) params.set('cloud_id', query.cloudId);
|
||||
params.set('page', String(query.page ?? 1));
|
||||
// Upstream rejects page_size above 100; clamp rather than surface a 400.
|
||||
params.set('page_size', String(Math.min(query.pageSize ?? 100, 100)));
|
||||
|
||||
const body = await this.request<unknown>(`/api/v1/availability/gpus?${params}`);
|
||||
return normaliseListingPage(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every page of GPU availability, as an async iterable.
|
||||
*
|
||||
* Yields page by page rather than accumulating, so a large inventory does not
|
||||
* have to fit in memory at once and the caller can begin upserting
|
||||
* immediately. Stops when a page comes back empty, which also protects
|
||||
* against a `totalCount` that disagrees with reality.
|
||||
*/
|
||||
async *iterateGpuAvailability(
|
||||
query: Omit<PrimeAvailabilityQuery, 'page'> = {},
|
||||
): AsyncGenerator<PrimeGpuListing[], void, undefined> {
|
||||
let page = 1;
|
||||
let seen = 0;
|
||||
for (;;) {
|
||||
const { items, totalCount } = await this.listGpuAvailability({ ...query, page });
|
||||
if (items.length === 0) return;
|
||||
yield items;
|
||||
seen += items.length;
|
||||
if (totalCount > 0 && seen >= totalCount) return;
|
||||
page += 1;
|
||||
// A defensive ceiling. Without it a misbehaving upstream that always
|
||||
// returns a full page would loop until the process is killed.
|
||||
if (page > 1000) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Multi-node cluster availability — the shape that can actually train. */
|
||||
async listMultiNodeAvailability(
|
||||
query: PrimeAvailabilityQuery = {},
|
||||
): Promise<{ items: PrimeGpuListing[]; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.gpuType) params.set('gpu_type', query.gpuType);
|
||||
if (query.gpuCount != null) params.set('gpu_count', String(query.gpuCount));
|
||||
params.set('page', String(query.page ?? 1));
|
||||
params.set('page_size', String(Math.min(query.pageSize ?? 100, 100)));
|
||||
|
||||
const body = await this.request<unknown>(`/api/v1/availability/multi-node?${params}`);
|
||||
return normaliseListingPage(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap credential check. Used by the settings screen so an operator finds
|
||||
* out their key is wrong at configuration time rather than at 3am when the
|
||||
* sync silently stops.
|
||||
*/
|
||||
async verifyCredentials(): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
await this.listGpuAvailability({ pageSize: 1 });
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error instanceof PrimeApiError) {
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
error.status === 401 || error.status === 403
|
||||
? 'Key rejected. Check it has the Availability → Read scope and has not expired.'
|
||||
: `Upstream returned ${error.status}.`,
|
||||
};
|
||||
}
|
||||
return { ok: false, detail: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
accept: 'application/json',
|
||||
'user-agent': 'pig-crm/0.1 (+https://primeintellectgrowth.com)',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) return (await response.json()) as T;
|
||||
|
||||
const text = await response.text().catch(() => '');
|
||||
const error = new PrimeApiError(
|
||||
`Prime Intellect API returned ${response.status}`,
|
||||
response.status,
|
||||
text.slice(0, 500),
|
||||
);
|
||||
if (!error.isRetryable || attempt === this.maxAttempts) throw error;
|
||||
|
||||
// Honour Retry-After when offered; it is better information than any
|
||||
// backoff curve we could invent.
|
||||
const retryAfter = parseRetryAfter(response.headers.get('retry-after'));
|
||||
const delay = retryAfter ?? this.backoffMs(attempt);
|
||||
this.onRetry?.({ attempt, delayMs: delay, reason: `HTTP ${response.status}` });
|
||||
await sleep(delay);
|
||||
lastError = error;
|
||||
} catch (error) {
|
||||
if (error instanceof PrimeApiError) {
|
||||
if (!error.isRetryable || attempt === this.maxAttempts) throw error;
|
||||
lastError = error;
|
||||
} else {
|
||||
// Network failure or timeout. Both are worth retrying.
|
||||
if (attempt === this.maxAttempts) throw error;
|
||||
const delay = this.backoffMs(attempt);
|
||||
this.onRetry?.({
|
||||
attempt,
|
||||
delayMs: delay,
|
||||
reason: error instanceof Error ? error.message : 'network error',
|
||||
});
|
||||
await sleep(delay);
|
||||
lastError = error;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Prime Intellect request failed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff with full jitter.
|
||||
*
|
||||
* Jitter matters more than the curve: without it, several workers that hit a
|
||||
* limit together retry together, and the thundering herd reproduces the
|
||||
* problem that caused the limit.
|
||||
*/
|
||||
private backoffMs(attempt: number): number {
|
||||
const ceiling = Math.min(this.maxBackoffMs, 1000 * 2 ** (attempt - 1));
|
||||
return Math.round(Math.random() * ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfter(header: string | null): number | null {
|
||||
if (!header) return null;
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
||||
const date = Date.parse(header);
|
||||
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a page of listings.
|
||||
*
|
||||
* The upstream has returned both `{items, totalCount}` and a bare array across
|
||||
* its endpoint generations, so both are accepted rather than assuming one and
|
||||
* breaking on the other.
|
||||
*/
|
||||
function normaliseListingPage(body: unknown): {
|
||||
items: PrimeGpuListing[];
|
||||
totalCount: number;
|
||||
} {
|
||||
const rawItems: unknown[] = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { items?: unknown[] })?.items)
|
||||
? ((body as { items: unknown[] }).items ?? [])
|
||||
: [];
|
||||
|
||||
const totalCount =
|
||||
typeof (body as { totalCount?: number })?.totalCount === 'number'
|
||||
? (body as { totalCount: number }).totalCount
|
||||
: rawItems.length;
|
||||
|
||||
return { items: rawItems.map(toListing), totalCount };
|
||||
}
|
||||
|
||||
function toListing(raw: unknown): PrimeGpuListing {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
cloudId: str(r.cloudId),
|
||||
gpuType: str(r.gpuType),
|
||||
socket: str(r.socket),
|
||||
provider: str(r.provider),
|
||||
region: str(r.region),
|
||||
dataCenter: str(r.dataCenter),
|
||||
country: str(r.country),
|
||||
gpuCount: num(r.gpuCount),
|
||||
gpuMemory: num(r.gpuMemory),
|
||||
vcpu: r.vcpu as PrimeGpuListing['vcpu'],
|
||||
memory: r.memory as PrimeGpuListing['memory'],
|
||||
disk: r.disk as PrimeGpuListing['disk'],
|
||||
internetSpeed: num(r.internetSpeed),
|
||||
interconnect: num(r.interconnect),
|
||||
interconnectType: str(r.interconnectType),
|
||||
provisioningTime: num(r.provisioningTime),
|
||||
stockStatus: str(r.stockStatus),
|
||||
security: str(r.security),
|
||||
prices: r.prices as PrimeGpuListing['prices'],
|
||||
images: Array.isArray(r.images) ? (r.images as string[]) : undefined,
|
||||
isSpot: typeof r.isSpot === 'boolean' ? r.isSpot : undefined,
|
||||
prepaidTime: num(r.prepaidTime),
|
||||
raw: r,
|
||||
};
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './client';
|
||||
export * from './map';
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Mapping upstream availability listings into PIG's inventory rows.
|
||||
*
|
||||
* The schema was written to mirror the upstream field names, so this is mostly
|
||||
* a rename rather than a transformation. The interesting parts are the two
|
||||
* places where a judgement is required.
|
||||
*
|
||||
* **Money.** Upstream prices are floating-point dollars per hour. PIG stores
|
||||
* integer cents, because these values feed margin reporting and floating-point
|
||||
* currency in a system that reports margin is a defect waiting to be found by
|
||||
* an accountant. Rounding happens exactly once, here, at the boundary.
|
||||
*
|
||||
* **Interconnect.** The single most commercially loaded field, since it decides
|
||||
* whether capacity can train or only serve. Upstream sends free text with
|
||||
* inconsistent casing, and an unrecognised value maps to `Unknown` rather than
|
||||
* being optimistically read as Ethernet — claiming a cluster has no fast fabric
|
||||
* when it does loses a deal, but the reverse sells a customer something that
|
||||
* will not work.
|
||||
*/
|
||||
import type { InterconnectType, SecurityTier, StockStatus } from '@pig/core';
|
||||
import type { PrimeGpuListing } from './client';
|
||||
|
||||
export interface MappedListing {
|
||||
externalCloudId: string | null;
|
||||
providerSlug: string | null;
|
||||
gpuType: string;
|
||||
socket: string | null;
|
||||
gpuCount: number;
|
||||
gpuMemoryGb: number | null;
|
||||
vcpu: number | null;
|
||||
memoryGb: number | null;
|
||||
diskGb: number | null;
|
||||
internetMbps: number | null;
|
||||
interconnectGbps: number | null;
|
||||
interconnectType: InterconnectType;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
securityTier: SecurityTier;
|
||||
stockStatus: StockStatus;
|
||||
isSpot: boolean;
|
||||
provisioningMinutes: number | null;
|
||||
prepaidHours: string | null;
|
||||
onDemandPriceCents: number | null;
|
||||
communityPriceCents: number | null;
|
||||
priceIsVariable: boolean;
|
||||
currency: string;
|
||||
images: string[];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function mapListing(listing: PrimeGpuListing): MappedListing | null {
|
||||
// Without a GPU type and a count there is nothing sellable to record.
|
||||
if (!listing.gpuType || !listing.gpuCount) return null;
|
||||
|
||||
return {
|
||||
externalCloudId: listing.cloudId ?? null,
|
||||
providerSlug: listing.provider ?? null,
|
||||
gpuType: listing.gpuType,
|
||||
socket: normaliseSocket(listing.socket),
|
||||
gpuCount: listing.gpuCount,
|
||||
gpuMemoryGb: listing.gpuMemory ?? null,
|
||||
vcpu: unwrapCount(listing.vcpu),
|
||||
memoryGb: unwrapCount(listing.memory),
|
||||
diskGb: listing.disk?.defaultCount ?? null,
|
||||
internetMbps: listing.internetSpeed ?? null,
|
||||
interconnectGbps: listing.interconnect ?? null,
|
||||
interconnectType: normaliseInterconnect(listing.interconnectType),
|
||||
region: listing.region ?? null,
|
||||
country: listing.country ?? null,
|
||||
securityTier: listing.security === 'community_cloud' ? 'community_cloud' : 'secure_cloud',
|
||||
stockStatus: normaliseStock(listing.stockStatus),
|
||||
isSpot: listing.isSpot ?? false,
|
||||
provisioningMinutes: listing.provisioningTime ?? null,
|
||||
prepaidHours: listing.prepaidTime != null ? String(listing.prepaidTime) : null,
|
||||
onDemandPriceCents: toCents(listing.prices?.onDemand),
|
||||
communityPriceCents: toCents(listing.prices?.communityPrice),
|
||||
priceIsVariable: listing.prices?.isVariable ?? false,
|
||||
currency: listing.prices?.currency ?? 'USD',
|
||||
images: listing.images ?? [],
|
||||
raw: listing.raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dollars to integer cents.
|
||||
*
|
||||
* Multiplying by 100 in floating point then truncating loses a cent on values
|
||||
* like 2.43 (which is 2.4299999... in binary), so this rounds rather than
|
||||
* truncates. At GPU-hour scale one cent compounds into real money across
|
||||
* millions of hours.
|
||||
*/
|
||||
export function toCents(dollars: number | null | undefined): number | null {
|
||||
if (dollars == null || !Number.isFinite(dollars)) return null;
|
||||
return Math.round(dollars * 100);
|
||||
}
|
||||
|
||||
function unwrapCount(value: unknown): number | null {
|
||||
if (typeof value === 'number') return value;
|
||||
if (value && typeof value === 'object') {
|
||||
const c = (value as { defaultCount?: unknown }).defaultCount;
|
||||
if (typeof c === 'number') return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normaliseSocket(socket: string | undefined): string | null {
|
||||
if (!socket) return null;
|
||||
const upper = socket.toUpperCase().replace(/[\s_-]/g, '');
|
||||
if (upper === 'PCIE') return 'PCIe';
|
||||
const sxm = /^SXM([2-6])$/.exec(upper);
|
||||
if (sxm) return `SXM${sxm[1]}`;
|
||||
// Unknown sockets are dropped rather than stored, since the column is an
|
||||
// enum. The verbatim value survives in `raw`.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interconnect, conservatively.
|
||||
*
|
||||
* `Unknown` is the safe default. Guessing Ethernet would understate real
|
||||
* capacity; guessing InfiniBand would sell a training customer a cluster that
|
||||
* cannot train. Neither error is acceptable, so an unrecognised value stays
|
||||
* explicitly unknown and a human resolves it.
|
||||
*/
|
||||
function normaliseInterconnect(value: string | undefined): InterconnectType {
|
||||
if (!value) return 'Unknown';
|
||||
const v = value.toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (v.includes('infiniband') || v === 'ib') return 'Infiniband';
|
||||
if (v.includes('roce')) return 'RoCE';
|
||||
if (v.includes('nvlink') || v.includes('nvl')) return 'NVLink';
|
||||
if (v.includes('ethernet') || v.includes('eth')) return 'Ethernet';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function normaliseStock(value: string | undefined): StockStatus {
|
||||
switch ((value ?? '').toLowerCase()) {
|
||||
case 'available':
|
||||
return 'Available';
|
||||
case 'low':
|
||||
return 'Low';
|
||||
case 'medium':
|
||||
return 'Medium';
|
||||
case 'high':
|
||||
return 'High';
|
||||
case 'unavailable':
|
||||
return 'Unavailable';
|
||||
default:
|
||||
// An unknown stock signal is treated as unavailable rather than
|
||||
// available: it is better to under-promise inventory than to have a
|
||||
// seller offer capacity that turns out not to exist.
|
||||
return 'Unavailable';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user