174 lines
7.3 KiB
TypeScript
174 lines
7.3 KiB
TypeScript
/**
|
||
* Allocations — the margin ledger.
|
||
*
|
||
* This is the table PIG exists for. Everything else is scaffolding around it.
|
||
*
|
||
* A `capacity_commitment` is a block of GPU-hours bought from a provider at a
|
||
* known cost. A `demand_deal` is an agreement to sell compute to a customer at
|
||
* a known price. An allocation records that some of a specific block was sold
|
||
* to a specific customer, at a specific price, for a specific window.
|
||
*
|
||
* From that single join, everything the business runs on falls out:
|
||
*
|
||
* margin = Σ(allocated hours × price) − (committed hours × cost)
|
||
* utilisation = Σ(allocated hours) ÷ committed hours
|
||
* idle capacity = committed hours − Σ(allocated hours)
|
||
*
|
||
* No generic CRM can compute these, because none of them has a concept of a
|
||
* cost-bearing commitment sitting behind the pipeline. That is the entire
|
||
* argument for building this rather than configuring HubSpot.
|
||
*
|
||
* Note the deliberate asymmetry in the margin formula: cost is charged against
|
||
* the FULL commitment, not merely the hours that sold. Unsold hours on a
|
||
* commitment are already paid for. Charging only the allocated share would
|
||
* report a healthy margin on a block that is bleeding money, which is precisely
|
||
* the failure this system is meant to make impossible.
|
||
*/
|
||
import {
|
||
index,
|
||
integer,
|
||
numeric,
|
||
pgTable,
|
||
text,
|
||
timestamp,
|
||
uuid,
|
||
} from 'drizzle-orm/pg-core';
|
||
import { ALLOCATION_STATUSES, GUARANTEE_TYPES } from '@pig/core';
|
||
export {
|
||
ALLOCATION_STATUSES,
|
||
CONSUMING_ALLOCATION_STATUSES,
|
||
RESERVING_ALLOCATION_STATUSES,
|
||
} from '@pig/core';
|
||
export type { AllocationStatus } from '@pig/core';
|
||
import { capacityCommitments } from './supply';
|
||
import { demandDeals } from './demand';
|
||
import { users } from './identity';
|
||
|
||
export const allocations = pgTable(
|
||
'allocations',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
|
||
/** The block being drawn from. Restricted: never orphan a cost record. */
|
||
capacityCommitmentId: uuid('capacity_commitment_id')
|
||
.notNull()
|
||
.references(() => capacityCommitments.id, { onDelete: 'restrict' }),
|
||
|
||
/**
|
||
* Who it was sold to. Nullable, because internal research consumption is a
|
||
* real and important allocation with no deal and no revenue behind it.
|
||
* Leaving research burn out of the ledger overstates available capacity and
|
||
* understates true cost — the two mistakes this table prevents.
|
||
*/
|
||
demandDealId: uuid('demand_deal_id').references(() => demandDeals.id, {
|
||
onDelete: 'set null',
|
||
}),
|
||
|
||
/**
|
||
* Set when the consumer is an internal team rather than a customer.
|
||
* Mutually exclusive with `demandDealId` in practice; enforced in the
|
||
* service layer rather than by constraint, because a research allocation
|
||
* occasionally converts into a customer one and the transition should not
|
||
* require deleting the row and losing its history.
|
||
*/
|
||
internalTeam: text('internal_team'),
|
||
|
||
/** Hours drawn from the block. */
|
||
gpuHours: numeric('gpu_hours', { precision: 16, scale: 2 }).notNull(),
|
||
|
||
/**
|
||
* Sell price in cents per GPU-hour. Zero for internal research
|
||
* consumption — which is meaningful, not missing: the hours cost real money
|
||
* and earn none.
|
||
*/
|
||
pricePerGpuHourCents: integer('price_per_gpu_hour_cents').notNull().default(0),
|
||
currency: text('currency').notNull().default('USD'),
|
||
|
||
/**
|
||
* The window this allocation occupies. Must sit inside the commitment's own
|
||
* window; capacity cannot be sold before it exists or after it lapses.
|
||
*/
|
||
startsAt: timestamp('starts_at', { withTimezone: true }).notNull(),
|
||
endsAt: timestamp('ends_at', { withTimezone: true }).notNull(),
|
||
|
||
/**
|
||
* `planned` — pencilled in against a deal that has not closed
|
||
* `committed` — contractually promised to the customer
|
||
* `active` — running now
|
||
* `completed` — finished and billable
|
||
* `released` — given back; the hours return to available inventory
|
||
*
|
||
* Only `committed`, `active` and `completed` consume capacity. `planned`
|
||
* allocations are shown separately so a seller can see what the pipeline
|
||
* would do to utilisation without letting forecasts pollute the actuals.
|
||
*/
|
||
status: text('status', { enum: ALLOCATION_STATUSES }).notNull().default('planned'),
|
||
|
||
/**
|
||
* A capacity HOLD with an expiry.
|
||
*
|
||
* When a seller reserves inventory against a deal that has not closed,
|
||
* that capacity must disappear from everyone else's availability
|
||
* immediately — otherwise two sellers promise the same GPUs and one of
|
||
* them is wrong. Holds expire on a timer so that a stalled deal releases
|
||
* inventory automatically rather than stranding it indefinitely.
|
||
*
|
||
* This single pair of fields is the clearest thing a generic CRM cannot
|
||
* do: it will happily let you create an opportunity for any amount, and
|
||
* nothing anywhere checks whether you can deliver it.
|
||
*/
|
||
holdExpiresAt: timestamp('hold_expires_at', { withTimezone: true }),
|
||
/** What else we turned away to keep this hold, in cents. Makes holds honest. */
|
||
holdOpportunityCostCents: integer('hold_opportunity_cost_cents'),
|
||
|
||
/**
|
||
* Priority ladder, borrowed from how ad servers arbitrate guaranteed
|
||
* against opportunistic demand — the closest published analogue to
|
||
* arbitrating reserved against spot against internal burn.
|
||
*
|
||
* `guaranteed` Contractually reserved; displaces everything below
|
||
* `committed` Reserved share, but not a fixed block
|
||
* `on_demand` Priced opportunistically
|
||
* `preemptible` Spot; yields to anything above it
|
||
* `internal` Research burn; the first thing displaced
|
||
*
|
||
* Lower `priority` integers win. Making this explicit is what lets the
|
||
* system answer "can I actually sell this?" rather than "is something
|
||
* technically free?"
|
||
*/
|
||
guaranteeType: text('guarantee_type', { enum: GUARANTEE_TYPES })
|
||
.notNull()
|
||
.default('committed'),
|
||
priority: integer('priority').notNull().default(100),
|
||
|
||
/**
|
||
* The compliance gate. An allocation is a specific buyer against specific
|
||
* capacity in a specific jurisdiction, which is exactly the granularity at
|
||
* which export control actually applies — see compliance.ts. Null means
|
||
* unevaluated, which the service layer treats as blocking rather than
|
||
* permissive for any cross-border match.
|
||
*/
|
||
complianceDecisionId: uuid('compliance_decision_id'),
|
||
|
||
createdByUserId: uuid('created_by_user_id').references(() => users.id, {
|
||
onDelete: 'set null',
|
||
}),
|
||
notes: text('notes'),
|
||
|
||
releasedAt: timestamp('released_at', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('allocations_commitment_idx').on(t.capacityCommitmentId),
|
||
index('allocations_deal_idx').on(t.demandDealId),
|
||
index('allocations_status_idx').on(t.status),
|
||
index('allocations_window_idx').on(t.startsAt, t.endsAt),
|
||
/** Sweeping expired holds back into available inventory. */
|
||
index('allocations_hold_expiry_idx').on(t.holdExpiresAt),
|
||
],
|
||
);
|
||
|
||
export type Allocation = typeof allocations.$inferSelect;
|
||
export type NewAllocation = typeof allocations.$inferInsert;
|