This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user