From 8a5a02feba84540dbef5dfd36e2a8e47f9bc8994 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 30 Jun 2026 18:59:21 -0300 Subject: [PATCH] refactor(usage): extract pure stat/coercer helpers from usageHistory.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usageHistory.ts (987 lines, frozen-baselined) mixes pure DB-free helpers with an in-memory pending-request state machine and DB CRUD. Extract the contiguous pure block verbatim into a leaf, leaving all stateful code in the host: - usageHistory/helpers.ts (85) asRecord, toStringOrNull, normalizeServiceTier, toNumber, percentile, stdDev, truncatePendingPreview (+ its MAX_PREVIEW_* bounds, co-located) Host usageHistory.ts: 987 -> 916. The pending-request state machine (module Maps + track/update/finalize/sweep) and DB CRUD stay in the host. These helpers were all module-internal, so the public API is unchanged (21 direct exports + the pre-existing getCompletedDetails re-export = 22). Bodies moved byte-identical (leaf 0 non-verbatim lines); the host's local 'type JsonRecord' moved with the bodies that used it (host no longer references it — typecheck-confirmed). Behavior-preserving: 38 existing usage-history consumer tests stay green (usage-history-db 5, api-key-usage-limits 6, log-retention 5, usage-endpoint-dimension 3, provider-request-failure-pipeline 6, database-settings-maintenance 13); new tests/unit/usagehistory-helpers-split.test.ts (30 assertions) pins the percentile/stdDev formulas + normalizeServiceTier + guards the public API. Refs #3501. --- src/lib/usage/usageHistory.ts | 89 ++--------- src/lib/usage/usageHistory/helpers.ts | 85 +++++++++++ tests/unit/usagehistory-helpers-split.test.ts | 144 ++++++++++++++++++ 3 files changed, 238 insertions(+), 80 deletions(-) create mode 100644 src/lib/usage/usageHistory/helpers.ts create mode 100644 tests/unit/usagehistory-helpers-split.test.ts diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index a71a76ef58d4..74fec28f16f4 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -9,6 +9,15 @@ import { getDbInstance } from "../db/core"; import { protectPayloadForLog } from "../logPayloads"; +import { + asRecord, + normalizeServiceTier, + percentile, + stdDev, + toNumber, + toStringOrNull, + truncatePendingPreview, +} from "./usageHistory/helpers"; import { clearCompletedDetails, maybeEnrichCompletedDetail, @@ -25,7 +34,6 @@ import { getReasoningTokens, } from "./tokenAccounting"; -type JsonRecord = Record; export type PendingRequestMetadata = { clientEndpoint?: string | null; clientRequest?: unknown; @@ -67,85 +75,6 @@ export type PendingRequestDetail = { } | null; }; -function asRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - -function toStringOrNull(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - -function normalizeServiceTier(value: unknown): string { - const tier = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (tier === "priority" || tier === "fast") return "priority"; - if (tier === "flex") return "flex"; - return "standard"; -} - -function toNumber(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; -} - -function percentile(sortedValues: number[], p: number): number { - if (sortedValues.length === 0) return 0; - if (sortedValues.length === 1) return sortedValues[0]; - const bounded = Math.max(0, Math.min(1, p)); - const idx = Math.round((sortedValues.length - 1) * bounded); - return sortedValues[idx] ?? sortedValues[sortedValues.length - 1]; -} - -function stdDev(values: number[], avg: number): number { - if (values.length <= 1) return 0; - const variance = values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length; - return Math.sqrt(Math.max(0, variance)); -} - -const MAX_PREVIEW_DEPTH = 6; -const MAX_PREVIEW_STRING = 1200; -const MAX_PREVIEW_ARRAY_ITEMS = 12; -const MAX_PREVIEW_OBJECT_KEYS = 24; - -function truncatePendingPreview(value: unknown, depth = 0): unknown { - if (depth >= MAX_PREVIEW_DEPTH) { - return "[TRUNCATED_DEPTH]"; - } - - if (typeof value === "string") { - return value.length > MAX_PREVIEW_STRING ? `${value.slice(0, MAX_PREVIEW_STRING)}...` : value; - } - - if (Array.isArray(value)) { - const preview = value - .slice(0, MAX_PREVIEW_ARRAY_ITEMS) - .map((item) => truncatePendingPreview(item, depth + 1)); - if (value.length > MAX_PREVIEW_ARRAY_ITEMS) { - preview.push({ _truncatedItems: value.length - MAX_PREVIEW_ARRAY_ITEMS }); - } - return preview; - } - - if (!value || typeof value !== "object") { - return value; - } - - const entries = Object.entries(value as JsonRecord); - const truncatedEntries = entries - .slice(0, MAX_PREVIEW_OBJECT_KEYS) - .map(([key, entryValue]) => [key, truncatePendingPreview(entryValue, depth + 1)]); - const preview = Object.fromEntries(truncatedEntries); - - if (entries.length > MAX_PREVIEW_OBJECT_KEYS) { - preview._truncatedKeys = entries.length - MAX_PREVIEW_OBJECT_KEYS; - } - - return preview; -} - function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingRequestMetadata { if (!metadata) return {}; diff --git a/src/lib/usage/usageHistory/helpers.ts b/src/lib/usage/usageHistory/helpers.ts new file mode 100644 index 000000000000..2c46a2ef24e9 --- /dev/null +++ b/src/lib/usage/usageHistory/helpers.ts @@ -0,0 +1,85 @@ +/** + * Pure, stateless helpers extracted from usageHistory.ts. + * No DB access, no module-level state — safe to import anywhere. + */ + +type JsonRecord = Record; + +export function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function toStringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +export function normalizeServiceTier(value: unknown): string { + const tier = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (tier === "priority" || tier === "fast") return "priority"; + if (tier === "flex") return "flex"; + return "standard"; +} + +export function toNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +export function percentile(sortedValues: number[], p: number): number { + if (sortedValues.length === 0) return 0; + if (sortedValues.length === 1) return sortedValues[0]; + const bounded = Math.max(0, Math.min(1, p)); + const idx = Math.round((sortedValues.length - 1) * bounded); + return sortedValues[idx] ?? sortedValues[sortedValues.length - 1]; +} + +export function stdDev(values: number[], avg: number): number { + if (values.length <= 1) return 0; + const variance = values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length; + return Math.sqrt(Math.max(0, variance)); +} + +export const MAX_PREVIEW_DEPTH = 6; +export const MAX_PREVIEW_STRING = 1200; +export const MAX_PREVIEW_ARRAY_ITEMS = 12; +export const MAX_PREVIEW_OBJECT_KEYS = 24; + +export function truncatePendingPreview(value: unknown, depth = 0): unknown { + if (depth >= MAX_PREVIEW_DEPTH) { + return "[TRUNCATED_DEPTH]"; + } + + if (typeof value === "string") { + return value.length > MAX_PREVIEW_STRING ? `${value.slice(0, MAX_PREVIEW_STRING)}...` : value; + } + + if (Array.isArray(value)) { + const preview = value + .slice(0, MAX_PREVIEW_ARRAY_ITEMS) + .map((item) => truncatePendingPreview(item, depth + 1)); + if (value.length > MAX_PREVIEW_ARRAY_ITEMS) { + preview.push({ _truncatedItems: value.length - MAX_PREVIEW_ARRAY_ITEMS }); + } + return preview; + } + + if (!value || typeof value !== "object") { + return value; + } + + const entries = Object.entries(value as JsonRecord); + const truncatedEntries = entries + .slice(0, MAX_PREVIEW_OBJECT_KEYS) + .map(([key, entryValue]) => [key, truncatePendingPreview(entryValue, depth + 1)]); + const preview = Object.fromEntries(truncatedEntries); + + if (entries.length > MAX_PREVIEW_OBJECT_KEYS) { + preview._truncatedKeys = entries.length - MAX_PREVIEW_OBJECT_KEYS; + } + + return preview; +} diff --git a/tests/unit/usagehistory-helpers-split.test.ts b/tests/unit/usagehistory-helpers-split.test.ts new file mode 100644 index 000000000000..981d05241900 --- /dev/null +++ b/tests/unit/usagehistory-helpers-split.test.ts @@ -0,0 +1,144 @@ +/** + * Characterization + API-surface test: usageHistory.ts god-file decomposition. + * + * The pure, DB-free helpers (coercers + percentile/stdDev stats + the + * depth-limited preview truncator) were extracted verbatim from + * src/lib/usage/usageHistory.ts into src/lib/usage/usageHistory/helpers.ts. + * The in-memory pending-request state machine + DB CRUD stay in the host. + * + * Verifies that: + * 1. The pure helpers behave correctly (stats formulas pinned). + * 2. The host usageHistory.ts still exposes the FULL public API. + * 3. The helpers leaf exports its pieces directly. + * + * Pure value assertions — no DB handle is opened. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + asRecord, + toNumber, + toStringOrNull, + normalizeServiceTier, + percentile, + stdDev, + truncatePendingPreview, +} from "../../src/lib/usage/usageHistory/helpers.ts"; + +describe("usageHistory/helpers — coercers", () => { + it("asRecord keeps plain objects, rejects arrays/primitives", () => { + const o = { a: 1 }; + assert.equal(asRecord(o), o); + assert.deepEqual(asRecord([1]), {}); + assert.deepEqual(asRecord(null), {}); + }); + it("toNumber / toStringOrNull coerce by type", () => { + assert.equal(toNumber("12"), 12); + assert.equal(toNumber("x"), 0); + assert.equal(toStringOrNull("hi"), "hi"); + assert.equal(toStringOrNull(" "), null); + }); +}); + +describe("usageHistory/helpers — normalizeServiceTier", () => { + it("maps priority/fast → priority, flex → flex, else → standard", () => { + assert.equal(normalizeServiceTier("priority"), "priority"); + assert.equal(normalizeServiceTier("Fast"), "priority"); + assert.equal(normalizeServiceTier("flex"), "flex"); + assert.equal(normalizeServiceTier("default"), "standard"); + assert.equal(normalizeServiceTier(undefined), "standard"); + }); +}); + +describe("usageHistory/helpers — percentile (round-index on sorted input)", () => { + const data = [10, 20, 30, 40, 50]; + it("returns 0 for empty, the sole value for length 1", () => { + assert.equal(percentile([], 0.5), 0); + assert.equal(percentile([42], 0.9), 42); + }); + it("picks round((n-1)*p) clamped to [0,1]", () => { + assert.equal(percentile(data, 0), 10); + assert.equal(percentile(data, 0.25), 20); + assert.equal(percentile(data, 0.5), 30); + assert.equal(percentile(data, 1), 50); + assert.equal(percentile(data, 5), 50); // p clamped to 1 + }); +}); + +describe("usageHistory/helpers — stdDev (population)", () => { + it("returns 0 for <=1 value or all-equal", () => { + assert.equal(stdDev([], 0), 0); + assert.equal(stdDev([7], 7), 0); + assert.equal(stdDev([3, 3, 3], 3), 0); + }); + it("computes sqrt of the population variance", () => { + assert.equal(stdDev([0, 2], 1), 1); // variance (1+1)/2 = 1 + assert.equal(stdDev([2, 4, 4, 4, 5, 5, 7, 9], 5), 2); // variance 32/8 = 4 + }); +}); + +describe("usageHistory/helpers — truncatePendingPreview", () => { + it("passes small primitives through", () => { + assert.equal(truncatePendingPreview(5), 5); + assert.equal(truncatePendingPreview("ok"), "ok"); + }); + it("shortens an over-long string", () => { + const out = truncatePendingPreview("x".repeat(5000)); + assert.equal(typeof out, "string"); + assert.ok((out as string).length < 5000, "a 5000-char string must be truncated"); + }); +}); + +// ── host public API surface ────────────────────────────────────────────────── + +const host = await import("../../src/lib/usage/usageHistory.ts"); + +describe("usageHistory.ts public API surface", () => { + const expected = [ + "appendRequestLog", + "clearPendingRequests", + "finalizeMostRecentPendingRequest", + "finalizePendingRequest", + "finalizePendingRequestById", + "getMaxPendingRequestAgeMs", + "getModelLatencyStats", + "getPendingById", + "getPendingRequests", + "getRecentLogs", + "getUsageDb", + "getUsageHistory", + "saveRequestUsage", + "sweepStalePendingRequests", + "trackPendingRequest", + "updatePendingRequest", + "updatePendingRequestById", + "updatePendingRequestStreamChunks", + "getCompletedDetails", // pre-existing re-export, must survive + ]; + for (const name of expected) { + it(`exposes ${name} as a function`, () => { + assert.equal(typeof host[name], "function", `${name} must be a function on the host`); + }); + } + it("loses no public function in the split", () => { + const missing = expected.filter((n) => typeof host[n] !== "function"); + assert.deepEqual(missing, [], `missing: ${missing.join(", ")}`); + }); +}); + +describe("helpers.ts exports its pieces directly", () => { + it("the moved helpers are functions on the leaf", async () => { + const h = await import("../../src/lib/usage/usageHistory/helpers.ts"); + for (const fn of [ + "asRecord", + "percentile", + "stdDev", + "normalizeServiceTier", + "truncatePendingPreview", + ]) { + assert.equal(typeof h[fn], "function", fn); + } + }); +});