mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +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 { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||||
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
|
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||||
import {
|
import {
|
||||||
getProviderConnections,
|
getCachedRawProviderConnections,
|
||||||
getCombos,
|
getCombos,
|
||||||
getAllCustomModels,
|
getAllCustomModels,
|
||||||
getSettings,
|
getSettings,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getModelIsHidden,
|
getModelIsHidden,
|
||||||
getModelAliases,
|
getModelAliases,
|
||||||
} from "@/lib/localDb";
|
} from "@/lib/localDb";
|
||||||
|
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
|
||||||
import { extractAliasBackedModels } from "./aliasBackedModels";
|
import { extractAliasBackedModels } from "./aliasBackedModels";
|
||||||
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
|
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
|
||||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
|
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
|
||||||
@@ -315,7 +316,7 @@ async function buildUnifiedModelsResponseCore(
|
|||||||
let connections = [];
|
let connections = [];
|
||||||
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
|
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
|
||||||
try {
|
try {
|
||||||
connections = await getProviderConnections();
|
connections = (await getCachedRawProviderConnections()).map(createLazyConnectionView);
|
||||||
totalConnectionCount = connections.length;
|
totalConnectionCount = connections.length;
|
||||||
// Filter to only active connections
|
// Filter to only active connections
|
||||||
connections = connections.filter((c) => c.isActive !== false);
|
connections = connections.filter((c) => c.isActive !== false);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
decryptConnectionFields,
|
decryptConnectionFields,
|
||||||
migrateLegacyEncryptedString,
|
migrateLegacyEncryptedString,
|
||||||
} from "./encryption";
|
} from "./encryption";
|
||||||
import { invalidateDbCache } from "./readCache";
|
import { createLazyRowProxy } from "./providers/lazyConnectionView";
|
||||||
|
import { invalidateDbCache, getCachedRawProviderConnections } from "./readCache";
|
||||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||||
import { bumpProxyConfigGeneration } from "./settings";
|
import { bumpProxyConfigGeneration } from "./settings";
|
||||||
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
|
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
|
||||||
@@ -42,42 +43,8 @@ interface DbLike {
|
|||||||
// ──────────────── Provider Connections ────────────────
|
// ──────────────── Provider Connections ────────────────
|
||||||
|
|
||||||
export async function getProviderConnections(filter: JsonRecord = {}) {
|
export async function getProviderConnections(filter: JsonRecord = {}) {
|
||||||
const db = getDbInstance() as unknown as DbLike;
|
const raw = await getCachedRawProviderConnections(filter);
|
||||||
let sql = "SELECT * FROM provider_connections";
|
return raw.map(createLazyRowProxy);
|
||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +56,10 @@ export async function getProviderConnections(filter: JsonRecord = {}) {
|
|||||||
* Used by the lazy-decryption path in auth selection (auth.ts) where
|
* Used by the lazy-decryption path in auth selection (auth.ts) where
|
||||||
* 10k+ connections are filtered in JS but only 1 needs its apiKey
|
* 10k+ connections are filtered in JS but only 1 needs its apiKey
|
||||||
* decrypted.
|
* 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 = {}) {
|
export async function getRawProviderConnections(filter: JsonRecord = {}) {
|
||||||
const db = getDbInstance() as unknown as DbLike;
|
const db = getDbInstance() as unknown as DbLike;
|
||||||
@@ -109,10 +80,16 @@ export async function getRawProviderConnections(filter: JsonRecord = {}) {
|
|||||||
params.authType = filter.authType;
|
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) {
|
if (conditions.length > 0) {
|
||||||
sql += " WHERE " + conditions.join(" AND ");
|
sql += " WHERE " + conditions.join(" AND ");
|
||||||
}
|
}
|
||||||
sql += " ORDER BY priority ASC, updated_at DESC";
|
sql += " ORDER BY priority ASC, updated_at DESC";
|
||||||
|
if (limitValue !== undefined) {
|
||||||
|
sql += " LIMIT " + limitValue;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = db.prepare(sql).all(params);
|
const rows = db.prepare(sql).all(params);
|
||||||
return rows.map((r) => {
|
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);
|
const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId);
|
||||||
backupDbFile("pre-write");
|
backupDbFile("pre-write");
|
||||||
|
invalidateDbCache("connections");
|
||||||
return result.changes;
|
return result.changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,4 +873,6 @@ export {
|
|||||||
getEffectiveQuotaUsage,
|
getEffectiveQuotaUsage,
|
||||||
clearStaleCrashCooldowns,
|
clearStaleCrashCooldowns,
|
||||||
formatResetCountdown,
|
formatResetCountdown,
|
||||||
|
isConnectionRateLimited,
|
||||||
|
getRateLimitedConnections,
|
||||||
} from "./providers/rateLimit";
|
} 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> {
|
class TTLCache<T> {
|
||||||
private cache = new Map<string, CacheEntry<T>>();
|
private cache = new Map<string, CacheEntry<T>>();
|
||||||
private readonly ttlMs: number;
|
private readonly ttlMs: number;
|
||||||
|
private readonly maxSize: number;
|
||||||
|
|
||||||
constructor(ttlMs: number) {
|
constructor(ttlMs: number, maxSize?: number) {
|
||||||
this.ttlMs = ttlMs;
|
this.ttlMs = ttlMs;
|
||||||
|
this.maxSize = maxSize ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): T | undefined {
|
get(key: string): T | undefined {
|
||||||
@@ -32,10 +34,18 @@ class TTLCache<T> {
|
|||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
// LRU: move to end (most recently used)
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, entry);
|
||||||
return entry.value;
|
return entry.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(key: string, value: T): void {
|
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 });
|
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +63,8 @@ class TTLCache<T> {
|
|||||||
const SETTINGS_TTL_MS = 5_000;
|
const SETTINGS_TTL_MS = 5_000;
|
||||||
const PRICING_TTL_MS = 30_000;
|
const PRICING_TTL_MS = 30_000;
|
||||||
const CONNECTIONS_TTL_MS = 5_000;
|
const CONNECTIONS_TTL_MS = 5_000;
|
||||||
|
|
||||||
const settingsCache = new TTLCache<Record<string, unknown>>(SETTINGS_TTL_MS);
|
const settingsCache = new TTLCache<Record<string, unknown>>(SETTINGS_TTL_MS);
|
||||||
const pricingCache = new TTLCache<Record<string, unknown>>(PRICING_TTL_MS);
|
const pricingCache = new TTLCache<Record<string, unknown>>(PRICING_TTL_MS);
|
||||||
const connectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached wrapper for getSettings.
|
* Cached wrapper for getSettings.
|
||||||
@@ -93,17 +101,11 @@ export async function getCachedPricing(): Promise<Record<string, unknown>> {
|
|||||||
export async function getCachedProviderConnections(
|
export async function getCachedProviderConnections(
|
||||||
filter?: Record<string, unknown>
|
filter?: Record<string, unknown>
|
||||||
): Promise<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 { getProviderConnections } = await import("@/lib/db/providers");
|
||||||
const value = await getProviderConnections(filter || {});
|
return getProviderConnections(filter || {});
|
||||||
connectionsCache.set(cacheKey, value);
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
|
const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS, 500);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached wrapper for getRawProviderConnections.
|
* Cached wrapper for getRawProviderConnections.
|
||||||
@@ -124,7 +126,7 @@ export async function getCachedRawProviderConnections(
|
|||||||
return rows;
|
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);
|
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).
|
* 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(
|
export function invalidateDbCache(
|
||||||
scope?: "settings" | "pricing" | "connections" | "combos" | "nodes"
|
scope?: "settings" | "pricing" | "connections" | "combos" | "nodes",
|
||||||
|
id?: string
|
||||||
): void {
|
): void {
|
||||||
if (!scope || scope === "settings") settingsCache.invalidate();
|
if (!scope || scope === "settings") settingsCache.invalidate();
|
||||||
if (!scope || scope === "pricing") pricingCache.invalidate();
|
if (!scope || scope === "pricing") pricingCache.invalidate();
|
||||||
if (!scope || scope === "connections") {
|
if (!scope || scope === "connections") {
|
||||||
connectionsCache.invalidate();
|
rawConnectionsCache.invalidate();
|
||||||
connectionByIdCache.invalidate();
|
if (id) {
|
||||||
|
connectionByIdCache.invalidate(id);
|
||||||
|
} else {
|
||||||
|
connectionByIdCache.invalidate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!scope || scope === "nodes") nodesCache.invalidate();
|
if (!scope || scope === "nodes") nodesCache.invalidate();
|
||||||
if (!scope || scope === "combos") combosCacheVersion++;
|
if (!scope || scope === "combos") combosCacheVersion++;
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
getSettings,
|
getSettings,
|
||||||
getCachedSettings,
|
getCachedSettings,
|
||||||
} from "@/lib/localDb";
|
} from "@/lib/localDb";
|
||||||
import { decrypt } from "@/lib/db/encryption";
|
import {
|
||||||
|
createLazyConnectionView,
|
||||||
|
toProviderConnection,
|
||||||
|
type ProviderConnectionView,
|
||||||
|
} from "@/lib/db/providers/lazyConnectionView";
|
||||||
import {
|
import {
|
||||||
DEFAULT_QUOTA_THRESHOLD_PERCENT,
|
DEFAULT_QUOTA_THRESHOLD_PERCENT,
|
||||||
getQuotaCache,
|
getQuotaCache,
|
||||||
@@ -72,37 +76,6 @@ import * as log from "../utils/logger";
|
|||||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
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 {
|
interface RecoverableConnectionState {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
testStatus?: string | null;
|
testStatus?: string | null;
|
||||||
@@ -145,43 +118,6 @@ function asRecord(value: unknown): JsonRecord {
|
|||||||
function toStringOrNull(value: unknown): string | null {
|
function toStringOrNull(value: unknown): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : 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 {
|
function toNumber(value: unknown, fallback = 0): number {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
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;
|
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 {
|
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||||
return typeof value === "boolean" ? value : fallback;
|
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 () => {
|
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 db = core.getDbInstance();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user