mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 18:02:17 +03:00
perf(cache): replace connectionsCache with rawConnectionsCache + shared lazyConnectionView
Phase 2a: extract lazy connection view into shared module - Move createLazyConnectionView, toProviderConnection, ProviderConnectionView from sse/services/auth.ts to src/lib/db/providers/lazyConnectionView.ts - Update auth.ts to import from the shared location - Update catalog.ts to use getCachedRawProviderConnections + createLazyConnectionView Phase 3: delegate getCachedProviderConnections through raw cache - getCachedProviderConnections now delegates to getProviderConnections which calls getCachedRawProviderConnections (single source of truth) - Add LRU eviction (maxSize param) to TTLCache to prevent memory leaks - rawConnectionsCache limited to 500 entries, connectionByIdCache to 10K - Provider metadata (combo/catalog) caches get dedicated invalidation scopes - Fix deleteProviderConnectionsByProvider: add missing invalidateDbCache call Phase 4: fix test isolation + dead code audit - db-read-cache.test.ts: use real module import (not importFresh) so invalidateDbCache reaches the same rawConnectionsCache instance - Verified: no stale connectionsCache references, clean barrel exports
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import {
|
||||
getProviderConnections,
|
||||
getCachedRawProviderConnections,
|
||||
getCombos,
|
||||
getAllCustomModels,
|
||||
getSettings,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getModelIsHidden,
|
||||
getModelAliases,
|
||||
} from "@/lib/localDb";
|
||||
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
|
||||
import { extractAliasBackedModels } from "./aliasBackedModels";
|
||||
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
|
||||
@@ -315,7 +316,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
let connections = [];
|
||||
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
|
||||
try {
|
||||
connections = await getProviderConnections();
|
||||
connections = (await getCachedRawProviderConnections()).map(createLazyConnectionView);
|
||||
totalConnectionCount = connections.length;
|
||||
// Filter to only active connections
|
||||
connections = connections.filter((c) => c.isActive !== false);
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
decryptConnectionFields,
|
||||
migrateLegacyEncryptedString,
|
||||
} from "./encryption";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
import { createLazyRowProxy } from "./providers/lazyConnectionView";
|
||||
import { invalidateDbCache, getCachedRawProviderConnections } from "./readCache";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
import { bumpProxyConfigGeneration } from "./settings";
|
||||
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
|
||||
@@ -42,42 +43,8 @@ interface DbLike {
|
||||
// ──────────────── Provider Connections ────────────────
|
||||
|
||||
export async function getProviderConnections(filter: JsonRecord = {}) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
let sql = "SELECT * FROM provider_connections";
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (filter.provider) {
|
||||
conditions.push("provider = @provider");
|
||||
params.provider = filter.provider;
|
||||
}
|
||||
if (filter.isActive !== undefined) {
|
||||
conditions.push("is_active = @isActive");
|
||||
params.isActive = filter.isActive ? 1 : 0;
|
||||
}
|
||||
if (filter.authType) {
|
||||
conditions.push("auth_type = @authType");
|
||||
params.authType = filter.authType;
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ");
|
||||
}
|
||||
sql += " ORDER BY priority ASC, updated_at DESC";
|
||||
|
||||
const rows = db.prepare(sql).all(params);
|
||||
return rows.map((r) => {
|
||||
const camelRow = rowToCamel(r);
|
||||
return decryptConnectionFields(
|
||||
withNullableRateLimitOverrides(
|
||||
withNullableQuotaWindowThresholds(
|
||||
withNullableMaxConcurrent(cleanNulls(camelRow), camelRow),
|
||||
camelRow
|
||||
),
|
||||
camelRow
|
||||
)
|
||||
);
|
||||
});
|
||||
const raw = await getCachedRawProviderConnections(filter);
|
||||
return raw.map(createLazyRowProxy);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +56,10 @@ export async function getProviderConnections(filter: JsonRecord = {}) {
|
||||
* Used by the lazy-decryption path in auth selection (auth.ts) where
|
||||
* 10k+ connections are filtered in JS but only 1 needs its apiKey
|
||||
* decrypted.
|
||||
*
|
||||
* @param filter.limit — Optional SQL LIMIT clause to cap rows returned
|
||||
* (useful for dashboards / admin panels that only need the first N).
|
||||
* Not a column filter — extracted before building WHERE conditions.
|
||||
*/
|
||||
export async function getRawProviderConnections(filter: JsonRecord = {}) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
@@ -109,10 +80,16 @@ export async function getRawProviderConnections(filter: JsonRecord = {}) {
|
||||
params.authType = filter.authType;
|
||||
}
|
||||
|
||||
// Extract LIMIT from filter — not a SQL column condition
|
||||
const limitValue = typeof filter.limit === "number" ? filter.limit : undefined;
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ");
|
||||
}
|
||||
sql += " ORDER BY priority ASC, updated_at DESC";
|
||||
if (limitValue !== undefined) {
|
||||
sql += " LIMIT " + limitValue;
|
||||
}
|
||||
|
||||
const rows = db.prepare(sql).all(params);
|
||||
return rows.map((r) => {
|
||||
@@ -781,6 +758,7 @@ export async function deleteProviderConnectionsByProvider(providerId: string) {
|
||||
|
||||
const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId);
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections");
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
@@ -895,4 +873,6 @@ export {
|
||||
getEffectiveQuotaUsage,
|
||||
clearStaleCrashCooldowns,
|
||||
formatResetCountdown,
|
||||
isConnectionRateLimited,
|
||||
getRateLimitedConnections,
|
||||
} from "./providers/rateLimit";
|
||||
|
||||
202
src/lib/db/providers/lazyConnectionView.ts
Normal file
202
src/lib/db/providers/lazyConnectionView.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Lazy-decrypting ProviderConnectionView.
|
||||
*
|
||||
* Wraps a raw (ciphertext) DB row in a JS Proxy that decrypts credential
|
||||
* fields (apiKey, accessToken, refreshToken) only on first access.
|
||||
* Non-credential reads hit the already-coerced view directly at zero cost.
|
||||
*
|
||||
* Extracted from sse/services/auth.ts for reuse across dashboard,
|
||||
* admin, and catalog callers during Phase 2/3 of the lazy-decrypt rollout.
|
||||
*/
|
||||
|
||||
import { decrypt } from "../encryption";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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 toNumber(value: unknown, fallback = 0): 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 : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNullableNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const parsed = toNumber(value, Number.NaN);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionView {
|
||||
id: string;
|
||||
provider: string;
|
||||
email: string | null;
|
||||
isActive: boolean;
|
||||
rateLimitedUntil: string | null;
|
||||
testStatus: string | null;
|
||||
apiKey: string | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
tokenExpiresAt: string | null;
|
||||
expiresAt: string | null;
|
||||
projectId: string | null;
|
||||
defaultModel: string | null;
|
||||
providerSpecificData: JsonRecord;
|
||||
lastUsedAt: string | null;
|
||||
consecutiveUseCount: number;
|
||||
priority: number;
|
||||
lastError: string | null;
|
||||
lastErrorType: string | null;
|
||||
lastErrorSource: string | null;
|
||||
errorCode: string | number | null;
|
||||
backoffLevel: number;
|
||||
maxConcurrent: number | null;
|
||||
quotaWindowThresholds: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw DB row into a fully resolved ProviderConnectionView.
|
||||
* Credential fields have already been decrypted by the DB layer.
|
||||
*/
|
||||
export function toProviderConnection(value: unknown): ProviderConnectionView {
|
||||
const row = asRecord(value);
|
||||
const rawThresholds = row.quotaWindowThresholds;
|
||||
const quotaWindowThresholds: Record<string, number> | null =
|
||||
rawThresholds && typeof rawThresholds === "object" && !Array.isArray(rawThresholds)
|
||||
? (rawThresholds as Record<string, number>)
|
||||
: null;
|
||||
return {
|
||||
id: toStringOrNull(row.id) || "",
|
||||
provider: toStringOrNull(row.provider) || "",
|
||||
email: toStringOrNull(row.email),
|
||||
isActive: row.isActive === true,
|
||||
rateLimitedUntil: toStringOrNull(row.rateLimitedUntil),
|
||||
testStatus: toStringOrNull(row.testStatus),
|
||||
apiKey: toStringOrNull(row.apiKey),
|
||||
accessToken: toStringOrNull(row.accessToken),
|
||||
refreshToken: toStringOrNull(row.refreshToken),
|
||||
tokenExpiresAt: toStringOrNull(row.tokenExpiresAt),
|
||||
expiresAt: toStringOrNull(row.expiresAt),
|
||||
projectId: toStringOrNull(row.projectId),
|
||||
defaultModel: toStringOrNull(row.defaultModel),
|
||||
providerSpecificData: asRecord(row.providerSpecificData),
|
||||
lastUsedAt: toStringOrNull(row.lastUsedAt),
|
||||
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
|
||||
priority: toNumber(row.priority, 999),
|
||||
lastError: toStringOrNull(row.lastError),
|
||||
lastErrorType: toStringOrNull(row.lastErrorType),
|
||||
lastErrorSource: toStringOrNull(row.lastErrorSource),
|
||||
errorCode:
|
||||
typeof row.errorCode === "string" || typeof row.errorCode === "number"
|
||||
? row.errorCode
|
||||
: null,
|
||||
backoffLevel: toNumber(row.backoffLevel, 0),
|
||||
maxConcurrent: toNullableNumber(row.maxConcurrent),
|
||||
quotaWindowThresholds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-decrypting ProviderConnectionView from a raw (ciphertext)
|
||||
* DB row. First calls toProviderConnection for full type coercion (isActive
|
||||
* boolean, providerSpecificData object, etc.), then proxies credential
|
||||
* fields (apiKey, accessToken, refreshToken) to decrypt-only-on-first-access.
|
||||
*
|
||||
* Non-credential reads hit the already-coerced view directly at zero cost.
|
||||
*/
|
||||
export function createLazyConnectionView(
|
||||
row: Record<string, unknown>
|
||||
): ProviderConnectionView {
|
||||
const base = toProviderConnection(row);
|
||||
let decrypted: Record<string, null | string> | undefined;
|
||||
|
||||
const ensureDecrypted = () => {
|
||||
if (!decrypted) {
|
||||
decrypted = {
|
||||
apiKey: toStringOrNull(decrypt(base.apiKey)),
|
||||
accessToken: toStringOrNull(decrypt(base.accessToken)),
|
||||
refreshToken: toStringOrNull(decrypt(base.refreshToken)),
|
||||
};
|
||||
}
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
return new Proxy(base, {
|
||||
get: (_target, prop: string | symbol) => {
|
||||
if (prop === "apiKey" || prop === "accessToken" || prop === "refreshToken") {
|
||||
return ensureDecrypted()[prop];
|
||||
}
|
||||
return Reflect.get(_target, prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const CREDENTIAL_FIELDS = new Set(["apiKey", "accessToken", "refreshToken", "idToken"]);
|
||||
|
||||
/**
|
||||
* Wraps a raw (ciphertext) DB row in a JS Proxy that decrypts credential
|
||||
* fields only on first access. Non-credential fields pass through from the
|
||||
* raw row untouched — no need to extend any typed interface.
|
||||
*
|
||||
* Returns Record<string, unknown> so it can replace getProviderConnections
|
||||
* without any caller changes. The typed createLazyConnectionView remains
|
||||
* available for new code that wants a structured view.
|
||||
*/
|
||||
export function createLazyRowProxy(
|
||||
row: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
let decrypted: Record<string, string | null | undefined> | undefined;
|
||||
|
||||
const ensureDecrypted = () => {
|
||||
if (!decrypted) {
|
||||
decrypted = {
|
||||
apiKey: lazyDecrypt(row.apiKey),
|
||||
accessToken: lazyDecrypt(row.accessToken),
|
||||
refreshToken: lazyDecrypt(row.refreshToken),
|
||||
idToken: lazyDecrypt(row.idToken),
|
||||
};
|
||||
}
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
return new Proxy(row, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === "string" && CREDENTIAL_FIELDS.has(prop)) {
|
||||
return ensureDecrypted()[prop];
|
||||
}
|
||||
if (prop === "toJSON") {
|
||||
return () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(target)) {
|
||||
result[key] =
|
||||
CREDENTIAL_FIELDS.has(key) ? ensureDecrypted()[key] : target[key];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop);
|
||||
},
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(target);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function lazyDecrypt(value: unknown): string | null | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return decrypt(value);
|
||||
}
|
||||
@@ -20,9 +20,11 @@ type CacheEntry<T> = {
|
||||
class TTLCache<T> {
|
||||
private cache = new Map<string, CacheEntry<T>>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxSize: number;
|
||||
|
||||
constructor(ttlMs: number) {
|
||||
constructor(ttlMs: number, maxSize?: number) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.maxSize = maxSize ?? 0;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
@@ -32,10 +34,18 @@ class TTLCache<T> {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
// LRU: move to end (most recently used)
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
// Evict LRU (first key in insertion order) when at capacity
|
||||
if (this.maxSize > 0 && this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest !== undefined) this.cache.delete(oldest);
|
||||
}
|
||||
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||
}
|
||||
|
||||
@@ -53,10 +63,8 @@ class TTLCache<T> {
|
||||
const SETTINGS_TTL_MS = 5_000;
|
||||
const PRICING_TTL_MS = 30_000;
|
||||
const CONNECTIONS_TTL_MS = 5_000;
|
||||
|
||||
const settingsCache = new TTLCache<Record<string, unknown>>(SETTINGS_TTL_MS);
|
||||
const pricingCache = new TTLCache<Record<string, unknown>>(PRICING_TTL_MS);
|
||||
const connectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
|
||||
|
||||
/**
|
||||
* Cached wrapper for getSettings.
|
||||
@@ -93,17 +101,11 @@ export async function getCachedPricing(): Promise<Record<string, unknown>> {
|
||||
export async function getCachedProviderConnections(
|
||||
filter?: Record<string, unknown>
|
||||
): Promise<unknown[]> {
|
||||
const cacheKey = filter && Object.keys(filter).length > 0 ? JSON.stringify(filter) : "all";
|
||||
const cached = connectionsCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const { getProviderConnections } = await import("@/lib/db/providers");
|
||||
const value = await getProviderConnections(filter || {});
|
||||
connectionsCache.set(cacheKey, value);
|
||||
return value;
|
||||
return getProviderConnections(filter || {});
|
||||
}
|
||||
|
||||
const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
|
||||
const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS, 500);
|
||||
|
||||
/**
|
||||
* Cached wrapper for getRawProviderConnections.
|
||||
@@ -124,7 +126,7 @@ export async function getCachedRawProviderConnections(
|
||||
return rows;
|
||||
}
|
||||
|
||||
const connectionByIdCache = new TTLCache<Record<string, unknown> | null>(CONNECTIONS_TTL_MS);
|
||||
const connectionByIdCache = new TTLCache<Record<string, unknown> | null>(CONNECTIONS_TTL_MS, 10_000);
|
||||
const nodesCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
|
||||
|
||||
/**
|
||||
@@ -241,17 +243,27 @@ export function getModelCatalogCacheVersion(): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all caches (call after writes to any of: settings, pricing,
|
||||
* Invalidate caches (call after writes to any of: settings, pricing,
|
||||
* connections, combos, nodes).
|
||||
*
|
||||
* When scope is `"connections"` and an `id` is provided, only that
|
||||
* connection's by-ID cache entry is invalidated (the filter-keyed raw
|
||||
* cache must still be fully cleared since overlapping filter results
|
||||
* cannot be selectively invalidated).
|
||||
*/
|
||||
export function invalidateDbCache(
|
||||
scope?: "settings" | "pricing" | "connections" | "combos" | "nodes"
|
||||
scope?: "settings" | "pricing" | "connections" | "combos" | "nodes",
|
||||
id?: string
|
||||
): void {
|
||||
if (!scope || scope === "settings") settingsCache.invalidate();
|
||||
if (!scope || scope === "pricing") pricingCache.invalidate();
|
||||
if (!scope || scope === "connections") {
|
||||
connectionsCache.invalidate();
|
||||
connectionByIdCache.invalidate();
|
||||
rawConnectionsCache.invalidate();
|
||||
if (id) {
|
||||
connectionByIdCache.invalidate(id);
|
||||
} else {
|
||||
connectionByIdCache.invalidate();
|
||||
}
|
||||
}
|
||||
if (!scope || scope === "nodes") nodesCache.invalidate();
|
||||
if (!scope || scope === "combos") combosCacheVersion++;
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
getSettings,
|
||||
getCachedSettings,
|
||||
} from "@/lib/localDb";
|
||||
import { decrypt } from "@/lib/db/encryption";
|
||||
import {
|
||||
createLazyConnectionView,
|
||||
toProviderConnection,
|
||||
type ProviderConnectionView,
|
||||
} from "@/lib/db/providers/lazyConnectionView";
|
||||
import {
|
||||
DEFAULT_QUOTA_THRESHOLD_PERCENT,
|
||||
getQuotaCache,
|
||||
@@ -72,37 +76,6 @@ import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface ProviderConnectionView {
|
||||
id: string;
|
||||
provider: string;
|
||||
email: string | null;
|
||||
isActive: boolean;
|
||||
rateLimitedUntil: string | null;
|
||||
testStatus: string | null;
|
||||
apiKey: string | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
tokenExpiresAt: string | null;
|
||||
expiresAt: string | null;
|
||||
projectId: string | null;
|
||||
defaultModel: string | null;
|
||||
providerSpecificData: JsonRecord;
|
||||
lastUsedAt: string | null;
|
||||
consecutiveUseCount: number;
|
||||
priority: number;
|
||||
lastError: string | null;
|
||||
lastErrorType: string | null;
|
||||
lastErrorSource: string | null;
|
||||
errorCode: string | number | null;
|
||||
backoffLevel: number;
|
||||
maxConcurrent: number | null;
|
||||
// Per-window quota cutoff overrides — null means "no overrides, inherit
|
||||
// resilience-settings defaults." Read by getProviderCredentialsWithQuotaPreflight
|
||||
// to decide whether to invoke the upstream usage fetcher.
|
||||
quotaWindowThresholds: Record<string, number> | null;
|
||||
}
|
||||
|
||||
interface RecoverableConnectionState {
|
||||
connectionId: string;
|
||||
testStatus?: string | null;
|
||||
@@ -145,43 +118,6 @@ function asRecord(value: unknown): JsonRecord {
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
/**
|
||||
* Creates a lazy-decrypting ProviderConnectionView from a raw (ciphertext)
|
||||
* DB row. First calls toProviderConnection for full type coercion (isActive
|
||||
* boolean, providerSpecificData object, etc.), then proxies credential
|
||||
* fields (apiKey, accessToken, refreshToken) to decrypt-only-on-first-access.
|
||||
*
|
||||
* Non-credential reads hit the already-coerced view directly at zero cost.
|
||||
*/
|
||||
function createLazyConnectionView(row: Record<string, unknown>): ProviderConnectionView {
|
||||
const base = toProviderConnection(row);
|
||||
let decrypted: Record<string, null | string> | undefined;
|
||||
|
||||
const ensureDecrypted = () => {
|
||||
if (!decrypted) {
|
||||
decrypted = {
|
||||
apiKey: toStringOrNull(decrypt(base.apiKey)),
|
||||
accessToken: toStringOrNull(decrypt(base.accessToken)),
|
||||
refreshToken: toStringOrNull(decrypt(base.refreshToken)),
|
||||
};
|
||||
}
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
return new Proxy(base, {
|
||||
get: (_target, prop: string | symbol) => {
|
||||
if (prop === "apiKey" || prop === "accessToken" || prop === "refreshToken") {
|
||||
return ensureDecrypted()[prop];
|
||||
}
|
||||
return Reflect.get(_target, prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw DB row into a fully resolved ProviderConnectionView.
|
||||
* Credential fields have already been decrypted by the DB layer.
|
||||
*/
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
@@ -198,44 +134,6 @@ function toNullableNumber(value: unknown): number | null {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toProviderConnection(value: unknown): ProviderConnectionView {
|
||||
const row = asRecord(value);
|
||||
// Only accept the per-window override map when it's a plain object —
|
||||
// anything else collapses to null so the preflight gate treats it as "no
|
||||
// overrides set."
|
||||
const rawThresholds = row.quotaWindowThresholds;
|
||||
const quotaWindowThresholds: Record<string, number> | null =
|
||||
rawThresholds && typeof rawThresholds === "object" && !Array.isArray(rawThresholds)
|
||||
? (rawThresholds as Record<string, number>)
|
||||
: null;
|
||||
return {
|
||||
id: toStringOrNull(row.id) || "",
|
||||
provider: toStringOrNull(row.provider) || "",
|
||||
email: toStringOrNull(row.email),
|
||||
isActive: row.isActive === true,
|
||||
rateLimitedUntil: toStringOrNull(row.rateLimitedUntil),
|
||||
testStatus: toStringOrNull(row.testStatus),
|
||||
apiKey: toStringOrNull(row.apiKey),
|
||||
accessToken: toStringOrNull(row.accessToken),
|
||||
refreshToken: toStringOrNull(row.refreshToken),
|
||||
tokenExpiresAt: toStringOrNull(row.tokenExpiresAt),
|
||||
expiresAt: toStringOrNull(row.expiresAt),
|
||||
projectId: toStringOrNull(row.projectId),
|
||||
defaultModel: toStringOrNull(row.defaultModel),
|
||||
providerSpecificData: asRecord(row.providerSpecificData),
|
||||
lastUsedAt: toStringOrNull(row.lastUsedAt),
|
||||
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
|
||||
priority: toNumber(row.priority, 999),
|
||||
lastError: toStringOrNull(row.lastError),
|
||||
lastErrorType: toStringOrNull(row.lastErrorType),
|
||||
lastErrorSource: toStringOrNull(row.lastErrorSource),
|
||||
errorCode:
|
||||
typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null,
|
||||
backoffLevel: toNumber(row.backoffLevel, 0),
|
||||
maxConcurrent: toNullableNumber(row.maxConcurrent),
|
||||
quotaWindowThresholds,
|
||||
};
|
||||
}
|
||||
|
||||
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
|
||||
@@ -110,7 +110,7 @@ test("getCachedPricing caches results and refreshes after invalidation", async (
|
||||
});
|
||||
|
||||
test("getCachedProviderConnections caches only the unfiltered query", async () => {
|
||||
const readCache = await importFresh("src/lib/db/readCache.ts");
|
||||
const readCache = await import("../../src/lib/db/readCache.ts");
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user