Files
pig/apps/api/src/routes/capacity-writes.ts
T
2026-08-13 01:39:01 -07:00

270 lines
9.6 KiB
TypeScript

import {
CONSUMING_ALLOCATION_STATUSES,
GPU_SOCKETS,
GUARANTEE_TYPES,
INTERCONNECT_TYPES,
SECURITY_TIERS,
} from '@pig/core';
import type { Allocation, CapacityCommitment, Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
import type { ApiEnv } from '../lib/mutation';
import type { MutationDefinition } from '../lib/mutation';
import { MutationError, mutation } from '../lib/mutation';
import {
CapacityWriteService,
type CapacityWriteTransaction,
} from '../services/capacity-writes';
const uuid = z.string().uuid();
const cents = z.number().int().nonnegative();
const percentage = z.number().min(0).max(100);
const gpuHours = z.number().positive().refine(
(value) => Math.abs(value * 100 - Math.round(value * 100)) < 1e-7,
'GPU-hours may have at most two decimal places.',
);
const currency = z.string().regex(/^[A-Z]{3}$/);
const shape = z
.object({
intervals: z.array(z.string().datetime()).min(2),
quantities: z.array(z.number().int().nonnegative()).min(1),
})
.strict();
const commitmentFields = {
accountId: uuid,
siteId: uuid.nullable().optional(),
supplyDealId: uuid.nullable().optional(),
name: z.string().trim().min(1).max(200),
gpuType: z.string().trim().min(1).max(100),
socket: z.enum(GPU_SOCKETS).nullable().optional(),
gpuCount: z.number().int().positive(),
interconnectType: z.enum(INTERCONNECT_TYPES).optional(),
securityTier: z.enum(SECURITY_TIERS).optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
totalGpuHours: gpuHours,
costPerGpuHourCents: cents,
currency: currency.optional(),
shape: shape.nullable().optional(),
colocateWith: z.array(uuid).max(100).optional(),
isContiguous: z.boolean().optional(),
minimumSpendCents: cents.nullable().optional(),
isAutoRenew: z.boolean().optional(),
noticeDays: z.number().int().nonnegative().nullable().optional(),
takeOrPayFloorPct: percentage.nullable().optional(),
prepaidPct: percentage.nullable().optional(),
prepaidAmountCents: cents.nullable().optional(),
usefulLifeYears: z.number().positive().max(100).nullable().optional(),
salvageValuePct: percentage.nullable().optional(),
depreciationStartAt: z.string().datetime().nullable().optional(),
costOfCapitalBps: z.number().int().nonnegative().nullable().optional(),
financingInstrument: z.string().trim().min(1).max(200).nullable().optional(),
oversubscriptionPct: z.number().min(0).max(1000).optional(),
notes: z.string().max(10_000).nullable().optional(),
};
const createCommitmentSchema = z.object(commitmentFields).strict();
const updateCommitmentSchema = z
.object({
...commitmentFields,
terminatedAt: z.string().datetime().nullable().optional(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const allocationFields = {
capacityCommitmentId: uuid,
demandDealId: uuid,
gpuHours,
pricePerGpuHourCents: cents,
currency: currency.optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
guaranteeType: z.enum(GUARANTEE_TYPES).optional(),
priority: z.number().int().nonnegative().optional(),
complianceDecisionId: uuid.nullable().optional(),
notes: z.string().max(10_000).nullable().optional(),
};
const createAllocationSchema = z
.object({
...allocationFields,
status: z.enum(CONSUMING_ALLOCATION_STATUSES),
})
.strict();
const createHoldSchema = z
.object({
...allocationFields,
pricePerGpuHourCents: cents.optional(),
holdExpiresAt: z.string().datetime(),
holdOpportunityCostCents: cents.nullable().optional(),
})
.strict();
const releaseSchema = z
.object({ reason: z.string().trim().min(1).max(1_000).optional() })
.strict();
type CapacityWriteOperations = Pick<
CapacityWriteService,
| 'createCommitment'
| 'updateCommitment'
| 'createAllocation'
| 'createHold'
| 'releaseAllocation'
>;
type ServiceFactory = (tx: CapacityWriteTransaction) => CapacityWriteOperations;
const service: ServiceFactory = (tx) => new CapacityWriteService(tx);
export function createCommitmentMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof createCommitmentSchema, CapacityCommitment> {
return {
schema: createCommitmentSchema,
permission: { capability: 'commitment:write' as const, team: 'supply' as const },
invalidMessage: 'Invalid capacity commitment.',
async mutate({ input, tx, now }) {
const result = await makeService(tx).createCommitment(input, now);
return {
data: result.commitment,
activity: {
type: 'note' as const,
subject: `Created capacity commitment: ${result.commitment.name}`,
accountId: result.commitment.accountId,
supplyDealId: result.commitment.supplyDealId ?? undefined,
meta: { capacityCommitmentId: result.commitment.id },
},
};
},
};
}
export function updateCommitmentMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof updateCommitmentSchema, CapacityCommitment> {
return {
schema: updateCommitmentSchema,
permission: { capability: 'commitment:write' as const, team: 'supply' as const },
invalidMessage: 'Invalid capacity commitment update.',
async mutate({ input, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Capacity commitment');
const result = await makeService(tx).updateCommitment(params.id, input, now);
return {
data: result.commitment,
activity: {
type: 'note' as const,
subject: `Updated capacity commitment: ${result.commitment.name}`,
accountId: result.commitment.accountId,
supplyDealId: result.commitment.supplyDealId ?? undefined,
meta: {
capacityCommitmentId: result.commitment.id,
changedFields: Object.keys(input),
...(input.terminatedAt !== undefined
? { terminatedAt: result.commitment.terminatedAt?.toISOString() ?? null }
: {}),
},
},
};
},
};
}
export function createAllocationMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof createAllocationSchema, Allocation> {
return {
schema: createAllocationSchema,
permission: { capability: 'deal:write' as const, team: 'demand' as const },
invalidMessage: 'Invalid allocation.',
async mutate({ input, principal, tx, now }) {
const result = await makeService(tx).createAllocation(input, principal, now);
return {
data: result.allocation,
activity: {
type: 'note' as const,
subject: `Allocated ${input.gpuHours} GPU-hours from ${result.commitment.name}`,
accountId: result.deal.accountId,
demandDealId: result.deal.id,
meta: {
allocationId: result.allocation.id,
capacityCommitmentId: result.commitment.id,
status: result.allocation.status,
pricePerGpuHourCents: result.allocation.pricePerGpuHourCents,
},
},
};
},
};
}
export function createHoldMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof createHoldSchema, Allocation> {
return {
schema: createHoldSchema,
permission: { capability: 'deal:write' as const, team: 'demand' as const },
invalidMessage: 'Invalid capacity hold.',
async mutate({ input, principal, tx, now }) {
const result = await makeService(tx).createHold(input, principal, now);
return {
data: result.allocation,
activity: {
type: 'note' as const,
subject: `Held ${input.gpuHours} GPU-hours from ${result.commitment.name}`,
accountId: result.deal.accountId,
demandDealId: result.deal.id,
meta: {
allocationId: result.allocation.id,
capacityCommitmentId: result.commitment.id,
holdExpiresAt: input.holdExpiresAt,
},
},
};
},
};
}
export function releaseAllocationMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof releaseSchema, Allocation> {
return {
schema: releaseSchema,
permission: { capability: 'deal:write' as const, team: 'demand' as const },
invalidMessage: 'Invalid allocation release.',
async mutate({ input, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Allocation');
const result = await makeService(tx).releaseAllocation(params.id, input, now);
return {
data: result.allocation,
activity: {
type: 'note' as const,
subject: `Released allocation ${result.allocation.id}`,
accountId: result.accountId,
demandDealId: result.allocation.demandDealId ?? undefined,
meta: {
allocationId: result.allocation.id,
capacityCommitmentId: result.allocation.capacityCommitmentId,
from: result.previousStatus,
to: 'released',
reason: input.reason,
},
},
};
},
};
}
export function createCapacityWriteRoutes(db: Database): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
routes.post('/api/commitments', mutation(db, createCommitmentMutationDefinition()));
routes.patch('/api/commitments/:id', mutation(db, updateCommitmentMutationDefinition()));
routes.post('/api/allocations', mutation(db, createAllocationMutationDefinition()));
routes.post('/api/allocations/holds', mutation(db, createHoldMutationDefinition()));
routes.post(
'/api/allocations/:id/release',
mutation(db, releaseAllocationMutationDefinition()),
);
return routes;
}