From 07d6a17643a05bfbe511ced1b94b62c8f85ac373 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:14:21 -0300 Subject: [PATCH] feat(db): add quotaPools module with CRUD and allocation management (B/F2) Implements listPools, getPool, createPool, updatePool, deletePool, upsertAllocations (replace strategy via transaction), and listAllocationsForApiKey. All SQL uses prepared statements. Local type shapes aligned with src/lib/quota/dimensions.ts contract (B13). --- src/lib/db/quotaPools.ts | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/lib/db/quotaPools.ts diff --git a/src/lib/db/quotaPools.ts b/src/lib/db/quotaPools.ts new file mode 100644 index 0000000000..1f76bd6205 --- /dev/null +++ b/src/lib/db/quotaPools.ts @@ -0,0 +1,241 @@ +/** + * db/quotaPools.ts — CRUD for quota_pools and quota_allocations tables. + * + * Quota pools group provider connections with per-API-key weight + cap + + * policy allocations. Used by the Quota Sharing Engine (plan 22, Group B). + * + * All SQL goes through prepared statements — never raw string interpolation. + * Import getDbInstance from ./core (Hard Rule #5). + */ + +import { getDbInstance } from "./core"; + +// --------------------------------------------------------------------------- +// Local type shapes (aligned with src/lib/quota/dimensions.ts — merged by F7) +// --------------------------------------------------------------------------- + +type QuotaUnit = "percent" | "requests" | "tokens" | "usd"; +type Policy = "hard" | "soft" | "burst"; + +export interface PoolAllocation { + apiKeyId: string; + weight: number; + capValue?: number; + capUnit?: QuotaUnit; + policy: Policy; +} + +export interface QuotaPool { + id: string; + connectionId: string; + name: string; + createdAt: string; + allocations: PoolAllocation[]; +} + +export interface PoolCreate { + connectionId: string; + name: string; + allocations?: PoolAllocation[]; +} + +export interface PoolUpdate { + name?: string; + allocations?: PoolAllocation[]; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface StatementLike { + all: (...params: unknown[]) => TRow[]; + get: (...params: unknown[]) => TRow | undefined; + run: (...params: unknown[]) => { changes: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; + transaction: (fn: () => T) => () => T; +} + +function getDb(): DbLike { + return getDbInstance() as unknown as DbLike; +} + +interface PoolRow { + id: string; + connection_id: string; + name: string; + created_at: string; +} + +interface AllocationRow { + pool_id: string; + api_key_id: string; + weight: number; + cap_value: number | null; + cap_unit: string | null; + policy: string; +} + +function rowToAllocation(row: AllocationRow): PoolAllocation { + const alloc: PoolAllocation = { + apiKeyId: row.api_key_id, + weight: row.weight, + policy: row.policy as Policy, + }; + if (row.cap_value != null) alloc.capValue = row.cap_value; + if (row.cap_unit != null) alloc.capUnit = row.cap_unit as QuotaUnit; + return alloc; +} + +function rowToPool(row: PoolRow, allocations: PoolAllocation[]): QuotaPool { + return { + id: row.id, + connectionId: row.connection_id, + name: row.name, + createdAt: row.created_at, + allocations, + }; +} + +function getAllocations(poolId: string): PoolAllocation[] { + const rows = getDb() + .prepare( + "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?" + ) + .all(poolId); + return rows.map(rowToAllocation); +} + +function makeId(): string { + // Use Web Crypto UUID (available in Node ≥19 globally; also available in browsers) + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback: timestamp + random (extremely unlikely to collide in tests) + return Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * List all quota pools with their allocations. + */ +export function listPools(): QuotaPool[] { + const rows = getDb() + .prepare( + "SELECT id, connection_id, name, created_at FROM quota_pools ORDER BY created_at ASC" + ) + .all(); + return rows.map((row) => rowToPool(row, getAllocations(row.id))); +} + +/** + * Get a single pool by id, or null if not found. + */ +export function getPool(id: string): QuotaPool | null { + const row = getDb() + .prepare("SELECT id, connection_id, name, created_at FROM quota_pools WHERE id = ?") + .get(id); + if (!row) return null; + return rowToPool(row, getAllocations(row.id)); +} + +/** + * Create a new quota pool, optionally with initial allocations. + */ +export function createPool(input: PoolCreate): QuotaPool { + const id = makeId(); + const now = new Date().toISOString(); + + getDb() + .prepare("INSERT INTO quota_pools (id, connection_id, name, created_at) VALUES (?, ?, ?, ?)") + .run(id, input.connectionId, input.name, now); + + if (input.allocations && input.allocations.length > 0) { + upsertAllocations(id, input.allocations); + } + + return rowToPool( + { id, connection_id: input.connectionId, name: input.name, created_at: now }, + getAllocations(id) + ); +} + +/** + * Update an existing pool's name and/or allocations. + * Returns updated pool, or null if pool not found. + */ +export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { + const existing = getDb() + .prepare("SELECT id, connection_id, name, created_at FROM quota_pools WHERE id = ?") + .get(id); + if (!existing) return null; + + if (input.name !== undefined) { + getDb().prepare("UPDATE quota_pools SET name = ? WHERE id = ?").run(input.name, id); + existing.name = input.name; + } + + if (input.allocations !== undefined) { + upsertAllocations(id, input.allocations); + } + + return rowToPool(existing, getAllocations(id)); +} + +/** + * Delete a pool by id. CASCADE removes associated allocations. + * Returns true if a row was deleted, false if not found. + */ +export function deletePool(id: string): boolean { + const result = getDb().prepare("DELETE FROM quota_pools WHERE id = ?").run(id); + return result.changes > 0; +} + +/** + * Replace all allocations for a pool with the provided list (delete + insert). + * Runs atomically inside a SQLite transaction. + */ +export function upsertAllocations(poolId: string, allocations: PoolAllocation[]): void { + const database = getDb(); + const doUpsert = database.transaction(() => { + database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(poolId); + const insert = database.prepare( + `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) + VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const alloc of allocations) { + insert.run( + poolId, + alloc.apiKeyId, + alloc.weight, + alloc.capValue ?? null, + alloc.capUnit ?? null, + alloc.policy + ); + } + }); + doUpsert(); +} + +/** + * List all allocations across all pools where apiKeyId is assigned. + * Returns pairs of { poolId, allocation }. + */ +export function listAllocationsForApiKey( + apiKeyId: string +): Array<{ poolId: string; allocation: PoolAllocation }> { + const rows = getDb() + .prepare( + `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy + FROM quota_allocations + WHERE api_key_id = ?` + ) + .all(apiKeyId); + return rows.map((row) => ({ poolId: row.pool_id, allocation: rowToAllocation(row) })); +}