feat: enhance osu! API integration with stale data resilience and improve match participant handling
This commit is contained in:
16
.svelte-kit/ambient.d.ts
vendored
16
.svelte-kit/ambient.d.ts
vendored
@@ -38,6 +38,14 @@
|
|||||||
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
||||||
*/
|
*/
|
||||||
declare module '$env/static/private' {
|
declare module '$env/static/private' {
|
||||||
|
export const ROOT_ADMIN_EMAIL: string;
|
||||||
|
export const DATABASE_URL: string;
|
||||||
|
export const ORIGIN: string;
|
||||||
|
export const BETTER_AUTH_SECRET: string;
|
||||||
|
export const OSU_CLIENT_ID: string;
|
||||||
|
export const OSU_CLIENT_SECRET: string;
|
||||||
|
export const OSU_IRC_USERNAME: string;
|
||||||
|
export const OSU_IRC_PASSWORD: string;
|
||||||
export const NVM_INC: string;
|
export const NVM_INC: string;
|
||||||
export const TERM_PROGRAM: string;
|
export const TERM_PROGRAM: string;
|
||||||
export const NODE: string;
|
export const NODE: string;
|
||||||
@@ -191,6 +199,14 @@ declare module '$env/static/public' {
|
|||||||
*/
|
*/
|
||||||
declare module '$env/dynamic/private' {
|
declare module '$env/dynamic/private' {
|
||||||
export const env: {
|
export const env: {
|
||||||
|
ROOT_ADMIN_EMAIL: string;
|
||||||
|
DATABASE_URL: string;
|
||||||
|
ORIGIN: string;
|
||||||
|
BETTER_AUTH_SECRET: string;
|
||||||
|
OSU_CLIENT_ID: string;
|
||||||
|
OSU_CLIENT_SECRET: string;
|
||||||
|
OSU_IRC_USERNAME: string;
|
||||||
|
OSU_IRC_PASSWORD: string;
|
||||||
NVM_INC: string;
|
NVM_INC: string;
|
||||||
TERM_PROGRAM: string;
|
TERM_PROGRAM: string;
|
||||||
NODE: string;
|
NODE: string;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { user } from '$lib/server/db/schema';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine';
|
import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine';
|
||||||
import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator';
|
import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator';
|
||||||
import { getLobby } from '$lib/server/bancho/client';
|
import { getLobby, getLobbyStatus } from '$lib/server/bancho/client';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import { hasRole } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { MatchConfig } from '$lib/server/match/types';
|
||||||
|
|
||||||
|
|
||||||
export const load = async ({ params, locals }: Parameters<PageServerLoad>[0]) => {
|
export const load = async ({ params, locals }: Parameters<PageServerLoad>[0]) => {
|
||||||
@@ -42,7 +44,20 @@ export const load = async ({ params, locals }: Parameters<PageServerLoad>[0]) =>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { match: m, beatmapCache, userId: locals.user.id };
|
const isStaff = hasRole(locals.user.role, 'referee');
|
||||||
|
|
||||||
|
// Fetch player usernames for lobby status display
|
||||||
|
const playerNames: Record<string, string> = {};
|
||||||
|
for (const p of m.participants) {
|
||||||
|
for (const pl of p.players) {
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||||
|
if (u?.name) playerNames[pl.userId] = u.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lobbyStatus = getLobbyStatus(params.id);
|
||||||
|
|
||||||
|
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -52,20 +67,14 @@ export const actions = {
|
|||||||
const lobby = getLobby(params.id);
|
const lobby = getLobby(params.id);
|
||||||
if (!lobby) return { error: 'No active IRC lobby for this match' };
|
if (!lobby) return { error: 'No active IRC lobby for this match' };
|
||||||
|
|
||||||
const m = await getMatchFull(params.id);
|
// Only invite the currently logged-in user
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, locals.user.id) });
|
||||||
const invited: string[] = [];
|
|
||||||
for (const p of m.participants) {
|
|
||||||
for (const pl of p.players) {
|
|
||||||
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
|
||||||
if (u?.name) {
|
if (u?.name) {
|
||||||
await lobby.invite(u.name);
|
await lobby.invite(u.name);
|
||||||
invited.push(u.name);
|
return { reinvited: [u.name] };
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { reinvited: invited };
|
return { error: 'Could not find your username' };
|
||||||
},
|
},
|
||||||
|
|
||||||
roll: async ({ params, locals }: import('./$types').RequestEvent) => {
|
roll: async ({ params, locals }: import('./$types').RequestEvent) => {
|
||||||
@@ -133,6 +142,27 @@ export const actions = {
|
|||||||
|
|
||||||
if (!expectedPicker) return { error: 'Cannot determine picker' };
|
if (!expectedPicker) return { error: 'Cannot determine picker' };
|
||||||
|
|
||||||
|
// ── Verify the logged-in user is on the expected picker's team ──
|
||||||
|
// No staff bypass here — even admins can't pick for another team
|
||||||
|
const isOnPickerTeam = expectedPicker.players.some(
|
||||||
|
(pl) => pl.userId === locals.user!.id
|
||||||
|
);
|
||||||
|
if (!isOnPickerTeam) {
|
||||||
|
return { error: "It's not your turn to pick" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tiebreaker restriction ──
|
||||||
|
// TB maps can only be picked when both teams are at match point (e.g. 2-2 in BO5)
|
||||||
|
const config = m.config as MatchConfig;
|
||||||
|
const winsNeeded = Math.ceil(config.bestOf / 2);
|
||||||
|
const slot = m.mappool?.slots?.find((s) => s.id === slotId);
|
||||||
|
if (slot?.category === 'TB') {
|
||||||
|
const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1);
|
||||||
|
if (!allAtMatchPoint) {
|
||||||
|
return { error: 'Tiebreaker can only be picked when both teams are at match point' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const game = await pickMap(params.id, expectedPicker.id, slotId);
|
const game = await pickMap(params.id, expectedPicker.id, slotId);
|
||||||
|
|
||||||
@@ -149,12 +179,20 @@ export const actions = {
|
|||||||
|
|
||||||
forceStart: async ({ params, locals }: import('./$types').RequestEvent) => {
|
forceStart: async ({ params, locals }: import('./$types').RequestEvent) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
if (!locals.user) redirect(302, '/');
|
||||||
|
// Only staff can force start
|
||||||
|
if (!hasRole(locals.user.role, 'referee')) {
|
||||||
|
return { error: 'Only referees and admins can force start' };
|
||||||
|
}
|
||||||
await forceStartGame(params.id);
|
await forceStartGame(params.id);
|
||||||
return { started: true };
|
return { started: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel: async ({ params, locals }: import('./$types').RequestEvent) => {
|
cancel: async ({ params, locals }: import('./$types').RequestEvent) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
if (!locals.user) redirect(302, '/');
|
||||||
|
// Only staff can cancel matches
|
||||||
|
if (!hasRole(locals.user.role, 'referee')) {
|
||||||
|
return { error: 'Only referees and admins can cancel matches' };
|
||||||
|
}
|
||||||
|
|
||||||
await cancelMatch(params.id);
|
await cancelMatch(params.id);
|
||||||
await closeLobby(params.id);
|
await closeLobby(params.id);
|
||||||
|
|||||||
@@ -2,8 +2,30 @@
|
|||||||
// src/routes/(app)/+layout.server.ts
|
// src/routes/(app)/+layout.server.ts
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { isRootAdmin } from '$lib/server/permissions';
|
import { isRootAdmin } from '$lib/server/permissions';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { match, matchParticipant, matchParticipantPlayer } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load = async ({ locals }: Parameters<LayoutServerLoad>[0]) => {
|
export const load = async ({ locals }: Parameters<LayoutServerLoad>[0]) => {
|
||||||
|
let activeMatch: { id: string; name: string | null; state: string } | null = null;
|
||||||
|
|
||||||
|
if (locals.user) {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: match.id, name: match.name, state: match.state })
|
||||||
|
.from(matchParticipantPlayer)
|
||||||
|
.innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id))
|
||||||
|
.innerJoin(match, eq(matchParticipant.matchId, match.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(matchParticipantPlayer.userId, locals.user.id),
|
||||||
|
inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
activeMatch = rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user
|
user: locals.user
|
||||||
? {
|
? {
|
||||||
@@ -14,6 +36,7 @@ export const load = async ({ locals }: Parameters<LayoutServerLoad>[0]) => {
|
|||||||
role: locals.user.role ?? 'player',
|
role: locals.user.role ?? 'player',
|
||||||
isRootAdmin: isRootAdmin(locals.user.email)
|
isRootAdmin: isRootAdmin(locals.user.email)
|
||||||
}
|
}
|
||||||
: null
|
: null,
|
||||||
|
activeMatch
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -380,8 +380,10 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
pp?: number | null;
|
pp?: number | null;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
// Build userId → osu accountId map for API enrichment
|
// ── FIX: Build matchParticipantPlayer.id → osu numeric account ID map ──
|
||||||
const userOsuIdMap = new Map<string, number>(); // userId → osu numeric ID
|
// Previously this was keyed by userId but looked up by playerId, which broke enrichment.
|
||||||
|
const playerOsuIdMap = new Map<string, number>(); // matchParticipantPlayer.id → osu numeric ID
|
||||||
|
const playerUserIdMap = new Map<string, string>(); // matchParticipantPlayer.id → userId
|
||||||
|
|
||||||
for (const irc of ircScores) {
|
for (const irc of ircScores) {
|
||||||
for (const participant of m.participants) {
|
for (const participant of m.participants) {
|
||||||
@@ -391,13 +393,15 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
const dbName = u?.name?.toLowerCase();
|
const dbName = u?.name?.toLowerCase();
|
||||||
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
||||||
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
|
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
|
||||||
// Cache the osu account ID for enrichment
|
playerUserIdMap.set(player.id, player.userId);
|
||||||
if (u && !userOsuIdMap.has(player.userId)) {
|
|
||||||
|
// Cache the osu account ID for enrichment, keyed by matchParticipantPlayer.id
|
||||||
|
if (!playerOsuIdMap.has(player.id)) {
|
||||||
const acc = await db.query.account.findFirst({
|
const acc = await db.query.account.findFirst({
|
||||||
where: and(eq(account.userId, player.userId), eq(account.providerId, 'osu'))
|
where: and(eq(account.userId, player.userId), eq(account.providerId, 'osu'))
|
||||||
});
|
});
|
||||||
if (acc?.accountId) {
|
if (acc?.accountId) {
|
||||||
userOsuIdMap.set(player.userId, parseInt(acc.accountId));
|
playerOsuIdMap.set(player.id, parseInt(acc.accountId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,9 +414,12 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp)
|
// ── Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp) ──
|
||||||
if (m.osuLobbyId) {
|
if (m.osuLobbyId) {
|
||||||
try {
|
try {
|
||||||
|
// Small delay to let osu! API update with the latest game data
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
const osuMatchData = await getOsuMatch(m.osuLobbyId);
|
const osuMatchData = await getOsuMatch(m.osuLobbyId);
|
||||||
// Find the most recent game event matching our beatmap
|
// Find the most recent game event matching our beatmap
|
||||||
const gameRecord = await db.query.matchGame.findFirst({
|
const gameRecord = await db.query.matchGame.findFirst({
|
||||||
@@ -435,13 +442,25 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
osuScoreMap.set(os.user_id, os);
|
osuScoreMap.set(os.user_id, os);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich each score
|
console.log(
|
||||||
for (const s of scores) {
|
`[Orchestrator] osu! API returned ${osuGame.scores.length} scores for beatmap ${beatmapId}. ` +
|
||||||
const playerOsuId = userOsuIdMap.get(s.playerId);
|
`Player map has ${playerOsuIdMap.size} entries.`
|
||||||
if (playerOsuId === undefined) continue;
|
);
|
||||||
const osuScore = osuScoreMap.get(playerOsuId);
|
|
||||||
if (!osuScore) continue;
|
|
||||||
|
|
||||||
|
// Enrich each score using the FIXED mapping (matchParticipantPlayer.id → osu ID)
|
||||||
|
for (const s of scores) {
|
||||||
|
const playerOsuId = playerOsuIdMap.get(s.playerId);
|
||||||
|
if (playerOsuId === undefined) {
|
||||||
|
console.warn(`[Orchestrator] No osu account ID for player ${s.playerId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const osuScore = osuScoreMap.get(playerOsuId);
|
||||||
|
if (!osuScore) {
|
||||||
|
console.warn(`[Orchestrator] No osu! API score for osu user ${playerOsuId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 API format: accuracy is a float (0-1), statistics has count_300 etc.
|
||||||
s.accuracy = osuScore.accuracy ?? undefined;
|
s.accuracy = osuScore.accuracy ?? undefined;
|
||||||
s.maxCombo = osuScore.max_combo ?? undefined;
|
s.maxCombo = osuScore.max_combo ?? undefined;
|
||||||
s.count300 = osuScore.statistics?.count_300 ?? undefined;
|
s.count300 = osuScore.statistics?.count_300 ?? undefined;
|
||||||
@@ -456,6 +475,8 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`[Orchestrator] Enriched scores from osu! API for game ${matchGameId}`);
|
console.log(`[Orchestrator] Enriched scores from osu! API for game ${matchGameId}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Orchestrator] No scores in osu! API game event for beatmap ${beatmapId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { account } from '$lib/server/db/auth.schema';
|
import { account } from '$lib/server/db/auth.schema';
|
||||||
import { getCached, setCache } from './cache';
|
import { getCached, setCache, getStale } from './cache';
|
||||||
|
|
||||||
let clientToken: string | null = null;
|
let clientToken: string | null = null;
|
||||||
let clientTokenExpiresAt = 0;
|
let clientTokenExpiresAt = 0;
|
||||||
@@ -95,14 +95,29 @@ async function osuFetch(path: string, token?: string) {
|
|||||||
|
|
||||||
// ── Public API (with caching) ───────────────────────────────────────────
|
// ── Public API (with caching) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch beatmap data with stale-on-error resilience.
|
||||||
|
* If the osu! API is down or rate-limited, we return the last known data
|
||||||
|
* so the UI doesn't degrade to "?" placeholders mid-match.
|
||||||
|
*/
|
||||||
export async function getBeatmap(beatmapId: string | number) {
|
export async function getBeatmap(beatmapId: string | number) {
|
||||||
const cacheKey = `beatmap:${beatmapId}`;
|
const cacheKey = `beatmap:${beatmapId}`;
|
||||||
const cached = getCached(cacheKey);
|
const cached = getCached(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
const data = await osuFetch(`/beatmaps/${beatmapId}`);
|
const data = await osuFetch(`/beatmaps/${beatmapId}`);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data);
|
||||||
return data;
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
// If fetch fails, try returning stale (expired) cached data
|
||||||
|
const stale = getStale(cacheKey);
|
||||||
|
if (stale) {
|
||||||
|
console.warn(`[osu! API] Failed to fetch beatmap ${beatmapId}, returning stale cache`);
|
||||||
|
return stale;
|
||||||
|
}
|
||||||
|
throw err; // No stale data either — propagate the error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBeatmapset(beatmapsetId: string | number) {
|
export async function getBeatmapset(beatmapsetId: string | number) {
|
||||||
@@ -110,9 +125,15 @@ export async function getBeatmapset(beatmapsetId: string | number) {
|
|||||||
const cached = getCached(cacheKey);
|
const cached = getCached(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
const data = await osuFetch(`/beatmapsets/${beatmapsetId}`);
|
const data = await osuFetch(`/beatmapsets/${beatmapsetId}`);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data);
|
||||||
return data;
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const stale = getStale(cacheKey);
|
||||||
|
if (stale) return stale;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUser(userId: string | number) {
|
export async function getUser(userId: string | number) {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// src/lib/server/osu/cache.ts
|
// src/lib/server/osu/cache.ts
|
||||||
//
|
//
|
||||||
// Simple in-memory cache for osu! beatmap metadata.
|
// In-memory cache for osu! beatmap metadata.
|
||||||
// TTL: 10 minutes. Max entries: 500.
|
// Beatmap data is essentially immutable, so we use a long TTL (2 hours)
|
||||||
// Avoids hammering the osu! API on every page load.
|
// and return stale entries on fetch failure to prevent "?" in the UI.
|
||||||
|
|
||||||
interface CacheEntry<T> {
|
interface CacheEntry<T> {
|
||||||
data: T;
|
data: T;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours (beatmap data rarely changes)
|
||||||
const MAX_ENTRIES = 500;
|
const MAX_ENTRIES = 500;
|
||||||
|
|
||||||
const cache = new Map<string, CacheEntry<any>>();
|
const cache = new Map<string, CacheEntry<any>>();
|
||||||
@@ -18,12 +18,22 @@ export function getCached<T>(key: string): T | null {
|
|||||||
const entry = cache.get(key);
|
const entry = cache.get(key);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
if (Date.now() > entry.expiresAt) {
|
if (Date.now() > entry.expiresAt) {
|
||||||
cache.delete(key);
|
// Expired — don't delete yet, keep as stale fallback
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.data as T;
|
return entry.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return stale (expired) data for a key if it exists.
|
||||||
|
* Used as a fallback when the osu! API fails, so the UI doesn't lose data.
|
||||||
|
*/
|
||||||
|
export function getStale<T>(key: string): T | null {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
return entry.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
export function setCache<T>(key: string, data: T, ttl = CACHE_TTL): void {
|
export function setCache<T>(key: string, data: T, ttl = CACHE_TTL): void {
|
||||||
// Evict oldest entries if at capacity
|
// Evict oldest entries if at capacity
|
||||||
if (cache.size >= MAX_ENTRIES) {
|
if (cache.size >= MAX_ENTRIES) {
|
||||||
|
|||||||
Reference in New Issue
Block a user