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,418 @@
|
||||
/**
|
||||
* Capacity: availability, matching and margin.
|
||||
*
|
||||
* This is PIG's business logic. Everything here answers one of three questions
|
||||
* a compute GTM team asks constantly and cannot ask a generic CRM at all:
|
||||
*
|
||||
* "What can I actually sell?" → availability
|
||||
* "What fits this customer?" → matching
|
||||
* "What is it worth?" → margin
|
||||
*/
|
||||
import { and, eq, gte, inArray, isNull, lte, or, sql } from 'drizzle-orm';
|
||||
import type { Database } from '@pig/db';
|
||||
import {
|
||||
allocations,
|
||||
capacityCommitments,
|
||||
CONSUMING_ALLOCATION_STATUSES,
|
||||
RESERVING_ALLOCATION_STATUSES,
|
||||
inventoryListings,
|
||||
} from '@pig/db';
|
||||
import { computeMargin, breakEvenPricePerGpuHourCents } from '@pig/core';
|
||||
import type { InterconnectType, SecurityTier } from '@pig/core';
|
||||
|
||||
export interface CommitmentShape {
|
||||
intervals: string[];
|
||||
quantities: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GPUs held at an instant, according to a commitment's shape.
|
||||
*
|
||||
* A commitment is not a rectangle: it ramps across tranches and steps down at
|
||||
* checkpoints. Where no shape is recorded the flat `gpuCount` applies for the
|
||||
* whole window, which is the common simple case.
|
||||
*/
|
||||
export function quantityAt(
|
||||
shape: CommitmentShape | null | undefined,
|
||||
flatCount: number,
|
||||
at: Date,
|
||||
): number {
|
||||
if (!shape || shape.intervals.length < 2) return flatCount;
|
||||
const t = at.getTime();
|
||||
for (let i = 0; i < shape.quantities.length; i++) {
|
||||
const start = Date.parse(shape.intervals[i]!);
|
||||
const end = Date.parse(shape.intervals[i + 1]!);
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && t >= start && t < end) {
|
||||
return shape.quantities[i] ?? 0;
|
||||
}
|
||||
}
|
||||
// Outside every declared interval the commitment holds nothing. Falling back
|
||||
// to the flat count here would invent capacity beyond the contract.
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total GPU-hours a commitment provides, integrating over its shape.
|
||||
*
|
||||
* Used to reconcile a recorded `totalGpuHours` against the shape actually
|
||||
* entered — a mismatch usually means someone typed a headline number from a
|
||||
* term sheet and then entered a ramp that does not add up to it.
|
||||
*/
|
||||
export function gpuHoursFromShape(shape: CommitmentShape): number {
|
||||
let hours = 0;
|
||||
for (let i = 0; i < shape.quantities.length; i++) {
|
||||
const start = Date.parse(shape.intervals[i]!);
|
||||
const end = Date.parse(shape.intervals[i + 1]!);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
|
||||
hours += ((end - start) / 3_600_000) * (shape.quantities[i] ?? 0);
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
export interface AvailabilityRow {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
interconnectType: InterconnectType;
|
||||
securityTier: SecurityTier;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
totalGpuHours: number;
|
||||
/** Hours consumed by allocations that count as sold. */
|
||||
soldGpuHours: number;
|
||||
/** Hours held by unexpired holds. Reserved, but not yet sold. */
|
||||
heldGpuHours: number;
|
||||
/** Hours neither sold nor held. What a seller may actually offer. */
|
||||
availableGpuHours: number;
|
||||
costPerGpuHourCents: number;
|
||||
utilisation: number;
|
||||
/** Price at which the remaining hours break even on this block. */
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
export class CapacityService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
/**
|
||||
* What is genuinely sellable, per commitment.
|
||||
*
|
||||
* The key subtlety is that sold and held are counted separately. A live hold
|
||||
* must remove capacity from availability — otherwise two sellers promise the
|
||||
* same GPUs — but it is not revenue and must not inflate utilisation. A
|
||||
* pipeline of optimistic holds should never be able to make the book look
|
||||
* full.
|
||||
*
|
||||
* Expired holds are ignored here rather than requiring a sweep to have run,
|
||||
* so availability is correct even if the cleanup job is behind.
|
||||
*/
|
||||
async availability(options: { at?: Date; gpuType?: string } = {}): Promise<AvailabilityRow[]> {
|
||||
const now = options.at ?? new Date();
|
||||
|
||||
const commitments = await this.db
|
||||
.select()
|
||||
.from(capacityCommitments)
|
||||
.where(
|
||||
and(
|
||||
isNull(capacityCommitments.terminatedAt),
|
||||
gte(capacityCommitments.endsAt, now),
|
||||
options.gpuType ? eq(capacityCommitments.gpuType, options.gpuType) : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (commitments.length === 0) return [];
|
||||
|
||||
const ids = commitments.map((c) => c.id);
|
||||
const allocRows = await this.db
|
||||
.select()
|
||||
.from(allocations)
|
||||
.where(
|
||||
and(
|
||||
inArray(allocations.capacityCommitmentId, ids),
|
||||
inArray(allocations.status, [...RESERVING_ALLOCATION_STATUSES]),
|
||||
),
|
||||
);
|
||||
|
||||
return commitments.map((commitment) => {
|
||||
const mine = allocRows.filter((a) => a.capacityCommitmentId === commitment.id);
|
||||
|
||||
let soldGpuHours = 0;
|
||||
let heldGpuHours = 0;
|
||||
for (const a of mine) {
|
||||
const hours = Number(a.gpuHours);
|
||||
if ((CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status)) {
|
||||
soldGpuHours += hours;
|
||||
} else if (!a.holdExpiresAt || a.holdExpiresAt > now) {
|
||||
// A lapsed hold reserves nothing, whether or not it has been swept.
|
||||
heldGpuHours += hours;
|
||||
}
|
||||
}
|
||||
|
||||
const totalGpuHours = Number(commitment.totalGpuHours);
|
||||
const margin = computeMargin(
|
||||
{ gpuHours: totalGpuHours, costPerGpuHourCents: commitment.costPerGpuHourCents },
|
||||
mine
|
||||
.filter((a) => (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status))
|
||||
.map((a) => ({
|
||||
gpuHours: Number(a.gpuHours),
|
||||
pricePerGpuHourCents: a.pricePerGpuHourCents,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
commitmentId: commitment.id,
|
||||
name: commitment.name,
|
||||
gpuType: commitment.gpuType,
|
||||
gpuCount: commitment.gpuCount,
|
||||
interconnectType: commitment.interconnectType as InterconnectType,
|
||||
securityTier: commitment.securityTier as SecurityTier,
|
||||
startsAt: commitment.startsAt,
|
||||
endsAt: commitment.endsAt,
|
||||
totalGpuHours,
|
||||
soldGpuHours,
|
||||
heldGpuHours,
|
||||
availableGpuHours: Math.max(0, totalGpuHours - soldGpuHours - heldGpuHours),
|
||||
costPerGpuHourCents: commitment.costPerGpuHourCents,
|
||||
utilisation: margin.utilisation,
|
||||
breakEvenPriceCents: breakEvenPricePerGpuHourCents(
|
||||
{ gpuHours: totalGpuHours, costPerGpuHourCents: commitment.costPerGpuHourCents },
|
||||
mine
|
||||
.filter((a) =>
|
||||
(CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status),
|
||||
)
|
||||
.map((a) => ({
|
||||
gpuHours: Number(a.gpuHours),
|
||||
pricePerGpuHourCents: a.pricePerGpuHourCents,
|
||||
})),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a customer requirement against capacity we hold.
|
||||
*
|
||||
* Interconnect is a hard filter rather than a preference. Selling
|
||||
* Ethernet-only capacity to a distributed-training customer produces a
|
||||
* cluster that cannot do the job, which is worse than losing the deal — so a
|
||||
* requirement for high-speed fabric excludes `Ethernet` and `Unknown` alike.
|
||||
* `Unknown` is excluded deliberately: unverified is not the same as adequate.
|
||||
*/
|
||||
async match(requirement: {
|
||||
gpuType?: string;
|
||||
gpuTypeAlternatives?: string[];
|
||||
gpuCount: number;
|
||||
totalGpuHours?: number;
|
||||
requiresHighSpeedInterconnect?: boolean;
|
||||
minSecurityTier?: SecurityTier;
|
||||
startsAt?: Date;
|
||||
endsAt?: Date;
|
||||
maxPricePerGpuHourCents?: number;
|
||||
}): Promise<
|
||||
(AvailabilityRow & {
|
||||
/** 0–1. Higher is a better fit. */
|
||||
score: number;
|
||||
/** Why this matched, in plain words, for the UI and for agents. */
|
||||
rationale: string[];
|
||||
})[]
|
||||
> {
|
||||
const acceptableTypes = [
|
||||
...(requirement.gpuType ? [requirement.gpuType] : []),
|
||||
...(requirement.gpuTypeAlternatives ?? []),
|
||||
];
|
||||
|
||||
const rows = await this.availability({ at: requirement.startsAt ?? new Date() });
|
||||
|
||||
const matches = rows
|
||||
.filter((row) => {
|
||||
if (acceptableTypes.length > 0 && !acceptableTypes.includes(row.gpuType)) return false;
|
||||
if (row.gpuCount < requirement.gpuCount) return false;
|
||||
|
||||
if (requirement.requiresHighSpeedInterconnect) {
|
||||
const fast: InterconnectType[] = ['Infiniband', 'RoCE', 'NVLink'];
|
||||
if (!fast.includes(row.interconnectType)) return false;
|
||||
}
|
||||
if (requirement.minSecurityTier === 'secure_cloud' && row.securityTier !== 'secure_cloud') {
|
||||
return false;
|
||||
}
|
||||
if (requirement.startsAt && row.startsAt > requirement.startsAt) return false;
|
||||
if (requirement.endsAt && row.endsAt < requirement.endsAt) return false;
|
||||
if (requirement.totalGpuHours && row.availableGpuHours < requirement.totalGpuHours) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((row) => {
|
||||
const rationale: string[] = [];
|
||||
let score = 0.5;
|
||||
|
||||
// Prefer filling blocks that are sitting idle: the marginal hour on an
|
||||
// under-utilised commitment is already paid for, so selling it is worth
|
||||
// more than selling one from a block that is nearly full.
|
||||
const idleBonus = 1 - row.utilisation;
|
||||
score += idleBonus * 0.3;
|
||||
if (row.utilisation < 0.5) {
|
||||
rationale.push(
|
||||
`Block is ${Math.round(row.utilisation * 100)}% utilised — selling here reduces idle spend.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (requirement.gpuType && row.gpuType === requirement.gpuType) {
|
||||
score += 0.1;
|
||||
rationale.push(`Exact GPU match (${row.gpuType}).`);
|
||||
} else if (acceptableTypes.includes(row.gpuType)) {
|
||||
rationale.push(`Acceptable alternative (${row.gpuType}).`);
|
||||
}
|
||||
|
||||
if (requirement.requiresHighSpeedInterconnect) {
|
||||
rationale.push(`${row.interconnectType} fabric meets the training requirement.`);
|
||||
}
|
||||
|
||||
// Margin headroom: can this be sold above break-even, within the
|
||||
// customer's ceiling?
|
||||
if (requirement.maxPricePerGpuHourCents && row.breakEvenPriceCents != null) {
|
||||
if (row.breakEvenPriceCents <= requirement.maxPricePerGpuHourCents) {
|
||||
score += 0.1;
|
||||
rationale.push(
|
||||
`Break-even is below the customer's ceiling — there is margin available.`,
|
||||
);
|
||||
} else {
|
||||
// Kept in the results but scored down, because a seller may still
|
||||
// want to know the option exists and price it as a loss leader.
|
||||
score -= 0.3;
|
||||
rationale.push(
|
||||
`⚠ Break-even exceeds the customer's stated ceiling. This would sell at a loss.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...row, score: Math.max(0, Math.min(1, score)), rationale };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks with meaningful unsold capacity — the alert that pays for PIG.
|
||||
*
|
||||
* Committed hours are already paid for, so idle capacity is money leaving the
|
||||
* business every hour it stays unsold. Filtered to blocks that are live or
|
||||
* starting soon, since idle capacity in eighteen months is a forecasting
|
||||
* matter rather than an alarm.
|
||||
*/
|
||||
async idleCapacity(options: { thresholdPct?: number; withinDays?: number } = {}) {
|
||||
const threshold = options.thresholdPct ?? 0.25;
|
||||
const horizon = new Date(Date.now() + (options.withinDays ?? 30) * 86_400_000);
|
||||
|
||||
const rows = await this.availability();
|
||||
return rows
|
||||
.filter((row) => row.startsAt <= horizon && 1 - row.utilisation >= threshold)
|
||||
.map((row) => {
|
||||
const idleHours = row.totalGpuHours - row.soldGpuHours;
|
||||
return {
|
||||
...row,
|
||||
idleGpuHours: idleHours,
|
||||
// The number that makes the case: what the unsold hours cost us.
|
||||
idleCostCents: Math.round(idleHours * row.costPerGpuHourCents),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.idleCostCents - a.idleCostCents);
|
||||
}
|
||||
|
||||
/** Book-level margin across every live commitment. */
|
||||
async marginReport(): Promise<{
|
||||
totals: ReturnType<typeof computeMargin>;
|
||||
blocks: AvailabilityRow[];
|
||||
}> {
|
||||
const blocks = await this.availability();
|
||||
const allocRows = blocks.length
|
||||
? await this.db
|
||||
.select()
|
||||
.from(allocations)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
allocations.capacityCommitmentId,
|
||||
blocks.map((b) => b.commitmentId),
|
||||
),
|
||||
inArray(allocations.status, [...CONSUMING_ALLOCATION_STATUSES]),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
const books = blocks.map((block) => ({
|
||||
commitment: {
|
||||
gpuHours: block.totalGpuHours,
|
||||
costPerGpuHourCents: block.costPerGpuHourCents,
|
||||
},
|
||||
allocations: allocRows
|
||||
.filter((a) => a.capacityCommitmentId === block.commitmentId)
|
||||
.map((a) => ({
|
||||
gpuHours: Number(a.gpuHours),
|
||||
pricePerGpuHourCents: a.pricePerGpuHourCents,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Aggregate by summing cents, never by averaging per-block percentages —
|
||||
// an average of ratios weights a tiny block equally with a huge one.
|
||||
const { aggregateMargin } = await import('@pig/core');
|
||||
return { totals: aggregateMargin(books), blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Release holds whose timer has lapsed.
|
||||
*
|
||||
* Availability already ignores expired holds, so this is bookkeeping rather
|
||||
* than correctness — it keeps the stored state honest and makes the release
|
||||
* visible in the UI.
|
||||
*/
|
||||
async sweepExpiredHolds(now = new Date()): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(allocations)
|
||||
.set({ status: 'released', releasedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(allocations.status, 'planned'),
|
||||
sql`${allocations.holdExpiresAt} IS NOT NULL`,
|
||||
lte(allocations.holdExpiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning({ id: allocations.id });
|
||||
return result.length;
|
||||
}
|
||||
|
||||
/** Live inventory from providers, for capacity we do not yet hold. */
|
||||
async searchInventory(query: {
|
||||
gpuType?: string;
|
||||
minGpuCount?: number;
|
||||
maxPriceCents?: number;
|
||||
requiresHighSpeedInterconnect?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const fast: InterconnectType[] = ['Infiniband', 'RoCE', 'NVLink'];
|
||||
return this.db
|
||||
.select()
|
||||
.from(inventoryListings)
|
||||
.where(
|
||||
and(
|
||||
query.gpuType ? eq(inventoryListings.gpuType, query.gpuType) : undefined,
|
||||
query.minGpuCount ? gte(inventoryListings.gpuCount, query.minGpuCount) : undefined,
|
||||
query.maxPriceCents
|
||||
? lte(inventoryListings.onDemandPriceCents, query.maxPriceCents)
|
||||
: undefined,
|
||||
query.requiresHighSpeedInterconnect
|
||||
? inArray(inventoryListings.interconnectType, fast)
|
||||
: undefined,
|
||||
// Unavailable stock is never a useful search result.
|
||||
or(
|
||||
eq(inventoryListings.stockStatus, 'Available'),
|
||||
eq(inventoryListings.stockStatus, 'Low'),
|
||||
eq(inventoryListings.stockStatus, 'Medium'),
|
||||
eq(inventoryListings.stockStatus, 'High'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(Math.min(query.limit ?? 50, 200));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Inventory sync from the Prime Intellect availability API.
|
||||
*
|
||||
* Runs on an interval, upserting listings on their natural key so repeated
|
||||
* syncs converge rather than accumulating near-duplicates.
|
||||
*
|
||||
* Two decisions worth explaining.
|
||||
*
|
||||
* **Stale listings are marked, not deleted.** A listing that disappears
|
||||
* upstream has usually sold out rather than ceased to exist, and deleting it
|
||||
* would destroy the price history that makes the inventory useful for
|
||||
* negotiation. Anything not seen in this pass is marked `Unavailable`, keeping
|
||||
* the record and its history while removing it from search.
|
||||
*
|
||||
* **A failed sync is not fatal.** The upstream publishes no rate limits, so
|
||||
* some failures are expected. The job logs and waits for the next tick rather
|
||||
* than crashing the server that people are using.
|
||||
*/
|
||||
import { and, eq, inArray, lt, sql } from 'drizzle-orm';
|
||||
import type { Database } from '@pig/db';
|
||||
import { inventoryListings } from '@pig/db';
|
||||
import { PrimeClient, mapListing } from '@pig/prime';
|
||||
import type { Config } from '../lib/config';
|
||||
|
||||
export function startPrimeSync(config: Config, db: Database): () => void {
|
||||
if (!config.PRIME_SYNC_ENABLED || !config.PRIME_API_KEY) {
|
||||
if (config.PRIME_SYNC_ENABLED) {
|
||||
console.warn('[pig] Prime sync enabled but no API key — not starting.');
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const client = new PrimeClient({
|
||||
apiKey: config.PRIME_API_KEY,
|
||||
baseUrl: config.PRIME_API_BASE,
|
||||
onRetry: ({ attempt, delayMs, reason }) =>
|
||||
console.warn(`[pig] prime retry ${attempt} in ${delayMs}ms (${reason})`),
|
||||
});
|
||||
|
||||
let running = false;
|
||||
|
||||
async function runOnce() {
|
||||
// Overlapping runs would fight over the same rows for no benefit.
|
||||
if (running) return;
|
||||
running = true;
|
||||
const startedAt = new Date();
|
||||
let upserted = 0;
|
||||
|
||||
try {
|
||||
for await (const page of client.iterateGpuAvailability()) {
|
||||
const rows = page
|
||||
.map(mapListing)
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
for (const row of rows) {
|
||||
await db
|
||||
.insert(inventoryListings)
|
||||
.values({
|
||||
...row,
|
||||
socket: row.socket as 'PCIe' | null,
|
||||
source: 'prime_api',
|
||||
observedAt: startedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
inventoryListings.externalCloudId,
|
||||
inventoryListings.gpuType,
|
||||
inventoryListings.socket,
|
||||
inventoryListings.gpuCount,
|
||||
inventoryListings.securityTier,
|
||||
],
|
||||
set: {
|
||||
stockStatus: row.stockStatus,
|
||||
onDemandPriceCents: row.onDemandPriceCents,
|
||||
communityPriceCents: row.communityPriceCents,
|
||||
priceIsVariable: row.priceIsVariable,
|
||||
interconnectGbps: row.interconnectGbps,
|
||||
interconnectType: row.interconnectType,
|
||||
provisioningMinutes: row.provisioningMinutes,
|
||||
region: row.region,
|
||||
country: row.country,
|
||||
images: row.images,
|
||||
raw: row.raw,
|
||||
observedAt: startedAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
upserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Anything the provider stopped listing is out of stock, not gone.
|
||||
const stale = await db
|
||||
.update(inventoryListings)
|
||||
.set({ stockStatus: 'Unavailable', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryListings.source, 'prime_api'),
|
||||
lt(inventoryListings.observedAt, startedAt),
|
||||
sql`${inventoryListings.stockStatus} <> 'Unavailable'`,
|
||||
),
|
||||
)
|
||||
.returning({ id: inventoryListings.id });
|
||||
|
||||
console.log(
|
||||
`[pig] prime sync: ${upserted} listing(s) upserted, ${stale.length} marked unavailable`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Logged, not thrown. A provider outage must not take down the CRM.
|
||||
console.error('[pig] prime sync failed:', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delay the first run so startup is not competing with an outbound sync.
|
||||
const initial = setTimeout(() => void runOnce(), 10_000);
|
||||
const interval = setInterval(
|
||||
() => void runOnce(),
|
||||
config.PRIME_SYNC_INTERVAL_MINUTES * 60_000,
|
||||
);
|
||||
|
||||
console.log(`[pig] prime sync every ${config.PRIME_SYNC_INTERVAL_MINUTES}m`);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initial);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user